Entrixy — End-to-End Encryption

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.

Decided on 2026-04-19. The work proceeds in stages — see the "Phases" section.

1. Goals and boundaries

2. Cryptographic primitive

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.

Ciphertext format

v1:<base64url(nonce || ciphertext || tag)>

Source of entropy

SecureRandom() (the system one). On Android that is /dev/urandom + Linux prng.

3. Key hierarchy

K_master — the owner's master key

K_obj — the object key

K_guest — the guest bundle key

Changing 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.

4. What is stored where

On the owner's device (EncryptedSharedPreferences)

KeyValue
master_key32 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.

On the server

Table / fieldContents
numbers.data_cipherJSON encrypted with K_obj: {phone, url, secret, geo_lat, geo_lon, share_lat, share_lon, snapshot, welcome, label}.
numbers.id / typeIn the clear — needed for routing and for showing the icon type.
keys.bundle_cipherJSON 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_busyIn the clear — for routing and a quick access check.
The greeting to the guest is encrypted separately inside the bundle, so the server cannot see it either.
https://entrixy.com/key#<user_key(32)><K_guest_base64url(43)>

6. Main flows

Creating an object

  1. The owner presses Add — this generates K_obj.
  2. The fields are packed into JSON and encrypted with K_objdata_cipher.
  3. POST /api/number_add with data_cipher plus the type. The server returns an id.
  4. Stored locally: obj_key_{id} = encrypt_k_master(K_obj).

Updating an object

Build the new JSON locally, encrypt it with the existing K_objPOST /api/number_update. K_obj does not change.

Creating a guest key

  1. The owner has chosen [obj1, obj2, obj3]. This generates K_guest.
  2. The bundle JSON is assembled: {obj_ids: [...], obj_keys: {1: K_obj1, ...}, welcome_cipher: ...}.
  3. Encrypted with K_guestbundle_cipher.
  4. POST /api/key_create with bundle_cipher, obj_ids (in the clear — for the access matrix).
  5. The server returns user_key.
  6. The owner is shown a QR code with the link entrixy.com/key#{user_key}{base64url(K_guest)}.

A guest accepting the key

  1. The guest opens the QR code or the link. The page /key reads location.hash and hands the key to the app or the web version.
  2. The client requests POST /api/key_bundle.php with user_key → receives bundle_cipher and the list of obj_ids.
  3. Decrypts the bundle with K_guest→ obtaining {obj_keys, welcome}.
  4. K_guest and the decrypted obj_keys are stored in the EncryptedSharedPreferences of the guest client.
  5. From then on, on every call: GET the encrypted data_cipher of the object → decrypt with the local obj_key_{id}.

Revoking a guest key

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.

Rotating an object key

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.

7. Backup and restore

This section describes a plan that has not been implemented yet: there is no master key export in the app today.

Losing K_master means losing access to every object and key, which is why an export is essential.

Without a passphrase the backup QR code is a plain K_master, so the passphrase is mandatory.

8. Rollout phases

Phase 1 — the client-side crypto store

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.

Phase 2 — server schema migration (done)

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.

Phase 3 — the client reads and writes the blob

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.

Phase 4 — the guest bundle

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.

Phase 5 — backup and restore

A settings screen with "Export master key": passphrase → Argon2id → QR code. Restoring through the scanner, plus tests of moving between devices. Not implemented.

Phase 6 — migrating existing data

A one-off routine in the client on the first launch of the new version:

  1. Read all of its objects from the server (the old plaintext fields).
  2. Generate a K_obj for each of them.
  3. Encrypt the fields and send data_cipher.
  4. The server clears the old columns (phone=NULL, …) — but only after confirmation.
Once the migration succeeds, the deprecated fields are dropped with an ALTER.

Phase 7 — audit

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.

9. Risks and measures

RiskMeasure
Loss of K_masterA mandatory backup flow (phase 5). Until a copy exists, a warning banner is shown.
Compromise of the owner's deviceNot 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 bundleThe 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 bundleThe AES-GCM tag will not match and the client reports a corrupted key.
K_guest leaking through a screenshot of the QR codeNot 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 historyThe page /key calls, right after reading the hash, history.replaceState(..., '#'), which wipes the fragment.

Last updated: 19 April 2026