46 lines
2.2 KiB
JavaScript

"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
class MapGate {
constructor({ mapsDir = "/etc/guanghu/navigation-maps", stateFile = "" } = {}) {
this.mapsDir = mapsDir;
this.stateFile = stateFile;
this.acks = new Map();
this.load();
}
read(target) {
if (!/^[A-Z0-9-]+$/.test(target)) throw new Error("invalid_target");
const data = JSON.parse(fs.readFileSync(path.join(this.mapsDir, `${target}.json`), "utf8"));
const canonical = JSON.stringify(data);
return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") };
}
ack(sessionToken, target, mapHash, now = Date.now() / 1000, ttl = 3600) {
const current = this.read(target);
if (!safeEqual(current.hash, mapHash)) return { ok: false, reason: "map_hash_mismatch" };
this.acks.set(hash(sessionToken), { target, mapHash, expiresAt: now + ttl });
this.persist();
return { ok: true, expiresAt: now + ttl };
}
verify(sessionToken, target, mapHash, now = Date.now() / 1000) {
const ack = this.acks.get(hash(sessionToken));
if (!ack) return { ok: false, reason: "map_not_acknowledged" };
if (now > ack.expiresAt) return { ok: false, reason: "map_ack_expired" };
if (ack.target !== target) return { ok: false, reason: "map_target_mismatch" };
if (!safeEqual(ack.mapHash, mapHash)) return { ok: false, reason: "map_changed" };
return { ok: true };
}
load(now = Date.now() / 1000) {
if (!this.stateFile || !fs.existsSync(this.stateFile)) return;
try { for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(this.stateFile, "utf8")).acks || {})) if (value.expiresAt >= now) this.acks.set(key, value); } catch { this.acks.clear(); }
}
persist() {
if (!this.stateFile) return;
fs.writeFileSync(this.stateFile, JSON.stringify({ version: 1, acks: Object.fromEntries(this.acks) }), { mode: 0o600 });
}
}
function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
module.exports = { MapGate };