@noy-db/on-recovery 0.1.0-pre.3
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 +21 -0
- package/README.md +199 -0
- package/dist/index.cjs +194 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +164 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
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,194 @@
|
|
|
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
|
+
deriveRecoveryWrappingKey: () => deriveRecoveryWrappingKey,
|
|
24
|
+
formatRecoveryCode: () => formatRecoveryCode,
|
|
25
|
+
generateRecoveryCodeSet: () => generateRecoveryCodeSet,
|
|
26
|
+
parseRecoveryCode: () => parseRecoveryCode,
|
|
27
|
+
unwrapKEKFromRecovery: () => unwrapKEKFromRecovery,
|
|
28
|
+
wrapKEKForRecovery: () => wrapKEKForRecovery
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
var import_hub = require("@noy-db/hub");
|
|
32
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
33
|
+
var STRIPPABLE = /[\s\-_]/g;
|
|
34
|
+
var CODE_ENTROPY_BYTES = 15;
|
|
35
|
+
var CHECKSUM_LEN = 4;
|
|
36
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
37
|
+
var PBKDF2_KEY_LENGTH = 32 * 8;
|
|
38
|
+
var SALT_BYTES = 16;
|
|
39
|
+
async function generateRecoveryCodeSet(opts) {
|
|
40
|
+
const count = opts.count ?? 10;
|
|
41
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
42
|
+
throw new Error(`on-recovery: count must be 1-100 (got ${count})`);
|
|
43
|
+
}
|
|
44
|
+
const codes = [];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
47
|
+
for (let i = 0; i < count; i++) {
|
|
48
|
+
const raw = generateRawCode();
|
|
49
|
+
const formatted = formatCodeForDisplay(raw);
|
|
50
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
51
|
+
const wrappingKey = await deriveRecoveryWrappingKey(raw, salt);
|
|
52
|
+
const wrappedKEK = await wrapKEK(opts.kek, wrappingKey);
|
|
53
|
+
codes.push(formatted);
|
|
54
|
+
entries.push({
|
|
55
|
+
codeId: (0, import_hub.generateULID)(),
|
|
56
|
+
salt: base64Encode(salt),
|
|
57
|
+
wrappedKEK: base64Encode(wrappedKEK),
|
|
58
|
+
enrolledAt: now
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { codes, entries };
|
|
62
|
+
}
|
|
63
|
+
function parseRecoveryCode(input) {
|
|
64
|
+
const normalized = input.toUpperCase().replace(STRIPPABLE, "");
|
|
65
|
+
const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN;
|
|
66
|
+
if (normalized.length !== expectedLen) {
|
|
67
|
+
return { status: "invalid-format" };
|
|
68
|
+
}
|
|
69
|
+
for (const ch of normalized) {
|
|
70
|
+
if (!BASE32_ALPHABET.includes(ch)) {
|
|
71
|
+
return { status: "invalid-format" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const bodyLen = normalized.length - CHECKSUM_LEN;
|
|
75
|
+
const body = normalized.slice(0, bodyLen);
|
|
76
|
+
const checksum = normalized.slice(bodyLen);
|
|
77
|
+
if (computeChecksum(body) !== checksum) {
|
|
78
|
+
return { status: "invalid-checksum" };
|
|
79
|
+
}
|
|
80
|
+
return { status: "valid", code: normalized };
|
|
81
|
+
}
|
|
82
|
+
function formatRecoveryCode(normalizedCode) {
|
|
83
|
+
const groups = [];
|
|
84
|
+
for (let i = 0; i < normalizedCode.length; i += 4) {
|
|
85
|
+
groups.push(normalizedCode.slice(i, i + 4));
|
|
86
|
+
}
|
|
87
|
+
return groups.join("-");
|
|
88
|
+
}
|
|
89
|
+
async function deriveRecoveryWrappingKey(code, salt) {
|
|
90
|
+
const password = new TextEncoder().encode(code);
|
|
91
|
+
const baseKey = await crypto.subtle.importKey(
|
|
92
|
+
"raw",
|
|
93
|
+
password,
|
|
94
|
+
"PBKDF2",
|
|
95
|
+
false,
|
|
96
|
+
["deriveKey"]
|
|
97
|
+
);
|
|
98
|
+
return crypto.subtle.deriveKey(
|
|
99
|
+
{
|
|
100
|
+
name: "PBKDF2",
|
|
101
|
+
hash: "SHA-256",
|
|
102
|
+
salt,
|
|
103
|
+
iterations: PBKDF2_ITERATIONS
|
|
104
|
+
},
|
|
105
|
+
baseKey,
|
|
106
|
+
{ name: "AES-KW", length: PBKDF2_KEY_LENGTH },
|
|
107
|
+
false,
|
|
108
|
+
["wrapKey", "unwrapKey"]
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
async function wrapKEKForRecovery(kek, code, salt) {
|
|
112
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, salt);
|
|
113
|
+
return wrapKEK(kek, wrappingKey);
|
|
114
|
+
}
|
|
115
|
+
async function unwrapKEKFromRecovery(code, entry) {
|
|
116
|
+
const salt = base64Decode(entry.salt);
|
|
117
|
+
const wrappedKEK = base64Decode(entry.wrappedKEK);
|
|
118
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, salt);
|
|
119
|
+
return crypto.subtle.unwrapKey(
|
|
120
|
+
"raw",
|
|
121
|
+
wrappedKEK,
|
|
122
|
+
wrappingKey,
|
|
123
|
+
"AES-KW",
|
|
124
|
+
{ name: "AES-GCM", length: 256 },
|
|
125
|
+
false,
|
|
126
|
+
["encrypt", "decrypt"]
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
function generateRawCode() {
|
|
130
|
+
const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES));
|
|
131
|
+
const body = base32Encode(entropy);
|
|
132
|
+
const checksum = computeChecksum(body);
|
|
133
|
+
return body + checksum;
|
|
134
|
+
}
|
|
135
|
+
function formatCodeForDisplay(rawNormalized) {
|
|
136
|
+
return formatRecoveryCode(rawNormalized);
|
|
137
|
+
}
|
|
138
|
+
function computeChecksum(body) {
|
|
139
|
+
let h = 0;
|
|
140
|
+
for (let i = 0; i < body.length; i++) {
|
|
141
|
+
const v = BASE32_ALPHABET.indexOf(body[i]);
|
|
142
|
+
h = h * 33 + v >>> 0;
|
|
143
|
+
}
|
|
144
|
+
const chars = [];
|
|
145
|
+
for (let i = 0; i < CHECKSUM_LEN; i++) {
|
|
146
|
+
chars.push(BASE32_ALPHABET[h >>> i * 5 & 31]);
|
|
147
|
+
}
|
|
148
|
+
return chars.join("");
|
|
149
|
+
}
|
|
150
|
+
function base32CharsForBytes(n) {
|
|
151
|
+
return Math.ceil(n * 8 / 5);
|
|
152
|
+
}
|
|
153
|
+
function base32Encode(bytes) {
|
|
154
|
+
let bits = 0;
|
|
155
|
+
let value = 0;
|
|
156
|
+
let out = "";
|
|
157
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
158
|
+
value = value << 8 | bytes[i];
|
|
159
|
+
bits += 8;
|
|
160
|
+
while (bits >= 5) {
|
|
161
|
+
bits -= 5;
|
|
162
|
+
out += BASE32_ALPHABET[value >>> bits & 31];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (bits > 0) {
|
|
166
|
+
out += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
async function wrapKEK(kek, wrappingKey) {
|
|
171
|
+
const wrapped = await crypto.subtle.wrapKey("raw", kek, wrappingKey, "AES-KW");
|
|
172
|
+
return new Uint8Array(wrapped);
|
|
173
|
+
}
|
|
174
|
+
function base64Encode(bytes) {
|
|
175
|
+
let s = "";
|
|
176
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
177
|
+
return btoa(s);
|
|
178
|
+
}
|
|
179
|
+
function base64Decode(str) {
|
|
180
|
+
const s = atob(str);
|
|
181
|
+
const out = new Uint8Array(s.length);
|
|
182
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
186
|
+
0 && (module.exports = {
|
|
187
|
+
deriveRecoveryWrappingKey,
|
|
188
|
+
formatRecoveryCode,
|
|
189
|
+
generateRecoveryCodeSet,
|
|
190
|
+
parseRecoveryCode,
|
|
191
|
+
unwrapKEKFromRecovery,
|
|
192
|
+
wrapKEKForRecovery
|
|
193
|
+
});
|
|
194
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — one-time printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication\n * (passphrase, WebAuthn, OIDC) is unavailable. Codes are designed to\n * be printed on paper and stored in a safe — each code unlocks the\n * vault exactly once and then is burned by deleting its keyring entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Security model\n *\n * Each code is a random 100-bit value (20 Base32 characters) with a\n * 5-character checksum appended. The wrapping key is derived from\n * the code via:\n *\n * ```\n * PBKDF2-SHA256(\n * password = normalizeCode(code),\n * salt = perCodeRandomSalt,\n * iter = 600_000,\n * length = 32,\n * )\n * ```\n *\n * The wrapping key is then used with AES-KW (RFC 3394) to wrap the\n * vault's KEK. The wrapped KEK + salt + code-ID land in the keyring\n * under a `_recovery_<N>` entry, alongside other unlock mechanisms.\n *\n * This package provides the CRYPTO layer only. Storage, burn, audit,\n * and rate-limiting are the caller's responsibility — typically\n * coordinated with `@noy-db/hub`'s keyring + audit-ledger APIs.\n *\n * ## Usage\n *\n * ```ts\n * import {\n * generateRecoveryCodeSet,\n * parseRecoveryCode,\n * deriveRecoveryWrappingKey,\n * wrapKEKForRecovery,\n * unwrapKEKFromRecovery,\n * } from '@noy-db/on-recovery'\n *\n * // ENROLL — after user unlocks with passphrase, generate N codes\n * const { codes, entries } = await generateRecoveryCodeSet({ count: 10, kek })\n * // `codes` is what you show the user ONCE (to print/save).\n * // `entries` goes into the keyring file (persistent storage).\n *\n * // UNLOCK — user types in a code later\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') handleInvalidFormat(parsed.status)\n *\n * for (const entry of storedEntries) {\n * try {\n * const kek = await unwrapKEKFromRecovery(parsed.code, entry)\n * // Match! Burn this entry: delete from keyring.\n * await deleteKeyringEntry(entry.codeId)\n * return kek\n * } catch (e) {\n * // Wrong entry, try next\n * }\n * }\n * throw new Error('no matching recovery code')\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID } 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/** PBKDF2 iteration count — matches hub's passphrase-unlock derivation. */\nconst PBKDF2_ITERATIONS = 600_000\n\n/** PBKDF2 output length — 32 bytes = 256-bit wrapping key. */\nconst PBKDF2_KEY_LENGTH = 32 * 8\n\n/** Per-code salt length. */\nconst SALT_BYTES = 16\n\n// Types ────────────────────────────────────────────────────────────────\n\n/**\n * A single recovery-code enrollment entry. The caller stores this in\n * the vault's keyring alongside other unlock mechanisms.\n */\nexport interface RecoveryCodeEntry {\n /** Stable identifier for this code (ULID). Used by the caller to delete the entry on burn. */\n readonly codeId: string\n /** PBKDF2 salt for this code, base64-encoded. */\n readonly salt: string\n /** Wrapped KEK, base64-encoded. Unwrap with AES-KW using the code-derived key. */\n readonly wrappedKEK: string\n /** Timestamp the code was enrolled. */\n readonly enrolledAt: string\n}\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /** The vault's current KEK (unwrapped). Required — proves possession. */\n kek: 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. The returned `codes` must be shown\n * to the user exactly once (print/save); the `entries` go into the\n * keyring for persistent storage.\n *\n * The caller is responsible for:\n * 1. Displaying the plaintext codes to the user ONCE.\n * 2. Writing `entries` into the vault's keyring.\n * 3. Writing an audit-ledger entry recording the enrollment.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: RecoveryCodeEntry[] }> {\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: RecoveryCodeEntry[] = []\n const now = new Date().toISOString()\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const wrappingKey = await deriveRecoveryWrappingKey(raw, salt)\n const wrappedKEK = await wrapKEK(opts.kek, wrappingKey)\n\n codes.push(formatted)\n entries.push({\n codeId: generateULID(),\n salt: base64Encode(salt),\n wrappedKEK: base64Encode(wrappedKEK),\n enrolledAt: now,\n })\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// Key derivation ───────────────────────────────────────────────────────\n\n/**\n * Derive the AES-KW wrapping key from a recovery code + salt. Used for\n * both enrollment (wrap the KEK) and unlock (unwrap the KEK).\n *\n * @param code - A normalized recovery code (uppercase Base32, no\n * hyphens/whitespace, checksum included).\n * @param salt - Per-code random salt from the stored entry.\n */\nexport async function deriveRecoveryWrappingKey(\n code: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const password = new TextEncoder().encode(code)\n const baseKey = await crypto.subtle.importKey(\n 'raw',\n password as BufferSource,\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n hash: 'SHA-256',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n },\n baseKey,\n { name: 'AES-KW', length: PBKDF2_KEY_LENGTH },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// KEK wrap/unwrap ──────────────────────────────────────────────────────\n\n/**\n * Wrap a KEK with a recovery-code-derived wrapping key.\n * Returns raw bytes suitable for base64 encoding + storage.\n */\nexport async function wrapKEKForRecovery(\n kek: CryptoKey,\n code: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const wrappingKey = await deriveRecoveryWrappingKey(code, salt)\n return wrapKEK(kek, wrappingKey)\n}\n\n/**\n * Unwrap the KEK using a recovery code + a stored entry.\n *\n * Throws (AES-KW authentication failure) if the code doesn't match\n * this entry. The caller typically iterates all enrolled entries and\n * catches the failure until one succeeds.\n *\n * On success, the caller MUST burn the entry — deleting `entry.codeId`\n * from the keyring — so the code can never be replayed.\n */\nexport async function unwrapKEKFromRecovery(\n code: string,\n entry: RecoveryCodeEntry,\n): Promise<CryptoKey> {\n const salt = base64Decode(entry.salt)\n const wrappedKEK = base64Decode(entry.wrappedKEK)\n const wrappingKey = await deriveRecoveryWrappingKey(code, salt)\n\n return crypto.subtle.unwrapKey(\n 'raw',\n wrappedKEK as BufferSource,\n wrappingKey,\n 'AES-KW',\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\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 // Extract 4 Base32 chars from the 20 low bits\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 // Each 5 bytes → 8 Base32 chars. Partial-byte handling via ceil.\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\nasync function wrapKEK(kek: CryptoKey, wrappingKey: CryptoKey): Promise<Uint8Array> {\n const wrapped = await crypto.subtle.wrapKey('raw', kek, wrappingKey, 'AES-KW')\n return new Uint8Array(wrapped)\n}\n\nfunction base64Encode(bytes: Uint8Array): string {\n let s = ''\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)\n // `btoa` is globally available in browsers, Node 16+, Deno, Bun.\n return btoa(s)\n}\n\nfunction base64Decode(str: string): Uint8Array {\n const s = atob(str)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **@noy-db/on-recovery** — one-time printable recovery codes for noy-db.
|
|
3
|
+
*
|
|
4
|
+
* The last-resort unlock path when the primary authentication
|
|
5
|
+
* (passphrase, WebAuthn, OIDC) is unavailable. Codes are designed to
|
|
6
|
+
* be printed on paper and stored in a safe — each code unlocks the
|
|
7
|
+
* vault exactly once and then is burned by deleting its keyring entry.
|
|
8
|
+
*
|
|
9
|
+
* Part of the `@noy-db/on-*` authentication family.
|
|
10
|
+
*
|
|
11
|
+
* ## Security model
|
|
12
|
+
*
|
|
13
|
+
* Each code is a random 100-bit value (20 Base32 characters) with a
|
|
14
|
+
* 5-character checksum appended. The wrapping key is derived from
|
|
15
|
+
* the code via:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* PBKDF2-SHA256(
|
|
19
|
+
* password = normalizeCode(code),
|
|
20
|
+
* salt = perCodeRandomSalt,
|
|
21
|
+
* iter = 600_000,
|
|
22
|
+
* length = 32,
|
|
23
|
+
* )
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The wrapping key is then used with AES-KW (RFC 3394) to wrap the
|
|
27
|
+
* vault's KEK. The wrapped KEK + salt + code-ID land in the keyring
|
|
28
|
+
* under a `_recovery_<N>` entry, alongside other unlock mechanisms.
|
|
29
|
+
*
|
|
30
|
+
* This package provides the CRYPTO layer only. Storage, burn, audit,
|
|
31
|
+
* and rate-limiting are the caller's responsibility — typically
|
|
32
|
+
* coordinated with `@noy-db/hub`'s keyring + audit-ledger APIs.
|
|
33
|
+
*
|
|
34
|
+
* ## Usage
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* import {
|
|
38
|
+
* generateRecoveryCodeSet,
|
|
39
|
+
* parseRecoveryCode,
|
|
40
|
+
* deriveRecoveryWrappingKey,
|
|
41
|
+
* wrapKEKForRecovery,
|
|
42
|
+
* unwrapKEKFromRecovery,
|
|
43
|
+
* } from '@noy-db/on-recovery'
|
|
44
|
+
*
|
|
45
|
+
* // ENROLL — after user unlocks with passphrase, generate N codes
|
|
46
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ count: 10, kek })
|
|
47
|
+
* // `codes` is what you show the user ONCE (to print/save).
|
|
48
|
+
* // `entries` goes into the keyring file (persistent storage).
|
|
49
|
+
*
|
|
50
|
+
* // UNLOCK — user types in a code later
|
|
51
|
+
* const parsed = parseRecoveryCode(userInput)
|
|
52
|
+
* if (parsed.status !== 'valid') handleInvalidFormat(parsed.status)
|
|
53
|
+
*
|
|
54
|
+
* for (const entry of storedEntries) {
|
|
55
|
+
* try {
|
|
56
|
+
* const kek = await unwrapKEKFromRecovery(parsed.code, entry)
|
|
57
|
+
* // Match! Burn this entry: delete from keyring.
|
|
58
|
+
* await deleteKeyringEntry(entry.codeId)
|
|
59
|
+
* return kek
|
|
60
|
+
* } catch (e) {
|
|
61
|
+
* // Wrong entry, try next
|
|
62
|
+
* }
|
|
63
|
+
* }
|
|
64
|
+
* throw new Error('no matching recovery code')
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @packageDocumentation
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* A single recovery-code enrollment entry. The caller stores this in
|
|
71
|
+
* the vault's keyring alongside other unlock mechanisms.
|
|
72
|
+
*/
|
|
73
|
+
interface RecoveryCodeEntry {
|
|
74
|
+
/** Stable identifier for this code (ULID). Used by the caller to delete the entry on burn. */
|
|
75
|
+
readonly codeId: string;
|
|
76
|
+
/** PBKDF2 salt for this code, base64-encoded. */
|
|
77
|
+
readonly salt: string;
|
|
78
|
+
/** Wrapped KEK, base64-encoded. Unwrap with AES-KW using the code-derived key. */
|
|
79
|
+
readonly wrappedKEK: string;
|
|
80
|
+
/** Timestamp the code was enrolled. */
|
|
81
|
+
readonly enrolledAt: string;
|
|
82
|
+
}
|
|
83
|
+
/** Options for `generateRecoveryCodeSet()`. */
|
|
84
|
+
interface GenerateRecoveryCodeSetOptions {
|
|
85
|
+
/** Number of codes to generate. Default 10. Reasonable: 8-20. */
|
|
86
|
+
count?: number;
|
|
87
|
+
/** The vault's current KEK (unwrapped). Required — proves possession. */
|
|
88
|
+
kek: CryptoKey;
|
|
89
|
+
}
|
|
90
|
+
/** Result of `parseRecoveryCode()`. */
|
|
91
|
+
type ParseResult = {
|
|
92
|
+
status: 'valid';
|
|
93
|
+
code: string;
|
|
94
|
+
} | {
|
|
95
|
+
status: 'invalid-checksum';
|
|
96
|
+
} | {
|
|
97
|
+
status: 'invalid-format';
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Generate a fresh recovery-code set. The returned `codes` must be shown
|
|
101
|
+
* to the user exactly once (print/save); the `entries` go into the
|
|
102
|
+
* keyring for persistent storage.
|
|
103
|
+
*
|
|
104
|
+
* The caller is responsible for:
|
|
105
|
+
* 1. Displaying the plaintext codes to the user ONCE.
|
|
106
|
+
* 2. Writing `entries` into the vault's keyring.
|
|
107
|
+
* 3. Writing an audit-ledger entry recording the enrollment.
|
|
108
|
+
*/
|
|
109
|
+
declare function generateRecoveryCodeSet(opts: GenerateRecoveryCodeSetOptions): Promise<{
|
|
110
|
+
codes: string[];
|
|
111
|
+
entries: RecoveryCodeEntry[];
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Parse user input into a normalized recovery code. Accepts whitespace,
|
|
115
|
+
* hyphens, and lowercase — strips them all. Verifies the checksum.
|
|
116
|
+
*/
|
|
117
|
+
declare function parseRecoveryCode(input: string): ParseResult;
|
|
118
|
+
/**
|
|
119
|
+
* Format a normalized recovery code for display (groups of 4, hyphenated).
|
|
120
|
+
* Inverse of the strip-hyphens step in `parseRecoveryCode`.
|
|
121
|
+
*/
|
|
122
|
+
declare function formatRecoveryCode(normalizedCode: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* Derive the AES-KW wrapping key from a recovery code + salt. Used for
|
|
125
|
+
* both enrollment (wrap the KEK) and unlock (unwrap the KEK).
|
|
126
|
+
*
|
|
127
|
+
* @param code - A normalized recovery code (uppercase Base32, no
|
|
128
|
+
* hyphens/whitespace, checksum included).
|
|
129
|
+
* @param salt - Per-code random salt from the stored entry.
|
|
130
|
+
*/
|
|
131
|
+
declare function deriveRecoveryWrappingKey(code: string, salt: Uint8Array): Promise<CryptoKey>;
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a KEK with a recovery-code-derived wrapping key.
|
|
134
|
+
* Returns raw bytes suitable for base64 encoding + storage.
|
|
135
|
+
*/
|
|
136
|
+
declare function wrapKEKForRecovery(kek: CryptoKey, code: string, salt: Uint8Array): Promise<Uint8Array>;
|
|
137
|
+
/**
|
|
138
|
+
* Unwrap the KEK using a recovery code + a stored entry.
|
|
139
|
+
*
|
|
140
|
+
* Throws (AES-KW authentication failure) if the code doesn't match
|
|
141
|
+
* this entry. The caller typically iterates all enrolled entries and
|
|
142
|
+
* catches the failure until one succeeds.
|
|
143
|
+
*
|
|
144
|
+
* On success, the caller MUST burn the entry — deleting `entry.codeId`
|
|
145
|
+
* from the keyring — so the code can never be replayed.
|
|
146
|
+
*/
|
|
147
|
+
declare function unwrapKEKFromRecovery(code: string, entry: RecoveryCodeEntry): Promise<CryptoKey>;
|
|
148
|
+
|
|
149
|
+
export { type GenerateRecoveryCodeSetOptions, type ParseResult, type RecoveryCodeEntry, deriveRecoveryWrappingKey, formatRecoveryCode, generateRecoveryCodeSet, parseRecoveryCode, unwrapKEKFromRecovery, wrapKEKForRecovery };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **@noy-db/on-recovery** — one-time printable recovery codes for noy-db.
|
|
3
|
+
*
|
|
4
|
+
* The last-resort unlock path when the primary authentication
|
|
5
|
+
* (passphrase, WebAuthn, OIDC) is unavailable. Codes are designed to
|
|
6
|
+
* be printed on paper and stored in a safe — each code unlocks the
|
|
7
|
+
* vault exactly once and then is burned by deleting its keyring entry.
|
|
8
|
+
*
|
|
9
|
+
* Part of the `@noy-db/on-*` authentication family.
|
|
10
|
+
*
|
|
11
|
+
* ## Security model
|
|
12
|
+
*
|
|
13
|
+
* Each code is a random 100-bit value (20 Base32 characters) with a
|
|
14
|
+
* 5-character checksum appended. The wrapping key is derived from
|
|
15
|
+
* the code via:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* PBKDF2-SHA256(
|
|
19
|
+
* password = normalizeCode(code),
|
|
20
|
+
* salt = perCodeRandomSalt,
|
|
21
|
+
* iter = 600_000,
|
|
22
|
+
* length = 32,
|
|
23
|
+
* )
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The wrapping key is then used with AES-KW (RFC 3394) to wrap the
|
|
27
|
+
* vault's KEK. The wrapped KEK + salt + code-ID land in the keyring
|
|
28
|
+
* under a `_recovery_<N>` entry, alongside other unlock mechanisms.
|
|
29
|
+
*
|
|
30
|
+
* This package provides the CRYPTO layer only. Storage, burn, audit,
|
|
31
|
+
* and rate-limiting are the caller's responsibility — typically
|
|
32
|
+
* coordinated with `@noy-db/hub`'s keyring + audit-ledger APIs.
|
|
33
|
+
*
|
|
34
|
+
* ## Usage
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* import {
|
|
38
|
+
* generateRecoveryCodeSet,
|
|
39
|
+
* parseRecoveryCode,
|
|
40
|
+
* deriveRecoveryWrappingKey,
|
|
41
|
+
* wrapKEKForRecovery,
|
|
42
|
+
* unwrapKEKFromRecovery,
|
|
43
|
+
* } from '@noy-db/on-recovery'
|
|
44
|
+
*
|
|
45
|
+
* // ENROLL — after user unlocks with passphrase, generate N codes
|
|
46
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ count: 10, kek })
|
|
47
|
+
* // `codes` is what you show the user ONCE (to print/save).
|
|
48
|
+
* // `entries` goes into the keyring file (persistent storage).
|
|
49
|
+
*
|
|
50
|
+
* // UNLOCK — user types in a code later
|
|
51
|
+
* const parsed = parseRecoveryCode(userInput)
|
|
52
|
+
* if (parsed.status !== 'valid') handleInvalidFormat(parsed.status)
|
|
53
|
+
*
|
|
54
|
+
* for (const entry of storedEntries) {
|
|
55
|
+
* try {
|
|
56
|
+
* const kek = await unwrapKEKFromRecovery(parsed.code, entry)
|
|
57
|
+
* // Match! Burn this entry: delete from keyring.
|
|
58
|
+
* await deleteKeyringEntry(entry.codeId)
|
|
59
|
+
* return kek
|
|
60
|
+
* } catch (e) {
|
|
61
|
+
* // Wrong entry, try next
|
|
62
|
+
* }
|
|
63
|
+
* }
|
|
64
|
+
* throw new Error('no matching recovery code')
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @packageDocumentation
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* A single recovery-code enrollment entry. The caller stores this in
|
|
71
|
+
* the vault's keyring alongside other unlock mechanisms.
|
|
72
|
+
*/
|
|
73
|
+
interface RecoveryCodeEntry {
|
|
74
|
+
/** Stable identifier for this code (ULID). Used by the caller to delete the entry on burn. */
|
|
75
|
+
readonly codeId: string;
|
|
76
|
+
/** PBKDF2 salt for this code, base64-encoded. */
|
|
77
|
+
readonly salt: string;
|
|
78
|
+
/** Wrapped KEK, base64-encoded. Unwrap with AES-KW using the code-derived key. */
|
|
79
|
+
readonly wrappedKEK: string;
|
|
80
|
+
/** Timestamp the code was enrolled. */
|
|
81
|
+
readonly enrolledAt: string;
|
|
82
|
+
}
|
|
83
|
+
/** Options for `generateRecoveryCodeSet()`. */
|
|
84
|
+
interface GenerateRecoveryCodeSetOptions {
|
|
85
|
+
/** Number of codes to generate. Default 10. Reasonable: 8-20. */
|
|
86
|
+
count?: number;
|
|
87
|
+
/** The vault's current KEK (unwrapped). Required — proves possession. */
|
|
88
|
+
kek: CryptoKey;
|
|
89
|
+
}
|
|
90
|
+
/** Result of `parseRecoveryCode()`. */
|
|
91
|
+
type ParseResult = {
|
|
92
|
+
status: 'valid';
|
|
93
|
+
code: string;
|
|
94
|
+
} | {
|
|
95
|
+
status: 'invalid-checksum';
|
|
96
|
+
} | {
|
|
97
|
+
status: 'invalid-format';
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Generate a fresh recovery-code set. The returned `codes` must be shown
|
|
101
|
+
* to the user exactly once (print/save); the `entries` go into the
|
|
102
|
+
* keyring for persistent storage.
|
|
103
|
+
*
|
|
104
|
+
* The caller is responsible for:
|
|
105
|
+
* 1. Displaying the plaintext codes to the user ONCE.
|
|
106
|
+
* 2. Writing `entries` into the vault's keyring.
|
|
107
|
+
* 3. Writing an audit-ledger entry recording the enrollment.
|
|
108
|
+
*/
|
|
109
|
+
declare function generateRecoveryCodeSet(opts: GenerateRecoveryCodeSetOptions): Promise<{
|
|
110
|
+
codes: string[];
|
|
111
|
+
entries: RecoveryCodeEntry[];
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Parse user input into a normalized recovery code. Accepts whitespace,
|
|
115
|
+
* hyphens, and lowercase — strips them all. Verifies the checksum.
|
|
116
|
+
*/
|
|
117
|
+
declare function parseRecoveryCode(input: string): ParseResult;
|
|
118
|
+
/**
|
|
119
|
+
* Format a normalized recovery code for display (groups of 4, hyphenated).
|
|
120
|
+
* Inverse of the strip-hyphens step in `parseRecoveryCode`.
|
|
121
|
+
*/
|
|
122
|
+
declare function formatRecoveryCode(normalizedCode: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* Derive the AES-KW wrapping key from a recovery code + salt. Used for
|
|
125
|
+
* both enrollment (wrap the KEK) and unlock (unwrap the KEK).
|
|
126
|
+
*
|
|
127
|
+
* @param code - A normalized recovery code (uppercase Base32, no
|
|
128
|
+
* hyphens/whitespace, checksum included).
|
|
129
|
+
* @param salt - Per-code random salt from the stored entry.
|
|
130
|
+
*/
|
|
131
|
+
declare function deriveRecoveryWrappingKey(code: string, salt: Uint8Array): Promise<CryptoKey>;
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a KEK with a recovery-code-derived wrapping key.
|
|
134
|
+
* Returns raw bytes suitable for base64 encoding + storage.
|
|
135
|
+
*/
|
|
136
|
+
declare function wrapKEKForRecovery(kek: CryptoKey, code: string, salt: Uint8Array): Promise<Uint8Array>;
|
|
137
|
+
/**
|
|
138
|
+
* Unwrap the KEK using a recovery code + a stored entry.
|
|
139
|
+
*
|
|
140
|
+
* Throws (AES-KW authentication failure) if the code doesn't match
|
|
141
|
+
* this entry. The caller typically iterates all enrolled entries and
|
|
142
|
+
* catches the failure until one succeeds.
|
|
143
|
+
*
|
|
144
|
+
* On success, the caller MUST burn the entry — deleting `entry.codeId`
|
|
145
|
+
* from the keyring — so the code can never be replayed.
|
|
146
|
+
*/
|
|
147
|
+
declare function unwrapKEKFromRecovery(code: string, entry: RecoveryCodeEntry): Promise<CryptoKey>;
|
|
148
|
+
|
|
149
|
+
export { type GenerateRecoveryCodeSetOptions, type ParseResult, type RecoveryCodeEntry, deriveRecoveryWrappingKey, formatRecoveryCode, generateRecoveryCodeSet, parseRecoveryCode, unwrapKEKFromRecovery, wrapKEKForRecovery };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { generateULID } 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
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
8
|
+
var PBKDF2_KEY_LENGTH = 32 * 8;
|
|
9
|
+
var SALT_BYTES = 16;
|
|
10
|
+
async function generateRecoveryCodeSet(opts) {
|
|
11
|
+
const count = opts.count ?? 10;
|
|
12
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
13
|
+
throw new Error(`on-recovery: count must be 1-100 (got ${count})`);
|
|
14
|
+
}
|
|
15
|
+
const codes = [];
|
|
16
|
+
const entries = [];
|
|
17
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
18
|
+
for (let i = 0; i < count; i++) {
|
|
19
|
+
const raw = generateRawCode();
|
|
20
|
+
const formatted = formatCodeForDisplay(raw);
|
|
21
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
22
|
+
const wrappingKey = await deriveRecoveryWrappingKey(raw, salt);
|
|
23
|
+
const wrappedKEK = await wrapKEK(opts.kek, wrappingKey);
|
|
24
|
+
codes.push(formatted);
|
|
25
|
+
entries.push({
|
|
26
|
+
codeId: generateULID(),
|
|
27
|
+
salt: base64Encode(salt),
|
|
28
|
+
wrappedKEK: base64Encode(wrappedKEK),
|
|
29
|
+
enrolledAt: now
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return { codes, entries };
|
|
33
|
+
}
|
|
34
|
+
function parseRecoveryCode(input) {
|
|
35
|
+
const normalized = input.toUpperCase().replace(STRIPPABLE, "");
|
|
36
|
+
const expectedLen = base32CharsForBytes(CODE_ENTROPY_BYTES) + CHECKSUM_LEN;
|
|
37
|
+
if (normalized.length !== expectedLen) {
|
|
38
|
+
return { status: "invalid-format" };
|
|
39
|
+
}
|
|
40
|
+
for (const ch of normalized) {
|
|
41
|
+
if (!BASE32_ALPHABET.includes(ch)) {
|
|
42
|
+
return { status: "invalid-format" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const bodyLen = normalized.length - CHECKSUM_LEN;
|
|
46
|
+
const body = normalized.slice(0, bodyLen);
|
|
47
|
+
const checksum = normalized.slice(bodyLen);
|
|
48
|
+
if (computeChecksum(body) !== checksum) {
|
|
49
|
+
return { status: "invalid-checksum" };
|
|
50
|
+
}
|
|
51
|
+
return { status: "valid", code: normalized };
|
|
52
|
+
}
|
|
53
|
+
function formatRecoveryCode(normalizedCode) {
|
|
54
|
+
const groups = [];
|
|
55
|
+
for (let i = 0; i < normalizedCode.length; i += 4) {
|
|
56
|
+
groups.push(normalizedCode.slice(i, i + 4));
|
|
57
|
+
}
|
|
58
|
+
return groups.join("-");
|
|
59
|
+
}
|
|
60
|
+
async function deriveRecoveryWrappingKey(code, salt) {
|
|
61
|
+
const password = new TextEncoder().encode(code);
|
|
62
|
+
const baseKey = await crypto.subtle.importKey(
|
|
63
|
+
"raw",
|
|
64
|
+
password,
|
|
65
|
+
"PBKDF2",
|
|
66
|
+
false,
|
|
67
|
+
["deriveKey"]
|
|
68
|
+
);
|
|
69
|
+
return crypto.subtle.deriveKey(
|
|
70
|
+
{
|
|
71
|
+
name: "PBKDF2",
|
|
72
|
+
hash: "SHA-256",
|
|
73
|
+
salt,
|
|
74
|
+
iterations: PBKDF2_ITERATIONS
|
|
75
|
+
},
|
|
76
|
+
baseKey,
|
|
77
|
+
{ name: "AES-KW", length: PBKDF2_KEY_LENGTH },
|
|
78
|
+
false,
|
|
79
|
+
["wrapKey", "unwrapKey"]
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
async function wrapKEKForRecovery(kek, code, salt) {
|
|
83
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, salt);
|
|
84
|
+
return wrapKEK(kek, wrappingKey);
|
|
85
|
+
}
|
|
86
|
+
async function unwrapKEKFromRecovery(code, entry) {
|
|
87
|
+
const salt = base64Decode(entry.salt);
|
|
88
|
+
const wrappedKEK = base64Decode(entry.wrappedKEK);
|
|
89
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, salt);
|
|
90
|
+
return crypto.subtle.unwrapKey(
|
|
91
|
+
"raw",
|
|
92
|
+
wrappedKEK,
|
|
93
|
+
wrappingKey,
|
|
94
|
+
"AES-KW",
|
|
95
|
+
{ name: "AES-GCM", length: 256 },
|
|
96
|
+
false,
|
|
97
|
+
["encrypt", "decrypt"]
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function generateRawCode() {
|
|
101
|
+
const entropy = crypto.getRandomValues(new Uint8Array(CODE_ENTROPY_BYTES));
|
|
102
|
+
const body = base32Encode(entropy);
|
|
103
|
+
const checksum = computeChecksum(body);
|
|
104
|
+
return body + checksum;
|
|
105
|
+
}
|
|
106
|
+
function formatCodeForDisplay(rawNormalized) {
|
|
107
|
+
return formatRecoveryCode(rawNormalized);
|
|
108
|
+
}
|
|
109
|
+
function computeChecksum(body) {
|
|
110
|
+
let h = 0;
|
|
111
|
+
for (let i = 0; i < body.length; i++) {
|
|
112
|
+
const v = BASE32_ALPHABET.indexOf(body[i]);
|
|
113
|
+
h = h * 33 + v >>> 0;
|
|
114
|
+
}
|
|
115
|
+
const chars = [];
|
|
116
|
+
for (let i = 0; i < CHECKSUM_LEN; i++) {
|
|
117
|
+
chars.push(BASE32_ALPHABET[h >>> i * 5 & 31]);
|
|
118
|
+
}
|
|
119
|
+
return chars.join("");
|
|
120
|
+
}
|
|
121
|
+
function base32CharsForBytes(n) {
|
|
122
|
+
return Math.ceil(n * 8 / 5);
|
|
123
|
+
}
|
|
124
|
+
function base32Encode(bytes) {
|
|
125
|
+
let bits = 0;
|
|
126
|
+
let value = 0;
|
|
127
|
+
let out = "";
|
|
128
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
129
|
+
value = value << 8 | bytes[i];
|
|
130
|
+
bits += 8;
|
|
131
|
+
while (bits >= 5) {
|
|
132
|
+
bits -= 5;
|
|
133
|
+
out += BASE32_ALPHABET[value >>> bits & 31];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (bits > 0) {
|
|
137
|
+
out += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
async function wrapKEK(kek, wrappingKey) {
|
|
142
|
+
const wrapped = await crypto.subtle.wrapKey("raw", kek, wrappingKey, "AES-KW");
|
|
143
|
+
return new Uint8Array(wrapped);
|
|
144
|
+
}
|
|
145
|
+
function base64Encode(bytes) {
|
|
146
|
+
let s = "";
|
|
147
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
148
|
+
return btoa(s);
|
|
149
|
+
}
|
|
150
|
+
function base64Decode(str) {
|
|
151
|
+
const s = atob(str);
|
|
152
|
+
const out = new Uint8Array(s.length);
|
|
153
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
deriveRecoveryWrappingKey,
|
|
158
|
+
formatRecoveryCode,
|
|
159
|
+
generateRecoveryCodeSet,
|
|
160
|
+
parseRecoveryCode,
|
|
161
|
+
unwrapKEKFromRecovery,
|
|
162
|
+
wrapKEKForRecovery
|
|
163
|
+
};
|
|
164
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-recovery** — one-time printable recovery codes for noy-db.\n *\n * The last-resort unlock path when the primary authentication\n * (passphrase, WebAuthn, OIDC) is unavailable. Codes are designed to\n * be printed on paper and stored in a safe — each code unlocks the\n * vault exactly once and then is burned by deleting its keyring entry.\n *\n * Part of the `@noy-db/on-*` authentication family.\n *\n * ## Security model\n *\n * Each code is a random 100-bit value (20 Base32 characters) with a\n * 5-character checksum appended. The wrapping key is derived from\n * the code via:\n *\n * ```\n * PBKDF2-SHA256(\n * password = normalizeCode(code),\n * salt = perCodeRandomSalt,\n * iter = 600_000,\n * length = 32,\n * )\n * ```\n *\n * The wrapping key is then used with AES-KW (RFC 3394) to wrap the\n * vault's KEK. The wrapped KEK + salt + code-ID land in the keyring\n * under a `_recovery_<N>` entry, alongside other unlock mechanisms.\n *\n * This package provides the CRYPTO layer only. Storage, burn, audit,\n * and rate-limiting are the caller's responsibility — typically\n * coordinated with `@noy-db/hub`'s keyring + audit-ledger APIs.\n *\n * ## Usage\n *\n * ```ts\n * import {\n * generateRecoveryCodeSet,\n * parseRecoveryCode,\n * deriveRecoveryWrappingKey,\n * wrapKEKForRecovery,\n * unwrapKEKFromRecovery,\n * } from '@noy-db/on-recovery'\n *\n * // ENROLL — after user unlocks with passphrase, generate N codes\n * const { codes, entries } = await generateRecoveryCodeSet({ count: 10, kek })\n * // `codes` is what you show the user ONCE (to print/save).\n * // `entries` goes into the keyring file (persistent storage).\n *\n * // UNLOCK — user types in a code later\n * const parsed = parseRecoveryCode(userInput)\n * if (parsed.status !== 'valid') handleInvalidFormat(parsed.status)\n *\n * for (const entry of storedEntries) {\n * try {\n * const kek = await unwrapKEKFromRecovery(parsed.code, entry)\n * // Match! Burn this entry: delete from keyring.\n * await deleteKeyringEntry(entry.codeId)\n * return kek\n * } catch (e) {\n * // Wrong entry, try next\n * }\n * }\n * throw new Error('no matching recovery code')\n * ```\n *\n * @packageDocumentation\n */\n\nimport { generateULID } 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/** PBKDF2 iteration count — matches hub's passphrase-unlock derivation. */\nconst PBKDF2_ITERATIONS = 600_000\n\n/** PBKDF2 output length — 32 bytes = 256-bit wrapping key. */\nconst PBKDF2_KEY_LENGTH = 32 * 8\n\n/** Per-code salt length. */\nconst SALT_BYTES = 16\n\n// Types ────────────────────────────────────────────────────────────────\n\n/**\n * A single recovery-code enrollment entry. The caller stores this in\n * the vault's keyring alongside other unlock mechanisms.\n */\nexport interface RecoveryCodeEntry {\n /** Stable identifier for this code (ULID). Used by the caller to delete the entry on burn. */\n readonly codeId: string\n /** PBKDF2 salt for this code, base64-encoded. */\n readonly salt: string\n /** Wrapped KEK, base64-encoded. Unwrap with AES-KW using the code-derived key. */\n readonly wrappedKEK: string\n /** Timestamp the code was enrolled. */\n readonly enrolledAt: string\n}\n\n/** Options for `generateRecoveryCodeSet()`. */\nexport interface GenerateRecoveryCodeSetOptions {\n /** Number of codes to generate. Default 10. Reasonable: 8-20. */\n count?: number\n /** The vault's current KEK (unwrapped). Required — proves possession. */\n kek: 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. The returned `codes` must be shown\n * to the user exactly once (print/save); the `entries` go into the\n * keyring for persistent storage.\n *\n * The caller is responsible for:\n * 1. Displaying the plaintext codes to the user ONCE.\n * 2. Writing `entries` into the vault's keyring.\n * 3. Writing an audit-ledger entry recording the enrollment.\n */\nexport async function generateRecoveryCodeSet(\n opts: GenerateRecoveryCodeSetOptions,\n): Promise<{ codes: string[]; entries: RecoveryCodeEntry[] }> {\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: RecoveryCodeEntry[] = []\n const now = new Date().toISOString()\n\n for (let i = 0; i < count; i++) {\n const raw = generateRawCode()\n const formatted = formatCodeForDisplay(raw)\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const wrappingKey = await deriveRecoveryWrappingKey(raw, salt)\n const wrappedKEK = await wrapKEK(opts.kek, wrappingKey)\n\n codes.push(formatted)\n entries.push({\n codeId: generateULID(),\n salt: base64Encode(salt),\n wrappedKEK: base64Encode(wrappedKEK),\n enrolledAt: now,\n })\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// Key derivation ───────────────────────────────────────────────────────\n\n/**\n * Derive the AES-KW wrapping key from a recovery code + salt. Used for\n * both enrollment (wrap the KEK) and unlock (unwrap the KEK).\n *\n * @param code - A normalized recovery code (uppercase Base32, no\n * hyphens/whitespace, checksum included).\n * @param salt - Per-code random salt from the stored entry.\n */\nexport async function deriveRecoveryWrappingKey(\n code: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const password = new TextEncoder().encode(code)\n const baseKey = await crypto.subtle.importKey(\n 'raw',\n password as BufferSource,\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n hash: 'SHA-256',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n },\n baseKey,\n { name: 'AES-KW', length: PBKDF2_KEY_LENGTH },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// KEK wrap/unwrap ──────────────────────────────────────────────────────\n\n/**\n * Wrap a KEK with a recovery-code-derived wrapping key.\n * Returns raw bytes suitable for base64 encoding + storage.\n */\nexport async function wrapKEKForRecovery(\n kek: CryptoKey,\n code: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const wrappingKey = await deriveRecoveryWrappingKey(code, salt)\n return wrapKEK(kek, wrappingKey)\n}\n\n/**\n * Unwrap the KEK using a recovery code + a stored entry.\n *\n * Throws (AES-KW authentication failure) if the code doesn't match\n * this entry. The caller typically iterates all enrolled entries and\n * catches the failure until one succeeds.\n *\n * On success, the caller MUST burn the entry — deleting `entry.codeId`\n * from the keyring — so the code can never be replayed.\n */\nexport async function unwrapKEKFromRecovery(\n code: string,\n entry: RecoveryCodeEntry,\n): Promise<CryptoKey> {\n const salt = base64Decode(entry.salt)\n const wrappedKEK = base64Decode(entry.wrappedKEK)\n const wrappingKey = await deriveRecoveryWrappingKey(code, salt)\n\n return crypto.subtle.unwrapKey(\n 'raw',\n wrappedKEK as BufferSource,\n wrappingKey,\n 'AES-KW',\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\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 // Extract 4 Base32 chars from the 20 low bits\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 // Each 5 bytes → 8 Base32 chars. Partial-byte handling via ceil.\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\nasync function wrapKEK(kek: CryptoKey, wrappingKey: CryptoKey): Promise<Uint8Array> {\n const wrapped = await crypto.subtle.wrapKey('raw', kek, wrappingKey, 'AES-KW')\n return new Uint8Array(wrapped)\n}\n\nfunction base64Encode(bytes: Uint8Array): string {\n let s = ''\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)\n // `btoa` is globally available in browsers, Node 16+, Deno, Bun.\n return btoa(s)\n}\n\nfunction base64Decode(str: string): Uint8Array {\n const s = atob(str)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\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.3",
|
|
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.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
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
|
+
}
|