guanghu-ice-heart/server-tools/lake-lamp-authz/guanghu-router.js

277 lines
8.5 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const CONNECT_SCHEMA = "guanghu.router-connect/v1";
const APPROVAL_SCHEMA = "guanghu.router-approval/v1";
const MAX_CLOCK_SKEW_SECONDS = 120;
class GuanghuRouter {
constructor({ devices = [], challengeTtl = 60, routeTokenTtl = 30 } = {}) {
this.devices = new Map(
devices
.filter(validDevice)
.map(device => [device.device_id, Object.freeze({ ...device })]),
);
this.challengeTtl = Math.max(15, Number(challengeTtl) || 60);
this.routeTokenTtl = Math.max(10, Number(routeTokenTtl) || 30);
this.challenges = new Map();
this.routeTokens = new Map();
this.connections = new Map();
}
challenge(deviceId, now = Date.now() / 1000) {
const device = this.devices.get(String(deviceId || ""));
if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" };
const challengeId = crypto.randomUUID();
const nonce = randomToken();
this.challenges.set(challengeId, {
deviceId: device.device_id,
nonce,
expiresAt: now + this.challengeTtl,
});
return {
ok: true,
schema: CONNECT_SCHEMA,
challengeId,
nonce,
expiresAt: now + this.challengeTtl,
serverTime: now,
};
}
authorizeConnection(input, now = Date.now() / 1000) {
const deviceId = String(input && input.deviceId || "");
const challengeId = String(input && input.challengeId || "");
const challenge = this.challenges.get(challengeId);
const device = this.devices.get(deviceId);
if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" };
if (!challenge || challenge.deviceId !== deviceId) return { ok: false, reason: "challenge_not_found" };
if (now > challenge.expiresAt) {
this.challenges.delete(challengeId);
return { ok: false, reason: "challenge_expired" };
}
const clientTimestamp = Number(input && input.clientTimestamp);
if (!Number.isFinite(clientTimestamp) || Math.abs(now - clientTimestamp) > MAX_CLOCK_SKEW_SECONDS) {
return { ok: false, reason: "device_clock_out_of_range" };
}
const message = canonicalConnect({
deviceId,
challengeId,
nonce: challenge.nonce,
clientTimestamp,
});
if (!verifyDeviceSignature(device, message, input && input.signature)) {
return { ok: false, reason: "device_signature_invalid" };
}
this.challenges.delete(challengeId);
const routeToken = randomToken();
this.routeTokens.set(tokenHash(routeToken), {
deviceId,
expiresAt: now + this.routeTokenTtl,
});
return {
ok: true,
deviceId,
deviceLabel: device.label,
ownerId: device.owner_id,
routeToken,
expiresAt: now + this.routeTokenTtl,
};
}
open(routeToken, send, now = Date.now() / 1000) {
const key = tokenHash(routeToken || "");
const pending = this.routeTokens.get(key);
if (!pending) return { ok: false, reason: "route_token_not_found" };
this.routeTokens.delete(key);
if (now > pending.expiresAt) return { ok: false, reason: "route_token_expired" };
const device = this.devices.get(pending.deviceId);
if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" };
const connectionId = crypto.randomUUID();
const existing = this.connections.get(device.device_id);
if (existing) existing.close(now, "replaced");
const connection = {
connectionId,
device,
send,
openedAt: now,
closed: false,
close: (closedAt = Date.now() / 1000, reason = "client_closed") => {
if (connection.closed) return;
connection.closed = true;
if (this.connections.get(device.device_id) === connection) {
this.connections.delete(device.device_id);
}
send({
type: "router.closed",
reason,
receipt: routeReceipt("offline", device, connectionId, closedAt),
});
},
};
this.connections.set(device.device_id, connection);
const receipt = routeReceipt("online", device, connectionId, now);
send({ type: "router.connected", connection_id: connectionId, receipt });
return {
ok: true,
state: "online",
connectionId,
deviceId: device.device_id,
ownerId: device.owner_id,
receipt,
close: connection.close,
};
}
isApproverOnline(approverId) {
return [...this.connections.values()].some(connection => (
!connection.closed && connection.device.owner_id === String(approverId || "")
));
}
deliver(approverId, order) {
const event = {
type: "authorization.requested",
digest: workorderDigest(order),
workorder: order,
};
let delivered = 0;
for (const connection of this.connections.values()) {
if (!connection.closed && connection.device.owner_id === String(approverId || "")) {
connection.send(event);
delivered += 1;
}
}
return delivered;
}
verifyApproval(deviceId, order, signature) {
const connection = this.connections.get(String(deviceId || ""));
if (!connection || connection.closed) return { ok: false, reason: "device_route_offline" };
const digest = workorderDigest(order);
const message = canonicalApproval({
deviceId: connection.device.device_id,
workorderId: order.id,
digest,
});
if (!verifyDeviceSignature(connection.device, message, signature)) {
return { ok: false, reason: "device_signature_invalid" };
}
return {
ok: true,
authorizerId: connection.device.owner_id,
deviceId: connection.device.device_id,
digest,
};
}
}
function loadDevices(file) {
if (!file || !fs.existsSync(file)) return [];
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!Array.isArray(parsed)) throw new Error("HoloLake device registry must be a JSON array");
return parsed.filter(validDevice);
}
function validDevice(device) {
return Boolean(
device
&& typeof device.device_id === "string"
&& /^[A-Za-z0-9._-]{3,128}$/.test(device.device_id)
&& typeof device.owner_id === "string"
&& device.owner_id.length > 0
&& typeof device.label === "string"
&& device.label.length > 0
&& typeof device.public_key === "string"
&& /^[A-Za-z0-9_-]{40,64}$/.test(device.public_key)
&& typeof device.enabled === "boolean",
);
}
function verifyDeviceSignature(device, message, signature) {
try {
const publicKey = crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: device.public_key },
format: "jwk",
});
return crypto.verify(
null,
Buffer.from(message),
publicKey,
Buffer.from(String(signature || ""), "base64url"),
);
} catch {
return false;
}
}
function canonicalConnect({ deviceId, challengeId, nonce, clientTimestamp }) {
return [
CONNECT_SCHEMA,
String(deviceId),
String(challengeId),
String(nonce),
String(clientTimestamp),
].join("\n");
}
function canonicalApproval({ deviceId, workorderId, digest }) {
return [
APPROVAL_SCHEMA,
String(deviceId),
String(workorderId),
String(digest),
].join("\n");
}
function workorderDigest(order) {
const value = {
id: String(order.id || ""),
persona_id: String(order.persona && order.persona.pid || ""),
persona_name: String(order.persona && order.persona.name || ""),
target: String(order.target || ""),
scope: String(order.scope || ""),
action: String(order.action || ""),
allowed_actions: [...(order.allowed_actions || order.allowedActions || [order.action])].map(String),
description: String(order.description || ""),
resource: String(order.resource || ""),
created_at: Number(order.createdAt || 0),
expires_at: Number(order.expiresAt || 0),
};
return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function routeReceipt(state, device, connectionId, now) {
return {
schema: "guanghu.route-receipt/v1",
receipt_id: crypto.randomUUID(),
state,
device_id: device.device_id,
owner_id: device.owner_id,
node_id: "JD-FD-PRIMARY",
connection_id: connectionId,
occurred_at: now,
};
}
function randomToken() {
return crypto.randomBytes(32).toString("base64url");
}
function tokenHash(value) {
return crypto.createHash("sha256").update(String(value)).digest("hex");
}
module.exports = {
APPROVAL_SCHEMA,
CONNECT_SCHEMA,
GuanghuRouter,
canonicalApproval,
canonicalConnect,
loadDevices,
workorderDigest,
};