Files
zcode-gatekeeper/gatekeeper/daemon-auth.mjs
T

98 lines
4.0 KiB
JavaScript

import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, statSync } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
const normalizeComparable = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
// JSON 的确定性编码用于 daemon HMAC;对象键排序,undefined 按 JSON 语义省略。
export function stableStringify(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return '[' + value.map(v => v === undefined ? 'null' : stableStringify(v)).join(',') + ']';
}
const parts = [];
for (const k of Object.keys(value).sort()) {
if (value[k] === undefined) continue;
parts.push(JSON.stringify(k) + ':' + stableStringify(value[k]));
}
return '{' + parts.join(',') + '}';
}
export function daemonInstanceIdFor(dir) {
return crypto.createHash('sha256')
.update(normalizeComparable(path.resolve(dir))).digest('hex').slice(0, 24);
}
export function resolveStateDir(scriptDir, custom = '') {
const secure = (dir) => {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const st = statSync(dir);
if (!st.isDirectory()) throw new Error('gatekeeper state 路径不是目录');
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
throw new Error('gatekeeper state 目录不属于当前用户');
}
try { chmodSync(dir, 0o700); } catch { /* Windows 或不支持 chmod */ }
return dir;
};
const preferred = custom ? path.resolve(custom) : path.join(scriptDir, 'state');
try { return secure(preferred); }
catch {
const fallback = path.join(os.tmpdir(), 'zcode-gatekeeper-' + daemonInstanceIdFor(scriptDir));
return secure(fallback);
}
}
const secretCache = new Map();
export function daemonSecretFor(stateDir) {
const dir = path.resolve(stateDir);
if (secretCache.has(dir)) return secretCache.get(dir);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = path.join(dir, 'daemon-secret');
const readExisting = () => {
const s = readFileSync(file, 'utf8').trim();
if (!/^[a-f0-9]{64}$/i.test(s)) throw new Error('daemon secret 文件格式无效');
return s;
};
let secret;
try {
secret = readExisting();
} catch (firstErr) {
if (existsSync(file)) throw firstErr;
const fresh = crypto.randomBytes(32).toString('hex');
try { writeFileSync(file, fresh + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' }); }
catch (e) { if (e?.code !== 'EEXIST') throw e; }
secret = existsSync(file) ? readExisting() : fresh;
}
try { chmodSync(file, 0o600); } catch { /* Windows 或不支持 chmod */ }
secretCache.set(dir, secret);
return secret;
}
function daemonMac(secret, direction, nonce, value) {
return crypto.createHmac('sha256', secret)
.update(direction + '\0' + nonce + '\0' + stableStringify(value)).digest('hex');
}
function sameMac(a, b) {
if (!/^[a-f0-9]{64}$/i.test(String(a)) || !/^[a-f0-9]{64}$/i.test(String(b))) return false;
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
export function makeDaemonRequest(secret, payload) {
const nonce = crypto.randomBytes(16).toString('hex');
const issuedAt = Date.now();
return { nonce, issuedAt, payload, auth: daemonMac(secret, 'request', nonce, { issuedAt, payload }) };
}
export function verifyDaemonRequest(secret, envelope) {
return Boolean(envelope && typeof envelope.nonce === 'string' && Number.isFinite(envelope.issuedAt) &&
Math.abs(Date.now() - envelope.issuedAt) <= 60_000 && envelope.payload &&
sameMac(envelope.auth, daemonMac(secret, 'request', envelope.nonce,
{ issuedAt: envelope.issuedAt, payload: envelope.payload })));
}
export function makeDaemonResponse(secret, nonce, result) {
return { nonce, result, auth: daemonMac(secret, 'response', nonce, result) };
}
export function verifyDaemonResponse(secret, envelope, nonce) {
return Boolean(envelope && envelope.nonce === nonce && envelope.result &&
sameMac(envelope.auth, daemonMac(secret, 'response', nonce, envelope.result)));
}