Key Transfer
Transfer the customer's E2EE keypair from the PWA to a native app on the same device.
Problem
The customer's private key lives in the PWA's IndexedDB. When the user installs the native app, the app has no key and would generate a fresh one, making every message wrapped to the old key undecryptable.
There is no direct channel between the PWA and the app. The payload must travel through the backend, encrypted with a secret the backend never sees.
Security Model
The security property is channel separation:
- The ciphertext travels over HTTPS (backend sees it but cannot decrypt).
- The PIN travels through the user's eyes and fingers (never sent to the server).
| Party | Sees | Does not see |
|---|---|---|
| Backend / DB | transferId, ciphertext, iv, salt | PIN, plaintext keypair |
| Network attacker | Same as backend | PIN |
| Another app | Nothing | PIN |
Cryptographic Parameters
| Purpose | Algorithm | Parameters |
|---|---|---|
| PIN → KEK | PBKDF2-SHA256 | 200,000 iterations, 16-byte random salt |
| Payload encryption | AES-256-GCM | 256-bit key, 96-bit IV, 128-bit tag |
PIN Protection
A 6-digit PIN has ~20 bits of entropy. It is safe because of:
- Short TTL (5 minutes) on the Redis entry.
- Attempt limiting (5 attempts, then the entry is destroyed).
- One-shot retrieval (GET transitions the entry to
RETRIEVED).
Flow
- Source client (PWA) reads keypair from IndexedDB.
- Payload is JSON → deflate → AES-256-GCM encrypted with KEK derived from PIN.
- PIN is generated locally, never sent to the server.
- Encrypted payload is uploaded:
POST /api/key-transfer. - Backend returns
transferIdandexpiresAt. - PWA shows PIN + store link to the user.
- User installs and opens the app, enters the PIN.
- App fetches:
GET /api/key-transfer/{transferId}. - App derives KEK, decrypts, validates, stores in secure storage.
- App confirms:
POST /api/key-transfer/{transferId}/confirm.
Quick Transfer (in-conversation)
When a customer opens a conversation on a new device and cannot decrypt the history, the chat page offers to import the key from another device. The transferId is exchanged over the shared Socket.io connection instead of a link, so the user only reads the PIN on one screen and types it on the other.
The source device does not need the same conversation open — only the app open and authenticated for the same keypair. The conversation list is enough.
The events are routed through the customer key room (customer:{customerPublicKey}), so a device only receives events for its own keypair.
| Direction | Event | Payload |
|---|---|---|
| target → server | request_key_transfer | { token } — deeplink token of the conversation |
| server → source | show_key_transfer_dialog | { conversationToken? } |
| source → server | send_transfer_id | { transferId } |
| server → target | receive_transfer_id | { transferId, conversationToken? } |
send_transfer_id carries only the transferId: the backend resolves the conversation the transfer is bound to and routes the event to that conversation's key room. The binding is set at creation time from the optional conversationToken in POST /api/key-transfer, which the backend resolves to the conversation id server-side — the client cannot claim an arbitrary conversation id. The token is echoed back on retrieval so the target can return to the conversation after importing the key.
Two properties make this work and are easy to break:
- The requesting device is joined to the key room. It is the one that cannot decrypt, so it usually has no key registered and
authenticate put it in the conversation room. Without the explicit join, the reply can never reach it. - A device that cannot decrypt does not register its key. Registering a fresh key would overwrite the one the source device still holds, making the conversation unreachable for it and breaking the transfer.
Payload Format
Key payload {
"v": 1,
"keyId": "a3f9...",
"publicKeySpki": "MIIBIjANBgkq...",
"privateKeyPkcs8": "MIIEvQIBADANBgkq..."
}
Backend Envelope
Stored envelope {
"ciphertext": "8f3a...",
"iv": "9c2b...",
"salt": "1d4e...",
"kdf": { "alg": "PBKDF2-SHA256", "iterations": 200000 },
"conversationToken": "d3f1..."
}
conversationToken is optional and only present when the transfer was started from a conversation screen. It is the conversation's deeplink token — the same value already present in the conversation URL, so it is not a secret.