@openparachute/agent 0.2.3-rc.4 → 0.2.3-rc.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/package.json +1 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/daemon.ts +235 -10
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-CAQMmePW.js +60 -0
- package/web/ui/dist/assets/{index-tvKbxee4.css → index-Dhr5Kl_d.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-0e7eQymr.js +0 -60
package/src/step-up.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-up auth (PIN) for high-privilege agent-admin actions (agent#80).
|
|
3
|
+
*
|
|
4
|
+
* ## The risk this closes
|
|
5
|
+
*
|
|
6
|
+
* A single authenticated `agent:admin` session (the operator's hub cookie traded
|
|
7
|
+
* for an `agent:admin` Bearer) can today do ANYTHING dangerous with no re-confirm:
|
|
8
|
+
* - set/rotate credentials (the Claude OAuth token + the generic env store)
|
|
9
|
+
* → exfiltrate vault / channel / Claude tokens,
|
|
10
|
+
* - open a TERMINAL → a raw host shell,
|
|
11
|
+
* - spawn a `filesystem: full` (unsandboxed) agent → read the whole disk.
|
|
12
|
+
* "One session = total control over the operator's vault + tokens."
|
|
13
|
+
*
|
|
14
|
+
* ## The design (mirrors hub's admin-lock PIN, design `2026-06-17-admin-ui-lock.md`)
|
|
15
|
+
*
|
|
16
|
+
* A SECOND factor on top of `agent:admin`: an operator-set PIN, exchanged for a
|
|
17
|
+
* short-lived **step-up token**. The dangerous endpoints require BOTH a valid
|
|
18
|
+
* `agent:admin` Bearer AND a valid step-up token; everything else stays
|
|
19
|
+
* frictionless.
|
|
20
|
+
*
|
|
21
|
+
* 1. **PIN, set once by the operator** (`setStepUpPin`). Stored HASHED + SALTED
|
|
22
|
+
* (`Bun.password.hash`, argon2id — salt is embedded in the PHC string) in
|
|
23
|
+
* `~/.parachute/agent/step-up.json`, mode 0600. NEVER plaintext, NEVER logged,
|
|
24
|
+
* NEVER returned. Setting/changing it requires the current `agent:admin`
|
|
25
|
+
* session and, if a PIN already exists, the current PIN.
|
|
26
|
+
* 2. **Step-up exchange** (`mintStepUpToken` after `verifyStepUpPin`): an opaque
|
|
27
|
+
* 256-bit CSPRNG nonce, TTL ~5min, held server-side in a TTL'd map. REUSABLE
|
|
28
|
+
* within its window (unlike the single-use SSE ticket) — one PIN entry buys a
|
|
29
|
+
* short working window across several gated actions.
|
|
30
|
+
* 3. **Gate** (`requireStepUp` in `auth.ts`): the dangerous endpoints assert a
|
|
31
|
+
* valid step-up token (header `X-Step-Up-Token`, or `?step_up=` for the
|
|
32
|
+
* terminal WebSocket which can't set a header) in addition to `agent:admin`.
|
|
33
|
+
* A missing/expired token → `403 { error: "step_up_required" }`, distinct from
|
|
34
|
+
* a plain 401 (no/invalid Bearer), so the UI knows to PROMPT vs RE-AUTH.
|
|
35
|
+
*
|
|
36
|
+
* ## Security properties (all load-bearing)
|
|
37
|
+
*
|
|
38
|
+
* - PIN hashed + salted (argon2id); never logged / returned. The hash never
|
|
39
|
+
* leaves this module — the only readers are `verifyStepUpPin` + `setStepUpPin`.
|
|
40
|
+
* - Rate-limited with LOCKOUT ({@link stepUpLimiter}): a compromised `agent:admin`
|
|
41
|
+
* session can't brute-force the PIN. 5 wrong PINs / 5 min, mirroring hub's
|
|
42
|
+
* `unlockLimiter`.
|
|
43
|
+
* - Step-up token: opaque (256-bit nonce), short TTL, SERVER-SIDE only. It NEVER
|
|
44
|
+
* widens scope — it's a second factor ON TOP of `agent:admin`, never a
|
|
45
|
+
* substitute. A request still needs its own valid `agent:admin` Bearer.
|
|
46
|
+
* - No secret in any log: neither the PIN nor the hash nor the token is ever
|
|
47
|
+
* written to a log line.
|
|
48
|
+
*
|
|
49
|
+
* Process-local in-memory token state by design (mirrors `ui-ticket.ts` + the
|
|
50
|
+
* daemon's other in-process registries). The daemon is single-instance per
|
|
51
|
+
* machine; tokens live ≤5min and are cheap to lose on restart (the UI re-prompts).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "fs";
|
|
55
|
+
import { join } from "path";
|
|
56
|
+
import { defaultStateDir } from "./registry.ts";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// PIN storage (argon2id hash in step-up.json, mode 0600)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* PIN format: 4–12 digits. A numeric PIN is the phone-lock affordance (mirrors
|
|
64
|
+
* hub's `ADMIN_LOCK_PIN_RE`). The real defense is the rate-limiter + the fact the
|
|
65
|
+
* session is already `agent:admin`-authenticated — this is a second, convenience-
|
|
66
|
+
* grade re-confirm gate, not a high-entropy secret.
|
|
67
|
+
*/
|
|
68
|
+
export const STEP_UP_PIN_RE = /^[0-9]{4,12}$/;
|
|
69
|
+
|
|
70
|
+
/** Whether a candidate string is a well-formed PIN (format check only). */
|
|
71
|
+
export function isValidPinFormat(pin: unknown): pin is string {
|
|
72
|
+
return typeof pin === "string" && STEP_UP_PIN_RE.test(pin);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The on-disk `step-up.json` shape. Namespaced so a future field can coexist. */
|
|
76
|
+
interface StepUpFile {
|
|
77
|
+
/** argon2id PHC hash of the operator PIN (salt embedded). Never plaintext. */
|
|
78
|
+
pinHash?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Absolute path to the step-up.json store in a state dir. */
|
|
82
|
+
export function stepUpFilePath(stateDir?: string): string {
|
|
83
|
+
return join(stateDir ?? defaultStateDir(), "step-up.json");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read `step-up.json`. Returns `{}` when absent. */
|
|
87
|
+
function readStepUpFile(stateDir?: string): StepUpFile {
|
|
88
|
+
const file = stepUpFilePath(stateDir);
|
|
89
|
+
if (!existsSync(file)) return {};
|
|
90
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as StepUpFile;
|
|
91
|
+
if (!parsed || typeof parsed !== "object") {
|
|
92
|
+
throw new Error(`step-up: ${file} must be a JSON object`);
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Persist `step-up.json` 0600 — it holds the PIN hash. Creates the state dir if
|
|
99
|
+
* needed; `chmod`s 0600 unconditionally (writeFileSync's `mode` only applies on
|
|
100
|
+
* CREATE, so an existing file under a looser umask is tightened on every write) —
|
|
101
|
+
* the exact discipline `credentials.ts` / `registry.ts` keep for secrets.
|
|
102
|
+
*/
|
|
103
|
+
function writeStepUpFile(file: StepUpFile, stateDir?: string): void {
|
|
104
|
+
const dir = stateDir ?? defaultStateDir();
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const path = stepUpFilePath(dir);
|
|
107
|
+
writeFileSync(path, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
|
|
108
|
+
chmodSync(path, 0o600);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True iff a step-up PIN is configured (the feature is set up for this install). */
|
|
112
|
+
export function isStepUpConfigured(stateDir?: string): boolean {
|
|
113
|
+
const h = readStepUpFile(stateDir).pinHash;
|
|
114
|
+
return typeof h === "string" && h.length > 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Thrown by {@link setStepUpPin} when the PIN format is rejected. */
|
|
118
|
+
export class StepUpPinFormatError extends Error {
|
|
119
|
+
constructor() {
|
|
120
|
+
super("PIN must be 4–12 digits");
|
|
121
|
+
this.name = "StepUpPinFormatError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Set (first-time) or rotate the step-up PIN. Hashes with argon2id
|
|
127
|
+
* (`Bun.password.hash` — salted, salt embedded in the PHC string). The CALLER
|
|
128
|
+
* must enforce that:
|
|
129
|
+
* - the request is `agent:admin`-authenticated, and
|
|
130
|
+
* - if a PIN ALREADY exists, the current PIN was verified first
|
|
131
|
+
* ({@link verifyStepUpPin}) — rotating a PIN needs the old one.
|
|
132
|
+
* This function trusts that gating; it only validates format + writes the hash.
|
|
133
|
+
*
|
|
134
|
+
* Returns nothing. Throws {@link StepUpPinFormatError} on a malformed PIN.
|
|
135
|
+
*/
|
|
136
|
+
export async function setStepUpPin(newPin: string, stateDir?: string): Promise<void> {
|
|
137
|
+
if (!isValidPinFormat(newPin)) throw new StepUpPinFormatError();
|
|
138
|
+
const hash = await Bun.password.hash(newPin, "argon2id");
|
|
139
|
+
const file = readStepUpFile(stateDir);
|
|
140
|
+
file.pinHash = hash;
|
|
141
|
+
writeStepUpFile(file, stateDir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Verify a submitted PIN against the stored hash. Returns false when no PIN is
|
|
146
|
+
* configured (defensive — callers gate on {@link isStepUpConfigured} first) or the
|
|
147
|
+
* hash is malformed. The CALLER must run the rate-limiter BEFORE this (a wrong PIN
|
|
148
|
+
* must count toward the lockout). The PIN is never logged.
|
|
149
|
+
*/
|
|
150
|
+
export async function verifyStepUpPin(pin: string, stateDir?: string): Promise<boolean> {
|
|
151
|
+
if (typeof pin !== "string" || pin.length === 0) return false;
|
|
152
|
+
const hash = readStepUpFile(stateDir).pinHash;
|
|
153
|
+
if (typeof hash !== "string" || hash.length === 0) return false;
|
|
154
|
+
try {
|
|
155
|
+
return await Bun.password.verify(pin, hash);
|
|
156
|
+
} catch {
|
|
157
|
+
// Corrupt / unparseable hash — fail closed.
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Step-up token store — opaque nonce, TTL'd, REUSABLE within its window
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Default step-up token lifetime — 5 min (the issue's ~5min). Long enough for an
|
|
168
|
+
* operator to set a credential / open a terminal / spawn after one PIN entry,
|
|
169
|
+
* short enough that a stolen token (or a walk-away) is bounded.
|
|
170
|
+
*/
|
|
171
|
+
export const STEP_UP_TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
172
|
+
|
|
173
|
+
/** Nonce entropy: 32 bytes = 256 bits, matching the SSE ticket's floor. */
|
|
174
|
+
const STEP_UP_TOKEN_BYTES = 32;
|
|
175
|
+
|
|
176
|
+
/** A minted step-up token's server-side record. Never leaves the process. */
|
|
177
|
+
interface StepUpTokenRecord {
|
|
178
|
+
/** Epoch ms after which the token is expired (treated as absent). */
|
|
179
|
+
expiresAt: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The process-local step-up token store. nonce → record. */
|
|
183
|
+
const stepUpTokens = new Map<string, StepUpTokenRecord>();
|
|
184
|
+
|
|
185
|
+
/** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
|
|
186
|
+
function base64url(bytes: Uint8Array): string {
|
|
187
|
+
let bin = "";
|
|
188
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
189
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pruneExpiredStepUpTokens(now = Date.now()): void {
|
|
193
|
+
for (const [k, rec] of stepUpTokens) {
|
|
194
|
+
if (now >= rec.expiresAt) stepUpTokens.delete(k);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mint a step-up token valid for `ttlMs` (default {@link STEP_UP_TOKEN_TTL_MS}).
|
|
200
|
+
* The CALLER must have already verified the PIN — this never authenticates. The
|
|
201
|
+
* token is opaque (no scope/claims rides in it); it is purely a "the PIN was
|
|
202
|
+
* entered recently" capability checked alongside the `agent:admin` Bearer.
|
|
203
|
+
* Returns the nonce + its absolute expiry.
|
|
204
|
+
*/
|
|
205
|
+
export function mintStepUpToken(ttlMs = STEP_UP_TOKEN_TTL_MS): {
|
|
206
|
+
token: string;
|
|
207
|
+
expiresAt: number;
|
|
208
|
+
} {
|
|
209
|
+
pruneExpiredStepUpTokens();
|
|
210
|
+
const bytes = new Uint8Array(STEP_UP_TOKEN_BYTES);
|
|
211
|
+
crypto.getRandomValues(bytes);
|
|
212
|
+
const token = base64url(bytes);
|
|
213
|
+
const expiresAt = Date.now() + ttlMs;
|
|
214
|
+
stepUpTokens.set(token, { expiresAt });
|
|
215
|
+
return { token, expiresAt };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether a step-up token is currently valid. REUSABLE within its window (does NOT
|
|
220
|
+
* delete on read — unlike the single-use SSE ticket), so one PIN entry buys a short
|
|
221
|
+
* window across several gated actions. An absent / expired token returns false (and
|
|
222
|
+
* an expired one is lazily pruned). Pure in-memory lookup — no I/O, no secret log.
|
|
223
|
+
*/
|
|
224
|
+
export function isStepUpTokenValid(token: string | null | undefined, now = Date.now()): boolean {
|
|
225
|
+
if (!token) return false;
|
|
226
|
+
const rec = stepUpTokens.get(token);
|
|
227
|
+
if (!rec) return false;
|
|
228
|
+
if (now >= rec.expiresAt) {
|
|
229
|
+
stepUpTokens.delete(token);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Explicitly revoke a step-up token ("lock now"). Idempotent. */
|
|
236
|
+
export function revokeStepUpToken(token: string | null | undefined): void {
|
|
237
|
+
if (token) stepUpTokens.delete(token);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Test seam: clear the in-memory token store. */
|
|
241
|
+
export function _resetStepUpTokensForTest(): void {
|
|
242
|
+
stepUpTokens.clear();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Test seam: the live step-up token count. */
|
|
246
|
+
export function _stepUpTokenCountForTest(): number {
|
|
247
|
+
return stepUpTokens.size;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// PIN brute-force limiter (lockout) — mirrors hub's admin-lock unlockLimiter
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 5 wrong PINs / 5-min sliding window before lockout. The step-up exchange is
|
|
256
|
+
* already `agent:admin`-gated, so the threat is a COMPROMISED session (stolen
|
|
257
|
+
* cookie → minted Bearer) grinding argon2id PIN verifications without bound. Keyed
|
|
258
|
+
* per-session (the validated token's subject) so an attacker can't get a fresh
|
|
259
|
+
* bucket by rotating something cheap. Same floor + posture as hub's `unlockLimiter`.
|
|
260
|
+
*/
|
|
261
|
+
export const STEP_UP_MAX_ATTEMPTS = 5;
|
|
262
|
+
export const STEP_UP_WINDOW_MS = 5 * 60 * 1000;
|
|
263
|
+
|
|
264
|
+
export interface RateLimitResult {
|
|
265
|
+
/** True if the attempt is admitted; the caller proceeds to the PIN check. */
|
|
266
|
+
allowed: boolean;
|
|
267
|
+
/** Seconds until the bucket frees up (only set when denied). Always >= 1. */
|
|
268
|
+
retryAfterSeconds?: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A small sliding-window rate limiter (the shape mirrors hub's `RateLimiter`,
|
|
273
|
+
* inlined here to keep the agent module dependency-free). Each key keeps the last
|
|
274
|
+
* N admitted attempt timestamps; on a new attempt we prune anything older than the
|
|
275
|
+
* window, count what remains, and allow/deny. `now` is injectable for tests.
|
|
276
|
+
*/
|
|
277
|
+
export class StepUpRateLimiter {
|
|
278
|
+
private readonly buckets = new Map<string, number[]>();
|
|
279
|
+
|
|
280
|
+
constructor(
|
|
281
|
+
private readonly maxAttempts: number,
|
|
282
|
+
private readonly windowMs: number,
|
|
283
|
+
) {}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Record an attempt and return whether it's admitted. A DENIED attempt is NOT
|
|
287
|
+
* recorded (so a flood of denials can't push the reset further out). `now` is
|
|
288
|
+
* epoch ms (injectable).
|
|
289
|
+
*/
|
|
290
|
+
checkAndRecord(key: string, now = Date.now()): RateLimitResult {
|
|
291
|
+
const cutoff = now - this.windowMs;
|
|
292
|
+
const pruned = (this.buckets.get(key) ?? []).filter((t) => t > cutoff);
|
|
293
|
+
if (pruned.length >= this.maxAttempts) {
|
|
294
|
+
const resetAtMs = (pruned[0] ?? now) + this.windowMs;
|
|
295
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now) / 1000));
|
|
296
|
+
this.buckets.set(key, pruned);
|
|
297
|
+
return { allowed: false, retryAfterSeconds };
|
|
298
|
+
}
|
|
299
|
+
pruned.push(now);
|
|
300
|
+
this.buckets.set(key, pruned);
|
|
301
|
+
return { allowed: true };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Clear a key's bucket (called on a SUCCESSFUL PIN entry so it resets). */
|
|
305
|
+
clear(key: string): void {
|
|
306
|
+
this.buckets.delete(key);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Test seam: wipe all buckets. */
|
|
310
|
+
reset(): void {
|
|
311
|
+
this.buckets.clear();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The singleton PIN-attempt limiter (all step-up exchanges share one bucket map). */
|
|
316
|
+
export const stepUpLimiter = new StepUpRateLimiter(STEP_UP_MAX_ATTEMPTS, STEP_UP_WINDOW_MS);
|
package/src/terminal-ui.ts
CHANGED
|
@@ -164,6 +164,65 @@ ${SHELL_JS}
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// --- step-up PIN (agent#80) --------------------------------------------
|
|
168
|
+
// A terminal is a raw host shell — the most dangerous capability — so the WS
|
|
169
|
+
// upgrade requires a STEP-UP TOKEN on top of the agent:admin Bearer. The WS
|
|
170
|
+
// can't set a header, so we present it as a step_up query param. We fetch the step-up
|
|
171
|
+
// status, prompt for the PIN (or set one first), exchange it for a short-TTL
|
|
172
|
+
// token, and cache it on window.__stepUp. Re-prompt on expiry / WS auth-fail.
|
|
173
|
+
// This page is server-rendered (no React) so the prompt is a native dialog.
|
|
174
|
+
function authedJson(suffix, init) {
|
|
175
|
+
init = init || {};
|
|
176
|
+
var headers = init.headers || {};
|
|
177
|
+
headers["accept"] = "application/json";
|
|
178
|
+
if (window.__token) headers["authorization"] = "Bearer " + window.__token;
|
|
179
|
+
init.headers = headers;
|
|
180
|
+
return fetch(MOUNT + "/api" + suffix, init);
|
|
181
|
+
}
|
|
182
|
+
function ensureStepUp() {
|
|
183
|
+
if (window.__stepUp && window.__stepUpExp && Date.now() < window.__stepUpExp - 5000) {
|
|
184
|
+
return Promise.resolve(window.__stepUp);
|
|
185
|
+
}
|
|
186
|
+
return authedJson("/step-up").then(function (r) {
|
|
187
|
+
return r.json();
|
|
188
|
+
}).then(function (s) {
|
|
189
|
+
if (!s.configured) {
|
|
190
|
+
var np = window.prompt("Set a step-up PIN (4-12 digits) — required to open a terminal:");
|
|
191
|
+
if (!np) return null;
|
|
192
|
+
var confirm = window.prompt("Confirm the PIN:");
|
|
193
|
+
if (confirm !== np) { showNotice("The PINs didn't match — try again.", true); return null; }
|
|
194
|
+
return authedJson("/step-up/pin", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ newPin: np }),
|
|
198
|
+
}).then(function (r) {
|
|
199
|
+
if (!r.ok) { showNotice("Could not set the PIN (must be 4-12 digits).", true); return null; }
|
|
200
|
+
return exchangePin(np);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
var pin = window.prompt("Enter your step-up PIN to open a terminal:");
|
|
204
|
+
if (!pin) return null;
|
|
205
|
+
return exchangePin(pin);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function exchangePin(pin) {
|
|
209
|
+
return authedJson("/step-up", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "content-type": "application/json" },
|
|
212
|
+
body: JSON.stringify({ pin: pin }),
|
|
213
|
+
}).then(function (r) {
|
|
214
|
+
if (r.status === 401) { showNotice("Incorrect PIN.", true); return null; }
|
|
215
|
+
if (r.status === 429) { showNotice("Too many PIN attempts — wait a minute.", true); return null; }
|
|
216
|
+
if (!r.ok) { showNotice("Step-up failed (" + r.status + ").", true); return null; }
|
|
217
|
+
return r.json();
|
|
218
|
+
}).then(function (body) {
|
|
219
|
+
if (!body || !body.stepUpToken) return null;
|
|
220
|
+
window.__stepUp = body.stepUpToken;
|
|
221
|
+
window.__stepUpExp = body.expires_at ? new Date(body.expires_at).getTime() : Date.now() + 5 * 60000;
|
|
222
|
+
return window.__stepUp;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
167
226
|
// --- WebSocket relay ----------------------------------------------------
|
|
168
227
|
// The path segment is the AGENT name — the daemon attaches to that agent's tmux
|
|
169
228
|
// session (<name>-agent). NOT a channel.
|
|
@@ -172,6 +231,7 @@ ${SHELL_JS}
|
|
|
172
231
|
var dims = "cols=" + term.cols + "&rows=" + term.rows;
|
|
173
232
|
var u = proto + "//" + location.host + MOUNT + "/terminal/" + encodeURIComponent(agent) + "?" + dims;
|
|
174
233
|
if (window.__token) u += "&token=" + encodeURIComponent(window.__token);
|
|
234
|
+
if (window.__stepUp) u += "&step_up=" + encodeURIComponent(window.__stepUp);
|
|
175
235
|
return u;
|
|
176
236
|
}
|
|
177
237
|
|
|
@@ -181,6 +241,19 @@ ${SHELL_JS}
|
|
|
181
241
|
if (ws) { manualClose = true; try { ws.close(); } catch (_e) {} ws = null; }
|
|
182
242
|
manualClose = false;
|
|
183
243
|
clearNotice();
|
|
244
|
+
// Step-up FIRST — a terminal needs the PIN-minted token (agent#80). Only open
|
|
245
|
+
// the socket once we hold one; a cancelled prompt leaves the page idle.
|
|
246
|
+
setStatus("step-up…");
|
|
247
|
+
ensureStepUp().then(function (tok) {
|
|
248
|
+
if (!tok) { setStatus("step-up required", "err"); return; }
|
|
249
|
+
openSocket(agent);
|
|
250
|
+
}).catch(function () {
|
|
251
|
+
setStatus("step-up failed", "err");
|
|
252
|
+
showNotice("Could not complete step-up. Reload and try again.", true);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function openSocket(agent) {
|
|
184
257
|
setStatus("connecting…");
|
|
185
258
|
doFit();
|
|
186
259
|
var socket = new WebSocket(wsUrl(agent));
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* to them (mirroring the daemon's `/events` SSE pattern for bridges).
|
|
17
17
|
*
|
|
18
18
|
* It owns two HTTP surfaces via `ingestHttp` (scoped to ITS OWN channel name):
|
|
19
|
-
* 1. POST /api/channels/<name>/send
|
|
20
|
-
* 2. GET /ui/events?channel=<name
|
|
19
|
+
* 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
|
|
20
|
+
* 2. GET /ui/events?channel=<name>&ticket= — SSE stream the browser subscribes to
|
|
21
|
+
* (one-time ticket auth — agent#25; the JWT never rides in this URL)
|
|
21
22
|
* The static `/ui` chat page itself is global and served by the daemon, since
|
|
22
23
|
* it's a channel picker across all http-ui channels.
|
|
23
24
|
*/
|
|
@@ -30,7 +31,7 @@ import type {
|
|
|
30
31
|
PermissionArgs,
|
|
31
32
|
} from "../transport.ts";
|
|
32
33
|
import { sseFrame } from "../routing.ts";
|
|
33
|
-
import { requireScope, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
|
|
34
|
+
import { requireScope, requireSseTicket, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
|
|
34
35
|
|
|
35
36
|
/** A connected browser SSE client (one per open chat page on this channel). */
|
|
36
37
|
interface UiClient {
|
|
@@ -168,11 +169,12 @@ export class HttpUiTransport implements Transport {
|
|
|
168
169
|
url.pathname === "/ui/events" &&
|
|
169
170
|
url.searchParams.get("channel") === channel
|
|
170
171
|
) {
|
|
171
|
-
// Layer 2:
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
172
|
+
// Layer 2: EventSource can't set headers, so this gates on a one-time
|
|
173
|
+
// `?ticket=<nonce>` (agent#25) — minted by POST /api/ui/sse-ticket
|
|
174
|
+
// (Bearer-gated) and consumed single-use here, carrying `agent:read`. The
|
|
175
|
+
// hub JWT never appears in this URL (the leak the ticket closes). Absent /
|
|
176
|
+
// expired / already-used ticket → 401 before the stream opens.
|
|
177
|
+
const denied = requireSseTicket(url, SCOPE_READ);
|
|
176
178
|
if (denied) return denied;
|
|
177
179
|
const clientId = crypto.randomUUID();
|
|
178
180
|
const clients = this.uiClients;
|
package/src/ui-kit.ts
CHANGED
|
@@ -376,9 +376,12 @@ export const SHELL_JS = `
|
|
|
376
376
|
el.className = "app-status" + (kind ? " " + kind : "");
|
|
377
377
|
}
|
|
378
378
|
// Hub-minted agent token (cookie-gated to the logged-in operator). Cached on
|
|
379
|
-
// window.__token; pages attach it as a Bearer header
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// window.__token; pages attach it as a Bearer header. (Browser SSE auth moved
|
|
380
|
+
// off the query-param token to a one-time ticket in agent#25, so the JWT never
|
|
381
|
+
// lands in a URL; this template is the retired server-rendered shell, superseded
|
|
382
|
+
// by the SPA.) Endpoint renamed /admin/channel-token to /admin/agent-token in
|
|
383
|
+
// the channel-to-agent rename; the hub 301-redirects the old path for old
|
|
384
|
+
// bookmarks.
|
|
382
385
|
function fetchToken() {
|
|
383
386
|
return fetch(window.location.origin + "/admin/agent-token", { credentials: "include" })
|
|
384
387
|
.then(function (r) { if (!r.ok) throw new Error("token " + r.status); return r.json(); })
|
package/src/ui-ticket.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time SSE tickets for the browser EventSource auth path (Layer 2, human↔UI).
|
|
3
|
+
*
|
|
4
|
+
* THE LEAK THIS CLOSES. A browser `EventSource` can't set an `Authorization`
|
|
5
|
+
* header, so the agent SPA used to put the hub JWT directly in the SSE URL
|
|
6
|
+
* (`/ui/events?token=<JWT>`, `/api/channels/<ch>/turn-events?token=<JWT>`). A
|
|
7
|
+
* full bearer JWT in a URL lands in any access log, proxy log, browser history,
|
|
8
|
+
* or network trace — mitigated before only by the token's ~10min TTL. That's a
|
|
9
|
+
* credential-in-a-URL leak.
|
|
10
|
+
*
|
|
11
|
+
* THE FIX. Trade the JWT for an opaque, single-use, very-short-lived TICKET that
|
|
12
|
+
* goes in the URL instead. The SPA presents its bearer JWT to a normal
|
|
13
|
+
* authenticated endpoint (a Bearer header on a `fetch`, no leak), which mints a
|
|
14
|
+
* ticket: a crypto-random 256-bit nonce (base64url) stored ONLY server-side in
|
|
15
|
+
* this TTL'd map, carrying the validated scope(s)/audience of the presenting
|
|
16
|
+
* token. The SPA opens `/ui/events?ticket=<nonce>`; the SSE consume path looks
|
|
17
|
+
* the nonce up, CONSUMES it (deletes immediately — single-use), and establishes
|
|
18
|
+
* the stream with the ticket's scopes. The JWT never appears in a URL or log.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY PROPERTIES (all load-bearing):
|
|
21
|
+
* - Unguessable: 32 random bytes (256 bits) from `crypto.getRandomValues`,
|
|
22
|
+
* base64url-encoded. Far above the issue's 128-bit floor.
|
|
23
|
+
* - Single-use: `consume` DELETES the entry before returning, so a replayed
|
|
24
|
+
* ticket (a second connect, or a stolen URL) finds nothing → 401.
|
|
25
|
+
* - Short TTL: default 60s — just long enough to open the connection. An
|
|
26
|
+
* expired entry is treated as absent (and lazily pruned).
|
|
27
|
+
* - No scope widening: the ticket stores EXACTLY the scopes the minting token
|
|
28
|
+
* presented (validated upstream by `requireScope` before `mint` is called).
|
|
29
|
+
* The consume path asserts the required scope against the stored set, so a
|
|
30
|
+
* ticket can never authorize more than the JWT that minted it.
|
|
31
|
+
*
|
|
32
|
+
* This is process-local in-memory state by design (mirrors the daemon's other
|
|
33
|
+
* in-process registries). The daemon is single-instance per machine; tickets
|
|
34
|
+
* live ≤60s and are cheap to lose on restart (the SPA just re-mints). A
|
|
35
|
+
* module-level singleton is used because the two consume paths live in different
|
|
36
|
+
* modules (`http-ui.ts`'s `ingestHttp` and the daemon's turn-events route) and
|
|
37
|
+
* both must hit the SAME store — `ingestHttp(req, url)` has no place to thread an
|
|
38
|
+
* instance through. `_resetTicketsForTest` isolates unit tests.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A minted ticket's server-side record. Never leaves the process. */
|
|
42
|
+
interface TicketRecord {
|
|
43
|
+
/** The validated scopes carried from the minting JWT (the ceiling — never widened). */
|
|
44
|
+
scopes: string[];
|
|
45
|
+
/** Epoch ms after which the ticket is expired (treated as absent). */
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default ticket lifetime — long enough to open an EventSource, no longer. */
|
|
50
|
+
export const TICKET_TTL_MS = 60_000;
|
|
51
|
+
|
|
52
|
+
/** Nonce entropy: 32 bytes = 256 bits, well above the 128-bit floor. */
|
|
53
|
+
const TICKET_BYTES = 32;
|
|
54
|
+
|
|
55
|
+
/** The process-local ticket store. nonce → record. */
|
|
56
|
+
const tickets = new Map<string, TicketRecord>();
|
|
57
|
+
|
|
58
|
+
/** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
|
|
59
|
+
function base64url(bytes: Uint8Array): string {
|
|
60
|
+
let bin = "";
|
|
61
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
62
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mint a single-use ticket carrying `scopes` (a COPY of the validated scopes from
|
|
67
|
+
* the presenting JWT — the caller must have already authenticated + scope-checked
|
|
68
|
+
* the token, so this never widens authority). Returns the opaque nonce + its
|
|
69
|
+
* absolute expiry. TTL defaults to {@link TICKET_TTL_MS}.
|
|
70
|
+
*/
|
|
71
|
+
export function mintTicket(scopes: readonly string[], ttlMs = TICKET_TTL_MS): {
|
|
72
|
+
ticket: string;
|
|
73
|
+
expiresAt: number;
|
|
74
|
+
} {
|
|
75
|
+
pruneExpiredTickets();
|
|
76
|
+
const bytes = new Uint8Array(TICKET_BYTES);
|
|
77
|
+
crypto.getRandomValues(bytes);
|
|
78
|
+
const ticket = base64url(bytes);
|
|
79
|
+
const expiresAt = Date.now() + ttlMs;
|
|
80
|
+
tickets.set(ticket, { scopes: [...scopes], expiresAt });
|
|
81
|
+
return { ticket, expiresAt };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Consume a ticket: look it up, and if present + unexpired, DELETE it (single-use)
|
|
86
|
+
* and return its scopes. Returns `null` for an absent / expired / already-consumed
|
|
87
|
+
* ticket — the caller maps that to a 401. Deletion happens before return, so two
|
|
88
|
+
* concurrent consumes of the same nonce can't both succeed.
|
|
89
|
+
*/
|
|
90
|
+
export function consumeTicket(ticket: string | null | undefined): { scopes: string[] } | null {
|
|
91
|
+
if (!ticket) return null;
|
|
92
|
+
const rec = tickets.get(ticket);
|
|
93
|
+
if (!rec) return null;
|
|
94
|
+
// Single-use: remove FIRST, so even an expired hit can't be retried and a
|
|
95
|
+
// concurrent second consume finds nothing.
|
|
96
|
+
tickets.delete(ticket);
|
|
97
|
+
if (Date.now() >= rec.expiresAt) return null;
|
|
98
|
+
return { scopes: rec.scopes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Drop every expired ticket. Called opportunistically on each mint so the map
|
|
103
|
+
* can't grow unbounded if some tickets are never consumed; not on a timer (no
|
|
104
|
+
* background work in a possibly-idle daemon). O(n) over a map that's tiny in
|
|
105
|
+
* practice (≤ a handful of live tickets at 60s TTL).
|
|
106
|
+
*/
|
|
107
|
+
export function pruneExpiredTickets(now = Date.now()): void {
|
|
108
|
+
for (const [k, rec] of tickets) {
|
|
109
|
+
if (now >= rec.expiresAt) tickets.delete(k);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Test seam: clear all tickets so unit tests start from a clean store. */
|
|
114
|
+
export function _resetTicketsForTest(): void {
|
|
115
|
+
tickets.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test seam: the current live ticket count (asserts single-use deletion). */
|
|
119
|
+
export function _ticketCountForTest(): number {
|
|
120
|
+
return tickets.size;
|
|
121
|
+
}
|