Skip to content

Commit 0b08f27

Browse files
committed
Generate command auth tokens locally
1 parent 1bb72c5 commit 0b08f27

6 files changed

Lines changed: 252 additions & 15 deletions

File tree

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ DUNE_DISCORD_ADAPTER_ENABLED=false
7373
# DUNE_DB_USER=dune
7474
# DUNE_DB_PASSWORD=dune
7575

76-
# Optional RabbitMQ/server-command override. Normally the web admin reads
77-
# runtime/secrets/command-auth-token.txt or uses the RedBlink built-in token.
76+
# Optional RabbitMQ/server-command override. Normally the web admin reads or
77+
# creates runtime/secrets/command-auth-token.txt with local 0600 permissions.
7878
# DUNE_COMMAND_AUTH_TOKEN=
7979

8080
# Optional runtime path override when the web admin is run outside this repo.

console/api/src/rmq.js

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { spawn } from "node:child_process";
2-
import { existsSync, readFileSync } from "node:fs";
3-
import { resolve } from "node:path";
4-
import { randomUUID } from "node:crypto";
2+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { dirname, resolve } from "node:path";
4+
import { randomBytes, randomUUID } from "node:crypto";
55
import { redact } from "./redact.js";
66

7-
const BUILTIN_COMMAND_AUTH_TOKEN = "Nu6VmPWUMvdPMeB7qErr";
87
const RMQ_CONTAINER = "dune-rmq-game";
8+
const COMMAND_AUTH_TOKEN_BYTES = 32;
99

1010
export function validateBroadcastMessage(message) {
1111
const raw = String(message || "").trim();
@@ -226,14 +226,38 @@ export function validatePublishLabel(value) {
226226
throw new Error("Invalid RabbitMQ publish label");
227227
}
228228

229-
function commandAuthToken(repoRoot) {
229+
export function commandAuthToken(repoRoot) {
230230
const file = resolve(repoRoot, "runtime/secrets/command-auth-token.txt");
231231
if (process.env.DUNE_COMMAND_AUTH_TOKEN) return process.env.DUNE_COMMAND_AUTH_TOKEN;
232-
if (existsSync(file)) {
233-
const raw = readFileSync(file, "utf8").trim();
234-
if (raw) return raw;
232+
const existing = readCommandAuthTokenFile(file);
233+
if (existing) return existing;
234+
return generateCommandAuthTokenFile(file);
235+
}
236+
237+
function readCommandAuthTokenFile(file) {
238+
if (!existsSync(file)) return "";
239+
return readFileSync(file, "utf8").trim();
240+
}
241+
242+
function generateCommandAuthTokenFile(file) {
243+
const token = randomBytes(COMMAND_AUTH_TOKEN_BYTES).toString("base64url");
244+
mkdirSync(dirname(file), { recursive: true });
245+
const writeFlag = existsSync(file) ? "w" : "wx";
246+
try {
247+
writeFileSync(file, `${token}\n`, { mode: 0o600, flag: writeFlag });
248+
} catch (error) {
249+
if (error?.code === "EEXIST") {
250+
const existing = readCommandAuthTokenFile(file);
251+
if (existing) return existing;
252+
}
253+
throw error;
254+
}
255+
try {
256+
chmodSync(file, 0o600);
257+
} catch {
258+
// Best effort on non-POSIX development hosts.
235259
}
236-
return BUILTIN_COMMAND_AUTH_TOKEN;
260+
return token;
237261
}
238262

239263
function dockerExec(args, timeoutMs = 30000) {

console/api/test/rmq.test.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import test from "node:test";
22
import assert from "node:assert/strict";
3-
import { buildBroadcastCommand, buildCarePackageWhisperPayload, buildMapChatPayload, buildShutdownBroadcastCommand, publishCarePackageWhisper, publishMapChat, validateBroadcastMessage, validateLocalizedTexts, validatePublishLabel } from "../src/rmq.js";
3+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join, resolve } from "node:path";
6+
import { buildBroadcastCommand, buildCarePackageWhisperPayload, buildMapChatPayload, buildShutdownBroadcastCommand, commandAuthToken, publishCarePackageWhisper, publishMapChat, validateBroadcastMessage, validateLocalizedTexts, validatePublishLabel } from "../src/rmq.js";
47

58
test("builds verified ServiceBroadcast generic command payload", () => {
69
const command = buildBroadcastCommand({ message: "Server event starts soon", durationSec: 45, title: "Event" });
@@ -107,6 +110,66 @@ test("validates RabbitMQ publish labels before eval construction", () => {
107110
assert.throws(() => validatePublishLabel("bad\"), halt(). %"));
108111
});
109112

113+
test("command auth token generates and reuses a local secret file", () => {
114+
const repoRoot = mkdtempSync(join(tmpdir(), "arrakis-rmq-token-"));
115+
const previous = process.env.DUNE_COMMAND_AUTH_TOKEN;
116+
delete process.env.DUNE_COMMAND_AUTH_TOKEN;
117+
try {
118+
const tokenFile = resolve(repoRoot, "runtime/secrets/command-auth-token.txt");
119+
const token = commandAuthToken(repoRoot);
120+
assert.match(token, /^[A-Za-z0-9_-]{40,}$/);
121+
assert.equal(existsSync(tokenFile), true);
122+
assert.equal(readFileSync(tokenFile, "utf8").trim(), token);
123+
assert.equal(statSync(tokenFile).mode & 0o777, 0o600);
124+
assert.equal(commandAuthToken(repoRoot), token);
125+
} finally {
126+
if (previous === undefined) {
127+
delete process.env.DUNE_COMMAND_AUTH_TOKEN;
128+
} else {
129+
process.env.DUNE_COMMAND_AUTH_TOKEN = previous;
130+
}
131+
rmSync(repoRoot, { recursive: true, force: true });
132+
}
133+
});
134+
135+
test("command auth token honors explicit environment override", () => {
136+
const repoRoot = mkdtempSync(join(tmpdir(), "arrakis-rmq-token-env-"));
137+
const previous = process.env.DUNE_COMMAND_AUTH_TOKEN;
138+
process.env.DUNE_COMMAND_AUTH_TOKEN = "explicit-token";
139+
try {
140+
const tokenFile = resolve(repoRoot, "runtime/secrets/command-auth-token.txt");
141+
assert.equal(commandAuthToken(repoRoot), "explicit-token");
142+
assert.equal(existsSync(tokenFile), false);
143+
} finally {
144+
if (previous === undefined) {
145+
delete process.env.DUNE_COMMAND_AUTH_TOKEN;
146+
} else {
147+
process.env.DUNE_COMMAND_AUTH_TOKEN = previous;
148+
}
149+
rmSync(repoRoot, { recursive: true, force: true });
150+
}
151+
});
152+
153+
test("command auth token reuses an existing local secret file", () => {
154+
const repoRoot = mkdtempSync(join(tmpdir(), "arrakis-rmq-token-existing-"));
155+
const previous = process.env.DUNE_COMMAND_AUTH_TOKEN;
156+
delete process.env.DUNE_COMMAND_AUTH_TOKEN;
157+
try {
158+
const tokenFile = resolve(repoRoot, "runtime/secrets/command-auth-token.txt");
159+
mkdirSync(resolve(repoRoot, "runtime/secrets"), { recursive: true });
160+
writeFileSync(tokenFile, "existing-local-token\n", { mode: 0o600 });
161+
assert.equal(commandAuthToken(repoRoot), "existing-local-token");
162+
assert.equal(readFileSync(tokenFile, "utf8"), "existing-local-token\n");
163+
} finally {
164+
if (previous === undefined) {
165+
delete process.env.DUNE_COMMAND_AUTH_TOKEN;
166+
} else {
167+
process.env.DUNE_COMMAND_AUTH_TOKEN = previous;
168+
}
169+
rmSync(repoRoot, { recursive: true, force: true });
170+
}
171+
});
172+
110173
test("publishes map chat to chat.map routing key", async () => {
111174
const originalSpawn = globalThis.__testSpawn;
112175
try {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Generated Command Auth Token
2+
3+
Branch: `security/generated-command-auth-token-fix`
4+
5+
## Purpose
6+
7+
Remove the shared built-in RabbitMQ server-command auth token and generate a deployment-local secret on first use.
8+
9+
The command channel behavior stays the same: admin tools and the WebUI still send the `Version`, `AuthToken`, and `MessageContent` envelope to the existing RabbitMQ route. The default token source changes from public source code to `runtime/secrets/command-auth-token.txt`.
10+
11+
## Source Findings
12+
13+
- Upstream `Red-Blink/dune-awakening-selfhost-docker` `main` at `1bb72c5027b5fbab4cbf4ae7d0328a8793539a0a` still contains the static fallback in `console/api/src/rmq.js` and `runtime/scripts/admin-tools.sh`.
14+
- Fork tracking issue: `yacketrj/dune-awakening-selfhost-docker-WSL#66`.
15+
- Related upstream history: `Red-Blink/dune-awakening-selfhost-docker#22` attempted this class of fix but was closed unmerged.
16+
17+
## Architecture Before
18+
19+
- `console/api/src/rmq.js` contained a public built-in token constant.
20+
- `runtime/scripts/admin-tools.sh` contained the same public built-in token constant.
21+
- If `DUNE_COMMAND_AUTH_TOKEN` and `runtime/secrets/command-auth-token.txt` were absent, both paths used the source-controlled fallback.
22+
- Every deployment without an override therefore shared the same command-channel secret.
23+
24+
## Architecture After
25+
26+
- `DUNE_COMMAND_AUTH_TOKEN` remains the highest-precedence explicit override.
27+
- If `runtime/secrets/command-auth-token.txt` exists and is non-empty, both Node and shell paths reuse it.
28+
- If no token exists, the Node path creates a random 32-byte base64url token and writes it with `0600` permissions.
29+
- If no token exists, the shell path creates a random 32-byte hex token with `openssl rand -hex 32`, falling back to Python `secrets.token_hex(32)` when OpenSSL is unavailable.
30+
- The public built-in token constant is removed from source.
31+
32+
## STRIDE Notes
33+
34+
- Spoofing: deployment-local tokens remove a public shared credential that could authenticate forged server-command payloads.
35+
- Tampering: generated files use `0600` permissions to limit local modification to the owning user.
36+
- Repudiation: existing admin audit and history output stay unchanged.
37+
- Information disclosure: the token is not printed in command output; RabbitMQ error output continues to redact `AuthToken`.
38+
- Denial of service: no routing or payload schema changes are introduced.
39+
- Elevation of privilege: explicit `DUNE_COMMAND_AUTH_TOKEN` override remains available for operators who manage secrets externally.
40+
41+
## Minimal Impact
42+
43+
- Existing deployments with `DUNE_COMMAND_AUTH_TOKEN` keep working.
44+
- Existing deployments with `runtime/secrets/command-auth-token.txt` keep working.
45+
- New deployments generate a local secret automatically instead of requiring extra setup.
46+
- RabbitMQ publish payload shape and routing are unchanged.
47+
48+
## Verification Plan
49+
50+
Run from the repository root:
51+
52+
```bash
53+
bash -n runtime/scripts/admin-tools.sh runtime/tests/test-command-auth-token.sh
54+
bash runtime/tests/test-command-auth-token.sh
55+
npm test --prefix console/api
56+
npm audit --prefix console/api --audit-level=moderate
57+
npm run build --prefix console/web
58+
git diff --check
59+
```
60+
61+
Run a source secret scan after committing or from a clean export so dependency folders and local runtime secrets are excluded:
62+
63+
```bash
64+
git archive --format=tar HEAD | tar -xf - -C /tmp/dune-gitleaks-source
65+
gitleaks detect --no-git --source /tmp/dune-gitleaks-source --redact
66+
```
67+
68+
## Verification Results
69+
70+
Validation was run from the staged source tree for this branch.
71+
72+
- `bash -n runtime/scripts/admin-tools.sh runtime/tests/test-command-auth-token.sh`: passed.
73+
- `bash runtime/tests/test-command-auth-token.sh`: passed.
74+
- `npm test --prefix console/api`: 183 tests passed.
75+
- `npm audit --prefix console/api --audit-level=moderate`: 0 vulnerabilities.
76+
- `npm audit --prefix console/web --audit-level=moderate`: 0 vulnerabilities.
77+
- `npm run build --prefix console/web`: passed.
78+
- `git diff --cached --check`: passed.
79+
- `gitleaks detect --no-git --source <staged export> --redact --exit-code 1`: no leaks found.
80+
- `trivy fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL <staged export>`: passed.
81+
- `semgrep --config p/secrets` on changed files: 0 findings.
82+
- `semgrep --config p/security-audit` on changed runtime command files: 0 findings.
83+
84+
Full Trivy misconfiguration scanning still reports pre-existing Dockerfile HIGH findings outside this PR:
85+
86+
- `DS-0002` non-root container users in `console/api/Dockerfile` and `orchestrator/Dockerfile`; tracked in fork issues `#34`, `#36`, and open upstream PR `Red-Blink/dune-awakening-selfhost-docker#13`.
87+
- `DS-0029` missing `--no-install-recommends` in `orchestrator/Dockerfile`; tracked in fork issue `#67`.
88+
89+
## Review Notes
90+
91+
- This change does not rotate existing deployment secrets. Operators who already created `runtime/secrets/command-auth-token.txt` or set `DUNE_COMMAND_AUTH_TOKEN` should rotate them through their normal secret management process if exposure is suspected.
92+
- This change does not claim SOC 2 certification. It provides evidence useful for secret-management and change-control review.

runtime/scripts/admin-tools.sh

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ SKILL_MODULES_FILE="runtime/data/admin-skill-modules.json"
99
XP_EVENT_TAGS_FILE="runtime/data/admin-xp-event-tags.json"
1010
TOKEN_FILE="runtime/secrets/funcom-token.txt"
1111
COMMAND_TOKEN_FILE="runtime/secrets/command-auth-token.txt"
12-
BUILTIN_COMMAND_AUTH_TOKEN="Nu6VmPWUMvdPMeB7qErr"
1312
RMQ_CONTAINER="dune-rmq-game"
1413
POSTGRES_CONTAINER="dune-postgres"
1514
ADMIN_HISTORY_TSV="runtime/generated/admin-command-history.tsv"
@@ -149,8 +148,31 @@ command_auth_token() {
149148
fi
150149
fi
151150

152-
# Matches the working upstream manager's command-auth fallback.
153-
printf '%s' "$BUILTIN_COMMAND_AUTH_TOKEN"
151+
generate_command_auth_token
152+
}
153+
154+
generate_command_auth_token() {
155+
local token
156+
157+
mkdir -p "$(dirname "$COMMAND_TOKEN_FILE")"
158+
if command -v openssl >/dev/null 2>&1; then
159+
token="$(openssl rand -hex 32)"
160+
elif command -v python3 >/dev/null 2>&1; then
161+
token="$(python3 - <<'PY'
162+
import secrets
163+
print(secrets.token_hex(32))
164+
PY
165+
)"
166+
else
167+
echo "Missing openssl or python3; cannot generate $COMMAND_TOKEN_FILE" >&2
168+
return 1
169+
fi
170+
(
171+
umask 077
172+
printf '%s\n' "$token" > "$COMMAND_TOKEN_FILE"
173+
)
174+
chmod 600 "$COMMAND_TOKEN_FILE" 2>/dev/null || true
175+
printf '%s' "$token"
154176
}
155177

156178
require_rmq_game_running() {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
fail() {
5+
echo "FAIL: $*" >&2
6+
exit 1
7+
}
8+
9+
assert_contains() {
10+
local file="$1"
11+
local pattern="$2"
12+
13+
grep -Fq -- "$pattern" "$file" || fail "$file missing: $pattern"
14+
}
15+
16+
assert_not_contains_anywhere() {
17+
local pattern="$1"
18+
local output
19+
shift
20+
output="$(mktemp)"
21+
22+
if grep -RIn -- "$pattern" "$@" >"$output"; then
23+
cat "$output" >&2
24+
rm -f "$output"
25+
fail "unexpected source match: $pattern"
26+
fi
27+
rm -f "$output"
28+
}
29+
30+
assert_not_contains_anywhere 'BUILTIN_COMMAND_AUTH_TOKEN' console/api/src runtime/scripts
31+
assert_contains runtime/scripts/admin-tools.sh 'generate_command_auth_token'
32+
assert_contains runtime/scripts/admin-tools.sh 'openssl rand -hex 32'
33+
assert_contains console/api/src/rmq.js 'randomBytes(COMMAND_AUTH_TOKEN_BYTES).toString("base64url")'
34+
assert_contains .env.example 'creates runtime/secrets/command-auth-token.txt'
35+
36+
echo "PASS: command auth token is generated instead of built in"

0 commit comments

Comments
 (0)