98 lines
4.0 KiB
JavaScript
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)));
|
|
}
|