App encryption keys (E2E)

An app can ask pykrete for a per-user, per-app 256-bit symmetric key to encrypt the user's data end-to-end. The key is derived in the user's browser from a secret that only the user's passkey (or password) can unlock, and it reaches the app encrypted to a public key the app supplied. The pykrete server stores only wrapped material and never sees the keys.

The scheme follows Firefox Accounts scoped keys: request a scope, pass keys_jwk, receive keys_jwe from the token endpoint.

How it works

Three pieces of key material are involved:

Material What it is Where it lives

Keybase (kB)

32 random bytes per user, generated in the browser the first time keys are needed. Never leaves the browser unwrapped.

Server stores one envelope per credential (kB encrypted with a key derived from that credential) and a fingerprint of kB.

Rotation secret

32 random bytes per user and app key scope, generated by the server on first use.

Server (app_keys table). Useless without kB.

App key

Derived from kB and the rotation secret.

Only in the browser (briefly) and in the app.

Unlocking kB needs keybase material from the credential the user signs in with:

  • Passkey: the output of the WebAuthn PRF extension (CTAP2 hmac-secret on security keys).

  • Password: a value derived from the password in the browser (never sent to the server).

All derivation runs in a WebAssembly worker on pykrete's sign-in page. The server only receives envelopes, the fingerprint and, at the end, a JWE encrypted to the app.

pykrete Browser (sign-in page) App pykrete Browser (sign-in page) App WebAuthn get with UV, PRF output stays here open envelope, recover kB derive app keys, encrypt JWE to keys_jwk decrypt keys_jwe with its private key redirect to /authorize with app_key scope and keys_jwk start the flow over /control passkey challenge with PRF salt signed assertion consent screen, Encryption keys UnlockKeybase with the envelope of this credential kB fingerprint and envelopes for new credentials EncryptAppKeys with rotation secret and timestamp per key JWE redirect to the app with code POST /oauth/token tokens and keys_jwe

The server checks the fingerprint on every unlock, so a credential can't silently introduce a different kB.

Derivation reference

uid below is the pykrete user id (U...), not necessarily the sub the app sees.

material (passkey)  = PRF(eval.first = "deltarocks-keybase-prf-v1")                  32 bytes
material (password) = derived from the password in the browser                     32 bytes

wrap_key    = HKDF-SHA256(salt = uid, ikm = material, info = "deltarocks-keybase-envelope-v1", 32 bytes)
envelope    = 0x01 || nonce(12) || AES-256-GCM(wrap_key, nonce, kB, aad = 0x01)    1 + 12 + 32 + 16 bytes
fingerprint = HKDF-SHA256(salt = uid, ikm = kB, info = "deltarocks-keybase-fingerprint", 32 bytes)

info = "identity.mozilla.com/picl/v1/scoped_key\n" || "app_key:" || scope_id
info = info || "\n" || client_id                                  (client-bound scopes only)

okm = HKDF-SHA256(salt = uid, ikm = kB || rotation_secret, info = info, 48 bytes)
kid = <rotation timestamp, unix seconds> || "-" || base64url(okm[0..16])
k   = okm[16..48]

For an ordinary scope every client allowed to request it gets the same key, as with Firefox Accounts. For a client-bound scope the requesting client's id is part of the derivation, so each client gets its own key from the same scope. The rotation secret is shared, so rotating it changes the keys of all clients at once.

Setting up an app key scope

An app key scope has an id, a name and a description (shown on the consent screen and in the dashboard), an owner, and the client-bound flag. Each client that may request it must be allowed explicitly.

  1. Create the scope under Account → OIDC → App key scopes (/account/app-key-scopes): pick the owner (yourself, or an organization where you hold org:audiences), the scope id, name and description, and whether it is client-bound.

  2. On the client's App keys tab (/account/clients/<client_id>/app-keys), tick the scope and save.

The same is available through GraphQL (manage root): createAppKeyScope, appKeyScopes, appKeyScope(scopeId:) { update delete } and, on a client, allowedAppKeyScopes and setAllowedAppKeyScopes.

mutation {
  manage {
    createAppKeyScope(scopeId: "notes", name: "Notes encryption key",
                      description: "Encrypts your notes end-to-end") { scopeId }
    client(clientId: "C...") { setAllowedAppKeyScopes(scopeIds: ["notes"]) { scopeId } }
  }
}
  • The scope id must not contain spaces or start with app_key:, and is at most 128 characters. The OAuth scope is app_key: followed by the id, here app_key:notes. URL-style ids such as https://notes.example.com/ work too. Ids are global: the first owner to create an id holds it.

  • The id and the client-bound flag can't be changed after creation; both are part of the key derivation.

  • Only the scope's owner (or an administrator) can allow a client to request it. A client's editor can always remove a scope from the client.

  • Use a client-bound scope when several clients should each get a separate key, for example one client per server sharing one luks scope. Use an ordinary scope when several clients of the same app must read the same data.

  • Keys are delivered by the authorization code flow and by CIBA. The device code grant refuses app_key: scopes.

  • Audience scopes can't start with app_key:, so app keys can't be shadowed by an ordinary audience scope.

  • Scopes created before ownership existed have no owner; only administrators manage them.

Warning
Deleting an app key scope (Delete app key scope, or delete in GraphQL) deletes every user's rotation secret for it, and the keys can never be derived again. Data encrypted with them is lost. The page shows how many users have a key before you confirm.

Requesting keys

For each authorization:

  1. Generate a fresh ECDH key pair on curve P-256 and keep the private key with the PKCE verifier.

  2. Put the public JWK, JSON-encoded and base64url-encoded without padding, into the keys_jwk parameter.

  3. Add app_key:<id> to scope (several app key scopes can be requested at once).

import { base64url, exportJWK, generateKeyPair } from "jose";

const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256" });
const keysJwk = base64url.encode(JSON.stringify(await exportJWK(publicKey)));

const url = new URL("https://auth.infra.delta.rocks/authorize");
url.search = new URLSearchParams({
  client_id: "C...",
  response_type: "code",
  redirect_uri: "https://notes.example.com/callback",
  scope: "openid app_key:notes",
  state,
  code_challenge,
  code_challenge_method: "S256",
  keys_jwk: keysJwk,
}).toString();

The rest is a normal authorization code exchange, see OpenID Connect.

What to expect:

  • The consent screen always shows an Encryption keys group for app key scopes, even if the user approved the app before: key access is never remembered as consent.

  • The user always has to confirm with a passkey or password, even with an active session, because the server has no way to unlock kB without fresh keybase material. prompt=none therefore can't deliver keys (it fails with login_required or consent_required).

  • A client that isn't allowed the scope gets error=invalid_scope ("client has no access to app key ...") on its redirect URI, after the user signed in.

  • If none of the user's credentials can unlock the keybase, the redirect carries error=invalid_request with "none of the passed challenges can unlock the keybase".

Important
keys_jwk is required when app key scopes are requested, and must be an EC P-256 public key. Never put the private part (d) into the URL.

What the app receives

The token response of the authorization code exchange carries keys_jwe next to the usual fields:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "id_token": "eyJ...",
  "expires_in": 3600,
  "scope": "app_key:notes openid",
  "keys_jwe": "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6ey4uLn19..<iv>.<ciphertext>.<tag>"
}

keys_jwe is a compact JWE (RFC 7516) encrypted to the keys_jwk of that authorization:

  • Protected header: {"alg":"ECDH-ES","enc":"A256GCM","epk":{"crv":"P-256","kty":"EC","x":"...","y":"..."}}. Direct key agreement, so the encrypted-key part is empty (note the ..). No apu/apv.

  • Plaintext: a JSON object with one JWK per requested app key scope, keyed by the full scope:

{
  "app_key:notes": {
    "kid": "1700000000-LeqpY753aIAKrzda4tSnlg",
    "kty": "oct",
    "k": "<base64url, 32 bytes>"
  }
}

Decrypt with any JOSE library:

import { compactDecrypt } from "jose";

const { plaintext } = await compactDecrypt(tokens.keys_jwe, privateKey);
const keys = JSON.parse(new TextDecoder().decode(plaintext));
const notesKey = keys["app_key:notes"];
  • k is the raw 256-bit key, for example for AES-256-GCM. The same user and scope always yield the same key, on any device.

  • kid is <timestamp>-<fingerprint>: the creation time of the rotation secret (unix seconds) and a 16-byte fingerprint of the key. Store it with your ciphertexts to detect key changes.

  • keys_jwe is only part of the authorization code exchange and of the CIBA token response. Refresh-token responses keep app_key:... in scope but carry no keys, and the device-code and client-credentials grants never deliver keys. Keep the key in the app, or run a new authorization to fetch it again.

  • The JWE is created in the user's browser. The server stores it with the authorization code (valid for 10 minutes, single use) or the CIBA request, and deletes both on exchange. Without the app's private key the JWE is unreadable.

Keys without a browser (CIBA)

A machine with no browser, such as a server that needs a disk key at boot, gets keys through backchannel authentication: it sends keys_jwk with the backchannel request, the user approves on pykrete, and the token response carries keys_jwe, exactly as above.

  • The user unlocks the keybase with a passkey or password on the approval page, every time. pykrete can't release a key without the user.

  • keys_jwk is checked when the request arrives (EC P-256, valid point, no private part), so a broken key is rejected before the user sees anything.

  • Use a fresh key pair per request, generated on the machine, and keep the private key in memory only.

  • Combine with a client-bound scope and one client per machine: each machine then gets its own key, and one machine's key reveals nothing about the others.

  • Everything the machine stores to authenticate (its client secret) can be read by anyone with access to its disk, who can then send requests in its name. The approval screen shows the binding message, the address the request came from and the time; deny requests you didn't expect.

Rotation

There is no rotation API yet.

  • An app key is created on the first authorization that requests it and stays the same afterwards.

  • Deleting the user's row in app_keys makes the next authorization create a new rotation secret, so a new key with a new kid. The app must still hold the old key to re-encrypt its data; pykrete can't recreate it.

  • kB itself never changes. Adding a credential only adds an envelope.

Passkeys, PRF and credentials without PRF

  • User verification is required on every passkey registration and sign-in. PRF (hmac-secret) output differs with and without user verification, so allowing both would produce different keys. Security keys therefore need a PIN; without one, registration and sign-in fail with an error ending in "security keys need a PIN set".

  • Passkeys are discoverable credentials using ES256 (P-256); other algorithms are rejected.

  • The PRF input is the fixed salt deltarocks-keybase-prf-v1.

Which credential can unlock the keybase:

Credential Can unlock

Password

yes

Passkey with PRF and an envelope

yes

Passkey with PRF, no envelope yet

if a keybase already exists, not on its own: after the user also confirms with a credential that can unlock, the browser wraps kB for this passkey too ("Confirm once with another method to let this passkey unlock encryption keys")

Passkey without PRF

never, the passkey only signs in

When the user signs in with a passkey that returns no PRF output, the server asks for another credential that can unlock (password or a PRF passkey) in the same flow. If there is none, the authorization fails with the invalid_request error above.

The very first time keys are needed (no keybase yet), a password or any PRF passkey creates kB, and every credential used in that flow gets an envelope. Accounts migrated from the older password-only keybase format are converted to envelopes on their next unlock.

What users see

  • The consent screen: the heading mentions "your encryption keys" and an Encryption keys group lists the scope names and descriptions.

  • E2EE page of the account dashboard (/account/app-keys): one entry per app key the user has (name, description, creation date).

  • Pending sign-ins (/account/pending): backchannel requests waiting for approval, with Review and Deny.

  • Sign-in methods (/account/sign-in): an "unlocks encryption keys" badge on each credential that has an envelope, and "End-to-end encryption keys set up" with the kB fingerprint and the number of envelopes.

  • Add passkey (/add-passkey): if the new passkey supports PRF but didn't return output at creation, the page offers "Enable encryption keys" (use the passkey once more) or "Skip, use it for sign-in only". Afterwards the sign-in methods page shows "Passkey added", "Passkey added. Sign in with it and confirm once with another method to let it unlock encryption keys" or "Passkey added. This authenticator can't unlock encryption keys".

  • Access tokens (/account/tokens): tokens list their app key scopes as app key: <id>.

Security notes

  • The server never receives kB, PRF output, password-derived material or app keys in clear. It stores envelopes, the fingerprint, rotation secrets and, until the code is exchanged, the JWE.

  • The derivation code is served by the same pykrete deployment. End-to-end here protects against database leaks and passive server compromise, not against an operator who serves modified sign-in code.

  • An envelope made from a password is only as strong as the password: anyone with a database dump can test password guesses against it offline. Passkeys with PRF don't have this problem.

  • There is no recovery. If a user loses every credential that has an envelope, kB and all app keys derived from it are gone.

  • Use a fresh keys_jwk key pair per authorization and drop the private key once keys_jwe is decrypted.