Skip to main content

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

PropertyMechanism
No seed phrasesk derived from authenticator PRF, never displayed
Biometric protectionuserVerification: 'required'; PRF result is UV-gated
Non-exportable PRF seedThe HMAC secret only exists inside the authenticator
Platform bindingauthenticatorAttachment: 'platform' by default, residentKey: 'required'
Phishing resistanceWebAuthn origin binding — credential only works on the registered rpId
Replay resistanceCaller must supply a server-issued challenge (≥16 bytes); client-side challenges are rejected
Domain separationDistinct 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.