Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions packages/core/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,32 +461,28 @@ async function resolvePublicKey(
options?: VerifyOptions,
): Promise<ResolvedKey> {
const resolveTxt = options?.resolveTxt !== false;
const httpsResult = await resolvePublicKeyFromHttps(author, keyId, options);

const httpsPromise = resolvePublicKeyFromHttps(author, keyId, options);
// HTTPS is authoritative whenever it produces a definitive answer:
// - A key (valid, revoked, or expired).
// - KEY_NOT_FOUND from a successful response (publisher's manifest excludes the keyId).
// DNS is consulted only as a fallback when HTTPS is unreachable
// (network error, non-OK response, or otherwise no usable answer).
const httpsAuthoritative =
!!httpsResult.key || httpsResult.code === VerifyErrorCode.KEY_NOT_FOUND;

if (!resolveTxt) {
return httpsPromise;
if (!resolveTxt || httpsAuthoritative) {
return httpsResult;
}

const dnsPromise = fetchPublicKeyFromDns(author, keyId, {
const dns = await fetchPublicKeyFromDns(author, keyId, {
fetch: options?.fetch,
now: options?.now,
}).then((r): ResolvedKey => ({
key: r.key,
code: r.code,
source: r.source,
}));

try {
return await Promise.any([
httpsPromise.then((r) => r.key ? r : Promise.reject(r)),
dnsPromise.then((r) => r.key ? r : Promise.reject(r)),
]);
} catch {
// Both failed — return HTTPS result for its error info
const httpsResult = await httpsPromise;
return httpsResult;
});
if (dns.key) {
return { key: dns.key, code: dns.code, source: "dns" };
}
return httpsResult;
}

// -- Unified verify -----------------------------------------------------------
Expand Down Expand Up @@ -518,20 +514,24 @@ async function tryAllKeys(
options?: VerifyOptions,
): Promise<SignerResult> {
const candidates: Array<{ key: PublicKeyEntry; source: "https" | "dns" }> = [];
let httpsAvailable = false;

try {
const keys = await fetchPublicKeys(publisher, options);
httpsAvailable = true;
for (const key of keys) candidates.push({ key, source: "https" });
} catch { /* HTTPS unavailable */ }

if (options?.resolveTxt !== false) {
// Only consult DNS when HTTPS is unreachable. Otherwise HTTPS is authoritative
// (including for revocation), so a now-revoked-but-stale DNS record cannot
// override the publisher's HTTPS key manifest.
if (!httpsAvailable && options?.resolveTxt !== false) {
const dns = await fetchPublicKeyFromDns(publisher, "key", {
fetch: options?.fetch,
now: options?.now,
});
if (dns.key && !dns.code) {
const dup = candidates.some((c) => c.key.publicKey === dns.key!.publicKey);
if (!dup) candidates.push({ key: dns.key, source: "dns" });
candidates.push({ key: dns.key, source: "dns" });
}
}

Expand Down
118 changes: 118 additions & 0 deletions packages/core/test/verify.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Comment on lines +458 to +465

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new httpsAuthoritative behavior treats an HTTPS 200 + KEY_NOT_FOUND as definitive (DNS should not be consulted). Consider adding a regression test where HTTPS responds 200 with a manifest that does not include key_test, while DNS returns a valid TXT record for key_test; expected outcome should be KEY_NOT_FOUND with keySource: "https".

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

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 DNS mocks an HTTPS manifest that does not list key_test while DNS publishes a valid TXT record for it, asserts KEY_NOT_FOUND with keySource: "https", and verifies the DNS endpoint is never fetched.

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

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added regression tests cover the resolvePublicKey (keyId present) path, but the related fix in tryAllKeys (keyId omitted/empty) isn’t exercised here. Consider adding a test where HTTPS returns a manifest containing only a revoked key (so fetchPublicKeys succeeds but yields no candidates) while DNS returns a still-valid TXT key; verification should remain invalid and must not succeed via DNS.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

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 no-keyId path: revoked HTTPS key + valid DNS key must not verify (tryAllKeys) strips the keyId from the signature to force the tryAllKeys path, has HTTPS list only a revoked key (so fetchPublicKeys yields no candidates) while DNS publishes a still-valid copy, and asserts verification fails (KEY_NOT_FOUND) — confirming DNS cannot rescue a revoked HTTPS state.

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, {
Expand Down
Loading