A zero-knowledge architecture: the server holds decryption keys for nothing but technical identifiers. Every meaningful field — phone numbers, URLs, coordinates, webhook secrets, Wi-Fi BSSIDs, greetings — is encrypted on the owner's device and sits on the server as an opaque blob. The decryption key reaches the guest inside the invitation link, in the URL fragment, which the browser never sends to the server.
Algorithm: AES-256-GCM. Available out of the box on the JVM (through javax.crypto),
and supported by the Android Keystore. It gives confidentiality and authentication at once.
v1:<base64url(nonce || ciphertext || tag)>
v1: — a version prefix, for rotating the format later.nonce — 12 random bytes, fresh for every encryption.ciphertext+tag — everything returned by Cipher.doFinal; the 128-bit tag is included.SecureRandom() (the system one). On Android that is /dev/urandom + Linux prng.
EncryptedSharedPreferences under the name master_key and never leaves the device in the clear.K_obj for local storage.encrypt_k_master(K_obj) under the key obj_key_{id}.data_cipher on the server).K_obj_new → re-encrypt data_cipher and every bundle that carried it.{obj_id: K_obj} for the objects the guest is allowed.bundle_cipher. The K_guest itself is never seen by the server.entrixy.com/key#<user_key><base64url(K_guest)> — a single 75-character block.
The fragment is not sent to the server with the HTTP request; that is a basic browser guarantee.K_guest is only possible by issuing a new link. Revocation is built on exactly that:
the server deletes the key record and the guest no longer receives a current bundle_cipher.| Key | Value |
|---|---|
master_key | 32 bytes in the clear (SharedPreferences encrypts them anyway). |
obj_key_{id} | The object's K_obj, wrapped with K_master. |
obj_plain_{id} | (optional) a cache of the decrypted JSON, so the UI draws quickly. |
| Table / field | Contents |
|---|---|
numbers.data_cipher | JSON encrypted with K_obj: {phone, url, secret, geo_lat, geo_lon, share_lat, share_lon, snapshot, welcome, label}. |
numbers.id / type | In the clear — needed for routing and for showing the icon type. |
keys.bundle_cipher | JSON encrypted with K_guest: {obj_ids: [1,2,3], obj_keys: {1: K_obj1, 2: K_obj2, ...}, welcome_cipher: ..., ...}. |
keys.user_key / mode / force_when_busy | In the clear — for routing and a quick access check. |
https://entrixy.com/key#<user_key(32)><K_guest_base64url(43)>
user_key and 43 characters of the guest key.user_key — the identifier on the server side. Without it the server will not hand over the bundle.#...) never reaches the server log — that is a browser guarantee./key — plain JS: it reads location.hash, parses the block and hands the key
either to the app or to the web version /app/. The server never sees the key./download/android,
from where the app picks the key up by itself.K_obj.K_obj → data_cipher.POST /api/number_add with data_cipher plus the type. The server returns an id.obj_key_{id} = encrypt_k_master(K_obj).Build the new JSON locally, encrypt it with the existing K_obj → POST /api/number_update.
K_obj does not change.
[obj1, obj2, obj3]. This generates K_guest.{obj_ids: [...], obj_keys: {1: K_obj1, ...}, welcome_cipher: ...}.K_guest → bundle_cipher.POST /api/key_create with bundle_cipher, obj_ids (in the clear — for the access matrix).user_key.entrixy.com/key#{user_key}{base64url(K_guest)}./key reads location.hash and hands the key to the app or the web version.POST /api/key_bundle.php with user_key → receives bundle_cipher and the list of obj_ids.K_guest→ obtaining {obj_keys, welcome}.K_guest and the decrypted obj_keys are stored in the EncryptedSharedPreferences of the guest client.data_cipher of the object → decrypt with the local obj_key_{id}.The owner presses Delete → POST /api/key_revoke.php → the server deletes the key record.
On the next bundle request the guest is refused and loses access.
The owner presses "Regenerate key" in the object settings. This generates K_obj_new,
and every bundle_cipher containing that object is re-encrypted. The client does not know them
offhand, so the owner's client rebuilds the bundles: it fetches the bundle_cipher of each of its
keys, decrypts it, inserts K_obj_newand re-encrypts.
The new blobs go to the server in one batch.
Losing K_master means losing access to every object and key, which is why an export is essential.
K_master → producing master_backup_blob.obj_keys from the server — they are there in the bundle of the owner's own keys — and re-encrypts them locally.K_master, so the passphrase is mandatory.Add Crypto.kt: an AES-256-GCM wrapper, generation and storage of K_master,
helpers for K_obj, serialisation of the format v1:<base64url>.
Unit tests: encrypt → decrypt → compare.
Nothing goes to the server yet — the plumbing is simply ready.
Every sensitive field has moved into numbers.data_cipher and user_keys.bundle_cipher.
The old plaintext columns (phone, label, radius, time_*, geo_*, wifi_*, share_*, has_avatar, security_level, user_keys.label, hosts.label, pending_actions.phone, hosts.last_ip) have been dropped from the database (phase 7).
webhook_url and webhook_secret stay in the clear ONLY when webhook_mode='server' — without them the server cannot send the HTTP request. For webhook_mode='phone' they are encrypted into data_cipher.
On create or update the client encrypts the JSON and sends data_cipher. On sync it reads the blob and decrypts it. New objects live entirely in the blob; old ones without a K_obj keep using the plaintext fields for backward compatibility.
Creating a key generates K_guest, assembles the bundle and sends bundle_cipherto the server.
The link carries the key in its fragment; the page /key reads the hash and passes the key to the app.
A settings screen with "Export master key": passphrase → Argon2id → QR code. Restoring through the scanner, plus tests of moving between devices. Not implemented.
A one-off routine in the client on the first launch of the new version:
K_obj for each of them.data_cipher.phone=NULL, …) — but only after confirmation.A security audit, external or our own. We check that the server really cannot see sensitive data: a database dump must contain nothing but blobs.
| Risk | Measure |
|---|---|
| Loss of K_master | A mandatory backup flow (phase 5). Until a copy exists, a warning banner is shown. |
| Compromise of the owner's device | Not fully preventable: entry to the app is guarded by a PIN or biometrics, with EncryptedSharedPreferences and the Android Keystore. |
| Rotating an object key requires rebuilding every bundle | The owner's client takes every bundle_cipher, locally or from the server, rebuilds them and sends them in one batch. A rare operation. |
| The server tampers with a bundle | The AES-GCM tag will not match and the client reports a corrupted key. |
| K_guest leaking through a screenshot of the QR code | Not technically preventable: the key is in the code itself. Show the QR code only to a guest you trust. |
| The URL fragment stays in browser history | The page /key calls, right after reading the hash, history.replaceState(..., '#'), which wipes the fragment. |
Last updated: 19 April 2026