diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index c2a4cf42..496521e6 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -8,7 +8,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthNonce, + isMatchingBarAuthProof, + getOrCreateBarAuthToken, +} from '../../utils/bar-auth-token'; const PROBE_TIMEOUT_MS = 1500; const MAX_PROBE_RESPONSE_BYTES = 8192; @@ -49,12 +55,10 @@ export function resolveBarPort(ccsDir: string): number | null { * from a healthy one (200) without depending on a higher-level HTTP client. * * Token authentication: the probe does NOT send the token in the request. - * The real CCS Bar server reads the token from the 0600 file and includes it - * unconditionally in the x-ccs-bar-token response header. The probe then checks - * that the echoed value matches the locally-read token. A rogue loopback process - * that has not read the 0600 file cannot produce the correct value, so a 200 - * without a matching token header is rejected. Sending the token in the request - * would defeat this — any process could echo what it received. + * Instead, it sends a fresh nonce. The real CCS Bar server reads the token from + * the 0600 file and returns HMAC(token, nonce) in the x-ccs-bar-token response + * header. The probe verifies the nonce-bound proof, so a captured proof cannot + * be replayed for a future probe. */ export async function defaultFindRunningServer(ccsDir: string): Promise { const token = getOrCreateBarAuthToken(ccsDir); @@ -64,6 +68,7 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { let rawResponse = ''; @@ -85,23 +90,21 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { - // Do NOT include the token in the request — sending the secret to the - // party being authenticated lets any reflector trivially pass the check. + // Do NOT include the token in the request; only send a fresh nonce so + // the server can prove it knows the token without disclosing it. socket.write( - `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n` + `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n` ); }); socket.setTimeout(PROBE_TIMEOUT_MS, () => finish()); diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 7a0e3248..2d1f1897 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -21,7 +21,13 @@ import * as os from 'os'; import * as path from 'path'; import type { ChildProcess } from 'child_process'; import { getCcsDir } from '../../config/config-loader-facade'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthNonce, + isMatchingBarAuthProof, + getOrCreateBarAuthToken, +} from '../../utils/bar-auth-token'; import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; import { createBarLaunchDescriptor } from './launch-descriptor'; @@ -152,6 +158,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { async function probe(): Promise<{ statusCode: number | null; tokenMatched: boolean }> { const url = new URL(`${baseUrl}/api/bar/summary`); + const nonce = createBarAuthNonce(); return new Promise((resolve) => { let rawResponse = ''; let settled = false; @@ -166,8 +173,8 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { const echoMatch = headerSection.match( new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') ); - const echoedToken = echoMatch ? echoMatch[1].trim() : ''; - resolve({ statusCode, tokenMatched: echoedToken === token }); + const proof = echoMatch ? echoMatch[1].trim() : ''; + resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) }); return; } resolve({ statusCode, tokenMatched: false }); @@ -175,10 +182,10 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { const socket = net.connect( { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) }, () => { - // Do NOT include the token in the request — sending the secret to the - // party being authenticated lets any reflector trivially pass the check. + // Do NOT include the token in the request; only send a fresh nonce so + // the server can prove it knows the token without disclosing it. socket.write( - `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` + `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n` ); } ); diff --git a/src/utils/bar-auth-token.ts b/src/utils/bar-auth-token.ts index bd8da5ca..0c9f4b58 100644 --- a/src/utils/bar-auth-token.ts +++ b/src/utils/bar-auth-token.ts @@ -4,7 +4,29 @@ import * as path from 'path'; import { getCcsDir } from '../config/config-loader-facade'; export const BAR_AUTH_TOKEN_HEADER = 'x-ccs-bar-token'; +export const BAR_AUTH_NONCE_HEADER = 'x-ccs-bar-nonce'; const TOKEN_BYTE_LENGTH = 32; +const NONCE_MIN_LENGTH = 16; + +export function createBarAuthNonce(): string { + return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex'); +} + +export function isValidBarAuthNonce(nonce: string): boolean { + return /^[a-f0-9]+$/i.test(nonce) && nonce.length >= NONCE_MIN_LENGTH && nonce.length <= 128; +} + +export function createBarAuthProof(token: string, nonce: string): string { + return crypto.createHmac('sha256', token).update(nonce).digest('hex'); +} + +export function isMatchingBarAuthProof(token: string, nonce: string, proof: string): boolean { + if (!isValidBarAuthNonce(nonce) || !/^[a-f0-9]{64}$/i.test(proof)) { + return false; + } + const expected = createBarAuthProof(token, nonce); + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(proof, 'hex')); +} export function getBarAuthTokenPath(ccsDir = getCcsDir()): string { return path.join(ccsDir, 'bar', '.auth-token'); diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 79d39161..a54cb44d 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -7,7 +7,13 @@ import { Router } from 'express'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthProof, + getOrCreateBarAuthToken, + isValidBarAuthNonce, +} from '../../utils/bar-auth-token'; // Import domain routers import profileRoutes from './profile-routes'; @@ -69,11 +75,13 @@ apiRoutes.use((req, res, next) => { // Exact segment match so a future sibling like '/barbaz' isn't accidentally gated. if (req.path === '/bar' || req.path.startsWith('/bar/')) { if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) { - // Echo the token unconditionally so the probe can verify it without - // having sent the secret in the request. Only the real CCS Bar process - // (which owns the 0600 file) can produce this value — a rogue loopback - // process that hasn't read the file cannot replicate it. - res.setHeader(BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken()); + // Authenticate liveness probes with a nonce-bound HMAC so normal Bar + // responses never disclose the persistent file token, and captured probe + // proofs cannot be replayed for a future probe. + const nonce = req.header(BAR_AUTH_NONCE_HEADER)?.trim() ?? ''; + if (isValidBarAuthNonce(nonce)) { + res.setHeader(BAR_AUTH_TOKEN_HEADER, createBarAuthProof(getOrCreateBarAuthToken(), nonce)); + } next(); } return; diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index fbc5e2cc..5f649ef1 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -13,7 +13,12 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../../src/utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthProof, + getOrCreateBarAuthToken, +} from '../../../src/utils/bar-auth-token'; // --------------------------------------------------------------------------- // Helpers @@ -1752,14 +1757,12 @@ describe('defaultFindRunningServer (GH-1500)', () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - // Start an ephemeral server that responds 200 to /api/bar/summary with the shared token. - // The server echoes the token unconditionally (reading it from the file), mirroring - // production behavior: only a process that owns the 0600 file can produce the value. - const server = http.createServer((_req, res) => { + const server = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -1970,11 +1973,12 @@ describe('defaultFindRunningServer (GH-1500)', () => { // This simulates `ccs config` starting the web-server with host 'localhost' // on macOS, where 'localhost' resolves to ::1. // The server echoes the token unconditionally (from the file), mirroring production. - const server = http.createServer((_req, res) => { + const server = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -2026,11 +2030,12 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () // Lower-priority server (default port candidate): responds immediately with 200. // Echoes token unconditionally (from file), mirroring production behavior. - const fastServer = http.createServer((_req, res) => { + const fastServer = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -2039,12 +2044,13 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () // Higher-priority server (bar.json port): adds ~300 ms artificial delay, // but still responds 200 within the 1500 ms timeout. - const slowServer = http.createServer((_req, res) => { + const slowServer = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }, 300); @@ -3152,7 +3158,17 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired setTimeout(_ms: number, _cb: () => void) { return socket; }, - write() { + write(data: string) { + const nonce = + data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + for (const cb of listeners.data ?? []) { + cb( + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(token, nonce)}\r\n\r\n`, + 'utf8' + ) + ); + } return true; }, destroy() { @@ -3161,9 +3177,6 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired }; setImmediate(() => { onConnect(); - for (const cb of listeners.data ?? []) { - cb(Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${token}\r\n\r\n`, 'utf8')); - } }); return socket; }, @@ -3205,7 +3218,7 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected // fully synchronous and immune to OS socket state. The invariant is // behaviour-coupled: removing the token check causes both tests to fail. - function buildNetMock(responseHeaders: string) { + function buildNetMock(responseHeaders: string | ((request: string) => string)) { // Returns a `net` mock whose connect() immediately delivers the response, // then emits 'end'. return { @@ -3219,7 +3232,12 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected setTimeout(_ms: number, _cb: () => void) { return socket; }, - write() { + write(data: string) { + const response = + typeof responseHeaders === 'function' ? responseHeaders(data) : responseHeaders; + for (const cb of listeners.data ?? []) { + cb(Buffer.from(response, 'utf8')); + } return true; }, destroy() { @@ -3228,9 +3246,6 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected }; setImmediate(() => { onConnect(); - for (const cb of listeners.data ?? []) { - cb(Buffer.from(responseHeaders, 'utf8')); - } for (const cb of listeners.end ?? []) { cb(); } @@ -3294,11 +3309,15 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected const { getOrCreateBarAuthToken: getToken } = await import( `../../../src/utils/bar-auth-token?test=${Date.now()}-legit-net` ); - const realToken = getToken(ccsDir); + const realToken = getToken(); - // Mock net: every probe gets 200 with the CORRECT token. + // Mock net: every probe gets 200 with the CORRECT nonce-bound proof. mock.module('net', () => - buildNetMock(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${realToken}\r\n\r\n`) + buildNetMock((request) => { + const nonce = + request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + return `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(realToken, nonce)}\r\n\r\n`; + }) ); moduleSeq++; @@ -3363,7 +3382,19 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { setTimeout() { return socket; }, - write() { + write(data: string) { + if (opts.port === 41235) { + const nonce = + data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + for (const cb of listeners.data ?? []) { + cb( + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`, + 'utf8' + ) + ); + } + } return true; }, destroy() { @@ -3375,18 +3406,6 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { // Fire the connect callback asynchronously, mirroring net.connect. setImmediate(() => { onConnect(); - if (opts.port === 41235) { - const data = listeners.data ?? []; - // The mock server includes the token unconditionally in the response - // (read from the 0600 file, not echoed from the request) — this is - // exactly what the production CCS Bar server does, and is the property - // that prevents a rogue reflector from passing the check. - for (const cb of data) { - cb( - Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8') - ); - } - } // Port 3000 never emits a status line: simulate an endlessly // streaming service that must not block the higher-priority hit. // Any other port stays silent and is settled by the 1.5s timeout, @@ -3439,6 +3458,7 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => { const listeners: Record void>> = {}; let interval: ReturnType | undefined; + let request = ''; const socket = { on(event: string, cb: (arg?: unknown) => void) { (listeners[event] ??= []).push(cb); @@ -3447,7 +3467,8 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers setTimeout() { return socket; }, - write() { + write(data: string) { + request = data; return true; }, destroy() { @@ -3466,9 +3487,15 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers return; } if (opts.port === 3000) { + const nonce = + request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? + ''; for (const cb of listeners.data ?? []) { cb( - Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8') + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`, + 'utf8' + ) ); } }