@skrr-ai/cli 0.1.123 → 0.1.124

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 (85) hide show
  1. package/dist/commands/agents/delete.d.ts +8 -0
  2. package/dist/commands/agents/delete.js +16 -0
  3. package/dist/commands/context.js +7 -0
  4. package/dist/commands/search/workspace.js +1 -1
  5. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  6. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  7. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  8. package/dist/commands/store/gallery-admissions/show.js +1 -1
  9. package/dist/lib/auth-core-init.d.ts +15 -2
  10. package/dist/lib/auth-core-init.js +47 -6
  11. package/dist/lib/command-hygiene.js +30 -1
  12. package/dist/lib/daemonBrokerRefusal.js +5 -1
  13. package/dist/lib/keychain.js +41 -4
  14. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  15. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  16. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +13 -28
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +102 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +8 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +11 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -10
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +15 -37
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +13 -3
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +15 -0
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +20 -0
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +14 -10
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  83. package/dist/node_modules/@skrr-ai/data-provider/index.js +25 -7
  84. package/oclif.manifest.json +1 -1
  85. package/package.json +4 -1
@@ -50,6 +50,33 @@ export interface Ed25519PublicJwk {
50
50
  export interface Ed25519PrivateJwk extends Ed25519PublicJwk {
51
51
  readonly d: string;
52
52
  }
53
+ /**
54
+ * NIST P-256 public JSON Web Key — RFC 7518 §6.2 ("Key Type 'EC'").
55
+ *
56
+ * The only curve a Secure Enclave or TPM will sign with, which is why a
57
+ * hardware-bound device key is a different `alg` (ES256) rather than a
58
+ * different storage backend for the same algorithm. `x` and `y` are the
59
+ * 32-byte affine coordinates in base64url-no-padding. There is deliberately
60
+ * no private form: a P-256 device key in this codebase is non-exportable, so
61
+ * nothing here ever holds its scalar.
62
+ */
63
+ export interface P256PublicJwk {
64
+ readonly kty: 'EC';
65
+ readonly crv: 'P-256';
66
+ readonly x: string;
67
+ readonly y: string;
68
+ }
69
+ /** Any public key a device proof may be signed with. */
70
+ export type DevicePublicJwk = Ed25519PublicJwk | P256PublicJwk;
71
+ /**
72
+ * The JWS algorithms a device proof may use. `EdDSA` is the software key every
73
+ * install has had since L11; `ES256` is the hardware-bound key. A server pins
74
+ * ONE per device and never requires a client to use an algorithm it did not
75
+ * announce.
76
+ */
77
+ export type DeviceKeyAlg = 'EdDSA' | 'ES256';
78
+ /** Every algorithm this build can verify, in the order a client prefers them. */
79
+ export declare const DEVICE_KEY_ALGS: readonly DeviceKeyAlg[];
53
80
  /**
54
81
  * The full keypair returned at generation time. Holding both forms here
55
82
  * is convenient for the daemon-side bridge — the public form is what we
@@ -57,6 +84,11 @@ export interface Ed25519PrivateJwk extends Ed25519PublicJwk {
57
84
  *
58
85
  * `thumbprint` is the RFC 7638 JWK thumbprint of the public key — the
59
86
  * canonical stable id we use as the device handle on the server.
87
+ *
88
+ * This is the SOFTWARE Ed25519 key only, and only the sealed-file store and
89
+ * key generation touch its private half. Everything that signs holds a
90
+ * `DeviceKeyHandle` (deviceKeyHandle.ts) instead, which cannot return the
91
+ * private scalar and so works unchanged for a key that has none.
60
92
  */
61
93
  export interface DeviceKeyPair {
62
94
  readonly publicJwk: Ed25519PublicJwk;
@@ -83,7 +115,23 @@ export declare function generateDeviceKeyPair(): DeviceKeyPair;
83
115
  * `kid` in JWS headers and as the registered identifier on the server's
84
116
  * `Daemon` table.
85
117
  */
86
- export declare function jwkThumbprint(publicJwk: Ed25519PublicJwk): string;
118
+ export declare function jwkThumbprint(publicJwk: DevicePublicJwk): string;
119
+ /**
120
+ * Structural check for a public device key: the right `kty`/`crv` pair, and
121
+ * coordinates that are exactly 32 bytes of base64url. It does not prove the
122
+ * point is on the curve (importing the key does that); it proves the value is
123
+ * safe to build a thumbprint from.
124
+ */
125
+ export declare function isDevicePublicJwk(value: unknown): value is DevicePublicJwk;
126
+ /** The JWS algorithm a public key signs with. */
127
+ export declare function algForJwk(jwk: DevicePublicJwk): DeviceKeyAlg;
128
+ /**
129
+ * A P-256 public JWK from the 64-byte `x || y` form a Secure Enclave reports
130
+ * (CryptoKit's `rawRepresentation`). Rejects any other length so a helper that
131
+ * returns an x9.63 point (65 bytes, leading 0x04) fails loudly instead of being
132
+ * mis-split into a key nothing can verify.
133
+ */
134
+ export declare function p256PublicJwkFromRaw(raw: Uint8Array): P256PublicJwk;
87
135
  /**
88
136
  * Construct an `Ed25519PublicJwk` from a base64url-encoded 32-byte
89
137
  * public point. Used by the persistence layer when reading a stored
@@ -124,13 +172,13 @@ export interface DeviceProofClaims {
124
172
  ath?: string;
125
173
  }
126
174
  export interface DeviceProofHeader {
127
- alg: 'EdDSA';
175
+ alg: DeviceKeyAlg;
128
176
  /** Custom `typ` so the server can refuse generic JWTs that happen
129
- * to be EdDSA-signed by a stolen device key. RFC 9449 uses
177
+ * to be signed by a stolen device key. RFC 9449 uses
130
178
  * `dpop+jwt`; we differentiate so OverSky proofs can't be
131
179
  * cross-replayed into a generic DPoP resource server. */
132
180
  typ: 'oversky-dpop+jwt';
133
- jwk: Ed25519PublicJwk;
181
+ jwk: DevicePublicJwk;
134
182
  }
135
183
  export interface BuildProofOptions {
136
184
  method: string;
@@ -172,11 +220,35 @@ export declare const DEVICE_PROOF_TYP = "oversky-dpop+jwt";
172
220
  * never verify as the other.
173
221
  */
174
222
  export declare function signEd25519Proof(privateKey: crypto.KeyObject, publicJwk: Ed25519PublicJwk, typ: string, opts: BuildProofOptions): string;
223
+ /**
224
+ * The `header.payload` half of a proof: everything the key signs. Split out so
225
+ * a key that cannot be handed to this process (a Secure Enclave key signs
226
+ * inside a helper) signs the very same bytes a software key does, and nothing
227
+ * about the wire format depends on where the signature came from.
228
+ *
229
+ * @internal Shared by `signEd25519Proof` and `signProofWith`.
230
+ */
231
+ export declare function proofSigningInput(alg: DeviceKeyAlg, publicJwk: DevicePublicJwk, typ: string, opts: BuildProofOptions): string;
232
+ /**
233
+ * Anything that can sign a device proof (a `DeviceKeySigner`): a software key held in this process,
234
+ * or a hardware key that signs elsewhere. `sign` returns the JWS signature for
235
+ * `alg` (RFC 7515 §A.3 / RFC 8037: raw r||s for ES256, the 64 bytes for EdDSA),
236
+ * never DER.
237
+ */
238
+ export interface DeviceKeySigner {
239
+ readonly alg: DeviceKeyAlg;
240
+ readonly publicJwk: DevicePublicJwk;
241
+ sign(signingInput: Uint8Array): Promise<Uint8Array>;
242
+ }
243
+ /** Sign a device-identity proof with any `DeviceKeySigner`. */
244
+ export declare function signProofWith(signer: DeviceKeySigner, opts: BuildProofOptions, typ?: string): Promise<string>;
175
245
  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';
176
246
  export interface VerifySuccess {
177
247
  valid: true;
178
248
  claims: DeviceProofClaims;
179
- publicJwk: Ed25519PublicJwk;
249
+ publicJwk: DevicePublicJwk;
250
+ /** The algorithm the proof was signed with; always one `allowedAlgs` admitted. */
251
+ alg: DeviceKeyAlg;
180
252
  thumbprint: string;
181
253
  }
182
254
  export interface VerifyFailure {
@@ -199,8 +271,17 @@ export interface VerifyOptions {
199
271
  expectedAth?: string;
200
272
  /** Public JWK pinned at the server side. When provided, the proof's
201
273
  * inlined JWK MUST equal this — defends against a stolen valid
202
- * proof signed by a different (e.g., revoked) device. */
203
- pinnedPublicJwk?: Ed25519PublicJwk;
274
+ * proof signed by a different (e.g., revoked) device. Compared by
275
+ * thumbprint, so a pin on one algorithm never admits a key of another. */
276
+ pinnedPublicJwk?: DevicePublicJwk;
277
+ /**
278
+ * The algorithms this verification admits. Defaults to `['EdDSA']`: a caller
279
+ * that has not decided it accepts hardware-bound keys keeps exactly the
280
+ * behaviour it had before ES256 existed. A server that pins a device to ES256
281
+ * passes the algorithm of the PIN, not `DEVICE_KEY_ALGS`, so a proof cannot
282
+ * choose a weaker algorithm than the device enrolled with.
283
+ */
284
+ allowedAlgs?: readonly DeviceKeyAlg[];
204
285
  /** Override the wall clock — testing hook. Defaults to `Date.now()`. */
205
286
  now?: number;
206
287
  }
@@ -217,7 +298,25 @@ export declare function verifyProof(jws: string, opts?: VerifyOptions): VerifySu
217
298
  /**
218
299
  * Verify a DPoP-shaped Ed25519 proof whose header `typ` must equal `typ`.
219
300
  *
301
+ * Always EdDSA, whatever `opts.allowedAlgs` says: the RFC 9449 access-token
302
+ * proof (`dpop.ts`) is signed by the CLI's own in-memory Ed25519 key, which is a
303
+ * different key from the device key and deliberately stays software.
304
+ *
220
305
  * @internal See `signEd25519Proof`. `verifyProof` (device identity) and
221
306
  * `verifyDpopProof` (`dpop.ts`, RFC 9449) are the two public doors.
222
307
  */
223
308
  export declare function verifyEd25519Proof(jws: string, opts: VerifyOptions, typ: string): VerifySuccess | VerifyFailure;
309
+ /**
310
+ * Verify a DPoP-shaped proof signed by an Ed25519 or P-256 key, whose header
311
+ * `typ` must equal `typ` and whose `alg` must be in `opts.allowedAlgs`.
312
+ *
313
+ * The algorithm comes from the header and is checked twice before any key is
314
+ * built: it must be one the caller admitted, and it must be the algorithm the
315
+ * header's own key type signs with. Without the second check a header could
316
+ * pair `alg: ES256` with an Ed25519 key (or the reverse) and steer the
317
+ * verifier into a code path nobody tested; with it, the only thing an attacker
318
+ * controls about the algorithm is a choice among the caller's own allow-list.
319
+ *
320
+ * @internal
321
+ */
322
+ export declare function verifyDevicePublicKeyProof(jws: string, opts: VerifyOptions, typ: string): VerifySuccess | VerifyFailure;
@@ -35,6 +35,8 @@
35
35
  * - Wire-up to the daemon's outgoing HTTP path (Phase 2 of L11).
36
36
  */
37
37
  import crypto from 'node:crypto';
38
+ /** Every algorithm this build can verify, in the order a client prefers them. */
39
+ export const DEVICE_KEY_ALGS = ['ES256', 'EdDSA'];
38
40
  // ---------------------------------------------------------------------------
39
41
  // Base64url helpers — same shape as `credentialEnvelope.ts` so the two
40
42
  // modules are visually consistent in the auth-core codebase.
@@ -91,9 +93,55 @@ export function jwkThumbprint(publicJwk) {
91
93
  // guarantee key order across Node versions, even though current V8
92
94
  // preserves insertion order. Build the bytes ourselves to defend
93
95
  // against a hypothetical future regression.
94
- const canonical = `{"crv":"${publicJwk.crv}","kty":"${publicJwk.kty}","x":"${publicJwk.x}"}`;
96
+ //
97
+ // RFC 7638 §3.2: an EC key's required members are crv, kty, x, y; an OKP
98
+ // key's are crv, kty, x. The caller has validated the members as base64url
99
+ // (`isDevicePublicJwk`), so nothing here can inject a quote into the input.
100
+ const canonical = publicJwk.kty === 'EC'
101
+ ? `{"crv":"${publicJwk.crv}","kty":"${publicJwk.kty}","x":"${publicJwk.x}","y":"${publicJwk.y}"}`
102
+ : `{"crv":"${publicJwk.crv}","kty":"${publicJwk.kty}","x":"${publicJwk.x}"}`;
95
103
  return b64u(crypto.createHash('sha256').update(canonical, 'utf-8').digest());
96
104
  }
105
+ const B64URL_32 = /^[A-Za-z0-9_-]{43}$/;
106
+ /**
107
+ * Structural check for a public device key: the right `kty`/`crv` pair, and
108
+ * coordinates that are exactly 32 bytes of base64url. It does not prove the
109
+ * point is on the curve (importing the key does that); it proves the value is
110
+ * safe to build a thumbprint from.
111
+ */
112
+ export function isDevicePublicJwk(value) {
113
+ if (!value || typeof value !== 'object')
114
+ return false;
115
+ const jwk = value;
116
+ if (jwk.kty === 'OKP') {
117
+ return jwk.crv === 'Ed25519' && typeof jwk.x === 'string' && B64URL_32.test(jwk.x);
118
+ }
119
+ if (jwk.kty === 'EC') {
120
+ return (jwk.crv === 'P-256' &&
121
+ typeof jwk.x === 'string' &&
122
+ typeof jwk.y === 'string' &&
123
+ B64URL_32.test(jwk.x) &&
124
+ B64URL_32.test(jwk.y));
125
+ }
126
+ return false;
127
+ }
128
+ /** The JWS algorithm a public key signs with. */
129
+ export function algForJwk(jwk) {
130
+ return jwk.kty === 'EC' ? 'ES256' : 'EdDSA';
131
+ }
132
+ /**
133
+ * A P-256 public JWK from the 64-byte `x || y` form a Secure Enclave reports
134
+ * (CryptoKit's `rawRepresentation`). Rejects any other length so a helper that
135
+ * returns an x9.63 point (65 bytes, leading 0x04) fails loudly instead of being
136
+ * mis-split into a key nothing can verify.
137
+ */
138
+ export function p256PublicJwkFromRaw(raw) {
139
+ if (raw.length !== 64) {
140
+ throw new Error(`p256PublicJwkFromRaw: expected 64 bytes (x||y), got ${raw.length}`);
141
+ }
142
+ const buf = Buffer.from(raw);
143
+ return { kty: 'EC', crv: 'P-256', x: b64u(buf.subarray(0, 32)), y: b64u(buf.subarray(32)) };
144
+ }
97
145
  /**
98
146
  * Construct an `Ed25519PublicJwk` from a base64url-encoded 32-byte
99
147
  * public point. Used by the persistence layer when reading a stored
@@ -180,12 +228,34 @@ export const DEVICE_PROOF_TYP = 'oversky-dpop+jwt';
180
228
  * never verify as the other.
181
229
  */
182
230
  export function signEd25519Proof(privateKey, publicJwk, typ, opts) {
231
+ const signingInput = proofSigningInput('EdDSA', publicJwk, typ, opts);
232
+ // EdDSA over Ed25519 — Node's crypto.sign expects `null` as the
233
+ // digest algorithm because Ed25519 hashes the message internally.
234
+ const signature = crypto.sign(null, Buffer.from(signingInput, 'utf-8'), privateKey);
235
+ return `${signingInput}.${b64u(signature)}`;
236
+ }
237
+ /**
238
+ * The `header.payload` half of a proof: everything the key signs. Split out so
239
+ * a key that cannot be handed to this process (a Secure Enclave key signs
240
+ * inside a helper) signs the very same bytes a software key does, and nothing
241
+ * about the wire format depends on where the signature came from.
242
+ *
243
+ * @internal Shared by `signEd25519Proof` and `signProofWith`.
244
+ */
245
+ export function proofSigningInput(alg, publicJwk, typ, opts) {
183
246
  if (typeof opts.method !== 'string' || opts.method.length === 0) {
184
247
  throw new Error('buildProof: method must be a non-empty string');
185
248
  }
186
249
  if (typeof opts.url !== 'string' || opts.url.length === 0) {
187
250
  throw new Error('buildProof: url must be a non-empty string');
188
251
  }
252
+ // A header that claims an algorithm its own key cannot produce is a bug in the
253
+ // caller, and the one a verifier is built to reject (`bad_alg`). Refuse to
254
+ // emit it rather than ship a proof that fails on the server with a reason
255
+ // nobody connects to this line.
256
+ if (algForJwk(publicJwk) !== alg) {
257
+ throw new Error(`buildProof: alg ${alg} does not match a ${publicJwk.kty} key`);
258
+ }
189
259
  const claims = {
190
260
  htm: opts.method.toUpperCase(),
191
261
  htu: normalizeHtu(opts.url),
@@ -196,14 +266,16 @@ export function signEd25519Proof(privateKey, publicJwk, typ, opts) {
196
266
  claims.rth = tokenHash(opts.refreshToken);
197
267
  if (opts.accessToken !== undefined)
198
268
  claims.ath = tokenHash(opts.accessToken);
199
- const header = { alg: 'EdDSA', typ, jwk: publicJwk };
269
+ const header = { alg, typ, jwk: publicJwk };
200
270
  const headerB64 = b64u(Buffer.from(JSON.stringify(header), 'utf-8'));
201
271
  const payloadB64 = b64u(Buffer.from(JSON.stringify(claims), 'utf-8'));
202
- const signingInput = `${headerB64}.${payloadB64}`;
203
- // EdDSA over Ed25519 — Node's crypto.sign expects `null` as the
204
- // digest algorithm because Ed25519 hashes the message internally.
205
- const signature = crypto.sign(null, Buffer.from(signingInput, 'utf-8'), privateKey);
206
- return `${signingInput}.${b64u(signature)}`;
272
+ return `${headerB64}.${payloadB64}`;
273
+ }
274
+ /** Sign a device-identity proof with any `DeviceKeySigner`. */
275
+ export async function signProofWith(signer, opts, typ = DEVICE_PROOF_TYP) {
276
+ const signingInput = proofSigningInput(signer.alg, signer.publicJwk, typ, opts);
277
+ const signature = await signer.sign(Buffer.from(signingInput, 'utf-8'));
278
+ return `${signingInput}.${b64u(Buffer.from(signature))}`;
207
279
  }
208
280
  /**
209
281
  * Verify a device-identity proof. Returns a discriminated union — the
@@ -215,15 +287,35 @@ export function signEd25519Proof(privateKey, publicJwk, typ, opts) {
215
287
  * `sig_invalid` to keep the failure surface narrow.
216
288
  */
217
289
  export function verifyProof(jws, opts = {}) {
218
- return verifyEd25519Proof(jws, opts, DEVICE_PROOF_TYP);
290
+ return verifyDevicePublicKeyProof(jws, opts, DEVICE_PROOF_TYP);
219
291
  }
220
292
  /**
221
293
  * Verify a DPoP-shaped Ed25519 proof whose header `typ` must equal `typ`.
222
294
  *
295
+ * Always EdDSA, whatever `opts.allowedAlgs` says: the RFC 9449 access-token
296
+ * proof (`dpop.ts`) is signed by the CLI's own in-memory Ed25519 key, which is a
297
+ * different key from the device key and deliberately stays software.
298
+ *
223
299
  * @internal See `signEd25519Proof`. `verifyProof` (device identity) and
224
300
  * `verifyDpopProof` (`dpop.ts`, RFC 9449) are the two public doors.
225
301
  */
226
302
  export function verifyEd25519Proof(jws, opts, typ) {
303
+ return verifyDevicePublicKeyProof(jws, { ...opts, allowedAlgs: ['EdDSA'] }, typ);
304
+ }
305
+ /**
306
+ * Verify a DPoP-shaped proof signed by an Ed25519 or P-256 key, whose header
307
+ * `typ` must equal `typ` and whose `alg` must be in `opts.allowedAlgs`.
308
+ *
309
+ * The algorithm comes from the header and is checked twice before any key is
310
+ * built: it must be one the caller admitted, and it must be the algorithm the
311
+ * header's own key type signs with. Without the second check a header could
312
+ * pair `alg: ES256` with an Ed25519 key (or the reverse) and steer the
313
+ * verifier into a code path nobody tested; with it, the only thing an attacker
314
+ * controls about the algorithm is a choice among the caller's own allow-list.
315
+ *
316
+ * @internal
317
+ */
318
+ export function verifyDevicePublicKeyProof(jws, opts, typ) {
227
319
  if (typeof jws !== 'string' || jws.length === 0) {
228
320
  return { valid: false, reason: 'malformed' };
229
321
  }
@@ -241,33 +333,34 @@ export function verifyEd25519Proof(jws, opts, typ) {
241
333
  catch {
242
334
  return { valid: false, reason: 'malformed' };
243
335
  }
244
- if (!header || typeof header !== 'object' || header.alg !== 'EdDSA') {
336
+ const allowedAlgs = opts.allowedAlgs ?? ['EdDSA'];
337
+ const headerAlg = header && typeof header === 'object' ? header.alg : undefined;
338
+ if ((headerAlg !== 'EdDSA' && headerAlg !== 'ES256') ||
339
+ !allowedAlgs.includes(headerAlg)) {
245
340
  return { valid: false, reason: 'bad_alg' };
246
341
  }
247
342
  if (header.typ !== typ) {
248
343
  return { valid: false, reason: 'bad_typ' };
249
344
  }
345
+ // Take only the members a key of this type has. Whatever else the header's
346
+ // `jwk` carries (a stray `d`, `use`, `key_ops`) is dropped here, so nothing
347
+ // downstream can come to depend on a member we never validated, and the
348
+ // thumbprint is over exactly what was checked.
250
349
  const headerJwk = header.jwk;
251
- if (!headerJwk ||
252
- typeof headerJwk !== 'object' ||
253
- headerJwk.kty !== 'OKP' ||
254
- headerJwk.crv !== 'Ed25519' ||
255
- typeof headerJwk.x !== 'string') {
350
+ if (!isDevicePublicJwk(headerJwk)) {
256
351
  return { valid: false, reason: 'bad_jwk' };
257
352
  }
258
- const publicJwk = {
259
- kty: 'OKP',
260
- crv: 'Ed25519',
261
- x: headerJwk.x,
262
- };
263
- // Validate the public point length — defends against an attacker who
264
- // smuggles a non-32-byte `x` to confuse downstream consumers.
353
+ const publicJwk = headerJwk.kty === 'EC'
354
+ ? { kty: 'EC', crv: 'P-256', x: headerJwk.x, y: headerJwk.y }
355
+ : { kty: 'OKP', crv: 'Ed25519', x: headerJwk.x };
356
+ // The algorithm and the key must agree (see the function comment).
357
+ if (algForJwk(publicJwk) !== headerAlg) {
358
+ return { valid: false, reason: 'bad_alg' };
359
+ }
360
+ // Importing the key validates it: an Ed25519 point of the wrong length, or a
361
+ // P-256 point that is not on the curve, throws here.
265
362
  let publicKeyObj;
266
363
  try {
267
- const raw = b64uDecode(publicJwk.x);
268
- if (raw.length !== 32) {
269
- return { valid: false, reason: 'bad_jwk' };
270
- }
271
364
  publicKeyObj = crypto.createPublicKey({
272
365
  format: 'jwk',
273
366
  key: publicJwk,
@@ -276,7 +369,7 @@ export function verifyEd25519Proof(jws, opts, typ) {
276
369
  catch {
277
370
  return { valid: false, reason: 'bad_jwk' };
278
371
  }
279
- if (opts.pinnedPublicJwk && opts.pinnedPublicJwk.x !== publicJwk.x) {
372
+ if (opts.pinnedPublicJwk && jwkThumbprint(opts.pinnedPublicJwk) !== jwkThumbprint(publicJwk)) {
280
373
  return { valid: false, reason: 'pin_mismatch' };
281
374
  }
282
375
  // Verify the signature itself before validating claims — a bad
@@ -292,7 +385,17 @@ export function verifyEd25519Proof(jws, opts, typ) {
292
385
  }
293
386
  let sigOk;
294
387
  try {
295
- sigOk = crypto.verify(null, signingInput, publicKeyObj, signature);
388
+ sigOk =
389
+ headerAlg === 'ES256'
390
+ ? // JWS ES256 is the fixed-width r||s form, never DER (RFC 7518 §3.4).
391
+ // Node's 'ieee-p1363' encoding is that form and already refuses any
392
+ // other width (removing the explicit length check changes no test
393
+ // result), so the check below states the requirement instead of
394
+ // inheriting it: a DER or truncated signature must fail here rather
395
+ // than be re-encoded into validity, even under a Node that changes.
396
+ signature.length === 64 &&
397
+ crypto.verify('sha256', signingInput, { key: publicKeyObj, dsaEncoding: 'ieee-p1363' }, signature)
398
+ : crypto.verify(null, signingInput, publicKeyObj, signature);
296
399
  }
297
400
  catch {
298
401
  sigOk = false;
@@ -334,6 +437,7 @@ export function verifyEd25519Proof(jws, opts, typ) {
334
437
  valid: true,
335
438
  claims,
336
439
  publicJwk,
440
+ alg: headerAlg,
337
441
  thumbprint: jwkThumbprint(publicJwk),
338
442
  };
339
443
  }
@@ -0,0 +1,33 @@
1
+ import { type DeviceKeyPair, type DeviceKeySigner } from './deviceKey.js';
2
+ /**
3
+ * Where the private half lives.
4
+ *
5
+ * - `software`: an exportable scalar, sealed in a file under the credential
6
+ * envelope. Anything that can read that file and the envelope key owns the
7
+ * device identity.
8
+ * - `hardware`: a non-exportable key inside a Secure Enclave / TPM. A copy of
9
+ * every file on the disk is still not the key.
10
+ */
11
+ export type DeviceKeyCustody = 'software' | 'hardware';
12
+ export interface DeviceKeyHandle extends DeviceKeySigner {
13
+ readonly custody: DeviceKeyCustody;
14
+ /** RFC 7638 thumbprint of `publicJwk`; the id the server pins. */
15
+ readonly thumbprint: string;
16
+ }
17
+ /**
18
+ * A handle over a software Ed25519 key. Throws if the pair is internally
19
+ * inconsistent (public and private halves from different keys, or a private
20
+ * JWK Node cannot import) so a corrupted identity fails at load, where the
21
+ * caller already handles it, rather than at the first signature.
22
+ */
23
+ export declare function softwareEd25519Handle(pair: DeviceKeyPair): DeviceKeyHandle;
24
+ /**
25
+ * A software P-256 handle with a fresh key. NOT a device-key backend: a
26
+ * software P-256 key gives up exactly what P-256 is for, and nothing in the
27
+ * product creates one. It exists so the ES256 wire format (sign, verify, pin)
28
+ * can be exercised end to end without a Secure Enclave, and so a fake hardware
29
+ * helper in a test signs with the real algorithm instead of a stub.
30
+ *
31
+ * @internal
32
+ */
33
+ export declare function generateSoftwareP256Handle(): DeviceKeyHandle;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * deviceKeyHandle.ts — the one thing the rest of the codebase holds instead of a
3
+ * private key.
4
+ *
5
+ * WHY THIS EXISTS. Every signing site used to be handed a `DeviceKeyPair` and
6
+ * call `buildProof(kp.privateJwk, kp.publicJwk, …)`. That is correct for a
7
+ * software key and impossible for any other kind: a Secure Enclave or TPM key
8
+ * has no scalar to put in a JWK, and the next kind of key would have meant
9
+ * editing every signing site again. A handle inverts it. The holder asks the
10
+ * key to sign bytes (`sign`) and to describe itself (`alg`, `publicJwk`,
11
+ * `custody`); how the signature is made, and whether the key could be copied
12
+ * out of this machine, is the key's business.
13
+ *
14
+ * The consequence that matters in practice: `privateJwk` is read in exactly two
15
+ * places — the sealed-file store that persists a software key and the function
16
+ * here that turns it into a handle. A handle holds a `KeyObject` in a closure
17
+ * and offers no way to get the scalar back, so no new caller can come to depend
18
+ * on having it. That is the property that makes "the device key is
19
+ * extractable" (docs/architecture/device-key-custody-2026-10-29.md §3.2) a
20
+ * property of ONE backend rather than of the whole design.
21
+ *
22
+ * `custody` is the honest name for that difference, and it is surfaced (status,
23
+ * telemetry, UI) rather than inferred: `software` means a file on this disk can
24
+ * be copied to another machine and used there; `hardware` means it cannot.
25
+ */
26
+ import crypto from 'node:crypto';
27
+ import { jwkThumbprint, } from './deviceKey.js';
28
+ /**
29
+ * A handle over a software Ed25519 key. Throws if the pair is internally
30
+ * inconsistent (public and private halves from different keys, or a private
31
+ * JWK Node cannot import) so a corrupted identity fails at load, where the
32
+ * caller already handles it, rather than at the first signature.
33
+ */
34
+ export function softwareEd25519Handle(pair) {
35
+ if (pair.publicJwk.x !== pair.privateJwk.x) {
36
+ // Public and private must come from the same keypair. A mismatch is a
37
+ // partial write, a manual edit, or one file swapped for another.
38
+ throw new Error('softwareEd25519Handle: public/private JWK x mismatch (corrupted keypair)');
39
+ }
40
+ // Cast through `unknown`: Node's `JsonWebKey` wants an index signature our
41
+ // narrow OKP type deliberately does not declare.
42
+ const privateKey = crypto.createPrivateKey({
43
+ format: 'jwk',
44
+ key: pair.privateJwk,
45
+ });
46
+ const publicJwk = pair.publicJwk;
47
+ return {
48
+ alg: 'EdDSA',
49
+ custody: 'software',
50
+ publicJwk,
51
+ thumbprint: jwkThumbprint(publicJwk),
52
+ // Ed25519 hashes the message itself, so Node takes `null` for the digest.
53
+ sign: async (signingInput) => new Uint8Array(crypto.sign(null, Buffer.from(signingInput), privateKey)),
54
+ };
55
+ }
56
+ /**
57
+ * A software P-256 handle with a fresh key. NOT a device-key backend: a
58
+ * software P-256 key gives up exactly what P-256 is for, and nothing in the
59
+ * product creates one. It exists so the ES256 wire format (sign, verify, pin)
60
+ * can be exercised end to end without a Secure Enclave, and so a fake hardware
61
+ * helper in a test signs with the real algorithm instead of a stub.
62
+ *
63
+ * @internal
64
+ */
65
+ export function generateSoftwareP256Handle() {
66
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
67
+ const jwk = publicKey.export({ format: 'jwk' });
68
+ const publicJwk = { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
69
+ return {
70
+ alg: 'ES256',
71
+ custody: 'software',
72
+ publicJwk,
73
+ thumbprint: jwkThumbprint(publicJwk),
74
+ sign: async (signingInput) => new Uint8Array(
75
+ // 'ieee-p1363' is the JWS (r||s) form; the default would be DER.
76
+ crypto.sign('sha256', Buffer.from(signingInput), {
77
+ key: privateKey,
78
+ dsaEncoding: 'ieee-p1363',
79
+ })),
80
+ };
81
+ }
@@ -0,0 +1,68 @@
1
+ import { type P256PublicJwk } from './deviceKey.js';
2
+ import type { DeviceKeyHandle } from './deviceKeyHandle.js';
3
+ /** File name of the helper next to the daemon binary and under `<machine root>/bin`. */
4
+ export declare const HARDWARE_KEY_HELPER_NAME = "skrr-native-device-key";
5
+ export declare const HARDWARE_KEY_PROTOCOL = 1;
6
+ export type HardwareDeviceKeyErrorCode = 'helper_missing' | 'helper_failed' | 'timeout' | 'bad_response' | 'key_mismatch' | 'unsupported';
7
+ export declare class HardwareDeviceKeyError extends Error {
8
+ readonly code: HardwareDeviceKeyErrorCode;
9
+ constructor(code: HardwareDeviceKeyErrorCode, message: string);
10
+ }
11
+ /**
12
+ * Tell auth-core where the helper is. The daemon calls this at boot with the
13
+ * path next to its own binary; a process that never does (the CLI, a test)
14
+ * falls back to the places the helper is installed: beside this process's
15
+ * executable, then `<machine root>/bin`. Pass `null` to forget a resolver.
16
+ */
17
+ export declare function configureHardwareDeviceKeyHelper(resolver: (() => string | null) | null): void;
18
+ /** Absolute path of an executable helper, or null when this machine has none. */
19
+ export declare function resolveHardwareDeviceKeyHelper(): string | null;
20
+ /** @internal Test seam — bound every helper call by `ms`. `null` restores the real limits. */
21
+ export declare function __setHardwareHelperTimeoutForTest(ms: number | null): void;
22
+ export type HardwareKeyCapability = {
23
+ available: true;
24
+ } | {
25
+ available: false;
26
+ reason: 'helper_missing' | 'no_secure_hardware' | 'probe_failed';
27
+ message?: string;
28
+ };
29
+ /**
30
+ * Can this machine create and use a hardware-bound device key right now? Looked
31
+ * up, never assumed: the answer needs both a helper that runs and hardware that
32
+ * answers. A positive result is kept for the life of the process; a negative one
33
+ * is retried after a minute, so a helper installed by an update is picked up
34
+ * without a restart and a broken one is not spawned on every proof.
35
+ */
36
+ export declare function probeHardwareDeviceKey(): Promise<HardwareKeyCapability>;
37
+ /** @internal Test seam — forget the cached capability. */
38
+ export declare function __resetHardwareProbeForTest(): void;
39
+ /** Contents of `device-key.se`. The blob is opaque and machine-bound; see the file header. */
40
+ export interface HardwareKeyRecord {
41
+ readonly blob: string;
42
+ readonly publicJwk: P256PublicJwk;
43
+ }
44
+ export declare function serializeHardwareKeyRecord(record: HardwareKeyRecord, createdAt?: Date): string;
45
+ /**
46
+ * Parse `device-key.se`. Null for anything that is not a well-formed v1 record
47
+ * whose stored thumbprint matches its own public key: a record is trusted for
48
+ * exactly what it proves about itself, so a half-written or hand-edited file
49
+ * reads as unreadable (which never replaces it) and not as a different key.
50
+ */
51
+ export declare function parseHardwareKeyRecord(text: string): HardwareKeyRecord | null;
52
+ /** A handle whose `sign` asks the helper, so no scalar is ever in this process. */
53
+ export declare function hardwareDeviceKeyHandle(record: HardwareKeyRecord): DeviceKeyHandle;
54
+ /**
55
+ * Create a hardware key and PROVE it before anyone relies on it: sign random
56
+ * bytes and verify them with Node's own crypto against the public key the helper
57
+ * reported. A helper that returns a mis-encoded signature, or a public key that
58
+ * is not the signer's, fails here with nothing yet persisted and nothing yet sent
59
+ * to the server, instead of as a rejected login that leaves a pin half-moved.
60
+ *
61
+ * The returned record is NOT persisted; the caller decides when it becomes the
62
+ * device's identity (see `commitDeviceKeyCandidate`). Dropping it destroys the
63
+ * key: the Secure Enclave keeps nothing the blob does not.
64
+ */
65
+ export declare function createHardwareDeviceKey(): Promise<{
66
+ record: HardwareKeyRecord;
67
+ handle: DeviceKeyHandle;
68
+ }>;