@skrr-ai/auth-core 0.1.2

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.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,62 @@
1
+ /** Currently the only PKCE challenge method we support. See file header. */
2
+ export type PkceChallengeMethod = 'S256';
3
+ /**
4
+ * Generate a cryptographically random `code_verifier`. Returns a 43-char
5
+ * base64url string from 32 bytes of CSPRNG output — the RFC 7636 lower
6
+ * bound and the recommended size for new implementations (256 bits of
7
+ * entropy).
8
+ *
9
+ * The verifier MUST be kept in CLI process memory only — never
10
+ * persisted, never logged, never sent over the wire until the
11
+ * token-exchange step. Treat it the same as a private key.
12
+ */
13
+ export declare function generateVerifier(): string;
14
+ /**
15
+ * Compute the `code_challenge` for a given verifier using S256 (the only
16
+ * supported method). Returns `base64url(SHA-256(ASCII(verifier)))`, no
17
+ * padding.
18
+ *
19
+ * Throws RangeError on a verifier that violates RFC 7636 §4.1 (length
20
+ * or charset). Throws TypeError on non-string input. Throws Error on an
21
+ * unsupported method. The throw policy is deliberate: misuse here is
22
+ * always a programmer bug, not a runtime condition the caller should
23
+ * recover from. Server-side verifier validation in `verifyChallenge()`
24
+ * has the opposite policy (return false, no oracle).
25
+ */
26
+ export declare function challenge(verifier: string, method?: PkceChallengeMethod): string;
27
+ /**
28
+ * Generate a CSRF-style `state` parameter for the auth flow. 32 bytes of
29
+ * CSPRNG output, base64url-encoded (43 chars). Used to bind the server's
30
+ * redirect back to the originating CLI process — a callback that doesn't
31
+ * echo this exact state is rejected by the local server in
32
+ * `loginLocalhost.ts`.
33
+ *
34
+ * Independent of PKCE: state defends against cross-site / replay attacks
35
+ * on the redirect path; PKCE defends against intercepted auth codes at
36
+ * the token-exchange step. Both are required.
37
+ */
38
+ export declare function generateState(): string;
39
+ /**
40
+ * Constant-time equality check for two PKCE-related strings (state,
41
+ * code_challenge, computed challenge). Returns false on length mismatch
42
+ * or non-string input — both signals are public (lengths are not
43
+ * secret), so the early return does not leak.
44
+ *
45
+ * Uses `crypto.timingSafeEqual` over UTF-8 bytes. Wrapped in try/catch
46
+ * because timingSafeEqual throws on mismatched buffer lengths even after
47
+ * our length pre-check (defensive — protects against a future caller
48
+ * passing pre-converted Buffers of different shapes).
49
+ */
50
+ export declare function safeEqual(a: string, b: string): boolean;
51
+ /**
52
+ * Server-side: verify a presented `code_verifier` against the stored
53
+ * `code_challenge` using the declared method. Used at
54
+ * `/api/auth/cli-token-exchange` to complete the PKCE proof.
55
+ *
56
+ * Returns false on ANY input failure (bad method, malformed verifier,
57
+ * mismatch). Never throws. The server treats a verifier mismatch and a
58
+ * malformed verifier identically — both surface as a 400 with no
59
+ * detail — so an attacker probing the endpoint cannot distinguish
60
+ * "well-formed-but-wrong" from "malformed" and use that as an oracle.
61
+ */
62
+ export declare function verifyChallenge(verifier: string, storedChallenge: string, method?: PkceChallengeMethod): boolean;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * pkce.ts — RFC 7636 Proof Key for Code Exchange primitives.
3
+ *
4
+ * Used by the localhost-callback login flow (Phase H.0,
5
+ * docs/daemon-auth-roadmap.md) to bind an authorization code to the
6
+ * originating CLI process. The CLI generates a random `code_verifier`,
7
+ * sends only its SHA-256 hash (`code_challenge`) to the server in the
8
+ * auth-init request, then proves possession of the verifier when
9
+ * redeeming the auth code at `/api/auth/cli-token-exchange`. An attacker
10
+ * who intercepts the redirect (malicious browser extension, local-host
11
+ * port race, screen-recorded URL bar) cannot redeem the code without
12
+ * the verifier — which never leaves the CLI process memory.
13
+ *
14
+ * No npm deps — `node:crypto` only. `auth-core` is destined for external
15
+ * publication (Phase P) and every transitive dep multiplies supply-chain
16
+ * surface. The PKCE primitives are ~50 lines of pure crypto and we
17
+ * control them end-to-end with full test coverage including the spec's
18
+ * known-answer vectors.
19
+ *
20
+ * Conformance:
21
+ * - RFC 7636 §4.1: code_verifier is 43–128 chars from the unreserved
22
+ * character set (RFC 3986 §2.3). We generate the recommended 43-char
23
+ * length from 32 bytes of CSPRNG output.
24
+ * - RFC 7636 §4.2: code_challenge for S256 is
25
+ * base64url(SHA-256(ASCII(verifier))), no padding.
26
+ * - RFC 7636 §4.6 verification: server recomputes the challenge from
27
+ * the verifier and compares constant-time.
28
+ * - RFC 7636 Appendix B known-answer vectors covered in pkce.test.ts.
29
+ *
30
+ * Method `'plain'` is intentionally NOT implemented. The spec allows it
31
+ * for clients that cannot SHA-256, but every viable PKCE client today
32
+ * can — supporting plain would be a foot-gun for future callers and
33
+ * complicates the server-side verifier (it would need a second branch).
34
+ * If a future caller genuinely needs plain, add it explicitly with a
35
+ * security-review gate.
36
+ */
37
+ import crypto from 'node:crypto';
38
+ /** RFC 7636 §4.1 lower bound for code_verifier length. */
39
+ const VERIFIER_MIN_LEN = 43;
40
+ /** RFC 7636 §4.1 upper bound for code_verifier length. */
41
+ const VERIFIER_MAX_LEN = 128;
42
+ /**
43
+ * Char class allowed by RFC 7636 §4.1 — the RFC 3986 unreserved set.
44
+ * Anchored regex: any character outside this set anywhere in the string
45
+ * fails. We validate on every challenge() call so a caller that hand-
46
+ * rolls a verifier (rather than using `generateVerifier()`) cannot
47
+ * silently feed garbage in.
48
+ */
49
+ const VERIFIER_CHARSET = /^[A-Za-z0-9\-._~]+$/;
50
+ /**
51
+ * Generate a cryptographically random `code_verifier`. Returns a 43-char
52
+ * base64url string from 32 bytes of CSPRNG output — the RFC 7636 lower
53
+ * bound and the recommended size for new implementations (256 bits of
54
+ * entropy).
55
+ *
56
+ * The verifier MUST be kept in CLI process memory only — never
57
+ * persisted, never logged, never sent over the wire until the
58
+ * token-exchange step. Treat it the same as a private key.
59
+ */
60
+ export function generateVerifier() {
61
+ // 32 random bytes → base64url (no padding) = 43 chars exactly.
62
+ return crypto.randomBytes(32).toString('base64url');
63
+ }
64
+ /**
65
+ * Compute the `code_challenge` for a given verifier using S256 (the only
66
+ * supported method). Returns `base64url(SHA-256(ASCII(verifier)))`, no
67
+ * padding.
68
+ *
69
+ * Throws RangeError on a verifier that violates RFC 7636 §4.1 (length
70
+ * or charset). Throws TypeError on non-string input. Throws Error on an
71
+ * unsupported method. The throw policy is deliberate: misuse here is
72
+ * always a programmer bug, not a runtime condition the caller should
73
+ * recover from. Server-side verifier validation in `verifyChallenge()`
74
+ * has the opposite policy (return false, no oracle).
75
+ */
76
+ export function challenge(verifier, method = 'S256') {
77
+ if (method !== 'S256') {
78
+ throw new Error(`Unsupported PKCE challenge method: ${String(method)} (only 'S256' is allowed)`);
79
+ }
80
+ if (typeof verifier !== 'string') {
81
+ throw new TypeError('verifier must be a string');
82
+ }
83
+ if (verifier.length < VERIFIER_MIN_LEN || verifier.length > VERIFIER_MAX_LEN) {
84
+ throw new RangeError(`verifier length ${verifier.length} outside RFC 7636 §4.1 bounds [${VERIFIER_MIN_LEN}, ${VERIFIER_MAX_LEN}]`);
85
+ }
86
+ if (!VERIFIER_CHARSET.test(verifier)) {
87
+ throw new RangeError('verifier contains characters outside RFC 7636 unreserved set ([A-Za-z0-9-._~])');
88
+ }
89
+ const hash = crypto.createHash('sha256').update(verifier, 'ascii').digest();
90
+ return hash.toString('base64url');
91
+ }
92
+ /**
93
+ * Generate a CSRF-style `state` parameter for the auth flow. 32 bytes of
94
+ * CSPRNG output, base64url-encoded (43 chars). Used to bind the server's
95
+ * redirect back to the originating CLI process — a callback that doesn't
96
+ * echo this exact state is rejected by the local server in
97
+ * `loginLocalhost.ts`.
98
+ *
99
+ * Independent of PKCE: state defends against cross-site / replay attacks
100
+ * on the redirect path; PKCE defends against intercepted auth codes at
101
+ * the token-exchange step. Both are required.
102
+ */
103
+ export function generateState() {
104
+ return crypto.randomBytes(32).toString('base64url');
105
+ }
106
+ /**
107
+ * Constant-time equality check for two PKCE-related strings (state,
108
+ * code_challenge, computed challenge). Returns false on length mismatch
109
+ * or non-string input — both signals are public (lengths are not
110
+ * secret), so the early return does not leak.
111
+ *
112
+ * Uses `crypto.timingSafeEqual` over UTF-8 bytes. Wrapped in try/catch
113
+ * because timingSafeEqual throws on mismatched buffer lengths even after
114
+ * our length pre-check (defensive — protects against a future caller
115
+ * passing pre-converted Buffers of different shapes).
116
+ */
117
+ export function safeEqual(a, b) {
118
+ if (typeof a !== 'string' || typeof b !== 'string')
119
+ return false;
120
+ if (a.length !== b.length)
121
+ return false;
122
+ try {
123
+ return crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Server-side: verify a presented `code_verifier` against the stored
131
+ * `code_challenge` using the declared method. Used at
132
+ * `/api/auth/cli-token-exchange` to complete the PKCE proof.
133
+ *
134
+ * Returns false on ANY input failure (bad method, malformed verifier,
135
+ * mismatch). Never throws. The server treats a verifier mismatch and a
136
+ * malformed verifier identically — both surface as a 400 with no
137
+ * detail — so an attacker probing the endpoint cannot distinguish
138
+ * "well-formed-but-wrong" from "malformed" and use that as an oracle.
139
+ */
140
+ export function verifyChallenge(verifier, storedChallenge, method = 'S256') {
141
+ try {
142
+ const computed = challenge(verifier, method);
143
+ return safeEqual(computed, storedChallenge);
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * recoveryCode.ts — 1Password-style printable account-recovery code format.
3
+ *
4
+ * Format: 11 groups × 5 base32 chars = 55 chars, encoding 32 bytes (256 bits).
5
+ * Example: W7K2J-X9P3M-R6Y4Q-T8N2A-C5VWB-D3FHK-M4LPZ-Q6RSX-Y2JGE-B7NHC-E9KWF
6
+ *
7
+ * Properties:
8
+ * (a) 11 groups × 5 chars = 55 chars total (encodes 32 bytes exactly,
9
+ * padded to 56 base32 chars → 55 significant chars + 1 trailing
10
+ * partial group from the 256-bit boundary math)
11
+ * (b) H15 — strict alphabet: no confusable-char substitution (0/1/8/9 are
12
+ * never in valid output; silent mapping shrinks key space and masks
13
+ * brute-force telemetry). Whitespace and dashes stripped; mixed-case
14
+ * tolerated. Any character outside A-Z2-7 after normalization is
15
+ * rejected with code INVALID_RECOVERY_CODE_ALPHABET.
16
+ * (c) No checksum — intentional, matches 1Password/Happy format;
17
+ * user discovers a typo immediately on failed redemption
18
+ *
19
+ * RFC 4648 base32 alphabet: A–Z, 2–7.
20
+ * No npm deps — Node.js built-ins only, same philosophy as pkce.ts.
21
+ */
22
+ /**
23
+ * Format 32 raw bytes as a printable recovery code.
24
+ *
25
+ * @param bytes — exactly 32 bytes of entropy
26
+ * @returns "AAAAA-BBBBB-CCCCC-...-KKKKK" (55 chars + 10 dashes = 65 chars)
27
+ * @throws if input is not 32 bytes
28
+ */
29
+ export declare function formatRecoveryCode(bytes: Buffer | Uint8Array): string;
30
+ /**
31
+ * Parse a formatted (or loosely-typed) recovery code back to its 32 raw bytes.
32
+ *
33
+ * Tolerant of:
34
+ * - whitespace (spaces, newlines, tabs)
35
+ * - dashes (group separators in the printed format)
36
+ * - lowercase
37
+ *
38
+ * H15 — strict alphabet: confusable-char substitution (0/1/8/9) is NOT
39
+ * applied. Any character outside A-Z2-7 after stripping whitespace/dashes
40
+ * and uppercasing throws with code INVALID_RECOVERY_CODE_ALPHABET.
41
+ *
42
+ * M3 — exact-length check: after normalization the cleaned string must be
43
+ * exactly TOTAL_CHARS (55) characters. Shorter inputs would produce fewer
44
+ * than 32 meaningful decoded bytes; longer inputs indicate a malformed or
45
+ * concatenated code. Both are rejected.
46
+ *
47
+ * @throws with a descriptive message on invalid characters or wrong length
48
+ */
49
+ export declare function parseRecoveryCode(formatted: string): Buffer;
50
+ /**
51
+ * Normalize any input form to the canonical 11-group display format.
52
+ * Applies the same confusable-char + whitespace tolerance as parseRecoveryCode.
53
+ *
54
+ * @throws the same errors as parseRecoveryCode on invalid input
55
+ */
56
+ export declare function normalizeRecoveryCode(input: string): string;
57
+ /**
58
+ * Non-throwing validity check.
59
+ * Returns true iff the input can be successfully parsed as a 32-byte recovery code.
60
+ */
61
+ export declare function isValidRecoveryCode(input: string): boolean;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * recoveryCode.ts — 1Password-style printable account-recovery code format.
3
+ *
4
+ * Format: 11 groups × 5 base32 chars = 55 chars, encoding 32 bytes (256 bits).
5
+ * Example: W7K2J-X9P3M-R6Y4Q-T8N2A-C5VWB-D3FHK-M4LPZ-Q6RSX-Y2JGE-B7NHC-E9KWF
6
+ *
7
+ * Properties:
8
+ * (a) 11 groups × 5 chars = 55 chars total (encodes 32 bytes exactly,
9
+ * padded to 56 base32 chars → 55 significant chars + 1 trailing
10
+ * partial group from the 256-bit boundary math)
11
+ * (b) H15 — strict alphabet: no confusable-char substitution (0/1/8/9 are
12
+ * never in valid output; silent mapping shrinks key space and masks
13
+ * brute-force telemetry). Whitespace and dashes stripped; mixed-case
14
+ * tolerated. Any character outside A-Z2-7 after normalization is
15
+ * rejected with code INVALID_RECOVERY_CODE_ALPHABET.
16
+ * (c) No checksum — intentional, matches 1Password/Happy format;
17
+ * user discovers a typo immediately on failed redemption
18
+ *
19
+ * RFC 4648 base32 alphabet: A–Z, 2–7.
20
+ * No npm deps — Node.js built-ins only, same philosophy as pkce.ts.
21
+ */
22
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
23
+ /** Number of base32 characters needed to encode 32 bytes.
24
+ * 256 bits / 5 bits-per-char = 51.2 → ceil = 52 significant chars.
25
+ * But we split into 11×5=55 groups, so the last group is padded.
26
+ * We always produce exactly 55 output chars (some trailing bits zeroed). */
27
+ const TOTAL_CHARS = 55; // 11 groups × 5 chars
28
+ const GROUP_COUNT = 11;
29
+ const GROUP_SIZE = 5;
30
+ const DECODED_BYTE_LENGTH = 32;
31
+ // Build a reverse-lookup table: char → 5-bit value (for fast decode).
32
+ const CHAR_TO_VALUE = new Uint8Array(128).fill(0xff);
33
+ for (let i = 0; i < BASE32_ALPHABET.length; i++) {
34
+ CHAR_TO_VALUE[BASE32_ALPHABET.charCodeAt(i)] = i;
35
+ }
36
+ /**
37
+ * Convert raw bytes to a base32 string (no padding chars, no dashes).
38
+ * Produces exactly TOTAL_CHARS characters for 32-byte input.
39
+ */
40
+ function bytesToBase32(bytes) {
41
+ const result = [];
42
+ let buffer = 0;
43
+ let bufferBits = 0;
44
+ for (const byte of bytes) {
45
+ buffer = (buffer << 8) | byte;
46
+ bufferBits += 8;
47
+ while (bufferBits >= 5) {
48
+ bufferBits -= 5;
49
+ result.push(BASE32_ALPHABET[(buffer >> bufferBits) & 0x1f]);
50
+ }
51
+ }
52
+ // Flush remaining bits — pad with zero bits on the right.
53
+ if (bufferBits > 0) {
54
+ result.push(BASE32_ALPHABET[(buffer << (5 - bufferBits)) & 0x1f]);
55
+ }
56
+ // Extend to TOTAL_CHARS by repeating the last char (deterministic, lossless
57
+ // since parseRecoveryCode only uses the first 32 decoded bytes).
58
+ while (result.length < TOTAL_CHARS) {
59
+ result.push(BASE32_ALPHABET[0]); // 'A' = zero padding
60
+ }
61
+ return result.slice(0, TOTAL_CHARS).join('');
62
+ }
63
+ /**
64
+ * Convert a base32 string (stripped of dashes/whitespace) back to bytes.
65
+ * Returns exactly as many bytes as the bits allow; callers validate length.
66
+ */
67
+ function base32ToBytes(chars) {
68
+ const bytes = [];
69
+ let buffer = 0;
70
+ let bufferBits = 0;
71
+ for (let i = 0; i < chars.length; i++) {
72
+ const code = chars.charCodeAt(i);
73
+ const value = code < 128 ? CHAR_TO_VALUE[code] : 0xff;
74
+ if (value === 0xff) {
75
+ throw new Error(`Invalid base32 character: '${chars[i]}'`);
76
+ }
77
+ buffer = (buffer << 5) | value;
78
+ bufferBits += 5;
79
+ if (bufferBits >= 8) {
80
+ bufferBits -= 8;
81
+ bytes.push((buffer >> bufferBits) & 0xff);
82
+ }
83
+ }
84
+ return Buffer.from(bytes);
85
+ }
86
+ /**
87
+ * Strip formatting (whitespace + dashes) and uppercase.
88
+ *
89
+ * H15 — strict alphabet. The previous confusable-char substitution map
90
+ * (0→O, 1→I, 8→B, 9→G) is removed. Digits 0/1/8/9 are never emitted by
91
+ * formatRecoveryCode; accepting them silently shrinks the effective key
92
+ * space and makes brute-force attempts indistinguishable from alphabet
93
+ * errors in telemetry. Any character outside A-Z2-7 after normalization
94
+ * is rejected; callers receive a structured error with code
95
+ * INVALID_RECOVERY_CODE_ALPHABET so they can surface a clear UX hint
96
+ * without leaking which validation step failed at the server surface.
97
+ *
98
+ * Returns the cleaned, uppercased string (no whitespace, no dashes).
99
+ * Throws with `code: 'INVALID_RECOVERY_CODE_ALPHABET'` on any illegal char.
100
+ */
101
+ function normalizeRaw(input) {
102
+ const upper = input.toUpperCase().replace(/[\s-]/g, '');
103
+ for (let i = 0; i < upper.length; i++) {
104
+ const ch = upper[i];
105
+ const code = ch.charCodeAt(0);
106
+ if (code >= 128 || CHAR_TO_VALUE[code] === 0xff) {
107
+ const err = new Error('Recovery codes only contain letters A-Z and digits 2-7. Copy from where you stored it.');
108
+ err.code = 'INVALID_RECOVERY_CODE_ALPHABET';
109
+ throw err;
110
+ }
111
+ }
112
+ return upper;
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Public API
116
+ // ---------------------------------------------------------------------------
117
+ /**
118
+ * Format 32 raw bytes as a printable recovery code.
119
+ *
120
+ * @param bytes — exactly 32 bytes of entropy
121
+ * @returns "AAAAA-BBBBB-CCCCC-...-KKKKK" (55 chars + 10 dashes = 65 chars)
122
+ * @throws if input is not 32 bytes
123
+ */
124
+ export function formatRecoveryCode(bytes) {
125
+ if (bytes.length !== DECODED_BYTE_LENGTH) {
126
+ throw new Error(`formatRecoveryCode: expected ${DECODED_BYTE_LENGTH} bytes, got ${bytes.length}`);
127
+ }
128
+ const input = bytes instanceof Buffer ? new Uint8Array(bytes) : bytes;
129
+ const base32 = bytesToBase32(input);
130
+ // Split into groups of GROUP_SIZE and join with dashes.
131
+ const groups = [];
132
+ for (let i = 0; i < GROUP_COUNT; i++) {
133
+ groups.push(base32.slice(i * GROUP_SIZE, (i + 1) * GROUP_SIZE));
134
+ }
135
+ return groups.join('-');
136
+ }
137
+ /**
138
+ * Parse a formatted (or loosely-typed) recovery code back to its 32 raw bytes.
139
+ *
140
+ * Tolerant of:
141
+ * - whitespace (spaces, newlines, tabs)
142
+ * - dashes (group separators in the printed format)
143
+ * - lowercase
144
+ *
145
+ * H15 — strict alphabet: confusable-char substitution (0/1/8/9) is NOT
146
+ * applied. Any character outside A-Z2-7 after stripping whitespace/dashes
147
+ * and uppercasing throws with code INVALID_RECOVERY_CODE_ALPHABET.
148
+ *
149
+ * M3 — exact-length check: after normalization the cleaned string must be
150
+ * exactly TOTAL_CHARS (55) characters. Shorter inputs would produce fewer
151
+ * than 32 meaningful decoded bytes; longer inputs indicate a malformed or
152
+ * concatenated code. Both are rejected.
153
+ *
154
+ * @throws with a descriptive message on invalid characters or wrong length
155
+ */
156
+ export function parseRecoveryCode(formatted) {
157
+ if (!formatted || formatted.trim().length === 0) {
158
+ throw new Error('parseRecoveryCode: input is empty');
159
+ }
160
+ // normalizeRaw throws with code INVALID_RECOVERY_CODE_ALPHABET on any
161
+ // character outside A-Z2-7 (after uppercase + whitespace/dash strip).
162
+ const cleaned = normalizeRaw(formatted);
163
+ if (cleaned.length === 0) {
164
+ throw new Error('parseRecoveryCode: no valid characters found after normalization');
165
+ }
166
+ // M3 — reject malformed pad-suffix: require exactly TOTAL_CHARS base32
167
+ // chars. The canonical format always produces exactly 55 chars (11×5);
168
+ // any deviation means the input is truncated, extended, or corrupted.
169
+ if (cleaned.length !== TOTAL_CHARS) {
170
+ throw new Error(`parseRecoveryCode: expected ${TOTAL_CHARS} base32 chars, got ${cleaned.length} — ` +
171
+ `input likely has wrong length`);
172
+ }
173
+ const decoded = base32ToBytes(cleaned);
174
+ // 55 base32 chars × 5 bits = 275 bits = 34 bytes + 3 trailing bits.
175
+ // The format intentionally pads to 55 chars (11×5) for visual grouping;
176
+ // the last 1–3 bytes are trailing zero-padding from the encoder and must
177
+ // be dropped. With the exact-length check above, decoded.length is always
178
+ // ≥ DECODED_BYTE_LENGTH here, but guard defensively.
179
+ if (decoded.length < DECODED_BYTE_LENGTH) {
180
+ throw new Error(`parseRecoveryCode: expected ${DECODED_BYTE_LENGTH} decoded bytes, got ${decoded.length} — ` +
181
+ `input likely has wrong length (got ${cleaned.length} base32 chars, expected ${TOTAL_CHARS})`);
182
+ }
183
+ return decoded.slice(0, DECODED_BYTE_LENGTH);
184
+ }
185
+ /**
186
+ * Normalize any input form to the canonical 11-group display format.
187
+ * Applies the same confusable-char + whitespace tolerance as parseRecoveryCode.
188
+ *
189
+ * @throws the same errors as parseRecoveryCode on invalid input
190
+ */
191
+ export function normalizeRecoveryCode(input) {
192
+ const bytes = parseRecoveryCode(input);
193
+ return formatRecoveryCode(bytes);
194
+ }
195
+ /**
196
+ * Non-throwing validity check.
197
+ * Returns true iff the input can be successfully parsed as a 32-byte recovery code.
198
+ */
199
+ export function isValidRecoveryCode(input) {
200
+ try {
201
+ parseRecoveryCode(input);
202
+ return true;
203
+ }
204
+ catch {
205
+ return false;
206
+ }
207
+ }