my-durable 0.0.7 → 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 +29 -3
- 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.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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
81
|
-
// (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
|
@@ -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(
|
|
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(),
|