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:
*
* - {@code v=ra-badge1} - the badge format version
- * - {@code version=1.0.0} - the agent's semantic version (optional for backwards compatibility)
+ * - {@code version=v1.0.0} - the agent's semantic version in canonical form (optional for
+ * backwards compatibility); a bare form without the leading {@code v} (e.g., {@code 1.0.0})
+ * is also accepted
* - {@code url=...} - the transparency log URL for this agent
*
*
* @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/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/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/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/dns/RaBadgeRecordTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java
index b4659d1..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;
@@ -214,17 +216,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
@@ -251,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 739c91f..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;
@@ -463,6 +465,84 @@ 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("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 8a1de42..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
@@ -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,18 @@ 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_ANS_NAME_V9_UPPER = "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;
@@ -108,6 +122,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
@@ -152,10 +199,10 @@ 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))
@@ -164,24 +211,57 @@ 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 (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));
- // 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 - 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))
+ .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);
+ // 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(
+ "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));
+
+ // 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());
}
}
@@ -302,6 +382,406 @@ 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);
+ }
+ }
+
+ // ==================== 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) {
@@ -320,7 +800,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);
@@ -342,6 +822,52 @@ 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);
+ 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);