stable

recovery

Printable, burn-on-use recovery codes — the last-resort unlock when secret, passkey, and OIDC are all unavailable.

@noy-db/on-recovery · pnpm add @noy-db/on-recovery Last-resort unlock via one-time printable codes.

What it does

generateRecoveryCodeSet({ deks, count }) mints (default 10, clamped 1–100) 28-character codes — a 24-char Base32 body plus a 4-char checksum, RFC 4648 alphabet (no confusing 0/O, 1/I/L), 120 bits of entropy each — and returns the plaintext codes (show once, never store) plus PaperRecoveryEntry records (safe to persist). deks is required: it is the vault's current DEK set, typically (await db.team.getKeyring(vault)).deks, and each entry wraps those DEKs against one code.

parseRecoveryCode validates and normalizes user input (valid / invalid-checksum / invalid-format), and the hub's unwrapDeksFromPaperEntry(entry, code) recovers the DEK set from one enrolled entry; the caller deletes the matching entry on success so the code burns itself and can never be replayed. Crypto layer only — storage, audit, and rate-limiting are the caller's job via the hub's keyring + ledger APIs. Never enroll it as the only unlock method.

There is no KEK path. Recovery codes wrap the DEK set directly. Older material — including this package's own README before 0.7.0 (noy-db #1252) — described a kek option, unwrapKEKFromRecovery, and _recovery_<N> keyring entries; none of those exist in any shipped version, having been removed with the tier-2 wrap-DEKs unification.

Usage

import { generateRecoveryCodeSet, formatRecoveryCode, parseRecoveryCode } from '@noy-db/on-recovery'
import { unwrapDeksFromPaperEntry } from '@noy-db/hub'

// Enrolment — `deks` is the vault's current DEK set, and is required.
const { deks } = await db.team.getKeyring(vault)
const { codes, entries } = await generateRecoveryCodeSet({ deks, count: 10 })
displayRecoveryCodes(codes.map(formatRecoveryCode)) // 'QWJC-3K7M-PX2A-6RT4-BND6-HFGE-5SYU'
await persistRecoveryEntries(entries) // stored alongside the vault, located by codeId

// Recovery — the user types one code off the printed sheet.
const parsed = parseRecoveryCode(userTypedCode)
if (parsed.status === 'valid') {
  const entry = await findRecoveryEntry(parsed.code)
  const recovered = await unwrapDeksFromPaperEntry(entry, parsed.code)
  await burnRecoveryEntry(entry) // one use only — delete on success
}

See also