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 builds on one property of modern passkeys: 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 bundle of plain JSON-safe strings.
- Server storage. Your server stores the wrapped bundle. 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 bundles 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 bundles 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 shared protocol settings
Three values define your application’s key-derivation domain. 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. The prefix is often your Apple team ID, but it can
differ for older or transferred apps. Serve the file as application/json
over HTTPS without a redirect.Create the client
Configure the same protocol strings in each client.- JavaScript
- iOS
localStorage. A same-origin script or XSS attacker that reads it can
derive the KEK. Pass storage: null unless you accept that risk, or provide
a StorageAdapter backed by storage appropriate for your threat model.isPrfSupported()
returns an optimistic capability check for the current browser or device:
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.enroll
runs all three steps in one call.
Use the same stable opaque user ID in every client. 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
enroll returns null when the user cancels the ceremony.Store the wrapped bundle
Store one or more wrapped bundles for each account. Both SDKs serialize the same fields:- Authenticate every read and write request.
- Associate each bundle with the authenticated account.
- Preserve all three fields without transforming their encoding.
- Allow multiple bundles so users can enroll more than one passkey.
- Treat bundle deletion and replacement as security-sensitive operations.
Unlock the CEK
Unlocking is the returning-user flow: load the account’s wrapped bundles, then let the matching passkey unwrap the CEK. The user approves one passkey prompt; whichever credential they authenticate with selects the matching bundle. Try the local cache first if your application enables it. A cached unlock reuses the stored PRF output and skips the passkey prompt entirely.- JavaScript
- iOS
unlock returns null when the user cancels or no usable credential is
available.Add another passkey
Each wrapped bundle 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 callgenerateCek or generateCEK when adding another passkey, or the account will
have bundles that recover different keys.
First unlock the current CEK, then create the additional passkey and wrap that
same CEK:
- JavaScript
- iOS
Handle passkey failures
Classify errors by type or enum case. Do not branch on localized 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 bundle.
- Load the same bundle on iOS and unlock it with the synced passkey.
- Enroll on iOS and unlock the resulting bundle on the web.
- Confirm both clients recover identical 32-byte CEK values.
- Confirm cancellation does not create or delete a server bundle.
- 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 bundles to your server.
- Use authenticated, authorized endpoints for wrapped bundles.
- Use HTTPS and a stable RP ID.
- Never change protocol strings for existing data.
- Prefer platform-protected storage over plaintext browser storage when your threat model requires at-rest protection, or disable the PRF cache.
- 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-bundle 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.

