Compare commits
14 Commits
main
...
bb0949553c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb0949553c | ||
|
|
e38e6d1689 | ||
|
|
5337281e85 | ||
|
|
6b8269c1c1 | ||
|
|
c1c837c54f | ||
|
|
89678c7949 | ||
|
|
7af4a68ef5 | ||
|
|
6efed9772e | ||
|
|
1a8984e851 | ||
|
|
d77f1ff62c | ||
|
|
5e7e4b22f2 | ||
|
|
bfe1be1dfe | ||
|
|
aa1912b962 | ||
|
|
95b9cccf07 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ npm-debug.log*
|
|||||||
# test coverage
|
# test coverage
|
||||||
coverage/
|
coverage/
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
||||||
|
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||||
|
deploy/.env
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
83
agent/src/cli/deps.ts
Normal file
83
agent/src/cli/deps.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* CliDeps factory — PLAN_RELAY_PHASE1 C2. Wires the abstract `CliDeps` seams (consumed by
|
||||||
|
* `runCli`) to their real implementations: env-driven config, the on-disk keystore, Ed25519
|
||||||
|
* identity generation, §4.5 pairing redemption, the supervised tunnel, and OS service install.
|
||||||
|
* All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||||
|
*/
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { homedir, userInfo } from 'node:os'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { CliDeps } from '../cli.js'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||||
|
import { openKeystore } from '../keys/keystore.js'
|
||||||
|
import { generateIdentity } from '../keys/identity.js'
|
||||||
|
import { redeemPairingCode } from '../enroll/pair.js'
|
||||||
|
import { runTunnel } from '../transport/runTunnel.js'
|
||||||
|
import {
|
||||||
|
detectPlatform,
|
||||||
|
installService as installServiceUnit,
|
||||||
|
uninstallService as uninstallServiceUnit,
|
||||||
|
type InstallDeps,
|
||||||
|
type ServicePlatform,
|
||||||
|
} from '../service/install.js'
|
||||||
|
|
||||||
|
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||||
|
function selfBinPath(): string {
|
||||||
|
return fileURLToPath(import.meta.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function realInstallDeps(): InstallDeps {
|
||||||
|
return {
|
||||||
|
writeFile: (path, content) => {
|
||||||
|
mkdirSync(dirname(path), { recursive: true })
|
||||||
|
writeFileSync(path, content)
|
||||||
|
},
|
||||||
|
runCommand,
|
||||||
|
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
|
||||||
|
homedir,
|
||||||
|
username: () => userInfo().username,
|
||||||
|
binPath: selfBinPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the current OS to its service manager, or fail fast with a clear message. */
|
||||||
|
function requirePlatform(): ServicePlatform {
|
||||||
|
const platform = detectPlatform(process.platform)
|
||||||
|
if (platform === null) {
|
||||||
|
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
|
||||||
|
}
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||||
|
export function createCliDeps(): CliDeps {
|
||||||
|
return {
|
||||||
|
loadConfig: () => loadAgentConfig(process.env),
|
||||||
|
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||||
|
generateIdentity: () => generateIdentity(),
|
||||||
|
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||||
|
runTunnel: async (cfg, ks) => {
|
||||||
|
const handle = await runTunnel(cfg, ks)
|
||||||
|
const onSignal = (): void => {
|
||||||
|
void handle.stop()
|
||||||
|
}
|
||||||
|
process.once('SIGTERM', onSignal)
|
||||||
|
process.once('SIGINT', onSignal)
|
||||||
|
return handle.done
|
||||||
|
},
|
||||||
|
installService: (cfg) => installServiceUnit(cfg, requirePlatform(), realInstallDeps()),
|
||||||
|
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||||
|
print: (line) => {
|
||||||
|
process.stdout.write(`${line}\n`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
|
||||||
|
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
|
||||||
|
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
|
||||||
|
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
|
||||||
|
*/
|
||||||
|
import { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||||
|
import { createCliDeps } from './cli/deps.js'
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const deps = createCliDeps()
|
||||||
|
try {
|
||||||
|
return await runCli(parseArgs(argv), deps)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
process.stderr.write(`web-terminal-agent: ${message}\n`)
|
||||||
|
return err instanceof CliUsageError ? 2 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then((code) => {
|
||||||
|
process.exitCode = code
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
|
||||||
|
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
|
||||||
|
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
|
||||||
|
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
|
||||||
|
*
|
||||||
|
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
|
||||||
|
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
|
||||||
|
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
|
||||||
|
*/
|
||||||
|
import { WebSocket } from 'ws'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
|
import { createLogger, type Logger } from '../log/logger.js'
|
||||||
|
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
|
||||||
|
import { dialRelay, type TlsWsConstructor } from './dial.js'
|
||||||
|
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
|
||||||
|
import { holdTunnel, type Tunnel } from './tunnel.js'
|
||||||
|
import { createStreamRouter, identityTransform } from './streamRouter.js'
|
||||||
|
import { createHeartbeat } from './heartbeat.js'
|
||||||
|
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||||
|
import type { TimerLike, WsLike } from './seams.js'
|
||||||
|
|
||||||
|
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
|
||||||
|
export interface TunnelHandle {
|
||||||
|
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
|
||||||
|
stop(): Promise<void>
|
||||||
|
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||||
|
readonly done: Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||||
|
export interface RunTunnelDeps {
|
||||||
|
connectRelay(): Promise<WsLike>
|
||||||
|
dialLoopback: DialLoopback
|
||||||
|
logger: Logger
|
||||||
|
timer: TimerLike
|
||||||
|
sleep: Sleep
|
||||||
|
backoff: BackoffPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
const realTimer: TimerLike = {
|
||||||
|
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||||
|
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||||
|
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||||
|
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||||
|
}
|
||||||
|
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
|
||||||
|
return {
|
||||||
|
connectRelay:
|
||||||
|
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
|
||||||
|
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
|
||||||
|
logger: o?.logger ?? createLogger('info'),
|
||||||
|
timer: o?.timer ?? realTimer,
|
||||||
|
sleep: o?.sleep ?? realSleep,
|
||||||
|
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
|
||||||
|
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
|
||||||
|
* caller with no way to `stop()`).
|
||||||
|
*/
|
||||||
|
export async function runTunnel(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
ks: Keystore,
|
||||||
|
overrides?: Partial<RunTunnelDeps>,
|
||||||
|
): Promise<TunnelHandle> {
|
||||||
|
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
|
||||||
|
|
||||||
|
let stopped = false
|
||||||
|
let currentTunnel: Tunnel | null = null
|
||||||
|
let currentSocket: WsLike | null = null
|
||||||
|
|
||||||
|
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
|
||||||
|
const revocation = createRevocationState(() => currentTunnel?.close())
|
||||||
|
const shouldStop = (): boolean => stopped || revocation.isRevoked()
|
||||||
|
|
||||||
|
async function dialTunnel(): Promise<Tunnel> {
|
||||||
|
const socket = await connectRelay()
|
||||||
|
currentSocket = socket
|
||||||
|
return holdTunnel(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
|
||||||
|
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
|
||||||
|
const heartbeat = createHeartbeat(tunnel, { timer })
|
||||||
|
tunnel.dispatchTo(router, heartbeat)
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
const endSession = (): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
heartbeat.stop()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat.onDead(() => {
|
||||||
|
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
|
||||||
|
tunnel.close()
|
||||||
|
endSession()
|
||||||
|
})
|
||||||
|
socket.on('close', () => endSession())
|
||||||
|
socket.on('error', () => {
|
||||||
|
tunnel.close()
|
||||||
|
endSession()
|
||||||
|
})
|
||||||
|
tunnel.onGoAway((reason) => {
|
||||||
|
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
|
||||||
|
logger.log('info', 'received GOAWAY', { action })
|
||||||
|
tunnel.close()
|
||||||
|
endSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
heartbeat.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supervise(): Promise<number> {
|
||||||
|
while (!shouldStop()) {
|
||||||
|
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
|
||||||
|
if (tunnel === null || currentSocket === null) break
|
||||||
|
if (shouldStop()) {
|
||||||
|
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
|
||||||
|
tunnel.close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentTunnel = tunnel
|
||||||
|
logger.log('info', 'relay tunnel established')
|
||||||
|
await runSession(tunnel, currentSocket)
|
||||||
|
currentTunnel = null
|
||||||
|
currentSocket = null
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = supervise()
|
||||||
|
|
||||||
|
return {
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
stopped = true
|
||||||
|
currentTunnel?.close()
|
||||||
|
await done
|
||||||
|
},
|
||||||
|
done,
|
||||||
|
}
|
||||||
|
}
|
||||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import type { Keystore } from '../src/keys/keystore.js'
|
||||||
|
import { createBackoff } from '../src/transport/backoff.js'
|
||||||
|
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||||
|
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||||
|
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||||
|
*/
|
||||||
|
const CFG: AgentConfig = {
|
||||||
|
relayUrl: 'wss://relay/agent',
|
||||||
|
enrollUrl: 'https://x/enroll',
|
||||||
|
stateDir: '/tmp/x',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: 'host-42',
|
||||||
|
hostId: 'h-1',
|
||||||
|
}
|
||||||
|
const OPEN: MuxOpen = {
|
||||||
|
streamId: 5,
|
||||||
|
subdomain: 'host-42',
|
||||||
|
requestPath: '/term?join=abc',
|
||||||
|
originHeader: 'https://host-42.term.example.com',
|
||||||
|
remoteAddrHash: 'x',
|
||||||
|
capabilityTokenRef: 'jti',
|
||||||
|
}
|
||||||
|
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||||
|
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||||
|
|
||||||
|
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||||
|
const payload = encodeOpen(open)
|
||||||
|
upstream.emitMessage(
|
||||||
|
encodeMuxFrame(
|
||||||
|
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||||
|
const payload = encodeGoaway(0, 'revoked')
|
||||||
|
upstream.emitMessage(
|
||||||
|
encodeMuxFrame(
|
||||||
|
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||||
|
return {
|
||||||
|
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||||
|
sleep: async () => {},
|
||||||
|
backoff: createBackoff(),
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runTunnel supervisor (C2)', () => {
|
||||||
|
it('splices bytes both ways through the loopback', async () => {
|
||||||
|
const upstream = new FakeWs()
|
||||||
|
const loopback = new FakeWs()
|
||||||
|
const connectRelay = vi.fn(async () => upstream)
|
||||||
|
const dialLoopback = vi.fn(async () => loopback)
|
||||||
|
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
emitOpen(upstream, OPEN)
|
||||||
|
await flush()
|
||||||
|
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||||
|
|
||||||
|
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||||
|
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||||
|
|
||||||
|
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||||
|
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||||
|
expect(last.header.type).toBe('data')
|
||||||
|
expect([...last.payload]).toEqual([79, 75])
|
||||||
|
|
||||||
|
await handle.stop()
|
||||||
|
expect(await handle.done).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reconnects after the tunnel dies', async () => {
|
||||||
|
const sockets = [new FakeWs(), new FakeWs()]
|
||||||
|
let i = 0
|
||||||
|
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||||
|
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||||
|
await flush()
|
||||||
|
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||||
|
await flush()
|
||||||
|
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await handle.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||||
|
const connectRelay = vi.fn(async () => new FakeWs())
|
||||||
|
let socket: FakeWs | undefined
|
||||||
|
const wrapped = vi.fn(async () => {
|
||||||
|
socket = new FakeWs()
|
||||||
|
return socket
|
||||||
|
})
|
||||||
|
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||||
|
await flush()
|
||||||
|
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
emitGoAwayRevoked(socket!)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(await handle.done).toBe(0)
|
||||||
|
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||||
|
expect(connectRelay).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||||
|
const connectRelay = vi.fn(async () => new FakeWs())
|
||||||
|
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
await handle.stop()
|
||||||
|
expect(await handle.done).toBe(0)
|
||||||
|
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
15
control-plane/db/migrations/0002_routes.sql
Normal file
15
control-plane/db/migrations/0002_routes.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- A1 — routes table (P3-owned). Not present in 0001_init.sql: the RouteStore port is a
|
||||||
|
-- Redis-like live routing table (INV7 — NOT source of truth). This Postgres adapter needs a
|
||||||
|
-- durable backing for it. Postgres has no per-key TTL, so we store an explicit `expires_at`
|
||||||
|
-- and treat `expires_at <= now()` as ABSENT (fail-closed, INV7); expired rows are lazily
|
||||||
|
-- DELETEd on access. This mirrors `store/memory.ts` memRouteStore() semantics exactly.
|
||||||
|
--
|
||||||
|
-- No FK to hosts(host_id): routes are an ephemeral location cache keyed by host_id, decoupled
|
||||||
|
-- from the ownership source of truth (matches memory.ts, where routes live independently).
|
||||||
|
CREATE TABLE IF NOT EXISTS routes (
|
||||||
|
host_id uuid PRIMARY KEY,
|
||||||
|
relay_node_id text NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS routes_node_idx ON routes(relay_node_id);
|
||||||
741
control-plane/package-lock.json
generated
741
control-plane/package-lock.json
generated
@@ -8,9 +8,12 @@
|
|||||||
"name": "control-plane",
|
"name": "control-plane",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@peculiar/x509": "^2.0.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
"relay-contracts": "file:../relay-contracts",
|
"relay-contracts": "file:../relay-contracts",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -18,6 +21,23 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../relay-auth": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
@@ -133,6 +153,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||||
@@ -231,6 +693,162 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@peculiar/asn1-cms": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-csr": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-ecc": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pfx": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.8.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs8": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs9": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pfx": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-rsa": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-schema": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509-attr": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/utils": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/x509": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.6.0",
|
||||||
|
"@peculiar/asn1-csr": "^2.6.0",
|
||||||
|
"@peculiar/asn1-ecc": "^2.6.0",
|
||||||
|
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.6.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.6.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.6.0",
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"tsyringe": "^4.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@@ -765,6 +1383,20 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1js": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"pvutils": "^1.1.5",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -884,6 +1516,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
|
"@esbuild/android-arm": "0.28.1",
|
||||||
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
|
"@esbuild/android-x64": "0.28.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
@@ -1791,6 +2465,24 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pvtsutils": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pvutils": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
@@ -1827,6 +2519,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reflect-metadata": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/relay-auth": {
|
||||||
|
"resolved": "../relay-auth",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/relay-contracts": {
|
"node_modules/relay-contracts": {
|
||||||
"resolved": "../relay-contracts",
|
"resolved": "../relay-contracts",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2075,9 +2777,44 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
|
||||||
|
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD",
|
"license": "MIT",
|
||||||
"optional": true
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsyringe": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^1.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsyringe/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
|
|||||||
@@ -9,22 +9,27 @@
|
|||||||
},
|
},
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"relay-contracts": "file:../relay-contracts",
|
"@peculiar/x509": "^2.0.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export class AuthzError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
|
/**
|
||||||
|
* P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3), which is
|
||||||
|
* ASYNC (Ed25519 verify over WebCrypto) — so this seam returns a Promise and all call sites await.
|
||||||
|
*/
|
||||||
export interface CapabilityVerifier {
|
export interface CapabilityVerifier {
|
||||||
verify(raw: string, expectedAud: string, now: number): CapabilityToken
|
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal request shape we read for the bearer token (never a body account field). */
|
/** Minimal request shape we read for the bearer token (never a body account field). */
|
||||||
@@ -49,19 +52,19 @@ function extractRawToken(req: AuthRequest): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Authorizer {
|
export interface Authorizer {
|
||||||
principalFromRequest(req: AuthRequest): AdminPrincipal
|
principalFromRequest(req: AuthRequest): Promise<AdminPrincipal>
|
||||||
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
|
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthorizer(deps: AuthzDeps): Authorizer {
|
export function createAuthorizer(deps: AuthzDeps): Authorizer {
|
||||||
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
|
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
|
||||||
return {
|
return {
|
||||||
principalFromRequest(req) {
|
async principalFromRequest(req) {
|
||||||
const raw = extractRawToken(req)
|
const raw = extractRawToken(req)
|
||||||
if (raw === null) throw new AuthzError(401, 'missing capability token')
|
if (raw === null) throw new AuthzError(401, 'missing capability token')
|
||||||
let token: CapabilityToken
|
let token: CapabilityToken
|
||||||
try {
|
try {
|
||||||
token = deps.verifier.verify(raw, deps.expectedAud, now())
|
token = await deps.verifier.verify(raw, deps.expectedAud, now())
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
|
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { HostRegistry } from '../registry/hosts.js'
|
|||||||
import type { PairingIssuer } from '../pairing/issue.js'
|
import type { PairingIssuer } from '../pairing/issue.js'
|
||||||
import type { PairingRedeemer } from '../pairing/redeem.js'
|
import type { PairingRedeemer } from '../pairing/redeem.js'
|
||||||
import { RedeemError } from '../pairing/redeem.js'
|
import { RedeemError } from '../pairing/redeem.js'
|
||||||
|
import { decodeCsrWire } from '../ca/csr.js'
|
||||||
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
||||||
|
|
||||||
export interface ProvisionDeps {
|
export interface ProvisionDeps {
|
||||||
@@ -30,8 +31,8 @@ const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
|
|||||||
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
||||||
const EnrollSchema = z.object({
|
const EnrollSchema = z.object({
|
||||||
code: z.string().min(1),
|
code: z.string().min(1),
|
||||||
agentPubkey: z.string().min(1), // base64
|
agentPubkey: z.string().min(1), // base64 (agent sends base64url; Buffer decodes both)
|
||||||
csr: z.string().min(1), // base64
|
csr: z.string().min(1), // PKCS#10: PEM block (agent) or base64(DER) — see decodeCsrWire
|
||||||
})
|
})
|
||||||
|
|
||||||
function sendError(reply: FastifyReply, err: unknown): void {
|
function sendError(reply: FastifyReply, err: unknown): void {
|
||||||
@@ -56,12 +57,12 @@ function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): voi
|
|||||||
|
|
||||||
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||||
return async (app) => {
|
return async (app) => {
|
||||||
const principal = (req: FastifyRequest): AdminPrincipal =>
|
const principal = (req: FastifyRequest): Promise<AdminPrincipal> =>
|
||||||
deps.authorizer.principalFromRequest({ headers: req.headers })
|
deps.authorizer.principalFromRequest({ headers: req.headers })
|
||||||
|
|
||||||
app.post('/accounts', async (req, reply) => {
|
app.post('/accounts', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const p = principal(req)
|
const p = await principal(req)
|
||||||
deps.authorizer.requireRight(p, 'manage')
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
|
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
|
||||||
const account = await deps.accounts.createAccount(plan)
|
const account = await deps.accounts.createAccount(plan)
|
||||||
@@ -73,7 +74,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
|
|
||||||
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
|
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const p = principal(req)
|
const p = await principal(req)
|
||||||
deps.authorizer.requireRight(p, 'manage')
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||||
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
|
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
|
||||||
@@ -85,7 +86,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
|
|
||||||
app.post('/accounts/:id/status', async (req, reply) => {
|
app.post('/accounts/:id/status', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const p = principal(req)
|
const p = await principal(req)
|
||||||
deps.authorizer.requireRight(p, 'manage')
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||||
const { status } = StatusSchema.parse(req.body)
|
const { status } = StatusSchema.parse(req.body)
|
||||||
@@ -98,7 +99,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
|
|
||||||
app.get('/accounts/:id/hosts', async (req, reply) => {
|
app.get('/accounts/:id/hosts', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const p = principal(req)
|
const p = await principal(req)
|
||||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||||
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
|
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
|
||||||
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
|
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
|
||||||
@@ -109,7 +110,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
|
|
||||||
app.delete('/hosts/:hostId', async (req, reply) => {
|
app.delete('/hosts/:hostId', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const p = principal(req)
|
const p = await principal(req)
|
||||||
deps.authorizer.requireRight(p, 'manage')
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
|
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
|
||||||
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
|
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
|
||||||
@@ -126,9 +127,10 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
const result = await deps.redeemer.redeemPairingCode({
|
const result = await deps.redeemer.redeemPairingCode({
|
||||||
code: body.code,
|
code: body.code,
|
||||||
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
||||||
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
|
csr: decodeCsrWire(body.csr),
|
||||||
})
|
})
|
||||||
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
// base64URL (not base64) — the agent decodes this via decodeBase64UrlBytes (enroll/pair.ts).
|
||||||
|
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64url') })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(reply, err)
|
sendError(reply, err)
|
||||||
}
|
}
|
||||||
|
|||||||
32
control-plane/src/boot/redis.ts
Normal file
32
control-plane/src/boot/redis.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* A2 — ioredis client + `RedisPublisher` adapter for the P3 server entrypoint.
|
||||||
|
*
|
||||||
|
* `createRedisClient(url)` constructs the single ioredis connection used for the revocation-bus
|
||||||
|
* PUBLISH side (the FROZEN `relay:revocations` channel; P1 nodes subscribe — see routing/bus.ts).
|
||||||
|
* `createRedisPublisher` narrows that client to the minimal `RedisPublisher` surface
|
||||||
|
* `createRedisRevocationBus` consumes, so the concrete ioredis type never leaks into the bus wiring
|
||||||
|
* and the seam stays unit-testable with a fake. INV9: no secrets pass through here — the URL is
|
||||||
|
* owned/validated by `loadEnv` and never logged.
|
||||||
|
*/
|
||||||
|
// The default export IS `Redis`; imported by name because this package's tsconfig has no
|
||||||
|
// `esModuleInterop`, under which a default import of the CJS module resolves to its namespace.
|
||||||
|
import { Redis } from 'ioredis'
|
||||||
|
import type { RedisPublisher } from '../routing/bus.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct the production ioredis client from a validated connection URL. Connection lifecycle
|
||||||
|
* (connect/retry) is managed by ioredis; the caller owns `quit()` on shutdown.
|
||||||
|
*/
|
||||||
|
export function createRedisClient(url: string): Redis {
|
||||||
|
return new Redis(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapt an ioredis client to the `RedisPublisher` seam. Forwards `publish(channel, message)`
|
||||||
|
* verbatim and returns the subscriber count the driver reports (Promise<number>).
|
||||||
|
*/
|
||||||
|
export function createRedisPublisher(redis: Redis): RedisPublisher {
|
||||||
|
return {
|
||||||
|
publish: (channel: string, message: string): Promise<number> => redis.publish(channel, message),
|
||||||
|
}
|
||||||
|
}
|
||||||
38
control-plane/src/boot/verifier.ts
Normal file
38
control-plane/src/boot/verifier.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* A3 — REAL capability verifier, backed by relay-auth's `verifyCapabilityToken` (P5). Replaces the
|
||||||
|
* fail-closed `refuseAllVerifier` stub. The frozen §4.3 verify signature is ASYNC (Ed25519 verify
|
||||||
|
* over WebCrypto) and reads its verifying key from relay-auth's startup registry (config/keys.ts),
|
||||||
|
* never as a parameter — so we (a) adapt it to the async `CapabilityVerifier` seam and (b) load the
|
||||||
|
* key at boot from the CP env's already-validated pubkey.
|
||||||
|
*
|
||||||
|
* KEY BRIDGE (INV9): the CP env var is `CAPABILITY_SIGN_PUBKEY_B64` (base64), parsed+validated in
|
||||||
|
* `env.ts` to `env.capabilitySignPubkey` (32 raw Ed25519 bytes). relay-auth's own env loader expects
|
||||||
|
* a DIFFERENT var name (`RELAY_AUTH_VERIFY_PUBKEY`, base64url), so we do NOT use it — we import the
|
||||||
|
* 32 raw bytes to a non-exportable verify-only CryptoKey via WebCrypto and call relay-auth's
|
||||||
|
* `configureVerifyKey(CryptoKey)` directly.
|
||||||
|
*/
|
||||||
|
import { verifyCapabilityToken, configureVerifyKey } from 'relay-auth'
|
||||||
|
import type { CapabilityToken } from 'relay-contracts'
|
||||||
|
import type { CapabilityVerifier } from '../api/authz.js'
|
||||||
|
|
||||||
|
/** The real verifier: delegates verbatim to P5's frozen §4.3 `verifyCapabilityToken`. */
|
||||||
|
export function createCapabilityVerifier(): CapabilityVerifier {
|
||||||
|
return {
|
||||||
|
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken> {
|
||||||
|
return verifyCapabilityToken(raw, expectedAud, now)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the §4.3 verifying key into relay-auth's startup registry from the CP env's raw 32-byte
|
||||||
|
* Ed25519 public key (`env.capabilitySignPubkey`). Non-exportable, `verify`-only. Must run before
|
||||||
|
* the real verifier serves any request, else `verifyCapabilityToken` throws `KeyConfigError`.
|
||||||
|
*/
|
||||||
|
export async function configureCapabilityVerifyKey(pubkeyRaw: Uint8Array): Promise<void> {
|
||||||
|
// Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource typing / no shared pool).
|
||||||
|
const bytes = new Uint8Array(pubkeyRaw.length)
|
||||||
|
bytes.set(pubkeyRaw)
|
||||||
|
const key = await globalThis.crypto.subtle.importKey('raw', bytes, { name: 'Ed25519' }, false, ['verify'])
|
||||||
|
configureVerifyKey(key)
|
||||||
|
}
|
||||||
@@ -1,53 +1,147 @@
|
|||||||
/**
|
/**
|
||||||
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
|
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
|
||||||
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
|
|
||||||
* embedded pubkey proves the requester holds the matching private key (the exact property a real
|
|
||||||
* PKCS#10 self-signature provides) — using builtin crypto only.
|
|
||||||
*
|
*
|
||||||
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
|
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
|
||||||
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
|
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
|
||||||
|
* self-signature (proof-of-possession — the requester holds the private key for the embedded
|
||||||
|
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
|
||||||
|
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
|
||||||
|
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
|
||||||
|
*
|
||||||
|
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
|
||||||
|
* before the library — hence the side-effect import on the first line (must stay first).
|
||||||
*/
|
*/
|
||||||
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
import 'reflect-metadata'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto } from 'node:crypto'
|
||||||
|
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
|
||||||
|
|
||||||
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
|
// --- Ed25519 SPKI constants -------------------------------------------------------------------
|
||||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
|
||||||
const message = concat(CSR_CHALLENGE, embeddedPub)
|
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
||||||
const sig = ed25519Sign(privateKey, message)
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
return concat(embeddedPub, sig)
|
])
|
||||||
}
|
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
|
||||||
|
const RAW_ED25519_LEN = 32
|
||||||
|
|
||||||
export interface CsrParts {
|
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
|
||||||
readonly embeddedPub: Uint8Array
|
|
||||||
readonly sig: Uint8Array
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the modelled CSR bytes. Throws on wrong length. */
|
function derLen(len: number): Uint8Array {
|
||||||
export function parseCsr(csr: Uint8Array): CsrParts {
|
if (len < 0x80) return Uint8Array.from([len])
|
||||||
if (csr.length !== 96) throw new Error('malformed csr')
|
const bytes: number[] = []
|
||||||
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
|
let n = len
|
||||||
}
|
while (n > 0) {
|
||||||
|
bytes.unshift(n & 0xff)
|
||||||
/**
|
n >>= 8
|
||||||
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
|
|
||||||
* Independent of any registry state (a forged signature is refused even for a registered key).
|
|
||||||
*/
|
|
||||||
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
|
|
||||||
let parts: CsrParts
|
|
||||||
try {
|
|
||||||
parts = parseCsr(csr)
|
|
||||||
} catch {
|
|
||||||
return { ok: false, embeddedPub: new Uint8Array(0) }
|
|
||||||
}
|
}
|
||||||
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||||
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
|
||||||
return { ok, embeddedPub: parts.embeddedPub }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||||
const out = new Uint8Array(a.length + b.length)
|
const len = derLen(value.length)
|
||||||
out.set(a, 0)
|
const out = new Uint8Array(1 + len.length + value.length)
|
||||||
out.set(b, a.length)
|
out[0] = tag
|
||||||
|
out.set(len, 1)
|
||||||
|
out.set(value, 1 + len.length)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||||
|
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
|
||||||
|
let off = 0
|
||||||
|
for (const c of chunks) {
|
||||||
|
out.set(c, off)
|
||||||
|
off += c.length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEQUENCE = 0x30
|
||||||
|
const SET = 0x31
|
||||||
|
const INTEGER = 0x02
|
||||||
|
const BIT_STRING = 0x03
|
||||||
|
const OID = 0x06
|
||||||
|
const UTF8_STRING = 0x0c
|
||||||
|
const CONTEXT_0 = 0xa0
|
||||||
|
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
|
||||||
|
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
|
||||||
|
|
||||||
|
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||||
|
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||||
|
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||||
|
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameFromCn(cn: string): Uint8Array {
|
||||||
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||||
|
return tlv(SEQUENCE, tlv(SET, atv))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
|
||||||
|
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
|
||||||
|
* control-plane's own tests to exercise the real parse/verify path; production requests come from
|
||||||
|
* the agent unchanged.
|
||||||
|
*/
|
||||||
|
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||||
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||||
|
const requestInfo = tlv(
|
||||||
|
SEQUENCE,
|
||||||
|
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
|
||||||
|
)
|
||||||
|
const signature = ed25519Sign(privateKey, requestInfo)
|
||||||
|
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||||
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||||
|
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wire decoding + verification --------------------------------------------------------------
|
||||||
|
|
||||||
|
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
|
||||||
|
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
|
||||||
|
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
|
||||||
|
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
|
||||||
|
*/
|
||||||
|
export function decodeCsrWire(wire: string): Uint8Array {
|
||||||
|
const pem = PEM_CSR_RE.exec(wire)
|
||||||
|
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
|
||||||
|
return new Uint8Array(Buffer.from(body, 'base64'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
|
||||||
|
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
|
||||||
|
if (spki.length !== ED25519_SPKI_LEN) return null
|
||||||
|
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
|
||||||
|
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
|
||||||
|
}
|
||||||
|
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
|
||||||
|
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
|
||||||
|
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
|
||||||
|
* with no leak of which check failed. Independent of any registry state.
|
||||||
|
*/
|
||||||
|
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
|
||||||
|
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
|
||||||
|
try {
|
||||||
|
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||||
|
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
|
||||||
|
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
|
||||||
|
const ok = await req.verify()
|
||||||
|
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
|
||||||
|
} catch {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
125
control-plane/src/ca/issue.ts
Normal file
125
control-plane/src/ca/issue.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
|
||||||
|
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
|
||||||
|
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
|
||||||
|
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
|
||||||
|
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
|
||||||
|
*
|
||||||
|
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
|
||||||
|
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
|
||||||
|
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
|
||||||
|
*
|
||||||
|
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto, randomBytes } from 'node:crypto'
|
||||||
|
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||||
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
|
||||||
|
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
|
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
|
||||||
|
const CLOCK_SKEW_SEC = 60
|
||||||
|
|
||||||
|
export interface RealLeafSignerDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
|
||||||
|
readonly intermediateKey: CryptoKey
|
||||||
|
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
|
||||||
|
readonly issuerName: x509.Name
|
||||||
|
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
|
||||||
|
readonly trustDomain: string
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
|
||||||
|
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const prefix = Uint8Array.from([
|
||||||
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
|
])
|
||||||
|
const spki = new Uint8Array(prefix.length + raw.length)
|
||||||
|
spki.set(prefix, 0)
|
||||||
|
spki.set(raw, prefix.length)
|
||||||
|
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
|
||||||
|
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
|
||||||
|
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
|
||||||
|
*/
|
||||||
|
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
|
||||||
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||||
|
return {
|
||||||
|
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||||
|
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||||
|
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
|
||||||
|
const subjectKey = await importEd25519Public(agentPubkey)
|
||||||
|
const now = Date.now()
|
||||||
|
const leaf = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: randomBytes(16).toString('hex'),
|
||||||
|
subject: `CN=${host.hostId}`,
|
||||||
|
issuer: deps.issuerName,
|
||||||
|
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
|
||||||
|
notAfter: new Date(now + ttl * 1000),
|
||||||
|
publicKey: subjectKey,
|
||||||
|
signingKey: deps.intermediateKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [
|
||||||
|
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
|
||||||
|
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||||
|
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||||
|
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadRealLeafSignerInput {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
|
||||||
|
readonly intermediateKeyPem: string
|
||||||
|
/** Intermediate certificate, PEM (single block). */
|
||||||
|
readonly intermediateCertPem: string
|
||||||
|
/** Self-signed root certificate, PEM (single block). */
|
||||||
|
readonly rootCertPem: string
|
||||||
|
readonly trustDomain: string
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToDer(pem: string): ArrayBuffer {
|
||||||
|
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
|
||||||
|
const bytes = Buffer.from(body, 'base64')
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
|
||||||
|
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
|
||||||
|
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
|
||||||
|
*/
|
||||||
|
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
|
||||||
|
const intermediateKey = await webcrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pemToDer(input.intermediateKeyPem),
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
false, // non-extractable: the raw private key can never leave the process (INV9)
|
||||||
|
['sign'],
|
||||||
|
)
|
||||||
|
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
|
||||||
|
const rootCert = new x509.X509Certificate(input.rootCertPem)
|
||||||
|
return createRealLeafSigner({
|
||||||
|
hosts: input.hosts,
|
||||||
|
intermediateKey,
|
||||||
|
issuerName: intermediateCert.subjectName,
|
||||||
|
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||||
|
trustDomain: input.trustDomain,
|
||||||
|
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
|||||||
// A revoked/absent host cannot renew (INV12 + INV14).
|
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||||
|
|
||||||
const pop = verifyCsrPoP(csr)
|
const pop = await verifyCsrPoP(csr)
|
||||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||||
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
||||||
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
* T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
||||||
* must pass, SAME reject path):
|
* must pass, SAME reject path):
|
||||||
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
|
* 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey.
|
||||||
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
||||||
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
||||||
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
|
* A failure at ANY step rejects identically; issuance is NEVER reached when any check fails.
|
||||||
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
|
*
|
||||||
|
* Two `LeafSigner` implementations share the `assertLeafGate` gate below:
|
||||||
|
* - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509);
|
||||||
|
* used only when no real CA material is configured (see `main.ts` fallback).
|
||||||
|
* - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3
|
||||||
|
* Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key.
|
||||||
*/
|
*/
|
||||||
import type { HostStore } from '../store/ports.js'
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import type { HostRecord } from '../model/records.js'
|
||||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||||
import { verifyCsrPoP } from './csr.js'
|
import { verifyCsrPoP } from './csr.js'
|
||||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||||
@@ -18,14 +24,6 @@ export class LeafSignError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeafSignerDeps {
|
|
||||||
readonly hosts: HostStore
|
|
||||||
readonly signer: CaSigner
|
|
||||||
readonly caChainDer: readonly Uint8Array[]
|
|
||||||
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
|
||||||
readonly leafTtlSec?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeafSigner {
|
export interface LeafSigner {
|
||||||
signHostLeaf(
|
signHostLeaf(
|
||||||
hostId: string,
|
hostId: string,
|
||||||
@@ -34,28 +32,56 @@ export interface LeafSigner {
|
|||||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
||||||
|
export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on
|
||||||
|
* success, returns the gated host record so the issuer can build the SPIFFE SAN from the
|
||||||
|
* authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure.
|
||||||
|
*/
|
||||||
|
export async function assertLeafGate(
|
||||||
|
hosts: HostStore,
|
||||||
|
hostId: string,
|
||||||
|
agentPubkey: Uint8Array,
|
||||||
|
csr: Uint8Array,
|
||||||
|
): Promise<HostRecord> {
|
||||||
|
// 1. proof-of-possession (independent of registry state)
|
||||||
|
const pop = await verifyCsrPoP(csr)
|
||||||
|
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||||
|
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
||||||
|
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||||
|
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
||||||
|
const host = await hosts.get(hostId)
|
||||||
|
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||||
|
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeafSignerDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
readonly signer: CaSigner
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev
|
||||||
|
* boots without configured CA material still exercise the gate/reject path. Production uses
|
||||||
|
* `createRealLeafSigner` (`ca/issue.ts`).
|
||||||
|
*/
|
||||||
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
||||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||||
return {
|
return {
|
||||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||||
// 1. proof-of-possession (independent of registry state)
|
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||||
const pop = verifyCsrPoP(csr)
|
|
||||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
|
||||||
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
|
||||||
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
|
||||||
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
|
||||||
const host = await deps.hosts.get(hostId)
|
|
||||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
|
||||||
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
|
||||||
|
|
||||||
// Only now do we invoke KMS sign() over the to-be-signed leaf.
|
// Only now do we invoke KMS sign() over the to-be-signed placeholder.
|
||||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||||
const tbs = new TextEncoder().encode(
|
const tbs = new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
hostId,
|
hostId: host.hostId,
|
||||||
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
||||||
notAfter,
|
notAfter,
|
||||||
}),
|
}),
|
||||||
|
|||||||
48
control-plane/src/db/migrate.ts
Normal file
48
control-plane/src/db/migrate.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* A1 — migration runner. Reads every `db/migrations/*.sql` in filename order and executes it
|
||||||
|
* over the parameterized `query` wrapper (db/pool.ts). All migrations are `IF NOT EXISTS`, so
|
||||||
|
* `runMigrations` is idempotent and safe to run at every boot / test setup.
|
||||||
|
*
|
||||||
|
* `createQuery` ALWAYS routes through the extended (parameterized) protocol — even with `[]`
|
||||||
|
* params — which rejects multi-statement command strings. So each file is split into its
|
||||||
|
* top-level statements (on `;`, after stripping `-- line comments`) and each statement is run
|
||||||
|
* as its own single-command `query(stmt, [])`. Our migrations are plain IF-NOT-EXISTS DDL with
|
||||||
|
* no `;` inside string literals and no dollar-quoted bodies, so this split is safe.
|
||||||
|
*/
|
||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { QueryFn } from './pool.js'
|
||||||
|
|
||||||
|
/** Absolute path to `control-plane/db/migrations`, resolved relative to this source file. */
|
||||||
|
function migrationsDir(): string {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url)) // .../control-plane/src/db
|
||||||
|
return join(here, '..', '..', 'db', 'migrations') // .../control-plane/db/migrations
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip `-- line comments`, then split into non-empty top-level statements on `;`. */
|
||||||
|
export function splitStatements(sql: string): readonly string[] {
|
||||||
|
const withoutComments = sql
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const idx = line.indexOf('--')
|
||||||
|
return idx >= 0 ? line.slice(0, idx) : line
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
return withoutComments
|
||||||
|
.split(';')
|
||||||
|
.map((stmt) => stmt.trim())
|
||||||
|
.filter((stmt) => stmt.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply all `*.sql` migrations in filename order. Idempotent (every migration is IF NOT EXISTS). */
|
||||||
|
export async function runMigrations(query: QueryFn): Promise<void> {
|
||||||
|
const dir = migrationsDir()
|
||||||
|
const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()
|
||||||
|
for (const file of files) {
|
||||||
|
const sql = await readFile(join(dir, file), 'utf8')
|
||||||
|
for (const stmt of splitStatements(sql)) {
|
||||||
|
await query(stmt, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
import { base64ToBytes } from './util/bytes.js'
|
import { base64ToBytes } from './util/bytes.js'
|
||||||
|
|
||||||
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
||||||
@@ -24,6 +25,16 @@ export interface ControlPlaneEnv {
|
|||||||
readonly caIntermediateKmsKeyRef: string
|
readonly caIntermediateKmsKeyRef: string
|
||||||
/** Intermediate cert (public) + chain up to the offline root. */
|
/** Intermediate cert (public) + chain up to the offline root. */
|
||||||
readonly caIntermediateCertPath: string
|
readonly caIntermediateCertPath: string
|
||||||
|
/**
|
||||||
|
* Intermediate Ed25519 PRIVATE key (PKCS#8 PEM) used by the REAL leaf issuer. Optional: when the
|
||||||
|
* file is present the control-plane issues real X.509 leaves; when absent it falls back to the
|
||||||
|
* dev placeholder signer. Defaults to `caIntermediateCertPath` with `.cert.pem`/`.pem` → `.key.pem`.
|
||||||
|
*/
|
||||||
|
readonly caIntermediateKeyPath: string
|
||||||
|
/** Self-signed root cert (PEM) for the agent CA bundle. Defaults to a sibling `root.cert.pem`. */
|
||||||
|
readonly caRootCertPath: string
|
||||||
|
/** Bare trust domain for the SPIFFE SAN (`spiffe://relay.<domain>/...`). */
|
||||||
|
readonly relayTrustDomain: string
|
||||||
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
||||||
readonly nodeMtlsTrustBundlePath: string
|
readonly nodeMtlsTrustBundlePath: string
|
||||||
/** 'term.<domain>' for subdomain assembly. */
|
/** 'term.<domain>' for subdomain assembly. */
|
||||||
@@ -45,12 +56,22 @@ const intWithDefault = (fallback: number) =>
|
|||||||
const requiredString = (name: string) =>
|
const requiredString = (name: string) =>
|
||||||
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
||||||
|
|
||||||
|
/** Default intermediate KEY path from its CERT path: `*.cert.pem`/`*.pem` → `*.key.pem`. */
|
||||||
|
function deriveKeyPath(certPath: string): string {
|
||||||
|
if (certPath.endsWith('.cert.pem')) return certPath.slice(0, -'.cert.pem'.length) + '.key.pem'
|
||||||
|
if (certPath.endsWith('.pem')) return certPath.slice(0, -'.pem'.length) + '.key.pem'
|
||||||
|
return certPath + '.key.pem'
|
||||||
|
}
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
||||||
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
||||||
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
||||||
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
||||||
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
||||||
|
CA_INTERMEDIATE_KEY_PATH: z.string().trim().optional(),
|
||||||
|
CA_ROOT_CERT_PATH: z.string().trim().optional(),
|
||||||
|
RELAY_TRUST_DOMAIN: z.string().trim().optional(),
|
||||||
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
||||||
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
||||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||||
@@ -81,12 +102,24 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
|||||||
if (capabilitySignPubkey.length !== 32) {
|
if (capabilitySignPubkey.length !== 32) {
|
||||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
||||||
}
|
}
|
||||||
|
const caIntermediateKeyPath =
|
||||||
|
e.CA_INTERMEDIATE_KEY_PATH !== undefined && e.CA_INTERMEDIATE_KEY_PATH.length > 0
|
||||||
|
? e.CA_INTERMEDIATE_KEY_PATH
|
||||||
|
: deriveKeyPath(e.CA_INTERMEDIATE_CERT_PATH)
|
||||||
|
const caRootCertPath =
|
||||||
|
e.CA_ROOT_CERT_PATH !== undefined && e.CA_ROOT_CERT_PATH.length > 0
|
||||||
|
? e.CA_ROOT_CERT_PATH
|
||||||
|
: join(dirname(e.CA_INTERMEDIATE_CERT_PATH), 'root.cert.pem')
|
||||||
return {
|
return {
|
||||||
pgUrl: e.PG_URL,
|
pgUrl: e.PG_URL,
|
||||||
redisUrl: e.REDIS_URL,
|
redisUrl: e.REDIS_URL,
|
||||||
capabilitySignPubkey,
|
capabilitySignPubkey,
|
||||||
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
||||||
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
||||||
|
caIntermediateKeyPath,
|
||||||
|
caRootCertPath,
|
||||||
|
relayTrustDomain:
|
||||||
|
e.RELAY_TRUST_DOMAIN !== undefined && e.RELAY_TRUST_DOMAIN.length > 0 ? e.RELAY_TRUST_DOMAIN : 'example.com',
|
||||||
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
||||||
baseDomain: e.BASE_DOMAIN,
|
baseDomain: e.BASE_DOMAIN,
|
||||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
||||||
*/
|
*/
|
||||||
import Fastify, { type FastifyInstance } from 'fastify'
|
import Fastify, { type FastifyInstance } from 'fastify'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import type { ControlPlaneEnv } from './env.js'
|
import type { ControlPlaneEnv } from './env.js'
|
||||||
import { createMemoryStores } from './store/memory.js'
|
import { createMemoryStores } from './store/memory.js'
|
||||||
import type { Stores } from './store/ports.js'
|
import type { Stores, HostStore } from './store/ports.js'
|
||||||
import { createAuditLog } from './audit/log.js'
|
import { createAuditLog } from './audit/log.js'
|
||||||
import { createAccountRegistry } from './registry/accounts.js'
|
import { createAccountRegistry } from './registry/accounts.js'
|
||||||
import { createHostRegistry } from './registry/hosts.js'
|
import { createHostRegistry } from './registry/hosts.js'
|
||||||
@@ -22,14 +23,16 @@ import { createSessionRegistry } from './registry/sessions.js'
|
|||||||
import { createSubdomainAssigner } from './subdomain/assign.js'
|
import { createSubdomainAssigner } from './subdomain/assign.js'
|
||||||
import { createPairingIssuer } from './pairing/issue.js'
|
import { createPairingIssuer } from './pairing/issue.js'
|
||||||
import { createPairingRedeemer } from './pairing/redeem.js'
|
import { createPairingRedeemer } from './pairing/redeem.js'
|
||||||
import { createLeafSigner } from './ca/sign.js'
|
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
|
||||||
|
import { loadRealLeafSigner } from './ca/issue.js'
|
||||||
import { createRoutingTable } from './routing/table.js'
|
import { createRoutingTable } from './routing/table.js'
|
||||||
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
||||||
import { createMeteringCollector } from './metering/collect.js'
|
import { createMeteringCollector } from './metering/collect.js'
|
||||||
import { createDeprovisioner } from './deprovision/deprovision.js'
|
import { createDeprovisioner } from './deprovision/deprovision.js'
|
||||||
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
||||||
import { buildRouter } from './api/provision.js'
|
import { buildRouter } from './api/provision.js'
|
||||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
|
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
|
||||||
|
import { configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||||
import type { RevocationBus } from 'relay-contracts'
|
import type { RevocationBus } from 'relay-contracts'
|
||||||
|
|
||||||
export interface ControlPlaneOverrides {
|
export interface ControlPlaneOverrides {
|
||||||
@@ -40,13 +43,47 @@ export interface ControlPlaneOverrides {
|
|||||||
readonly caChainDer?: readonly Uint8Array[]
|
readonly caChainDer?: readonly Uint8Array[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
|
/**
|
||||||
|
* Default fail-closed verifier — refuses everything until a real (P5) verifier is injected (INV6).
|
||||||
|
* Async-shaped to match `CapabilityVerifier`: an async body that throws rejects the promise, so the
|
||||||
|
* authorizer's `await` surfaces it as a 401.
|
||||||
|
*/
|
||||||
const refuseAllVerifier: CapabilityVerifier = {
|
const refuseAllVerifier: CapabilityVerifier = {
|
||||||
verify() {
|
async verify(): Promise<never> {
|
||||||
throw new Error('capability verification not configured (P5 integration point)')
|
throw new Error('capability verification not configured (P5 integration point)')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL
|
||||||
|
* X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and
|
||||||
|
* key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9).
|
||||||
|
*/
|
||||||
|
async function buildLeafSigner(
|
||||||
|
env: ControlPlaneEnv,
|
||||||
|
hosts: HostStore,
|
||||||
|
caSigner: CaSigner,
|
||||||
|
caChainDer: readonly Uint8Array[],
|
||||||
|
): Promise<LeafSigner> {
|
||||||
|
if (!existsSync(env.caIntermediateKeyPath)) {
|
||||||
|
return createLeafSigner({ hosts, signer: caSigner, caChainDer })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await loadRealLeafSigner({
|
||||||
|
hosts,
|
||||||
|
intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'),
|
||||||
|
intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'),
|
||||||
|
rootCertPem: readFileSync(env.caRootCertPath, 'utf8'),
|
||||||
|
trustDomain: env.relayTrustDomain,
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Never echo key material — only that loading failed and which path shape was configured (INV9).
|
||||||
|
throw new Error(
|
||||||
|
`failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
||||||
function devKmsResolver(): KmsResolver {
|
function devKmsResolver(): KmsResolver {
|
||||||
const signer = inProcessCaSigner()
|
const signer = inProcessCaSigner()
|
||||||
@@ -72,7 +109,7 @@ export async function buildControlPlane(
|
|||||||
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
||||||
|
|
||||||
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
||||||
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
|
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
|
||||||
|
|
||||||
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
||||||
const redeemer = createPairingRedeemer({
|
const redeemer = createPairingRedeemer({
|
||||||
@@ -92,8 +129,16 @@ export async function buildControlPlane(
|
|||||||
const deprovisioner = createDeprovisioner({ hosts, routing })
|
const deprovisioner = createDeprovisioner({ hosts, routing })
|
||||||
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
||||||
|
|
||||||
|
// A real (injected) verifier is P5's async `verifyCapabilityToken`, which reads its Ed25519 key
|
||||||
|
// from relay-auth's startup registry — so load that key from env at boot. The fail-closed default
|
||||||
|
// never reads a key, so leave relay-auth's registry untouched when nothing is injected (INV6).
|
||||||
|
const verifier = overrides.verifier ?? refuseAllVerifier
|
||||||
|
if (overrides.verifier !== undefined) {
|
||||||
|
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
const authorizer = createAuthorizer({
|
const authorizer = createAuthorizer({
|
||||||
verifier: overrides.verifier ?? refuseAllVerifier,
|
verifier,
|
||||||
expectedAud: env.baseDomain,
|
expectedAud: env.baseDomain,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
|
|||||||
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
||||||
|
|
||||||
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
||||||
const pop = verifyCsrPoP(input.csr)
|
const pop = await verifyCsrPoP(input.csr)
|
||||||
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
||||||
await deps.pairing.registerFailure(codeHash)
|
await deps.pairing.registerFailure(codeHash)
|
||||||
throw new RedeemError('bad_csr')
|
throw new RedeemError('bad_csr')
|
||||||
|
|||||||
99
control-plane/src/server.ts
Normal file
99
control-plane/src/server.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations →
|
||||||
|
* ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning
|
||||||
|
* API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so
|
||||||
|
* PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation.
|
||||||
|
*
|
||||||
|
* Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv`
|
||||||
|
* (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT.
|
||||||
|
* accountId is only ever derived from the authenticated capability token inside buildControlPlane
|
||||||
|
* (INV3) — this entrypoint never fabricates identity.
|
||||||
|
*/
|
||||||
|
import { loadEnv } from './env.js'
|
||||||
|
import { createPgPool, createQuery } from './db/pool.js'
|
||||||
|
import { createPgStores } from './store/pg.js'
|
||||||
|
import { runMigrations } from './db/migrate.js'
|
||||||
|
import { createRedisRevocationBus } from './routing/bus.js'
|
||||||
|
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||||
|
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
|
||||||
|
import { buildControlPlane } from './main.js'
|
||||||
|
|
||||||
|
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
|
||||||
|
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
|
||||||
|
const DEFAULT_CP_BIND_PORT = 8080
|
||||||
|
const MAX_TCP_PORT = 65535
|
||||||
|
|
||||||
|
function resolveBindHost(source: NodeJS.ProcessEnv): string {
|
||||||
|
const raw = source.CP_BIND_HOST?.trim()
|
||||||
|
return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBindPort(source: NodeJS.ProcessEnv): number {
|
||||||
|
const raw = source.CP_BIND_PORT?.trim()
|
||||||
|
if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT
|
||||||
|
const port = Number(raw)
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
|
||||||
|
throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535')
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const env = loadEnv(process.env)
|
||||||
|
|
||||||
|
// Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied
|
||||||
|
// idempotently at boot.
|
||||||
|
const pool = createPgPool(env.pgUrl)
|
||||||
|
const query = createQuery(pool)
|
||||||
|
const stores = createPgStores(query)
|
||||||
|
await runMigrations(query)
|
||||||
|
|
||||||
|
// Redis: single client drives the revocation-bus publish side (relay:revocations).
|
||||||
|
const redis = createRedisClient(env.redisUrl)
|
||||||
|
const bus = createRedisRevocationBus(createRedisPublisher(redis))
|
||||||
|
|
||||||
|
// Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving,
|
||||||
|
// then delegate to relay-auth's async verifyCapabilityToken (INV3).
|
||||||
|
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||||
|
const verifier = createCapabilityVerifier()
|
||||||
|
|
||||||
|
const { app } = await buildControlPlane(env, { stores, bus, verifier })
|
||||||
|
|
||||||
|
const host = resolveBindHost(process.env)
|
||||||
|
const port = resolveBindPort(process.env)
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
process.stdout.write(`control-plane received ${signal}, shutting down\n`)
|
||||||
|
try {
|
||||||
|
await app.close()
|
||||||
|
} finally {
|
||||||
|
// Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete.
|
||||||
|
await redis.quit().catch(() => redis.disconnect())
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
void shutdown(signal).then(
|
||||||
|
() => process.exit(0),
|
||||||
|
(err: unknown) => {
|
||||||
|
process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ host, port })
|
||||||
|
// Startup breadcrumb — bind address only, never secrets (INV9).
|
||||||
|
process.stdout.write(`control-plane listening on ${host}:${port}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
600
control-plane/src/store/pg.ts
Normal file
600
control-plane/src/store/pg.ts
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* A1 — Postgres adapter for the repository ports (store/ports.ts). Each store maps its port
|
||||||
|
* methods to PARAMETERIZED SQL over the injected `query` wrapper (db/pool.ts) — values ALWAYS
|
||||||
|
* travel as bound params, never string-interpolated (SQLi structurally impossible, §7).
|
||||||
|
*
|
||||||
|
* This adapter MUST reproduce store/memory.ts observable semantics EXACTLY:
|
||||||
|
* - INV8 versioning: `swapStatus` appends a `*_status_versions` row AND bumps the single-row
|
||||||
|
* `status`/`status_version` pointer ATOMICALLY. Postgres has real transactions, but we get
|
||||||
|
* the same all-or-nothing with a single writable-CTE statement (no explicit tx / PoolClient).
|
||||||
|
* - `insert` duplicates throw (PK / UNIQUE violation, pg code 23505 → `throw new Error`).
|
||||||
|
* - `casRedeem` is a single-winner CAS of `redeemed_at` from null.
|
||||||
|
* - `RouteStore` fails closed on TTL: `expires_at <= now()` ⇒ absent (INV7), lazily DELETEd.
|
||||||
|
* - metering/audit are append-only with inclusive `[from,to]` time-window queries.
|
||||||
|
*
|
||||||
|
* Timestamps: DB columns are `timestamptz` (node-pg returns them as JS `Date`); records use ISO
|
||||||
|
* strings. `toIso` normalizes Date|string → ISO so returned records match memory.ts string shapes.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AccountRecord,
|
||||||
|
AccountStatus,
|
||||||
|
AccountStatusVersionRow,
|
||||||
|
HostRecord,
|
||||||
|
HostStatus,
|
||||||
|
HostStatusVersionRow,
|
||||||
|
MeteringSampleRow,
|
||||||
|
PairingCodeRecord,
|
||||||
|
PlanTier,
|
||||||
|
RouteEntry,
|
||||||
|
SessionRecord,
|
||||||
|
} from '../model/records.js'
|
||||||
|
import type { QueryFn } from '../db/pool.js'
|
||||||
|
import type {
|
||||||
|
AccountStore,
|
||||||
|
AuditRow,
|
||||||
|
AuditStore,
|
||||||
|
CasOutcome,
|
||||||
|
HostStore,
|
||||||
|
MeteringStore,
|
||||||
|
NodeRow,
|
||||||
|
NodeStatus,
|
||||||
|
NodeStore,
|
||||||
|
PairingRow,
|
||||||
|
PairingStore,
|
||||||
|
RouteStore,
|
||||||
|
SessionStore,
|
||||||
|
Stores,
|
||||||
|
SubdomainStore,
|
||||||
|
} from './ports.js'
|
||||||
|
|
||||||
|
// ---- helpers ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const toIso = (v: Date | string): string => (v instanceof Date ? v : new Date(v)).toISOString()
|
||||||
|
const toIsoN = (v: Date | string | null): string | null => (v === null ? null : toIso(v))
|
||||||
|
|
||||||
|
/** pg unique/PK violation. */
|
||||||
|
function isUniqueViolation(err: unknown): err is { code: string; constraint?: string } {
|
||||||
|
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === '23505'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- raw DB row shapes ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AccountRow {
|
||||||
|
account_id: string
|
||||||
|
plan: PlanTier
|
||||||
|
created_at: Date | string
|
||||||
|
status: AccountStatus
|
||||||
|
}
|
||||||
|
interface HostRow {
|
||||||
|
host_id: string
|
||||||
|
account_id: string
|
||||||
|
subdomain: string
|
||||||
|
agent_pubkey: Buffer
|
||||||
|
enroll_fpr: string
|
||||||
|
status: HostStatus
|
||||||
|
last_seen: Date | string
|
||||||
|
created_at: Date | string
|
||||||
|
revoked_at: Date | string | null
|
||||||
|
}
|
||||||
|
interface SessionRow {
|
||||||
|
session_id: string
|
||||||
|
host_id: string
|
||||||
|
account_id: string
|
||||||
|
created_at: Date | string
|
||||||
|
last_attach_at: Date | string
|
||||||
|
}
|
||||||
|
interface AccountVersionRow {
|
||||||
|
account_id: string
|
||||||
|
version: number
|
||||||
|
status: AccountStatus
|
||||||
|
changed_at: Date | string
|
||||||
|
changed_by: string
|
||||||
|
}
|
||||||
|
interface HostVersionRow {
|
||||||
|
host_id: string
|
||||||
|
version: number
|
||||||
|
status: HostStatus
|
||||||
|
revoked_at: Date | string | null
|
||||||
|
changed_at: Date | string
|
||||||
|
changed_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAccount(r: AccountRow): AccountRecord {
|
||||||
|
return { accountId: r.account_id, plan: r.plan, createdAt: toIso(r.created_at), status: r.status }
|
||||||
|
}
|
||||||
|
function mapHost(r: HostRow): HostRecord {
|
||||||
|
return {
|
||||||
|
hostId: r.host_id,
|
||||||
|
accountId: r.account_id,
|
||||||
|
subdomain: r.subdomain,
|
||||||
|
agentPubkey: new Uint8Array(r.agent_pubkey),
|
||||||
|
enrollFpr: r.enroll_fpr,
|
||||||
|
status: r.status,
|
||||||
|
lastSeen: toIso(r.last_seen),
|
||||||
|
createdAt: toIso(r.created_at),
|
||||||
|
revokedAt: toIsoN(r.revoked_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AccountStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function pgAccountStore(query: QueryFn): AccountStore {
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
// Writable CTE: create the row AND its INV8 version-1 companion in ONE statement.
|
||||||
|
// version-1 changed_at = created_at, changed_by = 'system' (matches memory.ts).
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`WITH ins AS (
|
||||||
|
INSERT INTO accounts (account_id, plan, created_at, status, status_version)
|
||||||
|
VALUES ($1, $2, $3, $4, 1)
|
||||||
|
RETURNING account_id, created_at, status
|
||||||
|
)
|
||||||
|
INSERT INTO account_status_versions (account_id, version, status, changed_at, changed_by)
|
||||||
|
SELECT account_id, 1, status, created_at, 'system' FROM ins`,
|
||||||
|
[rec.accountId, rec.plan, rec.createdAt, rec.status],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new Error('duplicate accountId')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(accountId) {
|
||||||
|
const rows = await query<AccountRow>(
|
||||||
|
`SELECT account_id, plan, created_at, status FROM accounts WHERE account_id = $1`,
|
||||||
|
[accountId],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
return row === undefined ? null : mapAccount(row)
|
||||||
|
},
|
||||||
|
async swapStatus(accountId, status, changedBy) {
|
||||||
|
// Atomic INV8: bump pointer + append version row in one writable-CTE statement.
|
||||||
|
const rows = await query<AccountRow>(
|
||||||
|
`WITH upd AS (
|
||||||
|
UPDATE accounts SET status = $2, status_version = status_version + 1
|
||||||
|
WHERE account_id = $1
|
||||||
|
RETURNING account_id, plan, created_at, status, status_version
|
||||||
|
),
|
||||||
|
ins AS (
|
||||||
|
INSERT INTO account_status_versions (account_id, version, status, changed_by)
|
||||||
|
SELECT account_id, status_version, status, $3 FROM upd
|
||||||
|
)
|
||||||
|
SELECT account_id, plan, created_at, status FROM upd`,
|
||||||
|
[accountId, status, changedBy],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
if (row === undefined) throw new Error('account not found')
|
||||||
|
return mapAccount(row)
|
||||||
|
},
|
||||||
|
async versions(accountId) {
|
||||||
|
const rows = await query<AccountVersionRow>(
|
||||||
|
`SELECT account_id, version, status, changed_at, changed_by
|
||||||
|
FROM account_status_versions WHERE account_id = $1 ORDER BY version ASC`,
|
||||||
|
[accountId],
|
||||||
|
)
|
||||||
|
return rows.map(
|
||||||
|
(r): AccountStatusVersionRow => ({
|
||||||
|
accountId: r.account_id,
|
||||||
|
version: r.version,
|
||||||
|
status: r.status,
|
||||||
|
changedAt: toIso(r.changed_at),
|
||||||
|
changedBy: r.changed_by,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HostStore --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function pgHostStore(query: QueryFn): HostStore {
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`WITH ins AS (
|
||||||
|
INSERT INTO hosts
|
||||||
|
(host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1)
|
||||||
|
RETURNING host_id, created_at, status, revoked_at
|
||||||
|
)
|
||||||
|
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_at, changed_by)
|
||||||
|
SELECT host_id, 1, status, revoked_at, created_at, 'system' FROM ins`,
|
||||||
|
[
|
||||||
|
rec.hostId,
|
||||||
|
rec.accountId,
|
||||||
|
rec.subdomain,
|
||||||
|
Buffer.from(rec.agentPubkey),
|
||||||
|
rec.enrollFpr,
|
||||||
|
rec.status,
|
||||||
|
rec.lastSeen,
|
||||||
|
rec.createdAt,
|
||||||
|
rec.revokedAt,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) {
|
||||||
|
// subdomain UNIQUE is the subdomain single-winner substrate; PK is host_id.
|
||||||
|
throw new Error(err.constraint?.includes('subdomain') ? 'duplicate subdomain' : 'duplicate hostId')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(hostId) {
|
||||||
|
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE host_id = $1`, [hostId])
|
||||||
|
const row = rows[0]
|
||||||
|
return row === undefined ? null : mapHost(row)
|
||||||
|
},
|
||||||
|
async getBySubdomain(subdomain) {
|
||||||
|
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE subdomain = $1`, [subdomain])
|
||||||
|
const row = rows[0]
|
||||||
|
return row === undefined ? null : mapHost(row)
|
||||||
|
},
|
||||||
|
async listByAccount(accountId) {
|
||||||
|
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE account_id = $1 ORDER BY created_at ASC`, [
|
||||||
|
accountId,
|
||||||
|
])
|
||||||
|
return rows.map(mapHost)
|
||||||
|
},
|
||||||
|
async swapStatus(hostId, status, revokedAt, changedBy) {
|
||||||
|
const rows = await query<HostRow>(
|
||||||
|
`WITH upd AS (
|
||||||
|
UPDATE hosts SET status = $2, revoked_at = $3, status_version = status_version + 1
|
||||||
|
WHERE host_id = $1
|
||||||
|
RETURNING host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version
|
||||||
|
),
|
||||||
|
ins AS (
|
||||||
|
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_by)
|
||||||
|
SELECT host_id, status_version, status, revoked_at, $4 FROM upd
|
||||||
|
)
|
||||||
|
SELECT host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at FROM upd`,
|
||||||
|
[hostId, status, revokedAt, changedBy],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
if (row === undefined) throw new Error('host not found')
|
||||||
|
return mapHost(row)
|
||||||
|
},
|
||||||
|
async touchLastSeen(hostId, ts) {
|
||||||
|
// Advisory cache — NO version row (INV7). Silent no-op if absent (matches memory.ts).
|
||||||
|
await query(`UPDATE hosts SET last_seen = $2 WHERE host_id = $1`, [hostId, ts])
|
||||||
|
},
|
||||||
|
async versions(hostId) {
|
||||||
|
const rows = await query<HostVersionRow>(
|
||||||
|
`SELECT host_id, version, status, revoked_at, changed_at, changed_by
|
||||||
|
FROM host_status_versions WHERE host_id = $1 ORDER BY version ASC`,
|
||||||
|
[hostId],
|
||||||
|
)
|
||||||
|
return rows.map(
|
||||||
|
(r): HostStatusVersionRow => ({
|
||||||
|
hostId: r.host_id,
|
||||||
|
version: r.version,
|
||||||
|
status: r.status,
|
||||||
|
revokedAt: toIsoN(r.revoked_at),
|
||||||
|
changedAt: toIso(r.changed_at),
|
||||||
|
changedBy: r.changed_by,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SessionStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function pgSessionStore(query: QueryFn): SessionStore {
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO sessions (session_id, host_id, account_id, created_at, last_attach_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[rec.sessionId, rec.hostId, rec.accountId, rec.createdAt, rec.lastAttachAt],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new Error('duplicate sessionId')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(sessionId) {
|
||||||
|
const rows = await query<SessionRow>(`SELECT * FROM sessions WHERE session_id = $1`, [sessionId])
|
||||||
|
const row = rows[0]
|
||||||
|
if (row === undefined) return null
|
||||||
|
return {
|
||||||
|
sessionId: row.session_id,
|
||||||
|
hostId: row.host_id,
|
||||||
|
accountId: row.account_id,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
lastAttachAt: toIso(row.last_attach_at),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SubdomainStore ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No separate reservations table exists in the schema — the `hosts.subdomain UNIQUE` constraint
|
||||||
|
* is the single-winner substrate (the true winner is decided at host insert). So `reserve`/`isTaken`
|
||||||
|
* just report whether a host already owns the label (matches memory.ts intent). Two concurrent
|
||||||
|
* `reserve()`s can both see "free" before any host row exists; that is fine, because the actual
|
||||||
|
* single-winner is enforced when the losing insert hits the UNIQUE violation (INV1).
|
||||||
|
*/
|
||||||
|
function pgSubdomainStore(query: QueryFn): SubdomainStore {
|
||||||
|
const taken = async (subdomain: string): Promise<boolean> => {
|
||||||
|
const rows = await query<{ one: number }>(`SELECT 1 AS one FROM hosts WHERE subdomain = $1 LIMIT 1`, [
|
||||||
|
subdomain,
|
||||||
|
])
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async reserve(subdomain) {
|
||||||
|
return !(await taken(subdomain))
|
||||||
|
},
|
||||||
|
async isTaken(subdomain) {
|
||||||
|
return taken(subdomain)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PairingStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PairingCodeRow {
|
||||||
|
code_hash: string
|
||||||
|
account_id: string
|
||||||
|
expires_at: Date | string
|
||||||
|
redeemed_at: Date | string | null
|
||||||
|
redeem_attempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgPairingStore(query: QueryFn): PairingStore {
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO pairing_codes (code_hash, account_id, expires_at, redeemed_at, redeem_attempts)
|
||||||
|
VALUES ($1, $2, $3, $4, 0)`,
|
||||||
|
[rec.codeHash, rec.accountId, rec.expiresAt, rec.redeemedAt],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new Error('duplicate code_hash')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(codeHash): Promise<PairingRow | null> {
|
||||||
|
const rows = await query<PairingCodeRow>(`SELECT * FROM pairing_codes WHERE code_hash = $1`, [codeHash])
|
||||||
|
const row = rows[0]
|
||||||
|
if (row === undefined) return null
|
||||||
|
const record: PairingCodeRecord = {
|
||||||
|
codeHash: row.code_hash,
|
||||||
|
accountId: row.account_id,
|
||||||
|
expiresAt: toIso(row.expires_at),
|
||||||
|
redeemedAt: toIsoN(row.redeemed_at),
|
||||||
|
}
|
||||||
|
return { record, redeemAttempts: row.redeem_attempts }
|
||||||
|
},
|
||||||
|
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
|
||||||
|
// Single-winner CAS: only the row with redeemed_at IS NULL is updated; exactly one wins.
|
||||||
|
const won = await query<{ code_hash: string }>(
|
||||||
|
`UPDATE pairing_codes SET redeemed_at = $2
|
||||||
|
WHERE code_hash = $1 AND redeemed_at IS NULL
|
||||||
|
RETURNING code_hash`,
|
||||||
|
[codeHash, nowIso],
|
||||||
|
)
|
||||||
|
if (won.length === 1) return 'ok'
|
||||||
|
// 0 rows: either already redeemed or unknown — distinguish by existence.
|
||||||
|
const exists = await query<{ one: number }>(`SELECT 1 AS one FROM pairing_codes WHERE code_hash = $1`, [
|
||||||
|
codeHash,
|
||||||
|
])
|
||||||
|
return exists.length > 0 ? 'already_redeemed' : 'unknown'
|
||||||
|
},
|
||||||
|
async registerFailure(codeHash) {
|
||||||
|
const rows = await query<{ redeem_attempts: number }>(
|
||||||
|
`UPDATE pairing_codes SET redeem_attempts = redeem_attempts + 1
|
||||||
|
WHERE code_hash = $1
|
||||||
|
RETURNING redeem_attempts`,
|
||||||
|
[codeHash],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
return row === undefined ? 0 : row.redeem_attempts // 0 for unknown code (matches memory.ts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RouteStore -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RouteRow {
|
||||||
|
host_id: string
|
||||||
|
relay_node_id: string
|
||||||
|
updated_at: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgRouteStore(query: QueryFn): RouteStore {
|
||||||
|
const mapEntry = (r: RouteRow): RouteEntry => ({ relayNodeId: r.relay_node_id, updatedAt: toIso(r.updated_at) })
|
||||||
|
return {
|
||||||
|
async set(hostId, entry, ttlSec) {
|
||||||
|
// Postgres has no per-key TTL: store an explicit expires_at = now() + ttl (server clock).
|
||||||
|
await query(
|
||||||
|
`INSERT INTO routes (host_id, relay_node_id, updated_at, expires_at)
|
||||||
|
VALUES ($1, $2, $3, now() + make_interval(secs => $4))
|
||||||
|
ON CONFLICT (host_id) DO UPDATE SET
|
||||||
|
relay_node_id = EXCLUDED.relay_node_id,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
expires_at = EXCLUDED.expires_at`,
|
||||||
|
[hostId, entry.relayNodeId, entry.updatedAt, ttlSec],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async refreshTtl(hostId, ttlSec) {
|
||||||
|
// Refresh only a still-live key (fail closed on already-expired/absent, INV7).
|
||||||
|
const rows = await query<{ host_id: string }>(
|
||||||
|
`UPDATE routes SET expires_at = now() + make_interval(secs => $2)
|
||||||
|
WHERE host_id = $1 AND expires_at > now()
|
||||||
|
RETURNING host_id`,
|
||||||
|
[hostId, ttlSec],
|
||||||
|
)
|
||||||
|
return rows.length === 1
|
||||||
|
},
|
||||||
|
async get(hostId) {
|
||||||
|
// Lazily reap the expired row, then a surviving row is guaranteed live (INV7 fail-closed).
|
||||||
|
await query(`DELETE FROM routes WHERE host_id = $1 AND expires_at <= now()`, [hostId])
|
||||||
|
const rows = await query<RouteRow>(
|
||||||
|
`SELECT host_id, relay_node_id, updated_at FROM routes WHERE host_id = $1`,
|
||||||
|
[hostId],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
return row === undefined ? null : mapEntry(row)
|
||||||
|
},
|
||||||
|
async delete(hostId) {
|
||||||
|
await query(`DELETE FROM routes WHERE host_id = $1`, [hostId])
|
||||||
|
},
|
||||||
|
async listByNode(nodeId) {
|
||||||
|
await query(`DELETE FROM routes WHERE expires_at <= now()`, [])
|
||||||
|
const rows = await query<RouteRow>(
|
||||||
|
`SELECT host_id, relay_node_id, updated_at FROM routes
|
||||||
|
WHERE relay_node_id = $1 AND expires_at > now() ORDER BY host_id ASC`,
|
||||||
|
[nodeId],
|
||||||
|
)
|
||||||
|
return rows.map((r) => ({ hostId: r.host_id, entry: mapEntry(r) }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NodeStore --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NodeDbRow {
|
||||||
|
node_id: string
|
||||||
|
addr: string
|
||||||
|
status: NodeStatus
|
||||||
|
last_seen: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgNodeStore(query: QueryFn): NodeStore {
|
||||||
|
return {
|
||||||
|
async upsert(row) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO relay_nodes (node_id, addr, status, last_seen)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (node_id) DO UPDATE SET
|
||||||
|
addr = EXCLUDED.addr, status = EXCLUDED.status, last_seen = EXCLUDED.last_seen`,
|
||||||
|
[row.nodeId, row.addr, row.status, row.lastSeen],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async get(nodeId) {
|
||||||
|
const rows = await query<NodeDbRow>(`SELECT * FROM relay_nodes WHERE node_id = $1`, [nodeId])
|
||||||
|
const row = rows[0]
|
||||||
|
if (row === undefined) return null
|
||||||
|
return { nodeId: row.node_id, addr: row.addr, status: row.status, lastSeen: toIso(row.last_seen) }
|
||||||
|
},
|
||||||
|
async setStatus(nodeId, status) {
|
||||||
|
const rows = await query<{ node_id: string }>(
|
||||||
|
`UPDATE relay_nodes SET status = $2 WHERE node_id = $1 RETURNING node_id`,
|
||||||
|
[nodeId, status],
|
||||||
|
)
|
||||||
|
if (rows.length === 0) throw new Error('node not found')
|
||||||
|
},
|
||||||
|
async touch(nodeId, ts) {
|
||||||
|
// Silent no-op if absent (matches memory.ts).
|
||||||
|
await query(`UPDATE relay_nodes SET last_seen = $2 WHERE node_id = $1`, [nodeId, ts])
|
||||||
|
},
|
||||||
|
async delete(nodeId) {
|
||||||
|
await query(`DELETE FROM relay_nodes WHERE node_id = $1`, [nodeId])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MeteringStore ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MeteringDbRow {
|
||||||
|
host_id: string
|
||||||
|
account_id: string
|
||||||
|
node_id: string
|
||||||
|
concurrent_viewers: number
|
||||||
|
sampled_at: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgMeteringStore(query: QueryFn): MeteringStore {
|
||||||
|
return {
|
||||||
|
async append(row) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO metering_samples (host_id, account_id, node_id, concurrent_viewers, sampled_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[row.hostId, row.accountId, row.nodeId, row.concurrentViewers, row.sampledAt],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async query(accountId, fromIso, toIso2) {
|
||||||
|
const rows = await query<MeteringDbRow>(
|
||||||
|
`SELECT host_id, account_id, node_id, concurrent_viewers, sampled_at
|
||||||
|
FROM metering_samples
|
||||||
|
WHERE account_id = $1 AND sampled_at >= $2 AND sampled_at <= $3
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[accountId, fromIso, toIso2],
|
||||||
|
)
|
||||||
|
return rows.map(
|
||||||
|
(r): MeteringSampleRow => ({
|
||||||
|
hostId: r.host_id,
|
||||||
|
accountId: r.account_id,
|
||||||
|
nodeId: r.node_id,
|
||||||
|
concurrentViewers: r.concurrent_viewers,
|
||||||
|
sampledAt: toIso(r.sampled_at),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AuditStore -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AuditDbRow {
|
||||||
|
action: string
|
||||||
|
principal_id: string
|
||||||
|
account_id: string
|
||||||
|
host_id: string | null
|
||||||
|
ts: Date | string
|
||||||
|
meta: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgAuditStore(query: QueryFn): AuditStore {
|
||||||
|
return {
|
||||||
|
async append(row) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO audit_log (action, principal_id, account_id, host_id, ts, meta)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||||
|
[row.action, row.principalId, row.accountId, row.hostId, row.ts, JSON.stringify(row.meta)],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async query(accountId, fromIso, toIso2) {
|
||||||
|
const rows = await query<AuditDbRow>(
|
||||||
|
`SELECT action, principal_id, account_id, host_id, ts, meta
|
||||||
|
FROM audit_log
|
||||||
|
WHERE account_id = $1 AND ts >= $2 AND ts <= $3
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[accountId, fromIso, toIso2],
|
||||||
|
)
|
||||||
|
return rows.map(
|
||||||
|
(r): AuditRow => ({
|
||||||
|
action: r.action,
|
||||||
|
principalId: r.principal_id,
|
||||||
|
accountId: r.account_id,
|
||||||
|
hostId: r.host_id,
|
||||||
|
ts: toIso(r.ts),
|
||||||
|
meta: r.meta,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- assembly ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Build a full Postgres-backed store set (all ports mapped to parameterized SQL over `query`). */
|
||||||
|
export function createPgStores(query: QueryFn): Stores {
|
||||||
|
return {
|
||||||
|
accounts: pgAccountStore(query),
|
||||||
|
hosts: pgHostStore(query),
|
||||||
|
sessions: pgSessionStore(query),
|
||||||
|
subdomains: pgSubdomainStore(query),
|
||||||
|
pairing: pgPairingStore(query),
|
||||||
|
routes: pgRouteStore(query),
|
||||||
|
nodes: pgNodeStore(query),
|
||||||
|
metering: pgMeteringStore(query),
|
||||||
|
audit: pgAuditStore(query),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const env = loadEnv({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const verifier: CapabilityVerifier = {
|
const verifier: CapabilityVerifier = {
|
||||||
verify(raw, expectedAud, now): CapabilityToken {
|
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
|
||||||
if (raw !== 'tokenA') throw new Error('invalid token')
|
if (raw !== 'tokenA') throw new Error('invalid token')
|
||||||
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
|
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ const env = loadEnv({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
|
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
|
||||||
|
// Async to match the `CapabilityVerifier` seam (real §4.3 verify is async).
|
||||||
const verifier: CapabilityVerifier = {
|
const verifier: CapabilityVerifier = {
|
||||||
verify(raw, expectedAud, now): CapabilityToken {
|
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
|
||||||
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
|
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
|
||||||
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
|
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
|
||||||
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
|
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
|
||||||
|
|||||||
59
control-plane/test/boot-redis.test.ts
Normal file
59
control-plane/test/boot-redis.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* A2 — unit tests for the ioredis → RedisPublisher adapter (boot/redis.ts). The adapter is the only
|
||||||
|
* non-trivial logic in the server entrypoint's Redis wiring: it must forward publish(channel, message)
|
||||||
|
* verbatim to the underlying client and surface the driver's subscriber-count result unchanged. The
|
||||||
|
* live `createRedisClient` (a thin `new Redis(url)`) needs a real broker and is covered by the boot
|
||||||
|
* smoke, not here.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import type { Redis } from 'ioredis'
|
||||||
|
import { createRedisPublisher } from '../src/boot/redis.js'
|
||||||
|
import { createRedisRevocationBus } from '../src/routing/bus.js'
|
||||||
|
import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Minimal ioredis stand-in recording publish calls; cast to Redis at the seam (only `publish` is used). */
|
||||||
|
function fakeRedis(returnValue = 1): { calls: Array<[string, string]>; client: Redis } {
|
||||||
|
const calls: Array<[string, string]> = []
|
||||||
|
const client = {
|
||||||
|
publish: async (channel: string, message: string): Promise<number> => {
|
||||||
|
calls.push([channel, message])
|
||||||
|
return returnValue
|
||||||
|
},
|
||||||
|
} as unknown as Redis
|
||||||
|
return { calls, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('A2 createRedisPublisher (RedisPublisher adapter)', () => {
|
||||||
|
test('forwards channel + message verbatim and returns the driver subscriber count', async () => {
|
||||||
|
// Arrange
|
||||||
|
const { calls, client } = fakeRedis(3)
|
||||||
|
const publisher = createRedisPublisher(client)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const receivers = await publisher.publish('relay:revocations', 'payload')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(receivers).toBe(3)
|
||||||
|
expect(calls).toEqual([['relay:revocations', 'payload']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drives createRedisRevocationBus onto the FROZEN relay:revocations channel with JSON payload', async () => {
|
||||||
|
// Arrange
|
||||||
|
const { calls, client } = fakeRedis()
|
||||||
|
const bus = createRedisRevocationBus(createRedisPublisher(client))
|
||||||
|
const signal: KillSignal = {
|
||||||
|
scope: { kind: 'host', hostId: '11111111-1111-4111-8111-111111111111' },
|
||||||
|
at: 1_700_000_000,
|
||||||
|
reason: 'revoked',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await bus.publish(signal)
|
||||||
|
|
||||||
|
// Assert — bus publishes exactly one message on the shared channel, JSON-encoded.
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
const [channel, message] = calls[0]!
|
||||||
|
expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL)
|
||||||
|
expect(JSON.parse(message)).toMatchObject({ scope: { kind: 'host', hostId: signal.scope.kind === 'host' ? signal.scope.hostId : '' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
|
|||||||
const forged = new Uint8Array(96)
|
const forged = new Uint8Array(96)
|
||||||
forged.set(publicKeyRaw, 0)
|
forged.set(publicKeyRaw, 0)
|
||||||
forged.set(randomBytes(64), 32)
|
forged.set(randomBytes(64), 32)
|
||||||
expect(verifyCsrPoP(forged).ok).toBe(false)
|
expect((await verifyCsrPoP(forged)).ok).toBe(false)
|
||||||
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||||
expect(signSpy).not.toHaveBeenCalled()
|
expect(signSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
220
control-plane/test/interop.test.ts
Normal file
220
control-plane/test/interop.test.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
|
||||||
|
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
|
||||||
|
*
|
||||||
|
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
|
||||||
|
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
|
||||||
|
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
|
||||||
|
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto, randomUUID } from 'node:crypto'
|
||||||
|
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
|
||||||
|
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||||
|
import type { HostRecord } from 'relay-contracts'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||||
|
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
|
||||||
|
import { buildCsr } from '../src/ca/csr.js'
|
||||||
|
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||||
|
import { generateEd25519 } from '../src/util/crypto.js'
|
||||||
|
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
|
const TRUST_DOMAIN = 'example.com'
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface Ca {
|
||||||
|
readonly intKey: CryptoKey
|
||||||
|
readonly issuerName: x509.Name
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
readonly caChainPem: string
|
||||||
|
readonly intermediateKeyPem: string
|
||||||
|
readonly intermediateCertPem: string
|
||||||
|
readonly rootCertPem: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||||
|
|
||||||
|
function derToPemLabeled(der: Uint8Array, label: string): string {
|
||||||
|
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||||
|
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeCa(): Promise<Ca> {
|
||||||
|
const subtle = webcrypto.subtle
|
||||||
|
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||||
|
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||||
|
const now = Date.now()
|
||||||
|
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||||
|
serialNumber: '01',
|
||||||
|
name: 'CN=relay-root',
|
||||||
|
notBefore: new Date(now - DAY_MS),
|
||||||
|
notAfter: new Date(now + DAY_MS),
|
||||||
|
keys: rootKeys,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||||
|
})
|
||||||
|
const intCert = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: '02',
|
||||||
|
subject: 'CN=relay-intermediate',
|
||||||
|
issuer: rootCert.subjectName,
|
||||||
|
notBefore: new Date(now - DAY_MS),
|
||||||
|
notAfter: new Date(now + DAY_MS),
|
||||||
|
publicKey: intKeys.publicKey,
|
||||||
|
signingKey: rootKeys.privateKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||||
|
})
|
||||||
|
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
|
||||||
|
return {
|
||||||
|
intKey: intKeys.privateKey,
|
||||||
|
issuerName: intCert.subjectName,
|
||||||
|
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||||
|
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
|
||||||
|
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
|
||||||
|
intermediateCertPem: intCert.toString('pem'),
|
||||||
|
rootCertPem: rootCert.toString('pem'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function derToPem(der: Uint8Array): string {
|
||||||
|
return new x509.X509Certificate(der).toString('pem')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
|
||||||
|
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
|
||||||
|
return {
|
||||||
|
hostId,
|
||||||
|
accountId,
|
||||||
|
subdomain: 'host',
|
||||||
|
agentPubkey: new Uint8Array(32),
|
||||||
|
enrollFpr: 'fpr',
|
||||||
|
status,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
|
||||||
|
} as HostRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueLeaf() {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({
|
||||||
|
accountId,
|
||||||
|
subdomain: 'alice',
|
||||||
|
agentPubkey: publicKeyRaw,
|
||||||
|
enrollFpr: fingerprint(publicKeyRaw),
|
||||||
|
})
|
||||||
|
const signer = createRealLeafSigner({
|
||||||
|
hosts: stores.hosts,
|
||||||
|
intermediateKey: ca.intKey,
|
||||||
|
issuerName: ca.issuerName,
|
||||||
|
caChainDer: ca.caChainDer,
|
||||||
|
trustDomain: TRUST_DOMAIN,
|
||||||
|
})
|
||||||
|
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||||
|
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
|
||||||
|
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
|
||||||
|
expect(caChain).toHaveLength(2) // intermediate + root DER
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res).toEqual({ ok: true, hostId, accountId })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
|
||||||
|
const { ca, hostId, leafPem } = await issueLeaf()
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('spiffe_account_mismatch')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revoked host → host_revoked', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('host_revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expired leaf → expired', async () => {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const hostId = randomUUID()
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
|
||||||
|
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
|
||||||
|
const spki = new Uint8Array(44)
|
||||||
|
spki.set(prefix, 0)
|
||||||
|
spki.set(publicKeyRaw, 12)
|
||||||
|
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||||
|
const now = Date.now()
|
||||||
|
const expiredLeaf = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: '0e',
|
||||||
|
subject: `CN=${hostId}`,
|
||||||
|
issuer: ca.issuerName,
|
||||||
|
notBefore: new Date(now - 2 * DAY_MS),
|
||||||
|
notAfter: new Date(now - DAY_MS), // already expired
|
||||||
|
publicKey: subjectKey,
|
||||||
|
signingKey: ca.intKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
|
||||||
|
})
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(
|
||||||
|
expiredLeaf.toString('pem'),
|
||||||
|
ca.caChainPem,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
registry,
|
||||||
|
defaultParseX509,
|
||||||
|
)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({
|
||||||
|
accountId,
|
||||||
|
subdomain: 'boot',
|
||||||
|
agentPubkey: publicKeyRaw,
|
||||||
|
enrollFpr: fingerprint(publicKeyRaw),
|
||||||
|
})
|
||||||
|
const signer = await loadRealLeafSigner({
|
||||||
|
hosts: stores.hosts,
|
||||||
|
intermediateKeyPem: ca.intermediateKeyPem,
|
||||||
|
intermediateCertPem: ca.intermediateCertPem,
|
||||||
|
rootCertPem: ca.rootCertPem,
|
||||||
|
trustDomain: TRUST_DOMAIN,
|
||||||
|
})
|
||||||
|
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||||
|
const registry = {
|
||||||
|
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
|
||||||
|
}
|
||||||
|
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('intermediate-only bundle (no root) → chain_invalid', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||||
|
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('chain_invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
390
control-plane/test/store/pg.test.ts
Normal file
390
control-plane/test/store/pg.test.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner
|
||||||
|
* (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match
|
||||||
|
* store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain
|
||||||
|
* reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows.
|
||||||
|
*
|
||||||
|
* Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises'
|
||||||
|
import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js'
|
||||||
|
import { runMigrations } from '../../src/db/migrate.js'
|
||||||
|
import { createPgStores } from '../../src/store/pg.js'
|
||||||
|
import type { Stores } from '../../src/store/ports.js'
|
||||||
|
import type { AccountRecord, HostRecord } from '../../src/model/records.js'
|
||||||
|
|
||||||
|
const CONTAINER = 'cp_pg_a1_test'
|
||||||
|
const PORT = 5433
|
||||||
|
const READY_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
let pool: ReturnType<typeof createPgPool> | undefined
|
||||||
|
let query: QueryFn
|
||||||
|
let stores: Stores
|
||||||
|
let startedContainer = false
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
let url = process.env.PG_TEST_URL
|
||||||
|
if (!url) {
|
||||||
|
try {
|
||||||
|
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
|
||||||
|
} catch {
|
||||||
|
/* nothing to remove */
|
||||||
|
}
|
||||||
|
execSync(
|
||||||
|
`docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`,
|
||||||
|
{ stdio: 'ignore' },
|
||||||
|
)
|
||||||
|
startedContainer = true
|
||||||
|
url = `postgres://postgres:test@127.0.0.1:${PORT}/cp`
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = createPgPool(url)
|
||||||
|
query = createQuery(pool)
|
||||||
|
|
||||||
|
// Poll until the server accepts queries.
|
||||||
|
const deadline = Date.now() + READY_TIMEOUT_MS
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await query('SELECT 1', [])
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (Date.now() > deadline) throw err
|
||||||
|
await sleep(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runMigrations(query)
|
||||||
|
// Idempotency: a second run must not throw (all migrations are IF NOT EXISTS).
|
||||||
|
await runMigrations(query)
|
||||||
|
stores = createPgStores(query)
|
||||||
|
}, 180_000)
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool?.end()
|
||||||
|
if (startedContainer) {
|
||||||
|
try {
|
||||||
|
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- fixtures ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function newAccount(): AccountRecord {
|
||||||
|
return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' }
|
||||||
|
}
|
||||||
|
async function makeAccount(): Promise<AccountRecord> {
|
||||||
|
const rec = newAccount()
|
||||||
|
await stores.accounts.insert(rec)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
|
||||||
|
const rec: HostRecord = {
|
||||||
|
hostId: randomUUID(),
|
||||||
|
accountId,
|
||||||
|
subdomain,
|
||||||
|
agentPubkey: new Uint8Array([1, 2, 3, 4]),
|
||||||
|
enrollFpr: `fpr-${subdomain}`,
|
||||||
|
status: 'offline',
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
revokedAt: null,
|
||||||
|
}
|
||||||
|
await stores.hosts.insert(rec)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AccountStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AccountStore', () => {
|
||||||
|
test('insert + get round-trips the record', async () => {
|
||||||
|
const rec = await makeAccount()
|
||||||
|
expect(await stores.accounts.get(rec.accountId)).toEqual(rec)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get returns null for unknown account', async () => {
|
||||||
|
expect(await stores.accounts.get(randomUUID())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate insert throws', async () => {
|
||||||
|
const rec = await makeAccount()
|
||||||
|
await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('swapStatus bumps status + appends INV8 version history atomically', async () => {
|
||||||
|
const rec = await makeAccount()
|
||||||
|
expect(await stores.accounts.versions(rec.accountId)).toEqual([
|
||||||
|
{ accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1')
|
||||||
|
expect(swapped.status).toBe('suspended')
|
||||||
|
expect(swapped.accountId).toBe(rec.accountId)
|
||||||
|
expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended')
|
||||||
|
|
||||||
|
const versions = await stores.accounts.versions(rec.accountId)
|
||||||
|
expect(versions.length).toBe(2)
|
||||||
|
expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('swapStatus on unknown account throws', async () => {
|
||||||
|
await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- HostStore --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('HostStore', () => {
|
||||||
|
test('insert + get + getBySubdomain + listByAccount', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
expect(await stores.hosts.get(host.hostId)).toEqual(host)
|
||||||
|
expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host)
|
||||||
|
expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get / getBySubdomain return null when absent', async () => {
|
||||||
|
expect(await stores.hosts.get(randomUUID())).toBeNull()
|
||||||
|
expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate hostId and duplicate subdomain throw distinctly', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId')
|
||||||
|
|
||||||
|
const clash: HostRecord = { ...host, hostId: randomUUID() }
|
||||||
|
await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('swapStatus to revoked sets revokedAt + version history', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
const revokedAt = new Date().toISOString()
|
||||||
|
const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2')
|
||||||
|
expect(swapped.status).toBe('revoked')
|
||||||
|
expect(swapped.revokedAt).toBe(revokedAt)
|
||||||
|
|
||||||
|
const versions = await stores.hosts.versions(host.hostId)
|
||||||
|
expect(versions.length).toBe(2)
|
||||||
|
expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' })
|
||||||
|
expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('swapStatus on unknown host throws', async () => {
|
||||||
|
await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('touchLastSeen updates the advisory cache (no version row)', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
const ts = new Date(Date.now() + 5000).toISOString()
|
||||||
|
await stores.hosts.touchLastSeen(host.hostId, ts)
|
||||||
|
expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts)
|
||||||
|
expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged
|
||||||
|
await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- SessionStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SessionStore', () => {
|
||||||
|
test('insert + get + duplicate + missing', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
const rec = {
|
||||||
|
sessionId: randomUUID(),
|
||||||
|
hostId: host.hostId,
|
||||||
|
accountId: acct.accountId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastAttachAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
await stores.sessions.insert(rec)
|
||||||
|
expect(await stores.sessions.get(rec.sessionId)).toEqual(rec)
|
||||||
|
await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId')
|
||||||
|
expect(await stores.sessions.get(randomUUID())).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- SubdomainStore ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SubdomainStore', () => {
|
||||||
|
test('reserve/isTaken reflect host ownership of the label', async () => {
|
||||||
|
const sub = `claim-${randomUUID().slice(0, 8)}`
|
||||||
|
expect(await stores.subdomains.isTaken(sub)).toBe(false)
|
||||||
|
expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet)
|
||||||
|
|
||||||
|
const acct = await makeAccount()
|
||||||
|
await makeHost(acct.accountId, sub)
|
||||||
|
|
||||||
|
expect(await stores.subdomains.isTaken(sub)).toBe(true)
|
||||||
|
expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- PairingStore -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('PairingStore', () => {
|
||||||
|
test('insert + get + casRedeem single-winner + registerFailure', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const codeHash = `code-${randomUUID()}`
|
||||||
|
const rec = {
|
||||||
|
codeHash,
|
||||||
|
accountId: acct.accountId,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
redeemedAt: null,
|
||||||
|
}
|
||||||
|
await stores.pairing.insert(rec)
|
||||||
|
await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash')
|
||||||
|
|
||||||
|
const got = await stores.pairing.get(codeHash)
|
||||||
|
expect(got).toEqual({ record: rec, redeemAttempts: 0 })
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok')
|
||||||
|
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed')
|
||||||
|
expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown')
|
||||||
|
expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registerFailure increments; unknown code returns 0', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const codeHash = `code-${randomUUID()}`
|
||||||
|
await stores.pairing.insert({
|
||||||
|
codeHash,
|
||||||
|
accountId: acct.accountId,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
redeemedAt: null,
|
||||||
|
})
|
||||||
|
expect(await stores.pairing.registerFailure(codeHash)).toBe(1)
|
||||||
|
expect(await stores.pairing.registerFailure(codeHash)).toBe(2)
|
||||||
|
expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2)
|
||||||
|
expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get returns null for unknown code', async () => {
|
||||||
|
expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- RouteStore -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('RouteStore', () => {
|
||||||
|
test('set → get → refreshTtl → delete', async () => {
|
||||||
|
const hostId = randomUUID()
|
||||||
|
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
|
||||||
|
await stores.routes.set(hostId, entry, 60)
|
||||||
|
expect(await stores.routes.get(hostId)).toEqual(entry)
|
||||||
|
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true)
|
||||||
|
await stores.routes.delete(hostId)
|
||||||
|
expect(await stores.routes.get(hostId)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expired route fails closed (get null, refreshTtl false)', async () => {
|
||||||
|
const hostId = randomUUID()
|
||||||
|
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
|
||||||
|
await stores.routes.set(hostId, entry, -1) // already expired
|
||||||
|
expect(await stores.routes.get(hostId)).toBeNull()
|
||||||
|
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false)
|
||||||
|
expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listByNode returns only live routes targeting the node', async () => {
|
||||||
|
const node = `node-${randomUUID().slice(0, 8)}`
|
||||||
|
const live1 = randomUUID()
|
||||||
|
const live2 = randomUUID()
|
||||||
|
const expired = randomUUID()
|
||||||
|
const otherNode = randomUUID()
|
||||||
|
await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
|
||||||
|
await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
|
||||||
|
await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1)
|
||||||
|
await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60)
|
||||||
|
|
||||||
|
const listed = await stores.routes.listByNode(node)
|
||||||
|
const hostIds = listed.map((r) => r.hostId).sort()
|
||||||
|
expect(hostIds).toEqual([live1, live2].sort())
|
||||||
|
expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- NodeStore --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('NodeStore', () => {
|
||||||
|
test('upsert + get + setStatus + touch + delete', async () => {
|
||||||
|
const nodeId = `relay-${randomUUID().slice(0, 8)}`
|
||||||
|
const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() }
|
||||||
|
await stores.nodes.upsert(row)
|
||||||
|
expect(await stores.nodes.get(nodeId)).toEqual(row)
|
||||||
|
|
||||||
|
await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites
|
||||||
|
expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000')
|
||||||
|
|
||||||
|
await stores.nodes.setStatus(nodeId, 'draining')
|
||||||
|
expect((await stores.nodes.get(nodeId))?.status).toBe('draining')
|
||||||
|
|
||||||
|
const later = new Date(Date.now() + 1000).toISOString()
|
||||||
|
await stores.nodes.touch(nodeId, later)
|
||||||
|
expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later)
|
||||||
|
|
||||||
|
await stores.nodes.delete(nodeId)
|
||||||
|
expect(await stores.nodes.get(nodeId)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setStatus on unknown node throws; touch is a silent no-op', async () => {
|
||||||
|
await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found')
|
||||||
|
await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- MeteringStore ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('MeteringStore', () => {
|
||||||
|
test('append-only + inclusive [from,to] time-window query filtered by account', async () => {
|
||||||
|
const acct = await makeAccount()
|
||||||
|
const other = await makeAccount()
|
||||||
|
const host = await makeHost(acct.accountId)
|
||||||
|
const otherHost = await makeHost(other.accountId)
|
||||||
|
|
||||||
|
const t0 = '2026-01-01T00:00:00.000Z'
|
||||||
|
const t1 = '2026-01-01T01:00:00.000Z'
|
||||||
|
const t2 = '2026-01-01T02:00:00.000Z'
|
||||||
|
|
||||||
|
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 })
|
||||||
|
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 })
|
||||||
|
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 })
|
||||||
|
await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 })
|
||||||
|
|
||||||
|
const inWindow = await stores.metering.query(acct.accountId, t0, t1)
|
||||||
|
expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded
|
||||||
|
expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 })
|
||||||
|
|
||||||
|
const all = await stores.metering.query(acct.accountId, t0, t2)
|
||||||
|
expect(all.length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- AuditStore -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AuditStore', () => {
|
||||||
|
test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => {
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const t0 = '2026-02-01T00:00:00.000Z'
|
||||||
|
const t1 = '2026-02-01T01:00:00.000Z'
|
||||||
|
const t2 = '2026-02-01T02:00:00.000Z'
|
||||||
|
|
||||||
|
await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
|
||||||
|
await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} })
|
||||||
|
await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} })
|
||||||
|
await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} })
|
||||||
|
|
||||||
|
const rows = await stores.audit.query(accountId, t0, t1)
|
||||||
|
expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded
|
||||||
|
expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
|
||||||
|
expect(rows[1]?.hostId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
control-plane/test/verifier.test.ts
Normal file
78
control-plane/test/verifier.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* A3 — REAL capability verifier (boot/verifier.ts) backed by relay-auth's async `verifyCapabilityToken`.
|
||||||
|
* Mints tokens with the paired Ed25519 private key via P5's `issueCapabilityToken`, configures the
|
||||||
|
* matching public key, and asserts: a good token verifies; wrong `aud`, past `exp`, and a token
|
||||||
|
* signed by a NON-matching key all reject (deny-by-default, INV6).
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeAll } from 'vitest'
|
||||||
|
import { issueCapabilityToken } from 'relay-auth'
|
||||||
|
import type { AuthenticatedPrincipal } from 'relay-auth'
|
||||||
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
import { createCapabilityVerifier, configureCapabilityVerifyKey } from '../src/boot/verifier.js'
|
||||||
|
import type { CapabilityVerifier } from '../src/api/authz.js'
|
||||||
|
|
||||||
|
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const AUD = 'term.example.com'
|
||||||
|
const NOW = 1_000_000
|
||||||
|
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]; 32 bytes → 43 base64url chars.
|
||||||
|
const CNF_JKT = encodeBase64UrlBytes(new Uint8Array(32).fill(7))
|
||||||
|
|
||||||
|
const principal: AuthenticatedPrincipal = {
|
||||||
|
kind: 'human',
|
||||||
|
accountId: ACCOUNT_A,
|
||||||
|
principalId: 'cred-1',
|
||||||
|
amr: ['passkey'],
|
||||||
|
authAt: NOW - 10,
|
||||||
|
stepUpAt: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPair = { publicKey: CryptoKey; privateKey: CryptoKey }
|
||||||
|
async function genKeyPair(): Promise<KeyPair> {
|
||||||
|
// generateKey's named-algorithm overload is typed as CryptoKey; Ed25519 yields a pair at runtime.
|
||||||
|
return (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
function mint(signingKey: CryptoKey, now: number): Promise<string> {
|
||||||
|
return issueCapabilityToken(
|
||||||
|
{ principal, aud: AUD, host: 'host-1', rights: ['manage'], ttlSeconds: 60, cnfJkt: CNF_JKT },
|
||||||
|
signingKey,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('A3 real capability verifier (relay-auth verifyCapabilityToken)', () => {
|
||||||
|
let signingKey: CryptoKey
|
||||||
|
let verifier: CapabilityVerifier
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const pair = await genKeyPair()
|
||||||
|
signingKey = pair.privateKey
|
||||||
|
const rawPub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
|
||||||
|
await configureCapabilityVerifyKey(rawPub)
|
||||||
|
verifier = createCapabilityVerifier()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verifies a token signed by the configured key and returns the account principal', async () => {
|
||||||
|
const raw = await mint(signingKey, NOW)
|
||||||
|
const token = await verifier.verify(raw, AUD, NOW)
|
||||||
|
expect(token.sub).toBe(ACCOUNT_A)
|
||||||
|
expect(token.aud).toBe(AUD)
|
||||||
|
expect(token.rights).toContain('manage')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a token minted for a different aud (Host-confusion guard)', async () => {
|
||||||
|
const raw = await mint(signingKey, NOW)
|
||||||
|
await expect(verifier.verify(raw, 'evil.example.com', NOW)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an expired token', async () => {
|
||||||
|
const raw = await mint(signingKey, NOW) // exp = NOW + 60
|
||||||
|
await expect(verifier.verify(raw, AUD, NOW + 120)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a token signed by a NON-matching key (bad signature)', async () => {
|
||||||
|
const other = await genKeyPair()
|
||||||
|
const raw = await mint(other.privateKey, NOW)
|
||||||
|
await expect(verifier.verify(raw, AUD, NOW)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
deploy/.env.example
Normal file
43
deploy/.env.example
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# RELAY-PHASE1 env template. Copy to deploy/.env (gitignored) and fill. NEVER commit real secrets.
|
||||||
|
# Convert relative → absolute paths on the VPS. See docs/PLAN_RELAY_PHASE1.md §3.
|
||||||
|
|
||||||
|
# ── Docker Compose (deploy/docker-compose.yml) ────────────────────────────────────────────────
|
||||||
|
POSTGRES_DB=relay
|
||||||
|
POSTGRES_USER=relay
|
||||||
|
POSTGRES_PASSWORD= # REQUIRED — strong random
|
||||||
|
|
||||||
|
# ── Shared datastores (loopback; both processes) ─────────────────────────────────────────────
|
||||||
|
PG_URL=postgres://relay:CHANGEME@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# ── Deployment identity ──────────────────────────────────────────────────────────────────────
|
||||||
|
BASE_DOMAIN=term.example.com # your ICP-filed domain; browser hits <sub>.<BASE_DOMAIN>
|
||||||
|
RELAY_NODE_ID=relay-1
|
||||||
|
RELAY_TRUST_DOMAIN=relay.example.com # SPIFFE trust domain for agent certs
|
||||||
|
|
||||||
|
# ── P5 capability keypair — CONTROL-PLANE SIGNS, RELAY VERIFIES. Same public key on both. ────
|
||||||
|
# Generate an Ed25519 keypair; keep the PRIVATE key only where tokens are minted (never on disk here).
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64= # control-plane env: raw 32-byte Ed25519 pubkey, base64
|
||||||
|
RELAY_AUTH_VERIFY_PUBKEY= # relay env: SAME key, base64url
|
||||||
|
|
||||||
|
# ── Control-plane (P3) ───────────────────────────────────────────────────────────────────────
|
||||||
|
CP_BIND_HOST=127.0.0.1 # admin API stays loopback-only (front only via the relay)
|
||||||
|
CP_BIND_PORT=8080
|
||||||
|
CA_INTERMEDIATE_KMS_KEY_REF=dev-local # Phase 1: dev in-process signer accepts any ref (KMS → Phase 2)
|
||||||
|
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||||
|
# HEARTBEAT_TTL_SEC=15 PAIRING_TTL_SEC=600 PAIRING_MAX_REDEEM_ATTEMPTS=5 (defaults)
|
||||||
|
|
||||||
|
# ── Relay data-plane (P1, relay-run Phase 1) ─────────────────────────────────────────────────
|
||||||
|
BIND_HOST=0.0.0.0
|
||||||
|
BIND_PORT=443 # browser WSS (open in the Aliyun security group)
|
||||||
|
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem # Let's Encrypt for <sub>.<BASE_DOMAIN>
|
||||||
|
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
|
||||||
|
AGENT_BIND_PORT=8444 # agent mTLS (open in the security group)
|
||||||
|
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem # private enrollment CA — NOT Let's Encrypt
|
||||||
|
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||||
|
|
||||||
|
# ── Agent (P2) — runs on YOUR laptop, not the VPS. Shown here for reference. ──────────────────
|
||||||
|
# RELAY_URL=wss://<sub>.term.example.com:8444
|
||||||
|
# ENROLL_URL=https://<sub>.term.example.com/enroll (or the CP host)
|
||||||
|
# HOST_ID=... SUBDOMAIN=<sub> LOCAL_TARGET_URL=ws://127.0.0.1:3000 STATE_DIR=~/.web-terminal-agent
|
||||||
277
deploy/RUNBOOK.md
Normal file
277
deploy/RUNBOOK.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# RUNBOOK — deploy the rendezvous-relay to VPS `8.138.1.192` (Alibaba Cloud, mainland)
|
||||||
|
|
||||||
|
> **Staging, durable single-tenant** (PLAN_RELAY_PHASE1 §0). Real Postgres + Redis (Docker Compose on
|
||||||
|
> the VPS), real Let's Encrypt TLS on `:443` against an **already ICP-filed** domain, agent dials OUT
|
||||||
|
> from the operator's laptop. Dev in-process CA signer is accepted for staging (KMS → Phase 2).
|
||||||
|
>
|
||||||
|
> Everything below is a **staging template** — replace every `<placeholder>` and confirm each path on
|
||||||
|
> the box. Config is **env-only**; no host/port/secret is hardcoded in code or units.
|
||||||
|
|
||||||
|
Two independent trust chains — never cross-wire them:
|
||||||
|
|
||||||
|
| Chain | Issued by | Protects | Script |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Public web PKI** | Let's Encrypt | browser `:443` WSS (`TLS_CERT_PATH`) | `issue-tls-cert.sh` |
|
||||||
|
| **Private enrollment CA** | your offline root | agent mTLS + relay agent-server cert (`AGENT_CA_*`) | `gen-agent-ca.sh` |
|
||||||
|
|
||||||
|
And one shared key: the **P5 capability keypair** — control-plane **signs**, relay + admin API
|
||||||
|
**verify** (`gen-capability-key.sh`). `CAPABILITY_SIGN_PUBKEY_B64` (CP) and `RELAY_AUTH_VERIFY_PUBKEY`
|
||||||
|
(relay) are the **same public key**, two encodings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Prerequisites (on `8.138.1.192`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Engine + Compose plugin
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
|
||||||
|
# Node 20 (satisfies control-plane>=18, relay-run>=20, agent>=18) via NodeSource
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||||
|
sudo apt-get install -y nodejs git openssl
|
||||||
|
|
||||||
|
# Clone (this runbook assumes /opt/web-terminal; adjust the systemd --prefix if you change it)
|
||||||
|
sudo git clone <REPO_URL> /opt/web-terminal
|
||||||
|
cd /opt/web-terminal
|
||||||
|
npm ci # root deps
|
||||||
|
npm --prefix control-plane ci
|
||||||
|
npm --prefix relay-run ci
|
||||||
|
npm --prefix relay-web ci
|
||||||
|
|
||||||
|
# Dedicated non-root service user + secret dir
|
||||||
|
sudo useradd --system --home /opt/web-terminal relay || true
|
||||||
|
sudo mkdir -p /etc/relay
|
||||||
|
```
|
||||||
|
|
||||||
|
**DNS (do this first — LE HTTP-01 and the browser both need it):** create an A-record
|
||||||
|
`<SUBDOMAIN>.<BASE_DOMAIN>` → `8.138.1.192`. The domain must be **ICP-filed** (mainland Aliyun blocks
|
||||||
|
`:80/:443` on unfiled domains; if unfiled, you must use DNS-01 in step 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bring up Postgres + Redis (Docker Compose, loopback-only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/web-terminal
|
||||||
|
cp deploy/.env.example deploy/.env # gitignored; fill POSTGRES_PASSWORD (strong random)
|
||||||
|
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||||
|
docker compose -f deploy/docker-compose.yml ps # both healthy; bound to 127.0.0.1 only
|
||||||
|
```
|
||||||
|
|
||||||
|
Both services publish to `127.0.0.1` only — never the public interface (they are the relay's private
|
||||||
|
state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generate the CA, the capability key, and the TLS cert
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# (a) PRIVATE enrollment CA (agent mTLS) — NOT Let's Encrypt.
|
||||||
|
sudo AGENT_FACING_HOST="<SUBDOMAIN>.<BASE_DOMAIN>" \
|
||||||
|
AGENT_FACING_IP="8.138.1.192" \
|
||||||
|
RELAY_TRUST_DOMAIN="<RELAY_TRUST_DOMAIN>" \
|
||||||
|
bash deploy/scripts/gen-agent-ca.sh
|
||||||
|
# writes /etc/relay/ca/{root,intermediate,agent-ca.*,relay-agent-server.*}
|
||||||
|
|
||||||
|
# (b) P5 capability keypair — prints BOTH public-key encodings; private key -> 0600 file.
|
||||||
|
sudo bash deploy/scripts/gen-capability-key.sh
|
||||||
|
# -> note CAPABILITY_SIGN_PUBKEY_B64 (base64) and RELAY_AUTH_VERIFY_PUBKEY (base64url); same key.
|
||||||
|
|
||||||
|
# (c) PUBLIC TLS cert for the browser :443 (Let's Encrypt).
|
||||||
|
# http-01 needs inbound :80 open DURING issuance (step 4); dns-01 needs no inbound port.
|
||||||
|
sudo BASE_DOMAIN="<BASE_DOMAIN>" SUBDOMAIN="<SUBDOMAIN>" ACME_EMAIL="<you@example.com>" \
|
||||||
|
ACME_METHOD="http-01" \
|
||||||
|
TLS_CERT_PATH="/etc/relay/tls/fullchain.pem" TLS_KEY_PATH="/etc/relay/tls/privkey.pem" \
|
||||||
|
bash deploy/scripts/issue-tls-cert.sh
|
||||||
|
|
||||||
|
sudo chown -R relay:relay /etc/relay
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Staging CA seam (A-wave, not this task):** the control-plane's dev in-process signer must sign
|
||||||
|
> agent leaves with `/etc/relay/ca/intermediate.key.pem` so they chain to `agent-ca.bundle.pem`.
|
||||||
|
> `CA_INTERMEDIATE_KMS_KEY_REF=dev-local` selects the dev signer; point it at that key. Phase 2 = KMS.
|
||||||
|
> After first boot, move `/etc/relay/ca/root.key.pem` **off** the host (offline anchor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fill the two env files
|
||||||
|
|
||||||
|
Copy the relevant blocks of `deploy/.env.example` into two 0600 files. **The linchpin:**
|
||||||
|
`CAPABILITY_SIGN_PUBKEY_B64` (control-plane) and `RELAY_AUTH_VERIFY_PUBKEY` (relay) are the SAME key
|
||||||
|
from step 2b — paste both.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/control-plane.env
|
||||||
|
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/relay.env
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/relay/control-plane.env` (P3):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64=<base64 from step 2b>
|
||||||
|
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
|
||||||
|
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||||
|
BASE_DOMAIN=<BASE_DOMAIN>
|
||||||
|
CP_BIND_HOST=127.0.0.1
|
||||||
|
CP_BIND_PORT=8080
|
||||||
|
# HEARTBEAT_TTL_SEC / PAIRING_TTL_SEC / PAIRING_MAX_REDEEM_ATTEMPTS use defaults if unset
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/relay/relay.env` (P1 data-plane):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
BASE_DOMAIN=<BASE_DOMAIN>
|
||||||
|
BIND_HOST=0.0.0.0
|
||||||
|
BIND_PORT=443
|
||||||
|
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem
|
||||||
|
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
|
||||||
|
AGENT_BIND_PORT=8444
|
||||||
|
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem
|
||||||
|
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||||
|
RELAY_NODE_ID=relay-1
|
||||||
|
RELAY_AUTH_VERIFY_PUBKEY=<base64url from step 2b — SAME key as CAPABILITY_SIGN_PUBKEY_B64>
|
||||||
|
RELAY_TRUST_DOMAIN=<RELAY_TRUST_DOMAIN>
|
||||||
|
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Open the Aliyun security group (inbound)
|
||||||
|
|
||||||
|
Open **only** these, in the ECS instance's security group:
|
||||||
|
|
||||||
|
| Port | Who | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `443/tcp` | browsers (WSS) | permanent |
|
||||||
|
| `8444/tcp` (`AGENT_BIND_PORT`) | agents (mTLS) | permanent |
|
||||||
|
| `80/tcp` | Let's Encrypt HTTP-01 | **temporary** — only during cert issue/renew; skip entirely if using DNS-01 |
|
||||||
|
|
||||||
|
**Keep loopback-only (never open):** Postgres `5432`, Redis `6379`, control-plane admin
|
||||||
|
`8080`. Reach the admin API from your laptop via SSH tunnel: `ssh -L 8080:127.0.0.1:8080 root@8.138.1.192`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Build the browser bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/web-terminal
|
||||||
|
npm --prefix relay-web run build # emits relay-web/public/build/, served same-origin by D1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Enable + start both units
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/systemd/relay-control-plane.service deploy/systemd/relay-data-plane.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now relay-control-plane.service # migrates PG, listens on 127.0.0.1:8080
|
||||||
|
sudo systemctl enable --now relay-data-plane.service # :443 browser WSS + :8444 agent mTLS
|
||||||
|
sudo systemctl status relay-control-plane.service relay-data-plane.service
|
||||||
|
journalctl -u relay-data-plane.service -f # watch for bind + verify-key load
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Create an account + pairing code (control-plane admin)
|
||||||
|
|
||||||
|
The admin API is **deny-by-default**: `POST /accounts` and `POST /accounts/:id/pairing-codes` require a
|
||||||
|
**capability token** with the `manage` right whose **`aud` equals `BASE_DOMAIN`** (control-plane
|
||||||
|
`authz.ts` / `main.ts` `expectedAud: env.baseDomain`). `accountId` is taken ONLY from the verified
|
||||||
|
token (INV3) — never from the body.
|
||||||
|
|
||||||
|
Mint the token with the **capability PRIVATE key** from step 2b (the B6 token-mint owns the signing
|
||||||
|
helper; it imports `/etc/relay/capability/capability-sign.key.pem`). Token claims:
|
||||||
|
`rights=['manage']`, `aud=<BASE_DOMAIN>`. Send it as `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
Bootstrap has a two-token nuance (grounded in `provision.ts`):
|
||||||
|
|
||||||
|
1. `POST /accounts` checks `manage` only (no account-ownership check) → mint a `manage` token, create
|
||||||
|
the account, read back its `id`.
|
||||||
|
2. `POST /accounts/:id/pairing-codes` additionally enforces `token.sub == :id` (own-account). Mint a
|
||||||
|
second `manage` token with `sub=<the new accountId>`, then request the pairing code.
|
||||||
|
|
||||||
|
Over the SSH tunnel from step 4:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) create account
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/accounts \
|
||||||
|
-H "Authorization: Bearer <MANAGE_TOKEN aud=BASE_DOMAIN>" \
|
||||||
|
-H 'content-type: application/json' -d '{"plan":"personal"}'
|
||||||
|
# -> { "id": "<ACCOUNT_ID>", ... }
|
||||||
|
|
||||||
|
# 2) pairing code (token.sub must equal <ACCOUNT_ID>)
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/accounts/<ACCOUNT_ID>/pairing-codes \
|
||||||
|
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||||
|
# -> { "code": "<PAIRING_CODE>", ... } (single-use, TTL 600s default)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. On the LAPTOP — build the agent, pair, run
|
||||||
|
|
||||||
|
The agent dials OUT; nothing inbound is opened on the laptop.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo>/agent
|
||||||
|
npm ci && npm run build # emits dist/cli.js (C1)
|
||||||
|
export RELAY_URL="wss://<SUBDOMAIN>.<BASE_DOMAIN>:8444" # AGENT_BIND_PORT
|
||||||
|
export ENROLL_URL="https://<SUBDOMAIN>.<BASE_DOMAIN>/enroll"
|
||||||
|
export HOST_ID="<pick-a-host-id>"
|
||||||
|
export SUBDOMAIN="<SUBDOMAIN>"
|
||||||
|
export LOCAL_TARGET_URL="ws://127.0.0.1:3000" # the base web-terminal app
|
||||||
|
export STATE_DIR="$HOME/.web-terminal-agent"
|
||||||
|
|
||||||
|
node dist/cli.js pair <PAIRING_CODE> # redeems the code -> SPIFFE mTLS cert + hostContentSecret
|
||||||
|
node dist/cli.js run # dials the relay, holds the mux tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
`pair` redeems the single-use code and stores the enrolled SPIFFE cert under `STATE_DIR`; `run` opens
|
||||||
|
the outbound mTLS tunnel and keeps it alive (heartbeat/backoff).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Browser — log in and click through to the shell
|
||||||
|
|
||||||
|
1. Open `https://<SUBDOMAIN>.<BASE_DOMAIN>` (served same-origin as the WSS endpoint).
|
||||||
|
2. Operator login (B6) mints a short-lived connect capability token.
|
||||||
|
3. Pick the host → the relay splices browser ↔ agent. The relay only sees **ciphertext** (INV2); the
|
||||||
|
E2E is browser ↔ agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verify restart-safety + revocation teardown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart-safety (INV7): bounce the relay while a shell is open — the PTY and host registration survive.
|
||||||
|
sudo systemctl restart relay-data-plane.service
|
||||||
|
# the browser auto-reconnects; the session is still there (state is in PG/Redis, not the process).
|
||||||
|
|
||||||
|
# Revocation teardown (INV12): revoke the host and watch the tunnel drop within budget.
|
||||||
|
curl -sS -X DELETE http://127.0.0.1:8080/hosts/<HOST_ID> \
|
||||||
|
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||||
|
# -> control-plane publishes a KillSignal on Redis relay:revocations; the data-plane subscriber
|
||||||
|
# closes the spliced stream (browser WS closes 4403). Measure revoke -> close latency.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Meaning | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Browser WS closes **1013** | `WS_TRY_LATER` — **no agent tunnel** for that host (agent offline / not dialed) | Start the agent (`node dist/cli.js run`); check `RELAY_URL` host+port; confirm `:8444` open; watch `journalctl -u relay-data-plane`. |
|
||||||
|
| Browser WS closes **401** | **bad Origin** (CSWSH exact-match failed) — the `Origin` header is not in the relay's `allowedOrigins` | Open via `https://<SUBDOMAIN>.<BASE_DOMAIN>` (not the raw IP, not `http:`, no stray port). `allowedOrigins` is the port-less `https://<sub>.<BASE_DOMAIN>` on `:443`; must match EXACTLY. Verify `BASE_DOMAIN` in `relay.env`. |
|
||||||
|
| Agent mTLS closes **4401** | cert chains to the CA but the **pubkey is not enrolled** in the host registry (INV14 registry-gated) | The host was never enrolled or was revoked. Re-run `pair <CODE>` with a fresh code; confirm the CP signed the leaf with `/etc/relay/ca/intermediate.key.pem` (chains to `agent-ca.bundle.pem`). |
|
||||||
|
| Browser WS closes **4403** | `WS_REVOKED` — the host/session was revoked (expected after step 10) | Re-enroll the host to reconnect. |
|
||||||
|
| CP fails to boot: "CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes" | wrong key encoding in `control-plane.env` | Use the **base64** value (not base64url) from step 2b for the CP; base64url is the relay's `RELAY_AUTH_VERIFY_PUBKEY`. |
|
||||||
|
| Admin API returns **401** on `POST /accounts` | missing/expired/foreign-`aud` capability token | Mint a `manage` token with `aud=<BASE_DOMAIN>`, signed by the step-2b private key; send `Authorization: Bearer`. |
|
||||||
|
| Admin API returns **403** on `/pairing-codes` | token lacks `manage` right, or `token.sub != :id` | Mint with `rights:['manage']` and `sub=<ACCOUNT_ID>` (own-account rule). |
|
||||||
|
| LE issuance fails (timeout/connection) | HTTP-01 `:80` not reachable, or domain not ICP-filed | Open `:80` in the security group for issuance, or switch `ACME_METHOD=dns-01`; confirm the A-record + ICP filing. |
|
||||||
|
| `docker compose ps` unhealthy | PG/Redis not up | `docker compose -f deploy/docker-compose.yml logs`; confirm `POSTGRES_PASSWORD` set in `deploy/.env`. |
|
||||||
|
| Mixed-content / `ws:` blocked in browser | page is HTTPS but WS scheme resolved to `ws:` | Serve over HTTPS; the client follows page scheme (`wss:` on HTTPS, M6). Confirm the LE cert is valid for the FQDN. |
|
||||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# RELAY-PHASE1 · E1 — Postgres + Redis for the rendezvous-relay control-plane (P3) on the VPS.
|
||||||
|
#
|
||||||
|
# Both services bind to 127.0.0.1 ONLY — they are the relay's private state, never exposed on the
|
||||||
|
# public interface (INV9-adjacent; open only :443 + AGENT_PORT in the cloud security group). The
|
||||||
|
# control-plane / relay processes reach them over loopback.
|
||||||
|
#
|
||||||
|
# Usage on 8.138.1.192:
|
||||||
|
# cp deploy/.env.example deploy/.env # then fill secrets
|
||||||
|
# docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||||
|
#
|
||||||
|
# PG_URL for the app = postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}
|
||||||
|
# REDIS_URL for the app = redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-relay}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-relay}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432" # loopback-only
|
||||||
|
volumes:
|
||||||
|
- relay_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-relay} -d ${POSTGRES_DB:-relay}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379" # loopback-only
|
||||||
|
volumes:
|
||||||
|
- relay_redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
relay_pgdata:
|
||||||
|
relay_redisdata:
|
||||||
73
deploy/frp/README.md
Normal file
73
deploy/frp/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Native mTLS Tunnel — per-host runbook (V7)
|
||||||
|
|
||||||
|
Bring one local host onto the tunnel so native clients (iOS / Android / desktop) can reach its base
|
||||||
|
app at `https://<name>.terminal.yaojia.wang` over the VPS, gated **only** by device-certificate mTLS.
|
||||||
|
See `docs/PLAN_NATIVE_TUNNEL.md` for the full design; this is the short operational checklist.
|
||||||
|
|
||||||
|
**Trust model: A — single-owner fleet** (all devices/hosts are the operator's). A shared device-CA
|
||||||
|
means "the closed set of my devices"; there is **no cross-tenant isolation** — do not enroll
|
||||||
|
distrusting third parties on this CA (that is Model B, out of scope for v1).
|
||||||
|
|
||||||
|
## Files here
|
||||||
|
|
||||||
|
| File | Install target on the VPS | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `frps.toml.example` | `/etc/relay/frp/frps.toml` (0600) | frps control `:7000` + vhost `:7080` |
|
||||||
|
| `frpc.example.toml` | on each LOCAL host (0600) | dials the VPS, exposes `127.0.0.1:3000` |
|
||||||
|
| `../nginx/frp-mtls.conf` | `/etc/nginx/conf.d/frp-mtls.conf` | `:8470` TLS-term + device-CA mTLS + WS proxy |
|
||||||
|
| `../nginx/stream-sni-additions.md` | (merge into the live stream map) | the two additive SNI routes |
|
||||||
|
|
||||||
|
## One-time VPS setup (summarised — see PLAN V1–V5)
|
||||||
|
|
||||||
|
1. **frps** (`frps.toml.example` → `/etc/relay/frp/frps.toml`): set `auth.token` = `openssl rand -hex 32`.
|
||||||
|
2. **frp-client CA** (control-channel mTLS): `bash deploy/scripts/gen-device-ca.sh --kind frp-client`.
|
||||||
|
3. **device CA** (data-path mTLS): `bash deploy/scripts/gen-device-ca.sh` (KIND=device default).
|
||||||
|
4. **LE wildcard** `*.terminal.yaojia.wang` (V3) → `/etc/relay/frp-tls/{fullchain,privkey}.pem`.
|
||||||
|
5. **nginx** `:8470` (`frp-mtls.conf`) + the two stream SNI lines (`stream-sni-additions.md`), then
|
||||||
|
`nginx -t && systemctl reload nginx`.
|
||||||
|
|
||||||
|
## Onboard a host (the repeatable part)
|
||||||
|
|
||||||
|
1. **frp-client cert** for this host (control mTLS), issued from the frp-client CA:
|
||||||
|
```bash
|
||||||
|
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <name> ./out-<name>
|
||||||
|
# use out-<name>/<name>.cert.pem + <name>.key.pem for frpc.certFile/keyFile (ignore the .p12)
|
||||||
|
```
|
||||||
|
2. **frpc.toml** on the host from `frpc.example.toml`: set `subdomain=<name>`, the shared
|
||||||
|
`auth.token`, and the frp-client cert/key paths; `chmod 600 frpc.toml`.
|
||||||
|
3. **Base app** on the host — the tunnel is safe ONLY with these:
|
||||||
|
| Var | Value | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default `0.0.0.0` exposes an unauth'd shell on the LAN, bypassing mTLS. |
|
||||||
|
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`. |
|
||||||
|
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | bare host; `:443` also matches (normalized). |
|
||||||
|
| `IDLE_TTL` | `≥ 86400` | walk-away survival. |
|
||||||
|
| `USE_TMUX` | `1` (*nix) / `0` (Windows) | cross-restart PTY survival. |
|
||||||
|
4. **Device cert** for whoever will connect (data-path mTLS), issued from the device CA:
|
||||||
|
```bash
|
||||||
|
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
|
||||||
|
# deliver out-dev/<...>.p12 SECURELY: scp / AirDrop / Files — NEVER email.
|
||||||
|
```
|
||||||
|
5. **Verify** (M1): no-cert → TLS handshake failure; `curl --cert …/<dev>.cert.pem --key …/<dev>.key.pem
|
||||||
|
-sI https://<name>.terminal.yaojia.wang/live-sessions` → `200`.
|
||||||
|
|
||||||
|
## Revoke a device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/to/<dev>.cert.pem
|
||||||
|
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS:
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The revoked cert is rejected fleet-wide the moment nginx reloads the CRL.
|
||||||
|
|
||||||
|
## Trust-boundary note (Model A)
|
||||||
|
|
||||||
|
- The **frp-client cert + token is a trusted secret**: leaking a host's frp-client cert **and** the
|
||||||
|
shared token lets an attacker register a subdomain (subdomain-takeover / MITM). Keep both `chmod 600`;
|
||||||
|
deliver over scp, never email. Revoke a compromised host by rotating the `frp-client-ca` trust
|
||||||
|
(re-issue the remaining hosts) — device certs are unaffected.
|
||||||
|
- The **device cert** is the data-path gate. Under Model A any valid device cert can reach any
|
||||||
|
subdomain and can read `/live-sessions/:id/preview` scrollback — acceptable because every device is
|
||||||
|
yours. Do not extend this CA to third parties without moving to Model B (per-tenant CA).
|
||||||
|
- **P-256, never Ed25519** for both CAs (nginx `ssl_verify_client` + iOS client-cert compatibility).
|
||||||
31
deploy/frp/frpc.example.toml
Normal file
31
deploy/frp/frpc.example.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# frpc.toml — per-host client (Native mTLS tunnel · V7 / M1 / S1).
|
||||||
|
#
|
||||||
|
# Copy to the LOCAL host that runs the base app, fill the <PLACEHOLDERS>, chmod 600.
|
||||||
|
# TEMPLATE ONLY — NO REAL SECRETS committed. frpc dials OUT to the VPS :443; nothing is opened
|
||||||
|
# inbound on the local host.
|
||||||
|
#
|
||||||
|
# frps ≥ v0.52 required (`disableCustomTLSFirstByte` exists ≥ v0.50) — it makes frpc send a real TLS
|
||||||
|
# ClientHello first byte (0x16) so the VPS nginx `ssl_preread` stream can SNI-route it (PLAN M1 #6).
|
||||||
|
|
||||||
|
serverAddr = "8.138.1.192"
|
||||||
|
serverPort = 443
|
||||||
|
|
||||||
|
auth.method = "token"
|
||||||
|
auth.token = "<FRP_TOKEN>" # same token as frps.toml (/etc/relay/secrets.env)
|
||||||
|
|
||||||
|
# --- control-channel TLS: present THIS host's frp-client cert to frps; verify the frps server cert ---
|
||||||
|
transport.tls.enable = true
|
||||||
|
transport.tls.serverName = "frp.terminal.yaojia.wang" # SNI the stream routes to frps :7000
|
||||||
|
transport.tls.disableCustomTLSFirstByte = true # CRITICAL for ssl_preread (real ClientHello first)
|
||||||
|
transport.tls.certFile = "<host-frp-client.cert.pem>" # issued by gen-device-ca.sh --kind frp-client + issue-device-cert.sh
|
||||||
|
transport.tls.keyFile = "<host-frp-client.key.pem>" # (0600)
|
||||||
|
transport.tls.trustedCaFile = "<frps-ctrl-ca.cert.pem>" # CA that signed frps-ctrl.cert.pem (V1)
|
||||||
|
|
||||||
|
loginFailExit = false # keep retrying if the VPS/frps is briefly down
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "<name>" # unique per host; frps rejects duplicate subdomains
|
||||||
|
type = "http"
|
||||||
|
localIP = "127.0.0.1" # base app must bind loopback (BIND_HOST=127.0.0.1)
|
||||||
|
localPort = 3000
|
||||||
|
subdomain = "<name>" # reachable at https://<name>.terminal.yaojia.wang
|
||||||
29
deploy/frp/frps.toml.example
Normal file
29
deploy/frp/frps.toml.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# frps.toml — VPS control server (Native mTLS tunnel · V1).
|
||||||
|
#
|
||||||
|
# Install target on the VPS: /etc/relay/frp/frps.toml (chmod 600, owned frp:frp).
|
||||||
|
# This is a TEMPLATE with PLACEHOLDERS ONLY — NO REAL SECRETS are committed.
|
||||||
|
#
|
||||||
|
# auth.token: replace <FRP_TOKEN> with `openssl rand -hex 32`, stored in /etc/relay/secrets.env
|
||||||
|
# (0600). The token is NOT the sole gate — control-channel mTLS below is required too,
|
||||||
|
# so a leaked token alone cannot register a subdomain (PLAN R3 / V1b, Model A).
|
||||||
|
#
|
||||||
|
# Both ports are LOOPBACK-only (nginx stream terminates/routes :443 → these; they must stay absent
|
||||||
|
# from the Aliyun security group — PLAN P-B).
|
||||||
|
|
||||||
|
bindAddr = "127.0.0.1"
|
||||||
|
bindPort = 7000 # control channel (SNI frp.terminal.yaojia.wang → here, TLS passthrough)
|
||||||
|
vhostHTTPPort = 7080 # vhost HTTP (nginx :8470 proxies verified requests here by Host)
|
||||||
|
|
||||||
|
auth.method = "token"
|
||||||
|
auth.token = "<FRP_TOKEN>"
|
||||||
|
|
||||||
|
subDomainHost = "terminal.yaojia.wang"
|
||||||
|
|
||||||
|
# --- control-channel mTLS: token is NOT the sole gate (per-host frp-client CA) ---
|
||||||
|
transport.tls.force = true
|
||||||
|
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem" # frps control SERVER cert (V1)
|
||||||
|
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem" # (0600)
|
||||||
|
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem" # verifies each host's frpc cert
|
||||||
|
|
||||||
|
log.to = "/var/log/frp/frps.log"
|
||||||
|
log.level = "info"
|
||||||
44
deploy/nginx/frp-mtls.conf
Normal file
44
deploy/nginx/frp-mtls.conf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# frp-mtls.conf — nginx :8470 TLS-termination + device-CA mTLS + WS-upgrade proxy to frps vhost.
|
||||||
|
#
|
||||||
|
# Native mTLS tunnel · V4. Install target: /etc/nginx/conf.d/frp-mtls.conf
|
||||||
|
# This is a conf.d snippet (safe to ADD — it only introduces a NEW loopback :8470 server + one map;
|
||||||
|
# it does NOT touch the existing stream :443 map, which is edited separately per stream-sni-additions.md).
|
||||||
|
#
|
||||||
|
# Data-path security gate: `ssl_verify_client on` against the DEVICE CA (with CRL) is the ONE gate for
|
||||||
|
# the tunnelled base app. `on`, NEVER `optional` (PLAN P-D / R2). The public :8470 SERVER cert is the
|
||||||
|
# LE wildcard (V3); the CLIENT trust anchor is the private device CA (V2) — two different chains.
|
||||||
|
#
|
||||||
|
# nginx 1.24: there is NO standalone `http2` directive here and NO `http2 off;` — HTTP/2 is off by
|
||||||
|
# default on this listen and `http2 off;` is INVALID on 1.24 (would fail `nginx -t`, PLAN R7).
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 127.0.0.1:8470 ssl;
|
||||||
|
server_name ~^.+\.terminal\.yaojia\.wang$;
|
||||||
|
|
||||||
|
ssl_certificate /etc/relay/frp-tls/fullchain.pem; # LE wildcard *.terminal.yaojia.wang (V3)
|
||||||
|
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
|
||||||
|
|
||||||
|
# --- the data-path security gate ---
|
||||||
|
ssl_verify_client on; # ON, never `optional`
|
||||||
|
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
|
||||||
|
ssl_verify_depth 1;
|
||||||
|
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2)
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Origin https://$host; # needed for Android/curl; harmless for iOS
|
||||||
|
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s, R6)
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
deploy/nginx/stream-sni-additions.md
Normal file
45
deploy/nginx/stream-sni-additions.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Stream SNI additions (Native mTLS tunnel · V5)
|
||||||
|
|
||||||
|
Two SNI routes must be **merged into the existing** `stream { map $ssl_preread_server_name … }`
|
||||||
|
block on the VPS (`/etc/nginx/stream-relay.conf`). They add the frp control channel and the
|
||||||
|
`*.terminal` data path to the single shared `:443` `ssl_preread` listener.
|
||||||
|
|
||||||
|
> **This is a MERGE, not a file to ship.** Do **not** drop a full stream conf here — that would
|
||||||
|
> clobber the live one. Only the two `map` body lines below are new.
|
||||||
|
|
||||||
|
## The two additive lines
|
||||||
|
|
||||||
|
Add these **inside the existing `map` body** (exact match wins over wildcard, so order is not
|
||||||
|
load-bearing, but keep the exact `frp.terminal` line above the `*.terminal` wildcard for clarity):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
frp.terminal.yaojia.wang 127.0.0.1:7000; # frps control channel (TLS passthrough)
|
||||||
|
*.terminal.yaojia.wang 127.0.0.1:8470; # nginx :8470 TLS-term + device-CA mTLS (frp-mtls.conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coexistence warning (do NOT retype the existing lines)
|
||||||
|
|
||||||
|
The existing `map` body already contains — and MUST be preserved **verbatim**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# ... existing hostnames directive ...
|
||||||
|
*.term.yaojia.wang 127.0.0.1:8443; # E2E browser relay — DO NOT RETYPE / REORDER
|
||||||
|
default 127.0.0.1:10443; # xray Reality — DO NOT RETYPE / REORDER
|
||||||
|
```
|
||||||
|
|
||||||
|
- `*.term.yaojia.wang` (browser relay) and `*.terminal.yaojia.wang` (this tunnel) are **different
|
||||||
|
parent labels** — `term` vs `terminal`. `ssl_preread` matches the full SNI, so they never collide.
|
||||||
|
- Re-type nothing you did not author. Capture the current body first
|
||||||
|
(`nginx -T | sed -n '/map \$ssl_preread_server_name/,/}/p'`), then append only the two lines above.
|
||||||
|
|
||||||
|
## Deploy gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)} # snapshot first
|
||||||
|
# ...edit: append the two lines into the map body...
|
||||||
|
nginx -t && systemctl reload nginx # NEVER reload on a failed -t
|
||||||
|
```
|
||||||
|
|
||||||
|
After reload, run the 4-zone SNI oracle (`openssl s_client -servername …` for
|
||||||
|
`frp.terminal` / `<name>.terminal` / `<name>.term` / default) to confirm all four routes still
|
||||||
|
resolve to the right backend (PLAN V5 / M1 #7).
|
||||||
139
deploy/scripts/bootstrap-staging.sh
Executable file
139
deploy/scripts/bootstrap-staging.sh
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · staging bootstrap (mode B — self-signed TLS, no Let's Encrypt yet).
|
||||||
|
#
|
||||||
|
# Idempotent one-shot that prepares EVERYTHING the two services need to boot, EXCEPT starting them:
|
||||||
|
# 1. strong random POSTGRES_PASSWORD + OPERATOR_PASSWORD (persisted 0600, reused on re-run)
|
||||||
|
# 2. P5 capability keypair (deploy/scripts/gen-capability-key.sh)
|
||||||
|
# 3. private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh)
|
||||||
|
# 4. self-signed browser :443 cert (mode B stand-in for issue-tls-cert.sh)
|
||||||
|
# 5. /etc/relay/control-plane.env + /etc/relay/relay.env — the REAL env contract read by the code
|
||||||
|
# (control-plane/src/env.ts + relay-run/src/main-phase1.ts), which is a SUPERSET of .env.example:
|
||||||
|
# · relay needs AGENT_SERVER_CERT_PATH / AGENT_SERVER_KEY_PATH / ALLOWED_ORIGINS (template omits)
|
||||||
|
# · relay hosts the STAGING /auth/mint, so it needs CAPABILITY_SIGN_PRIVKEY (base64 PKCS#8 DER)
|
||||||
|
# + MINT_RATE_SALT. In Phase 1 the relay IS a token minter (staging shortcut); Phase 2 = WebAuthn.
|
||||||
|
# 6. deploy/.env for docker-compose, then `docker compose up -d` Postgres + Redis (loopback-only).
|
||||||
|
#
|
||||||
|
# Secrets are generated in place (0600); NOTHING secret is printed except OPERATOR_PASSWORD (once).
|
||||||
|
# Private keys are NEVER printed. Config via ENV (staging defaults for THIS deploy):
|
||||||
|
# BASE_DOMAIN (yaojia.wang) SUBDOMAIN (kai) SERVER_IP (8.138.1.192)
|
||||||
|
# RELAY_TRUST_DOMAIN (relay.yaojia.wang) RELAY_NODE_ID (relay-1) REPO (repo root)
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/bootstrap-staging.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DOMAIN="${BASE_DOMAIN:-yaojia.wang}"
|
||||||
|
SUBDOMAIN="${SUBDOMAIN:-kai}"
|
||||||
|
SERVER_IP="${SERVER_IP:-8.138.1.192}"
|
||||||
|
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN:-relay.${BASE_DOMAIN}}"
|
||||||
|
RELAY_NODE_ID="${RELAY_NODE_ID:-relay-1}"
|
||||||
|
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
|
||||||
|
|
||||||
|
# Repo root = two levels up from this script, unless overridden.
|
||||||
|
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
REPO="${REPO:-$SELF}"
|
||||||
|
|
||||||
|
ETC=/etc/relay
|
||||||
|
CAP_DIR="${ETC}/capability"
|
||||||
|
CA_DIR="${ETC}/ca"
|
||||||
|
TLS_DIR="${ETC}/tls"
|
||||||
|
SECRETS="${ETC}/secrets.env"
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not on PATH" >&2; exit 3; }
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker not on PATH" >&2; exit 3; }
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "${ETC}" "${TLS_DIR}"
|
||||||
|
|
||||||
|
echo "== [1/6] passwords (persisted, reused on re-run) =="
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
[[ -f "${SECRETS}" ]] && source "${SECRETS}"
|
||||||
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32)}"
|
||||||
|
OPERATOR_PASSWORD="${OPERATOR_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)}"
|
||||||
|
MINT_RATE_SALT="${MINT_RATE_SALT:-$(openssl rand -hex 16)}"
|
||||||
|
{
|
||||||
|
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
echo "OPERATOR_PASSWORD=${OPERATOR_PASSWORD}"
|
||||||
|
echo "MINT_RATE_SALT=${MINT_RATE_SALT}"
|
||||||
|
} > "${SECRETS}"
|
||||||
|
chmod 600 "${SECRETS}"
|
||||||
|
|
||||||
|
echo "== [2/6] P5 capability keypair =="
|
||||||
|
KEY_DIR="${CAP_DIR}" bash "${REPO}/deploy/scripts/gen-capability-key.sh" > "${ETC}/capability-summary.txt" 2>&1 \
|
||||||
|
|| { echo "FATAL: gen-capability-key.sh failed:" >&2; cat "${ETC}/capability-summary.txt" >&2; exit 1; }
|
||||||
|
CAP_B64="$(grep -oE 'CAPABILITY_SIGN_PUBKEY_B64=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
|
||||||
|
CAP_B64URL="$(grep -oE 'RELAY_AUTH_VERIFY_PUBKEY=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
|
||||||
|
[[ -n "${CAP_B64}" && -n "${CAP_B64URL}" ]] || { echo "FATAL: could not parse capability pubkeys" >&2; exit 1; }
|
||||||
|
# The relay ALSO mints operator tokens (staging /auth/mint) → it needs the private key as base64 PKCS#8 DER.
|
||||||
|
CAP_PRIV_B64="$(openssl pkey -in "${CAP_DIR}/capability-sign.key.pem" -outform DER | base64 | tr -d '\n')"
|
||||||
|
|
||||||
|
echo "== [3/6] private agent-enrollment CA (NOT Let's Encrypt) =="
|
||||||
|
CA_DIR="${CA_DIR}" AGENT_FACING_HOST="${FQDN}" AGENT_FACING_IP="${SERVER_IP}" \
|
||||||
|
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN}" \
|
||||||
|
bash "${REPO}/deploy/scripts/gen-agent-ca.sh" > "${ETC}/ca-summary.txt" 2>&1 \
|
||||||
|
|| { echo "FATAL: gen-agent-ca.sh failed:" >&2; cat "${ETC}/ca-summary.txt" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] self-signed browser :443 cert for ${FQDN} (mode B) =="
|
||||||
|
if [[ ! -f "${TLS_DIR}/privkey.pem" ]]; then
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
|
||||||
|
-keyout "${TLS_DIR}/privkey.pem" -out "${TLS_DIR}/fullchain.pem" \
|
||||||
|
-subj "/CN=${FQDN}" -addext "subjectAltName=DNS:${FQDN},IP:${SERVER_IP}"
|
||||||
|
fi
|
||||||
|
chmod 600 "${TLS_DIR}/privkey.pem"; chmod 644 "${TLS_DIR}/fullchain.pem"
|
||||||
|
|
||||||
|
echo "== [5/6] env files (REAL contract from env.ts + main-phase1.ts) =="
|
||||||
|
cat > "${ETC}/control-plane.env" <<EOF
|
||||||
|
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64=${CAP_B64}
|
||||||
|
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
|
||||||
|
CA_INTERMEDIATE_CERT_PATH=${CA_DIR}/intermediate.cert.pem
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH=${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
BASE_DOMAIN=${BASE_DOMAIN}
|
||||||
|
CP_BIND_HOST=127.0.0.1
|
||||||
|
CP_BIND_PORT=8080
|
||||||
|
EOF
|
||||||
|
chmod 600 "${ETC}/control-plane.env"
|
||||||
|
|
||||||
|
cat > "${ETC}/relay.env" <<EOF
|
||||||
|
BIND_HOST=0.0.0.0
|
||||||
|
BIND_PORT=443
|
||||||
|
AGENT_BIND_PORT=8444
|
||||||
|
TLS_CERT_PATH=${TLS_DIR}/fullchain.pem
|
||||||
|
TLS_KEY_PATH=${TLS_DIR}/privkey.pem
|
||||||
|
AGENT_SERVER_CERT_PATH=${CA_DIR}/relay-agent-server.cert.pem
|
||||||
|
AGENT_SERVER_KEY_PATH=${CA_DIR}/relay-agent-server.key.pem
|
||||||
|
AGENT_CA_CERT_PATH=${CA_DIR}/agent-ca.cert.pem
|
||||||
|
AGENT_CA_CHAIN_PATH=${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
BASE_DOMAIN=${BASE_DOMAIN}
|
||||||
|
RELAY_NODE_ID=${RELAY_NODE_ID}
|
||||||
|
RELAY_TRUST_DOMAIN=${RELAY_TRUST_DOMAIN}
|
||||||
|
ALLOWED_ORIGINS=https://${FQDN}
|
||||||
|
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
RELAY_AUTH_VERIFY_PUBKEY=${CAP_B64URL}
|
||||||
|
OPERATOR_PASSWORD=${OPERATOR_PASSWORD}
|
||||||
|
CAPABILITY_SIGN_PRIVKEY=${CAP_PRIV_B64}
|
||||||
|
MINT_RATE_SALT=${MINT_RATE_SALT}
|
||||||
|
EOF
|
||||||
|
chmod 600 "${ETC}/relay.env"
|
||||||
|
|
||||||
|
echo "== [6/6] docker compose up -d (Postgres + Redis, loopback-only) =="
|
||||||
|
cat > "${REPO}/deploy/.env" <<EOF
|
||||||
|
POSTGRES_DB=relay
|
||||||
|
POSTGRES_USER=relay
|
||||||
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
EOF
|
||||||
|
chmod 600 "${REPO}/deploy/.env"
|
||||||
|
docker compose --env-file "${REPO}/deploy/.env" -f "${REPO}/deploy/docker-compose.yml" up -d
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
=== bootstrap OK ===
|
||||||
|
FQDN : ${FQDN}
|
||||||
|
capability pubkey : b64 ${#CAP_B64} chars / b64url ${#CAP_B64URL} chars (same 32 bytes)
|
||||||
|
env files (0600) : ${ETC}/control-plane.env ${ETC}/relay.env
|
||||||
|
OPERATOR_PASSWORD : ${OPERATOR_PASSWORD} <-- browser login; shown once, also in ${SECRETS} (0600)
|
||||||
|
|
||||||
|
Next: start control-plane + relay, then mint a manage token to create an account + pairing code.
|
||||||
|
SUMMARY
|
||||||
145
deploy/scripts/gen-agent-ca.sh
Executable file
145
deploy/scripts/gen-agent-ca.sh
Executable file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · E (TASK E) — Private ENROLLMENT CA for agent mTLS.
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. This mints the PRIVATE trust chain the rendezvous-relay uses to (a) verify agent
|
||||||
|
# client certs (SPIFFE mTLS, INV14) and (b) present the relay's agent-facing SERVER cert on
|
||||||
|
# AGENT_BIND_PORT so the dialing agent can authenticate the relay.
|
||||||
|
#
|
||||||
|
# *** THIS IS NOT LET'S ENCRYPT. *** The browser-facing :443 cert is public-web PKI and is issued
|
||||||
|
# by deploy/scripts/issue-tls-cert.sh. This CA is a SEPARATE, private trust root that must NEVER be
|
||||||
|
# a public CA — mixing them would let any web-PKI leaf enroll as an agent.
|
||||||
|
#
|
||||||
|
# Hierarchy produced (Ed25519, to match the control-plane dev signer's algorithm):
|
||||||
|
# root (self-signed, offline anchor)
|
||||||
|
# └── intermediate (signs agent SPIFFE leaves at CP /enroll, AND the relay agent-server cert)
|
||||||
|
# ├── relay agent-server leaf (TLS serverAuth, CN/SAN = the agent-facing host)
|
||||||
|
# └── (agent leaves are issued at runtime by the control-plane, not here)
|
||||||
|
#
|
||||||
|
# Outputs (0600 keys, 0644 certs) under CA_DIR (default /etc/relay/ca):
|
||||||
|
# root.key.pem root.cert.pem
|
||||||
|
# intermediate.key.pem intermediate.cert.pem <- CA_INTERMEDIATE_CERT_PATH (+ its key = the CP signer)
|
||||||
|
# agent-ca.bundle.pem = intermediate + root <- AGENT_CA_CHAIN_PATH / NODE_MTLS_TRUST_BUNDLE_PATH
|
||||||
|
# agent-ca.cert.pem = intermediate cert <- AGENT_CA_CERT_PATH
|
||||||
|
# relay-agent-server.key.pem relay-agent-server.cert.pem (present on AGENT_BIND_PORT)
|
||||||
|
#
|
||||||
|
# Config via ENV only (no hardcoded hosts/ports/secrets):
|
||||||
|
# CA_DIR output dir (default /etc/relay/ca)
|
||||||
|
# AGENT_FACING_HOST REQUIRED — CN/SAN for the relay agent-server cert; the host the agent dials,
|
||||||
|
# e.g. "<sub>.<BASE_DOMAIN>" (must match RELAY_URL's host in the agent config).
|
||||||
|
# AGENT_FACING_IP OPTIONAL — extra IP SAN (e.g. 8.138.1.192) if the agent dials the raw IP.
|
||||||
|
# RELAY_TRUST_DOMAIN OPTIONAL — SPIFFE trust domain (recorded in a NOTE only; agent SPIFFE leaves
|
||||||
|
# are minted by the control-plane /enroll, not by this script).
|
||||||
|
# CA_DAYS_ROOT / CA_DAYS_INT / CA_DAYS_LEAF cert lifetimes (defaults 3650 / 1825 / 825).
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/gen-agent-ca.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CA_DIR="${CA_DIR:-/etc/relay/ca}"
|
||||||
|
CA_DAYS_ROOT="${CA_DAYS_ROOT:-3650}"
|
||||||
|
CA_DAYS_INT="${CA_DAYS_INT:-1825}"
|
||||||
|
CA_DAYS_LEAF="${CA_DAYS_LEAF:-825}"
|
||||||
|
|
||||||
|
if [[ -z "${AGENT_FACING_HOST:-}" ]]; then
|
||||||
|
echo "FATAL: AGENT_FACING_HOST is required (CN/SAN of the relay agent-server cert)." >&2
|
||||||
|
echo " e.g. AGENT_FACING_HOST=term1.example.com bash $0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "${CA_DIR}"
|
||||||
|
cd "${CA_DIR}"
|
||||||
|
|
||||||
|
echo "== Enrollment CA (PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
|
||||||
|
|
||||||
|
# ---- 1. Root CA (offline anchor) ------------------------------------------------------------------
|
||||||
|
if [[ ! -f root.key.pem ]]; then
|
||||||
|
openssl genpkey -algorithm ed25519 -out root.key.pem
|
||||||
|
fi
|
||||||
|
openssl req -x509 -new -key root.key.pem -days "${CA_DAYS_ROOT}" \
|
||||||
|
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Root" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||||
|
-addext "subjectKeyIdentifier=hash" \
|
||||||
|
-out root.cert.pem
|
||||||
|
|
||||||
|
# ---- 2. Intermediate CA (the control-plane's leaf-signing key in staging) --------------------------
|
||||||
|
# The CP dev in-process signer (CA_INTERMEDIATE_KMS_KEY_REF=dev-local) signs agent SPIFFE leaves with
|
||||||
|
# THIS intermediate key. In Phase 2 the intermediate key moves into a non-exportable KMS/HSM (§3.1).
|
||||||
|
if [[ ! -f intermediate.key.pem ]]; then
|
||||||
|
openssl genpkey -algorithm ed25519 -out intermediate.key.pem
|
||||||
|
fi
|
||||||
|
openssl req -new -key intermediate.key.pem \
|
||||||
|
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Intermediate" \
|
||||||
|
-out intermediate.csr.pem
|
||||||
|
cat > intermediate.ext <<'EXT'
|
||||||
|
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||||
|
keyUsage = critical,keyCertSign,cRLSign
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
authorityKeyIdentifier = keyid:always
|
||||||
|
EXT
|
||||||
|
openssl x509 -req -in intermediate.csr.pem \
|
||||||
|
-CA root.cert.pem -CAkey root.key.pem -CAcreateserial \
|
||||||
|
-days "${CA_DAYS_INT}" -extfile intermediate.ext \
|
||||||
|
-out intermediate.cert.pem
|
||||||
|
|
||||||
|
# ---- 3. Trust bundle (intermediate + root) --------------------------------------------------------
|
||||||
|
# The relay walks leaf -> intermediate -> root, so the anchor (root) MUST be in the bundle
|
||||||
|
# (relay-auth verifyChain terminates at a self-signed root present in the set, INV14).
|
||||||
|
cat intermediate.cert.pem root.cert.pem > agent-ca.bundle.pem
|
||||||
|
cp intermediate.cert.pem agent-ca.cert.pem
|
||||||
|
|
||||||
|
# ---- 4. Relay agent-facing SERVER cert (presented on AGENT_BIND_PORT) -----------------------------
|
||||||
|
# The agent dials wss://<AGENT_FACING_HOST>:<AGENT_BIND_PORT> and pins THIS CA to authenticate the
|
||||||
|
# relay server. CN/SAN must equal the host the agent dials.
|
||||||
|
if [[ ! -f relay-agent-server.key.pem ]]; then
|
||||||
|
openssl genpkey -algorithm ed25519 -out relay-agent-server.key.pem
|
||||||
|
fi
|
||||||
|
openssl req -new -key relay-agent-server.key.pem \
|
||||||
|
-subj "/O=web-terminal-relay/OU=agent-data-plane/CN=${AGENT_FACING_HOST}" \
|
||||||
|
-out relay-agent-server.csr.pem
|
||||||
|
{
|
||||||
|
echo "basicConstraints = critical,CA:FALSE"
|
||||||
|
echo "keyUsage = critical,digitalSignature"
|
||||||
|
echo "extendedKeyUsage = serverAuth"
|
||||||
|
echo "subjectKeyIdentifier = hash"
|
||||||
|
echo "authorityKeyIdentifier = keyid,issuer"
|
||||||
|
if [[ -n "${AGENT_FACING_IP:-}" ]]; then
|
||||||
|
echo "subjectAltName = DNS:${AGENT_FACING_HOST},IP:${AGENT_FACING_IP}"
|
||||||
|
else
|
||||||
|
echo "subjectAltName = DNS:${AGENT_FACING_HOST}"
|
||||||
|
fi
|
||||||
|
} > relay-agent-server.ext
|
||||||
|
openssl x509 -req -in relay-agent-server.csr.pem \
|
||||||
|
-CA intermediate.cert.pem -CAkey intermediate.key.pem -CAcreateserial \
|
||||||
|
-days "${CA_DAYS_LEAF}" -extfile relay-agent-server.ext \
|
||||||
|
-out relay-agent-server.cert.pem
|
||||||
|
|
||||||
|
# ---- 5. Permissions + summary ---------------------------------------------------------------------
|
||||||
|
chmod 600 ./*.key.pem
|
||||||
|
chmod 644 ./*.cert.pem ./*.bundle.pem
|
||||||
|
rm -f ./*.csr.pem ./*.ext
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
Enrollment CA ready in ${CA_DIR}. Wire these env paths (relay.env / control-plane.env):
|
||||||
|
|
||||||
|
CA_INTERMEDIATE_CERT_PATH = ${CA_DIR}/intermediate.cert.pem
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
AGENT_CA_CERT_PATH = ${CA_DIR}/agent-ca.cert.pem
|
||||||
|
AGENT_CA_CHAIN_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
|
||||||
|
relay agent-server cert = ${CA_DIR}/relay-agent-server.cert.pem
|
||||||
|
relay agent-server key = ${CA_DIR}/relay-agent-server.key.pem (0600)
|
||||||
|
intermediate signing key = ${CA_DIR}/intermediate.key.pem (0600 — the CP dev signer)
|
||||||
|
|
||||||
|
NOTE (staging integration seam, owned by the A-wave CP boot/ca-wiring, NOT by this script):
|
||||||
|
The control-plane's dev in-process signer must sign agent leaves with intermediate.key.pem so the
|
||||||
|
leaves chain to this bundle. CA_INTERMEDIATE_KMS_KEY_REF=dev-local selects the dev signer; point it
|
||||||
|
at ${CA_DIR}/intermediate.key.pem. Phase 2 replaces this with a KMS/HSM ref (§3.1).
|
||||||
|
SPIFFE trust domain for agent leaves = ${RELAY_TRUST_DOMAIN:-<RELAY_TRUST_DOMAIN unset>}.
|
||||||
|
|
||||||
|
*** root.key.pem is the offline trust anchor — after first use, move it OFF this host. ***
|
||||||
|
SUMMARY
|
||||||
67
deploy/scripts/gen-capability-key.sh
Executable file
67
deploy/scripts/gen-capability-key.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · E (TASK E) — P5 capability-token keypair (Ed25519).
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. One Ed25519 keypair underpins the whole authz split:
|
||||||
|
#
|
||||||
|
# CONTROL-PLANE SIGNS ──(mints capability tokens with the PRIVATE key, B6 token-mint)──▶
|
||||||
|
# RELAY VERIFIES
|
||||||
|
# The CP admin API (POST /accounts, /pairing-codes) ALSO verifies operator "manage" tokens with
|
||||||
|
# the SAME public key. So the public key is deployed to BOTH processes; the private key lives ONLY
|
||||||
|
# where tokens are minted (the B6 mint), never on the relay, never committed.
|
||||||
|
#
|
||||||
|
# The public key is printed in TWO encodings of the SAME raw 32 bytes (the split is encoding-only):
|
||||||
|
# CAPABILITY_SIGN_PUBKEY_B64 raw 32B, standard base64 -> control-plane env (env.ts base64ToBytes)
|
||||||
|
# RELAY_AUTH_VERIFY_PUBKEY raw 32B, base64url (no pad) -> relay env (loadVerifyKeyFromEnv)
|
||||||
|
# These MUST be the SAME key (PLAN §3 linchpin). This script prints both from one keypair so they
|
||||||
|
# cannot drift.
|
||||||
|
#
|
||||||
|
# The PRIVATE key is written PKCS#8 PEM to a 0600 file for the B6 mint to import (WebCrypto Ed25519).
|
||||||
|
# It is NEVER printed and NEVER placed in relay.env / control-plane.env.
|
||||||
|
#
|
||||||
|
# Config via ENV only:
|
||||||
|
# KEY_DIR output dir for the private key (default /etc/relay/capability)
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/gen-capability-key.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEY_DIR="${KEY_DIR:-/etc/relay/capability}"
|
||||||
|
PRIV="${KEY_DIR}/capability-sign.key.pem"
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "${KEY_DIR}"
|
||||||
|
|
||||||
|
if [[ -f "${PRIV}" ]]; then
|
||||||
|
echo "WARN: ${PRIV} already exists — reusing it (delete it to rotate). Rotating INVALIDATES every" >&2
|
||||||
|
echo " minted token AND requires re-deploying the public key to BOTH processes." >&2
|
||||||
|
else
|
||||||
|
openssl genpkey -algorithm ed25519 -out "${PRIV}"
|
||||||
|
fi
|
||||||
|
chmod 600 "${PRIV}"
|
||||||
|
|
||||||
|
# Raw 32-byte Ed25519 public key = last 32 bytes of the SPKI DER.
|
||||||
|
PUB_STD_B64="$(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | openssl base64 -A)"
|
||||||
|
# base64url of the SAME bytes, padding stripped (decodeBase64UrlBytes tolerates missing padding).
|
||||||
|
PUB_B64URL="$(printf '%s' "${PUB_STD_B64}" | tr '+/' '-_' | tr -d '=')"
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
P5 capability keypair ready.
|
||||||
|
|
||||||
|
PRIVATE key (B6 token-mint only; 0600; NEVER commit / NEVER on the relay):
|
||||||
|
${PRIV}
|
||||||
|
|
||||||
|
Put the SAME public key in BOTH env files (they are one key, two encodings):
|
||||||
|
|
||||||
|
# control-plane.env
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64=${PUB_STD_B64}
|
||||||
|
|
||||||
|
# relay.env
|
||||||
|
RELAY_AUTH_VERIFY_PUBKEY=${PUB_B64URL}
|
||||||
|
|
||||||
|
Sanity check they decode to the same 32 bytes:
|
||||||
|
diff <(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | xxd -p) \\
|
||||||
|
<(printf '%s' "${PUB_STD_B64}" | openssl base64 -d -A | xxd -p) && echo OK
|
||||||
|
SUMMARY
|
||||||
163
deploy/scripts/gen-device-ca.sh
Executable file
163
deploy/scripts/gen-device-ca.sh
Executable file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Device client-CA (and frp-client control-CA).
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. Mints a PRIVATE client-authentication CA whose leaves the native clients (iOS /
|
||||||
|
# Android / desktop) present at nginx :8470 (`ssl_verify_client on`). This CA is the ONE AND ONLY
|
||||||
|
# security gate for the tunnelled base app (the base app has no login of its own — PLAN §1), so it
|
||||||
|
# MUST be a SEPARATE trust root from Let's Encrypt (the public :8470 SERVER cert) and from the
|
||||||
|
# agent-enrollment CA (deploy/scripts/gen-agent-ca.sh). Mixing them would let a foreign leaf enroll.
|
||||||
|
#
|
||||||
|
# *** P-256 (prime256v1), NEVER Ed25519. *** nginx `ssl_verify_client` + iOS client-certificate
|
||||||
|
# selection require ECDSA-P256/RSA; Ed25519 client certs are not universally accepted (PLAN V2).
|
||||||
|
#
|
||||||
|
# Two KINDs (same structure, different name/subject/default dir):
|
||||||
|
# device → /etc/relay/device-ca (device-ca.cert.pem) leaves = client devices (V2/V4)
|
||||||
|
# frp-client → /etc/relay/frp-client-ca (frp-client-ca.cert.pem) leaves = per-host frpc control mTLS (V1/V1b)
|
||||||
|
#
|
||||||
|
# Produces an OpenSSL `ca` database (index.txt / serial / crlnumber / newcerts / openssl.cnf) so that
|
||||||
|
# deploy/scripts/issue-device-cert.sh can sign tracked leaves and deploy/scripts/revoke-device.sh can
|
||||||
|
# revoke by serial and regenerate the CRL. An EMPTY crl.pem is initialised NOW so V4's `ssl_crl` has a
|
||||||
|
# file to reference from day one.
|
||||||
|
#
|
||||||
|
# Outputs (0600 key, 0644 cert) under CA_DIR:
|
||||||
|
# <CA_NAME>.key.pem <CA_NAME>.cert.pem crl.pem
|
||||||
|
# index.txt serial crlnumber index.txt.attr newcerts/ openssl.cnf (the `openssl ca` db)
|
||||||
|
#
|
||||||
|
# Config via ENV or flags (no hardcoded secrets — this CA key is generated in place, never committed):
|
||||||
|
# KIND device | frp-client (default device) — or --kind <k>
|
||||||
|
# CA_DIR output dir (default per-KIND above) — or --dir <path>
|
||||||
|
# CA_DAYS CA lifetime in days (default 3650)
|
||||||
|
#
|
||||||
|
# Idempotent: an existing CA (key+cert) is REUSED, never overwritten (delete the dir to rotate).
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/gen-device-ca.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KIND="${KIND:-device}"
|
||||||
|
CA_DIR="${CA_DIR:-}"
|
||||||
|
CA_DAYS="${CA_DAYS:-3650}"
|
||||||
|
|
||||||
|
# ---- args (flags override env) --------------------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--kind) KIND="${2:?--kind needs a value}"; shift 2 ;;
|
||||||
|
--dir) CA_DIR="${2:?--dir needs a value}"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "FATAL: unknown argument '$1' (see --help)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "${KIND}" in
|
||||||
|
device) CA_NAME="device-ca"; CA_CN="web-terminal Device CA"; CA_OU="device-ca"
|
||||||
|
CA_DIR="${CA_DIR:-/etc/relay/device-ca}" ;;
|
||||||
|
frp-client) CA_NAME="frp-client-ca"; CA_CN="web-terminal frp-client CA"; CA_OU="frp-client-ca"
|
||||||
|
CA_DIR="${CA_DIR:-/etc/relay/frp-client-ca}" ;;
|
||||||
|
*) echo "FATAL: KIND must be 'device' or 'frp-client' (got '${KIND}')." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "${CA_DIR}/newcerts"
|
||||||
|
# Resolve to an absolute path so `openssl ca` (which we run cd'd into CA_DIR with dir=.) is stable.
|
||||||
|
CA_DIR="$(cd "${CA_DIR}" && pwd)"
|
||||||
|
|
||||||
|
KEY="${CA_DIR}/${CA_NAME}.key.pem"
|
||||||
|
CERT="${CA_DIR}/${CA_NAME}.cert.pem"
|
||||||
|
|
||||||
|
echo "== Device/frp-client CA (${KIND}, PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
|
||||||
|
|
||||||
|
# ---- 1. CA keypair + self-signed cert (P-256, CA:TRUE, keyCertSign+cRLSign) -----------------------
|
||||||
|
if [[ -f "${KEY}" && -f "${CERT}" ]]; then
|
||||||
|
echo ">> CA already exists — reusing (delete ${CA_DIR} to rotate)."
|
||||||
|
else
|
||||||
|
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}"
|
||||||
|
openssl req -x509 -new -key "${KEY}" -days "${CA_DAYS}" \
|
||||||
|
-subj "/O=web-terminal-relay/OU=${CA_OU}/CN=${CA_CN}" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||||
|
-addext "subjectKeyIdentifier=hash" \
|
||||||
|
-out "${CERT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2. OpenSSL `ca` database (so leaves are tracked → revocable via the CRL) ----------------------
|
||||||
|
[[ -f "${CA_DIR}/index.txt" ]] || : > "${CA_DIR}/index.txt"
|
||||||
|
[[ -f "${CA_DIR}/serial" ]] || echo 1000 > "${CA_DIR}/serial"
|
||||||
|
[[ -f "${CA_DIR}/crlnumber" ]] || echo 1000 > "${CA_DIR}/crlnumber"
|
||||||
|
# unique_subject=no allows re-issuing a leaf for the same CN (device/host cert rotation).
|
||||||
|
[[ -f "${CA_DIR}/index.txt.attr" ]] || echo "unique_subject = no" > "${CA_DIR}/index.txt.attr"
|
||||||
|
|
||||||
|
if [[ ! -f "${CA_DIR}/openssl.cnf" ]]; then
|
||||||
|
# dir = . → the issue/revoke scripts `cd` into CA_DIR before invoking `openssl ca`, so the db is
|
||||||
|
# relocatable (works in a mktemp test dir). $dir is expanded by openssl; ${CA_NAME} by bash here.
|
||||||
|
cat > "${CA_DIR}/openssl.cnf" <<EOF
|
||||||
|
[ ca ]
|
||||||
|
default_ca = CA_default
|
||||||
|
|
||||||
|
[ CA_default ]
|
||||||
|
dir = .
|
||||||
|
database = \$dir/index.txt
|
||||||
|
serial = \$dir/serial
|
||||||
|
new_certs_dir = \$dir/newcerts
|
||||||
|
certificate = \$dir/${CA_NAME}.cert.pem
|
||||||
|
private_key = \$dir/${CA_NAME}.key.pem
|
||||||
|
crlnumber = \$dir/crlnumber
|
||||||
|
crl = \$dir/crl.pem
|
||||||
|
default_md = sha256
|
||||||
|
default_days = 825
|
||||||
|
default_crl_days = 30
|
||||||
|
policy = policy_anything
|
||||||
|
copy_extensions = none
|
||||||
|
x509_extensions = client_cert
|
||||||
|
crl_extensions = crl_ext
|
||||||
|
|
||||||
|
[ policy_anything ]
|
||||||
|
countryName = optional
|
||||||
|
stateOrProvinceName = optional
|
||||||
|
localityName = optional
|
||||||
|
organizationName = optional
|
||||||
|
organizationalUnitName = optional
|
||||||
|
commonName = supplied
|
||||||
|
emailAddress = optional
|
||||||
|
|
||||||
|
# Leaf profile: client-authentication ONLY (never serverAuth, never CA).
|
||||||
|
[ client_cert ]
|
||||||
|
basicConstraints = critical, CA:FALSE
|
||||||
|
keyUsage = critical, digitalSignature
|
||||||
|
extendedKeyUsage = clientAuth
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
authorityKeyIdentifier = keyid,issuer
|
||||||
|
|
||||||
|
[ crl_ext ]
|
||||||
|
authorityKeyIdentifier = keyid:always
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 3. Empty CRL now (V4 `ssl_crl` needs the file to exist even with zero revocations) ------------
|
||||||
|
if [[ ! -f "${CA_DIR}/crl.pem" ]]; then
|
||||||
|
( cd "${CA_DIR}" && openssl ca -config openssl.cnf -gencrl -out crl.pem >/dev/null 2>&1 )
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4. Permissions + summary ---------------------------------------------------------------------
|
||||||
|
chmod 700 "${CA_DIR}"
|
||||||
|
chmod 600 "${KEY}"
|
||||||
|
chmod 644 "${CERT}" "${CA_DIR}/crl.pem"
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
${KIND} CA ready in ${CA_DIR}:
|
||||||
|
CA cert = ${CERT} (0644 — the trust anchor to deploy)
|
||||||
|
CA key = ${KEY} (0600 — NEVER commit / NEVER leave a shared host)
|
||||||
|
CRL = ${CA_DIR}/crl.pem (empty; regenerated by revoke-device.sh)
|
||||||
|
ca db = index.txt / serial / crlnumber / newcerts / openssl.cnf
|
||||||
|
|
||||||
|
Wire it:
|
||||||
|
device → nginx :8470 ssl_client_certificate ${CERT} ; ssl_crl ${CA_DIR}/crl.pem ;
|
||||||
|
frp-client → frps.toml transport.tls.trustedCaFile = ${CERT}
|
||||||
|
|
||||||
|
Next:
|
||||||
|
issue a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/issue-device-cert.sh <CN> <out-dir>
|
||||||
|
revoke a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh <leaf.cert.pem>
|
||||||
|
SUMMARY
|
||||||
120
deploy/scripts/issue-device-cert.sh
Executable file
120
deploy/scripts/issue-device-cert.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Issue a client-auth LEAF from the device CA.
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. Signs a P-256 client-certificate (EKU=clientAuth, 825d) with the CA minted by
|
||||||
|
# deploy/scripts/gen-device-ca.sh and emits BOTH delivery formats:
|
||||||
|
# <CN>.p12 — PKCS#12, `-legacy` (3DES/RC2) for iOS / Android import compatibility (V2)
|
||||||
|
# <CN>.cert.pem — leaf cert ┐
|
||||||
|
# <CN>.key.pem — leaf private key (0600) ├─ desktop / curl --cert (M1 acceptance)
|
||||||
|
# <CN>.fullchain.pem— leaf + CA cert ┘
|
||||||
|
# The leaf is signed via `openssl ca`, so it is recorded in the CA's index.txt and can later be
|
||||||
|
# revoked (CRL) by deploy/scripts/revoke-device.sh. CN + serial are appended to <CA_DIR>/issued.log.
|
||||||
|
#
|
||||||
|
# NOTE: the SAME script issues the per-host frp-client control certs (V1b) — just point CA_DIR at the
|
||||||
|
# frp-client CA: CA_DIR=/etc/relay/frp-client-ca bash issue-device-cert.sh <hostname> <out>
|
||||||
|
# (frpc uses the .cert.pem/.key.pem; ignore the .p12 for that use.)
|
||||||
|
#
|
||||||
|
# Usage: issue-device-cert.sh <CN> [OUT_DIR]
|
||||||
|
# <CN> required — the device/host common name (also the filename stem); [A-Za-z0-9._-]+
|
||||||
|
# [OUT_DIR] optional — where the leaf artifacts land (default <CA_DIR>/issued/<CN>)
|
||||||
|
#
|
||||||
|
# Config via ENV or flags (no hardcoded secrets):
|
||||||
|
# CA_DIR which CA signs (default /etc/relay/device-ca) — or --ca-dir <path>
|
||||||
|
# P12_PASSWORD .p12 export passphrase (REQUIRED via env/arg; auto-generated + printed ONCE if
|
||||||
|
# unset) — or --pass <secret>
|
||||||
|
# LEAF_DAYS leaf lifetime (default 825)
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/issue-device-cert.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CA_DIR="${CA_DIR:-/etc/relay/device-ca}"
|
||||||
|
LEAF_DAYS="${LEAF_DAYS:-825}"
|
||||||
|
CN=""
|
||||||
|
OUT_DIR=""
|
||||||
|
|
||||||
|
# ---- args ----------------------------------------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;;
|
||||||
|
--pass) P12_PASSWORD="${2:?--pass needs a value}"; shift 2 ;;
|
||||||
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
-*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;;
|
||||||
|
*) if [[ -z "${CN}" ]]; then CN="$1"; elif [[ -z "${OUT_DIR}" ]]; then OUT_DIR="$1";
|
||||||
|
else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "${CN}" ]] || { echo "FATAL: <CN> is required. usage: issue-device-cert.sh <CN> [OUT_DIR]" >&2; exit 2; }
|
||||||
|
# Boundary validation: CN becomes a filename — reject anything that could traverse or surprise.
|
||||||
|
[[ "${CN}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "FATAL: CN '${CN}' must match [A-Za-z0-9._-]+ (it is used as a filename)." >&2; exit 2; }
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||||
|
|
||||||
|
CA_CERT="${CA_DIR}/$(basename "${CA_DIR}").cert.pem"
|
||||||
|
# gen-device-ca names the CA cert after its dir (device-ca.cert.pem / frp-client-ca.cert.pem); if the
|
||||||
|
# dir was renamed, fall back to the single *.cert.pem that is not a leaf. Keep it explicit + validated.
|
||||||
|
if [[ ! -f "${CA_CERT}" ]]; then
|
||||||
|
CA_CERT="$(ls "${CA_DIR}"/*-ca.cert.pem 2>/dev/null | head -1 || true)"
|
||||||
|
fi
|
||||||
|
[[ -f "${CA_DIR}/openssl.cnf" && -f "${CA_CERT}" ]] || {
|
||||||
|
echo "FATAL: no CA db in ${CA_DIR}. Run gen-device-ca.sh first (expected openssl.cnf + *-ca.cert.pem)." >&2
|
||||||
|
exit 3; }
|
||||||
|
|
||||||
|
OUT_DIR="${OUT_DIR:-${CA_DIR}/issued/${CN}}"
|
||||||
|
umask 077
|
||||||
|
mkdir -p "${OUT_DIR}"
|
||||||
|
OUT_DIR="$(cd "${OUT_DIR}" && pwd)" # absolute — we cd into CA_DIR before `openssl ca`
|
||||||
|
|
||||||
|
# ---- passphrase (never hardcoded; auto-generate + print once if not supplied) ---------------------
|
||||||
|
P12_GENERATED=0
|
||||||
|
if [[ -z "${P12_PASSWORD:-}" ]]; then
|
||||||
|
P12_PASSWORD="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)"
|
||||||
|
P12_GENERATED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
KEY="${OUT_DIR}/${CN}.key.pem"
|
||||||
|
CERT="${OUT_DIR}/${CN}.cert.pem"
|
||||||
|
CHAIN="${OUT_DIR}/${CN}.fullchain.pem"
|
||||||
|
P12="${OUT_DIR}/${CN}.p12"
|
||||||
|
CSR="${OUT_DIR}/${CN}.csr.pem"
|
||||||
|
|
||||||
|
echo "== Issue client-auth leaf CN=${CN} (${LEAF_DAYS}d) signed by ${CA_CERT} → ${OUT_DIR} =="
|
||||||
|
|
||||||
|
# ---- 1. leaf keypair + CSR (P-256) ----------------------------------------------------------------
|
||||||
|
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}"
|
||||||
|
openssl req -new -key "${KEY}" -subj "/O=web-terminal-relay/OU=device/CN=${CN}" -out "${CSR}"
|
||||||
|
|
||||||
|
# ---- 2. sign via `openssl ca` (tracks the leaf in index.txt → revocable) ---------------------------
|
||||||
|
( cd "${CA_DIR}" && openssl ca -config openssl.cnf -batch -notext \
|
||||||
|
-extensions client_cert -days "${LEAF_DAYS}" \
|
||||||
|
-in "${CSR}" -out "${CERT}" )
|
||||||
|
|
||||||
|
cat "${CERT}" "${CA_CERT}" > "${CHAIN}"
|
||||||
|
|
||||||
|
# ---- 3. PKCS#12 for iOS / Android (legacy 3DES/RC2 — imports reliably on mobile) -------------------
|
||||||
|
openssl pkcs12 -export -legacy \
|
||||||
|
-inkey "${KEY}" -in "${CERT}" -certfile "${CA_CERT}" \
|
||||||
|
-name "${CN}" -passout "pass:${P12_PASSWORD}" -out "${P12}"
|
||||||
|
|
||||||
|
# ---- 4. bookkeeping -------------------------------------------------------------------------------
|
||||||
|
SERIAL="$(openssl x509 -in "${CERT}" -noout -serial | cut -d= -f2)"
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') CN=${CN} serial=${SERIAL} out=${OUT_DIR}" >> "${CA_DIR}/issued.log"
|
||||||
|
|
||||||
|
rm -f "${CSR}"
|
||||||
|
chmod 600 "${KEY}" "${P12}"
|
||||||
|
chmod 644 "${CERT}" "${CHAIN}"
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
Leaf issued for CN=${CN} (serial ${SERIAL}):
|
||||||
|
mobile (iOS/Android) : ${P12} (0600) import + passphrase in-app
|
||||||
|
desktop / curl : ${CERT}
|
||||||
|
${KEY} (0600)
|
||||||
|
${CHAIN}
|
||||||
|
logged : ${CA_DIR}/issued.log
|
||||||
|
|
||||||
|
Deliver the .p12 SECURELY (scp / AirDrop / Files — NEVER email).
|
||||||
|
$( [[ "${P12_GENERATED}" == "1" ]] && printf ' P12 PASSPHRASE (shown ONCE — record it now): %s\n' "${P12_PASSWORD}" )
|
||||||
|
Revoke later: CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh ${CERT}
|
||||||
|
SUMMARY
|
||||||
115
deploy/scripts/issue-tls-cert.sh
Executable file
115
deploy/scripts/issue-tls-cert.sh
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · E (TASK E) — PUBLIC-web TLS cert for the browser :443 endpoint (Let's Encrypt).
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. This is the ONLY public-CA cert in the deployment: the browser hits
|
||||||
|
# https://<SUBDOMAIN>.<BASE_DOMAIN> (WSS same-origin), so it must chain to a public root. It is a
|
||||||
|
# DIFFERENT trust chain from the private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh) — do
|
||||||
|
# NOT cross-wire them.
|
||||||
|
#
|
||||||
|
# ICP-備案 ASSUMPTION (mainland Alibaba Cloud): BASE_DOMAIN is ALREADY ICP-filed and its A-record
|
||||||
|
# <SUBDOMAIN>.<BASE_DOMAIN> -> 8.138.1.192 resolves. Serving :80/:443 on an unfiled domain in
|
||||||
|
# mainland CN is blocked upstream; HTTP-01 will then fail. If unfiled, use DNS-01 (no inbound :80).
|
||||||
|
#
|
||||||
|
# Two challenge methods (pick with ACME_METHOD):
|
||||||
|
# http-01 — a one-shot listener on :80 answers the challenge. REQUIRES inbound :80 open in the
|
||||||
|
# Aliyun security group DURING issuance (you can close it again after; renewals reopen).
|
||||||
|
# dns-01 — a TXT record under _acme-challenge.<SUBDOMAIN>.<BASE_DOMAIN>. No inbound :80. Needs the
|
||||||
|
# acme.sh DNS-API creds for your provider (e.g. Ali_Key/Ali_Secret for the Aliyun DNS API).
|
||||||
|
#
|
||||||
|
# Installs to TLS_CERT_PATH (fullchain) + TLS_KEY_PATH, matching relay.env.
|
||||||
|
#
|
||||||
|
# Config via ENV only:
|
||||||
|
# BASE_DOMAIN REQUIRED (e.g. term.example.com)
|
||||||
|
# SUBDOMAIN REQUIRED (the tenant sub; FQDN = <SUBDOMAIN>.<BASE_DOMAIN>)
|
||||||
|
# ACME_EMAIL REQUIRED account/registration email
|
||||||
|
# ACME_METHOD http-01 | dns-01 (default http-01)
|
||||||
|
# ACME_CLIENT acme.sh | certbot (default acme.sh)
|
||||||
|
# ACME_DNS_PROVIDER acme.sh dnsapi id for dns-01 (e.g. dns_ali) (dns-01 only)
|
||||||
|
# TLS_CERT_PATH install target for fullchain (default /etc/relay/tls/fullchain.pem)
|
||||||
|
# TLS_KEY_PATH install target for privkey (default /etc/relay/tls/privkey.pem)
|
||||||
|
# ACME_STAGING 1 to use the LE staging CA (untrusted, avoids rate limits while testing)
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/issue-tls-cert.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${BASE_DOMAIN:?FATAL: BASE_DOMAIN is required (your ICP-filed domain)}"
|
||||||
|
: "${SUBDOMAIN:?FATAL: SUBDOMAIN is required}"
|
||||||
|
: "${ACME_EMAIL:?FATAL: ACME_EMAIL is required}"
|
||||||
|
ACME_METHOD="${ACME_METHOD:-http-01}"
|
||||||
|
ACME_CLIENT="${ACME_CLIENT:-acme.sh}"
|
||||||
|
TLS_CERT_PATH="${TLS_CERT_PATH:-/etc/relay/tls/fullchain.pem}"
|
||||||
|
TLS_KEY_PATH="${TLS_KEY_PATH:-/etc/relay/tls/privkey.pem}"
|
||||||
|
|
||||||
|
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
|
||||||
|
echo "== Issuing Let's Encrypt cert for ${FQDN} via ${ACME_CLIENT} (${ACME_METHOD}) =="
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${TLS_CERT_PATH}")" "$(dirname "${TLS_KEY_PATH}")"
|
||||||
|
|
||||||
|
case "${ACME_CLIENT}" in
|
||||||
|
acme.sh)
|
||||||
|
command -v acme.sh >/dev/null 2>&1 || { echo "FATAL: acme.sh not installed. curl https://get.acme.sh | sh" >&2; exit 3; }
|
||||||
|
acme.sh --register-account -m "${ACME_EMAIL}" >/dev/null 2>&1 || true
|
||||||
|
STAGING_FLAG=""
|
||||||
|
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
|
||||||
|
|
||||||
|
case "${ACME_METHOD}" in
|
||||||
|
http-01)
|
||||||
|
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
|
||||||
|
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --standalone --httpport 80
|
||||||
|
;;
|
||||||
|
dns-01)
|
||||||
|
: "${ACME_DNS_PROVIDER:?FATAL: ACME_DNS_PROVIDER required for dns-01 (e.g. dns_ali); export the DNS-API creds too}"
|
||||||
|
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --dns "${ACME_DNS_PROVIDER}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --install-cert copies the current material AND registers the renew-reload hook.
|
||||||
|
acme.sh --install-cert -d "${FQDN}" \
|
||||||
|
--key-file "${TLS_KEY_PATH}" \
|
||||||
|
--fullchain-file "${TLS_CERT_PATH}" \
|
||||||
|
--reloadcmd "systemctl try-reload-or-restart relay-data-plane.service"
|
||||||
|
;;
|
||||||
|
|
||||||
|
certbot)
|
||||||
|
command -v certbot >/dev/null 2>&1 || { echo "FATAL: certbot not installed (apt-get install certbot)." >&2; exit 3; }
|
||||||
|
STAGING_FLAG=""
|
||||||
|
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
|
||||||
|
case "${ACME_METHOD}" in
|
||||||
|
http-01)
|
||||||
|
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
|
||||||
|
certbot certonly ${STAGING_FLAG} --standalone --non-interactive --agree-tos \
|
||||||
|
-m "${ACME_EMAIL}" -d "${FQDN}"
|
||||||
|
;;
|
||||||
|
dns-01)
|
||||||
|
echo "FATAL: certbot dns-01 needs a provider plugin; prefer ACME_CLIENT=acme.sh for Aliyun DNS." >&2
|
||||||
|
exit 2 ;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
install -m 644 "/etc/letsencrypt/live/${FQDN}/fullchain.pem" "${TLS_CERT_PATH}"
|
||||||
|
install -m 600 "/etc/letsencrypt/live/${FQDN}/privkey.pem" "${TLS_KEY_PATH}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "FATAL: ACME_CLIENT must be acme.sh or certbot" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
chmod 600 "${TLS_KEY_PATH}" || true
|
||||||
|
chmod 644 "${TLS_CERT_PATH}" || true
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
Installed:
|
||||||
|
TLS_CERT_PATH = ${TLS_CERT_PATH}
|
||||||
|
TLS_KEY_PATH = ${TLS_KEY_PATH}
|
||||||
|
|
||||||
|
RENEWAL (LE certs last ~90 days):
|
||||||
|
- acme.sh installs its OWN cron on install ('acme.sh --cron'); --install-cert registered the
|
||||||
|
reload hook above, so renewals auto-reload the data-plane. Verify: 'crontab -l | grep acme'.
|
||||||
|
- certbot: add a cron/timer, e.g.
|
||||||
|
0 3 * * * certbot renew --quiet --deploy-hook 'systemctl try-reload-or-restart relay-data-plane.service'
|
||||||
|
- http-01 renewals need :80 reachable at renew time; dns-01 does not.
|
||||||
|
SUMMARY
|
||||||
68
deploy/scripts/revoke-device.sh
Executable file
68
deploy/scripts/revoke-device.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Revoke a device/frp-client leaf, regenerate the CRL.
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. Marks a previously-issued leaf revoked in the CA database and regenerates
|
||||||
|
# <CA_DIR>/crl.pem, which nginx :8470 serves via `ssl_crl` (V4) — so the revoked device is rejected at
|
||||||
|
# the TLS handshake fleet-wide the moment nginx reloads. This script does NOT touch the remote VPS: it
|
||||||
|
# only prints the reload hint; run the reload yourself on the box after copying the CRL.
|
||||||
|
#
|
||||||
|
# Requires the `openssl ca` database created by deploy/scripts/gen-device-ca.sh (index.txt/serial/…).
|
||||||
|
#
|
||||||
|
# Usage: revoke-device.sh <LEAF_CERT.pem> # revoke by the issued cert file
|
||||||
|
# or revoke-device.sh --serial <HEX_SERIAL> # revoke by serial (uses <CA_DIR>/newcerts/<serial>.pem)
|
||||||
|
#
|
||||||
|
# Config via ENV or flags (no secrets):
|
||||||
|
# CA_DIR which CA to revoke against (default /etc/relay/device-ca) — or --ca-dir <path>
|
||||||
|
#
|
||||||
|
# Verify (syntax): bash -n deploy/scripts/revoke-device.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CA_DIR="${CA_DIR:-/etc/relay/device-ca}"
|
||||||
|
LEAF=""
|
||||||
|
SERIAL=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;;
|
||||||
|
--serial) SERIAL="${2:?--serial needs a value}"; shift 2 ;;
|
||||||
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
-*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;;
|
||||||
|
*) if [[ -z "${LEAF}" ]]; then LEAF="$1";
|
||||||
|
else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||||
|
[[ -f "${CA_DIR}/openssl.cnf" ]] || { echo "FATAL: no CA db in ${CA_DIR} (run gen-device-ca.sh first)." >&2; exit 3; }
|
||||||
|
|
||||||
|
# ---- resolve the target leaf (by path or by serial) to an absolute path --------------------------
|
||||||
|
if [[ -n "${SERIAL}" ]]; then
|
||||||
|
# Boundary-validate the serial (used to build a path); openssl stores newcerts/<UPPERCASE-HEX>.pem.
|
||||||
|
[[ "${SERIAL}" =~ ^[0-9A-Fa-f]+$ ]] || { echo "FATAL: --serial must be hex." >&2; exit 2; }
|
||||||
|
LEAF="${CA_DIR}/newcerts/$(echo "${SERIAL}" | tr '[:lower:]' '[:upper:]').pem"
|
||||||
|
fi
|
||||||
|
[[ -n "${LEAF}" ]] || { echo "FATAL: pass a <LEAF_CERT.pem> or --serial <HEX>." >&2; exit 2; }
|
||||||
|
[[ -f "${LEAF}" ]] || { echo "FATAL: leaf cert not found: ${LEAF}" >&2; exit 3; }
|
||||||
|
LEAF="$(cd "$(dirname "${LEAF}")" && pwd)/$(basename "${LEAF}")"
|
||||||
|
|
||||||
|
REVOKED_SERIAL="$(openssl x509 -in "${LEAF}" -noout -serial | cut -d= -f2)"
|
||||||
|
echo "== Revoking serial ${REVOKED_SERIAL} against CA ${CA_DIR} =="
|
||||||
|
|
||||||
|
# ---- revoke + regenerate the CRL (cd in so openssl.cnf's dir=. resolves to CA_DIR) ----------------
|
||||||
|
(
|
||||||
|
cd "${CA_DIR}"
|
||||||
|
openssl ca -config openssl.cnf -revoke "${LEAF}"
|
||||||
|
openssl ca -config openssl.cnf -gencrl -out crl.pem
|
||||||
|
)
|
||||||
|
chmod 644 "${CA_DIR}/crl.pem"
|
||||||
|
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
Revoked serial ${REVOKED_SERIAL}. Updated CRL: ${CA_DIR}/crl.pem
|
||||||
|
Confirm: openssl crl -in ${CA_DIR}/crl.pem -noout -text | grep -A1 'Serial Number'
|
||||||
|
|
||||||
|
RELOAD HINT (run ON THE VPS after copying the new crl.pem into place — NOT executed here):
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
Until nginx reloads the CRL, the revoked cert is still accepted.
|
||||||
|
SUMMARY
|
||||||
52
deploy/systemd/relay-control-plane.service
Normal file
52
deploy/systemd/relay-control-plane.service
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# RELAY-PHASE1 · E (TASK E) — control-plane (P3) systemd unit. STAGING TEMPLATE.
|
||||||
|
#
|
||||||
|
# Runs the P3 admin/registry/CA/pairing API (control-plane/src/server.ts, added in A2) on the
|
||||||
|
# loopback admin port (CP_BIND_HOST/CP_BIND_PORT in the env file). It is NEVER exposed publicly —
|
||||||
|
# the security group keeps 8080 loopback-only; operators reach it via SSH tunnel.
|
||||||
|
#
|
||||||
|
# INSTALL (adjust the placeholder paths to your VPS):
|
||||||
|
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
|
||||||
|
# 2) install: sudo cp deploy/systemd/relay-control-plane.service /etc/systemd/system/
|
||||||
|
# 3) put the filled env at /etc/relay/control-plane.env (0600, owned by the service user)
|
||||||
|
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-control-plane.service
|
||||||
|
#
|
||||||
|
# The env file supplies PG_URL/REDIS_URL/CAPABILITY_SIGN_PUBKEY_B64/CA_* /BASE_DOMAIN (see .env.example).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=web-terminal rendezvous-relay CONTROL PLANE (P3)
|
||||||
|
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
|
||||||
|
# Postgres + Redis run under Docker Compose (deploy/docker-compose.yml); wait for docker + network.
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Dedicated non-root service user (create once: sudo useradd --system --home /opt/web-terminal relay).
|
||||||
|
User=relay
|
||||||
|
Group=relay
|
||||||
|
WorkingDirectory=/opt/web-terminal
|
||||||
|
# Secrets/config come ONLY from the env file (INV9 — no secrets inline in the unit).
|
||||||
|
EnvironmentFile=/etc/relay/control-plane.env
|
||||||
|
# /usr/bin/env resolves npm via PATH (works for NodeSource /usr/bin/npm and nvm shims alike).
|
||||||
|
# Runs the "start" script (control-plane/package.json -> tsx src/server.ts).
|
||||||
|
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/control-plane start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
# Give in-flight requests / graceful SIGTERM (server.ts closes the http listener + pg/redis) time.
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
# --- hardening (INV9: shrink the blast radius around the CA + capability material) ---
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
# The CA/capability material under /etc/relay is READ-ONLY to this service.
|
||||||
|
ReadOnlyPaths=/etc/relay
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
53
deploy/systemd/relay-data-plane.service
Normal file
53
deploy/systemd/relay-data-plane.service
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# RELAY-PHASE1 · E (TASK E) — relay data-plane (P1) systemd unit. STAGING TEMPLATE.
|
||||||
|
#
|
||||||
|
# Runs the Phase-1 relay entry (relay-run/src/main-phase1.ts via `start:phase1`, added in B5): browser
|
||||||
|
# WSS on :443 (BIND_PORT) and agent mTLS on AGENT_BIND_PORT. Stateless / restart-safe (INV7) — all
|
||||||
|
# state is in Postgres/Redis, so bouncing this unit does NOT drop host registrations or kill PTYs.
|
||||||
|
#
|
||||||
|
# INSTALL (adjust the placeholder paths to your VPS):
|
||||||
|
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
|
||||||
|
# 2) install: sudo cp deploy/systemd/relay-data-plane.service /etc/systemd/system/
|
||||||
|
# 3) put the filled env at /etc/relay/relay.env (0600, owned by the service user)
|
||||||
|
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-data-plane.service
|
||||||
|
#
|
||||||
|
# The env file supplies BIND_HOST/BIND_PORT/TLS_*/AGENT_BIND_PORT/AGENT_CA_*/BASE_DOMAIN/
|
||||||
|
# RELAY_NODE_ID/RELAY_AUTH_VERIFY_PUBKEY/RELAY_TRUST_DOMAIN/PG_URL/REDIS_URL (see .env.example).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=web-terminal rendezvous-relay DATA PLANE (P1, Phase 1)
|
||||||
|
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
|
||||||
|
# Reads the SAME Postgres/Redis as the control-plane (routes, revocations, registries).
|
||||||
|
After=network-online.target docker.service relay-control-plane.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=relay
|
||||||
|
Group=relay
|
||||||
|
WorkingDirectory=/opt/web-terminal
|
||||||
|
EnvironmentFile=/etc/relay/relay.env
|
||||||
|
# Runs relay-run "start:phase1" (relay-run/package.json -> tsx src/main-phase1.ts).
|
||||||
|
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/relay-run run start:phase1
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
# Bind the privileged browser port :443 WITHOUT running as root (least privilege).
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
|
||||||
|
# --- hardening ---
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
# TLS keys + the pinned agent-CA bundle are read-only to the relay.
|
||||||
|
ReadOnlyPaths=/etc/relay
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
*
|
*
|
||||||
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
|
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
|
||||||
*/
|
*/
|
||||||
import { app, BrowserWindow, Menu, Notification, Tray } from 'electron'
|
import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron'
|
||||||
|
import type { Certificate } from 'electron'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
import { createLogger } from './logger.js'
|
import { createLogger } from './logger.js'
|
||||||
@@ -20,11 +21,21 @@ import { startEmbeddedServer } from './embedded-server.js'
|
|||||||
import { computeNotifications } from './notifications.js'
|
import { computeNotifications } from './notifications.js'
|
||||||
import { mapLiveSessions } from './live-poll.js'
|
import { mapLiveSessions } from './live-poll.js'
|
||||||
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
|
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
|
||||||
import { createMainWindow } from './window.js'
|
import { createMainWindow, originOf } from './window.js'
|
||||||
import { buildAppMenu } from './menu.js'
|
import { buildAppMenu } from './menu.js'
|
||||||
import { createTray } from './tray.js'
|
import { createTray, buildTrayMenu } from './tray.js'
|
||||||
|
import type { TrayHandlers } from './tray.js'
|
||||||
|
import {
|
||||||
|
selectHost,
|
||||||
|
selectedRemoteHost,
|
||||||
|
remoteHostOrigin,
|
||||||
|
makeRemoteHost,
|
||||||
|
addRemoteHost,
|
||||||
|
removeRemoteHost,
|
||||||
|
TERMINAL_ZONE_SUFFIX,
|
||||||
|
} from './remote-hosts.js'
|
||||||
import type { NotifyOptions } from './notify-policy.js'
|
import type { NotifyOptions } from './notify-policy.js'
|
||||||
import type { EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
||||||
|
|
||||||
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
|
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
|
||||||
const POLL_INTERVAL_MS = 4000
|
const POLL_INTERVAL_MS = 4000
|
||||||
@@ -35,12 +46,75 @@ const PRELOAD_FILE = 'preload.cjs'
|
|||||||
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
|
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
|
||||||
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
|
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2,
|
||||||
|
* gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login
|
||||||
|
* keychain / Windows Personal store); Chromium sources client certificates ONLY
|
||||||
|
* from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS
|
||||||
|
* handshake we select the cert whose issuer CN matches this. Override for a
|
||||||
|
* differently-named CA via the WEBTERM_DEVICE_CA_CN env var.
|
||||||
|
*/
|
||||||
|
const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA'
|
||||||
|
|
||||||
|
/** Add-remote-host prompt window dimensions. */
|
||||||
|
const PROMPT_WIDTH = 460
|
||||||
|
const PROMPT_HEIGHT = 260
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal self-contained HTML for the "Add remote host" prompt window. It runs no
|
||||||
|
* Node (sandboxed, no preload) and returns its result by setting `location.hash`
|
||||||
|
* (read back via did-navigate-in-page); user text is JSON+URI-encoded into the
|
||||||
|
* hash, never interpolated into markup, so there is no injection surface. The CSP
|
||||||
|
* allows only inline style/script from this trusted, app-authored document.
|
||||||
|
*/
|
||||||
|
const PROMPT_HTML = `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark light; }
|
||||||
|
body { font: 13px -apple-system, Segoe UI, system-ui, sans-serif; margin: 0; padding: 18px;
|
||||||
|
background: #0e0f13; color: #e6e6e6; }
|
||||||
|
h1 { font-size: 15px; margin: 0 0 14px; }
|
||||||
|
label { display: block; margin-bottom: 10px; }
|
||||||
|
span { display: block; margin-bottom: 4px; color: #9aa0ab; }
|
||||||
|
input { width: 100%; box-sizing: border-box; padding: 7px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid #333; background: #16181d; color: #e6e6e6; font: inherit; }
|
||||||
|
.row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||||
|
button { padding: 7px 14px; border-radius: 6px; border: 1px solid #333;
|
||||||
|
background: #22252c; color: #e6e6e6; font: inherit; cursor: pointer; }
|
||||||
|
button[type=submit] { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<h1>Add remote host</h1>
|
||||||
|
<form id="f">
|
||||||
|
<label><span>Name</span><input id="name" autofocus placeholder="Laptop"></label>
|
||||||
|
<label><span>URL</span><input id="url" value="https://" placeholder="https://t1.terminal.yaojia.wang"></label>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="cancel">Cancel</button>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
var form = document.getElementById('f');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var payload = { name: document.getElementById('name').value, url: document.getElementById('url').value };
|
||||||
|
location.hash = 'ok:' + encodeURIComponent(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
document.getElementById('cancel').addEventListener('click', function () { location.hash = 'cancel'; });
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
const logger = createLogger('main')
|
const logger = createLogger('main')
|
||||||
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
|
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let server: EmbeddedServer | null = null
|
let server: EmbeddedServer | null = null
|
||||||
|
// The origin the main window is currently locked to (local embedded server, or a
|
||||||
|
// selected remote tunnel host). Read live by window.ts's will-navigate guard;
|
||||||
|
// reassigned (never mutated) whenever we point the window at a different host.
|
||||||
|
let activeOrigin: string | null = null
|
||||||
let pollTimer: NodeJS.Timeout | null = null
|
let pollTimer: NodeJS.Timeout | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
let isShuttingDown = false
|
let isShuttingDown = false
|
||||||
@@ -66,6 +140,193 @@ function focusWindow(): void {
|
|||||||
mainWindow.focus()
|
mainWindow.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True iff `value` is a plain, indexable object (not null, not an array). */
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded local server's base URL (`http://127.0.0.1:<port>`), or null. */
|
||||||
|
function localBaseUrl(): string | null {
|
||||||
|
return server ? `http://127.0.0.1:${server.port}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the page URL + origin the window should currently be on, given prefs:
|
||||||
|
* the selected remote host, else the local embedded server. Returns null only if
|
||||||
|
* neither is resolvable (e.g. server not up yet and no valid remote selected).
|
||||||
|
*/
|
||||||
|
function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null {
|
||||||
|
const remote = selectedRemoteHost(prefs)
|
||||||
|
if (remote) {
|
||||||
|
const origin = remoteHostOrigin(remote)
|
||||||
|
if (origin !== null) return { url: remote.url, origin }
|
||||||
|
}
|
||||||
|
const base = localBaseUrl()
|
||||||
|
if (base === null) return null
|
||||||
|
return { url: `${base}/`, origin: base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the tray context menu from the current host list + selection. */
|
||||||
|
function refreshTray(): void {
|
||||||
|
if (!tray) return
|
||||||
|
const prefs = settings.get()
|
||||||
|
tray.setContextMenu(
|
||||||
|
buildTrayMenu(
|
||||||
|
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||||
|
trayHandlers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the window to a host (null → local). Persists the selection, updates the
|
||||||
|
* origin lock, navigates the window (programmatic loadURL does NOT trip
|
||||||
|
* will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves.
|
||||||
|
*/
|
||||||
|
function switchToHost(id: string | null): void {
|
||||||
|
const prefs = settings.set(selectHost(settings.get(), id))
|
||||||
|
const target = resolveTarget(prefs)
|
||||||
|
if (!target) {
|
||||||
|
logger.warn('cannot switch host: no resolvable target (server not ready?)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeOrigin = target.origin
|
||||||
|
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||||
|
refreshTray()
|
||||||
|
focusWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prompt for a new remote host, validate it, persist it, and switch to it. */
|
||||||
|
async function addRemoteHostFlow(): Promise<void> {
|
||||||
|
const input = await promptRemoteHost()
|
||||||
|
if (!input) return
|
||||||
|
const host = makeRemoteHost(input.name, input.url)
|
||||||
|
if (!host) {
|
||||||
|
showError(
|
||||||
|
'Invalid remote host',
|
||||||
|
'That is not a valid tunnel URL.',
|
||||||
|
`Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` +
|
||||||
|
'e.g. https://t1.terminal.yaojia.wang',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings.set(addRemoteHost(settings.get(), host))
|
||||||
|
switchToHost(host.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a remote host; if it was selected, selection falls back to local. */
|
||||||
|
function removeRemoteHostFlow(id: string): void {
|
||||||
|
const prefs = settings.set(removeRemoteHost(settings.get(), id))
|
||||||
|
const target = resolveTarget(prefs)
|
||||||
|
if (target) {
|
||||||
|
activeOrigin = target.origin
|
||||||
|
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||||
|
}
|
||||||
|
refreshTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handlers wired into the tray host-picker (stable object; reads prefs live). */
|
||||||
|
const trayHandlers: TrayHandlers = {
|
||||||
|
show: () => focusWindow(),
|
||||||
|
quit: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
},
|
||||||
|
selectHost: (id) => switchToHost(id),
|
||||||
|
addHost: () => {
|
||||||
|
void addRemoteHostFlow()
|
||||||
|
},
|
||||||
|
removeHost: (id) => removeRemoteHostFlow(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a native error dialog (parented to the main window when one exists). */
|
||||||
|
function showError(title: string, message: string, detail: string): void {
|
||||||
|
const options = { type: 'error' as const, title, message, detail }
|
||||||
|
if (mainWindow) void dialog.showMessageBox(mainWindow, options)
|
||||||
|
else void dialog.showMessageBox(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal prompt for a remote-host name + URL. Resolves to the entered values, or
|
||||||
|
* null if cancelled/closed. The window returns its result via location.hash (no
|
||||||
|
* Node, no preload, no IPC surface); see PROMPT_HTML.
|
||||||
|
*/
|
||||||
|
function promptRemoteHost(): Promise<{ name: string; url: string } | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
parent: mainWindow ?? undefined,
|
||||||
|
modal: mainWindow !== null,
|
||||||
|
width: PROMPT_WIDTH,
|
||||||
|
height: PROMPT_HEIGHT,
|
||||||
|
resizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
title: 'Add remote host',
|
||||||
|
backgroundColor: '#0e0f13',
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
|
})
|
||||||
|
let settled = false
|
||||||
|
const finish = (result: { name: string; url: string } | null): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(result)
|
||||||
|
if (!win.isDestroyed()) win.close()
|
||||||
|
}
|
||||||
|
win.webContents.on('did-navigate-in-page', (_event, navUrl) => {
|
||||||
|
let hash: string
|
||||||
|
try {
|
||||||
|
hash = new URL(navUrl).hash
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hash === '#cancel') {
|
||||||
|
finish(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hash.startsWith('#ok:')) return
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length)))
|
||||||
|
if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') {
|
||||||
|
finish({ name: parsed['name'], url: parsed['url'] })
|
||||||
|
} else {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
win.on('closed', () => finish(null))
|
||||||
|
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2: choose the client certificate whose issuer is our device CA. The OS
|
||||||
|
* keychain may hold unrelated certs, so match on issuer CN rather than picking
|
||||||
|
* blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN.
|
||||||
|
*/
|
||||||
|
function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null {
|
||||||
|
return (
|
||||||
|
list.find(
|
||||||
|
(cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn,
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell the user their device cert isn't installed (D-2: never fail silently). */
|
||||||
|
function showDeviceCertMissingDialog(requestUrl: string): void {
|
||||||
|
const host = originOf(requestUrl) ?? requestUrl
|
||||||
|
showError(
|
||||||
|
'Device certificate not installed',
|
||||||
|
'No matching device certificate was found in the OS keychain.',
|
||||||
|
`Connecting to ${host} requires your device client certificate (issued by ` +
|
||||||
|
`"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` +
|
||||||
|
'(macOS: double-click the file) or the Personal certificate store (Windows), ' +
|
||||||
|
'then reconnect. On macOS you will be prompted once to "Always Allow" access ' +
|
||||||
|
'to the private key.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the first terminalapp:// argument in a process argv list. */
|
/** Find the first terminalapp:// argument in a process argv list. */
|
||||||
function findDeepLinkArg(argv: readonly string[]): string | null {
|
function findDeepLinkArg(argv: readonly string[]): string | null {
|
||||||
const prefix = `${DEEP_LINK_PROTOCOL}://`
|
const prefix = `${DEEP_LINK_PROTOCOL}://`
|
||||||
@@ -90,9 +351,13 @@ function dispatchDeepLink(url: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = deepLinkToPath(link)
|
const targetPath = deepLinkToPath(link)
|
||||||
|
const base = `http://127.0.0.1:${server.port}`
|
||||||
|
// A deep link joins a LOCAL session, so navigate the window back to the local
|
||||||
|
// origin and move the lock with it (in case a remote host was active).
|
||||||
|
activeOrigin = base
|
||||||
focusWindow()
|
focusWindow()
|
||||||
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
|
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
|
||||||
void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`)
|
void mainWindow.loadURL(`${base}${targetPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
|
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
|
||||||
@@ -154,18 +419,23 @@ async function onReady(): Promise<void> {
|
|||||||
server = await startEmbeddedServer({ prefs, logger })
|
server = await startEmbeddedServer({ prefs, logger })
|
||||||
const base = `http://127.0.0.1:${server.port}`
|
const base = `http://127.0.0.1:${server.port}`
|
||||||
|
|
||||||
mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE))
|
// Honor a previously-selected remote host across restarts; else land on local.
|
||||||
|
const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base }
|
||||||
|
activeOrigin = initial.origin
|
||||||
|
mainWindow = createMainWindow(
|
||||||
|
initial.url,
|
||||||
|
path.join(__dirname, PRELOAD_FILE),
|
||||||
|
() => activeOrigin,
|
||||||
|
)
|
||||||
installWindowCloseGuard(mainWindow)
|
installWindowCloseGuard(mainWindow)
|
||||||
|
|
||||||
Menu.setApplicationMenu(buildAppMenu())
|
Menu.setApplicationMenu(buildAppMenu())
|
||||||
try {
|
try {
|
||||||
tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), {
|
tray = createTray(
|
||||||
show: () => focusWindow(),
|
path.join(__dirname, TRAY_ICON_SUBPATH),
|
||||||
quit: () => {
|
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||||
isQuitting = true
|
trayHandlers,
|
||||||
app.quit()
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// A missing/invalid tray icon (cosmetic) must not take down a working app.
|
// A missing/invalid tray icon (cosmetic) must not take down a working app.
|
||||||
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
|
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
|
||||||
@@ -216,6 +486,34 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
} else {
|
} else {
|
||||||
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
|
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
|
||||||
|
|
||||||
|
// ── D-2: mTLS device-certificate selection (from the OS keychain) ──────────
|
||||||
|
// When a remote tunnel host (nginx `ssl_verify_client on`) requests a client
|
||||||
|
// cert, Chromium asks us which of the OS-store certs to present. preventDefault
|
||||||
|
// is MANDATORY: without it Electron auto-picks list[0] (any cert, no control).
|
||||||
|
// We deliberately choose the one issued by our device CA; if none is installed
|
||||||
|
// we show a clear dialog (never a silent callback) and proceed cert-less so the
|
||||||
|
// TLS handshake fails cleanly rather than hanging.
|
||||||
|
app.on('select-client-certificate', (event, _webContents, url, list, callback) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const cert = pickCertByIssuer(list, DEVICE_CA_CN)
|
||||||
|
if (!cert) {
|
||||||
|
showDeviceCertMissingDialog(url)
|
||||||
|
callback() // no cert → nginx rejects the handshake (clean, non-hanging failure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(cert)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── D-3 (deferred — documented TODO; coordinate with Track V/S) ────────────
|
||||||
|
// This box can also be its OWN tunnel source. A follow-up may optionally spawn
|
||||||
|
// + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3")
|
||||||
|
// and inject the embedded server's ALLOWED_ORIGINS=
|
||||||
|
// https://<name>.terminal.yaojia.wang (additive via the backend config.ts). The
|
||||||
|
// embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which
|
||||||
|
// satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds
|
||||||
|
// 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a
|
||||||
|
// tunnel-source host must keep lanSharing OFF. Not implemented here.
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
focusWindow()
|
focusWindow()
|
||||||
const url = findDeepLinkArg(argv)
|
const url = findDeepLinkArg(argv)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* CRITICAL rule); they never mutate their arguments.
|
* CRITICAL rule); they never mutate their arguments.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DesktopPrefs } from './types.js'
|
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||||
|
import { normalizeRemoteUrl } from './remote-hosts.js'
|
||||||
|
|
||||||
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
|
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
|
||||||
const MIN_PORT = 1
|
const MIN_PORT = 1
|
||||||
@@ -36,6 +37,8 @@ export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
|
|||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
notifyOnApproval: true,
|
notifyOnApproval: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
|
remoteHosts: [],
|
||||||
|
selectedHostId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +73,51 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate one untrusted remote-host record. Requires string id/name and a URL
|
||||||
|
* that survives normalization (https + tunnel zone); the stored URL is replaced
|
||||||
|
* with its canonical form. Returns null for anything malformed, so a corrupt or
|
||||||
|
* hand-edited entry is dropped rather than trusted downstream.
|
||||||
|
*/
|
||||||
|
function pickRemoteHost(value: unknown): RemoteHost | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
const { id, name, url } = value
|
||||||
|
if (typeof id !== 'string' || id === '') return null
|
||||||
|
if (typeof name !== 'string' || name.trim() === '') return null
|
||||||
|
if (typeof url !== 'string') return null
|
||||||
|
const normalizedUrl = normalizeRemoteUrl(url)
|
||||||
|
if (normalizedUrl === null) return null
|
||||||
|
return { id, name: name.trim(), url: normalizedUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a list of remote hosts from untrusted input: keep only records that
|
||||||
|
* validate, and drop duplicate ids (first wins) so lookups are unambiguous. A
|
||||||
|
* non-array yields an empty list.
|
||||||
|
*/
|
||||||
|
function pickRemoteHosts(value: unknown): readonly RemoteHost[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const hosts: RemoteHost[] = []
|
||||||
|
for (const entry of value) {
|
||||||
|
const host = pickRemoteHost(entry)
|
||||||
|
if (host === null || seen.has(host.id)) continue
|
||||||
|
seen.add(host.id)
|
||||||
|
hosts.push(host)
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt the selected host id: null (local), or a string that refers to a host
|
||||||
|
* actually present in the validated list. A dangling id degrades to null so we
|
||||||
|
* never point selection at a host that was dropped or never existed.
|
||||||
|
*/
|
||||||
|
function pickSelectedHostId(value: unknown, hosts: readonly RemoteHost[]): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
return hosts.some((host) => host.id === value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
|
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
|
||||||
* Starts from defaults and adopts each field only when it is the right type and
|
* Starts from defaults and adopts each field only when it is the right type and
|
||||||
@@ -78,6 +126,7 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
|||||||
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
|
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
|
||||||
const defaults = defaultPrefs(platform)
|
const defaults = defaultPrefs(platform)
|
||||||
if (!isRecord(raw)) return defaults
|
if (!isRecord(raw)) return defaults
|
||||||
|
const remoteHosts = pickRemoteHosts(raw['remoteHosts'])
|
||||||
return {
|
return {
|
||||||
port: pickPort(raw['port'], defaults.port),
|
port: pickPort(raw['port'], defaults.port),
|
||||||
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
|
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
|
||||||
@@ -85,6 +134,8 @@ export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopP
|
|||||||
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
|
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
|
||||||
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
|
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
|
||||||
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
|
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
|
||||||
|
remoteHosts,
|
||||||
|
selectedHostId: pickSelectedHostId(raw['selectedHostId'], remoteHosts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
125
desktop/src/remote-hosts.ts
Normal file
125
desktop/src/remote-hosts.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL,
|
||||||
|
* Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in
|
||||||
|
* DesktopPrefs, plus the URL validation that guards what the BrowserWindow is
|
||||||
|
* allowed to load.
|
||||||
|
*
|
||||||
|
* A "remote host" is one of the user's own machines exposed through the mTLS
|
||||||
|
* reverse tunnel at `https://<name>.terminal.yaojia.wang/`. The desktop app can
|
||||||
|
* switch the main window between "This machine (local)" (the embedded loopback
|
||||||
|
* server, which keeps running) and any configured remote host; the device client
|
||||||
|
* certificate from the OS keychain (see main.ts / D-2) is the only auth gate.
|
||||||
|
*
|
||||||
|
* Every function is PURE and returns NEW objects (immutability — coding-style
|
||||||
|
* CRITICAL rule); none mutate their arguments. No `electron` import, so this file
|
||||||
|
* is unit-testable in plain Node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`).
|
||||||
|
* Overridable so a differently-named deployment isn't hard-blocked. A remote host
|
||||||
|
* URL must be https (mixed-content + mTLS both require TLS) and, by default, live
|
||||||
|
* under this zone — a boundary sanity check, not the security boundary itself
|
||||||
|
* (that is mTLS at nginx + the will-navigate origin lock).
|
||||||
|
*/
|
||||||
|
export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang'
|
||||||
|
|
||||||
|
/** Parse an origin, returning null for anything malformed. */
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + normalize a user-entered remote-host URL. Returns the canonical
|
||||||
|
* `https://<host>/` form, or null when the input is not a usable https tunnel URL
|
||||||
|
* (unparseable, non-https, or — by default — outside the tunnel zone). Validation
|
||||||
|
* at the boundary: everything downstream may trust the returned string.
|
||||||
|
*/
|
||||||
|
export function normalizeRemoteUrl(raw: string): string | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw.trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:') return null
|
||||||
|
if (parsed.hostname === '') return null
|
||||||
|
if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Canonical form: origin + trailing slash (path/query/hash stripped — the
|
||||||
|
// frontend is served from the root, and the origin is all the lock compares).
|
||||||
|
return `${parsed.origin}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a RemoteHost from user input, or null if the name is blank or the URL is
|
||||||
|
* not a valid https tunnel URL. The id is a fresh uuid (stable across renames).
|
||||||
|
*/
|
||||||
|
export function makeRemoteHost(name: string, url: string): RemoteHost | null {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
const normalizedUrl = normalizeRemoteUrl(url)
|
||||||
|
if (trimmedName === '' || normalizedUrl === null) return null
|
||||||
|
return { id: randomUUID(), name: trimmedName, url: normalizedUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a configured host by id. */
|
||||||
|
export function findRemoteHost(
|
||||||
|
prefs: Readonly<DesktopPrefs>,
|
||||||
|
id: string,
|
||||||
|
): RemoteHost | undefined {
|
||||||
|
return prefs.remoteHosts.find((host) => host.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably append a host, de-duplicating by URL so re-adding the same machine
|
||||||
|
* replaces the old entry (and its name) rather than piling up duplicates.
|
||||||
|
*/
|
||||||
|
export function addRemoteHost(
|
||||||
|
prefs: Readonly<DesktopPrefs>,
|
||||||
|
host: RemoteHost,
|
||||||
|
): DesktopPrefs {
|
||||||
|
const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url)
|
||||||
|
return { ...prefs, remoteHosts: [...withoutDup, host] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably remove a host by id. If the removed host was selected, selection
|
||||||
|
* falls back to local (selectedHostId → null) so we never point at a gone host.
|
||||||
|
*/
|
||||||
|
export function removeRemoteHost(prefs: Readonly<DesktopPrefs>, id: string): DesktopPrefs {
|
||||||
|
const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id)
|
||||||
|
const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId
|
||||||
|
return { ...prefs, remoteHosts, selectedHostId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably select a host (null → local). Selecting an unknown id is a no-op —
|
||||||
|
* the caller stays on whatever was selected, never a dangling reference.
|
||||||
|
*/
|
||||||
|
export function selectHost(prefs: Readonly<DesktopPrefs>, id: string | null): DesktopPrefs {
|
||||||
|
if (id === null) return { ...prefs, selectedHostId: null }
|
||||||
|
if (findRemoteHost(prefs, id) === undefined) return { ...prefs }
|
||||||
|
return { ...prefs, selectedHostId: id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently-selected remote host, or null when local is selected (either
|
||||||
|
* selectedHostId is null, or it dangles at a host that no longer exists).
|
||||||
|
*/
|
||||||
|
export function selectedRemoteHost(prefs: Readonly<DesktopPrefs>): RemoteHost | null {
|
||||||
|
if (prefs.selectedHostId === null) return null
|
||||||
|
return findRemoteHost(prefs, prefs.selectedHostId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The origin a remote host's window should be locked to, or null if malformed. */
|
||||||
|
export function remoteHostOrigin(host: Readonly<RemoteHost>): string | null {
|
||||||
|
return originOf(host.url)
|
||||||
|
}
|
||||||
@@ -3,30 +3,92 @@
|
|||||||
* after its window is hidden (the embedded server must keep running so remote
|
* after its window is hidden (the embedded server must keep running so remote
|
||||||
* devices stay connected — the vibe-coding scenario).
|
* devices stay connected — the vibe-coding scenario).
|
||||||
*
|
*
|
||||||
|
* The context menu also hosts the D-1 host-picker (PLAN_NATIVE_TUNNEL /
|
||||||
|
* C-Desktop): "This machine (local)" ↔ each configured remote tunnel host, as a
|
||||||
|
* radio group, plus add/remove. The menu is rebuilt (never mutated) whenever the
|
||||||
|
* host list or selection changes — call buildTrayMenu() and setContextMenu().
|
||||||
|
*
|
||||||
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
|
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
|
||||||
* missing/invalid path can throw on some platforms; that failure surfaces to the
|
* missing/invalid path can throw on some platforms; that failure surfaces to the
|
||||||
* caller rather than being swallowed (a broken install should be visible, not
|
* caller rather than being swallowed (a broken install should be visible, not
|
||||||
* silently degrade). The caller owns whether to guard startup around it.
|
* silently degrade). The caller owns whether to guard startup around it.
|
||||||
*/
|
*/
|
||||||
import { Menu, Tray } from 'electron'
|
import { Menu, Tray } from 'electron'
|
||||||
|
import type { MenuItemConstructorOptions } from 'electron'
|
||||||
|
import type { RemoteHost } from './types.js'
|
||||||
|
|
||||||
const TRAY_TOOLTIP = 'Web Terminal'
|
const TRAY_TOOLTIP = 'Web Terminal'
|
||||||
|
/** Label for the local (embedded server) entry — selectedHostId === null. */
|
||||||
|
const LOCAL_HOST_LABEL = 'This machine (local)'
|
||||||
|
|
||||||
export interface TrayHandlers {
|
export interface TrayHandlers {
|
||||||
show(): void
|
show(): void
|
||||||
quit(): void
|
quit(): void
|
||||||
|
/** Switch the active host; null → "This machine (local)". */
|
||||||
|
selectHost(id: string | null): void
|
||||||
|
/** Prompt for + add a new remote host. */
|
||||||
|
addHost(): void
|
||||||
|
/** Remove a configured remote host by id. */
|
||||||
|
removeHost(id: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTray(iconPath: string, handlers: TrayHandlers): Tray {
|
/** The host-picker state the menu renders (the local entry is implicit). */
|
||||||
const tray = new Tray(iconPath)
|
export interface TrayHostState {
|
||||||
|
readonly remoteHosts: readonly RemoteHost[]
|
||||||
|
/** Selected host id, or null → local. */
|
||||||
|
readonly selectedHostId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate([
|
/** Build the tray context menu for the given host state (pure construction). */
|
||||||
{ label: 'Show Web Terminal', click: handlers.show },
|
export function buildTrayMenu(state: TrayHostState, handlers: TrayHandlers): Menu {
|
||||||
|
const localItem: MenuItemConstructorOptions = {
|
||||||
|
label: LOCAL_HOST_LABEL,
|
||||||
|
type: 'radio',
|
||||||
|
checked: state.selectedHostId === null,
|
||||||
|
click: () => handlers.selectHost(null),
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostItems: MenuItemConstructorOptions[] = state.remoteHosts.map((host) => ({
|
||||||
|
label: host.name,
|
||||||
|
type: 'radio',
|
||||||
|
checked: state.selectedHostId === host.id,
|
||||||
|
click: () => handlers.selectHost(host.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedRemoteId =
|
||||||
|
state.selectedHostId !== null &&
|
||||||
|
state.remoteHosts.some((host) => host.id === state.selectedHostId)
|
||||||
|
? state.selectedHostId
|
||||||
|
: null
|
||||||
|
|
||||||
|
const template: MenuItemConstructorOptions[] = [
|
||||||
|
{ label: 'Host', enabled: false },
|
||||||
|
localItem,
|
||||||
|
...hostItems,
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Quit', click: handlers.quit },
|
{ label: 'Add remote host…', click: () => handlers.addHost() },
|
||||||
])
|
{
|
||||||
|
label: 'Remove selected host',
|
||||||
|
enabled: selectedRemoteId !== null,
|
||||||
|
click: () => {
|
||||||
|
if (selectedRemoteId !== null) handlers.removeHost(selectedRemoteId)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Show Web Terminal', click: () => handlers.show() },
|
||||||
|
{ label: 'Quit', click: () => handlers.quit() },
|
||||||
|
]
|
||||||
|
|
||||||
|
return Menu.buildFromTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTray(
|
||||||
|
iconPath: string,
|
||||||
|
state: TrayHostState,
|
||||||
|
handlers: TrayHandlers,
|
||||||
|
): Tray {
|
||||||
|
const tray = new Tray(iconPath)
|
||||||
tray.setToolTip(TRAY_TOOLTIP)
|
tray.setToolTip(TRAY_TOOLTIP)
|
||||||
tray.setContextMenu(menu)
|
tray.setContextMenu(buildTrayMenu(state, handlers))
|
||||||
return tray
|
return tray
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | '
|
|||||||
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
|
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
|
||||||
export type DesktopPermissionGate = 'tool' | 'plan'
|
export type DesktopPermissionGate = 'tool' | 'plan'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A remote web-terminal host reachable over the mTLS reverse tunnel
|
||||||
|
* (Track C-Desktop, PLAN_NATIVE_TUNNEL §"C-Desktop / D-1"). `url` is the https
|
||||||
|
* origin the BrowserWindow loads, e.g. `https://t1.terminal.yaojia.wang/`; the
|
||||||
|
* device client certificate (D-2, sourced from the OS keychain) is the only auth
|
||||||
|
* gate. `id` is a stable opaque handle used by the tray host-picker + prefs.
|
||||||
|
*/
|
||||||
|
export interface RemoteHost {
|
||||||
|
/** Stable opaque id (uuid); the tray/prefs key, never shown to the user. */
|
||||||
|
readonly id: string
|
||||||
|
/** Human label shown in the tray host-picker. */
|
||||||
|
readonly name: string
|
||||||
|
/** The https origin URL to load, normalized to `https://<host>/`. */
|
||||||
|
readonly url: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
|
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
|
||||||
* store — matches the project's minimal-deps convention; no electron-store).
|
* store — matches the project's minimal-deps convention; no electron-store).
|
||||||
@@ -38,6 +54,10 @@ export interface DesktopPrefs {
|
|||||||
readonly notifyOnApproval: boolean
|
readonly notifyOnApproval: boolean
|
||||||
/** Fire a native notification when a session's Claude status changes. */
|
/** Fire a native notification when a session's Claude status changes. */
|
||||||
readonly notifyOnStatusChange: boolean
|
readonly notifyOnStatusChange: boolean
|
||||||
|
/** Configured remote tunnel hosts (D-1); empty on a fresh install. */
|
||||||
|
readonly remoteHosts: readonly RemoteHost[]
|
||||||
|
/** Selected host id, or null → "This machine (local)" (the embedded server). */
|
||||||
|
readonly selectedHostId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle returned by the embedded server, for lifecycle + window wiring. */
|
/** Handle returned by the embedded server, for lifecycle + window wiring. */
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
|
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
|
||||||
* unchanged web frontend, loaded from the embedded localhost server.
|
* unchanged web frontend, loaded from either the embedded localhost server or a
|
||||||
|
* selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop).
|
||||||
*
|
*
|
||||||
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
|
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
|
||||||
* off, sandbox on, preload restricted to a minimal contextBridge. Because the
|
* off, sandbox on, preload restricted to a minimal contextBridge. We deny every
|
||||||
* only page ever loaded is the trusted embedded http://127.0.0.1:<port> origin,
|
* new-window request and block navigation to any foreign origin — a defence-in-
|
||||||
* we deny every new-window request and block navigation to any foreign origin —
|
* depth guard against a hijacked page trying to escape the active origin.
|
||||||
* a defence-in-depth guard against a hijacked page trying to escape localhost.
|
*
|
||||||
|
* D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts,
|
||||||
|
* via a programmatic loadURL — which does NOT fire will-navigate) at exactly one
|
||||||
|
* host at a time: the embedded `http://127.0.0.1:<port>` origin OR the selected
|
||||||
|
* remote `https://<name>.terminal.yaojia.wang` origin. `getAllowedOrigin` returns
|
||||||
|
* whichever is active NOW, so renderer-initiated navigation stays locked to that
|
||||||
|
* single origin and everything else is still blocked.
|
||||||
*/
|
*/
|
||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
@@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720
|
|||||||
const BACKGROUND_COLOR = '#0e0f13'
|
const BACKGROUND_COLOR = '#0e0f13'
|
||||||
|
|
||||||
/** Parse the origin of a URL, returning null for anything malformed. */
|
/** Parse the origin of a URL, returning null for anything malformed. */
|
||||||
function originOf(url: string): string | null {
|
export function originOf(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
return new URL(url).origin
|
return new URL(url).origin
|
||||||
} catch {
|
} catch {
|
||||||
@@ -23,7 +30,16 @@ function originOf(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
|
/**
|
||||||
|
* Create the main window. `url` is the initial page to load; `getAllowedOrigin`
|
||||||
|
* is queried live on every navigation attempt and must return the origin the
|
||||||
|
* window is currently allowed on (or null to allow nothing / fail closed).
|
||||||
|
*/
|
||||||
|
export function createMainWindow(
|
||||||
|
url: string,
|
||||||
|
preloadPath: string,
|
||||||
|
getAllowedOrigin: () => string | null,
|
||||||
|
): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: WINDOW_WIDTH,
|
width: WINDOW_WIDTH,
|
||||||
height: WINDOW_HEIGHT,
|
height: WINDOW_HEIGHT,
|
||||||
@@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowedOrigin = originOf(url)
|
|
||||||
|
|
||||||
// Never spawn child windows; the frontend has no legitimate reason to.
|
// Never spawn child windows; the frontend has no legitimate reason to.
|
||||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
|
||||||
// Block navigation away from the embedded localhost origin.
|
// Block navigation away from the currently-active (local or remote) origin.
|
||||||
win.webContents.on('will-navigate', (event, targetUrl) => {
|
win.webContents.on('will-navigate', (event, targetUrl) => {
|
||||||
if (allowedOrigin === null) return
|
const allowedOrigin = getAllowedOrigin()
|
||||||
if (originOf(targetUrl) !== allowedOrigin) {
|
// Fail closed: if we can't determine the active origin, allow nothing.
|
||||||
|
if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
341
docs/PLAN_NATIVE_TUNNEL.md
Normal file
341
docs/PLAN_NATIVE_TUNNEL.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Native mTLS Reverse-Tunnel — Master Development Plan
|
||||||
|
|
||||||
|
> **Destination:** `docs/PLAN_NATIVE_TUNNEL.md`
|
||||||
|
> **Status:** planning (synthesized from three reviewed track-plans: VPS, Server, Clients)
|
||||||
|
> **Deployed baseline:** VPS `8.138.1.192` (Ubuntu 24.04, nginx 1.24, `:443` shared via `ssl_preread` stream — existing `*.term.yaojia.wang`→relay:8443 and `default`→xray:10443 preserved verbatim). Wildcard DNS `*.terminal.yaojia.wang → 8.138.1.192` is confirmed resolving.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
We are building a **multi-tenant reverse tunnel** so native iOS / Android / desktop clients can reach a **base web-terminal server** running on the user's *local* machine(s) from the public internet, relayed through the user's VPS. The base app speaks its own WS protocol (`attach`/`attached`, `/live-sessions`) — **not** the E2E relay — so the already-deployed browser relay cannot serve these clients; this is a parallel, independent ingress.
|
||||||
|
|
||||||
|
The tool is **frp**. Each local host runs `frpc`, which dials the VPS on `:443` and exposes its loopback base app (`127.0.0.1:3000`) as `<name>.terminal.yaojia.wang`. On the VPS everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream` (`ssl_preread`, TLS passthrough), coexisting with the relay and xray-Reality.
|
||||||
|
|
||||||
|
**The base app has no login/auth of its own.** Therefore **mTLS client-certificate authentication is the one and only security gate**, and it must be airtight. Two SNI routes are added:
|
||||||
|
|
||||||
|
- **`frp.terminal.yaojia.wang` → `frps` control `:7000`** (TLS passthrough). Guarded by frps **control-channel mTLS** (a per-host *frp-client CA*) **plus** a token — a leaked token alone must not let an attacker register a subdomain.
|
||||||
|
- **`*.terminal.yaojia.wang` → nginx `:8470`** — a loopback TLS-terminating server that presents a publicly-trusted **LE wildcard cert**, enforces **`ssl_verify_client on`** against a **device CA** (with a CRL), then reverse-proxies (WS-upgrade) to the frps vhost `:7080` → `frpc` → local base app.
|
||||||
|
|
||||||
|
```
|
||||||
|
Public internet (only :443 open in Aliyun SG)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────┐
|
||||||
|
│ nginx stream :443 ssl_preread │ (TLS passthrough, no term)
|
||||||
|
│ map $ssl_preread_server_name → │
|
||||||
|
└───────────────────────────────────┘
|
||||||
|
┌───────────────┬───────────────────┬────────────────┬─────────────┐
|
||||||
|
│ frp.terminal │ *.terminal │ *.term │ default │
|
||||||
|
│ .yaojia.wang │ .yaojia.wang │ .yaojia.wang │ (fallthru) │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
127.0.0.1:7000 127.0.0.1:8470 127.0.0.1:8443 127.0.0.1:10443
|
||||||
|
┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ frps │ │ nginx :8470 │ │ E2E relay│ │ xray │ ← EXISTING,
|
||||||
|
│ control │ │ TLS-term + │ │ (browser)│ │ Reality │ preserved
|
||||||
|
│ mTLS+tok │ │ mTLS device-CA│ └──────────┘ └──────────┘ verbatim
|
||||||
|
│ :7000 │ │ + CRL │
|
||||||
|
└────┬─────┘ │ proxy→ :7080 │
|
||||||
|
control │ └──────┬────────┘
|
||||||
|
channel │ │ vhost HTTP (WS upgrade)
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
└──────────►│ frps vhost │ routes by Host → subdomain
|
||||||
|
│ :7080 │
|
||||||
|
└──────┬───────┘
|
||||||
|
│ frp tunnel (frpc dials OUT :443, frp-TLS)
|
||||||
|
▼
|
||||||
|
══════════════ user's LOCAL machine ══════════════
|
||||||
|
┌──────────────┐
|
||||||
|
│ frpc │ subdomain=<name>, localPort=3000
|
||||||
|
└──────┬───────┘
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ base app │ BIND_HOST=127.0.0.1 :3000 (loopback ONLY)
|
||||||
|
│ ALLOWED_ORIGINS = https://<name>.terminal.yaojia.wang
|
||||||
|
└──────────────┘
|
||||||
|
▲
|
||||||
|
native client ────────┘ iOS / Android / desktop present a DEVICE CERT
|
||||||
|
https://<name>.terminal.yaojia.wang (mTLS) → speak base WS /term + /live-sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trust model (declare in writing before shipping — this changes what "airtight" means):**
|
||||||
|
|
||||||
|
- **Model A — single-owner fleet** (matches the GOAL: "the user's LOCAL machine(s)"). Every device cert and host belongs to one operator. A single shared device-CA = "the closed set of my devices"; cross-host reach between your own machines is not a violation. **This is the v1 target.**
|
||||||
|
- **Model B — true multi-tenant** (distrusting third parties enroll). A single shared device-CA gives **zero cross-tenant isolation** (any valid cert reaches any subdomain). Model B is **out of scope for v1** and must not ship on the shared-CA design; §5-R3 records the mitigation path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The three tracks
|
||||||
|
|
||||||
|
### Track V — VPS (`8.138.1.192`)
|
||||||
|
|
||||||
|
Coexistence rule for every phase: never retype the existing `map` lines; merge additively; deploy only via `nginx -t && systemctl reload nginx`; re-run the coexistence oracle after each phase.
|
||||||
|
|
||||||
|
**V0 — Preflight & snapshot (~20 min)**
|
||||||
|
- [ ] `cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}`; `nginx -T > /root/nginx-dump.pre.txt`; `ss -ltnp | sort > /root/ports.pre.txt`.
|
||||||
|
- [ ] Capture the **exact** current `map` body (`hostnames; *.term.yaojia.wang→8443; default→10443`) and `nginx -v` (expect 1.24 → no standalone `http2` directive is legal).
|
||||||
|
- [ ] `dig +short {foo,frp}.terminal.yaojia.wang` → `8.138.1.192`.
|
||||||
|
- [ ] Capture "before" oracle: relay 200 + xray real-MS cert (`s_client`/`curl` probes).
|
||||||
|
- **Verify:** all reads succeed, `.bak` exists. **Risk:** none.
|
||||||
|
|
||||||
|
**V1 — frps: control `:7000` (mTLS) + vhost `:7080`, dedicated user, systemd (~1h)**
|
||||||
|
- [ ] Install `frps` ≥ v0.52 (`disableCustomTLSFirstByte` exists ≥ v0.50). Mainland fetch may be blocked → `scp` from laptop.
|
||||||
|
- [ ] Dedicated unprivileged `frp` user (loopback ports need no root) + systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=/var/log/frp`).
|
||||||
|
- [ ] `/etc/relay/frp/frps.toml` (0600, `frp:frp`):
|
||||||
|
```toml
|
||||||
|
bindAddr = "127.0.0.1"
|
||||||
|
bindPort = 7000
|
||||||
|
vhostHTTPPort = 7080
|
||||||
|
auth.method = "token"
|
||||||
|
auth.token = "<FRP_TOKEN>" # openssl rand -hex 32 → /etc/relay/secrets.env
|
||||||
|
subDomainHost = "terminal.yaojia.wang"
|
||||||
|
transport.tls.force = true # control-channel mTLS — token is NOT the sole gate
|
||||||
|
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem"
|
||||||
|
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem"
|
||||||
|
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem"
|
||||||
|
log.to = "/var/log/frp/frps.log"
|
||||||
|
log.level = "info"
|
||||||
|
```
|
||||||
|
- **Verify:** both ports loopback-bound; `journalctl -u frps` clean; `curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/` → frp 404 "no proxy". **Risk:** binary fetch (scp); token/cert never committed.
|
||||||
|
|
||||||
|
**V1b — Registration-authorization decision (~30 min–1h) — closes the subdomain-takeover hole**
|
||||||
|
- [ ] **Model A (v1):** control-channel mTLS from V1 is the accepted gate. Issue **frp-client certs per host** (revoke one host by rotating the `frp-client-ca` trust without touching device certs). Document: the frp-client cert + token is a trusted secret whose leak compromises all hosts.
|
||||||
|
- [ ] **Model B (deferred):** add an frps `[[httpPlugins]]` NewProxy authz delegate (reuse `term-relay/frp-scaffold/plugin-hook.ts`) mapping client-cert CN → allowed subdomain, rejecting mismatches.
|
||||||
|
- **Verify:** an `frpc` with a valid token but **no** frp-client cert is refused at the control handshake; (Model B) host-A's cert claiming `subdomain=hostB` is refused.
|
||||||
|
|
||||||
|
**V2 — Device client-CA + issuance + revocation (~2h)**
|
||||||
|
- [ ] `deploy/scripts/gen-device-ca.sh`: EC **P-256** self-signed CA (`CA:TRUE`, `keyCertSign,cRLSign`, 3650d) → `/etc/relay/device-ca/` (0700), idempotent, separate trust root from agent-CA/web-PKI. Parametrize to also scaffold `frp-client-ca` for V1.
|
||||||
|
- [ ] `deploy/scripts/issue-device-cert.sh`: P-256 leaf, `EKU=clientAuth`, 825d; emits `.p12` (`openssl pkcs12 -export -legacy` for iOS/Android) **and** `.pem`/`.key.pem`/`.fullchain.pem` (desktop/curl). Appends CN/serial to `issued.log`.
|
||||||
|
- [ ] `deploy/scripts/revoke-device.sh`: `openssl ca` revoke → regenerate `/etc/relay/device-ca/crl.pem` → `nginx -s reload`. **Initialize an empty CRL now** so V4 can reference it.
|
||||||
|
- **Verify:** `openssl verify -CAfile device-ca.cert.pem test-device.pem` → OK; p12 lists the leaf; revoke → CRL lists the serial. **Risk:** p12 import quirks (mitigate `-legacy`); doc "P-256, never Ed25519".
|
||||||
|
|
||||||
|
**V3 — Wildcard server cert via LE DNS-01 (~1h) — LE is mainline (hard iOS prerequisite)**
|
||||||
|
- [ ] `acme.sh --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang` → install `/etc/relay/frp-tls/fullchain.pem` + `privkey.pem` (0600), `--reloadcmd 'nginx -s reload'`. Needs `Ali_Key`/`Ali_Secret`. Separate cert from `*.term` (different parent label — do not try to cover both).
|
||||||
|
- **Verify:** SAN shows `*.terminal.yaojia.wang`; `curl https://<name>.terminal.yaojia.wang` from a stock machine validates with **no `-k`**. **Risk:** Aliyun DNS API creds/propagation — if unavailable, iOS bring-up stalls (self-signed fails on iOS).
|
||||||
|
|
||||||
|
**V4 — nginx `:8470` = TLS-term + mTLS + WS proxy (~1h)**
|
||||||
|
- [ ] `/etc/nginx/conf.d/frp-mtls.conf`:
|
||||||
|
```nginx
|
||||||
|
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 127.0.0.1:8470 ssl; # NO `http2 off;` — invalid on nginx 1.24
|
||||||
|
server_name ~^.+\.terminal\.yaojia\.wang$;
|
||||||
|
|
||||||
|
ssl_certificate /etc/relay/frp-tls/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
|
||||||
|
|
||||||
|
# --- the data-path security gate ---
|
||||||
|
ssl_verify_client on; # ON, never `optional`
|
||||||
|
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
|
||||||
|
ssl_verify_depth 1;
|
||||||
|
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:7080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Origin https://$host; # needed for Android/curl; redundant-harmless for iOS
|
||||||
|
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s)
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Verify (loopback, before touching the stream), with `test-device`:** no-cert → TLS handshake failure (must fail); `--cert/--key` → frp 404 (reaches frps); revoked cert (added to CRL) → handshake failure.
|
||||||
|
|
||||||
|
**V5 — stream SNI additions (~30 min)**
|
||||||
|
- [ ] Merge into the **captured** `map` body (do not retype the existing two lines):
|
||||||
|
```nginx
|
||||||
|
frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard
|
||||||
|
*.terminal.yaojia.wang 127.0.0.1:8470;
|
||||||
|
# existing *.term.yaojia.wang → 8443 and default → 10443 preserved verbatim
|
||||||
|
```
|
||||||
|
- [ ] `nginx -t && systemctl reload nginx`.
|
||||||
|
- **Verify:** 4-way `s_client -servername` matrix (frp control bytes / `*.terminal` LE cert / relay cert / xray real-MS cert). **Risk:** map typo → matrix + `.bak` + reload gate.
|
||||||
|
|
||||||
|
**V6 — End-to-end + acceptance (~1.5h)** — test `frpc` → local base app; full acceptance matrix (see M1). **Verify:** items 1–7 in Milestone M1.
|
||||||
|
|
||||||
|
**V7 — Per-host runbook + committable template (~45 min)**
|
||||||
|
- [ ] `deploy/frp/frpc.example.toml` (no secrets) + short runbook: `BIND_HOST=127.0.0.1`, exact `ALLOWED_ORIGINS`, secure P12/cert delivery (scp not email), `revoke-device.sh` procedure, the frp-client trust-boundary note.
|
||||||
|
|
||||||
|
**Track-V effort: ~8–10h.** New files touch nothing existing (all loopback listeners + two additive `map` lines).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Track S — Server / host packaging (base app — **zero `src/` changes**)
|
||||||
|
|
||||||
|
> Headline (verified): a working tunnel needs **zero base-app code changes** — env-extensible `ALLOWED_ORIGINS`, no `Host` reliance, PTY⊥WS decoupling. But "working" ≠ "safe": the tunnel MUST NOT be switched on until **S-GATE** is green.
|
||||||
|
|
||||||
|
**S-GATE — Preconditions (BLOCKING; before `frpc` starts on ANY host)**
|
||||||
|
- [ ] **Declare the trust model** (Model A / Model B) in the deploy doc — one line. v1 = Model A.
|
||||||
|
- [ ] **Prove mTLS is enforced / cert-free is rejected** (owned by Track V): `curl -sI https://<name>.terminal.yaojia.wang/live-sessions` **with no cert** → TLS failure. If it returns `200`, **STOP — the shell is world-open.**
|
||||||
|
- [ ] **Prove a client can present a cert** (owned by Track C): `curl --cert dev.crt --key dev.key -sI …/live-sessions` → `200`.
|
||||||
|
- **Effort:** 0.5h + cross-track wait. **Risk if skipped:** unauthenticated public root shell.
|
||||||
|
|
||||||
|
**S0 — Base-app config per host (no code)**
|
||||||
|
- [ ] Run the **compiled** app: `npm ci && npm run build` → `node dist/server.js` (`build:web` only if this host also serves the browser cockpit).
|
||||||
|
| Var | Value | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`; desktop-embedded may drift off 3000 — pin it (S2). |
|
||||||
|
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default is `0.0.0.0` (`src/config.ts:37`) → leaving it default exposes an **unauth'd shell on the LAN**, bypassing mTLS entirely. |
|
||||||
|
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | Bare host. `:443` also matches (URL normalizes it away on both sides; iOS omits it) — **not** a failure mode. A *non-default* port would mismatch. |
|
||||||
|
| `SHELL_PATH` | `/bin/zsh` · `/bin/bash` · `powershell.exe` | per-OS |
|
||||||
|
| `IDLE_TTL` | ≥ `86400` (raise for multi-day walk-away) | reaps only after detached **and** silent past TTL |
|
||||||
|
| `USE_TMUX` | `1` on *nix, `0`/unset on Windows | cross-restart PTY survival |
|
||||||
|
- **Origin note:** the client must send *some* `Origin` matching `ALLOWED_ORIGINS` or it 401s — a **functional** contract, not an authz boundary (a native client can send any Origin). Already satisfied: `HostEndpoint.originHeader` and the server's `isOriginAllowed` both normalize via WHATWG URL.
|
||||||
|
- **Verify:** `lsof -iTCP:3000 -sTCP:LISTEN` shows `127.0.0.1:3000` (not `*`); Origin-gate curl pair (101 good / 401 bogus). **Effort:** 0.5h/host.
|
||||||
|
|
||||||
|
**S1 — frpc on the host**
|
||||||
|
- [ ] `frpc.toml` (see M1 for the full block): `type=http`, unique `subdomain=<name>`, `transport.tls.serverName=frp.terminal.yaojia.wang`, `disableCustomTLSFirstByte=true`, frp-client cert/key (V1 control mTLS), `loginFailExit=false`, token `chmod 600`.
|
||||||
|
- [ ] Keep a **name registry** (frps rejects dup subdomains; while a host's frpc is offline anyone with the token could claim its subdomain — closed in Model A by the frp-client cert).
|
||||||
|
- **Verify:** frpc log `start proxy success`; S-GATE curl-with-cert → 200; kill frpc → app + live PTY survive → restart → client reattaches with replay. **Effort:** 1–2h first host, ~15 min after.
|
||||||
|
|
||||||
|
**S2 — Durable service packaging (the real work; ~1.5–2.5 days)**
|
||||||
|
- [ ] Port the *shape* of `agent/src/service/{launchd,systemd,install}.ts`, but note the gaps: the **launchd writer has no `<EnvironmentVariables>`** and the **systemd writer has no `EnvironmentFile`** — extend both to inject S0 env. `agent/src/service/originConfig.ts` writes the **wrong zone** (`.term.` not `.terminal.`) — parameterize the zone or write fresh.
|
||||||
|
- [ ] Two supervised user-scoped units per host (app + frpc), never root (keep the `RootRefusedError` pattern). Linux: `loginctl enable-linger`. Windows: WinSW/nssm, `USE_TMUX=0`.
|
||||||
|
- [ ] **Desktop-embedded hosts:** `pickFreePort` can drift off 3000 → **pin the port**; inject `ALLOWED_ORIGINS` (today `buildServerEnv` only relays ambient env). The Electron app must be **always-running** for the embedded server to exist → for walk-away hosts, prefer the standalone `node dist/server.js` service over the GUI.
|
||||||
|
- **Verify:** reboot → both units up, tunnel serves within ~30s; `kill -9` each → supervisor restarts. **Effort:** 1.5–2.5 days for env-injecting cross-OS templates + install script + 80%-coverage tests; ~15 min/host after.
|
||||||
|
|
||||||
|
**S3 — Survival & health validation (~1–2h)**
|
||||||
|
- Verified guarantees hold: PTY⊥WS (`src/server.ts:841` close→`detachWs`, never kill), idle reaper reaps only detached+silent (`:686`), client backoff-reconnect with `sessionId` replay.
|
||||||
|
- [ ] **Idle-WS timeout (was missing):** the server sends **no** WS pings. Fixed cross-track by V4's `proxy_read_timeout 3600s` **and** client periodic pings (iOS `URLSessionWebSocketTask.sendPing`).
|
||||||
|
- **Verify matrix:** (a) kill frpc mid-session → PTY alive, reattach on restart; (b) `IDLE_TTL=60` → detached+silent reaped, active not; (c) **idle 5 min, tunnel up → WS not dropped**; (d) reboot VPS → frpc re-establishes, client resumes.
|
||||||
|
|
||||||
|
**S4 — mTLS scoping (Model A: accept; Model B: conditional-CRITICAL)**
|
||||||
|
- [ ] Multi-tenant cert→host authorization (CRITICAL in B, N/A in A): per-tenant client-CA selected by `$host`/SNI, or `map $host → $expected_ou` reject on mismatch.
|
||||||
|
- Notes for the record: `/hook*` is tunnel-reachable (`isLoopback()` sees frpc's `127.0.0.1`) and `/live-sessions/:id/preview` (`:323`) **leaks scrollback** to any cert-holder with no Origin guard — sets Model B's blast radius (a foreign valid cert reads your terminals, not just opens new ones). Accept in Model A.
|
||||||
|
|
||||||
|
**Track-S effort: ~2.5–3.5 days** (S2 tooling + S3 validation) + a blocking wait on V and C tracks for the S-GATE proof. Zero `src/` changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Track C — Native clients (present the device cert)
|
||||||
|
|
||||||
|
**Client preconditions (gate on these; mostly infra):** P-A `BIND_HOST=127.0.0.1` on every host; P-B frps `:7080`/`:7000` loopback-only & absent from the Aliyun SG (`nmap … -p 7000,7080` closed); P-C LE wildcard live (self-signed fails iOS ATS + Chromium + Android default TLS); P-D mTLS `on` not `optional`, trust anchor = device-CA only; P-E scope decision (A/B).
|
||||||
|
|
||||||
|
#### C-iOS — do first (client exists; ~5–7 dev-days)
|
||||||
|
|
||||||
|
**iOS-1 · `ClientTLS` leaf SPM package** (`ios/Packages/ClientTLS/`, imports `Security`/`Foundation`)
|
||||||
|
- [ ] `ClientIdentity.swift` — `@unchecked Sendable` wrapper over `SecIdentity` + issuer chain.
|
||||||
|
- [ ] `PKCS12Importer.swift` — `SecPKCS12Import`; map `errSecAuthFailed`→`.wrongPassphrase`, `errSecDecode`→`.corruptFile`, `errSecPkg`→`.unsupported`.
|
||||||
|
- [ ] `KeychainClientIdentityStore.swift` — persist raw `.p12` + passphrase under `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, re-import at launch.
|
||||||
|
- [ ] `MutualTLSChallengeResponder.swift` — pure `resolve(challenge, identity:)`: `ClientCertificate`+identity → `.useCredential(URLCredential(identity:…, persistence:.forSession))`; `ClientCertificate`+no identity → `.cancelAuthenticationChallenge` (clean, classifiable error); `ServerTrust` → `.performDefaultHandling` (LE validated by system).
|
||||||
|
- **Verify:** `swift test --package-path Packages/ClientTLS` — responder truth table (3 challenge types × identity present/absent) + import of a CI-generated fixture `.p12` incl. wrong-passphrase.
|
||||||
|
|
||||||
|
**iOS-2 · Wire responder into both transports**
|
||||||
|
- [ ] `URLSessionTermTransport`/`WSConnection`: add `init(identity:)`; implement **`urlSession(_:task:didReceive:completionHandler:)`** (task-level receives connection-level challenges) delegating to the responder; keep `didCompleteWithError` intact. *(Note: the current transport delegate has no challenge handler at all — this is net-new.)*
|
||||||
|
- [ ] `URLSessionHTTPTransport`: build the session with an `NSObject` `ClientTLSSessionDelegate` implementing the session-level `urlSession(_:didReceive:completionHandler:)`. Apply to both `production()` and `SessionThumbnail.liveTransport` (`SessionThumbnail.swift:242`).
|
||||||
|
- [ ] `AppEnvironment.production()`: load identity from the store, inject into both transports + probe closure.
|
||||||
|
- **Verify:** `xcodebuild test`; `FakeChallenge` unit test asserts each transport returns the credential iff an identity is set.
|
||||||
|
|
||||||
|
**iOS-3 · Install UX + pairing guard + reworded warning**
|
||||||
|
- [ ] `ClientCertScreen.swift` — `.fileImporter([.pkcs12])` + secure passphrase field → import → store → show issuer CN + **expiry** + "replace certificate" (rotation). Settings entry "Device certificate."
|
||||||
|
- [ ] `PairingViewModel`: for a `.terminal.yaojia.wang` host, (a) block probe if no identity installed ("先安装本设备证书"); (b) **replace the `publicHostBlocking` copy** for tunnel hosts (the current "anyone who can reach the port gets a shell" is false under mTLS and deters the intended flow). Keep the blocking tier for genuinely public non-tunnel hosts.
|
||||||
|
- [ ] Add a `.clientCertRejected` classification (nginx-rejected mTLS surfaces as `NSURLErrorSecureConnectionFailed`/reset — currently mis-classified `.tlsFailure` "server cert invalid"). Copy: "本设备证书无效或已吊销,请重新导入。" *(`PairingError.swift` already maps `NSURLErrorClientCertificateRequired/Rejected` — primed.)*
|
||||||
|
- **Install UX:** deliver `device.p12` (AirDrop/Files) → WebTerm → Settings → Device certificate → Import + passphrase (in-app, app-sandboxed; not a config profile).
|
||||||
|
- **Verify:** XCUITest import fixture → pair `https://<name>.terminal.yaojia.wang` → probe → attach. Manual on-device against the real VPS; confirm nginx logs `$ssl_client_verify=SUCCESS`.
|
||||||
|
|
||||||
|
#### C-Desktop — do second (Electron; ~6–9 dev-days) — reverses `DESKTOP_PLAN.md §0` (record the deviation)
|
||||||
|
|
||||||
|
**D-1 · Remote-host mode**
|
||||||
|
- [ ] `desktop/src/remote-hosts.ts` — `RemoteHost{id,name,url}`, persisted via `settings-store.ts` (extend `DesktopPrefs`).
|
||||||
|
- [ ] `window.ts`/`main.ts`: allow a `BrowserWindow` to load `https://<name>.terminal.yaojia.wang/`; **relax the `will-navigate` block to exactly the selected remote origin** (keep it locked to that one origin; keep the embedded local server running — this box is also its own tunnel source). Tray host-picker: "This machine (local)" ↔ each remote host.
|
||||||
|
- **Verify:** `npm run typecheck`; switch to a remote host, confirm page + `wss://` load; confirm the origin lock still blocks elsewhere.
|
||||||
|
|
||||||
|
**D-2 · mTLS via OS store (the only viable path)**
|
||||||
|
- [ ] `main.ts`: `app.on('select-client-certificate', (event, wc, url, list, cb) => { event.preventDefault(); const cert = pickByIssuer(list, DEVICE_CA_CN); cb(cert) })`. **`event.preventDefault()` is mandatory** (else Electron auto-picks `list[0]`). Empty/no-match → clear "device certificate not installed" dialog, never silent `cb()`.
|
||||||
|
- **Drop** the "app reads `.p12`" idea — infeasible: Chromium sources client certs **only** from the OS store; `setCertificateVerifyProc` is server-side only. The `.p12` must live in the login keychain / Personal store. Document the macOS private-key ACL "Always Allow" one-time prompt.
|
||||||
|
- **Install UX:** macOS double-click `.p12` → login keychain; Windows → Personal store; app auto-selects by issuer.
|
||||||
|
- **Verify:** end-to-end connect; nginx logs a verified cert; empty keychain → dialog, not a hang.
|
||||||
|
|
||||||
|
**D-3 · Local `frpc` supervision (coordinate with Track V/S)**
|
||||||
|
- [ ] Optionally spawn/monitor `frpc` from `main.ts` and set the embedded server's `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang` (additive via `config.ts:214`). Embedded server already defaults `BIND_HOST=127.0.0.1` (`server-config.ts:17`) — satisfies P-A **unless `lanSharing` is enabled** (document: re-opens the unauth'd LAN path).
|
||||||
|
- **Verify:** desktop-launched frpc registers; host reachable from an off-LAN client.
|
||||||
|
|
||||||
|
#### C-Android — do last (no `android/` exists; WebView MVP ~2.5–3.5 wk)
|
||||||
|
|
||||||
|
Pick **WebView shell** (mirrors the desktop pattern; xterm.js in `public/` is the terminal; avoids the Termux GPLv3 entanglement of full-native).
|
||||||
|
- [ ] **C-1 Shell:** Kotlin, single Activity, hardened `WebView` (JS on, file access off, one allowed origin). Verify: loads a base app.
|
||||||
|
- [ ] **C-2 mTLS:** `WebViewClient.onReceivedClientCertRequest` → `req.proceed(privateKey, chain)` from `KeyStore.getInstance("PKCS12")`; import via SAF (`ACTION_OPEN_DOCUMENT`) + passphrase; `.p12` app-private, key wrapped by Android Keystore. Verify: connect to `<name>.terminal.yaojia.wang`; nginx logs verified cert; wrong/absent → clean error UI.
|
||||||
|
- [ ] **C-3 Host mgmt:** multi-host list (DataStore/Room), add/switch/remove.
|
||||||
|
- [ ] **C-4 Notifications + deep links:** reuse the ntfy bridge (matches existing infra) rather than FCM; `webterminal://` intent filter.
|
||||||
|
- Defer full-native (Compose + terminal-view + OkHttp `X509KeyManager`) — 4–8 wk, pulls in GPLv3.
|
||||||
|
|
||||||
|
**Track-C effort: iOS 5–7 d · Desktop 6–9 d · Android 2.5–3.5 wk**, gated on preconditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cross-track dependencies & build order
|
||||||
|
|
||||||
|
**Dependency graph**
|
||||||
|
- `Track V (V0→V6)` proves the server-side gate → **unblocks S-GATE #2** and the entire tunnel-enable step.
|
||||||
|
- `Track C iOS-1/2` (first client cert support) → **unblocks S-GATE #3**.
|
||||||
|
- `Track S S-GATE` gates `S1` (frpc on any host) on **both** V's cert-rejection proof and C's cert-presentation proof.
|
||||||
|
- `V3 (LE wildcard)` is a **hard prerequisite** for all three clients (P-C) — do it early.
|
||||||
|
- `V4 proxy_read_timeout` + client pings jointly fix idle-WS kill (S3).
|
||||||
|
- Desktop `D-3` and Android depend on the per-host runbook `V7` + service tooling `S2`.
|
||||||
|
|
||||||
|
**Concrete build ORDER**
|
||||||
|
1. **V0 → V5** (VPS: frps + device-CA + LE + nginx mTLS + SNI routes). Fully self-verifiable with `curl --cert` / `websocat`.
|
||||||
|
2. **V6** end-to-end with a throwaway `frpc` + a spare base app on the VPS/laptop → **Milestone M1** (mTLS proven with `curl`, no client yet).
|
||||||
|
3. In parallel once M1 is green: **C-iOS (iOS-1 → iOS-3)** and **S0/S1** on the first real local host.
|
||||||
|
4. **First real end-to-end from iOS** → **Milestone M2**.
|
||||||
|
5. **S2** durable service tooling → **S-GATE** repeatable per host → onboard a 2nd/3rd host → **Milestone M3**.
|
||||||
|
6. **C-Android** WebView MVP → **Milestone M4**.
|
||||||
|
7. **C-Desktop** remote mode + OS-store mTLS → **Milestone M5**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Milestones
|
||||||
|
|
||||||
|
- **M1 — VPS tunnel up + one local host + mTLS proven with ONE device (curl) end-to-end.** Acceptance matrix on `t1.terminal.yaojia.wang` (base app `BIND_HOST=127.0.0.1 ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang PORT=3000`; `frpc.toml`):
|
||||||
|
```toml
|
||||||
|
serverAddr="8.138.1.192"; serverPort=443; auth.token="<FRP_TOKEN>"
|
||||||
|
transport.tls.enable=true
|
||||||
|
transport.tls.serverName="frp.terminal.yaojia.wang"
|
||||||
|
transport.tls.disableCustomTLSFirstByte=true # CRITICAL for ssl_preread
|
||||||
|
transport.tls.certFile="<host-frp-client.pem>" # frps control mTLS
|
||||||
|
transport.tls.keyFile="<host-frp-client.key.pem>"
|
||||||
|
transport.tls.trustedCaFile="/etc/.../frps-ctrl-ca.pem"
|
||||||
|
[[proxies]]
|
||||||
|
name="t1"; type="http"; localIP="127.0.0.1"; localPort=3000; subdomain="t1"
|
||||||
|
```
|
||||||
|
1. no device cert → TLS handshake failure. 2. `--cert test-device.pem --key …` → `200` JSON from `/live-sessions`. 3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`. 4. **H1 negative:** a 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control. 5. revoked device cert → rejected (CRL). 6. first byte to `:443` is `0x16` (`tcpdump`, proves `disableCustomTLSFirstByte`). 7. 4-zone SNI + V0 coexistence oracle green; `journalctl -u xray -u nginx` clean.
|
||||||
|
- **M2 — iOS mTLS end-to-end.** Real iPhone with an imported `device.p12` pairs `https://t1.terminal.yaojia.wang`, probes `/live-sessions`, attaches `/term`, and drives a live shell; nginx logs `$ssl_client_verify=SUCCESS`; idle-5-min WS stays up.
|
||||||
|
- **M3 — Multi-host.** Two+ real local hosts (`h1`, `h2`) each running the durable service (S2) + frpc; each reachable at its own subdomain; onboarding a new host = runbook only (no VPS change); revoke one device removes its access fleet-wide.
|
||||||
|
- **M4 — Android WebView MVP.** Android device imports `.p12`, connects to a host over mTLS, manages ≥2 hosts, receives an ntfy push → deep-links into a session.
|
||||||
|
- **M5 — Desktop remote mode.** Electron app switches to a remote host, selects the device cert from the OS keychain, connects over `wss://` + mTLS; origin lock still blocks foreign navigation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risk register
|
||||||
|
|
||||||
|
| # | Risk | Sev | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **R1** | **mTLS client-cert support is net-new on all three clients** (iOS transport has no `didReceive challenge`; Android/desktop from-scratch). Until it ships, no client can reach a tunneled host. **Critical-path blocker.** | **CRITICAL** | Do iOS first (smallest, exists) to prove the whole path; VPS side is fully verifiable now with `curl --cert`/`websocat` independent of clients. LE wildcard (V3) de-scopes clients to only the client-cert branch. |
|
||||||
|
| **R2** | **Base-app public exposure — mTLS is the ONLY gate and is bypassable.** `BIND_HOST` default `0.0.0.0` serves an **unauth'd shell on the LAN**; frps `:7080`/`:7000` off-loopback or a single `ssl_verify_client optional`/misconfig = every session world-open; `/live-sessions/:id/preview` leaks scrollback to any cert-holder. | **CRITICAL** | Mandatory `BIND_HOST=127.0.0.1` (S0, P-A); frps loopback + absent from Aliyun SG (P-B, verify `nmap`); `ssl_verify_client on` never `optional` (P-D); **S-GATE blocks all tunnel-enable** until cert-free rejection is proven; CRL from day one (V2/V4). |
|
||||||
|
| **R3** | **frps registration = subdomain takeover / active MITM.** Shared token isn't bound to a subdomain → any token-holder registers any subdomain and MITMs a valid-cert client. | HIGH | Control-channel mTLS (V1, per-host frp-client CA) so a leaked token alone is insufficient; NewProxy authz plugin pins cert→subdomain for Model B (V1b). |
|
||||||
|
| **R4** | **No cross-tenant isolation under one device-CA** (Model B). | HIGH (B only) | Declare **Model A** for v1; Model B needs per-tenant CA / `$host`→OU map + per-host frp token. Do not ship B on shared-CA. |
|
||||||
|
| **R5** | **Self-signed server cert is a hard iOS blocker** (URLSession has no tap-to-accept). | HIGH | LE DNS-01 wildcard is mainline (V3) — system-trusted on all three platforms. |
|
||||||
|
| **R6** | **Idle-WS proxy kill** (~60s nginx default, no app-level pings) → reconnect churn. | HIGH | V4 `proxy_read_timeout/proxy_send_timeout 3600s` + client periodic WS pings; explicit idle-5-min test (S3, M2). |
|
||||||
|
| **R7** | **`http2 off;` invalid on nginx 1.24** → `nginx -t` fails, reload aborted. | HIGH (deploy) | Omit the directive (HTTP/2 off by default); pin at V0 via `nginx -v`. |
|
||||||
|
| **R8** | **No revocation** → one lost device = fleet access until full CA rotation. | HIGH | `ssl_crl` + `revoke-device.sh` from day one (V2); client re-import/rotate path (iOS-3, D-2, C-2). |
|
||||||
|
| **R9** | **Desktop-embedded weak substrate** — dynamic port drift off 3000; GUI must stay running. | MED | Pin the port + inject `ALLOWED_ORIGINS`; prefer standalone `node dist/server.js` service for walk-away hosts. |
|
||||||
|
| **R10** | **Env-injection missing** in `launchd`/`systemd` writers; `originConfig.ts` uses wrong zone. | MED | Treat S2 as real work (~1.5–2.5 d), not copy-paste; parameterize the zone. |
|
||||||
|
| **R11** | **Cert-provisioning ops burden** grows with device count. | MED | Small enrollment helper wrapping `issue-device-cert.sh`; secure `.p12` delivery (scp/AirDrop, not email). |
|
||||||
|
| **R12** | **LE DNS-01 depends on Aliyun API creds/propagation.** | MED | Provision `Ali_Key`/`Ali_Secret` early; if unavailable, iOS bring-up stalls — sequence V3 before M2. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MVP fast-path — shortest route to ONE device securely reaching ONE host
|
||||||
|
|
||||||
|
Goal: an iPhone opens a shell on your laptop through the VPS, with mTLS as the only gate, in the fewest steps. **Model A. Defer** the NewProxy authz plugin, multi-host tooling, Android, desktop, and durable service packaging.
|
||||||
|
|
||||||
|
1. **VPS `V0`→`V5`** but minimal: install frps with **control mTLS + token** (V1), `gen-device-ca.sh` + `issue-device-cert.sh` + empty CRL (V2), **LE wildcard** (V3, non-negotiable for iOS), nginx `:8470` mTLS (V4), two SNI `map` lines (V5). *(~5–6h.)*
|
||||||
|
2. **`V6`/M1 with curl:** run one throwaway base app + `frpc name=t1` on the laptop (`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang`). Prove: no-cert → reject; `curl --cert` → `200`; `websocat` `attach`→`attached`. **→ M1.**
|
||||||
|
3. **iOS client cert only:** build `ClientTLS` (iOS-1) + wire the two transports (iOS-2) + a minimal import screen (iOS-3, skip the reworded-warning polish for now). Import `device.p12`, pair `https://t1.terminal.yaojia.wang`, attach. **→ M2.**
|
||||||
|
|
||||||
|
**Fast-path total: ~1.5–2 days VPS + ~4–5 dev-days iOS.** Everything else (authz plugin, S2 durable services, multi-host, Android, desktop remote mode) layers on afterward without reworking this core.
|
||||||
172
docs/PLAN_RELAY_PHASE1.md
Normal file
172
docs/PLAN_RELAY_PHASE1.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)
|
||||||
|
|
||||||
|
> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
|
||||||
|
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
|
||||||
|
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
|
||||||
|
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
|
||||||
|
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
|
||||||
|
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
|
||||||
|
>
|
||||||
|
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
|
||||||
|
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
|
||||||
|
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
|
||||||
|
> subdomains, metering/alerting. Single tenant, single host, single relay node.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Target (what "done" means)
|
||||||
|
|
||||||
|
From the operator's laptop (agent dialing out) and any browser:
|
||||||
|
|
||||||
|
1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
|
||||||
|
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
|
||||||
|
relay only sees ciphertext (INV2); E2E is browser↔agent.
|
||||||
|
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
|
||||||
|
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
|
||||||
|
kill the operator's PTY (state is in Postgres/Redis; INV7).
|
||||||
|
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Code audit summary — what exists vs. what must be built
|
||||||
|
|
||||||
|
Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
|
||||||
|
integration + infra layer they were designed to receive. Grounded gaps:
|
||||||
|
|
||||||
|
| Area | State (file evidence) | Task |
|
||||||
|
|---|---|---|
|
||||||
|
| P3 Postgres adapter | **absent** — `store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
|
||||||
|
| P3 migration runner | none | **A1** |
|
||||||
|
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
|
||||||
|
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
|
||||||
|
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
|
||||||
|
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
|
||||||
|
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
|
||||||
|
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
|
||||||
|
| relay revocation subscriber | not wired. Subscribe `relay:revocations` → `killsScope` → `closeStream`. | **B4** |
|
||||||
|
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
|
||||||
|
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
|
||||||
|
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
|
||||||
|
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
|
||||||
|
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Build order (waves by dependency)
|
||||||
|
|
||||||
|
```
|
||||||
|
A (P3 durable + serving) ──▶ B (relay data-plane on shared store) ──▶ E (infra + wire-up)
|
||||||
|
└──▶ C (agent buildable + runnable) ──────────▶
|
||||||
|
└──▶ D (relay-web served) ────────────────────▶
|
||||||
|
```
|
||||||
|
|
||||||
|
A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
|
||||||
|
green. E stands up infra and does the end-to-end enroll→dial→click-through.
|
||||||
|
|
||||||
|
### Wave A — control-plane durable + serving
|
||||||
|
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
|
||||||
|
`control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
|
||||||
|
`createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
|
||||||
|
semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
|
||||||
|
single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
|
||||||
|
constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
|
||||||
|
snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
|
||||||
|
**Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
|
||||||
|
`127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
|
||||||
|
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
|
||||||
|
`control-plane/src/boot/redis.ts`, `package.json` (`start` script).
|
||||||
|
`loadEnv(process.env)` → `createPgPool(PG_URL)`+`createQuery` → `createPgStores` → migrate →
|
||||||
|
`ioredis` client → `createRedisRevocationBus(redis)` → `buildControlPlane(env, {stores, bus,
|
||||||
|
verifier, caChainDer})` → `app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
|
||||||
|
against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
|
||||||
|
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
|
||||||
|
`CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
|
||||||
|
`control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
|
||||||
|
Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
|
||||||
|
verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
|
||||||
|
verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.
|
||||||
|
|
||||||
|
### Wave B — relay data-plane on the shared store (P1 via relay-run)
|
||||||
|
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
|
||||||
|
`relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
|
||||||
|
`RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
|
||||||
|
the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
|
||||||
|
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
|
||||||
|
relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
|
||||||
|
`{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
|
||||||
|
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
|
||||||
|
`resolve(subdomain)` → `hosts.getBySubdomain` → hostId (or null).
|
||||||
|
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
|
||||||
|
`term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
|
||||||
|
`KillSignal` → `killsScope(signal, hostAccountId, hostId)` → `node.closeStream(...)`.
|
||||||
|
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
|
||||||
|
(`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
|
||||||
|
computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
|
||||||
|
paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
|
||||||
|
startup; compose B1–B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.
|
||||||
|
|
||||||
|
### Wave C — agent buildable + runnable (P2)
|
||||||
|
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
|
||||||
|
`dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
|
||||||
|
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
|
||||||
|
`agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
|
||||||
|
`dialRelay`→`holdTunnel`→`createStreamRouter`→`dialLoopback` + heartbeat/backoff, porting the proven
|
||||||
|
wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
|
||||||
|
`web-terminal-agent run` enrolls + dials against a local Phase-1 relay.
|
||||||
|
|
||||||
|
### Wave D — serve relay-web (P6)
|
||||||
|
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
|
||||||
|
browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
|
||||||
|
Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
|
||||||
|
deploy step. `loadReplay` stays fail-closed (Phase 2).
|
||||||
|
|
||||||
|
### Wave E — infra + integration on 8.138.1.192
|
||||||
|
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
|
||||||
|
`redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
|
||||||
|
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>` → `8.138.1.192`; Let's Encrypt cert
|
||||||
|
(HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
|
||||||
|
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
|
||||||
|
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
|
||||||
|
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
|
||||||
|
`deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
|
||||||
|
group; keep CP admin + PG + Redis loopback-only.
|
||||||
|
- **E5 · End-to-end.** `POST /accounts` → `POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
|
||||||
|
the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
|
||||||
|
restart-safety + revocation teardown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Environment / config reference (Phase 1, this VPS)
|
||||||
|
|
||||||
|
**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
|
||||||
|
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
|
||||||
|
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
|
||||||
|
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
|
||||||
|
|
||||||
|
**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
|
||||||
|
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
|
||||||
|
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
|
||||||
|
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.
|
||||||
|
|
||||||
|
**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
|
||||||
|
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
|
||||||
|
base app), `STATE_DIR` (`~/.web-terminal-agent`).
|
||||||
|
|
||||||
|
> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
|
||||||
|
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
|
||||||
|
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants to preserve (do not regress)
|
||||||
|
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
|
||||||
|
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
|
||||||
|
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
|
||||||
|
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Progress
|
||||||
|
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1–A3`,
|
||||||
|
`B1–B5`, `C1–C2`, `D1`, `E1–E5`. Orchestrator appends one entry per task on completion (status, files,
|
||||||
|
verification command+result, deviations, next).
|
||||||
@@ -24,6 +24,36 @@
|
|||||||
|
|
||||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||||
|
|
||||||
|
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
|
||||||
|
- **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。
|
||||||
|
- **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2、B1–B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 SQL)。
|
||||||
|
- **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 的 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。
|
||||||
|
- **B7 收尾 `[x]` DONE**: ① 功能阻塞已闭环 — `browser-server.ts` 现从 `term.dpop.<b64u>` 子协议读 DPoP proof 喂 `UpgradeRequest.dpop`(头优先、否则回退子协议),真实浏览器可通过 DPoP 门。② F1 `/auth/mint` 加 Redis 按 IP 限流(HMAC 盐哈希 key,burst5/refill0.2,早于口令比对,耗尽 429);F2 `activeSessionCount` 接真实同租户在线 WS 计数;F5 错误日志只记 `e.message(+.code)` 防 DSN 泄漏。relay-run `npx vitest run` → **10 files / 92 passed**、tsc 干净。**F3/F4(撤销后 agent 空转重连、slowloris)为 LOW → Phase 2 backlog。**
|
||||||
|
- **⚠️ 端到端仅可在 VPS 验证**:浏览器登录→token→DPoP→upgrade→agent 拼接,本地只到类型/单元层。E2–E5 远端执行见 `deploy/RUNBOOK.md`(需 VPS 访问权)。
|
||||||
|
- **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。
|
||||||
|
- **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户、F3/F4。
|
||||||
|
- **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。
|
||||||
|
|
||||||
|
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
|
||||||
|
- **交付**: `createPgStores(query): Stores` 把 9 个 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
|
||||||
|
- **文件(全在 Owns 内)**: `control-plane/src/store/pg.ts`、`src/db/migrate.ts`、`db/migrations/0002_routes.sql`(新增 — 0001 无 routes 表)、`test/store/pg.test.ts`。
|
||||||
|
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505`→`throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore 存 `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE。
|
||||||
|
- **验证(实测)**: `npx vitest run test/store/pg.test.ts` → **23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**。
|
||||||
|
- **偏差**: ① `0002_routes.sql` 新增;② SubdomainStore 无独立预留表,按 `hosts.subdomain UNIQUE` 归属裁决;③ migrate 按 `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**: 无。
|
||||||
|
|
||||||
|
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
|
||||||
|
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径。默认仍 fail-closed(异步化后 reject→401)。
|
||||||
|
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify`→`Promise`)、`src/api/provision.ts`(5 个路由 call site 改 `await principal(req)`)、`src/boot/verifier.ts`(新;`configureCapabilityVerifyKey` 把 `env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts`、`package.json`(加 `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`(新)+ 更新 api 测试 stub 为异步。
|
||||||
|
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||||
|
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
||||||
|
|
||||||
|
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
|
||||||
|
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
|
||||||
|
- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏、延伸进刘海/Home 指示条),但 `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**。于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区,而 `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞。教科书级 cover-无-safe-area 缺陷。
|
||||||
|
- **修复(纯 CSS,零 JS)**: `:root` 新增 `--safe-t/--safe-b = env(safe-area-inset-top/bottom, 0px)`,穿过所有贴边固定元素:`#tabbar` 顶部让出刘海(border-box + `padding-top:--safe-t`)、`#term` inset 改 `calc(--tabbar-h+--safe-t) … calc(--keybar-h+--safe-b)`、`#keybar` 高度 `calc(--keybar-h+--safe-b)` + `padding-bottom:--safe-b`(芯片留在顶部 --keybar-h,Home 条上方)、`#approvalbar` 与 `body.home-open #term` 底部、`#searchbox`/`#settingspanel` 顶部同步。inset=0 时全部塌回原值 → 桌面/无安全区平台**零回归**。
|
||||||
|
- **验证(headless,390×844,注入 `--safe-t:59px --safe-b:34px` 模拟 iPhone)**: 开一个真实会话后量测 `term_bottom==keybar_top==764`(gap=0,无重叠)、`tabbar_bottom==99`(=40+59,让出刘海);截图确认芯片位于 Home 指示条上方、终端不再被遮。`npm run build:web` 通过。
|
||||||
|
- **待办**: 未提交;未在真机 iPhone 上复验(仅 headless 模拟安全区)。
|
||||||
|
|
||||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
|
|||||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥,
|
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||||
/// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。
|
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||||
|
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||||
|
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||||
|
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||||
static func live() -> SessionThumbnailPipeline {
|
static func live() -> SessionThumbnailPipeline {
|
||||||
SessionThumbnailPipeline(
|
let identityStore = KeychainClientIdentityStore()
|
||||||
|
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||||
|
identityStore.loadedIdentityOrNil()
|
||||||
|
})
|
||||||
|
return SessionThumbnailPipeline(
|
||||||
loader: { request in
|
loader: { request in
|
||||||
try await APIClient(endpoint: request.endpoint, http: liveTransport)
|
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||||
.preview(id: request.sessionId)
|
.preview(id: request.sessionId)
|
||||||
},
|
},
|
||||||
renderer: { data, cols, rows in
|
renderer: { data, cols, rows in
|
||||||
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let liveTransport = URLSessionHTTPTransport()
|
|
||||||
|
|
||||||
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
||||||
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
||||||
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
||||||
|
|||||||
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import ClientTLS
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// C-iOS-3 · Device-certificate install / rotation screen.
|
||||||
|
///
|
||||||
|
/// Flow: `.fileImporter([.pkcs12])` → secure passphrase → import (validates via
|
||||||
|
/// `SecPKCS12Import`) → persist in the keychain → show issuer CN + expiry, with
|
||||||
|
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
|
||||||
|
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
|
||||||
|
///
|
||||||
|
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
|
||||||
|
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
|
||||||
|
/// live outside this task's ownership. The screen is fully self-contained so
|
||||||
|
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
|
||||||
|
struct ClientCertScreen: View {
|
||||||
|
@State private var model: ClientCertViewModel
|
||||||
|
|
||||||
|
init(model: ClientCertViewModel = ClientCertViewModel()) {
|
||||||
|
_model = State(initialValue: model)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
installedSection
|
||||||
|
importSection
|
||||||
|
if model.summary != nil {
|
||||||
|
removeSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(ClientCertCopy.title)
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $model.isPickingFile,
|
||||||
|
allowedContentTypes: [.pkcs12],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
model.handleFileSelection(result)
|
||||||
|
}
|
||||||
|
.task { model.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
@ViewBuilder private var installedSection: some View {
|
||||||
|
Section(ClientCertCopy.installedHeader) {
|
||||||
|
if let summary = model.summary {
|
||||||
|
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "—")
|
||||||
|
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "—")
|
||||||
|
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
|
||||||
|
if summary.isExpired() {
|
||||||
|
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var importSection: some View {
|
||||||
|
Section {
|
||||||
|
if let name = model.pendingFileName {
|
||||||
|
LabeledContent(ClientCertCopy.selectedFile, value: name)
|
||||||
|
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
|
||||||
|
.textContentType(.password)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button(ClientCertCopy.importAction) { model.importPending() }
|
||||||
|
.disabled(!model.canImport)
|
||||||
|
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
model.beginPicking()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
model.summary == nil
|
||||||
|
? ClientCertCopy.chooseFile
|
||||||
|
: ClientCertCopy.replaceFile,
|
||||||
|
systemImage: "doc.badge.plus"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = model.errorMessage {
|
||||||
|
Text(error).foregroundStyle(.red).font(.footnote)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
|
||||||
|
} footer: {
|
||||||
|
Text(ClientCertCopy.importFooter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var removeSection: some View {
|
||||||
|
Section {
|
||||||
|
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State machine for the install screen. Errors are mapped to actionable copy
|
||||||
|
/// and never swallowed; a failed import leaves any prior identity intact.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ClientCertViewModel {
|
||||||
|
private(set) var summary: ClientCertificateSummary?
|
||||||
|
var isPickingFile = false
|
||||||
|
var passphrase = ""
|
||||||
|
private(set) var pendingData: Data?
|
||||||
|
private(set) var pendingFileName: String?
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
@ObservationIgnored private let store: any ClientIdentityStore
|
||||||
|
|
||||||
|
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
do {
|
||||||
|
summary = try store.loadSummary()
|
||||||
|
} catch {
|
||||||
|
summary = nil
|
||||||
|
errorMessage = ClientCertCopy.loadFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginPicking() {
|
||||||
|
errorMessage = nil
|
||||||
|
isPickingFile = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFileSelection(_ result: Result<[URL], Error>) {
|
||||||
|
switch result {
|
||||||
|
case .failure:
|
||||||
|
errorMessage = ClientCertCopy.fileReadFailed
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
readFile(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readFile(at url: URL) {
|
||||||
|
let didAccess = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
pendingData = try Data(contentsOf: url)
|
||||||
|
pendingFileName = url.lastPathComponent
|
||||||
|
passphrase = ""
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
pendingData = nil
|
||||||
|
pendingFileName = nil
|
||||||
|
errorMessage = ClientCertCopy.fileReadFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importPending() {
|
||||||
|
guard let data = pendingData else { return }
|
||||||
|
do {
|
||||||
|
try store.save(p12Data: data, passphrase: passphrase)
|
||||||
|
clearPending()
|
||||||
|
refresh()
|
||||||
|
} catch {
|
||||||
|
errorMessage = Self.copy(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPending() {
|
||||||
|
pendingData = nil
|
||||||
|
pendingFileName = nil
|
||||||
|
passphrase = ""
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove() {
|
||||||
|
do {
|
||||||
|
try store.remove()
|
||||||
|
summary = nil
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = ClientCertCopy.removeFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiryText(_ summary: ClientCertificateSummary) -> String {
|
||||||
|
guard let notAfter = summary.notAfter else { return "—" }
|
||||||
|
return notAfter.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an import/storage error to install-UX copy (never swallowed).
|
||||||
|
private static func copy(for error: any Error) -> String {
|
||||||
|
switch error {
|
||||||
|
case PKCS12ImportError.wrongPassphrase:
|
||||||
|
return ClientCertCopy.errWrongPassphrase
|
||||||
|
case PKCS12ImportError.corruptFile:
|
||||||
|
return ClientCertCopy.errCorruptFile
|
||||||
|
case PKCS12ImportError.unsupported:
|
||||||
|
return ClientCertCopy.errUnsupported
|
||||||
|
case PKCS12ImportError.noIdentity:
|
||||||
|
return ClientCertCopy.errNoIdentity
|
||||||
|
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
|
||||||
|
return ClientCertCopy.errKeychain
|
||||||
|
default:
|
||||||
|
return ClientCertCopy.errUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing copy (Chinese, matching the rest of the app).
|
||||||
|
enum ClientCertCopy {
|
||||||
|
static let title = "设备证书"
|
||||||
|
static let installedHeader = "已安装证书"
|
||||||
|
static let subject = "设备 (CN)"
|
||||||
|
static let issuer = "签发 CA"
|
||||||
|
static let expiry = "有效期至"
|
||||||
|
static let expiredWarning = "证书已过期,请重新导入。"
|
||||||
|
static let noneInstalled = "尚未安装设备证书。"
|
||||||
|
|
||||||
|
static let importHeader = "导入证书"
|
||||||
|
static let rotateHeader = "替换证书"
|
||||||
|
static let chooseFile = "选择 .p12 文件"
|
||||||
|
static let replaceFile = "选择新的 .p12 文件"
|
||||||
|
static let selectedFile = "已选文件"
|
||||||
|
static let passphrase = "证书密码"
|
||||||
|
static let importAction = "导入"
|
||||||
|
static let cancelAction = "取消"
|
||||||
|
static let removeAction = "移除证书"
|
||||||
|
static let importFooter =
|
||||||
|
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
|
||||||
|
|
||||||
|
static let errWrongPassphrase = "密码错误,请重试。"
|
||||||
|
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
|
||||||
|
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
|
||||||
|
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
|
||||||
|
static let errKeychain = "保存到钥匙串失败,请重试。"
|
||||||
|
static let errUnknown = "导入失败,请重试。"
|
||||||
|
static let loadFailed = "读取已安装证书失败。"
|
||||||
|
static let fileReadFailed = "无法读取所选文件。"
|
||||||
|
static let removeFailed = "移除证书失败,请重试。"
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ struct SessionListScreen: View {
|
|||||||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||||
var onAddHost: () -> Void = {}
|
var onAddHost: () -> Void = {}
|
||||||
|
/// C-iOS-3 (HIGH reachability fix) · "设备证书" entry — presents
|
||||||
|
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
|
||||||
|
/// cert is a global concern, but the toolbar host-menu is the app's only
|
||||||
|
/// settings-like surface and is present in every empty state, so this is the
|
||||||
|
/// nav idiom that makes the orphaned screen reachable.
|
||||||
|
var onDeviceCert: () -> Void = {}
|
||||||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||||
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
onDeviceCert()
|
||||||
|
} label: {
|
||||||
|
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("sessions.deviceCert")
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||||
@@ -342,6 +354,7 @@ private enum ScreenCopy {
|
|||||||
static let newSession = "新建会话"
|
static let newSession = "新建会话"
|
||||||
static let kill = "结束"
|
static let kill = "结束"
|
||||||
static let addHost = "配对新主机"
|
static let addHost = "配对新主机"
|
||||||
|
static let deviceCert = "设备证书"
|
||||||
static let hostMenuFallback = "主机"
|
static let hostMenuFallback = "主机"
|
||||||
static let notPairedTitle = "还没有配对的主机"
|
static let notPairedTitle = "还没有配对的主机"
|
||||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import Observation
|
import Observation
|
||||||
@@ -116,10 +117,21 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
@ObservationIgnored private let store: any HostStore
|
@ObservationIgnored private let store: any HostStore
|
||||||
@ObservationIgnored private let probe: Probe
|
@ObservationIgnored private let probe: Probe
|
||||||
|
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||||
|
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||||
|
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||||
|
@ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool
|
||||||
|
|
||||||
init(store: any HostStore, probe: @escaping Probe) {
|
init(
|
||||||
|
store: any HostStore,
|
||||||
|
probe: @escaping Probe,
|
||||||
|
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||||
|
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.probe = probe
|
self.probe = probe
|
||||||
|
self.isDeviceCertInstalled = isDeviceCertInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||||
@@ -205,10 +217,20 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
private func runProbe(for pending: PendingHost) async {
|
private func runProbe(for pending: PendingHost) async {
|
||||||
needsPublicRiskAcknowledgement = false
|
needsPublicRiskAcknowledgement = false
|
||||||
|
// C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
|
||||||
|
// fail at the TLS handshake) until a device certificate is installed.
|
||||||
|
// This is the single choke point both confirmConnect and retry funnel
|
||||||
|
// through, so the gate can't be bypassed via retry().
|
||||||
|
if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
|
||||||
|
phase = .failed(pending, FailureDisplay(
|
||||||
|
message: PairingCopy.deviceCertRequired, action: .retry
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
phase = .probing(pending)
|
phase = .probing(pending)
|
||||||
switch await probe(pending.endpoint) {
|
switch await probe(pending.endpoint) {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
phase = .failed(pending, Self.display(for: error))
|
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||||
case .success(let endpoint):
|
case .success(let endpoint):
|
||||||
await storePairedHost(endpoint: endpoint, pending: pending)
|
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||||
}
|
}
|
||||||
@@ -239,6 +261,19 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||||
|
|
||||||
|
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||||
|
/// client cert at the TLS layer; URLSession surfaces that as
|
||||||
|
/// secureConnectionFailed / connection-reset → `PairingError.classify` maps
|
||||||
|
/// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
|
||||||
|
/// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
|
||||||
|
/// everything else falls through to the per-case mapping.
|
||||||
|
static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
|
||||||
|
if case .tlsFailure = error, isTunnelHost(endpoint) {
|
||||||
|
return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
|
||||||
|
}
|
||||||
|
return display(for: error)
|
||||||
|
}
|
||||||
|
|
||||||
static func display(for error: PairingError) -> FailureDisplay {
|
static func display(for error: PairingError) -> FailureDisplay {
|
||||||
switch error {
|
switch error {
|
||||||
case .localNetworkDenied:
|
case .localNetworkDenied:
|
||||||
@@ -270,6 +305,14 @@ final class PairingViewModel {
|
|||||||
/// block regardless of scheme (§5.4 table: https is included in the
|
/// block regardless of scheme (§5.4 table: https is included in the
|
||||||
/// public-host confirm warning); otherwise https clears every notice.
|
/// public-host confirm warning); otherwise https clears every notice.
|
||||||
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
||||||
|
// C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
|
||||||
|
// client certificate (mTLS), so the "anyone who can reach the port gets
|
||||||
|
// a shell" blocking warning is FALSE here and would only deter the
|
||||||
|
// intended flow. The real gate is the cert-install check in runProbe.
|
||||||
|
// Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
|
||||||
|
if isTunnelHost(endpoint) {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||||
if hostClass == .publicHost {
|
if hostClass == .publicHost {
|
||||||
return .publicHostBlocking
|
return .publicHostBlocking
|
||||||
@@ -353,8 +396,17 @@ final class PairingViewModel {
|
|||||||
return octets
|
return octets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
|
||||||
|
/// These reach a loopback base app through the VPS and are protected ONLY by
|
||||||
|
/// the device client certificate — hence the softened warning + the
|
||||||
|
/// cert-install gate + the client-cert-rejected re-classification.
|
||||||
|
static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
|
||||||
|
(endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Named constants (no magic values, plan §4)
|
// MARK: - Named constants (no magic values, plan §4)
|
||||||
|
|
||||||
|
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
|
||||||
private static let schemeSeparator = "://"
|
private static let schemeSeparator = "://"
|
||||||
private static let defaultManualScheme = "http://"
|
private static let defaultManualScheme = "http://"
|
||||||
private static let httpsScheme = "https"
|
private static let httpsScheme = "https"
|
||||||
@@ -385,6 +437,13 @@ enum PairingCopy {
|
|||||||
"TLS 连接失败:证书无效或不受信任。"
|
"TLS 连接失败:证书无效或不受信任。"
|
||||||
static let timeout =
|
static let timeout =
|
||||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||||
|
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||||
|
static let deviceCertRequired =
|
||||||
|
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||||
|
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||||
|
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||||
|
static let clientCertRejected =
|
||||||
|
"本设备证书无效或已吊销,请重新导入。"
|
||||||
|
|
||||||
static func hostUnreachable(_ underlying: String) -> String {
|
static func hostUnreachable(_ underlying: String) -> String {
|
||||||
"无法连接主机:\(underlying)"
|
"无法连接主机:\(underlying)"
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
|
|||||||
) {
|
) {
|
||||||
projectsSheet
|
projectsSheet
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||||||
|
deviceCertSheet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
// MARK: - Layout branch (the SOLE size-class consumer)
|
||||||
@@ -107,4 +110,20 @@ struct AdaptiveRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
|
||||||
|
|
||||||
|
/// 自带 NavigationStack(`ClientCertScreen` 用 `.navigationTitle`)+ 右上「完成」
|
||||||
|
/// 关闭按钮,保证导入 .p12 后能明确返回。`ClientCertScreen` 自持
|
||||||
|
/// `ClientCertViewModel`(keychain store),此处零依赖注入。
|
||||||
|
@ViewBuilder private var deviceCertSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ClientCertScreen()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ final class AppCoordinator {
|
|||||||
/// prefs 每次进入都重新拉取。
|
/// prefs 每次进入都重新拉取。
|
||||||
private(set) var projectsViewModel: ProjectsViewModel?
|
private(set) var projectsViewModel: ProjectsViewModel?
|
||||||
var isProjectsPresented = false
|
var isProjectsPresented = false
|
||||||
|
/// C-iOS-3 (HIGH reachability fix) · "设备证书" sheet (import / rotate the
|
||||||
|
/// mTLS device cert via `ClientCertScreen`). No VM state — the screen owns
|
||||||
|
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
||||||
|
/// refresh because the transports resolve the identity lazily per connection.
|
||||||
|
var isDeviceCertPresented = false
|
||||||
|
|
||||||
let sessionList: SessionListViewModel
|
let sessionList: SessionListViewModel
|
||||||
@ObservationIgnored let environment: AppEnvironment
|
@ObservationIgnored let environment: AppEnvironment
|
||||||
@@ -108,6 +113,13 @@ final class AppCoordinator {
|
|||||||
projectsViewModel = nil
|
projectsViewModel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device certificate (C-iOS-3)
|
||||||
|
|
||||||
|
/// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。
|
||||||
|
func presentDeviceCert() {
|
||||||
|
isDeviceCertPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||||
func openProject(_ request: ProjectOpenRequest) {
|
func openProject(_ request: ProjectOpenRequest) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import SessionCore
|
import SessionCore
|
||||||
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
|
|||||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||||
|
|
||||||
static func production() -> AppEnvironment {
|
static func production() -> AppEnvironment {
|
||||||
let http = URLSessionHTTPTransport()
|
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||||
let termTransport = URLSessionTermTransport()
|
// identity LAZILY from the keychain on each connect/challenge, injected
|
||||||
|
// into BOTH transports + the probe as a provider. This way a certificate
|
||||||
|
// imported from the "设备证书" screen takes effect on the NEXT connection
|
||||||
|
// without relaunching — a snapshot captured here would stay stale. A
|
||||||
|
// missing cert is the normal pre-install state (→ nil); genuine faults
|
||||||
|
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
||||||
|
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
||||||
|
let identityStore = KeychainClientIdentityStore()
|
||||||
|
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||||
|
identityStore.loadedIdentityOrNil()
|
||||||
|
}
|
||||||
|
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||||
|
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||||
return AppEnvironment(
|
return AppEnvironment(
|
||||||
hostStore: KeychainHostStore(),
|
hostStore: KeychainHostStore(),
|
||||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ struct StackRootView: View {
|
|||||||
SessionListScreen(
|
SessionListScreen(
|
||||||
viewModel: coordinator.sessionList,
|
viewModel: coordinator.sessionList,
|
||||||
onOpen: { coordinator.open($0) },
|
onOpen: { coordinator.open($0) },
|
||||||
onAddHost: { coordinator.presentAddHost() }
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
|
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||||
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
|
|||||||
enum RootCopy {
|
enum RootCopy {
|
||||||
static let continueLast = "继续上次会话"
|
static let continueLast = "继续上次会话"
|
||||||
static let projects = "项目"
|
static let projects = "项目"
|
||||||
|
static let done = "完成"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ struct SplitRootView: View {
|
|||||||
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
||||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||||
},
|
},
|
||||||
onAddHost: { coordinator.presentAddHost() }
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
|
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
.animation(
|
.animation(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import WireProtocol
|
import WireProtocol
|
||||||
|
|
||||||
@@ -9,14 +10,39 @@ import WireProtocol
|
|||||||
/// bypass that single audited point (review CRITICAL).
|
/// bypass that single audited point (review CRITICAL).
|
||||||
struct URLSessionHTTPTransport: HTTPTransport {
|
struct URLSessionHTTPTransport: HTTPTransport {
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||||
|
/// until invalidated, but this ephemeral session is never explicitly
|
||||||
|
/// invalidated, so holding it here documents the ownership and keeps the
|
||||||
|
/// transport a self-contained value.
|
||||||
|
private let tlsDelegate: LazyClientTLSSessionDelegate
|
||||||
|
|
||||||
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
/// provider, so behaviour is identical to capturing the identity directly.
|
||||||
/// that may contain printed secrets — and `.shared`'s default URLCache
|
init(identity: ClientIdentity? = nil) {
|
||||||
/// writes responses to disk. Ephemeral keeps them memory-only, matching
|
self.init(identityProvider: { identity })
|
||||||
/// the WS transport and the privacy-shade posture.
|
}
|
||||||
init(session: URLSession = URLSession(configuration: .ephemeral)) {
|
|
||||||
self.session = session
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · The session's mTLS delegate resolves
|
||||||
|
/// the device identity LAZILY, per client-certificate challenge, from
|
||||||
|
/// `identityProvider`. A `ClientCertificate` challenge from a tunneled host
|
||||||
|
/// is thus answered with whatever cert is installed AT CHALLENGE TIME — so a
|
||||||
|
/// certificate imported mid-run is presented on the NEXT TLS handshake
|
||||||
|
/// without an app relaunch (a snapshot captured here would stay stale). The
|
||||||
|
/// session itself stays a single long-lived value (no per-request churn).
|
||||||
|
/// Local http/https hosts never issue that challenge, so the provider is
|
||||||
|
/// never even consulted for them (and a `nil` result is inert regardless).
|
||||||
|
///
|
||||||
|
/// EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies include
|
||||||
|
/// `/live-sessions/:id/preview` — raw terminal ring-buffer bytes that may
|
||||||
|
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||||
|
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||||
|
/// transport and the privacy-shade posture.
|
||||||
|
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||||
|
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||||
|
self.tlsDelegate = delegate
|
||||||
|
self.session = URLSession(
|
||||||
|
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
|||||||
return (data, httpResponse)
|
return (data, httpResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||||
|
/// the device identity FRESH per client-certificate challenge — the App-layer
|
||||||
|
/// twin of ClientTLS's fixed-identity `ClientTLSSessionDelegate` (which captures
|
||||||
|
/// the identity once). The provider is only consulted for a `ClientCertificate`
|
||||||
|
/// challenge, so a `ServerTrust` challenge never triggers a keychain read; the
|
||||||
|
/// pure decision itself is delegated to the shared `MutualTLSChallengeResponder`
|
||||||
|
/// (single truth table, unit-tested in ClientTLS).
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||||
|
/// arbitrary queues; every stored field is an immutable `let` over a `@Sendable`
|
||||||
|
/// value (the provider is `@Sendable`, the responder is stateless).
|
||||||
|
private final class LazyClientTLSSessionDelegate:
|
||||||
|
NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||||
|
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||||
|
private let responder = MutualTLSChallengeResponder()
|
||||||
|
|
||||||
|
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||||
|
self.identityProvider = identityProvider
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
// Only a client-certificate challenge needs the identity; resolving it
|
||||||
|
// for a server-trust challenge would do a needless keychain read/import
|
||||||
|
// on every HTTPS handshake.
|
||||||
|
let isClientCert = challenge.protectionSpace.authenticationMethod
|
||||||
|
== NSURLAuthenticationMethodClientCertificate
|
||||||
|
let identity = isClientCert ? identityProvider() : nil
|
||||||
|
let resolution = responder.resolve(challenge, identity: identity)
|
||||||
|
completionHandler(resolution.disposition, resolution.credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
22
ios/Packages/ClientTLS/Package.swift
Normal file
22
ios/Packages/ClientTLS/Package.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
|
||||||
|
//
|
||||||
|
// Standalone, no local-package dependencies — imports ONLY Security/Foundation.
|
||||||
|
// It is a downward leaf: SessionCore and the App target may depend on it, it
|
||||||
|
// depends on nothing in this workspace. This keeps the pure, unit-testable
|
||||||
|
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
|
||||||
|
// from the WS/HTTP transport plumbing that consumes it.
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ClientTLS",
|
||||||
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
|
products: [
|
||||||
|
.library(name: "ClientTLS", targets: ["ClientTLS"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "ClientTLS"),
|
||||||
|
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v6]
|
||||||
|
)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
|
||||||
|
/// rotation screen so the user can confirm *which* cert is active and *when*
|
||||||
|
/// it expires before relying on it.
|
||||||
|
public struct ClientCertificateSummary: Equatable, Sendable {
|
||||||
|
/// Subject common name — the device/leaf CN (e.g. `t1-iphone`).
|
||||||
|
public let subjectCommonName: String?
|
||||||
|
/// Issuer common name — the device-CA CN (e.g. `webterm-device-ca`).
|
||||||
|
public let issuerCommonName: String?
|
||||||
|
/// Not-after date; `nil` if it could not be parsed.
|
||||||
|
public let notAfter: Date?
|
||||||
|
|
||||||
|
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
|
||||||
|
self.subjectCommonName = subjectCommonName
|
||||||
|
self.issuerCommonName = issuerCommonName
|
||||||
|
self.notAfter = notAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expired relative to `now` (defaults to the current instant). Unknown
|
||||||
|
/// expiry is treated as NOT expired (fail-open for display only — the TLS
|
||||||
|
/// stack, not this label, is the real gate).
|
||||||
|
public func isExpired(asOf now: Date = Date()) -> Bool {
|
||||||
|
guard let notAfter else { return false }
|
||||||
|
return notAfter < now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the display fields off a `SecCertificate`.
|
||||||
|
///
|
||||||
|
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
|
||||||
|
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
|
||||||
|
/// `kSecPropertyKey*` constants are **macOS-only** — unavailable on iOS, which
|
||||||
|
/// is the real deployment target. The DER walk is identical on both platforms
|
||||||
|
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
|
||||||
|
/// fields (the summary is display-only; the TLS stack is the gate).
|
||||||
|
enum CertificateInspector {
|
||||||
|
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
|
||||||
|
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
|
||||||
|
let fields = X509Fields.parse(der: der)
|
||||||
|
return ClientCertificateSummary(
|
||||||
|
subjectCommonName: fields?.subjectCommonName,
|
||||||
|
issuerCommonName: fields?.issuerCommonName,
|
||||||
|
notAfter: fields?.notAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Minimal ASN.1 DER reader
|
||||||
|
|
||||||
|
/// One tag-length-value element, as byte ranges into the source buffer.
|
||||||
|
private struct DERElement {
|
||||||
|
let tag: UInt8
|
||||||
|
let valueStart: Int
|
||||||
|
let valueEnd: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum DER {
|
||||||
|
/// DER tags used here.
|
||||||
|
static let sequence: UInt8 = 0x30
|
||||||
|
static let set: UInt8 = 0x31
|
||||||
|
static let oid: UInt8 = 0x06
|
||||||
|
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
|
||||||
|
static let utcTime: UInt8 = 0x17
|
||||||
|
static let generalizedTime: UInt8 = 0x18
|
||||||
|
|
||||||
|
/// Read a single TLV at `start`. Returns the element and the index just
|
||||||
|
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
|
||||||
|
/// bytes come from the system but are still parsed as untrusted input).
|
||||||
|
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
|
||||||
|
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||||
|
let tag = bytes[start]
|
||||||
|
var index = start + 1
|
||||||
|
let firstLengthByte = bytes[index]
|
||||||
|
index += 1
|
||||||
|
var length = 0
|
||||||
|
if firstLengthByte & 0x80 == 0 {
|
||||||
|
length = Int(firstLengthByte)
|
||||||
|
} else {
|
||||||
|
let byteCount = Int(firstLengthByte & 0x7F)
|
||||||
|
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _ in 0..<byteCount {
|
||||||
|
length = (length << 8) | Int(bytes[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let valueEnd = index + length
|
||||||
|
guard length >= 0, valueEnd <= bytes.count else { return nil }
|
||||||
|
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a constructed value `[start, end)` into its immediate children.
|
||||||
|
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
|
||||||
|
var elements: [DERElement] = []
|
||||||
|
var index = start
|
||||||
|
while index < end, let element = read(bytes, at: index) {
|
||||||
|
elements.append(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - X.509 field extraction
|
||||||
|
|
||||||
|
private struct X509Fields {
|
||||||
|
let subjectCommonName: String?
|
||||||
|
let issuerCommonName: String?
|
||||||
|
let notAfter: Date?
|
||||||
|
|
||||||
|
/// CommonName attribute OID `2.5.4.3` → DER content bytes `55 04 03`.
|
||||||
|
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
|
||||||
|
|
||||||
|
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
|
||||||
|
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
|
||||||
|
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
|
||||||
|
static func parse(der bytes: [UInt8]) -> X509Fields? {
|
||||||
|
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let certChildren = DER.children(
|
||||||
|
bytes, from: certificate.valueStart, to: certificate.valueEnd
|
||||||
|
)
|
||||||
|
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
|
||||||
|
|
||||||
|
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
|
||||||
|
// Optional EXPLICIT [0] version — drop it so the fixed fields align.
|
||||||
|
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
|
||||||
|
tbsChildren.removeFirst()
|
||||||
|
}
|
||||||
|
// Fixed order after (optional) version: serialNumber, signature,
|
||||||
|
// issuer, validity, subject, ...
|
||||||
|
guard tbsChildren.count >= 5 else { return nil }
|
||||||
|
let issuer = tbsChildren[2]
|
||||||
|
let validity = tbsChildren[3]
|
||||||
|
let subject = tbsChildren[4]
|
||||||
|
|
||||||
|
return X509Fields(
|
||||||
|
subjectCommonName: commonName(bytes, in: subject),
|
||||||
|
issuerCommonName: commonName(bytes, in: issuer),
|
||||||
|
notAfter: notAfterDate(bytes, in: validity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
|
||||||
|
/// Returns the first CN value found.
|
||||||
|
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
|
||||||
|
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
|
||||||
|
where rdn.tag == DER.set {
|
||||||
|
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
|
||||||
|
where atv.tag == DER.sequence {
|
||||||
|
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
|
||||||
|
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
|
||||||
|
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
|
||||||
|
if oid == commonNameOID {
|
||||||
|
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
|
||||||
|
// PrintableString / UTF8String both decode as UTF-8.
|
||||||
|
return String(bytes: value, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } — the 2nd child.
|
||||||
|
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
|
||||||
|
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
|
||||||
|
guard times.count >= 2 else { return nil }
|
||||||
|
let notAfter = times[1]
|
||||||
|
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
|
||||||
|
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
|
||||||
|
return parseTime(text, tag: notAfter.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
|
||||||
|
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
|
||||||
|
// (device certs live in the 2020s-2040s).
|
||||||
|
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||||
|
return formatter.date(from: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
|
||||||
|
/// certificate + its private key) plus the issuer chain that accompanies it in
|
||||||
|
/// the TLS handshake.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
|
||||||
|
/// handles that are immutable once imported and thread-safe to read; this value
|
||||||
|
/// only ever holds finished imports and never mutates them. Marking it lets the
|
||||||
|
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
|
||||||
|
/// delegates that answer client-certificate challenges.
|
||||||
|
public struct ClientIdentity: @unchecked Sendable {
|
||||||
|
/// The leaf certificate + private key used to authenticate to the server.
|
||||||
|
public let secIdentity: SecIdentity
|
||||||
|
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
|
||||||
|
/// excluded). May be empty when the trust anchor is already pinned server
|
||||||
|
/// side (nginx `ssl_client_certificate` = the device-CA) — the leaf alone
|
||||||
|
/// then verifies at `ssl_verify_depth 1`.
|
||||||
|
public let issuerCertificates: [SecCertificate]
|
||||||
|
|
||||||
|
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
|
||||||
|
self.secIdentity = secIdentity
|
||||||
|
self.issuerCertificates = issuerCertificates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
|
||||||
|
///
|
||||||
|
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
|
||||||
|
/// lives in the app's keychain item — persisting the credential in the
|
||||||
|
/// shared URL credential store would be a second, unmanaged copy.
|
||||||
|
public func urlCredential(
|
||||||
|
persistence: URLCredential.Persistence = .forSession
|
||||||
|
) -> URLCredential {
|
||||||
|
URLCredential(
|
||||||
|
identity: secIdentity,
|
||||||
|
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
|
||||||
|
persistence: persistence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The leaf `SecCertificate` backing this identity (for display / summary).
|
||||||
|
public func leafCertificate() -> SecCertificate? {
|
||||||
|
var certificate: SecCertificate?
|
||||||
|
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
|
||||||
|
return status == errSecSuccess ? certificate : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
|
||||||
|
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
|
||||||
|
/// read (should never happen for a valid import).
|
||||||
|
public func summary() -> ClientCertificateSummary? {
|
||||||
|
leafCertificate().map(CertificateInspector.summary(of:))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
|
||||||
|
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
|
||||||
|
///
|
||||||
|
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
|
||||||
|
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
|
||||||
|
/// transport can't reuse it — its connection object is already the session's
|
||||||
|
/// delegate and needs the **task-level** callback — so that one implements the
|
||||||
|
/// task-level method itself against the same responder.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||||
|
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
|
||||||
|
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
|
||||||
|
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||||
|
private let identity: ClientIdentity?
|
||||||
|
private let responder: MutualTLSChallengeResponder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
identity: ClientIdentity?,
|
||||||
|
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
|
||||||
|
) {
|
||||||
|
self.identity = identity
|
||||||
|
self.responder = responder
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
let resolution = responder.resolve(challenge, identity: identity)
|
||||||
|
completionHandler(resolution.disposition, resolution.credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
|
||||||
|
/// **and** its passphrase are stored together (the passphrase is required to
|
||||||
|
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
|
||||||
|
public protocol ClientIdentityStore: Sendable {
|
||||||
|
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
|
||||||
|
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
|
||||||
|
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
|
||||||
|
func save(p12Data: Data, passphrase: String) throws
|
||||||
|
/// Re-import and return the stored identity; `nil` if none is installed.
|
||||||
|
func loadIdentity() throws -> ClientIdentity?
|
||||||
|
/// Display summary of the stored certificate; `nil` if none is installed.
|
||||||
|
func loadSummary() throws -> ClientCertificateSummary?
|
||||||
|
/// Delete the stored identity (rotation / removal). Idempotent.
|
||||||
|
func remove() throws
|
||||||
|
/// Cheap existence check for gating (does NOT re-import).
|
||||||
|
func hasInstalledIdentity() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
|
||||||
|
/// A Keychain `SecItem*` call failed with this `OSStatus`.
|
||||||
|
case keychain(OSStatus)
|
||||||
|
/// The stored blob was present but could not be decoded.
|
||||||
|
case corruptStoredBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ClientIdentityStore {
|
||||||
|
/// Convenience for composition roots: load the identity, logging and
|
||||||
|
/// swallowing errors into `nil`. A missing cert is the normal pre-install
|
||||||
|
/// state; a genuine fault must not crash launch, but is logged (never
|
||||||
|
/// silently dropped).
|
||||||
|
func loadedIdentityOrNil() -> ClientIdentity? {
|
||||||
|
do {
|
||||||
|
return try loadIdentity()
|
||||||
|
} catch {
|
||||||
|
ClientTLSLog.identity.error(
|
||||||
|
"loadIdentity failed: \(String(describing: error), privacy: .public)"
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored payload — `.p12` bytes plus the passphrase needed to re-import.
|
||||||
|
private struct StoredP12Blob: Codable {
|
||||||
|
let p12: Data
|
||||||
|
let passphrase: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
|
||||||
|
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
|
||||||
|
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
|
||||||
|
/// unlock post-boot, never migrates off this device).
|
||||||
|
public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||||
|
public static let defaultService = "com.yaojia.webterm.clienttls"
|
||||||
|
public static let defaultAccount = "device-identity"
|
||||||
|
|
||||||
|
private let service: String
|
||||||
|
private let account: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
service: String = defaultService, account: String = defaultAccount
|
||||||
|
) {
|
||||||
|
self.service = service
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(p12Data: Data, passphrase: String) throws {
|
||||||
|
// Validate BEFORE persisting — a wrong passphrase / corrupt file must
|
||||||
|
// surface to the install UI and leave any prior identity untouched.
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||||
|
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
|
||||||
|
try writeItem(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadIdentity() throws -> ClientIdentity? {
|
||||||
|
guard let blob = try readBlob() else { return nil }
|
||||||
|
return try PKCS12Importer.importIdentity(
|
||||||
|
data: blob.p12, passphrase: blob.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||||
|
try loadIdentity()?.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove() throws {
|
||||||
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hasInstalledIdentity() -> Bool {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = false
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain plumbing
|
||||||
|
|
||||||
|
private func baseQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeItem(_ data: Data) throws {
|
||||||
|
// Delete-then-add keeps the item's protection class deterministic
|
||||||
|
// (SecItemUpdate can't change kSecAttrAccessible in place).
|
||||||
|
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||||
|
}
|
||||||
|
var attributes = baseQuery()
|
||||||
|
attributes[kSecValueData as String] = data
|
||||||
|
attributes[kSecAttrAccessible as String] =
|
||||||
|
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||||
|
guard addStatus == errSecSuccess else {
|
||||||
|
throw ClientIdentityStoreError.keychain(addStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readBlob() throws -> StoredP12Blob? {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return nil }
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else {
|
||||||
|
throw ClientIdentityStoreError.keychain(status)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw ClientIdentityStoreError.corruptStoredBlob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encode(_ blob: StoredP12Blob) throws -> Data {
|
||||||
|
do {
|
||||||
|
return try JSONEncoder().encode(blob)
|
||||||
|
} catch {
|
||||||
|
throw ClientIdentityStoreError.corruptStoredBlob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory store for previews and unit tests: same import/summary code path as
|
||||||
|
/// the keychain store (so the roundtrip is exercised) without any keychain
|
||||||
|
/// entitlement. `@unchecked Sendable` — mutable blob guarded by a lock.
|
||||||
|
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var blob: StoredP12BlobBox?
|
||||||
|
|
||||||
|
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
|
||||||
|
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
|
||||||
|
private struct StoredP12BlobBox {
|
||||||
|
let p12: Data
|
||||||
|
let passphrase: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func save(p12Data: Data, passphrase: String) throws {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||||
|
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadIdentity() throws -> ClientIdentity? {
|
||||||
|
guard let stored = lock.withLock({ blob }) else { return nil }
|
||||||
|
return try PKCS12Importer.importIdentity(
|
||||||
|
data: stored.p12, passphrase: stored.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||||
|
try loadIdentity()?.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove() throws {
|
||||||
|
lock.withLock { blob = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hasInstalledIdentity() -> Bool {
|
||||||
|
lock.withLock { blob != nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ClientTLSLog {
|
||||||
|
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
|
||||||
|
///
|
||||||
|
/// It maps a URLSession auth challenge (+ the optionally-installed device
|
||||||
|
/// identity) to a disposition and credential. Deliberately free of any
|
||||||
|
/// URLSession / socket state so the full truth table is unit-testable without a
|
||||||
|
/// live connection — the transports (WS task delegate, HTTP session delegate)
|
||||||
|
/// are thin adapters that call `resolve` and forward its result to the
|
||||||
|
/// completion handler.
|
||||||
|
///
|
||||||
|
/// Truth table (plan §C-iOS-1):
|
||||||
|
/// a. `ClientCertificate` + identity present → `.useCredential` with the
|
||||||
|
/// identity's `URLCredential`.
|
||||||
|
/// b. `ClientCertificate` + no identity → `.cancelAuthenticationChallenge`
|
||||||
|
/// (a clean, classifiable failure — never a silent no-cert continue).
|
||||||
|
/// c. `ServerTrust` → `.performDefaultHandling`
|
||||||
|
/// (the LE wildcard is validated by the system trust store).
|
||||||
|
/// d. anything else → `.performDefaultHandling`.
|
||||||
|
public struct MutualTLSChallengeResponder: Sendable {
|
||||||
|
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
|
||||||
|
/// which isn't) — it is produced and consumed synchronously on the delegate
|
||||||
|
/// queue, never sent across isolation domains.
|
||||||
|
public struct Resolution {
|
||||||
|
public let disposition: URLSession.AuthChallengeDisposition
|
||||||
|
public let credential: URLCredential?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
disposition: URLSession.AuthChallengeDisposition,
|
||||||
|
credential: URLCredential? = nil
|
||||||
|
) {
|
||||||
|
self.disposition = disposition
|
||||||
|
self.credential = credential
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
|
||||||
|
/// authentication method.
|
||||||
|
public func resolve(
|
||||||
|
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
|
||||||
|
) -> Resolution {
|
||||||
|
resolve(
|
||||||
|
authenticationMethod: challenge.protectionSpace.authenticationMethod,
|
||||||
|
identity: identity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method-keyed core (challenge-free) — the directly-tested pure function.
|
||||||
|
public func resolve(
|
||||||
|
authenticationMethod: String, identity: ClientIdentity?
|
||||||
|
) -> Resolution {
|
||||||
|
switch authenticationMethod {
|
||||||
|
case NSURLAuthenticationMethodClientCertificate:
|
||||||
|
guard let identity else {
|
||||||
|
// (b) No installed identity: cancel so the failure surfaces as a
|
||||||
|
// classifiable client-cert error rather than a bare TLS reset.
|
||||||
|
return Resolution(disposition: .cancelAuthenticationChallenge)
|
||||||
|
}
|
||||||
|
// (a) Present the device certificate for this session.
|
||||||
|
return Resolution(
|
||||||
|
disposition: .useCredential, credential: identity.urlCredential()
|
||||||
|
)
|
||||||
|
case NSURLAuthenticationMethodServerTrust:
|
||||||
|
// (c) System-trusted LE wildcard — default evaluation.
|
||||||
|
return Resolution(disposition: .performDefaultHandling)
|
||||||
|
default:
|
||||||
|
// (d) Basic/Digest/NTLM/etc. — not used by this app; default.
|
||||||
|
return Resolution(disposition: .performDefaultHandling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
|
||||||
|
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
|
||||||
|
/// `errSecAuthFailed → .wrongPassphrase`, `errSecDecode → .corruptFile`, and any
|
||||||
|
/// other non-success status (unsupported/unknown format — the plan's "errSecPkg"
|
||||||
|
/// bucket) → `.unsupported`.
|
||||||
|
public enum PKCS12ImportError: Error, Equatable, Sendable {
|
||||||
|
/// Wrong passphrase (`errSecAuthFailed`).
|
||||||
|
case wrongPassphrase
|
||||||
|
/// Not a decodable PKCS#12 blob (`errSecDecode`) — truncated / not a `.p12`.
|
||||||
|
case corruptFile
|
||||||
|
/// Decoded, but the format/algorithms aren't importable on this OS
|
||||||
|
/// (any other non-success `OSStatus`). Retains the raw status for logs.
|
||||||
|
case unsupported(OSStatus)
|
||||||
|
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
|
||||||
|
case noIdentity
|
||||||
|
|
||||||
|
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.wrongPassphrase, .wrongPassphrase),
|
||||||
|
(.corruptFile, .corruptFile),
|
||||||
|
(.noIdentity, .noIdentity):
|
||||||
|
return true
|
||||||
|
case let (.unsupported(a), .unsupported(b)):
|
||||||
|
return a == b
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
|
||||||
|
///
|
||||||
|
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
|
||||||
|
/// handles without writing to the keychain, so callers control persistence
|
||||||
|
/// (see `KeychainClientIdentityStore`).
|
||||||
|
public enum PKCS12Importer {
|
||||||
|
public static func importIdentity(
|
||||||
|
data: Data, passphrase: String
|
||||||
|
) throws -> ClientIdentity {
|
||||||
|
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
|
||||||
|
var rawItems: CFArray?
|
||||||
|
let status = SecPKCS12Import(data as CFData, options, &rawItems)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
break
|
||||||
|
case errSecAuthFailed:
|
||||||
|
throw PKCS12ImportError.wrongPassphrase
|
||||||
|
case errSecDecode:
|
||||||
|
throw PKCS12ImportError.corruptFile
|
||||||
|
default:
|
||||||
|
// Covers unsupported formats/algorithms and any other failure —
|
||||||
|
// the plan's `errSecPkg → .unsupported` mapping (that symbol does
|
||||||
|
// not exist in the SDK; the status is preserved for diagnostics).
|
||||||
|
throw PKCS12ImportError.unsupported(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = rawItems as? [[String: Any]], let first = items.first,
|
||||||
|
let identityValue = first[kSecImportItemIdentity as String]
|
||||||
|
else {
|
||||||
|
throw PKCS12ImportError.noIdentity
|
||||||
|
}
|
||||||
|
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
|
||||||
|
let secIdentity = identityValue as! SecIdentity
|
||||||
|
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
|
||||||
|
return ClientIdentity(
|
||||||
|
secIdentity: secIdentity,
|
||||||
|
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
|
||||||
|
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
|
||||||
|
/// the identity's own cert). Drops whichever chain entry DER-matches the
|
||||||
|
/// identity's leaf.
|
||||||
|
private static func issuerChain(
|
||||||
|
from chain: [SecCertificate], identity: SecIdentity
|
||||||
|
) -> [SecCertificate] {
|
||||||
|
var leaf: SecCertificate?
|
||||||
|
SecIdentityCopyCertificate(identity, &leaf)
|
||||||
|
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
|
||||||
|
// path as the keychain store, minus the entitlement — the keychain variant is
|
||||||
|
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
|
||||||
|
|
||||||
|
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
|
||||||
|
func storeRoundtrip() throws {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(try store.loadIdentity() == nil)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(store.hasInstalledIdentity() == true)
|
||||||
|
let identity = try #require(try store.loadIdentity())
|
||||||
|
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||||
|
let summary = try #require(try store.loadSummary())
|
||||||
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("save validates the passphrase and leaves prior state untouched on failure")
|
||||||
|
func storeSaveValidatesBeforePersisting() {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
|
||||||
|
// Act / Assert — a wrong passphrase must surface, not persist.
|
||||||
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||||
|
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
||||||
|
}
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("remove clears the stored identity")
|
||||||
|
func storeRemove() throws {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
#expect(store.hasInstalledIdentity() == true)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.remove()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(try store.loadIdentity() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
|
||||||
|
func loadedIdentityOrNilEmpty() {
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
#expect(store.loadedIdentityOrNil() == nil)
|
||||||
|
}
|
||||||
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
|
||||||
|
/// OpenSSL (EC P-256 self-signed device-CA → EC P-256 leaf, `EKU=clientAuth`,
|
||||||
|
/// exported `-legacy` for iOS/Android import parity — mirrors
|
||||||
|
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
|
||||||
|
///
|
||||||
|
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
|
||||||
|
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
|
||||||
|
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
|
||||||
|
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
|
||||||
|
/// -subj "/CN=webterm-device-ca-fixture" \
|
||||||
|
/// -addext "basicConstraints=critical,CA:TRUE" \
|
||||||
|
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
|
||||||
|
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
|
||||||
|
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
|
||||||
|
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
|
||||||
|
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
|
||||||
|
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
|
||||||
|
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
|
||||||
|
/// -name device-fixture -out device.p12 ; base64 device.p12
|
||||||
|
enum ClientTLSFixtures {
|
||||||
|
static let passphrase = "clienttls-test-pass"
|
||||||
|
static let leafCommonName = "device-fixture"
|
||||||
|
static let issuerCommonName = "webterm-device-ca-fixture"
|
||||||
|
|
||||||
|
static var deviceP12Data: Data {
|
||||||
|
Data(base64Encoded: deviceP12Base64)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
static let deviceP12Base64 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
|
||||||
|
// absent. Exercised through the pure method-keyed core AND through a real
|
||||||
|
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
|
||||||
|
// with no live socket.
|
||||||
|
|
||||||
|
private let responder = MutualTLSChallengeResponder()
|
||||||
|
|
||||||
|
private func makeIdentity() throws -> ClientIdentity {
|
||||||
|
try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ClientCertificate
|
||||||
|
|
||||||
|
@Test("ClientCertificate + identity → .useCredential with a credential")
|
||||||
|
func clientCertWithIdentityUsesCredential() throws {
|
||||||
|
// Arrange
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(resolution.disposition == .useCredential)
|
||||||
|
#expect(resolution.credential != nil)
|
||||||
|
#expect(resolution.credential?.identity != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
|
||||||
|
func clientCertWithoutIdentityCancels() {
|
||||||
|
// Act
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(resolution.disposition == .cancelAuthenticationChallenge)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ServerTrust
|
||||||
|
|
||||||
|
@Test("ServerTrust → .performDefaultHandling regardless of identity")
|
||||||
|
func serverTrustDefaultHandling() throws {
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
for id in [identity, nil] as [ClientIdentity?] {
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
|
||||||
|
)
|
||||||
|
#expect(resolution.disposition == .performDefaultHandling)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Other method (e.g. Basic)
|
||||||
|
|
||||||
|
@Test("an unrelated method → .performDefaultHandling regardless of identity")
|
||||||
|
func otherMethodDefaultHandling() throws {
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
for id in [identity, nil] as [ClientIdentity?] {
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
|
||||||
|
)
|
||||||
|
#expect(resolution.disposition == .performDefaultHandling)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full URLAuthenticationChallenge extraction
|
||||||
|
|
||||||
|
@Test("resolve(challenge:) extracts the method from the protection space")
|
||||||
|
func resolveFromRealChallenge() throws {
|
||||||
|
// Arrange — a real challenge for the ClientCertificate method.
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let withIdentity = responder.resolve(challenge, identity: identity)
|
||||||
|
let withoutIdentity = responder.resolve(challenge, identity: nil)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(withIdentity.disposition == .useCredential)
|
||||||
|
#expect(withIdentity.credential != nil)
|
||||||
|
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
|
||||||
|
/// (its designated init requires a non-optional sender). The responder never
|
||||||
|
/// calls back into the sender — it only reads `protectionSpace`.
|
||||||
|
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
|
||||||
|
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||||
|
let space = URLProtectionSpace(
|
||||||
|
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||||
|
realm: nil, authenticationMethod: method
|
||||||
|
)
|
||||||
|
return URLAuthenticationChallenge(
|
||||||
|
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||||
|
failureResponse: nil, error: nil, sender: NoopChallengeSender()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
|
||||||
|
// including the wrong-passphrase and corrupt-file error mappings.
|
||||||
|
|
||||||
|
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
|
||||||
|
func importSucceedsAndExposesLeaf() throws {
|
||||||
|
// Arrange
|
||||||
|
let data = ClientTLSFixtures.deviceP12Data
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
let summary = try #require(identity.summary())
|
||||||
|
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||||
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||||
|
// 825-day leaf minted at authoring time → not yet expired.
|
||||||
|
#expect(summary.isExpired() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
|
||||||
|
func importIssuerChainExcludesLeaf() throws {
|
||||||
|
// Arrange / Act
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
|
||||||
|
let leaf = try #require(identity.leafCertificate())
|
||||||
|
let leafData = SecCertificateCopyData(leaf) as Data
|
||||||
|
#expect(identity.issuerCertificates.count == 1)
|
||||||
|
for issuer in identity.issuerCertificates {
|
||||||
|
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
|
||||||
|
func importWrongPassphrase() {
|
||||||
|
// Arrange / Act / Assert
|
||||||
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("garbage bytes map errSecDecode → .corruptFile")
|
||||||
|
func importCorruptFile() {
|
||||||
|
// Arrange
|
||||||
|
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
|
||||||
|
|
||||||
|
// Act / Assert
|
||||||
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty data is treated as a corrupt file, not a crash")
|
||||||
|
func importEmptyData() {
|
||||||
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 6.0
|
||||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
||||||
// Dependency direction is strictly downward: SessionCore → WireProtocol only.
|
// Dependency direction is strictly downward: SessionCore → WireProtocol, plus
|
||||||
|
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
|
||||||
|
// answers connection-level challenges with. Both are downward leaves.
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
@@ -12,12 +14,16 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../WireProtocol"),
|
.package(path: "../WireProtocol"),
|
||||||
|
.package(path: "../ClientTLS"),
|
||||||
.package(path: "../TestSupport"),
|
.package(path: "../TestSupport"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "SessionCore",
|
name: "SessionCore",
|
||||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
dependencies: [
|
||||||
|
.product(name: "WireProtocol", package: "WireProtocol"),
|
||||||
|
.product(name: "ClientTLS", package: "ClientTLS"),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SessionCoreTests",
|
name: "SessionCoreTests",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClientTLS
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import WireProtocol
|
import WireProtocol
|
||||||
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
|
|||||||
public struct URLSessionTermTransport: TermTransport {
|
public struct URLSessionTermTransport: TermTransport {
|
||||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||||
private let maxMessageBytes: Int
|
private let maxMessageBytes: Int
|
||||||
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
|
||||||
|
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
|
||||||
|
/// takes effect on the NEXT connection without an app relaunch (a snapshot
|
||||||
|
/// captured at composition would stay stale). When the resolved identity is
|
||||||
|
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
|
||||||
|
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
|
||||||
|
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||||
|
/// result is inert there.
|
||||||
|
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||||
|
|
||||||
public init() {
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||||
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
|
/// provider, so behaviour is identical to capturing the identity directly.
|
||||||
|
public init(identity: ClientIdentity? = nil) {
|
||||||
|
self.init(identityProvider: { identity })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||||
|
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||||
|
/// transition is picked up without relaunch.
|
||||||
|
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||||
|
self.init(
|
||||||
|
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
||||||
/// deterministic without 16 MiB fixtures. Production code paths always go
|
/// deterministic without 16 MiB fixtures. Production code paths always go
|
||||||
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
|
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||||
init(maxMessageBytes: Int) {
|
/// `Tunables.maxWSMessageBytes`.
|
||||||
|
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||||
|
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
maxMessageBytes: Int,
|
||||||
|
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||||
|
) {
|
||||||
self.maxMessageBytes = maxMessageBytes
|
self.maxMessageBytes = maxMessageBytes
|
||||||
|
self.identityProvider = identityProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||||
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||||
/// `connectPingable` builds on it).
|
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||||
|
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||||
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
try await WSConnection.open(
|
||||||
|
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||||
|
identity: identityProvider()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
private let frames: AsyncThrowingStream<String, any Error>
|
private let frames: AsyncThrowingStream<String, any Error>
|
||||||
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||||
|
|
||||||
|
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
|
||||||
|
/// connection-level challenge (which can only arrive after resume) always
|
||||||
|
/// reads a stable value without locking — same set-once discipline as
|
||||||
|
/// `task`/`session`.
|
||||||
|
private var identity: ClientIdentity?
|
||||||
|
private let challengeResponder = MutualTLSChallengeResponder()
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||||
super.init()
|
super.init()
|
||||||
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
/// Connect: build session+task, resume, await the delegate-driven
|
/// Connect: build session+task, resume, await the delegate-driven
|
||||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||||
/// then start the re-arming receive loop.
|
/// then start the re-arming receive loop.
|
||||||
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
|
static func open(
|
||||||
|
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||||
|
) async throws -> WSConnection {
|
||||||
let connection = WSConnection()
|
let connection = WSConnection()
|
||||||
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
connection.configure(
|
||||||
|
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||||
|
)
|
||||||
try await connection.performHandshake()
|
try await connection.performHandshake()
|
||||||
connection.startReceiveLoop()
|
connection.startReceiveLoop()
|
||||||
return connection
|
return connection
|
||||||
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: Setup
|
// MARK: Setup
|
||||||
|
|
||||||
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
|
private func configure(
|
||||||
|
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||||
|
) {
|
||||||
|
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||||
|
// after `task.resume()`, so this write happens-before any read of it.
|
||||||
|
self.identity = identity
|
||||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||||
var request = URLRequest(url: endpoint.wsURL)
|
var request = URLRequest(url: endpoint.wsURL)
|
||||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||||
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
|||||||
takeHandshakeContinuation()?.resume()
|
takeHandshakeContinuation()?.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
|
||||||
|
/// arrives task-level for a WS task. Delegate the decision to the pure
|
||||||
|
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
|
||||||
|
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
|
||||||
|
/// client cert is demanded but none is installed. `identity` is set-once
|
||||||
|
/// before `resume()` (see `configure`), so this read needs no lock.
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
let resolution = challengeResponder.resolve(challenge, identity: identity)
|
||||||
|
completionHandler(resolution.disposition, resolution.credential)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server close frame processed → CLEAN close: the stream FINISHES
|
/// Server close frame processed → CLEAN close: the stream FINISHES
|
||||||
/// (distinguishable from the transport-error THROW path).
|
/// (distinguishable from the transport-error THROW path).
|
||||||
func urlSession(
|
func urlSession(
|
||||||
|
|||||||
@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
|
|||||||
await connection.close()
|
await connection.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
|
||||||
|
func identityProviderResolvedPerConnect() async throws {
|
||||||
|
// A cert imported mid-run must take effect on the NEXT connection. The
|
||||||
|
// transport therefore re-consults its provider on every `connect` rather
|
||||||
|
// than capturing a snapshot — proven here by a provider whose invocation
|
||||||
|
// count grows once per connect (nil identity: ScriptedWSServer is plain
|
||||||
|
// ws, so the connection succeeds regardless).
|
||||||
|
let (server, endpoint) = try await Self.startServer()
|
||||||
|
defer { server.stop() }
|
||||||
|
let calls = CallCounter()
|
||||||
|
let transport = URLSessionTermTransport(identityProvider: {
|
||||||
|
calls.increment()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
let first = try await transport.connect(to: endpoint)
|
||||||
|
await first.close()
|
||||||
|
let second = try await transport.connect(to: endpoint)
|
||||||
|
await second.close()
|
||||||
|
|
||||||
|
#expect(calls.value == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
||||||
func connectToDeadPortThrows() async throws {
|
func connectToDeadPortThrows() async throws {
|
||||||
let server = ScriptedWSServer()
|
let server = ScriptedWSServer()
|
||||||
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
|
|||||||
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||||
|
/// (URLSession may consult it off the test's isolation domain).
|
||||||
|
private final class CallCounter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var count = 0
|
||||||
|
func increment() { lock.withLock { count += 1 } }
|
||||||
|
var value: Int { lock.withLock { count } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ packages:
|
|||||||
path: Packages/HostRegistry
|
path: Packages/HostRegistry
|
||||||
APIClient:
|
APIClient:
|
||||||
path: Packages/APIClient
|
path: Packages/APIClient
|
||||||
|
ClientTLS:
|
||||||
|
path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package
|
||||||
TestSupport:
|
TestSupport:
|
||||||
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
|
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
|
||||||
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
||||||
@@ -50,6 +52,7 @@ targets:
|
|||||||
- package: SessionCore
|
- package: SessionCore
|
||||||
- package: HostRegistry
|
- package: HostRegistry
|
||||||
- package: APIClient
|
- package: APIClient
|
||||||
|
- package: ClientTLS
|
||||||
- package: SwiftTerm
|
- package: SwiftTerm
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
@@ -119,6 +122,7 @@ targets:
|
|||||||
- package: SessionCore
|
- package: SessionCore
|
||||||
- package: HostRegistry
|
- package: HostRegistry
|
||||||
- package: APIClient
|
- package: APIClient
|
||||||
|
- package: ClientTLS
|
||||||
- package: TestSupport
|
- package: TestSupport
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
--tabbar-h: 40px;
|
--tabbar-h: 40px;
|
||||||
--keybar-h: 46px;
|
--keybar-h: 46px;
|
||||||
|
|
||||||
|
/* iOS notch / home-indicator clearance. index.html sets viewport-fit=cover,
|
||||||
|
* so the layout spans the physical screen edges; without these the top bar
|
||||||
|
* hides under the status bar and the bottom key-bar covers terminal output.
|
||||||
|
* Falls back to 0px on platforms without safe areas. */
|
||||||
|
--safe-t: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +63,9 @@ body {
|
|||||||
#tabbar {
|
#tabbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
height: var(--tabbar-h);
|
box-sizing: border-box;
|
||||||
|
height: calc(var(--tabbar-h) + var(--safe-t));
|
||||||
|
padding-top: var(--safe-t);
|
||||||
background: linear-gradient(180deg, #181a22, var(--surface-1));
|
background: linear-gradient(180deg, #181a22, var(--surface-1));
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -285,7 +294,7 @@ body {
|
|||||||
/* ── Terminal area ───────────────────────────────────────────────── */
|
/* ── Terminal area ───────────────────────────────────────────────── */
|
||||||
#term {
|
#term {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--tabbar-h) 0 var(--keybar-h) 0;
|
inset: calc(var(--tabbar-h) + var(--safe-t)) 0 calc(var(--keybar-h) + var(--safe-b)) 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -301,13 +310,14 @@ body {
|
|||||||
#keybar {
|
#keybar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto 0 0 0;
|
inset: auto 0 0 0;
|
||||||
height: var(--keybar-h);
|
box-sizing: border-box;
|
||||||
|
height: calc(var(--keybar-h) + var(--safe-b));
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 0 6px;
|
padding: 0 6px var(--safe-b);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -382,7 +392,7 @@ body {
|
|||||||
|
|
||||||
#searchbox {
|
#searchbox {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--tabbar-h) + 8px);
|
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
|
||||||
right: 10px;
|
right: 10px;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -480,7 +490,7 @@ body {
|
|||||||
/* Settings panel */
|
/* Settings panel */
|
||||||
#settingspanel {
|
#settingspanel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--tabbar-h) + 8px);
|
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
|
||||||
right: 10px;
|
right: 10px;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -679,7 +689,7 @@ body {
|
|||||||
/* Approval banner (H3) */
|
/* Approval banner (H3) */
|
||||||
#approvalbar {
|
#approvalbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto 0 var(--keybar-h) 0;
|
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1547,9 +1557,10 @@ body {
|
|||||||
body.home-open #keybar {
|
body.home-open #keybar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom. */
|
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom
|
||||||
|
* (keeping the home-indicator clearance so launcher content stays reachable). */
|
||||||
body.home-open #term {
|
body.home-open #term {
|
||||||
bottom: 0;
|
bottom: var(--safe-b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: float the Sessions/Projects toggle top-right, on the SAME row as the
|
/* Desktop: float the Sessions/Projects toggle top-right, on the SAME row as the
|
||||||
|
|||||||
@@ -9,8 +9,24 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "tsx src/main.ts",
|
"start": "tsx src/main.ts",
|
||||||
|
"start:phase1": "tsx src/main-phase1.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"control-plane": "file:../control-plane",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
|
"relay-e2e": "file:../relay-e2e",
|
||||||
|
"term-relay": "file:../term-relay",
|
||||||
|
"relay-web": "file:../relay-web",
|
||||||
|
"agent": "file:../agent",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"ioredis": "^5.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
relay-run/scripts/mint-manage-token.ts
Normal file
56
relay-run/scripts/mint-manage-token.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* RELAY-PHASE1 · staging admin-token minter (run from relay-run/ so relay-auth resolves).
|
||||||
|
*
|
||||||
|
* cd relay-run && npx tsx scripts/mint-manage-token.ts --aud <BASE_DOMAIN> [--sub <accountId>]
|
||||||
|
*
|
||||||
|
* Mints a §4.3 `manage` capability token signed by the P5 PRIVATE key, for the deny-by-default
|
||||||
|
* control-plane admin API (POST /accounts, /accounts/:id/pairing-codes). `aud` MUST equal the CP's
|
||||||
|
* BASE_DOMAIN (its `expectedAud`). The admin API verifies signature+aud+rights and derives accountId
|
||||||
|
* ONLY from the token (INV3) — it does NOT require a live DPoP proof — but the issuer still stamps a
|
||||||
|
* well-formed `cnf.jkt` (from a throwaway ephemeral key) because issueCapabilityToken mandates it.
|
||||||
|
* TTL is clamped to 60 s, so mint immediately before each admin call.
|
||||||
|
*
|
||||||
|
* Prints ONLY the raw token to stdout; the signing key material is never printed.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { issueCapabilityToken } from 'relay-auth'
|
||||||
|
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||||
|
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(`--${name}`)
|
||||||
|
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const keyPath = arg('key', '/etc/relay/capability/capability-sign.key.pem') as string
|
||||||
|
const aud = arg('aud')
|
||||||
|
const sub = arg('sub', 'bootstrap') as string
|
||||||
|
const host = arg('host', '_manage_') as string
|
||||||
|
const rights = (arg('rights', 'manage') as string).split(',').map((r) => r.trim())
|
||||||
|
if (aud === undefined || aud.length === 0) {
|
||||||
|
process.stderr.write('FATAL: --aud <BASE_DOMAIN> is required\n')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pem = readFileSync(keyPath, 'utf8')
|
||||||
|
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64')
|
||||||
|
const signingKey = await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
|
||||||
|
|
||||||
|
const eph = await generateEd25519KeyPair()
|
||||||
|
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const token = await issueCapabilityToken(
|
||||||
|
// Runtime only reads principal.accountId; a minimal shape is intentional for this staging tool.
|
||||||
|
{ principal: { accountId: sub } as never, aud, host, rights: rights as never, ttlSeconds: 60, cnfJkt },
|
||||||
|
signingKey,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
process.stdout.write(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e: unknown) => {
|
||||||
|
process.stderr.write(`mint failed: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
303
relay-run/src/main-phase1.ts
Normal file
303
relay-run/src/main-phase1.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane
|
||||||
|
* (B1–B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the
|
||||||
|
* browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts).
|
||||||
|
*
|
||||||
|
* Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and
|
||||||
|
* env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7,
|
||||||
|
* restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis
|
||||||
|
* revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9).
|
||||||
|
*
|
||||||
|
* browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent
|
||||||
|
* │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert
|
||||||
|
* └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown
|
||||||
|
*
|
||||||
|
* All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js'
|
||||||
|
import { createPgPool, createQuery } from 'control-plane/src/db/pool.js'
|
||||||
|
import { createPgStores } from 'control-plane/src/store/pg.js'
|
||||||
|
import { createRedisClient } from 'control-plane/src/boot/redis.js'
|
||||||
|
import type {
|
||||||
|
MtlsVerifier,
|
||||||
|
TlsServerFactory,
|
||||||
|
} from 'term-relay/data-plane/agent-listener.js'
|
||||||
|
|
||||||
|
import { createRelayEnforceDeps } from './wiring/stores-pg.js'
|
||||||
|
import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js'
|
||||||
|
import { createStoreRouteResolver } from './wiring/route-resolver.js'
|
||||||
|
import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js'
|
||||||
|
import { createAuthorizer } from './wiring/authorizer.js'
|
||||||
|
import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js'
|
||||||
|
import { makeAgentTlsServerFactory } from './servers/agent-tls.js'
|
||||||
|
import { startBrowserServer } from './servers/browser-server.js'
|
||||||
|
import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js'
|
||||||
|
|
||||||
|
const DEFAULT_BIND_HOST = '0.0.0.0'
|
||||||
|
const DEFAULT_BIND_PORT = 443
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url)) // <repo>/relay-run/src
|
||||||
|
const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public')
|
||||||
|
|
||||||
|
// ── env helpers (fail-fast on misconfiguration) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = process.env[name]
|
||||||
|
if (v === undefined || v.length === 0) {
|
||||||
|
throw new KeyConfigError(`required env ${name} is not set`)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePort(name: string): number {
|
||||||
|
const raw = requireEnv(name)
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||||
|
throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function intEnv(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name]
|
||||||
|
if (raw === undefined || raw.length === 0) return fallback
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||||
|
throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry
|
||||||
|
* the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when
|
||||||
|
* present, e.g. 'ECONNREFUSED') and never the object itself.
|
||||||
|
*/
|
||||||
|
function errText(e: unknown): string {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
const code = (e as { code?: unknown }).code
|
||||||
|
return code === undefined ? e.message : `${e.message} (code=${String(code)})`
|
||||||
|
}
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── async mTLS → sync-slot bridge ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MtlsBridge {
|
||||||
|
/** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */
|
||||||
|
readonly sync: MtlsVerifier
|
||||||
|
/** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */
|
||||||
|
wrap(base: TlsServerFactory): TlsServerFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently
|
||||||
|
* async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT
|
||||||
|
* editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and
|
||||||
|
* caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by
|
||||||
|
* `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs
|
||||||
|
* synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed
|
||||||
|
* throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14).
|
||||||
|
*/
|
||||||
|
function bridgeAsyncMtls(
|
||||||
|
asyncMtls: AsyncMtlsVerifier,
|
||||||
|
onError: (e: unknown) => void,
|
||||||
|
): MtlsBridge {
|
||||||
|
const pending = new Map<string, { hostId: string; accountId: string } | null>()
|
||||||
|
const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64')
|
||||||
|
|
||||||
|
const sync: MtlsVerifier = {
|
||||||
|
verifyPeer(peerCert) {
|
||||||
|
const k = keyOf(peerCert)
|
||||||
|
const verdict = pending.get(k) ?? null
|
||||||
|
pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer
|
||||||
|
return verdict
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) =>
|
||||||
|
base(opts, (ws, der) => {
|
||||||
|
asyncMtls
|
||||||
|
.verifyPeer(der)
|
||||||
|
.then((verdict) => {
|
||||||
|
pending.set(keyOf(der), verdict)
|
||||||
|
onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
onError(e)
|
||||||
|
pending.set(keyOf(der), null) // fail-closed → attach() closes 4401
|
||||||
|
onPeer(ws, der)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sync, wrap }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── boot ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const now = (): number => Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Config (fail-fast; secrets are read but never logged — INV9).
|
||||||
|
const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST
|
||||||
|
const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT)
|
||||||
|
const agentBindPort = requirePort('AGENT_BIND_PORT')
|
||||||
|
const tlsCertPath = requireEnv('TLS_CERT_PATH')
|
||||||
|
const tlsKeyPath = requireEnv('TLS_KEY_PATH')
|
||||||
|
const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH')
|
||||||
|
const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH')
|
||||||
|
const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH')
|
||||||
|
const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH')
|
||||||
|
const baseDomain = requireEnv('BASE_DOMAIN')
|
||||||
|
const relayNodeId = requireEnv('RELAY_NODE_ID')
|
||||||
|
const trustDomain = requireEnv('RELAY_TRUST_DOMAIN')
|
||||||
|
const allowedOrigins = requireEnv('ALLOWED_ORIGINS')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter((o) => o.length > 0)
|
||||||
|
if (allowedOrigins.length === 0) {
|
||||||
|
throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)')
|
||||||
|
}
|
||||||
|
const pgUrl = requireEnv('PG_URL')
|
||||||
|
const redisUrl = requireEnv('REDIS_URL')
|
||||||
|
const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT
|
||||||
|
|
||||||
|
// Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9).
|
||||||
|
await loadVerifyKeyFromEnv()
|
||||||
|
|
||||||
|
// Shared stores: SAME Postgres + Redis as the control-plane (INV7).
|
||||||
|
const pool = createPgPool(pgUrl)
|
||||||
|
const query = createQuery(pool)
|
||||||
|
const stores = createPgStores(query)
|
||||||
|
const redis = createRedisClient(redisUrl)
|
||||||
|
const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection
|
||||||
|
|
||||||
|
const deps = createRelayEnforceDeps({ query, redis })
|
||||||
|
const resolver = createStoreRouteResolver({ hosts: stores.hosts })
|
||||||
|
|
||||||
|
const asyncMtls = createMtlsVerifier({
|
||||||
|
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
|
||||||
|
hosts: deps.hosts,
|
||||||
|
now,
|
||||||
|
onError: (e) => console.error('[mtls-verify]', errText(e)),
|
||||||
|
})
|
||||||
|
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e)))
|
||||||
|
|
||||||
|
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
|
||||||
|
|
||||||
|
const config = makeDataPlaneConfig({ baseDomain, bindHost, bindPort, agentBindPort, relayNodeId })
|
||||||
|
|
||||||
|
const agentTlsFactory = makeAgentTlsServerFactory({
|
||||||
|
serverCertPath: agentServerCertPath,
|
||||||
|
serverKeyPath: agentServerKeyPath,
|
||||||
|
bindHost,
|
||||||
|
bindPort: agentBindPort,
|
||||||
|
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
|
||||||
|
onError: (e) => console.error('[agent-mtls]', errText(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dp = buildDataPlane({
|
||||||
|
config,
|
||||||
|
authorizer,
|
||||||
|
resolver,
|
||||||
|
mtls: mtlsBridge.sync,
|
||||||
|
now,
|
||||||
|
// Agent-TLS trust store for validating the agent's CLIENT leaf (requestCert+rejectUnauthorized).
|
||||||
|
// MUST be the FULL chain (intermediate + self-signed root): Node/OpenSSL rejects a leaf whose only
|
||||||
|
// anchor is a non-self-signed intermediate (no PARTIAL_CHAIN flag). Intermediate-only ⇒ every agent
|
||||||
|
// is reset at the TLS layer before attach() runs.
|
||||||
|
caBundle: [readFileSync(agentCaChainPath)],
|
||||||
|
onError: (e) => console.error('[data-plane]', errText(e)),
|
||||||
|
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
|
||||||
|
})
|
||||||
|
|
||||||
|
// STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key
|
||||||
|
// are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves.
|
||||||
|
const operatorPassword = process.env.OPERATOR_PASSWORD ?? ''
|
||||||
|
const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? ''
|
||||||
|
let onRequest: ReturnType<typeof createAuthMintRoute> | undefined
|
||||||
|
let mintEnabled = false
|
||||||
|
if (operatorPassword.length > 0 && signPrivRaw.length > 0) {
|
||||||
|
const signingKey = await loadSigningKeyFromEnv(signPrivRaw)
|
||||||
|
// F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell).
|
||||||
|
// Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored).
|
||||||
|
const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt'
|
||||||
|
onRequest = createAuthMintRoute({
|
||||||
|
signingKey,
|
||||||
|
hosts: stores.hosts,
|
||||||
|
operatorPassword,
|
||||||
|
now,
|
||||||
|
rateLimit: { buckets: deps.buckets, salt: mintRateSalt },
|
||||||
|
onError: (e) => console.error('[auth-mint]', errText(e)),
|
||||||
|
})
|
||||||
|
mintEnabled = true
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserServer = startBrowserServer({
|
||||||
|
certPath: tlsCertPath,
|
||||||
|
keyPath: tlsKeyPath,
|
||||||
|
bindHost,
|
||||||
|
bindPort,
|
||||||
|
node: dp.node,
|
||||||
|
landingHtml: '<!doctype html><title>relay</title>', // unused when staticRoot is set
|
||||||
|
staticRoot: webRoot,
|
||||||
|
...(onRequest ? { onRequest } : {}),
|
||||||
|
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
|
||||||
|
onError: (e) => console.error('[browser-wss]', errText(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
|
||||||
|
const revocableNode: RevocableNode = {
|
||||||
|
activeTunnels: () =>
|
||||||
|
[...dp.listener.tunnels().values()].map((t) => ({ hostId: t.hostId, accountId: t.accountId })),
|
||||||
|
closeStream: (hostId) => dp.node.closeTunnel(hostId),
|
||||||
|
}
|
||||||
|
const revsub = startRevocationSubscriber({
|
||||||
|
redisSubscriber,
|
||||||
|
node: revocableNode,
|
||||||
|
// INV10: log counts + scope KIND only — never signal.reason / terminal payload.
|
||||||
|
onApplied: (signal, hostsAffected) =>
|
||||||
|
console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`),
|
||||||
|
onDropped: () => console.warn('[revocation] dropped malformed kill-signal'),
|
||||||
|
onError: (e) => console.error('[revocation]', errText(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n=== relay-run Phase 1 READY ===')
|
||||||
|
console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`)
|
||||||
|
console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`)
|
||||||
|
console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`)
|
||||||
|
console.log(`Allowed origins : ${allowedOrigins.join(', ')}`)
|
||||||
|
console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`)
|
||||||
|
console.log('Ctrl-C to stop.\n')
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
const shutdown = async (): Promise<void> => {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
console.log('\nshutting down…')
|
||||||
|
try {
|
||||||
|
revsub.close()
|
||||||
|
browserServer.close()
|
||||||
|
dp.listener.close()
|
||||||
|
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[shutdown]', errText(e))
|
||||||
|
} finally {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.on('SIGINT', () => void shutdown())
|
||||||
|
process.on('SIGTERM', () => void shutdown())
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('fatal:', errText(e))
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -37,6 +37,11 @@ export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFacto
|
|||||||
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
|
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
|
||||||
onPeer(wsToWebSocketLike(ws), der)
|
onPeer(wsToWebSocketLike(ws), der)
|
||||||
})
|
})
|
||||||
|
wss.on('error', (e) => opts.onError?.(e))
|
||||||
|
// Surface TLS-layer client failures (e.g. a client leaf that doesn't chain to `ca`). Node
|
||||||
|
// otherwise SWALLOWS 'tlsClientError' — the peer just sees an abrupt reset with no server signal,
|
||||||
|
// which is exactly what masked the intermediate-only-CA bug.
|
||||||
|
server.on('tlsClientError', (e) => opts.onError?.(e))
|
||||||
server.on('error', (e) => opts.onError?.(e))
|
server.on('error', (e) => opts.onError?.(e))
|
||||||
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
||||||
return {
|
return {
|
||||||
|
|||||||
314
relay-run/src/servers/auth-mint.ts
Normal file
314
relay-run/src/servers/auth-mint.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`).
|
||||||
|
*
|
||||||
|
* ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a │
|
||||||
|
* │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │
|
||||||
|
* │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5–T8). │
|
||||||
|
* └────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK
|
||||||
|
* thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the
|
||||||
|
* subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability
|
||||||
|
* token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path.
|
||||||
|
*
|
||||||
|
* SECURITY:
|
||||||
|
* - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP
|
||||||
|
* store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it
|
||||||
|
* never supplies an accountId/hostId.
|
||||||
|
* - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only
|
||||||
|
* the thrown error (callers must not log request bodies / tokens either).
|
||||||
|
* - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token.
|
||||||
|
* - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
|
||||||
|
*/
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
|
import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth'
|
||||||
|
import type { CapabilityRight } from 'relay-contracts'
|
||||||
|
import type { HostStore } from 'control-plane/src/store/ports.js'
|
||||||
|
|
||||||
|
/** Route this handler owns. */
|
||||||
|
const MINT_PATH = '/auth/mint' as const
|
||||||
|
/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */
|
||||||
|
const MINT_TTL_SEC = 60
|
||||||
|
/** Reject an oversized request body before buffering it (DoS guard). */
|
||||||
|
const MAX_BODY_BYTES = 4096
|
||||||
|
/** Least-privilege: an operator connecting to their terminal needs only `attach`. */
|
||||||
|
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
|
||||||
|
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
|
||||||
|
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
|
||||||
|
/**
|
||||||
|
* F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short
|
||||||
|
* burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing
|
||||||
|
* OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries.
|
||||||
|
*/
|
||||||
|
export const MINT_RATE_BURST = 5
|
||||||
|
export const MINT_RATE_REFILL_PER_SEC = 0.2
|
||||||
|
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 1–63 chars, no leading/trailing '-'. */
|
||||||
|
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
||||||
|
|
||||||
|
/** The single CP `hosts` capability this route needs (interface segregation). */
|
||||||
|
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token
|
||||||
|
* bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to
|
||||||
|
* disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it.
|
||||||
|
*/
|
||||||
|
export interface MintRateLimit {
|
||||||
|
/** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */
|
||||||
|
readonly buckets: TokenBucketStore
|
||||||
|
/** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */
|
||||||
|
readonly salt: string
|
||||||
|
/** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */
|
||||||
|
readonly burst?: number
|
||||||
|
/** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */
|
||||||
|
readonly refillPerSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthMintDeps {
|
||||||
|
/** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
|
||||||
|
readonly signingKey: CryptoKey
|
||||||
|
/** CP hosts store — the ONLY source of accountId/hostId (INV3). */
|
||||||
|
readonly hosts: SubdomainHostLookup
|
||||||
|
/** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */
|
||||||
|
readonly operatorPassword: string
|
||||||
|
/** Epoch-SECONDS clock (token iat/exp). */
|
||||||
|
readonly now: () => number
|
||||||
|
/** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */
|
||||||
|
readonly rateLimit?: MintRateLimit
|
||||||
|
/** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
|
||||||
|
readonly onError?: (e: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MintRequest {
|
||||||
|
readonly password: string
|
||||||
|
readonly jkt: string
|
||||||
|
readonly subdomain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */
|
||||||
|
function parseMintRequest(json: unknown): MintRequest | null {
|
||||||
|
if (typeof json !== 'object' || json === null) return null
|
||||||
|
const { password, jkt, subdomain } = json as Record<string, unknown>
|
||||||
|
if (typeof password !== 'string' || password.length === 0) return null
|
||||||
|
if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null
|
||||||
|
if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null
|
||||||
|
return { password, jkt, subdomain }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ha = createHash('sha256').update(a).digest()
|
||||||
|
const hb = createHash('sha256').update(b).digest()
|
||||||
|
return timingSafeEqual(ha, hb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */
|
||||||
|
function mintBucketKey(salt: string, remoteAddr: string): string {
|
||||||
|
return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed,
|
||||||
|
* false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to
|
||||||
|
* handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default).
|
||||||
|
*/
|
||||||
|
async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise<boolean> {
|
||||||
|
const key = mintBucketKey(rl.salt, remoteAddr)
|
||||||
|
const burst = rl.burst ?? MINT_RATE_BURST
|
||||||
|
const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC
|
||||||
|
return rl.buckets.take(key, refillPerSec, burst, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
const payload = JSON.stringify(body)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
})
|
||||||
|
res.end(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path portion of a URL (query/hash stripped) — the route matches on path only. */
|
||||||
|
function pathOf(url: string): string {
|
||||||
|
return url.split('?', 1)[0].split('#', 1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */
|
||||||
|
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let size = 0
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
req.on('data', (chunk: Buffer) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > maxBytes) {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('request body too large'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||||
|
req.on('error', (e) => reject(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal {
|
||||||
|
// Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3).
|
||||||
|
return {
|
||||||
|
kind: 'human',
|
||||||
|
accountId,
|
||||||
|
principalId: `operator:${accountId}`,
|
||||||
|
amr: ['passkey'],
|
||||||
|
authAt,
|
||||||
|
stepUpAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMint(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
deps: AuthMintDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of
|
||||||
|
// OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored).
|
||||||
|
if (deps.rateLimit !== undefined) {
|
||||||
|
const remoteAddr = req.socket?.remoteAddress ?? ''
|
||||||
|
if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) {
|
||||||
|
sendJson(res, 429, { error: 'rate_limited' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
raw = await readBody(req, MAX_BODY_BYTES)
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 413, { error: 'payload_too_large' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: unknown
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: 'invalid_json' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseMintRequest(json)
|
||||||
|
if (parsed === null) {
|
||||||
|
sendJson(res, 400, { error: 'invalid_request' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { password, jkt, subdomain } = parsed
|
||||||
|
|
||||||
|
// STAGING gate. Constant-time to avoid a password-length/prefix timing oracle.
|
||||||
|
if (!safeEqual(password, deps.operatorPassword)) {
|
||||||
|
sendJson(res, 401, { error: 'unauthorized' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await deps.hosts.getBySubdomain(subdomain)
|
||||||
|
if (host === null) {
|
||||||
|
sendJson(res, 404, { error: 'unknown_subdomain' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (host.status === 'revoked') {
|
||||||
|
sendJson(res, 403, { error: 'host_revoked' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSec = deps.now()
|
||||||
|
// INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's.
|
||||||
|
const token = await issueCapabilityToken(
|
||||||
|
{
|
||||||
|
principal: operatorPrincipal(host.accountId, nowSec),
|
||||||
|
aud: host.subdomain,
|
||||||
|
host: host.hostId,
|
||||||
|
rights: MINT_RIGHTS,
|
||||||
|
ttlSeconds: MINT_TTL_SEC,
|
||||||
|
cnfJkt: jkt,
|
||||||
|
},
|
||||||
|
deps.signingKey,
|
||||||
|
nowSec,
|
||||||
|
)
|
||||||
|
// INV9: the token is a bearer secret — return it, NEVER log it.
|
||||||
|
sendJson(res, 200, { token })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
deps.onError?.(e)
|
||||||
|
if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook.
|
||||||
|
*
|
||||||
|
* Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also
|
||||||
|
* write a response — the actual mint completes asynchronously. Returns `false` for any other path so
|
||||||
|
* the request falls through to the static/landing handler.
|
||||||
|
*/
|
||||||
|
export function createAuthMintRoute(
|
||||||
|
deps: AuthMintDeps,
|
||||||
|
): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||||
|
return (req, res) => {
|
||||||
|
if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendJson(res, 405, { error: 'method_not_allowed' })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
void handleMint(req, res, deps)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P5 capability signing-key loader ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g
|
||||||
|
|
||||||
|
/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array<ArrayBuffer>`). */
|
||||||
|
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
const out = new Uint8Array(u.byteLength)
|
||||||
|
out.set(u)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode standard-or-URL base64 (padding optional) to bytes. */
|
||||||
|
function base64AnyToBytes(s: string): Uint8Array {
|
||||||
|
const std = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
return new Uint8Array(Buffer.from(std, 'base64'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */
|
||||||
|
function pemBodyToDer(pem: string): Uint8Array {
|
||||||
|
const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '')
|
||||||
|
return base64AnyToBytes(b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value.
|
||||||
|
* Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a
|
||||||
|
* base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage.
|
||||||
|
*
|
||||||
|
* INV9: the raw key material is never logged; this throws a generic error on a malformed value.
|
||||||
|
*/
|
||||||
|
export async function loadSigningKeyFromEnv(raw: string): Promise<CryptoKey> {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty')
|
||||||
|
const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed)
|
||||||
|
if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes')
|
||||||
|
try {
|
||||||
|
return await globalThis.crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
toArrayBufferView(der),
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Never surface the underlying material in the error.
|
||||||
|
throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
|
|||||||
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||||||
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||||
|
import { serveStatic } from './static-web.js'
|
||||||
|
import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js'
|
||||||
|
|
||||||
export interface BrowserServerOptions {
|
export interface BrowserServerOptions {
|
||||||
readonly certPath: string
|
readonly certPath: string
|
||||||
@@ -20,6 +22,20 @@ export interface BrowserServerOptions {
|
|||||||
readonly bindPort: number
|
readonly bindPort: number
|
||||||
readonly node: RelayNode
|
readonly node: RelayNode
|
||||||
readonly landingHtml: string
|
readonly landingHtml: string
|
||||||
|
/**
|
||||||
|
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
|
||||||
|
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
|
||||||
|
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
|
||||||
|
*/
|
||||||
|
readonly staticRoot?: string
|
||||||
|
/**
|
||||||
|
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
|
||||||
|
* request. Return `true` to claim the request (the hook owns the response — it may finish it
|
||||||
|
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
|
||||||
|
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
|
||||||
|
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
|
||||||
|
*/
|
||||||
|
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
|
||||||
readonly onListening?: () => void
|
readonly onListening?: () => void
|
||||||
readonly onError?: (e: unknown) => void
|
readonly onError?: (e: unknown) => void
|
||||||
}
|
}
|
||||||
@@ -35,12 +51,26 @@ function parseCookies(header: string | undefined): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
/**
|
||||||
|
* Build the P1 `UpgradeRequest` from the raw upgrade request.
|
||||||
|
*
|
||||||
|
* DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it),
|
||||||
|
* but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra
|
||||||
|
* `term.dpop.<b64u>` subprotocol entry. The header WINS when both are present; otherwise the proof
|
||||||
|
* falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here:
|
||||||
|
* the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from
|
||||||
|
* client-supplied claims — so both transports feed the SAME downstream DPoP binding.
|
||||||
|
*
|
||||||
|
* `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2).
|
||||||
|
*/
|
||||||
|
export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest {
|
||||||
const proto = req.headers['sec-websocket-protocol']
|
const proto = req.headers['sec-websocket-protocol']
|
||||||
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => v.length > 0)
|
.filter((v) => v.length > 0)
|
||||||
const dpopHeader = req.headers['dpop']
|
const dpopHeader = req.headers['dpop']
|
||||||
|
const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null
|
||||||
|
const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols)
|
||||||
return {
|
return {
|
||||||
host: req.headers.host ?? '',
|
host: req.headers.host ?? '',
|
||||||
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||||
@@ -48,15 +78,32 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
|||||||
subprotocols,
|
subprotocols,
|
||||||
cookies: parseCookies(req.headers.cookie),
|
cookies: parseCookies(req.headers.cookie),
|
||||||
remoteAddr: req.socket.remoteAddress ?? '',
|
remoteAddr: req.socket.remoteAddress ?? '',
|
||||||
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
|
dpop: { proof, publicKeyThumbprint: null },
|
||||||
activeSessionCount: 0,
|
activeSessionCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||||
const server = createServer(
|
const server = createServer(
|
||||||
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
||||||
(_req: IncomingMessage, res: ServerResponse) => {
|
(req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
|
||||||
|
// must NOT fall through to static/landing (which would double-write the response).
|
||||||
|
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
|
||||||
|
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
|
||||||
|
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
|
||||||
|
if (opts.staticRoot !== undefined) {
|
||||||
|
const file = serveStatic(opts.staticRoot, req.url ?? '/')
|
||||||
|
if (file) {
|
||||||
|
res.writeHead(file.status, file.headers)
|
||||||
|
res.end(file.body)
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
||||||
|
res.end('Not Found')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Phase-0 fallback: a single landing page.
|
||||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||||
res.end(opts.landingHtml)
|
res.end(opts.landingHtml)
|
||||||
},
|
},
|
||||||
@@ -66,9 +113,24 @@ export function startBrowserServer(opts: BrowserServerOptions): Server {
|
|||||||
handleProtocols: (protocols: Set<string>) =>
|
handleProtocols: (protocols: Set<string>) =>
|
||||||
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
||||||
})
|
})
|
||||||
|
// F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent
|
||||||
|
// splice index PRIVATE (no public per-host stream count), so we approximate the account's "active
|
||||||
|
// sessions" with the number of browser WS connections currently open to the SAME tenant origin
|
||||||
|
// (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live
|
||||||
|
// connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This
|
||||||
|
// over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap).
|
||||||
|
const liveByTenant = new Map<string, number>()
|
||||||
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
||||||
|
const tenantKey = req.headers.host ?? ''
|
||||||
|
const priorCount = liveByTenant.get(tenantKey) ?? 0
|
||||||
|
liveByTenant.set(tenantKey, priorCount + 1)
|
||||||
|
ws.once('close', () => {
|
||||||
|
const next = (liveByTenant.get(tenantKey) ?? 1) - 1
|
||||||
|
if (next <= 0) liveByTenant.delete(tenantKey)
|
||||||
|
else liveByTenant.set(tenantKey, next)
|
||||||
|
})
|
||||||
opts.node
|
opts.node
|
||||||
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
|
.handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws))
|
||||||
.catch((e) => opts.onError?.(e))
|
.catch((e) => opts.onError?.(e))
|
||||||
})
|
})
|
||||||
server.on('error', (e) => opts.onError?.(e))
|
server.on('error', (e) => opts.onError?.(e))
|
||||||
|
|||||||
36
relay-run/src/servers/dpop-subprotocol.ts
Normal file
36
relay-run/src/servers/dpop-subprotocol.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* B7 · Decode the browser's DPoP proof carried as an EXTRA WS subprotocol entry.
|
||||||
|
*
|
||||||
|
* A browser's native WebSocket API cannot set request headers, so relay-web offers the §4.3 DPoP
|
||||||
|
* proof-of-possession JWS as an additional `Sec-WebSocket-Protocol` entry
|
||||||
|
* `term.dpop.<base64url(proofJws)>`
|
||||||
|
* — mirroring how the capability token rides `term.token.<b64u>` (relay-contracts subprotocol.ts).
|
||||||
|
* This is the relay-side inverse of relay-web `encodeDpopSubprotocol`; the wire format is decoded
|
||||||
|
* byte-for-byte with the SAME isomorphic base64url helper both sides share (relay-contracts), so
|
||||||
|
* issuance and verification can never drift.
|
||||||
|
*
|
||||||
|
* FAIL-CLOSED (INV15): an absent entry OR malformed base64url / non-UTF-8 payload → `null`, so the
|
||||||
|
* downstream DPoP gate denies through the audited path instead of ever seeing a half-decoded proof.
|
||||||
|
*/
|
||||||
|
import { decodeBase64UrlString } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Subprotocol entry prefix carrying the DPoP proof (mirrors relay-web `DPOP_SUBPROTOCOL_PREFIX`). */
|
||||||
|
export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the DPoP proof JWS from a parsed subprotocol list: find the first `term.dpop.` entry,
|
||||||
|
* strip the prefix, and base64url-decode the remainder. Returns `null` when the entry is absent,
|
||||||
|
* empty, or does not decode (deny-by-default).
|
||||||
|
*/
|
||||||
|
export function extractDpopProofFromSubprotocols(values: readonly string[]): string | null {
|
||||||
|
const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX))
|
||||||
|
if (entry === undefined) return null
|
||||||
|
const encoded = entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)
|
||||||
|
if (encoded.length === 0) return null
|
||||||
|
try {
|
||||||
|
const decoded = decodeBase64UrlString(encoded)
|
||||||
|
return decoded.length > 0 ? decoded : null
|
||||||
|
} catch {
|
||||||
|
return null // fail-closed: malformed base64url or invalid UTF-8
|
||||||
|
}
|
||||||
|
}
|
||||||
96
relay-run/src/servers/static-web.ts
Normal file
96
relay-run/src/servers/static-web.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Pure static-file resolver for the browser HTTPS server (D1). Serves the built relay-web bundle
|
||||||
|
* SAME-ORIGIN as the browser WSS so the page's Origin and CSP stay aligned with the WS endpoint.
|
||||||
|
*
|
||||||
|
* Deterministic given (root, urlPath): it resolves the request path under `root`, reads the file,
|
||||||
|
* and returns its bytes + Content-Type on a hit — or `null` when the path is malformed, escapes
|
||||||
|
* `root` (traversal), or names no readable regular file. The caller maps `null` to a 404.
|
||||||
|
*
|
||||||
|
* Security (STRICT traversal guard): the resolved absolute path MUST be `root` itself or a
|
||||||
|
* descendant of it. `../`, percent-encoded `..` (`%2e%2e`), and NUL bytes all reject to `null` —
|
||||||
|
* this function never reads a byte outside `root`.
|
||||||
|
*/
|
||||||
|
import { readFileSync, statSync } from 'node:fs'
|
||||||
|
import { resolve, sep, extname } from 'node:path'
|
||||||
|
|
||||||
|
export interface StaticFile {
|
||||||
|
readonly status: number
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly body: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Served for a bare `/` request. */
|
||||||
|
const DEFAULT_DOC = 'index.html'
|
||||||
|
const OCTET_STREAM = 'application/octet-stream'
|
||||||
|
|
||||||
|
/** Extension → Content-Type. Covers what the relay-web bundle ships plus common static assets. */
|
||||||
|
const MIME_BY_EXT: Readonly<Record<string, string>> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.webmanifest': 'application/manifest+json',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFor(filePath: string): string {
|
||||||
|
return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? OCTET_STREAM
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip query/hash, percent-decode, and reject malformed / NUL-byte paths. Returns the path made
|
||||||
|
* relative to `root` (leading slashes stripped, `/` → `index.html`), or `null` to reject.
|
||||||
|
*/
|
||||||
|
function normalizeUrlPath(urlPath: string): string | null {
|
||||||
|
const noQuery = urlPath.split('?')[0].split('#')[0]
|
||||||
|
let decoded: string
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(noQuery)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (decoded.includes('\0')) return null
|
||||||
|
const rel = decoded.replace(/^\/+/, '')
|
||||||
|
return rel.length === 0 ? DEFAULT_DOC : rel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `urlPath` to a file under `root`. Returns the file bytes + MIME on a hit, or `null` when
|
||||||
|
* the path is malformed, escapes `root`, or names no readable regular file.
|
||||||
|
*/
|
||||||
|
export function serveStatic(root: string, urlPath: string): StaticFile | null {
|
||||||
|
const rel = normalizeUrlPath(urlPath)
|
||||||
|
if (rel === null) return null
|
||||||
|
|
||||||
|
const rootAbs = resolve(root)
|
||||||
|
const resolved = resolve(rootAbs, rel)
|
||||||
|
|
||||||
|
// STRICT traversal guard: resolved must be the root itself or a descendant of it.
|
||||||
|
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!statSync(resolved).isFile()) return null
|
||||||
|
const body = readFileSync(resolved)
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': contentTypeFor(resolved),
|
||||||
|
'content-length': String(body.length),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ENOENT / EACCES / etc. — treat as "no servable file", caller 404s.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* B2 · registry-backed MtlsVerifier (INV4/INV14) — replaces the Phase-0 stub in `relay-world.ts:149`
|
||||||
|
* that trusted ANY peer cert. Wraps relay-auth's `verifyAgentCert` against the SHARED host registry
|
||||||
|
* (the same `HostRegistryPort` B1 builds over Postgres) and the pinned agent-CA bundle, returning
|
||||||
|
* `{ hostId, accountId }` ONLY for an enrolled, unrevoked host whose leaf chains to our CA and is in
|
||||||
|
* its validity window. Every other outcome is `null` (fail-closed).
|
||||||
|
*
|
||||||
|
* INV3: `hostId`/`accountId` originate ONLY from the authenticated cert material (SPIFFE-ID) matched
|
||||||
|
* against the registry — never from a client-supplied field. INV14: the pubkey is bound to the
|
||||||
|
* registry AFTER CA-chain validation (a cert can chain to our CA yet still be denied if not enrolled).
|
||||||
|
*
|
||||||
|
* ── ASYNC IMPEDANCE (flagged for the orchestrator; adaptation per task B2) ──────────────────────
|
||||||
|
* The term-relay `MtlsVerifier.verifyPeer` (agent-listener.ts:16) is declared SYNC and its caller
|
||||||
|
* (agent-listener.ts:93) does NOT await it (`if (verified === null)`). A registry-backed verifier
|
||||||
|
* CANNOT be sync — `HostRegistryPort.getById` returns a Promise, so `verifyAgentCert` is async. This
|
||||||
|
* factory therefore returns an `AsyncMtlsVerifier` (Promise-returning `verifyPeer`). Wiring it into the
|
||||||
|
* data-plane requires `MtlsVerifier.verifyPeer` to become async END-TO-END: `agent-listener.ts` must
|
||||||
|
* `await` the result, and the `relay-world.ts:149` stub must be replaced. Those files are OUTSIDE B2's
|
||||||
|
* Owns. Until that migration lands, assigning this into the sync slot type-errors at the wiring site
|
||||||
|
* (`Promise<…>` is not assignable to `{…}|null`) — a useful compile-time tripwire, not a silent bug.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
verifyAgentCert,
|
||||||
|
defaultParseX509,
|
||||||
|
type ParseCert,
|
||||||
|
type HostRegistryPort,
|
||||||
|
} from 'relay-auth'
|
||||||
|
|
||||||
|
const PEM_LINE_WIDTH = 64
|
||||||
|
/** Non-global (safe for `.test()`): asserts a bundle actually holds a PEM CERTIFICATE block. */
|
||||||
|
const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async analog of term-relay's sync `MtlsVerifier` (agent-listener.ts). See ASYNC IMPEDANCE above:
|
||||||
|
* a registry lookup is inherently async, so `verifyPeer` returns a Promise.
|
||||||
|
*/
|
||||||
|
export interface AsyncMtlsVerifier {
|
||||||
|
verifyPeer(peerCertDer: Uint8Array): Promise<{ hostId: string; accountId: string } | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MtlsVerifierDeps {
|
||||||
|
/** Pinned agent-CA bundle (PEM: intermediate(s) + root). NEVER the browser LE chain (INV14). */
|
||||||
|
readonly caChainPem: string
|
||||||
|
/** Shared host registry (same port B1 builds over Postgres). accountId/hostId only from here (INV3). */
|
||||||
|
readonly hosts: HostRegistryPort
|
||||||
|
/** Epoch-SECONDS provider (compared against the leaf's notBefore/notAfter). */
|
||||||
|
readonly now: () => number
|
||||||
|
/** Optional observability seam for a registry/parse fault; the verifier still fails closed. */
|
||||||
|
readonly onError?: (e: unknown) => void
|
||||||
|
/** Test seam: cert parser (defaults to production `defaultParseX509`). */
|
||||||
|
readonly parse?: ParseCert
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap raw DER bytes (`getPeerCertificate(true).raw`) into a PEM certificate block with 64-column
|
||||||
|
* base64, the shape node's `X509Certificate` (via `verifyAgentCert`) parses.
|
||||||
|
*/
|
||||||
|
export function derToPem(der: Uint8Array): string {
|
||||||
|
const b64 = Buffer.from(der).toString('base64')
|
||||||
|
const wrapped = b64.match(new RegExp(`.{1,${PEM_LINE_WIDTH}}`, 'g'))?.join('\n') ?? ''
|
||||||
|
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMtlsVerifier(deps: MtlsVerifierDeps): AsyncMtlsVerifier {
|
||||||
|
const { caChainPem, hosts, now } = deps
|
||||||
|
const onError = deps.onError ?? (() => {})
|
||||||
|
const parse = deps.parse ?? defaultParseX509
|
||||||
|
|
||||||
|
// Fail fast at construction on a misconfigured CA bundle (INV14: a real pinned CA is required; an
|
||||||
|
// empty/garbage bundle would otherwise silently deny every agent with an opaque `chain_invalid`).
|
||||||
|
if (typeof caChainPem !== 'string' || !PEM_CERT_RE.test(caChainPem)) {
|
||||||
|
throw new Error('createMtlsVerifier: caChainPem must contain at least one PEM CERTIFICATE block')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async verifyPeer(peerCertDer) {
|
||||||
|
if (peerCertDer === undefined || peerCertDer === null || peerCertDer.length === 0) {
|
||||||
|
return null // no client cert presented → fail closed
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const leafPem = derToPem(peerCertDer)
|
||||||
|
const result = await verifyAgentCert(leafPem, caChainPem, now(), hosts, parse)
|
||||||
|
if (!result.ok || result.hostId === undefined || result.accountId === undefined) {
|
||||||
|
return null // expired / chain-invalid / not-enrolled / revoked / account-mismatch → fail closed
|
||||||
|
}
|
||||||
|
return { hostId: result.hostId, accountId: result.accountId }
|
||||||
|
} catch (e: unknown) {
|
||||||
|
onError(e) // registry/DB fault: surface it, never throw out of verifyPeer → fail closed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
121
relay-run/src/wiring/revocation-subscriber.ts
Normal file
121
relay-run/src/wiring/revocation-subscriber.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* B4 · relay-run revocation subscriber (INV12 data-plane teardown). Bridges the frozen §4.2
|
||||||
|
* `relay:revocations` Redis pub/sub bus onto the running relay node so a revocation tears the live
|
||||||
|
* tunnel(s) down within the INV12 budget.
|
||||||
|
*
|
||||||
|
* Flow: an injected ioredis subscriber-mode client SUBSCRIBEs the one named channel; each message is
|
||||||
|
* validated as a `KillSignal` with the relay-contracts schema (malformed → dropped + counted, never
|
||||||
|
* a teardown and never a bus crash — a poisoned message can neither kill the bus nor fire a spurious
|
||||||
|
* kill); then for every live tunnel THIS node serves the pure relay-auth predicate
|
||||||
|
* `killsScope(signal, hostAccountId, hostId)` decides coverage and, on a match, the whole host is
|
||||||
|
* torn down (revoked ⇒ grace forced to 0 upstream in T11).
|
||||||
|
*
|
||||||
|
* Reuse, don't re-derive: scope math is `killsScope` (relay-auth) and validation is `KillSignalSchema`
|
||||||
|
* (relay-contracts) — this module only wires those pure primitives to a concrete ioredis client and
|
||||||
|
* the relay node. Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host
|
||||||
|
* this node does not serve is a no-op (no cross-tenant reach). `signal.reason` is metadata only and is
|
||||||
|
* never written as terminal payload or log content (INV10). The owning `accountId` used for
|
||||||
|
* account-scope fan-out comes only from the authenticated tunnel (INV3).
|
||||||
|
*/
|
||||||
|
import { KillSignalSchema, RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
|
||||||
|
import { killsScope } from 'relay-auth'
|
||||||
|
|
||||||
|
/** One live tunnel this node currently serves: the host and the account that owns it. Both are
|
||||||
|
* authenticated material carried on the tunnel (INV3) — never derived from the untrusted signal. */
|
||||||
|
export interface ActiveTunnelRef {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly accountId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the subscriber needs from the running relay node: enumerate the tunnels it serves and tear a
|
||||||
|
* whole host's live streams/devices down (INV12). Kept narrow + injectable so the Phase-1 entry (B5)
|
||||||
|
* adapts the real data-plane {node, listener}: `activeTunnels` ← `listener.tunnels()` values,
|
||||||
|
* `closeStream(hostId)` ← `node.closeTunnel(hostId)` (the whole-host revocation lever).
|
||||||
|
*/
|
||||||
|
export interface RevocableNode {
|
||||||
|
/** Snapshot-friendly view of the tunnels currently attached to this node. */
|
||||||
|
activeTunnels(): Iterable<ActiveTunnelRef>
|
||||||
|
/** Immediately tear down every live stream/device on `hostId` (whole-host revocation, INV12). */
|
||||||
|
closeStream(hostId: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal structural view of an ioredis client in subscriber mode — only the members used here — so
|
||||||
|
* this module carries no hard dependency on ioredis and stays unit-testable with a fake. A real
|
||||||
|
* ioredis `Redis` instance satisfies this shape.
|
||||||
|
*/
|
||||||
|
export interface RedisSubscriber {
|
||||||
|
subscribe(channel: string): Promise<unknown>
|
||||||
|
unsubscribe(channel: string): Promise<unknown>
|
||||||
|
on(event: 'message', listener: (channel: string, message: string) => void): unknown
|
||||||
|
off(event: 'message', listener: (channel: string, message: string) => void): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevocationSubscriberDeps {
|
||||||
|
readonly redisSubscriber: RedisSubscriber
|
||||||
|
readonly node: RevocableNode
|
||||||
|
/** Observability (metadata only, INV10): a valid signal was applied to `hostsAffected` hosts here. */
|
||||||
|
readonly onApplied?: (signal: KillSignal, hostsAffected: number) => void
|
||||||
|
/** Malformed-message counter — a dropped signal never fires a teardown (INV12 safety). */
|
||||||
|
readonly onDropped?: () => void
|
||||||
|
/** Boundary error routing for subscribe/unsubscribe failures — never silently swallowed. */
|
||||||
|
readonly onError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevocationSubscription {
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKillSignal(raw: string): KillSignal | null {
|
||||||
|
let json: unknown
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const result = KillSignalSchema.safeParse(json)
|
||||||
|
return result.success ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): RevocationSubscription {
|
||||||
|
const { redisSubscriber, node, onApplied, onDropped, onError } = deps
|
||||||
|
|
||||||
|
const onMessage = (channel: string, message: string): void => {
|
||||||
|
if (channel !== RELAY_REVOCATIONS_CHANNEL) return // this client may also carry other channels
|
||||||
|
const signal = parseKillSignal(message)
|
||||||
|
if (signal === null) {
|
||||||
|
onDropped?.() // dropped + counted; bus stays alive, no teardown fired
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Snapshot first: closeStream tears down (mutates) the node's live-tunnel set as we iterate.
|
||||||
|
const tunnels = [...node.activeTunnels()]
|
||||||
|
let hostsAffected = 0
|
||||||
|
for (const { hostId, accountId } of tunnels) {
|
||||||
|
if (!killsScope(signal, accountId, hostId)) continue // blast-radius bounded; unrelated host = no-op
|
||||||
|
node.closeStream(hostId)
|
||||||
|
hostsAffected += 1
|
||||||
|
}
|
||||||
|
onApplied?.(signal, hostsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
redisSubscriber.on('message', onMessage)
|
||||||
|
// Fire the SUBSCRIBE; route a rejected subscribe to onError (boundary I/O — never swallowed).
|
||||||
|
Promise.resolve(redisSubscriber.subscribe(RELAY_REVOCATIONS_CHANNEL)).catch((error: unknown) => {
|
||||||
|
onError?.(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
let closed = false
|
||||||
|
return {
|
||||||
|
close(): void {
|
||||||
|
if (closed) return // idempotent teardown
|
||||||
|
closed = true
|
||||||
|
redisSubscriber.off('message', onMessage)
|
||||||
|
Promise.resolve(redisSubscriber.unsubscribe(RELAY_REVOCATIONS_CHANNEL)).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
40
relay-run/src/wiring/route-resolver.ts
Normal file
40
relay-run/src/wiring/route-resolver.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* B3 · Store-backed RouteResolver — the PRODUCTION replacement for the one-entry in-RAM
|
||||||
|
* `memoryRouteResolver` (wiring/data-plane.ts:110). It implements term-relay's `RouteResolver`
|
||||||
|
* interface EXACTLY (`resolveSubdomain(subdomain) -> ResolvedHost | null`) by looking the tenant
|
||||||
|
* label up in the control-plane Postgres `hosts` store via `hosts.getBySubdomain(subdomain)`.
|
||||||
|
*
|
||||||
|
* Fail-closed (returns null, → 403 at the T8 upgrade edge) on:
|
||||||
|
* - unknown subdomain: `getBySubdomain` returns null.
|
||||||
|
* - revoked host: the row still exists (status is versioned, not deleted — INV8/INV12), so we
|
||||||
|
* must reject `status === 'revoked'` here; a revoked host must never resolve to a route.
|
||||||
|
*
|
||||||
|
* The resolver is only a HINT for candidate lookup (subdomain-router.ts): the returned `hostId`
|
||||||
|
* is what `authorizeUpgrade` feeds to P5 as `requestedHostId`, and P5 gates the signed token's
|
||||||
|
* `host` against it (INV1). Identity (`accountId`/`hostId`) here comes solely from the CP store —
|
||||||
|
* the ownership source of truth — never from the caller (INV3).
|
||||||
|
*/
|
||||||
|
import type { HostStore } from 'control-plane/src/store/ports.js'
|
||||||
|
import type { RouteResolver, ResolvedHost } from 'term-relay/data-plane/subdomain-router.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single HostStore capability this resolver needs (interface segregation): a subdomain lookup.
|
||||||
|
* A full `createPgStores().hosts` (`HostStore`) satisfies it structurally.
|
||||||
|
*/
|
||||||
|
export type HostSubdomainLookup = Pick<HostStore, 'getBySubdomain'>
|
||||||
|
|
||||||
|
export interface StoreRouteResolverDeps {
|
||||||
|
readonly hosts: HostSubdomainLookup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a RouteResolver that resolves subdomain → host from the control-plane `hosts` store. */
|
||||||
|
export function createStoreRouteResolver({ hosts }: StoreRouteResolverDeps): RouteResolver {
|
||||||
|
return {
|
||||||
|
async resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> {
|
||||||
|
const host = await hosts.getBySubdomain(subdomain)
|
||||||
|
if (host === null) return null // unknown subdomain — fail closed
|
||||||
|
if (host.status === 'revoked') return null // revoked host — fail closed (INV12)
|
||||||
|
return { hostId: host.hostId, accountId: host.accountId, subdomain: host.subdomain }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,8 +82,33 @@ function toUint8(data: unknown): Uint8Array | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */
|
/**
|
||||||
|
* Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only).
|
||||||
|
*
|
||||||
|
* The `ws.on('message'|'close')` listeners are attached NOW (at construction), not when the consumer
|
||||||
|
* later calls `onMessage`/`onClose`. The composition root builds this synchronously on the socket's
|
||||||
|
* 'connection' event, but the mux consumer only wires its handler AFTER an async mTLS-registry lookup
|
||||||
|
* (the bridge). Any frame that arrives in that gap — notably the agent's FIRST heartbeat ping, sent
|
||||||
|
* immediately on open — would otherwise be dropped by `ws` (no listener yet), starving the heartbeat
|
||||||
|
* and flapping the tunnel every 15 s. We buffer early frames (and an early close) and flush on wire-up.
|
||||||
|
*/
|
||||||
export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
|
export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
|
||||||
|
let onMessage: ((data: Uint8Array) => void) | null = null
|
||||||
|
let onClose: (() => void) | null = null
|
||||||
|
const backlog: Uint8Array[] = []
|
||||||
|
let closedEarly = false
|
||||||
|
|
||||||
|
ws.on('message', (data: unknown) => {
|
||||||
|
const bytes = toUint8(data)
|
||||||
|
if (bytes === null) return
|
||||||
|
if (onMessage === null) backlog.push(bytes)
|
||||||
|
else onMessage(bytes)
|
||||||
|
})
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (onClose === null) closedEarly = true
|
||||||
|
else onClose()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send(data: Uint8Array) {
|
send(data: Uint8Array) {
|
||||||
ws.send(data, { binary: true })
|
ws.send(data, { binary: true })
|
||||||
@@ -92,13 +117,12 @@ export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
|
|||||||
ws.close(code)
|
ws.close(code)
|
||||||
},
|
},
|
||||||
onMessage(handler) {
|
onMessage(handler) {
|
||||||
ws.on('message', (data: unknown) => {
|
onMessage = handler
|
||||||
const bytes = toUint8(data)
|
while (backlog.length > 0) handler(backlog.shift()!)
|
||||||
if (bytes !== null) handler(bytes)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onClose(handler) {
|
onClose(handler) {
|
||||||
ws.on('close', () => handler())
|
onClose = handler
|
||||||
|
if (closedEarly) handler()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
197
relay-run/src/wiring/stores-pg.ts
Normal file
197
relay-run/src/wiring/stores-pg.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* B1 — shared-store `EnforceDeps` for relay-auth's `onUpgrade` pipeline, backed by the SAME
|
||||||
|
* Postgres + Redis as the control-plane (P3). This replaces the Phase-0 in-RAM fakes
|
||||||
|
* (`memory-stores.ts`) so the data-plane and the control-plane read one world of truth
|
||||||
|
* (restart-safe, INV7).
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - hosts / sessions / audit -> Postgres via the CP repository adapter (`createPgStores(query)`).
|
||||||
|
* The CP `HostRecord` IS the relay-auth `HostRecord` (both re-export the frozen relay-contracts
|
||||||
|
* §4.2 shape), so `getById` is a direct pass-through; sessions/audit remap to the port shapes.
|
||||||
|
* - revocation / buckets -> Redis. `consumeOnce` is a single-use `SET NX` burn (Finding-4); the
|
||||||
|
* token bucket is an atomic Lua script (no read-modify-write race).
|
||||||
|
*
|
||||||
|
* SECURITY:
|
||||||
|
* - INV3: `accountId` is only ever read from authenticated material (the CP registry rows / the
|
||||||
|
* principal), never from client input. This adapter surfaces stored rows; it never fabricates an
|
||||||
|
* accountId from a request field.
|
||||||
|
* - Fail-closed: store/Redis errors are NOT swallowed — they reject and the enforcement pipeline
|
||||||
|
* denies the upgrade (deny-by-default).
|
||||||
|
* - INV9/INV10: audit rows carry metadata only (no keys, no terminal payload).
|
||||||
|
*/
|
||||||
|
import { createPgStores } from 'control-plane/src/store/pg.js'
|
||||||
|
import type { QueryFn } from 'control-plane/src/db/pool.js'
|
||||||
|
import type {
|
||||||
|
AuditEvent,
|
||||||
|
EnforceDeps,
|
||||||
|
HostRegistryPort,
|
||||||
|
RevocationStore,
|
||||||
|
SessionRegistryPort,
|
||||||
|
TokenBucketStore,
|
||||||
|
} from 'relay-auth'
|
||||||
|
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'
|
||||||
|
|
||||||
|
// ── Redis surface (ioredis-compatible, injected loosely so tests can pass a mock) ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimal slice of an ioredis client this adapter uses. A real `ioredis` `Redis` instance
|
||||||
|
* structurally satisfies this (its methods are supersets), and a unit-test mock can implement it.
|
||||||
|
*/
|
||||||
|
export interface RedisLike {
|
||||||
|
exists(key: string): Promise<number>
|
||||||
|
set(key: string, value: string, mode?: 'NX'): Promise<string | null>
|
||||||
|
expireat(key: string, timestamp: number): Promise<number>
|
||||||
|
eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayEnforceDepsConfig {
|
||||||
|
readonly query: QueryFn
|
||||||
|
readonly redis: RedisLike
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Redis key namespaces (opaque to relay-auth) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REVOKED_PREFIX = 'revoked:' as const
|
||||||
|
const USED_PREFIX = 'used:' as const
|
||||||
|
const BUCKET_PREFIX = 'bucket:' as const
|
||||||
|
|
||||||
|
/** Buffer added to the computed refill window before a fully-refilled bucket key may be reaped. */
|
||||||
|
const BUCKET_TTL_BUFFER_SEC = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic token bucket (lazy refill). One round-trip, no read-modify-write race:
|
||||||
|
* KEYS[1]=bucket key · ARGV: refillPerSec, burst(capacity), now(epoch sec), ttl(sec).
|
||||||
|
* Returns 1 when a token was available (and consumed), 0 when throttled.
|
||||||
|
*/
|
||||||
|
const TOKEN_BUCKET_LUA = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local refill = tonumber(ARGV[1])
|
||||||
|
local burst = tonumber(ARGV[2])
|
||||||
|
local now = tonumber(ARGV[3])
|
||||||
|
local ttl = tonumber(ARGV[4])
|
||||||
|
local state = redis.call('HMGET', key, 't', 'ts')
|
||||||
|
local tokens = tonumber(state[1])
|
||||||
|
local ts = tonumber(state[2])
|
||||||
|
if tokens == nil then
|
||||||
|
tokens = burst
|
||||||
|
ts = now
|
||||||
|
end
|
||||||
|
local elapsed = now - ts
|
||||||
|
if elapsed > 0 then
|
||||||
|
tokens = math.min(burst, tokens + elapsed * refill)
|
||||||
|
end
|
||||||
|
local allowed = 0
|
||||||
|
if tokens >= 1 then
|
||||||
|
tokens = tokens - 1
|
||||||
|
allowed = 1
|
||||||
|
end
|
||||||
|
redis.call('HSET', key, 't', tokens, 'ts', now)
|
||||||
|
redis.call('EXPIRE', key, ttl)
|
||||||
|
return allowed
|
||||||
|
`
|
||||||
|
|
||||||
|
/** Seconds a bucket needs to fully refill from empty, plus a small reap buffer. */
|
||||||
|
function bucketTtlSec(refillPerSec: number, burst: number): number {
|
||||||
|
const refillWindow = refillPerSec > 0 ? Math.ceil(burst / refillPerSec) : Math.ceil(burst)
|
||||||
|
return Math.max(1, refillWindow) + BUCKET_TTL_BUFFER_SEC
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Redis-backed ports ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function redisRevocationStore(redis: RedisLike): RevocationStore {
|
||||||
|
return {
|
||||||
|
async isRevoked(jti) {
|
||||||
|
return (await redis.exists(REVOKED_PREFIX + jti)) > 0
|
||||||
|
},
|
||||||
|
async revokeJti(jti, exp) {
|
||||||
|
await redis.set(REVOKED_PREFIX + jti, '1')
|
||||||
|
await redis.expireat(REVOKED_PREFIX + jti, exp)
|
||||||
|
},
|
||||||
|
async consumeOnce(jti, exp) {
|
||||||
|
// First-use wins: SET NX returns 'OK' only when the key did not exist (Finding-4).
|
||||||
|
const set = await redis.set(USED_PREFIX + jti, '1', 'NX')
|
||||||
|
if (set !== 'OK') return false
|
||||||
|
await redis.expireat(USED_PREFIX + jti, exp)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redisTokenBucketStore(redis: RedisLike): TokenBucketStore {
|
||||||
|
return {
|
||||||
|
async take(key, refillPerSec, burst, now) {
|
||||||
|
const ttl = bucketTtlSec(refillPerSec, burst)
|
||||||
|
const allowed = await redis.eval(
|
||||||
|
TOKEN_BUCKET_LUA,
|
||||||
|
1,
|
||||||
|
BUCKET_PREFIX + key,
|
||||||
|
refillPerSec,
|
||||||
|
burst,
|
||||||
|
now,
|
||||||
|
ttl,
|
||||||
|
)
|
||||||
|
return Number(allowed) === 1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Postgres-backed ports (over the CP repository adapter) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Map a relay-auth `AuditEvent` onto the CP `audit_log` row shape (metadata only, INV10). */
|
||||||
|
function toAuditRow(e: AuditEvent): {
|
||||||
|
action: string
|
||||||
|
principalId: string
|
||||||
|
accountId: string
|
||||||
|
hostId: string | null
|
||||||
|
ts: string
|
||||||
|
meta: Record<string, string>
|
||||||
|
} {
|
||||||
|
const meta: Record<string, string> = {
|
||||||
|
outcome: e.outcome,
|
||||||
|
reason: e.reason,
|
||||||
|
remoteAddrHash: e.remoteAddrHash,
|
||||||
|
}
|
||||||
|
if (e.sessionId !== null) meta.sessionId = e.sessionId
|
||||||
|
if (e.jti !== null) meta.jti = e.jti
|
||||||
|
return {
|
||||||
|
action: e.action,
|
||||||
|
principalId: e.principalId,
|
||||||
|
accountId: e.accountId,
|
||||||
|
hostId: e.hostId,
|
||||||
|
ts: e.ts,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assembly ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the relay-auth `EnforceDeps` over the shared Postgres (`query`) + Redis (`redis`).
|
||||||
|
* `stepUpPolicyFor` returns `NO_STEPUP_POLICY` (staging single-operator; Phase 2 -> per-host WebAuthn).
|
||||||
|
*/
|
||||||
|
export function createRelayEnforceDeps({ query, redis }: RelayEnforceDepsConfig): EnforceDeps {
|
||||||
|
const stores = createPgStores(query)
|
||||||
|
|
||||||
|
const hosts: HostRegistryPort = {
|
||||||
|
// CP HostRecord === relay-auth HostRecord (both = relay-contracts §4.2) — direct pass-through.
|
||||||
|
getById: (hostId) => stores.hosts.get(hostId),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: SessionRegistryPort = {
|
||||||
|
getById: async (sessionId) => {
|
||||||
|
const rec = await stores.sessions.get(sessionId)
|
||||||
|
return rec === null ? null : { hostId: rec.hostId, accountId: rec.accountId }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts,
|
||||||
|
sessions,
|
||||||
|
revocation: redisRevocationStore(redis),
|
||||||
|
buckets: redisTokenBucketStore(redis),
|
||||||
|
audit: {
|
||||||
|
append: (e) => stores.audit.append(toAuditRow(e)),
|
||||||
|
},
|
||||||
|
stepUpPolicyFor: () => NO_STEPUP_POLICY,
|
||||||
|
}
|
||||||
|
}
|
||||||
354
relay-run/tests/auth-mint.test.ts
Normal file
354
relay-run/tests/auth-mint.test.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
|
||||||
|
* signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
|
||||||
|
* the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
|
||||||
|
* method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import type { HostRecord } from 'control-plane/src/model/records.js'
|
||||||
|
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||||
|
import type { TokenBucketStore } from 'relay-auth'
|
||||||
|
import {
|
||||||
|
createAuthMintRoute,
|
||||||
|
loadSigningKeyFromEnv,
|
||||||
|
MINT_RATE_BURST,
|
||||||
|
type SubdomainHostLookup,
|
||||||
|
type MintRateLimit,
|
||||||
|
} from '../src/servers/auth-mint.js'
|
||||||
|
|
||||||
|
const subtle = globalThis.crypto.subtle
|
||||||
|
const NOW = 1_800_000_000
|
||||||
|
const PASSWORD = 'staging-operator-secret'
|
||||||
|
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
|
||||||
|
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)
|
||||||
|
|
||||||
|
interface Loaded {
|
||||||
|
readonly signingKey: CryptoKey
|
||||||
|
readonly publicKey: CryptoKey
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys: Loaded
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
|
||||||
|
publicKey: CryptoKey
|
||||||
|
privateKey: CryptoKey
|
||||||
|
}
|
||||||
|
keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
|
||||||
|
})
|
||||||
|
|
||||||
|
function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
|
||||||
|
return {
|
||||||
|
hostId: 'host-1',
|
||||||
|
accountId: 'acct-1',
|
||||||
|
subdomain: 'alice',
|
||||||
|
agentPubkey: new Uint8Array(32),
|
||||||
|
enrollFpr: 'fpr-host-1',
|
||||||
|
status: 'online',
|
||||||
|
lastSeen: '2026-01-01T00:00:00.000Z',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
|
||||||
|
return { getBySubdomain: async () => rec }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapturedRes {
|
||||||
|
statusCode: number
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: string
|
||||||
|
headersSent: boolean
|
||||||
|
writeHead(status: number, headers: Record<string, string>): CapturedRes
|
||||||
|
end(chunk?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
|
||||||
|
let resolveDone!: () => void
|
||||||
|
const done = new Promise<void>((r) => (resolveDone = r))
|
||||||
|
const captured: CapturedRes = {
|
||||||
|
statusCode: 0,
|
||||||
|
headers: {},
|
||||||
|
body: '',
|
||||||
|
headersSent: false,
|
||||||
|
writeHead(status, headers) {
|
||||||
|
this.statusCode = status
|
||||||
|
this.headers = headers
|
||||||
|
this.headersSent = true
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
end(chunk?: string) {
|
||||||
|
if (chunk !== undefined) this.body += chunk
|
||||||
|
resolveDone()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { res: captured as unknown as ServerResponse, captured, done }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage {
|
||||||
|
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
|
||||||
|
const req = Readable.from(chunks) as unknown as IncomingMessage
|
||||||
|
;(req as { method?: string }).method = method
|
||||||
|
;(req as { url?: string }).url = url
|
||||||
|
;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr }
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||||
|
return createAuthMintRoute({
|
||||||
|
signingKey: keys.signingKey,
|
||||||
|
hosts,
|
||||||
|
operatorPassword: PASSWORD,
|
||||||
|
now: () => NOW,
|
||||||
|
onError: () => {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
hosts: SubdomainHostLookup,
|
||||||
|
bodyObj: unknown,
|
||||||
|
): Promise<CapturedRes> {
|
||||||
|
const route = mkRoute(hosts)
|
||||||
|
const { res, captured, done } = fakeRes()
|
||||||
|
const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
|
||||||
|
expect(claimed).toBe(true)
|
||||||
|
await done
|
||||||
|
return captured
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createAuthMintRoute — happy path', () => {
|
||||||
|
it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
|
||||||
|
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })
|
||||||
|
|
||||||
|
expect(captured.statusCode).toBe(200)
|
||||||
|
expect(captured.headers['cache-control']).toBe('no-store')
|
||||||
|
const parsedBody = JSON.parse(captured.body) as { token: string }
|
||||||
|
expect(typeof parsedBody.token).toBe('string')
|
||||||
|
|
||||||
|
const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
|
||||||
|
sub: string
|
||||||
|
aud: string
|
||||||
|
host: string
|
||||||
|
rights: string[]
|
||||||
|
iat: number
|
||||||
|
exp: number
|
||||||
|
cnf: { jkt: string }
|
||||||
|
}
|
||||||
|
// Identity is the STORE row's, never the request body's (INV3).
|
||||||
|
expect(claims.sub).toBe('acct-1')
|
||||||
|
expect(claims.host).toBe('host-1')
|
||||||
|
expect(claims.aud).toBe('alice')
|
||||||
|
expect(claims.rights).toContain('attach')
|
||||||
|
// DPoP proof-of-possession binding to the client-provided thumbprint.
|
||||||
|
expect(claims.cnf.jkt).toBe(JKT)
|
||||||
|
// Short-lived (<= 60 s).
|
||||||
|
expect(claims.iat).toBe(NOW)
|
||||||
|
expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
|
||||||
|
expect(claims.exp - claims.iat).toBeGreaterThan(0)
|
||||||
|
// The token itself must never leak into logs — asserted by construction (no console here).
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createAuthMintRoute — deny by default', () => {
|
||||||
|
it('rejects a wrong password with 401 (no token)', async () => {
|
||||||
|
const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
|
||||||
|
expect(captured.statusCode).toBe(401)
|
||||||
|
expect(captured.body).not.toContain('token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unknown subdomain with 404', async () => {
|
||||||
|
const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
|
||||||
|
expect(captured.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
|
||||||
|
const captured = await post(
|
||||||
|
fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
|
||||||
|
{ password: PASSWORD, jkt: JKT, subdomain: 'alice' },
|
||||||
|
)
|
||||||
|
expect(captured.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a malformed jkt with 400', async () => {
|
||||||
|
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
|
||||||
|
expect(captured.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a malformed subdomain with 400', async () => {
|
||||||
|
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
|
||||||
|
expect(captured.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid JSON with 400', async () => {
|
||||||
|
const route = mkRoute(fakeHosts(mkHost()))
|
||||||
|
const { res, captured, done } = fakeRes()
|
||||||
|
const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
|
||||||
|
expect(claimed).toBe(true)
|
||||||
|
await done
|
||||||
|
expect(captured.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an oversized body with 413', async () => {
|
||||||
|
const big = 'x'.repeat(5000)
|
||||||
|
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
|
||||||
|
expect(captured.statusCode).toBe(413)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createAuthMintRoute — routing semantics', () => {
|
||||||
|
it('claims /auth/mint but 405s a non-POST method', () => {
|
||||||
|
const route = mkRoute(fakeHosts(mkHost()))
|
||||||
|
const { res, captured } = fakeRes()
|
||||||
|
const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
|
||||||
|
expect(claimed).toBe(true)
|
||||||
|
expect(captured.statusCode).toBe(405)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT claim a non-matching path (returns false, response untouched)', () => {
|
||||||
|
const route = mkRoute(fakeHosts(mkHost()))
|
||||||
|
const { res, captured } = fakeRes()
|
||||||
|
const claimed = route(fakeReq('POST', '/index.html', undefined), res)
|
||||||
|
expect(claimed).toBe(false)
|
||||||
|
expect(captured.statusCode).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('claims /auth/mint even with a query string', () => {
|
||||||
|
const route = mkRoute(fakeHosts(null))
|
||||||
|
const { res } = fakeRes()
|
||||||
|
const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
|
||||||
|
expect(claimed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── F1 · per-IP mint throttle ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */
|
||||||
|
function inMemoryBucket(): TokenBucketStore {
|
||||||
|
const state = new Map<string, { tokens: number; ts: number }>()
|
||||||
|
return {
|
||||||
|
async take(key, refillPerSec, burst, now) {
|
||||||
|
const s = state.get(key) ?? { tokens: burst, ts: now }
|
||||||
|
const elapsed = now - s.ts
|
||||||
|
let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens
|
||||||
|
const allowed = tokens >= 1
|
||||||
|
if (allowed) tokens -= 1
|
||||||
|
state.set(key, { tokens, ts: now })
|
||||||
|
return allowed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkRouteRl(
|
||||||
|
hosts: SubdomainHostLookup,
|
||||||
|
rateLimit: MintRateLimit,
|
||||||
|
): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||||
|
return createAuthMintRoute({
|
||||||
|
signingKey: keys.signingKey,
|
||||||
|
hosts,
|
||||||
|
operatorPassword: PASSWORD,
|
||||||
|
now: () => NOW,
|
||||||
|
rateLimit,
|
||||||
|
onError: () => {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postRl(
|
||||||
|
route: (req: IncomingMessage, res: ServerResponse) => boolean,
|
||||||
|
bodyObj: unknown,
|
||||||
|
remoteAddr: string,
|
||||||
|
): Promise<CapturedRes> {
|
||||||
|
const { res, captured, done } = fakeRes()
|
||||||
|
route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res)
|
||||||
|
await done
|
||||||
|
return captured
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createAuthMintRoute — F1 per-IP throttle', () => {
|
||||||
|
const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' }
|
||||||
|
|
||||||
|
it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => {
|
||||||
|
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' })
|
||||||
|
const ip = '198.51.100.4'
|
||||||
|
for (let i = 0; i < MINT_RATE_BURST; i++) {
|
||||||
|
const ok = await postRl(route, GOOD, ip)
|
||||||
|
expect(ok.statusCode).toBe(200)
|
||||||
|
}
|
||||||
|
const throttled = await postRl(route, GOOD, ip)
|
||||||
|
expect(throttled.statusCode).toBe(429)
|
||||||
|
expect(throttled.body).not.toContain('token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => {
|
||||||
|
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
|
||||||
|
const ip = '198.51.100.9'
|
||||||
|
expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token
|
||||||
|
const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip)
|
||||||
|
expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => {
|
||||||
|
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
|
||||||
|
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200)
|
||||||
|
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted
|
||||||
|
expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keys the bucket on a SALTED HASH, never the raw client IP', async () => {
|
||||||
|
const seenKeys: string[] = []
|
||||||
|
const spyBucket: TokenBucketStore = {
|
||||||
|
async take(key) {
|
||||||
|
seenKeys.push(key)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const rawIp = '203.0.113.55'
|
||||||
|
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' })
|
||||||
|
await postRl(route, GOOD, rawIp)
|
||||||
|
expect(seenKeys).toHaveLength(1)
|
||||||
|
expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/)
|
||||||
|
expect(seenKeys[0]).not.toContain(rawIp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadSigningKeyFromEnv', () => {
|
||||||
|
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
|
||||||
|
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
|
||||||
|
publicKey: CryptoKey
|
||||||
|
privateKey: CryptoKey
|
||||||
|
}
|
||||||
|
const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
|
||||||
|
const b64 = Buffer.from(der).toString('base64')
|
||||||
|
const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
|
||||||
|
return { pem, b64, publicKey: kp.publicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
|
||||||
|
const data = new Uint8Array([1, 2, 3, 4])
|
||||||
|
const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
|
||||||
|
return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('loads a PKCS#8 PEM into a usable signing key', async () => {
|
||||||
|
const { pem, publicKey } = await exportPkcs8Pem()
|
||||||
|
const key = await loadSigningKeyFromEnv(pem)
|
||||||
|
expect(await canSign(key, publicKey)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
|
||||||
|
const { b64, publicKey } = await exportPkcs8Pem()
|
||||||
|
const key = await loadSigningKeyFromEnv(b64)
|
||||||
|
expect(await canSign(key, publicKey)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on an empty value', async () => {
|
||||||
|
await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on a non-key value (never leaking material)', async () => {
|
||||||
|
await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
68
relay-run/tests/browser-server.test.ts
Normal file
68
relay-run/tests/browser-server.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* B7 · unit tests for `buildUpgradeRequest` — the browser upgrade → P1 `UpgradeRequest` builder.
|
||||||
|
* Focus: the DPoP proof transport (FUNCTIONAL BLOCKER). A browser cannot set the `dpop` request
|
||||||
|
* header, so the proof rides the `term.dpop.<b64u>` subprotocol entry; the builder must read it there
|
||||||
|
* while keeping the header path working (header WINS if both). Also pins `activeSessionCount` plumbing.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { IncomingMessage } from 'node:http'
|
||||||
|
import { encodeBase64UrlString, APP_SUBPROTOCOL } from 'relay-contracts'
|
||||||
|
import { buildUpgradeRequest } from '../src/servers/browser-server.js'
|
||||||
|
import { DPOP_SUBPROTOCOL_PREFIX } from '../src/servers/dpop-subprotocol.js'
|
||||||
|
|
||||||
|
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
||||||
|
|
||||||
|
function dpopEntry(proofJws: string): string {
|
||||||
|
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkReq(headers: Record<string, string | undefined>): IncomingMessage {
|
||||||
|
return {
|
||||||
|
headers: { host: 'alice.example.com', ...headers },
|
||||||
|
url: '/ws',
|
||||||
|
socket: { remoteAddress: '1.2.3.4' },
|
||||||
|
} as unknown as IncomingMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildUpgradeRequest — DPoP transport', () => {
|
||||||
|
it('reads the proof from the term.dpop.<b64u> subprotocol when no header is set (the browser path)', () => {
|
||||||
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
||||||
|
const upgrade = buildUpgradeRequest(req, 0)
|
||||||
|
expect(upgrade.dpop.proof).toBe(PROOF)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the dpop HEADER over the subprotocol entry when both are present (header wins)', () => {
|
||||||
|
const req = mkReq({
|
||||||
|
dpop: 'header-proof-jws',
|
||||||
|
'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}`,
|
||||||
|
})
|
||||||
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-proof-jws')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still honours the dpop header alone (header path unchanged)', () => {
|
||||||
|
const req = mkReq({ dpop: 'header-only', 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||||
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-only')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('yields proof=null when neither header nor subprotocol carries a proof', () => {
|
||||||
|
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||||
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('yields proof=null (fail-closed) for a malformed term.dpop. subprotocol entry', () => {
|
||||||
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${DPOP_SUBPROTOCOL_PREFIX}not*b64u!` })
|
||||||
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses the comma-separated subprotocol list and always carries the app subprotocol through', () => {
|
||||||
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
||||||
|
expect(buildUpgradeRequest(req, 0).subprotocols).toContain(APP_SUBPROTOCOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildUpgradeRequest — activeSessionCount plumbing (F2)', () => {
|
||||||
|
it('carries the caller-supplied active-session count verbatim', () => {
|
||||||
|
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||||
|
expect(buildUpgradeRequest(req, 3).activeSessionCount).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* B7 · unit tests for the relay-side DPoP subprotocol decoder (`extractDpopProofFromSubprotocols`).
|
||||||
|
* The browser (relay-web) offers the DPoP proof as an extra `term.dpop.<b64u(proofJws)>` entry on
|
||||||
|
* the WS upgrade because native WebSocket cannot set request headers. These tests pin the wire
|
||||||
|
* contract (valid decodes; absent → null; malformed → null, fail-closed) against the SAME isomorphic
|
||||||
|
* base64url helper relay-web encodes with (relay-contracts), so the two sides agree byte-for-byte.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { encodeBase64UrlString } from 'relay-contracts'
|
||||||
|
import {
|
||||||
|
extractDpopProofFromSubprotocols,
|
||||||
|
DPOP_SUBPROTOCOL_PREFIX,
|
||||||
|
} from '../src/servers/dpop-subprotocol.js'
|
||||||
|
import { APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Build the wire entry exactly as relay-web `encodeDpopSubprotocol` does. */
|
||||||
|
function dpopEntry(proofJws: string): string {
|
||||||
|
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A representative three-part DPoP proof JWS (header.payload.sig) — content is opaque to the decoder.
|
||||||
|
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
||||||
|
|
||||||
|
describe('extractDpopProofFromSubprotocols — valid', () => {
|
||||||
|
it('decodes a term.dpop.<b64u> entry back to the exact proof JWS', () => {
|
||||||
|
const subprotocols = [APP_SUBPROTOCOL, dpopEntry(PROOF)]
|
||||||
|
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the DPoP entry regardless of position (after app + token entries)', () => {
|
||||||
|
const subprotocols = [
|
||||||
|
APP_SUBPROTOCOL,
|
||||||
|
TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString('v4.public.raw-token'),
|
||||||
|
dpopEntry(PROOF),
|
||||||
|
]
|
||||||
|
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractDpopProofFromSubprotocols — absent → null', () => {
|
||||||
|
it('returns null when no term.dpop. entry is present', () => {
|
||||||
|
expect(extractDpopProofFromSubprotocols([APP_SUBPROTOCOL])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for an empty list', () => {
|
||||||
|
expect(extractDpopProofFromSubprotocols([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the prefix is present but the payload is empty', () => {
|
||||||
|
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractDpopProofFromSubprotocols — malformed → null (fail-closed)', () => {
|
||||||
|
it('returns null for a non-base64url payload (illegal characters)', () => {
|
||||||
|
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + 'not*base64url!'])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for base64url bytes that are not valid UTF-8', () => {
|
||||||
|
// 0xFF 0xFE is not a valid UTF-8 sequence; base64url of those two bytes is "__4".
|
||||||
|
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + '__4'])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
109
relay-run/tests/static-web.test.ts
Normal file
109
relay-run/tests/static-web.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query
|
||||||
|
* stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte
|
||||||
|
* all reject — even when the escaped target file genuinely exists on disk).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { serveStatic } from '../src/servers/static-web.js'
|
||||||
|
|
||||||
|
const INDEX_HTML = '<!doctype html><title>idx</title>'
|
||||||
|
const PAIR_HTML = '<!doctype html><title>pair</title>'
|
||||||
|
const INDEX_JS = 'export const x = 1\n'
|
||||||
|
const XTERM_CSS = 'body{margin:0}'
|
||||||
|
const SECRET = 'TOP SECRET — outside root'
|
||||||
|
|
||||||
|
let base: string // temp parent dir (holds the secret sibling)
|
||||||
|
let root: string // the static root passed to serveStatic
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
base = mkdtempSync(join(tmpdir(), 'relay-static-'))
|
||||||
|
root = join(base, 'public')
|
||||||
|
mkdirSync(join(root, 'build'), { recursive: true })
|
||||||
|
writeFileSync(join(root, 'index.html'), INDEX_HTML)
|
||||||
|
writeFileSync(join(root, 'pair.html'), PAIR_HTML)
|
||||||
|
writeFileSync(join(root, 'build', 'index.js'), INDEX_JS)
|
||||||
|
writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS)
|
||||||
|
// A real file OUTSIDE root — the traversal target the guard must never reach.
|
||||||
|
writeFileSync(join(base, 'secret.txt'), SECRET)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serveStatic — index resolution', () => {
|
||||||
|
it('serves index.html at "/"', () => {
|
||||||
|
const res = serveStatic(root, '/')
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||||
|
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||||
|
expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML)))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves an explicit .html page', () => {
|
||||||
|
const res = serveStatic(root, '/pair.html')
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||||
|
expect(res?.body.toString('utf8')).toBe(PAIR_HTML)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips the query string before resolving', () => {
|
||||||
|
const res = serveStatic(root, '/index.html?join=abc123')
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips the hash fragment before resolving', () => {
|
||||||
|
const res = serveStatic(root, '/#section')
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serveStatic — MIME types', () => {
|
||||||
|
it('maps /build/*.js to text/javascript', () => {
|
||||||
|
const res = serveStatic(root, '/build/index.js')
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||||
|
expect(res?.body.toString('utf8')).toBe(INDEX_JS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps .css to text/css', () => {
|
||||||
|
const res = serveStatic(root, '/build/xterm.css')
|
||||||
|
expect(res?.status).toBe(200)
|
||||||
|
expect(res?.headers['content-type']).toBe('text/css; charset=utf-8')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serveStatic — traversal guard (returns null)', () => {
|
||||||
|
it('blocks a raw ".." escape even though the target exists', () => {
|
||||||
|
// Sanity: the target file really is readable outside root, so a null result proves the guard.
|
||||||
|
expect(serveStatic(root, '/../secret.txt')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks a deep ".." escape', () => {
|
||||||
|
expect(serveStatic(root, '/build/../../secret.txt')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks percent-encoded ".." (%2e%2e)', () => {
|
||||||
|
expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a NUL byte in the path', () => {
|
||||||
|
expect(serveStatic(root, '/index.html%00.js')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serveStatic — misses (returns null)', () => {
|
||||||
|
it('returns null for a nonexistent file', () => {
|
||||||
|
expect(serveStatic(root, '/nope.html')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a directory (no listing)', () => {
|
||||||
|
expect(serveStatic(root, '/build')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed).
|
||||||
|
*
|
||||||
|
* Two layers, mirroring relay-auth/test/mtls.test.ts:
|
||||||
|
* 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to
|
||||||
|
* `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM
|
||||||
|
* conversion end-to-end against a fake host registry.
|
||||||
|
* 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating +
|
||||||
|
* fail-closed mapping without depending on fixture contents.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth'
|
||||||
|
import type { HostRegistryPort } from 'relay-auth'
|
||||||
|
import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js'
|
||||||
|
import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js'
|
||||||
|
|
||||||
|
// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1).
|
||||||
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls')
|
||||||
|
const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8')
|
||||||
|
|
||||||
|
const leafPem = readFixture('leaf.pem')
|
||||||
|
const caChainPem = readFixture('ca-chain.pem')
|
||||||
|
const foreignLeafPem = readFixture('foreign-leaf.pem')
|
||||||
|
|
||||||
|
const leafDer = new Uint8Array(new X509Certificate(leafPem).raw)
|
||||||
|
const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw)
|
||||||
|
|
||||||
|
// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode).
|
||||||
|
const parsedLeaf = defaultParseX509(leafPem, caChainPem)
|
||||||
|
const IN_WINDOW = parsedLeaf.notBefore + 60
|
||||||
|
|
||||||
|
const enrolledHosts = (): HostRegistryPort =>
|
||||||
|
fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')])
|
||||||
|
|
||||||
|
describe('derToPem (DER → PEM)', () => {
|
||||||
|
it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => {
|
||||||
|
const pem = derToPem(leafDer)
|
||||||
|
expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true)
|
||||||
|
expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true)
|
||||||
|
// No base64 body line exceeds the PEM 64-column width.
|
||||||
|
const body = pem.split('\n').slice(1, -2)
|
||||||
|
for (const line of body) expect(line.length).toBeLessThanOrEqual(64)
|
||||||
|
// Parsing the produced PEM yields byte-identical DER.
|
||||||
|
expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createMtlsVerifier — real X.509 path (INV14)', () => {
|
||||||
|
it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(foreignDer)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => {
|
||||||
|
const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')])
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a revoked host (INV12/registry gate) → null', async () => {
|
||||||
|
const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')])
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses an expired leaf → null', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a not-yet-valid leaf → null', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 })
|
||||||
|
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createMtlsVerifier — fail-closed on malformed / absent input', () => {
|
||||||
|
it('returns null for an empty DER (no client cert presented)', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(new Uint8Array(0))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for garbage DER bytes that are not a certificate', async () => {
|
||||||
|
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||||
|
expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createMtlsVerifier — construction validation (INV14)', () => {
|
||||||
|
it('throws when the CA bundle is empty', () => {
|
||||||
|
expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when the CA bundle has no PEM CERTIFICATE block', () => {
|
||||||
|
expect(() =>
|
||||||
|
createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ──────────────────
|
||||||
|
const okParsed = (spiffeUri: string): ParsedCert => ({
|
||||||
|
spiffeUri,
|
||||||
|
notBefore: 0,
|
||||||
|
notAfter: 4_000_000_000,
|
||||||
|
chainValid: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createMtlsVerifier — ParseCert seam', () => {
|
||||||
|
it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => {
|
||||||
|
let seenLeafPem: string | null = null
|
||||||
|
const capturingParse: ParseCert = (leaf) => {
|
||||||
|
seenLeafPem = leaf
|
||||||
|
return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com'))
|
||||||
|
}
|
||||||
|
const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1])
|
||||||
|
const v = createMtlsVerifier({
|
||||||
|
caChainPem,
|
||||||
|
hosts: enrolledHosts(),
|
||||||
|
now: () => 1_000_000,
|
||||||
|
parse: capturingParse,
|
||||||
|
})
|
||||||
|
expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
|
||||||
|
expect(seenLeafPem).toBe(derToPem(der))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed (null) and reports to onError when the registry lookup throws', async () => {
|
||||||
|
const errors: unknown[] = []
|
||||||
|
const throwingHosts: HostRegistryPort = {
|
||||||
|
getById: async () => {
|
||||||
|
throw new Error('registry unavailable')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const v = createMtlsVerifier({
|
||||||
|
caChainPem,
|
||||||
|
hosts: throwingHosts,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
onError: (e) => errors.push(e),
|
||||||
|
parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')),
|
||||||
|
})
|
||||||
|
expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull()
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user