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
@@ -18,8 +18,16 @@
18
18
  */
19
19
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
20
20
  import { CheckCircle2, Copy, RefreshCw, ShieldAlert, X } from "lucide-vue-next";
21
- import { createPairingSession, pairingAdmin, pollCode, type PairingSession } from "../client/pair";
21
+ import {
22
+ createPairingSession,
23
+ pairingAdmin,
24
+ pollCode,
25
+ qrPayload,
26
+ type PairingSession,
27
+ type PendingPairing,
28
+ } from "../client/pair";
22
29
  import { qrSvg } from "../client/qrcode";
30
+ import DeviceHandoff from "./DeviceHandoff.vue";
23
31
  import type { PairCodeIssue, PairCodeState } from "@sdk/api";
24
32
  import { DpiError } from "@sdk/errors";
25
33
  import { isPairCode } from "@sdk/protocol/pairing";
@@ -44,6 +52,8 @@ const replaceKey = ref(false);
44
52
  const copied = ref(false);
45
53
  const linkCopied = ref(false);
46
54
  const watcher = ref<(() => void) | undefined>();
55
+ /** Machines that asked the hub for a code themselves (device-initiated enrollment). */
56
+ const pending = ref<PendingPairing[]>([]);
47
57
 
48
58
  const grouped = computed(() => {
49
59
  const code = issue.value?.code ?? "";
@@ -55,7 +65,9 @@ const grouped = computed(() => {
55
65
  });
56
66
  const qrData = computed(() => {
57
67
  if (!issue.value) return "";
58
- return issue.value.pairUrl;
68
+ // Gap #3: the QR rides the #offer= fragment (qrPayload) — never the v1
69
+ // pairUrl, whose ?code= query would land the pairing code in server logs.
70
+ return qrPayload(issue.value);
59
71
  });
60
72
  const qr = computed(() => (qrData.value ? qrSvg(qrData.value, 148) : ""));
61
73
  const fingerprint = computed(() => state.value?.enrollment?.keyFingerprint ?? "");
@@ -249,7 +261,40 @@ async function close(): Promise<void> {
249
261
 
250
262
  defineExpose({ close });
251
263
 
264
+ let pendingTimer: ReturnType<typeof setInterval> | undefined;
265
+ async function refreshPending(): Promise<void> {
266
+ try {
267
+ pending.value = (await pairingAdmin.pending()).codes;
268
+ } catch {
269
+ // The list is a convenience; a failure must never break the manual flow.
270
+ }
271
+ }
272
+ /** Open the approval flow for a machine that already asked (no code to carry). */
273
+ async function reviewPending(p: PendingPairing): Promise<void> {
274
+ issue.value = undefined;
275
+ state.value = undefined;
276
+ error.value = "";
277
+ const s = createPairingSession();
278
+ session.value = s;
279
+ step.value = p.phase === "approved_ready" ? "approve" : "proof";
280
+ void pollCode(s, p.codeId, {
281
+ intervalMs: 1_000,
282
+ onState: (st) => {
283
+ state.value = st;
284
+ if (st.phase === "awaiting_proof") step.value = "proof";
285
+ else if (st.phase === "awaiting_approval" || st.phase === "approved") step.value = "approve";
286
+ else if (st.phase === "issued" || st.phase === "connected") step.value = "issued";
287
+ else if (st.phase === "cancelled") step.value = "cancelled";
288
+ else if (st.phase === "expired") step.value = "expired";
289
+ },
290
+ }).catch((e) => {
291
+ error.value = e instanceof Error ? e.message : String(e);
292
+ });
293
+ }
294
+
252
295
  onMounted(() => {
296
+ void refreshPending();
297
+ pendingTimer = setInterval(() => void refreshPending(), 3_000);
253
298
  if (props.handoff && "error" in props.handoff) {
254
299
  error.value = props.handoff.error;
255
300
  step.value = "expired";
@@ -258,6 +303,7 @@ onMounted(() => {
258
303
  void createCode(props.handoff?.code);
259
304
  });
260
305
  onBeforeUnmount(() => {
306
+ clearInterval(pendingTimer);
261
307
  const s = session.value;
262
308
  if (s && !s.closed() && issue.value && !settled()) {
263
309
  void cancelLive(true).then((result) => {
@@ -280,6 +326,37 @@ onBeforeUnmount(() => {
280
326
  </button>
281
327
  </header>
282
328
 
329
+ <DeviceHandoff
330
+ v-if="props.handoff && 'offerFragment' in props.handoff && props.handoff.offerFragment"
331
+ :offer-fragment="props.handoff.offerFragment"
332
+ />
333
+
334
+ <!-- Machines that asked the hub for a code themselves. This is the
335
+ device-initiated enrollment: the operator reads WHO is asking (name,
336
+ host, platform, hub-computed fingerprint) before approving — the shape
337
+ the reference clients put in their QR/offer. -->
338
+ <section v-if="pending.length" class="pair-pending" aria-label="Machines waiting for approval">
339
+ <h3>Machines waiting for you</h3>
340
+ <ul>
341
+ <li v-for="p in pending" :key="p.codeId">
342
+ <div class="who">
343
+ <strong>{{ p.deviceName ?? "unnamed device" }}</strong>
344
+ <span v-if="p.hostname" class="muted">{{ p.hostname }}</span>
345
+ <span v-if="p.platform" class="muted">{{ p.platform }}</span>
346
+ </div>
347
+ <div class="fp">{{ p.keyFingerprint }}</div>
348
+ <div class="row">
349
+ <span class="phase">{{ p.phase.replaceAll("_", " ") }}</span>
350
+ <button type="button" class="small" @click="reviewPending(p)">Review</button>
351
+ </div>
352
+ </li>
353
+ </ul>
354
+ <p class="hint">
355
+ Compare each fingerprint with the machine's own terminal before approving. Codes are
356
+ minted by the hub — nothing has to be copied to the machine.
357
+ </p>
358
+ </section>
359
+
283
360
  <p v-if="step === 'boot'" class="hint">Minting a pairing code…</p>
284
361
 
285
362
  <template v-else-if="issue">
@@ -297,7 +374,12 @@ onBeforeUnmount(() => {
297
374
  <button type="button" class="small" @click="copyCode">
298
375
  <Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
299
376
  </button>
300
- <button type="button" class="small" @click="copyLink" title="Self-contained link: hub, code and expiry all in the fragment — it never reaches server logs">
377
+ <button
378
+ type="button"
379
+ class="small"
380
+ @click="copyLink"
381
+ title="Self-contained link: hub, code and expiry all in the fragment — it never reaches server logs"
382
+ >
301
383
  <Copy :size="14" /> {{ linkCopied ? "Copied link" : "Copy link" }}
302
384
  </button>
303
385
  </div>
@@ -305,7 +387,12 @@ onBeforeUnmount(() => {
305
387
  Run the device pairing command and scan, or enter this code on the device. Expires in
306
388
  5 minutes.
307
389
  </p>
308
- <div class="pair-qr" aria-label="Pairing QR code" v-html="qr" />
390
+ <div
391
+ class="pair-qr"
392
+ aria-label="Pairing QR code"
393
+ title="Same offer link as Copy link: hub, code and expiry all in the #offer= fragment — it never reaches server logs"
394
+ v-html="qr"
395
+ />
309
396
  </div>
310
397
 
311
398
  <div class="pair-side">
@@ -458,4 +545,19 @@ onBeforeUnmount(() => {
458
545
  transform: rotate(360deg);
459
546
  }
460
547
  }
461
- </style>
548
+
549
+ .pair-pending {
550
+ margin: 0 0 18px;
551
+ padding: 14px 16px;
552
+ border: 1px solid #2a3442;
553
+ border-radius: 12px;
554
+ }
555
+ .pair-pending h3 { margin: 0 0 10px; font-size: 14px; }
556
+ .pair-pending ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
557
+ .pair-pending li { display: grid; gap: 4px; padding-bottom: 10px; border-bottom: 1px solid #222b36; }
558
+ .pair-pending li:last-child { border-bottom: 0; padding-bottom: 0; }
559
+ .pair-pending .who { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
560
+ .pair-pending .fp { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; color: #7ee2a8; }
561
+ .pair-pending .row { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
562
+ .pair-pending .phase { font-size: 12px; color: #8fa0b5; }
563
+ </style>
@@ -60,7 +60,9 @@ const dot = computed(() => {
60
60
  const s = device.value?.status;
61
61
  return s === "online" ? "on" : s === "checking" ? "chk" : s === "offline" ? "off" : "err";
62
62
  });
63
- const label = computed(() => device.value?.id ?? session.value?.name ?? agent.value?.name ?? "");
63
+ const label = computed(
64
+ () => device.value?.name ?? device.value?.id ?? session.value?.name ?? agent.value?.name ?? "",
65
+ );
64
66
  const title = computed(() => {
65
67
  if (device.value)
66
68
  return `${device.value.name} · ${device.value.url}${device.value.error ? `\n${device.value.error}` : ""}`;
@@ -7,7 +7,8 @@
7
7
  * configuration mistake and REFUSES to boot — it is never silently replaced.
8
8
  * - Otherwise the token is minted at first boot (`adm_` + 32 random bytes,
9
9
  * base64url) into `<DPI_HOME>/admin.token` (or `DPI_ADMIN_TOKEN_FILE`),
10
- * owner-only 0600, and logged once.
10
+ * owner-only 0600, and its value is logged exactly once — at mint time only;
11
+ * every later boot logs just the file path.
11
12
  *
12
13
  * Presentation:
13
14
  * - Browsers: `POST /api/session {token}` sets a stateless cookie `dpi_admin` =
@@ -23,7 +24,8 @@
23
24
  * both compared in constant time.
24
25
  */
25
26
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
- import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
27
+ import { readFileSync } from "node:fs";
28
+ import { writePrivateFileAtomic, restrictToOwner } from "../../../../dpi-sdk/src/node/private-file.js";
27
29
  import { dirname, join } from "node:path";
28
30
  import type { RequestHandler } from "express";
29
31
  import { homedir } from "node:os";
@@ -57,9 +59,9 @@ export interface AdminTokenInfo {
57
59
  * Resolve the admin token at boot: env override (>= 32 chars or throw), the
58
60
  * existing token file, or a freshly minted one written 0600.
59
61
  */
60
- export function bootstrapAdminToken(
62
+ export async function bootstrapAdminToken(
61
63
  home = process.env.DPI_HOME ?? join(homedir(), ".dpi"),
62
- ): AdminTokenInfo {
64
+ ): Promise<AdminTokenInfo> {
63
65
  const path = process.env.DPI_ADMIN_TOKEN_FILE ?? join(home, "admin.token");
64
66
  const env = process.env.DPI_ADMIN_TOKEN;
65
67
  if (env !== undefined && env !== "") {
@@ -77,22 +79,22 @@ export function bootstrapAdminToken(
77
79
  /* first run (or an unreadable file): mint below */
78
80
  }
79
81
  if (existing) {
80
- // The token file must stay 0600 — tighten quietly if a copy restored looser bits
81
- // (the same quiet re-tightening Audit applies to the registry file).
82
- try {
83
- if ((statSync(path).mode & 0o777) !== 0o600) chmodSync(path, 0o600);
84
- } catch {
85
- /* best effort */
86
- }
82
+ // The token file must stay owner-only — tighten quietly if a copy restored
83
+ // looser bits (POSIX mode or, on Windows, the owner-only DACL — the same
84
+ // shared primitive every secret write in the stack uses).
85
+ void restrictToOwner(path);
87
86
  return { token: existing, path, created: false };
88
87
  }
89
88
  const token = `adm_${randomBytes(32).toString("base64url")}`;
90
- // DPI_ADMIN_TOKEN_FILE may name a directory that does not exist yet — create it
91
- // owner-only rather than refusing the operator's explicit choice of location.
92
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
93
- writeFileSync(path, `${token}\n`, { mode: 0o600 });
94
- chmodSync(path, 0o600); // a pre-existing (e.g. empty) file may carry wider bits
95
- console.log(`first run: created admin token in ${path}`);
89
+ // Atomic, owner-only mint (DPI_ADMIN_TOKEN_FILE may name a directory that does
90
+ // not exist yet — the shared writer creates it owner-only rather than refusing
91
+ // the operator's explicit choice of location).
92
+ await writePrivateFileAtomic(path, `${token}\n`);
93
+ // Gap #5: first-login friction — the value appears exactly once, here at
94
+ // mint time (this line only ever runs on a first boot), so the operator can
95
+ // log into the web UI without reading a file off the hub machine. Restarts
96
+ // print only the file path (see the banner in main.ts).
97
+ console.log(`first run: created admin token: ${token}`);
96
98
  return { token, path, created: true };
97
99
  }
98
100
 
@@ -39,6 +39,7 @@ export const AUDIT_ACTIONS = [
39
39
  "tunnel.connect",
40
40
  "tunnel.disconnect",
41
41
  "admin.token_created",
42
+ "hub.identity_created",
42
43
  "admin.login",
43
44
  "auth.denied",
44
45
  "rate_limited",
@@ -22,6 +22,7 @@ import type { Audit } from "./audit.js";
22
22
  import { observeIdentity } from "./identity.js";
23
23
  import { newTunnelToken } from "./tunnel-token.js";
24
24
  import type { TokenIssue } from "../../../../dpi-sdk/src/protocol/pairing.js";
25
+ import type { HubIdentity } from "./hub-identity.js";
25
26
 
26
27
  export interface IssueInput {
27
28
  enrollmentId: string;
@@ -50,6 +51,8 @@ export class CredentialService {
50
51
  private readonly hubOrigin: string,
51
52
  private readonly hubId: string,
52
53
  private readonly failAt?: IssueFailPoint,
54
+ /** Offer-v3 pinning: stamped into every issued TokenIssue. */
55
+ private readonly identity?: HubIdentity,
53
56
  ) {}
54
57
 
55
58
  /**
@@ -195,6 +198,7 @@ export class CredentialService {
195
198
  tunnelToken: token,
196
199
  issuedAt: now,
197
200
  expiresAt: null,
201
+ ...(this.identity ? { hubKeyFingerprint: this.identity.fingerprint } : {}),
198
202
  };
199
203
  }
200
204
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Hub identity key (offer v3 pinning, comparison gap #6).
3
+ *
4
+ * The hub keeps a persistent Ed25519 keypair in <DPI_HOME>/hub-identity.pem
5
+ * (owner-only 0600) + hub-identity.pub.pem. Its sha256-of-SPKI fingerprint is
6
+ * the `kfp` that rides every v3 pairing offer: devices pin it and demand a
7
+ * proof of possession both at the ceremony (pair.challenge) and on every
8
+ * tunnel connect (hello), so a hub swap is detectable even with TLS intact.
9
+ *
10
+ * First boot mints the key and audits `hub.identity_created` ONCE; later
11
+ * boots load the same key. Like admin-auth, an unreadable/corrupt file is a
12
+ * hard failure — a hub that silently re-minted its identity would strand
13
+ * every pinned device.
14
+ */
15
+ import {
16
+ createHash,
17
+ createPrivateKey,
18
+ createPublicKey,
19
+ generateKeyPairSync,
20
+ sign as edSign,
21
+ } from "node:crypto";
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { restrictToOwner, writePrivateFileAtomic } from "../../../../dpi-sdk/src/node/private-file.js";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join } from "node:path";
26
+ import type { Audit } from "./audit.js";
27
+
28
+ export interface HubIdentity {
29
+ /** Ed25519 SPKI PEM — sent to devices in the ceremony/hello proofs. */
30
+ publicKeyPem: string;
31
+ /** `sha256:<64 hex>` of the SPKI DER — the value devices pin (offer kfp). */
32
+ fingerprint: string;
33
+ /** Ed25519 signature over a canonical payload, base64url. */
34
+ signPayload: (payload: string) => string;
35
+ }
36
+
37
+ function fingerprintOf(publicKeyPem: string): string {
38
+ const der = createPublicKey(publicKeyPem).export({ format: "der", type: "spki" }) as Buffer;
39
+ return `sha256:${createHash("sha256").update(der).digest("hex")}`;
40
+ }
41
+
42
+ export async function bootstrapHubIdentity(
43
+ home: string = process.env.DPI_HOME ?? join(homedir(), ".dpi"),
44
+ audit?: Audit,
45
+ hooks: { envFile?: string | undefined; existsSync?: typeof existsSync; now?: () => number } = {},
46
+ ): Promise<HubIdentity> {
47
+ const privPath =
48
+ hooks.envFile ?? process.env.DPI_HUB_IDENTITY_FILE ?? join(home, "hub-identity.pem");
49
+ const pubPath = `${privPath}.pub.pem`;
50
+ const exists = hooks.existsSync ?? existsSync;
51
+ let privateKeyPem: string;
52
+ let created = false;
53
+ if (exists(privPath)) {
54
+ privateKeyPem = readFileSync(privPath, "utf8");
55
+ try {
56
+ createPrivateKey(privateKeyPem); // unreadable/corrupt → hard failure, never re-mint
57
+ } catch (e) {
58
+ throw new Error(
59
+ `hub identity key ${privPath} is unreadable; refusing to boot (a silent re-mint would strand every pinned device): ${(e as Error).message}`,
60
+ );
61
+ }
62
+ } else {
63
+ const kp = generateKeyPairSync("ed25519");
64
+ privateKeyPem = kp.privateKey.export({ type: "pkcs8", format: "pem" }) as string;
65
+ mkdirSync(dirname(privPath), { recursive: true, mode: 0o700 });
66
+ // The hub's signing key is the highest-value secret on disk: publish it
67
+ // atomically and owner-only on every platform (Windows DACL included).
68
+ await writePrivateFileAtomic(privPath, privateKeyPem);
69
+ writeFileSync(pubPath, kp.publicKey.export({ type: "spki", format: "pem" }) as string, {
70
+ mode: 0o600,
71
+ });
72
+ created = true;
73
+ }
74
+ await restrictToOwner(privPath); // 0600 on POSIX, owner-only DACL on Windows
75
+ const publicKeyPem = createPublicKey(createPrivateKey(privateKeyPem)).export({
76
+ type: "spki",
77
+ format: "pem",
78
+ }) as unknown as string;
79
+ const identity: HubIdentity = {
80
+ publicKeyPem,
81
+ fingerprint: fingerprintOf(publicKeyPem),
82
+ signPayload: (payload: string) =>
83
+ edSign(null, Buffer.from(payload), createPrivateKey(privateKeyPem)).toString("base64url"),
84
+ };
85
+ if (created)
86
+ try {
87
+ audit?.append({
88
+ actor: "hub",
89
+ action: "hub.identity_created",
90
+ outcome: "ok",
91
+ detail: { fingerprint: identity.fingerprint },
92
+ });
93
+ } catch {
94
+ // GA-02: an audit volume under its free-space floor must not kill hub
95
+ // boot — the identity file is already durable; the row is best-effort.
96
+ }
97
+ return identity;
98
+ }
@@ -23,6 +23,7 @@ import {
23
23
  signSession,
24
24
  tokenEquals,
25
25
  } from "./admin-auth.js";
26
+ import { bootstrapHubIdentity } from "./hub-identity.js";
26
27
  import { body as failureBody, describeFailure, errorMiddleware, safe } from "./http-errors.js";
27
28
  import { TokenBuckets } from "./limiter.js";
28
29
  import { PairingService } from "./pairing.js";
@@ -67,7 +68,10 @@ const registry = new NetworkRegistry();
67
68
  registry.clearTunnelState(); // live tunnel state is RAM-only; never resurrect from DB
68
69
  // The operator's own credential: minted at first boot into DPI_HOME/admin.token (0600),
69
70
  // overridable via DPI_ADMIN_TOKEN. A set-but-short token refuses to boot (admin-auth.ts).
70
- const adminToken = bootstrapAdminToken();
71
+ const adminToken = await bootstrapAdminToken();
72
+ // Gap #5: later boots point the operator at the file — the value itself only
73
+ // ever appears on the first-boot mint line (admin-auth.ts).
74
+ if (!adminToken.created) console.log(`admin: login with the token in ${adminToken.path}`);
71
75
  const configFile = seedFromConfig(registry, appDir);
72
76
  const monitor = new DeviceMonitor(registry);
73
77
  monitor.start();
@@ -120,6 +124,9 @@ function auditFloorFromEnv(): { minFreeBytes?: number } {
120
124
  }
121
125
  const audit = new Audit(registry, auditFloorFromEnv());
122
126
  audit.startSweeper();
127
+ // Gap #6: the hub's persistent identity key — its fingerprint is the `kfp`
128
+ // pinned inside every v3 pairing offer and proven at ceremony + tunnel connect.
129
+ const identity = await bootstrapHubIdentity(undefined, audit);
123
130
  if (adminToken.created)
124
131
  audit.append({
125
132
  actor: "hub",
@@ -164,12 +171,13 @@ const hubId = (
164
171
  registry.db.prepare("SELECT value FROM hub_meta WHERE key='hub_id'").get() as { value: string }
165
172
  ).value;
166
173
  const hubOrigin = process.env.DPI_HUB_ORIGIN ?? `https://${hostname().toLowerCase()}`;
167
- const credentials = new CredentialService(registry, audit, hubOrigin, hubId);
174
+ const credentials = new CredentialService(registry, audit, hubOrigin, hubId, undefined, identity);
168
175
  const pairing = new PairingService({
169
176
  registry,
170
177
  audit,
171
178
  hubOrigin,
172
179
  hubId,
180
+ identity,
173
181
  issue: async (e) => {
174
182
  const ticket = await credentials.issue(e);
175
183
  tunnel.closeConnection(ticket.deviceId, 1000, "pairing replaced");
@@ -219,6 +227,17 @@ app.post("/api/pair/verify", ...pairGuard, express.json({ limit: "16kb" }), asyn
219
227
  if (r.ok) return res.status(r.status).json(r.value);
220
228
  pairErr(r, res);
221
229
  });
230
+ /**
231
+ * Device-INITIATED enrollment (public, same guard stack as pair/start): the
232
+ * machine asks for a code, so the operator reads its full identity in the list
233
+ * below instead of carrying a secret between machines.
234
+ */
235
+ app.post("/api/pair/request", ...pairGuard, express.json({ limit: "16kb" }), (req, res) => {
236
+ const r = pairing.requestForDevice(req.body, req.ip ?? "unknown");
237
+ return r.ok ? res.json(r.value) : pairErr(r, res);
238
+ });
239
+ /** Every live request with the identity to approve against (no codes, no key material). */
240
+ app.get("/api/pair/codes", ...adminPairGuard, (_req, res) => res.json({ codes: pairing.listPending() }));
222
241
  app.get("/api/pair/codes/:codeId", ...adminPairGuard, (req, res) => {
223
242
  const r = pairing.status(String(req.params.codeId));
224
243
  return r.ok ? res.json(r.value) : pairErr(r, res);
@@ -451,7 +470,16 @@ app.use(errorMiddleware);
451
470
  // Tunnel endpoint (04 §4): /api/tunnel upgrades after hash-first credential
452
471
  // auth. In --dev mode Vite owns its own HMR upgrade path, so only the exact
453
472
  // tunnel path is claimed here and other upgrades are left alone.
454
- const tunnel = new TunnelServer(registry, audit, hubId, {}, { requireTls: requireTlsEnabled });
473
+ const tunnel = new TunnelServer(
474
+ registry,
475
+ audit,
476
+ hubId,
477
+ {},
478
+ { requireTls: requireTlsEnabled },
479
+ {},
480
+ hubOrigin,
481
+ identity,
482
+ );
455
483
  const dispatcher = new TunnelDispatcher(tunnel, { audit, budget: bufferBudget });
456
484
  const rotation = new RotationService(registry, audit, tunnel, {
457
485
  onChange: () => monitor.refresh(),