feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)
- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传) - Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效 - 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id - 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署 - 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在 - 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程 - 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0 - 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通 - docs 00-10 同步改写;README 更新
This commit is contained in:
+38
-51
@@ -1,13 +1,15 @@
|
||||
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
|
||||
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式(Worker-Console 协议) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canonicalString,
|
||||
computeSignature,
|
||||
generateWmailKey,
|
||||
isValidWmailKey,
|
||||
verifyNodeRequest,
|
||||
sha256Hex,
|
||||
verifyWorkerRequest,
|
||||
signWorkerRequest,
|
||||
} from "../shared/src/index.ts";
|
||||
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
|
||||
import { makeTestEnv, signedAuthorizeRequest, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
describe("wmh_ Key 规范", () => {
|
||||
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
|
||||
@@ -29,41 +31,35 @@ describe("wmh_ Key 规范", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("HMAC 请求签名", () => {
|
||||
describe("Worker-Console HMAC 请求签名", () => {
|
||||
test("canonical 串格式符合 docs/04", () => {
|
||||
const c = canonicalString({
|
||||
method: "POST",
|
||||
path: "/api/x",
|
||||
path: "/internal/email/authorize",
|
||||
timestamp: "123",
|
||||
nonce: "n",
|
||||
bodyHash: "h",
|
||||
});
|
||||
expect(c).toBe("POST\n/api/x\n123\nn\nh");
|
||||
expect(c).toBe("POST\n/internal/email/authorize\n123\nn\nh");
|
||||
});
|
||||
|
||||
test("正确签名可通过校验", async () => {
|
||||
test("正确签名可通过校验(Authorization: WmailHub-Worker <worker_id>)", async () => {
|
||||
const body = new TextEncoder().encode("hello");
|
||||
const headers = new Headers({
|
||||
"Authorization": "WmailHub-Node node_1",
|
||||
"Authorization": "WmailHub-Worker wmailhub-email-x1",
|
||||
"X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
"X-WmailHub-Nonce": "nonce-1",
|
||||
"X-WmailHub-Body-SHA256": await (async () => {
|
||||
const { sha256Hex } = await import("../shared/src/index.ts");
|
||||
return sha256Hex(body);
|
||||
})(),
|
||||
"X-WmailHub-Body-SHA256": await sha256Hex(body),
|
||||
"X-WmailHub-Signature": await computeSignature("s", {
|
||||
method: "POST",
|
||||
path: "/p",
|
||||
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
nonce: "nonce-1",
|
||||
bodyHash: await (async () => {
|
||||
const { sha256Hex } = await import("../shared/src/index.ts");
|
||||
return sha256Hex(body);
|
||||
})(),
|
||||
bodyHash: await sha256Hex(body),
|
||||
}),
|
||||
"X-WmailHub-Protocol": "1",
|
||||
});
|
||||
const result = await verifyNodeRequest({
|
||||
const result = await verifyWorkerRequest({
|
||||
secret: "s",
|
||||
method: "POST",
|
||||
url: "http://x/p",
|
||||
@@ -71,30 +67,36 @@ describe("HMAC 请求签名", () => {
|
||||
headers,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.workerId).toBe("wmailhub-email-x1");
|
||||
});
|
||||
|
||||
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
|
||||
test("错误 secret 签名被拒(伪造 Worker 请求)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { secret: "whs_wrong_secret" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("body 篡改被拒", async () => {
|
||||
test("未知 worker_id 被拒(公网匿名调用 /internal/email/*)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { workerId: "wmailhub-email-unknown" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("body 篡改被拒(authorize 与 ingest 都校验 body hash)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const tampered = new TextEncoder().encode(TEST_MAIL + "injected");
|
||||
const req = await signedEmailRequest(test, body, { tamperBody: tampered });
|
||||
const req = await signedIngestRequest(test, body, { tamperBody: tampered });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("过期 timestamp 超窗被拒", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前
|
||||
const req = await signedEmailRequest(test, body, { timestamp: old });
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { timestamp: old });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
const data = (await resp.json()) as { error: string };
|
||||
@@ -103,39 +105,24 @@ describe("HMAC 请求签名", () => {
|
||||
|
||||
test("nonce 重放被拒(replay attack)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req1 = await signedEmailRequest(test, body);
|
||||
const resp1 = await test.app.fetch(req1, test.env);
|
||||
expect(resp1.status).toBe(202);
|
||||
// 取第一次请求的 nonce 重放(重新签名同一 nonce)
|
||||
const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`;
|
||||
const { signNodeRequest } = await import("../shared/src/index.ts");
|
||||
const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
|
||||
headers["Content-Type"] = "message/rfc822";
|
||||
const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env);
|
||||
expect(resp2a.status).toBe(202);
|
||||
// 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名)
|
||||
const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
|
||||
headers3["Content-Type"] = "message/rfc822";
|
||||
const replayReq = () => new Request(url, { method: "POST", headers: headers3, body });
|
||||
// 同一组签名头发两次:第一次 nonce 入库通过,第二次构成 replay
|
||||
const url = "http://localhost/internal/email/authorize";
|
||||
const body = new TextEncoder().encode(JSON.stringify({ recipient: "hello@example.test", sender: "a@b.c" }));
|
||||
const headers = await signWorkerRequest(test.workerSecret, test.workerId, "POST", url, body);
|
||||
headers["Content-Type"] = "application/json";
|
||||
const replayReq = () => new Request(url, { method: "POST", headers, body: body.slice() });
|
||||
const respFirst = await test.app.fetch(replayReq(), test.env);
|
||||
expect(respFirst.status).toBe(202);
|
||||
const resp3 = await test.app.fetch(replayReq(), test.env);
|
||||
expect(resp3.status).toBe(401);
|
||||
const data = (await resp3.json()) as { error: string };
|
||||
expect(respFirst.status).toBe(200);
|
||||
const respReplay = await test.app.fetch(replayReq(), test.env);
|
||||
expect(respReplay.status).toBe(401);
|
||||
const data = (await respReplay.json()) as { error: string };
|
||||
expect(data.error).toContain("replay");
|
||||
});
|
||||
|
||||
test("错误协议版本被拒", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req = await signedEmailRequest(test, body);
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("X-WmailHub-Protocol", "9");
|
||||
const resp = await test.app.fetch(
|
||||
new Request(req.url, { method: "POST", headers, body }),
|
||||
test.env,
|
||||
);
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { protocol: "9" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user