-
Notifications
You must be signed in to change notification settings - Fork 1
fix(core): make HTTPS authoritative for key resolution (closes #2) #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -455,6 +455,124 @@ signatures: | |
| expect(result.code).toBe(VerifyErrorCode.NO_SIGNATURES); | ||
| }); | ||
|
|
||
| it("rejects revoked HTTPS key even when DNS reports it valid (no race bypass)", async () => { | ||
| const { privateKey, publicKey } = await generateKeyPair(); | ||
| const signed = await signFile(SAMPLE_MD, privateKey, { keyId: "key_test", publisher: "example.com" }); | ||
| const pubKeyB64 = uint8ToBase64(publicKey); | ||
|
|
||
| const fetchMock = (async (input: string | URL | Request) => { | ||
| const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; | ||
| if (url.includes("/.well-known/notar-keys.json")) { | ||
| await new Promise((r) => setTimeout(r, 100)); | ||
| return new Response(JSON.stringify({ | ||
| keys: [{ | ||
| keyId: "key_test", algorithm: "ed25519", publicKey: pubKeyB64, | ||
| expires: "2099-01-01T00:00:00.000Z", revoked: true, | ||
| }], | ||
| }), { status: 200 }); | ||
| } | ||
| if (url.includes("cloudflare-dns.com/dns-query")) { | ||
| const txt = `v=sk1; k=ed25519; p=${pubKeyB64}; exp=4102444800`; | ||
| return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: `"${txt}"` }] }), { status: 200 }); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }) as typeof globalThis.fetch; | ||
|
|
||
| const result = await verifyFromAuthor(signed, { fetch: fetchMock }); | ||
| expect(result.valid).toBe(false); | ||
| expect(result.details?.signers![0].code).toBe(VerifyErrorCode.KEY_REVOKED); | ||
| expect(result.details?.signers![0].keySource).toBe("https"); | ||
| }); | ||
|
|
||
| it("falls back to DNS only when HTTPS is unreachable", async () => { | ||
| const { privateKey, publicKey } = await generateKeyPair(); | ||
| const signed = await signFile(SAMPLE_MD, privateKey, { keyId: "key_test", publisher: "example.com" }); | ||
| const pubKeyB64 = uint8ToBase64(publicKey); | ||
|
|
||
|
Comment on lines
+487
to
+491
|
||
| const fetchMock = (async (input: string | URL | Request) => { | ||
| const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; | ||
| if (url.includes("/.well-known/notar-keys.json")) { | ||
| throw new Error("HTTPS unreachable"); | ||
| } | ||
| if (url.includes("cloudflare-dns.com/dns-query")) { | ||
| const txt = `v=sk1; k=ed25519; p=${pubKeyB64}; exp=4102444800`; | ||
| return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: `"${txt}"` }] }), { status: 200 }); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }) as typeof globalThis.fetch; | ||
|
|
||
| const result = await verifyFromAuthor(signed, { fetch: fetchMock }); | ||
| expect(result.valid).toBe(true); | ||
| expect(result.details?.signers![0].keySource).toBe("dns"); | ||
| }); | ||
|
|
||
| it("treats HTTPS 200 + KEY_NOT_FOUND as authoritative; does not consult DNS", async () => { | ||
| const { privateKey, publicKey } = await generateKeyPair(); | ||
| const signed = await signFile(SAMPLE_MD, privateKey, { keyId: "key_test", publisher: "example.com" }); | ||
| const pubKeyB64 = uint8ToBase64(publicKey); | ||
|
|
||
| let dnsCalled = false; | ||
| const fetchMock = (async (input: string | URL | Request) => { | ||
| const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; | ||
| if (url.includes("/.well-known/notar-keys.json")) { | ||
| // HTTPS responds successfully but the manifest does not list key_test. | ||
| return new Response(JSON.stringify({ | ||
| keys: [{ | ||
| keyId: "other_key", algorithm: "ed25519", publicKey: pubKeyB64, | ||
| expires: "2099-01-01T00:00:00.000Z", | ||
| }], | ||
| }), { status: 200 }); | ||
| } | ||
| if (url.includes("cloudflare-dns.com/dns-query")) { | ||
| dnsCalled = true; | ||
| const txt = `v=sk1; k=ed25519; p=${pubKeyB64}; exp=4102444800`; | ||
| return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: `"${txt}"` }] }), { status: 200 }); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }) as typeof globalThis.fetch; | ||
|
|
||
| const result = await verifyFromAuthor(signed, { fetch: fetchMock }); | ||
| expect(result.valid).toBe(false); | ||
| expect(result.details?.signers![0].code).toBe(VerifyErrorCode.KEY_NOT_FOUND); | ||
| expect(result.details?.signers![0].keySource).toBe("https"); | ||
| expect(dnsCalled).toBe(false); | ||
| }); | ||
|
|
||
| it("no-keyId path: revoked HTTPS key + valid DNS key must not verify (tryAllKeys)", async () => { | ||
| const { privateKey, publicKey } = await generateKeyPair(); | ||
| // Sign with a real keyId so signFile produces a valid signature, then | ||
| // strip the keyId from the front matter so verifyFromAuthor takes the | ||
| // tryAllKeys path. | ||
| const signed = await signFile(SAMPLE_MD, privateKey, { keyId: "key_test", publisher: "example.com" }); | ||
| const stripped = signed.replace(/keyId: key_test/, "keyId: \"\""); | ||
| const pubKeyB64 = uint8ToBase64(publicKey); | ||
|
|
||
| const fetchMock = (async (input: string | URL | Request) => { | ||
| const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; | ||
| if (url.includes("/.well-known/notar-keys.json")) { | ||
| // HTTPS succeeds but the only listed key is revoked. | ||
| // fetchPublicKeys filters revoked keys, so candidates are empty, | ||
| // but the HTTPS source is still considered "available". | ||
| return new Response(JSON.stringify({ | ||
| keys: [{ | ||
| keyId: "key_test", algorithm: "ed25519", publicKey: pubKeyB64, | ||
| expires: "2099-01-01T00:00:00.000Z", revoked: true, | ||
| }], | ||
| }), { status: 200 }); | ||
| } | ||
| if (url.includes("cloudflare-dns.com/dns-query")) { | ||
| // DNS still publishes a valid copy of the same key — must not be used. | ||
| const txt = `v=sk1; k=ed25519; p=${pubKeyB64}; exp=4102444800`; | ||
| return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: `"${txt}"` }] }), { status: 200 }); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }) as typeof globalThis.fetch; | ||
|
|
||
| const result = await verifyFromAuthor(stripped, { fetch: fetchMock }); | ||
| expect(result.valid).toBe(false); | ||
| expect(result.details?.signers![0].code).toBe(VerifyErrorCode.KEY_NOT_FOUND); | ||
| }); | ||
|
|
||
| it("unsigned ZIP returns MISSING_MANIFEST", async () => { | ||
| const zip = zipSync({ "file.txt": enc("hello") }); | ||
| const result = await verifyFromAuthor(zip, { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
httpsAuthoritativebehavior treats an HTTPS 200 +KEY_NOT_FOUNDas definitive (DNS should not be consulted). Consider adding a regression test where HTTPS responds 200 with a manifest that does not includekey_test, while DNS returns a valid TXT record forkey_test; expected outcome should beKEY_NOT_FOUNDwithkeySource: "https".There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — added in f250f24. New test
treats HTTPS 200 + KEY_NOT_FOUND as authoritative; does not consult DNSmocks an HTTPS manifest that does not listkey_testwhile DNS publishes a valid TXT record for it, assertsKEY_NOT_FOUNDwithkeySource: "https", and verifies the DNS endpoint is never fetched.