@monster-orange/den 0.4.3 → 0.4.5
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/CHANGELOG.md +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: `<cli> web login` waits, and its link asks for a code (SEC-2
|
|
3
|
+
* spec, "Seeing that you're signed in"). The CLI mints the one-time link
|
|
4
|
+
* as before, makes an 8-character confirmation code, and registers both
|
|
5
|
+
* with the running server (its bearer): the link's nonce and a hash of the
|
|
6
|
+
* code. Then:
|
|
7
|
+
*
|
|
8
|
+
* - the link (GET /auth) no longer signs in by itself: it opens a page
|
|
9
|
+
* that asks for the code, so a leaked link alone is useless. Opening it
|
|
10
|
+
* starts a 5-minute window to type the code;
|
|
11
|
+
* - the page posts the code; the right one signs the browser in (the link
|
|
12
|
+
* is consumed: still single-use), a wrong one is refused. Five wrong
|
|
13
|
+
* codes burn the link; past 20 wrong codes a minute (any link), every
|
|
14
|
+
* attempt waits;
|
|
15
|
+
* - the CLI polls the wait by its own id and prints who signed in, or
|
|
16
|
+
* that nobody did.
|
|
17
|
+
*
|
|
18
|
+
* In memory only. The code is kept as a hash and never logged; the nonce
|
|
19
|
+
* alone (without the link's mac) signs nothing in. Clock injected.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
22
|
+
import { normalizeConfirmCode } from "../../shared/confirm-code.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
newConfirmCode,
|
|
26
|
+
normalizeConfirmCode,
|
|
27
|
+
} from "../../shared/confirm-code.js";
|
|
28
|
+
|
|
29
|
+
/** How long an opened link's page waits for its code. */
|
|
30
|
+
export const CONFIRM_WINDOW_MS = 5 * 60_000;
|
|
31
|
+
/** Wrong codes before a link is burned. */
|
|
32
|
+
export const MAX_WRONG_PER_LINK = 5;
|
|
33
|
+
/** Wrong codes a minute, all links together, before every attempt waits. */
|
|
34
|
+
export const MAX_WRONG_PER_MINUTE = 20;
|
|
35
|
+
/** The most waits at once; a new one drops the oldest. */
|
|
36
|
+
export const MAX_LOGIN_WAITS = 10;
|
|
37
|
+
/** How long an ended wait still answers the CLI. */
|
|
38
|
+
const ENDED_KEEP_MS = 5 * 60_000;
|
|
39
|
+
|
|
40
|
+
const hash = (s: string) => createHash("sha256").update(s).digest();
|
|
41
|
+
|
|
42
|
+
export type LoginWaitState =
|
|
43
|
+
| "waiting"
|
|
44
|
+
| "opened"
|
|
45
|
+
| "used"
|
|
46
|
+
| "expired"
|
|
47
|
+
| "refused";
|
|
48
|
+
|
|
49
|
+
export interface LoginWaitView {
|
|
50
|
+
state: LoginWaitState;
|
|
51
|
+
/** used: the browser and OS that signed in, its address, and when. */
|
|
52
|
+
device?: string;
|
|
53
|
+
client?: string;
|
|
54
|
+
usedAt?: number;
|
|
55
|
+
expiresAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface LoginWait extends LoginWaitView {
|
|
59
|
+
id: string;
|
|
60
|
+
nonce: string;
|
|
61
|
+
codeHash: Buffer;
|
|
62
|
+
createdAt: number;
|
|
63
|
+
openedAt?: number;
|
|
64
|
+
wrong: number;
|
|
65
|
+
forgetAt?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ConfirmResult =
|
|
69
|
+
| { ok: true }
|
|
70
|
+
| {
|
|
71
|
+
ok: false;
|
|
72
|
+
error: "wrong" | "refused" | "expired" | "rate_limited";
|
|
73
|
+
left?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export class LoginWaits {
|
|
77
|
+
private readonly byId = new Map<string, LoginWait>();
|
|
78
|
+
private readonly now: () => number;
|
|
79
|
+
private wrongAt: number[] = [];
|
|
80
|
+
|
|
81
|
+
constructor(opts: { now?: () => number } = {}) {
|
|
82
|
+
this.now = opts.now ?? Date.now;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A wait for the link with this nonce (it must be opened by `linkExpiresAt`),
|
|
87
|
+
* confirmed by `code`. Its id is the CLI's to poll.
|
|
88
|
+
*/
|
|
89
|
+
register(nonce: string, code: string, linkExpiresAt: number): string | null {
|
|
90
|
+
const norm = normalizeConfirmCode(code);
|
|
91
|
+
if (!norm || !/^[A-Za-z0-9_-]{16,64}$/.test(nonce)) return null;
|
|
92
|
+
this.sweep();
|
|
93
|
+
const live = [...this.byId.values()].sort(
|
|
94
|
+
(a, b) => a.createdAt - b.createdAt,
|
|
95
|
+
);
|
|
96
|
+
while (live.length >= MAX_LOGIN_WAITS) this.byId.delete(live.shift()!.id);
|
|
97
|
+
const id = randomBytes(24).toString("base64url");
|
|
98
|
+
this.byId.set(id, {
|
|
99
|
+
id,
|
|
100
|
+
nonce,
|
|
101
|
+
codeHash: hash(norm),
|
|
102
|
+
state: "waiting",
|
|
103
|
+
createdAt: this.now(),
|
|
104
|
+
expiresAt: linkExpiresAt,
|
|
105
|
+
wrong: 0,
|
|
106
|
+
});
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A wait by its id (the CLI's poll). */
|
|
111
|
+
get(id: string): LoginWaitView | null {
|
|
112
|
+
this.sweep();
|
|
113
|
+
const w = this.byId.get(id);
|
|
114
|
+
return w ? view(w) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The wait for a link's nonce, still open to use, or null. */
|
|
118
|
+
forNonce(nonce: string): (LoginWaitView & { openedAt?: number }) | null {
|
|
119
|
+
this.sweep();
|
|
120
|
+
const w = this.find(nonce);
|
|
121
|
+
return w
|
|
122
|
+
? { ...view(w), ...(w.openedAt ? { openedAt: w.openedAt } : {}) }
|
|
123
|
+
: null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether a link with this nonce has a wait at all (any state). */
|
|
127
|
+
has(nonce: string): boolean {
|
|
128
|
+
return !!this.find(nonce);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The link was opened: its code window starts (once). */
|
|
132
|
+
open(nonce: string): void {
|
|
133
|
+
this.sweep();
|
|
134
|
+
const w = this.find(nonce);
|
|
135
|
+
if (!w || w.state !== "waiting") return;
|
|
136
|
+
const now = this.now();
|
|
137
|
+
w.state = "opened";
|
|
138
|
+
w.openedAt = now;
|
|
139
|
+
w.expiresAt = now + CONFIRM_WINDOW_MS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The page's code for this link. */
|
|
143
|
+
confirm(nonce: string, typed: string): ConfirmResult {
|
|
144
|
+
this.sweep();
|
|
145
|
+
const now = this.now();
|
|
146
|
+
const w = this.find(nonce);
|
|
147
|
+
if (!w || w.state === "used") return { ok: false, error: "expired" };
|
|
148
|
+
if (w.state === "expired") return { ok: false, error: "expired" };
|
|
149
|
+
if (w.state === "refused") return { ok: false, error: "refused" };
|
|
150
|
+
this.wrongAt = this.wrongAt.filter((t) => now - t < 60_000);
|
|
151
|
+
if (this.wrongAt.length >= MAX_WRONG_PER_MINUTE)
|
|
152
|
+
return { ok: false, error: "rate_limited" };
|
|
153
|
+
const norm = normalizeConfirmCode(typed);
|
|
154
|
+
const given = hash(norm ?? `\u0000${typed}`);
|
|
155
|
+
if (norm && timingSafeEqual(given, w.codeHash)) return { ok: true };
|
|
156
|
+
this.wrongAt.push(now);
|
|
157
|
+
w.wrong++;
|
|
158
|
+
if (w.wrong >= MAX_WRONG_PER_LINK) {
|
|
159
|
+
this.end(w, "refused");
|
|
160
|
+
return { ok: false, error: "refused" };
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, error: "wrong", left: MAX_WRONG_PER_LINK - w.wrong };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The link signed a browser in: the CLI is told who. */
|
|
166
|
+
used(nonce: string, meta: { device: string; client?: string }): void {
|
|
167
|
+
const w = this.find(nonce);
|
|
168
|
+
if (!w) return;
|
|
169
|
+
w.device = meta.device;
|
|
170
|
+
if (meta.client) w.client = meta.client;
|
|
171
|
+
w.usedAt = this.now();
|
|
172
|
+
this.end(w, "used");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private find(nonce: string): LoginWait | undefined {
|
|
176
|
+
for (const w of this.byId.values()) if (w.nonce === nonce) return w;
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private end(w: LoginWait, state: "used" | "expired" | "refused"): void {
|
|
181
|
+
w.state = state;
|
|
182
|
+
w.forgetAt = this.now() + ENDED_KEEP_MS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private sweep(): void {
|
|
186
|
+
const now = this.now();
|
|
187
|
+
for (const [id, w] of this.byId) {
|
|
188
|
+
if ((w.state === "waiting" || w.state === "opened") && now >= w.expiresAt)
|
|
189
|
+
this.end(w, "expired");
|
|
190
|
+
if (w.forgetAt !== undefined && now >= w.forgetAt) this.byId.delete(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function view(w: LoginWait): LoginWaitView {
|
|
196
|
+
return {
|
|
197
|
+
state: w.state,
|
|
198
|
+
...(w.device ? { device: w.device } : {}),
|
|
199
|
+
...(w.client ? { client: w.client } : {}),
|
|
200
|
+
...(w.usedAt ? { usedAt: w.usedAt } : {}),
|
|
201
|
+
expiresAt: w.expiresAt,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: approve this computer with a code (reverse pairing). The
|
|
3
|
+
* signed-out page asks for a request; the server keeps it in memory and
|
|
4
|
+
* hands that page two things:
|
|
5
|
+
*
|
|
6
|
+
* - a short code (`K7Q-42M`: six Crockford base32 characters, 30 bits)
|
|
7
|
+
* that a person who is already in (the CLI's bearer, `<cli> web approve
|
|
8
|
+
* <code>`, or a signed-in Settings › Access › Waiting devices) types or
|
|
9
|
+
* clicks to approve it. The code is never a credential by itself: it
|
|
10
|
+
* only names a request to someone already trusted;
|
|
11
|
+
* - a separate poll id (192 bits) that only that page holds. Once the
|
|
12
|
+
* request is approved, the page's poll with this id gets the session
|
|
13
|
+
* cookie, once.
|
|
14
|
+
*
|
|
15
|
+
* A request lasts 10 minutes and is single-use: claiming it (the cookie),
|
|
16
|
+
* denying it, or its expiry ends it. At most `max` are pending at once; a
|
|
17
|
+
* new one beyond that drops the oldest. Approve and deny attempts are
|
|
18
|
+
* rate-limited (a guess of a code costs one): past `maxAttempts` in
|
|
19
|
+
* `attemptWindowMs`, every attempt is refused until the window passes.
|
|
20
|
+
*
|
|
21
|
+
* Nothing is written to disk. Clock injected, so a test drives expiry.
|
|
22
|
+
*/
|
|
23
|
+
import { randomBytes, randomInt } from "crypto";
|
|
24
|
+
import { PAIR_ALPHABET } from "../../shared/confirm-code.js";
|
|
25
|
+
import { deviceName } from "../../shared/web-session.js";
|
|
26
|
+
|
|
27
|
+
/** How long a request waits for its approval. */
|
|
28
|
+
export const PAIR_TTL_MS = 10 * 60_000;
|
|
29
|
+
/** The most pending requests at once; a new one drops the oldest. */
|
|
30
|
+
export const MAX_PENDING_PAIRS = 5;
|
|
31
|
+
/** How long an ended request still answers its page (denied, expired). */
|
|
32
|
+
export const PAIR_ENDED_KEEP_MS = 60_000;
|
|
33
|
+
/** Approve or deny attempts allowed per window. */
|
|
34
|
+
export const MAX_APPROVE_ATTEMPTS = 10;
|
|
35
|
+
export const APPROVE_WINDOW_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
export { PAIR_ALPHABET };
|
|
38
|
+
const CODE_LEN = 6;
|
|
39
|
+
|
|
40
|
+
/** A fresh code, unformatted (six characters of PAIR_ALPHABET). */
|
|
41
|
+
export function newPairCode(rand: (n: number) => number = randomInt): string {
|
|
42
|
+
let s = "";
|
|
43
|
+
for (let i = 0; i < CODE_LEN; i++) s += PAIR_ALPHABET[rand(32)];
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `K7Q42M` → `K7Q-42M`. */
|
|
48
|
+
export function formatPairCode(code: string): string {
|
|
49
|
+
return `${code.slice(0, 3)}-${code.slice(3)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a person typed, as a code: case-insensitive, dashes and spaces
|
|
54
|
+
* ignored, and Crockford's look-alikes read as what they look like (O as
|
|
55
|
+
* 0, I and L as 1). Null when it can't be a code.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizePairCode(input: string): string | null {
|
|
58
|
+
const s = String(input ?? "")
|
|
59
|
+
.toUpperCase()
|
|
60
|
+
.replace(/[\s-]/g, "")
|
|
61
|
+
.replace(/O/g, "0")
|
|
62
|
+
.replace(/[IL]/g, "1");
|
|
63
|
+
if (s.length !== CODE_LEN) return null;
|
|
64
|
+
for (const c of s) if (!PAIR_ALPHABET.includes(c)) return null;
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PairState = "pending" | "approved" | "denied" | "expired";
|
|
69
|
+
|
|
70
|
+
/** What the approver sees of a request (and the page, of its own). */
|
|
71
|
+
export interface PairView {
|
|
72
|
+
code: string;
|
|
73
|
+
state: PairState;
|
|
74
|
+
/** "Chrome on macOS", from the User-Agent. */
|
|
75
|
+
device: string;
|
|
76
|
+
/** The client's address, when Den knows it. */
|
|
77
|
+
client?: string;
|
|
78
|
+
createdAt: number;
|
|
79
|
+
expiresAt: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PairRequest extends PairView {
|
|
83
|
+
id: string;
|
|
84
|
+
userAgent: string;
|
|
85
|
+
/** When an ended request is forgotten. */
|
|
86
|
+
forgetAt?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ApproveResult =
|
|
90
|
+
| { ok: true; request: PairView }
|
|
91
|
+
| { ok: false; error: "malformed" | "unknown" | "expired" | "rate_limited" };
|
|
92
|
+
|
|
93
|
+
export class PairRequests {
|
|
94
|
+
private readonly byId = new Map<string, PairRequest>();
|
|
95
|
+
private readonly now: () => number;
|
|
96
|
+
private readonly max: number;
|
|
97
|
+
private readonly ttlMs: number;
|
|
98
|
+
private readonly maxAttempts: number;
|
|
99
|
+
private readonly windowMs: number;
|
|
100
|
+
private attempts: number[] = [];
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
opts: {
|
|
104
|
+
now?: () => number;
|
|
105
|
+
max?: number;
|
|
106
|
+
ttlMs?: number;
|
|
107
|
+
maxAttempts?: number;
|
|
108
|
+
windowMs?: number;
|
|
109
|
+
} = {},
|
|
110
|
+
) {
|
|
111
|
+
this.now = opts.now ?? Date.now;
|
|
112
|
+
this.max = opts.max ?? MAX_PENDING_PAIRS;
|
|
113
|
+
this.ttlMs = opts.ttlMs ?? PAIR_TTL_MS;
|
|
114
|
+
this.maxAttempts = opts.maxAttempts ?? MAX_APPROVE_ATTEMPTS;
|
|
115
|
+
this.windowMs = opts.windowMs ?? APPROVE_WINDOW_MS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A new request: its poll id (the page's only), code and expiry. */
|
|
119
|
+
create(meta: { userAgent?: string; client?: string }): {
|
|
120
|
+
id: string;
|
|
121
|
+
code: string;
|
|
122
|
+
expiresAt: number;
|
|
123
|
+
} {
|
|
124
|
+
this.sweep();
|
|
125
|
+
const pending = [...this.byId.values()]
|
|
126
|
+
.filter((r) => r.state === "pending")
|
|
127
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
128
|
+
while (pending.length >= this.max) {
|
|
129
|
+
const oldest = pending.shift()!;
|
|
130
|
+
this.byId.delete(oldest.id);
|
|
131
|
+
}
|
|
132
|
+
const live = new Set([...this.byId.values()].map((r) => r.code));
|
|
133
|
+
let code = newPairCode();
|
|
134
|
+
while (live.has(code)) code = newPairCode();
|
|
135
|
+
const now = this.now();
|
|
136
|
+
const ua = String(meta.userAgent ?? "").slice(0, 300);
|
|
137
|
+
const r: PairRequest = {
|
|
138
|
+
id: randomBytes(24).toString("base64url"),
|
|
139
|
+
code,
|
|
140
|
+
state: "pending",
|
|
141
|
+
device: deviceName(ua),
|
|
142
|
+
userAgent: ua,
|
|
143
|
+
...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
|
|
144
|
+
createdAt: now,
|
|
145
|
+
expiresAt: now + this.ttlMs,
|
|
146
|
+
};
|
|
147
|
+
this.byId.set(r.id, r);
|
|
148
|
+
return { id: r.id, code: formatPairCode(code), expiresAt: r.expiresAt };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A request's view by its poll id, or null (unknown, or forgotten). */
|
|
152
|
+
get(id: string): PairView | null {
|
|
153
|
+
this.sweep();
|
|
154
|
+
const r = this.byId.get(id);
|
|
155
|
+
return r ? view(r) : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The requests waiting for approval, oldest first. */
|
|
159
|
+
waiting(): PairView[] {
|
|
160
|
+
this.sweep();
|
|
161
|
+
return [...this.byId.values()]
|
|
162
|
+
.filter((r) => r.state === "pending")
|
|
163
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
164
|
+
.map(view);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Approve the pending request with this code (as typed). */
|
|
168
|
+
approve(input: string): ApproveResult {
|
|
169
|
+
return this.decide(input, "approved");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Deny the pending request with this code: its page offers a new one. */
|
|
173
|
+
deny(input: string): ApproveResult {
|
|
174
|
+
return this.decide(input, "denied");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* An approved request, once, by its poll id: it is consumed, so a second
|
|
179
|
+
* claim gets null (an approval makes one session).
|
|
180
|
+
*/
|
|
181
|
+
claim(id: string): PairView | null {
|
|
182
|
+
this.sweep();
|
|
183
|
+
const r = this.byId.get(id);
|
|
184
|
+
if (!r || r.state !== "approved") return null;
|
|
185
|
+
this.byId.delete(id);
|
|
186
|
+
return view(r);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Forget every request (the server is stopping). */
|
|
190
|
+
clear(): void {
|
|
191
|
+
this.byId.clear();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private decide(input: string, state: "approved" | "denied"): ApproveResult {
|
|
195
|
+
const now = this.now();
|
|
196
|
+
this.attempts = this.attempts.filter((t) => now - t < this.windowMs);
|
|
197
|
+
if (this.attempts.length >= this.maxAttempts)
|
|
198
|
+
return { ok: false, error: "rate_limited" };
|
|
199
|
+
this.attempts.push(now);
|
|
200
|
+
const code = normalizePairCode(input);
|
|
201
|
+
if (!code) return { ok: false, error: "malformed" };
|
|
202
|
+
this.sweep();
|
|
203
|
+
const r = [...this.byId.values()].find((x) => x.code === code);
|
|
204
|
+
if (!r) return { ok: false, error: "unknown" };
|
|
205
|
+
if (r.state === "expired") return { ok: false, error: "expired" };
|
|
206
|
+
if (r.state !== "pending") return { ok: false, error: "unknown" };
|
|
207
|
+
r.state = state;
|
|
208
|
+
if (state === "denied") r.forgetAt = now + PAIR_ENDED_KEEP_MS;
|
|
209
|
+
return { ok: true, request: view(r) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Expire requests past their time; forget ended ones. */
|
|
213
|
+
private sweep(): void {
|
|
214
|
+
const now = this.now();
|
|
215
|
+
for (const [id, r] of this.byId) {
|
|
216
|
+
if (
|
|
217
|
+
(r.state === "pending" || r.state === "approved") &&
|
|
218
|
+
now >= r.expiresAt
|
|
219
|
+
) {
|
|
220
|
+
r.state = "expired";
|
|
221
|
+
r.forgetAt = r.expiresAt + PAIR_ENDED_KEEP_MS;
|
|
222
|
+
}
|
|
223
|
+
if (r.forgetAt !== undefined && now >= r.forgetAt) this.byId.delete(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function view(r: PairRequest): PairView {
|
|
229
|
+
return {
|
|
230
|
+
code: formatPairCode(r.code),
|
|
231
|
+
state: r.state,
|
|
232
|
+
device: r.device,
|
|
233
|
+
...(r.client ? { client: r.client } : {}),
|
|
234
|
+
createdAt: r.createdAt,
|
|
235
|
+
expiresAt: r.expiresAt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a's approve-with-a-code routes (pair-requests.ts):
|
|
3
|
+
*
|
|
4
|
+
* POST /api/signin/code a new request (sign-in free; an allowed
|
|
5
|
+
* Origin, no CSRF token: the signed-out
|
|
6
|
+
* page holds none). Its poll id, code,
|
|
7
|
+
* expiry, and the commands that approve it
|
|
8
|
+
* GET /api/signin/code/<id> its state; approved: the session cookie,
|
|
9
|
+
* once (the request is consumed)
|
|
10
|
+
* GET /api/web/waiting the requests waiting (shell-grade)
|
|
11
|
+
* POST /api/web/approve {code} approve one (shell-grade: `<cli> web
|
|
12
|
+
* approve`, Settings › Access)
|
|
13
|
+
* POST /api/web/deny {code} deny one (shell-grade)
|
|
14
|
+
*
|
|
15
|
+
* Approve and deny are audited with the actor; neither the code nor the
|
|
16
|
+
* poll id is logged.
|
|
17
|
+
*/
|
|
18
|
+
import type * as http from "http";
|
|
19
|
+
import type { PairRequests, PairView } from "./pair-requests.js";
|
|
20
|
+
|
|
21
|
+
export interface PairRouteDeps {
|
|
22
|
+
pairs: PairRequests;
|
|
23
|
+
sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
|
|
24
|
+
readBody: (req: http.IncomingMessage) => Promise<string>;
|
|
25
|
+
/** A new browser session for this request (via "code"); its Set-Cookie line. */
|
|
26
|
+
signIn: (req: http.IncomingMessage) => string;
|
|
27
|
+
/** The client's address for a new request's record, when Den knows it. */
|
|
28
|
+
client: (req: http.IncomingMessage) => string | undefined;
|
|
29
|
+
/** The commands that approve `code`: on the server, and by ssh from the browser's machine. */
|
|
30
|
+
commands: (
|
|
31
|
+
req: http.IncomingMessage,
|
|
32
|
+
code: string,
|
|
33
|
+
) => { approve: string; ssh: string };
|
|
34
|
+
/** Record an approve or deny (who, what, whether it was refused). */
|
|
35
|
+
audit: (
|
|
36
|
+
req: http.IncomingMessage,
|
|
37
|
+
verb: "approve" | "deny",
|
|
38
|
+
detail: string,
|
|
39
|
+
refused?: string,
|
|
40
|
+
) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const POLL_PATH = /^\/api\/signin\/code\/([A-Za-z0-9_-]{16,64})$/;
|
|
44
|
+
|
|
45
|
+
/** Whether `pathname` is one of these routes' (index.ts dispatches them here). */
|
|
46
|
+
export function isPairRoute(pathname: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
pathname === "/api/signin/code" ||
|
|
49
|
+
POLL_PATH.test(pathname) ||
|
|
50
|
+
pathname === "/api/web/waiting" ||
|
|
51
|
+
pathname === "/api/web/approve" ||
|
|
52
|
+
pathname === "/api/web/deny"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function waitingJson(v: PairView): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
code: v.code,
|
|
59
|
+
device: v.device,
|
|
60
|
+
...(v.client ? { client: v.client } : {}),
|
|
61
|
+
createdAt: new Date(v.createdAt).toISOString(),
|
|
62
|
+
expiresAt: new Date(v.expiresAt).toISOString(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ERRORS: Record<string, { status: number; error: string }> = {
|
|
67
|
+
malformed: {
|
|
68
|
+
status: 400,
|
|
69
|
+
error:
|
|
70
|
+
"That isn't a code: it is six letters and digits, like K7Q-42M (the signed-out page shows it).",
|
|
71
|
+
},
|
|
72
|
+
unknown: {
|
|
73
|
+
status: 404,
|
|
74
|
+
error:
|
|
75
|
+
"No device is waiting with that code. Check the code on the other computer's page; it may have been used or replaced.",
|
|
76
|
+
},
|
|
77
|
+
expired: {
|
|
78
|
+
status: 410,
|
|
79
|
+
error:
|
|
80
|
+
"That code expired. On the other computer, click Get a new code and approve the new one.",
|
|
81
|
+
},
|
|
82
|
+
rate_limited: {
|
|
83
|
+
status: 429,
|
|
84
|
+
error: "Too many approve attempts. Wait a minute and try again.",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Handles a route here; false when `pathname` is none of them. */
|
|
89
|
+
export async function handlePairRoutes(
|
|
90
|
+
req: http.IncomingMessage,
|
|
91
|
+
res: http.ServerResponse,
|
|
92
|
+
pathname: string,
|
|
93
|
+
deps: PairRouteDeps,
|
|
94
|
+
): Promise<boolean> {
|
|
95
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
96
|
+
res.setHeader("Cache-Control", "no-store");
|
|
97
|
+
|
|
98
|
+
if (pathname === "/api/signin/code" && method === "POST") {
|
|
99
|
+
const r = deps.pairs.create({
|
|
100
|
+
userAgent: req.headers["user-agent"],
|
|
101
|
+
client: deps.client(req),
|
|
102
|
+
});
|
|
103
|
+
deps.sendJson(res, {
|
|
104
|
+
ok: true,
|
|
105
|
+
id: r.id,
|
|
106
|
+
code: r.code,
|
|
107
|
+
expiresAt: new Date(r.expiresAt).toISOString(),
|
|
108
|
+
// What is left, for the page's own countdown (no clock skew).
|
|
109
|
+
expiresIn: Math.max(0, r.expiresAt - Date.now()),
|
|
110
|
+
...deps.commands(req, r.code),
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const p = POLL_PATH.exec(pathname);
|
|
115
|
+
if (p && (method === "GET" || method === "HEAD")) {
|
|
116
|
+
const v = deps.pairs.get(p[1]);
|
|
117
|
+
if (!v) {
|
|
118
|
+
deps.sendJson(
|
|
119
|
+
res,
|
|
120
|
+
{
|
|
121
|
+
ok: false,
|
|
122
|
+
state: "gone",
|
|
123
|
+
code: "not_found",
|
|
124
|
+
error: "That request ended. Get a new code.",
|
|
125
|
+
},
|
|
126
|
+
404,
|
|
127
|
+
);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (v.state === "approved") {
|
|
131
|
+
if (!deps.pairs.claim(p[1])) {
|
|
132
|
+
deps.sendJson(res, { ok: false, state: "gone" }, 404);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
res.setHeader("Set-Cookie", deps.signIn(req));
|
|
136
|
+
deps.sendJson(res, { ok: true, state: "ok" });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
deps.sendJson(res, {
|
|
140
|
+
ok: v.state === "pending",
|
|
141
|
+
state: v.state,
|
|
142
|
+
expiresAt: new Date(v.expiresAt).toISOString(),
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
pathname === "/api/web/waiting" &&
|
|
149
|
+
(method === "GET" || method === "HEAD")
|
|
150
|
+
) {
|
|
151
|
+
deps.sendJson(res, { waiting: deps.pairs.waiting().map(waitingJson) });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (
|
|
156
|
+
(pathname === "/api/web/approve" || pathname === "/api/web/deny") &&
|
|
157
|
+
method === "POST"
|
|
158
|
+
) {
|
|
159
|
+
const verb = pathname === "/api/web/approve" ? "approve" : "deny";
|
|
160
|
+
let body: { code?: unknown } = {};
|
|
161
|
+
try {
|
|
162
|
+
body = (JSON.parse((await deps.readBody(req)) || "{}") ??
|
|
163
|
+
{}) as typeof body;
|
|
164
|
+
} catch {
|
|
165
|
+
deps.sendJson(
|
|
166
|
+
res,
|
|
167
|
+
{
|
|
168
|
+
ok: false,
|
|
169
|
+
code: "bad_request",
|
|
170
|
+
error: "the body must be JSON: { code }",
|
|
171
|
+
},
|
|
172
|
+
400,
|
|
173
|
+
);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const r =
|
|
177
|
+
verb === "approve"
|
|
178
|
+
? deps.pairs.approve(String(body.code ?? ""))
|
|
179
|
+
: deps.pairs.deny(String(body.code ?? ""));
|
|
180
|
+
if (!r.ok) {
|
|
181
|
+
const e = ERRORS[r.error];
|
|
182
|
+
deps.audit(req, verb, "a waiting device", e.error);
|
|
183
|
+
deps.sendJson(
|
|
184
|
+
res,
|
|
185
|
+
{ ok: false, code: r.error, error: e.error },
|
|
186
|
+
e.status,
|
|
187
|
+
);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const who = [r.request.device, r.request.client].filter(Boolean).join(", ");
|
|
191
|
+
deps.audit(req, verb, `a waiting device (${who})`);
|
|
192
|
+
deps.sendJson(res, { ok: true, ...waitingJson(r.request) });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|