277 lines
8.5 KiB
JavaScript
277 lines
8.5 KiB
JavaScript
"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,
|
|
};
|