my-durable 0.0.4 → 0.0.6
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/apps/dpi-backend/src/config.ts +20 -3
- package/apps/dpi-backend/src/main.ts +2 -0
- package/apps/dpi-backend/src/pairing.ts +42 -11
- package/apps/dpi-backend/src/routes.ts +11 -3
- package/apps/dpi-playground/README.md +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.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/App.vue +3 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
- package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
- package/apps/dpi-tui/src/app.ts +25 -1
- package/apps/dpi-tui/src/main.ts +116 -52
- package/apps/dpi-tui/src/pickers.ts +98 -0
- package/apps/dpi-tui/src/welcome.ts +394 -0
- package/bin/dpi.mjs +14 -9
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
|
@@ -12,8 +12,17 @@ import { DeviceMonitor } from "./monitor.js";
|
|
|
12
12
|
import { StreamMux } from "./mux.js";
|
|
13
13
|
import { proxy } from "./proxy.js";
|
|
14
14
|
import { guard, requireTls } from "./guard.js";
|
|
15
|
-
import { ipKey, isTrustedProxy } from "./client-ip.js";
|
|
15
|
+
import { ipKey, isTrustedProxy, viaTls } from "./client-ip.js";
|
|
16
16
|
import { Audit, denialLogging } from "./audit.js";
|
|
17
|
+
import {
|
|
18
|
+
ADMIN_COOKIE,
|
|
19
|
+
LOGIN_RATE,
|
|
20
|
+
SESSION_TTL_MS,
|
|
21
|
+
bootstrapAdminToken,
|
|
22
|
+
requireAdmin,
|
|
23
|
+
signSession,
|
|
24
|
+
tokenEquals,
|
|
25
|
+
} from "./admin-auth.js";
|
|
17
26
|
import { body as failureBody, describeFailure, errorMiddleware, safe } from "./http-errors.js";
|
|
18
27
|
import { TokenBuckets } from "./limiter.js";
|
|
19
28
|
import { PairingService } from "./pairing.js";
|
|
@@ -56,6 +65,9 @@ const requireTlsEnabled = tlsRequired();
|
|
|
56
65
|
|
|
57
66
|
const registry = new NetworkRegistry();
|
|
58
67
|
registry.clearTunnelState(); // live tunnel state is RAM-only; never resurrect from DB
|
|
68
|
+
// The operator's own credential: minted at first boot into DPI_HOME/admin.token (0600),
|
|
69
|
+
// overridable via DPI_ADMIN_TOKEN. A set-but-short token refuses to boot (admin-auth.ts).
|
|
70
|
+
const adminToken = bootstrapAdminToken();
|
|
59
71
|
const configFile = seedFromConfig(registry, appDir);
|
|
60
72
|
const monitor = new DeviceMonitor(registry);
|
|
61
73
|
monitor.start();
|
|
@@ -108,6 +120,15 @@ function auditFloorFromEnv(): { minFreeBytes?: number } {
|
|
|
108
120
|
}
|
|
109
121
|
const audit = new Audit(registry, auditFloorFromEnv());
|
|
110
122
|
audit.startSweeper();
|
|
123
|
+
if (adminToken.created)
|
|
124
|
+
audit.append({
|
|
125
|
+
actor: "hub",
|
|
126
|
+
action: "admin.token_created",
|
|
127
|
+
outcome: "ok",
|
|
128
|
+
detail: { file: adminToken.path },
|
|
129
|
+
});
|
|
130
|
+
/** Every admin route requires this: the dpi_admin cookie or the raw token as a bearer. */
|
|
131
|
+
const adminAuth = requireAdmin(adminToken.token);
|
|
111
132
|
// Everything registered below needs proof of TLS; /healthz (above) stays reachable for supervisors.
|
|
112
133
|
if (requireTlsEnabled) app.use(denialLogging(audit), requireTls());
|
|
113
134
|
|
|
@@ -181,7 +202,11 @@ const pairErr = (
|
|
|
181
202
|
// PairingService enforces its own per-IP code/verify buckets + fail-window
|
|
182
203
|
// here. Host-allowlist + denial logging still apply.
|
|
183
204
|
const pairGuard = [denialLogging(audit), guard(allowedHosts)] as const;
|
|
184
|
-
|
|
205
|
+
// The operator side of pairing (mint/state/approve/cancel) is admin surface:
|
|
206
|
+
// it needs the admin cookie or bearer too. pair/start + pair/verify stay public
|
|
207
|
+
// — their bearer is the pairing code, presented by the DEVICE, not the operator.
|
|
208
|
+
const adminPairGuard = [denialLogging(audit), guard(allowedHosts), adminAuth] as const;
|
|
209
|
+
app.post("/api/pair/code", ...adminPairGuard, express.json({ limit: "16kb" }), (req, res) => {
|
|
185
210
|
const r = pairing.createCode(req.ip ?? "unknown");
|
|
186
211
|
return r.ok ? res.status(201).json(r.value) : pairErr(r, res);
|
|
187
212
|
});
|
|
@@ -194,31 +219,76 @@ app.post("/api/pair/verify", ...pairGuard, express.json({ limit: "16kb" }), asyn
|
|
|
194
219
|
if (r.ok) return res.status(r.status).json(r.value);
|
|
195
220
|
pairErr(r, res);
|
|
196
221
|
});
|
|
197
|
-
app.get("/api/pair/codes/:codeId", ...
|
|
222
|
+
app.get("/api/pair/codes/:codeId", ...adminPairGuard, (req, res) => {
|
|
198
223
|
const r = pairing.status(String(req.params.codeId));
|
|
199
224
|
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
200
225
|
});
|
|
201
226
|
app.post(
|
|
202
227
|
"/api/pair/codes/:codeId/approve",
|
|
203
|
-
...
|
|
228
|
+
...adminPairGuard,
|
|
204
229
|
express.json({ limit: "16kb" }),
|
|
205
230
|
(req, res) => {
|
|
206
231
|
const r = pairing.approve(String(req.params.codeId), req.body);
|
|
207
232
|
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
208
233
|
},
|
|
209
234
|
);
|
|
210
|
-
app.post("/api/pair/codes/:codeId/cancel", ...
|
|
235
|
+
app.post("/api/pair/codes/:codeId/cancel", ...adminPairGuard, (req, res) => {
|
|
211
236
|
const r = pairing.cancel(String(req.params.codeId));
|
|
212
237
|
return r.ok ? res.json(r.value) : pairErr(r, res);
|
|
213
238
|
});
|
|
214
239
|
|
|
215
|
-
// Admin /api
|
|
240
|
+
// Admin login: POST /api/session {token} → the dpi_admin cookie. Own per-IP bucket
|
|
241
|
+
// (5/min burst 5) — a failed guess must never be retried at line rate. Audited as
|
|
242
|
+
// admin.login ok|denied with the client address as the actor and no token material.
|
|
243
|
+
const loginBuckets = new TokenBuckets({
|
|
244
|
+
capacity: LOGIN_RATE.capacity,
|
|
245
|
+
perMinute: LOGIN_RATE.perMinute,
|
|
246
|
+
maxKeys: 10_000,
|
|
247
|
+
idleTtlMs: 15 * 60_000,
|
|
248
|
+
});
|
|
249
|
+
setInterval(() => loginBuckets.sweep(), 60_000).unref();
|
|
250
|
+
app.post(
|
|
251
|
+
"/api/session",
|
|
252
|
+
denialLogging(audit),
|
|
253
|
+
guard(allowedHosts),
|
|
254
|
+
(req, res, next) => {
|
|
255
|
+
const taken = loginBuckets.take(ipKey(req.ip ?? req.socket.remoteAddress ?? "?"));
|
|
256
|
+
if (taken.ok) return void next();
|
|
257
|
+
res.set("Retry-After", String(Math.max(1, Math.ceil(taken.retryAfterMs / 1000))));
|
|
258
|
+
res.status(429).json(err("rate_limited", "Too many login attempts; slow down"));
|
|
259
|
+
},
|
|
260
|
+
express.json({ limit: "16kb" }),
|
|
261
|
+
(req, res) => {
|
|
262
|
+
const presented = typeof req.body?.token === "string" ? req.body.token : "";
|
|
263
|
+
const ip = req.ip ?? "unknown";
|
|
264
|
+
if (!presented || !tokenEquals(presented, adminToken.token)) {
|
|
265
|
+
audit.denied("admin.login", ip, { reason: "bad_token" });
|
|
266
|
+
return void res.status(401).json(err("unauthorized", "Missing or invalid admin token"));
|
|
267
|
+
}
|
|
268
|
+
const exp = Date.now() + SESSION_TTL_MS;
|
|
269
|
+
audit.append({ actor: `ip:${ipKey(ip)}`, action: "admin.login", outcome: "ok" });
|
|
270
|
+
// Secure only when the request arrived via the TLS proxy: on plain loopback
|
|
271
|
+
// (local development, tests) the flag would just break the cookie.
|
|
272
|
+
res.cookie(ADMIN_COOKIE, signSession(adminToken.token, exp), {
|
|
273
|
+
httpOnly: true,
|
|
274
|
+
sameSite: "strict",
|
|
275
|
+
secure: viaTls(req),
|
|
276
|
+
path: "/",
|
|
277
|
+
maxAge: SESSION_TTL_MS,
|
|
278
|
+
});
|
|
279
|
+
res.json({ ok: true, exp });
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Admin /api surface: guarded, rate-bucketed per real client IP, 256 KiB JSON,
|
|
284
|
+
// and behind the admin credential (cookie or bearer).
|
|
216
285
|
app.use(
|
|
217
286
|
"/api",
|
|
218
287
|
denialLogging(audit),
|
|
219
288
|
guard(allowedHosts),
|
|
220
289
|
apiRate,
|
|
221
290
|
express.json({ limit: "256kb" }),
|
|
291
|
+
adminAuth,
|
|
222
292
|
);
|
|
223
293
|
app.post("/api/pair/resolve", (req, res) => {
|
|
224
294
|
const r = pairing.resolveCode(req.body);
|
|
@@ -435,6 +505,9 @@ server.listen(port, host, () => {
|
|
|
435
505
|
console.log(
|
|
436
506
|
"TLS required: only requests the loopback TLS proxy marks X-Forwarded-Proto: https are served",
|
|
437
507
|
);
|
|
508
|
+
console.log(
|
|
509
|
+
`admin: login with the token in ${adminToken.path} (POST /api/session sets the browser cookie)`,
|
|
510
|
+
);
|
|
438
511
|
if (host !== "127.0.0.1" && host !== "localhost")
|
|
439
512
|
console.warn(
|
|
440
513
|
`WARNING: the hub is listening on ${host}. Anyone who can reach it can drive every device it holds tokens for.`,
|
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
PairRequest as PairRequestSchema,
|
|
40
40
|
PairResponse as PairResponseSchema,
|
|
41
41
|
checkPairingDto,
|
|
42
|
+
encodePairOffer,
|
|
43
|
+
isPairCode,
|
|
44
|
+
normalizePairCode,
|
|
42
45
|
pairingSigningPayload,
|
|
43
46
|
} from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
44
47
|
import type { PairCodeIssue, TokenIssue } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
@@ -52,7 +55,18 @@ import type { PairCodeState } from "../../../../dpi-sdk/src/protocol/pairing.js"
|
|
|
52
55
|
const CODE_TTL_MS = 300_000;
|
|
53
56
|
const NONCE_TTL_MS = 60_000;
|
|
54
57
|
const RECEIPT_GRACE_MS = 120_000;
|
|
55
|
-
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A code as a human types it (`abcd-2345-ef67`) is canonicalised before the strict wire
|
|
61
|
+
* schema sees it: only the code field is rewritten, so unknown fields are still rejected.
|
|
62
|
+
*/
|
|
63
|
+
function withCanonicalCode(body: unknown): unknown {
|
|
64
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
65
|
+
const raw = (body as { code?: unknown }).code;
|
|
66
|
+
if (typeof raw !== "string") return body;
|
|
67
|
+
const code = normalizePairCode(raw);
|
|
68
|
+
return code && code !== raw ? { ...(body as Record<string, unknown>), code } : body;
|
|
69
|
+
}
|
|
56
70
|
|
|
57
71
|
export type PairingFailure = {
|
|
58
72
|
ok: false;
|
|
@@ -191,6 +205,21 @@ export class PairingService {
|
|
|
191
205
|
return { ok: true, value };
|
|
192
206
|
}
|
|
193
207
|
|
|
208
|
+
/**
|
|
209
|
+
* v2 offer link, minted from the SAME values the v1 pairUrl uses (same code,
|
|
210
|
+
* same expiry): the whole handoff — hub origin, hubId, code, expiry — rides
|
|
211
|
+
* the #offer= fragment, which no server or CDN ever sees in a log.
|
|
212
|
+
*/
|
|
213
|
+
private offerUrlFor(code: string, expiresAt: number): string {
|
|
214
|
+
return `${this.deps.hubOrigin}/pair#offer=${encodePairOffer({
|
|
215
|
+
v: 2,
|
|
216
|
+
hub: this.deps.hubOrigin,
|
|
217
|
+
id: this.deps.hubId,
|
|
218
|
+
code,
|
|
219
|
+
exp: expiresAt,
|
|
220
|
+
})}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
/** POST /api/pair/code (admin) — mint a fresh single-use code. */
|
|
195
224
|
createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
196
225
|
const now = this.now();
|
|
@@ -200,7 +229,9 @@ export class PairingService {
|
|
|
200
229
|
return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
|
|
201
230
|
let code = "";
|
|
202
231
|
do {
|
|
203
|
-
code
|
|
232
|
+
// A code is a bearer secret authorising one enrollment: canonical UUID v4 = 122 random bits.
|
|
233
|
+
// The re-mint guard is kept: it costs nothing and makes "two live codes share a string" impossible.
|
|
234
|
+
code = randomUUID();
|
|
204
235
|
} while (this.byCode.has(code));
|
|
205
236
|
const entry: CodeEntry = {
|
|
206
237
|
codeId: randomUUID(),
|
|
@@ -225,18 +256,19 @@ export class PairingService {
|
|
|
225
256
|
expiresAt: entry.expiresAt,
|
|
226
257
|
hubOrigin: this.deps.hubOrigin,
|
|
227
258
|
pairUrl: `${this.deps.hubOrigin}/pair?code=${code}&v=1`,
|
|
259
|
+
offerUrl: this.offerUrlFor(code, entry.expiresAt),
|
|
228
260
|
});
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
/** Admin handoff lookup. Reuses a code; never creates an enrollment or approves it. */
|
|
232
264
|
resolveCode(body: unknown): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
233
|
-
const
|
|
265
|
+
const raw = (body as { code?: unknown } | null)?.code;
|
|
266
|
+
const code = typeof raw === "string" ? normalizePairCode(raw) : undefined;
|
|
234
267
|
if (
|
|
235
268
|
!body ||
|
|
236
269
|
typeof body !== "object" ||
|
|
237
270
|
Object.keys(body).length !== 1 ||
|
|
238
|
-
|
|
239
|
-
!/^[A-Z2-7]{8}$/.test(code)
|
|
271
|
+
!isPairCode(code)
|
|
240
272
|
)
|
|
241
273
|
return this.fail(400, "code_invalid", "Invalid pairing handoff");
|
|
242
274
|
const codeId = this.byCode.get(code);
|
|
@@ -252,6 +284,7 @@ export class PairingService {
|
|
|
252
284
|
expiresAt: entry.expiresAt,
|
|
253
285
|
hubOrigin: this.deps.hubOrigin,
|
|
254
286
|
pairUrl: `${this.deps.hubOrigin}/pair?code=${entry.code}&v=1`,
|
|
287
|
+
offerUrl: this.offerUrlFor(entry.code, entry.expiresAt),
|
|
255
288
|
});
|
|
256
289
|
}
|
|
257
290
|
|
|
@@ -272,11 +305,13 @@ export class PairingService {
|
|
|
272
305
|
return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
|
|
273
306
|
const taken = this.startLimiter.take(ip);
|
|
274
307
|
if (!taken.ok) return this.rateLimited(taken, "Too many pairing starts; slow down");
|
|
275
|
-
|
|
308
|
+
// A grouped or lower-case code from a client is the same code: canonicalise it first.
|
|
309
|
+
const request = withCanonicalCode(body);
|
|
310
|
+
if (!checkPairingDto(PairRequestSchema, request)) {
|
|
276
311
|
this.noteFailure(ip);
|
|
277
312
|
return this.fail(400, "invalid", "pair.request body failed schema validation");
|
|
278
313
|
}
|
|
279
|
-
const req =
|
|
314
|
+
const req = request as {
|
|
280
315
|
requestId: string;
|
|
281
316
|
code: string;
|
|
282
317
|
deviceUid: string;
|
|
@@ -72,6 +72,17 @@ export const RECONNECT_BACKOFF_S = { initS: 1, capS: 15 } as const;
|
|
|
72
72
|
/** Monitor probe backoff: the same curve with a 30 s cap. */
|
|
73
73
|
export const MONITOR_BACKOFF_S = { initS: 1, capS: 30 } as const;
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Pairing codes are minted as canonical lower-case UUID v4 (122 random bits): the code is a
|
|
77
|
+
* bearer secret that authorises one enrollment, so 40/60-bit base32 was too short.
|
|
78
|
+
* `PAIR_CODE_LENGTH` and `PAIR_CODE_LEGACY_LENGTH` are the base32 lengths PARSING still accepts
|
|
79
|
+
* for links already in flight; the hub never mints them anymore.
|
|
80
|
+
*/
|
|
81
|
+
export const PAIR_CODE_UUID_LENGTH = 36;
|
|
82
|
+
/** Legacy base32 code length (60 bits), accepted when parsing only. */
|
|
83
|
+
export const PAIR_CODE_LENGTH = 12;
|
|
84
|
+
/** Oldest legacy base32 code length (40 bits), accepted when parsing only. */
|
|
85
|
+
export const PAIR_CODE_LEGACY_LENGTH = 8;
|
|
75
86
|
/** Pairing code TTL by hub clock. 5 min. */
|
|
76
87
|
export const PAIR_CODE_TTL_MS = 300_000;
|
|
77
88
|
/** Challenge nonce TTL. 60 s. */
|
|
@@ -82,6 +93,12 @@ export const PAIR_RECEIPT_GRACE_MS = 120_000;
|
|
|
82
93
|
export const PAIR_SIGNATURE_FAIL_LIMIT = 3;
|
|
83
94
|
/** Clock-skew tolerance for challenges accepted by the device. ±60 s. */
|
|
84
95
|
export const PAIR_CLOCK_SKEW_MS = 60_000;
|
|
96
|
+
/** Pair-offer decode window: exp may sit this far in the PAST (clock skew). 30 s. */
|
|
97
|
+
export const PAIR_OFFER_SKEW_MS = 30_000;
|
|
98
|
+
/** Pair-offer decode window: exp may sit at most this far in the FUTURE. 10 min. */
|
|
99
|
+
export const PAIR_OFFER_MAX_AHEAD_MS = 600_000;
|
|
100
|
+
/** Decoded pair-offer JSON byte cap — minted offers are ~180 bytes; anything bigger is not ours. */
|
|
101
|
+
export const PAIR_OFFER_MAX_JSON_BYTES = 1_024;
|
|
85
102
|
|
|
86
103
|
/** Rate limits (per real-IP, hub-side token buckets). */
|
|
87
104
|
export const PAIR_RATE_LIMITS = {
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Type, type Static } from "@sinclair/typebox";
|
|
12
12
|
import { Value } from "@sinclair/typebox/value";
|
|
13
|
+
import {
|
|
14
|
+
PAIR_CODE_LENGTH,
|
|
15
|
+
PAIR_CODE_LEGACY_LENGTH,
|
|
16
|
+
PAIR_CODE_UUID_LENGTH,
|
|
17
|
+
PAIR_OFFER_MAX_AHEAD_MS,
|
|
18
|
+
PAIR_OFFER_MAX_JSON_BYTES,
|
|
19
|
+
PAIR_OFFER_SKEW_MS,
|
|
20
|
+
} from "./limits.js";
|
|
13
21
|
|
|
14
22
|
const Strict = { additionalProperties: false } as const;
|
|
15
23
|
|
|
@@ -29,7 +37,57 @@ const DeviceId = Type.String({
|
|
|
29
37
|
const Fingerprint = Type.String({ pattern: "^sha256:[0-9a-f]{64}$", minLength: 71, maxLength: 71 });
|
|
30
38
|
const Nonce = Type.String({ pattern: "^[A-Za-z0-9_-]{43}$", minLength: 43, maxLength: 43 });
|
|
31
39
|
const TunnelToken = Type.String({ pattern: "^dpt_[A-Za-z0-9_-]{43}$", minLength: 47, maxLength: 47 });
|
|
32
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Canonical lower-case UUID v4: any hex is NOT a code, so the version nibble must be 4 and the
|
|
42
|
+
* variant nibble 8/9/a/b.
|
|
43
|
+
*/
|
|
44
|
+
const UUID_V4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
45
|
+
/**
|
|
46
|
+
* One pattern for every accepted code shape: the minted UUID v4, plus the 12- and 8-char base32
|
|
47
|
+
* forms that links already in flight still carry. `$` alone can match before a final newline, so
|
|
48
|
+
* the pattern ends with an explicit lookahead — the length clamp below cannot catch a newline
|
|
49
|
+
* when min and max differ.
|
|
50
|
+
*/
|
|
51
|
+
export const PAIR_CODE_PATTERN = `^(?:${UUID_V4}|[A-Z2-7]{12}|[A-Z2-7]{8})(?![\\s\\S])`;
|
|
52
|
+
const PairCode = Type.String({
|
|
53
|
+
pattern: PAIR_CODE_PATTERN,
|
|
54
|
+
minLength: PAIR_CODE_LEGACY_LENGTH,
|
|
55
|
+
maxLength: PAIR_CODE_UUID_LENGTH,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const PAIR_CODE_RE = new RegExp(PAIR_CODE_PATTERN);
|
|
59
|
+
/** Shape test for the UUID family — used to pick the normalisation rule, never to accept on its own. */
|
|
60
|
+
const UUID_SHAPED_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Exact-shape check for a pairing code, with NO trimming: a value with a leading/trailing space
|
|
64
|
+
* or a newline is not a code. Consumers that accept a typed value use `normalizePairCode`.
|
|
65
|
+
*/
|
|
66
|
+
export function isPairCode(value: unknown): value is string {
|
|
67
|
+
if (typeof value !== "string" || value.length > PAIR_CODE_UUID_LENGTH) return false;
|
|
68
|
+
return PAIR_CODE_RE.test(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Canonicalise a code a human typed or pasted. The two families canonicalise differently, so the
|
|
73
|
+
* shape decides first:
|
|
74
|
+
* - UUID v4 (`A1B2…-E5F6-4789-ABCD-EFF012345678`): hyphens are structure — only the case changes.
|
|
75
|
+
* - base32 (`abcd-2345-ef67`, `ABCD 2345 EF67`): hyphens/spaces are group separators — they go
|
|
76
|
+
* and the result is upper case.
|
|
77
|
+
* Anything that does not fit either family exactly (a hyphen-less hex run, a 10/11/13-char run,
|
|
78
|
+
* non-alphabet characters, a truncated group like `ABCD-2345`) is refused.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizePairCode(raw: string): string | undefined {
|
|
81
|
+
if (typeof raw !== "string") return undefined;
|
|
82
|
+
if (UUID_SHAPED_RE.test(raw)) {
|
|
83
|
+
const lower = raw.toLowerCase();
|
|
84
|
+
return isPairCode(lower) ? lower : undefined; // version/variant nibbles must be v4
|
|
85
|
+
}
|
|
86
|
+
const compact = raw.replace(/[\s-]+/g, "").toUpperCase();
|
|
87
|
+
if (!isPairCode(compact)) return undefined;
|
|
88
|
+
if (/[\s-]/.test(raw) && compact.length !== PAIR_CODE_LENGTH) return undefined;
|
|
89
|
+
return compact;
|
|
90
|
+
}
|
|
33
91
|
|
|
34
92
|
export const PairRequest = Type.Object(
|
|
35
93
|
{
|
|
@@ -163,6 +221,8 @@ export const PairCodeIssue = Type.Object(
|
|
|
163
221
|
expiresAt: Type.Integer(),
|
|
164
222
|
hubOrigin: Type.String({ maxLength: 2048 }),
|
|
165
223
|
pairUrl: Type.String({ maxLength: 4096 }),
|
|
224
|
+
/** v2 offer link: the whole handoff rides the #offer= fragment (never a query). */
|
|
225
|
+
offerUrl: Type.Optional(Type.String({ maxLength: 2048 })),
|
|
166
226
|
},
|
|
167
227
|
Strict,
|
|
168
228
|
);
|
|
@@ -270,6 +330,146 @@ export function parseHubOrigin(
|
|
|
270
330
|
return { ok: true, origin: url.origin, tunnelUrl: `${url.origin}/api/tunnel` };
|
|
271
331
|
}
|
|
272
332
|
|
|
333
|
+
/**
|
|
334
|
+
* v2 pairing offer — ONE artifact carrying identity, trust anchor and transport:
|
|
335
|
+
* hub origin + hubId + code + expiry, base64url-encoded into the URL FRAGMENT
|
|
336
|
+
* (`<hubOrigin>/pair#offer=…`). A fragment never leaves the client, so the
|
|
337
|
+
* code cannot reach server/CDN query logs, and the origin travels inside the
|
|
338
|
+
* link (no --hub needed on the device).
|
|
339
|
+
*/
|
|
340
|
+
export interface PairOffer {
|
|
341
|
+
v: 2;
|
|
342
|
+
/** Canonical HTTPS hub origin (the transport the device pairs against). */
|
|
343
|
+
hub: string;
|
|
344
|
+
/** hubId trust anchor (deployment id), a canonical UUID. */
|
|
345
|
+
id: string;
|
|
346
|
+
/** The single-use pairing code — canonical UUID v4, the only minted family. */
|
|
347
|
+
code: string;
|
|
348
|
+
/** Expiry, hub epoch ms. */
|
|
349
|
+
exp: number;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** base64url (no padding) of UTF-8 bytes — browser-safe, no Node builtins. */
|
|
353
|
+
function offerEncode(bytes: Uint8Array): string {
|
|
354
|
+
let binary = "";
|
|
355
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
356
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function offerDecode(raw: string): Uint8Array | undefined {
|
|
360
|
+
if (raw === "" || !/^[A-Za-z0-9_-]+$/.test(raw)) return undefined;
|
|
361
|
+
try {
|
|
362
|
+
const binary = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
|
|
363
|
+
const bytes = new Uint8Array(binary.length);
|
|
364
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
365
|
+
return bytes;
|
|
366
|
+
} catch {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const OFFER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
372
|
+
/** The minted family only: offer links carry a UUID v4 code, never legacy base32. */
|
|
373
|
+
const OFFER_CODE_RE = new RegExp(`^${UUID_V4}$`);
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Encode an offer to the exact fragment payload: canonical JSON (field order
|
|
377
|
+
* v,hub,id,code,exp — byte-lean and deterministic), base64url, no padding.
|
|
378
|
+
* Pure encoder: it does not validate; callers mint from live hub values.
|
|
379
|
+
*/
|
|
380
|
+
export function encodePairOffer(offer: PairOffer): string {
|
|
381
|
+
return offerEncode(
|
|
382
|
+
new TextEncoder().encode(
|
|
383
|
+
JSON.stringify({ v: offer.v, hub: offer.hub, id: offer.id, code: offer.code, exp: offer.exp }),
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Structural decode (freshness-blind): exactly the five known fields, v===2,
|
|
390
|
+
* canonical HTTPS origin (byte-exact, reusing the parseHubOrigin rules),
|
|
391
|
+
* canonical UUIDs for id and code, safe-integer exp. Unknown or malformed
|
|
392
|
+
* input → undefined, never a throw. Exported so a caller can tell an EXPIRED
|
|
393
|
+
* offer apart from junk (decodePairOffer refuses both).
|
|
394
|
+
*/
|
|
395
|
+
export function readPairOffer(raw: string): PairOffer | undefined {
|
|
396
|
+
const bytes = offerDecode(raw);
|
|
397
|
+
if (!bytes || bytes.length > PAIR_OFFER_MAX_JSON_BYTES) return undefined;
|
|
398
|
+
let json: unknown;
|
|
399
|
+
try {
|
|
400
|
+
json = JSON.parse(new TextDecoder().decode(bytes));
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return undefined;
|
|
405
|
+
const o = json as Record<string, unknown>;
|
|
406
|
+
if (Object.keys(o).length !== 5 || o.v !== 2) return undefined; // unknown/missing fields refuse
|
|
407
|
+
if (typeof o.hub !== "string") return undefined;
|
|
408
|
+
const origin = parseHubOrigin(o.hub);
|
|
409
|
+
if (!origin.ok || origin.origin !== o.hub) return undefined; // canonical form, byte-exact
|
|
410
|
+
if (typeof o.id !== "string" || !OFFER_UUID_RE.test(o.id)) return undefined;
|
|
411
|
+
if (typeof o.code !== "string" || !OFFER_CODE_RE.test(o.code)) return undefined;
|
|
412
|
+
if (typeof o.exp !== "number" || !Number.isSafeInteger(o.exp)) return undefined;
|
|
413
|
+
return { v: 2, hub: o.hub, id: o.id, code: o.code, exp: o.exp };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Strict decode: the structural rules above PLUS freshness — exp is in the
|
|
418
|
+
* future (30 s clock skew allowed) and no further ahead than 10 minutes.
|
|
419
|
+
* Unknown/malformed/stale → undefined, never a throw.
|
|
420
|
+
*/
|
|
421
|
+
export function decodePairOffer(raw: string, now: number = Date.now()): PairOffer | undefined {
|
|
422
|
+
const offer = readPairOffer(raw);
|
|
423
|
+
if (!offer) return undefined;
|
|
424
|
+
if (offer.exp < now - PAIR_OFFER_SKEW_MS) return undefined;
|
|
425
|
+
if (offer.exp > now + PAIR_OFFER_MAX_AHEAD_MS) return undefined;
|
|
426
|
+
return offer;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface ParsedPairOfferUrl {
|
|
430
|
+
offer: PairOffer;
|
|
431
|
+
/** The URL minus its fragment (`<origin>/pair`) — safe to keep in history. */
|
|
432
|
+
rest: string;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The raw `#offer=` payload of a URL, when the fragment is exactly one
|
|
437
|
+
* unpadded base64url run — undefined otherwise. The cheap first look a caller
|
|
438
|
+
* uses to tell "this is an offer link" apart from a v1 pair URL.
|
|
439
|
+
*/
|
|
440
|
+
export function pairOfferFragment(url: string): string | undefined {
|
|
441
|
+
let u: URL;
|
|
442
|
+
try {
|
|
443
|
+
u = new URL(url);
|
|
444
|
+
} catch {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
if (!u.hash.startsWith("#offer=")) return undefined;
|
|
448
|
+
const payload = u.hash.slice("#offer=".length);
|
|
449
|
+
return /^[A-Za-z0-9_-]+$/.test(payload) ? payload : undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Parse an offer LINK: only `<origin>/pair` (or `/pair/`) with the fragment
|
|
454
|
+
* `#offer=<payload>` and nothing else — a stray query or any other path →
|
|
455
|
+
* undefined. The offer's hub must equal the link's own origin (the hub minted
|
|
456
|
+
* this link); the fragment never travels server-side.
|
|
457
|
+
*/
|
|
458
|
+
export function parsePairOfferUrl(url: string, now: number = Date.now()): ParsedPairOfferUrl | undefined {
|
|
459
|
+
let u: URL;
|
|
460
|
+
try {
|
|
461
|
+
u = new URL(url);
|
|
462
|
+
} catch {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
if (!/^\/pair\/?$/.test(u.pathname)) return undefined;
|
|
466
|
+
if (u.username || u.password || u.search) return undefined; // the payload rides the fragment only
|
|
467
|
+
if (!u.hash.startsWith("#offer=")) return undefined;
|
|
468
|
+
const offer = decodePairOffer(u.hash.slice("#offer=".length), now);
|
|
469
|
+
if (!offer || offer.hub !== u.origin) return undefined;
|
|
470
|
+
return { offer, rest: `${u.origin}/pair` };
|
|
471
|
+
}
|
|
472
|
+
|
|
273
473
|
/** Exact Ed25519 signing payload — element order is normative (03 §4.2). */
|
|
274
474
|
export function pairingSigningPayload(parts: {
|
|
275
475
|
hubOrigin: string;
|
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "../../dpi-sdk/src/api.ts";
|
|
27
27
|
import type { NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
|
|
28
28
|
import { COMMANDS, runCommand } from "./commands.ts";
|
|
29
|
-
import { pickModel, pickSession, Prompt } from "./pickers.ts";
|
|
29
|
+
import { pickModel, pickSession, Prompt, SecretPrompt } from "./pickers.ts";
|
|
30
30
|
import { Line, spinner, TranscriptView } from "./render.ts";
|
|
31
31
|
import { c, editorTheme } from "./theme.ts";
|
|
32
32
|
|
|
@@ -249,6 +249,11 @@ export class App {
|
|
|
249
249
|
this.tui.requestRender();
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** How many overlays are open (the welcome screen waits for panels to close). */
|
|
253
|
+
get overlayCount() {
|
|
254
|
+
return this.overlays.length;
|
|
255
|
+
}
|
|
256
|
+
|
|
252
257
|
closeOverlay(exitIfNothing = false) {
|
|
253
258
|
const overlay = this.overlays.pop();
|
|
254
259
|
overlay?.handle.hide();
|
|
@@ -277,6 +282,25 @@ export class App {
|
|
|
277
282
|
});
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
/** Like prompt(), but the value is only ever rendered as bullets (tokens). */
|
|
286
|
+
promptSecret(label: string) {
|
|
287
|
+
return new Promise<string | undefined>((done) => {
|
|
288
|
+
this.overlay(
|
|
289
|
+
new SecretPrompt(
|
|
290
|
+
label,
|
|
291
|
+
(value) => {
|
|
292
|
+
this.closeOverlay();
|
|
293
|
+
done(value.trim());
|
|
294
|
+
},
|
|
295
|
+
() => {
|
|
296
|
+
this.closeOverlay();
|
|
297
|
+
done(undefined);
|
|
298
|
+
},
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
280
304
|
flash(text: string, error = false) {
|
|
281
305
|
this.notice = { text, error, at: Date.now() };
|
|
282
306
|
this.redraw(false);
|