Search selfstore
v1.8.21

API: passkey unlock

Open a store with the device instead of the keyboard. A platform passkey carrying the WebAuthn PRF extension yields a stable 32-byte secret, and this module seals your own secret behind it - normally the backup password - so unlocking becomes a fingerprint instead of typing.

import { passkeyUnlock } from 'selfstore/passkey';

const device = passkeyUnlock({ appName: 'Ledger', salt: 'ledger-unlock-v1' });

// Offer it only where the device can recognise someone.
if (await device.available()) {
  await device.enroll(password); // false when the user cancels, or PRF is absent
}

// Later, on the opening screen:
const password = await device.reveal(); // null means: ask for it

It carries the password, it does not replace it

This is the part to get right before shipping it.

The password keeps opening everything, on every device, and stays the thing that encrypts the backup. What this module adds is bound to one browser profile on one machine. Losing the passkey costs a typed password, never data, and that asymmetry is the design: granting the data a second independent key widens what a stolen device gives away, which is the opposite of what a locked cache is for.

So the password field belongs on the screen even when a passkey is enrolled. An unlock that cannot fall back is a lockout waiting for the day the authenticator says no.

What it preserves

The PRF secret never leaves the authenticator and demands user verification, so the sealed blob is useless on its own: a copy of the browser profile opens nothing. That is precisely the guarantee a lock-mode cache exists for, so enrolling does not trade it away.

passkeyUnlock

function passkeyUnlock(options: PasskeyUnlockOptions): PasskeyUnlock;

Nothing here runs unless you call it. There is no global switch and nothing turns itself on: an app that never calls passkeyUnlock() has no such capability, and forget() is the documented way back out.

PasskeyUnlockOptions

interface PasskeyUnlockOptions {
  appName: string;    // shown by the operating system's passkey prompt
  salt: string;       // domain separator, not a secret
  storageKey?: string; // default: 'selfstore.passkey'
}

salt may be a constant: the credential’s own secret is what makes the derived key unique, so this only separates domains. Changing it rotates every passkey-derived key at once, which orphans every existing enrolment - pick one and leave it alone.

Give storageKey a distinct value if one origin hosts two apps, so that enrolling in one does not answer for the other.

PasskeyUnlock

interface PasskeyUnlock {
  available(): Promise<boolean>;
  enrolled(): boolean;
  enroll(secret: string): Promise<boolean>;
  reveal(): Promise<string | null>;
  forget(): void;
}
Method What it answers
available() A user-verifying platform authenticator exists. Necessary, not sufficient - only an actual enroll() proves the PRF extension works here, so use this to decide whether to show the control, not to promise it will succeed.
enrolled() This device currently holds a sealed secret. Synchronous, so an opening screen can branch on it without awaiting anything.
enroll(secret) Creates the passkey and seals secret behind it. false on cancel, or where PRF yields nothing.
reveal() Asks for Face / fingerprint / Hello and returns the secret. null on cancel, on a missing enrolment, or on any failure. Never throws.
forget() Drops the sealed secret from this device.

forget() does not delete the platform credential: the operating system owns it, and only the person in front of the machine can remove it from their passkey manager. Say that in your interface rather than implying a full erasure.

It fails closed

The dangerous case is a platform that announces an authenticator, lets the credential be created, and returns no PRF result. Storing anything there would leave an enrolment nothing could ever open, so enroll() refuses instead and returns false. A result of the wrong size is refused too.

There is also a self-heal: an enrolment the passkey can no longer open clears itself on the next reveal() rather than failing at every attempt for good.

Both mean the same thing for your interface - a false or a null is a state to say out loud, not a glitch to swallow. The person is about to wonder why nothing happened.

Where it works

The PRF extension is required: recent Chrome and Edge, Safari 18+, recent Android. Inside a native WebView it depends on the system WebView version. Nothing trusts that blindly - available() and enroll() feature-detect at runtime, so the option is only ever usable where a real secret comes back. Everywhere else the password stays what it always was.