diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index 7113e78..e749f6b 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -43,6 +43,23 @@ HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准 `/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型 登记文件、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 数字,在内存中补全邮箱并只发送 SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密 request credential 时,脚本自动切换到跨设备公开建单模式。 diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index 1bfe7cb..3af5ea0 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -1115,6 +1115,12 @@ function createApp(options = {}) { resource, ); 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); if (!mapGate.verify(token, target, map.hash).ok) { return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", { diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js index 82d2d77..7f68723 100644 --- a/server-tools/lake-lamp-authz/server.test.js +++ b/server-tools/lake-lamp-authz/server.test.js @@ -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 () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-dispatch-")); const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir); diff --git a/server-tools/lake-lamp-authz/workorder-manager.js b/server-tools/lake-lamp-authz/workorder-manager.js index 361e517..0616558 100644 --- a/server-tools/lake-lamp-authz/workorder-manager.js +++ b/server-tools/lake-lamp-authz/workorder-manager.js @@ -40,6 +40,7 @@ class WorkOrderManager { approvalHash: "", claimHash: hash(claimToken), emailSentAt: 0, + approvalChannel: "", state: "pending", claimed: false, }); @@ -98,6 +99,7 @@ class WorkOrderManager { const order = this.workorders.get(inspected.order.id); order.state = "approved"; order.approvedAt = now; + order.approvalChannel = "email"; order.approvalHash = ""; order.handoffHash = ""; this.persist(); @@ -134,6 +136,7 @@ class WorkOrderManager { } order.state = "approved"; order.approvedAt = now; + order.approvalChannel = "guanghu_router"; order.approvalHash = ""; order.handoffHash = ""; this.persist(); @@ -157,6 +160,7 @@ class WorkOrderManager { actions: order.allowedActions || [order.action], resource: order.resource || "", authorizerId: order.authorizerId || "", + approvalChannel: order.approvalChannel || "legacy_unspecified", createdAt: now, expiresAt: now + this.sessionTtl, maxExpiresAt: now + this.maxSessionLifetime, diff --git a/server-tools/lake-lamp-authz/workorder-manager.test.js b/server-tools/lake-lamp-authz/workorder-manager.test.js index 6f8d725..a558af6 100644 --- a/server-tools/lake-lamp-authz/workorder-manager.test.js +++ b/server-tools/lake-lamp-authz/workorder-manager.test.js @@ -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); assert.equal(claimed.ok, true); 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.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); @@ -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, "owner", 102).ok, true); 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", () => {