@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,167 @@
1
+ /**
2
+ * Ed25519 JSON Web Key — RFC 8037 §2 ("Key Type 'OKP'").
3
+ *
4
+ * `x` is the 32-byte public point in base64url-no-padding. `d` is the
5
+ * 32-byte private scalar; only present in the private JWK and must NEVER
6
+ * cross the trust boundary (server, log file, dashboard, or backup).
7
+ */
8
+ export interface Ed25519PublicJwk {
9
+ readonly kty: 'OKP';
10
+ readonly crv: 'Ed25519';
11
+ readonly x: string;
12
+ }
13
+ export interface Ed25519PrivateJwk extends Ed25519PublicJwk {
14
+ readonly d: string;
15
+ }
16
+ /**
17
+ * The full keypair returned at generation time. Holding both forms here
18
+ * is convenient for the daemon-side bridge — the public form is what we
19
+ * publish; the private form is what we sign with.
20
+ *
21
+ * `thumbprint` is the RFC 7638 JWK thumbprint of the public key — the
22
+ * canonical stable id we use as the device handle on the server.
23
+ */
24
+ export interface DeviceKeyPair {
25
+ readonly publicJwk: Ed25519PublicJwk;
26
+ readonly privateJwk: Ed25519PrivateJwk;
27
+ readonly thumbprint: string;
28
+ }
29
+ /**
30
+ * Generate a fresh Ed25519 keypair. Uses Node's native bindings —
31
+ * `crypto.generateKeyPairSync('ed25519')` is available since Node 12 and
32
+ * is the same primitive `crypto.sign(null, ..., privateKey)` consumes.
33
+ *
34
+ * Output is the JWK form (RFC 8037) so callers can serialize directly
35
+ * without dealing with PKCS#8 or raw byte representations. The private
36
+ * scalar `d` and public point `x` are 32 bytes each.
37
+ */
38
+ export declare function generateDeviceKeyPair(): DeviceKeyPair;
39
+ /**
40
+ * RFC 7638 JWK Thumbprint — SHA-256 of the canonical JSON of the
41
+ * required members. For an OKP key those are `crv`, `kty`, and `x`,
42
+ * lexicographically ordered with no whitespace.
43
+ *
44
+ * Stable across implementations: a server that derives the thumbprint
45
+ * the same way always agrees with us. This is the value we use as the
46
+ * `kid` in JWS headers and as the registered identifier on the server's
47
+ * `Daemon` table.
48
+ */
49
+ export declare function jwkThumbprint(publicJwk: Ed25519PublicJwk): string;
50
+ /**
51
+ * Construct an `Ed25519PublicJwk` from a base64url-encoded 32-byte
52
+ * public point. Used by the persistence layer when reading a stored
53
+ * public key; rejects garbage so a corrupted JWK file fails loudly
54
+ * rather than producing a silently-broken signing key.
55
+ */
56
+ export declare function publicJwkFromX(x: string): Ed25519PublicJwk;
57
+ /**
58
+ * `ath` / `rth` claim value: SHA-256 of the token ASCII bytes,
59
+ * base64url-encoded (no padding). Must match exactly on both sides.
60
+ */
61
+ export declare function tokenHash(token: string): string;
62
+ /**
63
+ * Normalize a URL for the `htu` claim per RFC 9449 §4.2:
64
+ * - strip query string
65
+ * - strip fragment
66
+ * - strip userinfo
67
+ * - lowercase scheme and host
68
+ *
69
+ * The path is left as-is (case-sensitive per RFC 3986). Trailing slash
70
+ * is preserved — the server compares to its own normalized form so as
71
+ * long as both sides use the same rule the comparison holds.
72
+ */
73
+ export declare function normalizeHtu(url: string): string;
74
+ export interface DeviceProofClaims {
75
+ /** HTTP method, uppercase. */
76
+ htm: string;
77
+ /** Normalized HTTP URI, see `normalizeHtu`. */
78
+ htu: string;
79
+ /** Issued-at, seconds since epoch. */
80
+ iat: number;
81
+ /** Unique nonce per signature. Server tracks recently-seen jti for
82
+ * replay defense within the iat window. */
83
+ jti: string;
84
+ /** SHA-256 of the refresh token. Set on refresh requests. */
85
+ rth?: string;
86
+ /** SHA-256 of the access token. Set on resource-access requests. */
87
+ ath?: string;
88
+ }
89
+ export interface DeviceProofHeader {
90
+ alg: 'EdDSA';
91
+ /** Custom `typ` so the server can refuse generic JWTs that happen
92
+ * to be EdDSA-signed by a stolen device key. RFC 9449 uses
93
+ * `dpop+jwt`; we differentiate so OverSky proofs can't be
94
+ * cross-replayed into a generic DPoP resource server. */
95
+ typ: 'oversky-dpop+jwt';
96
+ jwk: Ed25519PublicJwk;
97
+ }
98
+ export interface BuildProofOptions {
99
+ method: string;
100
+ url: string;
101
+ /** Plaintext refresh token. Hashed inline; the token itself is not
102
+ * carried in the proof. */
103
+ refreshToken?: string;
104
+ /** Plaintext access token. Hashed inline. */
105
+ accessToken?: string;
106
+ /** Override iat (seconds). Useful for tests; production should leave
107
+ * unset so the wall clock is used. */
108
+ iat?: number;
109
+ /** Override jti. Useful for tests; production should leave unset so
110
+ * a fresh 16-byte random is generated. */
111
+ jti?: string;
112
+ }
113
+ /**
114
+ * Build a JWS Compact Serialization device-identity proof.
115
+ *
116
+ * Header includes the public JWK inline (RFC 9449 pattern) so the
117
+ * server can verify the signature without prior key lookup, then
118
+ * additionally check that the inlined JWK matches the daemon's
119
+ * registered public key.
120
+ *
121
+ * Throws on invalid input (bad URL, bad method, etc.) — never produces
122
+ * a malformed proof. A failed sign at the daemon side is a hard error,
123
+ * not a silent fallback.
124
+ */
125
+ export declare function buildProof(privateJwk: Ed25519PrivateJwk, publicJwk: Ed25519PublicJwk, opts: BuildProofOptions): string;
126
+ export type VerifyFailureReason = 'malformed' | 'bad_alg' | 'bad_typ' | 'bad_jwk' | 'sig_invalid' | 'iat_old' | 'iat_future' | 'htm_mismatch' | 'htu_mismatch' | 'rth_mismatch' | 'ath_mismatch' | 'pin_mismatch';
127
+ export interface VerifySuccess {
128
+ valid: true;
129
+ claims: DeviceProofClaims;
130
+ publicJwk: Ed25519PublicJwk;
131
+ thumbprint: string;
132
+ }
133
+ export interface VerifyFailure {
134
+ valid: false;
135
+ reason: VerifyFailureReason;
136
+ }
137
+ export interface VerifyOptions {
138
+ /** Expected HTTP method. Compared case-insensitively (proof claim is
139
+ * uppercase by construction). */
140
+ expectedHtm?: string;
141
+ /** Expected HTTP URI. Normalized before comparison. */
142
+ expectedHtu?: string;
143
+ /** Maximum proof age in seconds. Default 60s. */
144
+ maxAgeSeconds?: number;
145
+ /** Tolerated negative skew (server clock ahead of daemon). Default 5s. */
146
+ clockSkewSeconds?: number;
147
+ /** Refresh-token hash that the proof must commit to. Server side: derive from the body. */
148
+ expectedRth?: string;
149
+ /** Access-token hash that the proof must commit to. */
150
+ expectedAth?: string;
151
+ /** Public JWK pinned at the server side. When provided, the proof's
152
+ * inlined JWK MUST equal this — defends against a stolen valid
153
+ * proof signed by a different (e.g., revoked) device. */
154
+ pinnedPublicJwk?: Ed25519PublicJwk;
155
+ /** Override the wall clock — testing hook. Defaults to `Date.now()`. */
156
+ now?: number;
157
+ }
158
+ /**
159
+ * Verify a device-identity proof. Returns a discriminated union — the
160
+ * reason is part of the failure shape so the server middleware can emit
161
+ * structured telemetry without re-parsing.
162
+ *
163
+ * Critical: Node's `crypto.verify` returns false on a bad signature
164
+ * AND throws on a structurally-broken key. Both are surfaced as
165
+ * `sig_invalid` to keep the failure surface narrow.
166
+ */
167
+ export declare function verifyProof(jws: string, opts?: VerifyOptions): VerifySuccess | VerifyFailure;
@@ -0,0 +1,320 @@
1
+ /**
2
+ * deviceKey.ts — L11 Phase 1: Ed25519 device identity + DPoP-style proofs.
3
+ *
4
+ * The load-bearing recommendation in `docs/daemon-auth-cc-deep-comparison-
5
+ * 2026-04-24.md` §L11. Two-axis identity:
6
+ *
7
+ * user identity = OAuth refresh token (server-rotated, scope-pinned, reuse-detected)
8
+ * device identity = Ed25519 keypair (device-generated, server-revocable)
9
+ *
10
+ * Every daemon→server request carries a JWS signature over
11
+ * `{htm, htu, iat, jti, rth?, ath?}` using the device key. Server validates
12
+ * against the public key registered for this daemon.
13
+ *
14
+ * Stolen credentials require BOTH halves:
15
+ * - refresh token alone is useless without the device key
16
+ * - device key alone is useless without the refresh token
17
+ *
18
+ * Why DPoP-shaped (RFC 9449) rather than rolling our own JWS:
19
+ * - The htm/htu/iat/jti/ath claim set is well-studied. Server-side
20
+ * replay defenses, clock-skew handling, and audit-trail formats all
21
+ * have a body of prior art to copy from.
22
+ * - Future interop: if we ever expose OverSky as a third-party OAuth
23
+ * resource server, DPoP-bearer is the way clients will already
24
+ * understand.
25
+ *
26
+ * Why we extend with `rth` (refresh-token-hash):
27
+ * - RFC 9449 only mints `ath` (access-token-hash). The whole point of
28
+ * L11 is to bind the *refresh* token to the device, so the proof
29
+ * accompanying a refresh request must commit to the refresh token.
30
+ * `rth` is structurally identical to `ath`; the name disambiguates.
31
+ *
32
+ * What this module is NOT responsible for:
33
+ * - Persistence (lives in `daemon/src/deviceIdentity.ts`).
34
+ * - Server-side public-key registration (separate API in a later PR).
35
+ * - Wire-up to the daemon's outgoing HTTP path (Phase 2 of L11).
36
+ */
37
+ import crypto from 'node:crypto';
38
+ // ---------------------------------------------------------------------------
39
+ // Base64url helpers — same shape as `credentialEnvelope.ts` so the two
40
+ // modules are visually consistent in the auth-core codebase.
41
+ // ---------------------------------------------------------------------------
42
+ function b64u(buf) {
43
+ return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
44
+ }
45
+ function b64uDecode(s) {
46
+ const padded = s.replace(/-/g, '+').replace(/_/g, '/');
47
+ const pad = padded.length % 4;
48
+ return Buffer.from(pad === 0 ? padded : padded + '='.repeat(4 - pad), 'base64');
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Key generation + canonicalization
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Generate a fresh Ed25519 keypair. Uses Node's native bindings —
55
+ * `crypto.generateKeyPairSync('ed25519')` is available since Node 12 and
56
+ * is the same primitive `crypto.sign(null, ..., privateKey)` consumes.
57
+ *
58
+ * Output is the JWK form (RFC 8037) so callers can serialize directly
59
+ * without dealing with PKCS#8 or raw byte representations. The private
60
+ * scalar `d` and public point `x` are 32 bytes each.
61
+ */
62
+ export function generateDeviceKeyPair() {
63
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
64
+ // `crypto.KeyObject.export({format: 'jwk'})` for an Ed25519 private
65
+ // key yields {kty, crv, d, x} — both halves in one shot. Saves a
66
+ // separate call to derive the public form.
67
+ const jwk = privateKey.export({ format: 'jwk' });
68
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
69
+ throw new Error(`generateDeviceKeyPair: unexpected JWK kty/crv (${jwk.kty}/${jwk.crv})`);
70
+ }
71
+ const privateJwk = { kty: 'OKP', crv: 'Ed25519', d: jwk.d, x: jwk.x };
72
+ const publicJwk = { kty: 'OKP', crv: 'Ed25519', x: jwk.x };
73
+ return {
74
+ publicJwk,
75
+ privateJwk,
76
+ thumbprint: jwkThumbprint(publicJwk),
77
+ };
78
+ }
79
+ /**
80
+ * RFC 7638 JWK Thumbprint — SHA-256 of the canonical JSON of the
81
+ * required members. For an OKP key those are `crv`, `kty`, and `x`,
82
+ * lexicographically ordered with no whitespace.
83
+ *
84
+ * Stable across implementations: a server that derives the thumbprint
85
+ * the same way always agrees with us. This is the value we use as the
86
+ * `kid` in JWS headers and as the registered identifier on the server's
87
+ * `Daemon` table.
88
+ */
89
+ export function jwkThumbprint(publicJwk) {
90
+ // Manually constructed — JSON.stringify with object literal does NOT
91
+ // guarantee key order across Node versions, even though current V8
92
+ // preserves insertion order. Build the bytes ourselves to defend
93
+ // against a hypothetical future regression.
94
+ const canonical = `{"crv":"${publicJwk.crv}","kty":"${publicJwk.kty}","x":"${publicJwk.x}"}`;
95
+ return b64u(crypto.createHash('sha256').update(canonical, 'utf-8').digest());
96
+ }
97
+ /**
98
+ * Construct an `Ed25519PublicJwk` from a base64url-encoded 32-byte
99
+ * public point. Used by the persistence layer when reading a stored
100
+ * public key; rejects garbage so a corrupted JWK file fails loudly
101
+ * rather than producing a silently-broken signing key.
102
+ */
103
+ export function publicJwkFromX(x) {
104
+ const raw = b64uDecode(x);
105
+ if (raw.length !== 32) {
106
+ throw new Error(`publicJwkFromX: expected 32-byte public point, got ${raw.length}`);
107
+ }
108
+ return { kty: 'OKP', crv: 'Ed25519', x };
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Token-hash and HTU normalization — RFC 9449 §4.1, §4.2
112
+ // ---------------------------------------------------------------------------
113
+ /**
114
+ * `ath` / `rth` claim value: SHA-256 of the token ASCII bytes,
115
+ * base64url-encoded (no padding). Must match exactly on both sides.
116
+ */
117
+ export function tokenHash(token) {
118
+ if (typeof token !== 'string' || token.length === 0) {
119
+ throw new Error('tokenHash: token must be a non-empty string');
120
+ }
121
+ return b64u(crypto.createHash('sha256').update(token, 'utf-8').digest());
122
+ }
123
+ /**
124
+ * Normalize a URL for the `htu` claim per RFC 9449 §4.2:
125
+ * - strip query string
126
+ * - strip fragment
127
+ * - strip userinfo
128
+ * - lowercase scheme and host
129
+ *
130
+ * The path is left as-is (case-sensitive per RFC 3986). Trailing slash
131
+ * is preserved — the server compares to its own normalized form so as
132
+ * long as both sides use the same rule the comparison holds.
133
+ */
134
+ export function normalizeHtu(url) {
135
+ const u = new URL(url);
136
+ u.search = '';
137
+ u.hash = '';
138
+ u.username = '';
139
+ u.password = '';
140
+ return u.toString();
141
+ }
142
+ /**
143
+ * Build a JWS Compact Serialization device-identity proof.
144
+ *
145
+ * Header includes the public JWK inline (RFC 9449 pattern) so the
146
+ * server can verify the signature without prior key lookup, then
147
+ * additionally check that the inlined JWK matches the daemon's
148
+ * registered public key.
149
+ *
150
+ * Throws on invalid input (bad URL, bad method, etc.) — never produces
151
+ * a malformed proof. A failed sign at the daemon side is a hard error,
152
+ * not a silent fallback.
153
+ */
154
+ export function buildProof(privateJwk, publicJwk, opts) {
155
+ if (typeof opts.method !== 'string' || opts.method.length === 0) {
156
+ throw new Error('buildProof: method must be a non-empty string');
157
+ }
158
+ if (typeof opts.url !== 'string' || opts.url.length === 0) {
159
+ throw new Error('buildProof: url must be a non-empty string');
160
+ }
161
+ if (publicJwk.x !== privateJwk.x) {
162
+ // Defensive — public and private must come from the same keypair.
163
+ // A mismatch indicates a corrupted in-memory state.
164
+ throw new Error('buildProof: public/private JWK x mismatch (corrupted keypair)');
165
+ }
166
+ const claims = {
167
+ htm: opts.method.toUpperCase(),
168
+ htu: normalizeHtu(opts.url),
169
+ iat: opts.iat ?? Math.floor(Date.now() / 1000),
170
+ jti: opts.jti ?? b64u(crypto.randomBytes(16)),
171
+ };
172
+ if (opts.refreshToken !== undefined)
173
+ claims.rth = tokenHash(opts.refreshToken);
174
+ if (opts.accessToken !== undefined)
175
+ claims.ath = tokenHash(opts.accessToken);
176
+ const header = {
177
+ alg: 'EdDSA',
178
+ typ: 'oversky-dpop+jwt',
179
+ jwk: publicJwk,
180
+ };
181
+ const headerB64 = b64u(Buffer.from(JSON.stringify(header), 'utf-8'));
182
+ const payloadB64 = b64u(Buffer.from(JSON.stringify(claims), 'utf-8'));
183
+ const signingInput = `${headerB64}.${payloadB64}`;
184
+ // Cast to crypto's `JsonWebKey` shape — that type requires an index
185
+ // signature for arbitrary string keys; our narrow OKP shape doesn't
186
+ // declare one because the strict shape is more useful at our API
187
+ // boundary. Cast through `unknown` to silence the structural mismatch
188
+ // without losing the rest of the type safety on this surface.
189
+ const privateKey = crypto.createPrivateKey({
190
+ format: 'jwk',
191
+ key: privateJwk,
192
+ });
193
+ // EdDSA over Ed25519 — Node's crypto.sign expects `null` as the
194
+ // digest algorithm because Ed25519 hashes the message internally.
195
+ const signature = crypto.sign(null, Buffer.from(signingInput, 'utf-8'), privateKey);
196
+ return `${signingInput}.${b64u(signature)}`;
197
+ }
198
+ /**
199
+ * Verify a device-identity proof. Returns a discriminated union — the
200
+ * reason is part of the failure shape so the server middleware can emit
201
+ * structured telemetry without re-parsing.
202
+ *
203
+ * Critical: Node's `crypto.verify` returns false on a bad signature
204
+ * AND throws on a structurally-broken key. Both are surfaced as
205
+ * `sig_invalid` to keep the failure surface narrow.
206
+ */
207
+ export function verifyProof(jws, opts = {}) {
208
+ if (typeof jws !== 'string' || jws.length === 0) {
209
+ return { valid: false, reason: 'malformed' };
210
+ }
211
+ const parts = jws.split('.');
212
+ if (parts.length !== 3) {
213
+ return { valid: false, reason: 'malformed' };
214
+ }
215
+ const [headerB64, payloadB64, sigB64] = parts;
216
+ let header;
217
+ let claims;
218
+ try {
219
+ header = JSON.parse(b64uDecode(headerB64).toString('utf-8'));
220
+ claims = JSON.parse(b64uDecode(payloadB64).toString('utf-8'));
221
+ }
222
+ catch {
223
+ return { valid: false, reason: 'malformed' };
224
+ }
225
+ if (!header || typeof header !== 'object' || header.alg !== 'EdDSA') {
226
+ return { valid: false, reason: 'bad_alg' };
227
+ }
228
+ if (header.typ !== 'oversky-dpop+jwt') {
229
+ return { valid: false, reason: 'bad_typ' };
230
+ }
231
+ const headerJwk = header.jwk;
232
+ if (!headerJwk ||
233
+ typeof headerJwk !== 'object' ||
234
+ headerJwk.kty !== 'OKP' ||
235
+ headerJwk.crv !== 'Ed25519' ||
236
+ typeof headerJwk.x !== 'string') {
237
+ return { valid: false, reason: 'bad_jwk' };
238
+ }
239
+ const publicJwk = {
240
+ kty: 'OKP',
241
+ crv: 'Ed25519',
242
+ x: headerJwk.x,
243
+ };
244
+ // Validate the public point length — defends against an attacker who
245
+ // smuggles a non-32-byte `x` to confuse downstream consumers.
246
+ let publicKeyObj;
247
+ try {
248
+ const raw = b64uDecode(publicJwk.x);
249
+ if (raw.length !== 32) {
250
+ return { valid: false, reason: 'bad_jwk' };
251
+ }
252
+ publicKeyObj = crypto.createPublicKey({
253
+ format: 'jwk',
254
+ key: publicJwk,
255
+ });
256
+ }
257
+ catch {
258
+ return { valid: false, reason: 'bad_jwk' };
259
+ }
260
+ if (opts.pinnedPublicJwk && opts.pinnedPublicJwk.x !== publicJwk.x) {
261
+ return { valid: false, reason: 'pin_mismatch' };
262
+ }
263
+ // Verify the signature itself before validating claims — a bad
264
+ // signature should always be the reported failure (more important
265
+ // signal than a stale iat).
266
+ const signingInput = Buffer.from(`${headerB64}.${payloadB64}`, 'utf-8');
267
+ let signature;
268
+ try {
269
+ signature = b64uDecode(sigB64);
270
+ }
271
+ catch {
272
+ return { valid: false, reason: 'malformed' };
273
+ }
274
+ let sigOk;
275
+ try {
276
+ sigOk = crypto.verify(null, signingInput, publicKeyObj, signature);
277
+ }
278
+ catch {
279
+ sigOk = false;
280
+ }
281
+ if (!sigOk) {
282
+ return { valid: false, reason: 'sig_invalid' };
283
+ }
284
+ // Claim-level checks.
285
+ const now = Math.floor((opts.now ?? Date.now()) / 1000);
286
+ const maxAge = opts.maxAgeSeconds ?? 60;
287
+ const skew = opts.clockSkewSeconds ?? 5;
288
+ if (typeof claims.iat !== 'number' || !Number.isFinite(claims.iat)) {
289
+ return { valid: false, reason: 'malformed' };
290
+ }
291
+ if (claims.iat > now + skew) {
292
+ return { valid: false, reason: 'iat_future' };
293
+ }
294
+ if (claims.iat < now - maxAge) {
295
+ return { valid: false, reason: 'iat_old' };
296
+ }
297
+ if (typeof claims.htm !== 'string' ||
298
+ typeof claims.htu !== 'string' ||
299
+ typeof claims.jti !== 'string') {
300
+ return { valid: false, reason: 'malformed' };
301
+ }
302
+ if (opts.expectedHtm && claims.htm !== opts.expectedHtm.toUpperCase()) {
303
+ return { valid: false, reason: 'htm_mismatch' };
304
+ }
305
+ if (opts.expectedHtu && claims.htu !== normalizeHtu(opts.expectedHtu)) {
306
+ return { valid: false, reason: 'htu_mismatch' };
307
+ }
308
+ if (opts.expectedRth !== undefined && claims.rth !== opts.expectedRth) {
309
+ return { valid: false, reason: 'rth_mismatch' };
310
+ }
311
+ if (opts.expectedAth !== undefined && claims.ath !== opts.expectedAth) {
312
+ return { valid: false, reason: 'ath_mismatch' };
313
+ }
314
+ return {
315
+ valid: true,
316
+ claims,
317
+ publicJwk,
318
+ thumbprint: jwkThumbprint(publicJwk),
319
+ };
320
+ }
@@ -0,0 +1,17 @@
1
+ export interface FdAuthPayload {
2
+ accessToken: string;
3
+ accessExpiresAt?: number;
4
+ refreshToken?: string;
5
+ refreshExpiresAt?: number;
6
+ }
7
+ /** @internal test-only — replace the fd reader. Pass null to restore. */
8
+ export declare function __setFdReaderForTest(fn: ((fd: number) => string | Promise<string>) | null): void;
9
+ /**
10
+ * Attempt a one-shot read of the handoff fd. Returns the parsed payload on
11
+ * success, or null on any failure (missing env var, bad fd, bad JSON, etc.).
12
+ *
13
+ * Side effect: on the first successful read the env var is scrubbed so a
14
+ * later caller that misses every in-process cache doesn't retry the now-
15
+ * closed fd.
16
+ */
17
+ export declare function readAuthFromFd(): Promise<FdAuthPayload | null>;