Passkey Architecture
Platus derives a user's spending key sk from a passkey stored in a platform authenticator (Touch ID, Face ID, Windows Hello, or a PRF-capable hardware key). The user authenticates with biometrics, the authenticator returns a deterministic 32-byte secret that secret becomes the seed for sk.
This eliminates seed phrases and reduces the trust surface to the authenticator hardware.
Key derivation via WebAuthn PRF
The WebAuthn PRF (Pseudorandom Function) extension lets an authenticator evaluate a keyed PRF — backed by the CTAP2 hmac-secret extension with a caller-supplied input and return a deterministic 32-byte output. The output is:
- Authenticator-bound: a different authenticator (even for the same username) returns a different value.
- Biometric-protected: the PRF call only succeeds after local user verification (fingerprint, face, PIN).
- Non-exportable: the PRF seed never leaves the authenticator.
Security properties
| Property | Mechanism |
|---|---|
| No seed phrase | sk derived from authenticator PRF, never displayed |
| Biometric protection | userVerification: 'required'; PRF result is UV-gated |
| Non-exportable PRF seed | The HMAC secret only exists inside the authenticator |
| Platform binding | authenticatorAttachment: 'platform' by default, residentKey: 'required' |
| Phishing resistance | WebAuthn origin binding — credential only works on the registered rpId |
| Replay resistance | Caller must supply a server-issued challenge (≥16 bytes); client-side challenges are rejected |
| Domain separation | Distinct HKDF info labels for seed, store-AES, store-HMAC |
Browser support
The PRF extension is supported in:
- Chrome/Edge 116+ on desktop and Android (via CTAP2 authenticators)
- Safari 18+ / iOS 18+
- Firefox 130+
@platus/passkey usage
import { Passkey, prfOutputToSeed, deriveStoreKeysFromPrf } from '@platus/passkey';
import { deriveSpendingKeyFromSeed, deriveSpendingKeyChain } from '@platus/core';
import { BrowserStore } from '@platus/core/store/browserStore';
const passkey = new Passkey({ rpId: 'platus.xyz', timeout: 60_000 });
// --- Registration (first time) ---
const challenge = await fetchChallengeFromServer();
const { credentialId, prfFirst } = await passkey.register({
challenge,
rpName: 'Platus',
user: { id: crypto.getRandomValues(new Uint8Array(32)), name: 'alice@example.com' },
});
await persistCredentialOnServer(credentialId);
// PRF output is sometimes available at registration; if so, derive sk without
// a second prompt. Otherwise, immediately authenticate() to fetch it.
const prfOutput = prfFirst ?? (await passkey.authenticate({
challenge: await fetchChallengeFromServer(),
})).prfOutput;
// --- Derive keys ---
const seed = prfOutputToSeed(prfOutput);
const sk = deriveSpendingKeyFromSeed(seed);
const skc = deriveSpendingKeyChain(sk);
// --- Unlock encrypted local storage ---
const { aesKey, hmacKey } = await deriveStoreKeysFromPrf(prfOutput);
const store = new BrowserStore();
await store.initializeWithKey(aesKey, hmacKey);
// --- Logout ---
await passkey.logout({ store, prfOutput });
The Passkey class is browser-only — it throws if instantiated in a Node.js environment.