From bec7a3d7a0d0ff1fe40d9e9464cb69655d973576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 2 Aug 2026 23:41:48 +0800 Subject: [PATCH] feat(authz): add email-authorized GHDR dual signing --- .../lake-lamp-authz/ghdr-authorizer.js | 75 +++++++ .../lake-lamp-authz/ghdr-authorizer.test.js | 39 ++++ .../lake-lamp-authz/ghdr-controller-broker.js | 195 ++++++++++++++++++ .../ghdr-controller-broker.test.js | 116 +++++++++++ .../lake-lamp-authz/ghdr-controllers.json | 17 ++ .../install-architecture-provisioner.sh | 4 +- server-tools/lake-lamp-authz/map-gate.js | 11 +- .../navigation-maps/GH-CVM-MAIN-PROD-01.json | 19 ++ server-tools/lake-lamp-authz/server.js | 166 ++++++++++++++- server-tools/lake-lamp-authz/server.test.js | 134 ++++++++++++ 10 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 server-tools/lake-lamp-authz/ghdr-authorizer.js create mode 100644 server-tools/lake-lamp-authz/ghdr-authorizer.test.js create mode 100644 server-tools/lake-lamp-authz/ghdr-controller-broker.js create mode 100644 server-tools/lake-lamp-authz/ghdr-controller-broker.test.js create mode 100644 server-tools/lake-lamp-authz/ghdr-controllers.json create mode 100644 server-tools/lake-lamp-authz/navigation-maps/GH-CVM-MAIN-PROD-01.json diff --git a/server-tools/lake-lamp-authz/ghdr-authorizer.js b/server-tools/lake-lamp-authz/ghdr-authorizer.js new file mode 100644 index 0000000..cb340c9 --- /dev/null +++ b/server-tools/lake-lamp-authz/ghdr-authorizer.js @@ -0,0 +1,75 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +class GhdrAuthorizer { + constructor({ + privateKeyFile = "/var/lib/guanghu/lake-lamp-authz/ghdr-authorizer-private.pem", + now = () => Math.floor(Date.now() / 1000), + } = {}) { + this.privateKeyFile = privateKeyFile; + this.now = now; + this.privateKey = this.loadOrCreate(); + this.publicKey = crypto.createPublicKey(this.privateKey); + } + + loadOrCreate() { + if (fs.existsSync(this.privateKeyFile)) { + const metadata = fs.lstatSync(this.privateKeyFile); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.mode & 0o077) { + throw new Error("ghdr_authorizer_private_key_permissions_invalid"); + } + return crypto.createPrivateKey(fs.readFileSync(this.privateKeyFile)); + } + fs.mkdirSync(path.dirname(this.privateKeyFile), { recursive: true, mode: 0o700 }); + const { privateKey } = crypto.generateKeyPairSync("ed25519"); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }); + const temporary = `${this.privateKeyFile}.${process.pid}.tmp`; + fs.writeFileSync(temporary, pem, { mode: 0o600, flag: "wx" }); + fs.renameSync(temporary, this.privateKeyFile); + return privateKey; + } + + publicBinding() { + const publicPem = this.publicKey.export({ type: "spki", format: "pem" }); + const fingerprint = crypto.createHash("sha256").update(publicPem).digest("hex"); + return { + schema: "guanghu.ghdr-authorizer-public-binding/v1", + authorizer_id: "JD-FD-PRIMARY-LAKE-LAMP", + algorithm: "Ed25519", + public_key_pem: publicPem, + public_key_sha256: fingerprint, + }; + } + + issue({ + controllerNodeId, + targetNodeId, + layoutPayloadSha256, + resource, + workorderId, + }) { + const issued = this.now(); + const capability = { + schema: "guanghu.ghdr-signing-capability/v1", + authorizer_id: "JD-FD-PRIMARY-LAKE-LAMP", + controller_node_id: controllerNodeId, + target_node_id: targetNodeId, + layout_payload_sha256: layoutPayloadSha256, + resource, + workorder_id: workorderId, + issued_at_unix: issued, + expires_at_unix: issued + 120, + nonce: crypto.randomBytes(24).toString("base64url"), + }; + const canonical = Buffer.from(JSON.stringify(capability)); + return { + capability, + capability_signature_base64url: crypto.sign(null, canonical, this.privateKey).toString("base64url"), + }; + } +} + +module.exports = { GhdrAuthorizer }; diff --git a/server-tools/lake-lamp-authz/ghdr-authorizer.test.js b/server-tools/lake-lamp-authz/ghdr-authorizer.test.js new file mode 100644 index 0000000..818245a --- /dev/null +++ b/server-tools/lake-lamp-authz/ghdr-authorizer.test.js @@ -0,0 +1,39 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { GhdrAuthorizer } = require("./ghdr-authorizer"); + +test("GHDR authorizer persists a private key without exposing it and signs a two-minute capability", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ghdr-authorizer-")); + try { + const privateKeyFile = path.join(directory, "authorizer.pem"); + const authorizer = new GhdrAuthorizer({ privateKeyFile, now: () => 1000 }); + const issued = authorizer.issue({ + controllerNodeId: "GH-CTRL-GZ-01", + targetNodeId: "GH-CVM-MAIN-PROD-01", + layoutPayloadSha256: "a".repeat(64), + resource: `GH-CVM-MAIN-PROD-01:${"a".repeat(64)}:1`, + workorderId: "00000000-0000-4000-8000-000000000001", + }); + assert.equal(fs.statSync(privateKeyFile).mode & 0o777, 0o600); + assert.equal(issued.capability.expires_at_unix, 1120); + const publicKey = crypto.createPublicKey(authorizer.publicBinding().public_key_pem); + assert.equal( + crypto.verify( + null, + Buffer.from(JSON.stringify(issued.capability)), + publicKey, + Buffer.from(issued.capability_signature_base64url, "base64url"), + ), + true, + ); + assert.doesNotMatch(JSON.stringify(authorizer.publicBinding()), /PRIVATE KEY/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/server-tools/lake-lamp-authz/ghdr-controller-broker.js b/server-tools/lake-lamp-authz/ghdr-controller-broker.js new file mode 100644 index 0000000..8c55c1d --- /dev/null +++ b/server-tools/lake-lamp-authz/ghdr-controller-broker.js @@ -0,0 +1,195 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const POLL_FIELDS = [ + "schema", "node_id", "issued_at_unix", "nonce", +]; +const RESULT_FIELDS = [ + "schema", "node_id", "job_id", "layout_payload_sha256", + "signature_hex", "issued_at_unix", "nonce", +]; + +class GhdrControllerBroker { + constructor({ + controllers, + stateDir = "/var/lib/guanghu/lake-lamp-authz/ghdr-jobs", + authorizer, + now = () => Math.floor(Date.now() / 1000), + waitMs = 45000, + }) { + if (!Array.isArray(controllers) || controllers.length !== 2) { + throw new Error("ghdr_controller_registry_requires_exactly_two"); + } + this.controllers = new Map(controllers.map(item => [item.node_id, validateController(item)])); + if (this.controllers.size !== 2 + || new Set(controllers.map(item => item.failure_domain)).size !== 2) { + throw new Error("ghdr_controller_registry_not_independent"); + } + this.stateDir = stateDir; + this.authorizer = authorizer; + this.now = now; + this.waitMs = waitMs; + this.replay = new Map(); + fs.mkdirSync(this.stateDir, { recursive: true, mode: 0o700 }); + } + + async queueAndWait({ plan, binding, workorderId }) { + const jobId = crypto.randomUUID(); + const job = { + schema: "guanghu.ghdr-controller-job/v1", + job_id: jobId, + created_at_unix: this.now(), + expires_at_unix: this.now() + 120, + layout_payload_sha256: binding.payload_sha256, + resource: binding.resource, + workorder_id: workorderId, + plan, + authorizations: {}, + results: {}, + }; + for (const controller of this.controllers.values()) { + job.authorizations[controller.node_id] = this.authorizer.issue({ + controllerNodeId: controller.node_id, + targetNodeId: "GH-CVM-MAIN-PROD-01", + layoutPayloadSha256: binding.payload_sha256, + resource: binding.resource, + workorderId, + }); + } + this.writeJob(job); + const deadline = Date.now() + this.waitMs; + while (Date.now() < deadline) { + const current = this.readJob(jobId); + if (Object.keys(current.results).length === 2) { + return { + ok: true, + signatures: [...this.controllers.keys()].map(nodeId => current.results[nodeId]), + }; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + return { ok: false, error: "ghdr_controller_poll_timeout" }; + } + + poll(body) { + const authenticated = this.authenticate(body && body.request, body && body.request_signature_hex, POLL_FIELDS, "guanghu.ghdr-controller-poll/v1"); + if (!authenticated.ok) return authenticated; + const nodeId = authenticated.node_id; + const names = fs.readdirSync(this.stateDir).filter(name => /^[0-9a-f-]{36}\.json$/.test(name)).sort(); + for (const name of names) { + const job = this.readJob(name.slice(0, -5)); + if (job.expires_at_unix <= this.now() || job.results[nodeId]) continue; + return { + ok: true, + job: { + job_id: job.job_id, + expires_at_unix: job.expires_at_unix, + layout_payload_sha256: job.layout_payload_sha256, + authorization: job.authorizations[nodeId], + plan: job.plan, + }, + }; + } + return { ok: true, job: null }; + } + + submit(body) { + const authenticated = this.authenticate(body && body.request, body && body.request_signature_hex, RESULT_FIELDS, "guanghu.ghdr-controller-result/v1"); + if (!authenticated.ok) return authenticated; + const request = body.request; + const job = this.readJob(request.job_id); + if (job.expires_at_unix <= this.now()) return { ok: false, error: "ghdr_job_expired" }; + if (job.layout_payload_sha256 !== request.layout_payload_sha256) { + return { ok: false, error: "ghdr_result_layout_mismatch" }; + } + const controller = this.controllers.get(request.node_id); + const signature = body.signature; + if (!signature + || signature.node_id !== controller.node_id + || signature.failure_domain !== controller.failure_domain + || signature.public_key_hex !== controller.layout_public_key_hex + || signature.signature_hex !== request.signature_hex) { + return { ok: false, error: "ghdr_result_binding_mismatch" }; + } + const payload = Buffer.from(JSON.stringify(job.plan.payload)); + if (!verifyHex(controller.layout_public_key_hex, payload, request.signature_hex)) { + return { ok: false, error: "ghdr_layout_signature_invalid" }; + } + if (job.results[controller.node_id]) { + return { ok: false, error: "ghdr_result_already_submitted" }; + } + job.results[controller.node_id] = signature; + this.writeJob(job); + return { ok: true }; + } + + authenticate(request, signatureHex, fields, schema) { + if (!request || typeof request !== "object" || JSON.stringify(Object.keys(request)) !== JSON.stringify(fields)) { + return { ok: false, error: "ghdr_controller_auth_not_canonical" }; + } + if (request.schema !== schema || !this.controllers.has(request.node_id)) { + return { ok: false, error: "ghdr_controller_auth_unknown" }; + } + if (!Number.isSafeInteger(request.issued_at_unix) + || Math.abs(this.now() - request.issued_at_unix) > 30 + || !/^[A-Za-z0-9_-]{32}$/.test(String(request.nonce || ""))) { + return { ok: false, error: "ghdr_controller_auth_expired" }; + } + const controller = this.controllers.get(request.node_id); + const canonical = Buffer.from(JSON.stringify(request)); + if (!verifyHex(controller.transport_public_key_hex, canonical, signatureHex)) { + return { ok: false, error: "ghdr_controller_auth_invalid" }; + } + const replay = crypto.createHash("sha256").update(canonical).digest("hex"); + for (const [digest, expires] of this.replay) { + if (expires < this.now()) this.replay.delete(digest); + } + if (this.replay.has(replay)) return { ok: false, error: "ghdr_controller_auth_replayed" }; + this.replay.set(replay, this.now() + 35); + return { ok: true, node_id: request.node_id }; + } + + readJob(jobId) { + if (!/^[0-9a-f-]{36}$/.test(String(jobId))) throw new Error("ghdr_job_id_invalid"); + return JSON.parse(fs.readFileSync(path.join(this.stateDir, `${jobId}.json`), "utf8")); + } + + writeJob(job) { + const destination = path.join(this.stateDir, `${job.job_id}.json`); + const temporary = `${destination}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(job), { mode: 0o600 }); + fs.renameSync(temporary, destination); + } +} + +function validateController(controller) { + if (!controller + || !/^GH-CTRL-(GZ|SG)-01$/.test(String(controller.node_id)) + || !/^tencent\/ap-(guangzhou|singapore)\/[A-Z0-9-]+$/.test(String(controller.failure_domain)) + || !/^[0-9a-f]{64}$/.test(String(controller.layout_public_key_hex)) + || !/^[0-9a-f]{64}$/.test(String(controller.transport_public_key_hex))) { + throw new Error("ghdr_controller_registry_invalid"); + } + return Object.freeze({ ...controller }); +} + +function verifyHex(publicKeyHex, message, signatureHex) { + if (!/^[0-9a-f]{128}$/.test(String(signatureHex || ""))) return false; + const prefix = Buffer.from("302a300506032b6570032100", "hex"); + const publicKey = crypto.createPublicKey({ + key: Buffer.concat([prefix, Buffer.from(publicKeyHex, "hex")]), + format: "der", + type: "spki", + }); + return crypto.verify(null, message, publicKey, Buffer.from(signatureHex, "hex")); +} + +function loadControllers(file) { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + return parsed.controllers; +} + +module.exports = { GhdrControllerBroker, loadControllers, verifyHex }; diff --git a/server-tools/lake-lamp-authz/ghdr-controller-broker.test.js b/server-tools/lake-lamp-authz/ghdr-controller-broker.test.js new file mode 100644 index 0000000..3d8077d --- /dev/null +++ b/server-tools/lake-lamp-authz/ghdr-controller-broker.test.js @@ -0,0 +1,116 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { GhdrAuthorizer } = require("./ghdr-authorizer"); +const { GhdrControllerBroker } = require("./ghdr-controller-broker"); + +test("two outbound controllers poll one email-authorized job and return independent layout signatures", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ghdr-broker-")); + const now = Math.floor(Date.now() / 1000); + const nodes = [ + makeController("GH-CTRL-GZ-01", "tencent/ap-guangzhou/BS-GZ-006"), + makeController("GH-CTRL-SG-01", "tencent/ap-singapore/ZY-SG-006"), + ]; + const authorizer = new GhdrAuthorizer({ + privateKeyFile: path.join(directory, "authorizer.pem"), + now: () => now, + }); + const broker = new GhdrControllerBroker({ + controllers: nodes.map(item => item.binding), + stateDir: path.join(directory, "jobs"), + authorizer, + now: () => now, + waitMs: 2000, + }); + const plan = { + schema: "guanghu.ghdr-signed-layout-plan/v1", + payload: { + node_id: "GH-CVM-MAIN-PROD-01", + generation: 1, + }, + signatures: [], + }; + const digest = crypto.createHash("sha256").update(JSON.stringify(plan.payload)).digest("hex"); + const binding = { + payload_sha256: digest, + resource: `GH-CVM-MAIN-PROD-01:${digest}:1`, + }; + try { + const pending = broker.queueAndWait({ + plan, + binding, + workorderId: "00000000-0000-4000-8000-000000000001", + }); + const submitted = []; + for (const node of nodes) { + const poll = { + schema: "guanghu.ghdr-controller-poll/v1", + node_id: node.binding.node_id, + issued_at_unix: now, + nonce: crypto.randomBytes(24).toString("base64url"), + }; + const body = { + request: poll, + request_signature_hex: crypto.sign(null, Buffer.from(JSON.stringify(poll)), node.transport.privateKey).toString("hex"), + }; + const job = broker.poll(body).job; + assert.ok(job); + assert.equal(broker.poll(body).error, "ghdr_controller_auth_replayed"); + const layoutSignatureHex = crypto.sign( + null, + Buffer.from(JSON.stringify(plan.payload)), + node.layout.privateKey, + ).toString("hex"); + const result = { + schema: "guanghu.ghdr-controller-result/v1", + node_id: node.binding.node_id, + job_id: job.job_id, + layout_payload_sha256: digest, + signature_hex: layoutSignatureHex, + issued_at_unix: now, + nonce: crypto.randomBytes(24).toString("base64url"), + }; + const signature = { + node_id: node.binding.node_id, + failure_domain: node.binding.failure_domain, + public_key_hex: node.binding.layout_public_key_hex, + signature_hex: layoutSignatureHex, + }; + assert.equal(broker.submit({ + request: result, + request_signature_hex: crypto.sign(null, Buffer.from(JSON.stringify(result)), node.transport.privateKey).toString("hex"), + signature, + }).ok, true); + submitted.push(signature); + } + const completed = await pending; + assert.equal(completed.ok, true); + assert.deepEqual(completed.signatures, submitted); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeController(nodeId, failureDomain) { + const layout = crypto.generateKeyPairSync("ed25519"); + const transport = crypto.generateKeyPairSync("ed25519"); + return { + layout, + transport, + binding: { + node_id: nodeId, + failure_domain: failureDomain, + layout_public_key_hex: rawPublic(layout.publicKey), + transport_public_key_hex: rawPublic(transport.publicKey), + }, + }; +} + +function rawPublic(key) { + return key.export({ type: "spki", format: "der" }).subarray(-32).toString("hex"); +} diff --git a/server-tools/lake-lamp-authz/ghdr-controllers.json b/server-tools/lake-lamp-authz/ghdr-controllers.json new file mode 100644 index 0000000..3e395c4 --- /dev/null +++ b/server-tools/lake-lamp-authz/ghdr-controllers.json @@ -0,0 +1,17 @@ +{ + "schema": "guanghu.ghdr-controller-registry/v1", + "controllers": [ + { + "node_id": "GH-CTRL-GZ-01", + "failure_domain": "tencent/ap-guangzhou/BS-GZ-006", + "layout_public_key_hex": "d37d943381adeb215b5af769dd0cda79c37c289dedad9c52504f68aa71fcf353", + "transport_public_key_hex": "97d4e886276f3d06544dcd02422fd19a50142752218620bdde9c70e43ff374f0" + }, + { + "node_id": "GH-CTRL-SG-01", + "failure_domain": "tencent/ap-singapore/ZY-SG-006", + "layout_public_key_hex": "58b2fe26d90c26a09ecde6af2c742b5df35267d1c2b03af94468698d6bf2864f", + "transport_public_key_hex": "099e5963ae3956408fd6e0c7980b3c7813e20eae85972ffba4220976e7093395" + } + ] +} diff --git a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh index 1c6e593..eaf4a05 100755 --- a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh +++ b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh @@ -10,9 +10,11 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) install_root=/opt/guanghu/lake-lamp-authz install -d -m 0755 "$install_root" -for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js hololake-session.js hololake-capabilities.js; do +for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js hololake-session.js hololake-capabilities.js ghdr-authorizer.js ghdr-controller-broker.js ghdr-controllers.json; do install -m 0644 "$script_dir/$file" "$install_root/$file" done +install -d -m 0755 "$install_root/navigation-maps" +install -m 0644 "$script_dir/navigation-maps/GH-CVM-MAIN-PROD-01.json" "$install_root/navigation-maps/GH-CVM-MAIN-PROD-01.json" install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service install -m 0644 "$script_dir/lake-lamp-deployment-event-worker.service" /etc/systemd/system/lake-lamp-deployment-event-worker.service install -d -m 0700 /var/lib/guanghu/architecture-provision diff --git a/server-tools/lake-lamp-authz/map-gate.js b/server-tools/lake-lamp-authz/map-gate.js index 15ff1ec..d43f1c9 100644 --- a/server-tools/lake-lamp-authz/map-gate.js +++ b/server-tools/lake-lamp-authz/map-gate.js @@ -4,15 +4,22 @@ const fs = require("node:fs"); const path = require("node:path"); class MapGate { - constructor({ mapsDir = "/etc/guanghu/navigation-maps", stateFile = "" } = {}) { + constructor({ mapsDir = "/etc/guanghu/navigation-maps", fallbackMapsDir = "", stateFile = "" } = {}) { this.mapsDir = mapsDir; + this.fallbackMapsDir = fallbackMapsDir; 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 primary = path.join(this.mapsDir, `${target}.json`); + const fallback = this.fallbackMapsDir + ? path.join(this.fallbackMapsDir, `${target}.json`) + : ""; + const selected = fs.existsSync(primary) ? primary : fallback; + if (!selected || !fs.existsSync(selected)) throw new Error("navigation_map_not_found"); + const data = JSON.parse(fs.readFileSync(selected, "utf8")); const canonical = JSON.stringify(data); return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") }; } diff --git a/server-tools/lake-lamp-authz/navigation-maps/GH-CVM-MAIN-PROD-01.json b/server-tools/lake-lamp-authz/navigation-maps/GH-CVM-MAIN-PROD-01.json new file mode 100644 index 0000000..827bef2 --- /dev/null +++ b/server-tools/lake-lamp-authz/navigation-maps/GH-CVM-MAIN-PROD-01.json @@ -0,0 +1,19 @@ +{ + "schema": "guanghu.navigation-map/v1", + "node_id": "GH-CVM-MAIN-PROD-01", + "display_name": "光湖企业原生 OS 主节点", + "provider": "tencent_cloud", + "region": "ap-guangzhou", + "system_disk": "/dev/vda", + "authorized_entry": "JD-FD-PRIMARY email authorization", + "allowed_actions": [ + "read-navigation-map", + "sign-native-layout-plan" + ], + "safety": { + "private_keys_exportable": false, + "controller_signatures_required": 2, + "independent_failure_domains_required": 2, + "raw_disk_write": "blocked_until_valid_double_signature" + } +} diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index a26c394..f6ba0d7 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -11,6 +11,8 @@ const { executeRegisteredAction } = require("./action-client"); const { enqueueDeploymentEvent } = require("./deployment-event"); const { GuanghuRouter, loadDevices } = require("./guanghu-router"); const { HoloLakeSessionManager } = require("./hololake-session"); +const { GhdrAuthorizer } = require("./ghdr-authorizer"); +const { GhdrControllerBroker, loadControllers } = require("./ghdr-controller-broker"); const { HoloLakeAiGateway, HoloLakeKnowledgeProvider, @@ -48,6 +50,7 @@ const DEFAULT_ACTIONS = Object.freeze({ ], "repo-push": ["read-navigation-map", "push-repository"], "linked-node-ops": ["authorize-linked-node-session"], + "native-recovery": ["read-navigation-map", "sign-native-layout-plan"], }); function createApp(options = {}) { @@ -57,6 +60,7 @@ function createApp(options = {}) { const approvers = options.approvers || loadApprovers(options.approversFile || process.env.LAKE_LAMP_APPROVERS_FILE || "", ownerEmail); const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, ""); const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006")); + targets.add("GH-CVM-MAIN-PROD-01"); const actions = options.actions || DEFAULT_ACTIONS; const devices = options.devices || loadDevices( options.devicesFile @@ -145,6 +149,7 @@ function createApp(options = {}) { ); const mapGate = options.mapGate || new MapGate({ mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps", + fallbackMapsDir: options.fallbackMapsDir || path.join(__dirname, "navigation-maps"), stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"), }); const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations"; @@ -173,6 +178,36 @@ function createApp(options = {}) { const deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events"; const deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json"; const executeAction = options.executeAction || executeRegisteredAction; + let ghdrAuthorizer = null; + const getGhdrAuthorizer = options.getGhdrAuthorizer || (() => { + if (!ghdrAuthorizer) { + ghdrAuthorizer = new GhdrAuthorizer({ + privateKeyFile: options.ghdrAuthorizerPrivateKeyFile + || process.env.GHDR_AUTHORIZER_PRIVATE_KEY_FILE + || "/var/lib/guanghu/lake-lamp-authz/ghdr-authorizer-private.pem", + }); + } + return ghdrAuthorizer; + }); + let ghdrControllerBroker = null; + const getGhdrControllerBroker = options.getGhdrControllerBroker || (() => { + if (!ghdrControllerBroker) { + const registryFile = options.ghdrControllerRegistryFile + || process.env.GHDR_CONTROLLER_REGISTRY_FILE + || path.join(__dirname, "ghdr-controllers.json"); + ghdrControllerBroker = new GhdrControllerBroker({ + controllers: loadControllers(registryFile), + stateDir: options.ghdrJobStateDir + || process.env.GHDR_JOB_STATE_DIR + || "/var/lib/guanghu/lake-lamp-authz/ghdr-jobs", + authorizer: getGhdrAuthorizer(), + }); + } + return ghdrControllerBroker; + }); + const signGhdrPlan = options.signGhdrPlan || ( + request => getGhdrControllerBroker().queueAndWait(request) + ); // Creating a powerless request must never become harder than the human mail // handoff. Keep at least three attempts per network each hour. const publicCreateLimit = Math.max(3, Number(options.publicCreateLimit || process.env.LAKE_LAMP_PUBLIC_CREATE_LIMIT || 24)); @@ -1035,6 +1070,97 @@ function createApp(options = {}) { return json(res, queued.state === "queued_for_resident_agent" ? 202 : 400, { ok: queued.state === "queued_for_resident_agent", deployment: queued, receipt: operationReceipt }); } + if (req.method === "POST" && url.pathname === "/api/ghdr/controllers/poll") { + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + const result = getGhdrControllerBroker().poll(body); + return json(res, result.ok ? 200 : 403, result); + } + + if (req.method === "POST" && url.pathname === "/api/ghdr/controllers/result") { + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + try { + const result = getGhdrControllerBroker().submit(body); + return json(res, result.ok ? 200 : 403, result); + } catch { + return json(res, 400, failure("ghdr_result_invalid")); + } + } + + if (req.method === "POST" && url.pathname === "/api/ghdr/sign-layout") { + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + if (body.cmd || body.command || body.shell || body.args) { + return json(res, 400, failure("arbitrary_command_forbidden")); + } + const token = bearer(req); + const target = String(body.target || ""); + const scope = String(body.scope || "native-recovery"); + const resource = String(body.resource || ""); + const binding = validateGhdrLayoutRequest(body.plan, target, resource); + if (!binding.ok) return json(res, 400, failure(binding.error)); + const verified = manager.verifySession( + token, + { pid: String(body.persona_id || "") }, + target, + scope, + "sign-native-layout-plan", + Date.now() / 1000, + resource, + ); + if (!verified.ok) return json(res, 403, failure(verified.reason)); + const map = mapGate.read(target); + if (!mapGate.verify(token, target, map.hash).ok) { + return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", { + required_action: "read-navigation-map", + })); + } + const result = await signGhdrPlan({ + plan: body.plan, + binding, + workorderId: verified.session.workorderId, + authorizer: getGhdrAuthorizer(), + }); + const signatures = Array.isArray(result.signatures) ? result.signatures : []; + const independent = signatures.length === 2 + && new Set(signatures.map(item => item && item.node_id)).size === 2 + && new Set(signatures.map(item => item && item.failure_domain)).size === 2; + const succeeded = Boolean(result.ok && independent); + const operationReceipt = receipt({ + state: succeeded ? "succeeded" : "failed", + diagnostic_code: succeeded + ? "ghdr_layout_double_signature_succeeded" + : String(result.error || "ghdr_layout_double_signature_failed"), + workorder_id: verified.session.workorderId, + target, + action: "sign-native-layout-plan", + evidence: { + layout_payload_sha256: binding.payload_sha256, + generation: binding.generation, + controller_count: signatures.length, + controller_ids: signatures.map(item => String(item && item.node_id || "")), + }, + next_step: succeeded + ? "把两份签名合并回同一份布局计划,并由企业目标机独立验证公钥、故障域、实时读回与有效期。" + : "读取 diagnostic_code;不得降级为单签、复制私钥或绕过邮件授权。", + }); + manager.recordReceipt(token, operationReceipt); + return json(res, succeeded ? 200 : 502, { + ok: succeeded, + layout_payload_sha256: binding.payload_sha256, + signatures: succeeded ? signatures : [], + receipt: operationReceipt, + }); + } + + if (req.method === "GET" && url.pathname === "/api/ghdr/authorizer-public-key") { + return json(res, 200, { + ok: true, + binding: getGhdrAuthorizer().publicBinding(), + }); + } + return json(res, 404, { error: "not_found" }); } catch (error) { process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`); @@ -1248,15 +1374,44 @@ function validateWorkorderBody(body, targets, actions) { const immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment"; const repoPushResourceAction = action === "push-repository"; const linkedNodeResourceAction = action === "authorize-linked-node-session"; + const ghdrLayoutResourceAction = action === "sign-native-layout-plan"; if (immutableResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(resource)) return { ok: false, status: 400, error: "immutable_architecture_resource_required" }; if (repoPushResourceAction && resource && !/^bingshuo\/[a-z0-9._-]+@[a-z0-9][a-z0-9._/-]{0,199}$/.test(resource)) return { ok: false, status: 400, error: "repo_push_resource_invalid" }; if (linkedNodeResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}:[A-Za-z0-9._-]{3,120}$/.test(resource)) return { ok: false, status: 400, error: "linked_node_resource_required" }; - if (!immutableResourceAction && !repoPushResourceAction && !linkedNodeResourceAction && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" }; + if (ghdrLayoutResourceAction && !/^GH-CVM-MAIN-PROD-01:[0-9a-f]{64}:[1-9][0-9]{0,19}$/.test(resource)) return { ok: false, status: 400, error: "ghdr_layout_resource_required" }; + if (!immutableResourceAction && !repoPushResourceAction && !linkedNodeResourceAction && !ghdrLayoutResourceAction && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" }; if (body.owner_notify !== undefined && typeof body.owner_notify !== "boolean") return { ok: false, status: 400, error: "invalid_owner_notify" }; if (body.owner_notify === true && provenance.system_entry !== "光湖语言人格系统当前实例") return { ok: false, status: 400, error: "owner_notify_requires_language_system_provenance" }; if (Object.values(provenance).some(Boolean) && (provenance.system_entry !== "光湖语言人格系统当前实例" || Object.values(provenance).some(item => !item || item.length > 120))) return { ok: false, status: 400, error: "invalid_instance_provenance" }; return { ok: true, request: { persona: { pid: personaId, name: personaName }, provenance, target, scope, action, allowedActions: actions[scope], description, resource } }; } +function validateGhdrLayoutRequest(plan, target, resource) { + if (target !== "GH-CVM-MAIN-PROD-01" + || !plan + || plan.schema !== "guanghu.ghdr-signed-layout-plan/v1" + || !plan.payload + || !Array.isArray(plan.signatures) + || plan.signatures.length !== 0) { + return { ok: false, error: "ghdr_layout_plan_invalid" }; + } + const payload = plan.payload; + const generation = Number(payload.generation); + if (payload.node_id !== target + || payload.provider !== "tencent_cloud" + || payload.region !== "ap-guangzhou" + || payload.system_disk !== "/dev/vda" + || payload.operation !== "install_native_ab" + || !Number.isSafeInteger(generation) + || generation < 1) { + return { ok: false, error: "ghdr_layout_binding_mismatch" }; + } + const canonical = JSON.stringify(payload); + const payloadSha256 = crypto.createHash("sha256").update(canonical).digest("hex"); + if (resource !== `${target}:${payloadSha256}:${generation}`) { + return { ok: false, error: "ghdr_layout_resource_mismatch" }; + } + return { ok: true, payload_sha256: payloadSha256, generation, resource }; +} function originLabel(order) { const value = order && order.provenance || {}; return value.software || value.model || value.instance ? `${value.software || "未知软件"} · ${value.model || "未知模型"} · ${value.instance || "当前实例"}` : "旧版工单未记录"; @@ -1286,4 +1441,11 @@ if (require.main === module) { createApp().listen(port, host, () => process.stdout.write(`lake-lamp-authz listening on ${host}:${port}\n`)); } -module.exports = { createApp, DEFAULT_ACTIONS, SlidingWindowLimiter, loadApprovers, selectApprover }; +module.exports = { + createApp, + DEFAULT_ACTIONS, + SlidingWindowLimiter, + loadApprovers, + selectApprover, + validateGhdrLayoutRequest, +}; diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js index 3b2cbcc..c254559 100644 --- a/server-tools/lake-lamp-authz/server.test.js +++ b/server-tools/lake-lamp-authz/server.test.js @@ -597,6 +597,140 @@ test("failed server actions return a durable diagnosis instead of making a perso } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); +test("GHDR double signing is email-authorized and bound to one canonical layout digest", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-ghdr-sign-")); + const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir); + fs.writeFileSync(path.join(mapsDir, "GH-CVM-MAIN-PROD-01.json"), JSON.stringify({ + node_id: "GH-CVM-MAIN-PROD-01", + })); + const now = Math.floor(Date.now() / 1000); + const plan = { + schema: "guanghu.ghdr-signed-layout-plan/v1", + payload: { + node_id: "GH-CVM-MAIN-PROD-01", + provider: "tencent_cloud", + region: "ap-guangzhou", + target_probe_sha256: "11".repeat(32), + system_disk: "/dev/vda", + disk_sectors: 104857600, + logical_sector_bytes: 512, + disk_identity_sha256: "22".repeat(32), + recovery_evidence_sha256: "55".repeat(32), + first_partition_lba: 2048, + generation: 1, + operation: "install_native_ab", + issued_at_unix: now, + expires_at_unix: now + 300, + slots: [ + { name: "A", lba_start: 34, sector_count: 29, image_sha256: "33".repeat(32) }, + { name: "B", lba_start: 73, sector_count: 29, image_sha256: "44".repeat(32) }, + ], + }, + signatures: [], + }; + const digest = crypto.createHash("sha256").update(JSON.stringify(plan.payload)).digest("hex"); + const resource = `GH-CVM-MAIN-PROD-01:${digest}:1`; + const signatures = [ + { node_id: "GH-CTRL-GZ-01", failure_domain: "tencent/ap-guangzhou/BS-GZ-006", public_key_hex: "aa".repeat(32), signature_hex: "bb".repeat(64) }, + { node_id: "GH-CTRL-SG-01", failure_domain: "tencent/ap-singapore/ZY-SG-006", public_key_hex: "cc".repeat(32), signature_hex: "dd".repeat(64) }, + ]; + const calls = []; + try { + await withServer(async ({ base, mail }) => { + const malformed = await fetch(`${base}/api/public/workorders`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + persona_id: "ICE-GL-ZY001", + target: "GH-CVM-MAIN-PROD-01", + scope: "native-recovery", + action: "sign-native-layout-plan", + resource: "GH-CVM-MAIN-PROD-01:not-a-digest:1", + }), + }); + assert.equal(malformed.status, 400); + assert.equal((await malformed.json()).error, "ghdr_layout_resource_required"); + + const requested = await fetch(`${base}/api/public/workorders`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + persona_id: "ICE-GL-ZY001", + target: "GH-CVM-MAIN-PROD-01", + scope: "native-recovery", + action: "sign-native-layout-plan", + resource, + }), + }); + const order = await requested.json(); + await fetch(`${base}${new URL(order.request_url).pathname.replace("/authz", "")}`, ownerForm()); + await fetch(`${base}${new URL(mail[0].approvalUrl).pathname.replace("/authz", "")}`, { method: "POST" }); + const session = await (await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { + method: "POST", + headers: { authorization: `Bearer ${order.claim_token}` }, + })).json(); + const common = { + persona_id: "ICE-GL-ZY001", + target: "GH-CVM-MAIN-PROD-01", + scope: "native-recovery", + }; + const map = await (await fetch(`${base}/api/navigation-map/read`, { + method: "POST", + headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, + body: JSON.stringify(common), + })).json(); + await fetch(`${base}/api/navigation-map/ack`, { + method: "POST", + headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, + body: JSON.stringify({ ...common, map_hash: map.map_hash }), + }); + + const changed = structuredClone(plan); + changed.payload.generation = 2; + const refused = await fetch(`${base}/api/ghdr/sign-layout`, { + method: "POST", + headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, + body: JSON.stringify({ ...common, resource, plan: changed }), + }); + assert.equal(refused.status, 400); + assert.equal((await refused.json()).error, "ghdr_layout_resource_mismatch"); + + const signed = await fetch(`${base}/api/ghdr/sign-layout`, { + method: "POST", + headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, + body: JSON.stringify({ ...common, resource, plan }), + }); + assert.equal(signed.status, 200); + const payload = await signed.json(); + assert.equal(payload.signatures.length, 2); + assert.equal(payload.receipt.diagnostic_code, "ghdr_layout_double_signature_succeeded"); + assert.deepEqual(calls, [{ + plan, + binding: { + ok: true, + payload_sha256: digest, + generation: 1, + resource, + }, + workorderId: order.workorder_id, + authorizer: "test-authorizer", + }]); + }, { + mapsDir, + mapStateFile: path.join(dir, "acks.json"), + targets: ["GH-CVM-MAIN-PROD-01"], + actions: { "native-recovery": ["read-navigation-map", "sign-native-layout-plan"] }, + getGhdrAuthorizer: () => "test-authorizer", + signGhdrPlan: async request => { + calls.push(request); + return { ok: true, signatures }; + }, + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("deployment is dispatched only by an explicit approved second signal", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-dispatch-")); const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir);