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
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 };
|
||||
Loading…
Reference in a new issue