View on GitHub
tinfoilsh/tinfoil-passkey-kit
Tinfoil Chat uses Passkey Kit to protect its
end-to-end chat encryption keys across its web and iOS clients.
How it works
Passkey Kit uses the WebAuthn PRF (pseudo-random function) extension. When the user approves a passkey prompt, the authenticator can return a 32-byte secret that is stable for that passkey but never leaves the ceremony unapproved. That secret becomes the root of the key hierarchy:- Passkey ceremony. The user approves a passkey prompt (biometric or device PIN). The authenticator returns the PRF output, a deterministic 32-byte secret tied to that passkey.
- Key derivation. Passkey Kit derives a key-encryption key (KEK) from the PRF output with HKDF-SHA-256. The KEK never leaves the client.
- Key wrapping. The KEK encrypts your application’s 32-byte content-encryption key (CEK) with AES-256-GCM. The result is a wrapped key record that serializes to JSON.
- Server storage. Your server stores the wrapped record. It is ciphertext: without the passkey, it cannot be turned back into the CEK.
- Recovery. On any device with the passkey, the user approves a prompt, the same PRF output comes back, the same KEK is derived, and the CEK is unwrapped locally.
What you will build
This guide covers how to:- Install and configure Passkey Kit in JavaScript and Swift.
- Configure a shared WebAuthn relying party.
- Create a passkey and protect a fresh CEK with it.
- Store only wrapped key records on your server.
- Recover the CEK on the user’s device.
- Let users enroll additional passkeys for the same key.
- Use the same wrapped-key format across web and iOS clients.
Prerequisites
- A domain you control, such as
example.com - An authenticated backend that can store wrapped key records per user
- A stable opaque user ID of at most 64 UTF-8 bytes
- For web: a browser with WebAuthn PRF support (recent Chrome, Safari, and Edge on platforms with a screen lock)
- For iOS: iOS 18 or later
localhost for local development, but native passkeys require a configured
associated domain.
Install the package
- JavaScript
- iOS
Install the published npm package:
Choose a shared profile
A profile defines your application’s key-derivation domain. It has aversion, which is always 1, and three values. Every client must use
identical bytes for all three, or keys wrapped on one client cannot be
unwrapped on another:
- Relying-party ID (RP ID): the domain that owns the passkeys, usually your registrable domain. Passkeys created for one RP ID are invisible to other RP IDs.
- PRF salt input: an application-chosen string mixed into the PRF evaluation. Different salts produce unrelated PRF outputs from the same passkey.
- HKDF info: a purpose-binding string for the KEK derivation. Different info strings produce unrelated KEKs from the same PRF output.
Configure the relying party
1
Use the same RP ID on the web
A web page can use
example.com as its RP ID when its origin is
example.com or a subdomain such as app.example.com.2
Add the iOS associated domain
Add the Associated Domains capability to the app target, then include the
relying-party domain:
3
Host the Apple association file
Serve this file from
Replace
https://example.com/.well-known/apple-app-site-association:APP_ID_PREFIX with the prefix from the built app’s
application-identifier entitlement, and replace com.example.app with
your bundle identifier. Use the entitlement’s prefix rather than assuming
it matches your Apple team ID. Serve the file as application/json
over HTTPS without a redirect.Create the key manager
A key manager is bound to one profile. Configure the same profile in each client.- JavaScript
- iOS
storage. Cached PRF
output is raw key material:
createInsecureBrowserLocalStoragePasskeyKeyStorage(namespace) stores it
as plaintext in localStorage, where a same-origin script or XSS attacker
can read it and derive the KEK. Leave storage unset unless you accept
that risk, or implement PasskeyKeyStorage with storage appropriate for
your threat model.capability returns
supported, unsupported, or unknown. The check is advisory, so attempt
the ceremony unless the result is unsupported:
Enroll and wrap a CEK
Enrollment is the first-time setup for a user: generate a fresh CEK, create a PRF-capable passkey, and wrap the CEK under the passkey-derived KEK.createAndWrapKey creates the passkey and wraps the CEK in one call.
Use the same stable opaque user ID in every client, encoded as the same bytes.
Use an email address only as the user-facing account name, not as the stable
ID.
The examples use application-provided user, api, AppUser, useCek, and
useCEK placeholders. Connect them to your account, networking, and encryption
layers.
- JavaScript
- iOS
createAndWrapKey throws a PasskeyKeyError with the cancelled
category when the user cancels the ceremony.Store the wrapped record
Store one or more wrapped records for each account.encodeWrappedKeyRecord
produces the same JSON in both SDKs (a string in JavaScript, Data in Swift),
and decodeWrappedKeyRecord reads it back:
- Authenticate every read and write request.
- Associate each record with the authenticated account.
- Store each record exactly as encoded, without adding or transforming fields.
- Allow multiple records so users can enroll more than one passkey.
- Treat record deletion and replacement as security-sensitive operations.
Recover the CEK
Recovery is the returning-user flow: load the account’s wrapped records, then let the matching passkey unwrap the CEK. The user approves one passkey prompt; whichever credential they authenticate with selects the matching record. Try the local cache first if your application enables it. A cached recovery reuses the stored PRF output and skips the passkey prompt entirely.- JavaScript
- iOS
recoverKeyFromCache returns null when no matching cached PRF output
exists. recoverKey skips records whose profile differs from the
manager’s, and throws a PasskeyKeyError when the user cancels or no
usable record remains.Add another passkey
Each wrapped record ties the CEK to one passkey. To let a user unlock from more devices or survive losing a passkey, enroll additional passkeys that each wrap the same CEK. Every passkey for an account must wrap the same existing CEK. Do not generate a new key when adding another passkey, or the account will have records that recover different keys. First recover the current CEK, then pass that same CEK tocreateAndWrapKey:
- JavaScript
- iOS
Handle passkey failures
Every failure is aPasskeyKeyError with one category: unsupported,
cancelled, timeout, operation_in_progress, invalid_input, or
operation_failed. Branch on the category, not on messages.
- JavaScript
- iOS
Clear local state
Clear cached PRF output when the user signs out or removes local access.Test cross-platform recovery
Use a staging account and complete these checks before production:- Enroll on the web and store the wrapped record.
- Load the same record on iOS and recover the CEK with the synced passkey.
- Enroll on iOS and recover the CEK from the resulting record on the web.
- Confirm both clients recover identical 32-byte CEK values.
- Confirm cancellation does not create or delete a server record.
- Confirm a tampered IV or ciphertext fails authentication.
- Confirm signing out clears the local PRF cache.
Security checklist
- Keep the PRF output and unwrapped CEK on the client. Send only ciphertext and wrapped records to your server.
- Use authenticated, authorized endpoints for wrapped records.
- Use HTTPS and a stable RP ID.
- Never change profile values for existing data.
- Leave the PRF cache disabled unless your threat model allows it, and prefer platform-protected storage over plaintext browser storage.
- Support multiple passkeys and a deliberate recovery flow. A user with a single passkey and no other recovery path loses their data if the passkey is lost.
- Treat passkey removal and wrapped-record deletion as sensitive operations.
- Remember the boundary: this design keeps your server out of the key path, but the client code that handles the unwrapped CEK is still trusted. Protect your software supply chain and release process.
Next steps
Passkey Kit source
Review the JavaScript and Swift implementations, tests, and protocol
constants.
JavaScript package
View published versions and npm installation details.
WebAuthn PRF extension
Read the specification behind the passkey-derived secret used for key
protection.
Tinfoil Chat
See customer-managed keys in production across web and iOS clients.