@noy-db/on-recovery 0.2.0-pre.1 → 0.2.0-pre.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication is\n * unavailable. Codes are designed to be printed on paper and stored\n * in a safe — each code unlocks the vault exactly once and then is\n * burned by deleting its `_meta/recovery-paper` entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Format (post pre.8, #38 Option A)\n *\n * This package is now a **thin code-generator + parser layer over\n * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly\n * three things:\n *\n * 1. Generate printable Base32 codes with checksums (the user-facing\n * string format).\n * 2. Parse user input back into a normalized code (whitespace /\n * hyphen / case insensitive).\n * 3. Delegate the wrapping crypto to the hub via\n * `mintPaperRecoveryEntry(deks, code, codeId)`.\n *\n * This delegation aligns recovery with the hub's wrap-DEKs primitive\n * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`\n * after #26 Path C). It eliminates the format mismatch that made the\n * pre.7 package unusable with `db.enrollRecovery` (#38).\n *\n * ## Usage\n *\n * ```ts\n * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'\n *\n * // ENROLL — after the user unlocks at tier 1, mint N codes\n * const keyring = await db.getKeyring('acme')\n * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })\n * showCodesToUser(codes)\n * await db.enrollRecovery('acme', { profile: 'paper', entries })\n *\n * // RECOVER — user types one back later (handled by db.recoverPassphrase)\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') return handleInvalid(parsed.status)\n * await db.recoverPassphrase('acme', {\n * newPassphrase,\n * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID, mintPaperRecoveryEntry, type PaperRecoveryEntry } from '@noy-db/hub'\n\n// Constants ────────────────────────────────────────────────────────────\n\n/** RFC 4648 Base32 alphabet — A-Z + 2-7, no ambiguous chars. */\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Characters to ignore on input (whitespace, hyphens, lowercase). */\nconst STRIPPABLE = /[\\s\\-_]/g\n\n/** How many random bytes of entropy per code. */\nconst CODE_ENTROPY_BYTES = 15 // 15 bytes = 120 bits = exactly 24 Base32 chars (clean groups of 4)\n\n/** Length of the checksum portion (Base32 chars). */\nconst CHECKSUM_LEN = 4 // 24 body + 4 checksum = 28 chars = 7 groups of 4\n\n// Types ────────────────────────────────────────────────────────────────\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /**\n * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).\n * Required — proves possession and is the input the hub's\n * `mintPaperRecoveryEntry` needs.\n */\n deks: Map<string, CryptoKey>\n}\n\n/** Result of `parseRecoveryCode()`. */\nexport type ParseResult =\n | { status: 'valid'; code: string } // Normalized, checksum-verified\n | { status: 'invalid-checksum' } // Format OK, checksum wrong\n | { status: 'invalid-format' } // Not a valid code shape\n\n// Code generation ──────────────────────────────────────────────────────\n\n/**\n * Generate a fresh recovery-code set. Returned `codes` must be shown\n * to the user exactly once (print/save); `entries` go into the vault\n * via `db.enrollRecovery({ profile: 'paper', entries })`.\n *\n * Internally calls the hub's `mintPaperRecoveryEntry` once per code.\n * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`\n * stores the entries verbatim and `db.recoverPassphrase` consumes them\n * via `unwrapDeksFromPaperEntry`.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: PaperRecoveryEntry[] }> {\n const count = opts.count ?? 10\n if (!Number.isInteger(count) || count < 1 || count > 100) {\n throw new Error(`on-recovery: count must be 1-100 (got ${count})`)\n }\n\n const codes: string[] = []\n const entries: PaperRecoveryEntry[] = []\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n // The hub stores entries keyed on the NORMALIZED code (raw, no\n // hyphens). Mint with the normalized form so `db.recoverPassphrase`'s\n // `normalizePaperCode(input)` matches at unlock time.\n const entry = await mintPaperRecoveryEntry(opts.deks, raw, generateULID())\n codes.push(formatted)\n entries.push(entry)\n }\n\n return { codes, entries }\n}\n\n// Code parsing + normalization ─────────────────────────────────────────\n\n/**\n * Parse user input into a normalized recovery code. Accepts whitespace,\n * hyphens, and lowercase — strips them all. Verifies the checksum.\n */\nexport function parseRecoveryCode(input: string): ParseResult {\n const normalized = input.toUpperCase().replace(STRIPPABLE, '')\n\n const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN\n if (normalized.length !== expectedLen) {\n return { status: 'invalid-format' }\n }\n for (const ch of normalized) {\n if (!BASE32_ALPHABET.includes(ch)) {\n return { status: 'invalid-format' }\n }\n }\n\n const bodyLen = normalized.length - CHECKSUM_LEN\n const body = normalized.slice(0, bodyLen)\n const checksum = normalized.slice(bodyLen)\n\n if (computeChecksum(body) !== checksum) {\n return { status: 'invalid-checksum' }\n }\n\n return { status: 'valid', code: normalized }\n}\n\n/**\n * Format a normalized recovery code for display (groups of 4, hyphenated).\n * Inverse of the strip-hyphens step in `parseRecoveryCode`.\n */\nexport function formatRecoveryCode(normalizedCode: string): string {\n const groups: string[] = []\n for (let i = 0; i < normalizedCode.length; i += 4) {\n groups.push(normalizedCode.slice(i, i + 4))\n }\n return groups.join('-')\n}\n\n// Internals ────────────────────────────────────────────────────────────\n\nfunction generateRawCode(): string {\n const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES))\n const body = base32Encode(entropy)\n const checksum = computeChecksum(body)\n return body + checksum\n}\n\nfunction formatCodeForDisplay(rawNormalized: string): string {\n return formatRecoveryCode(rawNormalized)\n}\n\n/**\n * Deterministic 4-character checksum over Base32 body. Catches\n * transcription errors (single-char swaps, shifted digits) with very\n * high probability.\n *\n * Uses a simple polynomial hash reduced modulo the Base32 alphabet\n * size (32). Four output chars = 20 bits ≈ 1-in-1M false positive.\n */\nfunction computeChecksum(body: string): string {\n let h = 0\n for (let i = 0; i < body.length; i++) {\n const v = BASE32_ALPHABET.indexOf(body[i]!)\n h = (h * 33 + v) >>> 0\n }\n const chars: string[] = []\n for (let i = 0; i < CHECKSUM_LEN; i++) {\n chars.push(BASE32_ALPHABET[(h >>> (i * 5)) & 0x1f]!)\n }\n return chars.join('')\n}\n\nfunction base32CharsForBytes(n: number): number {\n return Math.ceil((n * 8) / 5)\n}\n\nfunction base32Encode(bytes: Uint8Array): string {\n let bits = 0\n let value = 0\n let out = ''\n for (let i = 0; i < bytes.length; i++) {\n value = (value << 8) | bytes[i]!\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(value >>> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]\n }\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication is\n * unavailable. Codes are designed to be printed on paper and stored\n * in a safe — each code unlocks the vault exactly once and then is\n * burned by deleting its `_meta/recovery-paper` entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Format (Option A)\n *\n * This package is now a **thin code-generator + parser layer over\n * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly\n * three things:\n *\n * 1. Generate printable Base32 codes with checksums (the user-facing\n * string format).\n * 2. Parse user input back into a normalized code (whitespace /\n * hyphen / case insensitive).\n * 3. Delegate the wrapping crypto to the hub via\n * `mintPaperRecoveryEntry(deks, code, codeId)`.\n *\n * This delegation aligns recovery with the hub's wrap-DEKs primitive\n * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`\n * after the wrap-DEKs path change). It eliminates the format mismatch\n * that made the previous package version unusable with `db.enrollRecovery`.\n *\n * ## Usage\n *\n * ```ts\n * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'\n *\n * // ENROLL — after the user unlocks at tier 1, mint N codes\n * const keyring = await db.getKeyring('acme')\n * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })\n * showCodesToUser(codes)\n * await db.enrollRecovery('acme', { profile: 'paper', entries })\n *\n * // RECOVER — user types one back later (handled by db.recoverPassphrase)\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') return handleInvalid(parsed.status)\n * await db.recoverPassphrase('acme', {\n * newPassphrase,\n * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID, mintPaperRecoveryEntry, type PaperRecoveryEntry } from '@noy-db/hub'\n\n// Constants ────────────────────────────────────────────────────────────\n\n/** RFC 4648 Base32 alphabet — A-Z + 2-7, no ambiguous chars. */\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Characters to ignore on input (whitespace, hyphens, lowercase). */\nconst STRIPPABLE = /[\\s\\-_]/g\n\n/** How many random bytes of entropy per code. */\nconst CODE_ENTROPY_BYTES = 15 // 15 bytes = 120 bits = exactly 24 Base32 chars (clean groups of 4)\n\n/** Length of the checksum portion (Base32 chars). */\nconst CHECKSUM_LEN = 4 // 24 body + 4 checksum = 28 chars = 7 groups of 4\n\n// Types ────────────────────────────────────────────────────────────────\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /**\n * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).\n * Required — proves possession and is the input the hub's\n * `mintPaperRecoveryEntry` needs.\n */\n deks: Map<string, CryptoKey>\n}\n\n/** Result of `parseRecoveryCode()`. */\nexport type ParseResult =\n | { status: 'valid'; code: string } // Normalized, checksum-verified\n | { status: 'invalid-checksum' } // Format OK, checksum wrong\n | { status: 'invalid-format' } // Not a valid code shape\n\n// Code generation ──────────────────────────────────────────────────────\n\n/**\n * Generate a fresh recovery-code set. Returned `codes` must be shown\n * to the user exactly once (print/save); `entries` go into the vault\n * via `db.enrollRecovery({ profile: 'paper', entries })`.\n *\n * Internally calls the hub's `mintPaperRecoveryEntry` once per code.\n * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`\n * stores the entries verbatim and `db.recoverPassphrase` consumes them\n * via `unwrapDeksFromPaperEntry`.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: PaperRecoveryEntry[] }> {\n const count = opts.count ?? 10\n if (!Number.isInteger(count) || count < 1 || count > 100) {\n throw new Error(`on-recovery: count must be 1-100 (got ${count})`)\n }\n\n const codes: string[] = []\n const entries: PaperRecoveryEntry[] = []\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n // The hub stores entries keyed on the NORMALIZED code (raw, no\n // hyphens). Mint with the normalized form so `db.recoverPassphrase`'s\n // `normalizePaperCode(input)` matches at unlock time.\n const entry = await mintPaperRecoveryEntry(opts.deks, raw, generateULID())\n codes.push(formatted)\n entries.push(entry)\n }\n\n return { codes, entries }\n}\n\n// Code parsing + normalization ─────────────────────────────────────────\n\n/**\n * Parse user input into a normalized recovery code. Accepts whitespace,\n * hyphens, and lowercase — strips them all. Verifies the checksum.\n */\nexport function parseRecoveryCode(input: string): ParseResult {\n const normalized = input.toUpperCase().replace(STRIPPABLE, '')\n\n const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN\n if (normalized.length !== expectedLen) {\n return { status: 'invalid-format' }\n }\n for (const ch of normalized) {\n if (!BASE32_ALPHABET.includes(ch)) {\n return { status: 'invalid-format' }\n }\n }\n\n const bodyLen = normalized.length - CHECKSUM_LEN\n const body = normalized.slice(0, bodyLen)\n const checksum = normalized.slice(bodyLen)\n\n if (computeChecksum(body) !== checksum) {\n return { status: 'invalid-checksum' }\n }\n\n return { status: 'valid', code: normalized }\n}\n\n/**\n * Format a normalized recovery code for display (groups of 4, hyphenated).\n * Inverse of the strip-hyphens step in `parseRecoveryCode`.\n */\nexport function formatRecoveryCode(normalizedCode: string): string {\n const groups: string[] = []\n for (let i = 0; i < normalizedCode.length; i += 4) {\n groups.push(normalizedCode.slice(i, i + 4))\n }\n return groups.join('-')\n}\n\n// Internals ────────────────────────────────────────────────────────────\n\nfunction generateRawCode(): string {\n const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES))\n const body = base32Encode(entropy)\n const checksum = computeChecksum(body)\n return body + checksum\n}\n\nfunction formatCodeForDisplay(rawNormalized: string): string {\n return formatRecoveryCode(rawNormalized)\n}\n\n/**\n * Deterministic 4-character checksum over Base32 body. Catches\n * transcription errors (single-char swaps, shifted digits) with very\n * high probability.\n *\n * Uses a simple polynomial hash reduced modulo the Base32 alphabet\n * size (32). Four output chars = 20 bits ≈ 1-in-1M false positive.\n */\nfunction computeChecksum(body: string): string {\n let h = 0\n for (let i = 0; i < body.length; i++) {\n const v = BASE32_ALPHABET.indexOf(body[i]!)\n h = (h * 33 + v) >>> 0\n }\n const chars: string[] = []\n for (let i = 0; i < CHECKSUM_LEN; i++) {\n chars.push(BASE32_ALPHABET[(h >>> (i * 5)) & 0x1f]!)\n }\n return chars.join('')\n}\n\nfunction base32CharsForBytes(n: number): number {\n return Math.ceil((n * 8) / 5)\n}\n\nfunction base32Encode(bytes: Uint8Array): string {\n let bits = 0\n let value = 0\n let out = ''\n for (let i = 0; i < bytes.length; i++) {\n value = (value << 8) | bytes[i]!\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(value >>> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]\n }\n return out\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -10,7 +10,7 @@ import { PaperRecoveryEntry } from '@noy-db/hub';
10
10
  *
11
11
  * Part of the `@noy-db/on-*` authentication family.
12
12
  *
13
- * ## Format (post pre.8, #38 Option A)
13
+ * ## Format (Option A)
14
14
  *
15
15
  * This package is now a **thin code-generator + parser layer over
16
16
  * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly
@@ -25,8 +25,8 @@ import { PaperRecoveryEntry } from '@noy-db/hub';
25
25
  *
26
26
  * This delegation aligns recovery with the hub's wrap-DEKs primitive
27
27
  * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`
28
- * after #26 Path C). It eliminates the format mismatch that made the
29
- * pre.7 package unusable with `db.enrollRecovery` (#38).
28
+ * after the wrap-DEKs path change). It eliminates the format mismatch
29
+ * that made the previous package version unusable with `db.enrollRecovery`.
30
30
  *
31
31
  * ## Usage
32
32
  *
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ import { PaperRecoveryEntry } from '@noy-db/hub';
10
10
  *
11
11
  * Part of the `@noy-db/on-*` authentication family.
12
12
  *
13
- * ## Format (post pre.8, #38 Option A)
13
+ * ## Format (Option A)
14
14
  *
15
15
  * This package is now a **thin code-generator + parser layer over
16
16
  * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly
@@ -25,8 +25,8 @@ import { PaperRecoveryEntry } from '@noy-db/hub';
25
25
  *
26
26
  * This delegation aligns recovery with the hub's wrap-DEKs primitive
27
27
  * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`
28
- * after #26 Path C). It eliminates the format mismatch that made the
29
- * pre.7 package unusable with `db.enrollRecovery` (#38).
28
+ * after the wrap-DEKs path change). It eliminates the format mismatch
29
+ * that made the previous package version unusable with `db.enrollRecovery`.
30
30
  *
31
31
  * ## Usage
32
32
  *
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication is\n * unavailable. Codes are designed to be printed on paper and stored\n * in a safe — each code unlocks the vault exactly once and then is\n * burned by deleting its `_meta/recovery-paper` entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Format (post pre.8, #38 Option A)\n *\n * This package is now a **thin code-generator + parser layer over\n * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly\n * three things:\n *\n * 1. Generate printable Base32 codes with checksums (the user-facing\n * string format).\n * 2. Parse user input back into a normalized code (whitespace /\n * hyphen / case insensitive).\n * 3. Delegate the wrapping crypto to the hub via\n * `mintPaperRecoveryEntry(deks, code, codeId)`.\n *\n * This delegation aligns recovery with the hub's wrap-DEKs primitive\n * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`\n * after #26 Path C). It eliminates the format mismatch that made the\n * pre.7 package unusable with `db.enrollRecovery` (#38).\n *\n * ## Usage\n *\n * ```ts\n * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'\n *\n * // ENROLL — after the user unlocks at tier 1, mint N codes\n * const keyring = await db.getKeyring('acme')\n * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })\n * showCodesToUser(codes)\n * await db.enrollRecovery('acme', { profile: 'paper', entries })\n *\n * // RECOVER — user types one back later (handled by db.recoverPassphrase)\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') return handleInvalid(parsed.status)\n * await db.recoverPassphrase('acme', {\n * newPassphrase,\n * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID, mintPaperRecoveryEntry, type PaperRecoveryEntry } from '@noy-db/hub'\n\n// Constants ────────────────────────────────────────────────────────────\n\n/** RFC 4648 Base32 alphabet — A-Z + 2-7, no ambiguous chars. */\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Characters to ignore on input (whitespace, hyphens, lowercase). */\nconst STRIPPABLE = /[\\s\\-_]/g\n\n/** How many random bytes of entropy per code. */\nconst CODE_ENTROPY_BYTES = 15 // 15 bytes = 120 bits = exactly 24 Base32 chars (clean groups of 4)\n\n/** Length of the checksum portion (Base32 chars). */\nconst CHECKSUM_LEN = 4 // 24 body + 4 checksum = 28 chars = 7 groups of 4\n\n// Types ────────────────────────────────────────────────────────────────\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /**\n * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).\n * Required — proves possession and is the input the hub's\n * `mintPaperRecoveryEntry` needs.\n */\n deks: Map<string, CryptoKey>\n}\n\n/** Result of `parseRecoveryCode()`. */\nexport type ParseResult =\n | { status: 'valid'; code: string } // Normalized, checksum-verified\n | { status: 'invalid-checksum' } // Format OK, checksum wrong\n | { status: 'invalid-format' } // Not a valid code shape\n\n// Code generation ──────────────────────────────────────────────────────\n\n/**\n * Generate a fresh recovery-code set. Returned `codes` must be shown\n * to the user exactly once (print/save); `entries` go into the vault\n * via `db.enrollRecovery({ profile: 'paper', entries })`.\n *\n * Internally calls the hub's `mintPaperRecoveryEntry` once per code.\n * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`\n * stores the entries verbatim and `db.recoverPassphrase` consumes them\n * via `unwrapDeksFromPaperEntry`.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: PaperRecoveryEntry[] }> {\n const count = opts.count ?? 10\n if (!Number.isInteger(count) || count < 1 || count > 100) {\n throw new Error(`on-recovery: count must be 1-100 (got ${count})`)\n }\n\n const codes: string[] = []\n const entries: PaperRecoveryEntry[] = []\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n // The hub stores entries keyed on the NORMALIZED code (raw, no\n // hyphens). Mint with the normalized form so `db.recoverPassphrase`'s\n // `normalizePaperCode(input)` matches at unlock time.\n const entry = await mintPaperRecoveryEntry(opts.deks, raw, generateULID())\n codes.push(formatted)\n entries.push(entry)\n }\n\n return { codes, entries }\n}\n\n// Code parsing + normalization ─────────────────────────────────────────\n\n/**\n * Parse user input into a normalized recovery code. Accepts whitespace,\n * hyphens, and lowercase — strips them all. Verifies the checksum.\n */\nexport function parseRecoveryCode(input: string): ParseResult {\n const normalized = input.toUpperCase().replace(STRIPPABLE, '')\n\n const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN\n if (normalized.length !== expectedLen) {\n return { status: 'invalid-format' }\n }\n for (const ch of normalized) {\n if (!BASE32_ALPHABET.includes(ch)) {\n return { status: 'invalid-format' }\n }\n }\n\n const bodyLen = normalized.length - CHECKSUM_LEN\n const body = normalized.slice(0, bodyLen)\n const checksum = normalized.slice(bodyLen)\n\n if (computeChecksum(body) !== checksum) {\n return { status: 'invalid-checksum' }\n }\n\n return { status: 'valid', code: normalized }\n}\n\n/**\n * Format a normalized recovery code for display (groups of 4, hyphenated).\n * Inverse of the strip-hyphens step in `parseRecoveryCode`.\n */\nexport function formatRecoveryCode(normalizedCode: string): string {\n const groups: string[] = []\n for (let i = 0; i < normalizedCode.length; i += 4) {\n groups.push(normalizedCode.slice(i, i + 4))\n }\n return groups.join('-')\n}\n\n// Internals ────────────────────────────────────────────────────────────\n\nfunction generateRawCode(): string {\n const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES))\n const body = base32Encode(entropy)\n const checksum = computeChecksum(body)\n return body + checksum\n}\n\nfunction formatCodeForDisplay(rawNormalized: string): string {\n return formatRecoveryCode(rawNormalized)\n}\n\n/**\n * Deterministic 4-character checksum over Base32 body. Catches\n * transcription errors (single-char swaps, shifted digits) with very\n * high probability.\n *\n * Uses a simple polynomial hash reduced modulo the Base32 alphabet\n * size (32). Four output chars = 20 bits ≈ 1-in-1M false positive.\n */\nfunction computeChecksum(body: string): string {\n let h = 0\n for (let i = 0; i < body.length; i++) {\n const v = BASE32_ALPHABET.indexOf(body[i]!)\n h = (h * 33 + v) >>> 0\n }\n const chars: string[] = []\n for (let i = 0; i < CHECKSUM_LEN; i++) {\n chars.push(BASE32_ALPHABET[(h >>> (i * 5)) & 0x1f]!)\n }\n return chars.join('')\n}\n\nfunction base32CharsForBytes(n: number): number {\n return Math.ceil((n * 8) / 5)\n}\n\nfunction base32Encode(bytes: Uint8Array): string {\n let bits = 0\n let value = 0\n let out = ''\n for (let i = 0; i < bytes.length; i++) {\n value = (value << 8) | bytes[i]!\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(value >>> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]\n }\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication is\n * unavailable. Codes are designed to be printed on paper and stored\n * in a safe — each code unlocks the vault exactly once and then is\n * burned by deleting its `_meta/recovery-paper` entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Format (Option A)\n *\n * This package is now a **thin code-generator + parser layer over\n * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly\n * three things:\n *\n * 1. Generate printable Base32 codes with checksums (the user-facing\n * string format).\n * 2. Parse user input back into a normalized code (whitespace /\n * hyphen / case insensitive).\n * 3. Delegate the wrapping crypto to the hub via\n * `mintPaperRecoveryEntry(deks, code, codeId)`.\n *\n * This delegation aligns recovery with the hub's wrap-DEKs primitive\n * (the same shape used by `@noy-db/on-pin` and now `@noy-db/on-password`\n * after the wrap-DEKs path change). It eliminates the format mismatch\n * that made the previous package version unusable with `db.enrollRecovery`.\n *\n * ## Usage\n *\n * ```ts\n * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'\n *\n * // ENROLL — after the user unlocks at tier 1, mint N codes\n * const keyring = await db.getKeyring('acme')\n * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })\n * showCodesToUser(codes)\n * await db.enrollRecovery('acme', { profile: 'paper', entries })\n *\n * // RECOVER — user types one back later (handled by db.recoverPassphrase)\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') return handleInvalid(parsed.status)\n * await db.recoverPassphrase('acme', {\n * newPassphrase,\n * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID, mintPaperRecoveryEntry, type PaperRecoveryEntry } from '@noy-db/hub'\n\n// Constants ────────────────────────────────────────────────────────────\n\n/** RFC 4648 Base32 alphabet — A-Z + 2-7, no ambiguous chars. */\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Characters to ignore on input (whitespace, hyphens, lowercase). */\nconst STRIPPABLE = /[\\s\\-_]/g\n\n/** How many random bytes of entropy per code. */\nconst CODE_ENTROPY_BYTES = 15 // 15 bytes = 120 bits = exactly 24 Base32 chars (clean groups of 4)\n\n/** Length of the checksum portion (Base32 chars). */\nconst CHECKSUM_LEN = 4 // 24 body + 4 checksum = 28 chars = 7 groups of 4\n\n// Types ────────────────────────────────────────────────────────────────\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /**\n * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).\n * Required — proves possession and is the input the hub's\n * `mintPaperRecoveryEntry` needs.\n */\n deks: Map<string, CryptoKey>\n}\n\n/** Result of `parseRecoveryCode()`. */\nexport type ParseResult =\n | { status: 'valid'; code: string } // Normalized, checksum-verified\n | { status: 'invalid-checksum' } // Format OK, checksum wrong\n | { status: 'invalid-format' } // Not a valid code shape\n\n// Code generation ──────────────────────────────────────────────────────\n\n/**\n * Generate a fresh recovery-code set. Returned `codes` must be shown\n * to the user exactly once (print/save); `entries` go into the vault\n * via `db.enrollRecovery({ profile: 'paper', entries })`.\n *\n * Internally calls the hub's `mintPaperRecoveryEntry` once per code.\n * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`\n * stores the entries verbatim and `db.recoverPassphrase` consumes them\n * via `unwrapDeksFromPaperEntry`.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: PaperRecoveryEntry[] }> {\n const count = opts.count ?? 10\n if (!Number.isInteger(count) || count < 1 || count > 100) {\n throw new Error(`on-recovery: count must be 1-100 (got ${count})`)\n }\n\n const codes: string[] = []\n const entries: PaperRecoveryEntry[] = []\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n // The hub stores entries keyed on the NORMALIZED code (raw, no\n // hyphens). Mint with the normalized form so `db.recoverPassphrase`'s\n // `normalizePaperCode(input)` matches at unlock time.\n const entry = await mintPaperRecoveryEntry(opts.deks, raw, generateULID())\n codes.push(formatted)\n entries.push(entry)\n }\n\n return { codes, entries }\n}\n\n// Code parsing + normalization ─────────────────────────────────────────\n\n/**\n * Parse user input into a normalized recovery code. Accepts whitespace,\n * hyphens, and lowercase — strips them all. Verifies the checksum.\n */\nexport function parseRecoveryCode(input: string): ParseResult {\n const normalized = input.toUpperCase().replace(STRIPPABLE, '')\n\n const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN\n if (normalized.length !== expectedLen) {\n return { status: 'invalid-format' }\n }\n for (const ch of normalized) {\n if (!BASE32_ALPHABET.includes(ch)) {\n return { status: 'invalid-format' }\n }\n }\n\n const bodyLen = normalized.length - CHECKSUM_LEN\n const body = normalized.slice(0, bodyLen)\n const checksum = normalized.slice(bodyLen)\n\n if (computeChecksum(body) !== checksum) {\n return { status: 'invalid-checksum' }\n }\n\n return { status: 'valid', code: normalized }\n}\n\n/**\n * Format a normalized recovery code for display (groups of 4, hyphenated).\n * Inverse of the strip-hyphens step in `parseRecoveryCode`.\n */\nexport function formatRecoveryCode(normalizedCode: string): string {\n const groups: string[] = []\n for (let i = 0; i < normalizedCode.length; i += 4) {\n groups.push(normalizedCode.slice(i, i + 4))\n }\n return groups.join('-')\n}\n\n// Internals ────────────────────────────────────────────────────────────\n\nfunction generateRawCode(): string {\n const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES))\n const body = base32Encode(entropy)\n const checksum = computeChecksum(body)\n return body + checksum\n}\n\nfunction formatCodeForDisplay(rawNormalized: string): string {\n return formatRecoveryCode(rawNormalized)\n}\n\n/**\n * Deterministic 4-character checksum over Base32 body. Catches\n * transcription errors (single-char swaps, shifted digits) with very\n * high probability.\n *\n * Uses a simple polynomial hash reduced modulo the Base32 alphabet\n * size (32). Four output chars = 20 bits ≈ 1-in-1M false positive.\n */\nfunction computeChecksum(body: string): string {\n let h = 0\n for (let i = 0; i < body.length; i++) {\n const v = BASE32_ALPHABET.indexOf(body[i]!)\n h = (h * 33 + v) >>> 0\n }\n const chars: string[] = []\n for (let i = 0; i < CHECKSUM_LEN; i++) {\n chars.push(BASE32_ALPHABET[(h >>> (i * 5)) & 0x1f]!)\n }\n return chars.join('')\n}\n\nfunction base32CharsForBytes(n: number): number {\n return Math.ceil((n * 8) / 5)\n}\n\nfunction base32Encode(bytes: Uint8Array): string {\n let bits = 0\n let value = 0\n let out = ''\n for (let i = 0; i < bytes.length; i++) {\n value = (value << 8) | bytes[i]!\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(value >>> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]\n }\n return out\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-recovery",
3
- "version": "0.2.0-pre.1",
3
+ "version": "0.2.0-pre.11",
4
4
  "description": "One-time printable recovery codes for noy-db — last-resort vault unlock when the passphrase, passkey, and OIDC provider are all unavailable. Base32 + checksum codes, PBKDF2-derived wrapping keys, burn-on-use. Part of the @noy-db/on-* authentication family.",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -39,10 +39,10 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@noy-db/hub": "0.2.0-pre.1"
42
+ "@noy-db/hub": "0.2.0-pre.11"
43
43
  },
44
44
  "devDependencies": {
45
- "@noy-db/hub": "0.2.0-pre.1"
45
+ "@noy-db/hub": "0.2.0-pre.11"
46
46
  },
47
47
  "keywords": [
48
48
  "noy-db",