Skip to content

fix(transparency): add X509-DV-CLIENT cert type and enforce version-matched badge selection - #126

Open
jcrisp-godaddy wants to merge 3 commits into
agentnameservice:mainfrom
jcrisp-godaddy:fix/issue-116-v2
Open

jcrisp-godaddy wants to merge 3 commits into
agentnameservice:mainfrom
jcrisp-godaddy:fix/issue-116-v2

Conversation

@jcrisp-godaddy

Copy link
Copy Markdown

Fixes #116

Summary

Two defects in BadgeVerificationService.verifyClient caused every client verification against a production transparency log to fail with FINGERPRINT_MISMATCH and expected=null:

  1. CertType had no X509-DV-CLIENT. Production registrations carry identityCert.type: "X509-DV-CLIENT". The constant was missing, so Jackson threw on the typed payload conversion. That exception is caught in TransparencyService.parseAndSetPayload and only logged at WARN, so the fetch was recorded as a success with a null parsed payload. getIdentityCertFingerprint() then returned null and the fingerprint comparison failed. (The failure was a silently degraded parse, not a failed fetch — a failed fetch would have produced LOOKUP_FAILED.)

  2. The badge version filter never matched production records and fell back to all badges. ANS_VERSION_PATTERN extracts the certificate version without the v (1.0.9), while production zones publish version=v1.0.9 per ANS-3 §6.3 / ANS-6 §4.2. The exact-equals filter returned empty and the code fell back to checking every valid badge for the host, so a version the zone no longer publishes was rejected only incidentally (by fingerprint mismatch against some other version) rather than deterministically.

Changes (one commit each)

  • fix(transparency): add X509_DV_CLIENT cert type — adds the constant and annotates the existing CertType.fromString with @JsonCreator(mode = DELEGATING), so an unrecognised cert type deserializes to null instead of destroying the whole parsed payload, and a case variant of a known type resolves to that type (ANS-6 §4.4 says consumers SHOULD compare the type case-insensitively). This is deliberately scoped to CertType: a global READ_UNKNOWN_ENUM_VALUES_AS_NULL was rejected because the shared ObjectMapper also binds EventTypeV0/V1 and RevocationReason, where silent nulls would mask protocol drift. BadgeVerificationService never reads type, so a null type cannot cause a false VERIFIED.
  • fix(transparency): enforce version-matched badge selection per ANS-6 — filterBadgesByVersion now normalizes an optional leading v/V on both sides and uses two-pass selection mirroring the Go SDK: exact-version matches; otherwise versionless (version= absent) records; otherwise empty. When the certificate carries a version and nothing matches, verifyClient returns NOT_ANS_AGENT with a warning naming the presented version and the count of other-version records, and does not fetch unrelated registrations. Certificates with no version in the ANS name are unchanged (all valid badges are checked). NOT_ANS_AGENT, ClientVerificationResult.isNotAnsAgent() and RaBadgeRecord javadoc updated; two misleading comments in RaBadgeRecordTest corrected.
  • test(transparency): add version normalization edge-case tests — uppercase V, mixed badge sets, exact warning text, verbatim badge-version storage, all CertType values through Jackson, CertificateInfoV2 DV-CLIENT and unknown-type cases, and a check that the warning's badge count excludes badges whose URL failed validation; also removes one dead Mockito stub.

Behaviour change (status only, one input class)

A certificate presenting version vX against a host whose zone publishes badge records only for other versions previously returned FINGERPRINT_MISMATCH with a null expected fingerprint. It now returns NOT_ANS_AGENT with a warning. This follows ANS-6 §6.2 (select the record whose version equals the SAN version) and §8.1 (no badge matches → Reject), and matches the Go SDK (FindBadgeForVersion → NotAnsAgent without a log fetch) and the Rust SDK's pending fix in ans-sdk-rust#140 (supersedes #127; open at the time of writing). It is a bug fix, not a breaking change; no ! marker. isNotAnsAgent() returning true for this case is intentional: callers should treat it as they treat an unregistered host.

One consequence worth stating explicitly: servers that admit non-ANS clients anonymously on isNotAnsAgent() while also accepting RA-chain client certificates will now treat a certificate for a de-published version as a non-ANS client rather than rejecting it. Such deployments should additionally check for an ans:// URI SAN before admitting a client anonymously. Deployments that reject every non-success result see no change.

Decisions worth calling out

  • Lenient v prefix. ANS-2 §2 and ANS-6 §4.2 mandate the v on both the SAN and the TXT version= value. The SDK accepts both v1.0.0 and 1.0.0 on the badge side (the existing tests use the bare form). This is SDK leniency relative to the spec, applied at the comparison site; RaBadgeRecord.parse still stores the value verbatim. For comparison, the Go SDK strips only a lowercase v and compares versions numerically; this implementation compares normalized strings and is never more permissive than Go.
  • Versionless fallback kept for Go SDK parity and backward compatibility with existing callers; the spec is silent on versionless records.
  • Non-semver version= values (e.g. version=latest) are treated as a non-matching version here and therefore rejected; the Go SDK treats an unparseable version as versionless and falls back to it. The spec does not define the case; this implementation is the stricter of the two.
  • Case handling asymmetry (pre-existing, documented, not changed): the version filter is case-insensitive, but findMatchingClientRegistration compares the ANS name case-sensitively while comparing the CN case-insensitively. An uppercase-V SAN therefore passes the version filter and is then rejected as ANS_NAME_MISMATCH. The spec's examples and RA issuance use lowercase ans://v… SANs and ANS-6 §6.2 requires equality, so this is not reachable with certificates as issued; a test documents the behaviour. Note that ANS-6 §7.5 steps 2-3 (Method B, application-layer proof of possession) compare the ANS name case-insensitively (the host at step 2, the full name at step 3), while §6.2 and §6.3 (Method A) say only "Equal"; whether the badge tier should adopt the same rule is a candidate for a separate issue.

Follow-ups (not in this PR)

  • TransparencyService.parseAndSetPayload swallows typed-payload conversion failures at WARN and returns a shell record that downstream reports as FINGERPRINT_MISMATCH. Surfacing that as a distinct outcome (e.g. LOOKUP_FAILED with a parse reason) would make this failure class diagnosable; it is a broader behaviour change and belongs in its own issue.
  • A client certificate with no ans:// URI SAN still checks all badges and can verify on fingerprint and CN alone (unchanged here). ANS-6 §6.1 step 2 says a certificate without a URI SAN is not an Identity Certificate, and the Go SDK rejects it. Tracked in verifyClient accepts a client certificate with no ans:// URI SAN and can verify it on fingerprint + CN alone #125.
  • The badge tier compares the ANS name case-sensitively, while ANS-6 §7.5 steps 2-3 (Method B) compare it case-insensitively (see above).

Testing

./gradlew build and ./gradlew jacocoTestCoverageVerification pass (Java 17). New tests cover: DV-CLIENT deserialization through the real V1 wire shape (WireMock), unknown cert type → null type with the rest of the payload intact, version filter with and without the v prefix (both cases), exact-over-versionless preference, versionless fallback, no-match → NOT_ANS_AGENT with the transparency client never invoked, the legacy no-version-in-SAN path, and the exact warning text.

AI assistance

Assisted-by: Claude Code (claude-fable-5-1)
Implementation, review and adversarial testing were done with Claude Code; a human reviewed every commit, signs off the DCO, and is responsible for the contribution.

jcrisp-godaddy and others added 3 commits October 31, 2026 18:38
The X509-DV-CLIENT certificate type is in use in production transparency
logs but was absent from the CertType enum. Jackson would throw
InvalidFormatException when it encountered this value, causing
parseAndSetPayload to swallow the exception and return null for the
entire parsed V1 payload. This in turn made getIdentityCertFingerprint()
return null, so verifyClient reported FINGERPRINT_MISMATCH with an
expected fingerprint of null for any DV-client-certified agent.

Added X509_DV_CLIENT("X509-DV-CLIENT") as the first DV constant.
Annotated the existing fromString(String) with @jsoncreator(mode =
DELEGATING) so Jackson uses it for deserialization: any unrecognised
cert type deserializes to null rather than throwing, and a case variant
of a known type resolves to that type (ANS-6 §4.4 says consumers SHOULD
compare the type case-insensitively). This is scoped to CertType only
so other enums keep failing loudly on unknown values. Callers must
treat a null type as "unrecognized".

Refs agentnameservice#116

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com>
The ANS SAN pattern (ANS_VERSION_PATTERN) strips the leading v so
certVersion is e.g. "1.0.0", while production DNS zones publish badge
records with version=v1.0.0 as the specs require; the previous
exact-equals filter therefore never matched the v-prefixed records that
production zones publish and always fell back to checking all badges
regardless of the version in the certificate.

Now: an optional leading v or V is stripped from both sides before
comparison (normalizeVersion helper, null-safe). filterBadgesByVersion
uses two-pass semantics mirroring the Go SDK: pass 1 returns exact-version
matches if any exist; pass 2 returns versionless (version= absent) records
as a fallback; otherwise returns empty. When certVersion is non-null and
the filtered list is empty the verifyClient path returns NOT_ANS_AGENT with
a human-readable warning instead of fetching unrelated registrations from
the transparency log. Certificates with no version in the ANS name still
check all valid badges (certVersion == null path unchanged).

Behaviour change for one input class: a certificate presenting version
v1.0.0 against a host that publishes only records for other versions
previously fell through the fallback, fetched those records, and returned
FINGERPRINT_MISMATCH with a null expected fingerprint. It now returns
NOT_ANS_AGENT with a warning. This is a bug fix per ANS-6 §6.2/§8.1 and
aligns with the Go SDK and the pending Rust SDK fix, not a breaking
change. The NOT_ANS_AGENT and ClientVerificationResult.isNotAnsAgent()
javadoc are widened to cover the no-matching-version case.

Refs agentnameservice#116

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com>
- Uppercase V in badge version (V1.0.9) normalizes correctly against lowercase
  SAN via normalizeVersion, yielding VERIFIED (shouldMatchUppercaseVBadgeVersion)
- Documents that an uppercase-V SAN yields ANS_NAME_MISMATCH because
  findMatchingClientRegistration compares the ansName case-sensitively while
  the version filter is case-insensitive; the badge is correctly selected but
  the stored TL ansName is lowercase as issued. Pre-existing behaviour, not
  changed here (shouldReportAnsNameMismatchForUppercaseSan)
- Mixed 3-badge set [other-version, versionless, exact]: pass 1 selects only
  exact, other and versionless TL never fetched (shouldSelectExactFromThreeBadgeMixedSet)
- Mixed 2-badge set [other-version, versionless]: pass 2 selects versionless as
  fallback, other-version TL never fetched (shouldFallbackToVersionlessWhenOtherVersionAndVersionlessExist)
- Two other-version badges [1.0.1, 2.0.0] with cert v1.0.0: both passes empty,
  TL never called, result is NOT_ANS_AGENT (shouldReturnNotAnsAgentWhenOnlyOtherVersionBadgesExist)
- Warning message exact text: "No badge record published for version 1.0.0; 2
  badge record(s) found for other versions" (shouldIncludeExactWarningMessageForVersionMismatch)
- Warning message counts only badges whose URL passed validation: a third badge
  with an untrusted URL is excluded before the version filter, so the count
  stays 2 (shouldCountOnlyUrlValidBadgesInVersionMismatchWarning)
- RaBadgeRecordTest: parameterized test asserting 1.0.0, v1.0.0 and V1.0.0 are
  each stored verbatim, documenting the "normalize at compare site" contract
  (shouldStoreVersionVerbatim)
- ModelClassesTest: @ParameterizedTest @EnumSource over all 6 CertType values
  round-tripping through Jackson CertificateInfo; CertificateInfoV2 round-trip
  for X509_DV_CLIENT; CertificateInfoV2 unknown type deserializes to null;
  a lowercase type string on the wire resolves to the constant
  (lowercaseCertTypeDeserializesToConstant)

Also removes an orphaned fingerprintMatches stub from
shouldRejectWhenNoMatchingVersionInDnsVPrefixedBadge; the code returns
NOT_ANS_AGENT before any fingerprint comparison.

Refs agentnameservice#116

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CertType has no X509-DV-CLIENT, so client verification fails on every GoDaddy production TL payload

1 participant