@skrr-ai/cli 0.1.78 → 0.1.80

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 (49) hide show
  1. package/dist/base-command.js +9 -2
  2. package/dist/commands/inbox/index.js +4 -2
  3. package/dist/commands/login.d.ts +8 -3
  4. package/dist/commands/login.js +14 -4
  5. package/dist/commands/spaces/workflows/events.js +9 -2
  6. package/dist/lib/agentic-stream.js +13 -4
  7. package/dist/lib/api-fetch.js +6 -2
  8. package/dist/lib/brokered-dpop.d.ts +34 -0
  9. package/dist/lib/brokered-dpop.js +89 -0
  10. package/dist/lib/daemonBroker.d.ts +71 -2
  11. package/dist/lib/daemonBroker.js +203 -36
  12. package/dist/lib/daemonBrokerRefusal.d.ts +26 -0
  13. package/dist/lib/daemonBrokerRefusal.js +83 -4
  14. package/dist/lib/daemonRestartWait.d.ts +39 -0
  15. package/dist/lib/daemonRestartWait.js +181 -0
  16. package/dist/lib/dedicated-lease-command.js +1 -0
  17. package/dist/lib/dedicated-service.js +10 -5
  18. package/dist/lib/dedicated-ssh.js +6 -3
  19. package/dist/lib/dedicated-terminal.d.ts +3 -0
  20. package/dist/lib/dedicated-terminal.js +77 -18
  21. package/dist/lib/dpop-auth.d.ts +35 -0
  22. package/dist/lib/dpop-auth.js +57 -0
  23. package/dist/lib/login.d.ts +6 -0
  24. package/dist/lib/login.js +13 -4
  25. package/dist/lib/node-adapter.js +4 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +42 -1
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +35 -3
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +49 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +47 -9
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +56 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +42 -20
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.d.ts +101 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +179 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +19 -3
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +42 -1
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +32 -2
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +49 -1
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +47 -9
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +56 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +39 -20
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.d.ts +101 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +138 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +4 -0
  46. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  47. package/dist/node_modules/@skrr-ai/data-provider/index.js +3422 -3407
  48. package/oclif.manifest.json +41505 -41505
  49. package/package.json +2 -2
@@ -22,7 +22,7 @@
22
22
  * - `access_token`: broker mode — a short-lived access token from a
23
23
  * daemon-owned family; nothing durable leaves the daemon.
24
24
  */
25
- export declare const CLI_HANDOFF_MODES: readonly ["refresh_family", "access_token"];
25
+ export declare const CLI_HANDOFF_MODES: readonly ["refresh_family", "access_token", "access_token_dpop"];
26
26
  export type CliHandoffMode = (typeof CLI_HANDOFF_MODES)[number];
27
27
  /** Broker-mode redeem route on the daemon's loopback server. */
28
28
  export declare const CLI_HANDOFF_TOKEN_PATH = "/v1/auth/cli-handoff-token";
@@ -74,10 +74,26 @@ export declare function normalizeHandoffModes(value: unknown): CliHandoffMode[];
74
74
  */
75
75
  export declare function advertisedHandoffModes(d: CliHandoffDescriptor): CliHandoffMode[];
76
76
  export declare function descriptorOffersAccessToken(d: CliHandoffDescriptor): boolean;
77
+ /** True when the token route can serve DPoP-bound tokens (OSK-12020). */
78
+ export declare function descriptorOffersDpop(d: CliHandoffDescriptor): boolean;
77
79
  export interface CliHandoffTokenRequest {
78
80
  /** Bypass the daemon broker's cached token (the 401-recovery path). */
79
81
  forceRefresh?: boolean;
82
+ /**
83
+ * The caller can fetch per-request proofs from `CLI_HANDOFF_DPOP_PROOF_PATH`
84
+ * and wants a DPoP-bound token. Sent only to a descriptor that advertises
85
+ * `access_token_dpop`; a daemon that does not know the field ignores it and
86
+ * answers an unbound token, which `tokenType` then says.
87
+ */
88
+ dpop?: boolean;
80
89
  }
90
+ /**
91
+ * How the token must be presented. `DPoP` means the token carries `cnf.jkt`
92
+ * and every request needs a fresh proof; absent or `Bearer` means an ordinary
93
+ * bearer token. Read from the RESPONSE, never assumed from the request: an
94
+ * older server ignores the binding request and mints an unbound token.
95
+ */
96
+ export type CliHandoffTokenType = 'Bearer' | 'DPoP';
81
97
  export interface CliHandoffTokenResponse {
82
98
  accessToken: string;
83
99
  /** Absolute expiry in ms since epoch, when the daemon knows it. */
@@ -85,6 +101,31 @@ export interface CliHandoffTokenResponse {
85
101
  /** Server the minted credential belongs to — lets the caller verify it is
86
102
  * about to use a token for the API it thinks it is talking to. */
87
103
  serverUrl?: string;
104
+ tokenType?: CliHandoffTokenType;
88
105
  }
89
106
  /** Parse the redeem response; null when the token itself is absent. */
90
107
  export declare function parseCliHandoffTokenResponse(value: unknown): CliHandoffTokenResponse | null;
108
+ /** The daemon refuses an UNBOUND token because its posture requires binding. */
109
+ export declare const CLI_HANDOFF_ERROR_DPOP_REQUIRED = "HANDOFF_DPOP_REQUIRED";
110
+ /**
111
+ * The daemon signs RFC 9449 proofs for a bound token with a key that never
112
+ * leaves it. Same descriptor secret as the token route: using a stolen bound
113
+ * token therefore needs live loopback access to THIS daemon, which is the
114
+ * boundary the capability already has.
115
+ */
116
+ export declare const CLI_HANDOFF_DPOP_PROOF_PATH = "/v1/auth/cli-handoff-dpop-proof";
117
+ /** The oracle refused to sign (bad htm/htu, or `ath` names no token it issued). */
118
+ export declare const CLI_HANDOFF_ERROR_DPOP_PROOF_REFUSED = "HANDOFF_DPOP_PROOF_REFUSED";
119
+ /** The daemon holds no DPoP key (binding is off on this daemon). */
120
+ export declare const CLI_HANDOFF_ERROR_DPOP_UNAVAILABLE = "HANDOFF_DPOP_UNAVAILABLE";
121
+ export interface CliHandoffDpopProofRequest {
122
+ htm: string;
123
+ htu: string;
124
+ /** base64url SHA-256 of the bound access token. The token itself is never
125
+ * sent: the oracle commits to a hash, and only to one it issued. */
126
+ ath: string;
127
+ }
128
+ export interface CliHandoffDpopProofResponse {
129
+ proof: string;
130
+ }
131
+ export declare function parseCliHandoffDpopProofResponse(value: unknown): CliHandoffDpopProofResponse | null;
@@ -18,19 +18,21 @@
18
18
  * must produce the full shape.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.CLI_HANDOFF_ERROR_BROKER_ONLY = exports.CLI_HANDOFF_TOKEN_ERROR_UNAVAILABLE = exports.CLI_HANDOFF_TOKEN_PATH = exports.CLI_HANDOFF_MODES = void 0;
21
+ exports.CLI_HANDOFF_ERROR_DPOP_UNAVAILABLE = exports.CLI_HANDOFF_ERROR_DPOP_PROOF_REFUSED = exports.CLI_HANDOFF_DPOP_PROOF_PATH = exports.CLI_HANDOFF_ERROR_DPOP_REQUIRED = exports.CLI_HANDOFF_ERROR_BROKER_ONLY = exports.CLI_HANDOFF_TOKEN_ERROR_UNAVAILABLE = exports.CLI_HANDOFF_TOKEN_PATH = exports.CLI_HANDOFF_MODES = void 0;
22
22
  exports.parseCliHandoffDescriptor = parseCliHandoffDescriptor;
23
23
  exports.normalizeHandoffModes = normalizeHandoffModes;
24
24
  exports.advertisedHandoffModes = advertisedHandoffModes;
25
25
  exports.descriptorOffersAccessToken = descriptorOffersAccessToken;
26
+ exports.descriptorOffersDpop = descriptorOffersDpop;
26
27
  exports.parseCliHandoffTokenResponse = parseCliHandoffTokenResponse;
28
+ exports.parseCliHandoffDpopProofResponse = parseCliHandoffDpopProofResponse;
27
29
  /**
28
30
  * What a caller may redeem the descriptor secret for.
29
31
  * - `refresh_family`: the legacy durable mint (90-day cli-scope family).
30
32
  * - `access_token`: broker mode — a short-lived access token from a
31
33
  * daemon-owned family; nothing durable leaves the daemon.
32
34
  */
33
- exports.CLI_HANDOFF_MODES = ['refresh_family', 'access_token'];
35
+ exports.CLI_HANDOFF_MODES = ['refresh_family', 'access_token', 'access_token_dpop'];
34
36
  /** Broker-mode redeem route on the daemon's loopback server. */
35
37
  exports.CLI_HANDOFF_TOKEN_PATH = '/v1/auth/cli-handoff-token';
36
38
  /** Error codes this contract emits. String literals so both ends compare
@@ -96,7 +98,12 @@ function advertisedHandoffModes(d) {
96
98
  return declared.length > 0 ? declared : ['refresh_family'];
97
99
  }
98
100
  function descriptorOffersAccessToken(d) {
99
- return advertisedHandoffModes(d).includes('access_token');
101
+ const modes = advertisedHandoffModes(d);
102
+ return modes.includes('access_token') || modes.includes('access_token_dpop');
103
+ }
104
+ /** True when the token route can serve DPoP-bound tokens (OSK-12020). */
105
+ function descriptorOffersDpop(d) {
106
+ return advertisedHandoffModes(d).includes('access_token_dpop');
100
107
  }
101
108
  /** Parse the redeem response; null when the token itself is absent. */
102
109
  function parseCliHandoffTokenResponse(value) {
@@ -109,5 +116,30 @@ function parseCliHandoffTokenResponse(value) {
109
116
  accessToken: v.accessToken,
110
117
  ...(typeof v.accessExpiresAt === 'number' ? { accessExpiresAt: v.accessExpiresAt } : {}),
111
118
  ...(typeof v.serverUrl === 'string' ? { serverUrl: v.serverUrl } : {}),
119
+ ...(v.tokenType === 'DPoP' || v.tokenType === 'Bearer' ? { tokenType: v.tokenType } : {}),
112
120
  };
113
121
  }
122
+ /** The daemon refuses an UNBOUND token because its posture requires binding. */
123
+ exports.CLI_HANDOFF_ERROR_DPOP_REQUIRED = 'HANDOFF_DPOP_REQUIRED';
124
+ // ---------------------------------------------------------------------
125
+ // Proof oracle — POST CLI_HANDOFF_DPOP_PROOF_PATH (OSK-12020)
126
+ // ---------------------------------------------------------------------
127
+ /**
128
+ * The daemon signs RFC 9449 proofs for a bound token with a key that never
129
+ * leaves it. Same descriptor secret as the token route: using a stolen bound
130
+ * token therefore needs live loopback access to THIS daemon, which is the
131
+ * boundary the capability already has.
132
+ */
133
+ exports.CLI_HANDOFF_DPOP_PROOF_PATH = '/v1/auth/cli-handoff-dpop-proof';
134
+ /** The oracle refused to sign (bad htm/htu, or `ath` names no token it issued). */
135
+ exports.CLI_HANDOFF_ERROR_DPOP_PROOF_REFUSED = 'HANDOFF_DPOP_PROOF_REFUSED';
136
+ /** The daemon holds no DPoP key (binding is off on this daemon). */
137
+ exports.CLI_HANDOFF_ERROR_DPOP_UNAVAILABLE = 'HANDOFF_DPOP_UNAVAILABLE';
138
+ function parseCliHandoffDpopProofResponse(value) {
139
+ if (value == null || typeof value !== 'object')
140
+ return null;
141
+ const v = value;
142
+ if (typeof v.proof !== 'string' || v.proof.split('.').length !== 3)
143
+ return null;
144
+ return { proof: v.proof };
145
+ }
@@ -13,6 +13,20 @@
13
13
  * Deliberately thin: no persistence, no retry policy, no logging. Callers
14
14
  * own their store and their error surfaces; this owns the cache/single-
15
15
  * flight/invalidate invariants so they cannot drift between surfaces.
16
+ *
17
+ * Two policies are options because mechanisms genuinely need different ones,
18
+ * and both default to the original behaviour:
19
+ *
20
+ * - `forcedAcquire: 'join'` — a forced acquire joins whatever is in flight,
21
+ * for a mechanism where a second concurrent renewal is itself the hazard
22
+ * (a refresh-family rotation or a family mint);
23
+ * - `classifyAcquireError` — stale-on-degrade: a TRANSIENT failure serves the
24
+ * still-unexpired cached credential instead of failing, a PERMANENT one
25
+ * drops it.
26
+ *
27
+ * Mechanism-specific limits — how often a new family may be minted, which
28
+ * failures retire it — stay in the mechanism. The daemon's task token broker
29
+ * (`daemon/src/task/token-broker.ts`) is the consumer that uses both options.
16
30
  */
17
31
  /** What an acquire returns. `expiresAtMs` absent means "no known expiry" —
18
32
  * the token is served until `invalidate()` or an acquire replaces it. */
@@ -26,13 +40,45 @@ export interface CredentialSessionOptions {
26
40
  * The mechanism. Called with `forceRefresh: true` when the caller asked to
27
41
  * bypass the cache (the 401-recovery path) — implementations should renew
28
42
  * rather than serve their own cache. A rejected promise propagates to every
29
- * waiter and is never cached.
43
+ * waiter and is never cached (`classifyAcquireError` is the one way a waiter
44
+ * can be served the previous credential instead).
30
45
  */
31
46
  acquire: (forceRefresh: boolean) => Promise<AcquiredCredential>;
32
47
  /** Serve the cached token until this many ms before expiry. Default 30s. */
33
48
  skewMs?: number;
34
49
  /** Injectable clock for tests. */
35
50
  now?: () => number;
51
+ /**
52
+ * What a forced acquire does when an acquire is already in flight.
53
+ *
54
+ * - `'supersede'` (default): a forced acquire never joins a NON-forced
55
+ * in-flight one — that could serve the very credential a 401 just
56
+ * invalidated — so it starts a second operation, and the superseded one
57
+ * cannot write its result back. Right for a mechanism whose plain
58
+ * acquire may serve its own cache.
59
+ * - `'join'`: every acquire, forced or not, joins the one in flight. Right
60
+ * — and required — for a mechanism whose acquire ALWAYS renews (it never
61
+ * serves a cache of its own), and for which a second concurrent renewal
62
+ * is itself the hazard: a rotation that burns a refresh family, a mint
63
+ * that creates a new one. Whatever is in flight was started after the
64
+ * credential being replaced was issued, so its result is not that
65
+ * credential.
66
+ */
67
+ forcedAcquire?: 'supersede' | 'join';
68
+ /**
69
+ * Classify an acquire failure. Without it every failure propagates and the
70
+ * cache is left as it was (the default).
71
+ *
72
+ * - `'transient'` — the renewal failed but the cached credential is still
73
+ * good: a waiter is served the cached token while it is inside its HARD
74
+ * expiry (the skew window only says when to start renewing), instead of
75
+ * the error. Nothing stale is served after `invalidate()`, after a forced
76
+ * acquire discarded it, or once it has actually expired.
77
+ * - `'permanent'` — the credential is dead upstream: the cache is dropped
78
+ * and the error propagates.
79
+ * - `undefined` — not classified: the default behaviour.
80
+ */
81
+ classifyAcquireError?: (err: unknown) => 'transient' | 'permanent' | undefined;
36
82
  }
37
83
  export interface CredentialSession {
38
84
  /**
@@ -41,6 +87,8 @@ export interface CredentialSession {
41
87
  * discards the cache first (use after a resource 401).
42
88
  */
43
89
  getToken(forceRefresh?: boolean): Promise<string>;
90
+ /** Same as getToken, but resolves the whole credential (with its expiry). */
91
+ getCredential(forceRefresh?: boolean): Promise<AcquiredCredential>;
44
92
  /** Drop the cached token; the next getToken() acquires. */
45
93
  invalidate(): void;
46
94
  }
@@ -14,6 +14,20 @@
14
14
  * Deliberately thin: no persistence, no retry policy, no logging. Callers
15
15
  * own their store and their error surfaces; this owns the cache/single-
16
16
  * flight/invalidate invariants so they cannot drift between surfaces.
17
+ *
18
+ * Two policies are options because mechanisms genuinely need different ones,
19
+ * and both default to the original behaviour:
20
+ *
21
+ * - `forcedAcquire: 'join'` — a forced acquire joins whatever is in flight,
22
+ * for a mechanism where a second concurrent renewal is itself the hazard
23
+ * (a refresh-family rotation or a family mint);
24
+ * - `classifyAcquireError` — stale-on-degrade: a TRANSIENT failure serves the
25
+ * still-unexpired cached credential instead of failing, a PERMANENT one
26
+ * drops it.
27
+ *
28
+ * Mechanism-specific limits — how often a new family may be minted, which
29
+ * failures retire it — stay in the mechanism. The daemon's task token broker
30
+ * (`daemon/src/task/token-broker.ts`) is the consumer that uses both options.
17
31
  */
18
32
  Object.defineProperty(exports, "__esModule", { value: true });
19
33
  exports.createCredentialSession = createCredentialSession;
@@ -21,6 +35,8 @@ const DEFAULT_SKEW_MS = 30_000;
21
35
  function createCredentialSession(opts) {
22
36
  const skewMs = opts.skewMs ?? DEFAULT_SKEW_MS;
23
37
  const now = opts.now ?? Date.now;
38
+ const forcedJoins = opts.forcedAcquire === 'join';
39
+ const classify = opts.classifyAcquireError;
24
40
  let cached = null;
25
41
  let inFlight = null;
26
42
  let inFlightForced = false;
@@ -29,17 +45,26 @@ function createCredentialSession(opts) {
29
45
  // result back over the newer one.
30
46
  let generation = 0;
31
47
  const fresh = (t) => t.expiresAtMs == null || now() < t.expiresAtMs - skewMs;
48
+ /** Not yet actually expired — the bar for serving a stale credential. */
49
+ const unexpired = (t) => t.expiresAtMs == null || now() < t.expiresAtMs;
32
50
  const acquireShared = (force) => {
33
- // A forced acquire must not join a non-forced in-flight one — that would
34
- // serve the very credential a 401 just invalidated. A forced in-flight
35
- // DOES satisfy non-forced waiters: it is already renewing.
36
- if (inFlight && (inFlightForced || !force))
51
+ // Under 'supersede', a forced acquire must not join a non-forced in-flight
52
+ // one — that would serve the very credential a 401 just invalidated. A
53
+ // forced in-flight DOES satisfy non-forced waiters: it is already renewing.
54
+ // Under 'join', everything joins (see `forcedAcquire`).
55
+ if (inFlight && (forcedJoins || inFlightForced || !force))
37
56
  return inFlight;
38
57
  const gen = ++generation;
39
58
  const p = opts.acquire(force).then((t) => {
40
59
  if (gen === generation)
41
60
  cached = t;
42
61
  return t;
62
+ }, (err) => {
63
+ // Settled before any waiter resumes, so a waiter's stale check below
64
+ // already sees a permanent failure's dropped cache.
65
+ if (gen === generation && classify?.(err) === 'permanent')
66
+ cached = null;
67
+ throw err;
43
68
  });
44
69
  inFlight = p;
45
70
  inFlightForced = force;
@@ -54,13 +79,26 @@ function createCredentialSession(opts) {
54
79
  p.then(cleanup, cleanup);
55
80
  return p;
56
81
  };
82
+ const getCredential = async (forceRefresh = false) => {
83
+ if (forceRefresh)
84
+ cached = null;
85
+ if (cached && fresh(cached))
86
+ return cached;
87
+ try {
88
+ return await acquireShared(forceRefresh);
89
+ }
90
+ catch (err) {
91
+ // Stale-on-degrade: read `cached` AFTER the failure, so an invalidate()
92
+ // or a forced discard that happened meanwhile is honoured.
93
+ if (classify?.(err) === 'transient' && cached && unexpired(cached))
94
+ return cached;
95
+ throw err;
96
+ }
97
+ };
57
98
  return {
99
+ getCredential,
58
100
  async getToken(forceRefresh = false) {
59
- if (forceRefresh)
60
- cached = null;
61
- if (cached && fresh(cached))
62
- return cached.accessToken;
63
- return (await acquireShared(forceRefresh)).accessToken;
101
+ return (await getCredential(forceRefresh)).accessToken;
64
102
  },
65
103
  invalidate() {
66
104
  cached = null;
@@ -1,3 +1,40 @@
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';
1
38
  /**
2
39
  * Ed25519 JSON Web Key — RFC 8037 §2 ("Key Type 'OKP'").
3
40
  *
@@ -123,6 +160,18 @@ export interface BuildProofOptions {
123
160
  * not a silent fallback.
124
161
  */
125
162
  export declare function buildProof(privateJwk: Ed25519PrivateJwk, publicJwk: Ed25519PublicJwk, opts: BuildProofOptions): string;
163
+ /** The header `typ` of an OverSky device-identity proof. */
164
+ export declare const DEVICE_PROOF_TYP = "oversky-dpop+jwt";
165
+ /**
166
+ * Sign a DPoP-shaped proof with an Ed25519 key under the given header `typ`.
167
+ *
168
+ * @internal Shared by the device-identity proof (`oversky-dpop+jwt`, above)
169
+ * and the RFC 9449 access-token proof (`dpop+jwt`, `dpop.ts`). `typ` is the
170
+ * only thing on the wire that separates the two protocols, which is why it is
171
+ * a required argument rather than a default: a proof signed for one must
172
+ * never verify as the other.
173
+ */
174
+ export declare function signEd25519Proof(privateKey: crypto.KeyObject, publicJwk: Ed25519PublicJwk, typ: string, opts: BuildProofOptions): string;
126
175
  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
176
  export interface VerifySuccess {
128
177
  valid: true;
@@ -165,3 +214,10 @@ export interface VerifyOptions {
165
214
  * `sig_invalid` to keep the failure surface narrow.
166
215
  */
167
216
  export declare function verifyProof(jws: string, opts?: VerifyOptions): VerifySuccess | VerifyFailure;
217
+ /**
218
+ * Verify a DPoP-shaped Ed25519 proof whose header `typ` must equal `typ`.
219
+ *
220
+ * @internal See `signEd25519Proof`. `verifyProof` (device identity) and
221
+ * `verifyDpopProof` (`dpop.ts`, RFC 9449) are the two public doors.
222
+ */
223
+ export declare function verifyEd25519Proof(jws: string, opts: VerifyOptions, typ: string): VerifySuccess | VerifyFailure;
@@ -3,13 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEVICE_PROOF_TYP = void 0;
6
7
  exports.generateDeviceKeyPair = generateDeviceKeyPair;
7
8
  exports.jwkThumbprint = jwkThumbprint;
8
9
  exports.publicJwkFromX = publicJwkFromX;
9
10
  exports.tokenHash = tokenHash;
10
11
  exports.normalizeHtu = normalizeHtu;
11
12
  exports.buildProof = buildProof;
13
+ exports.signEd25519Proof = signEd25519Proof;
12
14
  exports.verifyProof = verifyProof;
15
+ exports.verifyEd25519Proof = verifyEd25519Proof;
13
16
  /**
14
17
  * deviceKey.ts — L11 Phase 1: Ed25519 device identity + DPoP-style proofs.
15
18
  *
@@ -164,17 +167,40 @@ function normalizeHtu(url) {
164
167
  * not a silent fallback.
165
168
  */
166
169
  function buildProof(privateJwk, publicJwk, opts) {
170
+ if (publicJwk.x !== privateJwk.x) {
171
+ // Defensive — public and private must come from the same keypair.
172
+ // A mismatch indicates a corrupted in-memory state.
173
+ throw new Error('buildProof: public/private JWK x mismatch (corrupted keypair)');
174
+ }
175
+ // Cast to crypto's `JsonWebKey` shape — that type requires an index
176
+ // signature for arbitrary string keys; our narrow OKP shape doesn't
177
+ // declare one because the strict shape is more useful at our API
178
+ // boundary. Cast through `unknown` to silence the structural mismatch
179
+ // without losing the rest of the type safety on this surface.
180
+ const privateKey = node_crypto_1.default.createPrivateKey({
181
+ format: 'jwk',
182
+ key: privateJwk,
183
+ });
184
+ return signEd25519Proof(privateKey, publicJwk, exports.DEVICE_PROOF_TYP, opts);
185
+ }
186
+ /** The header `typ` of an OverSky device-identity proof. */
187
+ exports.DEVICE_PROOF_TYP = 'oversky-dpop+jwt';
188
+ /**
189
+ * Sign a DPoP-shaped proof with an Ed25519 key under the given header `typ`.
190
+ *
191
+ * @internal Shared by the device-identity proof (`oversky-dpop+jwt`, above)
192
+ * and the RFC 9449 access-token proof (`dpop+jwt`, `dpop.ts`). `typ` is the
193
+ * only thing on the wire that separates the two protocols, which is why it is
194
+ * a required argument rather than a default: a proof signed for one must
195
+ * never verify as the other.
196
+ */
197
+ function signEd25519Proof(privateKey, publicJwk, typ, opts) {
167
198
  if (typeof opts.method !== 'string' || opts.method.length === 0) {
168
199
  throw new Error('buildProof: method must be a non-empty string');
169
200
  }
170
201
  if (typeof opts.url !== 'string' || opts.url.length === 0) {
171
202
  throw new Error('buildProof: url must be a non-empty string');
172
203
  }
173
- if (publicJwk.x !== privateJwk.x) {
174
- // Defensive — public and private must come from the same keypair.
175
- // A mismatch indicates a corrupted in-memory state.
176
- throw new Error('buildProof: public/private JWK x mismatch (corrupted keypair)');
177
- }
178
204
  const claims = {
179
205
  htm: opts.method.toUpperCase(),
180
206
  htu: normalizeHtu(opts.url),
@@ -185,23 +211,10 @@ function buildProof(privateJwk, publicJwk, opts) {
185
211
  claims.rth = tokenHash(opts.refreshToken);
186
212
  if (opts.accessToken !== undefined)
187
213
  claims.ath = tokenHash(opts.accessToken);
188
- const header = {
189
- alg: 'EdDSA',
190
- typ: 'oversky-dpop+jwt',
191
- jwk: publicJwk,
192
- };
214
+ const header = { alg: 'EdDSA', typ, jwk: publicJwk };
193
215
  const headerB64 = b64u(Buffer.from(JSON.stringify(header), 'utf-8'));
194
216
  const payloadB64 = b64u(Buffer.from(JSON.stringify(claims), 'utf-8'));
195
217
  const signingInput = `${headerB64}.${payloadB64}`;
196
- // Cast to crypto's `JsonWebKey` shape — that type requires an index
197
- // signature for arbitrary string keys; our narrow OKP shape doesn't
198
- // declare one because the strict shape is more useful at our API
199
- // boundary. Cast through `unknown` to silence the structural mismatch
200
- // without losing the rest of the type safety on this surface.
201
- const privateKey = node_crypto_1.default.createPrivateKey({
202
- format: 'jwk',
203
- key: privateJwk,
204
- });
205
218
  // EdDSA over Ed25519 — Node's crypto.sign expects `null` as the
206
219
  // digest algorithm because Ed25519 hashes the message internally.
207
220
  const signature = node_crypto_1.default.sign(null, Buffer.from(signingInput, 'utf-8'), privateKey);
@@ -217,6 +230,15 @@ function buildProof(privateJwk, publicJwk, opts) {
217
230
  * `sig_invalid` to keep the failure surface narrow.
218
231
  */
219
232
  function verifyProof(jws, opts = {}) {
233
+ return verifyEd25519Proof(jws, opts, exports.DEVICE_PROOF_TYP);
234
+ }
235
+ /**
236
+ * Verify a DPoP-shaped Ed25519 proof whose header `typ` must equal `typ`.
237
+ *
238
+ * @internal See `signEd25519Proof`. `verifyProof` (device identity) and
239
+ * `verifyDpopProof` (`dpop.ts`, RFC 9449) are the two public doors.
240
+ */
241
+ function verifyEd25519Proof(jws, opts, typ) {
220
242
  if (typeof jws !== 'string' || jws.length === 0) {
221
243
  return { valid: false, reason: 'malformed' };
222
244
  }
@@ -237,7 +259,7 @@ function verifyProof(jws, opts = {}) {
237
259
  if (!header || typeof header !== 'object' || header.alg !== 'EdDSA') {
238
260
  return { valid: false, reason: 'bad_alg' };
239
261
  }
240
- if (header.typ !== 'oversky-dpop+jwt') {
262
+ if (header.typ !== typ) {
241
263
  return { valid: false, reason: 'bad_typ' };
242
264
  }
243
265
  const headerJwk = header.jwk;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * dpop.ts — RFC 9449 proof-of-possession for ACCESS tokens.
3
+ *
4
+ * Sibling of `deviceKey.ts`, and deliberately a different protocol on the
5
+ * wire. A device-identity proof (`typ: oversky-dpop+jwt`, header
6
+ * `OverSky-DPoP`) proves a daemon holds its ENROLLED key and authorizes
7
+ * minting/rotating that daemon's families. An access-token proof
8
+ * (`typ: dpop+jwt`, header `DPoP`) proves the presenter of an access token
9
+ * holds the key the token is bound to (`cnf.jkt`). The `typ` keeps either
10
+ * proof from ever verifying as the other.
11
+ *
12
+ * Why a SEPARATE key, never the device key (OSK-12020): the access-token key
13
+ * signs whatever `{htm, htu, ath}` a local caller asks of the daemon's proof
14
+ * oracle (`/v1/auth/cli-handoff-dpop-proof`). Handing that oracle the device
15
+ * key would turn it into a signer for the credential that mints families —
16
+ * one confused-deputy step from the workload asking for a device proof over
17
+ * `POST /api/daemons/cli-handoff`. A dedicated key carries no authority but
18
+ * the tokens bound to it, lives only in daemon memory (`createDpopKey` never
19
+ * exposes the private half), and dies with the family it binds.
20
+ *
21
+ * Algorithm: EdDSA / Ed25519, the same primitive as the device key. RFC 9449
22
+ * lets the server advertise `dpop_signing_alg_values_supported`; ours is
23
+ * exactly `["EdDSA"]`, and the verifier refuses anything else.
24
+ */
25
+ import { type DeviceProofClaims, type Ed25519PublicJwk, type VerifyFailureReason } from './deviceKey.js';
26
+ /** RFC 9449 §4.2 — the proof's JOSE header `typ`. */
27
+ export declare const DPOP_PROOF_TYP = "dpop+jwt";
28
+ /** RFC 9449 §4.1 — the request header carrying the proof. */
29
+ export declare const DPOP_HEADER = "DPoP";
30
+ /** Default freshness window, matching the device-proof verifier. */
31
+ export declare const DPOP_MAX_AGE_SECONDS = 60;
32
+ /** Server error codes for a bound token presented without a usable proof. */
33
+ export declare const DPOP_PROOF_REQUIRED = "DPOP_PROOF_REQUIRED";
34
+ export declare const DPOP_PROOF_INVALID = "DPOP_PROOF_INVALID";
35
+ export declare const DPOP_PROOF_VERIFY_UNAVAILABLE = "DPOP_PROOF_VERIFY_UNAVAILABLE";
36
+ /** A proof's claims: RFC 9449's `{htm, htu, iat, jti, ath?}`. */
37
+ export type DpopProofClaims = Pick<DeviceProofClaims, 'htm' | 'htu' | 'iat' | 'jti' | 'ath'>;
38
+ export interface DpopProofRequest {
39
+ /** HTTP method of the request the proof will accompany. */
40
+ htm: string;
41
+ /** Target URI; query and fragment are stripped (RFC 9449 §4.2). */
42
+ htu: string;
43
+ /** The access token the proof commits to (hashed into `ath`). Omitted only
44
+ * at the token endpoint, where the token does not exist yet. */
45
+ accessToken?: string;
46
+ /** Pre-computed `ath`, for a signer that never sees the token itself. */
47
+ ath?: string;
48
+ /** Test hooks. */
49
+ iat?: number;
50
+ jti?: string;
51
+ }
52
+ /**
53
+ * An in-memory DPoP key. The private half lives in this closure and nowhere
54
+ * else: there is no accessor, no serializer, and it is never written to disk.
55
+ */
56
+ export interface DpopKey {
57
+ readonly publicJwk: Ed25519PublicJwk;
58
+ /** RFC 7638 thumbprint — the value a bound token carries as `cnf.jkt`. */
59
+ readonly thumbprint: string;
60
+ sign(req: DpopProofRequest): string;
61
+ }
62
+ export declare function createDpopKey(): DpopKey;
63
+ /** RFC 9449 §4.2 htu normalization — identical to the device proof's. */
64
+ export declare function normalizeDpopHtu(url: string): string;
65
+ /** `ath` for an access token: base64url(SHA-256(ascii(token))), RFC 9449 §4.2. */
66
+ export declare function accessTokenHash(accessToken: string): string;
67
+ /** True for a value shaped like an `ath` / `jkt` (43-char base64url SHA-256). */
68
+ export declare function isAth(value: unknown): value is string;
69
+ /** Same shape test, named for the `cnf.jkt` call sites. */
70
+ export declare const isJwkThumbprint: typeof isAth;
71
+ export type DpopVerifyFailureReason = Exclude<VerifyFailureReason, 'rth_mismatch' | 'pin_mismatch'> | 'ath_missing' | 'jkt_mismatch';
72
+ export interface DpopVerifyOptions {
73
+ expectedHtm: string;
74
+ expectedHtu: string;
75
+ /** `ath` the proof must carry. Required for a resource request. */
76
+ expectedAth?: string;
77
+ /** Thumbprint the proof's key must have — the token's `cnf.jkt`. */
78
+ expectedJkt?: string;
79
+ maxAgeSeconds?: number;
80
+ clockSkewSeconds?: number;
81
+ now?: number;
82
+ }
83
+ export type DpopVerifyResult = {
84
+ valid: true;
85
+ claims: DpopProofClaims;
86
+ thumbprint: string;
87
+ publicJwk: Ed25519PublicJwk;
88
+ } | {
89
+ valid: false;
90
+ reason: DpopVerifyFailureReason;
91
+ };
92
+ /**
93
+ * Verify an RFC 9449 proof: `typ` dpop+jwt, `alg` EdDSA, a well-formed inline
94
+ * JWK, the signature, iat window, htm, htu, `ath` (when expected) and the
95
+ * key's thumbprint against `expectedJkt` (when given).
96
+ *
97
+ * Replay (`jti`) is NOT checked here — it needs shared state, and the caller
98
+ * owns that (the API's Redis jti cache). A verifier that claimed to do it in
99
+ * process memory would be wrong on every multi-replica deployment.
100
+ */
101
+ export declare function verifyDpopProof(jws: string, opts: DpopVerifyOptions): DpopVerifyResult;