@noy-db/on-recovery 0.1.0-pre.10

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vLannaAi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # @noy-db/on-recovery
2
+
3
+ One-time printable recovery codes for noy-db. The **last-resort unlock path** when the primary authentication (passphrase, WebAuthn, OIDC) is unavailable. Codes are generated once, shown to the user once, printed on paper, stored in a safe. Each code unlocks the vault exactly **one time** and then burns itself.
4
+
5
+ Part of the `@noy-db/on-*` authentication family. Sibling packages: `on-webauthn`, `on-oidc`, `on-magic-link`, `on-pin`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @noy-db/on-recovery
11
+ ```
12
+
13
+ ## Threat model
14
+
15
+ **Protects against:**
16
+ - Primary authentication becoming unavailable (forgotten passphrase, lost passkey device, OIDC provider down)
17
+ - Code replay — each code burns on successful unlock by deleting its keyring entry
18
+
19
+ **Does NOT protect against:**
20
+ - Physical theft of printed codes — assume paper compromise → user calls `revokeAllRecoveryCodes` + re-enrolls
21
+ - User enrolling without actually printing — the calling application must enforce this UX
22
+
23
+ Recovery codes should NEVER be the only unlock method on a vault. Enroll passphrase / WebAuthn / OIDC first, then recovery codes as a fallback.
24
+
25
+ ## Code format
26
+
27
+ ```
28
+ XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
29
+ ```
30
+
31
+ - **28 characters** total (24 Base32 body + 4 Base32 checksum)
32
+ - **120 bits of entropy per code** — infeasible to brute-force
33
+ - **RFC 4648 Base32 alphabet** (`A-Z2-7`) — no confusing `0/O`, `1/I/L`, `8/B` pairs
34
+ - **4-character checksum** catches single-character transcription errors (≥99.9999% of them)
35
+ - **Groups of 4 with hyphens** for eye-tracking when writing down
36
+
37
+ Input is lenient: whitespace, hyphens, lowercase are all stripped before validation.
38
+
39
+ ## Security model
40
+
41
+ Each code is processed through:
42
+
43
+ ```
44
+ wrappingKey = PBKDF2-SHA256(
45
+ password = normalizeCode(code),
46
+ salt = perCodeRandomSalt, // Stored alongside wrapped KEK
47
+ iterations = 600_000, // Matches hub's passphrase derivation
48
+ length = 256 // bits
49
+ )
50
+
51
+ wrappedKEK = AES-KW(kek, wrappingKey)
52
+ ```
53
+
54
+ The `wrappedKEK + salt + codeId` goes into the keyring under a `_recovery_<N>` entry. On unlock, PBKDF2 re-derives the wrapping key from the user-typed code + salt, and AES-KW unwraps the KEK.
55
+
56
+ ## Usage
57
+
58
+ This package provides the **crypto layer only**. Storage, audit, rate-limiting, and burn-on-use are application-layer concerns handled by hub's keyring + audit-ledger APIs.
59
+
60
+ ### Enrollment (after primary unlock)
61
+
62
+ ```ts
63
+ import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
64
+
65
+ // After the user unlocks with passphrase, offer recovery-code enrollment
66
+ const { codes, entries } = await generateRecoveryCodeSet({
67
+ count: 10, // 8-20 is reasonable; default 10
68
+ kek: currentKEK, // The vault's currently-unwrapped KEK
69
+ })
70
+
71
+ // Show `codes` to the user ONCE — print, download, copy. Do NOT store them.
72
+ displayRecoveryCodes(codes)
73
+ downloadRecoveryCodes(codes)
74
+
75
+ // Persist `entries` to the vault's keyring. Each entry is safe to
76
+ // store on disk — it holds only the salt + wrapped KEK + codeId.
77
+ for (const entry of entries) {
78
+ await vault.keyring.put(`_recovery_${entry.codeId}`, entry)
79
+ }
80
+
81
+ // Write an audit-ledger entry
82
+ await vault.ledger.append({
83
+ type: 'on-recovery:enroll',
84
+ actor: currentUserId,
85
+ codeCount: entries.length,
86
+ timestamp: new Date().toISOString(),
87
+ })
88
+ ```
89
+
90
+ ### Unlock (when primary auth is unavailable)
91
+
92
+ ```ts
93
+ import { parseRecoveryCode, unwrapKEKFromRecovery } from '@noy-db/on-recovery'
94
+
95
+ const parsed = parseRecoveryCode(userInput)
96
+
97
+ if (parsed.status === 'invalid-format') {
98
+ // User typed junk — show "not a valid recovery code" without counting against rate limit
99
+ return showError('format')
100
+ }
101
+ if (parsed.status === 'invalid-checksum') {
102
+ // Well-formed but checksum wrong — transcription error, not a guess
103
+ return showError('checksum')
104
+ }
105
+
106
+ // Find which enrolled entry this code matches
107
+ const allEntries = await vault.keyring.list({ prefix: '_recovery_' })
108
+
109
+ for (const entry of allEntries) {
110
+ try {
111
+ const kek = await unwrapKEKFromRecovery(parsed.code, entry)
112
+
113
+ // Match! Burn this entry — delete the keyring record so the code
114
+ // can never be replayed.
115
+ await vault.keyring.delete(`_recovery_${entry.codeId}`)
116
+
117
+ // Write an audit-ledger entry
118
+ await vault.ledger.append({
119
+ type: 'on-recovery:unlock',
120
+ actor: currentUserId,
121
+ codesRemaining: allEntries.length - 1,
122
+ timestamp: new Date().toISOString(),
123
+ })
124
+
125
+ return kek
126
+ } catch {
127
+ // Wrong entry, try next
128
+ }
129
+ }
130
+
131
+ // No matching entry — counts against the host app's rate limit
132
+ await vault.ledger.append({
133
+ type: 'on-recovery:unlock-failed',
134
+ actor: currentUserId,
135
+ reason: 'not-found',
136
+ timestamp: new Date().toISOString(),
137
+ })
138
+ throw new Error('no matching recovery code')
139
+ ```
140
+
141
+ ### Revocation (after a suspected paper leak)
142
+
143
+ ```ts
144
+ // Scan all recovery entries, delete each.
145
+ const allEntries = await vault.keyring.list({ prefix: '_recovery_' })
146
+ for (const entry of allEntries) {
147
+ await vault.keyring.delete(`_recovery_${entry.codeId}`)
148
+ }
149
+ // Optionally re-enroll a fresh set.
150
+ ```
151
+
152
+ ## API
153
+
154
+ ```ts
155
+ // Generate a full enrollment
156
+ async function generateRecoveryCodeSet(options: {
157
+ count?: number // Default 10, clamped to 1..100
158
+ kek: CryptoKey // Currently-unwrapped KEK
159
+ }): Promise<{
160
+ codes: string[] // Show to user once, then forget
161
+ entries: RecoveryCodeEntry[] // Persist to keyring
162
+ }>
163
+
164
+ // Parse + normalize user input
165
+ function parseRecoveryCode(input: string): ParseResult
166
+
167
+ type ParseResult =
168
+ | { status: 'valid'; code: string } // Normalized, checksum verified
169
+ | { status: 'invalid-checksum' } // Format OK, checksum wrong
170
+ | { status: 'invalid-format' } // Not a valid code shape
171
+
172
+ // Attempt to unwrap the KEK with a code + an entry; throws on mismatch
173
+ async function unwrapKEKFromRecovery(
174
+ code: string, // The normalized code from parseRecoveryCode
175
+ entry: RecoveryCodeEntry, // One of the enrolled entries
176
+ ): Promise<CryptoKey>
177
+
178
+ // Lower-level helpers (for advanced use cases)
179
+ function formatRecoveryCode(normalized: string): string
180
+ async function deriveRecoveryWrappingKey(code: string, salt: Uint8Array): Promise<CryptoKey>
181
+ async function wrapKEKForRecovery(kek: CryptoKey, code: string, salt: Uint8Array): Promise<Uint8Array>
182
+
183
+ interface RecoveryCodeEntry {
184
+ codeId: string // ULID — caller uses this to delete the entry on burn
185
+ salt: string // Base64
186
+ wrappedKEK: string // Base64
187
+ enrolledAt: string // ISO timestamp
188
+ }
189
+ ```
190
+
191
+ ## Performance
192
+
193
+ PBKDF2 with 600K iterations takes ~500ms per derive on modern hardware. Generating 10 codes enrolls in ~5 seconds (serial) — acceptable for a one-time enrollment flow; show a loading indicator. Unlock is a single derive per attempt (~500ms).
194
+
195
+ If you need faster enrollment (e.g., a CLI test), you can parallelize via `Promise.all`.
196
+
197
+ ## License
198
+
199
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ formatRecoveryCode: () => formatRecoveryCode,
24
+ generateRecoveryCodeSet: () => generateRecoveryCodeSet,
25
+ parseRecoveryCode: () => parseRecoveryCode
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+ var import_hub = require("@noy-db/hub");
29
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
30
+ var STRIPPABLE = /[\s\-_]/g;
31
+ var CODE_ENTROPY_BYTES = 15;
32
+ var CHECKSUM_LEN = 4;
33
+ async function generateRecoveryCodeSet(opts) {
34
+ const count = opts.count ?? 10;
35
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
36
+ throw new Error(`on-recovery: count must be 1-100 (got ${count})`);
37
+ }
38
+ const codes = [];
39
+ const entries = [];
40
+ for (let i = 0; i < count; i++) {
41
+ const raw = generateRawCode();
42
+ const formatted = formatCodeForDisplay(raw);
43
+ const entry = await (0, import_hub.mintPaperRecoveryEntry)(opts.deks, raw, (0, import_hub.generateULID)());
44
+ codes.push(formatted);
45
+ entries.push(entry);
46
+ }
47
+ return { codes, entries };
48
+ }
49
+ function parseRecoveryCode(input) {
50
+ const normalized = input.toUpperCase().replace(STRIPPABLE, "");
51
+ const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN;
52
+ if (normalized.length !== expectedLen) {
53
+ return { status: "invalid-format" };
54
+ }
55
+ for (const ch of normalized) {
56
+ if (!BASE32_ALPHABET.includes(ch)) {
57
+ return { status: "invalid-format" };
58
+ }
59
+ }
60
+ const bodyLen = normalized.length - CHECKSUM_LEN;
61
+ const body = normalized.slice(0, bodyLen);
62
+ const checksum = normalized.slice(bodyLen);
63
+ if (computeChecksum(body) !== checksum) {
64
+ return { status: "invalid-checksum" };
65
+ }
66
+ return { status: "valid", code: normalized };
67
+ }
68
+ function formatRecoveryCode(normalizedCode) {
69
+ const groups = [];
70
+ for (let i = 0; i < normalizedCode.length; i += 4) {
71
+ groups.push(normalizedCode.slice(i, i + 4));
72
+ }
73
+ return groups.join("-");
74
+ }
75
+ function generateRawCode() {
76
+ const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES));
77
+ const body = base32Encode(entropy);
78
+ const checksum = computeChecksum(body);
79
+ return body + checksum;
80
+ }
81
+ function formatCodeForDisplay(rawNormalized) {
82
+ return formatRecoveryCode(rawNormalized);
83
+ }
84
+ function computeChecksum(body) {
85
+ let h = 0;
86
+ for (let i = 0; i < body.length; i++) {
87
+ const v = BASE32_ALPHABET.indexOf(body[i]);
88
+ h = h * 33 + v >>> 0;
89
+ }
90
+ const chars = [];
91
+ for (let i = 0; i < CHECKSUM_LEN; i++) {
92
+ chars.push(BASE32_ALPHABET[h >>> i * 5 & 31]);
93
+ }
94
+ return chars.join("");
95
+ }
96
+ function base32CharsForBytes(n) {
97
+ return Math.ceil(n * 8 / 5);
98
+ }
99
+ function base32Encode(bytes) {
100
+ let bits = 0;
101
+ let value = 0;
102
+ let out = "";
103
+ for (let i = 0; i < bytes.length; i++) {
104
+ value = value << 8 | bytes[i];
105
+ bits += 8;
106
+ while (bits >= 5) {
107
+ bits -= 5;
108
+ out += BASE32_ALPHABET[value >>> bits & 31];
109
+ }
110
+ }
111
+ if (bits > 0) {
112
+ out += BASE32_ALPHABET[value << 5 - bits & 31];
113
+ }
114
+ return out;
115
+ }
116
+ // Annotate the CommonJS export names for ESM import in node:
117
+ 0 && (module.exports = {
118
+ formatRecoveryCode,
119
+ generateRecoveryCodeSet,
120
+ parseRecoveryCode
121
+ });
122
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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":[]}
@@ -0,0 +1,99 @@
1
+ import { PaperRecoveryEntry } from '@noy-db/hub';
2
+
3
+ /**
4
+ * **@noy-db/on-recovery** — printable recovery codes for noy-db.
5
+ *
6
+ * The last-resort unlock path when the primary authentication is
7
+ * unavailable. Codes are designed to be printed on paper and stored
8
+ * in a safe — each code unlocks the vault exactly once and then is
9
+ * burned by deleting its `_meta/recovery-paper` entry.
10
+ *
11
+ * Part of the `@noy-db/on-*` authentication family.
12
+ *
13
+ * ## Format (post pre.8, #38 Option A)
14
+ *
15
+ * This package is now a **thin code-generator + parser layer over
16
+ * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly
17
+ * three things:
18
+ *
19
+ * 1. Generate printable Base32 codes with checksums (the user-facing
20
+ * string format).
21
+ * 2. Parse user input back into a normalized code (whitespace /
22
+ * hyphen / case insensitive).
23
+ * 3. Delegate the wrapping crypto to the hub via
24
+ * `mintPaperRecoveryEntry(deks, code, codeId)`.
25
+ *
26
+ * This delegation aligns recovery with the hub's wrap-DEKs primitive
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).
30
+ *
31
+ * ## Usage
32
+ *
33
+ * ```ts
34
+ * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'
35
+ *
36
+ * // ENROLL — after the user unlocks at tier 1, mint N codes
37
+ * const keyring = await db.getKeyring('acme')
38
+ * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })
39
+ * showCodesToUser(codes)
40
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
41
+ *
42
+ * // RECOVER — user types one back later (handled by db.recoverPassphrase)
43
+ * const parsed = parseRecoveryCode(userInput)
44
+ * if (parsed.status !== 'valid') return handleInvalid(parsed.status)
45
+ * await db.recoverPassphrase('acme', {
46
+ * newPassphrase,
47
+ * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },
48
+ * })
49
+ * ```
50
+ *
51
+ * @packageDocumentation
52
+ */
53
+
54
+ /** Options for `generateRecoveryCodeSet()`. */
55
+ interface GenerateRecoveryCodeSetOptions {
56
+ /** Number of codes to generate. Default 10. Reasonable: 8-20. */
57
+ count?: number;
58
+ /**
59
+ * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).
60
+ * Required — proves possession and is the input the hub's
61
+ * `mintPaperRecoveryEntry` needs.
62
+ */
63
+ deks: Map<string, CryptoKey>;
64
+ }
65
+ /** Result of `parseRecoveryCode()`. */
66
+ type ParseResult = {
67
+ status: 'valid';
68
+ code: string;
69
+ } | {
70
+ status: 'invalid-checksum';
71
+ } | {
72
+ status: 'invalid-format';
73
+ };
74
+ /**
75
+ * Generate a fresh recovery-code set. Returned `codes` must be shown
76
+ * to the user exactly once (print/save); `entries` go into the vault
77
+ * via `db.enrollRecovery({ profile: 'paper', entries })`.
78
+ *
79
+ * Internally calls the hub's `mintPaperRecoveryEntry` once per code.
80
+ * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`
81
+ * stores the entries verbatim and `db.recoverPassphrase` consumes them
82
+ * via `unwrapDeksFromPaperEntry`.
83
+ */
84
+ declare function generateRecoveryCodeSet(opts: GenerateRecoveryCodeSetOptions): Promise<{
85
+ codes: string[];
86
+ entries: PaperRecoveryEntry[];
87
+ }>;
88
+ /**
89
+ * Parse user input into a normalized recovery code. Accepts whitespace,
90
+ * hyphens, and lowercase — strips them all. Verifies the checksum.
91
+ */
92
+ declare function parseRecoveryCode(input: string): ParseResult;
93
+ /**
94
+ * Format a normalized recovery code for display (groups of 4, hyphenated).
95
+ * Inverse of the strip-hyphens step in `parseRecoveryCode`.
96
+ */
97
+ declare function formatRecoveryCode(normalizedCode: string): string;
98
+
99
+ export { type GenerateRecoveryCodeSetOptions, type ParseResult, formatRecoveryCode, generateRecoveryCodeSet, parseRecoveryCode };
@@ -0,0 +1,99 @@
1
+ import { PaperRecoveryEntry } from '@noy-db/hub';
2
+
3
+ /**
4
+ * **@noy-db/on-recovery** — printable recovery codes for noy-db.
5
+ *
6
+ * The last-resort unlock path when the primary authentication is
7
+ * unavailable. Codes are designed to be printed on paper and stored
8
+ * in a safe — each code unlocks the vault exactly once and then is
9
+ * burned by deleting its `_meta/recovery-paper` entry.
10
+ *
11
+ * Part of the `@noy-db/on-*` authentication family.
12
+ *
13
+ * ## Format (post pre.8, #38 Option A)
14
+ *
15
+ * This package is now a **thin code-generator + parser layer over
16
+ * the hub's `mintPaperRecoveryEntry` primitive**. Its job is exactly
17
+ * three things:
18
+ *
19
+ * 1. Generate printable Base32 codes with checksums (the user-facing
20
+ * string format).
21
+ * 2. Parse user input back into a normalized code (whitespace /
22
+ * hyphen / case insensitive).
23
+ * 3. Delegate the wrapping crypto to the hub via
24
+ * `mintPaperRecoveryEntry(deks, code, codeId)`.
25
+ *
26
+ * This delegation aligns recovery with the hub's wrap-DEKs primitive
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).
30
+ *
31
+ * ## Usage
32
+ *
33
+ * ```ts
34
+ * import { generateRecoveryCodeSet, parseRecoveryCode } from '@noy-db/on-recovery'
35
+ *
36
+ * // ENROLL — after the user unlocks at tier 1, mint N codes
37
+ * const keyring = await db.getKeyring('acme')
38
+ * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 10 })
39
+ * showCodesToUser(codes)
40
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
41
+ *
42
+ * // RECOVER — user types one back later (handled by db.recoverPassphrase)
43
+ * const parsed = parseRecoveryCode(userInput)
44
+ * if (parsed.status !== 'valid') return handleInvalid(parsed.status)
45
+ * await db.recoverPassphrase('acme', {
46
+ * newPassphrase,
47
+ * recoveryProof: { profile: 'paper', payload: { code: parsed.code } },
48
+ * })
49
+ * ```
50
+ *
51
+ * @packageDocumentation
52
+ */
53
+
54
+ /** Options for `generateRecoveryCodeSet()`. */
55
+ interface GenerateRecoveryCodeSetOptions {
56
+ /** Number of codes to generate. Default 10. Reasonable: 8-20. */
57
+ count?: number;
58
+ /**
59
+ * The vault's current DEK set (typically `(await db.getKeyring(vault)).deks`).
60
+ * Required — proves possession and is the input the hub's
61
+ * `mintPaperRecoveryEntry` needs.
62
+ */
63
+ deks: Map<string, CryptoKey>;
64
+ }
65
+ /** Result of `parseRecoveryCode()`. */
66
+ type ParseResult = {
67
+ status: 'valid';
68
+ code: string;
69
+ } | {
70
+ status: 'invalid-checksum';
71
+ } | {
72
+ status: 'invalid-format';
73
+ };
74
+ /**
75
+ * Generate a fresh recovery-code set. Returned `codes` must be shown
76
+ * to the user exactly once (print/save); `entries` go into the vault
77
+ * via `db.enrollRecovery({ profile: 'paper', entries })`.
78
+ *
79
+ * Internally calls the hub's `mintPaperRecoveryEntry` once per code.
80
+ * The hub's wrap-DEKs format is preserved end-to-end — `db.enrollRecovery`
81
+ * stores the entries verbatim and `db.recoverPassphrase` consumes them
82
+ * via `unwrapDeksFromPaperEntry`.
83
+ */
84
+ declare function generateRecoveryCodeSet(opts: GenerateRecoveryCodeSetOptions): Promise<{
85
+ codes: string[];
86
+ entries: PaperRecoveryEntry[];
87
+ }>;
88
+ /**
89
+ * Parse user input into a normalized recovery code. Accepts whitespace,
90
+ * hyphens, and lowercase — strips them all. Verifies the checksum.
91
+ */
92
+ declare function parseRecoveryCode(input: string): ParseResult;
93
+ /**
94
+ * Format a normalized recovery code for display (groups of 4, hyphenated).
95
+ * Inverse of the strip-hyphens step in `parseRecoveryCode`.
96
+ */
97
+ declare function formatRecoveryCode(normalizedCode: string): string;
98
+
99
+ export { type GenerateRecoveryCodeSetOptions, type ParseResult, formatRecoveryCode, generateRecoveryCodeSet, parseRecoveryCode };
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ // src/index.ts
2
+ import { generateULID, mintPaperRecoveryEntry } from "@noy-db/hub";
3
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4
+ var STRIPPABLE = /[\s\-_]/g;
5
+ var CODE_ENTROPY_BYTES = 15;
6
+ var CHECKSUM_LEN = 4;
7
+ async function generateRecoveryCodeSet(opts) {
8
+ const count = opts.count ?? 10;
9
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
10
+ throw new Error(`on-recovery: count must be 1-100 (got ${count})`);
11
+ }
12
+ const codes = [];
13
+ const entries = [];
14
+ for (let i = 0; i < count; i++) {
15
+ const raw = generateRawCode();
16
+ const formatted = formatCodeForDisplay(raw);
17
+ const entry = await mintPaperRecoveryEntry(opts.deks, raw, generateULID());
18
+ codes.push(formatted);
19
+ entries.push(entry);
20
+ }
21
+ return { codes, entries };
22
+ }
23
+ function parseRecoveryCode(input) {
24
+ const normalized = input.toUpperCase().replace(STRIPPABLE, "");
25
+ const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN;
26
+ if (normalized.length !== expectedLen) {
27
+ return { status: "invalid-format" };
28
+ }
29
+ for (const ch of normalized) {
30
+ if (!BASE32_ALPHABET.includes(ch)) {
31
+ return { status: "invalid-format" };
32
+ }
33
+ }
34
+ const bodyLen = normalized.length - CHECKSUM_LEN;
35
+ const body = normalized.slice(0, bodyLen);
36
+ const checksum = normalized.slice(bodyLen);
37
+ if (computeChecksum(body) !== checksum) {
38
+ return { status: "invalid-checksum" };
39
+ }
40
+ return { status: "valid", code: normalized };
41
+ }
42
+ function formatRecoveryCode(normalizedCode) {
43
+ const groups = [];
44
+ for (let i = 0; i < normalizedCode.length; i += 4) {
45
+ groups.push(normalizedCode.slice(i, i + 4));
46
+ }
47
+ return groups.join("-");
48
+ }
49
+ function generateRawCode() {
50
+ const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES));
51
+ const body = base32Encode(entropy);
52
+ const checksum = computeChecksum(body);
53
+ return body + checksum;
54
+ }
55
+ function formatCodeForDisplay(rawNormalized) {
56
+ return formatRecoveryCode(rawNormalized);
57
+ }
58
+ function computeChecksum(body) {
59
+ let h = 0;
60
+ for (let i = 0; i < body.length; i++) {
61
+ const v = BASE32_ALPHABET.indexOf(body[i]);
62
+ h = h * 33 + v >>> 0;
63
+ }
64
+ const chars = [];
65
+ for (let i = 0; i < CHECKSUM_LEN; i++) {
66
+ chars.push(BASE32_ALPHABET[h >>> i * 5 & 31]);
67
+ }
68
+ return chars.join("");
69
+ }
70
+ function base32CharsForBytes(n) {
71
+ return Math.ceil(n * 8 / 5);
72
+ }
73
+ function base32Encode(bytes) {
74
+ let bits = 0;
75
+ let value = 0;
76
+ let out = "";
77
+ for (let i = 0; i < bytes.length; i++) {
78
+ value = value << 8 | bytes[i];
79
+ bits += 8;
80
+ while (bits >= 5) {
81
+ bits -= 5;
82
+ out += BASE32_ALPHABET[value >>> bits & 31];
83
+ }
84
+ }
85
+ if (bits > 0) {
86
+ out += BASE32_ALPHABET[value << 5 - bits & 31];
87
+ }
88
+ return out;
89
+ }
90
+ export {
91
+ formatRecoveryCode,
92
+ generateRecoveryCodeSet,
93
+ parseRecoveryCode
94
+ };
95
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@noy-db/on-recovery",
3
+ "version": "0.1.0-pre.10",
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
+ "license": "MIT",
6
+ "author": "vLannaAi <vicio@lanna.ai>",
7
+ "homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/on-recovery#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vLannaAi/noy-db.git",
11
+ "directory": "packages/on-recovery"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/vLannaAi/noy-db/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "import": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "require": {
25
+ "types": "./dist/index.d.cts",
26
+ "default": "./dist/index.cjs"
27
+ }
28
+ }
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@noy-db/hub": "0.1.0-pre.10"
43
+ },
44
+ "devDependencies": {
45
+ "@noy-db/hub": "0.1.0-pre.10"
46
+ },
47
+ "keywords": [
48
+ "noy-db",
49
+ "auth",
50
+ "on-recovery",
51
+ "recovery-codes",
52
+ "one-time-codes",
53
+ "paper-backup",
54
+ "base32",
55
+ "pbkdf2",
56
+ "zero-knowledge"
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "tag": "latest"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "test": "vitest run",
65
+ "lint": "eslint src/",
66
+ "typecheck": "tsc --noEmit"
67
+ }
68
+ }