Provision instance bank credentials automatically (Refs #146)
This commit is contained in:
+38
-33
@@ -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' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user