stable

Unlock ceremonies

The full unlock lifecycle: secret setup, passkey enrollment, daily no-secret unlock, session re-auth gates, and printed recovery codes.

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 runs isWebAuthnAvailable() as the pre-flight). Node-side unlock uses the at-* 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-pin for session resume.
  • Social recovery instead of paper: on-shamir splits the recovery secret across trustees.
  • Duress and lockout: on-threat wraps 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.