@monster-orange/den 0.4.0 → 0.4.2
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 +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-1a: the web server's secret and the person token derived from it.
|
|
3
|
+
*
|
|
4
|
+
* `web.secret` is 32 random bytes in the distribution's state dir, mode
|
|
5
|
+
* 0600 (the dir 0700 when Den creates it). Reading it is the proof that a
|
|
6
|
+
* caller is the OS user, the same proof as the session host's socket. It is
|
|
7
|
+
* never printed, never logged and never served.
|
|
8
|
+
*
|
|
9
|
+
* The person token is HMAC-SHA256(secret, "den-person-v1:" + os user),
|
|
10
|
+
* sent as `Authorization: Bearer den_pt_<b64url>`. The server recomputes it
|
|
11
|
+
* and compares in constant time; its actor is `person:<os user>`.
|
|
12
|
+
*
|
|
13
|
+
* SEC-1b's `web logout --all` rotates the secret (rotateWebSecret).
|
|
14
|
+
* SEC-1c's agent tokens (`den_at_…`) are actor-token.ts's: random, not
|
|
15
|
+
* derived from the secret.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { ACTOR_TOKEN_ENV, isActorTokenShape } from "./actor-token.js";
|
|
22
|
+
import { getStateDir } from "./paths.js";
|
|
23
|
+
import { AGENT_ACTOR_ENV } from "./sessions/env.js";
|
|
24
|
+
|
|
25
|
+
export const WEB_SECRET_FILE = "web.secret";
|
|
26
|
+
const SECRET_BYTES = 32;
|
|
27
|
+
export const PERSON_TOKEN_PREFIX = "den_pt_";
|
|
28
|
+
const PERSON_CONTEXT = "den-person-v1:";
|
|
29
|
+
|
|
30
|
+
/** `<state dir>/web.secret`. */
|
|
31
|
+
export function webSecretPath(stateDir: string = getStateDir()): string {
|
|
32
|
+
return path.join(stateDir, WEB_SECRET_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The OS user the process runs as (the person-token actor's name). */
|
|
36
|
+
export function osUser(): string {
|
|
37
|
+
try {
|
|
38
|
+
return os.userInfo().username;
|
|
39
|
+
} catch {
|
|
40
|
+
return process.env.USER ?? process.env.LOGNAME ?? "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The secret, or null when it is missing, unreadable or malformed. */
|
|
45
|
+
export function readWebSecret(stateDir: string = getStateDir()): Buffer | null {
|
|
46
|
+
try {
|
|
47
|
+
const b = fs.readFileSync(webSecretPath(stateDir));
|
|
48
|
+
return b.length === SECRET_BYTES ? b : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The secret, created when missing: the state dir 0700 when it is made
|
|
56
|
+
* here, the file 0600, written exclusively so two starts never race to two
|
|
57
|
+
* secrets. An existing file looser than 0600 is tightened.
|
|
58
|
+
*/
|
|
59
|
+
export function ensureWebSecret(stateDir: string = getStateDir()): Buffer {
|
|
60
|
+
const file = webSecretPath(stateDir);
|
|
61
|
+
const existing = readWebSecret(stateDir);
|
|
62
|
+
if (existing) {
|
|
63
|
+
tighten(file);
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
67
|
+
const fresh = randomBytes(SECRET_BYTES);
|
|
68
|
+
try {
|
|
69
|
+
// A malformed file (wrong length) is replaced; a missing one created.
|
|
70
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
71
|
+
fs.writeFileSync(file, fresh, { mode: 0o600, flag: "wx" });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Another process created it first: use theirs.
|
|
74
|
+
const won = readWebSecret(stateDir);
|
|
75
|
+
if (won) return won;
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
tighten(file);
|
|
79
|
+
return fresh;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* SEC-1b (`web logout --all`): a fresh secret in place of the old one,
|
|
84
|
+
* written to a temp file (0600) and renamed over it, so a reader sees one
|
|
85
|
+
* or the other, never half. Every person token and every browser session
|
|
86
|
+
* derived from the old one stops verifying; the CLI re-reads the file on
|
|
87
|
+
* each call, so it keeps working.
|
|
88
|
+
*/
|
|
89
|
+
export function rotateWebSecret(stateDir: string = getStateDir()): void {
|
|
90
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
91
|
+
const file = webSecretPath(stateDir);
|
|
92
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
93
|
+
fs.writeFileSync(tmp, randomBytes(SECRET_BYTES), { mode: 0o600, flag: "wx" });
|
|
94
|
+
fs.renameSync(tmp, file);
|
|
95
|
+
tighten(file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tighten(file: string): void {
|
|
99
|
+
try {
|
|
100
|
+
if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600);
|
|
101
|
+
} catch {
|
|
102
|
+
/* gone or not ours: the read already decided */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `den_pt_<b64url(HMAC-SHA256(secret, "den-person-v1:" + user))>`. */
|
|
107
|
+
export function personToken(secret: Buffer, user: string = osUser()): string {
|
|
108
|
+
const mac = createHmac("sha256", secret)
|
|
109
|
+
.update(PERSON_CONTEXT + user)
|
|
110
|
+
.digest("base64url");
|
|
111
|
+
return PERSON_TOKEN_PREFIX + mac;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The actor a presented person token proves (`person:<user>`), or null.
|
|
116
|
+
* Constant time: both sides are hashed to one length before comparing.
|
|
117
|
+
*/
|
|
118
|
+
export function verifyPersonToken(
|
|
119
|
+
presented: string,
|
|
120
|
+
secret: Buffer,
|
|
121
|
+
user: string = osUser(),
|
|
122
|
+
): `person:${string}` | null {
|
|
123
|
+
const expected = personToken(secret, user);
|
|
124
|
+
const a = createHmac("sha256", "den-cmp").update(presented).digest();
|
|
125
|
+
const b = createHmac("sha256", "den-cmp").update(expected).digest();
|
|
126
|
+
return timingSafeEqual(a, b) ? `person:${user}` : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The headers every CLI and stdio-MCP call to the web server carries:
|
|
131
|
+
* - inside an agent's session (SEC-1c): its DEN_ACTOR_TOKEN, never the
|
|
132
|
+
* person's; an agent (DEN_AGENT set) without one sends no credential
|
|
133
|
+
* rather than fall back to the person token;
|
|
134
|
+
* - otherwise the person token, or none when there is no secret yet (a
|
|
135
|
+
* server that predates SEC-1, or none started).
|
|
136
|
+
*/
|
|
137
|
+
export function cliAuthHeaders(
|
|
138
|
+
stateDir: string = getStateDir(),
|
|
139
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
140
|
+
): Record<string, string> {
|
|
141
|
+
const actorToken = env[ACTOR_TOKEN_ENV];
|
|
142
|
+
if (isActorTokenShape(actorToken))
|
|
143
|
+
return { Authorization: `Bearer ${actorToken}` };
|
|
144
|
+
if (env[AGENT_ACTOR_ENV] !== undefined || actorToken !== undefined) return {};
|
|
145
|
+
const secret = readWebSecret(stateDir);
|
|
146
|
+
return secret ? { Authorization: `Bearer ${personToken(secret)}` } : {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The server's view of the secret: read once, re-read when the file changes
|
|
151
|
+
* (SEC-1b's rotation), created at start.
|
|
152
|
+
*/
|
|
153
|
+
export class WebSecretStore {
|
|
154
|
+
private cached: { key: string; secret: Buffer } | null = null;
|
|
155
|
+
constructor(private readonly stateDir: () => string = getStateDir) {}
|
|
156
|
+
|
|
157
|
+
/** Create the secret if needed (at server start). */
|
|
158
|
+
ensure(): void {
|
|
159
|
+
const secret = ensureWebSecret(this.stateDir());
|
|
160
|
+
this.cached = { key: this.key() ?? "", secret };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The current secret, or null when it is missing. */
|
|
164
|
+
current(): Buffer | null {
|
|
165
|
+
const key = this.key();
|
|
166
|
+
if (key === null) return null;
|
|
167
|
+
if (this.cached?.key === key) return this.cached.secret;
|
|
168
|
+
const secret = readWebSecret(this.stateDir());
|
|
169
|
+
this.cached = secret ? { key, secret } : null;
|
|
170
|
+
return secret;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private key(): string | null {
|
|
174
|
+
try {
|
|
175
|
+
const s = fs.statSync(webSecretPath(this.stateDir()));
|
|
176
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-1b (SEC-1 spec §2): the browser's sign-in.
|
|
3
|
+
*
|
|
4
|
+
* - The login code: one-time, valid for 60 s, derived from web.secret.
|
|
5
|
+
* `v1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(secret, the rest). The
|
|
6
|
+
* CLI mints it (it reads the secret as the same OS user); the server
|
|
7
|
+
* checks the mac in constant time and the expiry, and records the nonce
|
|
8
|
+
* as used, so a second use is refused. It is printed only on the one
|
|
9
|
+
* link line a person asked for, and never logged (the request log
|
|
10
|
+
* records the path, never the query).
|
|
11
|
+
* - The session: an opaque random id in the `den_session_<port>` cookie;
|
|
12
|
+
* the record lives in `<state dir>/web-sessions.json` (0600), keyed by
|
|
13
|
+
* HMAC(secret, id), so the file holds no cookie value and rotating the
|
|
14
|
+
* secret (`web logout --all`) ends every session at once.
|
|
15
|
+
* - Lifetime: none (Ward, 2026-11-06). A session ends only at sign-out,
|
|
16
|
+
* `web logout [<id>]`, or `web logout --all` (which rotates the secret).
|
|
17
|
+
* The 0.4.1-dev `web.session.idle` / `web.session.max` keys are ignored,
|
|
18
|
+
* with a warning at start.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
import * as path from "path";
|
|
23
|
+
import * as yaml from "yaml";
|
|
24
|
+
import { getStateDir } from "./paths.js";
|
|
25
|
+
|
|
26
|
+
export const LOGIN_CODE_TTL_MS = 60_000;
|
|
27
|
+
/** Clock skew allowed between the CLI that mints a code and the server. */
|
|
28
|
+
const LOGIN_CODE_SKEW_MS = 5_000;
|
|
29
|
+
const LOGIN_CONTEXT = "den-login-v1:";
|
|
30
|
+
const SESSION_CONTEXT = "den-session-v1:";
|
|
31
|
+
export const WEB_SESSIONS_FILE = "web-sessions.json";
|
|
32
|
+
/**
|
|
33
|
+
* lastSeenAt is written at most this often per session (so a page load
|
|
34
|
+
* doesn't rewrite the file): `web sessions` shows it to the minute.
|
|
35
|
+
*/
|
|
36
|
+
export const SESSION_TOUCH_MS = 60_000;
|
|
37
|
+
const TOUCH_EVERY_MS = SESSION_TOUCH_MS;
|
|
38
|
+
const UA_MAX = 120;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The cookie's Max-Age: the most a browser keeps one (RFC 6265bis caps it
|
|
42
|
+
* at 400 days). The session itself never expires server-side.
|
|
43
|
+
*/
|
|
44
|
+
export const SESSION_COOKIE_MAX_AGE_S = 400 * 86_400;
|
|
45
|
+
|
|
46
|
+
/** The cookie's name: per port, so two distributions on one machine never share one. */
|
|
47
|
+
export function sessionCookieName(port: number): string {
|
|
48
|
+
return `den_session_${port}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── The login code ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function mac(secret: Buffer, body: string): Buffer {
|
|
54
|
+
return createHmac("sha256", secret)
|
|
55
|
+
.update(LOGIN_CONTEXT + body)
|
|
56
|
+
.digest();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A fresh login code, valid for 60 s from `now`. */
|
|
60
|
+
export function mintLoginCode(
|
|
61
|
+
secret: Buffer,
|
|
62
|
+
now: number = Date.now(),
|
|
63
|
+
): string {
|
|
64
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
65
|
+
const body = `v1.${nonce}.${(now + LOGIN_CODE_TTL_MS).toString(36)}`;
|
|
66
|
+
return `${body}.${mac(secret, body).toString("base64url")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type LoginCodeCheck =
|
|
70
|
+
| { ok: true; nonce: string; expiresAt: number }
|
|
71
|
+
| { ok: false; reason: "malformed" | "bad_mac" | "expired" };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `code` is one this secret minted and still in its 60 s. The mac is
|
|
75
|
+
* compared in constant time. Single use is the caller's (consumeCode).
|
|
76
|
+
*/
|
|
77
|
+
export function checkLoginCode(
|
|
78
|
+
code: string,
|
|
79
|
+
secret: Buffer,
|
|
80
|
+
now: number = Date.now(),
|
|
81
|
+
): LoginCodeCheck {
|
|
82
|
+
const m =
|
|
83
|
+
/^(v1\.([A-Za-z0-9_-]{22})\.([0-9a-z]{1,11}))\.([A-Za-z0-9_-]{43})$/.exec(
|
|
84
|
+
code,
|
|
85
|
+
);
|
|
86
|
+
if (!m) return { ok: false, reason: "malformed" };
|
|
87
|
+
const given = Buffer.from(m[4], "base64url");
|
|
88
|
+
const want = mac(secret, m[1]);
|
|
89
|
+
if (given.length !== want.length || !timingSafeEqual(given, want))
|
|
90
|
+
return { ok: false, reason: "bad_mac" };
|
|
91
|
+
const expiresAt = parseInt(m[3], 36);
|
|
92
|
+
if (
|
|
93
|
+
!Number.isFinite(expiresAt) ||
|
|
94
|
+
now >= expiresAt ||
|
|
95
|
+
expiresAt - now > LOGIN_CODE_TTL_MS + LOGIN_CODE_SKEW_MS
|
|
96
|
+
)
|
|
97
|
+
return { ok: false, reason: "expired" };
|
|
98
|
+
return { ok: true, nonce: m[2], expiresAt };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where a sign-in may send the browser: a same-origin relative path, else
|
|
103
|
+
* `/`. `//evil`, `/\evil`, `http://evil`, a control character or a path back
|
|
104
|
+
* to /auth all go to `/`.
|
|
105
|
+
*/
|
|
106
|
+
export function safeNext(raw: string | null | undefined): string {
|
|
107
|
+
const v = String(raw ?? "");
|
|
108
|
+
const control = [...v].some((c) => c.charCodeAt(0) < 0x20 || c === "\x7f");
|
|
109
|
+
if (!v.startsWith("/") || v.startsWith("//") || v.includes("\\") || control)
|
|
110
|
+
return "/";
|
|
111
|
+
let u: URL;
|
|
112
|
+
try {
|
|
113
|
+
u = new URL(v, "http://den.invalid");
|
|
114
|
+
} catch {
|
|
115
|
+
return "/";
|
|
116
|
+
}
|
|
117
|
+
if (u.origin !== "http://den.invalid") return "/";
|
|
118
|
+
if (u.pathname === "/auth") return "/";
|
|
119
|
+
return `${u.pathname}${u.search}${u.hash}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The sign-in link: `<origin>/auth?c=<code>&next=<path>`. */
|
|
123
|
+
export function loginLink(origin: string, code: string, next = "/"): string {
|
|
124
|
+
const n = safeNext(next);
|
|
125
|
+
return (
|
|
126
|
+
`${origin.replace(/\/+$/, "")}/auth?c=${encodeURIComponent(code)}` +
|
|
127
|
+
(n === "/" ? "" : `&next=${encodeURIComponent(n)}`)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `http://localhost:<port>`: the canonical loopback origin (§2). */
|
|
132
|
+
export function localhostOrigin(port: number): string {
|
|
133
|
+
return `http://localhost:${port}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An origin a person passed to `web login --origin`: http(s), a host, no
|
|
138
|
+
* path, query or credentials. Null when it isn't one.
|
|
139
|
+
*/
|
|
140
|
+
export function parseOrigin(raw: string): string | null {
|
|
141
|
+
let u: URL;
|
|
142
|
+
try {
|
|
143
|
+
u = new URL(raw);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
148
|
+
if (u.username || u.password || u.search || u.hash) return null;
|
|
149
|
+
if (u.pathname && u.pathname !== "/") return null;
|
|
150
|
+
return u.origin;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Remote-access config ───────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export interface WebAuthConfig {
|
|
156
|
+
/** `web.allow_plain_http_remote` (config/local/user.yml only). */
|
|
157
|
+
allowPlainHttpRemote: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Config keys read and ignored, each with its file: the 0.4.1-dev
|
|
160
|
+
* session lifetimes (`web.session.idle`, `web.session.max`), since a
|
|
161
|
+
* session never expires. The server warns about each at start.
|
|
162
|
+
*/
|
|
163
|
+
ignored: string[];
|
|
164
|
+
/**
|
|
165
|
+
* The distribution config's file when it sets
|
|
166
|
+
* `web.allow_plain_http_remote`: ignored there (a per-machine choice,
|
|
167
|
+
* never the distribution's), and warned about at start.
|
|
168
|
+
*/
|
|
169
|
+
plainHttpInDistribution?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readYaml(file: string): Record<string, unknown> {
|
|
173
|
+
try {
|
|
174
|
+
const v = yaml.parse(fs.readFileSync(file, "utf-8")) as unknown;
|
|
175
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function webBlock(doc: Record<string, unknown>, file: string) {
|
|
183
|
+
const w = doc.web;
|
|
184
|
+
if (w === undefined || w === null) return {};
|
|
185
|
+
if (typeof w !== "object" || Array.isArray(w))
|
|
186
|
+
throw new Error(`web must be a map in ${file}`);
|
|
187
|
+
return w as Record<string, unknown>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `web.session`'s keys in one file, as `web.session.<key> (<file>)`. */
|
|
191
|
+
function ignoredSessionKeys(
|
|
192
|
+
w: Record<string, unknown>,
|
|
193
|
+
file: string,
|
|
194
|
+
): string[] {
|
|
195
|
+
const s = w.session;
|
|
196
|
+
if (s === undefined) return [];
|
|
197
|
+
if (s === null || typeof s !== "object" || Array.isArray(s))
|
|
198
|
+
return [`web.session (${file})`];
|
|
199
|
+
const keys = Object.keys(s);
|
|
200
|
+
return keys.length
|
|
201
|
+
? keys.map((k) => `web.session.${k} (${file})`)
|
|
202
|
+
: [`web.session (${file})`];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The start-up warning for the ignored keys, or null when there are none. */
|
|
206
|
+
export function ignoredConfigWarning(cfg: WebAuthConfig): string | null {
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
if (cfg.ignored.length)
|
|
209
|
+
lines.push(
|
|
210
|
+
`${cfg.ignored.join(", ")}: ignored. A browser session never expires; ` +
|
|
211
|
+
"it ends at sign-out or `web logout`. Remove the key to silence this.",
|
|
212
|
+
);
|
|
213
|
+
if (cfg.plainHttpInDistribution)
|
|
214
|
+
lines.push(
|
|
215
|
+
`web.allow_plain_http_remote (${cfg.plainHttpInDistribution}): ignored. ` +
|
|
216
|
+
"It is a choice for one machine, read only from config/local/user.yml; " +
|
|
217
|
+
"move it there on the machine that needs it, and remove it here.",
|
|
218
|
+
);
|
|
219
|
+
return lines.length ? lines.join("\n ") : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `web.allow_plain_http_remote` (user.yml only: a choice for this machine,
|
|
224
|
+
* never the distribution's), and the ignored `web.session` keys of either
|
|
225
|
+
* file. Throws naming the key when a value is wrong.
|
|
226
|
+
*/
|
|
227
|
+
export function readWebAuthConfig(
|
|
228
|
+
root: string,
|
|
229
|
+
configFile = path.join("config", "den.yml"),
|
|
230
|
+
): WebAuthConfig {
|
|
231
|
+
const teamFile = path.join(root, configFile);
|
|
232
|
+
const userFile = path.join(root, "config", "local", "user.yml");
|
|
233
|
+
const team = webBlock(readYaml(teamFile), configFile);
|
|
234
|
+
const user = webBlock(readYaml(userFile), "config/local/user.yml");
|
|
235
|
+
const ignored = [
|
|
236
|
+
...ignoredSessionKeys(team, configFile),
|
|
237
|
+
...ignoredSessionKeys(user, "config/local/user.yml"),
|
|
238
|
+
];
|
|
239
|
+
const plain = user.allow_plain_http_remote;
|
|
240
|
+
if (plain !== undefined && typeof plain !== "boolean")
|
|
241
|
+
throw new Error(
|
|
242
|
+
`web.allow_plain_http_remote must be true or false, got ${JSON.stringify(plain)}`,
|
|
243
|
+
);
|
|
244
|
+
return {
|
|
245
|
+
allowPlainHttpRemote: plain === true,
|
|
246
|
+
ignored,
|
|
247
|
+
...(team.allow_plain_http_remote !== undefined
|
|
248
|
+
? { plainHttpInDistribution: configFile }
|
|
249
|
+
: {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── The session store ──────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export interface SessionRecord {
|
|
256
|
+
createdAt: number;
|
|
257
|
+
lastSeenAt: number;
|
|
258
|
+
userAgent: string;
|
|
259
|
+
/** This session's CSRF token (X-Den-CSRF). */
|
|
260
|
+
csrf: string;
|
|
261
|
+
/** The Host it was issued on. */
|
|
262
|
+
host: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface StoreFile {
|
|
266
|
+
v: 1;
|
|
267
|
+
sessions: Record<string, SessionRecord>;
|
|
268
|
+
/** sha256(nonce) → the code's expiry: codes already used. */
|
|
269
|
+
usedCodes: Record<string, number>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface SessionView extends SessionRecord {
|
|
273
|
+
/** HMAC(secret, id), hex: the record's key (its prefix is what `web sessions` shows). */
|
|
274
|
+
key: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function webSessionsPath(stateDir: string = getStateDir()): string {
|
|
278
|
+
return path.join(stateDir, WEB_SESSIONS_FILE);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const empty = (): StoreFile => ({ v: 1, sessions: {}, usedCodes: {} });
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The browser sessions, server-side. The file is re-read when it changes
|
|
285
|
+
* (the CLI edits it while the server is down), written atomically, 0600.
|
|
286
|
+
*/
|
|
287
|
+
export class WebSessionStore {
|
|
288
|
+
private cache: { key: string; data: StoreFile } | null = null;
|
|
289
|
+
private readonly stateDir: () => string;
|
|
290
|
+
private readonly secret: () => Buffer | null;
|
|
291
|
+
private readonly now: () => number;
|
|
292
|
+
|
|
293
|
+
constructor(opts: {
|
|
294
|
+
stateDir?: () => string;
|
|
295
|
+
secret: () => Buffer | null;
|
|
296
|
+
now?: () => number;
|
|
297
|
+
}) {
|
|
298
|
+
this.stateDir = opts.stateDir ?? getStateDir;
|
|
299
|
+
this.secret = opts.secret;
|
|
300
|
+
this.now = opts.now ?? Date.now;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private file(): string {
|
|
304
|
+
return webSessionsPath(this.stateDir());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private statKey(): string | null {
|
|
308
|
+
try {
|
|
309
|
+
const s = fs.statSync(this.file());
|
|
310
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private load(): StoreFile {
|
|
317
|
+
const key = this.statKey();
|
|
318
|
+
if (key === null) return empty();
|
|
319
|
+
if (this.cache?.key === key) return this.cache.data;
|
|
320
|
+
let data = empty();
|
|
321
|
+
try {
|
|
322
|
+
const raw = JSON.parse(
|
|
323
|
+
fs.readFileSync(this.file(), "utf-8"),
|
|
324
|
+
) as Partial<StoreFile>;
|
|
325
|
+
data = {
|
|
326
|
+
v: 1,
|
|
327
|
+
sessions:
|
|
328
|
+
raw.sessions && typeof raw.sessions === "object" ? raw.sessions : {},
|
|
329
|
+
usedCodes:
|
|
330
|
+
raw.usedCodes && typeof raw.usedCodes === "object"
|
|
331
|
+
? raw.usedCodes
|
|
332
|
+
: {},
|
|
333
|
+
};
|
|
334
|
+
} catch {
|
|
335
|
+
/* unreadable or malformed: no sessions */
|
|
336
|
+
}
|
|
337
|
+
this.cache = { key, data };
|
|
338
|
+
return data;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private save(data: StoreFile): void {
|
|
342
|
+
const dir = this.stateDir();
|
|
343
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
344
|
+
const file = this.file();
|
|
345
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
346
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
347
|
+
fs.renameSync(tmp, file);
|
|
348
|
+
const key = this.statKey();
|
|
349
|
+
this.cache = key ? { key, data } : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private keyOf(id: string): string | null {
|
|
353
|
+
const secret = this.secret();
|
|
354
|
+
if (!secret) return null;
|
|
355
|
+
return createHmac("sha256", secret)
|
|
356
|
+
.update(SESSION_CONTEXT + id)
|
|
357
|
+
.digest("hex");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Drop used codes past their expiry (a session never expires). */
|
|
361
|
+
private prune(data: StoreFile, now: number): boolean {
|
|
362
|
+
let changed = false;
|
|
363
|
+
for (const [k, exp] of Object.entries(data.usedCodes))
|
|
364
|
+
if (exp <= now) {
|
|
365
|
+
delete data.usedCodes[k];
|
|
366
|
+
changed = true;
|
|
367
|
+
}
|
|
368
|
+
return changed;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Record a login code's nonce as used. False when it was used already
|
|
373
|
+
* (the code is refused).
|
|
374
|
+
*/
|
|
375
|
+
consumeCode(nonce: string, expiresAt: number): boolean {
|
|
376
|
+
const now = this.now();
|
|
377
|
+
const data = structuredClone(this.load());
|
|
378
|
+
this.prune(data, now);
|
|
379
|
+
const h = createHash("sha256").update(nonce).digest("hex");
|
|
380
|
+
if (data.usedCodes[h] !== undefined) return false;
|
|
381
|
+
data.usedCodes[h] = expiresAt;
|
|
382
|
+
this.save(data);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A new session: its id (the cookie's value) and record. */
|
|
387
|
+
create(meta: { userAgent?: string; host?: string }): {
|
|
388
|
+
id: string;
|
|
389
|
+
session: SessionView;
|
|
390
|
+
} {
|
|
391
|
+
const id = randomBytes(32).toString("base64url");
|
|
392
|
+
const key = this.keyOf(id);
|
|
393
|
+
if (!key) throw new Error("web.secret is missing: no session can be made");
|
|
394
|
+
const now = this.now();
|
|
395
|
+
const rec: SessionRecord = {
|
|
396
|
+
createdAt: now,
|
|
397
|
+
lastSeenAt: now,
|
|
398
|
+
userAgent: String(meta.userAgent ?? "").slice(0, UA_MAX),
|
|
399
|
+
csrf: randomBytes(32).toString("base64url"),
|
|
400
|
+
host: String(meta.host ?? "")
|
|
401
|
+
.toLowerCase()
|
|
402
|
+
.slice(0, 255),
|
|
403
|
+
};
|
|
404
|
+
const data = structuredClone(this.load());
|
|
405
|
+
this.prune(data, now);
|
|
406
|
+
data.sessions[key] = rec;
|
|
407
|
+
this.save(data);
|
|
408
|
+
return { id, session: { key, ...rec } };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The session a cookie value names, its lastSeenAt slid forward
|
|
413
|
+
* (written at most once a minute). Null when unknown, ended, or the
|
|
414
|
+
* secret has rotated since.
|
|
415
|
+
*/
|
|
416
|
+
lookup(id: string | undefined | null): SessionView | null {
|
|
417
|
+
if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
|
|
418
|
+
const key = this.keyOf(id);
|
|
419
|
+
if (!key) return null;
|
|
420
|
+
const data = this.load();
|
|
421
|
+
const rec = data.sessions[key];
|
|
422
|
+
if (!rec) return null;
|
|
423
|
+
const now = this.now();
|
|
424
|
+
if (now - rec.lastSeenAt >= TOUCH_EVERY_MS) {
|
|
425
|
+
const next = structuredClone(data);
|
|
426
|
+
next.sessions[key] = { ...rec, lastSeenAt: now };
|
|
427
|
+
this.trySave(next);
|
|
428
|
+
return { key, ...next.sessions[key] };
|
|
429
|
+
}
|
|
430
|
+
return { key, ...rec };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private trySave(data: StoreFile): void {
|
|
434
|
+
try {
|
|
435
|
+
this.save(data);
|
|
436
|
+
} catch {
|
|
437
|
+
/* a read-only state dir: the lookup still answers */
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Every session, newest first. */
|
|
442
|
+
list(): SessionView[] {
|
|
443
|
+
return Object.entries(this.load().sessions)
|
|
444
|
+
.map(([key, r]) => ({ key, ...r }))
|
|
445
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** End the session with this key. */
|
|
449
|
+
end(key: string): boolean {
|
|
450
|
+
const data = structuredClone(this.load());
|
|
451
|
+
if (!data.sessions[key]) return false;
|
|
452
|
+
delete data.sessions[key];
|
|
453
|
+
this.save(data);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* End the one session whose key starts with `prefix` (4 hex at least).
|
|
459
|
+
* Throws when none or several match.
|
|
460
|
+
*/
|
|
461
|
+
endByPrefix(prefix: string): SessionView {
|
|
462
|
+
const p = prefix.trim().toLowerCase();
|
|
463
|
+
if (!/^[0-9a-f]{4,64}$/.test(p))
|
|
464
|
+
throw new Error(
|
|
465
|
+
`"${prefix}" is not a session id prefix (4 or more hex characters, as \`web sessions\` lists them)`,
|
|
466
|
+
);
|
|
467
|
+
const hits = this.list().filter((s) => s.key.startsWith(p));
|
|
468
|
+
if (hits.length === 0)
|
|
469
|
+
throw new Error(`No browser session starts with ${p}`);
|
|
470
|
+
if (hits.length > 1)
|
|
471
|
+
throw new Error(
|
|
472
|
+
`${hits.length} browser sessions start with ${p}; give more of the id`,
|
|
473
|
+
);
|
|
474
|
+
this.end(hits[0].key);
|
|
475
|
+
return hits[0];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** End every browser session; how many there were. */
|
|
479
|
+
endAll(): number {
|
|
480
|
+
const data = structuredClone(this.load());
|
|
481
|
+
const n = Object.keys(data.sessions).length;
|
|
482
|
+
data.sessions = {};
|
|
483
|
+
this.save(data);
|
|
484
|
+
return n;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A session's id as `web sessions` shows it: the first 8 hex of its key. */
|
|
489
|
+
export function sessionIdPrefix(key: string): string {
|
|
490
|
+
return key.slice(0, 8);
|
|
491
|
+
}
|