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