recovery
@noy-db/on-recovery·pnpm add @noy-db/on-recoveryLast-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 akekoption,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
}