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
@@ -36,7 +36,11 @@ const DeviceId = Type.String({
36
36
  });
37
37
  const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
38
38
  const Nonce = Type.String({ pattern: "^[A-Za-z0-9_-]{43}$", minLength: 43, maxLength: 43 });
39
- const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
39
+ const TunnelToken = Type.String({
40
+ pattern: "^dpt_[A-Za-z0-9_-]{43}$",
41
+ minLength: 47,
42
+ maxLength: 47,
43
+ });
40
44
  /**
41
45
  * Canonical lower-case UUID v4: any hex is NOT a code, so the version nibble must be 4 and the
42
46
  * variant nibble 8/9/a/b.
@@ -57,7 +61,8 @@ const PairCode = Type.String({
57
61
 
58
62
  const PAIR_CODE_RE = new RegExp(PAIR_CODE_PATTERN);
59
63
  /** Shape test for the UUID family — used to pick the normalisation rule, never to accept on its own. */
60
- const UUID_SHAPED_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
64
+ const UUID_SHAPED_RE =
65
+ /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
61
66
 
62
67
  /**
63
68
  * Exact-shape check for a pairing code, with NO trimming: a value with a leading/trailing space
@@ -97,9 +102,45 @@ export const PairRequest = Type.Object(
97
102
  code: PairCode,
98
103
  deviceUid: Uuid,
99
104
  deviceName: Type.String({ minLength: 1, maxLength: 64 }),
105
+ /** Machine description the operator sees BEFORE approving (like orca/paseo offers). */
106
+ hostname: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
107
+ platform: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
100
108
  publicKey: Type.String({ minLength: 1, maxLength: 256 }), // Ed25519 SPKI PEM
101
109
  keyFingerprint: Fingerprint,
102
110
  clientTime: Type.Integer(),
111
+ /**
112
+ * Hub-proof challenge (offer v3): present only when the offer pinned a hub
113
+ * key fingerprint. The hub answers with its public key + a signature over
114
+ * hubProofSigningPayload(...subject: enrollmentId) so the device can check
115
+ * the responder owns the pinned key BEFORE any credential is accepted.
116
+ * Optional keeps v1 wire shape: only v3-offer flows ever set it.
117
+ */
118
+ hubNonce: Type.Optional(Nonce),
119
+ },
120
+ Strict,
121
+ );
122
+
123
+ /**
124
+ * Device-INITIATED enrollment (the shape orca/paseo use): the machine asks the
125
+ * hub for a code instead of waiting for an operator-minted one, so the operator
126
+ * never has to carry a secret across machines. The tuple is still the full
127
+ * identity — name, hostname, platform, public key — and the hub still computes
128
+ * the fingerprint itself and still requires a human approval.
129
+ */
130
+ export const PairDeviceRequest = Type.Object(
131
+ {
132
+ type: Type.Literal("pair.device-request"),
133
+ v: Type.Literal(1),
134
+ requestId: Uuid,
135
+ deviceUid: Uuid,
136
+ deviceName: Type.String({ minLength: 1, maxLength: 64 }),
137
+ hostname: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
138
+ platform: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
139
+ publicKey: Type.String({ minLength: 1, maxLength: 256 }),
140
+ keyFingerprint: Fingerprint,
141
+ clientTime: Type.Integer(),
142
+ /** Offer-v3 hub pinning, exactly as the operator-minted flow carries it. */
143
+ hubNonce: Type.Optional(Nonce),
103
144
  },
104
145
  Strict,
105
146
  );
@@ -112,6 +153,28 @@ export const PairChallenge = Type.Object(
112
153
  nonce: Nonce,
113
154
  issuedAt: Type.Integer(),
114
155
  hubId: Uuid, // deployment ID, not a cryptographic fingerprint
156
+ /** Present iff the request carried hubNonce (offer v3 pinning). */
157
+ hubPublicKey: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), // Ed25519 SPKI PEM
158
+ hubKeyFingerprint: Type.Optional(Fingerprint),
159
+ /** Ed25519 over hubProofSigningPayload({..., subject: enrollmentId, nonce: request hubNonce}). */
160
+ hubSignature: Type.Optional(
161
+ Type.String({ pattern: "^[A-Za-z0-9_-]{86}$", minLength: 86, maxLength: 86 }),
162
+ ),
163
+ },
164
+ Strict,
165
+ );
166
+
167
+ /** The hub's answer to a device-initiated request: the code it minted, plus the challenge. */
168
+ export const PairDeviceRequestAck = Type.Object(
169
+ {
170
+ type: Type.Literal("pair.device-ack"),
171
+ v: Type.Literal(1),
172
+ codeId: Uuid,
173
+ code: PairCode,
174
+ expiresAt: Type.Integer(),
175
+ hubOrigin: Type.String(),
176
+ hubId: Uuid,
177
+ challenge: PairChallenge,
115
178
  },
116
179
  Strict,
117
180
  );
@@ -155,6 +218,8 @@ export const TokenIssue = Type.Object(
155
218
  tunnelToken: TunnelToken,
156
219
  issuedAt: Type.Integer(),
157
220
  expiresAt: Type.Null(),
221
+ /** Present for offer-v3 pairings: the pinned hub identity (same value the offer carried). */
222
+ hubKeyFingerprint: Type.Optional(Fingerprint),
158
223
  },
159
224
  Strict,
160
225
  );
@@ -287,6 +352,8 @@ export const PAIRING_DTOS = [
287
352
  PairError,
288
353
  PairCodeIssue,
289
354
  PairCodeState,
355
+ PairDeviceRequest,
356
+ PairDeviceRequestAck,
290
357
  ] as const;
291
358
 
292
359
  export function checkPairingDto<T extends (typeof PAIRING_DTOS)[number]>(
@@ -337,7 +404,7 @@ export function parseHubOrigin(
337
404
  * code cannot reach server/CDN query logs, and the origin travels inside the
338
405
  * link (no --hub needed on the device).
339
406
  */
340
- export interface PairOffer {
407
+ export interface PairOfferV2 {
341
408
  v: 2;
342
409
  /** Canonical HTTPS hub origin (the transport the device pairs against). */
343
410
  hub: string;
@@ -349,6 +416,20 @@ export interface PairOffer {
349
416
  exp: number;
350
417
  }
351
418
 
419
+ /**
420
+ * v3 offer — v2 plus `kfp`: the sha256-of-SPKI fingerprint of the hub's
421
+ * persistent Ed25519 identity key. The device pins it: the hub must PROVE
422
+ * possession of that exact key during the ceremony (and on every tunnel
423
+ * connect), so a hub swap is detectable at the device even with TLS intact.
424
+ */
425
+ export interface PairOfferV3 extends Omit<PairOfferV2, "v"> {
426
+ v: 3;
427
+ /** Pinned hub identity: `sha256:<64 hex>` of the hub key's SPKI DER. */
428
+ kfp: string;
429
+ }
430
+
431
+ export type PairOffer = PairOfferV2 | PairOfferV3;
432
+
352
433
  /** base64url (no padding) of UTF-8 bytes — browser-safe, no Node builtins. */
353
434
  function offerEncode(bytes: Uint8Array): string {
354
435
  let binary = "";
@@ -374,24 +455,34 @@ const OFFER_CODE_RE = new RegExp(`^${UUID_V4}$`);
374
455
 
375
456
  /**
376
457
  * Encode an offer to the exact fragment payload: canonical JSON (field order
377
- * v,hub,id,code,exp — byte-lean and deterministic), base64url, no padding.
458
+ * v,hub,id,code,exp[,kfp] — byte-lean and deterministic), base64url, no padding.
378
459
  * Pure encoder: it does not validate; callers mint from live hub values.
379
460
  */
380
461
  export function encodePairOffer(offer: PairOffer): string {
381
- return offerEncode(
382
- new TextEncoder().encode(
383
- JSON.stringify({ v: offer.v, hub: offer.hub, id: offer.id, code: offer.code, exp: offer.exp }),
384
- ),
385
- );
462
+ const json =
463
+ offer.v === 3
464
+ ? {
465
+ v: offer.v,
466
+ hub: offer.hub,
467
+ id: offer.id,
468
+ code: offer.code,
469
+ exp: offer.exp,
470
+ kfp: offer.kfp,
471
+ }
472
+ : { v: offer.v, hub: offer.hub, id: offer.id, code: offer.code, exp: offer.exp };
473
+ return offerEncode(new TextEncoder().encode(JSON.stringify(json)));
386
474
  }
387
475
 
388
476
  /**
389
- * Structural decode (freshness-blind): exactly the five known fields, v===2,
390
- * canonical HTTPS origin (byte-exact, reusing the parseHubOrigin rules),
391
- * canonical UUIDs for id and code, safe-integer exp. Unknown or malformed
392
- * input → undefined, never a throw. Exported so a caller can tell an EXPIRED
393
- * offer apart from junk (decodePairOffer refuses both).
477
+ * Structural decode (freshness-blind): exactly the known fields for the
478
+ * declared version — five for v2, six for v3 (`kfp` must be a canonical
479
+ * `sha256:<64 hex>`) — canonical HTTPS origin (byte-exact, reusing the
480
+ * parseHubOrigin rules), canonical UUIDs for id and code, safe-integer exp.
481
+ * Unknown or malformed input → undefined, never a throw. Exported so a
482
+ * caller can tell an EXPIRED offer apart from junk (decodePairOffer refuses
483
+ * both).
394
484
  */
485
+ const OFFER_KFP_RE = /^sha256:[0-9a-f]{64}$/;
395
486
  export function readPairOffer(raw: string): PairOffer | undefined {
396
487
  const bytes = offerDecode(raw);
397
488
  if (!bytes || bytes.length > PAIR_OFFER_MAX_JSON_BYTES) return undefined;
@@ -403,13 +494,19 @@ export function readPairOffer(raw: string): PairOffer | undefined {
403
494
  }
404
495
  if (!json || typeof json !== "object" || Array.isArray(json)) return undefined;
405
496
  const o = json as Record<string, unknown>;
406
- if (Object.keys(o).length !== 5 || o.v !== 2) return undefined; // unknown/missing fields refuse
497
+ if (o.v !== 2 && o.v !== 3) return undefined;
498
+ const fieldCount = o.v === 3 ? 6 : 5;
499
+ if (Object.keys(o).length !== fieldCount) return undefined; // unknown/missing fields refuse
407
500
  if (typeof o.hub !== "string") return undefined;
408
501
  const origin = parseHubOrigin(o.hub);
409
502
  if (!origin.ok || origin.origin !== o.hub) return undefined; // canonical form, byte-exact
410
503
  if (typeof o.id !== "string" || !OFFER_UUID_RE.test(o.id)) return undefined;
411
504
  if (typeof o.code !== "string" || !OFFER_CODE_RE.test(o.code)) return undefined;
412
505
  if (typeof o.exp !== "number" || !Number.isSafeInteger(o.exp)) return undefined;
506
+ if (o.v === 3) {
507
+ if (typeof o.kfp !== "string" || !OFFER_KFP_RE.test(o.kfp)) return undefined;
508
+ return { v: 3, hub: o.hub, id: o.id, code: o.code, exp: o.exp, kfp: o.kfp };
509
+ }
413
510
  return { v: 2, hub: o.hub, id: o.id, code: o.code, exp: o.exp };
414
511
  }
415
512
 
@@ -455,7 +552,10 @@ export function pairOfferFragment(url: string): string | undefined {
455
552
  * undefined. The offer's hub must equal the link's own origin (the hub minted
456
553
  * this link); the fragment never travels server-side.
457
554
  */
458
- export function parsePairOfferUrl(url: string, now: number = Date.now()): ParsedPairOfferUrl | undefined {
555
+ export function parsePairOfferUrl(
556
+ url: string,
557
+ now: number = Date.now(),
558
+ ): ParsedPairOfferUrl | undefined {
459
559
  let u: URL;
460
560
  try {
461
561
  u = new URL(url);
@@ -492,6 +592,27 @@ export function pairingSigningPayload(parts: {
492
592
  ]);
493
593
  }
494
594
 
595
+ /**
596
+ * Exact Ed25519 payload the HUB signs to prove its identity key (offer v3).
597
+ * `subject` binds the proof to one ceremony ("pair:<enrollmentId>") or one
598
+ * tunnel session ("tunnel:<deviceId>:<pairingEpoch>") so a captured proof
599
+ * cannot be replayed elsewhere. Element order is normative.
600
+ */
601
+ export function hubProofSigningPayload(parts: {
602
+ hubOrigin: string;
603
+ hubId: string;
604
+ subject: string;
605
+ nonce: string;
606
+ }): string {
607
+ return JSON.stringify([
608
+ "dpi-hub-proof-v1",
609
+ parts.hubOrigin,
610
+ parts.hubId,
611
+ parts.subject,
612
+ parts.nonce,
613
+ ]);
614
+ }
615
+
495
616
  /**
496
617
  * Base64url byte-length validation for credential-bearing strings read from
497
618
  * files/env (which may carry a trailing newline). Protocol contexts use the
@@ -26,7 +26,12 @@ const DeviceId = Type.String({
26
26
  maxLength: 32,
27
27
  });
28
28
  const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
29
- const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
29
+ const TunnelToken = Type.String({
30
+ pattern: "^dpt_[A-Za-z0-9_-]{43}$",
31
+ minLength: 47,
32
+ maxLength: 47,
33
+ });
34
+ const Nonce = Type.String({ pattern: "^[A-Za-z0-9_-]{43}$", minLength: 43, maxLength: 43 });
30
35
  const SubscriptionFields = {
31
36
  subscriptionId: Uuid,
32
37
  sub: Type.String({ minLength: 1, maxLength: 512 }),
@@ -60,37 +65,94 @@ export type TunnelAuthError = Static<typeof TunnelAuthError>;
60
65
  * models/api; the tunnel validates it only as a JSON object with a known
61
66
  * discriminator to avoid duplicating the full grammar here.
62
67
  */
63
- const FramePayload = Type.Object({
64
- type: Type.Union([
65
- Type.Literal("hello"), Type.Literal("fault"), Type.Literal("tree"), Type.Literal("activity"),
66
- Type.Literal("session"), Type.Literal("agents"), Type.Literal("tasks"), Type.Literal("usage"),
67
- Type.Literal("snapshot"), Type.Literal("batch"), Type.Literal("inbox"), Type.Literal("doc"),
68
- ]),
69
- }, { additionalProperties: true });
68
+ const FramePayload = Type.Object(
69
+ {
70
+ type: Type.Union([
71
+ Type.Literal("hello"),
72
+ Type.Literal("fault"),
73
+ Type.Literal("tree"),
74
+ Type.Literal("activity"),
75
+ Type.Literal("session"),
76
+ Type.Literal("agents"),
77
+ Type.Literal("tasks"),
78
+ Type.Literal("usage"),
79
+ Type.Literal("snapshot"),
80
+ Type.Literal("batch"),
81
+ Type.Literal("inbox"),
82
+ Type.Literal("doc"),
83
+ ]),
84
+ },
85
+ { additionalProperties: true },
86
+ );
70
87
 
71
88
  /** Hub mux control frames must never be accepted from a device, even after chunk reassembly. */
72
- export function checkDeviceFrame(value: unknown): value is Exclude<Frame, { type: "network" | "disconnected" }> {
89
+ export function checkDeviceFrame(
90
+ value: unknown,
91
+ ): value is Exclude<Frame, { type: "network" | "disconnected" }> {
73
92
  return Value.Check(FramePayload, value);
74
93
  }
75
94
 
76
95
  const TunnelControl = Type.Union([
77
- Type.Object({ type: Type.Literal("control"), action: Type.Literal("subscribe"), ...SubscriptionFields }, Strict),
78
- Type.Object({ type: Type.Literal("control"), action: Type.Literal("unsubscribe"), ...SubscriptionFields }, Strict),
79
- Type.Object({ type: Type.Literal("control"), action: Type.Literal("revoked"), notice: Type.Object({
80
- type: Type.Literal("pair.revoke"), v: Type.Literal(1), deviceId: DeviceId, pairingEpoch: Uuid,
81
- reason: Type.Union([Type.Literal("user"), Type.Literal("compromise"), Type.Literal("hub_migrated")]),
82
- revokedAt: Type.Integer(),
83
- }, Strict) }, Strict),
84
96
  Type.Object(
85
- { type: Type.Literal("control"), action: Type.Literal("token-rotate"), tunnelToken: TunnelToken, ...RotationFields },
97
+ { type: Type.Literal("control"), action: Type.Literal("subscribe"), ...SubscriptionFields },
98
+ Strict,
99
+ ),
100
+ Type.Object(
101
+ { type: Type.Literal("control"), action: Type.Literal("unsubscribe"), ...SubscriptionFields },
102
+ Strict,
103
+ ),
104
+ Type.Object(
105
+ {
106
+ type: Type.Literal("control"),
107
+ action: Type.Literal("revoked"),
108
+ notice: Type.Object(
109
+ {
110
+ type: Type.Literal("pair.revoke"),
111
+ v: Type.Literal(1),
112
+ deviceId: DeviceId,
113
+ pairingEpoch: Uuid,
114
+ reason: Type.Union([
115
+ Type.Literal("user"),
116
+ Type.Literal("compromise"),
117
+ Type.Literal("hub_migrated"),
118
+ ]),
119
+ revokedAt: Type.Integer(),
120
+ },
121
+ Strict,
122
+ ),
123
+ },
124
+ Strict,
125
+ ),
126
+ Type.Object(
127
+ {
128
+ type: Type.Literal("control"),
129
+ action: Type.Literal("token-rotate"),
130
+ tunnelToken: TunnelToken,
131
+ ...RotationFields,
132
+ },
133
+ Strict,
134
+ ),
135
+ Type.Object(
136
+ { type: Type.Literal("control"), action: Type.Literal("token-rotate-ack"), ...RotationFields },
137
+ Strict,
138
+ ),
139
+ Type.Object(
140
+ {
141
+ type: Type.Literal("control"),
142
+ action: Type.Literal("unpair"),
143
+ pairingEpoch: Uuid,
144
+ reason: Type.Literal("hub_migrated"),
145
+ },
146
+ Strict,
147
+ ),
148
+ Type.Object(
149
+ {
150
+ type: Type.Literal("control"),
151
+ action: Type.Literal("close"),
152
+ reason: Type.String({ maxLength: 512 }),
153
+ },
86
154
  Strict,
87
155
  ),
88
- Type.Object({ type: Type.Literal("control"), action: Type.Literal("token-rotate-ack"), ...RotationFields }, Strict),
89
- Type.Object({
90
- type: Type.Literal("control"), action: Type.Literal("unpair"),
91
- pairingEpoch: Uuid, reason: Type.Literal("hub_migrated"),
92
- }, Strict),
93
- Type.Object({ type: Type.Literal("control"), action: Type.Literal("close"), reason: Type.String({ maxLength: 512 }) }, Strict),
94
156
  ]);
95
157
 
96
158
  export const TunnelFrame = Type.Union([
@@ -106,6 +168,12 @@ export const TunnelFrame = Type.Union([
106
168
  apiVersion: Type.Literal(1),
107
169
  tunnelProto: Type.Literal(1),
108
170
  keyFingerprint: Fingerprint,
171
+ /**
172
+ * Hub-proof challenge (pinned pairings): the hub's `hello` must answer
173
+ * with proof of the pinned identity key over this nonce. Optional keeps
174
+ * the v1 wire shape for pre-pinning devices.
175
+ */
176
+ proofNonce: Type.Optional(Nonce),
109
177
  },
110
178
  Strict,
111
179
  ),
@@ -117,15 +185,30 @@ export const TunnelFrame = Type.Union([
117
185
  hubId: Uuid,
118
186
  connectionId: Uuid,
119
187
  serverTime: Type.Integer(),
188
+ /** Present iff the handshake carried proofNonce (hub identity proof). */
189
+ hubPublicKey: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
190
+ hubKeyFingerprint: Type.Optional(Fingerprint),
191
+ hubSignature: Type.Optional(
192
+ Type.String({ pattern: "^[A-Za-z0-9_-]{86}$", minLength: 86, maxLength: 86 }),
193
+ ),
120
194
  },
121
195
  Strict,
122
196
  ),
123
- Type.Object({ type: Type.Literal("tunnel-data"), frame: FramePayload, ...SubscriptionFields }, Strict),
197
+ Type.Object(
198
+ { type: Type.Literal("tunnel-data"), frame: FramePayload, ...SubscriptionFields },
199
+ Strict,
200
+ ),
124
201
  Type.Object(
125
202
  {
126
203
  type: Type.Literal("rest-request"),
127
204
  reqId: Uuid,
128
- method: Type.Union([Type.Literal("GET"), Type.Literal("POST"), Type.Literal("PUT"), Type.Literal("PATCH"), Type.Literal("DELETE")]),
205
+ method: Type.Union([
206
+ Type.Literal("GET"),
207
+ Type.Literal("POST"),
208
+ Type.Literal("PUT"),
209
+ Type.Literal("PATCH"),
210
+ Type.Literal("DELETE"),
211
+ ]),
129
212
  path: Type.String({ minLength: 1, maxLength: 2048 }),
130
213
  idempotencyKey: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
131
214
  body: Type.Optional(Type.Unknown()),
@@ -146,7 +229,10 @@ export const TunnelFrame = Type.Union([
146
229
  Type.Object(
147
230
  {
148
231
  type: Type.Literal("fault"),
149
- error: Type.Object({ code: Type.String({ maxLength: 64 }), message: Type.String({ maxLength: 512 }) }, Strict),
232
+ error: Type.Object(
233
+ { code: Type.String({ maxLength: 64 }), message: Type.String({ maxLength: 512 }) },
234
+ Strict,
235
+ ),
150
236
  subscriptionId: Type.Optional(Uuid),
151
237
  reqId: Type.Optional(Uuid),
152
238
  },
@@ -167,7 +253,14 @@ const ChunkBase = {
167
253
 
168
254
  const TunnelChunk = Type.Union([
169
255
  Type.Object({ ...ChunkBase, kind: Type.Literal("tunnel-data"), ...SubscriptionFields }, Strict),
170
- Type.Object({ ...ChunkBase, kind: Type.Union([Type.Literal("rest-request"), Type.Literal("rest-response")]), reqId: Uuid }, Strict),
256
+ Type.Object(
257
+ {
258
+ ...ChunkBase,
259
+ kind: Type.Union([Type.Literal("rest-request"), Type.Literal("rest-response")]),
260
+ reqId: Uuid,
261
+ },
262
+ Strict,
263
+ ),
171
264
  ]);
172
265
  export type TunnelChunk = Static<typeof TunnelChunk>;
173
266
 
@@ -211,7 +304,11 @@ function messageKey(msg: { type: string; action?: string; kind?: string }): stri
211
304
  }
212
305
 
213
306
  /** Returns the allowed direction(s), or null if the message type is unknown. */
214
- export function messageDirection(msg: { type: string; action?: string; kind?: string }): Direction[] {
307
+ export function messageDirection(msg: {
308
+ type: string;
309
+ action?: string;
310
+ kind?: string;
311
+ }): Direction[] {
215
312
  const key = messageKey(msg);
216
313
  const dirs: Direction[] = [];
217
314
  if (HUB_TO_DEVICE.has(key)) dirs.push("hub->device");
@@ -258,31 +355,57 @@ export function allowedInPhase(
258
355
  * the decision is made on exactly the bytes that will be requested, and only a route the device serves to a
259
356
  * remote caller (rest-routes.ts) with one of its methods passes.
260
357
  */
261
- export function validateRestRequest(input: { method: string; path: string }): { ok: true } | { ok: false; error: string } {
358
+ export function validateRestRequest(input: {
359
+ method: string;
360
+ path: string;
361
+ }): { ok: true } | { ok: false; error: string } {
262
362
  const refuse = (error: string) => ({ ok: false as const, error });
263
- if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(input.method)) return refuse("rest: method not allowed");
363
+ if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(input.method))
364
+ return refuse("rest: method not allowed");
264
365
  const raw = input.path;
265
- if (raw.includes("..") || raw.includes("\\") || raw.includes("://") || raw.startsWith("//")) {
366
+ // Traversal and absolute-URL checks apply to the ROUTED PATH only. The query
367
+ // string is data (fs/dirs carries `path=C:\Users\…` on Windows devices) and
368
+ // never influences routing, so backslashes and %5C are legal there.
369
+ const rawPath = raw.split("?")[0]!;
370
+ if (rawPath.includes("..") || rawPath.includes("\\") || rawPath.includes("://") || rawPath.startsWith("//")) {
266
371
  return refuse("rest: path traversal or absolute URL rejected");
267
372
  }
268
373
  let decoded: string;
269
374
  try {
270
- decoded = decodeURIComponent(raw);
375
+ decoded = decodeURIComponent(rawPath);
271
376
  } catch {
272
377
  return refuse("rest: invalid percent-encoding");
273
378
  }
274
- if (decoded.includes("..") || decoded.includes("\\") || decoded.includes("://") || decoded.startsWith("//")) {
379
+ if (
380
+ decoded.includes("..") ||
381
+ decoded.includes("\\") ||
382
+ decoded.includes("://") ||
383
+ decoded.startsWith("//")
384
+ ) {
275
385
  return refuse("rest: encoded traversal rejected");
276
386
  }
387
+ // The query is data, but its percent-encoding must still be well-formed.
388
+ const queryAt = raw.indexOf("?");
389
+ if (queryAt >= 0) {
390
+ try {
391
+ decodeURIComponent(raw.slice(queryAt + 1));
392
+ } catch {
393
+ return refuse("rest: invalid percent-encoding");
394
+ }
395
+ }
277
396
  if (!raw.startsWith(REST_PATH_PREFIX)) return refuse("rest: path outside /v1 rejected");
278
397
  if (!/^[\x21-\x7e]+$/.test(raw) || raw.includes("#")) {
279
- return refuse("rest: path must be printable ASCII without a fragment (percent-encode everything else)");
398
+ return refuse(
399
+ "rest: path must be printable ASCII without a fragment (percent-encode everything else)",
400
+ );
280
401
  }
281
402
  const query = raw.indexOf("?");
282
403
  const pathname = query < 0 ? raw : raw.slice(0, query);
283
404
  if (pathname.includes(";")) return refuse("rest: path parameters are not accepted");
284
405
  const segments = pathname.slice(REST_PATH_PREFIX.length).split("/");
285
- if (segments.some((segment) => segment === "" || segment.toLowerCase() === "%2e" || segment === ".")) {
406
+ if (
407
+ segments.some((segment) => segment === "" || segment.toLowerCase() === "%2e" || segment === ".")
408
+ ) {
286
409
  return refuse("rest: path is not canonical (empty or dot segment)");
287
410
  }
288
411
  if (matchesRestRoute(input.method, segments)) return { ok: true };
@@ -314,9 +437,10 @@ export function validateChunkEnvelope(chunk: {
314
437
  }
315
438
  const decodedLength = base64DecodedLength(chunk.data);
316
439
  if (decodedLength < 0) return { ok: false, error: "chunk: invalid base64" };
317
- const expected = chunk.index === chunk.count - 1
318
- ? chunk.totalBytes - CHUNK_RAW_BYTES * (chunk.count - 1)
319
- : CHUNK_RAW_BYTES;
440
+ const expected =
441
+ chunk.index === chunk.count - 1
442
+ ? chunk.totalBytes - CHUNK_RAW_BYTES * (chunk.count - 1)
443
+ : CHUNK_RAW_BYTES;
320
444
  if (decodedLength !== expected) {
321
445
  return { ok: false, error: `chunk: size mismatch at index ${chunk.index}` };
322
446
  }