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
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
PAIR_SIGNATURE_FAIL_LIMIT,
|
|
34
34
|
PAIR_POLL_MS,
|
|
35
35
|
} from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
36
|
+
import { hubProofSigningPayload, PairDeviceRequest as PairDeviceRequestSchema } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
37
|
+
import type { HubIdentity } from "./hub-identity.js";
|
|
36
38
|
import {
|
|
37
39
|
PairApproval as PairApprovalSchema,
|
|
38
40
|
PairCodeState as PairCodeStateSchema,
|
|
@@ -80,6 +82,9 @@ interface Enrollment {
|
|
|
80
82
|
code: string;
|
|
81
83
|
deviceUid: string;
|
|
82
84
|
deviceName: string;
|
|
85
|
+
/** Machine description shown to the operator before approval (device-initiated flow). */
|
|
86
|
+
hostname?: string;
|
|
87
|
+
platform?: string;
|
|
83
88
|
publicKeyPem: string;
|
|
84
89
|
computedFingerprint: string;
|
|
85
90
|
declaredFingerprint: string;
|
|
@@ -109,11 +114,39 @@ interface CodeEntry {
|
|
|
109
114
|
enrollmentId?: string;
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
/** One live pairing request as the admin list renders it (no secrets). */
|
|
118
|
+
export interface PendingPairing {
|
|
119
|
+
codeId: string;
|
|
120
|
+
expiresAt: number;
|
|
121
|
+
phase: "awaiting_device" | "awaiting_proof" | "approved_ready" | "cancelled";
|
|
122
|
+
enrollmentId?: string;
|
|
123
|
+
deviceUid?: string;
|
|
124
|
+
deviceName?: string;
|
|
125
|
+
hostname?: string;
|
|
126
|
+
platform?: string;
|
|
127
|
+
keyFingerprint?: string;
|
|
128
|
+
proofVerified?: boolean;
|
|
129
|
+
startedAt?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface PairDeviceAck {
|
|
133
|
+
type: "pair.device-ack";
|
|
134
|
+
v: 1;
|
|
135
|
+
codeId: string;
|
|
136
|
+
code: string;
|
|
137
|
+
expiresAt: number;
|
|
138
|
+
hubOrigin: string;
|
|
139
|
+
hubId: string;
|
|
140
|
+
challenge: unknown;
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
export interface PairingDeps {
|
|
113
144
|
registry: NetworkRegistry;
|
|
114
145
|
audit: Audit;
|
|
115
146
|
hubOrigin: string;
|
|
116
147
|
hubId: string;
|
|
148
|
+
/** Offer-v3 pinning: present on every real hub; absent only in legacy harnesses. */
|
|
149
|
+
identity?: HubIdentity;
|
|
117
150
|
/**
|
|
118
151
|
* Issuance boundary (P2-10 CredentialService). Returns the committed TokenIssue. It must reject with
|
|
119
152
|
* `code: "identity_changed"` when `expectedIdentity` no longer matches the registry (GA-05).
|
|
@@ -206,27 +239,30 @@ export class PairingService {
|
|
|
206
239
|
}
|
|
207
240
|
|
|
208
241
|
/**
|
|
209
|
-
*
|
|
210
|
-
* same expiry): the whole handoff — hub
|
|
211
|
-
*
|
|
242
|
+
* v3 offer link (v2 without an identity key), minted from the SAME values
|
|
243
|
+
* the v1 pairUrl uses (same code, same expiry): the whole handoff — hub
|
|
244
|
+
* origin, hubId, code, expiry, pinned hub-key fingerprint — rides the
|
|
245
|
+
* #offer= fragment, which no server or CDN ever sees in a log.
|
|
212
246
|
*/
|
|
213
247
|
private offerUrlFor(code: string, expiresAt: number): string {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
const identity = this.deps.identity;
|
|
249
|
+
return `${this.deps.hubOrigin}/pair#offer=${encodePairOffer(
|
|
250
|
+
identity
|
|
251
|
+
? {
|
|
252
|
+
v: 3,
|
|
253
|
+
hub: this.deps.hubOrigin,
|
|
254
|
+
id: this.deps.hubId,
|
|
255
|
+
code,
|
|
256
|
+
exp: expiresAt,
|
|
257
|
+
kfp: identity.fingerprint,
|
|
258
|
+
}
|
|
259
|
+
: { v: 2, hub: this.deps.hubOrigin, id: this.deps.hubId, code, exp: expiresAt },
|
|
260
|
+
)}`;
|
|
221
261
|
}
|
|
222
262
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
263
|
+
/** Mint a single-use code entry (shared by the admin and device-initiated flows). */
|
|
264
|
+
private mintEntry(actor: "admin" | "device"): CodeEntry {
|
|
225
265
|
const now = this.now();
|
|
226
|
-
const taken = this.createLimiter.take(ipKey(address));
|
|
227
|
-
if (!taken.ok) return this.rateLimited(taken, "Too many codes; slow down");
|
|
228
|
-
if (this.codes.size >= PAIR_RAM_CAPS.codes)
|
|
229
|
-
return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
|
|
230
266
|
let code = "";
|
|
231
267
|
do {
|
|
232
268
|
// A code is a bearer secret authorising one enrollment: canonical UUID v4 = 122 random bits.
|
|
@@ -239,15 +275,25 @@ export class PairingService {
|
|
|
239
275
|
expiresAt: now + CODE_TTL_MS,
|
|
240
276
|
cancelled: false,
|
|
241
277
|
};
|
|
242
|
-
// Audit row first: a code that cannot be audited must not exist (and must not eat the code capacity).
|
|
243
278
|
this.deps.audit.append({
|
|
244
|
-
actor
|
|
279
|
+
actor,
|
|
245
280
|
action: "pair.code_created",
|
|
246
281
|
outcome: "ok",
|
|
247
|
-
detail: { codeId: entry.codeId, expiresAt: entry.expiresAt },
|
|
282
|
+
detail: { codeId: entry.codeId, expiresAt: entry.expiresAt, ...(actor === "device" ? { initiated: "device" } : {}) },
|
|
248
283
|
});
|
|
249
284
|
this.codes.set(entry.codeId, entry);
|
|
250
285
|
this.byCode.set(code, entry.codeId);
|
|
286
|
+
return entry;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** POST /api/pair/code (admin) — mint a fresh single-use code. */
|
|
290
|
+
createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
291
|
+
const taken = this.createLimiter.take(ipKey(address));
|
|
292
|
+
if (!taken.ok) return this.rateLimited(taken, "Too many codes; slow down");
|
|
293
|
+
if (this.codes.size >= PAIR_RAM_CAPS.codes)
|
|
294
|
+
return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
|
|
295
|
+
const entry = this.mintEntry("admin");
|
|
296
|
+
const code = entry.code;
|
|
251
297
|
return this.ok({
|
|
252
298
|
type: "pair.code",
|
|
253
299
|
v: 1,
|
|
@@ -260,16 +306,92 @@ export class PairingService {
|
|
|
260
306
|
});
|
|
261
307
|
}
|
|
262
308
|
|
|
309
|
+
/**
|
|
310
|
+
* POST /api/pair/request (public, rate-limited) — DEVICE-INITIATED enrollment.
|
|
311
|
+
*
|
|
312
|
+
* The machine that wants to pair asks for a code instead of receiving one, so
|
|
313
|
+
* the request carries its whole identity (name, hostname, platform, public
|
|
314
|
+
* key) and the operator reads it in the approval list — the shape orca/paseo
|
|
315
|
+
* offers have. Nothing gets weaker: the hub mints the same 122-bit single-use
|
|
316
|
+
* code, parses the key itself, owns the challenge nonce, and the credential is
|
|
317
|
+
* still issued only by a human approval carrying the hub-computed fingerprint.
|
|
318
|
+
*/
|
|
319
|
+
requestForDevice(body: unknown, address: string): { ok: true; value: PairDeviceAck } | PairingFailure {
|
|
320
|
+
const ip = ipKey(address);
|
|
321
|
+
if (this.failureBlocked(ip))
|
|
322
|
+
return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
|
|
323
|
+
const taken = this.createLimiter.take(ip);
|
|
324
|
+
if (!taken.ok) return this.rateLimited(taken, "Too many pairing requests; slow down");
|
|
325
|
+
if (this.codes.size >= PAIR_RAM_CAPS.codes)
|
|
326
|
+
return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
|
|
327
|
+
const request = body as { type?: unknown } | null;
|
|
328
|
+
if (request?.type !== "pair.device-request" || !checkPairingDto(PairDeviceRequestSchema, request))
|
|
329
|
+
return this.fail(400, "invalid", "pair.device-request body failed schema validation");
|
|
330
|
+
|
|
331
|
+
const entry = this.mintEntry("device");
|
|
332
|
+
const started = this.start({ ...(request as object), type: "pair.request", v: 1, code: entry.code }, address);
|
|
333
|
+
if (!started.ok) {
|
|
334
|
+
// The enrollment never existed: drop the code so a failed request leaves nothing behind.
|
|
335
|
+
this.codes.delete(entry.codeId);
|
|
336
|
+
this.byCode.delete(entry.code);
|
|
337
|
+
return started;
|
|
338
|
+
}
|
|
339
|
+
return this.ok({
|
|
340
|
+
type: "pair.device-ack",
|
|
341
|
+
v: 1,
|
|
342
|
+
codeId: entry.codeId,
|
|
343
|
+
code: entry.code,
|
|
344
|
+
expiresAt: entry.expiresAt,
|
|
345
|
+
hubOrigin: this.deps.hubOrigin,
|
|
346
|
+
hubId: this.deps.hubId,
|
|
347
|
+
challenge: started.value,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* GET /api/pair/codes (admin) — every live code with the machine identity the
|
|
353
|
+
* operator needs to decide. Deliberately carries NO code, token or key
|
|
354
|
+
* material: identity + hub-computed fingerprint + phase only.
|
|
355
|
+
*/
|
|
356
|
+
listPending(): PendingPairing[] {
|
|
357
|
+
const now = this.now();
|
|
358
|
+
const out: PendingPairing[] = [];
|
|
359
|
+
for (const entry of this.codes.values()) {
|
|
360
|
+
if (entry.cancelled || now >= entry.expiresAt) continue;
|
|
361
|
+
const enrollment = entry.enrollmentId ? this.enrollments.get(entry.enrollmentId) : undefined;
|
|
362
|
+
const phase = !enrollment
|
|
363
|
+
? "awaiting_device"
|
|
364
|
+
: enrollment.cancelled
|
|
365
|
+
? "cancelled"
|
|
366
|
+
: enrollment.proofVerified
|
|
367
|
+
? "approved_ready"
|
|
368
|
+
: "awaiting_proof";
|
|
369
|
+
out.push({
|
|
370
|
+
codeId: entry.codeId,
|
|
371
|
+
expiresAt: entry.expiresAt,
|
|
372
|
+
phase,
|
|
373
|
+
...(enrollment
|
|
374
|
+
? {
|
|
375
|
+
enrollmentId: enrollment.enrollmentId,
|
|
376
|
+
deviceUid: enrollment.deviceUid,
|
|
377
|
+
deviceName: enrollment.deviceName,
|
|
378
|
+
...(enrollment.hostname ? { hostname: enrollment.hostname } : {}),
|
|
379
|
+
...(enrollment.platform ? { platform: enrollment.platform } : {}),
|
|
380
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
381
|
+
proofVerified: enrollment.proofVerified,
|
|
382
|
+
startedAt: enrollment.nonceIssuedAt,
|
|
383
|
+
}
|
|
384
|
+
: {}),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
263
390
|
/** Admin handoff lookup. Reuses a code; never creates an enrollment or approves it. */
|
|
264
391
|
resolveCode(body: unknown): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
265
392
|
const raw = (body as { code?: unknown } | null)?.code;
|
|
266
393
|
const code = typeof raw === "string" ? normalizePairCode(raw) : undefined;
|
|
267
|
-
if (
|
|
268
|
-
!body ||
|
|
269
|
-
typeof body !== "object" ||
|
|
270
|
-
Object.keys(body).length !== 1 ||
|
|
271
|
-
!isPairCode(code)
|
|
272
|
-
)
|
|
394
|
+
if (!body || typeof body !== "object" || Object.keys(body).length !== 1 || !isPairCode(code))
|
|
273
395
|
return this.fail(400, "code_invalid", "Invalid pairing handoff");
|
|
274
396
|
const codeId = this.byCode.get(code);
|
|
275
397
|
const entry = codeId ? this.codes.get(codeId) : undefined;
|
|
@@ -297,6 +419,24 @@ export class PairingService {
|
|
|
297
419
|
return entry;
|
|
298
420
|
}
|
|
299
421
|
|
|
422
|
+
/** Hub-identity proof for a device that pinned kfp (offer v3): sign the canonical payload over the enrollment. */
|
|
423
|
+
private hubProof(hubNonce: string, enrollmentId: string) {
|
|
424
|
+
const identity = this.deps.identity;
|
|
425
|
+
if (!identity) return undefined;
|
|
426
|
+
return {
|
|
427
|
+
hubPublicKey: identity.publicKeyPem,
|
|
428
|
+
hubKeyFingerprint: identity.fingerprint,
|
|
429
|
+
hubSignature: identity.signPayload(
|
|
430
|
+
hubProofSigningPayload({
|
|
431
|
+
hubOrigin: this.deps.hubOrigin,
|
|
432
|
+
hubId: this.deps.hubId,
|
|
433
|
+
subject: `pair:${enrollmentId}`,
|
|
434
|
+
nonce: hubNonce,
|
|
435
|
+
}),
|
|
436
|
+
),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
300
440
|
/** POST /api/pair/start — issue a challenge bound to one immutable tuple. */
|
|
301
441
|
start(body: unknown, address: string): { ok: true; value: PairChallengeOut } | PairingFailure {
|
|
302
442
|
const now = this.now();
|
|
@@ -316,9 +456,12 @@ export class PairingService {
|
|
|
316
456
|
code: string;
|
|
317
457
|
deviceUid: string;
|
|
318
458
|
deviceName: string;
|
|
459
|
+
hostname?: string;
|
|
460
|
+
platform?: string;
|
|
319
461
|
publicKey: string;
|
|
320
462
|
keyFingerprint: string;
|
|
321
463
|
clientTime: number;
|
|
464
|
+
hubNonce?: string;
|
|
322
465
|
};
|
|
323
466
|
const live = this.liveCode(req.code, now);
|
|
324
467
|
if (live === "invalid") {
|
|
@@ -352,6 +495,7 @@ export class PairingService {
|
|
|
352
495
|
nonce: existing.nonce,
|
|
353
496
|
issuedAt: existing.nonceIssuedAt,
|
|
354
497
|
hubId: this.deps.hubId,
|
|
498
|
+
...(req.hubNonce ? this.hubProof(req.hubNonce, existing.enrollmentId) : {}),
|
|
355
499
|
});
|
|
356
500
|
}
|
|
357
501
|
|
|
@@ -388,6 +532,8 @@ export class PairingService {
|
|
|
388
532
|
code: req.code,
|
|
389
533
|
deviceUid: req.deviceUid,
|
|
390
534
|
deviceName: req.deviceName,
|
|
535
|
+
...(req.hostname ? { hostname: req.hostname } : {}),
|
|
536
|
+
...(req.platform ? { platform: req.platform } : {}),
|
|
391
537
|
publicKeyPem: req.publicKey,
|
|
392
538
|
computedFingerprint: fingerprint,
|
|
393
539
|
declaredFingerprint: req.keyFingerprint,
|
|
@@ -407,6 +553,7 @@ export class PairingService {
|
|
|
407
553
|
nonce: enrollment.nonce,
|
|
408
554
|
issuedAt: enrollment.nonceIssuedAt,
|
|
409
555
|
hubId: this.deps.hubId,
|
|
556
|
+
...(req.hubNonce ? this.hubProof(req.hubNonce, enrollment.enrollmentId) : {}),
|
|
410
557
|
});
|
|
411
558
|
}
|
|
412
559
|
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
type TunnelFrame,
|
|
33
33
|
type TunnelWireMessage,
|
|
34
34
|
} from "../../../../dpi-sdk/src/protocol/tunnel.js";
|
|
35
|
+
import { hubProofSigningPayload } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
36
|
+
import type { HubIdentity } from "./hub-identity.js";
|
|
35
37
|
import {
|
|
36
38
|
HANDSHAKE_DEADLINE_MS,
|
|
37
39
|
IDLE_TIMEOUT_MS,
|
|
@@ -162,6 +164,9 @@ export class TunnelServer {
|
|
|
162
164
|
handlers: TunnelHandlers = {},
|
|
163
165
|
policy: Partial<TunnelPolicy> = {},
|
|
164
166
|
hooks: TunnelTestHooks = {},
|
|
167
|
+
/** Offer-v3 pinning: signs hello proofs for proofNonce-carrying handshakes. */
|
|
168
|
+
private readonly hubOrigin: string = "",
|
|
169
|
+
private readonly identity?: HubIdentity,
|
|
165
170
|
) {
|
|
166
171
|
this.policy = { ...DEFAULT_TUNNEL_POLICY, ...policy };
|
|
167
172
|
if (policy.ipCapacity === undefined)
|
|
@@ -753,6 +758,25 @@ export class TunnelServer {
|
|
|
753
758
|
hubId: this.hubId,
|
|
754
759
|
connectionId: conn.connectionId,
|
|
755
760
|
serverTime: this.now(),
|
|
761
|
+
// Offer-v3 pinning: a device that sent proofNonce gets the hub identity
|
|
762
|
+
// proof bound to ITS device+epoch, so a captured proof cannot replay.
|
|
763
|
+
...(hs.proofNonce && this.identity && pairing
|
|
764
|
+
? (() => {
|
|
765
|
+
const i = this.identity!;
|
|
766
|
+
return {
|
|
767
|
+
hubPublicKey: i.publicKeyPem,
|
|
768
|
+
hubKeyFingerprint: i.fingerprint,
|
|
769
|
+
hubSignature: i.signPayload(
|
|
770
|
+
hubProofSigningPayload({
|
|
771
|
+
hubOrigin: this.hubOrigin,
|
|
772
|
+
hubId: this.hubId,
|
|
773
|
+
subject: `tunnel:${conn.auth.device_id}:${pairing.pairing_epoch}`,
|
|
774
|
+
nonce: hs.proofNonce,
|
|
775
|
+
}),
|
|
776
|
+
),
|
|
777
|
+
};
|
|
778
|
+
})()
|
|
779
|
+
: {}),
|
|
756
780
|
} satisfies TunnelFrame),
|
|
757
781
|
);
|
|
758
782
|
this.handlers.onOpen?.(this.asConnection(conn));
|
package/apps/dpi-sdk/src/api.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface DeviceInfo {
|
|
|
94
94
|
startDirs: string[];
|
|
95
95
|
models: ModelInfo[];
|
|
96
96
|
defaultModel: string;
|
|
97
|
+
/** Provider id of this device ("openrouter" | "faux"); FE model fallback. */
|
|
98
|
+
provider?: string;
|
|
97
99
|
storage: { dataDir: string; catalogPath: string; appStatePath: string };
|
|
98
100
|
/** The checkout the backend runs from (repo root locally, `code_dir` on deployed devices). */
|
|
99
101
|
codeDir?: string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private-file hygiene shared by every app (device backend, hub, sdk registry):
|
|
3
|
+
* atomic writes for credentials and owner-only access on every platform.
|
|
4
|
+
*
|
|
5
|
+
* POSIX gets file modes (0600/0700). Windows ignores them, so the same calls
|
|
6
|
+
* install the hardened DACL Orca uses (`windows-secure-dacl.ts`): protected
|
|
7
|
+
* from inheritance, full control for SYSTEM, Administrators and the user —
|
|
8
|
+
* nobody else. `icacls` ships with every Windows box, so no native addon is
|
|
9
|
+
* needed. Like Orca (and paseo's applyPrivateMode) a restriction failure never
|
|
10
|
+
* crashes the caller: it reports and returns false so the boot can continue and
|
|
11
|
+
* a later pass can retry.
|
|
12
|
+
*/
|
|
13
|
+
import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export interface RestrictDeps {
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
/** Injectable for tests: run a command, resolve stdout or reject. */
|
|
21
|
+
execFile?: (cmd: string, args: string[]) => Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** orca's SIDs: SYSTEM and local Administrators can take ownership anyway. */
|
|
25
|
+
const LOCAL_SYSTEM_SID = "S-1-5-18";
|
|
26
|
+
const BUILTIN_ADMINISTRATORS_SID = "S-1-5-32-544";
|
|
27
|
+
|
|
28
|
+
const execDefault = (cmd: string, args: string[]) =>
|
|
29
|
+
new Promise<string>((resolve, reject) => {
|
|
30
|
+
execFile(cmd, args, { timeout: 15_000, windowsHide: true }, (error, stdout) => {
|
|
31
|
+
if (error) reject(error);
|
|
32
|
+
else resolve(String(stdout ?? ""));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The current user's SID via `whoami` (locale-proof: extract the S-1-5-* token). */
|
|
37
|
+
async function userSid(deps: RestrictDeps): Promise<string> {
|
|
38
|
+
const out = await deps.execFile!("whoami", ["/user", "/fo", "csv", "/nh"]);
|
|
39
|
+
const m = out.match(/S-1-5-[0-9-]+/);
|
|
40
|
+
if (!m) throw Error(`whoami returned no SID: ${out.trim()}`);
|
|
41
|
+
return m[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Restrict one FILE to its owner. POSIX: chmod 0600 (idempotent). Windows:
|
|
46
|
+
* strip inheritance, grant SYSTEM + Administrators + the user. Never throws.
|
|
47
|
+
*/
|
|
48
|
+
export async function restrictToOwner(path: string, deps: RestrictDeps = {}): Promise<boolean> {
|
|
49
|
+
const platform = deps.platform ?? process.platform;
|
|
50
|
+
const exec = deps.execFile ?? execDefault;
|
|
51
|
+
if (platform === "win32") {
|
|
52
|
+
try {
|
|
53
|
+
const sid = await userSid({ execFile: exec });
|
|
54
|
+
const grants = [LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID, sid].map(
|
|
55
|
+
(s) => `*${s}:F`,
|
|
56
|
+
);
|
|
57
|
+
await exec("icacls", [path, "/inheritance:r", "/grant:r", ...grants]);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(path, 0o600);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Restrict a DIRECTORY (children inherit on Windows via (OI)(CI)). Never throws. */
|
|
74
|
+
export async function restrictDirToOwner(path: string, deps: RestrictDeps = {}): Promise<boolean> {
|
|
75
|
+
const platform = deps.platform ?? process.platform;
|
|
76
|
+
const exec = deps.execFile ?? execDefault;
|
|
77
|
+
if (platform === "win32") {
|
|
78
|
+
try {
|
|
79
|
+
const sid = await userSid({ execFile: exec });
|
|
80
|
+
const grants = [LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID, sid].map(
|
|
81
|
+
(s) => `*${s}:(OI)(CI)F`,
|
|
82
|
+
);
|
|
83
|
+
await exec("icacls", [path, "/inheritance:r", "/grant:r", ...grants]);
|
|
84
|
+
return true;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(path, 0o700);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Atomic write for credential files (orca STA-3941 / paseo writePrivateFileAtomicSync):
|
|
101
|
+
* temp file 0600 + fsync + rename, so readers only ever see the old bytes or the
|
|
102
|
+
* complete new ones — a crash mid-write can never truncate a token. Then the
|
|
103
|
+
* owner restriction runs on every platform. Throws only if the disk write
|
|
104
|
+
* itself fails.
|
|
105
|
+
*/
|
|
106
|
+
export async function writePrivateFileAtomic(
|
|
107
|
+
path: string,
|
|
108
|
+
contents: string,
|
|
109
|
+
deps: RestrictDeps = {},
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
112
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
113
|
+
let fd: number | undefined;
|
|
114
|
+
try {
|
|
115
|
+
fd = openSync(temp, "w", 0o600);
|
|
116
|
+
writeSync(fd, Buffer.from(contents, "utf8"));
|
|
117
|
+
closeSync(fd);
|
|
118
|
+
fd = undefined;
|
|
119
|
+
renameSync(temp, path);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (fd !== undefined) closeSync(fd);
|
|
122
|
+
rmSync(temp, { force: true });
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
// POSIX: fsync the directory so the rename itself is durable (Windows dir
|
|
126
|
+
// fds cannot be fsynced — EPERM; NTFS journaling carries it there).
|
|
127
|
+
if ((deps.platform ?? process.platform) !== "win32") {
|
|
128
|
+
try {
|
|
129
|
+
const dirFd = openSync(dirname(path), "r");
|
|
130
|
+
try {
|
|
131
|
+
fsyncSync(dirFd);
|
|
132
|
+
} finally {
|
|
133
|
+
closeSync(dirFd);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* best effort, like pairing-files' fsyncReadOnlySupported probe */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await restrictToOwner(path, deps);
|
|
140
|
+
}
|
|
@@ -388,7 +388,15 @@ function preflightV0(db: DatabaseSync): void {
|
|
|
388
388
|
}
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Windows cannot fsync read-only or directory handles (FlushFileBuffers needs a write
|
|
393
|
+
* handle; directory handles cannot be flushed at all — EPERM, errno -4048). POSIX-only
|
|
394
|
+
* durability steps are skipped there: NTFS journaling plus the atomic rename carry it.
|
|
395
|
+
*/
|
|
396
|
+
export const fsyncReadOnlySupported = process.platform !== "win32";
|
|
397
|
+
|
|
391
398
|
function syncFile(file: string): void {
|
|
399
|
+
if (!fsyncReadOnlySupported) return;
|
|
392
400
|
const fd = openSync(file, "r");
|
|
393
401
|
try {
|
|
394
402
|
fsyncSync(fd);
|
|
@@ -405,6 +413,7 @@ function syncFile(file: string): void {
|
|
|
405
413
|
* Exported for a focused unit test — the fsync itself is not observable from JS.
|
|
406
414
|
*/
|
|
407
415
|
export function syncDirectory(dir: string): void {
|
|
416
|
+
if (!fsyncReadOnlySupported) return; // Windows: directory fds cannot be fsynced (EPERM)
|
|
408
417
|
const fd = openSync(dir, "r");
|
|
409
418
|
try {
|
|
410
419
|
if (!fstatSync(fd).isDirectory()) throw new Error(`not a directory: ${dir}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The client-side device registry: ~/.dpi/network.sqlite. Shared by the playground hub,
|
|
2
2
|
// dpi-tui and scripts/net.ts on this machine. Holds tokens (mode 0600), live status and UI state.
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { restrictToOwner } from "./private-file.js";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { migrateRegistryFile } from "./registry-migration.js";
|
|
@@ -100,6 +101,11 @@ export class NetworkRegistry {
|
|
|
100
101
|
// migration). Any failure throws — the hub/TUI must not boot on it.
|
|
101
102
|
migrateRegistryFile(path);
|
|
102
103
|
this.db = new DatabaseSync(path);
|
|
104
|
+
// The registry carries device tokens: sqlite owns the file's creation mode,
|
|
105
|
+
// so tighten it here — 0600 on POSIX is ensurePrivateDir's job for the
|
|
106
|
+
// directory; on Windows this installs the owner-only DACL (registry.ts is
|
|
107
|
+
// shared by the hub, so keep it fire-and-forget and non-fatal).
|
|
108
|
+
void restrictToOwner(path);
|
|
103
109
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; PRAGMA foreign_keys=ON;");
|
|
104
110
|
}
|
|
105
111
|
|