my-durable 0.0.7 → 0.0.9

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 (44) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +29 -3
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  33. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  34. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  35. package/apps/dpi-tui/src/app.ts +889 -187
  36. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  37. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  38. package/apps/dpi-tui/src/main.ts +5 -1
  39. package/apps/dpi-tui/src/theme.ts +14 -0
  40. package/apps/dpi-tui/src/views.ts +311 -0
  41. package/bin/dpi.mjs +21 -4
  42. package/package.json +2 -2
  43. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
@@ -11,7 +11,17 @@
11
11
  * origin. Multiple EXPLICIT differing origins → error before any network.
12
12
  * HTTPS-only canonical origin (protocol/pairing.ts); redirects refused.
13
13
  */
14
- import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign as edSign } from "node:crypto";
14
+ import {
15
+ createHash,
16
+ createPrivateKey,
17
+ createPublicKey,
18
+ generateKeyPairSync,
19
+ randomBytes,
20
+ randomUUID,
21
+ sign as edSign,
22
+ verify as edVerify,
23
+ } from "node:crypto";
24
+ import { hostname, platform } from "node:os";
15
25
  import type { Store } from "./store.js";
16
26
  import {
17
27
  ensureFileMode0600,
@@ -25,8 +35,14 @@ import {
25
35
  type DeviceKeypair,
26
36
  type PairingFile,
27
37
  } from "./pairing-files.js";
28
- import { PAIR_HTTP_MAX_RETRIES, PAIR_HTTP_TIMEOUT_MS, PAIR_OFFER_SKEW_MS, PAIR_POLL_MS } from "../../dpi-sdk/src/protocol/limits.js";
29
38
  import {
39
+ PAIR_HTTP_MAX_RETRIES,
40
+ PAIR_HTTP_TIMEOUT_MS,
41
+ PAIR_OFFER_SKEW_MS,
42
+ PAIR_POLL_MS,
43
+ } from "../../dpi-sdk/src/protocol/limits.js";
44
+ import {
45
+ hubProofSigningPayload,
30
46
  normalizePairCode,
31
47
  pairOfferFragment,
32
48
  pairingSigningPayload,
@@ -38,8 +54,26 @@ import type { TokenIssue } from "../../dpi-sdk/src/protocol/pairing.js";
38
54
 
39
55
  const CLOCK_SKEW_MS = 60_000;
40
56
 
57
+ interface Challenge {
58
+ type: string;
59
+ enrollmentId: string;
60
+ nonce: string;
61
+ issuedAt: number;
62
+ hubId: string;
63
+ hubPublicKey?: string;
64
+ hubKeyFingerprint?: string;
65
+ hubSignature?: string;
66
+ }
67
+
68
+ interface Keypair {
69
+ publicKeyPem: string;
70
+ privateKeyPem: string;
71
+ fingerprint: string;
72
+ }
73
+
41
74
  export interface PairingStartInput {
42
- code: string;
75
+ /** Empty means DEVICE-INITIATED: the device asks the hub for a code. */
76
+ code?: string;
43
77
  hub?: string;
44
78
  pairUrl?: string;
45
79
  force?: boolean;
@@ -68,32 +102,55 @@ interface PendingOp {
68
102
  keyFingerprint: string;
69
103
  proof: { type: "pair.response"; v: 1; enrollmentId: string; nonce: string; signature: string };
70
104
  hubId: string;
105
+ /** Offer-v3 pin carried from the link to issuance + persistence. */
106
+ kfp?: string;
71
107
  expiresAt: number;
72
108
  startedAt: number;
73
109
  abort: AbortController;
74
110
  }
75
111
 
76
- const err = (code: string, message: string) => Object.assign(new Error(message), { code, status: 400 });
112
+ const err = (code: string, message: string) =>
113
+ Object.assign(new Error(message), { code, status: 400 });
77
114
 
78
- const isLoopbackHost = (hostname: string) => hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
79
- const nonEmpty = (v: unknown, max: number): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
115
+ const isLoopbackHost = (hostname: string) =>
116
+ hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
117
+ const nonEmpty = (v: unknown, max: number): v is string =>
118
+ typeof v === "string" && v.length > 0 && v.length <= max;
80
119
 
81
120
  /** Why a hub-issued credential must not be saved, or undefined when it may be. The token leaves this device for hubUrl, so that is judged hardest. */
82
- function refuseIssue(issue: TokenIssue, expect: { origin: string; hubId: string; enrollmentId: string; deviceUid: string; now: number }): string | undefined {
121
+ function refuseIssue(
122
+ issue: TokenIssue,
123
+ expect: {
124
+ origin: string;
125
+ hubId: string;
126
+ enrollmentId: string;
127
+ deviceUid: string;
128
+ now: number;
129
+ kfp?: string;
130
+ },
131
+ ): string | undefined {
83
132
  const raw = issue as unknown as Record<string, unknown>;
84
- if (raw.enrollmentId !== expect.enrollmentId) return "enrollmentId differs from the enrollment this device started";
133
+ if (raw.enrollmentId !== expect.enrollmentId)
134
+ return "enrollmentId differs from the enrollment this device started";
85
135
  if (raw.deviceUid !== expect.deviceUid) return "deviceUid is not this device's";
86
136
  if (raw.hubId !== expect.hubId) return "hubId differs from the hubId the challenge announced";
87
- if (raw.hubOrigin !== expect.origin) return `hubOrigin differs from the origin paired against (${expect.origin})`;
137
+ if (raw.hubOrigin !== expect.origin)
138
+ return `hubOrigin differs from the origin paired against (${expect.origin})`;
88
139
  const urlProblem = refuseTunnelUrl(raw.hubUrl, expect.origin);
89
140
  if (urlProblem) return urlProblem;
90
- if (typeof raw.issuedAt !== "number" || !Number.isFinite(raw.issuedAt)) return "issuedAt is not a finite number";
91
- if (Math.abs(expect.now - raw.issuedAt) > CLOCK_SKEW_MS) return `issuedAt is more than ${CLOCK_SKEW_MS / 1000}s from this device's clock`;
141
+ if (typeof raw.issuedAt !== "number" || !Number.isFinite(raw.issuedAt))
142
+ return "issuedAt is not a finite number";
143
+ if (Math.abs(expect.now - raw.issuedAt) > CLOCK_SKEW_MS)
144
+ return `issuedAt is more than ${CLOCK_SKEW_MS / 1000}s from this device's clock`;
92
145
  if (!nonEmpty(raw.deviceId, 64)) return "deviceId is empty or not a string";
93
146
  if (!nonEmpty(raw.pairingEpoch, 128)) return "pairingEpoch is empty or not a string";
94
147
  if (!nonEmpty(raw.credentialId, 128)) return "credentialId is empty or not a string";
95
- if (typeof raw.generation !== "number" || !Number.isInteger(raw.generation) || raw.generation < 1) return "generation is not a positive integer";
96
- if (typeof raw.tunnelToken !== "string" || !/^dpt_[A-Za-z0-9_-]{43}$/.test(raw.tunnelToken)) return "tunnelToken is malformed";
148
+ if (typeof raw.generation !== "number" || !Number.isInteger(raw.generation) || raw.generation < 1)
149
+ return "generation is not a positive integer";
150
+ if (typeof raw.tunnelToken !== "string" || !/^dpt_[A-Za-z0-9_-]{43}$/.test(raw.tunnelToken))
151
+ return "tunnelToken is malformed";
152
+ if (expect.kfp && raw.hubKeyFingerprint !== expect.kfp)
153
+ return "hubKeyFingerprint differs from the fingerprint pinned in the offer";
97
154
  return undefined;
98
155
  }
99
156
 
@@ -108,10 +165,13 @@ function refuseTunnelUrl(raw: unknown, origin: string): string | undefined {
108
165
  }
109
166
  const tls = url.protocol === "wss:" || url.protocol === "https:";
110
167
  const plain = url.protocol === "ws:" || url.protocol === "http:";
111
- if (!tls && !(plain && isLoopbackHost(url.hostname))) return `hubUrl scheme ${url.protocol} is not allowed (wss or https; ws or http only on a loopback host)`;
168
+ if (!tls && !(plain && isLoopbackHost(url.hostname)))
169
+ return `hubUrl scheme ${url.protocol} is not allowed (wss or https; ws or http only on a loopback host)`;
112
170
  if (url.username || url.password) return "hubUrl must not carry credentials";
113
- if (url.host !== new URL(origin).host) return `hubUrl host differs from the origin paired against (${origin})`;
114
- if (url.pathname !== "/api/tunnel" || url.search || url.hash) return "hubUrl must be exactly the origin's /api/tunnel";
171
+ if (url.host !== new URL(origin).host)
172
+ return `hubUrl host differs from the origin paired against (${origin})`;
173
+ if (url.pathname !== "/api/tunnel" || url.search || url.hash)
174
+ return "hubUrl must be exactly the origin's /api/tunnel";
115
175
  return undefined;
116
176
  }
117
177
 
@@ -126,7 +186,13 @@ export class DevicePairing {
126
186
  private readonly home: string,
127
187
  private readonly deviceName: string,
128
188
  private readonly envHubUrl: string | undefined,
129
- private readonly hooks: { now?: () => number; fetchImpl?: typeof fetch; stopOldConnector?: (origin: string) => void; onPaired?: () => void; pollMs?: number } = {},
189
+ private readonly hooks: {
190
+ now?: () => number;
191
+ fetchImpl?: typeof fetch;
192
+ stopOldConnector?: (origin: string) => void;
193
+ onPaired?: () => void;
194
+ pollMs?: number;
195
+ } = {},
130
196
  ) {
131
197
  ensurePrivateDir(home);
132
198
  removeStaleTemps(home);
@@ -161,7 +227,13 @@ export class DevicePairing {
161
227
 
162
228
  status(): PairingStatus {
163
229
  const base: PairingStatus = {
164
- state: this.pending ? "pairing" : this.revokedNotice ? "revoked" : this.paired ? "paired" : "unpaired",
230
+ state: this.pending
231
+ ? "pairing"
232
+ : this.revokedNotice
233
+ ? "revoked"
234
+ : this.paired
235
+ ? "paired"
236
+ : "unpaired",
165
237
  deviceUid: this.store.uid,
166
238
  deviceName: this.deviceName,
167
239
  fingerprint: this.keypair!.fingerprint,
@@ -174,20 +246,27 @@ export class DevicePairing {
174
246
  }
175
247
  : {}),
176
248
  ...(this.pending
177
- ? { pending: { hubOrigin: this.pending.hubOrigin, startedAt: this.pending.startedAt, note: "polling for approval; cancel stops the local operation only" } }
249
+ ? {
250
+ pending: {
251
+ hubOrigin: this.pending.hubOrigin,
252
+ startedAt: this.pending.startedAt,
253
+ note: "polling for approval; cancel stops the local operation only",
254
+ },
255
+ }
178
256
  : {}),
179
257
  ...(this.lastFailure ? { lastError: this.lastFailure } : {}),
180
258
  };
181
259
  return base;
182
260
  }
183
261
 
184
- /** Resolve the target origin per 03 §4.1 — BEFORE any network call. */
185
- private resolveOrigin(input: PairingStartInput): { origin: string; code: string } {
262
+ /** Resolve the target origin per 03 §4.1 — BEFORE any network call. A v3 offer additionally yields the hub-key pin. */
263
+ private resolveOrigin(input: PairingStartInput): { origin: string; code: string; kfp?: string } {
186
264
  let pairUrlOrigin: string | undefined;
265
+ let kfp: string | undefined;
187
266
  let code = input.code ?? "";
188
267
  if (input.pairUrl) {
189
- // v2 offer link: the whole handoff (hub origin, hubId, code, expiry) rides the
190
- // #offer= fragment — decode it and take the code + origin FROM the offer.
268
+ // v2/v3 offer link: the whole handoff (hub origin, hubId, code, expiry — and, on v3, the pinned
269
+ // hub-key fingerprint) rides the #offer= fragment — decode it and take everything FROM the offer.
191
270
  if (pairOfferFragment(input.pairUrl) !== undefined) {
192
271
  const parsed = parsePairOfferUrl(input.pairUrl, this.now());
193
272
  if (!parsed) {
@@ -195,15 +274,20 @@ export class DevicePairing {
195
274
  const structural = readPairOffer(pairOfferFragment(input.pairUrl)!);
196
275
  if (structural && structural.exp < this.now() - PAIR_OFFER_SKEW_MS)
197
276
  throw err("invalid", "pairing offer expired; mint a fresh code in the hub wizard");
198
- throw err("invalid", "pairUrl is not a valid offer link; mint a fresh one in the hub wizard");
277
+ throw err(
278
+ "invalid",
279
+ "pairUrl is not a valid offer link; mint a fresh one in the hub wizard",
280
+ );
199
281
  }
200
282
  code = parsed.offer.code;
283
+ if (parsed.offer.v === 3) kfp = parsed.offer.kfp;
201
284
  const o = this.originOf(parsed.offer.hub);
202
285
  if (!o.ok) throw err("invalid", o.error);
203
286
  pairUrlOrigin = o.origin;
204
287
  } else {
205
288
  const parsed = this.codeFromPairUrl(input.pairUrl);
206
- if (!parsed) throw err("invalid", "pairUrl must look like https://<hub>/pair?code=<CODE>&v=1");
289
+ if (!parsed)
290
+ throw err("invalid", "pairUrl must look like https://<hub>/pair?code=<CODE>&v=1");
207
291
  code = parsed;
208
292
  const o = this.originOf(input.pairUrl);
209
293
  if (!o.ok) throw err("invalid", o.error);
@@ -213,19 +297,31 @@ export class DevicePairing {
213
297
  // Precedence: pair URL → explicit --hub → DPI_HUB_URL → saved origin. Every
214
298
  // EXPLICIT source must be a valid HTTPS origin (fail on the bad one), then
215
299
  // differing explicit origins are a conflict.
216
- const explicitSources = [pairUrlOrigin, input.hub, this.envHubUrl].filter((x): x is string => !!x);
300
+ const explicitSources = [pairUrlOrigin, input.hub, this.envHubUrl].filter(
301
+ (x): x is string => !!x,
302
+ );
217
303
  const explicitOrigins = explicitSources.map((raw) => {
218
304
  const o = this.originOf(raw);
219
305
  if (!o.ok) throw err("invalid", o.error);
220
306
  return o.origin;
221
307
  });
222
308
  if (new Set(explicitOrigins).size > 1)
223
- throw err("invalid", `Multiple explicit hub origins conflict: ${[...new Set(explicitOrigins)].join(", ")}; pass exactly one`);
309
+ throw err(
310
+ "invalid",
311
+ `Multiple explicit hub origins conflict: ${[...new Set(explicitOrigins)].join(", ")}; pass exactly one`,
312
+ );
224
313
  const origin = explicitOrigins[0] ?? this.paired?.hubOrigin;
225
314
  if (!origin)
226
- throw err("hub_required", "A pairing code alone is not enough: provide --hub, DPI_HUB_URL, a pair URL, or a prior saved hub");
315
+ throw err(
316
+ "hub_required",
317
+ "A pairing code alone is not enough: provide --hub, DPI_HUB_URL, a pair URL, or a prior saved hub",
318
+ );
227
319
  const o = this.originOf(origin);
228
320
  if (!o.ok) throw err("invalid", o.error);
321
+ // Device-initiated: no code yet — the hub mints one and answers with it.
322
+ // Only an explicit hub origin can start this flow; the hub choice is already
323
+ // validated above, so an empty code is legitimate here and nowhere else.
324
+ if (code.trim() === "") return { origin: o.origin, code: "", ...(kfp ? { kfp } : {}) };
229
325
  // A typed code may be grouped/lower case (`abcd-2345-ef67`); the hub only ever sees the canonical form.
230
326
  // `normalizePairCode` is the SDK's exact validator: UUID v4 | legacy 12-char | legacy 8-char base32.
231
327
  const canonical = normalizePairCode(code);
@@ -234,20 +330,35 @@ export class DevicePairing {
234
330
  "invalid",
235
331
  "Pairing code must be a UUID v4, or a legacy 8- or 12-character base32 code (A-Z2-7)",
236
332
  );
237
- return { origin: o.origin, code: canonical };
333
+ return { origin: o.origin, code: canonical, ...(kfp ? { kfp } : {}) };
238
334
  }
239
335
 
240
336
  private codeFromPairUrl(url: string): string | undefined {
241
337
  try {
242
338
  const u = new URL(url);
243
339
  const code = u.searchParams.get("code");
244
- if (!/^\/pair\/?$/.test(u.pathname) || u.username || u.password || u.hash
245
- || u.searchParams.getAll("code").length !== 1
246
- || u.searchParams.getAll("v").length !== 1 || u.searchParams.get("v") !== "1") return undefined;
340
+ if (
341
+ !/^\/pair\/?$/.test(u.pathname) ||
342
+ u.username ||
343
+ u.password ||
344
+ u.hash ||
345
+ u.searchParams.getAll("code").length !== 1 ||
346
+ u.searchParams.getAll("v").length !== 1 ||
347
+ u.searchParams.get("v") !== "1"
348
+ )
349
+ return undefined;
247
350
  if (u.searchParams.has("origin")) {
248
351
  const origin = new URL(u.searchParams.get("origin")!);
249
- if (u.searchParams.getAll("origin").length !== 1 || origin.origin !== u.origin || origin.pathname !== "/"
250
- || origin.username || origin.password || origin.search || origin.hash) return undefined;
352
+ if (
353
+ u.searchParams.getAll("origin").length !== 1 ||
354
+ origin.origin !== u.origin ||
355
+ origin.pathname !== "/" ||
356
+ origin.username ||
357
+ origin.password ||
358
+ origin.search ||
359
+ origin.hash
360
+ )
361
+ return undefined;
251
362
  }
252
363
  return code ? normalizePairCode(code) : undefined;
253
364
  } catch {
@@ -259,12 +370,73 @@ export class DevicePairing {
259
370
  // Accept bare origins and full pair URLs; canonicalize via the shared validator.
260
371
  const candidate = raw.includes("/pair?") || raw.includes("/pair/") ? new URL(raw).origin : raw;
261
372
  const parsed = parseHubOrigin(candidate);
262
- return parsed.ok ? { ok: true, origin: parsed.origin } : { ok: false, error: parsed.error };
373
+ if (parsed.ok) return { ok: true, origin: parsed.origin };
374
+ // Loopback harnesses (local hub + local device on one machine) speak plain
375
+ // HTTP. Only 127.0.0.1/[::1]/localhost qualifies — never a reachable host, so
376
+ // a network hub still has to be HTTPS, exactly as before.
377
+ try {
378
+ const url = new URL(candidate);
379
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname) && !url.username && !url.password)
380
+ return { ok: true, origin: url.origin };
381
+ } catch {
382
+ /* fall through to the validator's error */
383
+ }
384
+ return { ok: false, error: parsed.error };
385
+ }
386
+
387
+ /**
388
+ * Offer v3 hub-proof check (gap #6): the challenge must carry a public key
389
+ * whose SPKI hash EQUALS the offer's pin, plus a valid Ed25519 signature by
390
+ * that key over the canonical payload. Undefined = verified; a string = the
391
+ * refusal reason. Never throws: a bad proof must read as a mismatch.
392
+ */
393
+ private verifyHubProof(
394
+ pinnedKfp: string,
395
+ origin: string,
396
+ challenge: {
397
+ hubId: string;
398
+ enrollmentId: string;
399
+ hubPublicKey?: string;
400
+ hubKeyFingerprint?: string;
401
+ hubSignature?: string;
402
+ },
403
+ nonce: string,
404
+ ): string | undefined {
405
+ if (!challenge.hubPublicKey || !challenge.hubSignature)
406
+ return "the hub answered without an identity proof";
407
+ const payload = hubProofSigningPayload({
408
+ hubOrigin: origin,
409
+ hubId: challenge.hubId,
410
+ subject: `pair:${challenge.enrollmentId}`,
411
+ nonce,
412
+ });
413
+ let fingerprint: string;
414
+ let verified: boolean;
415
+ try {
416
+ const pub = createPublicKey(challenge.hubPublicKey);
417
+ if (pub.asymmetricKeyType !== "ed25519") return "the hub identity key is not Ed25519";
418
+ const der = pub.export({ format: "der", type: "spki" }) as Buffer;
419
+ fingerprint = `sha256:${createHash("sha256").update(der).digest("hex")}`;
420
+ if (challenge.hubKeyFingerprint !== undefined && challenge.hubKeyFingerprint !== fingerprint)
421
+ return "the hub's declared fingerprint does not match the key it sent";
422
+ if (fingerprint !== pinnedKfp)
423
+ return `hub key fingerprint ${fingerprint.slice(0, 19)}… differs from the offer's pin`;
424
+ verified = edVerify(
425
+ null,
426
+ Buffer.from(payload),
427
+ pub,
428
+ Buffer.from(challenge.hubSignature!, "base64url"),
429
+ );
430
+ } catch {
431
+ return "the hub identity proof is malformed";
432
+ }
433
+ return verified ? undefined : "the hub signature does not verify";
263
434
  }
264
435
 
265
436
  /** POST /v1/pairing/start — kick off (or restart) pairing. */
266
437
  async start(input: PairingStartInput): Promise<PairingStatus> {
267
- const { origin, code } = this.resolveOrigin(input);
438
+ const { origin, kfp, code: resolvedCode } = this.resolveOrigin(input);
439
+ let code = resolvedCode;
268
440
 
269
441
  if (this.pending) throw err("busy", "A pairing operation is already running; cancel it first");
270
442
  // A revoked pairing no longer binds the device to its hub.
@@ -280,24 +452,100 @@ export class DevicePairing {
280
452
  const keypair = this.keypair!;
281
453
  const requestId = randomUUID();
282
454
  const deviceUid = this.store.uid;
455
+ // Offer v3: challenge the hub to prove it owns the pinned identity key.
456
+ const hubNonce = kfp ? randomBytes(32).toString("base64url") : undefined;
283
457
 
284
- const challenge = (await this.hubFetch(origin, "/api/pair/start", {
285
- type: "pair.request",
286
- v: 1,
458
+ // Device-initiated enrollment (code-less): the hub mints the code and answers
459
+ // with its challenge in ONE round trip, so the request carries the machine's
460
+ // full identity — the shape the operator approves against.
461
+ const body = {
287
462
  requestId,
288
- code,
289
463
  deviceUid,
290
464
  deviceName: this.deviceName,
465
+ hostname: hostname(),
466
+ platform: `${platform()}-${process.arch} node ${process.version}`,
291
467
  publicKey: keypair.publicKeyPem,
292
468
  keyFingerprint: keypair.fingerprint,
293
469
  clientTime: this.now(),
294
- })) as { type: string; enrollmentId: string; nonce: string; issuedAt: number; hubId: string };
295
- if (challenge.type !== "pair.challenge") throw err("invalid", `Unexpected hub response: ${JSON.stringify(challenge).slice(0, 200)}`);
296
- if (typeof challenge.issuedAt !== "number" || !Number.isFinite(challenge.issuedAt)) throw err("invalid", "Challenge issuedAt is not a finite number");
297
- if (!nonEmpty(challenge.hubId, 64) || !nonEmpty(challenge.enrollmentId, 64)) throw err("invalid", "Challenge is missing hubId or enrollmentId");
470
+ ...(hubNonce ? { hubNonce } : {}),
471
+ };
472
+ if (!code) {
473
+ const ack = (await this.hubFetch(origin, "/api/pair/request", {
474
+ type: "pair.device-request",
475
+ v: 1,
476
+ ...body,
477
+ })) as { code?: string; challenge?: unknown };
478
+ if (typeof ack.code !== "string" || !ack.challenge)
479
+ throw err("invalid", `Unexpected hub response: ${JSON.stringify(ack).slice(0, 200)}`);
480
+ code = ack.code;
481
+ return this.completeChallenge(
482
+ origin,
483
+ ack.challenge as Challenge,
484
+ code,
485
+ kfp,
486
+ hubNonce,
487
+ operationId,
488
+ requestId,
489
+ deviceUid,
490
+ keypair,
491
+ );
492
+ }
493
+ const challenge = (await this.hubFetch(origin, "/api/pair/start", {
494
+ type: "pair.request",
495
+ v: 1,
496
+ ...body,
497
+ code,
498
+ })) as Challenge;
499
+ return this.completeChallenge(
500
+ origin,
501
+ challenge,
502
+ code,
503
+ kfp,
504
+ hubNonce,
505
+ operationId,
506
+ requestId,
507
+ deviceUid,
508
+ keypair,
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Shared tail of BOTH enrollment directions (operator-minted code and
514
+ * device-initiated): validate the challenge, verify the pinned hub proof,
515
+ * sign, and start polling for the issuance.
516
+ */
517
+ private completeChallenge(
518
+ origin: string,
519
+ challenge: Challenge,
520
+ code: string,
521
+ kfp: string | undefined,
522
+ hubNonce: string | undefined,
523
+ operationId: string,
524
+ requestId: string,
525
+ deviceUid: string,
526
+ keypair: Keypair,
527
+ ): PairingStatus {
528
+ if (challenge.type !== "pair.challenge")
529
+ throw err("invalid", `Unexpected hub response: ${JSON.stringify(challenge).slice(0, 200)}`);
530
+ if (typeof challenge.issuedAt !== "number" || !Number.isFinite(challenge.issuedAt))
531
+ throw err("invalid", "Challenge issuedAt is not a finite number");
532
+ if (!nonEmpty(challenge.hubId, 64) || !nonEmpty(challenge.enrollmentId, 64))
533
+ throw err("invalid", "Challenge is missing hubId or enrollmentId");
298
534
  // P-4: device rejects a challenge skewed more than ±60s from its clock.
299
535
  if (Math.abs(this.now() - challenge.issuedAt) > CLOCK_SKEW_MS)
300
- throw err("clock_skew", `Hub clock differs by more than ${CLOCK_SKEW_MS / 1000}s; fix NTP and retry`);
536
+ throw err(
537
+ "clock_skew",
538
+ `Hub clock differs by more than ${CLOCK_SKEW_MS / 1000}s; fix NTP and retry`,
539
+ );
540
+ // Offer v3: verify the hub's identity proof BEFORE signing its challenge.
541
+ if (kfp && hubNonce) {
542
+ const problem = this.verifyHubProof(kfp, origin, challenge, hubNonce);
543
+ if (problem)
544
+ throw err(
545
+ "hub_proof_failed",
546
+ `${problem}; the credential flow was not started — this hub is not the one the offer pins`,
547
+ );
548
+ }
301
549
 
302
550
  const payload = pairingSigningPayload({
303
551
  hubOrigin: origin,
@@ -308,8 +556,18 @@ export class DevicePairing {
308
556
  deviceUid,
309
557
  keyFingerprint: keypair.fingerprint,
310
558
  });
311
- const signature = edSign(null, Buffer.from(payload), createPrivateKey(keypair.privateKeyPem)).toString("base64url");
312
- const proof = { type: "pair.response" as const, v: 1 as const, enrollmentId: challenge.enrollmentId, nonce: challenge.nonce, signature };
559
+ const signature = edSign(
560
+ null,
561
+ Buffer.from(payload),
562
+ createPrivateKey(keypair.privateKeyPem),
563
+ ).toString("base64url");
564
+ const proof = {
565
+ type: "pair.response" as const,
566
+ v: 1 as const,
567
+ enrollmentId: challenge.enrollmentId,
568
+ nonce: challenge.nonce,
569
+ signature,
570
+ };
313
571
 
314
572
  const pending: PendingOp = {
315
573
  operationId,
@@ -321,6 +579,7 @@ export class DevicePairing {
321
579
  keyFingerprint: keypair.fingerprint,
322
580
  proof,
323
581
  hubId: challenge.hubId,
582
+ ...(kfp ? { kfp } : {}),
324
583
  expiresAt: this.now() + 300_000,
325
584
  startedAt: this.now(),
326
585
  abort: new AbortController(),
@@ -333,16 +592,36 @@ export class DevicePairing {
333
592
  /** Poll the same signed proof until approval issues a TokenIssue (or expiry). */
334
593
  private async pollLoop(pending: PendingOp): Promise<void> {
335
594
  const pollMs = this.hooks.pollMs ?? PAIR_POLL_MS;
336
- while (!pending.abort.signal.aborted && this.now() < pending.expiresAt && this.pending?.operationId === pending.operationId) {
337
- const outcome = await this.hubFetchRaw(pending.hubOrigin, "/api/pair/verify", pending.proof, pending.abort.signal).catch(() => null);
595
+ while (
596
+ !pending.abort.signal.aborted &&
597
+ this.now() < pending.expiresAt &&
598
+ this.pending?.operationId === pending.operationId
599
+ ) {
600
+ const outcome = await this.hubFetchRaw(
601
+ pending.hubOrigin,
602
+ "/api/pair/verify",
603
+ pending.proof,
604
+ pending.abort.signal,
605
+ ).catch(() => null);
338
606
  if (this.pending?.operationId !== pending.operationId) return; // superseded
339
607
  if (!outcome) {
340
608
  await sleep(pollMs * 2, pending.abort.signal);
341
609
  continue;
342
610
  }
343
- if (outcome.status === 200 && outcome.body && (outcome.body as TokenIssue).type === "pair.token") {
611
+ if (
612
+ outcome.status === 200 &&
613
+ outcome.body &&
614
+ (outcome.body as TokenIssue).type === "pair.token"
615
+ ) {
344
616
  const issue = outcome.body as TokenIssue;
345
- const refusal = refuseIssue(issue, { origin: pending.hubOrigin, hubId: pending.hubId, enrollmentId: pending.proof.enrollmentId, deviceUid: pending.deviceUid, now: this.now() });
617
+ const refusal = refuseIssue(issue, {
618
+ origin: pending.hubOrigin,
619
+ hubId: pending.hubId,
620
+ enrollmentId: pending.proof.enrollmentId,
621
+ deviceUid: pending.deviceUid,
622
+ now: this.now(),
623
+ ...(pending.kfp ? { kfp: pending.kfp } : {}),
624
+ });
346
625
  if (refusal) {
347
626
  this.pending = undefined;
348
627
  this.lastFailure = `invalid_token_issue: ${refusal}; the credential was not saved`;
@@ -355,9 +634,15 @@ export class DevicePairing {
355
634
  await sleep(pollMs, pending.abort.signal);
356
635
  continue;
357
636
  }
358
- const code = (outcome.body as { error?: { code?: string; message?: string } })?.error?.code ?? "invalid";
359
- const message = (outcome.body as { error?: { message?: string } })?.error?.message ?? "pairing failed";
360
- if (["code_expired", "enrollment_cancelled", "enrollment_lost", "challenge_expired"].includes(code)) {
637
+ const code =
638
+ (outcome.body as { error?: { code?: string; message?: string } })?.error?.code ?? "invalid";
639
+ const message =
640
+ (outcome.body as { error?: { message?: string } })?.error?.message ?? "pairing failed";
641
+ if (
642
+ ["code_expired", "enrollment_cancelled", "enrollment_lost", "challenge_expired"].includes(
643
+ code,
644
+ )
645
+ ) {
361
646
  this.pending = undefined;
362
647
  this.lastFailure = `${code}: ${message}`;
363
648
  return;
@@ -391,6 +676,9 @@ export class DevicePairing {
391
676
  tunnelToken: issue.tunnelToken,
392
677
  updatedAt: this.now(),
393
678
  operationId: pending.operationId,
679
+ // Gap #6: pin ONLY what the offer carried — an issuance-declared value was
680
+ // never authenticated (the proof was checked at the challenge, not here).
681
+ ...(pending.kfp ? { hubKeyFingerprint: pending.kfp } : {}),
394
682
  };
395
683
  writeAtomic(pairingPath(this.home), JSON.stringify(file, null, 2));
396
684
  // Re-check: a newer operation/pairing must not be clobbered by this commit.
@@ -430,7 +718,8 @@ export class DevicePairing {
430
718
 
431
719
  /** The connector learned the hub revoked this pairing (and removed the file). A notice for another epoch is about a pairing we no longer hold. */
432
720
  markRevoked(reason: string, pairingEpoch?: string): void {
433
- if (pairingEpoch !== undefined && this.paired && this.paired.pairingEpoch !== pairingEpoch) return;
721
+ if (pairingEpoch !== undefined && this.paired && this.paired.pairingEpoch !== pairingEpoch)
722
+ return;
434
723
  this.revokedNotice = true;
435
724
  this.lastFailure = reason;
436
725
  }
@@ -438,21 +727,36 @@ export class DevicePairing {
438
727
  /** The connector persisted a rotated credential for this pairing; only a strictly newer generation of the same epoch counts. */
439
728
  markRotated(file: PairingFile): void {
440
729
  if (!this.paired || this.revokedNotice) return;
441
- if (file.pairingEpoch !== this.paired.pairingEpoch || file.generation <= this.paired.generation) return;
730
+ if (file.pairingEpoch !== this.paired.pairingEpoch || file.generation <= this.paired.generation)
731
+ return;
442
732
  this.paired = file;
443
733
  }
444
734
 
445
735
  private async bestEffortUnpair(paired: PairingFile): Promise<void> {
446
736
  const ctl = new AbortController();
447
737
  setTimeout(() => ctl.abort(), 2_000).unref?.();
448
- await this.hubFetchRaw(paired.hubOrigin, "/api/tunnel", { type: "control", action: "unpair", pairingEpoch: paired.pairingEpoch, reason: "hub_migrated" }, ctl.signal).catch(() => undefined);
738
+ await this.hubFetchRaw(
739
+ paired.hubOrigin,
740
+ "/api/tunnel",
741
+ {
742
+ type: "control",
743
+ action: "unpair",
744
+ pairingEpoch: paired.pairingEpoch,
745
+ reason: "hub_migrated",
746
+ },
747
+ ctl.signal,
748
+ ).catch(() => undefined);
449
749
  }
450
750
 
451
751
  private async hubFetch<T = unknown>(origin: string, path: string, body: unknown): Promise<T> {
452
752
  for (let attempt = 0; attempt < PAIR_HTTP_MAX_RETRIES; attempt++) {
453
753
  const outcome = await this.hubFetchRaw(origin, path, body).catch(() => null);
454
754
  if (outcome && outcome.status < 500 && outcome.status !== 429) {
455
- if (outcome.status >= 400) throw err("invalid", `Hub rejected ${path}: ${JSON.stringify(outcome.body).slice(0, 200)}`);
755
+ if (outcome.status >= 400)
756
+ throw err(
757
+ "invalid",
758
+ `Hub rejected ${path}: ${JSON.stringify(outcome.body).slice(0, 200)}`,
759
+ );
456
760
  return outcome.body as T;
457
761
  }
458
762
  await sleep(500 * (attempt + 1));
@@ -460,7 +764,12 @@ export class DevicePairing {
460
764
  throw err("offline", `Hub unreachable after ${PAIR_HTTP_MAX_RETRIES} attempts`);
461
765
  }
462
766
 
463
- private async hubFetchRaw(origin: string, path: string, body: unknown, signal?: AbortSignal): Promise<{ status: number; body: unknown }> {
767
+ private async hubFetchRaw(
768
+ origin: string,
769
+ path: string,
770
+ body: unknown,
771
+ signal?: AbortSignal,
772
+ ): Promise<{ status: number; body: unknown }> {
464
773
  const fetchImpl = this.hooks.fetchImpl ?? fetch;
465
774
  const timeout = AbortSignal.timeout(PAIR_HTTP_TIMEOUT_MS);
466
775
  const effective = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -24,9 +24,22 @@ const int = (value: unknown, name: string) => {
24
24
  return n;
25
25
  };
26
26
 
27
- export function v1(store: Store, pairing?: DevicePairing, connector?: { status(): unknown }) {
27
+ export function v1(
28
+ store: Store,
29
+ pairing?: DevicePairing,
30
+ connector?: { status(): unknown },
31
+ stop?: () => void,
32
+ ) {
28
33
  const r = Router();
29
34
  const create = idempotent(store);
35
+ // `dpi device stop`: graceful local shutdown (token-authed like every /v1
36
+ // route). The response flushes BEFORE the daemon tears down so the caller
37
+ // always sees 200 instead of a dropped connection.
38
+ if (stop)
39
+ r.post("/shutdown", (_req, res) => {
40
+ res.json({ ok: true });
41
+ setImmediate(stop);
42
+ });
30
43
 
31
44
  // Local pairing control point (03 §7): the TUI never touches crypto or the
32
45
  // pairing files directly — everything flows through these token-authed routes.