results,
+ VerificationResult.VerificationType type) {
+ return results.stream()
+ .filter(r -> r.type() == type && r.isSuccess())
+ .findFirst();
}
private VerificationMode getModeForType(VerificationResult.VerificationType type, VerificationPolicy policy) {
return switch (type) {
case DANE -> policy.daneMode();
case BADGE -> policy.badgeMode();
+ case SCITT -> policy.scittMode();
case PKI_ONLY -> VerificationMode.DISABLED;
};
}
@@ -202,6 +410,7 @@ private VerificationMode getModeForType(VerificationResult.VerificationType type
public static class Builder {
private DaneVerifier daneVerifier;
private BadgeVerifier badgeVerifier;
+ private ScittVerifierAdapter scittVerifier;
private Builder() {
}
@@ -228,6 +437,17 @@ public Builder badgeVerifier(BadgeVerifier badgeVerifier) {
return this;
}
+ /**
+ * Sets the SCITT verifier.
+ *
+ * @param scittVerifier the SCITT verifier (null to disable SCITT)
+ * @return this builder
+ */
+ public Builder scittVerifier(ScittVerifierAdapter scittVerifier) {
+ this.scittVerifier = scittVerifier;
+ return this;
+ }
+
/**
* Builds the DefaultConnectionVerifier.
*
diff --git a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/PreVerificationResult.java b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/PreVerificationResult.java
index 220220c..daf5c54 100644
--- a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/PreVerificationResult.java
+++ b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/PreVerificationResult.java
@@ -1,5 +1,7 @@
package com.godaddy.ans.sdk.agent.verification;
+import com.godaddy.ans.sdk.transparency.scitt.ScittPreVerifyResult;
+
import java.time.Instant;
import java.util.List;
@@ -10,6 +12,7 @@
*
* - DANE: Look up TLSA records and extract expected certificate data
* - Badge: Query transparency log for registered certificate fingerprints
+ * - SCITT: Extract and verify receipts/status tokens from HTTP headers
*
*
* After the TLS handshake completes, the actual server certificate is compared
@@ -27,6 +30,7 @@
* @param badgeFingerprints expected fingerprints from transparency log (empty if not registered)
* @param badgePreVerifyFailed true if badge pre-verification failed (e.g., revoked/expired)
* @param badgeFailureReason the reason for badge pre-verification failure (null if not failed)
+ * @param scittPreVerifyResult the SCITT pre-verification result (null if not performed)
* @param timestamp when the pre-verification was performed
*/
public record PreVerificationResult(
@@ -38,6 +42,7 @@ public record PreVerificationResult(
List badgeFingerprints,
boolean badgePreVerifyFailed,
String badgeFailureReason,
+ ScittPreVerifyResult scittPreVerifyResult,
Instant timestamp
) {
@@ -66,7 +71,8 @@ public static Builder builder(String hostname, int port) {
* @return true if DANE expectations are available
*/
public boolean hasDaneExpectation() {
- return daneExpectations != null && !daneExpectations.isEmpty();
+ // Note: compact constructor guarantees daneExpectations is never null
+ return !daneExpectations.isEmpty();
}
/**
@@ -75,7 +81,49 @@ public boolean hasDaneExpectation() {
* @return true if badge fingerprints are available from transparency log
*/
public boolean hasBadgeExpectation() {
- return badgeFingerprints != null && !badgeFingerprints.isEmpty();
+ // Note: compact constructor guarantees badgeFingerprints is never null
+ return !badgeFingerprints.isEmpty();
+ }
+
+ /**
+ * Returns true if SCITT verification should be performed.
+ *
+ * @return true if SCITT artifacts are available
+ */
+ public boolean hasScittExpectation() {
+ return scittPreVerifyResult != null && scittPreVerifyResult.isPresent();
+ }
+
+ /**
+ * Returns true if SCITT pre-verification was successful.
+ *
+ * @return true if SCITT expectation is verified
+ */
+ public boolean scittPreVerifySucceeded() {
+ return scittPreVerifyResult != null
+ && scittPreVerifyResult.isPresent()
+ && scittPreVerifyResult.expectation().isVerified();
+ }
+
+ /**
+ * Returns a new PreVerificationResult with the SCITT result replaced.
+ *
+ * @param scittResult the new SCITT pre-verification result
+ * @return a new PreVerificationResult with the updated SCITT result
+ */
+ public PreVerificationResult withScittResult(ScittPreVerifyResult scittResult) {
+ return new PreVerificationResult(
+ this.hostname,
+ this.port,
+ this.daneExpectations,
+ this.daneDnsError,
+ this.daneDnsErrorMessage,
+ this.badgeFingerprints,
+ this.badgePreVerifyFailed,
+ this.badgeFailureReason,
+ scittResult,
+ this.timestamp
+ );
}
/**
@@ -90,26 +138,19 @@ public static class Builder {
private List badgeFingerprints = List.of();
private boolean badgePreVerifyFailed;
private String badgeFailureReason;
+ private ScittPreVerifyResult scittPreVerifyResult;
private Builder(String hostname, int port) {
this.hostname = hostname;
this.port = port;
}
- /**
- * Sets the expected DANE expectations from TLSA records.
- *
- * @param expectations the TLSA expectations
- * @return this builder
- */
- public Builder daneExpectations(List expectations) {
- this.daneExpectations = expectations != null ? expectations : List.of();
- return this;
- }
-
/**
* Sets the DANE pre-verify result, extracting expectations and DNS error status.
*
+ * This is the preferred method for setting DANE state. It atomically sets
+ * all DANE-related fields from a single result object, ensuring consistency.
+ *
* @param result the DANE pre-verify result
* @return this builder
*/
@@ -122,9 +163,35 @@ public Builder danePreVerifyResult(DaneVerifier.PreVerifyResult result) {
return this;
}
+ /**
+ * Sets the expected DANE expectations from TLSA records.
+ *
+ * Note: Prefer {@link #danePreVerifyResult(DaneVerifier.PreVerifyResult)} which
+ * sets all DANE state atomically. This method exists primarily for testing scenarios
+ * where constructing a full {@code PreVerifyResult} is inconvenient.
+ *
+ * Warning: Calling this after {@link #danePreVerifyResult} will overwrite
+ * the expectations but leave DNS error flags unchanged, potentially creating
+ * inconsistent state.
+ *
+ * @param expectations the TLSA expectations
+ * @return this builder
+ */
+ public Builder daneExpectations(List expectations) {
+ this.daneExpectations = expectations != null ? expectations : List.of();
+ return this;
+ }
+
/**
* Marks DANE pre-verification as failed due to DNS error.
*
+ * Note: Prefer {@link #danePreVerifyResult(DaneVerifier.PreVerifyResult)} which
+ * sets all DANE state atomically. This method exists primarily for testing scenarios.
+ *
+ * Warning: Calling this after {@link #danePreVerifyResult} will overwrite
+ * the DNS error state but leave expectations unchanged, potentially creating
+ * inconsistent state.
+ *
* @param errorMessage the DNS error message
* @return this builder
*/
@@ -161,6 +228,17 @@ public Builder badgePreVerifyFailed(String reason) {
return this;
}
+ /**
+ * Sets the SCITT pre-verification result.
+ *
+ * @param result the SCITT pre-verification result
+ * @return this builder
+ */
+ public Builder scittPreVerifyResult(ScittPreVerifyResult result) {
+ this.scittPreVerifyResult = result;
+ return this;
+ }
+
/**
* Builds the PreVerificationResult.
*
@@ -176,6 +254,7 @@ public PreVerificationResult build() {
badgeFingerprints,
badgePreVerifyFailed,
badgeFailureReason,
+ scittPreVerifyResult,
Instant.now()
);
}
@@ -184,7 +263,7 @@ public PreVerificationResult build() {
@Override
public String toString() {
return String.format("PreVerificationResult{hostname='%s', port=%d, " +
- "hasDane=%s, hasBadge=%s}",
- hostname, port, hasDaneExpectation(), hasBadgeExpectation());
+ "hasDane=%s, hasBadge=%s, hasScitt=%s}",
+ hostname, port, hasDaneExpectation(), hasBadgeExpectation(), hasScittExpectation());
}
}
diff --git a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/ScittVerifierAdapter.java b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/ScittVerifierAdapter.java
new file mode 100644
index 0000000..ffd585b
--- /dev/null
+++ b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/ScittVerifierAdapter.java
@@ -0,0 +1,320 @@
+package com.godaddy.ans.sdk.agent.verification;
+
+import com.godaddy.ans.sdk.concurrent.AnsExecutors;
+import com.godaddy.ans.sdk.transparency.TransparencyClient;
+import com.godaddy.ans.sdk.transparency.scitt.CwtClaims;
+import com.godaddy.ans.sdk.transparency.scitt.DefaultScittHeaderProvider;
+import com.godaddy.ans.sdk.transparency.scitt.DefaultScittVerifier;
+import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
+import com.godaddy.ans.sdk.transparency.scitt.ScittExpectation;
+import com.godaddy.ans.sdk.transparency.scitt.ScittHeaderProvider;
+import com.godaddy.ans.sdk.transparency.scitt.ScittPreVerifyResult;
+import com.godaddy.ans.sdk.transparency.scitt.ScittReceipt;
+import com.godaddy.ans.sdk.transparency.scitt.ScittVerifier;
+import com.godaddy.ans.sdk.transparency.scitt.StatusToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.PublicKey;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Executor;
+
+/**
+ * Adapter for SCITT verification in the agent client connection flow.
+ *
+ * This class bridges the SCITT verification infrastructure in ans-sdk-transparency
+ * with the connection verification flow in ans-sdk-agent-client.
+ *
+ * The TransparencyClient provides both root key fetching and domain configuration,
+ * eliminating the need to manually synchronize SCITT domain settings.
+ */
+public class ScittVerifierAdapter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ScittVerifierAdapter.class);
+
+ private final TransparencyClient transparencyClient;
+ private final ScittVerifier scittVerifier;
+ private final ScittHeaderProvider headerProvider;
+ private final Executor executor;
+
+ /**
+ * Creates a new adapter with custom components.
+ *
+ * This constructor is package-private. Use {@link #builder()} to create instances.
+ * The builder ensures proper configuration including clock skew tolerance.
+ *
+ * @param transparencyClient the transparency client for root key fetching
+ * @param scittVerifier the SCITT verifier
+ * @param headerProvider the header provider for extracting SCITT artifacts
+ * @param executor the executor for async operations
+ */
+ ScittVerifierAdapter(
+ TransparencyClient transparencyClient,
+ ScittVerifier scittVerifier,
+ ScittHeaderProvider headerProvider,
+ Executor executor) {
+ this.transparencyClient = Objects.requireNonNull(transparencyClient, "transparencyClient cannot be null");
+ this.scittVerifier = Objects.requireNonNull(scittVerifier, "scittVerifier cannot be null");
+ this.headerProvider = Objects.requireNonNull(headerProvider, "headerProvider cannot be null");
+ this.executor = Objects.requireNonNull(executor, "executor cannot be null");
+ }
+
+ /**
+ * Pre-verifies SCITT artifacts from response headers.
+ *
+ * This should be called after receiving HTTP response headers but before
+ * post-verification of the TLS certificate. The domain is automatically
+ * derived from the TransparencyClient configuration.
+ *
+ * @param responseHeaders the HTTP response headers
+ * @return future containing the pre-verification result
+ */
+ public CompletableFuture preVerify(Map responseHeaders) {
+
+ // Step 1: extract artifacts synchronously — this is cheap and has no I/O
+ Optional artifactsOpt;
+ try {
+ artifactsOpt = headerProvider.extractArtifacts(responseHeaders);
+ } catch (RuntimeException e) {
+ LOGGER.error("SCITT artifact parsing error: {}", e.getMessage());
+ return CompletableFuture.completedFuture(
+ ScittPreVerifyResult.parseError("Artifact error: " + e.getMessage()));
+ }
+
+ if (artifactsOpt.isEmpty() || !artifactsOpt.get().isComplete()) {
+ LOGGER.debug("SCITT headers not present or incomplete");
+ return CompletableFuture.completedFuture(ScittPreVerifyResult.notPresent());
+ }
+
+ ScittHeaderProvider.ScittArtifacts artifacts = artifactsOpt.get();
+ ScittReceipt receipt = artifacts.receipt();
+ StatusToken token = artifacts.statusToken();
+
+ // Step 2: fetch keys asynchronously — uses transparencyClient's configured domain
+ return transparencyClient.getRootKeysAsync()
+ .thenApplyAsync((Map rootKeys) -> {
+ try {
+ ScittExpectation expectation = scittVerifier.verify(receipt, token, rootKeys);
+
+ // Check if verification failed due to unknown key - may need cache refresh
+ if (expectation.isKeyNotFound()) {
+ return handleKeyNotFound(receipt, token, expectation);
+ }
+
+ LOGGER.debug("SCITT pre-verification result: {}", expectation.status());
+ return ScittPreVerifyResult.verified(expectation, receipt, token);
+ } catch (RuntimeException e) {
+ LOGGER.error("SCITT verification error: {}", e.getMessage(), e);
+ return ScittPreVerifyResult.parseError("Verification error: " + e.getMessage());
+ }
+ }, executor)
+ .exceptionally(e -> {
+ Throwable cause = e instanceof CompletionException && e.getCause() != null
+ ? e.getCause() : e;
+ LOGGER.error("SCITT pre-verification error: {}", cause.getMessage(), cause);
+ return ScittPreVerifyResult.parseError("Pre-verification error: " + cause.getMessage());
+ });
+ }
+
+ /**
+ * Handles a key-not-found verification failure by attempting to refresh the cache.
+ *
+ * This method implements secure cache refresh logic:
+ *
+ * - Extracts the artifact's issued-at timestamp
+ * - Only refreshes if the artifact is newer than our cache
+ * - Enforces a cooldown to prevent cache thrashing attacks
+ * - Retries verification once with refreshed keys
+ *
+ */
+ private ScittPreVerifyResult handleKeyNotFound(
+ ScittReceipt receipt,
+ StatusToken token,
+ ScittExpectation originalExpectation) {
+
+ // Get the artifact's issued-at timestamp for refresh decision
+ Instant artifactIssuedAt = getArtifactIssuedAt(receipt, token);
+ if (artifactIssuedAt == null) {
+ LOGGER.warn("Cannot determine artifact issued-at time, failing verification");
+ return ScittPreVerifyResult.verified(originalExpectation, receipt, token);
+ }
+
+ LOGGER.debug("Key not found, checking if cache refresh is needed (artifact iat={})", artifactIssuedAt);
+
+ // Attempt refresh with security checks
+ RefreshDecision decision = transparencyClient.refreshRootKeysIfNeeded(artifactIssuedAt);
+
+ switch (decision.action()) {
+ case REJECT:
+ // Artifact is invalid (too old or from future) - return original error
+ LOGGER.warn("Cache refresh rejected: {}", decision.reason());
+ return ScittPreVerifyResult.verified(originalExpectation, receipt, token);
+
+ case DEFER:
+ // Cooldown in effect - return temporary failure
+ LOGGER.info("Cache refresh deferred: {}", decision.reason());
+ return ScittPreVerifyResult.parseError("Verification deferred: " + decision.reason());
+
+ case REFRESHED:
+ // Retry verification with fresh keys
+ LOGGER.info("Cache refreshed, retrying verification");
+ Map freshKeys = decision.keys();
+ ScittExpectation retryExpectation = scittVerifier.verify(receipt, token, freshKeys);
+ LOGGER.debug("Retry verification result: {}", retryExpectation.status());
+ return ScittPreVerifyResult.verified(retryExpectation, receipt, token);
+
+ default:
+ // Should never happen
+ return ScittPreVerifyResult.verified(originalExpectation, receipt, token);
+ }
+ }
+
+ /**
+ * Extracts the issued-at timestamp from the SCITT artifacts.
+ *
+ * Prefers the status token's issued-at time since it's typically more recent.
+ * Falls back to the receipt's CWT claims if available.
+ */
+ private Instant getArtifactIssuedAt(ScittReceipt receipt, StatusToken token) {
+ // Prefer token's issued-at (typically more recent)
+ if (token.issuedAt() != null) {
+ return token.issuedAt();
+ }
+
+ // Fall back to receipt's CWT claims
+ if (receipt.protectedHeader() != null) {
+ CwtClaims claims = receipt.protectedHeader().cwtClaims();
+ if (claims != null && claims.issuedAtTime() != null) {
+ return claims.issuedAtTime();
+ }
+ }
+
+ return null;
+ }
+ /**
+ * Post-verifies the server certificate against SCITT expectations.
+ *
+ * @param hostname the hostname being connected to
+ * @param serverCert the server certificate from TLS handshake
+ * @param preResult the result from pre-verification
+ * @return the verification result
+ */
+ public VerificationResult postVerify(
+ String hostname,
+ X509Certificate serverCert,
+ ScittPreVerifyResult preResult) {
+
+ Objects.requireNonNull(hostname, "hostname cannot be null");
+ Objects.requireNonNull(serverCert, "serverCert cannot be null");
+ Objects.requireNonNull(preResult, "preResult cannot be null");
+
+ // If SCITT was not present, return NOT_FOUND
+ if (!preResult.isPresent()) {
+ return VerificationResult.notFound(
+ VerificationResult.VerificationType.SCITT,
+ "SCITT headers not present in response");
+ }
+
+ ScittExpectation expectation = preResult.expectation();
+
+ // If pre-verification failed, return error
+ if (!expectation.isVerified()) {
+ String reason = expectation.failureReason() != null
+ ? expectation.failureReason()
+ : "SCITT verification failed: " + expectation.status();
+ LOGGER.warn("SCITT pre-verification failed for {}: {}", hostname, reason);
+ return VerificationResult.error(VerificationResult.VerificationType.SCITT, reason);
+ }
+
+ // Verify certificate fingerprint
+ ScittVerifier.ScittVerificationResult result =
+ scittVerifier.postVerify(hostname, serverCert, expectation);
+
+ if (result.success()) {
+ LOGGER.debug("SCITT post-verification successful for {}", hostname);
+ return VerificationResult.success(
+ VerificationResult.VerificationType.SCITT,
+ result.actualFingerprint(),
+ "Certificate matches SCITT status token");
+ } else {
+ LOGGER.warn("SCITT post-verification failed for {}: {}", hostname, result.failureReason());
+ return VerificationResult.mismatch(
+ VerificationResult.VerificationType.SCITT,
+ result.actualFingerprint(),
+ expectation.validServerCertFingerprints().isEmpty()
+ ? "unknown"
+ : String.join(",", expectation.validServerCertFingerprints()));
+ }
+ }
+
+ /**
+ * Builder for ScittVerifierAdapter.
+ */
+ public static class Builder {
+ private TransparencyClient transparencyClient;
+ private Duration clockSkewTolerance = StatusToken.DEFAULT_CLOCK_SKEW;
+ private Executor executor = AnsExecutors.sharedIoExecutor();
+
+ /**
+ * Sets the TransparencyClient for root key fetching and domain configuration.
+ *
+ * @param transparencyClient the transparency client (required)
+ * @return this builder
+ */
+ public Builder transparencyClient(TransparencyClient transparencyClient) {
+ this.transparencyClient = transparencyClient;
+ return this;
+ }
+
+ /**
+ * Sets the clock skew tolerance for token expiry checks.
+ *
+ * @param tolerance the clock skew tolerance (default: 60 seconds)
+ * @return this builder
+ */
+ public Builder clockSkewTolerance(Duration tolerance) {
+ this.clockSkewTolerance = tolerance;
+ return this;
+ }
+
+ /**
+ * Sets the executor for async operations.
+ *
+ * @param executor the executor
+ * @return this builder
+ */
+ public Builder executor(Executor executor) {
+ this.executor = executor;
+ return this;
+ }
+
+ /**
+ * Builds the adapter.
+ *
+ * @return the configured adapter
+ * @throws NullPointerException if transparencyClient is not set
+ */
+ public ScittVerifierAdapter build() {
+ Objects.requireNonNull(transparencyClient, "transparencyClient is required");
+ ScittVerifier verifier = new DefaultScittVerifier(clockSkewTolerance);
+ ScittHeaderProvider headerProvider = new DefaultScittHeaderProvider();
+ return new ScittVerifierAdapter(transparencyClient, verifier, headerProvider, executor);
+ }
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/TlsaUtils.java b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/TlsaUtils.java
index ab743f4..9ae80c4 100644
--- a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/TlsaUtils.java
+++ b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/TlsaUtils.java
@@ -1,10 +1,9 @@
package com.godaddy.ans.sdk.agent.verification;
+import com.godaddy.ans.sdk.crypto.CryptoCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
@@ -75,11 +74,10 @@ private TlsaUtils() {
* @param selector the TLSA selector (0 = full cert, 1 = SPKI)
* @param matchingType the TLSA matching type (0 = exact, 1 = SHA-256, 2 = SHA-512)
* @return the computed certificate data, or null if selector/matchingType is unknown
- * @throws NoSuchAlgorithmException if the hash algorithm is not available
* @throws CertificateEncodingException if the certificate cannot be encoded
*/
public static byte[] computeCertificateData(X509Certificate cert, int selector, int matchingType)
- throws NoSuchAlgorithmException, CertificateEncodingException {
+ throws CertificateEncodingException {
// Extract data based on selector
byte[] data;
@@ -95,8 +93,8 @@ public static byte[] computeCertificateData(X509Certificate cert, int selector,
// Apply matching type (hash or exact)
return switch (matchingType) {
case MATCH_EXACT -> data;
- case MATCH_SHA256 -> MessageDigest.getInstance("SHA-256").digest(data);
- case MATCH_SHA512 -> MessageDigest.getInstance("SHA-512").digest(data);
+ case MATCH_SHA256 -> CryptoCache.sha256(data);
+ case MATCH_SHA512 -> CryptoCache.sha512(data);
default -> {
LOGGER.warn("Unknown TLSA matching type: {}", matchingType);
yield null;
diff --git a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/VerificationResult.java b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/VerificationResult.java
index 0d02587..e8e6abe 100644
--- a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/VerificationResult.java
+++ b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/verification/VerificationResult.java
@@ -43,6 +43,8 @@ public enum VerificationType {
DANE,
/** ANS transparency log badge verification (proof of registration) */
BADGE,
+ /** SCITT verification via HTTP headers (receipt + status token) */
+ SCITT,
/** PKI-only verification (no additional ANS verification performed) */
PKI_ONLY
}
diff --git a/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsConnectionTest.java b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsConnectionTest.java
new file mode 100644
index 0000000..2cc9631
--- /dev/null
+++ b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsConnectionTest.java
@@ -0,0 +1,238 @@
+package com.godaddy.ans.sdk.agent;
+
+import com.godaddy.ans.sdk.agent.http.CertificateCapturingTrustManager;
+import com.godaddy.ans.sdk.agent.verification.DefaultConnectionVerifier;
+import com.godaddy.ans.sdk.agent.verification.PreVerificationResult;
+import com.godaddy.ans.sdk.agent.verification.VerificationResult;
+import com.godaddy.ans.sdk.agent.verification.VerificationResult.VerificationType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AnsConnectionTest {
+
+ private static final String TEST_HOSTNAME = "test.example.com";
+
+ @Mock
+ private PreVerificationResult mockPreResult;
+
+ @Mock
+ private DefaultConnectionVerifier mockVerifier;
+
+ private VerificationPolicy policy = VerificationPolicy.SCITT_REQUIRED;
+
+ private AnsConnection connection;
+
+ @BeforeEach
+ void setUp() {
+ connection = new AnsConnection(TEST_HOSTNAME, mockPreResult, mockVerifier, policy);
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Clean up any captured certificates
+ CertificateCapturingTrustManager.clearCapturedCertificates(TEST_HOSTNAME);
+ }
+
+ @Nested
+ @DisplayName("Accessor tests")
+ class AccessorTests {
+
+ @Test
+ @DisplayName("hostname() returns the hostname")
+ void hostnameShouldReturnHostname() {
+ assertThat(connection.hostname()).isEqualTo(TEST_HOSTNAME);
+ }
+
+ @Test
+ @DisplayName("preVerifyResult() returns the pre-verification result")
+ void preVerifyResultShouldReturnPreResult() {
+ assertThat(connection.preVerifyResult()).isSameAs(mockPreResult);
+ }
+ }
+
+ @Nested
+ @DisplayName("hasScittArtifacts() tests")
+ class HasScittArtifactsTests {
+
+ @Test
+ @DisplayName("Should return true when pre-result has SCITT expectation")
+ void shouldReturnTrueWhenScittPresent() {
+ when(mockPreResult.hasScittExpectation()).thenReturn(true);
+
+ assertThat(connection.hasScittArtifacts()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should return false when pre-result has no SCITT expectation")
+ void shouldReturnFalseWhenScittAbsent() {
+ when(mockPreResult.hasScittExpectation()).thenReturn(false);
+
+ assertThat(connection.hasScittArtifacts()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("hasBadgeRegistration() tests")
+ class HasBadgeRegistrationTests {
+
+ @Test
+ @DisplayName("Should return true when pre-result has badge expectation")
+ void shouldReturnTrueWhenBadgePresent() {
+ when(mockPreResult.hasBadgeExpectation()).thenReturn(true);
+
+ assertThat(connection.hasBadgeRegistration()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should return false when pre-result has no badge expectation")
+ void shouldReturnFalseWhenBadgeAbsent() {
+ when(mockPreResult.hasBadgeExpectation()).thenReturn(false);
+
+ assertThat(connection.hasBadgeRegistration()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("hasDaneRecords() tests")
+ class HasDaneRecordsTests {
+
+ @Test
+ @DisplayName("Should return true when pre-result has DANE expectation")
+ void shouldReturnTrueWhenDanePresent() {
+ when(mockPreResult.hasDaneExpectation()).thenReturn(true);
+
+ assertThat(connection.hasDaneRecords()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should return false when pre-result has no DANE expectation")
+ void shouldReturnFalseWhenDaneAbsent() {
+ when(mockPreResult.hasDaneExpectation()).thenReturn(false);
+
+ assertThat(connection.hasDaneRecords()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("verifyServer() tests")
+ class VerifyServerTests {
+
+ @Test
+ @DisplayName("Should throw SecurityException when no certificates captured")
+ void shouldThrowWhenNoCertificates() {
+ // No certificates captured for this hostname
+
+ assertThatThrownBy(() -> connection.verifyServer())
+ .isInstanceOf(SecurityException.class)
+ .hasMessageContaining("No server certificate captured");
+ }
+
+ @Test
+ @DisplayName("Should verify with provided certificate")
+ void shouldVerifyWithProvidedCertificate() {
+ X509Certificate cert = mock(X509Certificate.class);
+ List results = List.of(
+ VerificationResult.success(VerificationType.SCITT, "fingerprint", "Server SCITT verified")
+ );
+ VerificationResult combined = VerificationResult.success(VerificationType.SCITT, "fingerprint", "Combined");
+
+ when(mockVerifier.postVerify(eq(TEST_HOSTNAME), eq(cert), eq(mockPreResult)))
+ .thenReturn(results);
+ when(mockVerifier.combine(eq(results), eq(policy))).thenReturn(combined);
+
+ VerificationResult result = connection.verifyServer(cert);
+
+ assertThat(result).isSameAs(combined);
+ verify(mockVerifier).postVerify(TEST_HOSTNAME, cert, mockPreResult);
+ verify(mockVerifier).combine(results, policy);
+ }
+ }
+
+ @Nested
+ @DisplayName("verifyServerDetailed() tests")
+ class VerifyServerDetailedTests {
+
+ @Test
+ @DisplayName("Should throw SecurityException when no certificates captured")
+ void shouldThrowWhenNoCertificates() {
+ assertThatThrownBy(() -> connection.verifyServerDetailed())
+ .isInstanceOf(SecurityException.class)
+ .hasMessageContaining("No server certificate captured");
+ }
+
+ @Test
+ @DisplayName("Should return detailed results with provided certificate")
+ void shouldReturnDetailedResultsWithProvidedCert() {
+ X509Certificate cert = mock(X509Certificate.class);
+ List expectedResults = List.of(
+ VerificationResult.success(VerificationType.SCITT, "fingerprint", "SCITT OK"),
+ VerificationResult.notFound(VerificationType.DANE, "DANE record not found")
+ );
+
+ when(mockVerifier.postVerify(eq(TEST_HOSTNAME), eq(cert), eq(mockPreResult)))
+ .thenReturn(expectedResults);
+
+ List results = connection.verifyServerDetailed(cert);
+
+ assertThat(results).isEqualTo(expectedResults);
+ }
+ }
+
+ @Nested
+ @DisplayName("close() tests")
+ class CloseTests {
+
+ @Test
+ @DisplayName("Should clear captured certificates on close")
+ void shouldClearCapturedCertificatesOnClose() {
+ // The close method clears captured certs - verify it doesn't throw
+ connection.close();
+
+ // Verify that getting certificates returns null/empty after close
+ X509Certificate[] certs = CertificateCapturingTrustManager.getCapturedCertificates(TEST_HOSTNAME);
+ assertThat(certs).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("AutoCloseable behavior tests")
+ class AutoCloseableTests {
+
+ @Test
+ @DisplayName("Should work in try-with-resources")
+ void shouldWorkInTryWithResources() {
+ X509Certificate cert = mock(X509Certificate.class);
+ VerificationResult successResult = VerificationResult.success(VerificationType.SCITT, "fingerprint", "OK");
+
+ when(mockVerifier.postVerify(any(), any(), any())).thenReturn(List.of(successResult));
+ when(mockVerifier.combine(any(), any())).thenReturn(successResult);
+
+ try (AnsConnection conn = new AnsConnection(TEST_HOSTNAME, mockPreResult, mockVerifier, policy)) {
+ VerificationResult result = conn.verifyServer(cert);
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ // After close, captured certs should be cleared
+ X509Certificate[] certs = CertificateCapturingTrustManager.getCapturedCertificates(TEST_HOSTNAME);
+ assertThat(certs).isNull();
+ }
+ }
+}
diff --git a/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsVerifiedClientTest.java b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsVerifiedClientTest.java
new file mode 100644
index 0000000..5ec3ae7
--- /dev/null
+++ b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/AnsVerifiedClientTest.java
@@ -0,0 +1,783 @@
+package com.godaddy.ans.sdk.agent;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.godaddy.ans.sdk.transparency.TransparencyClient;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.FileOutputStream;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.head;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AnsVerifiedClientTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Mock
+ private TransparencyClient mockTransparencyClient;
+
+ @Nested
+ @DisplayName("Builder tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should create client with defaults")
+ void shouldCreateClientWithDefaults() throws Exception {
+ // Create a minimal PKCS12 keystore for testing
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .build();
+
+ assertThat(client).isNotNull();
+ assertThat(client.sslContext()).isNotNull();
+ assertThat(client.policy()).isEqualTo(VerificationPolicy.SCITT_REQUIRED);
+ assertThat(client.scittHeadersAsync().join()).isEmpty(); // No agent ID set
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should use provided policy")
+ void shouldUseProvidedPolicy() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.PKI_ONLY)
+ .build();
+
+ assertThat(client.policy()).isEqualTo(VerificationPolicy.PKI_ONLY);
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should throw on invalid keystore path")
+ void shouldThrowOnInvalidKeystorePath() {
+ assertThatThrownBy(() -> AnsVerifiedClient.builder()
+ .keyStorePath("/nonexistent/path.p12", "password")
+ .transparencyClient(mockTransparencyClient)
+ .build())
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Failed to load keystore");
+ }
+
+ @Test
+ @DisplayName("Should load keystore from path")
+ void shouldLoadKeystoreFromPath() throws Exception {
+ // Create a PKCS12 keystore file
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "testpass".toCharArray());
+ Path keystorePath = tempDir.resolve("test.p12");
+ try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
+ keyStore.store(fos, "testpass".toCharArray());
+ }
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStorePath(keystorePath.toString(), "testpass")
+ .transparencyClient(mockTransparencyClient)
+ .build();
+
+ assertThat(client.sslContext()).isNotNull();
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should set connect timeout")
+ void shouldSetConnectTimeout() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ // Just verify it doesn't throw
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .connectTimeout(Duration.ofSeconds(15))
+ .build();
+
+ assertThat(client).isNotNull();
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should set agent ID")
+ void shouldSetAgentIdButNotFetchWithoutScitt() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ // With PKI_ONLY, SCITT is disabled so no headers will be fetched
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .agentId("test-agent-123")
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.PKI_ONLY)
+ .build();
+
+ assertThat(client.scittHeadersAsync().join()).isEmpty();
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should fetch SCITT headers when SCITT enabled and agentId provided")
+ void shouldFetchScittHeadersWhenEnabled() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ byte[] mockReceipt = new byte[]{0x01, 0x02, 0x03};
+ byte[] mockToken = new byte[]{0x04, 0x05, 0x06};
+ // Mock async methods used for parallel fetch
+ when(mockTransparencyClient.getReceiptAsync(anyString()))
+ .thenReturn(CompletableFuture.completedFuture(mockReceipt));
+ when(mockTransparencyClient.getStatusTokenAsync(anyString()))
+ .thenReturn(CompletableFuture.completedFuture(mockToken));
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .agentId("test-agent-123")
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.SCITT_REQUIRED)
+ .build();
+
+ assertThat(client.scittHeadersAsync().join()).isNotEmpty();
+ assertThat(client.scittHeadersAsync().join()).containsKey("x-scitt-receipt");
+ assertThat(client.scittHeadersAsync().join()).containsKey("x-ans-status-token");
+ client.close();
+ }
+
+ @Test
+ @DisplayName("Should handle SCITT fetch failure gracefully")
+ void shouldHandleScittFetchFailure() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ // Mock async methods - receipt fails, token succeeds (but failure should propagate)
+ when(mockTransparencyClient.getReceiptAsync(anyString()))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Failed to fetch")));
+ when(mockTransparencyClient.getStatusTokenAsync(anyString()))
+ .thenReturn(CompletableFuture.completedFuture(new byte[]{0x01}));
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .agentId("test-agent-123")
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.SCITT_REQUIRED)
+ .build();
+
+ // Should not throw, just have empty headers (lazy fetch fails gracefully)
+ assertThat(client.scittHeadersAsync().join()).isEmpty();
+ client.close();
+ }
+ }
+
+ @Nested
+ @DisplayName("Accessor tests")
+ class AccessorTests {
+
+ @Test
+ @DisplayName("transparencyClient() returns the configured client")
+ void transparencyClientReturnsConfiguredClient() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .build();
+
+ assertThat(client.transparencyClient()).isSameAs(mockTransparencyClient);
+ client.close();
+ }
+
+ @Test
+ @DisplayName("scittHeaders() returns immutable map")
+ void scittHeadersReturnsImmutableMap() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.PKI_ONLY)
+ .build();
+
+ assertThatThrownBy(() -> client.scittHeadersAsync().join().put("key", "value"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ client.close();
+ }
+ }
+
+ @Nested
+ @DisplayName("scittHeadersAsync() tests")
+ class ScittHeadersAsyncTests {
+
+ @Test
+ @DisplayName("Should return completed future when SCITT disabled")
+ void shouldReturnCompletedFutureWhenScittDisabled() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, "password".toCharArray());
+
+ AnsVerifiedClient client = AnsVerifiedClient.builder()
+ .agentId("test-agent")
+ .keyStore(keyStore, "password".toCharArray())
+ .transparencyClient(mockTransparencyClient)
+ .policy(VerificationPolicy.PKI_ONLY)
+ .build();
+
+ CompletableFuture
*
* @param poolSize the number of threads in the pool
* @return a new executor
*/
public static ExecutorService newIoExecutor(int poolSize) {
- return Executors.newFixedThreadPool(poolSize, new AnsThreadFactory());
+ return new ThreadPoolExecutor(
+ poolSize, poolSize,
+ 60L, TimeUnit.SECONDS,
+ new ArrayBlockingQueue<>(DEFAULT_QUEUE_CAPACITY),
+ new AnsThreadFactory(),
+ new ThreadPoolExecutor.CallerRunsPolicy()
+ );
+ }
+
+ /**
+ * Creates a new scheduled executor with the specified core pool size.
+ *
+ * Use this for operations that need to run on a schedule, such as
+ * SCITT artifact refresh or cache expiration.
+ *
+ * @param corePoolSize the number of threads to keep in the pool
+ * @return a new scheduled executor
+ */
+ public static ScheduledExecutorService newScheduledExecutor(int corePoolSize) {
+ return Executors.newScheduledThreadPool(corePoolSize, new AnsThreadFactory("ans-scheduled"));
+ }
+
+ /**
+ * Creates a new single-threaded scheduled executor.
+ *
+ * Use this for lightweight scheduled tasks that don't need parallelism.
+ *
+ * @return a new single-threaded scheduled executor
+ */
+ public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
+ return newScheduledExecutor(1);
}
/**
@@ -129,16 +171,17 @@ public static void shutdown() {
/**
* Returns whether the shared executor has been initialized.
*
+ * This method reads the volatile field directly without synchronization,
+ * which is safe for this diagnostic/testing use case.
+ *
* @return true if the shared executor exists
*/
public static boolean isInitialized() {
- synchronized (LOCK) {
- return sharedExecutor != null;
- }
+ return sharedExecutor != null;
}
private static ExecutorService createSharedExecutor(int poolSize) {
- return Executors.newFixedThreadPool(poolSize, new AnsThreadFactory());
+ return newIoExecutor(poolSize);
}
/**
@@ -146,10 +189,19 @@ private static ExecutorService createSharedExecutor(int poolSize) {
*/
private static class AnsThreadFactory implements ThreadFactory {
private final AtomicInteger threadNumber = new AtomicInteger(1);
+ private final String namePrefix;
+
+ AnsThreadFactory() {
+ this("ans-io");
+ }
+
+ AnsThreadFactory(String namePrefix) {
+ this.namePrefix = namePrefix;
+ }
@Override
public Thread newThread(Runnable r) {
- Thread t = new Thread(r, "ans-io-" + threadNumber.getAndIncrement());
+ Thread t = new Thread(r, namePrefix + "-" + threadNumber.getAndIncrement());
t.setDaemon(true);
if (t.getPriority() != Thread.NORM_PRIORITY) {
t.setPriority(Thread.NORM_PRIORITY);
diff --git a/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/crypto/CryptoCache.java b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/crypto/CryptoCache.java
new file mode 100644
index 0000000..88e6ecb
--- /dev/null
+++ b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/crypto/CryptoCache.java
@@ -0,0 +1,116 @@
+package com.godaddy.ans.sdk.crypto;
+
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+
+/**
+ * Thread-local cache for cryptographic primitives.
+ *
+ * This class provides cached access to commonly-used cryptographic objects
+ * like {@link MessageDigest} and {@link Signature}, avoiding the overhead of
+ * creating new instances for each operation. These instances are not thread-safe,
+ * so this class uses {@link ThreadLocal} to provide each thread with its own instance.
+ *
+ * Performance
+ * Creating MessageDigest and Signature instances involves synchronization and provider
+ * lookup. Caching instances per-thread eliminates this overhead for repeated
+ * operations on the same thread.
+ *
+ * Usage
+ * {@code
+ * // Instead of:
+ * MessageDigest md = MessageDigest.getInstance("SHA-256");
+ * byte[] hash = md.digest(data);
+ *
+ * // Use:
+ * byte[] hash = CryptoCache.sha256(data);
+ *
+ * // Instead of:
+ * Signature sig = Signature.getInstance("SHA256withECDSA");
+ * sig.initVerify(publicKey);
+ * sig.update(data);
+ * boolean valid = sig.verify(signature);
+ *
+ * // Use:
+ * boolean valid = CryptoCache.verifyEs256(data, signature, publicKey);
+ * }
+ */
+public final class CryptoCache {
+
+ private static final ThreadLocal SHA256 = ThreadLocal.withInitial(() -> {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ });
+
+ private static final ThreadLocal SHA512 = ThreadLocal.withInitial(() -> {
+ try {
+ return MessageDigest.getInstance("SHA-512");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-512 not available", e);
+ }
+ });
+
+ private static final ThreadLocal ES256 = ThreadLocal.withInitial(() -> {
+ try {
+ return Signature.getInstance("SHA256withECDSA");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA256withECDSA not available", e);
+ }
+ });
+
+ private CryptoCache() {
+ // Utility class
+ }
+
+ /**
+ * Computes the SHA-256 hash of the given data.
+ *
+ * @param data the data to hash
+ * @return the 32-byte SHA-256 hash
+ */
+ public static byte[] sha256(byte[] data) {
+ MessageDigest md = SHA256.get();
+ md.reset();
+ return md.digest(data);
+ }
+
+ /**
+ * Computes the SHA-512 hash of the given data.
+ *
+ * @param data the data to hash
+ * @return the 64-byte SHA-512 hash
+ */
+ public static byte[] sha512(byte[] data) {
+ MessageDigest md = SHA512.get();
+ md.reset();
+ return md.digest(data);
+ }
+
+ /**
+ * Verifies an ES256 (ECDSA with SHA-256 on P-256) signature.
+ *
+ * Uses a thread-local Signature instance to avoid the overhead of
+ * provider lookup on each verification.
+ *
+ * @param data the data that was signed
+ * @param signature the signature (typically in DER format for Java's Signature API)
+ * @param publicKey the EC public key to verify against
+ * @return true if the signature is valid, false otherwise
+ * @throws InvalidKeyException if the public key is invalid
+ * @throws SignatureException if the signature format is invalid
+ */
+ public static boolean verifyEs256(byte[] data, byte[] signature, PublicKey publicKey)
+ throws InvalidKeyException, SignatureException {
+ Signature sig = ES256.get();
+ sig.initVerify(publicKey);
+ sig.update(data);
+ return sig.verify(signature);
+ }
+}
diff --git a/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/concurrent/AnsExecutorsTest.java b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/concurrent/AnsExecutorsTest.java
index ffe8809..a0aca2b 100644
--- a/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/concurrent/AnsExecutorsTest.java
+++ b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/concurrent/AnsExecutorsTest.java
@@ -7,6 +7,7 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -184,4 +185,91 @@ void concurrentAccessToSharedIoExecutorShouldBeSafe() throws Exception {
assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(firstExecutor.get()).isNotNull();
}
+
+ @Test
+ @DisplayName("newScheduledExecutor should create functional scheduled executor")
+ void newScheduledExecutorShouldCreateFunctionalExecutor() throws Exception {
+ ScheduledExecutorService scheduler = AnsExecutors.newScheduledExecutor(2);
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference threadName = new AtomicReference<>();
+
+ try {
+ scheduler.schedule(() -> {
+ threadName.set(Thread.currentThread().getName());
+ latch.countDown();
+ }, 10, TimeUnit.MILLISECONDS);
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(threadName.get()).startsWith("ans-scheduled-");
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("newScheduledExecutor threads should be daemon threads")
+ void newScheduledExecutorThreadsShouldBeDaemon() throws Exception {
+ ScheduledExecutorService scheduler = AnsExecutors.newScheduledExecutor(1);
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference isDaemon = new AtomicReference<>();
+
+ try {
+ scheduler.execute(() -> {
+ isDaemon.set(Thread.currentThread().isDaemon());
+ latch.countDown();
+ });
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(isDaemon.get()).isTrue();
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("newSingleThreadScheduledExecutor should create single-threaded executor")
+ void newSingleThreadScheduledExecutorShouldCreateSingleThreadedExecutor() throws Exception {
+ ScheduledExecutorService scheduler = AnsExecutors.newSingleThreadScheduledExecutor();
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference threadName = new AtomicReference<>();
+
+ try {
+ scheduler.schedule(() -> {
+ threadName.set(Thread.currentThread().getName());
+ latch.countDown();
+ }, 10, TimeUnit.MILLISECONDS);
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(threadName.get()).startsWith("ans-scheduled-");
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("newSingleThreadScheduledExecutor should be a daemon thread")
+ void newSingleThreadScheduledExecutorShouldBeDaemon() throws Exception {
+ ScheduledExecutorService scheduler = AnsExecutors.newSingleThreadScheduledExecutor();
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference isDaemon = new AtomicReference<>();
+
+ try {
+ scheduler.execute(() -> {
+ isDaemon.set(Thread.currentThread().isDaemon());
+ latch.countDown();
+ });
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(isDaemon.get()).isTrue();
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("DEFAULT_QUEUE_CAPACITY should be reasonable")
+ void defaultQueueCapacityShouldBeReasonable() {
+ assertThat(AnsExecutors.DEFAULT_QUEUE_CAPACITY).isGreaterThanOrEqualTo(50);
+ assertThat(AnsExecutors.DEFAULT_QUEUE_CAPACITY).isLessThanOrEqualTo(1000);
+ }
}
diff --git a/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/crypto/CryptoCacheTest.java b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/crypto/CryptoCacheTest.java
new file mode 100644
index 0000000..26ff4d9
--- /dev/null
+++ b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/crypto/CryptoCacheTest.java
@@ -0,0 +1,297 @@
+package com.godaddy.ans.sdk.crypto;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link CryptoCache}.
+ */
+class CryptoCacheTest {
+
+ @Test
+ @DisplayName("sha256 should compute correct hash")
+ void sha256ShouldComputeCorrectHash() throws Exception {
+ byte[] data = "hello world".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result = CryptoCache.sha256(data);
+
+ // Verify against direct MessageDigest
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] expected = md.digest(data);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("sha256 should return 32 bytes")
+ void sha256ShouldReturn32Bytes() {
+ byte[] data = "test data".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result = CryptoCache.sha256(data);
+
+ assertThat(result).hasSize(32);
+ }
+
+ @Test
+ @DisplayName("sha256 should handle empty input")
+ void sha256ShouldHandleEmptyInput() throws Exception {
+ byte[] data = new byte[0];
+
+ byte[] result = CryptoCache.sha256(data);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] expected = md.digest(data);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("sha256 should produce consistent results")
+ void sha256ShouldProduceConsistentResults() {
+ byte[] data = "consistent test".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result1 = CryptoCache.sha256(data);
+ byte[] result2 = CryptoCache.sha256(data);
+
+ assertThat(result1).isEqualTo(result2);
+ }
+
+ @Test
+ @DisplayName("sha256 should be thread-safe")
+ void sha256ShouldBeThreadSafe() throws Exception {
+ int threadCount = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ AtomicReference firstResult = new AtomicReference<>();
+ AtomicReference error = new AtomicReference<>();
+
+ byte[] data = "concurrent test".getBytes(StandardCharsets.UTF_8);
+
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ executor.execute(() -> {
+ try {
+ startLatch.await();
+ byte[] result = CryptoCache.sha256(data);
+ firstResult.compareAndSet(null, result);
+ if (!java.util.Arrays.equals(result, firstResult.get())) {
+ error.set(new AssertionError("Hash mismatch in concurrent execution"));
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(error.get()).isNull();
+ assertThat(firstResult.get()).isNotNull();
+ } finally {
+ executor.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("sha512 should compute correct hash")
+ void sha512ShouldComputeCorrectHash() throws Exception {
+ byte[] data = "hello world".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result = CryptoCache.sha512(data);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-512");
+ byte[] expected = md.digest(data);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("sha512 should return 64 bytes")
+ void sha512ShouldReturn64Bytes() {
+ byte[] data = "test data".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result = CryptoCache.sha512(data);
+
+ assertThat(result).hasSize(64);
+ }
+
+ @Test
+ @DisplayName("sha512 should handle empty input")
+ void sha512ShouldHandleEmptyInput() throws Exception {
+ byte[] data = new byte[0];
+
+ byte[] result = CryptoCache.sha512(data);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-512");
+ byte[] expected = md.digest(data);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("sha512 should produce consistent results")
+ void sha512ShouldProduceConsistentResults() {
+ byte[] data = "consistent test".getBytes(StandardCharsets.UTF_8);
+
+ byte[] result1 = CryptoCache.sha512(data);
+ byte[] result2 = CryptoCache.sha512(data);
+
+ assertThat(result1).isEqualTo(result2);
+ }
+
+ @Test
+ @DisplayName("sha512 should be thread-safe")
+ void sha512ShouldBeThreadSafe() throws Exception {
+ int threadCount = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ AtomicReference firstResult = new AtomicReference<>();
+ AtomicReference error = new AtomicReference<>();
+
+ byte[] data = "concurrent test".getBytes(StandardCharsets.UTF_8);
+
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ executor.execute(() -> {
+ try {
+ startLatch.await();
+ byte[] result = CryptoCache.sha512(data);
+ firstResult.compareAndSet(null, result);
+ if (!java.util.Arrays.equals(result, firstResult.get())) {
+ error.set(new AssertionError("Hash mismatch in concurrent execution"));
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(error.get()).isNull();
+ assertThat(firstResult.get()).isNotNull();
+ } finally {
+ executor.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("sha256 and sha512 should produce different hashes")
+ void sha256AndSha512ShouldProduceDifferentHashes() {
+ byte[] data = "same input".getBytes(StandardCharsets.UTF_8);
+
+ byte[] sha256Result = CryptoCache.sha256(data);
+ byte[] sha512Result = CryptoCache.sha512(data);
+
+ assertThat(sha256Result).isNotEqualTo(sha512Result);
+ assertThat(sha256Result).hasSize(32);
+ assertThat(sha512Result).hasSize(64);
+ }
+
+ @Test
+ @DisplayName("verifyEs256 should verify valid signature")
+ void verifyEs256ShouldVerifyValidSignature() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ KeyPair keyPair = keyGen.generateKeyPair();
+
+ byte[] data = "test data to sign".getBytes(StandardCharsets.UTF_8);
+
+ // Sign with standard Signature API
+ Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(keyPair.getPrivate());
+ signer.update(data);
+ byte[] signature = signer.sign();
+
+ // Verify with CryptoCache
+ boolean result = CryptoCache.verifyEs256(data, signature, keyPair.getPublic());
+
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("verifyEs256 should reject invalid signature")
+ void verifyEs256ShouldRejectInvalidSignature() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ KeyPair keyPair = keyGen.generateKeyPair();
+
+ byte[] data = "test data to sign".getBytes(StandardCharsets.UTF_8);
+
+ // Sign with standard Signature API
+ Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(keyPair.getPrivate());
+ signer.update(data);
+ byte[] signature = signer.sign();
+
+ // Verify with different data
+ byte[] differentData = "different data".getBytes(StandardCharsets.UTF_8);
+ boolean result = CryptoCache.verifyEs256(differentData, signature, keyPair.getPublic());
+
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("verifyEs256 should be thread-safe")
+ void verifyEs256ShouldBeThreadSafe() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ KeyPair keyPair = keyGen.generateKeyPair();
+
+ byte[] data = "concurrent test data".getBytes(StandardCharsets.UTF_8);
+
+ Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(keyPair.getPrivate());
+ signer.update(data);
+ byte[] signature = signer.sign();
+
+ int threadCount = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ AtomicBoolean allValid = new AtomicBoolean(true);
+ AtomicReference error = new AtomicReference<>();
+
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ executor.execute(() -> {
+ try {
+ startLatch.await();
+ boolean result = CryptoCache.verifyEs256(data, signature, keyPair.getPublic());
+ if (!result) {
+ allValid.set(false);
+ }
+ } catch (Exception e) {
+ error.set(e);
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(error.get()).isNull();
+ assertThat(allValid.get()).isTrue();
+ } finally {
+ executor.shutdown();
+ }
+ }
+}
diff --git a/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/CertificateUtils.java b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/CertificateUtils.java
index aa36fc3..df5b768 100644
--- a/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/CertificateUtils.java
+++ b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/CertificateUtils.java
@@ -13,8 +13,6 @@
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
@@ -209,14 +207,13 @@ public static String computeSha256Fingerprint(X509Certificate certificate) {
throw new IllegalArgumentException("Certificate cannot be null");
}
try {
- MessageDigest md = MessageDigest.getInstance("SHA-256");
- byte[] digest = md.digest(certificate.getEncoded());
+ byte[] digest = CryptoCache.sha256(certificate.getEncoded());
StringBuilder hex = new StringBuilder("SHA256:");
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString();
- } catch (NoSuchAlgorithmException | CertificateEncodingException e) {
+ } catch (CertificateEncodingException e) {
throw new RuntimeException("Failed to compute certificate fingerprint", e);
}
}
@@ -241,7 +238,7 @@ public static boolean fingerprintMatches(String actual, String expected) {
return normalizedActual.equals(normalizedExpected);
}
- private static String normalizeFingerprint(String fingerprint) {
+ public static String normalizeFingerprint(String fingerprint) {
String normalized = fingerprint.toLowerCase().trim();
// Remove common prefixes
if (normalized.startsWith("sha256:")) {
diff --git a/ans-sdk-transparency/build.gradle.kts b/ans-sdk-transparency/build.gradle.kts
index eb0ddb0..f6a40a3 100644
--- a/ans-sdk-transparency/build.gradle.kts
+++ b/ans-sdk-transparency/build.gradle.kts
@@ -4,6 +4,8 @@ val junitVersion: String by project
val mockitoVersion: String by project
val assertjVersion: String by project
val wiremockVersion: String by project
+val bouncyCastleVersion: String by project
+val caffeineVersion: String by project
dependencies {
// Core module for exceptions and HTTP utilities
@@ -12,6 +14,9 @@ dependencies {
// Crypto module for certificate utilities (fingerprint, SAN extraction)
api(project(":ans-sdk-crypto"))
+ // BouncyCastle for hex encoding utilities
+ implementation("org.bouncycastle:bcprov-jdk18on:$bouncyCastleVersion")
+
// Jackson for JSON serialization
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
@@ -22,6 +27,12 @@ dependencies {
// dnsjava for _ra-badge TXT record lookups (JNDI doesn't support all TXT features)
implementation("dnsjava:dnsjava:3.6.4")
+ // CBOR parsing for SCITT COSE_Sign1 structures
+ implementation("com.upokecenter:cbor:4.5.4")
+
+ // Caffeine for high-performance caching with TTL and automatic eviction
+ implementation("com.github.ben-manes.caffeine:caffeine:$caffeineVersion")
+
// Testing
testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion")
testImplementation("org.mockito:mockito-core:$mockitoVersion")
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
index 1007dad..c703c0c 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
@@ -7,8 +7,13 @@
import com.godaddy.ans.sdk.transparency.model.CheckpointResponse;
import com.godaddy.ans.sdk.transparency.model.TransparencyLog;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
+import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
+import com.godaddy.ans.sdk.transparency.scitt.TrustedDomainRegistry;
+import java.net.URI;
+import java.security.PublicKey;
import java.time.Duration;
+import java.time.Instant;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -46,15 +51,23 @@ public final class TransparencyClient {
*/
public static final String DEFAULT_BASE_URL = "https://transparency.ans.ote-godaddy.com";
+ /**
+ * Default cache TTL for the root public key (24 hours).
+ *
+ * Root keys rarely change, so a long TTL is appropriate.
+ */
+ public static final Duration DEFAULT_ROOT_KEY_CACHE_TTL = Duration.ofHours(24);
+
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(30);
private final String baseUrl;
private final TransparencyService service;
- private TransparencyClient(String baseUrl, Duration connectTimeout, Duration readTimeout) {
+ private TransparencyClient(String baseUrl, Duration connectTimeout, Duration readTimeout,
+ Duration rootKeyCacheTtl) {
this.baseUrl = baseUrl;
- this.service = new TransparencyService(baseUrl, connectTimeout, readTimeout);
+ this.service = new TransparencyService(baseUrl, connectTimeout, readTimeout, rootKeyCacheTtl);
}
/**
@@ -161,6 +174,81 @@ public Map getLogSchema(String version) {
return service.getLogSchema(version);
}
+ // ==================== SCITT Operations (Sync) ====================
+
+ /**
+ * Retrieves the SCITT receipt for an agent.
+ *
+ * The receipt is a COSE_Sign1 structure containing a Merkle inclusion
+ * proof that the agent's registration was recorded in the transparency log.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the raw receipt bytes (COSE_Sign1)
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found
+ */
+ public byte[] getReceipt(String agentId) {
+ return service.getReceipt(agentId);
+ }
+
+ /**
+ * Retrieves the status token for an agent.
+ *
+ * The status token is a COSE_Sign1 structure containing a time-bounded
+ * assertion of the agent's current status and valid certificate fingerprints.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the raw status token bytes (COSE_Sign1)
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found
+ */
+ public byte[] getStatusToken(String agentId) {
+ return service.getStatusToken(agentId);
+ }
+
+ /**
+ * Invalidates the cached root public keys.
+ *
+ * Call this method to force the next {@link #getRootKeysAsync()} call to
+ * fetch fresh keys from the server. This is useful when you know the
+ * root keys have been rotated.
+ */
+ public void invalidateRootKeyCache() {
+ service.invalidateRootKeyCache();
+ }
+
+ /**
+ * Returns the timestamp when the root key cache was last populated.
+ *
+ * This can be used to determine if an artifact was issued after the cache
+ * was refreshed, which may indicate the artifact was signed with a new key
+ * that we don't have yet.
+ *
+ * @return the cache population timestamp, or {@link Instant#EPOCH} if never populated
+ */
+ public Instant getCachePopulatedAt() {
+ return service.getCachePopulatedAt();
+ }
+
+ /**
+ * Attempts to refresh the root key cache if the artifact's issued-at timestamp
+ * indicates it may have been signed with a new key not yet in our cache.
+ *
+ * This method performs security checks to prevent cache thrashing attacks:
+ *
+ * - Rejects artifacts claiming to be from the future (beyond 60s clock skew)
+ * - Rejects artifacts older than our cache (key should already be present)
+ * - Enforces a 30-second global cooldown between refresh attempts
+ *
+ *
+ * Use this method when a key lookup fails during SCITT verification to
+ * potentially recover from a key rotation scenario.
+ *
+ * @param artifactIssuedAt the issued-at timestamp from the SCITT artifact
+ * @return the refresh decision indicating whether to retry verification
+ */
+ public RefreshDecision refreshRootKeysIfNeeded(Instant artifactIssuedAt) {
+ return service.refreshRootKeysIfNeeded(artifactIssuedAt);
+ }
+
// ==================== Async Operations ====================
/**
@@ -206,6 +294,50 @@ public CompletableFuture getCheckpointHistoryAsync(
return CompletableFuture.supplyAsync(() -> getCheckpointHistory(params), AnsExecutors.sharedIoExecutor());
}
+ /**
+ * Retrieves the SCITT receipt for an agent asynchronously.
+ *
+ * This method uses non-blocking I/O and does not occupy a thread pool
+ * thread during the HTTP request. Use this instead of the sync variant
+ * for high-concurrency scenarios.
+ *
+ * @param agentId the agent's unique identifier
+ * @return a CompletableFuture with the raw receipt bytes
+ */
+ public CompletableFuture getReceiptAsync(String agentId) {
+ return service.getReceiptAsync(agentId);
+ }
+
+ /**
+ * Retrieves the status token for an agent asynchronously.
+ *
+ * This method uses non-blocking I/O and does not occupy a thread pool
+ * thread during the HTTP request. Use this instead of the sync variant
+ * for high-concurrency scenarios.
+ *
+ * @param agentId the agent's unique identifier
+ * @return a CompletableFuture with the raw status token bytes
+ */
+ public CompletableFuture getStatusTokenAsync(String agentId) {
+ return service.getStatusTokenAsync(agentId);
+ }
+
+ /**
+ * Retrieves the SCITT root public keys asynchronously.
+ *
+ * This method uses non-blocking I/O and does not occupy a thread pool
+ * thread during the HTTP request. The keys are cached with a configurable
+ * TTL (default: 24 hours) to avoid redundant network calls.
+ *
+ * The returned map is keyed by hex key ID (4-byte SHA-256 of SPKI-DER),
+ * enabling O(1) lookup by key ID from COSE headers.
+ *
+ * @return a CompletableFuture with the root public keys (keyed by hex key ID)
+ */
+ public CompletableFuture> getRootKeysAsync() {
+ return service.getRootKeysAsync();
+ }
+
// ==================== Accessors ====================
/**
@@ -225,6 +357,7 @@ public static final class Builder {
private String baseUrl = DEFAULT_BASE_URL;
private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT;
private Duration readTimeout = DEFAULT_READ_TIMEOUT;
+ private Duration rootKeyCacheTtl = DEFAULT_ROOT_KEY_CACHE_TTL;
private Builder() {
}
@@ -232,7 +365,12 @@ private Builder() {
/**
* Sets the base URL for the transparency log API.
*
- * @param baseUrl the base URL (default: https://transparency.ans.godaddy.com)
+ * Security note: Only URLs pointing to trusted SCITT domains
+ * (defined in {@link TrustedDomainRegistry}) are accepted. This prevents
+ * root key substitution attacks where a malicious transparency log could
+ * provide a forged root key.
+ *
+ * @param baseUrl the base URL (default: https://transparency.ans.ote-godaddy.com)
* @return this builder
*/
public Builder baseUrl(String baseUrl) {
@@ -262,13 +400,38 @@ public Builder readTimeout(Duration timeout) {
return this;
}
+ /**
+ * Sets the cache TTL for the root public key.
+ *
+ * The root key is cached to avoid redundant network calls during
+ * verification. Since root keys rarely change, a long TTL is appropriate.
+ *
+ * @param ttl the cache TTL (default: 24 hours)
+ * @return this builder
+ */
+ public Builder rootKeyCacheTtl(Duration ttl) {
+ this.rootKeyCacheTtl = ttl;
+ return this;
+ }
+
/**
* Builds the TransparencyClient.
*
* @return a new TransparencyClient instance
+ * @throws SecurityException if the configured baseUrl is not a trusted SCITT domain
*/
public TransparencyClient build() {
- return new TransparencyClient(baseUrl, connectTimeout, readTimeout);
+ validateTrustedDomain();
+ return new TransparencyClient(baseUrl, connectTimeout, readTimeout, rootKeyCacheTtl);
+ }
+
+ private void validateTrustedDomain() {
+ String host = URI.create(baseUrl).getHost();
+ if (!TrustedDomainRegistry.isTrustedDomain(host)) {
+ throw new SecurityException(
+ "Untrusted transparency log domain: " + host + ". "
+ + "Trusted domains: " + TrustedDomainRegistry.getTrustedDomains());
+ }
}
}
}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
index 33091ad..74bf1f6 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
@@ -3,6 +3,8 @@
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import com.godaddy.ans.sdk.exception.AnsNotFoundException;
import com.godaddy.ans.sdk.exception.AnsServerException;
import com.godaddy.ans.sdk.transparency.model.AgentAuditParams;
@@ -13,6 +15,14 @@
import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogV0;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogV1;
+import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.godaddy.ans.sdk.crypto.CryptoCache;
+
+import org.bouncycastle.util.encoders.Hex;
import java.io.IOException;
import java.net.URI;
@@ -21,34 +31,96 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
+import java.time.Instant;
import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
/**
* Internal service for handling transparency log API calls.
*/
class TransparencyService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(TransparencyService.class);
private static final String SCHEMA_VERSION_HEADER = "X-Schema-Version";
+ private static final String ROOT_KEY_CACHE_KEY = "root";
+
+ /**
+ * Maximum number of root keys to cache. Prevents DoS from unbounded key sets.
+ */
+ private static final int MAX_ROOT_KEYS = 20;
+
+ /**
+ * Global cooldown between cache refresh attempts to prevent cache thrashing.
+ */
+ private static final Duration REFRESH_COOLDOWN = Duration.ofSeconds(30);
+
+ /**
+ * Maximum tolerance for artifact timestamps in the future (clock skew).
+ */
+ private static final Duration FUTURE_TOLERANCE = Duration.ofSeconds(60);
+
+ /**
+ * Tolerance for artifacts issued slightly before cache refresh (race conditions).
+ */
+ private static final Duration PAST_TOLERANCE = Duration.ofMinutes(5);
+
+ /**
+ * Cached KeyFactory instance. Thread-safe after initialization.
+ */
+ private static final KeyFactory EC_KEY_FACTORY;
+
+ static {
+ try {
+ EC_KEY_FACTORY = KeyFactory.getInstance("EC");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("EC algorithm not available", e);
+ }
+ }
+
private final String baseUrl;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final Duration readTimeout;
- TransparencyService(String baseUrl, Duration connectTimeout, Duration readTimeout) {
+ // Root keys cache with automatic TTL and stampede prevention (keyed by hex key ID)
+ private final AsyncLoadingCache> rootKeyCache;
+
+ // Timestamp when cache was last populated (for refresh-on-miss logic)
+ private final AtomicReference cachePopulatedAt = new AtomicReference<>(Instant.EPOCH);
+
+ // Timestamp of last refresh attempt (for cooldown enforcement)
+ private final AtomicReference lastRefreshAttempt = new AtomicReference<>(Instant.EPOCH);
+
+ TransparencyService(String baseUrl, Duration connectTimeout, Duration readTimeout, Duration rootKeyCacheTtl) {
this.baseUrl = baseUrl;
this.readTimeout = readTimeout;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(connectTimeout)
- .followRedirects(HttpClient.Redirect.NORMAL)
- .version(HttpClient.Version.HTTP_1_1)
+ .followRedirects(HttpClient.Redirect.NEVER)
.build();
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ // Build root keys cache with TTL - stampede prevention is automatic
+ this.rootKeyCache = Caffeine.newBuilder()
+ .maximumSize(1)
+ .expireAfterWrite(rootKeyCacheTtl)
+ .buildAsync((key, executor) -> fetchRootKeysFromServerAsync());
}
/**
@@ -138,6 +210,325 @@ Map getLogSchema(String version) {
}
}
+ /**
+ * Gets the SCITT receipt for an agent.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the raw receipt bytes (COSE_Sign1)
+ */
+ byte[] getReceipt(String agentId) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/receipt";
+ return fetchBinaryResponse(path, "application/scitt-receipt+cose");
+ }
+
+ /**
+ * Gets the status token for an agent.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the raw status token bytes (COSE_Sign1)
+ */
+ byte[] getStatusToken(String agentId) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/status-token";
+ return fetchBinaryResponse(path, "application/ans-status-token+cbor");
+ }
+
+ /**
+ * Gets the SCITT receipt for an agent asynchronously using non-blocking I/O.
+ *
+ * @param agentId the agent's unique identifier
+ * @return a CompletableFuture with the raw receipt bytes (COSE_Sign1)
+ */
+ CompletableFuture getReceiptAsync(String agentId) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/receipt";
+ return fetchBinaryResponseAsync(path, "application/scitt-receipt+cose");
+ }
+
+ /**
+ * Gets the status token for an agent asynchronously using non-blocking I/O.
+ *
+ * @param agentId the agent's unique identifier
+ * @return a CompletableFuture with the raw status token bytes (COSE_Sign1)
+ */
+ CompletableFuture getStatusTokenAsync(String agentId) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/status-token";
+ return fetchBinaryResponseAsync(path, "application/ans-status-token+cbor");
+ }
+
+ /**
+ * Returns the SCITT root public keys asynchronously, using cached values if available.
+ *
+ * The root keys are cached with a configurable TTL to avoid redundant
+ * network calls on every verification request. Concurrent callers share
+ * a single in-flight fetch to prevent cache stampedes.
+ *
+ * The returned map is keyed by hex key ID (4-byte SHA-256 of SPKI-DER),
+ * enabling O(1) lookup by key ID from COSE headers.
+ *
+ * @return a CompletableFuture with the root public keys for verifying receipts and status tokens
+ */
+ CompletableFuture> getRootKeysAsync() {
+ return rootKeyCache.get(ROOT_KEY_CACHE_KEY);
+ }
+
+ /**
+ * Invalidates the cached root key, forcing the next call to fetch from the server.
+ */
+ void invalidateRootKeyCache() {
+ rootKeyCache.synchronous().invalidate(ROOT_KEY_CACHE_KEY);
+ LOGGER.debug("Root key cache invalidated");
+ }
+
+ /**
+ * Returns the timestamp when the root key cache was last populated.
+ *
+ * @return the cache population timestamp, or {@link Instant#EPOCH} if never populated
+ */
+ Instant getCachePopulatedAt() {
+ return cachePopulatedAt.get();
+ }
+
+ /**
+ * Attempts to refresh the root key cache if the artifact's issued-at timestamp
+ * indicates it may have been signed with a new key not yet in our cache.
+ *
+ * Security checks performed:
+ *
+ * - Reject artifacts claiming to be from the future (beyond clock skew tolerance)
+ * - Reject artifacts older than our cache (key should already be present)
+ * - Enforce global cooldown to prevent cache thrashing attacks
+ *
+ *
+ * @param artifactIssuedAt the issued-at timestamp from the SCITT artifact
+ * @return the refresh decision with action, reason, and optionally refreshed keys
+ */
+ RefreshDecision refreshRootKeysIfNeeded(Instant artifactIssuedAt) {
+ Instant now = Instant.now();
+ Instant cacheTime = cachePopulatedAt.get();
+
+ // Check 1: Reject artifacts from the future (beyond clock skew tolerance)
+ if (artifactIssuedAt.isAfter(now.plus(FUTURE_TOLERANCE))) {
+ LOGGER.warn("Artifact timestamp {} is in the future (now={}), rejecting",
+ artifactIssuedAt, now);
+ return RefreshDecision.reject("Artifact timestamp is in the future");
+ }
+
+ // Check 2: Reject artifacts older than cache (with past tolerance for race conditions)
+ // If artifact was issued before we refreshed cache, the key SHOULD be there
+ if (artifactIssuedAt.isBefore(cacheTime.minus(PAST_TOLERANCE))) {
+ LOGGER.debug("Artifact issued at {} predates cache refresh at {} (with {}min tolerance), "
+ + "key should be present - rejecting refresh",
+ artifactIssuedAt, cacheTime, PAST_TOLERANCE.toMinutes());
+ return RefreshDecision.reject(
+ "Key not found and artifact predates cache refresh");
+ }
+
+ // Check 3: Enforce global cooldown to prevent cache thrashing
+ Instant lastAttempt = lastRefreshAttempt.get();
+ if (lastAttempt.plus(REFRESH_COOLDOWN).isAfter(now)) {
+ Duration remaining = Duration.between(now, lastAttempt.plus(REFRESH_COOLDOWN));
+ LOGGER.debug("Cache refresh on cooldown, {} remaining", remaining);
+ return RefreshDecision.defer(
+ "Cache was recently refreshed, retry in " + remaining.toSeconds() + "s");
+ }
+
+ // All checks passed - attempt refresh
+ LOGGER.info("Artifact issued at {} is newer than cache at {}, refreshing root keys",
+ artifactIssuedAt, cacheTime);
+
+ // Update cooldown timestamp before fetch to prevent concurrent refresh attempts
+ lastRefreshAttempt.set(now);
+
+ try {
+ // Invalidate and fetch fresh keys
+ invalidateRootKeyCache();
+ Map freshKeys = getRootKeysAsync().join();
+ LOGGER.info("Cache refresh complete, now have {} keys", freshKeys.size());
+ return RefreshDecision.refreshed(freshKeys);
+ } catch (Exception e) {
+ LOGGER.error("Failed to refresh root keys: {}", e.getMessage());
+ return RefreshDecision.defer("Failed to refresh: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Fetches the SCITT root public keys from the /root-keys endpoint asynchronously.
+ */
+ private CompletableFuture> fetchRootKeysFromServerAsync() {
+ LOGGER.info("Fetching root keys from server");
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/root-keys"))
+ .header("Accept", "application/json")
+ .timeout(readTimeout)
+ .GET()
+ .build();
+
+ return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
+ .thenApply(response -> {
+ if (response.statusCode() != 200) {
+ throw new AnsServerException(
+ "Failed to fetch root keys: HTTP " + response.statusCode(),
+ response.statusCode(),
+ response.headers().firstValue("X-Request-Id").orElse(null));
+ }
+ Map keys = parsePublicKeysResponse(response.body());
+ cachePopulatedAt.set(Instant.now());
+ LOGGER.info("Fetched and cached {} root key(s) at {}", keys.size(), cachePopulatedAt.get());
+ return keys;
+ });
+ }
+
+ /**
+ * Parses public keys from the root-keys API response.
+ *
+ * Format is C2SP note: each line is {@code name+key_hash+base64_public_key}
+ * Example:
+ *
+ * transparency.ans.godaddy.com+bb7ed8cf+AjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IAB...
+ * transparency.ans.godaddy.com+cc8fe9d0+AjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IAB...
+ *
+ *
+ * Returns a map keyed by hex key ID (4-byte SHA-256 of SPKI-DER) for O(1) lookup.
+ *
+ * @param responseBody the raw response body (text/plain, C2SP note format)
+ * @return map of hex key ID to public key
+ * @throws IllegalArgumentException if no valid keys found or too many keys
+ */
+ private Map parsePublicKeysResponse(String responseBody) {
+ Map keys = new HashMap<>();
+ List parseErrors = new ArrayList<>();
+
+ String[] lines = responseBody.split("\n");
+ int lineNum = 0;
+ for (String line : lines) {
+ lineNum++;
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+
+ // Check max keys limit
+ if (keys.size() >= MAX_ROOT_KEYS) {
+ LOGGER.warn("Reached max root keys limit ({}), ignoring remaining keys", MAX_ROOT_KEYS);
+ break;
+ }
+
+ // C2SP format: name+key_hash+base64_key (limit split to 3 since base64 can contain '+')
+ String[] parts = line.split("\\+", 3);
+ if (parts.length != 3) {
+ String error = String.format("Line %d: expected C2SP format (name+hash+key), got %d parts",
+ lineNum, parts.length);
+ LOGGER.debug("Public key parse failed - {}", error);
+ parseErrors.add(error);
+ continue;
+ }
+
+ try {
+ PublicKey key = decodePublicKey(parts[2].trim());
+ String hexKeyId = computeHexKeyId(key);
+ if (keys.containsKey(hexKeyId)) {
+ LOGGER.warn("Duplicate key ID {} at line {}, skipping", hexKeyId, lineNum);
+ } else {
+ keys.put(hexKeyId, key);
+ LOGGER.debug("Parsed key with ID {} at line {}", hexKeyId, lineNum);
+ }
+ } catch (Exception e) {
+ String error = String.format("Line %d: %s", lineNum, e.getMessage());
+ LOGGER.debug("Public key parse failed - {}", error);
+ parseErrors.add(error);
+ }
+ }
+
+ if (keys.isEmpty()) {
+ String errorDetail = parseErrors.isEmpty()
+ ? "No parseable key lines found"
+ : "Parse attempts failed: " + String.join("; ", parseErrors);
+ throw new IllegalArgumentException("Could not parse any public keys from response. " + errorDetail);
+ }
+
+ return keys;
+ }
+
+ /**
+ * Computes the hex key ID for a public key per C2SP specification.
+ *
+ * The key ID is the first 4 bytes of SHA-256(SPKI-DER), where SPKI-DER
+ * is the Subject Public Key Info DER encoding of the public key.
+ *
+ * @param publicKey the public key
+ * @return the 8-character hex key ID
+ */
+ static String computeHexKeyId(PublicKey publicKey) {
+ byte[] spkiDer = publicKey.getEncoded();
+ byte[] hash = CryptoCache.sha256(spkiDer);
+ return Hex.toHexString(Arrays.copyOf(hash, 4));
+ }
+
+ /**
+ * Decodes a base64-encoded public key.
+ */
+ private PublicKey decodePublicKey(String base64Key) throws Exception {
+ byte[] keyBytes = Base64.getDecoder().decode(base64Key);
+
+ // C2SP note format includes a version byte prefix (0x02) before the SPKI-DER data.
+ // We need to strip it to get valid SPKI-DER for Java's KeyFactory.
+ // Detection: SPKI-DER starts with 0x30 (SEQUENCE tag), C2SP prefixed data starts with 0x02.
+ if (keyBytes.length > 0 && keyBytes[0] == 0x02) {
+ // Strip C2SP version byte (first byte)
+ keyBytes = Arrays.copyOfRange(keyBytes, 1, keyBytes.length);
+ }
+
+ X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
+ return EC_KEY_FACTORY.generatePublic(keySpec);
+ }
+
+ /**
+ * Fetches a binary response from the API.
+ */
+ private byte[] fetchBinaryResponse(String path, String acceptHeader) {
+ HttpRequest request = buildBinaryRequest(path, acceptHeader);
+
+ try {
+ HttpResponse response = httpClient.send(
+ request, HttpResponse.BodyHandlers.ofByteArray());
+ String requestId = response.headers().firstValue("X-Request-Id").orElse(null);
+ String body = new String(response.body(), StandardCharsets.UTF_8);
+ throwForStatus(response.statusCode(), body, requestId);
+ return response.body();
+ } catch (IOException e) {
+ throw new AnsServerException("Network error: " + e.getMessage(), 0, e, null);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AnsServerException("Request interrupted", 0, e, null);
+ }
+ }
+
+ /**
+ * Fetches a binary response from the API asynchronously using non-blocking I/O.
+ */
+ private CompletableFuture fetchBinaryResponseAsync(String path, String acceptHeader) {
+ HttpRequest request = buildBinaryRequest(path, acceptHeader);
+
+ return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
+ .thenApply(response -> {
+ String requestId = response.headers().firstValue("X-Request-Id").orElse(null);
+ String body = new String(response.body(), StandardCharsets.UTF_8);
+ throwForStatus(response.statusCode(), body, requestId);
+ return response.body();
+ });
+ }
+
+ /**
+ * Builds an HTTP request for binary content.
+ */
+ private HttpRequest buildBinaryRequest(String path, String acceptHeader) {
+ return HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + path))
+ .header("Accept", acceptHeader)
+ .timeout(readTimeout)
+ .GET()
+ .build();
+ }
+
/**
* Fetches a transparency log entry with schema version handling.
*/
@@ -182,18 +573,16 @@ private void parseAndSetPayload(TransparencyLog result, String schemaVersion) {
}
try {
- String payloadJson = objectMapper.writeValueAsString(result.getPayload());
-
if ("V1".equalsIgnoreCase(schemaVersion)) {
- TransparencyLogV1 v1 = objectMapper.readValue(payloadJson, TransparencyLogV1.class);
+ TransparencyLogV1 v1 = objectMapper.convertValue(result.getPayload(), TransparencyLogV1.class);
result.setParsedPayload(v1);
} else {
// V0 is default for missing or unknown schema version
- TransparencyLogV0 v0 = objectMapper.readValue(payloadJson, TransparencyLogV0.class);
+ TransparencyLogV0 v0 = objectMapper.convertValue(result.getPayload(), TransparencyLogV0.class);
result.setParsedPayload(v0);
}
- } catch (IOException e) {
- // If parsing fails, leave parsedPayload as null
+ } catch (IllegalArgumentException e) {
+ // If conversion fails, leave parsedPayload as null
// The raw payload is still available
}
}
@@ -219,17 +608,24 @@ private HttpResponse sendRequest(HttpRequest request) {
* Handles error responses from the API.
*/
private void handleErrorResponse(HttpResponse response) {
- int statusCode = response.statusCode();
+ String requestId = response.headers().firstValue("X-Request-Id").orElse(null);
+ throwForStatus(response.statusCode(), response.body(), requestId);
+ }
+ /**
+ * Throws an appropriate exception for non-success HTTP status codes.
+ *
+ * @param statusCode the HTTP status code
+ * @param body the response body as a string
+ * @param requestId the request ID from headers, may be null
+ */
+ private void throwForStatus(int statusCode, String body, String requestId) {
if (statusCode >= 200 && statusCode < 300) {
return; // Success
}
- String requestId = response.headers().firstValue("X-Request-Id").orElse(null);
- String body = response.body();
-
if (statusCode == 404) {
- throw new AnsNotFoundException("Agent not found: " + body, null, null, requestId);
+ throw new AnsNotFoundException("Resource not found: " + body, null, null, requestId);
} else if (statusCode >= 500) {
throw new AnsServerException("Server error: " + body, statusCode, requestId);
} else {
@@ -253,46 +649,68 @@ private HttpRequest.Builder createRequestBuilder(String path) {
* Appends audit parameters to the path.
*/
private String appendAuditParams(String path, AgentAuditParams params) {
- StringJoiner joiner = new StringJoiner("&");
- if (params.getOffset() > 0) {
- joiner.add("offset=" + params.getOffset());
- }
- if (params.getLimit() > 0) {
- joiner.add("limit=" + params.getLimit());
- }
- if (joiner.length() > 0) {
- return path + "?" + joiner;
- }
- return path;
+ QueryParamBuilder builder = new QueryParamBuilder();
+ builder.addIfPositive("offset", params.getOffset());
+ builder.addIfPositive("limit", params.getLimit());
+ return builder.buildUrl(path);
}
/**
* Appends checkpoint history parameters to the path.
*/
private String appendCheckpointHistoryParams(String path, CheckpointHistoryParams params) {
- StringJoiner joiner = new StringJoiner("&");
- if (params.getLimit() > 0) {
- joiner.add("limit=" + params.getLimit());
- }
- if (params.getOffset() > 0) {
- joiner.add("offset=" + params.getOffset());
- }
- if (params.getFromSize() > 0) {
- joiner.add("fromSize=" + params.getFromSize());
- }
- if (params.getToSize() > 0) {
- joiner.add("toSize=" + params.getToSize());
- }
+ QueryParamBuilder builder = new QueryParamBuilder();
+ builder.addIfPositive("limit", params.getLimit());
+ builder.addIfPositive("offset", params.getOffset());
+ builder.addIfPositive("fromSize", params.getFromSize());
+ builder.addIfPositive("toSize", params.getToSize());
if (params.getSince() != null) {
String since = params.getSince().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
- joiner.add("since=" + URLEncoder.encode(since, StandardCharsets.UTF_8));
+ builder.addEncoded("since", since);
}
- if (params.getOrder() != null && !params.getOrder().isEmpty()) {
- joiner.add("order=" + URLEncoder.encode(params.getOrder(), StandardCharsets.UTF_8));
+ builder.addEncodedIfNotEmpty("order", params.getOrder());
+ return builder.buildUrl(path);
+ }
+
+ /**
+ * Helper for building URL query strings.
+ */
+ private static final class QueryParamBuilder {
+ private final StringJoiner joiner = new StringJoiner("&");
+
+ /**
+ * Adds a parameter if the value is positive.
+ */
+ void addIfPositive(String name, long value) {
+ if (value > 0) {
+ joiner.add(name + "=" + value);
+ }
}
- if (joiner.length() > 0) {
- return path + "?" + joiner;
+
+ /**
+ * Adds a URL-encoded parameter.
+ */
+ void addEncoded(String name, String value) {
+ joiner.add(name + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Adds a URL-encoded parameter if the value is not null or empty.
+ */
+ void addEncodedIfNotEmpty(String name, String value) {
+ if (value != null && !value.isEmpty()) {
+ addEncoded(name, value);
+ }
+ }
+
+ /**
+ * Builds the final URL with query string.
+ */
+ String buildUrl(String path) {
+ if (joiner.length() > 0) {
+ return path + "?" + joiner;
+ }
+ return path;
}
- return path;
}
}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseProtectedHeader.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseProtectedHeader.java
new file mode 100644
index 0000000..0e509d3
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseProtectedHeader.java
@@ -0,0 +1,84 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.util.Arrays;
+
+/**
+ * Parsed COSE protected header for SCITT receipts and status tokens.
+ *
+ * @param algorithm the signing algorithm (must be -7 for ES256)
+ * @param keyId the key identifier (4-byte truncated SHA-256 of SPKI-DER per C2SP)
+ * @param vds the Verifiable Data Structure type (1 = RFC9162_SHA256 for Merkle trees)
+ * @param cwtClaims CWT claims embedded in the protected header (optional)
+ * @param contentType the content type (optional)
+ */
+public record CoseProtectedHeader(
+ int algorithm,
+ byte[] keyId,
+ Integer vds,
+ CwtClaims cwtClaims,
+ String contentType
+) {
+
+ /**
+ * VDS type for RFC 9162 SHA-256 Merkle trees.
+ */
+ public static final int VDS_RFC9162_SHA256 = 1;
+
+ /**
+ * Returns true if this header uses the RFC 9162 Merkle tree VDS.
+ *
+ * @return true if VDS is RFC9162_SHA256
+ */
+ public boolean isRfc9162MerkleTree() {
+ return vds != null && vds == VDS_RFC9162_SHA256;
+ }
+
+ /**
+ * Returns the key ID as a hex string for logging/display.
+ *
+ * @return the key ID in hex, or null if not present
+ */
+ public String keyIdHex() {
+ if (keyId == null) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (byte b : keyId) {
+ sb.append(String.format("%02x", b & 0xFF));
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CoseProtectedHeader that = (CoseProtectedHeader) o;
+ return algorithm == that.algorithm
+ && Arrays.equals(keyId, that.keyId)
+ && java.util.Objects.equals(vds, that.vds)
+ && java.util.Objects.equals(cwtClaims, that.cwtClaims)
+ && java.util.Objects.equals(contentType, that.contentType);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = java.util.Objects.hash(algorithm, vds, cwtClaims, contentType);
+ result = 31 * result + Arrays.hashCode(keyId);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "CoseProtectedHeader{" +
+ "algorithm=" + algorithm +
+ ", keyId=" + keyIdHex() +
+ ", vds=" + vds +
+ ", contentType='" + contentType + '\'' +
+ '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1Parser.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1Parser.java
new file mode 100644
index 0000000..f090769
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1Parser.java
@@ -0,0 +1,286 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.upokecenter.cbor.CBORObject;
+import com.upokecenter.cbor.CBORType;
+
+import java.util.Objects;
+
+/**
+ * Parser for COSE_Sign1 structures (CBOR tag 18) as defined in RFC 9052.
+ *
+ * COSE_Sign1 is a CBOR structure containing:
+ *
+ * - Protected header (CBOR byte string containing encoded CBOR map)
+ * - Unprotected header (CBOR map, typically empty)
+ * - Payload (CBOR byte string or null for detached)
+ * - Signature (CBOR byte string)
+ *
+ *
+ * Security: This parser enforces ES256 (algorithm -7) as the only
+ * accepted signing algorithm to prevent algorithm substitution attacks.
+ */
+public final class CoseSign1Parser {
+
+ /**
+ * CBOR tag for COSE_Sign1 structures.
+ */
+ public static final int COSE_SIGN1_TAG = 18;
+
+ /**
+ * ES256 algorithm identifier (ECDSA with SHA-256 on P-256 curve).
+ */
+ public static final int ES256_ALGORITHM = -7;
+
+ /**
+ * Expected signature length for ES256 in IEEE P1363 format (r || s, each 32 bytes).
+ */
+ public static final int ES256_SIGNATURE_LENGTH = 64;
+
+ /**
+ * MAX_COSE_SIZE - 1MB.
+ */
+ private static final int MAX_COSE_SIZE = 1024 * 1024;
+
+ private CoseSign1Parser() {
+ // Utility class
+ }
+
+ /**
+ * Parses a COSE_Sign1 structure from raw CBOR bytes.
+ *
+ * @param coseBytes the raw COSE_Sign1 bytes
+ * @return the parsed COSE_Sign1 structure
+ * @throws ScittParseException if parsing fails or security validation fails
+ */
+ public static ParsedCoseSign1 parse(byte[] coseBytes) throws ScittParseException {
+ Objects.requireNonNull(coseBytes, "coseBytes cannot be null");
+ if (coseBytes.length > MAX_COSE_SIZE) {
+ throw new ScittParseException("COSE payload exceeds maximum size");
+ }
+ try {
+ CBORObject cborObject = CBORObject.DecodeFromBytes(coseBytes);
+ return parseFromCbor(cborObject);
+ } catch (ScittParseException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ScittParseException("Failed to decode CBOR: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Parses a COSE_Sign1 structure from a decoded CBOR object.
+ *
+ * @param cborObject the decoded CBOR object
+ * @return the parsed COSE_Sign1 structure
+ * @throws ScittParseException if parsing fails or security validation fails
+ */
+ public static ParsedCoseSign1 parseFromCbor(CBORObject cborObject) throws ScittParseException {
+ Objects.requireNonNull(cborObject, "cborObject cannot be null");
+
+ // Verify COSE_Sign1 tag
+ if (!cborObject.HasMostOuterTag(COSE_SIGN1_TAG)) {
+ throw new ScittParseException("Expected COSE_Sign1 tag (18), got: " +
+ (cborObject.getMostOuterTag() != null ? cborObject.getMostOuterTag() : "no tag"));
+ }
+
+ CBORObject untagged = cborObject.UntagOne();
+
+ // COSE_Sign1 is an array of 4 elements
+ if (untagged.getType() != CBORType.Array || untagged.size() != 4) {
+ throw new ScittParseException("COSE_Sign1 must be an array of 4 elements, got: " +
+ untagged.getType() + " with " + (untagged.getType() == CBORType.Array ? untagged.size() : 0)
+ + " elements");
+ }
+
+ // Extract components
+ byte[] protectedHeaderBytes = extractByteString(untagged, 0, "protected header");
+ CBORObject unprotectedHeader = untagged.get(1); // Keep as CBORObject, avoid encode/decode round-trip
+ byte[] payload = extractOptionalByteString(untagged, 2, "payload");
+ byte[] signature = extractByteString(untagged, 3, "signature");
+
+ // Parse protected header
+ CoseProtectedHeader protectedHeader = parseProtectedHeader(protectedHeaderBytes);
+
+ // Validate signature length for ES256
+ if (signature.length != ES256_SIGNATURE_LENGTH) {
+ throw new ScittParseException(
+ "Invalid ES256 signature length: expected " + ES256_SIGNATURE_LENGTH +
+ " bytes (IEEE P1363 format), got " + signature.length);
+ }
+
+ return new ParsedCoseSign1(
+ protectedHeaderBytes,
+ protectedHeader,
+ unprotectedHeader,
+ payload,
+ signature
+ );
+ }
+
+ /**
+ * Parses the protected header CBOR map.
+ *
+ * @param protectedHeaderBytes the encoded protected header
+ * @return the parsed protected header
+ * @throws ScittParseException if parsing fails or algorithm is not ES256
+ */
+ private static CoseProtectedHeader parseProtectedHeader(byte[] protectedHeaderBytes) throws ScittParseException {
+ if (protectedHeaderBytes == null || protectedHeaderBytes.length == 0) {
+ throw new ScittParseException("Protected header cannot be empty");
+ }
+
+ CBORObject headerMap;
+ try {
+ headerMap = CBORObject.DecodeFromBytes(protectedHeaderBytes);
+ } catch (Exception e) {
+ throw new ScittParseException("Failed to decode protected header: " + e.getMessage(), e);
+ }
+
+ if (headerMap.getType() != CBORType.Map) {
+ throw new ScittParseException("Protected header must be a CBOR map");
+ }
+
+ // Extract algorithm (label 1) - REQUIRED
+ CBORObject algObject = headerMap.get(CBORObject.FromObject(1));
+ if (algObject == null) {
+ throw new ScittParseException("Protected header missing algorithm (label 1)");
+ }
+
+ int algorithm = algObject.AsInt32();
+
+ // SECURITY: Reject non-ES256 algorithms to prevent algorithm substitution attacks
+ if (algorithm != ES256_ALGORITHM) {
+ throw new ScittParseException(
+ "Algorithm substitution attack prevented: only ES256 (alg=-7) is accepted, got alg=" + algorithm);
+ }
+
+ // Extract key ID (label 4) - Optional but expected for SCITT
+ byte[] keyId = null;
+ CBORObject kidObject = headerMap.get(CBORObject.FromObject(4));
+ if (kidObject != null && kidObject.getType() == CBORType.ByteString) {
+ keyId = kidObject.GetByteString();
+ }
+
+ // Extract VDS (Verifiable Data Structure) - label 395 per draft-ietf-cose-merkle-tree-proofs
+ Integer vds = null;
+ CBORObject vdsObject = headerMap.get(CBORObject.FromObject(395));
+ if (vdsObject != null) {
+ vds = vdsObject.AsInt32();
+ }
+
+ // Extract CWT claims if present (label 13 for cwt_claims)
+ CwtClaims cwtClaims = null;
+ CBORObject cwtObject = headerMap.get(CBORObject.FromObject(13));
+ if (cwtObject != null && cwtObject.getType() == CBORType.Map) {
+ cwtClaims = parseCwtClaims(cwtObject);
+ }
+
+ // Extract content type (label 3) if present
+ String contentType = null;
+ CBORObject ctObject = headerMap.get(CBORObject.FromObject(3));
+ if (ctObject != null) {
+ if (ctObject.getType() == CBORType.TextString) {
+ contentType = ctObject.AsString();
+ } else if (ctObject.getType() == CBORType.Integer) {
+ contentType = String.valueOf(ctObject.AsInt32());
+ }
+ }
+
+ return new CoseProtectedHeader(algorithm, keyId, vds, cwtClaims, contentType);
+ }
+
+ /**
+ * Parses CWT (CBOR Web Token) claims from a CBOR map.
+ */
+ private static CwtClaims parseCwtClaims(CBORObject cwtMap) {
+ // CWT claim labels per RFC 8392
+ Long iat = extractOptionalLong(cwtMap, 6); // iat (issued at)
+ Long exp = extractOptionalLong(cwtMap, 4); // exp (expiration)
+ Long nbf = extractOptionalLong(cwtMap, 5); // nbf (not before)
+ String iss = extractOptionalString(cwtMap, 1); // iss (issuer)
+ String sub = extractOptionalString(cwtMap, 2); // sub (subject)
+ String aud = extractOptionalString(cwtMap, 3); // aud (audience)
+
+ return new CwtClaims(iss, sub, aud, exp, nbf, iat);
+ }
+
+ private static byte[] extractByteString(CBORObject array, int index, String name) throws ScittParseException {
+ CBORObject element = array.get(index);
+ if (element == null || element.getType() != CBORType.ByteString) {
+ throw new ScittParseException(name + " must be a byte string");
+ }
+ return element.GetByteString();
+ }
+
+ private static byte[] extractOptionalByteString(CBORObject array, int index, String name)
+ throws ScittParseException {
+ CBORObject element = array.get(index);
+ if (element == null || element.isNull()) {
+ return null; // Detached payload
+ }
+ if (element.getType() != CBORType.ByteString) {
+ throw new ScittParseException(name + " must be a byte string or null");
+ }
+ return element.GetByteString();
+ }
+
+ private static Long extractOptionalLong(CBORObject map, int label) {
+ CBORObject value = map.get(CBORObject.FromObject(label));
+ if (value != null && value.isNumber()) {
+ return value.AsInt64();
+ }
+ return null;
+ }
+
+ private static String extractOptionalString(CBORObject map, int label) {
+ CBORObject value = map.get(CBORObject.FromObject(label));
+ if (value != null && value.getType() == CBORType.TextString) {
+ return value.AsString();
+ }
+ return null;
+ }
+
+ /**
+ * Constructs the Sig_structure for COSE_Sign1 signature verification.
+ *
+ * Per RFC 9052, the Sig_structure is:
+ *
+ * Sig_structure = [
+ * context : "Signature1",
+ * body_protected : empty_or_serialized_map,
+ * external_aad : bstr,
+ * payload : bstr
+ * ]
+ *
+ *
+ * @param protectedHeaderBytes the serialized protected header
+ * @param externalAad external additional authenticated data (typically empty)
+ * @param payload the payload bytes
+ * @return the encoded Sig_structure
+ */
+ public static byte[] buildSigStructure(byte[] protectedHeaderBytes, byte[] externalAad, byte[] payload) {
+ CBORObject sigStructure = CBORObject.NewArray();
+ sigStructure.Add("Signature1");
+ sigStructure.Add(protectedHeaderBytes != null ? protectedHeaderBytes : new byte[0]);
+ sigStructure.Add(externalAad != null ? externalAad : new byte[0]);
+ sigStructure.Add(payload != null ? payload : new byte[0]);
+ return sigStructure.EncodeToBytes();
+ }
+
+ /**
+ * Parsed COSE_Sign1 structure.
+ *
+ * @param protectedHeaderBytes raw bytes of the protected header (needed for signature verification)
+ * @param protectedHeader parsed protected header
+ * @param unprotectedHeader the unprotected header as a CBORObject (avoids encode/decode round-trip)
+ * @param payload the payload bytes (null if detached)
+ * @param signature the signature bytes (64 bytes for ES256 in IEEE P1363 format)
+ */
+ public record ParsedCoseSign1(
+ byte[] protectedHeaderBytes,
+ CoseProtectedHeader protectedHeader,
+ CBORObject unprotectedHeader,
+ byte[] payload,
+ byte[] signature
+ ) {}
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CwtClaims.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CwtClaims.java
new file mode 100644
index 0000000..7b029ee
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/CwtClaims.java
@@ -0,0 +1,107 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.time.Instant;
+
+/**
+ * CWT (CBOR Web Token) claims as defined in RFC 8392.
+ *
+ * These claims are embedded in SCITT status tokens to provide
+ * time-bounded assertions about agent status.
+ *
+ * @param iss issuer - identifies the principal that issued the token
+ * @param sub subject - identifies the principal that is the subject
+ * @param aud audience - identifies the recipients the token is intended for
+ * @param exp expiration time - time after which the token must not be accepted (seconds since epoch)
+ * @param nbf not before - time before which the token must not be accepted (seconds since epoch)
+ * @param iat issued at - time at which the token was issued (seconds since epoch)
+ */
+public record CwtClaims(
+ String iss,
+ String sub,
+ String aud,
+ Long exp,
+ Long nbf,
+ Long iat
+) {
+
+ /**
+ * Returns the expiration time as an Instant.
+ *
+ * @return the expiration time, or null if not set
+ */
+ public Instant expirationTime() {
+ return exp != null ? Instant.ofEpochSecond(exp) : null;
+ }
+
+ /**
+ * Returns the not-before time as an Instant.
+ *
+ * @return the not-before time, or null if not set
+ */
+ public Instant notBeforeTime() {
+ return nbf != null ? Instant.ofEpochSecond(nbf) : null;
+ }
+
+ /**
+ * Returns the issued-at time as an Instant.
+ *
+ * @return the issued-at time, or null if not set
+ */
+ public Instant issuedAtTime() {
+ return iat != null ? Instant.ofEpochSecond(iat) : null;
+ }
+
+ /**
+ * Checks if the token is expired at the given time.
+ *
+ * @param now the current time
+ * @return true if the token is expired
+ */
+ public boolean isExpired(Instant now) {
+ if (exp == null) {
+ return false; // No expiration set
+ }
+ return now.isAfter(expirationTime());
+ }
+
+ /**
+ * Checks if the token is expired at the given time with clock skew tolerance.
+ *
+ * @param now the current time
+ * @param clockSkewSeconds allowed clock skew in seconds
+ * @return true if the token is expired (accounting for clock skew)
+ */
+ public boolean isExpired(Instant now, long clockSkewSeconds) {
+ if (exp == null) {
+ return false;
+ }
+ return now.minusSeconds(clockSkewSeconds).isAfter(expirationTime());
+ }
+
+ /**
+ * Checks if the token is not yet valid at the given time.
+ *
+ * @param now the current time
+ * @return true if the token is not yet valid
+ */
+ public boolean isNotYetValid(Instant now) {
+ if (nbf == null) {
+ return false; // No not-before set
+ }
+ return now.isBefore(notBeforeTime());
+ }
+
+ /**
+ * Checks if the token is not yet valid at the given time with clock skew tolerance.
+ *
+ * @param now the current time
+ * @param clockSkewSeconds allowed clock skew in seconds
+ * @return true if the token is not yet valid (accounting for clock skew)
+ */
+ public boolean isNotYetValid(Instant now, long clockSkewSeconds) {
+ if (nbf == null) {
+ return false;
+ }
+ return now.plusSeconds(clockSkewSeconds).isBefore(notBeforeTime());
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProvider.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProvider.java
new file mode 100644
index 0000000..4eab815
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProvider.java
@@ -0,0 +1,199 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Default implementation of {@link ScittHeaderProvider}.
+ *
+ * Handles Base64 encoding/decoding of SCITT artifacts for HTTP header transport.
+ */
+public class DefaultScittHeaderProvider implements ScittHeaderProvider {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(DefaultScittHeaderProvider.class);
+
+ private final byte[] ownReceiptBytes;
+ private final byte[] ownTokenBytes;
+ // Pre-computed headers to avoid Base64 encoding on every getOutgoingHeaders() call
+ private final Map cachedOutgoingHeaders;
+
+ /**
+ * Creates a provider without own artifacts (client-only mode).
+ *
+ * Use this when only extracting SCITT artifacts from responses,
+ * not including them in requests.
+ */
+ public DefaultScittHeaderProvider() {
+ this(null, null);
+ }
+
+ /**
+ * Creates a provider with own SCITT artifacts.
+ *
+ * @param ownReceiptBytes the caller's receipt bytes (may be null)
+ * @param ownTokenBytes the caller's status token bytes (may be null)
+ */
+ public DefaultScittHeaderProvider(byte[] ownReceiptBytes, byte[] ownTokenBytes) {
+ this.ownReceiptBytes = ownReceiptBytes != null ? ownReceiptBytes.clone() : null;
+ this.ownTokenBytes = ownTokenBytes != null ? ownTokenBytes.clone() : null;
+ this.cachedOutgoingHeaders = buildOutgoingHeaders();
+ }
+
+ /**
+ * Builds and caches the outgoing headers at construction time.
+ * Base64 encoding happens once, not on every getOutgoingHeaders() call.
+ */
+ private Map buildOutgoingHeaders() {
+ if (ownReceiptBytes == null && ownTokenBytes == null) {
+ return Collections.emptyMap();
+ }
+
+ Map headers = new HashMap<>();
+
+ if (ownReceiptBytes != null) {
+ headers.put(ScittHeaders.SCITT_RECEIPT_HEADER,
+ Base64.getEncoder().encodeToString(ownReceiptBytes));
+ }
+
+ if (ownTokenBytes != null) {
+ headers.put(ScittHeaders.STATUS_TOKEN_HEADER,
+ Base64.getEncoder().encodeToString(ownTokenBytes));
+ }
+
+ return Collections.unmodifiableMap(headers);
+ }
+
+ @Override
+ public Map getOutgoingHeaders() {
+ return cachedOutgoingHeaders;
+ }
+
+ @Override
+ public Optional extractArtifacts(Map headers) {
+ Objects.requireNonNull(headers, "headers cannot be null");
+
+ String receiptHeader = getHeaderCaseInsensitive(headers, ScittHeaders.SCITT_RECEIPT_HEADER);
+ String tokenHeader = getHeaderCaseInsensitive(headers, ScittHeaders.STATUS_TOKEN_HEADER);
+
+ if (receiptHeader == null && tokenHeader == null) {
+ LOGGER.debug("No SCITT headers present in response");
+ return Optional.empty();
+ }
+
+ byte[] receiptBytes = null;
+ byte[] tokenBytes = null;
+ ScittReceipt receipt = null;
+ StatusToken statusToken = null;
+ List parseErrors = new ArrayList<>();
+
+ // Parse receipt
+ if (receiptHeader != null) {
+ try {
+ receiptBytes = Base64.getDecoder().decode(receiptHeader);
+ receipt = ScittReceipt.parse(receiptBytes);
+ LOGGER.debug("Parsed SCITT receipt ({} bytes)", receiptBytes.length);
+ } catch (IllegalArgumentException e) {
+ String error = "Invalid Base64 in receipt header: " + e.getMessage();
+ LOGGER.warn(error);
+ parseErrors.add(error);
+ } catch (ScittParseException e) {
+ String error = "Failed to parse receipt: " + e.getMessage();
+ LOGGER.warn(error);
+ parseErrors.add(error);
+ }
+ }
+
+ // Parse status token
+ if (tokenHeader != null) {
+ try {
+ tokenBytes = Base64.getDecoder().decode(tokenHeader);
+ statusToken = StatusToken.parse(tokenBytes);
+ LOGGER.debug("Parsed status token for agent {} ({} bytes)",
+ statusToken.agentId(), tokenBytes.length);
+ } catch (IllegalArgumentException e) {
+ String error = "Invalid Base64 in status token header: " + e.getMessage();
+ LOGGER.warn(error);
+ parseErrors.add(error);
+ } catch (ScittParseException e) {
+ String error = "Failed to parse status token: " + e.getMessage();
+ LOGGER.warn(error);
+ parseErrors.add(error);
+ }
+ }
+
+ if (receipt == null && statusToken == null) {
+ // Headers were present but BOTH failed to parse
+ String errorDetail = String.join("; ", parseErrors);
+ LOGGER.error("SCITT headers present but all artifacts failed to parse: {}", errorDetail);
+ throw new IllegalStateException(
+ "SCITT headers present but failed to parse: " + errorDetail);
+ }
+
+ return Optional.of(new ScittArtifacts(receipt, statusToken, receiptBytes, tokenBytes));
+ }
+
+ /**
+ * Gets a header value with case-insensitive key lookup.
+ * Headers are expected to have lowercase keys (normalized by caller).
+ */
+ private String getHeaderCaseInsensitive(Map headers, String key) {
+ return headers.get(key.toLowerCase());
+ }
+
+ /**
+ * Builder for creating DefaultScittHeaderProvider instances.
+ */
+ public static class Builder {
+ private byte[] receiptBytes;
+ private byte[] tokenBytes;
+
+ /**
+ * Sets the caller's SCITT receipt bytes.
+ *
+ * @param receiptBytes the receipt bytes
+ * @return this builder
+ */
+ public Builder receipt(byte[] receiptBytes) {
+ this.receiptBytes = receiptBytes;
+ return this;
+ }
+
+ /**
+ * Sets the caller's status token bytes.
+ *
+ * @param tokenBytes the token bytes
+ * @return this builder
+ */
+ public Builder statusToken(byte[] tokenBytes) {
+ this.tokenBytes = tokenBytes;
+ return this;
+ }
+
+ /**
+ * Builds the header provider.
+ *
+ * @return the configured provider
+ */
+ public DefaultScittHeaderProvider build() {
+ return new DefaultScittHeaderProvider(receiptBytes, tokenBytes);
+ }
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifier.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifier.java
new file mode 100644
index 0000000..867beac
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifier.java
@@ -0,0 +1,429 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.godaddy.ans.sdk.crypto.CryptoCache;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.MessageDigest;
+import java.security.PublicKey;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Default implementation of {@link ScittVerifier}.
+ *
+ * This implementation performs:
+ *
+ * - COSE_Sign1 signature verification using ES256
+ * - RFC 9162 Merkle inclusion proof verification
+ * - Status token expiry checking with clock skew tolerance
+ * - Constant-time fingerprint comparison
+ *
+ */
+public class DefaultScittVerifier implements ScittVerifier {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(DefaultScittVerifier.class);
+
+ private final Duration clockSkewTolerance;
+
+ /**
+ * Creates a new verifier with default clock skew tolerance (60 seconds).
+ */
+ public DefaultScittVerifier() {
+ this(StatusToken.DEFAULT_CLOCK_SKEW);
+ }
+
+ /**
+ * Creates a new verifier with the specified clock skew tolerance.
+ *
+ * @param clockSkewTolerance the clock skew tolerance for token expiry checks
+ */
+ public DefaultScittVerifier(Duration clockSkewTolerance) {
+ this.clockSkewTolerance = Objects.requireNonNull(clockSkewTolerance, "clockSkewTolerance cannot be null");
+ }
+
+ @Override
+ public ScittExpectation verify(
+ ScittReceipt receipt,
+ StatusToken token,
+ Map rootKeys) {
+
+ Objects.requireNonNull(receipt, "receipt cannot be null");
+ Objects.requireNonNull(token, "token cannot be null");
+ Objects.requireNonNull(rootKeys, "rootKeys cannot be null");
+
+ if (rootKeys.isEmpty()) {
+ return ScittExpectation.invalidReceipt("No root keys available for verification");
+ }
+
+ LOGGER.debug("Verifying SCITT artifacts for agent {} (have {} root keys)",
+ token.agentId(), rootKeys.size());
+
+ try {
+ // 1. Look up receipt key by key ID (O(1) map lookup)
+ String receiptKeyId = receipt.protectedHeader().keyIdHex();
+ PublicKey receiptKey = rootKeys.get(receiptKeyId);
+ if (receiptKey == null) {
+ LOGGER.warn("Receipt key ID {} not in trust store (have {} keys)",
+ receiptKeyId, rootKeys.size());
+ return ScittExpectation.invalidReceipt(
+ "Key ID " + receiptKeyId + " not in trust store (have " + rootKeys.size() + " keys)");
+ }
+ LOGGER.debug("Found receipt key with ID {}", receiptKeyId);
+
+ // 2. Verify receipt signature
+ if (!verifyReceiptSignature(receipt, receiptKey)) {
+ LOGGER.warn("Receipt signature verification failed for agent {}", token.agentId());
+ return ScittExpectation.invalidReceipt("Receipt signature verification failed");
+ }
+ LOGGER.debug("Receipt signature verified for agent {}", token.agentId());
+
+ // 3. Verify Merkle inclusion proof
+ if (!verifyMerkleProof(receipt)) {
+ LOGGER.warn("Merkle proof verification failed for agent {}", token.agentId());
+ return ScittExpectation.invalidReceipt("Merkle proof verification failed");
+ }
+ LOGGER.debug("Merkle proof verified for agent {}", token.agentId());
+
+ // 4. Look up token key by key ID (O(1) map lookup)
+ String tokenKeyId = token.protectedHeader().keyIdHex();
+ PublicKey tokenKey = rootKeys.get(tokenKeyId);
+ if (tokenKey == null) {
+ LOGGER.warn("Token key ID {} not in trust store (have {} keys)",
+ tokenKeyId, rootKeys.size());
+ return ScittExpectation.invalidToken(
+ "Key ID " + tokenKeyId + " not in trust store (have " + rootKeys.size() + " keys)");
+ }
+ LOGGER.debug("Found token key with ID {}", tokenKeyId);
+
+ // 5. Verify status token signature
+ if (!verifyTokenSignature(token, tokenKey)) {
+ LOGGER.warn("Status token signature verification failed for agent {}", token.agentId());
+ return ScittExpectation.invalidToken("Status token signature verification failed");
+ }
+ LOGGER.debug("Status token signature verified for agent {}", token.agentId());
+
+ // 6. Check status token expiry
+ Instant now = Instant.now();
+ if (token.isExpired(now, clockSkewTolerance)) {
+ LOGGER.warn("Status token expired for agent {} (expired at {})",
+ token.agentId(), token.expiresAt());
+ return ScittExpectation.expired();
+ }
+
+ // 7. Check agent status
+ if (token.status() == StatusToken.Status.REVOKED) {
+ LOGGER.warn("Agent {} is revoked", token.agentId());
+ return ScittExpectation.revoked(token.ansName());
+ }
+
+ if (token.status() != StatusToken.Status.ACTIVE &&
+ token.status() != StatusToken.Status.WARNING) {
+ LOGGER.warn("Agent {} has status {}", token.agentId(), token.status());
+ return ScittExpectation.inactive(token.status(), token.ansName());
+ }
+
+ // 8. Extract expectations
+ LOGGER.debug("SCITT verification successful for agent {}", token.agentId());
+ return ScittExpectation.verified(
+ token.serverCertFingerprints(),
+ token.identityCertFingerprints(),
+ token.agentHost(),
+ token.ansName(),
+ token.metadataHashes(),
+ token
+ );
+
+ } catch (Exception e) {
+ LOGGER.error("SCITT verification error for agent {}: {}", token.agentId(), e.getMessage());
+ return ScittExpectation.parseError("Verification error: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public ScittVerificationResult postVerify(
+ String hostname,
+ X509Certificate serverCert,
+ ScittExpectation expectation) {
+
+ Objects.requireNonNull(hostname, "hostname cannot be null");
+ Objects.requireNonNull(serverCert, "serverCert cannot be null");
+ Objects.requireNonNull(expectation, "expectation cannot be null");
+
+ // If expectation indicates failure, return error
+ if (!expectation.isVerified()) {
+ return ScittVerificationResult.error(
+ "SCITT pre-verification failed: " + expectation.failureReason());
+ }
+
+ List expectedFingerprints = expectation.validServerCertFingerprints();
+ if (expectedFingerprints.isEmpty()) {
+ return ScittVerificationResult.error("No server certificate fingerprints in expectation");
+ }
+
+ try {
+ // Compute actual fingerprint
+ String actualFingerprint = computeCertificateFingerprint(serverCert);
+
+ LOGGER.debug("Comparing certificate fingerprint {} against {} expected fingerprints",
+ truncateFingerprint(actualFingerprint), expectedFingerprints.size());
+
+ // SECURITY: Use constant-time comparison for fingerprints
+ for (String expectedFingerprint : expectedFingerprints) {
+ if (fingerprintMatches(actualFingerprint, expectedFingerprint)) {
+ LOGGER.debug("Certificate fingerprint matches for {}", hostname);
+ return ScittVerificationResult.success(actualFingerprint);
+ }
+ }
+
+ // No match found
+ LOGGER.warn("Certificate fingerprint mismatch for {}: got {}, expected one of {}",
+ hostname, truncateFingerprint(actualFingerprint), expectedFingerprints.size());
+ return ScittVerificationResult.mismatch(
+ actualFingerprint,
+ "Certificate fingerprint does not match any expected fingerprint");
+
+ } catch (Exception e) {
+ LOGGER.error("Error computing certificate fingerprint: {}", e.getMessage());
+ return ScittVerificationResult.error("Error computing fingerprint: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Verifies the receipt's COSE_Sign1 signature using the TL public key.
+ *
+ * Note: Key ID validation is performed before this method is called
+ * via the rootKeys map lookup.
+ */
+ private boolean verifyReceiptSignature(ScittReceipt receipt, PublicKey tlPublicKey) {
+ try {
+ // Build Sig_structure for verification
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(
+ receipt.protectedHeaderBytes(),
+ null, // No external AAD
+ receipt.eventPayload()
+ );
+
+ // Verify ES256 signature
+ return verifyEs256Signature(sigStructure, receipt.signature(), tlPublicKey);
+
+ } catch (Exception e) {
+ LOGGER.error("Receipt signature verification error: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Verifies the Merkle inclusion proof in the receipt.
+ */
+ private boolean verifyMerkleProof(ScittReceipt receipt) {
+ try {
+ ScittReceipt.InclusionProof proof = receipt.inclusionProof();
+
+ if (proof == null) {
+ LOGGER.error("Receipt missing inclusion proof");
+ return false;
+ }
+
+ // If we have all the components, verify the proof
+ if (proof.treeSize() > 0 && proof.rootHash() != null && receipt.eventPayload() != null) {
+ return MerkleProofVerifier.verifyInclusion(
+ receipt.eventPayload(),
+ proof.leafIndex(),
+ proof.treeSize(),
+ proof.hashPath(),
+ proof.rootHash()
+ );
+ }
+
+ // Incomplete Merkle proof data - fail verification
+ // All components are required to prove the entry exists in the append-only log
+ LOGGER.error("Incomplete Merkle proof data (treeSize={}, hasRootHash={}, hasPayload={}), " +
+ "cannot verify log inclusion",
+ proof.treeSize(),
+ proof.rootHash() != null,
+ receipt.eventPayload() != null);
+ return false;
+
+ } catch (Exception e) {
+ LOGGER.error("Merkle proof verification error: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Verifies the status token's COSE_Sign1 signature using the RA public key.
+ *
+ * Note: Key ID validation is performed before this method is called
+ * via the rootKeys map lookup.
+ */
+ private boolean verifyTokenSignature(StatusToken token, PublicKey raPublicKey) {
+ try {
+ // Build Sig_structure for verification
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(
+ token.protectedHeaderBytes(),
+ null, // No external AAD
+ token.payload()
+ );
+
+ // Verify ES256 signature
+ return verifyEs256Signature(sigStructure, token.signature(), raPublicKey);
+
+ } catch (Exception e) {
+ LOGGER.error("Token signature verification error: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Verifies an ES256 (ECDSA with SHA-256 on P-256) signature.
+ *
+ * @param data the data that was signed
+ * @param signature the signature in IEEE P1363 format (64 bytes: r || s)
+ * @param publicKey the EC public key
+ * @return true if signature is valid
+ */
+ private boolean verifyEs256Signature(byte[] data, byte[] signature, PublicKey publicKey) throws Exception {
+ // Convert IEEE P1363 format to DER format for Java's Signature API
+ byte[] derSignature = convertP1363ToDer(signature);
+
+ return CryptoCache.verifyEs256(data, derSignature, publicKey);
+ }
+
+ /**
+ * Converts an ECDSA signature from IEEE P1363 format (r || s) to DER format.
+ *
+ * Java's Signature API expects DER-encoded signatures, but COSE uses
+ * the IEEE P1363 format (fixed-size concatenation of r and s).
+ */
+ private byte[] convertP1363ToDer(byte[] p1363Signature) {
+ if (p1363Signature.length != 64) {
+ throw new IllegalArgumentException("Expected 64-byte P1363 signature, got " + p1363Signature.length);
+ }
+
+ // Split into r and s (each 32 bytes for P-256)
+ byte[] r = new byte[32];
+ byte[] s = new byte[32];
+ System.arraycopy(p1363Signature, 0, r, 0, 32);
+ System.arraycopy(p1363Signature, 32, s, 0, 32);
+
+ // Convert to DER format
+ return toDerSignature(r, s);
+ }
+
+ /**
+ * Encodes r and s as a DER SEQUENCE of two INTEGERs.
+ */
+ private byte[] toDerSignature(byte[] r, byte[] s) {
+ byte[] rDer = toDerInteger(r);
+ byte[] sDer = toDerInteger(s);
+
+ // SEQUENCE { r INTEGER, s INTEGER }
+ int totalLen = rDer.length + sDer.length;
+ byte[] der;
+
+ if (totalLen < 128) {
+ der = new byte[2 + totalLen];
+ der[0] = 0x30; // SEQUENCE
+ der[1] = (byte) totalLen;
+ System.arraycopy(rDer, 0, der, 2, rDer.length);
+ System.arraycopy(sDer, 0, der, 2 + rDer.length, sDer.length);
+ } else {
+ der = new byte[3 + totalLen];
+ der[0] = 0x30; // SEQUENCE
+ der[1] = (byte) 0x81; // Long form length
+ der[2] = (byte) totalLen;
+ System.arraycopy(rDer, 0, der, 3, rDer.length);
+ System.arraycopy(sDer, 0, der, 3 + rDer.length, sDer.length);
+ }
+
+ return der;
+ }
+
+ /**
+ * Encodes a big integer value as a DER INTEGER.
+ */
+ private byte[] toDerInteger(byte[] value) {
+ // Skip leading zeros but ensure at least one byte
+ int start = 0;
+ while (start < value.length - 1 && value[start] == 0) {
+ start++;
+ }
+
+ // Check if we need a leading zero (if high bit is set)
+ boolean needLeadingZero = (value[start] & 0x80) != 0;
+
+ int length = value.length - start;
+ if (needLeadingZero) {
+ length++;
+ }
+
+ byte[] der = new byte[2 + length];
+ der[0] = 0x02; // INTEGER
+ der[1] = (byte) length;
+
+ if (needLeadingZero) {
+ der[2] = 0x00;
+ System.arraycopy(value, start, der, 3, value.length - start);
+ } else {
+ System.arraycopy(value, start, der, 2, value.length - start);
+ }
+
+ return der;
+ }
+
+ /**
+ * Computes the SHA-256 fingerprint of an X.509 certificate.
+ */
+ private String computeCertificateFingerprint(X509Certificate cert) throws Exception {
+ byte[] digest = CryptoCache.sha256(cert.getEncoded());
+ return bytesToHex(digest);
+ }
+
+ /**
+ * Compares two fingerprints using constant-time comparison.
+ *
+ * Normalizes fingerprints to lowercase hex without colons before comparison.
+ */
+ private boolean fingerprintMatches(String actual, String expected) {
+ if (actual == null || expected == null) {
+ return false;
+ }
+
+ // Normalize: lowercase, remove colons and "SHA256:" prefix
+ String normalizedActual = normalizeFingerprint(actual);
+ String normalizedExpected = normalizeFingerprint(expected);
+
+ if (normalizedActual.length() != normalizedExpected.length()) {
+ return false;
+ }
+
+ // SECURITY: Constant-time comparison
+ byte[] actualBytes = normalizedActual.getBytes();
+ byte[] expectedBytes = normalizedExpected.getBytes();
+ return MessageDigest.isEqual(actualBytes, expectedBytes);
+ }
+
+ private String normalizeFingerprint(String fingerprint) {
+ String normalized = fingerprint.toLowerCase()
+ .replace("sha256:", "") // Remove prefix first
+ .replace(":", ""); // Then remove colons
+ return normalized;
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ return Hex.toHexString(bytes);
+ }
+
+ private static String truncateFingerprint(String fingerprint) {
+ if (fingerprint == null || fingerprint.length() <= 16) {
+ return fingerprint;
+ }
+ return fingerprint.substring(0, 16) + "...";
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifier.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifier.java
new file mode 100644
index 0000000..594f96e
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifier.java
@@ -0,0 +1,287 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.godaddy.ans.sdk.crypto.CryptoCache;
+import org.bouncycastle.util.encoders.Hex;
+
+import java.security.MessageDigest;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Verifies RFC 9162 Merkle tree inclusion proofs.
+ *
+ * This implementation follows RFC 9162 Section 2.1 for computing
+ * Merkle tree hashes and verifying inclusion proofs.
+ *
+ * Security considerations:
+ *
+ * - Uses unsigned arithmetic for tree_size and leaf_index comparisons
+ * - Validates hash path length against tree size
+ * - Uses constant-time comparison for root hash verification
+ *
+ */
+public final class MerkleProofVerifier {
+
+ /**
+ * Domain separation byte for leaf nodes (RFC 9162).
+ */
+ private static final byte LEAF_PREFIX = 0x00;
+
+ /**
+ * Domain separation byte for interior nodes (RFC 9162).
+ */
+ private static final byte NODE_PREFIX = 0x01;
+
+ /**
+ * SHA-256 hash output size in bytes.
+ */
+ private static final int HASH_SIZE = 32;
+
+ private MerkleProofVerifier() {
+ // Utility class
+ }
+
+ /**
+ * Verifies a Merkle inclusion proof.
+ *
+ * @param leafData the leaf data (will be hashed with leaf prefix)
+ * @param leafIndex the 0-based index of the leaf in the tree
+ * @param treeSize the total number of leaves in the tree
+ * @param hashPath the proof path (sibling hashes from leaf to root)
+ * @param expectedRootHash the expected root hash
+ * @return true if the proof is valid
+ * @throws ScittParseException if verification fails due to invalid parameters
+ */
+ public static boolean verifyInclusion(
+ byte[] leafData,
+ long leafIndex,
+ long treeSize,
+ List hashPath,
+ byte[] expectedRootHash) throws ScittParseException {
+
+ Objects.requireNonNull(leafData, "leafData cannot be null");
+ Objects.requireNonNull(hashPath, "hashPath cannot be null");
+ Objects.requireNonNull(expectedRootHash, "expectedRootHash cannot be null");
+
+ // Validate parameters using unsigned comparison
+ if (Long.compareUnsigned(leafIndex, treeSize) >= 0) {
+ throw new ScittParseException(
+ "Invalid leaf index: " + Long.toUnsignedString(leafIndex) +
+ " >= tree size " + Long.toUnsignedString(treeSize));
+ }
+
+ if (treeSize == 0) {
+ throw new ScittParseException("Tree size cannot be zero");
+ }
+
+ // Validate hash path length
+ int expectedPathLength = calculatePathLength(treeSize);
+ if (hashPath.size() > expectedPathLength) {
+ throw new ScittParseException(
+ "Hash path too long: " + hashPath.size() +
+ " > expected max " + expectedPathLength + " for tree size " + treeSize);
+ }
+
+ // Validate all hashes in path are correct size
+ for (int i = 0; i < hashPath.size(); i++) {
+ if (hashPath.get(i) == null || hashPath.get(i).length != HASH_SIZE) {
+ throw new ScittParseException(
+ "Invalid hash at path index " + i + ": expected " + HASH_SIZE + " bytes");
+ }
+ }
+
+ if (expectedRootHash.length != HASH_SIZE) {
+ throw new ScittParseException(
+ "Invalid expected root hash length: " + expectedRootHash.length);
+ }
+
+ // Compute leaf hash
+ byte[] computedHash = hashLeaf(leafData);
+
+ // Walk up the tree using the inclusion proof
+ computedHash = computeRootFromPath(computedHash, leafIndex, treeSize, hashPath);
+
+ // SECURITY: Use constant-time comparison
+ return MessageDigest.isEqual(computedHash, expectedRootHash);
+ }
+
+ /**
+ * Verifies a Merkle inclusion proof where the leaf hash is already computed.
+ *
+ * @param leafHash the pre-computed leaf hash
+ * @param leafIndex the 0-based index of the leaf in the tree
+ * @param treeSize the total number of leaves in the tree
+ * @param hashPath the proof path (sibling hashes from leaf to root)
+ * @param expectedRootHash the expected root hash
+ * @return true if the proof is valid
+ * @throws ScittParseException if verification fails
+ */
+ public static boolean verifyInclusionWithHash(
+ byte[] leafHash,
+ long leafIndex,
+ long treeSize,
+ List hashPath,
+ byte[] expectedRootHash) throws ScittParseException {
+
+ Objects.requireNonNull(leafHash, "leafHash cannot be null");
+ Objects.requireNonNull(hashPath, "hashPath cannot be null");
+ Objects.requireNonNull(expectedRootHash, "expectedRootHash cannot be null");
+
+ if (leafHash.length != HASH_SIZE) {
+ throw new ScittParseException("Invalid leaf hash length: " + leafHash.length);
+ }
+
+ if (Long.compareUnsigned(leafIndex, treeSize) >= 0) {
+ throw new ScittParseException(
+ "Invalid leaf index: " + Long.toUnsignedString(leafIndex) +
+ " >= tree size " + Long.toUnsignedString(treeSize));
+ }
+
+ if (treeSize == 0) {
+ throw new ScittParseException("Tree size cannot be zero");
+ }
+
+ if (expectedRootHash.length != HASH_SIZE) {
+ throw new ScittParseException(
+ "Invalid expected root hash length: " + expectedRootHash.length);
+ }
+
+ // Walk up the tree
+ byte[] computedHash = computeRootFromPath(leafHash, leafIndex, treeSize, hashPath);
+
+ // SECURITY: Use constant-time comparison
+ return MessageDigest.isEqual(computedHash, expectedRootHash);
+ }
+
+ /**
+ * Computes the root hash from a leaf and inclusion proof path.
+ *
+ * Implements the RFC 9162 algorithm for computing the root from
+ * an inclusion proof (Section 2.1.3.2):
+ *
+ *
+ * fn = leaf_index
+ * sn = tree_size - 1
+ * r = leaf_hash
+ * for each p[i] in path:
+ * if LSB(fn) == 1 OR fn == sn:
+ * r = SHA-256(0x01 || p[i] || r)
+ * while fn is not zero and LSB(fn) == 0:
+ * fn = fn >> 1
+ * sn = sn >> 1
+ * else:
+ * r = SHA-256(0x01 || r || p[i])
+ * fn = fn >> 1
+ * sn = sn >> 1
+ * verify fn == 0
+ *
+ */
+ private static byte[] computeRootFromPath(
+ byte[] leafHash,
+ long leafIndex,
+ long treeSize,
+ List hashPath) throws ScittParseException {
+
+ byte[] r = leafHash.clone();
+ long fn = leafIndex;
+ long sn = treeSize - 1;
+
+ for (byte[] p : hashPath) {
+ if ((fn & 1) == 1 || fn == sn) {
+ // Left sibling: r = H(0x01 || p || r)
+ r = hashNode(p, r);
+ // Remove consecutive right-side path bits
+ while (fn != 0 && (fn & 1) == 0) {
+ fn >>>= 1;
+ sn >>>= 1;
+ }
+ } else {
+ // Right sibling: r = H(0x01 || r || p)
+ r = hashNode(r, p);
+ }
+ fn >>>= 1;
+ sn >>>= 1;
+ }
+
+ if (fn != 0) {
+ throw new ScittParseException(
+ "Proof path too short: fn=" + fn + " after consuming all path elements");
+ }
+
+ return r;
+ }
+
+ /**
+ * Computes the hash of a leaf node.
+ *
+ * Per RFC 9162: MTH({d(0)}) = SHA-256(0x00 || d(0))
+ *
+ * @param data the leaf data
+ * @return the leaf hash
+ */
+ public static byte[] hashLeaf(byte[] data) {
+ byte[] prefixed = new byte[1 + data.length];
+ prefixed[0] = LEAF_PREFIX;
+ System.arraycopy(data, 0, prefixed, 1, data.length);
+ return CryptoCache.sha256(prefixed);
+ }
+
+ /**
+ * Computes the hash of an interior node.
+ *
+ * Per RFC 9162: MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))
+ *
+ * @param left the left child hash
+ * @param right the right child hash
+ * @return the node hash
+ */
+ public static byte[] hashNode(byte[] left, byte[] right) {
+ byte[] combined = new byte[1 + HASH_SIZE + HASH_SIZE];
+ combined[0] = NODE_PREFIX;
+ System.arraycopy(left, 0, combined, 1, HASH_SIZE);
+ System.arraycopy(right, 0, combined, 1 + HASH_SIZE, HASH_SIZE);
+ return CryptoCache.sha256(combined);
+ }
+
+ /**
+ * Calculates the expected maximum path length for a tree of the given size.
+ *
+ * For a tree with n leaves, the path length is ceil(log2(n)).
+ *
+ * @param treeSize the number of leaves
+ * @return the maximum path length
+ */
+ public static int calculatePathLength(long treeSize) {
+ if (treeSize <= 1) {
+ return 0;
+ }
+ // Use bit manipulation for ceiling of log2
+ return 64 - Long.numberOfLeadingZeros(treeSize - 1);
+ }
+
+ /**
+ * Converts a hex string to bytes.
+ *
+ * @param hex the hex string
+ * @return the byte array
+ * @throws IllegalArgumentException if hex is null or has odd length
+ */
+ public static byte[] hexToBytes(String hex) {
+ Objects.requireNonNull(hex, "hex cannot be null");
+ if (hex.length() % 2 != 0) {
+ throw new IllegalArgumentException("Hex string must have even length");
+ }
+ return Hex.decode(hex);
+ }
+
+ /**
+ * Converts bytes to a hex string.
+ *
+ * @param bytes the byte array
+ * @return the hex string (lowercase)
+ */
+ public static String bytesToHex(byte[] bytes) {
+ Objects.requireNonNull(bytes, "bytes cannot be null");
+ return Hex.toHexString(bytes);
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifier.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifier.java
new file mode 100644
index 0000000..d29bc25
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifier.java
@@ -0,0 +1,144 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.MessageDigest;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Verifies that fetched metadata matches expected hashes from SCITT status tokens.
+ *
+ * When an agent endpoint includes a metadataUrl, the status token contains
+ * a hash of that metadata. After fetching the metadata, this verifier confirms
+ * it hasn't been tampered with.
+ *
+ * Hash Format
+ * Hashes are formatted as {@code SHA256:<64-hex-chars>}
+ *
+ * Usage
+ * {@code
+ * byte[] metadataBytes = fetchMetadata(metadataUrl);
+ * String expectedHash = statusToken.metadataHashes().get("a2a");
+ *
+ * if (!MetadataHashVerifier.verify(metadataBytes, expectedHash)) {
+ * throw new SecurityException("Metadata hash mismatch");
+ * }
+ * }
+ */
+public final class MetadataHashVerifier {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(MetadataHashVerifier.class);
+
+ /**
+ * Pattern for metadata hash format: SHA256:<64 hex chars>
+ */
+ private static final Pattern HASH_PATTERN = Pattern.compile("^SHA256:([a-f0-9]{64})$", Pattern.CASE_INSENSITIVE);
+
+ private MetadataHashVerifier() {
+ // Utility class
+ }
+
+ /**
+ * Verifies that the metadata bytes match the expected hash.
+ *
+ * @param metadataBytes the fetched metadata content
+ * @param expectedHash the expected hash in format {@code SHA256:}
+ * @return true if the hash matches
+ */
+ public static boolean verify(byte[] metadataBytes, String expectedHash) {
+ Objects.requireNonNull(metadataBytes, "metadataBytes cannot be null");
+ Objects.requireNonNull(expectedHash, "expectedHash cannot be null");
+
+ // Parse expected hash
+ Matcher matcher = HASH_PATTERN.matcher(expectedHash);
+ if (!matcher.matches()) {
+ LOGGER.warn("Invalid hash format: {}", expectedHash);
+ return false;
+ }
+
+ String expectedHex = matcher.group(1).toLowerCase();
+
+ try {
+ // Compute actual hash
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] actualHash = md.digest(metadataBytes);
+ String actualHex = bytesToHex(actualHash);
+
+ // SECURITY: Use constant-time comparison
+ boolean matches = MessageDigest.isEqual(
+ actualHex.getBytes(),
+ expectedHex.getBytes()
+ );
+
+ if (!matches) {
+ LOGGER.warn("Metadata hash mismatch: expected {}, got SHA256:{}",
+ expectedHash, actualHex);
+ }
+
+ return matches;
+
+ } catch (Exception e) {
+ LOGGER.error("Error computing metadata hash: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Computes the hash of metadata bytes in the expected format.
+ *
+ * @param metadataBytes the metadata content
+ * @return the hash in format {@code SHA256:}
+ */
+ public static String computeHash(byte[] metadataBytes) {
+ Objects.requireNonNull(metadataBytes, "metadataBytes cannot be null");
+
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] hash = md.digest(metadataBytes);
+ return "SHA256:" + bytesToHex(hash);
+ } catch (Exception e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * Validates that a hash string is in the expected format.
+ *
+ * @param hash the hash string to validate
+ * @return true if the format is valid
+ */
+ public static boolean isValidHashFormat(String hash) {
+ if (hash == null) {
+ return false;
+ }
+ return HASH_PATTERN.matcher(hash).matches();
+ }
+
+ /**
+ * Extracts the hex portion from a hash string.
+ *
+ * @param hash the hash string in format {@code SHA256:}
+ * @return the hex portion, or null if format is invalid
+ */
+ public static String extractHex(String hash) {
+ if (hash == null) {
+ return null;
+ }
+ Matcher matcher = HASH_PATTERN.matcher(hash);
+ if (matcher.matches()) {
+ return matcher.group(1).toLowerCase();
+ }
+ return null;
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b & 0xFF));
+ }
+ return sb.toString();
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecision.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecision.java
new file mode 100644
index 0000000..2cd084d
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecision.java
@@ -0,0 +1,68 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.security.PublicKey;
+import java.util.Map;
+
+/**
+ * Result of a root key cache refresh decision.
+ *
+ * Used by the SCITT verification flow to determine whether a cache refresh
+ * should be attempted when a key is not found in the trust store.
+ *
+ * @param action the action to take
+ * @param reason human-readable explanation (for logging/debugging)
+ * @param keys the refreshed keys (only present if action is REFRESHED)
+ */
+public record RefreshDecision(RefreshAction action, String reason, Map keys) {
+
+ /**
+ * Actions that can be taken when a key is not found in cache.
+ */
+ public enum RefreshAction {
+ /** Refresh not allowed - artifact is invalid (too old or from future) */
+ REJECT,
+ /** Refresh not allowed now - try again later (cooldown in effect) */
+ DEFER,
+ /** Cache was refreshed - use the new keys for retry */
+ REFRESHED
+ }
+
+ /**
+ * Creates a REJECT decision indicating the artifact is invalid.
+ *
+ * @param reason explanation of why the artifact is invalid
+ * @return a REJECT decision
+ */
+ public static RefreshDecision reject(String reason) {
+ return new RefreshDecision(RefreshAction.REJECT, reason, null);
+ }
+
+ /**
+ * Creates a DEFER decision indicating refresh should be retried later.
+ *
+ * @param reason explanation of why refresh was deferred
+ * @return a DEFER decision
+ */
+ public static RefreshDecision defer(String reason) {
+ return new RefreshDecision(RefreshAction.DEFER, reason, null);
+ }
+
+ /**
+ * Creates a REFRESHED decision with the new keys.
+ *
+ * @param keys the refreshed root keys
+ * @return a REFRESHED decision
+ */
+ public static RefreshDecision refreshed(Map keys) {
+ return new RefreshDecision(RefreshAction.REFRESHED, null, keys);
+ }
+
+ /**
+ * Returns true if the cache was successfully refreshed.
+ *
+ * @return true if action is REFRESHED
+ */
+ public boolean isRefreshed() {
+ return action == RefreshAction.REFRESHED;
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManager.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManager.java
new file mode 100644
index 0000000..b6d9085
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManager.java
@@ -0,0 +1,457 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import com.godaddy.ans.sdk.concurrent.AnsExecutors;
+import com.godaddy.ans.sdk.transparency.TransparencyClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Manages SCITT artifact lifecycle including fetching, caching, and background refresh.
+ *
+ * Intended use case: This class is designed for server-side or
+ * proactive-fetch scenarios where an agent needs to pre-fetch and cache its
+ * own SCITT artifacts to include in outgoing HTTP response headers. It is not
+ * used in the client verification flow, which extracts artifacts from incoming HTTP headers
+ * via {@link ScittHeaderProvider}.
+ *
+ * This manager handles:
+ *
+ * - Fetching receipts and status tokens from the transparency log
+ * - Caching artifacts to avoid redundant network calls
+ * - Background refresh of status tokens before expiry
+ * - Graceful shutdown of background tasks
+ *
+ *
+ * Server-Side Usage
+ * {@code
+ * // On agent startup
+ * ScittArtifactManager manager = ScittArtifactManager.builder()
+ * .transparencyClient(client)
+ * .build();
+ *
+ * // Start background refresh to keep token fresh
+ * manager.startBackgroundRefresh(myAgentId);
+ *
+ * // When handling requests, get pre-computed Base64 strings for response headers
+ * String receiptBase64 = manager.getReceiptBase64(myAgentId).join();
+ * String tokenBase64 = manager.getStatusTokenBase64(myAgentId).join();
+ * response.addHeader("X-SCITT-Receipt", receiptBase64);
+ * response.addHeader("X-ANS-Status-Token", tokenBase64);
+ *
+ * // On shutdown
+ * manager.close();
+ * }
+ *
+ * @see ScittHeaderProvider#getOutgoingHeaders()
+ * @see TransparencyClient#getReceiptAsync(String)
+ * @see TransparencyClient#getStatusTokenAsync(String)
+ * @see ScittVerifierAdapter for client-side verification
+ */
+public class ScittArtifactManager implements AutoCloseable {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ScittArtifactManager.class);
+
+ private static final int DEFAULT_CACHE_SIZE = 1000;
+
+ private final TransparencyClient transparencyClient;
+ private final ScheduledExecutorService scheduler;
+ private final Executor ioExecutor;
+ private final boolean ownsScheduler;
+
+ // Caffeine caches with automatic stampede prevention
+ private final AsyncLoadingCache receiptCache;
+ private final AsyncLoadingCache tokenCache;
+
+ // Background refresh tracking
+ private final Map> refreshTasks;
+
+ private volatile boolean closed = false;
+
+ private ScittArtifactManager(Builder builder) {
+ this.transparencyClient = Objects.requireNonNull(builder.transparencyClient,
+ "transparencyClient cannot be null");
+
+ if (builder.scheduler != null) {
+ this.scheduler = builder.scheduler;
+ this.ownsScheduler = false;
+ } else {
+ this.scheduler = AnsExecutors.newSingleThreadScheduledExecutor();
+ this.ownsScheduler = true;
+ }
+
+ // Use shared I/O executor for blocking HTTP work - keeps scheduler thread free for timing
+ this.ioExecutor = AnsExecutors.sharedIoExecutor();
+
+ // Receipts are immutable Merkle proofs - cache indefinitely, evict only by LRU
+ this.receiptCache = Caffeine.newBuilder()
+ .maximumSize(DEFAULT_CACHE_SIZE)
+ .executor(ioExecutor)
+ .buildAsync(this::loadReceipt);
+
+ // Build token cache with dynamic expiry based on token's expiresAt()
+ this.tokenCache = Caffeine.newBuilder()
+ .maximumSize(DEFAULT_CACHE_SIZE)
+ .expireAfter(new StatusTokenExpiry())
+ .executor(ioExecutor)
+ .buildAsync(this::loadToken);
+
+ this.refreshTasks = new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fetches the SCITT receipt for an agent.
+ *
+ * Receipts are cached indefinitely since they are immutable Merkle inclusion proofs.
+ * Concurrent callers share a single in-flight fetch to prevent stampedes.
+ *
+ * @param agentId the agent's unique identifier
+ * @return future containing the receipt
+ */
+ public CompletableFuture getReceipt(String agentId) {
+ Objects.requireNonNull(agentId, "agentId cannot be null");
+
+ if (closed) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("ScittArtifactManager is closed"));
+ }
+
+ return receiptCache.get(agentId).thenApply(CachedReceipt::receipt);
+ }
+
+ /**
+ * Fetches the Base64-encoded SCITT receipt for an agent.
+ *
+ * This method returns the pre-computed Base64 string ready for use in
+ * HTTP headers. The Base64 encoding is computed once at cache-fill time,
+ * avoiding byte array allocation on each call.
+ *
+ * @param agentId the agent's unique identifier
+ * @return future containing the Base64-encoded receipt
+ */
+ public CompletableFuture getReceiptBase64(String agentId) {
+ Objects.requireNonNull(agentId, "agentId cannot be null");
+
+ if (closed) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("ScittArtifactManager is closed"));
+ }
+
+ return receiptCache.get(agentId).thenApply(CachedReceipt::base64);
+ }
+
+ /**
+ * Fetches the status token for an agent.
+ *
+ * Tokens are cached but have shorter TTL based on their expiry time.
+ *
+ * @param agentId the agent's unique identifier
+ * @return future containing the status token
+ */
+ public CompletableFuture getStatusToken(String agentId) {
+ Objects.requireNonNull(agentId, "agentId cannot be null");
+
+ if (closed) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("ScittArtifactManager is closed"));
+ }
+
+ return tokenCache.get(agentId).thenApply(CachedToken::token);
+ }
+
+ /**
+ * Fetches the Base64-encoded status token for an agent.
+ *
+ * This method returns the pre-computed Base64 string ready for use in
+ * HTTP headers. The Base64 encoding is computed once at cache-fill time,
+ * avoiding byte array allocation on each call.
+ *
+ * @param agentId the agent's unique identifier
+ * @return future containing the Base64-encoded status token
+ */
+ public CompletableFuture getStatusTokenBase64(String agentId) {
+ Objects.requireNonNull(agentId, "agentId cannot be null");
+
+ if (closed) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("ScittArtifactManager is closed"));
+ }
+
+ return tokenCache.get(agentId).thenApply(CachedToken::base64);
+ }
+
+ /**
+ * Starts background refresh for an agent's status token.
+ *
+ * The refresh interval is computed as (exp - iat) / 2 from the token,
+ * ensuring the token is refreshed before expiry.
+ *
+ * @param agentId the agent's unique identifier
+ */
+ public void startBackgroundRefresh(String agentId) {
+ Objects.requireNonNull(agentId, "agentId cannot be null");
+
+ if (closed) {
+ LOGGER.warn("Cannot start background refresh - manager is closed");
+ return;
+ }
+
+ // Get current token to compute refresh interval
+ CachedToken cached = tokenCache.synchronous().getIfPresent(agentId);
+ Duration refreshInterval = cached != null
+ ? cached.token().computeRefreshInterval()
+ : Duration.ofMinutes(5);
+
+ scheduleRefresh(agentId, refreshInterval);
+ }
+
+ /**
+ * Stops background refresh for an agent.
+ *
+ * @param agentId the agent's unique identifier
+ */
+ public void stopBackgroundRefresh(String agentId) {
+ ScheduledFuture> task = refreshTasks.remove(agentId);
+ if (task != null) {
+ task.cancel(false);
+ LOGGER.debug("Stopped background refresh for agent {}", agentId);
+ }
+ }
+
+ /**
+ * Clears all cached artifacts for an agent.
+ *
+ * @param agentId the agent's unique identifier
+ */
+ public void clearCache(String agentId) {
+ receiptCache.synchronous().invalidate(agentId);
+ tokenCache.synchronous().invalidate(agentId);
+ LOGGER.debug("Cleared cache for agent {}", agentId);
+ }
+
+ /**
+ * Clears all cached artifacts.
+ */
+ public void clearAllCaches() {
+ receiptCache.synchronous().invalidateAll();
+ tokenCache.synchronous().invalidateAll();
+ LOGGER.info("Cleared all SCITT artifact caches");
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+
+ closed = true;
+ LOGGER.info("Shutting down ScittArtifactManager");
+
+ // Cancel all refresh tasks
+ refreshTasks.values().forEach(task -> task.cancel(false));
+ refreshTasks.clear();
+
+ // Shutdown scheduler if we own it
+ if (ownsScheduler) {
+ scheduler.shutdown();
+ try {
+ if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+ scheduler.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ scheduler.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ clearAllCaches();
+ }
+
+ // ==================== Cache Loaders ====================
+
+ private CachedReceipt loadReceipt(String agentId) {
+ LOGGER.info("Fetching receipt for agent from TL {}", agentId);
+ try {
+ byte[] receiptBytes = transparencyClient.getReceipt(agentId);
+ ScittReceipt receipt = ScittReceipt.parse(receiptBytes);
+ LOGGER.info("Fetched and cached receipt for agent {} from TL", agentId);
+ return new CachedReceipt(receipt, receiptBytes);
+ } catch (Exception e) {
+ LOGGER.error("Failed to fetch receipt for agent {}: {}", agentId, e.getMessage());
+ throw new ScittFetchException(
+ "Failed to fetch receipt: " + e.getMessage(), e,
+ ScittFetchException.ArtifactType.RECEIPT, agentId);
+ }
+ }
+
+ private CachedToken loadToken(String agentId) {
+ LOGGER.info("Fetching status token for agent {}", agentId);
+ try {
+ byte[] tokenBytes = transparencyClient.getStatusToken(agentId);
+ StatusToken token = StatusToken.parse(tokenBytes);
+ LOGGER.info("Fetched and cached status token for agent {} (expires {})",
+ agentId, token.expiresAt());
+ return new CachedToken(token, tokenBytes);
+ } catch (Exception e) {
+ LOGGER.error("Failed to fetch status token for agent {}: {}", agentId, e.getMessage());
+ throw new ScittFetchException(
+ "Failed to fetch status token: " + e.getMessage(), e,
+ ScittFetchException.ArtifactType.STATUS_TOKEN, agentId);
+ }
+ }
+
+ // ==================== Background Refresh ====================
+
+ private void scheduleRefresh(String agentId, Duration interval) {
+ // Cancel existing task if any
+ stopBackgroundRefresh(agentId);
+
+ if (closed) {
+ return;
+ }
+
+ LOGGER.debug("Scheduling status token refresh for agent {} in {}", agentId, interval);
+
+ // Use schedule() instead of scheduleAtFixedRate() so we can adjust interval after each refresh
+ ScheduledFuture> task = scheduler.schedule(
+ () -> refreshToken(agentId),
+ interval.toMillis(),
+ TimeUnit.MILLISECONDS
+ );
+
+ refreshTasks.put(agentId, task);
+ }
+
+ private void refreshToken(String agentId) {
+ if (closed) {
+ return;
+ }
+
+ LOGGER.debug("Background refresh triggered for agent {}", agentId);
+
+ // Use Caffeine's refresh which handles stampede prevention
+ tokenCache.synchronous().refresh(agentId);
+
+ // Reschedule with new interval based on refreshed token
+ CachedToken refreshed = tokenCache.synchronous().getIfPresent(agentId);
+ if (refreshed != null && !closed) {
+ Duration newInterval = refreshed.token().computeRefreshInterval();
+ scheduleRefresh(agentId, newInterval);
+ }
+ }
+
+ // ==================== Caffeine Expiry for Status Tokens ====================
+
+ /**
+ * Custom expiry that uses the token's own expiration time.
+ */
+ private static class StatusTokenExpiry implements Expiry {
+ @Override
+ public long expireAfterCreate(String key, CachedToken value, long currentTime) {
+ if (value.token().isExpired()) {
+ return 0; // Already expired
+ }
+ Duration remaining = Duration.between(Instant.now(), value.token().expiresAt());
+ return Math.max(0, remaining.toNanos());
+ }
+
+ @Override
+ public long expireAfterUpdate(String key, CachedToken value,
+ long currentTime, long currentDuration) {
+ return expireAfterCreate(key, value, currentTime);
+ }
+
+ @Override
+ public long expireAfterRead(String key, CachedToken value,
+ long currentTime, long currentDuration) {
+ return currentDuration; // No change on read
+ }
+ }
+
+ // ==================== Cache Entry Records ====================
+
+ /**
+ * Cached receipt with pre-computed Base64 for header encoding.
+ */
+ private record CachedReceipt(ScittReceipt receipt, String base64) {
+ CachedReceipt(ScittReceipt receipt, byte[] rawBytes) {
+ this(receipt, Base64.getEncoder().encodeToString(rawBytes));
+ }
+ }
+
+ /**
+ * Cached status token with pre-computed Base64 for header encoding.
+ */
+ private record CachedToken(StatusToken token, String base64) {
+ CachedToken(StatusToken token, byte[] rawBytes) {
+ this(token, Base64.getEncoder().encodeToString(rawBytes));
+ }
+ }
+
+ // ==================== Builder ====================
+
+ /**
+ * Builder for ScittArtifactManager.
+ */
+ public static class Builder {
+ private TransparencyClient transparencyClient;
+ private ScheduledExecutorService scheduler;
+
+ /**
+ * Sets the transparency client for fetching artifacts.
+ *
+ * @param client the transparency client
+ * @return this builder
+ */
+ public Builder transparencyClient(TransparencyClient client) {
+ this.transparencyClient = client;
+ return this;
+ }
+
+ /**
+ * Sets a custom scheduler for background refresh.
+ *
+ * If not set, a single-threaded scheduler will be created
+ * and managed by this manager.
+ *
+ * @param scheduler the scheduler
+ * @return this builder
+ */
+ public Builder scheduler(ScheduledExecutorService scheduler) {
+ this.scheduler = scheduler;
+ return this;
+ }
+
+ /**
+ * Builds the ScittArtifactManager.
+ *
+ * @return the configured manager
+ */
+ public ScittArtifactManager build() {
+ return new ScittArtifactManager(this);
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectation.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectation.java
new file mode 100644
index 0000000..81645c8
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectation.java
@@ -0,0 +1,305 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Expected verification state from SCITT artifacts (receipt + status token).
+ *
+ * This class uses factory methods to ensure valid state combinations
+ * and prevent construction of invalid expectations.
+ */
+public final class ScittExpectation {
+
+ /**
+ * Verification status from SCITT artifacts.
+ */
+ public enum Status {
+ /** Both receipt and status token verified successfully */
+ VERIFIED,
+ /** Receipt signature or Merkle proof invalid */
+ INVALID_RECEIPT,
+ /** Status token signature invalid or malformed */
+ INVALID_TOKEN,
+ /** Status token has expired */
+ TOKEN_EXPIRED,
+ /** Agent status is REVOKED */
+ AGENT_REVOKED,
+ /** Agent status is not ACTIVE (WARNING, DEPRECATED, EXPIRED) */
+ AGENT_INACTIVE,
+ /** Required public key not found */
+ KEY_NOT_FOUND,
+ /** SCITT artifacts not present (no headers) */
+ NOT_PRESENT,
+ /** Parse error in SCITT artifacts */
+ PARSE_ERROR
+ }
+
+ private final Status status;
+ private final List validServerCertFingerprints;
+ private final List validIdentityCertFingerprints;
+ private final String agentHost;
+ private final String ansName;
+ private final Map metadataHashes;
+ private final String failureReason;
+ private final StatusToken statusToken;
+
+ private ScittExpectation(
+ Status status,
+ List validServerCertFingerprints,
+ List validIdentityCertFingerprints,
+ String agentHost,
+ String ansName,
+ Map metadataHashes,
+ String failureReason,
+ StatusToken statusToken) {
+ this.status = Objects.requireNonNull(status, "status cannot be null");
+ this.validServerCertFingerprints = validServerCertFingerprints != null
+ ? List.copyOf(validServerCertFingerprints) : List.of();
+ this.validIdentityCertFingerprints = validIdentityCertFingerprints != null
+ ? List.copyOf(validIdentityCertFingerprints) : List.of();
+ this.agentHost = agentHost;
+ this.ansName = ansName;
+ this.metadataHashes = metadataHashes != null ? Map.copyOf(metadataHashes) : Map.of();
+ this.failureReason = failureReason;
+ this.statusToken = statusToken;
+ }
+
+ // ==================== Factory Methods ====================
+
+ /**
+ * Creates a verified expectation with all valid data.
+ *
+ * @param serverCertFingerprints valid server certificate fingerprints
+ * @param identityCertFingerprints valid identity certificate fingerprints
+ * @param agentHost the agent's host
+ * @param ansName the agent's ANS name
+ * @param metadataHashes the metadata hashes
+ * @param statusToken the verified status token
+ * @return verified expectation
+ */
+ public static ScittExpectation verified(
+ List serverCertFingerprints,
+ List identityCertFingerprints,
+ String agentHost,
+ String ansName,
+ Map metadataHashes,
+ StatusToken statusToken) {
+ return new ScittExpectation(
+ Status.VERIFIED,
+ serverCertFingerprints,
+ identityCertFingerprints,
+ agentHost,
+ ansName,
+ metadataHashes,
+ null,
+ statusToken
+ );
+ }
+
+ /**
+ * Creates an expectation indicating invalid receipt.
+ *
+ * @param reason the failure reason
+ * @return invalid receipt expectation
+ */
+ public static ScittExpectation invalidReceipt(String reason) {
+ return new ScittExpectation(
+ Status.INVALID_RECEIPT,
+ null, null, null, null, null,
+ reason,
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating invalid status token.
+ *
+ * @param reason the failure reason
+ * @return invalid token expectation
+ */
+ public static ScittExpectation invalidToken(String reason) {
+ return new ScittExpectation(
+ Status.INVALID_TOKEN,
+ null, null, null, null, null,
+ reason,
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating expired status token.
+ *
+ * @return expired token expectation
+ */
+ public static ScittExpectation expired() {
+ return new ScittExpectation(
+ Status.TOKEN_EXPIRED,
+ null, null, null, null, null,
+ "Status token has expired",
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating agent is revoked.
+ *
+ * @param ansName the revoked agent's ANS name
+ * @return revoked agent expectation
+ */
+ public static ScittExpectation revoked(String ansName) {
+ return new ScittExpectation(
+ Status.AGENT_REVOKED,
+ null, null, null, ansName, null,
+ "Agent registration has been revoked",
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating agent is not active.
+ *
+ * @param status the agent's actual status
+ * @param ansName the agent's ANS name
+ * @return inactive agent expectation
+ */
+ public static ScittExpectation inactive(StatusToken.Status status, String ansName) {
+ return new ScittExpectation(
+ Status.AGENT_INACTIVE,
+ null, null, null, ansName, null,
+ "Agent status is " + status,
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating required key not found.
+ *
+ * @param reason the failure reason
+ * @return key not found expectation
+ */
+ public static ScittExpectation keyNotFound(String reason) {
+ return new ScittExpectation(
+ Status.KEY_NOT_FOUND,
+ null, null, null, null, null,
+ reason,
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating SCITT artifacts not present.
+ *
+ * @return not present expectation
+ */
+ public static ScittExpectation notPresent() {
+ return new ScittExpectation(
+ Status.NOT_PRESENT,
+ null, null, null, null, null,
+ "SCITT headers not present in response",
+ null
+ );
+ }
+
+ /**
+ * Creates an expectation indicating parse error.
+ *
+ * @param reason the parse error reason
+ * @return parse error expectation
+ */
+ public static ScittExpectation parseError(String reason) {
+ return new ScittExpectation(
+ Status.PARSE_ERROR,
+ null, null, null, null, null,
+ reason,
+ null
+ );
+ }
+
+ // ==================== Accessors ====================
+
+ public Status status() {
+ return status;
+ }
+
+ public List validServerCertFingerprints() {
+ return validServerCertFingerprints;
+ }
+
+ public List validIdentityCertFingerprints() {
+ return validIdentityCertFingerprints;
+ }
+
+ public String agentHost() {
+ return agentHost;
+ }
+
+ public String ansName() {
+ return ansName;
+ }
+
+ public Map metadataHashes() {
+ return metadataHashes;
+ }
+
+ public String failureReason() {
+ return failureReason;
+ }
+
+ public StatusToken statusToken() {
+ return statusToken;
+ }
+
+ /**
+ * Returns true if SCITT verification was successful.
+ *
+ * @return true if verified
+ */
+ public boolean isVerified() {
+ return status == Status.VERIFIED;
+ }
+
+ /**
+ * Returns true if SCITT satus NOT_FOUND.
+ *
+ * @return true if verified
+ */
+ public boolean isKeyNotFound() {
+ return status == Status.KEY_NOT_FOUND;
+ }
+
+ /**
+ * Returns true if this expectation represents a failure that should block the connection.
+ *
+ * @return true if this is a blocking failure
+ */
+ public boolean shouldFail() {
+ return switch (status) {
+ case VERIFIED -> false;
+ case NOT_PRESENT -> false; // Not a failure, just means fallback to badge
+ case INVALID_RECEIPT, INVALID_TOKEN, TOKEN_EXPIRED,
+ AGENT_REVOKED, AGENT_INACTIVE, KEY_NOT_FOUND, PARSE_ERROR -> true;
+ };
+ }
+
+ /**
+ * Returns true if SCITT artifacts were not present (should fall back to badge).
+ *
+ * @return true if not present
+ */
+ public boolean isNotPresent() {
+ return status == Status.NOT_PRESENT;
+ }
+
+ @Override
+ public String toString() {
+ if (status == Status.VERIFIED) {
+ return "ScittExpectation{status=VERIFIED, ansName='" + ansName +
+ "', serverCerts=" + validServerCertFingerprints.size() +
+ ", identityCerts=" + validIdentityCertFingerprints.size() + "}";
+ }
+ return "ScittExpectation{status=" + status +
+ ", reason='" + failureReason + "'}";
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchException.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchException.java
new file mode 100644
index 0000000..ee2d950
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchException.java
@@ -0,0 +1,70 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+/**
+ * Exception thrown when fetching SCITT artifacts fails.
+ *
+ * This exception is thrown when operations like fetching receipts or
+ * status tokens from the transparency log encounter errors.
+ */
+public class ScittFetchException extends RuntimeException {
+
+ /**
+ * The type of artifact that failed to fetch.
+ */
+ public enum ArtifactType {
+ /** SCITT receipt (Merkle inclusion proof) */
+ RECEIPT,
+ /** Status token (time-bounded status assertion) */
+ STATUS_TOKEN,
+ /** Public key from TL or RA */
+ PUBLIC_KEY
+ }
+
+ private final ArtifactType artifactType;
+ private final String agentId;
+
+ /**
+ * Creates a new ScittFetchException.
+ *
+ * @param message the error message
+ * @param artifactType the type of artifact that failed to fetch
+ * @param agentId the agent ID (may be null for public key fetches)
+ */
+ public ScittFetchException(String message, ArtifactType artifactType, String agentId) {
+ super(message);
+ this.artifactType = artifactType;
+ this.agentId = agentId;
+ }
+
+ /**
+ * Creates a new ScittFetchException with a cause.
+ *
+ * @param message the error message
+ * @param cause the underlying cause
+ * @param artifactType the type of artifact that failed to fetch
+ * @param agentId the agent ID (may be null for public key fetches)
+ */
+ public ScittFetchException(String message, Throwable cause, ArtifactType artifactType, String agentId) {
+ super(message, cause);
+ this.artifactType = artifactType;
+ this.agentId = agentId;
+ }
+
+ /**
+ * Returns the type of artifact that failed to fetch.
+ *
+ * @return the artifact type
+ */
+ public ArtifactType getArtifactType() {
+ return artifactType;
+ }
+
+ /**
+ * Returns the agent ID for which the fetch failed.
+ *
+ * @return the agent ID, or null for public key fetches
+ */
+ public String getAgentId() {
+ return agentId;
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaderProvider.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaderProvider.java
new file mode 100644
index 0000000..49a0fa3
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaderProvider.java
@@ -0,0 +1,77 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Provider for SCITT HTTP headers.
+ *
+ * This interface is used by HTTP clients to:
+ *
+ * - Include SCITT artifacts in outgoing requests (for servers to verify callers)
+ * - Extract SCITT artifacts from incoming responses (for clients to verify servers)
+ *
+ *
+ * Usage in HTTP Client
+ * {@code
+ * // Before sending request
+ * Map headers = scittProvider.getOutgoingHeaders();
+ * request.headers().putAll(headers);
+ *
+ * // After receiving response
+ * ScittArtifacts artifacts = scittProvider.extractArtifacts(response.headers());
+ * if (artifacts.isPresent()) {
+ * ScittExpectation expectation = verifier.verify(
+ * artifacts.receipt(), artifacts.statusToken(), tlKey, raKey);
+ * }
+ * }
+ */
+public interface ScittHeaderProvider {
+
+ /**
+ * Returns headers to include in outgoing requests.
+ *
+ * These headers contain the caller's own SCITT artifacts for
+ * the server to verify the caller's identity.
+ *
+ * @return map of header names to Base64-encoded values
+ */
+ Map getOutgoingHeaders();
+
+ /**
+ * Extracts SCITT artifacts from incoming response headers.
+ *
+ * @param headers the response headers
+ * @return the extracted artifacts, or empty if not present
+ */
+ Optional extractArtifacts(Map headers);
+
+ /**
+ * Extracted SCITT artifacts from HTTP headers.
+ *
+ * @param receipt the parsed SCITT receipt (null if not present)
+ * @param statusToken the parsed status token (null if not present)
+ * @param receiptBytes raw receipt bytes for caching
+ * @param tokenBytes raw token bytes for caching
+ */
+ record ScittArtifacts(
+ ScittReceipt receipt,
+ StatusToken statusToken,
+ byte[] receiptBytes,
+ byte[] tokenBytes
+ ) {
+ /**
+ * Returns true if both receipt and status token are present.
+ */
+ public boolean isComplete() {
+ return receipt != null && statusToken != null;
+ }
+
+ /**
+ * Returns true if at least one artifact is present.
+ */
+ public boolean isPresent() {
+ return receipt != null || statusToken != null;
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaders.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaders.java
new file mode 100644
index 0000000..f34c3b4
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittHeaders.java
@@ -0,0 +1,30 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+/**
+ * HTTP header constants for SCITT artifact delivery.
+ *
+ * SCITT artifacts (receipts and status tokens) are delivered via HTTP headers
+ * to eliminate live Transparency Log queries during connection establishment.
+ */
+public final class ScittHeaders {
+
+ /**
+ * HTTP header for SCITT receipt (Base64-encoded COSE_Sign1).
+ *
+ * Contains the cryptographic proof that the agent's registration
+ * was included in the Transparency Log.
+ */
+ public static final String SCITT_RECEIPT_HEADER = "x-scitt-receipt";
+
+ /**
+ * HTTP header for ANS status token (Base64-encoded COSE_Sign1).
+ *
+ * Contains a time-bounded assertion of the agent's current status,
+ * including valid certificate fingerprints.
+ */
+ public static final String STATUS_TOKEN_HEADER = "x-ans-status-token";
+
+ private ScittHeaders() {
+ // Constants class
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittParseException.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittParseException.java
new file mode 100644
index 0000000..88e4ff4
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittParseException.java
@@ -0,0 +1,26 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+/**
+ * Exception thrown when parsing SCITT artifacts (receipts, status tokens) fails.
+ */
+public class ScittParseException extends Exception {
+
+ /**
+ * Creates a new parse exception with the specified message.
+ *
+ * @param message the error message
+ */
+ public ScittParseException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new parse exception with the specified message and cause.
+ *
+ * @param message the error message
+ * @param cause the underlying cause
+ */
+ public ScittParseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResult.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResult.java
new file mode 100644
index 0000000..2edc659
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResult.java
@@ -0,0 +1,57 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+/**
+ * Result of SCITT pre-verification from HTTP response headers.
+ *
+ * This record captures the outcome of extracting and verifying SCITT artifacts
+ * (receipts and status tokens) from HTTP headers before post-verification of
+ * the TLS certificate.
+ *
+ * @param expectation the SCITT expectation containing valid fingerprints and status
+ * @param receipt the parsed SCITT receipt (may be null if not present or parsing failed)
+ * @param statusToken the parsed status token (may be null if not present or parsing failed)
+ * @param isPresent true if SCITT headers were present in the response
+ */
+public record ScittPreVerifyResult(
+ ScittExpectation expectation,
+ ScittReceipt receipt,
+ StatusToken statusToken,
+ boolean isPresent
+) {
+
+ /**
+ * Creates a result indicating SCITT headers were not present in the response.
+ *
+ * @return a result with isPresent=false and a NOT_PRESENT expectation
+ */
+ public static ScittPreVerifyResult notPresent() {
+ return new ScittPreVerifyResult(ScittExpectation.notPresent(), null, null, false);
+ }
+
+ /**
+ * Creates a result indicating a parse error occurred.
+ *
+ * @param errorMessage the error message
+ * @return a result with isPresent=true but a PARSE_ERROR expectation
+ */
+ public static ScittPreVerifyResult parseError(String errorMessage) {
+ return new ScittPreVerifyResult(
+ ScittExpectation.parseError(errorMessage),
+ null, null, true);
+ }
+
+ /**
+ * Creates a successful pre-verification result.
+ *
+ * @param expectation the verified expectation
+ * @param receipt the parsed receipt
+ * @param statusToken the parsed status token
+ * @return a result with isPresent=true and the verified expectation
+ */
+ public static ScittPreVerifyResult verified(
+ ScittExpectation expectation,
+ ScittReceipt receipt,
+ StatusToken statusToken) {
+ return new ScittPreVerifyResult(expectation, receipt, statusToken, true);
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceipt.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceipt.java
new file mode 100644
index 0000000..284c70f
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceipt.java
@@ -0,0 +1,256 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.upokecenter.cbor.CBORObject;
+import com.upokecenter.cbor.CBORType;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * SCITT Receipt - a COSE_Sign1 structure containing a Merkle inclusion proof.
+ *
+ * A SCITT receipt proves that a specific event was included in the
+ * transparency log at a specific tree version. The receipt contains:
+ *
+ * - Protected header with TL public key ID and VDS type
+ * - Inclusion proof (tree size, leaf index, hash path)
+ * - The event payload (JCS-canonicalized)
+ * - TL signature over the Sig_structure
+ *
+ *
+ * @param protectedHeader the parsed COSE protected header
+ * @param protectedHeaderBytes raw protected header bytes (for signature verification)
+ * @param inclusionProof the Merkle tree inclusion proof
+ * @param eventPayload the JCS-canonicalized event data
+ * @param signature the TL signature (64 bytes ES256 in IEEE P1363 format)
+ */
+public record ScittReceipt(
+ CoseProtectedHeader protectedHeader,
+ byte[] protectedHeaderBytes,
+ InclusionProof inclusionProof,
+ byte[] eventPayload,
+ byte[] signature
+) {
+
+ /**
+ * Merkle tree inclusion proof extracted from the receipt.
+ *
+ * @param treeSize the total number of leaves when this leaf was added
+ * @param leafIndex the 0-based index of the leaf
+ * @param rootHash the root hash at the time of inclusion
+ * @param hashPath the sibling hashes from leaf to root
+ */
+ public record InclusionProof(
+ long treeSize,
+ long leafIndex,
+ byte[] rootHash,
+ List hashPath
+ ) {
+ public InclusionProof {
+ hashPath = hashPath != null ? List.copyOf(hashPath) : List.of();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ InclusionProof that = (InclusionProof) o;
+ if (treeSize != that.treeSize || leafIndex != that.leafIndex) {
+ return false;
+ }
+ if (!Arrays.equals(rootHash, that.rootHash)) {
+ return false;
+ }
+ if (hashPath.size() != that.hashPath.size()) {
+ return false;
+ }
+ for (int i = 0; i < hashPath.size(); i++) {
+ if (!Arrays.equals(hashPath.get(i), that.hashPath.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Long.hashCode(treeSize);
+ result = 31 * result + Long.hashCode(leafIndex);
+ result = 31 * result + Arrays.hashCode(rootHash);
+ for (byte[] hash : hashPath) {
+ result = 31 * result + Arrays.hashCode(hash);
+ }
+ return result;
+ }
+ }
+
+ /**
+ * Parses a SCITT receipt from raw COSE_Sign1 bytes.
+ *
+ * @param coseBytes the raw COSE_Sign1 bytes
+ * @return the parsed receipt
+ * @throws ScittParseException if parsing fails
+ */
+ public static ScittReceipt parse(byte[] coseBytes) throws ScittParseException {
+ Objects.requireNonNull(coseBytes, "coseBytes cannot be null");
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(coseBytes);
+ return fromParsedCose(parsed);
+ }
+
+ /**
+ * Creates a ScittReceipt from an already-parsed COSE_Sign1 structure.
+ *
+ * @param parsed the parsed COSE_Sign1
+ * @return the ScittReceipt
+ * @throws ScittParseException if the structure doesn't contain valid receipt data
+ */
+ public static ScittReceipt fromParsedCose(CoseSign1Parser.ParsedCoseSign1 parsed) throws ScittParseException {
+ Objects.requireNonNull(parsed, "parsed cannot be null");
+
+ // Verify VDS indicates RFC 9162 Merkle tree
+ CoseProtectedHeader header = parsed.protectedHeader();
+ if (!header.isRfc9162MerkleTree()) {
+ throw new ScittParseException(
+ "Receipt must use VDS=1 (RFC9162_SHA256), got: " + header.vds());
+ }
+
+ // Parse inclusion proof from unprotected header (CBORObject passed directly, no round-trip)
+ InclusionProof inclusionProof = parseInclusionProof(parsed.unprotectedHeader());
+
+ return new ScittReceipt(
+ header,
+ parsed.protectedHeaderBytes(),
+ inclusionProof,
+ parsed.payload(),
+ parsed.signature()
+ );
+ }
+
+ /**
+ * Parses the inclusion proof from the unprotected header.
+ *
+ * The inclusion proof is stored in the unprotected header with label 396
+ * per draft-ietf-cose-merkle-tree-proofs. The format is a map with negative
+ * integer keys:
+ *
+ * - -1: tree_size (required)
+ * - -2: leaf_index (required)
+ * - -3: hash_path (array of 32-byte hashes, optional)
+ * - -4: root_hash (32 bytes, optional)
+ *
+ */
+ private static InclusionProof parseInclusionProof(CBORObject unprotectedHeader) throws ScittParseException {
+ if (unprotectedHeader == null || unprotectedHeader.isNull()
+ || unprotectedHeader.getType() != CBORType.Map) {
+ throw new ScittParseException("Receipt must have an unprotected header map");
+ }
+
+ // Label 396 contains the inclusion proof map
+ CBORObject proofObject = unprotectedHeader.get(CBORObject.FromObject(396));
+ if (proofObject == null) {
+ throw new ScittParseException("Receipt missing inclusion proofs (label 396)");
+ }
+
+ // Proof must be a map with negative integer keys
+ if (proofObject.getType() != CBORType.Map) {
+ throw new ScittParseException("Inclusion proof at label 396 must be a map");
+ }
+
+ return parseMapFormatProof(proofObject);
+ }
+
+ /**
+ * Parses inclusion proof from MAP format with negative integer keys.
+ *
+ * Expected keys:
+ *
+ * - -1: tree_size (required)
+ * - -2: leaf_index (required)
+ * - -3: hash_path (array of 32-byte hashes, optional)
+ * - -4: root_hash (32 bytes, optional)
+ *
+ */
+ private static InclusionProof parseMapFormatProof(CBORObject proofMap) throws ScittParseException {
+ // Extract tree_size (-1) - required
+ CBORObject treeSizeObj = proofMap.get(CBORObject.FromObject(-1));
+ if (treeSizeObj == null || !treeSizeObj.isNumber()) {
+ throw new ScittParseException("Inclusion proof missing required tree_size (key -1)");
+ }
+ long treeSize = treeSizeObj.AsInt64Value();
+
+ // Extract leaf_index (-2) - required
+ CBORObject leafIndexObj = proofMap.get(CBORObject.FromObject(-2));
+ if (leafIndexObj == null || !leafIndexObj.isNumber()) {
+ throw new ScittParseException("Inclusion proof missing required leaf_index (key -2)");
+ }
+ long leafIndex = leafIndexObj.AsInt64Value();
+
+ // Extract hash_path (-3) - optional array of 32-byte hashes
+ List hashPath = new ArrayList<>();
+ CBORObject hashPathObj = proofMap.get(CBORObject.FromObject(-3));
+ if (hashPathObj != null && hashPathObj.getType() == CBORType.Array) {
+ for (int i = 0; i < hashPathObj.size(); i++) {
+ CBORObject element = hashPathObj.get(i);
+ if (element.getType() == CBORType.ByteString) {
+ byte[] hash = element.GetByteString();
+ if (hash.length == 32) {
+ hashPath.add(hash);
+ }
+ }
+ }
+ }
+
+ // Extract root_hash (-4) - optional 32-byte hash
+ byte[] rootHash = null;
+ CBORObject rootHashObj = proofMap.get(CBORObject.FromObject(-4));
+ if (rootHashObj != null && rootHashObj.getType() == CBORType.ByteString) {
+ byte[] hash = rootHashObj.GetByteString();
+ if (hash.length == 32) {
+ rootHash = hash;
+ }
+ }
+
+ return new InclusionProof(treeSize, leafIndex, rootHash, hashPath);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ScittReceipt that = (ScittReceipt) o;
+ return Objects.equals(protectedHeader, that.protectedHeader)
+ && Arrays.equals(protectedHeaderBytes, that.protectedHeaderBytes)
+ && Objects.equals(inclusionProof, that.inclusionProof)
+ && Arrays.equals(eventPayload, that.eventPayload)
+ && Arrays.equals(signature, that.signature);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hash(protectedHeader, inclusionProof);
+ result = 31 * result + Arrays.hashCode(protectedHeaderBytes);
+ result = 31 * result + Arrays.hashCode(eventPayload);
+ result = 31 * result + Arrays.hashCode(signature);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "ScittReceipt{" +
+ "protectedHeader=" + protectedHeader +
+ ", inclusionProof=" + inclusionProof +
+ ", payloadSize=" + (eventPayload != null ? eventPayload.length : 0) +
+ '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittVerifier.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittVerifier.java
new file mode 100644
index 0000000..c68dccc
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/ScittVerifier.java
@@ -0,0 +1,100 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.security.PublicKey;
+import java.security.cert.X509Certificate;
+import java.util.Map;
+
+/**
+ * Interface for SCITT (Supply Chain Integrity, Transparency, and Trust) verification.
+ *
+ * SCITT verification replaces live transparency log queries with cryptographic
+ * proof verification. Artifacts (receipt + status token) are delivered via HTTP
+ * headers and verified locally using cached public keys.
+ *
+ * Verification Flow
+ *
+ * - Parse receipt and status token from HTTP headers
+ * - Verify receipt signature using TL public key
+ * - Verify Merkle inclusion proof in receipt
+ * - Verify status token signature using RA public key
+ * - Check status token expiry (with clock skew tolerance)
+ * - Extract expected certificate fingerprints
+ *
+ *
+ * Post-Verification
+ * After TLS handshake, compare actual server certificate against
+ * the expected fingerprints from the status token.
+ */
+public interface ScittVerifier {
+
+ /**
+ * Verifies SCITT artifacts and extracts expectations.
+ *
+ * Both the receipt and status token are signed by the same transparency log key.
+ * The correct key is selected from the map by matching the key ID in the artifact
+ * header.
+ *
+ * @param receipt the parsed SCITT receipt
+ * @param token the parsed status token
+ * @param rootKeys the root public keys, keyed by hex key ID (4-byte SHA-256 of SPKI-DER)
+ * @return the verification expectation with expected certificate fingerprints
+ */
+ ScittExpectation verify(
+ ScittReceipt receipt,
+ StatusToken token,
+ Map rootKeys
+ );
+
+ /**
+ * Verifies that the server certificate matches the SCITT expectation.
+ *
+ * This should be called after the TLS handshake completes to compare
+ * the actual server certificate against the expected fingerprints.
+ *
+ * @param hostname the hostname that was connected to
+ * @param serverCert the server certificate from TLS handshake
+ * @param expectation the expectation from {@link #verify}
+ * @return the verification result
+ */
+ ScittVerificationResult postVerify(
+ String hostname,
+ X509Certificate serverCert,
+ ScittExpectation expectation
+ );
+
+ /**
+ * Result of SCITT post-verification.
+ *
+ * @param success true if server certificate matches expectations
+ * @param actualFingerprint the fingerprint of the server certificate
+ * @param matchedFingerprint the expected fingerprint that matched (null if no match)
+ * @param failureReason reason for failure (null if successful)
+ */
+ record ScittVerificationResult(
+ boolean success,
+ String actualFingerprint,
+ String matchedFingerprint,
+ String failureReason
+ ) {
+ /**
+ * Creates a successful result.
+ */
+ public static ScittVerificationResult success(String fingerprint) {
+ return new ScittVerificationResult(true, fingerprint, fingerprint, null);
+ }
+
+ /**
+ * Creates a mismatch result.
+ */
+ public static ScittVerificationResult mismatch(String actual, String reason) {
+ return new ScittVerificationResult(false, actual, null, reason);
+ }
+
+ /**
+ * Creates an error result.
+ */
+ public static ScittVerificationResult error(String reason) {
+ return new ScittVerificationResult(false, null, null, reason);
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/StatusToken.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/StatusToken.java
new file mode 100644
index 0000000..1b71f3e
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/StatusToken.java
@@ -0,0 +1,411 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.godaddy.ans.sdk.transparency.model.CertificateInfo;
+import com.godaddy.ans.sdk.transparency.model.CertType;
+import com.upokecenter.cbor.CBORObject;
+import com.upokecenter.cbor.CBORType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * SCITT Status Token - a time-bounded assertion about an agent's status.
+ *
+ * Status tokens are COSE_Sign1 structures signed by the RA (Registration Authority)
+ * that assert the current status of an agent. They include:
+ *
+ * - Agent ID and ANS name
+ * - Current status (ACTIVE, WARNING, DEPRECATED, EXPIRED, REVOKED)
+ * - Validity window (issued at, expires at)
+ * - Valid certificate fingerprints (identity and server)
+ * - Metadata hashes for endpoint protocols
+ *
+ *
+ * @param agentId the agent's unique identifier
+ * @param status the agent's current status
+ * @param issuedAt when the token was issued
+ * @param expiresAt when the token expires
+ * @param ansName the agent's ANS name
+ * @param agentHost the agent's host (FQDN)
+ * @param validIdentityCerts valid identity certificate fingerprints
+ * @param validServerCerts valid server certificate fingerprints
+ * @param metadataHashes map of protocol to metadata hash (SHA256:...)
+ * @param protectedHeader the COSE protected header
+ * @param signature the RA signature
+ */
+public record StatusToken(
+ String agentId,
+ Status status,
+ Instant issuedAt,
+ Instant expiresAt,
+ String ansName,
+ String agentHost,
+ List validIdentityCerts,
+ List validServerCerts,
+ Map metadataHashes,
+ CoseProtectedHeader protectedHeader,
+ byte[] protectedHeaderBytes,
+ byte[] payload,
+ byte[] signature
+) {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(StatusToken.class);
+
+ /**
+ * Default clock skew tolerance for expiry checks.
+ */
+ public static final Duration DEFAULT_CLOCK_SKEW = Duration.ofSeconds(60);
+
+ /**
+ * Agent status values.
+ */
+ public enum Status {
+ /** Agent is active and in good standing */
+ ACTIVE,
+ /** Agent is active but has warnings (e.g., certificate expiring soon) */
+ WARNING,
+ /** Agent is deprecated and should not be used for new connections */
+ DEPRECATED,
+ /** Agent registration has expired */
+ EXPIRED,
+ /** Agent registration has been revoked */
+ REVOKED,
+ /** Unknown status */
+ UNKNOWN
+ }
+
+ /**
+ * Compact constructor for defensive copying.
+ */
+ public StatusToken {
+ validIdentityCerts = validIdentityCerts != null ? List.copyOf(validIdentityCerts) : List.of();
+ validServerCerts = validServerCerts != null ? List.copyOf(validServerCerts) : List.of();
+ metadataHashes = metadataHashes != null ? Map.copyOf(metadataHashes) : Map.of();
+ }
+
+ /**
+ * Parses a status token from raw COSE_Sign1 bytes.
+ *
+ * @param coseBytes the raw COSE_Sign1 bytes
+ * @return the parsed status token
+ * @throws ScittParseException if parsing fails
+ */
+ public static StatusToken parse(byte[] coseBytes) throws ScittParseException {
+ Objects.requireNonNull(coseBytes, "coseBytes cannot be null");
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(coseBytes);
+ return fromParsedCose(parsed);
+ }
+
+ /**
+ * Creates a StatusToken from an already-parsed COSE_Sign1 structure.
+ *
+ * @param parsed the parsed COSE_Sign1
+ * @return the StatusToken
+ * @throws ScittParseException if the payload doesn't contain valid status token data
+ */
+ public static StatusToken fromParsedCose(CoseSign1Parser.ParsedCoseSign1 parsed) throws ScittParseException {
+ Objects.requireNonNull(parsed, "parsed cannot be null");
+
+ CoseProtectedHeader header = parsed.protectedHeader();
+ byte[] payload = parsed.payload();
+
+ if (payload == null || payload.length == 0) {
+ throw new ScittParseException("Status token payload cannot be empty");
+ }
+
+ // Parse the payload as CBOR
+ CBORObject payloadCbor;
+ try {
+ payloadCbor = CBORObject.DecodeFromBytes(payload);
+ } catch (Exception e) {
+ throw new ScittParseException("Failed to decode status token payload: " + e.getMessage(), e);
+ }
+
+ if (payloadCbor.getType() != CBORType.Map) {
+ throw new ScittParseException("Status token payload must be a CBOR map");
+ }
+
+ // Extract fields from payload using integer keys
+ // Key mapping: 1=agent_id, 2=status, 3=iat, 4=exp, 5=ans_name, 6=identity_certs, 7=server_certs, 8=metadata
+ String agentId = extractRequiredString(payloadCbor, 1);
+ String statusStr = extractRequiredString(payloadCbor, 2);
+ Status status = parseStatus(statusStr);
+
+ String ansName = extractOptionalString(payloadCbor, 5);
+ String agentHost = null; // Not used in TL format
+
+ // Extract timestamps from CWT claims in header or payload
+ Instant issuedAt = null;
+ Instant expiresAt = null;
+
+ if (header.cwtClaims() != null) {
+ issuedAt = header.cwtClaims().issuedAtTime();
+ expiresAt = header.cwtClaims().expirationTime();
+ }
+
+ // Payload might override header claims
+ Long iatSeconds = extractOptionalLong(payloadCbor, 3);
+ Long expSeconds = extractOptionalLong(payloadCbor, 4);
+
+ if (iatSeconds != null) {
+ issuedAt = Instant.ofEpochSecond(iatSeconds);
+ }
+ if (expSeconds != null) {
+ expiresAt = Instant.ofEpochSecond(expSeconds);
+ }
+
+ // SECURITY: Tokens must have an expiration time - no infinite validity allowed
+ if (expiresAt == null) {
+ throw new ScittParseException("Status token missing required expiration time (exp claim)");
+ }
+
+ // Extract certificate lists
+ List identityCerts = extractCertificateList(payloadCbor, 6);
+ List serverCerts = extractCertificateList(payloadCbor, 7);
+
+ // Extract metadata hashes
+ Map metadataHashes = extractMetadataHashes(payloadCbor, 8);
+
+ return new StatusToken(
+ agentId,
+ status,
+ issuedAt,
+ expiresAt,
+ ansName,
+ agentHost,
+ identityCerts,
+ serverCerts,
+ metadataHashes,
+ header,
+ parsed.protectedHeaderBytes(),
+ payload,
+ parsed.signature()
+ );
+ }
+
+ /**
+ * Checks if this token is expired.
+ *
+ * @return true if the token is expired
+ */
+ public boolean isExpired() {
+ return isExpired(Instant.now(), DEFAULT_CLOCK_SKEW);
+ }
+
+ /**
+ * Checks if this token is expired with the specified clock skew tolerance.
+ *
+ * @param clockSkew the clock skew tolerance
+ * @return true if the token is expired
+ */
+ public boolean isExpired(Duration clockSkew) {
+ return isExpired(Instant.now(), clockSkew);
+ }
+
+ /**
+ * Checks if this token is expired at the given time with clock skew tolerance.
+ *
+ * SECURITY: Tokens without an expiration time are considered expired.
+ * This is a defensive check - parsing should reject such tokens.
+ *
+ * @param now the current time
+ * @param clockSkew the clock skew tolerance
+ * @return true if the token is expired or has no expiration time
+ */
+ public boolean isExpired(Instant now, Duration clockSkew) {
+ if (expiresAt == null) {
+ return true; // No expiration set - treat as expired (defensive)
+ }
+ return now.minus(clockSkew).isAfter(expiresAt);
+ }
+
+ /**
+ * Returns the server certificate fingerprints as a list of strings.
+ *
+ * @return list of fingerprints
+ */
+ public List serverCertFingerprints() {
+ return validServerCerts.stream()
+ .map(CertificateInfo::getFingerprint)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ /**
+ * Returns the identity certificate fingerprints as a list of strings.
+ *
+ * @return list of fingerprints
+ */
+ public List identityCertFingerprints() {
+ return validIdentityCerts.stream()
+ .map(CertificateInfo::getFingerprint)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ /**
+ * Computes the recommended refresh interval based on token lifetime.
+ *
+ * Returns half of (exp - iat) to refresh before expiry.
+ *
+ * @return the recommended refresh interval, or 5 minutes if cannot be computed
+ */
+ public Duration computeRefreshInterval() {
+ if (issuedAt == null || expiresAt == null) {
+ return Duration.ofMinutes(5); // Default
+ }
+ Duration lifetime = Duration.between(issuedAt, expiresAt);
+ Duration halfLife = lifetime.dividedBy(2);
+ // Minimum 1 minute, maximum 1 hour
+ if (halfLife.compareTo(Duration.ofMinutes(1)) < 0) {
+ return Duration.ofMinutes(1);
+ }
+ if (halfLife.compareTo(Duration.ofHours(1)) > 0) {
+ return Duration.ofHours(1);
+ }
+ return halfLife;
+ }
+
+ private static Status parseStatus(String statusStr) {
+ if (statusStr == null) {
+ return Status.UNKNOWN;
+ }
+ try {
+ return Status.valueOf(statusStr.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ LOGGER.warn("Unrecognized status value '{}', treating as UNKNOWN", statusStr);
+ return Status.UNKNOWN;
+ }
+ }
+
+ private static String extractRequiredString(CBORObject map, int key) throws ScittParseException {
+ CBORObject value = map.get(CBORObject.FromObject(key));
+ if (value == null || value.isNull()) {
+ throw new ScittParseException("Missing required field at key " + key);
+ }
+ if (value.getType() != CBORType.TextString) {
+ throw new ScittParseException("Field at key " + key + " must be a string");
+ }
+ return value.AsString();
+ }
+
+ private static String extractOptionalString(CBORObject map, int key) {
+ CBORObject value = map.get(CBORObject.FromObject(key));
+ if (value != null && value.getType() == CBORType.TextString) {
+ return value.AsString();
+ }
+ return null;
+ }
+
+ private static Long extractOptionalLong(CBORObject map, int key) {
+ CBORObject value = map.get(CBORObject.FromObject(key));
+ if (value != null && value.isNumber()) {
+ return value.AsInt64();
+ }
+ return null;
+ }
+
+ private static List extractCertificateList(CBORObject map, int key) {
+ CBORObject value = map.get(CBORObject.FromObject(key));
+ if (value == null || value.getType() != CBORType.Array) {
+ return Collections.emptyList();
+ }
+
+ List certs = new ArrayList<>();
+ for (int i = 0; i < value.size(); i++) {
+ CBORObject certObj = value.get(i);
+ if (certObj.getType() == CBORType.Map) {
+ // Integer keys: 1=fingerprint, 2=type
+ CBORObject fingerprintObj = certObj.get(CBORObject.FromObject(1));
+ if (fingerprintObj != null && fingerprintObj.getType() == CBORType.TextString) {
+ CertificateInfo cert = new CertificateInfo();
+ cert.setFingerprint(fingerprintObj.AsString());
+
+ CBORObject typeObj = certObj.get(CBORObject.FromObject(2));
+ if (typeObj != null && typeObj.getType() == CBORType.TextString) {
+ CertType certType = CertType.fromString(typeObj.AsString());
+ if (certType != null) {
+ cert.setType(certType);
+ }
+ }
+ certs.add(cert);
+ }
+ } else if (certObj.getType() == CBORType.TextString) {
+ // Simple string fingerprint
+ CertificateInfo cert = new CertificateInfo();
+ cert.setFingerprint(certObj.AsString());
+ certs.add(cert);
+ }
+ }
+ return certs;
+ }
+
+ private static Map extractMetadataHashes(CBORObject map, int key) {
+ CBORObject value = map.get(CBORObject.FromObject(key));
+ if (value == null || value.getType() != CBORType.Map) {
+ return Collections.emptyMap();
+ }
+
+ Map hashes = new HashMap<>();
+ for (CBORObject hashKey : value.getKeys()) {
+ if (hashKey.getType() == CBORType.TextString) {
+ CBORObject hashValue = value.get(hashKey);
+ if (hashValue != null && hashValue.getType() == CBORType.TextString) {
+ hashes.put(hashKey.AsString(), hashValue.AsString());
+ }
+ }
+ }
+ return hashes;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ StatusToken that = (StatusToken) o;
+ return Objects.equals(agentId, that.agentId)
+ && status == that.status
+ && Objects.equals(issuedAt, that.issuedAt)
+ && Objects.equals(expiresAt, that.expiresAt)
+ && Objects.equals(ansName, that.ansName)
+ && Objects.equals(agentHost, that.agentHost)
+ && Objects.equals(validIdentityCerts, that.validIdentityCerts)
+ && Objects.equals(validServerCerts, that.validServerCerts)
+ && Objects.equals(metadataHashes, that.metadataHashes)
+ && Arrays.equals(signature, that.signature);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hash(agentId, status, issuedAt, expiresAt, ansName, agentHost,
+ validIdentityCerts, validServerCerts, metadataHashes);
+ result = 31 * result + Arrays.hashCode(signature);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "StatusToken{" +
+ "agentId='" + agentId + '\'' +
+ ", status=" + status +
+ ", ansName='" + ansName + '\'' +
+ ", expiresAt=" + expiresAt +
+ ", serverCerts=" + validServerCerts.size() +
+ ", identityCerts=" + validIdentityCerts.size() +
+ '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistry.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistry.java
new file mode 100644
index 0000000..5c67772
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistry.java
@@ -0,0 +1,95 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Registry of trusted SCITT domains for the ANS transparency infrastructure.
+ *
+ * Trusted domains can be configured via the system property
+ * {@value #TRUSTED_DOMAINS_PROPERTY}. If not set, defaults to the production
+ * ANS transparency log domains.
+ *
+ * Security note: Only domains in this registry will be trusted for
+ * fetching SCITT root keys. This prevents root key substitution attacks.
+ *
+ * Immutability: The trusted domain set is captured once at class
+ * initialization and cannot be changed afterward. This prevents runtime
+ * modification attacks via system property manipulation.
+ *
+ * Configuration
+ * {@code
+ * # Use default production domains (no property set)
+ *
+ * # Or specify custom domains (comma-separated) - must be set BEFORE first use
+ * -Dans.transparency.trusted.domains=transparency.ans.godaddy.com,localhost
+ * }
+ */
+public final class TrustedDomainRegistry {
+
+ /**
+ * System property to specify trusted domains (comma-separated).
+ * If not set, defaults to production ANS transparency log domains.
+ * Note: This property is read only once at class initialization.
+ * Changes after that point have no effect.
+ */
+ public static final String TRUSTED_DOMAINS_PROPERTY = "ans.transparency.trusted.domains";
+
+ /**
+ * Default trusted SCITT domains used when no system property is set.
+ */
+ public static final Set DEFAULT_TRUSTED_DOMAINS = Set.of(
+ "transparency.ans.godaddy.com",
+ "transparency.ans.ote-godaddy.com"
+ );
+
+ /**
+ * Immutable set of trusted domains, captured once at class initialization.
+ * This ensures the trusted domain set cannot be modified at runtime via
+ * system property manipulation - a security requirement for trust anchors.
+ */
+ private static final Set TRUSTED_DOMAINS;
+
+ static {
+ String property = System.getProperty(TRUSTED_DOMAINS_PROPERTY);
+ if (property == null || property.isBlank()) {
+ TRUSTED_DOMAINS = DEFAULT_TRUSTED_DOMAINS;
+ } else {
+ TRUSTED_DOMAINS = Arrays.stream(property.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .map(String::toLowerCase)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+ }
+
+ private TrustedDomainRegistry() {
+ // Utility class
+ }
+
+ /**
+ * Checks if a domain is trusted.
+ *
+ * @param domain the domain to check
+ * @return true if the domain is trusted
+ */
+ public static boolean isTrustedDomain(String domain) {
+ if (domain == null) {
+ return false;
+ }
+ return TRUSTED_DOMAINS.contains(domain.toLowerCase());
+ }
+
+ /**
+ * Returns the set of trusted domains.
+ *
+ * The returned set is immutable and was captured at class initialization.
+ * Subsequent changes to the system property have no effect.
+ *
+ * @return trusted domains (immutable)
+ */
+ public static Set getTrustedDomains() {
+ return TRUSTED_DOMAINS;
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/package-info.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/package-info.java
new file mode 100644
index 0000000..f0def8e
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/scitt/package-info.java
@@ -0,0 +1,38 @@
+/**
+ * SCITT (Supply Chain Integrity, Transparency, and Trust) verification support.
+ *
+ * This package provides cryptographic verification of agent registrations using
+ * SCITT artifacts delivered via HTTP headers, eliminating the need for live
+ * Transparency Log queries during connection establishment.
+ *
+ * Key Components
+ *
+ * - {@link com.godaddy.ans.sdk.transparency.scitt.ScittReceipt} - COSE_Sign1 receipt with Merkle proof
+ * - {@link com.godaddy.ans.sdk.transparency.scitt.StatusToken} - Time-bounded status assertion
+ * - {@link com.godaddy.ans.sdk.transparency.scitt.ScittVerifier} - Receipt and token verification
+ * - {@link com.godaddy.ans.sdk.transparency.TransparencyClient} - Public key fetching via getRootKeyAsync()
+ *
+ *
+ * Verification Flow
+ *
+ * - Extract SCITT headers from HTTP response
+ * - Parse receipt (COSE_Sign1) and verify TL signature
+ * - Verify Merkle inclusion proof in receipt
+ * - Parse status token (COSE_Sign1) and verify RA signature
+ * - Check token expiry with clock skew tolerance
+ * - Extract expected certificate fingerprints
+ * - Compare actual certificate against expectations
+ *
+ *
+ * Security Considerations
+ *
+ * - Only ES256 (ECDSA P-256) signatures are accepted
+ * - Key pinning prevents first-use attacks
+ * - Constant-time comparison for fingerprints
+ * - Trusted RA registry prevents rogue TL acceptance
+ *
+ *
+ * @see com.godaddy.ans.sdk.transparency.scitt.ScittVerifier
+ * @see com.godaddy.ans.sdk.transparency.scitt.StatusToken
+ */
+package com.godaddy.ans.sdk.transparency.scitt;
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/CachingBadgeVerificationService.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/CachingBadgeVerificationService.java
index cf64470..484729b 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/CachingBadgeVerificationService.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/CachingBadgeVerificationService.java
@@ -1,5 +1,8 @@
package com.godaddy.ans.sdk.transparency.verification;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -7,9 +10,8 @@
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.time.Duration;
-import java.time.Instant;
import java.util.HexFormat;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Predicate;
/**
* A caching wrapper for {@link BadgeVerificationService} that reduces blocking
@@ -53,19 +55,28 @@ public final class CachingBadgeVerificationService implements ServerVerifier {
private static final Duration DEFAULT_CACHE_TTL = Duration.ofMinutes(15);
private static final Duration DEFAULT_NEGATIVE_CACHE_TTL = Duration.ofMinutes(5);
+ private static final int DEFAULT_MAX_CACHE_SIZE = 10_000;
private final BadgeVerificationService delegate;
- private final Duration cacheTtl;
- private final Duration negativeCacheTtl;
-
- private final ConcurrentHashMap serverCache = new ConcurrentHashMap<>();
- private final ConcurrentHashMap clientCache = new ConcurrentHashMap<>();
+ private final Cache serverCache;
+ private final Cache clientCache;
private CachingBadgeVerificationService(Builder builder) {
this.delegate = builder.delegate;
- this.cacheTtl = builder.cacheTtl != null ? builder.cacheTtl : DEFAULT_CACHE_TTL;
- this.negativeCacheTtl = builder.negativeCacheTtl != null ? builder.negativeCacheTtl
- : DEFAULT_NEGATIVE_CACHE_TTL;
+
+ Duration positiveTtl = builder.cacheTtl != null ? builder.cacheTtl : DEFAULT_CACHE_TTL;
+ Duration negativeTtl = builder.negativeCacheTtl != null
+ ? builder.negativeCacheTtl : DEFAULT_NEGATIVE_CACHE_TTL;
+
+ this.serverCache = Caffeine.newBuilder()
+ .maximumSize(DEFAULT_MAX_CACHE_SIZE)
+ .expireAfter(new VariableTtlExpiry<>(positiveTtl, negativeTtl, ServerVerificationResult::isSuccess))
+ .build();
+
+ this.clientCache = Caffeine.newBuilder()
+ .maximumSize(DEFAULT_MAX_CACHE_SIZE)
+ .expireAfter(new VariableTtlExpiry<>(positiveTtl, negativeTtl, ClientVerificationResult::isSuccess))
+ .build();
}
/**
@@ -74,30 +85,12 @@ private CachingBadgeVerificationService(Builder builder) {
* @param hostname the server hostname to verify
* @return the verification result (may be cached)
*/
+ @Override
public ServerVerificationResult verifyServer(String hostname) {
- // Check cache first
- CachedServerResult cached = serverCache.get(hostname);
- if (cached != null && !cached.isExpired()) {
- LOG.debug("Cache hit for server verification: {}", hostname);
- return cached.result;
- }
-
- // Lazy eviction: remove expired entry immediately to free memory
- if (cached != null) {
- serverCache.remove(hostname);
- LOG.debug("Lazily evicted expired server cache entry: {}", hostname);
- }
-
- // Cache miss - perform verification
- LOG.debug("Cache miss for server verification: {}", hostname);
- ServerVerificationResult result = delegate.verifyServer(hostname);
-
- // Cache the result
- Duration ttl = result.isSuccess() ? cacheTtl : negativeCacheTtl;
- serverCache.put(hostname, new CachedServerResult(result, ttl));
- LOG.debug("Cached server verification result for {} (ttl={})", hostname, ttl);
-
- return result;
+ return serverCache.get(hostname, key -> {
+ LOG.debug("Cache miss for server verification: {}", key);
+ return delegate.verifyServer(key);
+ });
}
/**
@@ -109,36 +102,16 @@ public ServerVerificationResult verifyServer(String hostname) {
* @return the verification result (may be cached)
*/
public ClientVerificationResult verifyClient(X509Certificate clientCert) {
- // Compute fingerprint for cache key
String fingerprint = computeFingerprint(clientCert);
if (fingerprint == null) {
// Can't cache without fingerprint - delegate directly
return delegate.verifyClient(clientCert);
}
- // Check cache first
- CachedClientResult cached = clientCache.get(fingerprint);
- if (cached != null && !cached.isExpired()) {
- LOG.debug("Cache hit for client verification: {}", truncateFingerprint(fingerprint));
- return cached.result;
- }
-
- // Lazy eviction: remove expired entry immediately to free memory
- if (cached != null) {
- clientCache.remove(fingerprint);
- LOG.debug("Lazily evicted expired client cache entry: {}", truncateFingerprint(fingerprint));
- }
-
- // Cache miss - perform verification
- LOG.debug("Cache miss for client verification: {}", truncateFingerprint(fingerprint));
- ClientVerificationResult result = delegate.verifyClient(clientCert);
-
- // Cache the result
- Duration ttl = result.isSuccess() ? cacheTtl : negativeCacheTtl;
- clientCache.put(fingerprint, new CachedClientResult(result, ttl));
- LOG.debug("Cached client verification result for {} (ttl={})", truncateFingerprint(fingerprint), ttl);
-
- return result;
+ return clientCache.get(fingerprint, key -> {
+ LOG.debug("Cache miss for client verification: {}", truncateFingerprint(key));
+ return delegate.verifyClient(clientCert);
+ });
}
// ==================== Cache Management ====================
@@ -149,9 +122,8 @@ public ClientVerificationResult verifyClient(X509Certificate clientCert) {
* @param hostname the hostname to invalidate
*/
public void invalidateServer(String hostname) {
- if (serverCache.remove(hostname) != null) {
- LOG.debug("Invalidated server cache for: {}", hostname);
- }
+ serverCache.invalidate(hostname);
+ LOG.debug("Invalidated server cache for: {}", hostname);
}
/**
@@ -161,7 +133,8 @@ public void invalidateServer(String hostname) {
*/
public void invalidateClient(X509Certificate clientCert) {
String fingerprint = computeFingerprint(clientCert);
- if (fingerprint != null && clientCache.remove(fingerprint) != null) {
+ if (fingerprint != null) {
+ clientCache.invalidate(fingerprint);
LOG.debug("Invalidated client cache for: {}", truncateFingerprint(fingerprint));
}
}
@@ -170,55 +143,29 @@ public void invalidateClient(X509Certificate clientCert) {
* Clears all cached verification results.
*/
public void clearCache() {
- int serverCount = serverCache.size();
- int clientCount = clientCache.size();
- serverCache.clear();
- clientCache.clear();
+ long serverCount = serverCache.estimatedSize();
+ long clientCount = clientCache.estimatedSize();
+ serverCache.invalidateAll();
+ clientCache.invalidateAll();
LOG.debug("Cleared verification cache ({} server, {} client entries)", serverCount, clientCount);
}
/**
- * Returns the number of cached server verification results.
- */
- public int serverCacheSize() {
- return serverCache.size();
- }
-
- /**
- * Returns the number of cached client verification results.
+ * Returns the estimated number of cached server verification results.
+ *
+ * @return estimated cache size
*/
- public int clientCacheSize() {
- return clientCache.size();
+ public long serverCacheSize() {
+ return serverCache.estimatedSize();
}
/**
- * Removes expired entries from both caches.
+ * Returns the estimated number of cached client verification results.
*
- * Call this periodically to prevent memory buildup from expired entries.
+ * @return estimated cache size
*/
- public void evictExpired() {
- int serverEvicted = 0;
- int clientEvicted = 0;
-
- var serverIt = serverCache.entrySet().iterator();
- while (serverIt.hasNext()) {
- if (serverIt.next().getValue().isExpired()) {
- serverIt.remove();
- serverEvicted++;
- }
- }
-
- var clientIt = clientCache.entrySet().iterator();
- while (clientIt.hasNext()) {
- if (clientIt.next().getValue().isExpired()) {
- clientIt.remove();
- clientEvicted++;
- }
- }
-
- if (serverEvicted > 0 || clientEvicted > 0) {
- LOG.debug("Evicted {} server and {} client expired cache entries", serverEvicted, clientEvicted);
- }
+ public long clientCacheSize() {
+ return clientCache.estimatedSize();
}
// ==================== Private Helpers ====================
@@ -245,33 +192,35 @@ private String truncateFingerprint(String fingerprint) {
return fingerprint.substring(0, 16) + "...";
}
- // ==================== Cache Entry Classes ====================
-
- private static class CachedServerResult {
- final ServerVerificationResult result;
- final Instant expiresAt;
+ // ==================== Caffeine Expiry for Variable TTL ====================
- CachedServerResult(ServerVerificationResult result, Duration ttl) {
- this.result = result;
- this.expiresAt = Instant.now().plus(ttl);
+ /**
+ * Custom Caffeine Expiry that applies different TTLs for positive and negative results.
+ */
+ private static class VariableTtlExpiry implements Expiry {
+ private final long positiveTtlNanos;
+ private final long negativeTtlNanos;
+ private final Predicate isSuccess;
+
+ VariableTtlExpiry(Duration positiveTtl, Duration negativeTtl, Predicate isSuccess) {
+ this.positiveTtlNanos = positiveTtl.toNanos();
+ this.negativeTtlNanos = negativeTtl.toNanos();
+ this.isSuccess = isSuccess;
}
- boolean isExpired() {
- return Instant.now().isAfter(expiresAt);
+ @Override
+ public long expireAfterCreate(String key, V value, long currentTime) {
+ return isSuccess.test(value) ? positiveTtlNanos : negativeTtlNanos;
}
- }
-
- private static class CachedClientResult {
- final ClientVerificationResult result;
- final Instant expiresAt;
- CachedClientResult(ClientVerificationResult result, Duration ttl) {
- this.result = result;
- this.expiresAt = Instant.now().plus(ttl);
+ @Override
+ public long expireAfterUpdate(String key, V value, long currentTime, long currentDuration) {
+ return expireAfterCreate(key, value, currentTime);
}
- boolean isExpired() {
- return Instant.now().isAfter(expiresAt);
+ @Override
+ public long expireAfterRead(String key, V value, long currentTime, long currentDuration) {
+ return currentDuration; // No change on read
}
}
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 432b4ca..ca08586 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
@@ -11,9 +11,13 @@
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogV1;
+import com.godaddy.ans.sdk.transparency.scitt.TrustedDomainRegistry;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import java.security.PublicKey;
import java.time.Duration;
import java.util.Map;
@@ -30,6 +34,18 @@ class TransparencyClientTest {
private static final String TEST_AGENT_ID = "6bf2b7a9-1383-4e33-a945-845f34af7526";
+ @BeforeAll
+ static void setUpClass() {
+ // Include localhost for WireMock tests along with production domains
+ System.setProperty(TrustedDomainRegistry.TRUSTED_DOMAINS_PROPERTY,
+ "transparency.ans.godaddy.com,transparency.ans.ote-godaddy.com,localhost");
+ }
+
+ @AfterAll
+ static void tearDownClass() {
+ System.clearProperty(TrustedDomainRegistry.TRUSTED_DOMAINS_PROPERTY);
+ }
+
@Test
@DisplayName("Should retrieve agent transparency log with V1 schema")
void shouldRetrieveAgentTransparencyLogV1(WireMockRuntimeInfo wmRuntimeInfo) {
@@ -543,6 +559,257 @@ void shouldDefaultToV0WhenNoSchemaVersionPresent(WireMockRuntimeInfo wmRuntimeIn
assertThat(result.getSchemaVersion()).isEqualTo("V0");
}
+ @Test
+ @DisplayName("Should retrieve root key from C2SP format")
+ void shouldRetrieveRootKeyFromC2spFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ Map keys = client.getRootKeysAsync().join();
+
+ assertThat(keys).isNotEmpty();
+ assertThat(keys.values().iterator().next().getAlgorithm()).isEqualTo("EC");
+ }
+
+ @Test
+ @DisplayName("Should retrieve multiple root keys from C2SP format")
+ void shouldRetrieveMultipleRootKeysFromC2spFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spMultipleResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ Map keys = client.getRootKeysAsync().join();
+
+ assertThat(keys).hasSize(2);
+ keys.values().forEach(k -> assertThat(k.getAlgorithm()).isEqualTo("EC"));
+ }
+
+ @Test
+ @DisplayName("Should retrieve root key asynchronously")
+ void shouldRetrieveRootKeyAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ Map keys = client.getRootKeysAsync().get();
+
+ assertThat(keys).isNotEmpty();
+ assertThat(keys.values().iterator().next().getAlgorithm()).isEqualTo("EC");
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException for root key 500 error")
+ void shouldThrowServerExceptionForRootKeyError(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withHeader("X-Request-Id", "req-123")
+ .withBody("Internal error")));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ assertThatThrownBy(() -> client.getRootKeysAsync().join())
+ .hasCauseInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class);
+ }
+
+ @Test
+ @DisplayName("Should throw exception for invalid root key format")
+ void shouldThrowExceptionForInvalidRootKeyFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody("not a valid C2SP format line")));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ assertThatThrownBy(() -> client.getRootKeysAsync().join())
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("Should retrieve receipt bytes")
+ void shouldRetrieveReceiptBytes(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x01, 0x02, 0x03};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(expectedBytes)));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ byte[] result = client.getReceipt(TEST_AGENT_ID);
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should retrieve status token bytes")
+ void shouldRetrieveStatusTokenBytes(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x04, 0x05, 0x06};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/status-token"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(expectedBytes)));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ byte[] result = client.getStatusToken(TEST_AGENT_ID);
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should retrieve receipt asynchronously")
+ void shouldRetrieveReceiptAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x07, 0x08};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(expectedBytes)));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ byte[] result = client.getReceiptAsync(TEST_AGENT_ID).get();
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should retrieve status token asynchronously")
+ void shouldRetrieveStatusTokenAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x09, 0x0A};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/status-token"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(expectedBytes)));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ byte[] result = client.getStatusTokenAsync(TEST_AGENT_ID).get();
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should build client with custom root key cache TTL")
+ void shouldBuildClientWithCustomRootKeyCacheTtl(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .rootKeyCacheTtl(Duration.ofMinutes(30))
+ .build();
+
+ assertThat(client).isNotNull();
+ assertThat(client.getBaseUrl()).isEqualTo(baseUrl);
+ }
+
+ @Test
+ @DisplayName("Should invalidate root key cache")
+ void shouldInvalidateRootKeyCache(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ // First call fetches keys
+ Map keys1 = client.getRootKeysAsync().join();
+ assertThat(keys1).isNotEmpty();
+
+ // Invalidate cache - should not throw
+ client.invalidateRootKeyCache();
+
+ // Second call should fetch again (cache was invalidated)
+ Map keys2 = client.getRootKeysAsync().join();
+ assertThat(keys2).isNotEmpty();
+ }
+
+ @Test
+ @DisplayName("Should use default root key cache TTL of 24 hours")
+ void shouldUseDefaultRootKeyCacheTtl() {
+ assertThat(TransparencyClient.DEFAULT_ROOT_KEY_CACHE_TTL).isEqualTo(Duration.ofHours(24));
+ }
+
+ @Test
+ @DisplayName("Should reject untrusted transparency log domain")
+ void shouldRejectUntrustedDomain() {
+ // malicious domain is not in our configured trusted domains
+ assertThatThrownBy(() -> TransparencyClient.builder()
+ .baseUrl("https://malicious-transparency-log.example.com")
+ .build())
+ .isInstanceOf(SecurityException.class)
+ .hasMessageContaining("Untrusted transparency log domain")
+ .hasMessageContaining("malicious-transparency-log.example.com");
+ }
+
+ @Test
+ @DisplayName("Should accept trusted production domain")
+ void shouldAcceptTrustedProductionDomain() {
+ // These are in our configured trusted domains
+ TransparencyClient prodClient = TransparencyClient.builder()
+ .baseUrl("https://transparency.ans.godaddy.com")
+ .build();
+ assertThat(prodClient.getBaseUrl()).isEqualTo("https://transparency.ans.godaddy.com");
+
+ TransparencyClient oteClient = TransparencyClient.builder()
+ .baseUrl("https://transparency.ans.ote-godaddy.com")
+ .build();
+ assertThat(oteClient.getBaseUrl()).isEqualTo("https://transparency.ans.ote-godaddy.com");
+ }
+
// ==================== Test Data ====================
private String v1TransparencyLogResponse() {
@@ -718,4 +985,29 @@ private String v0TransparencyLogWithoutSchemaVersion() {
}
""";
}
+
+ // Valid EC P-256 public key for testing (SPKI-DER, base64 encoded)
+ private static final String TEST_EC_PUBLIC_KEY =
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEveuRZW0vWcVjh4enr9tA7VAKPFmL"
+ + "OZs1S99lGDqRhAQBEdetB290Det8rO1ojnHEA8PX4Yojb0oomwA2krO5Ag==";
+
+ // Second test key (different point on P-256 curve)
+ private static final String TEST_EC_PUBLIC_KEY_2 =
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb3cL8bLB0m5Dz7NiJj3xz0oPp4at"
+ + "Hj8bTqJf4d3nVkPR5eK8jFrLhCPQgKcZvWpJhH9q0vwPiT3v5RCKnGdDgA==";
+
+ /**
+ * Returns a valid EC P-256 public key in C2SP note format.
+ */
+ private String rootKeyC2spSingleResponse() {
+ return "transparency.ans.godaddy.com+abcd1234+" + TEST_EC_PUBLIC_KEY;
+ }
+
+ /**
+ * Returns multiple valid EC P-256 public keys in C2SP note format.
+ */
+ private String rootKeyC2spMultipleResponse() {
+ return "transparency.ans.godaddy.com+abcd1234+" + TEST_EC_PUBLIC_KEY + "\n"
+ + "transparency.ans.godaddy.com+efgh5678+" + TEST_EC_PUBLIC_KEY_2;
+ }
}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java
new file mode 100644
index 0000000..2b3bcb0
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java
@@ -0,0 +1,1095 @@
+package com.godaddy.ans.sdk.transparency;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.godaddy.ans.sdk.exception.AnsNotFoundException;
+import com.godaddy.ans.sdk.exception.AnsServerException;
+import com.godaddy.ans.sdk.transparency.model.AgentAuditParams;
+import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryParams;
+import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse;
+import com.godaddy.ans.sdk.transparency.model.CheckpointResponse;
+import com.godaddy.ans.sdk.transparency.model.TransparencyLog;
+import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.security.PublicKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@WireMockTest
+class TransparencyServiceTest {
+
+ private static final String TEST_AGENT_ID = "test-agent-123";
+
+ private TransparencyService createService(String baseUrl) {
+ return createService(baseUrl, Duration.ofHours(24));
+ }
+
+ private TransparencyService createService(String baseUrl, Duration rootKeyCacheTtl) {
+ return new TransparencyService(baseUrl, Duration.ofSeconds(5), Duration.ofSeconds(10), rootKeyCacheTtl);
+ }
+
+ @Nested
+ @DisplayName("getReceipt() tests")
+ class GetReceiptTests {
+
+ @Test
+ @DisplayName("Should retrieve receipt bytes")
+ void shouldRetrieveReceiptBytes(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x01, 0x02, 0x03, 0x04};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/cbor")
+ .withBody(expectedBytes)));
+
+ TransparencyService service = createService(baseUrl);
+ byte[] result = service.getReceipt(TEST_AGENT_ID);
+
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException for 404")
+ void shouldThrowNotFoundFor404(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("X-Request-Id", "req-123")
+ .withBody("Not found")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getReceipt(TEST_AGENT_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException for 500")
+ void shouldThrowServerExceptionFor500(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withHeader("X-Request-Id", "req-456")
+ .withBody("Internal error")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getReceipt(TEST_AGENT_ID))
+ .isInstanceOf(AnsServerException.class);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException for unexpected 4xx")
+ void shouldThrowServerExceptionForUnexpected4xx(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(403)
+ .withBody("Forbidden")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getReceipt(TEST_AGENT_ID))
+ .isInstanceOf(AnsServerException.class);
+ }
+
+ @Test
+ @DisplayName("Should URL encode agent ID with special characters")
+ void shouldUrlEncodeAgentId(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ String agentIdWithSpecialChars = "agent/with spaces";
+ byte[] expectedBytes = {0x05, 0x06};
+
+ stubFor(get(urlEqualTo("/v1/agents/agent%2Fwith+spaces/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(expectedBytes)));
+
+ TransparencyService service = createService(baseUrl);
+ byte[] result = service.getReceipt(agentIdWithSpecialChars);
+
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+ }
+
+ @Nested
+ @DisplayName("getStatusToken() tests")
+ class GetStatusTokenTests {
+
+ @Test
+ @DisplayName("Should retrieve status token bytes")
+ void shouldRetrieveStatusTokenBytes(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] expectedBytes = {0x10, 0x20, 0x30, 0x40};
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/status-token"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/cose")
+ .withBody(expectedBytes)));
+
+ TransparencyService service = createService(baseUrl);
+ byte[] result = service.getStatusToken(TEST_AGENT_ID);
+
+ assertThat(result).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException for 404")
+ void shouldThrowNotFoundFor404(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/status-token"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withBody("Token not found")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getStatusToken(TEST_AGENT_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException for 500")
+ void shouldThrowServerExceptionFor500(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/status-token"))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withBody("Server error")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getStatusToken(TEST_AGENT_ID))
+ .isInstanceOf(AnsServerException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("getAgentTransparencyLog() tests")
+ class GetAgentTransparencyLogTests {
+
+ @Test
+ @DisplayName("Should parse V1 payload correctly")
+ void shouldParseV1Payload(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withHeader("X-Schema-Version", "V1")
+ .withBody(v1Response())));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLog result = service.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getSchemaVersion()).isEqualTo("V1");
+ }
+
+ @Test
+ @DisplayName("Should parse V0 payload correctly")
+ void shouldParseV0Payload(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withHeader("X-Schema-Version", "V0")
+ .withBody(v0Response())));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLog result = service.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getSchemaVersion()).isEqualTo("V0");
+ }
+
+ @Test
+ @DisplayName("Should default to V0 when schema version missing")
+ void shouldDefaultToV0WhenSchemaMissing(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(v0Response())));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLog result = service.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getSchemaVersion()).isEqualTo("V0");
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException for 404")
+ void shouldThrowNotFoundFor404(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("X-Request-Id", "req-123")
+ .withBody("Agent not found")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getAgentTransparencyLog(TEST_AGENT_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("getCheckpoint() tests")
+ class GetCheckpointTests {
+
+ @Test
+ @DisplayName("Should retrieve checkpoint")
+ void shouldRetrieveCheckpoint(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/log/checkpoint"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(checkpointResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ CheckpointResponse result = service.getCheckpoint();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getLogSize()).isEqualTo(1000L);
+ }
+ }
+
+ @Nested
+ @DisplayName("getCheckpointHistory() tests")
+ class GetCheckpointHistoryTests {
+
+ @Test
+ @DisplayName("Should retrieve checkpoint history")
+ void shouldRetrieveCheckpointHistory(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlMatching("/v1/log/checkpoint/history.*"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(checkpointHistoryResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ CheckpointHistoryResponse result = service.getCheckpointHistory(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getCheckpoints()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should include query parameters")
+ void shouldIncludeQueryParameters(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlMatching("/v1/log/checkpoint/history\\?.*limit=10.*"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(checkpointHistoryResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ CheckpointHistoryParams params = CheckpointHistoryParams.builder().limit(10).build();
+ CheckpointHistoryResponse result = service.getCheckpointHistory(params);
+
+ assertThat(result).isNotNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("getLogSchema() tests")
+ class GetLogSchemaTests {
+
+ @Test
+ @DisplayName("Should retrieve schema")
+ void shouldRetrieveSchema(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/log/schema/V1"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody("{\"type\":\"object\"}")));
+
+ TransparencyService service = createService(baseUrl);
+ Map result = service.getLogSchema("V1");
+
+ assertThat(result).isNotNull();
+ assertThat(result.get("type")).isEqualTo("object");
+ }
+ }
+
+ @Nested
+ @DisplayName("getAgentTransparencyLogAudit() tests")
+ class GetAgentTransparencyLogAuditTests {
+
+ @Test
+ @DisplayName("Should retrieve audit trail")
+ void shouldRetrieveAuditTrail(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlMatching("/v1/agents/" + TEST_AGENT_ID + "/audit.*"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(auditResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLogAudit result = service.getAgentTransparencyLogAudit(TEST_AGENT_ID, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getRecords()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should include audit parameters")
+ void shouldIncludeAuditParameters(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlMatching("/v1/agents/" + TEST_AGENT_ID + "/audit\\?.*offset=10.*"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(auditResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ AgentAuditParams params = AgentAuditParams.builder().offset(10).limit(20).build();
+ TransparencyLogAudit result = service.getAgentTransparencyLogAudit(TEST_AGENT_ID, params);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should handle audit response with null records")
+ void shouldHandleNullRecords(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/audit"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody("{\"totalRecords\": 0}")));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLogAudit result = service.getAgentTransparencyLogAudit(TEST_AGENT_ID, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getRecords()).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("getRootKey() tests")
+ class GetRootKeyTests {
+
+ @Test
+ @DisplayName("Should retrieve single root key from C2SP format")
+ void shouldRetrieveSingleRootKeyFromC2spFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ Map keys = service.getRootKeysAsync().join();
+
+ assertThat(keys).hasSize(1);
+ assertThat(keys.values().iterator().next().getAlgorithm()).isEqualTo("EC");
+ }
+
+ @Test
+ @DisplayName("Should retrieve root key from C2SP format with alternate hash")
+ void shouldRetrieveRootKeyFromC2spFormatWithAlternateHash(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spResponse())));
+
+ TransparencyService service = createService(baseUrl);
+ Map keys = service.getRootKeysAsync().join();
+
+ assertThat(keys).hasSize(1);
+ assertThat(keys.values().iterator().next().getAlgorithm()).isEqualTo("EC");
+ }
+
+ @Test
+ @DisplayName("Should retrieve root key with C2SP version byte prefix")
+ void shouldRetrieveRootKeyWithC2spVersionPrefix(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ // C2SP format includes a version byte (0x02) prefix before SPKI-DER
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spWithVersionByte())));
+
+ TransparencyService service = createService(baseUrl);
+ Map keys = service.getRootKeysAsync().join();
+
+ assertThat(keys).isNotEmpty();
+ assertThat(keys.values().iterator().next().getAlgorithm()).isEqualTo("EC");
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException for 500 error")
+ void shouldThrowServerExceptionFor500(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withHeader("X-Request-Id", "req-123")
+ .withBody("Internal error")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getRootKeysAsync().join())
+ .hasCauseInstanceOf(AnsServerException.class);
+ }
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for invalid key format")
+ void shouldThrowExceptionForInvalidFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody("{\"notkey\": \"value\"}")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getRootKeysAsync().join())
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Could not parse any public keys");
+ }
+
+ @Test
+ @DisplayName("Should skip comment lines in C2SP format")
+ void shouldSkipCommentLinesInC2spFormat(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spWithComments())));
+
+ TransparencyService service = createService(baseUrl);
+ Map keys = service.getRootKeysAsync().join();
+
+ assertThat(keys).isNotEmpty();
+ }
+
+ @Test
+ @DisplayName("Should throw for non-200 status on root key")
+ void shouldThrowForNon200Status(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("X-Request-Id", "req-999")
+ .withBody("Not found")));
+
+ TransparencyService service = createService(baseUrl);
+
+ assertThatThrownBy(() -> service.getRootKeysAsync().join())
+ .hasCauseInstanceOf(AnsServerException.class);
+ }
+
+ @Test
+ @DisplayName("Should return cached root key on second call (no HTTP request)")
+ void shouldReturnCachedRootKeyOnSecondCall(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl, Duration.ofHours(1));
+
+ // First call - should make HTTP request
+ Map keys1 = service.getRootKeysAsync().join();
+ assertThat(keys1).isNotEmpty();
+
+ // Second call - should use cache, no HTTP request
+ Map keys2 = service.getRootKeysAsync().join();
+ assertThat(keys2).isNotEmpty();
+ assertThat(keys2).isSameAs(keys1);
+
+ // Verify only one HTTP request was made
+ verify(1, getRequestedFor(urlEqualTo("/root-keys")));
+ }
+
+ @Test
+ @DisplayName("Should refetch root key when cache expires")
+ void shouldRefetchRootKeyWhenCacheExpires(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ // Use very short TTL for testing
+ TransparencyService service = createService(baseUrl, Duration.ofMillis(50));
+
+ // First call - should make HTTP request
+ Map keys1 = service.getRootKeysAsync().join();
+ assertThat(keys1).isNotEmpty();
+
+ // Wait for cache to expire
+ Thread.sleep(100);
+
+ // Second call - should make another HTTP request (cache expired)
+ Map keys2 = service.getRootKeysAsync().join();
+ assertThat(keys2).isNotEmpty();
+
+ // Verify two HTTP requests were made
+ verify(2, getRequestedFor(urlEqualTo("/root-keys")));
+ }
+
+ @Test
+ @DisplayName("Should make only one HTTP request for concurrent calls")
+ void shouldMakeOnlyOneHttpRequestForConcurrentCalls(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withFixedDelay(100) // Simulate network latency
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl, Duration.ofHours(1));
+
+ int threadCount = 10;
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ List> results = new ArrayList<>();
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+
+ try {
+ // Launch concurrent requests
+ for (int i = 0; i < threadCount; i++) {
+ executor.submit(() -> {
+ try {
+ startLatch.await(); // Wait for all threads to be ready
+ Map keys = service.getRootKeysAsync().join();
+ synchronized (results) {
+ results.add(keys);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ // Release all threads simultaneously
+ startLatch.countDown();
+
+ // Wait for all threads to complete
+ doneLatch.await(5, TimeUnit.SECONDS);
+
+ // All results should be the same instance
+ assertThat(results).hasSize(threadCount);
+ Map firstKeys = results.get(0);
+ for (Map keys : results) {
+ assertThat(keys).isSameAs(firstKeys);
+ }
+
+ // Only one HTTP request should have been made
+ verify(1, getRequestedFor(urlEqualTo("/root-keys")));
+ } finally {
+ executor.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("Async: Should make only one HTTP request for concurrent async calls (stampede prevention)")
+ void shouldMakeOnlyOneHttpRequestForConcurrentAsyncCalls(WireMockRuntimeInfo wmRuntimeInfo)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withFixedDelay(200) // Simulate network latency to ensure overlap
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl, Duration.ofHours(1));
+
+ int concurrentCalls = 10;
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(concurrentCalls);
+ List>> futures = new ArrayList<>();
+ ExecutorService executor = Executors.newFixedThreadPool(concurrentCalls);
+
+ try {
+ // Launch concurrent async requests
+ for (int i = 0; i < concurrentCalls; i++) {
+ executor.submit(() -> {
+ try {
+ startLatch.await(); // Wait for all threads to be ready
+ CompletableFuture> future = service.getRootKeysAsync();
+ synchronized (futures) {
+ futures.add(future);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ // Release all threads simultaneously
+ startLatch.countDown();
+
+ // Wait for all threads to submit their futures
+ doneLatch.await(5, TimeUnit.SECONDS);
+
+ // Wait for all futures to complete and collect results
+ List> results = new ArrayList<>();
+ for (CompletableFuture> future : futures) {
+ results.add(future.get(5, TimeUnit.SECONDS));
+ }
+
+ // All results should be the same instance
+ assertThat(results).hasSize(concurrentCalls);
+ Map firstKeys = results.get(0);
+ for (Map keys : results) {
+ assertThat(keys).isSameAs(firstKeys);
+ }
+
+ // Only one HTTP request should have been made (stampede prevention)
+ verify(1, getRequestedFor(urlEqualTo("/root-keys")));
+ } finally {
+ executor.shutdown();
+ }
+ }
+
+ @Test
+ @DisplayName("Should clear cache when invalidateRootKeyCache is called")
+ void shouldClearCacheWhenInvalidateCalled(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl, Duration.ofHours(1));
+
+ // First call - should make HTTP request
+ Map keys1 = service.getRootKeysAsync().join();
+ assertThat(keys1).isNotEmpty();
+ verify(1, getRequestedFor(urlEqualTo("/root-keys")));
+
+ // Invalidate cache
+ service.invalidateRootKeyCache();
+
+ // Second call - should make new HTTP request
+ Map keys2 = service.getRootKeysAsync().join();
+ assertThat(keys2).isNotEmpty();
+
+ // Verify two HTTP requests were made
+ verify(2, getRequestedFor(urlEqualTo("/root-keys")));
+ }
+ }
+
+ @Nested
+ @DisplayName("refreshRootKeysIfNeeded() tests")
+ class RefreshRootKeysIfNeededTests {
+
+ @Test
+ @DisplayName("Should reject artifact with future timestamp beyond tolerance")
+ void shouldRejectArtifactFromFuture(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache first
+ service.getRootKeysAsync().join();
+
+ // Try refresh with artifact claiming to be 2 minutes in the future (beyond 60s tolerance)
+ Instant futureTime = Instant.now().plus(Duration.ofMinutes(2));
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(futureTime);
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REJECT);
+ assertThat(decision.reason()).contains("future");
+ }
+
+ @Test
+ @DisplayName("Should reject artifact older than cache refresh time")
+ void shouldRejectArtifactOlderThanCache(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache first
+ service.getRootKeysAsync().join();
+
+ // Try refresh with artifact from 10 minutes ago (beyond 5 min past tolerance)
+ Instant oldTime = Instant.now().minus(Duration.ofMinutes(10));
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(oldTime);
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REJECT);
+ assertThat(decision.reason()).contains("predates cache refresh");
+ }
+
+ @Test
+ @DisplayName("Should allow refresh for artifact issued after cache refresh")
+ void shouldAllowRefreshForNewerArtifact(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache first
+ service.getRootKeysAsync().join();
+ verify(1, getRequestedFor(urlEqualTo("/root-keys")));
+
+ // Try refresh with artifact issued just now (after cache was populated)
+ Instant recentTime = Instant.now();
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(recentTime);
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REFRESHED);
+ assertThat(decision.keys()).isNotNull();
+ assertThat(decision.keys()).isNotEmpty();
+
+ // Should have made another request to refresh the cache
+ verify(2, getRequestedFor(urlEqualTo("/root-keys")));
+ }
+
+ @Test
+ @DisplayName("Should defer refresh when cooldown is in effect")
+ void shouldDeferRefreshDuringCooldown(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache first
+ service.getRootKeysAsync().join();
+
+ // First refresh should succeed
+ Instant recentTime = Instant.now();
+ RefreshDecision decision1 = service.refreshRootKeysIfNeeded(recentTime);
+ assertThat(decision1.action()).isEqualTo(RefreshDecision.RefreshAction.REFRESHED);
+
+ // Second refresh immediately after should be deferred (30s cooldown)
+ RefreshDecision decision2 = service.refreshRootKeysIfNeeded(Instant.now());
+ assertThat(decision2.action()).isEqualTo(RefreshDecision.RefreshAction.DEFER);
+ assertThat(decision2.reason()).contains("recently refreshed");
+ }
+
+ @Test
+ @DisplayName("Should track cache populated timestamp")
+ void shouldTrackCachePopulatedTimestamp(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Initially should be EPOCH
+ assertThat(service.getCachePopulatedAt()).isEqualTo(Instant.EPOCH);
+
+ // After populating cache, timestamp should be recent
+ Instant beforeFetch = Instant.now();
+ service.getRootKeysAsync().join();
+ Instant afterFetch = Instant.now();
+
+ Instant cacheTime = service.getCachePopulatedAt();
+ assertThat(cacheTime).isAfterOrEqualTo(beforeFetch);
+ assertThat(cacheTime).isBeforeOrEqualTo(afterFetch);
+ }
+
+ @Test
+ @DisplayName("Should allow artifact within past tolerance window")
+ void shouldAllowArtifactWithinPastTolerance(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache
+ service.getRootKeysAsync().join();
+
+ // Artifact from 3 minutes ago should be allowed (within 5 min past tolerance)
+ Instant threeMinutesAgo = Instant.now().minus(Duration.ofMinutes(3));
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(threeMinutesAgo);
+
+ // Should allow refresh since it's within tolerance
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REFRESHED);
+ }
+
+ @Test
+ @DisplayName("Should allow artifact with small future timestamp (within clock skew)")
+ void shouldAllowArtifactWithinClockSkewTolerance(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/root-keys"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse())));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache
+ service.getRootKeysAsync().join();
+
+ // Artifact from 30 seconds in future should be allowed (within 60s tolerance)
+ Instant thirtySecondsAhead = Instant.now().plus(Duration.ofSeconds(30));
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(thirtySecondsAhead);
+
+ // Should allow refresh since it's within clock skew tolerance
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REFRESHED);
+ }
+
+ @Test
+ @DisplayName("Should defer when network error occurs during refresh")
+ void shouldDeferOnNetworkError(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ // First request succeeds (initial cache population)
+ stubFor(get(urlEqualTo("/root-keys"))
+ .inScenario("network-error")
+ .whenScenarioStateIs("Started")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withBody(rootKeyC2spSingleResponse()))
+ .willSetStateTo("first-call-done"));
+
+ // Second request fails (network error during refresh)
+ stubFor(get(urlEqualTo("/root-keys"))
+ .inScenario("network-error")
+ .whenScenarioStateIs("first-call-done")
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withBody("Server error")));
+
+ TransparencyService service = createService(baseUrl);
+
+ // Populate the cache
+ service.getRootKeysAsync().join();
+
+ // Attempt refresh - should fail and return DEFER
+ Instant recentTime = Instant.now();
+ RefreshDecision decision = service.refreshRootKeysIfNeeded(recentTime);
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.DEFER);
+ assertThat(decision.reason()).contains("Failed to refresh");
+ }
+ }
+
+ // Helper methods for test data
+
+ private String v1Response() {
+ return """
+ {
+ "status": "ACTIVE",
+ "schemaVersion": "V1",
+ "payload": {
+ "logId": "log-123",
+ "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"
+ }
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ private String v0Response() {
+ return """
+ {
+ "status": "ACTIVE",
+ "schemaVersion": "V0",
+ "payload": {
+ "ansId": "6bf2b7a9-1383-4e33-a945-845f34af7526",
+ "ansName": "ans://v1.0.0.agent.example.com",
+ "eventType": "AGENT_REGISTERED"
+ }
+ }
+ """;
+ }
+
+ private String checkpointResponse() {
+ return """
+ {
+ "logSize": 1000,
+ "rootHash": "abcd1234"
+ }
+ """;
+ }
+
+ private String checkpointHistoryResponse() {
+ return """
+ {
+ "checkpoints": [
+ {
+ "logSize": 1000,
+ "rootHash": "abcd1234"
+ }
+ ]
+ }
+ """;
+ }
+
+ private String auditResponse() {
+ return """
+ {
+ "records": [],
+ "totalRecords": 5
+ }
+ """;
+ }
+
+ // Valid EC P-256 public key for testing (SPKI-DER, base64 encoded)
+ private static final String TEST_EC_PUBLIC_KEY =
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEveuRZW0vWcVjh4enr9tA7VAKPFmL"
+ + "OZs1S99lGDqRhAQBEdetB290Det8rO1ojnHEA8PX4Yojb0oomwA2krO5Ag==";
+
+ /**
+ * Returns a valid EC P-256 public key in JSON format.
+ */
+ private String rootKeyC2spSingleResponse() {
+ return "transparency.ans.godaddy.com+abcd1234+" + TEST_EC_PUBLIC_KEY;
+ }
+
+ /**
+ * Returns a valid EC P-256 public key in C2SP note format.
+ */
+ private String rootKeyC2spResponse() {
+ return "transparency.ans.godaddy.com+abc123+" + TEST_EC_PUBLIC_KEY;
+ }
+
+ /**
+ * Returns a valid EC P-256 public key with C2SP version byte prefix (0x02).
+ * This tests the version byte stripping logic in decodePublicKey().
+ */
+ private String rootKeyC2spWithVersionByte() {
+ // Prepend 0x02 version byte to the SPKI-DER bytes
+ byte[] originalKey = java.util.Base64.getDecoder().decode(TEST_EC_PUBLIC_KEY);
+ byte[] prefixedKey = new byte[originalKey.length + 1];
+ prefixedKey[0] = 0x02; // C2SP version byte
+ System.arraycopy(originalKey, 0, prefixedKey, 1, originalKey.length);
+ String prefixedBase64 = java.util.Base64.getEncoder().encodeToString(prefixedKey);
+ return "transparency.ans.godaddy.com+abc123+" + prefixedBase64;
+ }
+
+ /**
+ * Returns a C2SP note format with comment lines.
+ */
+ private String rootKeyC2spWithComments() {
+ return "# This is a comment\n\n"
+ + "transparency.ans.godaddy.com+abc123+" + TEST_EC_PUBLIC_KEY;
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1ParserTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1ParserTest.java
new file mode 100644
index 0000000..f69f7cc
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/CoseSign1ParserTest.java
@@ -0,0 +1,386 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.upokecenter.cbor.CBORObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CoseSign1ParserTest {
+
+ @Nested
+ @DisplayName("parse() tests")
+ class ParseTests {
+
+ @Test
+ @DisplayName("Should reject null input")
+ void shouldRejectNullInput() {
+ assertThatThrownBy(() -> CoseSign1Parser.parse(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("coseBytes cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject empty input")
+ void shouldRejectEmptyInput() {
+ assertThatThrownBy(() -> CoseSign1Parser.parse(new byte[0]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Failed to decode CBOR");
+ }
+
+ @Test
+ @DisplayName("Should reject invalid CBOR")
+ void shouldRejectInvalidCbor() {
+ byte[] invalidCbor = {0x01, 0x02, 0x03};
+ assertThatThrownBy(() -> CoseSign1Parser.parse(invalidCbor))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Failed to decode CBOR");
+ }
+
+ @Test
+ @DisplayName("Should reject CBOR without COSE_Sign1 tag")
+ void shouldRejectCborWithoutTag() {
+ // Array without tag
+ CBORObject array = CBORObject.NewArray();
+ array.Add(new byte[0]);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(array.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Expected COSE_Sign1 tag (18)");
+ }
+
+ @Test
+ @DisplayName("Should reject COSE_Sign1 with wrong number of elements")
+ void shouldRejectWrongElementCount() {
+ // Tag 18 but only 3 elements
+ CBORObject array = CBORObject.NewArray();
+ array.Add(new byte[0]);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("must be an array of 4 elements");
+ }
+
+ @Test
+ @DisplayName("Should reject non-ES256 algorithm")
+ void shouldRejectNonEs256Algorithm() throws Exception {
+ // Build COSE_Sign1 with RS256 (alg = -257)
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -257); // alg = RS256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]); // payload
+ array.Add(new byte[64]); // signature
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Algorithm substitution attack prevented")
+ .hasMessageContaining("only ES256 (alg=-7) is accepted");
+ }
+
+ @Test
+ @DisplayName("Should reject invalid signature length")
+ void shouldRejectInvalidSignatureLength() throws Exception {
+ // Build valid COSE_Sign1 with ES256 but wrong signature length
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]); // payload
+ array.Add(new byte[32]); // Wrong! Should be 64 bytes
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid ES256 signature length")
+ .hasMessageContaining("expected 64 bytes");
+ }
+
+ @Test
+ @DisplayName("Should parse valid COSE_Sign1 with ES256")
+ void shouldParseValidCoseSign1() throws Exception {
+ // Build valid COSE_Sign1
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(4, new byte[]{0x01, 0x02, 0x03, 0x04}); // kid
+ protectedHeader.Add(395, 1); // vds = RFC9162_SHA256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] payload = "test payload".getBytes(StandardCharsets.UTF_8);
+ byte[] signature = new byte[64]; // 64-byte placeholder
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(payload);
+ array.Add(signature);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ assertThat(parsed.protectedHeader().algorithm()).isEqualTo(-7);
+ assertThat(parsed.protectedHeader().keyId()).containsExactly(0x01, 0x02, 0x03, 0x04);
+ assertThat(parsed.protectedHeader().vds()).isEqualTo(1);
+ assertThat(parsed.payload()).isEqualTo(payload);
+ assertThat(parsed.signature()).hasSize(64);
+ }
+
+ @Test
+ @DisplayName("Should reject empty protected header bytes")
+ void shouldRejectEmptyProtectedHeaderBytes() {
+ // Build COSE_Sign1 with empty protected header
+ CBORObject array = CBORObject.NewArray();
+ array.Add(new byte[0]); // Empty protected header
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Protected header cannot be empty");
+ }
+
+ @Test
+ @DisplayName("Should reject protected header that is not a CBOR map")
+ void shouldRejectNonMapProtectedHeader() {
+ // Protected header encoded as array instead of map
+ CBORObject protectedArray = CBORObject.NewArray();
+ protectedArray.Add(-7);
+ byte[] protectedBytes = protectedArray.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Protected header must be a CBOR map");
+ }
+
+ @Test
+ @DisplayName("Should reject protected header missing algorithm")
+ void shouldRejectMissingAlgorithm() {
+ // Protected header without alg field
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(4, new byte[]{0x01, 0x02, 0x03, 0x04}); // Only kid, no alg
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Protected header missing algorithm");
+ }
+
+ @Test
+ @DisplayName("Should parse COSE_Sign1 with detached (null) payload")
+ void shouldParseDetachedPayload() throws Exception {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(CBORObject.Null); // Null payload (detached)
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ assertThat(parsed.payload()).isNull();
+ }
+
+ @Test
+ @DisplayName("Should reject non-byte-string protected header element")
+ void shouldRejectNonByteStringProtectedHeader() {
+ CBORObject array = CBORObject.NewArray();
+ array.Add("not bytes"); // String instead of byte string
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> CoseSign1Parser.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("must be a byte string");
+ }
+
+ @Test
+ @DisplayName("Should parse protected header with integer content type")
+ void shouldParseIntegerContentType() throws Exception {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(3, 60); // content type as integer (application/cbor)
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ assertThat(parsed.protectedHeader().contentType()).isEqualTo("60");
+ }
+
+ @Test
+ @DisplayName("Should parse protected header with string content type")
+ void shouldParseStringContentType() throws Exception {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(3, "application/json"); // content type as string
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ assertThat(parsed.protectedHeader().contentType()).isEqualTo("application/json");
+ }
+
+ @Test
+ @DisplayName("Should handle null unprotected header")
+ void shouldHandleNullUnprotectedHeader() throws Exception {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.Null); // Null unprotected header
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ assertThat(parsed.unprotectedHeader().isNull()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should parse COSE_Sign1 with CWT claims")
+ void shouldParseCwtClaims() throws Exception {
+ // Build COSE_Sign1 with CWT claims in protected header
+ CBORObject cwtClaims = CBORObject.NewMap();
+ cwtClaims.Add(1, "issuer"); // iss
+ cwtClaims.Add(2, "subject"); // sub
+ cwtClaims.Add(4, 1700000000L); // exp
+ cwtClaims.Add(6, 1600000000L); // iat
+
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(13, cwtClaims); // cwt_claims
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(new byte[0]);
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ CoseSign1Parser.ParsedCoseSign1 parsed = CoseSign1Parser.parse(tagged.EncodeToBytes());
+
+ CwtClaims claims = parsed.protectedHeader().cwtClaims();
+ assertThat(claims).isNotNull();
+ assertThat(claims.iss()).isEqualTo("issuer");
+ assertThat(claims.sub()).isEqualTo("subject");
+ assertThat(claims.exp()).isEqualTo(1700000000L);
+ assertThat(claims.iat()).isEqualTo(1600000000L);
+ }
+ }
+
+ @Nested
+ @DisplayName("buildSigStructure() tests")
+ class BuildSigStructureTests {
+
+ @Test
+ @DisplayName("Should build correct Sig_structure")
+ void shouldBuildCorrectSigStructure() {
+ byte[] protectedHeader = new byte[]{0x01, 0x02};
+ byte[] externalAad = new byte[]{0x03, 0x04};
+ byte[] payload = "payload".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeader, externalAad, payload);
+
+ // Decode and verify structure
+ CBORObject decoded = CBORObject.DecodeFromBytes(sigStructure);
+ assertThat(decoded.size()).isEqualTo(4);
+ assertThat(decoded.get(0).AsString()).isEqualTo("Signature1");
+ assertThat(decoded.get(1).GetByteString()).isEqualTo(protectedHeader);
+ assertThat(decoded.get(2).GetByteString()).isEqualTo(externalAad);
+ assertThat(decoded.get(3).GetByteString()).isEqualTo(payload);
+ }
+
+ @Test
+ @DisplayName("Should handle null values")
+ void shouldHandleNullValues() {
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(null, null, null);
+
+ CBORObject decoded = CBORObject.DecodeFromBytes(sigStructure);
+ assertThat(decoded.get(1).GetByteString()).isEmpty();
+ assertThat(decoded.get(2).GetByteString()).isEmpty();
+ assertThat(decoded.get(3).GetByteString()).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("CoseProtectedHeader tests")
+ class CoseProtectedHeaderTests {
+
+ @Test
+ @DisplayName("Should detect RFC 9162 Merkle tree VDS")
+ void shouldDetectRfc9162MerkleTree() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, null, 1, null, null);
+ assertThat(header.isRfc9162MerkleTree()).isTrue();
+
+ CoseProtectedHeader headerOther = new CoseProtectedHeader(-7, null, 2, null, null);
+ assertThat(headerOther.isRfc9162MerkleTree()).isFalse();
+
+ CoseProtectedHeader headerNull = new CoseProtectedHeader(-7, null, null, null, null);
+ assertThat(headerNull.isRfc9162MerkleTree()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should format key ID as hex")
+ void shouldFormatKeyIdAsHex() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7,
+ new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF}, null, null, null);
+ assertThat(header.keyIdHex()).isEqualTo("deadbeef");
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProviderTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProviderTest.java
new file mode 100644
index 0000000..5e4ddfb
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittHeaderProviderTest.java
@@ -0,0 +1,398 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.upokecenter.cbor.CBORObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DefaultScittHeaderProviderTest {
+
+ @Nested
+ @DisplayName("Constructor tests")
+ class ConstructorTests {
+
+ @Test
+ @DisplayName("Should create provider with no arguments")
+ void shouldCreateWithNoArguments() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+ assertThat(provider).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should create provider with receipt and token bytes")
+ void shouldCreateWithReceiptAndToken() {
+ byte[] receipt = {0x01, 0x02, 0x03};
+ byte[] token = {0x04, 0x05, 0x06};
+
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider(receipt, token);
+ assertThat(provider).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should create provider with null values")
+ void shouldCreateWithNullValues() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider(null, null);
+ assertThat(provider).isNotNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("Builder tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should build empty provider")
+ void shouldBuildEmptyProvider() {
+ DefaultScittHeaderProvider provider = DefaultScittHeaderProvider.builder().build();
+ assertThat(provider).isNotNull();
+ assertThat(provider.getOutgoingHeaders()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should build provider with receipt")
+ void shouldBuildProviderWithReceipt() {
+ byte[] receipt = {0x01, 0x02, 0x03};
+
+ DefaultScittHeaderProvider provider = DefaultScittHeaderProvider.builder()
+ .receipt(receipt)
+ .build();
+
+ Map headers = provider.getOutgoingHeaders();
+ assertThat(headers).containsKey(ScittHeaders.SCITT_RECEIPT_HEADER);
+ }
+
+ @Test
+ @DisplayName("Should build provider with status token")
+ void shouldBuildProviderWithStatusToken() {
+ byte[] token = {0x01, 0x02, 0x03};
+
+ DefaultScittHeaderProvider provider = DefaultScittHeaderProvider.builder()
+ .statusToken(token)
+ .build();
+
+ Map headers = provider.getOutgoingHeaders();
+ assertThat(headers).containsKey(ScittHeaders.STATUS_TOKEN_HEADER);
+ }
+
+ @Test
+ @DisplayName("Should build provider with both artifacts")
+ void shouldBuildProviderWithBoth() {
+ byte[] receipt = {0x01, 0x02, 0x03};
+ byte[] token = {0x04, 0x05, 0x06};
+
+ DefaultScittHeaderProvider provider = DefaultScittHeaderProvider.builder()
+ .receipt(receipt)
+ .statusToken(token)
+ .build();
+
+ Map headers = provider.getOutgoingHeaders();
+ assertThat(headers).hasSize(2);
+ assertThat(headers).containsKey(ScittHeaders.SCITT_RECEIPT_HEADER);
+ assertThat(headers).containsKey(ScittHeaders.STATUS_TOKEN_HEADER);
+ }
+ }
+
+ @Nested
+ @DisplayName("getOutgoingHeaders() tests")
+ class GetOutgoingHeadersTests {
+
+ @Test
+ @DisplayName("Should return empty map when no artifacts")
+ void shouldReturnEmptyMapWhenNoArtifacts() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+
+ Map headers = provider.getOutgoingHeaders();
+
+ assertThat(headers).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should Base64 encode receipt")
+ void shouldBase64EncodeReceipt() {
+ byte[] receipt = {0x01, 0x02, 0x03};
+ String expectedBase64 = Base64.getEncoder().encodeToString(receipt);
+
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider(receipt, null);
+
+ Map headers = provider.getOutgoingHeaders();
+
+ assertThat(headers.get(ScittHeaders.SCITT_RECEIPT_HEADER)).isEqualTo(expectedBase64);
+ }
+
+ @Test
+ @DisplayName("Should Base64 encode status token")
+ void shouldBase64EncodeStatusToken() {
+ byte[] token = {0x04, 0x05, 0x06};
+ String expectedBase64 = Base64.getEncoder().encodeToString(token);
+
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider(null, token);
+
+ Map headers = provider.getOutgoingHeaders();
+
+ assertThat(headers.get(ScittHeaders.STATUS_TOKEN_HEADER)).isEqualTo(expectedBase64);
+ }
+
+ @Test
+ @DisplayName("Should return immutable map")
+ void shouldReturnImmutableMap() {
+ byte[] receipt = {0x01, 0x02, 0x03};
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider(receipt, null);
+
+ Map headers = provider.getOutgoingHeaders();
+
+ assertThatThrownBy(() -> headers.put("new-key", "value"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("extractArtifacts() tests")
+ class ExtractArtifactsTests {
+
+ @Test
+ @DisplayName("Should reject null headers")
+ void shouldRejectNullHeaders() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+
+ assertThatThrownBy(() -> provider.extractArtifacts(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("headers cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return empty when no SCITT headers")
+ void shouldReturnEmptyWhenNoScittHeaders() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+
+ Optional result =
+ provider.extractArtifacts(Map.of("Content-Type", "application/json"));
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should extract valid status token")
+ void shouldExtractValidStatusToken() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ String base64Token = Base64.getEncoder().encodeToString(tokenBytes);
+
+ Map headers = Map.of(ScittHeaders.STATUS_TOKEN_HEADER, base64Token);
+
+ Optional result = provider.extractArtifacts(headers);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().statusToken()).isNotNull();
+ assertThat(result.get().statusToken().agentId()).isEqualTo("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should extract valid receipt")
+ void shouldExtractValidReceipt() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+ byte[] receiptBytes = createValidReceiptBytes();
+ String base64Receipt = Base64.getEncoder().encodeToString(receiptBytes);
+
+ Map headers = Map.of(ScittHeaders.SCITT_RECEIPT_HEADER, base64Receipt);
+
+ Optional result = provider.extractArtifacts(headers);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().receipt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should extract both receipt and token")
+ void shouldExtractBothArtifacts() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+ byte[] receiptBytes = createValidReceiptBytes();
+ byte[] tokenBytes = createValidStatusTokenBytes();
+
+ Map headers = new HashMap<>();
+ headers.put(ScittHeaders.SCITT_RECEIPT_HEADER, Base64.getEncoder().encodeToString(receiptBytes));
+ headers.put(ScittHeaders.STATUS_TOKEN_HEADER, Base64.getEncoder().encodeToString(tokenBytes));
+
+ Optional result = provider.extractArtifacts(headers);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().receipt()).isNotNull();
+ assertThat(result.get().statusToken()).isNotNull();
+ assertThat(result.get().isComplete()).isTrue();
+ assertThat(result.get().isPresent()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should throw when headers present but invalid Base64")
+ void shouldThrowOnInvalidBase64() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+
+ Map headers = Map.of(ScittHeaders.STATUS_TOKEN_HEADER, "not-valid-base64!!!");
+
+ // Headers present but parse failed should throw, not return empty
+ // This allows callers to distinguish "no headers" from "headers present but malformed"
+ assertThatThrownBy(() -> provider.extractArtifacts(headers))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("SCITT headers present but failed to parse")
+ .hasMessageContaining("Invalid Base64");
+ }
+
+ @Test
+ @DisplayName("Should throw when headers present but invalid CBOR")
+ void shouldThrowOnInvalidCbor() {
+ DefaultScittHeaderProvider provider = new DefaultScittHeaderProvider();
+ byte[] invalidCbor = {0x01, 0x02, 0x03};
+
+ Map headers = Map.of(
+ ScittHeaders.STATUS_TOKEN_HEADER, Base64.getEncoder().encodeToString(invalidCbor));
+
+ // Headers present but parse failed should throw, not return empty
+ assertThatThrownBy(() -> provider.extractArtifacts(headers))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("SCITT headers present but failed to parse");
+ }
+ }
+
+ @Nested
+ @DisplayName("ScittArtifacts tests")
+ class ScittArtifactsTests {
+
+ @Test
+ @DisplayName("isComplete should return true when both present")
+ void isCompleteShouldReturnTrueWhenBothPresent() {
+ ScittReceipt receipt = createMockReceipt();
+ StatusToken token = createMockToken();
+
+ ScittHeaderProvider.ScittArtifacts artifacts =
+ new ScittHeaderProvider.ScittArtifacts(receipt, token, new byte[0], new byte[0]);
+
+ assertThat(artifacts.isComplete()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isComplete should return false when receipt missing")
+ void isCompleteShouldReturnFalseWhenReceiptMissing() {
+ StatusToken token = createMockToken();
+
+ ScittHeaderProvider.ScittArtifacts artifacts =
+ new ScittHeaderProvider.ScittArtifacts(null, token, null, new byte[0]);
+
+ assertThat(artifacts.isComplete()).isFalse();
+ }
+
+ @Test
+ @DisplayName("isComplete should return false when token missing")
+ void isCompleteShouldReturnFalseWhenTokenMissing() {
+ ScittReceipt receipt = createMockReceipt();
+
+ ScittHeaderProvider.ScittArtifacts artifacts =
+ new ScittHeaderProvider.ScittArtifacts(receipt, null, new byte[0], null);
+
+ assertThat(artifacts.isComplete()).isFalse();
+ }
+
+ @Test
+ @DisplayName("isPresent should return true when at least one present")
+ void isPresentShouldReturnTrueWhenAtLeastOnePresent() {
+ ScittReceipt receipt = createMockReceipt();
+
+ ScittHeaderProvider.ScittArtifacts artifacts =
+ new ScittHeaderProvider.ScittArtifacts(receipt, null, new byte[0], null);
+
+ assertThat(artifacts.isPresent()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isPresent should return false when both null")
+ void isPresentShouldReturnFalseWhenBothNull() {
+ ScittHeaderProvider.ScittArtifacts artifacts =
+ new ScittHeaderProvider.ScittArtifacts(null, null, null, null);
+
+ assertThat(artifacts.isPresent()).isFalse();
+ }
+ }
+
+ // Helper methods
+
+ private byte[] createValidStatusTokenBytes() {
+ long now = Instant.now().getEpochSecond();
+
+ // Use integer keys: 1=agent_id, 2=status, 3=iat, 4=exp
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "ACTIVE"); // status
+ payload.Add(3, now); // iat
+ payload.Add(4, now + 3600); // exp
+
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(payload.EncodeToBytes());
+ array.Add(new byte[64]); // signature
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+
+ private byte[] createValidReceiptBytes() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(395, 1); // vds = RFC9162_SHA256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Create unprotected header with inclusion proof (MAP format)
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 1L); // tree_size
+ inclusionProofMap.Add(-2, 0L); // leaf_index
+ inclusionProofMap.Add(-3, CBORObject.NewArray()); // empty hash_path
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("test-payload".getBytes());
+ array.Add(new byte[64]); // signature
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+
+ private ScittReceipt createMockReceipt() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], java.util.List.of());
+ return new ScittReceipt(header, new byte[10], proof, "payload".getBytes(), new byte[64]);
+ }
+
+ private StatusToken createMockToken() {
+ return new StatusToken(
+ "test-agent",
+ StatusToken.Status.ACTIVE,
+ Instant.now(),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "agent.example.com",
+ java.util.List.of(),
+ java.util.List.of(),
+ java.util.Map.of(),
+ null,
+ null,
+ null,
+ null
+ );
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifierTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifierTest.java
new file mode 100644
index 0000000..d181611
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/DefaultScittVerifierTest.java
@@ -0,0 +1,1080 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import com.godaddy.ans.sdk.crypto.CryptoCache;
+
+import org.bouncycastle.util.encoders.Hex;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DefaultScittVerifierTest {
+
+ private DefaultScittVerifier verifier;
+ private KeyPair keyPair;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ verifier = new DefaultScittVerifier();
+
+ // Generate test EC key pair (P-256)
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ keyPair = keyGen.generateKeyPair();
+ }
+
+ /**
+ * Helper to convert a PublicKey to a Map keyed by hex key ID.
+ */
+ private Map toRootKeys(PublicKey publicKey) {
+ // Compute hex key ID: SHA-256(SPKI-DER)[0:4] as hex
+ byte[] hash = CryptoCache.sha256(publicKey.getEncoded());
+ String hexKeyId = Hex.toHexString(Arrays.copyOf(hash, 4));
+ Map map = new HashMap<>();
+ map.put(hexKeyId, publicKey);
+ return map;
+ }
+
+ @Nested
+ @DisplayName("Constructor tests")
+ class ConstructorTests {
+
+ @Test
+ @DisplayName("Should create verifier with default clock skew")
+ void shouldCreateWithDefaultClockSkew() {
+ DefaultScittVerifier v = new DefaultScittVerifier();
+ assertThat(v).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should create verifier with custom clock skew")
+ void shouldCreateWithCustomClockSkew() {
+ DefaultScittVerifier v = new DefaultScittVerifier(Duration.ofMinutes(5));
+ assertThat(v).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should reject null clock skew tolerance")
+ void shouldRejectNullClockSkew() {
+ assertThatThrownBy(() -> new DefaultScittVerifier(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("clockSkewTolerance cannot be null");
+ }
+ }
+
+ @Nested
+ @DisplayName("verify() tests")
+ class VerifyTests {
+
+ @Test
+ @DisplayName("Should reject null receipt")
+ void shouldRejectNullReceipt() {
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ assertThatThrownBy(() -> verifier.verify(null, token, toRootKeys(keyPair.getPublic())))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("receipt cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null token")
+ void shouldRejectNullToken() {
+ ScittReceipt receipt = createMockReceipt();
+
+ assertThatThrownBy(() -> verifier.verify(receipt, null, toRootKeys(keyPair.getPublic())))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("token cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null root keys map")
+ void shouldRejectNullRootKeys() {
+ ScittReceipt receipt = createMockReceipt();
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ assertThatThrownBy(() -> verifier.verify(receipt, token, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("rootKeys cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return error for empty root keys map")
+ void shouldReturnErrorForEmptyRootKeys() {
+ ScittReceipt receipt = createMockReceipt();
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, new HashMap<>());
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(result.failureReason()).contains("No root keys available");
+ }
+
+ @Test
+ @DisplayName("Should return invalid receipt for bad receipt signature")
+ void shouldReturnInvalidReceiptForBadSignature() throws Exception {
+ ScittReceipt receipt = createReceiptWithSignature(new byte[64]); // Bad signature
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(result.failureReason()).contains("signature verification failed");
+ }
+
+ @Test
+ @DisplayName("Should return invalid token for revoked agent")
+ void shouldReturnInvalidTokenForRevokedAgent() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.REVOKED);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.AGENT_REVOKED);
+ }
+
+ @Test
+ @DisplayName("Should return inactive for deprecated agent")
+ void shouldReturnInactiveForDeprecatedAgent() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.DEPRECATED);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.AGENT_INACTIVE);
+ }
+
+ @Test
+ @DisplayName("Should allow WARNING status as valid")
+ void shouldAllowWarningStatus() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.WARNING);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // WARNING should be allowed (verified), not rejected
+ assertThat(result.status()).isIn(ScittExpectation.Status.VERIFIED, ScittExpectation.Status.INVALID_RECEIPT);
+ }
+ }
+
+ @Nested
+ @DisplayName("postVerify() tests")
+ class PostVerifyTests {
+
+ @Test
+ @DisplayName("Should reject null hostname")
+ void shouldRejectNullHostname() {
+ X509Certificate cert = mock(X509Certificate.class);
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("abc123"), List.of(), "host", "ans.test", Map.of(), null);
+
+ assertThatThrownBy(() -> verifier.postVerify(null, cert, expectation))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("hostname cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null server certificate")
+ void shouldRejectNullServerCert() {
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("abc123"), List.of(), "host", "ans.test", Map.of(), null);
+
+ assertThatThrownBy(() -> verifier.postVerify("test.example.com", null, expectation))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("serverCert cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null expectation")
+ void shouldRejectNullExpectation() {
+ X509Certificate cert = mock(X509Certificate.class);
+
+ assertThatThrownBy(() -> verifier.postVerify("test.example.com", cert, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("expectation cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return error for unverified expectation")
+ void shouldReturnErrorForUnverifiedExpectation() {
+ X509Certificate cert = mock(X509Certificate.class);
+ ScittExpectation expectation = ScittExpectation.invalidReceipt("Test failure");
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("pre-verification failed");
+ }
+
+ @Test
+ @DisplayName("Should return error when no expected fingerprints")
+ void shouldReturnErrorWhenNoFingerprints() {
+ X509Certificate cert = mock(X509Certificate.class);
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("No server certificate fingerprints");
+ }
+
+ @Test
+ @DisplayName("Should return success when fingerprint matches")
+ void shouldReturnSuccessWhenFingerprintMatches() throws Exception {
+ // Create a real-ish mock certificate
+ X509Certificate cert = mock(X509Certificate.class);
+ byte[] certBytes = new byte[100];
+ when(cert.getEncoded()).thenReturn(certBytes);
+
+ // Compute expected fingerprint
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(certBytes);
+ String expectedFingerprint = bytesToHex(digest);
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(expectedFingerprint), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isTrue();
+ assertThat(result.actualFingerprint()).isEqualTo(expectedFingerprint);
+ }
+
+ @Test
+ @DisplayName("Should return mismatch when fingerprint does not match")
+ void shouldReturnMismatchWhenFingerprintDoesNotMatch() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ when(cert.getEncoded()).thenReturn(new byte[100]);
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("deadbeef00000000000000000000000000000000000000000000000000000000"),
+ List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("does not match");
+ }
+
+ @Test
+ @DisplayName("Should normalize fingerprints with colons")
+ void shouldNormalizeFingerprintsWithColons() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ byte[] certBytes = new byte[100];
+ when(cert.getEncoded()).thenReturn(certBytes);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(certBytes);
+ String hexFingerprint = bytesToHex(digest);
+
+ // Format with colons (every 2 chars) and SHA256: prefix
+ StringBuilder colonFormatted = new StringBuilder("SHA256:");
+ for (int i = 0; i < hexFingerprint.length(); i += 2) {
+ if (i > 0) {
+ colonFormatted.append(":");
+ }
+ colonFormatted.append(hexFingerprint.substring(i, i + 2));
+ }
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(colonFormatted.toString()), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should match any of multiple expected fingerprints")
+ void shouldMatchAnyOfMultipleFingerprints() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ byte[] certBytes = new byte[100];
+ when(cert.getEncoded()).thenReturn(certBytes);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(certBytes);
+ String expectedFingerprint = bytesToHex(digest);
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(
+ "wrong1000000000000000000000000000000000000000000000000000000000",
+ expectedFingerprint,
+ "wrong2000000000000000000000000000000000000000000000000000000000"
+ ),
+ List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("Clock skew handling tests")
+ class ClockSkewTests {
+
+ @Test
+ @DisplayName("Should accept token within clock skew tolerance")
+ void shouldAcceptTokenWithinClockSkew() throws Exception {
+ // Create verifier with 60 second clock skew
+ DefaultScittVerifier v = new DefaultScittVerifier(Duration.ofSeconds(60));
+
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ // Token expired 30 seconds ago (within 60 second tolerance)
+ StatusToken token = createExpiredToken(keyPair.getPrivate(), Duration.ofSeconds(30));
+
+ ScittExpectation result = v.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // Should not be marked as expired
+ assertThat(result.status()).isNotEqualTo(ScittExpectation.Status.TOKEN_EXPIRED);
+ }
+
+ @Test
+ @DisplayName("Should reject token beyond clock skew tolerance")
+ void shouldRejectTokenBeyondClockSkew() throws Exception {
+ DefaultScittVerifier v = new DefaultScittVerifier(Duration.ofSeconds(60));
+
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ // Token expired 120 seconds ago (beyond 60 second tolerance)
+ StatusToken token = createExpiredToken(keyPair.getPrivate(), Duration.ofSeconds(120));
+
+ ScittExpectation result = v.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // May be TOKEN_EXPIRED or INVALID_TOKEN/INVALID_RECEIPT depending on verification order
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.TOKEN_EXPIRED,
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+ }
+
+ @Nested
+ @DisplayName("Merkle proof verification tests")
+ class MerkleProofTests {
+
+ @Test
+ @DisplayName("Should handle receipt with null inclusion proof")
+ void shouldHandleReceiptWithNullInclusionProof() throws Exception {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ ScittReceipt receipt = new ScittReceipt(
+ header,
+ new byte[10],
+ null, // null inclusion proof
+ "test-payload".getBytes(),
+ new byte[64]
+ );
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // Should fail at receipt signature verification first, or merkle proof verification
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+
+ @Test
+ @DisplayName("Should reject receipt with incomplete Merkle proof (no root hash)")
+ void shouldRejectIncompleteProof() throws Exception {
+ // Create a properly signed receipt but with incomplete Merkle proof
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "test-payload".getBytes();
+
+ // Sign the receipt properly
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(keyPair.getPrivate());
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+
+ // Proof without root hash (treeSize > 0 but rootHash = null) - INCOMPLETE
+ ScittReceipt.InclusionProof incompleteProof = new ScittReceipt.InclusionProof(
+ 10, 5, null, List.of());
+
+ ScittReceipt receipt = new ScittReceipt(header, protectedHeaderBytes, incompleteProof, payload,
+ p1363Signature);
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // Incomplete Merkle proof must fail - cannot verify log inclusion without all components
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(result.failureReason()).contains("Merkle proof");
+ }
+ }
+
+ @Nested
+ @DisplayName("Signature validation tests")
+ class SignatureValidationTests {
+
+ @Test
+ @DisplayName("Should fail verification with wrong signature length (not 64 bytes)")
+ void shouldFailWithWrongSignatureLength() throws Exception {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ byte[] payload = "test-payload".getBytes();
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, leafHash, List.of());
+
+ // Wrong signature length - 32 bytes instead of 64
+ byte[] wrongLengthSignature = new byte[32];
+ ScittReceipt receipt = new ScittReceipt(
+ header,
+ new byte[10],
+ proof,
+ payload,
+ wrongLengthSignature
+ );
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ }
+
+ @Test
+ @DisplayName("Should fail verification with wrong key")
+ void shouldFailWithWrongKey() throws Exception {
+ // Sign receipt with one key
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ // But provide a different key for verification
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ KeyPair wrongKeyPair = keyGen.generateKeyPair();
+
+ // Verify with wrong key
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(wrongKeyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ }
+ }
+
+ @Nested
+ @DisplayName("Merkle proof validation tests")
+ class MerkleProofValidationTests {
+
+ @Test
+ @DisplayName("Should fail verification with wrong root hash")
+ void shouldFailWithWrongRootHash() throws Exception {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ byte[] payload = "test-payload".getBytes();
+
+ // Create proof with correct leaf but wrong root hash
+ byte[] wrongRootHash = new byte[32];
+ Arrays.fill(wrongRootHash, (byte) 0xFF);
+
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, wrongRootHash, List.of());
+
+ ScittReceipt receipt = new ScittReceipt(
+ header,
+ new byte[10],
+ proof,
+ payload,
+ new byte[64]
+ );
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // Should fail at receipt signature verification first (invalid signature bytes)
+ // or at Merkle proof verification
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+
+ @Test
+ @DisplayName("Should fail verification with incorrect hash path")
+ void shouldFailWithIncorrectHashPath() throws Exception {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ byte[] payload = "test-payload".getBytes();
+
+ // Build a tree with 2 elements but provide wrong sibling hash
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ byte[] siblingHash = new byte[32];
+ Arrays.fill(siblingHash, (byte) 0xAA);
+
+ // Calculate root with wrong sibling
+ byte[] wrongRoot = MerkleProofVerifier.hashNode(leafHash, siblingHash);
+
+ // But use a different (incorrect) sibling in the path
+ byte[] incorrectSibling = new byte[32];
+ Arrays.fill(incorrectSibling, (byte) 0xBB);
+
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 2, 0, wrongRoot, List.of(incorrectSibling));
+
+ ScittReceipt receipt = new ScittReceipt(
+ header,
+ new byte[10],
+ proof,
+ payload,
+ new byte[64]
+ );
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+
+ @Test
+ @DisplayName("Should handle empty hash path for single element tree")
+ void shouldHandleEmptyHashPathForSingleElement() throws Exception {
+ // Sign receipt properly
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "test-payload".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(keyPair.getPrivate());
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+
+ // Single element tree: root == leaf hash
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, leafHash, List.of()); // Empty path for single element
+
+ ScittReceipt receipt = new ScittReceipt(header, protectedHeaderBytes, proof, payload, p1363Signature);
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // Should succeed - valid receipt and token
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.VERIFIED);
+ }
+ }
+
+ @Nested
+ @DisplayName("postVerify error handling tests")
+ class PostVerifyErrorHandlingTests {
+
+ @Test
+ @DisplayName("Should handle certificate encoding exception")
+ void shouldHandleCertificateEncodingException() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ when(cert.getEncoded()).thenThrow(new java.security.cert.CertificateEncodingException("Test error"));
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("abc123"), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("Error computing fingerprint");
+ }
+
+ @Test
+ @DisplayName("Should return error for expired expectation")
+ void shouldReturnErrorForExpiredExpectation() {
+ X509Certificate cert = mock(X509Certificate.class);
+ ScittExpectation expectation = ScittExpectation.expired();
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("pre-verification failed");
+ }
+
+ @Test
+ @DisplayName("Should return error for revoked expectation")
+ void shouldReturnErrorForRevokedExpectation() {
+ X509Certificate cert = mock(X509Certificate.class);
+ ScittExpectation expectation = ScittExpectation.revoked("test.ans");
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.failureReason()).contains("pre-verification failed");
+ }
+ }
+
+ @Nested
+ @DisplayName("Fingerprint normalization tests")
+ class FingerprintNormalizationTests {
+
+ @Test
+ @DisplayName("Should normalize uppercase fingerprint")
+ void shouldNormalizeUppercaseFingerprint() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ byte[] certBytes = new byte[100];
+ when(cert.getEncoded()).thenReturn(certBytes);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(certBytes);
+ String expectedFingerprint = bytesToHex(digest).toUpperCase();
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(expectedFingerprint), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should handle mixed case SHA256 prefix")
+ void shouldHandleMixedCaseSha256Prefix() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ byte[] certBytes = new byte[100];
+ when(cert.getEncoded()).thenReturn(certBytes);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(certBytes);
+ String hexFingerprint = bytesToHex(digest);
+ String fingerprintWithPrefix = "SHA256:" + hexFingerprint;
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(fingerprintWithPrefix), List.of(), "host", "ans.test", Map.of(), null);
+
+ ScittVerifier.ScittVerificationResult result =
+ verifier.postVerify("test.example.com", cert, expectation);
+
+ assertThat(result.success()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("Key ID validation tests")
+ class KeyIdValidationTests {
+
+ @Test
+ @DisplayName("Should reject receipt with mismatched key ID")
+ void shouldRejectReceiptWithMismatchedKeyId() throws Exception {
+ // Create receipt with wrong key ID (not matching the public key)
+ byte[] wrongKeyId = new byte[] {
+ 0x00, 0x00, 0x00, 0x00
+ };
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, wrongKeyId, 1, null, null);
+
+ byte[] payload = "test-payload".getBytes();
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, leafHash, List.of());
+
+ ScittReceipt receipt = new ScittReceipt(header, new byte[10], proof, payload, new byte[64]);
+ StatusToken token = createMockStatusToken(StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(result.failureReason()).contains("not in trust store");
+ }
+
+ @Test
+ @DisplayName("Should reject token with mismatched key ID")
+ void shouldRejectTokenWithMismatchedKeyId() throws Exception {
+ // Create valid receipt with correct key ID
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+
+ // Create token with wrong key ID
+ byte[] wrongKeyId = new byte[] {
+ 0x00, 0x00, 0x00, 0x00
+ };
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "agent_id:test-agent,status:ACTIVE".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(keyPair.getPrivate());
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ CoseProtectedHeader tokenHeader = new CoseProtectedHeader(-7, wrongKeyId, null, null, null);
+ StatusToken token = new StatusToken(
+ "test-agent-id",
+ StatusToken.Status.ACTIVE,
+ Instant.now().minusSeconds(60),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "test.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ tokenHeader,
+ protectedHeaderBytes,
+ payload,
+ p1363Signature
+ );
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_TOKEN);
+ assertThat(result.failureReason()).contains("not in trust store");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt with missing key ID")
+ void shouldRejectReceiptWithMissingKeyId() throws Exception {
+ // Create receipt with null key ID
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "test-payload".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(keyPair.getPrivate());
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ // null key ID should be rejected
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, null, 1, null, null);
+
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, leafHash, List.of());
+
+ ScittReceipt receipt = new ScittReceipt(header, protectedHeaderBytes, proof, payload, p1363Signature);
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(result.failureReason()).contains("not in trust store");
+ }
+
+ @Test
+ @DisplayName("Should reject token with missing key ID")
+ void shouldRejectTokenWithMissingKeyId() throws Exception {
+ // Create valid receipt with correct key ID
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+
+ // Create token with null key ID
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "agent_id:test-agent,status:ACTIVE".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(keyPair.getPrivate());
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ // null key ID should be rejected
+ CoseProtectedHeader tokenHeader = new CoseProtectedHeader(-7, null, null, null, null);
+ StatusToken token = new StatusToken(
+ "test-agent-id",
+ StatusToken.Status.ACTIVE,
+ Instant.now().minusSeconds(60),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "test.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ tokenHeader,
+ protectedHeaderBytes,
+ payload,
+ p1363Signature
+ );
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.INVALID_TOKEN);
+ assertThat(result.failureReason()).contains("not in trust store");
+ }
+
+ @Test
+ @DisplayName("Should accept artifact with correct key ID")
+ void shouldAcceptArtifactWithCorrectKeyId() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.ACTIVE);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isEqualTo(ScittExpectation.Status.VERIFIED);
+ }
+ }
+
+ @Nested
+ @DisplayName("Verification with different status tests")
+ class VerificationStatusTests {
+
+ @Test
+ @DisplayName("Should return inactive for UNKNOWN status")
+ void shouldReturnInactiveForUnknownStatus() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.UNKNOWN);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ // May be AGENT_INACTIVE or INVALID_RECEIPT depending on signature verification
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.AGENT_INACTIVE,
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+
+ @Test
+ @DisplayName("Should return inactive for EXPIRED status")
+ void shouldReturnInactiveForExpiredStatus() throws Exception {
+ ScittReceipt receipt = createValidSignedReceipt(keyPair.getPrivate());
+ StatusToken token = createValidSignedToken(keyPair.getPrivate(), StatusToken.Status.EXPIRED);
+
+ ScittExpectation result = verifier.verify(receipt, token, toRootKeys(keyPair.getPublic()));
+
+ assertThat(result.status()).isIn(
+ ScittExpectation.Status.AGENT_INACTIVE,
+ ScittExpectation.Status.INVALID_RECEIPT,
+ ScittExpectation.Status.INVALID_TOKEN
+ );
+ }
+ }
+
+ // Helper methods
+
+ private ScittReceipt createMockReceipt() {
+ try {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, new byte[32], List.of());
+ return new ScittReceipt(
+ header,
+ new byte[10],
+ proof,
+ "test-payload".getBytes(),
+ new byte[64]
+ );
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ScittReceipt createReceiptWithSignature(byte[] signature) {
+ try {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, new byte[32], List.of());
+ return new ScittReceipt(
+ header,
+ new byte[10],
+ proof,
+ "test-payload".getBytes(),
+ signature
+ );
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ScittReceipt createValidSignedReceipt(PrivateKey privateKey) throws Exception {
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "test-payload".getBytes();
+
+ // Build sig structure
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+
+ // Sign
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(privateKey);
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, 1, null, null);
+
+ // Create valid Merkle proof
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(payload);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 1, 0, leafHash, List.of());
+
+ return new ScittReceipt(header, protectedHeaderBytes, proof, payload, p1363Signature);
+ }
+
+ private StatusToken createMockStatusToken(StatusToken.Status status) {
+ try {
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ return new StatusToken(
+ "test-agent-id",
+ status,
+ Instant.now().minusSeconds(60),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "test.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ new CoseProtectedHeader(-7, keyId, null, null, null),
+ new byte[10],
+ "test-payload".getBytes(),
+ new byte[64]
+ );
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private StatusToken createValidSignedToken(PrivateKey privateKey, StatusToken.Status status) throws Exception {
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = ("agent_id:test-agent,status:" + status.name()).getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(privateKey);
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, null, null, null);
+
+ return new StatusToken(
+ "test-agent-id",
+ status,
+ Instant.now().minusSeconds(60),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "test.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ header,
+ protectedHeaderBytes,
+ payload,
+ p1363Signature
+ );
+ }
+
+ private StatusToken createExpiredToken(PrivateKey privateKey, Duration expiredAgo) throws Exception {
+ byte[] protectedHeaderBytes = new byte[10];
+ byte[] payload = "agent_id:test-agent,status:ACTIVE".getBytes();
+
+ byte[] sigStructure = CoseSign1Parser.buildSigStructure(protectedHeaderBytes, null, payload);
+
+ Signature sig = Signature.getInstance("SHA256withECDSA");
+ sig.initSign(privateKey);
+ sig.update(sigStructure);
+ byte[] derSignature = sig.sign();
+ byte[] p1363Signature = convertDerToP1363(derSignature);
+
+ byte[] keyId = computeKeyId(keyPair.getPublic());
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, keyId, null, null, null);
+
+ return new StatusToken(
+ "test-agent-id",
+ StatusToken.Status.ACTIVE,
+ Instant.now().minusSeconds(7200),
+ Instant.now().minus(expiredAgo), // Expired
+ "test.ans",
+ "test.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ header,
+ protectedHeaderBytes,
+ payload,
+ p1363Signature
+ );
+ }
+
+ private byte[] convertDerToP1363(byte[] derSignature) {
+ // DER format: SEQUENCE { INTEGER r, INTEGER s }
+ // P1363 format: r || s (each 32 bytes for P-256)
+ byte[] p1363 = new byte[64];
+
+ int offset = 2; // Skip SEQUENCE tag and length
+ if (derSignature[1] == (byte) 0x81) {
+ offset++;
+ }
+
+ // Parse r
+ offset++; // Skip INTEGER tag
+ int rLen = derSignature[offset++] & 0xFF;
+ int rOffset = offset;
+ if (rLen == 33 && derSignature[rOffset] == 0) {
+ rOffset++;
+ rLen--;
+ }
+ System.arraycopy(derSignature, rOffset, p1363, 32 - rLen, rLen);
+ offset += (derSignature[offset - 1] & 0xFF);
+
+ // Parse s
+ offset++; // Skip INTEGER tag
+ int sLen = derSignature[offset++] & 0xFF;
+ int sOffset = offset;
+ if (sLen == 33 && derSignature[sOffset] == 0) {
+ sOffset++;
+ sLen--;
+ }
+ System.arraycopy(derSignature, sOffset, p1363, 64 - sLen, sLen);
+
+ return p1363;
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Computes the key ID for a public key per C2SP specification.
+ * The key ID is the first 4 bytes of SHA-256(SPKI-DER).
+ */
+ private byte[] computeKeyId(java.security.PublicKey publicKey) throws Exception {
+ byte[] spkiDer = publicKey.getEncoded();
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] hash = md.digest(spkiDer);
+ return Arrays.copyOf(hash, 4);
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifierTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifierTest.java
new file mode 100644
index 0000000..11703c2
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MerkleProofVerifierTest.java
@@ -0,0 +1,453 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class MerkleProofVerifierTest {
+
+ @Nested
+ @DisplayName("hashLeaf() tests")
+ class HashLeafTests {
+
+ @Test
+ @DisplayName("Should compute correct leaf hash with domain separation")
+ void shouldComputeCorrectLeafHash() {
+ byte[] data = "test".getBytes(StandardCharsets.UTF_8);
+ byte[] hash = MerkleProofVerifier.hashLeaf(data);
+
+ // Should be 32 bytes (SHA-256)
+ assertThat(hash).hasSize(32);
+
+ // Different data should produce different hash
+ byte[] data2 = "test2".getBytes(StandardCharsets.UTF_8);
+ byte[] hash2 = MerkleProofVerifier.hashLeaf(data2);
+ assertThat(hash).isNotEqualTo(hash2);
+ }
+
+ @Test
+ @DisplayName("Should produce consistent hashes")
+ void shouldProduceConsistentHashes() {
+ byte[] data = "consistent".getBytes(StandardCharsets.UTF_8);
+ byte[] hash1 = MerkleProofVerifier.hashLeaf(data);
+ byte[] hash2 = MerkleProofVerifier.hashLeaf(data);
+ assertThat(hash1).isEqualTo(hash2);
+ }
+
+ @Test
+ @DisplayName("Leaf hash should differ from raw SHA-256 (domain separation)")
+ void leafHashShouldDifferFromRawSha256() throws Exception {
+ byte[] data = "test".getBytes(StandardCharsets.UTF_8);
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(data);
+
+ // Raw SHA-256 without domain separation prefix
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
+ byte[] rawHash = md.digest(data);
+
+ // Should be different due to 0x00 prefix in leaf hash
+ assertThat(leafHash).isNotEqualTo(rawHash);
+ }
+ }
+
+ @Nested
+ @DisplayName("hashNode() tests")
+ class HashNodeTests {
+
+ @Test
+ @DisplayName("Should compute correct node hash with domain separation")
+ void shouldComputeCorrectNodeHash() {
+ byte[] left = new byte[32];
+ byte[] right = new byte[32];
+ Arrays.fill(left, (byte) 0x01);
+ Arrays.fill(right, (byte) 0x02);
+
+ byte[] hash = MerkleProofVerifier.hashNode(left, right);
+ assertThat(hash).hasSize(32);
+
+ // Different order should produce different hash
+ byte[] hashReversed = MerkleProofVerifier.hashNode(right, left);
+ assertThat(hash).isNotEqualTo(hashReversed);
+ }
+ }
+
+ @Nested
+ @DisplayName("calculatePathLength() tests")
+ class CalculatePathLengthTests {
+
+ @Test
+ @DisplayName("Should return 0 for tree size 1")
+ void shouldReturn0ForSize1() {
+ assertThat(MerkleProofVerifier.calculatePathLength(1)).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("Should return 1 for tree size 2")
+ void shouldReturn1ForSize2() {
+ assertThat(MerkleProofVerifier.calculatePathLength(2)).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("Should return correct length for power-of-two sizes")
+ void shouldReturnCorrectLengthForPowerOfTwo() {
+ assertThat(MerkleProofVerifier.calculatePathLength(4)).isEqualTo(2);
+ assertThat(MerkleProofVerifier.calculatePathLength(8)).isEqualTo(3);
+ assertThat(MerkleProofVerifier.calculatePathLength(16)).isEqualTo(4);
+ assertThat(MerkleProofVerifier.calculatePathLength(1024)).isEqualTo(10);
+ }
+
+ @Test
+ @DisplayName("Should return correct length for non-power-of-two sizes")
+ void shouldReturnCorrectLengthForNonPowerOfTwo() {
+ assertThat(MerkleProofVerifier.calculatePathLength(3)).isEqualTo(2);
+ assertThat(MerkleProofVerifier.calculatePathLength(5)).isEqualTo(3);
+ assertThat(MerkleProofVerifier.calculatePathLength(7)).isEqualTo(3);
+ assertThat(MerkleProofVerifier.calculatePathLength(100)).isEqualTo(7);
+ }
+ }
+
+ @Nested
+ @DisplayName("verifyInclusion() tests")
+ class VerifyInclusionTests {
+
+ @Test
+ @DisplayName("Should reject null leaf data")
+ void shouldRejectNullLeafData() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(null, 0, 1, List.of(), new byte[32]))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("leafData cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject leaf index >= tree size")
+ void shouldRejectInvalidLeafIndex() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(new byte[10], 5, 5, List.of(), new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid leaf index");
+ }
+
+ @Test
+ @DisplayName("Should reject zero tree size")
+ void shouldRejectZeroTreeSize() {
+ // Note: leaf index validation happens before tree size validation
+ // when leaf index >= tree size, so we expect the leaf index error first
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(new byte[10], 0, 0, List.of(), new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid leaf index");
+ }
+
+ @Test
+ @DisplayName("Should reject invalid root hash length")
+ void shouldRejectInvalidRootHashLength() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(new byte[10], 0, 1, List.of(), new byte[16]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid expected root hash length");
+ }
+
+ @Test
+ @DisplayName("Should verify single-element tree")
+ void shouldVerifySingleElementTree() throws ScittParseException {
+ byte[] leafData = "single leaf".getBytes(StandardCharsets.UTF_8);
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(leafData);
+
+ // For a single-element tree, the root hash IS the leaf hash
+ boolean valid = MerkleProofVerifier.verifyInclusion(
+ leafData, 0, 1, List.of(), leafHash);
+
+ assertThat(valid).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should reject mismatched root hash")
+ void shouldRejectMismatchedRootHash() throws ScittParseException {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+ byte[] wrongRoot = new byte[32];
+ Arrays.fill(wrongRoot, (byte) 0xFF);
+
+ boolean valid = MerkleProofVerifier.verifyInclusion(
+ leafData, 0, 1, List.of(), wrongRoot);
+
+ assertThat(valid).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should verify two-element tree")
+ void shouldVerifyTwoElementTree() throws ScittParseException {
+ // Build a 2-element tree manually
+ byte[] leaf0Data = "leaf0".getBytes(StandardCharsets.UTF_8);
+ byte[] leaf1Data = "leaf1".getBytes(StandardCharsets.UTF_8);
+
+ byte[] leaf0Hash = MerkleProofVerifier.hashLeaf(leaf0Data);
+ byte[] leaf1Hash = MerkleProofVerifier.hashLeaf(leaf1Data);
+
+ // Root = hash(leaf0Hash || leaf1Hash)
+ byte[] rootHash = MerkleProofVerifier.hashNode(leaf0Hash, leaf1Hash);
+
+ // Verify leaf0 with leaf1Hash as sibling
+ boolean valid0 = MerkleProofVerifier.verifyInclusion(
+ leaf0Data, 0, 2, List.of(leaf1Hash), rootHash);
+ assertThat(valid0).isTrue();
+
+ // Verify leaf1 with leaf0Hash as sibling
+ boolean valid1 = MerkleProofVerifier.verifyInclusion(
+ leaf1Data, 1, 2, List.of(leaf0Hash), rootHash);
+ assertThat(valid1).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("verifyInclusionWithHash() tests")
+ class VerifyInclusionWithHashTests {
+
+ @Test
+ @DisplayName("Should reject invalid leaf hash length")
+ void shouldRejectInvalidLeafHashLength() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[16], 0, 1, List.of(), new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid leaf hash length");
+ }
+
+ @Test
+ @DisplayName("Should verify with pre-computed hash")
+ void shouldVerifyWithPreComputedHash() throws ScittParseException {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+ byte[] leafHash = MerkleProofVerifier.hashLeaf(leafData);
+
+ boolean valid = MerkleProofVerifier.verifyInclusionWithHash(
+ leafHash, 0, 1, List.of(), leafHash);
+
+ assertThat(valid).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should reject null leaf hash")
+ void shouldRejectNullLeafHash() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(null, 0, 1, List.of(), new byte[32]))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("leafHash cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null hash path")
+ void shouldRejectNullHashPath() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[32], 0, 1, null, new byte[32]))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("hashPath cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null expected root hash")
+ void shouldRejectNullExpectedRootHash() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[32], 0, 1, List.of(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("expectedRootHash cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject leaf index >= tree size")
+ void shouldRejectInvalidLeafIndex() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[32], 5, 5, List.of(), new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid leaf index");
+ }
+
+ @Test
+ @DisplayName("Should reject zero tree size")
+ void shouldRejectZeroTreeSize() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[32], 0, 0, List.of(), new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid leaf index");
+ }
+
+ @Test
+ @DisplayName("Should reject invalid expected root hash length")
+ void shouldRejectInvalidExpectedRootHashLength() {
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusionWithHash(new byte[32], 0, 1, List.of(), new byte[16]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid expected root hash length");
+ }
+
+ @Test
+ @DisplayName("Should verify two-element tree with pre-computed hash")
+ void shouldVerifyTwoElementTreeWithPreComputedHash() throws ScittParseException {
+ byte[] leaf0Hash = MerkleProofVerifier.hashLeaf("leaf0".getBytes(StandardCharsets.UTF_8));
+ byte[] leaf1Hash = MerkleProofVerifier.hashLeaf("leaf1".getBytes(StandardCharsets.UTF_8));
+ byte[] rootHash = MerkleProofVerifier.hashNode(leaf0Hash, leaf1Hash);
+
+ boolean valid = MerkleProofVerifier.verifyInclusionWithHash(
+ leaf0Hash, 0, 2, List.of(leaf1Hash), rootHash);
+
+ assertThat(valid).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("Hash path validation tests")
+ class HashPathValidationTests {
+
+ @Test
+ @DisplayName("Should reject hash path too long for tree size")
+ void shouldRejectHashPathTooLong() {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+ // For tree size 2, max path length is 1
+ List tooLongPath = List.of(new byte[32], new byte[32], new byte[32]);
+
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(leafData, 0, 2, tooLongPath, new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Hash path too long");
+ }
+
+ @Test
+ @DisplayName("Should reject null hash in path")
+ void shouldRejectNullHashInPath() {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+ List pathWithNull = Arrays.asList(new byte[32], null);
+
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(leafData, 0, 4, pathWithNull, new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid hash at path index 1");
+ }
+
+ @Test
+ @DisplayName("Should reject wrong-sized hash in path")
+ void shouldRejectWrongSizedHashInPath() {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+ List pathWithWrongSize = List.of(new byte[32], new byte[16]);
+
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(leafData, 0, 4, pathWithWrongSize, new byte[32]))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Invalid hash at path index 1");
+ }
+
+ @Test
+ @DisplayName("Should reject null hashPath")
+ void shouldRejectNullHashPath() {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(leafData, 0, 1, null, new byte[32]))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("hashPath cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null expectedRootHash")
+ void shouldRejectNullExpectedRootHash() {
+ byte[] leafData = "leaf".getBytes(StandardCharsets.UTF_8);
+
+ assertThatThrownBy(() ->
+ MerkleProofVerifier.verifyInclusion(leafData, 0, 1, List.of(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("expectedRootHash cannot be null");
+ }
+ }
+
+ @Nested
+ @DisplayName("Tree structure tests")
+ class TreeStructureTests {
+
+ @Test
+ @DisplayName("Should verify four-element tree (balanced)")
+ void shouldVerifyFourElementTree() throws ScittParseException {
+ // Tree structure for 4 leaves:
+ // root
+ // / \
+ // node01 node23
+ // / \ / \
+ // L0 L1 L2 L3
+
+ byte[] leaf0Hash = MerkleProofVerifier.hashLeaf("leaf0".getBytes(StandardCharsets.UTF_8));
+ byte[] leaf1Hash = MerkleProofVerifier.hashLeaf("leaf1".getBytes(StandardCharsets.UTF_8));
+ byte[] leaf2Hash = MerkleProofVerifier.hashLeaf("leaf2".getBytes(StandardCharsets.UTF_8));
+ byte[] leaf3Hash = MerkleProofVerifier.hashLeaf("leaf3".getBytes(StandardCharsets.UTF_8));
+
+ byte[] node01Hash = MerkleProofVerifier.hashNode(leaf0Hash, leaf1Hash);
+ byte[] node23Hash = MerkleProofVerifier.hashNode(leaf2Hash, leaf3Hash);
+ byte[] rootHash = MerkleProofVerifier.hashNode(node01Hash, node23Hash);
+
+ // Verify leaf0 (index=0)
+ boolean valid0 = MerkleProofVerifier.verifyInclusionWithHash(
+ leaf0Hash, 0, 4, List.of(leaf1Hash, node23Hash), rootHash);
+ assertThat(valid0).isTrue();
+
+ // Verify leaf3 (index=3)
+ boolean valid3 = MerkleProofVerifier.verifyInclusionWithHash(
+ leaf3Hash, 3, 4, List.of(leaf2Hash, node01Hash), rootHash);
+ assertThat(valid3).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("calculatePathLength edge cases")
+ class CalculatePathLengthEdgeCaseTests {
+
+ @Test
+ @DisplayName("Should return 0 for tree size 0")
+ void shouldReturn0ForSize0() {
+ assertThat(MerkleProofVerifier.calculatePathLength(0)).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("Should handle large tree sizes")
+ void shouldHandleLargeTreeSizes() {
+ assertThat(MerkleProofVerifier.calculatePathLength(1_000_000)).isEqualTo(20);
+ assertThat(MerkleProofVerifier.calculatePathLength(1L << 30)).isEqualTo(30);
+ }
+
+ @Test
+ @DisplayName("Should handle max practical tree size (2^62)")
+ void shouldHandleMaxPracticalTreeSize() {
+ // Test a very large but practical tree size (2^62)
+ // Path length should be 62
+ long largeTreeSize = 1L << 62;
+ assertThat(MerkleProofVerifier.calculatePathLength(largeTreeSize)).isEqualTo(62);
+ }
+ }
+
+ @Nested
+ @DisplayName("Utility methods tests")
+ class UtilityMethodsTests {
+
+ @Test
+ @DisplayName("Should convert hex to bytes")
+ void shouldConvertHexToBytes() {
+ byte[] bytes = MerkleProofVerifier.hexToBytes("deadbeef");
+ assertThat(bytes).containsExactly((byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF);
+ }
+
+ @Test
+ @DisplayName("Should convert bytes to hex")
+ void shouldConvertBytesToHex() {
+ byte[] bytes = {(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF};
+ assertThat(MerkleProofVerifier.bytesToHex(bytes)).isEqualTo("deadbeef");
+ }
+
+ @Test
+ @DisplayName("Should reject odd-length hex string")
+ void shouldRejectOddLengthHex() {
+ assertThatThrownBy(() -> MerkleProofVerifier.hexToBytes("abc"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Hex string must have even length");
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifierTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifierTest.java
new file mode 100644
index 0000000..eafef7c
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/MetadataHashVerifierTest.java
@@ -0,0 +1,192 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class MetadataHashVerifierTest {
+
+ @Nested
+ @DisplayName("verify() tests")
+ class VerifyTests {
+
+ @Test
+ @DisplayName("Should reject null metadata bytes")
+ void shouldRejectNullMetadataBytes() {
+ assertThatThrownBy(() -> MetadataHashVerifier.verify(null, "SHA256:abc"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("metadataBytes cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject null expected hash")
+ void shouldRejectNullExpectedHash() {
+ assertThatThrownBy(() -> MetadataHashVerifier.verify(new byte[10], null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("expectedHash cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject invalid hash format")
+ void shouldRejectInvalidHashFormat() {
+ byte[] data = "test".getBytes(StandardCharsets.UTF_8);
+
+ assertThat(MetadataHashVerifier.verify(data, "invalid")).isFalse();
+ assertThat(MetadataHashVerifier.verify(data, "SHA256:abc")).isFalse(); // Too short
+ assertThat(MetadataHashVerifier.verify(data, "MD5:0123456789abcdef0123456789abcdef")).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should verify matching hash")
+ void shouldVerifyMatchingHash() {
+ byte[] data = "test metadata content".getBytes(StandardCharsets.UTF_8);
+ String hash = MetadataHashVerifier.computeHash(data);
+
+ assertThat(MetadataHashVerifier.verify(data, hash)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should reject mismatched hash")
+ void shouldRejectMismatchedHash() {
+ byte[] data = "test metadata".getBytes(StandardCharsets.UTF_8);
+ String wrongHash = "SHA256:0000000000000000000000000000000000000000000000000000000000000000";
+
+ assertThat(MetadataHashVerifier.verify(data, wrongHash)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should be case insensitive for hash prefix")
+ void shouldBeCaseInsensitiveForPrefix() {
+ byte[] data = "test".getBytes(StandardCharsets.UTF_8);
+ String hash = MetadataHashVerifier.computeHash(data);
+ String lowerHash = hash.toLowerCase();
+ String upperHash = hash.toUpperCase();
+
+ assertThat(MetadataHashVerifier.verify(data, lowerHash)).isTrue();
+ assertThat(MetadataHashVerifier.verify(data, upperHash)).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("computeHash() tests")
+ class ComputeHashTests {
+
+ @Test
+ @DisplayName("Should reject null input")
+ void shouldRejectNullInput() {
+ assertThatThrownBy(() -> MetadataHashVerifier.computeHash(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("metadataBytes cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should compute hash with correct format")
+ void shouldComputeHashWithCorrectFormat() {
+ byte[] data = "test".getBytes(StandardCharsets.UTF_8);
+ String hash = MetadataHashVerifier.computeHash(data);
+
+ assertThat(hash).startsWith("SHA256:");
+ assertThat(hash).hasSize(7 + 64); // "SHA256:" + 64 hex chars
+ }
+
+ @Test
+ @DisplayName("Should produce consistent hashes")
+ void shouldProduceConsistentHashes() {
+ byte[] data = "consistent data".getBytes(StandardCharsets.UTF_8);
+
+ assertThat(MetadataHashVerifier.computeHash(data))
+ .isEqualTo(MetadataHashVerifier.computeHash(data));
+ }
+
+ @Test
+ @DisplayName("Should produce different hashes for different data")
+ void shouldProduceDifferentHashes() {
+ String hash1 = MetadataHashVerifier.computeHash("data1".getBytes());
+ String hash2 = MetadataHashVerifier.computeHash("data2".getBytes());
+
+ assertThat(hash1).isNotEqualTo(hash2);
+ }
+ }
+
+ @Nested
+ @DisplayName("isValidHashFormat() tests")
+ class IsValidHashFormatTests {
+
+ @Test
+ @DisplayName("Should accept valid hash format")
+ void shouldAcceptValidFormat() {
+ String validHash = "SHA256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ assertThat(MetadataHashVerifier.isValidHashFormat(validHash)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should accept uppercase hex")
+ void shouldAcceptUppercaseHex() {
+ String validHash = "SHA256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ assertThat(MetadataHashVerifier.isValidHashFormat(validHash)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should reject null")
+ void shouldRejectNull() {
+ assertThat(MetadataHashVerifier.isValidHashFormat(null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject wrong prefix")
+ void shouldRejectWrongPrefix() {
+ assertThat(MetadataHashVerifier.isValidHashFormat("MD5:abc")).isFalse();
+ assertThat(MetadataHashVerifier.isValidHashFormat("sha256:abc")).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject wrong length")
+ void shouldRejectWrongLength() {
+ assertThat(MetadataHashVerifier.isValidHashFormat("SHA256:abc")).isFalse();
+ assertThat(MetadataHashVerifier.isValidHashFormat("SHA256:")).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject non-hex characters")
+ void shouldRejectNonHexCharacters() {
+ String invalidHash = "SHA256:ghijklmnopqrstuvwxyz0123456789abcdef0123456789abcdef01234567";
+ assertThat(MetadataHashVerifier.isValidHashFormat(invalidHash)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("extractHex() tests")
+ class ExtractHexTests {
+
+ @Test
+ @DisplayName("Should extract hex portion")
+ void shouldExtractHexPortion() {
+ String hash = "SHA256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ String hex = MetadataHashVerifier.extractHex(hash);
+
+ assertThat(hex).isEqualTo("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
+ }
+
+ @Test
+ @DisplayName("Should return lowercase hex")
+ void shouldReturnLowercaseHex() {
+ String hash = "SHA256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ String hex = MetadataHashVerifier.extractHex(hash);
+
+ assertThat(hex).isEqualTo("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
+ }
+
+ @Test
+ @DisplayName("Should return null for invalid format")
+ void shouldReturnNullForInvalidFormat() {
+ assertThat(MetadataHashVerifier.extractHex(null)).isNull();
+ assertThat(MetadataHashVerifier.extractHex("invalid")).isNull();
+ assertThat(MetadataHashVerifier.extractHex("SHA256:abc")).isNull();
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecisionTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecisionTest.java
new file mode 100644
index 0000000..1a8c3f4
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/RefreshDecisionTest.java
@@ -0,0 +1,62 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("RefreshDecision tests")
+class RefreshDecisionTest {
+
+ @Test
+ @DisplayName("reject() should create REJECT decision with reason")
+ void rejectShouldCreateRejectDecision() {
+ RefreshDecision decision = RefreshDecision.reject("test reason");
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REJECT);
+ assertThat(decision.reason()).isEqualTo("test reason");
+ assertThat(decision.keys()).isNull();
+ assertThat(decision.isRefreshed()).isFalse();
+ }
+
+ @Test
+ @DisplayName("defer() should create DEFER decision with reason")
+ void deferShouldCreateDeferDecision() {
+ RefreshDecision decision = RefreshDecision.defer("cooldown active");
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.DEFER);
+ assertThat(decision.reason()).isEqualTo("cooldown active");
+ assertThat(decision.keys()).isNull();
+ assertThat(decision.isRefreshed()).isFalse();
+ }
+
+ @Test
+ @DisplayName("refreshed() should create REFRESHED decision with keys")
+ void refreshedShouldCreateRefreshedDecision() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(256);
+ KeyPair keyPair = keyGen.generateKeyPair();
+ PublicKey publicKey = keyPair.getPublic();
+
+ Map keys = Map.of("test-key-id", publicKey);
+ RefreshDecision decision = RefreshDecision.refreshed(keys);
+
+ assertThat(decision.action()).isEqualTo(RefreshDecision.RefreshAction.REFRESHED);
+ assertThat(decision.reason()).isNull();
+ assertThat(decision.keys()).isEqualTo(keys);
+ assertThat(decision.isRefreshed()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isRefreshed() should return true only for REFRESHED action")
+ void isRefreshedShouldReturnTrueOnlyForRefreshed() {
+ assertThat(RefreshDecision.reject("reason").isRefreshed()).isFalse();
+ assertThat(RefreshDecision.defer("reason").isRefreshed()).isFalse();
+ assertThat(RefreshDecision.refreshed(Map.of()).isRefreshed()).isTrue();
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManagerTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManagerTest.java
new file mode 100644
index 0000000..c12c32d
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittArtifactManagerTest.java
@@ -0,0 +1,729 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.godaddy.ans.sdk.transparency.TransparencyClient;
+import com.upokecenter.cbor.CBORObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ScittArtifactManagerTest {
+
+ private TransparencyClient mockClient;
+ private ScittArtifactManager manager;
+
+ @BeforeEach
+ void setUp() {
+ mockClient = mock(TransparencyClient.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (manager != null) {
+ manager.close();
+ }
+ }
+
+ @Nested
+ @DisplayName("Builder tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should require transparency client")
+ void shouldRequireTransparencyClient() {
+ assertThatThrownBy(() -> ScittArtifactManager.builder().build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("transparencyClient cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should build with minimum configuration")
+ void shouldBuildWithMinimumConfiguration() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ assertThat(manager).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should build with custom scheduler")
+ void shouldBuildWithCustomScheduler() {
+ ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ try {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .scheduler(scheduler)
+ .build();
+
+ assertThat(manager).isNotNull();
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ }
+
+ @Nested
+ @DisplayName("getReceipt() tests")
+ class GetReceiptTests {
+
+ @Test
+ @DisplayName("Should reject null agentId")
+ void shouldRejectNullAgentId() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ assertThatThrownBy(() -> manager.getReceipt(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("agentId cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return failed future when manager is closed")
+ void shouldReturnFailedFutureWhenClosed() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ CompletableFuture future = manager.getReceipt("test-agent");
+ assertThat(future).isCompletedExceptionally();
+ }
+
+ @Test
+ @DisplayName("Should fetch receipt from transparency client")
+ void shouldFetchReceiptFromClient() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ when(mockClient.getReceipt("test-agent")).thenReturn(receiptBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getReceipt("test-agent");
+ ScittReceipt receipt = future.get(5, TimeUnit.SECONDS);
+
+ assertThat(receipt).isNotNull();
+ verify(mockClient).getReceipt("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should cache receipt on subsequent calls")
+ void shouldCacheReceipt() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ when(mockClient.getReceipt("test-agent")).thenReturn(receiptBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // First call
+ manager.getReceipt("test-agent").get(5, TimeUnit.SECONDS);
+ // Second call should use cache
+ manager.getReceipt("test-agent").get(5, TimeUnit.SECONDS);
+
+ // Client should only be called once
+ verify(mockClient, times(1)).getReceipt("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should wrap client exception in ScittFetchException")
+ void shouldWrapClientException() {
+ when(mockClient.getReceipt(anyString())).thenThrow(new RuntimeException("Network error"));
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getReceipt("test-agent");
+
+ assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(ScittFetchException.class)
+ .hasMessageContaining("Failed to fetch receipt");
+ }
+ }
+
+ @Nested
+ @DisplayName("getStatusToken() tests")
+ class GetStatusTokenTests {
+
+ @Test
+ @DisplayName("Should reject null agentId")
+ void shouldRejectNullAgentId() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ assertThatThrownBy(() -> manager.getStatusToken(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("agentId cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return failed future when manager is closed")
+ void shouldReturnFailedFutureWhenClosed() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ CompletableFuture future = manager.getStatusToken("test-agent");
+ assertThat(future).isCompletedExceptionally();
+ }
+
+ @Test
+ @DisplayName("Should fetch status token from transparency client")
+ void shouldFetchTokenFromClient() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getStatusToken("test-agent");
+ StatusToken token = future.get(5, TimeUnit.SECONDS);
+
+ assertThat(token).isNotNull();
+ verify(mockClient).getStatusToken("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should cache status token on subsequent calls")
+ void shouldCacheToken() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // First call
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+ // Second call should use cache
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+
+ verify(mockClient, times(1)).getStatusToken("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should wrap client exception in ScittFetchException")
+ void shouldWrapClientException() {
+ when(mockClient.getStatusToken(anyString())).thenThrow(new RuntimeException("Network error"));
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getStatusToken("test-agent");
+
+ assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(ScittFetchException.class)
+ .hasMessageContaining("Failed to fetch status token");
+ }
+
+ @Test
+ @DisplayName("Should coalesce concurrent status token requests")
+ void shouldCoalesceConcurrentRequests() throws Exception {
+ // Delay the response to simulate slow network
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenAnswer(invocation -> {
+ Thread.sleep(200); // Simulate network delay
+ return tokenBytes;
+ });
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Start two concurrent requests
+ CompletableFuture future1 = manager.getStatusToken("test-agent");
+ CompletableFuture future2 = manager.getStatusToken("test-agent");
+
+ // Both should complete
+ StatusToken token1 = future1.get(5, TimeUnit.SECONDS);
+ StatusToken token2 = future2.get(5, TimeUnit.SECONDS);
+
+ // Both should get the same token
+ assertThat(token1).isNotNull();
+ assertThat(token2).isNotNull();
+
+ // Client should only be called once due to pending request coalescing
+ // (or twice if the second request started after first completed)
+ verify(mockClient, times(1)).getStatusToken("test-agent");
+ }
+ }
+
+ @Nested
+ @DisplayName("getReceiptBase64() tests")
+ class GetReceiptBytesTests {
+
+ @Test
+ @DisplayName("Should reject null agentId")
+ void shouldRejectNullAgentId() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ assertThatThrownBy(() -> manager.getReceiptBase64(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("agentId cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return failed future when manager is closed")
+ void shouldReturnFailedFutureWhenClosed() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ CompletableFuture future = manager.getReceiptBase64("test-agent");
+ assertThat(future).isCompletedExceptionally();
+ }
+
+ @Test
+ @DisplayName("Should fetch receipt Base64 from transparency client")
+ void shouldFetchReceiptBase64FromClient() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ when(mockClient.getReceipt("test-agent")).thenReturn(receiptBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getReceiptBase64("test-agent");
+ String result = future.get(5, TimeUnit.SECONDS);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isNotEmpty();
+ // Verify it's valid Base64 that decodes to the original bytes
+ assertThat(java.util.Base64.getDecoder().decode(result)).isEqualTo(receiptBytes);
+ verify(mockClient).getReceipt("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should cache receipt Base64 on subsequent calls")
+ void shouldCacheReceiptBase64() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ when(mockClient.getReceipt("test-agent")).thenReturn(receiptBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // First call
+ String first = manager.getReceiptBase64("test-agent").get(5, TimeUnit.SECONDS);
+ // Second call should use cache and return same String instance
+ String second = manager.getReceiptBase64("test-agent").get(5, TimeUnit.SECONDS);
+
+ assertThat(first).isSameAs(second);
+ // Client should only be called once
+ verify(mockClient, times(1)).getReceipt("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should wrap client exception in ScittFetchException")
+ void shouldWrapClientException() {
+ when(mockClient.getReceipt(anyString())).thenThrow(new RuntimeException("Network error"));
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getReceiptBase64("test-agent");
+
+ assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(ScittFetchException.class)
+ .hasMessageContaining("Failed to fetch receipt");
+ }
+ }
+
+ @Nested
+ @DisplayName("getStatusTokenBase64() tests")
+ class GetStatusTokenBytesTests {
+
+ @Test
+ @DisplayName("Should reject null agentId")
+ void shouldRejectNullAgentId() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ assertThatThrownBy(() -> manager.getStatusTokenBase64(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("agentId cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should return failed future when manager is closed")
+ void shouldReturnFailedFutureWhenClosed() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ CompletableFuture future = manager.getStatusTokenBase64("test-agent");
+ assertThat(future).isCompletedExceptionally();
+ }
+
+ @Test
+ @DisplayName("Should fetch status token Base64 from transparency client")
+ void shouldFetchTokenBase64FromClient() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getStatusTokenBase64("test-agent");
+ String result = future.get(5, TimeUnit.SECONDS);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isNotEmpty();
+ // Verify it's valid Base64 that decodes to the original bytes
+ assertThat(java.util.Base64.getDecoder().decode(result)).isEqualTo(tokenBytes);
+ verify(mockClient).getStatusToken("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should cache status token Base64 on subsequent calls")
+ void shouldCacheTokenBase64() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // First call
+ String first = manager.getStatusTokenBase64("test-agent").get(5, TimeUnit.SECONDS);
+ // Second call should use cache and return same String instance
+ String second = manager.getStatusTokenBase64("test-agent").get(5, TimeUnit.SECONDS);
+
+ assertThat(first).isSameAs(second);
+ verify(mockClient, times(1)).getStatusToken("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should wrap client exception in ScittFetchException")
+ void shouldWrapClientException() {
+ when(mockClient.getStatusToken(anyString())).thenThrow(new RuntimeException("Network error"));
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ CompletableFuture future = manager.getStatusTokenBase64("test-agent");
+
+ assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(ScittFetchException.class)
+ .hasMessageContaining("Failed to fetch status token");
+ }
+ }
+
+ @Nested
+ @DisplayName("Background refresh tests")
+ class BackgroundRefreshTests {
+
+ @Test
+ @DisplayName("Should not start refresh when manager is closed")
+ void shouldNotStartWhenClosed() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ // Should not throw
+ manager.startBackgroundRefresh("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should stop background refresh")
+ void shouldStopBackgroundRefresh() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Fetch initial token
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+
+ // Start refresh
+ manager.startBackgroundRefresh("test-agent");
+
+ // Stop refresh
+ manager.stopBackgroundRefresh("test-agent");
+
+ // Should not throw
+ }
+
+ @Test
+ @DisplayName("Should handle stopping non-existent refresh")
+ void shouldHandleStoppingNonExistentRefresh() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Should not throw
+ manager.stopBackgroundRefresh("non-existent-agent");
+ }
+
+ @Test
+ @DisplayName("Should start refresh without cached token using default interval")
+ void shouldStartRefreshWithoutCachedToken() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Start refresh without fetching token first
+ manager.startBackgroundRefresh("test-agent");
+
+ // Should not throw - uses default 5 minute interval
+ Thread.sleep(100); // Give scheduler time to initialize
+
+ manager.stopBackgroundRefresh("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should replace existing refresh task when starting again")
+ void shouldReplaceExistingRefreshTask() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Fetch token
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+
+ // Start refresh twice
+ manager.startBackgroundRefresh("test-agent");
+ manager.startBackgroundRefresh("test-agent");
+
+ // Should not throw, second call should replace first
+ manager.stopBackgroundRefresh("test-agent");
+ }
+ }
+
+ @Nested
+ @DisplayName("Cache management tests")
+ class CacheManagementTests {
+
+ @Test
+ @DisplayName("Should clear cache for specific agent")
+ void shouldClearCacheForAgent() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getReceipt("test-agent")).thenReturn(receiptBytes);
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Populate cache
+ manager.getReceipt("test-agent").get(5, TimeUnit.SECONDS);
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+
+ // Clear cache
+ manager.clearCache("test-agent");
+
+ // Fetch again - should hit client
+ manager.getReceipt("test-agent").get(5, TimeUnit.SECONDS);
+
+ verify(mockClient, times(2)).getReceipt("test-agent");
+ }
+
+ @Test
+ @DisplayName("Should clear all caches")
+ void shouldClearAllCaches() throws Exception {
+ byte[] receiptBytes = createValidReceiptBytes();
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getReceipt(anyString())).thenReturn(receiptBytes);
+ when(mockClient.getStatusToken(anyString())).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ // Populate caches for multiple agents
+ manager.getReceipt("agent1").get(5, TimeUnit.SECONDS);
+ manager.getReceipt("agent2").get(5, TimeUnit.SECONDS);
+
+ // Clear all
+ manager.clearAllCaches();
+
+ // Fetch again - should hit client
+ manager.getReceipt("agent1").get(5, TimeUnit.SECONDS);
+ manager.getReceipt("agent2").get(5, TimeUnit.SECONDS);
+
+ verify(mockClient, times(2)).getReceipt("agent1");
+ verify(mockClient, times(2)).getReceipt("agent2");
+ }
+ }
+
+ @Nested
+ @DisplayName("AutoCloseable tests")
+ class AutoCloseableTests {
+
+ @Test
+ @DisplayName("Should shutdown scheduler on close")
+ void shouldShutdownSchedulerOnClose() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+
+ // Verify manager is closed by checking subsequent operations fail
+ assertThat(manager.getReceipt("test")).isCompletedExceptionally();
+ }
+
+ @Test
+ @DisplayName("Should be idempotent when closing multiple times")
+ void shouldBeIdempotentOnClose() {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.close();
+ manager.close();
+ manager.close();
+
+ // Should not throw
+ }
+
+ @Test
+ @DisplayName("Should cancel refresh tasks on close")
+ void shouldCancelRefreshTasksOnClose() throws Exception {
+ byte[] tokenBytes = createValidStatusTokenBytes();
+ when(mockClient.getStatusToken("test-agent")).thenReturn(tokenBytes);
+
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .build();
+
+ manager.getStatusToken("test-agent").get(5, TimeUnit.SECONDS);
+ manager.startBackgroundRefresh("test-agent");
+
+ manager.close();
+
+ // Should not throw
+ }
+
+ @Test
+ @DisplayName("Should not shutdown external scheduler")
+ void shouldNotShutdownExternalScheduler() throws Exception {
+ ScheduledExecutorService externalScheduler = Executors.newSingleThreadScheduledExecutor();
+
+ try {
+ manager = ScittArtifactManager.builder()
+ .transparencyClient(mockClient)
+ .scheduler(externalScheduler)
+ .build();
+
+ manager.close();
+
+ // External scheduler should still be running
+ assertThat(externalScheduler.isShutdown()).isFalse();
+ } finally {
+ externalScheduler.shutdown();
+ }
+ }
+ }
+
+ // Helper methods
+
+ private byte[] createValidReceiptBytes() {
+ // Create a minimal valid COSE_Sign1 for receipt
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(395, 1); // vds = RFC9162_SHA256 (required for receipts)
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] payload = "test-payload".getBytes();
+ byte[] signature = new byte[64];
+
+ // Create unprotected header with inclusion proof (MAP format)
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 1L); // tree_size
+ inclusionProofMap.Add(-2, 0L); // leaf_index
+ inclusionProofMap.Add(-3, CBORObject.NewArray()); // empty hash_path
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap); // proofs label
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add(payload);
+ array.Add(signature);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+
+ private byte[] createReceiptPayload() {
+ return "test-payload".getBytes();
+ }
+
+ private byte[] createValidStatusTokenBytes() {
+ // Create a minimal valid COSE_Sign1 for status token
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] payload = createStatusTokenPayload();
+ byte[] signature = new byte[64];
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(payload);
+ array.Add(signature);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+
+ private byte[] createStatusTokenPayload() {
+ // Use integer keys: 1=agent_id, 2=status, 3=iat, 4=exp
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "ACTIVE"); // status
+ payload.Add(3, Instant.now().minusSeconds(60).getEpochSecond()); // iat
+ payload.Add(4, Instant.now().plusSeconds(3600).getEpochSecond()); // exp
+ return payload.EncodeToBytes();
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectationTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectationTest.java
new file mode 100644
index 0000000..19dd52a
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittExpectationTest.java
@@ -0,0 +1,198 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ScittExpectationTest {
+
+ @Nested
+ @DisplayName("Factory method tests")
+ class FactoryMethodTests {
+
+ @Test
+ @DisplayName("verified() should create expectation with all data")
+ void verifiedShouldCreateExpectationWithAllData() {
+ List serverCerts = List.of("SHA256:server1", "SHA256:server2");
+ List identityCerts = List.of("SHA256:identity1");
+ Map metadataHashes = Map.of("a2a", "SHA256:metadata1");
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ serverCerts, identityCerts, "agent.example.com", "ans://test",
+ metadataHashes, null);
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.VERIFIED);
+ assertThat(expectation.validServerCertFingerprints()).containsExactlyElementsOf(serverCerts);
+ assertThat(expectation.validIdentityCertFingerprints()).containsExactlyElementsOf(identityCerts);
+ assertThat(expectation.agentHost()).isEqualTo("agent.example.com");
+ assertThat(expectation.ansName()).isEqualTo("ans://test");
+ assertThat(expectation.metadataHashes()).isEqualTo(metadataHashes);
+ assertThat(expectation.failureReason()).isNull();
+ assertThat(expectation.isVerified()).isTrue();
+ assertThat(expectation.shouldFail()).isFalse();
+ }
+
+ @Test
+ @DisplayName("invalidReceipt() should create failure expectation")
+ void invalidReceiptShouldCreateFailureExpectation() {
+ ScittExpectation expectation = ScittExpectation.invalidReceipt("Bad signature");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.INVALID_RECEIPT);
+ assertThat(expectation.failureReason()).isEqualTo("Bad signature");
+ assertThat(expectation.isVerified()).isFalse();
+ assertThat(expectation.shouldFail()).isTrue();
+ assertThat(expectation.validServerCertFingerprints()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("invalidToken() should create failure expectation")
+ void invalidTokenShouldCreateFailureExpectation() {
+ ScittExpectation expectation = ScittExpectation.invalidToken("Malformed token");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.INVALID_TOKEN);
+ assertThat(expectation.failureReason()).isEqualTo("Malformed token");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("expired() should create expiry expectation")
+ void expiredShouldCreateExpiryExpectation() {
+ ScittExpectation expectation = ScittExpectation.expired();
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.TOKEN_EXPIRED);
+ assertThat(expectation.failureReason()).isEqualTo("Status token has expired");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("revoked() should create revoked expectation")
+ void revokedShouldCreateRevokedExpectation() {
+ ScittExpectation expectation = ScittExpectation.revoked("ans://revoked.agent");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.AGENT_REVOKED);
+ assertThat(expectation.ansName()).isEqualTo("ans://revoked.agent");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("inactive() should create inactive expectation")
+ void inactiveShouldCreateInactiveExpectation() {
+ ScittExpectation expectation = ScittExpectation.inactive(
+ StatusToken.Status.DEPRECATED, "ans://deprecated.agent");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.AGENT_INACTIVE);
+ assertThat(expectation.failureReason()).isEqualTo("Agent status is DEPRECATED");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("keyNotFound() should create key not found expectation")
+ void keyNotFoundShouldCreateExpectation() {
+ ScittExpectation expectation = ScittExpectation.keyNotFound("TL key not found");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.KEY_NOT_FOUND);
+ assertThat(expectation.failureReason()).isEqualTo("TL key not found");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("notPresent() should create not present expectation")
+ void notPresentShouldCreateExpectation() {
+ ScittExpectation expectation = ScittExpectation.notPresent();
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.NOT_PRESENT);
+ assertThat(expectation.isNotPresent()).isTrue();
+ assertThat(expectation.shouldFail()).isFalse(); // Not a failure, just fallback needed
+ }
+
+ @Test
+ @DisplayName("parseError() should create parse error expectation")
+ void parseErrorShouldCreateExpectation() {
+ ScittExpectation expectation = ScittExpectation.parseError("Invalid CBOR");
+
+ assertThat(expectation.status()).isEqualTo(ScittExpectation.Status.PARSE_ERROR);
+ assertThat(expectation.failureReason()).isEqualTo("Invalid CBOR");
+ assertThat(expectation.shouldFail()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("Status behavior tests")
+ class StatusBehaviorTests {
+
+ @Test
+ @DisplayName("shouldFail() should return correct values for each status")
+ void shouldFailShouldReturnCorrectValues() {
+ assertThat(ScittExpectation.verified(List.of(), List.of(), null, null, null, null)
+ .shouldFail()).isFalse();
+ assertThat(ScittExpectation.notPresent().shouldFail()).isFalse();
+
+ assertThat(ScittExpectation.invalidReceipt("").shouldFail()).isTrue();
+ assertThat(ScittExpectation.invalidToken("").shouldFail()).isTrue();
+ assertThat(ScittExpectation.expired().shouldFail()).isTrue();
+ assertThat(ScittExpectation.revoked("").shouldFail()).isTrue();
+ assertThat(ScittExpectation.inactive(StatusToken.Status.EXPIRED, "").shouldFail()).isTrue();
+ assertThat(ScittExpectation.keyNotFound("").shouldFail()).isTrue();
+ assertThat(ScittExpectation.parseError("").shouldFail()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isVerified() should only return true for VERIFIED status")
+ void isVerifiedShouldOnlyBeTrueForVerifiedStatus() {
+ assertThat(ScittExpectation.verified(List.of(), List.of(), null, null, null, null)
+ .isVerified()).isTrue();
+
+ assertThat(ScittExpectation.notPresent().isVerified()).isFalse();
+ assertThat(ScittExpectation.invalidReceipt("").isVerified()).isFalse();
+ assertThat(ScittExpectation.expired().isVerified()).isFalse();
+ }
+
+ @Test
+ @DisplayName("isNotPresent() should only return true for NOT_PRESENT status")
+ void isNotPresentShouldOnlyBeTrueForNotPresentStatus() {
+ assertThat(ScittExpectation.notPresent().isNotPresent()).isTrue();
+
+ assertThat(ScittExpectation.verified(List.of(), List.of(), null, null, null, null)
+ .isNotPresent()).isFalse();
+ assertThat(ScittExpectation.invalidReceipt("").isNotPresent()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Defensive copying tests")
+ class DefensiveCopyingTests {
+
+ @Test
+ @DisplayName("Should defensively copy server cert fingerprints")
+ void shouldDefensivelyCopyServerCerts() {
+ List mutableList = new java.util.ArrayList<>();
+ mutableList.add("cert1");
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ mutableList, List.of(), null, null, null, null);
+
+ mutableList.add("cert2");
+
+ assertThat(expectation.validServerCertFingerprints()).containsExactly("cert1");
+ }
+
+ @Test
+ @DisplayName("Should defensively copy metadata hashes")
+ void shouldDefensivelyCopyMetadataHashes() {
+ Map mutableMap = new java.util.HashMap<>();
+ mutableMap.put("key1", "value1");
+
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of(), List.of(), null, null, mutableMap, null);
+
+ mutableMap.put("key2", "value2");
+
+ assertThat(expectation.metadataHashes()).containsOnlyKeys("key1");
+ }
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchExceptionTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchExceptionTest.java
new file mode 100644
index 0000000..d977b98
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittFetchExceptionTest.java
@@ -0,0 +1,110 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ScittFetchExceptionTest {
+
+ @Nested
+ @DisplayName("Constructor tests")
+ class ConstructorTests {
+
+ @Test
+ @DisplayName("Should create exception with message and artifact type")
+ void shouldCreateExceptionWithMessageAndArtifactType() {
+ ScittFetchException exception = new ScittFetchException(
+ "Failed to fetch", ScittFetchException.ArtifactType.RECEIPT, "test-agent");
+
+ assertThat(exception.getMessage()).isEqualTo("Failed to fetch");
+ assertThat(exception.getArtifactType()).isEqualTo(ScittFetchException.ArtifactType.RECEIPT);
+ assertThat(exception.getAgentId()).isEqualTo("test-agent");
+ assertThat(exception.getCause()).isNull();
+ }
+
+ @Test
+ @DisplayName("Should create exception with message, cause, and artifact type")
+ void shouldCreateExceptionWithCause() {
+ RuntimeException cause = new RuntimeException("Network error");
+ ScittFetchException exception = new ScittFetchException(
+ "Failed to fetch", cause, ScittFetchException.ArtifactType.STATUS_TOKEN, "agent-123");
+
+ assertThat(exception.getMessage()).isEqualTo("Failed to fetch");
+ assertThat(exception.getCause()).isEqualTo(cause);
+ assertThat(exception.getArtifactType()).isEqualTo(ScittFetchException.ArtifactType.STATUS_TOKEN);
+ assertThat(exception.getAgentId()).isEqualTo("agent-123");
+ }
+
+ @Test
+ @DisplayName("Should allow null agent ID for public key fetches")
+ void shouldAllowNullAgentId() {
+ ScittFetchException exception = new ScittFetchException(
+ "Key fetch failed", ScittFetchException.ArtifactType.PUBLIC_KEY, null);
+
+ assertThat(exception.getAgentId()).isNull();
+ assertThat(exception.getArtifactType()).isEqualTo(ScittFetchException.ArtifactType.PUBLIC_KEY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ArtifactType enum tests")
+ class ArtifactTypeTests {
+
+ @Test
+ @DisplayName("Should have RECEIPT artifact type")
+ void shouldHaveReceiptType() {
+ assertThat(ScittFetchException.ArtifactType.RECEIPT).isNotNull();
+ assertThat(ScittFetchException.ArtifactType.valueOf("RECEIPT"))
+ .isEqualTo(ScittFetchException.ArtifactType.RECEIPT);
+ }
+
+ @Test
+ @DisplayName("Should have STATUS_TOKEN artifact type")
+ void shouldHaveStatusTokenType() {
+ assertThat(ScittFetchException.ArtifactType.STATUS_TOKEN).isNotNull();
+ assertThat(ScittFetchException.ArtifactType.valueOf("STATUS_TOKEN"))
+ .isEqualTo(ScittFetchException.ArtifactType.STATUS_TOKEN);
+ }
+
+ @Test
+ @DisplayName("Should have PUBLIC_KEY artifact type")
+ void shouldHavePublicKeyType() {
+ assertThat(ScittFetchException.ArtifactType.PUBLIC_KEY).isNotNull();
+ assertThat(ScittFetchException.ArtifactType.valueOf("PUBLIC_KEY"))
+ .isEqualTo(ScittFetchException.ArtifactType.PUBLIC_KEY);
+ }
+
+ @Test
+ @DisplayName("Should have exactly 3 artifact types")
+ void shouldHaveThreeArtifactTypes() {
+ assertThat(ScittFetchException.ArtifactType.values()).hasSize(3);
+ }
+ }
+
+ @Nested
+ @DisplayName("Exception behavior tests")
+ class ExceptionBehaviorTests {
+
+ @Test
+ @DisplayName("Should be throwable as RuntimeException")
+ void shouldBeThrowableAsRuntimeException() {
+ ScittFetchException exception = new ScittFetchException(
+ "Test", ScittFetchException.ArtifactType.RECEIPT, "agent");
+
+ assertThat(exception).isInstanceOf(RuntimeException.class);
+ }
+
+ @Test
+ @DisplayName("Should preserve stack trace")
+ void shouldPreserveStackTrace() {
+ RuntimeException cause = new RuntimeException("Original");
+ ScittFetchException exception = new ScittFetchException(
+ "Wrapped", cause, ScittFetchException.ArtifactType.RECEIPT, "agent");
+
+ assertThat(exception.getStackTrace()).isNotEmpty();
+ assertThat(exception.getCause().getMessage()).isEqualTo("Original");
+ }
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResultTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResultTest.java
new file mode 100644
index 0000000..e69e825
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittPreVerifyResultTest.java
@@ -0,0 +1,117 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ScittPreVerifyResultTest {
+
+ @Nested
+ @DisplayName("Factory methods tests")
+ class FactoryMethodsTests {
+
+ @Test
+ @DisplayName("notPresent() should create result with isPresent=false")
+ void notPresentShouldCreateResultWithIsPresentFalse() {
+ ScittPreVerifyResult result = ScittPreVerifyResult.notPresent();
+
+ assertThat(result.isPresent()).isFalse();
+ assertThat(result.expectation()).isNotNull();
+ assertThat(result.expectation().status()).isEqualTo(ScittExpectation.Status.NOT_PRESENT);
+ assertThat(result.receipt()).isNull();
+ assertThat(result.statusToken()).isNull();
+ }
+
+ @Test
+ @DisplayName("parseError() should create result with isPresent=true")
+ void parseErrorShouldCreateResultWithIsPresentTrue() {
+ ScittPreVerifyResult result = ScittPreVerifyResult.parseError("Test error");
+
+ assertThat(result.isPresent()).isTrue();
+ assertThat(result.expectation()).isNotNull();
+ assertThat(result.expectation().status()).isEqualTo(ScittExpectation.Status.PARSE_ERROR);
+ assertThat(result.expectation().failureReason()).contains("Test error");
+ assertThat(result.receipt()).isNull();
+ assertThat(result.statusToken()).isNull();
+ }
+
+ @Test
+ @DisplayName("verified() should create result with all components")
+ void verifiedShouldCreateResultWithAllComponents() {
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("fp1"), List.of("fp2"), "host", "ans.test", Map.of(), null);
+ ScittReceipt receipt = createMockReceipt();
+ StatusToken token = createMockToken();
+
+ ScittPreVerifyResult result = ScittPreVerifyResult.verified(expectation, receipt, token);
+
+ assertThat(result.isPresent()).isTrue();
+ assertThat(result.expectation()).isEqualTo(expectation);
+ assertThat(result.expectation().isVerified()).isTrue();
+ assertThat(result.receipt()).isEqualTo(receipt);
+ assertThat(result.statusToken()).isEqualTo(token);
+ }
+ }
+
+ @Nested
+ @DisplayName("Record accessor tests")
+ class RecordAccessorTests {
+
+ @Test
+ @DisplayName("Should access all record components")
+ void shouldAccessAllRecordComponents() {
+ ScittExpectation expectation = ScittExpectation.verified(
+ List.of("fp1"), List.of(), "host", "ans.test", Map.of(), null);
+ ScittReceipt receipt = createMockReceipt();
+ StatusToken token = createMockToken();
+
+ ScittPreVerifyResult result = new ScittPreVerifyResult(expectation, receipt, token, true);
+
+ assertThat(result.expectation()).isEqualTo(expectation);
+ assertThat(result.receipt()).isEqualTo(receipt);
+ assertThat(result.statusToken()).isEqualTo(token);
+ assertThat(result.isPresent()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should handle null components")
+ void shouldHandleNullComponents() {
+ ScittPreVerifyResult result = new ScittPreVerifyResult(null, null, null, false);
+
+ assertThat(result.expectation()).isNull();
+ assertThat(result.receipt()).isNull();
+ assertThat(result.statusToken()).isNull();
+ assertThat(result.isPresent()).isFalse();
+ }
+ }
+
+ private ScittReceipt createMockReceipt() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+ return new ScittReceipt(header, new byte[10], proof, "payload".getBytes(), new byte[64]);
+ }
+
+ private StatusToken createMockToken() {
+ return new StatusToken(
+ "test-agent",
+ StatusToken.Status.ACTIVE,
+ Instant.now(),
+ Instant.now().plusSeconds(3600),
+ "test.ans",
+ "agent.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ null,
+ null,
+ null,
+ null
+ );
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceiptTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceiptTest.java
new file mode 100644
index 0000000..6f2a1f7
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/ScittReceiptTest.java
@@ -0,0 +1,721 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.upokecenter.cbor.CBORObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ScittReceiptTest {
+
+ @Nested
+ @DisplayName("parse() tests")
+ class ParseTests {
+
+ @Test
+ @DisplayName("Should reject null input")
+ void shouldRejectNullInput() {
+ assertThatThrownBy(() -> ScittReceipt.parse(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("coseBytes cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt without VDS")
+ void shouldRejectReceiptWithoutVds() {
+ // Create COSE_Sign1 without VDS (395) in protected header
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256, but no VDS
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject unprotectedHeader = createValidUnprotectedHeader();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("VDS=1");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt with wrong VDS value")
+ void shouldRejectReceiptWithWrongVds() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(395, 2); // Wrong VDS value
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject unprotectedHeader = createValidUnprotectedHeader();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("VDS=1");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt without proofs")
+ void shouldRejectReceiptWithoutProofs() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Empty unprotected header (no proofs)
+ CBORObject emptyUnprotected = CBORObject.NewMap();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(emptyUnprotected);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("inclusion proofs");
+ }
+
+ @Test
+ @DisplayName("Should parse valid receipt with RFC 9162 proof format")
+ void shouldParseValidReceiptWithRfc9162Format() throws ScittParseException {
+ byte[] receiptBytes = createValidReceiptWithRfc9162Proof();
+
+ ScittReceipt receipt = ScittReceipt.parse(receiptBytes);
+
+ assertThat(receipt).isNotNull();
+ assertThat(receipt.protectedHeader()).isNotNull();
+ assertThat(receipt.protectedHeader().algorithm()).isEqualTo(-7);
+ assertThat(receipt.inclusionProof()).isNotNull();
+ assertThat(receipt.eventPayload()).isNotNull();
+ assertThat(receipt.signature()).hasSize(64);
+ }
+
+ @Test
+ @DisplayName("Should parse receipt with tree size and leaf index")
+ void shouldParseReceiptWithTreeSizeAndLeafIndex() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Create proof with tree_size=100, leaf_index=42 using MAP format
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 100L); // tree_size
+ inclusionProofMap.Add(-2, 42L); // leaf_index
+ inclusionProofMap.Add(-3, CBORObject.NewArray()); // empty hash_path
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ assertThat(receipt.inclusionProof().treeSize()).isEqualTo(100);
+ assertThat(receipt.inclusionProof().leafIndex()).isEqualTo(42);
+ }
+
+ @Test
+ @DisplayName("Should parse receipt with hash path")
+ void shouldParseReceiptWithHashPath() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] hash1 = new byte[32];
+ byte[] hash2 = new byte[32];
+ hash1[0] = 0x01;
+ hash2[0] = 0x02;
+
+ // MAP format with hash path array at key -3
+ CBORObject hashPathArray = CBORObject.NewArray();
+ hashPathArray.Add(CBORObject.FromObject(hash1));
+ hashPathArray.Add(CBORObject.FromObject(hash2));
+
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 4L); // tree_size
+ inclusionProofMap.Add(-2, 2L); // leaf_index
+ inclusionProofMap.Add(-3, hashPathArray); // hash_path array
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ assertThat(receipt.inclusionProof().hashPath()).hasSize(2);
+ }
+ }
+
+ @Nested
+ @DisplayName("InclusionProof tests")
+ class InclusionProofTests {
+
+ @Test
+ @DisplayName("Should create inclusion proof with null hashPath")
+ void shouldCreateInclusionProofWithNullHashPath() {
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], null);
+
+ assertThat(proof.hashPath()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should defensively copy hashPath")
+ void shouldDefensivelyCopyHashPath() {
+ List originalPath = new java.util.ArrayList<>();
+ originalPath.add(new byte[32]);
+
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], originalPath);
+
+ // Original list modification should not affect proof
+ originalPath.add(new byte[32]);
+
+ assertThat(proof.hashPath()).hasSize(1);
+ }
+ }
+
+ @Nested
+ @DisplayName("equals() and hashCode() tests")
+ class EqualsHashCodeTests {
+
+ @Test
+ @DisplayName("Should be equal for same values")
+ void shouldBeEqualForSameValues() {
+ ScittReceipt receipt1 = createBasicReceipt();
+ ScittReceipt receipt2 = createBasicReceipt();
+
+ assertThat(receipt1).isEqualTo(receipt2);
+ assertThat(receipt1.hashCode()).isEqualTo(receipt2.hashCode());
+ }
+
+ @Test
+ @DisplayName("Should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ ScittReceipt receipt = createBasicReceipt();
+ assertThat(receipt).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("Should be equal to itself")
+ void shouldBeEqualToItself() {
+ ScittReceipt receipt = createBasicReceipt();
+ assertThat(receipt).isEqualTo(receipt);
+ }
+
+ @Test
+ @DisplayName("toString should contain useful info")
+ void toStringShouldContainUsefulInfo() {
+ ScittReceipt receipt = createBasicReceipt();
+ String str = receipt.toString();
+
+ assertThat(str).contains("ScittReceipt");
+ }
+
+ @Test
+ @DisplayName("Should not be equal when protected header differs")
+ void shouldNotBeEqualWhenProtectedHeaderDiffers() {
+ CoseProtectedHeader header1 = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ CoseProtectedHeader header2 = new CoseProtectedHeader(-35, new byte[4], 1, null, null); // Different alg
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+
+ ScittReceipt receipt1 = new ScittReceipt(header1, new byte[10], proof, "payload".getBytes(), new byte[64]);
+ ScittReceipt receipt2 = new ScittReceipt(header2, new byte[10], proof, "payload".getBytes(), new byte[64]);
+
+ assertThat(receipt1).isNotEqualTo(receipt2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when signature differs")
+ void shouldNotBeEqualWhenSignatureDiffers() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+
+ byte[] sig1 = new byte[64];
+ byte[] sig2 = new byte[64];
+ sig2[0] = 1; // Different signature
+
+ ScittReceipt receipt1 = new ScittReceipt(header, new byte[10], proof, "payload".getBytes(), sig1);
+ ScittReceipt receipt2 = new ScittReceipt(header, new byte[10], proof, "payload".getBytes(), sig2);
+
+ assertThat(receipt1).isNotEqualTo(receipt2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when payload differs")
+ void shouldNotBeEqualWhenPayloadDiffers() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+
+ ScittReceipt receipt1 = new ScittReceipt(header, new byte[10], proof, "payload1".getBytes(), new byte[64]);
+ ScittReceipt receipt2 = new ScittReceipt(header, new byte[10], proof, "payload2".getBytes(), new byte[64]);
+
+ assertThat(receipt1).isNotEqualTo(receipt2);
+ }
+ }
+
+ @Nested
+ @DisplayName("InclusionProof equals tests")
+ class InclusionProofEqualsTests {
+
+ @Test
+ @DisplayName("Should not be equal when tree size differs")
+ void shouldNotBeEqualWhenTreeSizeDiffers() {
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of());
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 20, 5, new byte[32], List.of());
+
+ assertThat(proof1).isNotEqualTo(proof2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when leaf index differs")
+ void shouldNotBeEqualWhenLeafIndexDiffers() {
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of());
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 10, 7, new byte[32], List.of());
+
+ assertThat(proof1).isNotEqualTo(proof2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when root hash differs")
+ void shouldNotBeEqualWhenRootHashDiffers() {
+ byte[] hash1 = new byte[32];
+ byte[] hash2 = new byte[32];
+ hash2[0] = 1;
+
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, hash1, List.of());
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 10, 5, hash2, List.of());
+
+ assertThat(proof1).isNotEqualTo(proof2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when hash path length differs")
+ void shouldNotBeEqualWhenHashPathLengthDiffers() {
+ List path1 = List.of(new byte[32]);
+ List path2 = List.of(new byte[32], new byte[32]);
+
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], path1);
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], path2);
+
+ assertThat(proof1).isNotEqualTo(proof2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal when hash path content differs")
+ void shouldNotBeEqualWhenHashPathContentDiffers() {
+ byte[] pathHash1 = new byte[32];
+ byte[] pathHash2 = new byte[32];
+ pathHash2[0] = 1;
+
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of(pathHash1));
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of(pathHash2));
+
+ assertThat(proof1).isNotEqualTo(proof2);
+ }
+
+ @Test
+ @DisplayName("Should have different hash codes for different proofs")
+ void shouldHaveDifferentHashCodesForDifferentProofs() {
+ ScittReceipt.InclusionProof proof1 = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of());
+ ScittReceipt.InclusionProof proof2 = new ScittReceipt.InclusionProof(
+ 20, 5, new byte[32], List.of());
+
+ assertThat(proof1.hashCode()).isNotEqualTo(proof2.hashCode());
+ }
+
+ @Test
+ @DisplayName("Should not be equal to different type")
+ void shouldNotBeEqualToDifferentType() {
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(
+ 10, 5, new byte[32], List.of());
+
+ assertThat(proof).isNotEqualTo("string");
+ }
+ }
+
+ @Nested
+ @DisplayName("Parsing edge cases")
+ class ParsingEdgeCaseTests {
+
+ @Test
+ @DisplayName("Should reject receipt with empty inclusion proof map")
+ void shouldRejectReceiptWithEmptyInclusionProofMap() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Empty inclusion proof map (missing required keys)
+ CBORObject emptyProofMap = CBORObject.NewMap();
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, emptyProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("tree_size");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt with non-map at label 396")
+ void shouldRejectReceiptWithNonMapAtLabel396() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Label 396 with string instead of map
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, "not a map");
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("must be a map");
+ }
+
+ @Test
+ @DisplayName("Should reject receipt with missing leaf_index key")
+ void shouldRejectReceiptWithMissingLeafIndex() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Inclusion proof map with only tree_size (missing leaf_index)
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 1L); // tree_size only
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ assertThatThrownBy(() -> ScittReceipt.parse(tagged.EncodeToBytes()))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("leaf_index");
+ }
+
+ @Test
+ @DisplayName("Should parse receipt with root hash at key -4")
+ void shouldParseReceiptWithRootHash() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] rootHash = new byte[32];
+ rootHash[0] = 0x01;
+
+ // MAP format with root hash at key -4
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 100L); // tree_size
+ inclusionProofMap.Add(-2, 42L); // leaf_index
+ inclusionProofMap.Add(-3, CBORObject.NewArray()); // empty hash_path
+ inclusionProofMap.Add(-4, CBORObject.FromObject(rootHash)); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ assertThat(receipt.inclusionProof().treeSize()).isEqualTo(100);
+ assertThat(receipt.inclusionProof().leafIndex()).isEqualTo(42);
+ assertThat(receipt.inclusionProof().rootHash()).isEqualTo(rootHash);
+ }
+
+ @Test
+ @DisplayName("Should parse receipt with multiple hashes in path")
+ void shouldParseReceiptWithMultipleHashesInPath() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ byte[] hash1 = new byte[32];
+ byte[] hash2 = new byte[32];
+ hash1[0] = 0x11;
+ hash2[0] = 0x22;
+
+ // Hash path array at key -3
+ CBORObject hashPathArray = CBORObject.NewArray();
+ hashPathArray.Add(CBORObject.FromObject(hash1));
+ hashPathArray.Add(CBORObject.FromObject(hash2));
+
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 8L); // tree_size
+ inclusionProofMap.Add(-2, 3L); // leaf_index
+ inclusionProofMap.Add(-3, hashPathArray); // hash_path array
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ assertThat(receipt.inclusionProof().hashPath()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("Should parse receipt with minimal required fields")
+ void shouldParseReceiptWithMinimalRequiredFields() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Minimal map with just tree_size and leaf_index
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 10L); // tree_size
+ inclusionProofMap.Add(-2, 5L); // leaf_index
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ assertThat(receipt.inclusionProof().treeSize()).isEqualTo(10);
+ assertThat(receipt.inclusionProof().leafIndex()).isEqualTo(5);
+ assertThat(receipt.inclusionProof().hashPath()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should skip non-32-byte entries in hash path")
+ void shouldSkipNon32ByteEntriesInHashPath() throws ScittParseException {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7);
+ protectedHeader.Add(395, 1);
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ // Hash path with mixed valid and invalid entries
+ CBORObject hashPathArray = CBORObject.NewArray();
+ hashPathArray.Add(CBORObject.FromObject(new byte[32])); // valid 32-byte hash
+ hashPathArray.Add(CBORObject.FromObject(new byte[16])); // invalid 16-byte (skipped)
+
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 4L); // tree_size
+ inclusionProofMap.Add(-2, 1L); // leaf_index
+ inclusionProofMap.Add(-3, hashPathArray); // hash_path with mixed sizes
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap);
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("payload".getBytes());
+ array.Add(new byte[64]);
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ ScittReceipt receipt = ScittReceipt.parse(tagged.EncodeToBytes());
+
+ // Only the valid 32-byte hash should be included
+ assertThat(receipt.inclusionProof().hashPath()).hasSize(1);
+ }
+ }
+
+ @Nested
+ @DisplayName("toString() tests")
+ class ToStringTests {
+
+ @Test
+ @DisplayName("Should include protectedHeader info")
+ void shouldIncludeProtectedHeaderInfo() {
+ ScittReceipt receipt = createBasicReceipt();
+ String str = receipt.toString();
+
+ assertThat(str).contains("protectedHeader");
+ }
+
+ @Test
+ @DisplayName("Should include inclusionProof info")
+ void shouldIncludeInclusionProofInfo() {
+ ScittReceipt receipt = createBasicReceipt();
+ String str = receipt.toString();
+
+ assertThat(str).contains("inclusionProof");
+ }
+
+ @Test
+ @DisplayName("Should include payload size")
+ void shouldIncludePayloadSize() {
+ ScittReceipt receipt = createBasicReceipt();
+ String str = receipt.toString();
+
+ assertThat(str).contains("payloadSize");
+ }
+
+ @Test
+ @DisplayName("Should handle null payload in toString")
+ void shouldHandleNullPayloadInToString() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+ ScittReceipt receipt = new ScittReceipt(header, new byte[10], proof, null, new byte[64]);
+
+ String str = receipt.toString();
+ assertThat(str).contains("payloadSize=0");
+ }
+ }
+
+ @Nested
+ @DisplayName("fromParsedCose() tests")
+ class FromParsedCoseTests {
+
+ @Test
+ @DisplayName("Should reject null parsed input")
+ void shouldRejectNullParsedInput() {
+ assertThatThrownBy(() -> ScittReceipt.fromParsedCose(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("parsed cannot be null");
+ }
+ }
+
+ @Nested
+ @DisplayName("hashCode() tests")
+ class HashCodeTests {
+
+ @Test
+ @DisplayName("Should have consistent hashCode")
+ void shouldHaveConsistentHashCode() {
+ ScittReceipt receipt = createBasicReceipt();
+ int hash1 = receipt.hashCode();
+ int hash2 = receipt.hashCode();
+
+ assertThat(hash1).isEqualTo(hash2);
+ }
+
+ @Test
+ @DisplayName("Should have same hashCode for equal receipts")
+ void shouldHaveSameHashCodeForEqualReceipts() {
+ ScittReceipt receipt1 = createBasicReceipt();
+ ScittReceipt receipt2 = createBasicReceipt();
+
+ assertThat(receipt1.hashCode()).isEqualTo(receipt2.hashCode());
+ }
+ }
+
+ // Helper methods
+
+ private byte[] createValidReceiptWithRfc9162Proof() {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ protectedHeader.Add(395, 1); // vds = RFC9162_SHA256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject unprotectedHeader = createValidUnprotectedHeader();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(unprotectedHeader);
+ array.Add("test-payload".getBytes());
+ array.Add(new byte[64]); // signature
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+
+ /**
+ * Creates a valid unprotected header using MAP format at label 396.
+ * This matches the Go server format with negative integer keys:
+ * -1: tree_size, -2: leaf_index, -3: hash_path, -4: root_hash
+ */
+ private CBORObject createValidUnprotectedHeader() {
+ CBORObject inclusionProofMap = CBORObject.NewMap();
+ inclusionProofMap.Add(-1, 1L); // tree_size
+ inclusionProofMap.Add(-2, 0L); // leaf_index
+ inclusionProofMap.Add(-3, CBORObject.NewArray()); // empty hash_path
+ inclusionProofMap.Add(-4, CBORObject.FromObject(new byte[32])); // root_hash
+
+ CBORObject unprotectedHeader = CBORObject.NewMap();
+ unprotectedHeader.Add(396, inclusionProofMap); // proofs label
+
+ return unprotectedHeader;
+ }
+
+ private ScittReceipt createBasicReceipt() {
+ CoseProtectedHeader header = new CoseProtectedHeader(-7, new byte[4], 1, null, null);
+ ScittReceipt.InclusionProof proof = new ScittReceipt.InclusionProof(1, 0, new byte[32], List.of());
+ return new ScittReceipt(header, new byte[10], proof, "payload".getBytes(), new byte[64]);
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/StatusTokenTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/StatusTokenTest.java
new file mode 100644
index 0000000..61276fd
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/StatusTokenTest.java
@@ -0,0 +1,509 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import com.godaddy.ans.sdk.transparency.model.CertificateInfo;
+import com.upokecenter.cbor.CBORObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class StatusTokenTest {
+
+ @Nested
+ @DisplayName("CwtClaims tests")
+ class CwtClaimsTests {
+
+ @Test
+ @DisplayName("Should convert epoch seconds to Instant")
+ void shouldConvertEpochToInstant() {
+ CwtClaims claims = new CwtClaims(
+ "issuer", "subject", "audience",
+ 1700000000L, 1600000000L, 1650000000L);
+
+ assertThat(claims.expirationTime()).isEqualTo(Instant.ofEpochSecond(1700000000L));
+ assertThat(claims.notBeforeTime()).isEqualTo(Instant.ofEpochSecond(1600000000L));
+ assertThat(claims.issuedAtTime()).isEqualTo(Instant.ofEpochSecond(1650000000L));
+ }
+
+ @Test
+ @DisplayName("Should return null for missing timestamps")
+ void shouldReturnNullForMissingTimestamps() {
+ CwtClaims claims = new CwtClaims("issuer", null, null, null, null, null);
+
+ assertThat(claims.expirationTime()).isNull();
+ assertThat(claims.notBeforeTime()).isNull();
+ assertThat(claims.issuedAtTime()).isNull();
+ }
+
+ @Test
+ @DisplayName("Should check expiration correctly")
+ void shouldCheckExpirationCorrectly() {
+ long futureExp = Instant.now().plusSeconds(3600).getEpochSecond();
+ long pastExp = Instant.now().minusSeconds(3600).getEpochSecond();
+
+ CwtClaims futureClaims = new CwtClaims(null, null, null, futureExp, null, null);
+ CwtClaims pastClaims = new CwtClaims(null, null, null, pastExp, null, null);
+ CwtClaims noClaims = new CwtClaims(null, null, null, null, null, null);
+
+ assertThat(futureClaims.isExpired(Instant.now())).isFalse();
+ assertThat(pastClaims.isExpired(Instant.now())).isTrue();
+ assertThat(noClaims.isExpired(Instant.now())).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should check expiration with clock skew")
+ void shouldCheckExpirationWithClockSkew() {
+ // Token that expired 30 seconds ago
+ long exp = Instant.now().minusSeconds(30).getEpochSecond();
+ CwtClaims claims = new CwtClaims(null, null, null, exp, null, null);
+
+ // Without clock skew, it's expired
+ assertThat(claims.isExpired(Instant.now(), 0)).isTrue();
+
+ // With 60 second clock skew, it's still valid
+ assertThat(claims.isExpired(Instant.now(), 60)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should check not-before correctly")
+ void shouldCheckNotBeforeCorrectly() {
+ long futureNbf = Instant.now().plusSeconds(3600).getEpochSecond();
+ long pastNbf = Instant.now().minusSeconds(3600).getEpochSecond();
+
+ CwtClaims futureClaims = new CwtClaims(null, null, null, null, futureNbf, null);
+ CwtClaims pastClaims = new CwtClaims(null, null, null, null, pastNbf, null);
+
+ assertThat(futureClaims.isNotYetValid(Instant.now())).isTrue();
+ assertThat(pastClaims.isNotYetValid(Instant.now())).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should check not-before with clock skew")
+ void shouldCheckNotBeforeWithClockSkew() {
+ // Token that becomes valid 30 seconds from now
+ long nbf = Instant.now().plusSeconds(30).getEpochSecond();
+ CwtClaims claims = new CwtClaims(null, null, null, null, nbf, null);
+
+ // Without clock skew, it's not yet valid
+ assertThat(claims.isNotYetValid(Instant.now(), 0)).isTrue();
+
+ // With 60 second clock skew, it's valid
+ assertThat(claims.isNotYetValid(Instant.now(), 60)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("StatusToken expiry tests")
+ class StatusTokenExpiryTests {
+
+ @Test
+ @DisplayName("Should check token expiration")
+ void shouldCheckTokenExpiration() {
+ Instant past = Instant.now().minusSeconds(3600);
+ Instant future = Instant.now().plusSeconds(3600);
+
+ StatusToken expiredToken = createToken("id", StatusToken.Status.ACTIVE, past, past);
+ StatusToken validToken = createToken("id", StatusToken.Status.ACTIVE, past, future);
+
+ assertThat(expiredToken.isExpired()).isTrue();
+ assertThat(validToken.isExpired()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should respect clock skew tolerance")
+ void shouldRespectClockSkewTolerance() {
+ // Token expired 30 seconds ago
+ Instant past = Instant.now().minusSeconds(3600);
+ Instant recentExpiry = Instant.now().minusSeconds(30);
+
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, past, recentExpiry);
+
+ // With default 60s clock skew, should not be expired
+ assertThat(token.isExpired(Duration.ofSeconds(60))).isFalse();
+
+ // With 0 clock skew, should be expired
+ assertThat(token.isExpired(Duration.ZERO)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should treat null expiry as expired (defensive)")
+ void shouldTreatNullExpiryAsExpired() {
+ // Direct construction with null expiry is treated as expired (defensive check)
+ // Normal parsing would reject such tokens
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, Instant.now(), null);
+ assertThat(token.isExpired()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("StatusToken refresh interval tests")
+ class RefreshIntervalTests {
+
+ @Test
+ @DisplayName("Should compute refresh interval as half of lifetime")
+ void shouldComputeRefreshIntervalAsHalfLifetime() {
+ Instant issuedAt = Instant.now();
+ Instant expiresAt = issuedAt.plusSeconds(7200); // 2 hours
+
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, issuedAt, expiresAt);
+
+ Duration interval = token.computeRefreshInterval();
+ assertThat(interval).isEqualTo(Duration.ofSeconds(3600)); // 1 hour
+ }
+
+ @Test
+ @DisplayName("Should return minimum 1 minute interval")
+ void shouldReturnMinimumInterval() {
+ Instant issuedAt = Instant.now();
+ Instant expiresAt = issuedAt.plusSeconds(30); // 30 seconds
+
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, issuedAt, expiresAt);
+
+ Duration interval = token.computeRefreshInterval();
+ assertThat(interval).isEqualTo(Duration.ofMinutes(1));
+ }
+
+ @Test
+ @DisplayName("Should return maximum 1 hour interval")
+ void shouldReturnMaximumInterval() {
+ Instant issuedAt = Instant.now();
+ Instant expiresAt = issuedAt.plusSeconds(86400); // 24 hours
+
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, issuedAt, expiresAt);
+
+ Duration interval = token.computeRefreshInterval();
+ assertThat(interval).isEqualTo(Duration.ofHours(1));
+ }
+
+ @Test
+ @DisplayName("Should return default for missing timestamps")
+ void shouldReturnDefaultForMissingTimestamps() {
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, null, null);
+
+ Duration interval = token.computeRefreshInterval();
+ assertThat(interval).isEqualTo(Duration.ofMinutes(5));
+ }
+ }
+
+ @Nested
+ @DisplayName("StatusToken status tests")
+ class StatusTests {
+
+ @Test
+ @DisplayName("Should parse all status values")
+ void shouldParseAllStatusValues() {
+ assertThat(StatusToken.Status.valueOf("ACTIVE")).isEqualTo(StatusToken.Status.ACTIVE);
+ assertThat(StatusToken.Status.valueOf("WARNING")).isEqualTo(StatusToken.Status.WARNING);
+ assertThat(StatusToken.Status.valueOf("DEPRECATED")).isEqualTo(StatusToken.Status.DEPRECATED);
+ assertThat(StatusToken.Status.valueOf("EXPIRED")).isEqualTo(StatusToken.Status.EXPIRED);
+ assertThat(StatusToken.Status.valueOf("REVOKED")).isEqualTo(StatusToken.Status.REVOKED);
+ assertThat(StatusToken.Status.valueOf("UNKNOWN")).isEqualTo(StatusToken.Status.UNKNOWN);
+ }
+ }
+
+ @Nested
+ @DisplayName("StatusToken parsing tests")
+ class ParsingTests {
+
+ @Test
+ @DisplayName("Should reject null input")
+ void shouldRejectNullInput() {
+ assertThatThrownBy(() -> StatusToken.parse(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("coseBytes cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should reject empty payload")
+ void shouldRejectEmptyPayload() throws Exception {
+ byte[] coseBytes = createCoseSign1WithPayload(new byte[0]);
+
+ assertThatThrownBy(() -> StatusToken.parse(coseBytes))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("payload cannot be empty");
+ }
+
+ @Test
+ @DisplayName("Should reject non-map payload")
+ void shouldRejectNonMapPayload() throws Exception {
+ CBORObject array = CBORObject.NewArray();
+ array.Add("test");
+ byte[] coseBytes = createCoseSign1WithPayload(array.EncodeToBytes());
+
+ assertThatThrownBy(() -> StatusToken.parse(coseBytes))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("must be a CBOR map");
+ }
+
+ @Test
+ @DisplayName("Should reject missing agent_id")
+ void shouldRejectMissingAgentId() throws Exception {
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(2, "ACTIVE"); // status only, no agent_id
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ assertThatThrownBy(() -> StatusToken.parse(coseBytes))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Missing required field");
+ }
+
+ @Test
+ @DisplayName("Should reject missing status")
+ void shouldRejectMissingStatus() throws Exception {
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id only, no status
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ assertThatThrownBy(() -> StatusToken.parse(coseBytes))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("Missing required field");
+ }
+
+ @Test
+ @DisplayName("Should reject missing expiration")
+ void shouldRejectMissingExpiration() throws Exception {
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "ACTIVE"); // status - no exp
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ assertThatThrownBy(() -> StatusToken.parse(coseBytes))
+ .isInstanceOf(ScittParseException.class)
+ .hasMessageContaining("missing required expiration time");
+ }
+
+ @Test
+ @DisplayName("Should parse minimal valid token")
+ void shouldParseMinimalValidToken() throws Exception {
+ long future = Instant.now().plusSeconds(3600).getEpochSecond();
+
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "ACTIVE"); // status
+ payload.Add(4, future); // exp (required)
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ StatusToken token = StatusToken.parse(coseBytes);
+
+ assertThat(token.agentId()).isEqualTo("test-agent");
+ assertThat(token.status()).isEqualTo(StatusToken.Status.ACTIVE);
+ assertThat(token.expiresAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should parse token with all fields")
+ void shouldParseTokenWithAllFields() throws Exception {
+ long now = Instant.now().getEpochSecond();
+ long future = now + 3600;
+
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "WARNING"); // status
+ payload.Add(3, now); // iat
+ payload.Add(4, future); // exp
+ payload.Add(5, "test.agent.ans"); // ans_name
+
+ // Add server certs (key 7)
+ CBORObject serverCerts = CBORObject.NewArray();
+ CBORObject cert = CBORObject.NewMap();
+ cert.Add(1, "abc123"); // fingerprint
+ cert.Add(2, "LEAF"); // type
+ serverCerts.Add(cert);
+ payload.Add(7, serverCerts);
+
+ // Add identity certs (key 6) as simple strings
+ CBORObject identityCerts = CBORObject.NewArray();
+ identityCerts.Add("def456");
+ payload.Add(6, identityCerts);
+
+ // Add metadata hashes (key 8)
+ CBORObject metadataHashes = CBORObject.NewMap();
+ metadataHashes.Add("a2a", "SHA256:hash1");
+ metadataHashes.Add("mcp", "SHA256:hash2");
+ payload.Add(8, metadataHashes);
+
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ StatusToken token = StatusToken.parse(coseBytes);
+
+ assertThat(token.agentId()).isEqualTo("test-agent");
+ assertThat(token.status()).isEqualTo(StatusToken.Status.WARNING);
+ assertThat(token.ansName()).isEqualTo("test.agent.ans");
+ assertThat(token.issuedAt()).isEqualTo(Instant.ofEpochSecond(now));
+ assertThat(token.expiresAt()).isEqualTo(Instant.ofEpochSecond(future));
+ assertThat(token.validServerCerts()).hasSize(1);
+ assertThat(token.validIdentityCerts()).hasSize(1);
+ assertThat(token.metadataHashes()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("Should parse unknown status as UNKNOWN")
+ void shouldParseUnknownStatusAsUnknown() throws Exception {
+ long future = Instant.now().plusSeconds(3600).getEpochSecond();
+
+ CBORObject payload = CBORObject.NewMap();
+ payload.Add(1, "test-agent"); // agent_id
+ payload.Add(2, "BOGUS_STATUS"); // status
+ payload.Add(4, future); // exp (required)
+ byte[] coseBytes = createCoseSign1WithPayload(payload.EncodeToBytes());
+
+ StatusToken token = StatusToken.parse(coseBytes);
+
+ assertThat(token.status()).isEqualTo(StatusToken.Status.UNKNOWN);
+ }
+
+ private byte[] createCoseSign1WithPayload(byte[] payload) {
+ CBORObject protectedHeader = CBORObject.NewMap();
+ protectedHeader.Add(1, -7); // alg = ES256
+ byte[] protectedBytes = protectedHeader.EncodeToBytes();
+
+ CBORObject array = CBORObject.NewArray();
+ array.Add(protectedBytes);
+ array.Add(CBORObject.NewMap());
+ array.Add(payload);
+ array.Add(new byte[64]); // signature
+ CBORObject tagged = CBORObject.FromObjectAndTag(array, 18);
+
+ return tagged.EncodeToBytes();
+ }
+ }
+
+ @Nested
+ @DisplayName("Certificate fingerprint accessor tests")
+ class FingerprintAccessorTests {
+
+ @Test
+ @DisplayName("Should return server cert fingerprints")
+ void shouldReturnServerCertFingerprints() {
+ CertificateInfo cert1 = new CertificateInfo();
+ cert1.setFingerprint("fp1");
+ CertificateInfo cert2 = new CertificateInfo();
+ cert2.setFingerprint("fp2");
+
+ StatusToken token = new StatusToken(
+ "id", StatusToken.Status.ACTIVE, null, null,
+ null, null, List.of(), List.of(cert1, cert2),
+ Map.of(), null, null, null, null
+ );
+
+ assertThat(token.serverCertFingerprints()).containsExactly("fp1", "fp2");
+ }
+
+ @Test
+ @DisplayName("Should return identity cert fingerprints")
+ void shouldReturnIdentityCertFingerprints() {
+ CertificateInfo cert1 = new CertificateInfo();
+ cert1.setFingerprint("id1");
+ CertificateInfo cert2 = new CertificateInfo();
+ cert2.setFingerprint("id2");
+
+ StatusToken token = new StatusToken(
+ "id", StatusToken.Status.ACTIVE, null, null,
+ null, null, List.of(cert1, cert2), List.of(),
+ Map.of(), null, null, null, null
+ );
+
+ assertThat(token.identityCertFingerprints()).containsExactly("id1", "id2");
+ }
+
+ @Test
+ @DisplayName("Should filter null fingerprints")
+ void shouldFilterNullFingerprints() {
+ CertificateInfo cert1 = new CertificateInfo();
+ cert1.setFingerprint("fp1");
+ CertificateInfo cert2 = new CertificateInfo();
+ // No fingerprint set
+
+ StatusToken token = new StatusToken(
+ "id", StatusToken.Status.ACTIVE, null, null,
+ null, null, List.of(), List.of(cert1, cert2),
+ Map.of(), null, null, null, null
+ );
+
+ assertThat(token.serverCertFingerprints()).containsExactly("fp1");
+ }
+ }
+
+ @Nested
+ @DisplayName("Equals and hashCode tests")
+ class EqualsHashCodeTests {
+
+ @Test
+ @DisplayName("Should be equal to itself")
+ void shouldBeEqualToItself() {
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, Instant.now(),
+ Instant.now().plusSeconds(3600));
+ assertThat(token).isEqualTo(token);
+ }
+
+ @Test
+ @DisplayName("Should be equal for same values")
+ void shouldBeEqualForSameValues() {
+ Instant now = Instant.now();
+ Instant later = now.plusSeconds(3600);
+
+ StatusToken token1 = createToken("id", StatusToken.Status.ACTIVE, now, later);
+ StatusToken token2 = createToken("id", StatusToken.Status.ACTIVE, now, later);
+
+ assertThat(token1).isEqualTo(token2);
+ assertThat(token1.hashCode()).isEqualTo(token2.hashCode());
+ }
+
+ @Test
+ @DisplayName("Should not be equal for different agent IDs")
+ void shouldNotBeEqualForDifferentIds() {
+ Instant now = Instant.now();
+ Instant later = now.plusSeconds(3600);
+
+ StatusToken token1 = createToken("id1", StatusToken.Status.ACTIVE, now, later);
+ StatusToken token2 = createToken("id2", StatusToken.Status.ACTIVE, now, later);
+
+ assertThat(token1).isNotEqualTo(token2);
+ }
+
+ @Test
+ @DisplayName("Should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ StatusToken token = createToken("id", StatusToken.Status.ACTIVE, Instant.now(),
+ Instant.now().plusSeconds(3600));
+ assertThat(token).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("Should have meaningful toString")
+ void shouldHaveMeaningfulToString() {
+ StatusToken token = createToken("test-id", StatusToken.Status.ACTIVE, Instant.now(),
+ Instant.now().plusSeconds(3600));
+ String str = token.toString();
+
+ assertThat(str).contains("test-id");
+ assertThat(str).contains("ACTIVE");
+ }
+ }
+
+ private StatusToken createToken(String agentId, StatusToken.Status status,
+ Instant issuedAt, Instant expiresAt) {
+ return new StatusToken(
+ agentId,
+ status,
+ issuedAt,
+ expiresAt,
+ "ans://test",
+ "agent.example.com",
+ List.of(),
+ List.of(),
+ Map.of(),
+ null,
+ null,
+ null,
+ null
+ );
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistryTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistryTest.java
new file mode 100644
index 0000000..9f6c52d
--- /dev/null
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/scitt/TrustedDomainRegistryTest.java
@@ -0,0 +1,163 @@
+package com.godaddy.ans.sdk.transparency.scitt;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for TrustedDomainRegistry.
+ *
+ * Note: The trusted domains are captured once at class initialization
+ * and cannot be changed afterward. Tests that need custom domains must be run
+ * in a separate JVM with the system property set before class loading.
+ */
+class TrustedDomainRegistryTest {
+
+ @Nested
+ @DisplayName("isTrustedDomain() with defaults")
+ class DefaultDomainTests {
+
+ @Test
+ @DisplayName("Should accept production domain")
+ void shouldAcceptProductionDomain() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain("transparency.ans.godaddy.com")).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should accept OTE domain")
+ void shouldAcceptOteDomain() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain("transparency.ans.ote-godaddy.com")).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should be case insensitive")
+ void shouldBeCaseInsensitive() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain("TRANSPARENCY.ANS.GODADDY.COM")).isTrue();
+ assertThat(TrustedDomainRegistry.isTrustedDomain("Transparency.Ans.Godaddy.Com")).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should reject unknown domains")
+ void shouldRejectUnknownDomains() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain("unknown.example.com")).isFalse();
+ assertThat(TrustedDomainRegistry.isTrustedDomain("transparency.ans.evil.com")).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject null")
+ void shouldRejectNull() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain(null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject empty string")
+ void shouldRejectEmptyString() {
+ assertThat(TrustedDomainRegistry.isTrustedDomain("")).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Immutability guarantees")
+ class ImmutabilityTests {
+
+ @Test
+ @DisplayName("getTrustedDomains() should return same instance on repeated calls")
+ void shouldReturnSameInstance() {
+ Set first = TrustedDomainRegistry.getTrustedDomains();
+ Set second = TrustedDomainRegistry.getTrustedDomains();
+
+ // Same reference - not just equal, but identical
+ assertThat(first).isSameAs(second);
+ }
+
+ @Test
+ @DisplayName("Returned set should be unmodifiable")
+ void returnedSetShouldBeUnmodifiable() {
+ Set