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 ( |
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 ( |
Rotation secret |
32 random bytes per user and app key scope, generated by the server on first use. |
Server
( |
App key |
Derived from |
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-secreton 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.
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.
-
Create the scope under Account → OIDC → App key scopes (
/account/app-key-scopes): pick the owner (yourself, or an organization where you holdorg:audiences), the scope id, name and description, and whether it is client-bound. -
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 isapp_key:followed by the id, hereapp_key:notes. URL-style ids such ashttps://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
luksscope. 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.
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:
-
Generate a fresh ECDH key pair on curve P-256 and keep the private key with the PKCE verifier.
-
Put the public JWK, JSON-encoded and base64url-encoded without padding, into the
keys_jwkparameter. -
Add
app_key:<id>toscope(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
kBwithout fresh keybase material.prompt=nonetherefore can't deliver keys (it fails withlogin_requiredorconsent_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_requestwith "none of the passed challenges can unlock the keybase".
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..). Noapu/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"];
-
kis the raw 256-bit key, for example for AES-256-GCM. The same user and scope always yield the same key, on any device. -
kidis<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_jweis only part of the authorization code exchange and of the CIBA token response. Refresh-token responses keepapp_key:...inscopebut 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_jwkis 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_keysmakes the next authorization create a new rotation secret, so a new key with a newkid. The app must still hold the old key to re-encrypt its data; pykrete can't recreate it. -
kBitself 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 |
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 thekBfingerprint 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 asapp 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,
kBand all app keys derived from it are gone. -
Use a fresh
keys_jwkkey pair per authorization and drop the private key oncekeys_jweis decrypted.