fix(authz): require email approval for native layout signing

This commit is contained in:
冰朔 2026-08-03 00:00:04 +08:00
commit a385249819
5 changed files with 92 additions and 1 deletions

View file

@ -43,6 +43,23 @@ HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准
`/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型 `/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型
登记文件、session pepper 和会话状态路径由 `authorization.env` 固定;手机不能切换这些路径。 登记文件、session pepper 和会话状态路径由 `authorization.env` 固定;手机不能切换这些路径。
## 企业 GHDR 邮件授权与服务器代签
企业原生布局使用独立的 `native-recovery / sign-native-layout-plan`
工单。该动作必须由京东主控的预登记邮箱完成本次批准;在线 HoloLake
广播面板、旧会话和未记录批准通道的会话都不能替代邮件批准。
批准后,京东只签发最长两分钟、单次使用、精确绑定控制器、目标节点、工单、
布局摘要、资源和代次的 Ed25519 能力票据。广州和新加坡控制器通过出站 HTTPS
轮询取得各自票据,在本机固定用途签名器中完成代签,再把签名结果回送京东。
- 布局私钥与轮询传输私钥分离,均只存在于控制器服务器;
- 客户端、浏览器、Mac、企业目标机和代码仓库都不接收私钥
- `/api/ghdr/authorizer-public-key` 只返回京东授权公钥和指纹;
- `/api/ghdr/controllers/poll``/result` 只接受控制器传输私钥签过的规范请求;
- `/api/ghdr/sign-layout` 必须收齐两个不同节点、不同故障域的签名才成功;
- 任何端点都不提供私钥导出、任意 URL、任意命令或单签降级。
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送 `request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密 SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密
request credential 时,脚本自动切换到跨设备公开建单模式。 request credential 时,脚本自动切换到跨设备公开建单模式。

View file

@ -1115,6 +1115,12 @@ function createApp(options = {}) {
resource, resource,
); );
if (!verified.ok) return json(res, 403, failure(verified.reason)); if (!verified.ok) return json(res, 403, failure(verified.reason));
if (verified.session.approvalChannel !== "email") {
return json(res, 403, failure(
"ghdr_email_approval_required",
"企业原生布局必须由京东主控的预登记邮箱完成本次批准。",
));
}
const map = mapGate.read(target); const map = mapGate.read(target);
if (!mapGate.verify(token, target, map.hash).ok) { if (!mapGate.verify(token, target, map.hash).ok) {
return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", { return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", {

View file

@ -789,6 +789,61 @@ test("GHDR controller transport endpoints expose jobs and public authority witho
}); });
}); });
test("GHDR layout signing refuses a Guanghu Router approval in place of email approval", async () => {
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`;
await withServer(async ({ base }) => {
const response = await fetch(`${base}/api/ghdr/sign-layout`, {
method: "POST",
headers: {
authorization: "Bearer router-approved-session",
"content-type": "application/json",
},
body: JSON.stringify({
persona_id: "ICE-GL-ZY001",
target: "GH-CVM-MAIN-PROD-01",
scope: "native-recovery",
resource,
plan,
}),
});
assert.equal(response.status, 403);
assert.equal((await response.json()).error, "ghdr_email_approval_required");
}, {
manager: {
verifySession: () => ({
ok: true,
session: { approvalChannel: "guanghu_router" },
}),
},
});
});
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);

View file

@ -40,6 +40,7 @@ class WorkOrderManager {
approvalHash: "", approvalHash: "",
claimHash: hash(claimToken), claimHash: hash(claimToken),
emailSentAt: 0, emailSentAt: 0,
approvalChannel: "",
state: "pending", state: "pending",
claimed: false, claimed: false,
}); });
@ -98,6 +99,7 @@ class WorkOrderManager {
const order = this.workorders.get(inspected.order.id); const order = this.workorders.get(inspected.order.id);
order.state = "approved"; order.state = "approved";
order.approvedAt = now; order.approvedAt = now;
order.approvalChannel = "email";
order.approvalHash = ""; order.approvalHash = "";
order.handoffHash = ""; order.handoffHash = "";
this.persist(); this.persist();
@ -134,6 +136,7 @@ class WorkOrderManager {
} }
order.state = "approved"; order.state = "approved";
order.approvedAt = now; order.approvedAt = now;
order.approvalChannel = "guanghu_router";
order.approvalHash = ""; order.approvalHash = "";
order.handoffHash = ""; order.handoffHash = "";
this.persist(); this.persist();
@ -157,6 +160,7 @@ class WorkOrderManager {
actions: order.allowedActions || [order.action], actions: order.allowedActions || [order.action],
resource: order.resource || "", resource: order.resource || "",
authorizerId: order.authorizerId || "", authorizerId: order.authorizerId || "",
approvalChannel: order.approvalChannel || "legacy_unspecified",
createdAt: now, createdAt: now,
expiresAt: now + this.sessionTtl, expiresAt: now + this.sessionTtl,
maxExpiresAt: now + this.maxSessionLifetime, maxExpiresAt: now + this.maxSessionLifetime,

View file

@ -31,6 +31,10 @@ test("approval link is single use and the session is claimed once", () => {
const claimed = manager.claim(created.id, created.claimToken, 104); const claimed = manager.claim(created.id, created.claimToken, 104);
assert.equal(claimed.ok, true); assert.equal(claimed.ok, true);
assert.equal(claimed.expiresIn, 3600); assert.equal(claimed.expiresIn, 3600);
assert.equal(
manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 106).session.approvalChannel,
"email",
);
assert.equal(manager.claim(created.id, created.claimToken, 105).ok, false); assert.equal(manager.claim(created.id, created.claimToken, 105).ok, false);
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 106).ok, true); assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 106).ok, true);
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "inspect-services", 106).ok, true); assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "inspect-services", 106).ok, true);
@ -169,7 +173,12 @@ test("a broadcast console lists and approves only its bound pending cards", () =
assert.equal(manager.approveById(first.id, "awen", 102).reason, "authorizer_mismatch"); assert.equal(manager.approveById(first.id, "awen", 102).reason, "authorizer_mismatch");
assert.equal(manager.approveById(first.id, "owner", 102).ok, true); assert.equal(manager.approveById(first.id, "owner", 102).ok, true);
assert.equal(manager.pendingForApprover("owner", 103).length, 0); assert.equal(manager.pendingForApprover("owner", 103).length, 0);
assert.equal(manager.claim(first.id, first.claimToken, 104).ok, true); const claimed = manager.claim(first.id, first.claimToken, 104);
assert.equal(claimed.ok, true);
assert.equal(
manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 105).session.approvalChannel,
"guanghu_router",
);
}); });
test("session cannot switch an approved architecture resource", () => { test("session cannot switch an approved architecture resource", () => {