fix(authz): require email approval for native layout signing
This commit is contained in:
parent
12517bf2ba
commit
a385249819
5 changed files with 92 additions and 1 deletions
|
|
@ -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 时,脚本自动切换到跨设备公开建单模式。
|
||||||
|
|
|
||||||
|
|
@ -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", "先读取并确认导航图。", {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue