@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,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>;
|