fix(core): make HTTPS authoritative for key resolution (closes #2)#4
Conversation
resolvePublicKey raced HTTPS and DNS via Promise.any. The HTTPS
branch resolved with { key, code: KEY_REVOKED } for revoked keys,
but if a stale DNS TXT record returned a still-valid copy faster,
DNS won the race and the revocation status from HTTPS was silently
dropped. verifySignatureEntry then proceeded against the DNS key
without the revocation check, returning valid: true.
tryAllKeys had the same class of bypass: fetchPublicKeys filters
revoked keys out, so only DNS could supply a now-revoked-but-stale
key, and any matching signature would verify against it.
Adopt an HTTPS-authoritative resolution policy:
- await HTTPS first.
- Honor any definitive HTTPS answer: a key (valid/expired/revoked)
or KEY_NOT_FOUND from a successful response.
- Fall back to DNS only when HTTPS is unreachable (network error,
non-OK response, or fetchPublicKeys threw).
- Drop the Promise.any race entirely.
Add regression tests:
- HTTPS revoked + DNS valid (slow HTTPS, fast DNS) -> KEY_REVOKED.
- HTTPS unreachable + DNS valid -> success via DNS fallback.
Fixes #2
Made-with: Cursor
-e
Signed-off-by: Emre Tinaztepe <emre@binalyze.com>
There was a problem hiding this comment.
Pull request overview
Security fix to eliminate a race-based revocation bypass in author-based signature verification by making the publisher’s HTTPS key manifest authoritative over DNS TXT lookups (closing issue #2 / CWE-367).
Changes:
- Remove
Promise.anykey-resolution race; prefer HTTPS results (including revoked/expired) and treat HTTPSKEY_NOT_FOUNDas definitive. - Restrict DNS TXT usage to a fallback only when HTTPS key retrieval is unavailable (applies to both
resolvePublicKeyand the no-keyIdpath viatryAllKeys). - Add regression tests covering revoked-HTTPS-vs-DNS and DNS fallback when HTTPS throws.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/core/src/verify.ts | Reworks key resolution precedence to avoid DNS overriding HTTPS revocation/absence signals; limits DNS usage to HTTPS-unavailable cases. |
| packages/core/test/verify.spec.ts | Adds regression tests for revoked HTTPS key precedence and DNS fallback when HTTPS is unreachable. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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")) { |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
…vocation Address Copilot review feedback on #4 by adding two regression tests: - HTTPS 200 + KEY_NOT_FOUND must be authoritative; DNS must not be consulted even if it publishes a matching TXT record. Asserts KEY_NOT_FOUND with keySource=https and that the DNS fetch is never invoked. - No-keyId (tryAllKeys) path: when HTTPS succeeds but lists only a revoked key (so fetchPublicKeys yields no candidates) and DNS still publishes a valid copy of the same key, verification must not succeed via DNS. This covers the second bypass class fixed in this PR. Made-with: Cursor -e Signed-off-by: Emre Tinaztepe <emre@binalyze.com>
Summary
Closes #2 — Revoked HTTPS key bypassed by faster valid DNS key (CWE-367).
resolvePublicKeyraced HTTPS and DNS viaPromise.any. Because the HTTPS branch resolves with{ key, code: KEY_REVOKED }for revoked keys, a stale-but-still-valid DNS TXT record returning faster would win the race, and the HTTPS revocation signal was silently dropped.tryAllKeys(the no-keyIdpath) had the same bypass class —fetchPublicKeysfilters revoked HTTPS keys out, so DNS could supply a now-revoked-but-stale matching key.Changes
resolvePublicKey: await HTTPS first; HTTPS is authoritative when it returns a key (valid / expired / revoked) orKEY_NOT_FOUNDfrom a successful response. DNS is consulted only when HTTPS is unreachable.tryAllKeys: only consult DNS iffetchPublicKeysthrew (HTTPS unreachable). Otherwise the publisher's HTTPS key manifest is the source of truth.Promise.anyrace is removed.rejects revoked HTTPS key even when DNS reports it valid (no race bypass)— slow HTTPS + fast DNS, expectsKEY_REVOKED.falls back to DNS only when HTTPS is unreachable— preserves the legitimate fallback behavior.Trade-off
We give up the marginal latency benefit of resolving HTTPS and DNS in parallel, but HTTPS is a single RTT and is the publisher's source of truth for revocation. The race was a security regression masquerading as an optimization.
Test plan
pnpm --filter @binalyze/notar test— 123 passedpnpm --filter @binalyze/notar typecheck— cleanpnpm lint— 0 warnings / 0 errorsMade with Cursor