@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.
- package/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- 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,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;
|
package/dist/cjs/pkce.js
ADDED
|
@@ -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
|
+
}
|