From 765082c26878a86a8eb729d7533f3bbac4570521 Mon Sep 17 00:00:00 2001
From: James Hateley Use this method if you need a dedicated executor with different sizing.
- * The returned executor is NOT shared and should be managed by the caller.Default Configuration
*
*
*
* Usage
@@ -50,6 +55,12 @@ public final class AnsExecutors {
*/
public static final int DEFAULT_POOL_SIZE = 10;
+ /**
+ * Default queue capacity for bounded task queues.
+ * When the queue is full, tasks are executed on the caller's thread (back-pressure).
+ */
+ public static final int DEFAULT_QUEUE_CAPACITY = 100;
+
private static volatile ExecutorService sharedExecutor;
private static final Object LOCK = new Object();
@@ -88,13 +99,44 @@ public static Executor sharedIoExecutor() {
* Creates a new I/O executor with the specified 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.
+ * + *Creating MessageDigest and Signature instances involves synchronization and provider + * lookup. Caching instances per-thread eliminates this overhead for repeated + * operations on the same thread.
+ * + *{@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 ThreadLocalUses 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); + AtomicReferenceCOSE_Sign1 is a CBOR structure containing:
+ *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 MapUse 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 MapThis implementation performs:
+ *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:
+ *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
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.
+ * + *Hashes are formatted as {@code SHA256:<64-hex-chars>}
+ * + *{@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: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, MapIntended 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:
+ *{@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 AsyncLoadingCacheReceipts 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 CompletableFutureThis 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 CompletableFutureTokens 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 CompletableFutureThis 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 CompletableFutureThe 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 ExpiryIf 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 ListThis 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:
+ *{@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 + */ + MapSCITT 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:
+ *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:
+ *Expected keys:
+ *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.
+ * + *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, + MapThis 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:
+ *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 ListReturns 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 ListTrusted 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.
+ * + *{@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 SetThe 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 SetThis 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.
+ * + *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() { + SetRoot 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 MapThe 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:
+ *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 CompletableFutureThis 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 CompletableFutureThis 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 CompletableFutureThis 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