my-durable 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +39 -9
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +8 -3
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- 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({
|
|
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 =
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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 (
|
|
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(
|
|
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({
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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("
|
|
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(
|
|
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([
|
|
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(
|
|
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(
|
|
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: {
|
|
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: {
|
|
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))
|
|
363
|
+
if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(input.method))
|
|
364
|
+
return refuse("rest: method not allowed");
|
|
264
365
|
const raw = input.path;
|
|
265
|
-
|
|
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(
|
|
375
|
+
decoded = decodeURIComponent(rawPath);
|
|
271
376
|
} catch {
|
|
272
377
|
return refuse("rest: invalid percent-encoding");
|
|
273
378
|
}
|
|
274
|
-
if (
|
|
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(
|
|
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 (
|
|
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 =
|
|
318
|
-
|
|
319
|
-
|
|
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
|
}
|