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
|
@@ -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 {
|
|
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
|
-
|
|
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) =>
|
|
112
|
+
const err = (code: string, message: string) =>
|
|
113
|
+
Object.assign(new Error(message), { code, status: 400 });
|
|
77
114
|
|
|
78
|
-
const isLoopbackHost = (hostname: string) =>
|
|
79
|
-
|
|
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(
|
|
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)
|
|
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)
|
|
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))
|
|
91
|
-
|
|
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)
|
|
96
|
-
|
|
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)))
|
|
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)
|
|
114
|
-
|
|
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: {
|
|
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
|
|
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
|
-
? {
|
|
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
|
|
190
|
-
// #offer= fragment — decode it and take
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
245
|
-
|
|
246
|
-
|
|
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 (
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
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(
|
|
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(
|
|
312
|
-
|
|
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 (
|
|
337
|
-
|
|
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 (
|
|
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, {
|
|
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 =
|
|
359
|
-
|
|
360
|
-
|
|
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)
|
|
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)
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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.
|