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).
PartySeesDoes not see
Backend / DBtransferId, ciphertext, iv, saltPIN, plaintext keypair
Network attackerSame as backendPIN
Another appNothingPIN

Cryptographic Parameters

PurposeAlgorithmParameters
PIN → KEKPBKDF2-SHA256200,000 iterations, 16-byte random salt
Payload encryptionAES-256-GCM256-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

  1. Source client (PWA) reads keypair from IndexedDB.
  2. Payload is JSON → deflate → AES-256-GCM encrypted with KEK derived from PIN.
  3. PIN is generated locally, never sent to the server.
  4. Encrypted payload is uploaded: POST /api/key-transfer.
  5. Backend returns transferId and expiresAt.
  6. PWA shows PIN + store link to the user.
  7. User installs and opens the app, enters the PIN.
  8. App fetches: GET /api/key-transfer/{transferId}.
  9. App derives KEK, decrypts, validates, stores in secure storage.
  10. 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.

DirectionEventPayload
target → serverrequest_key_transfer{ token } — deeplink token of the conversation
server → sourceshow_key_transfer_dialog{ conversationToken? }
source → serversend_transfer_id{ transferId }
server → targetreceive_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.