From cec129d4075e3026543878de494c63951cd726aa Mon Sep 17 00:00:00 2001 From: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:05:06 -0500 Subject: [PATCH 1/3] fix(transparency): add X509_DV_CLIENT cert type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #116 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> --- .../ans/sdk/transparency/model/CertType.java | 12 +- .../transparency/TransparencyClientTest.java | 134 ++++++++++++++++++ .../transparency/model/ModelClassesTest.java | 31 ++++ .../VersionChangeHandlingTest.java | 71 ++++++++++ 4 files changed, 246 insertions(+), 2 deletions(-) diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java index 9311c98..4d4cb03 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java @@ -1,11 +1,13 @@ package com.godaddy.ans.sdk.transparency.model; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; /** * Certificate types used in attestations. */ public enum CertType { + X509_DV_CLIENT("X509-DV-CLIENT"), X509_DV_SERVER("X509-DV-SERVER"), X509_EV_CLIENT("X509-EV-CLIENT"), X509_EV_SERVER("X509-EV-SERVER"), @@ -24,11 +26,17 @@ public String getValue() { } /** - * Parses a certificate type from string. + * Parses a certificate type from its string value, case-insensitively. + * + *

This is also the Jackson deserialization entry point. Unknown certificate type strings + * are tolerated and return {@code null} so that a new certificate type introduced upstream + * does not cause deserialization to fail and lose the entire parsed payload. Callers must + * treat a {@code null} type as "unrecognized". * * @param value the string value - * @return the certificate type, or null if unknown + * @return the matching {@code CertType}, or {@code null} for any unrecognized value */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static CertType fromString(String value) { if (value == null) { return null; diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java index 83551db..f32ff3e 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java @@ -9,6 +9,7 @@ import com.godaddy.ans.sdk.transparency.model.AgentAuditParams; import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse; import com.godaddy.ans.sdk.transparency.model.CheckpointResponse; +import com.godaddy.ans.sdk.transparency.model.CertType; import com.godaddy.ans.sdk.transparency.model.EventTypeV1; import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse; import com.godaddy.ans.sdk.transparency.model.LinkedAgentView; @@ -93,6 +94,55 @@ void shouldRetrieveAgentTransparencyLogV1(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(result.getAgentHost()).isEqualTo("agent.example.com"); } + @Test + @DisplayName("Should parse V1 log with X509-DV-CLIENT identity cert type") + void shouldParseV1LogWithDvClientCertType(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("X-Schema-Version", "V1") + .withBody(v1TransparencyLogResponseWithDvClientCert()))); + + TransparencyClient client = TransparencyClient.builder() + .baseUrl(baseUrl) + .build(); + + TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID); + + assertThat(result.getV1Payload()).isNotNull(); + assertThat(result.getIdentityCertFingerprint()).isEqualTo("SHA256:d1c2b3a4"); + TransparencyLogV1 v1 = result.getV1Payload(); + assertThat(v1.getProducer().getEvent().getAttestations().getIdentityCert().getType()) + .isEqualTo(CertType.X509_DV_CLIENT); + } + + @Test + @DisplayName("Unknown cert type in V1 log: payload non-null, fingerprint resolvable, type null") + void shouldParseV1LogWithUnknownCertType(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("X-Schema-Version", "V1") + .withBody(v1TransparencyLogResponseWithUnknownCertType()))); + + TransparencyClient client = TransparencyClient.builder() + .baseUrl(baseUrl) + .build(); + + TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID); + + assertThat(result.getV1Payload()).isNotNull(); + assertThat(result.getIdentityCertFingerprint()).isEqualTo("SHA256:e5f6g7h8"); + TransparencyLogV1 v1 = result.getV1Payload(); + assertThat(v1.getProducer().getEvent().getAttestations().getIdentityCert().getType()).isNull(); + } + @Test @DisplayName("Agent badge exposes the computed identities[] join with overflow total") void getAgentBadgeExposesLinkedIdentities(WireMockRuntimeInfo wmRuntimeInfo) { @@ -1486,6 +1536,90 @@ private String agentIdentitiesResponse() { // ==================== Test Data ==================== + private String v1TransparencyLogResponseWithDvClientCert() { + return """ + { + "status": "ACTIVE", + "schemaVersion": "V1", + "payload": { + "logId": "log-456", + "producer": { + "event": { + "ansId": "7cf3c8b0-2494-5f44-b056-956f45bf8637", + "ansName": "ans://v1.0.0.dv-agent.example.com", + "eventType": "AGENT_REGISTERED", + "agent": { + "host": "dv-agent.example.com", + "name": "DV Agent", + "version": "v1.0.0" + }, + "attestations": { + "domainValidation": "ACME-DNS-01", + "serverCert": { + "fingerprint": "SHA256:a1b2c3d4", + "type": "X509-DV-SERVER" + }, + "identityCert": { + "fingerprint": "SHA256:d1c2b3a4", + "type": "X509-DV-CLIENT" + } + }, + "issuedAt": "2025-09-24T21:03:47.055Z", + "expiresAt": "2026-09-24T21:03:47.055Z", + "raId": "ra.example.com", + "timestamp": "2025-09-24T21:03:47.055Z" + }, + "keyId": "key-2", + "signature": "sig456" + } + }, + "signature": "eyJhbGci..." + } + """; + } + + private String v1TransparencyLogResponseWithUnknownCertType() { + return """ + { + "status": "ACTIVE", + "schemaVersion": "V1", + "payload": { + "logId": "log-789", + "producer": { + "event": { + "ansId": "6bf2b7a9-1383-4e33-a945-845f34af7526", + "ansName": "ans://v1.0.0.agent.example.com", + "eventType": "AGENT_REGISTERED", + "agent": { + "host": "agent.example.com", + "name": "Example Agent", + "version": "v1.0.0" + }, + "attestations": { + "domainValidation": "ACME-DNS-01", + "serverCert": { + "fingerprint": "SHA256:a1b2c3d4", + "type": "X509-DV-SERVER" + }, + "identityCert": { + "fingerprint": "SHA256:e5f6g7h8", + "type": "X509-FUTURE-TYPE" + } + }, + "issuedAt": "2025-09-24T21:03:47.055Z", + "expiresAt": "2026-09-24T21:03:47.055Z", + "raId": "ra.example.com", + "timestamp": "2025-09-24T21:03:47.055Z" + }, + "keyId": "key-1", + "signature": "sig123" + } + }, + "signature": "eyJhbGci..." + } + """; + } + private String v1TransparencyLogResponse() { return """ { diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java index 739c91f..d56570b 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java @@ -463,6 +463,37 @@ void certTypeFromStringWorks() { assertThat(CertType.fromString("UNKNOWN")).isNull(); } + @Test + @DisplayName("CertType X509_DV_CLIENT has correct wire value and fromString works case-insensitively") + void certTypeDvClientValueAndFromString() { + assertThat(CertType.X509_DV_CLIENT.getValue()).isEqualTo("X509-DV-CLIENT"); + assertThat(CertType.fromString("X509-DV-CLIENT")).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(CertType.fromString("x509-dv-client")).isEqualTo(CertType.X509_DV_CLIENT); + } + + @Test + @DisplayName("CertType X509_DV_CLIENT round-trips through Jackson CertificateInfo") + void certTypeDvClientJacksonRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"fingerprint\":\"fp-dv\",\"type\":\"X509-DV-CLIENT\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(info.getFingerprint()).isEqualTo("fp-dv"); + String json = mapper.writeValueAsString(info); + assertThat(json).contains("X509-DV-CLIENT"); + } + + @Test + @DisplayName("Unknown CertType in JSON deserializes to null without throwing") + void unknownCertTypeDeserializesToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"X509-FUTURE-TYPE\"}", + CertificateInfo.class); + assertThat(info.getType()).isNull(); + } + @Test @DisplayName("EventTypeV0 enum values exist") void eventTypeV0EnumValuesExist() { diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java index 8a1de42..bb24631 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java @@ -108,6 +108,39 @@ void shouldSelectCorrectBadgeWithTwoActiveVersions() { } } + // ==================== DV-CLIENT cert type ==================== + + @Test + @DisplayName("Should verify client with X509-DV-CLIENT identity cert type") + void shouldVerifyClientWithDvClientCertType() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when( + () -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord badge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + + TEST_AGENT_ID_V1); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badge)); + + TransparencyLog registration = createMockRegistrationWithDvClientCert( + "ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(registration); + + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + } + } + // ==================== Old version DEPRECATED ==================== @Test @@ -342,6 +375,44 @@ private TransparencyLog createMockRegistration(String status, String fingerprint return log; } + private TransparencyLog createMockRegistrationWithDvClientCert(String status, String fingerprint, String ansName) { + CertificateInfo serverCert = new CertificateInfo(); + serverCert.setFingerprint(fingerprint); + serverCert.setType(CertType.X509_DV_SERVER); + + CertificateInfo identityCert = new CertificateInfo(); + identityCert.setFingerprint(fingerprint); + identityCert.setType(CertType.X509_DV_CLIENT); + + AttestationsV1 attestations = new AttestationsV1(); + attestations.setServerCert(serverCert); + attestations.setIdentityCert(identityCert); + + AgentV1 agent = new AgentV1(); + agent.setHost(TEST_HOSTNAME); + agent.setName("Test Agent"); + agent.setVersion("v1.0.0"); + + EventV1 event = new EventV1(); + event.setAnsName(ansName); + event.setAgent(agent); + event.setAttestations(attestations); + + ProducerV1 producer = new ProducerV1(); + producer.setEvent(event); + + TransparencyLogV1 v1Payload = new TransparencyLogV1(); + v1Payload.setLogId("log-dv-client"); + v1Payload.setProducer(producer); + + TransparencyLog log = new TransparencyLog(); + log.setStatus(status); + log.setSchemaVersion("V1"); + log.setParsedPayload(v1Payload); + + return log; + } + private TransparencyLog createMockRegistrationNoAnsName(String status, String fingerprint) { CertificateInfo serverCert = new CertificateInfo(); serverCert.setFingerprint(fingerprint); From 2f693be55ee58df4b06015f1ff58ab4462890547 Mon Sep 17 00:00:00 2001 From: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:29:07 -0500 Subject: [PATCH 2/3] fix(transparency): enforce version-matched badge selection per ANS-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #116 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> --- .../sdk/transparency/dns/RaBadgeRecord.java | 8 +- .../BadgeVerificationService.java | 65 +++++- .../ClientVerificationResult.java | 3 +- .../verification/VerificationStatus.java | 5 +- .../transparency/dns/RaBadgeRecordTest.java | 9 +- .../VersionChangeHandlingTest.java | 220 +++++++++++++++++- 6 files changed, 279 insertions(+), 31 deletions(-) diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java index 00720d7..2b7a87a 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java @@ -8,18 +8,20 @@ * *

The _ra-badge TXT record format is:

*
- * v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/{uuid}
+ * v=ra-badge1; version=v1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/{uuid}
  * 
* *

Where:

* * * @param badgeVersion the badge format version (e.g., "ra-badge1") - * @param agentVersion the agent's semantic version (e.g., "1.0.0"), may be null + * @param agentVersion the agent's semantic version (e.g., "v1.0.0"), stored verbatim, may be null * @param url the full transparency log URL * @param agentId the extracted agent ID from the URL */ diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java index 19d02a1..d5dc475 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java @@ -231,12 +231,21 @@ public ClientVerificationResult verifyClient(X509Certificate clientCert) { .build(); } - // Step 8: Filter badges by version to reduce TL API calls during version rotation + // Step 8: Select badges for the certificate's version (ANS-6 §6.2); also avoids unnecessary TL calls List filteredBadges = filterBadgesByVersion(validBadges, certVersion); - if (filteredBadges.isEmpty()) { - // Fall back to all valid badges if no version match (backwards compatibility) - LOG.debug("No badges match version {}, checking all {} valid badges", certVersion, validBadges.size()); + if (certVersion == null) { + // No version in cert — check all valid badges (legacy / no-URI-SAN path) + LOG.debug("No version in cert, checking all {} valid badges", validBadges.size()); filteredBadges = validBadges; + } else if (filteredBadges.isEmpty()) { + // Version present in cert but no badge matches — per ANS-6 §6.2/§8.1, reject + LOG.debug("No badge record for version {}, found {} badge(s) for other versions", + certVersion, validBadges.size()); + return ClientVerificationResult.builder() + .status(VerificationStatus.NOT_ANS_AGENT) + .warningMessage("No badge record published for version " + certVersion + + "; " + validBadges.size() + " badge record(s) found for other versions") + .build(); } else { LOG.debug("Filtered {} valid badges to {} matching version {}", validBadges.size(), filteredBadges.size(), certVersion); @@ -644,24 +653,58 @@ private List filterValidBadgeUrls(List badges) { } /** - * Filters badges to only those matching the specified version. + * Filters badges to only those matching the specified version, using two-pass semantics + * that mirror the Go SDK (ANS-6 §6.2). * - *

This optimization reduces transparency log API calls during version rotation - * by only fetching registrations for badges that match the certificate version.

+ *

Pass 1: badges whose {@code agentVersion}, after stripping one optional leading + * {@code v} or {@code V}, equals the normalized {@code version}. If any match, they + * are returned immediately.

* - * @param badges the list of badges to filter - * @param version the version to match (may be null) - * @return filtered list, or empty if no matches (caller should fall back to all badges) + *

Pass 2: badges that carry no {@code version=} field ({@code agentVersion == null}). + * These are legacy/versionless records accepted as a fallback for backwards + * compatibility.

+ * + *

When {@code version} or {@code badges} is {@code null} an empty list is returned; + * the caller decides whether to proceed with all badges or reject.

+ * + * @param badges the list of badges to filter + * @param version the version to match (may be null; null causes empty return) + * @return exact-version matches if any, otherwise versionless records, otherwise empty */ private List filterBadgesByVersion(List badges, String version) { if (version == null || badges == null) { return List.of(); } + String normalizedVersion = normalizeVersion(version); + // Pass 1: exact version match (lenient v/V prefix on both sides) + List exactMatches = badges.stream() + .filter(badge -> normalizedVersion.equals(normalizeVersion(badge.agentVersion()))) + .collect(Collectors.toList()); + if (!exactMatches.isEmpty()) { + return exactMatches; + } + // Pass 2: versionless records as fallback return badges.stream() - .filter(badge -> version.equals(badge.agentVersion())) + .filter(badge -> badge.agentVersion() == null) .collect(Collectors.toList()); } + /** + * Normalizes a version string by stripping exactly one optional leading {@code v} or {@code V}. + * + * @param version the version string to normalize (may be null) + * @return the normalized version, or {@code null} if the input is {@code null} + */ + private static String normalizeVersion(String version) { + if (version == null) { + return null; + } + if (version.length() > 1 && (version.charAt(0) == 'v' || version.charAt(0) == 'V')) { + return version.substring(1); + } + return version; + } + /** * Creates a new builder. * diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java index 8cad350..40964b1 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java @@ -105,7 +105,8 @@ public boolean isSuccess() { } /** - * Returns true if the client is not an ANS agent (no ra-badge record). + * Returns true if the client is not an ANS agent: no ra-badge record exists for the host, + * or none matches the version in the certificate's ANS name. * * @return true if not an ANS agent */ diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java index 074a1a7..70e6c4b 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java @@ -17,8 +17,9 @@ public enum VerificationStatus { DEPRECATED_OK, /** - * The host does not have an ra-badge DNS record, indicating - * it is not an ANS-registered agent. + * The host does not have an ra-badge DNS record, indicating it is not an ANS-registered agent. + * Also returned when the host publishes badge records but none matches the version presented + * in the certificate's ANS name (and no versionless record exists). */ NOT_ANS_AGENT, diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java index b4659d1..03d6f72 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java @@ -214,17 +214,16 @@ void shouldParseLegacyRaBadgeWithoutVersionField() { @Test @DisplayName("Should accept bare semver without v prefix") void shouldAcceptBareSemverWithoutVPrefix() { - // Legacy records may have bare semver (1.0.0) instead of v-prefixed (v1.0.0) - // Note: The RaBadgeRecord stores the version as-is. Normalization to v-prefixed - // format is expected to happen at the comparison level (BadgeVerificationService). + // Records store the version field verbatim — "1.0.0" is stored as "1.0.0". + // BadgeVerificationService.filterBadgesByVersion tolerates an optional leading v/V + // on both the badge record and the certificate version when comparing. String txtValue = "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/bare-semver-agent"; RaBadgeRecord record = RaBadgeRecord.parse(txtValue); assertThat(record).isNotNull(); assertThat(record.agentVersion()).isEqualTo("1.0.0"); // Stored as-is - // The comparison logic in BadgeVerificationService.extractVersionFromAnsName() - // handles the v prefix when matching versions + // "v1.0.0" and "1.0.0" are treated as equivalent during version comparison } @Test diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java index bb24631..7ad9446 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java @@ -24,6 +24,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mockStatic; @@ -44,6 +46,17 @@ class VersionChangeHandlingTest { private static final String TEST_FINGERPRINT_V2 = "SHA256:b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; + // v1.0.9 constants for cross-form matching tests + private static final String TEST_AGENT_ID_V9 = "9ef5e0d2-4616-7a66-d278-178a67cf0859"; + private static final String TEST_ANS_NAME_V9 = "ans://v1.0.9.agent.example.com"; + private static final String TEST_FINGERPRINT_V9 = + "SHA256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; + + // versionless constants for two-pass fallback tests + private static final String TEST_AGENT_ID_VERSIONLESS = "0af6f1e3-5727-8b77-e389-289b78df1970"; + private static final String TEST_FINGERPRINT_VERSIONLESS = + "SHA256:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"; + @Mock private TransparencyClient transparencyClient; @@ -185,10 +198,43 @@ void shouldPassWithWarningWhenOldVersionDeprecated() { // ==================== No matching version in DNS ==================== @Test - @DisplayName("Should reject when no matching version in DNS") + @DisplayName("Should reject when no matching version in DNS (bare semver badge)") void shouldRejectWhenNoMatchingVersionInDns() { try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { - // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 + // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 (bare form) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + // Only v1.0.1 badge exists in DNS (bare semver form) + RaBadgeRecord badgeV2 = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV2)); + + // Stub TL: previously reachable via the all-badges fallback and returned FINGERPRINT_MISMATCH + TransparencyLog registrationV2 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V2, TEST_ANS_NAME_V2); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V2)).thenReturn(registrationV2); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - no badge published for v1.0.0 → NOT_ANS_AGENT, TL never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).contains("1.0.0"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + + @Test + @DisplayName("Should reject when no matching version in DNS (v-prefixed badge)") + void shouldRejectWhenNoMatchingVersionInDnsVPrefixedBadge() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 (production v-prefixed form) certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) .thenReturn(Optional.of(TEST_HOSTNAME)); certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) @@ -197,24 +243,25 @@ void shouldRejectWhenNoMatchingVersionInDns() { .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) .thenReturn(TEST_FINGERPRINT_V1); - // Fingerprint doesn't match v1.0.1's fingerprint certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V2)) .thenReturn(false); - // Only v1.0.1 badge exists in DNS + // Only v1.0.1 badge exists in DNS (production v-prefixed form: version=v1.0.1) RaBadgeRecord badgeV2 = RaBadgeRecord.parse( - "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV2)); - // v1.0.1 registration doesn't match the cert + // Stub TL: previously reachable via the all-badges fallback and returned FINGERPRINT_MISMATCH TransparencyLog registrationV2 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V2, TEST_ANS_NAME_V2); when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V2)).thenReturn(registrationV2); // When ClientVerificationResult result = verificationService.verifyClient(mockCertificate); - // Then - should fail with fingerprint mismatch (no matching badge) - assertThat(result.getStatus()).isEqualTo(VerificationStatus.FINGERPRINT_MISMATCH); + // Then - no badge published for v1.0.0 → NOT_ANS_AGENT, TL never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).contains("1.0.0"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); } } @@ -335,6 +382,153 @@ void shouldApplyFailurePolicyWhenAllBadgeFetchesFail() { } } + // ==================== Cross-form version matching ==================== + + @Test + @DisplayName("Should match v-prefixed badge with versioned SAN (ANS-6 §6.2 leniency)") + void shouldMatchVPrefixedBadgeWithVersionedSan() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert SAN ans://v1.0.9.agent.example.com → certVersion "1.0.9" + // badge version=v1.0.9 (production form) → agentVersion "v1.0.9" + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses production v-prefixed form: version=v1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - v-prefixed badge selected; verification reaches fingerprint check → VERIFIED + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + assertThat(result.getExpectedAnsName()).isEqualTo(TEST_ANS_NAME_V9); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + @Test + @DisplayName("Should match bare-semver badge with versioned SAN (regression guard)") + void shouldMatchBareSemverBadgeWithVersionedSan() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert SAN ans://v1.0.9.agent.example.com → certVersion "1.0.9" + // badge version=1.0.9 (bare form) → agentVersion "1.0.9" + // (regression guard: this form matched before this change) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses bare semver form: version=1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - bare-semver badge selected and verified + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + assertThat(result.getExpectedAnsName()).isEqualTo(TEST_ANS_NAME_V9); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + // ==================== Two-pass badge selection ==================== + + @Test + @DisplayName("Should prefer exact-version badge over versionless badge (pass 1 wins)") + void shouldPreferExactVersionBadgeOverVersionlessBadge() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert v1.0.0; two badges: exact-version + versionless + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord exactBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V1); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(exactBadge, versionlessBadge)); + + TransparencyLog exactRegistration = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(exactRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 selected exact-version badge only; versionless badge's TL never fetched + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V1); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + + @Test + @DisplayName("Should fall back to versionless badge when only versionless badge exists (pass 2)") + void shouldFallbackToVersionlessBadgeWhenOnlyVersionlessBadgeExists() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert v1.0.0; only a versionless badge exists → pass 2 fallback + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_VERSIONLESS); + String fp = TEST_FINGERPRINT_VERSIONLESS; + certUtils.when(() -> CertificateUtils.fingerprintMatches(fp, fp)) + .thenReturn(true); + + // Only a versionless badge (no version= field) + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(versionlessBadge)); + + TransparencyLog versionlessRegistration = + createMockRegistrationNoAnsName("ACTIVE", TEST_FINGERPRINT_VERSIONLESS); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS)) + .thenReturn(versionlessRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 2 used the versionless badge; verification succeeded + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + // ==================== Helper Methods ==================== private TransparencyLog createMockRegistration(String status, String fingerprint, String ansName) { @@ -353,7 +547,7 @@ private TransparencyLog createMockRegistration(String status, String fingerprint AgentV1 agent = new AgentV1(); agent.setHost(TEST_HOSTNAME); agent.setName("Test Agent"); - agent.setVersion(ansName.contains("v1.0.0") ? "v1.0.0" : "v1.0.1"); + agent.setVersion(extractAgentVersion(ansName)); EventV1 event = new EventV1(); event.setAnsName(ansName); @@ -375,6 +569,14 @@ private TransparencyLog createMockRegistration(String status, String fingerprint return log; } + private static String extractAgentVersion(String ansName) { + // Extract the version segment from an ANS name like ans://v1.0.9.agent.example.com + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("ans://v?(\\d+\\.\\d+\\.\\d+)\\.", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(ansName); + return m.find() ? "v" + m.group(1) : "v1.0.0"; + } + private TransparencyLog createMockRegistrationWithDvClientCert(String status, String fingerprint, String ansName) { CertificateInfo serverCert = new CertificateInfo(); serverCert.setFingerprint(fingerprint); From f69161bbbd15c4a4b64666f019d2208eeb2020a5 Mon Sep 17 00:00:00 2001 From: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:55:06 -0500 Subject: [PATCH 3/3] test(transparency): add version normalization edge-case tests - 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 #116 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Crisp <117124563+jcrisp-godaddy@users.noreply.github.com> --- .../transparency/dns/RaBadgeRecordTest.java | 19 ++ .../transparency/model/ModelClassesTest.java | 49 ++++ .../VersionChangeHandlingTest.java | 257 +++++++++++++++++- 3 files changed, 323 insertions(+), 2 deletions(-) diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java index 03d6f72..02d35ed 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; @@ -250,4 +252,21 @@ void shouldSupportAnsBadgeFormatVersion() { assertThat(record.badgeVersion()).isEqualTo("ans-badge1"); assertThat(record.agentVersion()).isEqualTo("1.0.0"); } + + @ParameterizedTest(name = "version={0} stored verbatim") + @ValueSource(strings = {"1.0.0", "v1.0.0", "V1.0.0"}) + @DisplayName("Should store version= field verbatim — normalization happens at compare site") + void shouldStoreVersionVerbatim(String versionValue) { + // Documents the "normalize at compare site" contract: RaBadgeRecord stores whatever appears + // after version= in the TXT record without stripping the v/V prefix. Callers that need to + // compare versions (e.g. BadgeVerificationService.filterBadgesByVersion) are responsible + // for normalizing both sides before comparing. + String txtValue = "v=ra-badge1; version=" + versionValue + + "; url=https://transparency.ans.godaddy.com/v1/agents/verbatim-test-agent"; + + RaBadgeRecord record = RaBadgeRecord.parse(txtValue); + + assertThat(record).isNotNull(); + assertThat(record.agentVersion()).isEqualTo(versionValue); + } } \ No newline at end of file diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java index d56570b..c2b4520 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java @@ -4,6 +4,8 @@ import com.godaddy.ans.sdk.model.LinkedIdentity; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.time.OffsetDateTime; import java.util.HashMap; @@ -494,6 +496,53 @@ void unknownCertTypeDeserializesToNull() throws Exception { assertThat(info.getType()).isNull(); } + @Test + @DisplayName("Lowercase CertType in JSON resolves to the constant") + void lowercaseCertTypeDeserializesToConstant() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"x509-dv-client\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + } + + @ParameterizedTest(name = "{0} round-trips through CertificateInfo") + @EnumSource(CertType.class) + @DisplayName("Each CertType value round-trips through Jackson CertificateInfo (parameterized)") + void eachCertTypeRoundTripsThroughCertificateInfo(CertType expected) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"" + expected.getValue() + "\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(expected); + String serialized = mapper.writeValueAsString(info); + assertThat(serialized).contains(expected.getValue()); + } + + @Test + @DisplayName("CertificateInfoV2 round-trips X509_DV_CLIENT through Jackson") + void certificateInfoV2DvClientRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfoV2 info = mapper.readValue( + "{\"fingerprint\":\"fp-dv-v2\",\"type\":\"X509-DV-CLIENT\"}", + CertificateInfoV2.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(info.getFingerprint()).isEqualTo("fp-dv-v2"); + String serialized = mapper.writeValueAsString(info); + assertThat(serialized).contains("X509-DV-CLIENT"); + } + + @Test + @DisplayName("CertificateInfoV2 unknown cert type deserializes to null without throwing") + void certificateInfoV2UnknownTypeDeserializesToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfoV2 info = mapper.readValue( + "{\"type\":\"X509-FUTURE-TYPE\",\"fingerprint\":\"fp-unknown\"}", + CertificateInfoV2.class); + assertThat(info.getType()).isNull(); + assertThat(info.getFingerprint()).isEqualTo("fp-unknown"); + } + @Test @DisplayName("EventTypeV0 enum values exist") void eventTypeV0EnumValuesExist() { diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java index 7ad9446..89f8a1f 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java @@ -49,6 +49,7 @@ class VersionChangeHandlingTest { // v1.0.9 constants for cross-form matching tests private static final String TEST_AGENT_ID_V9 = "9ef5e0d2-4616-7a66-d278-178a67cf0859"; private static final String TEST_ANS_NAME_V9 = "ans://v1.0.9.agent.example.com"; + private static final String TEST_ANS_NAME_V9_UPPER = "ans://V1.0.9.agent.example.com"; private static final String TEST_FINGERPRINT_V9 = "SHA256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; @@ -243,8 +244,7 @@ void shouldRejectWhenNoMatchingVersionInDnsVPrefixedBadge() { .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) .thenReturn(TEST_FINGERPRINT_V1); - certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V2)) - .thenReturn(false); + // No fingerprintMatches stub needed: code returns NOT_ANS_AGENT before any fingerprint check. // Only v1.0.1 badge exists in DNS (production v-prefixed form: version=v1.0.1) RaBadgeRecord badgeV2 = RaBadgeRecord.parse( @@ -529,6 +529,259 @@ void shouldFallbackToVersionlessBadgeWhenOnlyVersionlessBadgeExists() { } } + // ==================== Uppercase V normalization ==================== + + @Test + @DisplayName("Should normalize uppercase-V badge version against lowercase-SAN version (badge V1.0.9 + SAN v1.0.9)") + void shouldMatchUppercaseVBadgeVersion() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // badge version=V1.0.9 (uppercase V); SAN ans://v1.0.9.agent.example.com (lowercase) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); // lowercase SAN + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses uppercase-V form: version=V1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=V1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - normalizeVersion strips uppercase V on both sides; badge is selected → VERIFIED + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + @Test + @DisplayName("Uppercase-V SAN yields ANS_NAME_MISMATCH — case-sensitive ansName comparison is a known limitation") + void shouldReportAnsNameMismatchForUppercaseSan() { + // Note: ANS_VERSION_PATTERN is CASE_INSENSITIVE so "ans://V1.0.9.host" → certVersion "1.0.9" + // and filterBadgesByVersion selects the badge correctly. However, + // findMatchingClientRegistration compares certAnsName.equals(expectedAnsName) case-sensitively: + // "ans://V1.0.9.agent.example.com" ≠ "ans://v1.0.9.agent.example.com" → ANS_NAME_MISMATCH. + // Spec examples and RA issuance use lowercase ans:// SANs and ANS-6 §6.2 requires equality; + // this asymmetry is pre-existing and not changed here. + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + // SAN uses uppercase V + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9_UPPER)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses lowercase v-prefixed form (production form) + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + // TL stores the ansName as issued (lowercase) + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - badge selected (version filter is case-insensitive) but certAnsName case mismatch + // with stored ansName → ANS_NAME_MISMATCH. Assert the actual observable outcome. + assertThat(result.getStatus()).isEqualTo(VerificationStatus.ANS_NAME_MISMATCH); + } + } + + // ==================== Mixed badge sets ==================== + + @Test + @DisplayName("Should select exact-version badge from set [other-version, versionless, exact] — pass 1 preferred") + void shouldSelectExactFromThreeBadgeMixedSet() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; three badges: version=1.0.1 (other), no-version, version=1.0.0 (exact) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord otherBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + RaBadgeRecord exactBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V1); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(otherBadge, versionlessBadge, exactBadge)); + + TransparencyLog exactRegistration = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(exactRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 selected only the exact-version badge; other and versionless TLs never fetched + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V1); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_V2); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + + @Test + @DisplayName("Should fall back to versionless badge from set [other-version, versionless]") + void shouldFallbackToVersionlessWhenOtherVersionAndVersionlessExist() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two badges: version=1.0.1 (other), no-version (versionless fallback) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_VERSIONLESS); + String fp = TEST_FINGERPRINT_VERSIONLESS; + certUtils.when(() -> CertificateUtils.fingerprintMatches(fp, fp)) + .thenReturn(true); + + RaBadgeRecord otherBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(otherBadge, versionlessBadge)); + + TransparencyLog versionlessRegistration = + createMockRegistrationNoAnsName("ACTIVE", TEST_FINGERPRINT_VERSIONLESS); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS)) + .thenReturn(versionlessRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 empty (no exact match), pass 2 selects versionless badge + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_V2); + } + } + + @Test + @DisplayName("Should return NOT_ANS_AGENT when only other-version badges exist — neither pass matches") + void shouldReturnNotAnsAgentWhenOnlyOtherVersionBadgesExist() { + String otherAgentIdA = "aaaaaaaa-1111-2222-3333-444444444444"; + String otherAgentIdB = "bbbbbbbb-5555-6666-7777-888888888888"; + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two badges both at other versions — neither pass yields a match + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord otherA = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + otherAgentIdA); + RaBadgeRecord otherB = RaBadgeRecord.parse( + "v=ra-badge1; version=2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + otherAgentIdB); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(otherA, otherB)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - both passes empty → NOT_ANS_AGENT; transparency client never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + + // ==================== Warning message exact text ==================== + + @Test + @DisplayName("Warning message names the presented version and the count of other-version records exactly") + void shouldIncludeExactWarningMessageForVersionMismatch() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; badges at v1.0.1 and v2.0.0 (2 other-version valid badges) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord badge101 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord badge200 = RaBadgeRecord.parse( + "v=ra-badge1; version=v2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badge101, badge200)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - exact warning message format: version is bare (1.0.0), count is total valid badges + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).isEqualTo( + "No badge record published for version 1.0.0; 2 badge record(s) found for other versions"); + } + } + + @Test + @DisplayName("Warning message counts only badges whose URL passed validation") + void shouldCountOnlyUrlValidBadgesInVersionMismatchWarning() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two other-version badges with trusted URLs plus one with an untrusted URL + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord badge101 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord badge200 = RaBadgeRecord.parse( + "v=ra-badge1; version=v2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + RaBadgeRecord badgeUntrusted = RaBadgeRecord.parse( + "v=ra-badge1; version=v3.0.0; url=https://untrusted.example.com/v1/agents/" + + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(badge101, badge200, badgeUntrusted)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - the untrusted-URL badge is excluded before the version filter, so the count is 2 not 3 + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).isEqualTo( + "No badge record published for version 1.0.0; 2 badge record(s) found for other versions"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + // ==================== Helper Methods ==================== private TransparencyLog createMockRegistration(String status, String fingerprint, String ansName) {