feat(authz): add email-authorized GHDR dual signing
This commit is contained in:
parent
84f7c4c198
commit
bec7a3d7a0
10 changed files with 771 additions and 5 deletions
75
server-tools/lake-lamp-authz/ghdr-authorizer.js
Normal file
75
server-tools/lake-lamp-authz/ghdr-authorizer.js
Normal file
|
|
@ -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 };
|
||||||
39
server-tools/lake-lamp-authz/ghdr-authorizer.test.js
Normal file
39
server-tools/lake-lamp-authz/ghdr-authorizer.test.js
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
195
server-tools/lake-lamp-authz/ghdr-controller-broker.js
Normal file
195
server-tools/lake-lamp-authz/ghdr-controller-broker.js
Normal file
|
|
@ -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 };
|
||||||
116
server-tools/lake-lamp-authz/ghdr-controller-broker.test.js
Normal file
116
server-tools/lake-lamp-authz/ghdr-controller-broker.test.js
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
17
server-tools/lake-lamp-authz/ghdr-controllers.json
Normal file
17
server-tools/lake-lamp-authz/ghdr-controllers.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,11 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
install_root=/opt/guanghu/lake-lamp-authz
|
install_root=/opt/guanghu/lake-lamp-authz
|
||||||
|
|
||||||
install -d -m 0755 "$install_root"
|
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"
|
install -m 0644 "$script_dir/$file" "$install_root/$file"
|
||||||
done
|
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-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 -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
|
install -d -m 0700 /var/lib/guanghu/architecture-provision
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,22 @@ const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
class MapGate {
|
class MapGate {
|
||||||
constructor({ mapsDir = "/etc/guanghu/navigation-maps", stateFile = "" } = {}) {
|
constructor({ mapsDir = "/etc/guanghu/navigation-maps", fallbackMapsDir = "", stateFile = "" } = {}) {
|
||||||
this.mapsDir = mapsDir;
|
this.mapsDir = mapsDir;
|
||||||
|
this.fallbackMapsDir = fallbackMapsDir;
|
||||||
this.stateFile = stateFile;
|
this.stateFile = stateFile;
|
||||||
this.acks = new Map();
|
this.acks = new Map();
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
read(target) {
|
read(target) {
|
||||||
if (!/^[A-Z0-9-]+$/.test(target)) throw new Error("invalid_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);
|
const canonical = JSON.stringify(data);
|
||||||
return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") };
|
return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ const { executeRegisteredAction } = require("./action-client");
|
||||||
const { enqueueDeploymentEvent } = require("./deployment-event");
|
const { enqueueDeploymentEvent } = require("./deployment-event");
|
||||||
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
|
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
|
||||||
const { HoloLakeSessionManager } = require("./hololake-session");
|
const { HoloLakeSessionManager } = require("./hololake-session");
|
||||||
|
const { GhdrAuthorizer } = require("./ghdr-authorizer");
|
||||||
|
const { GhdrControllerBroker, loadControllers } = require("./ghdr-controller-broker");
|
||||||
const {
|
const {
|
||||||
HoloLakeAiGateway,
|
HoloLakeAiGateway,
|
||||||
HoloLakeKnowledgeProvider,
|
HoloLakeKnowledgeProvider,
|
||||||
|
|
@ -48,6 +50,7 @@ const DEFAULT_ACTIONS = Object.freeze({
|
||||||
],
|
],
|
||||||
"repo-push": ["read-navigation-map", "push-repository"],
|
"repo-push": ["read-navigation-map", "push-repository"],
|
||||||
"linked-node-ops": ["authorize-linked-node-session"],
|
"linked-node-ops": ["authorize-linked-node-session"],
|
||||||
|
"native-recovery": ["read-navigation-map", "sign-native-layout-plan"],
|
||||||
});
|
});
|
||||||
|
|
||||||
function createApp(options = {}) {
|
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 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 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"));
|
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 actions = options.actions || DEFAULT_ACTIONS;
|
||||||
const devices = options.devices || loadDevices(
|
const devices = options.devices || loadDevices(
|
||||||
options.devicesFile
|
options.devicesFile
|
||||||
|
|
@ -145,6 +149,7 @@ function createApp(options = {}) {
|
||||||
);
|
);
|
||||||
const mapGate = options.mapGate || new MapGate({
|
const mapGate = options.mapGate || new MapGate({
|
||||||
mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps",
|
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"),
|
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";
|
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 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 deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json";
|
||||||
const executeAction = options.executeAction || executeRegisteredAction;
|
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
|
// Creating a powerless request must never become harder than the human mail
|
||||||
// handoff. Keep at least three attempts per network each hour.
|
// 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));
|
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 });
|
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" });
|
return json(res, 404, { error: "not_found" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`);
|
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 immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment";
|
||||||
const repoPushResourceAction = action === "push-repository";
|
const repoPushResourceAction = action === "push-repository";
|
||||||
const linkedNodeResourceAction = action === "authorize-linked-node-session";
|
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 (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 (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 (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 !== 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 (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" };
|
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 } };
|
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) {
|
function originLabel(order) {
|
||||||
const value = order && order.provenance || {};
|
const value = order && order.provenance || {};
|
||||||
return value.software || value.model || value.instance ? `${value.software || "未知软件"} · ${value.model || "未知模型"} · ${value.instance || "当前实例"}` : "旧版工单未记录";
|
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`));
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 }); }
|
} 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 () => {
|
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 dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-dispatch-"));
|
||||||
const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir);
|
const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue