Unlock ceremonies
Audience: an app developer designing how users open the vault — day zero, every day after, and the day everything is lost. Bundle: core +
@noy-db/on-webauthn(passkeys) +@noy-db/on-recovery(printed codes) +withSession()(re-auth policy).
Unlocking is a ceremony, not a config option: something the user does, in an order you design. This recipe walks the complete lifecycle — the secret as root of trust, a passkey so nothing is typed day-to-day, a session policy that refuses sensitive operations until fresh proof-of-presence, and recovery codes for the day the passkey and secret are both gone. Methods stack: one vault, several ways in.
Verified by: showcases/src/recipe-unlock-ceremonies.recipe.test.ts — the code below is the same code that runs there (with a synthetic PRF authenticator standing in for Touch ID; the calls are identical against real hardware).
Prerequisites
- Packages:
pnpm add @noy-db/hub @noy-db/on-webauthn @noy-db/on-recovery sessionStrategy: withSession()for the policy step — the enforcer ships in@noy-db/hub/session.- A browser with WebAuthn for passkeys (
navigator.credentials+ the PRF extension — every UI runsisWebAuthnAvailable()as the pre-flight). Node-side unlock uses theat-*family instead. - Somewhere to persist the enrollment record — it is opaque ciphertext, so any store or plain file works.
Day zero — the secret creates the vault
The secret is the root of trust: it derives the key that wraps everything else.
import { createNoydb } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
const db = await createNoydb({
store: toBrowserIdb(),
user: 'alice',
secret: 'correct horse battery staple', // 4+ diceware words or 12+ chars
})
const vault = await db.openVault('acme')
Enroll a passkey
enrollWebAuthn calls navigator.credentials.create, derives a wrapping key from the authenticator's PRF output, and returns an enrollment record — the keyring wrapped under a key that never left the hardware:
import { enrollWebAuthn, isWebAuthnAvailable } from '@noy-db/on-webauthn'
if (isWebAuthnAvailable()) {
const keyring = await db.team.getKeyring('acme')
const enrollment = await enrollWebAuthn(keyring, 'acme')
// persist it anywhere — it is opaque ciphertext:
localStorage.setItem('acme-passkey', JSON.stringify(enrollment))
}
The secret still works — enrollment adds a way in, it never replaces one.
Every day after — unlock with a touch
unlockWebAuthn calls navigator.credentials.get, reproduces the wrapping key from the same authenticator, and hands back the full keyring. Feed it to createNoydb through the getKeyring callback — the cold-start contract every on-* method satisfies:
import { unlockWebAuthn } from '@noy-db/on-webauthn'
const enrollment = JSON.parse(localStorage.getItem('acme-passkey')!)
const unlocked = await unlockWebAuthn(enrollment) // ← the touch
const db = await createNoydb({
store: toBrowserIdb(),
user: 'alice',
getKeyring: async () => unlocked,
})
No secret in the page, nothing to keylog — the property the threat model leans on.
Gate sensitive operations — session policy
The session layer is a policy oracle: it refuses operations you listed, and your app supplies the re-auth act — a fresh on-* verification — before proceeding:
import { createSession, createEnforcer } from '@noy-db/hub/session'
import { SessionPolicyError } from '@noy-db/hub'
const { token, sessionId } = await createSession(keyring, 'acme', { ttlMs: 15 * 60_000 })
const enforcer = createEnforcer({
policy: { idleTimeoutMs: 15 * 60_000, requireReAuthFor: ['grant'] },
sessionId,
onRevoke: (reason) => showLockScreen(reason), // 'idle' | 'absolute' | 'background'
})
enforcer.touch() // top of every operation the idle timer should track
try {
enforcer.checkOperation('grant')
} catch (e) {
if (e instanceof SessionPolicyError) {
await unlockWebAuthn(enrollment) // fresh proof-of-presence
// …now perform the grant
}
}
sessionStrategy: withSession() on createNoydb enables the machinery; the session page covers liveness, revocation, and the full policy surface.
The escape hatch — printed recovery codes
For the day the passkey is lost and the secret is forgotten: a set of one-time codes, each independently able to unwrap the collection keys. Print them, put them in a drawer:
import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'
import { unwrapDeksFromPaperEntry } from '@noy-db/hub'
const keyring = await db.team.getKeyring('acme')
const set = await generateRecoveryCodeSet({ deks: keyring.deks, count: 3 })
// set.codes → print these; set.entries → persist alongside the vault
// Years later, the user types one code from the sheet:
const parsed = parseRecoveryCode(typedCode)
if (parsed.status === 'valid') {
const deks = await unwrapDeksFromPaperEntry(matchingEntry, parsed.code)
// the recovered DEKs decrypt everything the originals encrypted
}
Variations
The ceremony's shape stays the same whichever method plays each part — every on-* unlock returns the same UnlockedKeyring:
- SSO instead of passkeys:
on-oidc(Google / Apple / LINE proven in showcases 73–75). - Second factors:
on-totp,on-email-otp,on-pinfor session resume. - Social recovery instead of paper:
on-shamirsplits the recovery secret across trustees. - Duress and lockout:
on-threatwraps any of the above.
When an unlock FAILS
⚠️ Before you translate an unlock error into user-facing advice, read Choosing a store → A store's locality changes what an unlock error means. The short version: branch on error.details.reason first ('format-superseded' is a format transition, not an attack; the absence reasons are unclassifiable), then on whether the store is local or remote — and in every branch, say what to preserve before telling anyone to remove anything.
See also
on-*family — the full unlock catalog and how methods compare.- Choosing a store — why the same unlock error needs different advice on a remote store.
- session — policies, liveness, revocation in depth.
- Threat model — what each method protects against, honestly.