Repository navigation
fix(transparency): add X509-DV-CLIENT cert type and enforce version-matched badge selection - #126
Open
jcrisp-godaddy wants to merge 3 commits into
Open
jcrisp-godaddy wants to merge 3 commits into
jcrisp-godaddy wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #116
Summary
Two defects in
BadgeVerificationService.verifyClientcaused every client verification against a production transparency log to fail withFINGERPRINT_MISMATCHandexpected=null:CertTypehad noX509-DV-CLIENT. Production registrations carryidentityCert.type: "X509-DV-CLIENT". The constant was missing, so Jackson threw on the typed payload conversion. That exception is caught inTransparencyService.parseAndSetPayloadand 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 producedLOOKUP_FAILED.)The badge version filter never matched production records and fell back to all badges.
ANS_VERSION_PATTERNextracts the certificate version without thev(1.0.9), while production zones publishversion=v1.0.9per 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 existingCertType.fromStringwith@JsonCreator(mode = DELEGATING), so an unrecognised cert type deserializes tonullinstead 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 toCertType: a globalREAD_UNKNOWN_ENUM_VALUES_AS_NULLwas rejected because the sharedObjectMapperalso bindsEventTypeV0/V1andRevocationReason, where silent nulls would mask protocol drift.BadgeVerificationServicenever readstype, so a null type cannot cause a falseVERIFIED.fix(transparency): enforce version-matched badge selection per ANS-6—filterBadgesByVersionnow normalizes an optional leadingv/Von 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,verifyClientreturnsNOT_ANS_AGENTwith 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()andRaBadgeRecordjavadoc updated; two misleading comments inRaBadgeRecordTestcorrected.test(transparency): add version normalization edge-case tests— uppercaseV, mixed badge sets, exact warning text, verbatim badge-version storage, allCertTypevalues through Jackson,CertificateInfoV2DV-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
vXagainst a host whose zone publishes badge records only for other versions previously returnedFINGERPRINT_MISMATCHwith a null expected fingerprint. It now returnsNOT_ANS_AGENTwith a warning. This follows ANS-6 §6.2 (select the record whoseversionequals the SAN version) and §8.1 (no badge matches → Reject), and matches the Go SDK (FindBadgeForVersion→NotAnsAgentwithout 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()returningtruefor 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 anans://URI SAN before admitting a client anonymously. Deployments that reject every non-success result see no change.Decisions worth calling out
vprefix. ANS-2 §2 and ANS-6 §4.2 mandate thevon both the SAN and the TXTversion=value. The SDK accepts bothv1.0.0and1.0.0on the badge side (the existing tests use the bare form). This is SDK leniency relative to the spec, applied at the comparison site;RaBadgeRecord.parsestill stores the value verbatim. For comparison, the Go SDK strips only a lowercasevand compares versions numerically; this implementation compares normalized strings and is never more permissive than Go.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.findMatchingClientRegistrationcompares the ANS name case-sensitively while comparing the CN case-insensitively. An uppercase-VSAN therefore passes the version filter and is then rejected asANS_NAME_MISMATCH. The spec's examples and RA issuance use lowercaseans://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.parseAndSetPayloadswallows typed-payload conversion failures at WARN and returns a shell record that downstream reports asFINGERPRINT_MISMATCH. Surfacing that as a distinct outcome (e.g.LOOKUP_FAILEDwith a parse reason) would make this failure class diagnosable; it is a broader behaviour change and belongs in its own issue.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.Testing
./gradlew buildand./gradlew jacocoTestCoverageVerificationpass (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 thevprefix (both cases), exact-over-versionless preference, versionless fallback, no-match →NOT_ANS_AGENTwith 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.