Provision instance bank credentials automatically (Refs #146)

This commit is contained in:
2026-10-12 20:44:01 -04:00
parent e071e5b574
commit d14c6a7ff4
21 changed files with 634 additions and 109 deletions
+38 -33
View File
@@ -1,41 +1,46 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { db } from '../db/index.js';
const digest = (value: string) => createHash('sha256').update(value).digest();
const valid = (value: unknown): value is string =>
typeof value === 'string' && /^ec_bank_[A-Za-z0-9_-]{32,128}$/.test(value);
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
/** Dedicated, unique credentials; telemetry and browser credentials never authorize spending. */
/** Private controller state, reused only by the same immutable native instance. */
export function bankCredential(instanceId: string): string {
return db()
.transaction(() => {
const instance = db()
.prepare('SELECT server FROM native_instances WHERE id=? AND retired_at IS NULL')
.get(instanceId) as { server: string } | undefined;
if (!instance) throw new Error('Bank credentials require an active native instance.');
const saved = db()
.prepare('SELECT token FROM bank_credentials WHERE instance_id=?')
.get(instanceId) as { token: string } | undefined;
if (saved) return saved.token;
const token = 'ec_bank_' + randomBytes(32).toString('base64url');
db()
.prepare(
'INSERT INTO bank_credentials(instance_id,server,token,token_hash,created_at) VALUES (?,?,?,?,?)'
)
.run(instanceId, instance.server, token, digest(token), new Date().toISOString());
return token;
})
.immediate();
}
/** Retiring an instance immediately revokes its game credential, including reads. */
export function authenticateBank(
request: Request
): { ok: true; server: string } | { ok: false; status: 401 | 503; code: string } {
let entries: [string, string][];
try {
const config: unknown = JSON.parse(process.env.EC_BANK_TOKENS ?? '{}');
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error();
const values = Object.entries(config);
if (
!values.length ||
values.some(
([server, token]) =>
!server || !valid(token) || token === process.env.EC_TELEMETRY_TOKEN
)
)
throw new Error();
entries = values as [string, string][];
if (new Set(entries.map(([, token]) => token)).size !== entries.length) throw new Error();
} catch {
return { ok: false, status: 503, code: 'unconfigured' };
}
): { ok: true; server: string; instanceId: string } | { ok: false; status: 401; code: string } {
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_bank_[A-Za-z0-9_-]{32,128})$/i)?.[1];
if (!token) return { ok: false, status: 401, code: 'unauthorized' };
const supplied = digest(token);
let matched: string | undefined;
for (const [server, expected] of entries) {
if (timingSafeEqual(supplied, digest(expected))) matched = server;
}
return matched
? { ok: true, server: matched }
: { ok: false, status: 401, code: 'unauthorized' };
const matched = token
? (db()
.prepare(
`SELECT c.server, c.instance_id AS instanceId
FROM bank_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.server=c.server
WHERE c.token_hash=? AND n.retired_at IS NULL`
)
.get(digest(token)) as { server: string; instanceId: string } | undefined)
: undefined;
return matched ? { ok: true, ...matched } : { ok: false, status: 401, code: 'unauthorized' };
}
+22
View File
@@ -0,0 +1,22 @@
import type Database from 'better-sqlite3';
export function migrateBankCredentials(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS bank_credentials (
instance_id TEXT PRIMARY KEY, server TEXT NOT NULL,
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL
);
`);
if (
!(handle.pragma('table_info(bank_requests)') as { name: string }[]).some(
(column) => column.name === 'instance_id'
)
) {
handle.exec(`
ALTER TABLE bank_requests ADD COLUMN instance_id TEXT;
UPDATE bank_requests SET instance_id = (
SELECT id FROM native_instances WHERE server = bank_requests.server AND retired_at IS NULL
);
`);
}
}
+3 -1
View File
@@ -19,6 +19,7 @@ import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateBankCredentials } from './bank-credentials.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
@@ -111,7 +112,8 @@ export const migrations: readonly Migration[] = [
name: 'cumulative player score per match',
run: migratePlayerScoreTotal
},
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank }
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials }
];
export const databaseVersion = migrations.length;
+16 -5
View File
@@ -20,6 +20,7 @@ interface Result {
balance: number;
}
interface Saved {
instance_id: string | null;
fingerprint: string;
state: string;
amount: number;
@@ -79,7 +80,8 @@ export function bankProfile(guid: string) {
export function bankRequest(
action: BankAction,
value: unknown,
authenticatedServer: string
authenticatedServer: string,
instanceId: string
): { status: number; body: Result } {
const result = (status: number, code: string, request_id = '') => ({
status,
@@ -121,7 +123,7 @@ export function bankRequest(
const saved = handle
.prepare('SELECT * FROM bank_requests WHERE server = ? AND request_id = ?')
.get(server, request_id) as Saved | undefined;
if (saved && saved.fingerprint !== fingerprint)
if (saved && (saved.instance_id !== instanceId || saved.fingerprint !== fingerprint))
return result(409, 'request_conflict', request_id);
const balance =
(
@@ -145,8 +147,8 @@ export function bankRequest(
const insert = (body: Result, matchId: number | null = null) => {
handle
.prepare(
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at,instance_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
server,
@@ -158,7 +160,8 @@ export function bankRequest(
body.amount,
body.state,
JSON.stringify(body),
now
now,
instanceId
);
return { status: 200, body };
};
@@ -200,6 +203,14 @@ export function bankRequest(
return { status: 200, body };
}
if (saved) return { status: 200, body: JSON.parse(saved.result) as Result };
if (
!handle
.prepare(
'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL'
)
.get(instanceId, server)
)
return result(403, 'retired_instance', request_id);
const member = handle
.prepare(
`SELECT m.id FROM leaderboard_matches m
@@ -13,6 +13,7 @@ export interface NativeLaunchInput {
instance: NativeInstance;
serverKey: string;
rconPassword: string;
bankToken: string;
rebuild: boolean;
safetyOverride?: boolean;
unhealthyRecovery?: boolean;
+15 -1
View File
@@ -71,6 +71,19 @@ parentPort!.on(
};
})();
const instance = input.instance;
// Credential files travel only in this host's private operation payload, never
// shared recipes, promoted files, or the content-addressed download store.
const bankPath = posix.join(
relDestForKind('config', recipe.game),
'electric_cherry_bank.cfg'
);
if (
recipe.files.some(
(file) =>
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath
)
)
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
const payload = {
name: instance.server,
game: recipe.game,
@@ -79,13 +92,14 @@ parentPort!.on(
port: instance.port,
bindAddress: instance.bind_address,
rconPassword: password,
bank: { token: input.bankToken, configPath: bankPath },
env: {
PLUTAINER_SERVER_NAME: instance.server,
PLUTAINER_PORT: String(instance.port),
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile,
PLUTAINER_MAP_ROTATE: 'false',
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey
},
files: recipe.files.map((file) => ({
+2
View File
@@ -12,6 +12,7 @@ import { isIP } from 'node:net';
import { setImmediate as yieldToController } from 'node:timers/promises';
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
import { randomBytes, randomUUID } from 'node:crypto';
import { bankCredential } from '../auth/bank.js';
import { posix } from 'node:path';
import { db } from '../db/index.js';
import { unsettledCancelledCommand } from './cancellation.js';
@@ -1936,6 +1937,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
instance,
serverKey: preparedKey,
rconPassword: preparedPassword,
bankToken: bankCredential(instance.id),
rebuild: job.action === 'rebuild' && !migration,
safetyOverride: overrideSafety,
unhealthyRecovery: host.inventory.capabilities?.includes(