Merge "Filter out SCTs emitted after a log expired" into main am: ff806829af

Original change: https://android-review.googlesource.com/c/platform/external/conscrypt/+/3217616

Change-Id: Ifb57c990807ac9fa5dec30d57045a7d746c04331
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/common/src/main/java/org/conscrypt/ct/LogInfo.java b/common/src/main/java/org/conscrypt/ct/LogInfo.java
index 3b031f6..99c8139 100644
--- a/common/src/main/java/org/conscrypt/ct/LogInfo.java
+++ b/common/src/main/java/org/conscrypt/ct/LogInfo.java
@@ -44,6 +44,7 @@
     private final byte[] logId;
     private final PublicKey publicKey;
     private final int state;
+    private final long stateTimestamp;
     private final String description;
     private final String url;
     private final String operator;
@@ -60,6 +61,7 @@
         this.logId = builder.logId;
         this.publicKey = builder.publicKey;
         this.state = builder.state;
+        this.stateTimestamp = builder.stateTimestamp;
         this.description = builder.description;
         this.url = builder.url;
         this.operator = builder.operator;
@@ -69,6 +71,7 @@
         private byte[] logId;
         private PublicKey publicKey;
         private int state;
+        private long stateTimestamp;
         private String description;
         private String url;
         private String operator;
@@ -85,11 +88,12 @@
             return this;
         }
 
-        public Builder setState(int state) {
+        public Builder setState(int state, long timestamp) {
             if (state < 0 || state > STATE_REJECTED) {
                 throw new IllegalArgumentException("invalid state value");
             }
             this.state = state;
+            this.stateTimestamp = timestamp;
             return this;
         }
 
@@ -139,6 +143,17 @@
         return state;
     }
 
+    public int getStateAt(long when) {
+        if (when >= this.stateTimestamp) {
+            return state;
+        }
+        return STATE_UNKNOWN;
+    }
+
+    public long getStateTimestamp() {
+        return stateTimestamp;
+    }
+
     public String getOperator() {
         return operator;
     }
@@ -155,12 +170,14 @@
         LogInfo that = (LogInfo) other;
         return this.state == that.state && this.description.equals(that.description)
                 && this.url.equals(that.url) && this.operator.equals(that.operator)
+                && this.stateTimestamp == that.stateTimestamp
                 && Arrays.equals(this.logId, that.logId);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(Arrays.hashCode(logId), description, url, state, operator);
+        return Objects.hash(
+                Arrays.hashCode(logId), description, url, state, stateTimestamp, operator);
     }
 
     /**
diff --git a/common/src/main/java/org/conscrypt/ct/VerificationResult.java b/common/src/main/java/org/conscrypt/ct/VerificationResult.java
index 1a486ce..354b16a 100644
--- a/common/src/main/java/org/conscrypt/ct/VerificationResult.java
+++ b/common/src/main/java/org/conscrypt/ct/VerificationResult.java
@@ -21,6 +21,14 @@
 import java.util.List;
 import org.conscrypt.Internal;
 
+/**
+ * Container for verified SignedCertificateTimestamp.
+ *
+ * getValidSCTs returns SCTs which were found to match a known log and for
+ * which the signature has been verified. There is no guarantee on the state of
+ * the log (e.g., getLogInfo.getState() may return STATE_UNKNOWN). Further
+ * verification on the compliance with the policy is performed in PolicyImpl.
+ */
 @Internal
 public class VerificationResult {
     private final ArrayList<VerifiedSCT> validSCTs = new ArrayList<VerifiedSCT>();
diff --git a/common/src/test/java/org/conscrypt/ct/VerifierTest.java b/common/src/test/java/org/conscrypt/ct/VerifierTest.java
index 18cc64b..bd26e2c 100644
--- a/common/src/test/java/org/conscrypt/ct/VerifierTest.java
+++ b/common/src/test/java/org/conscrypt/ct/VerifierTest.java
@@ -54,7 +54,7 @@
                                     .setDescription("Test Log")
                                     .setUrl("http://example.com")
                                     .setOperator("LogOperator")
-                                    .setState(LogInfo.STATE_USABLE)
+                                    .setState(LogInfo.STATE_USABLE, 1643709600000L)
                                     .build();
         LogStore store = new LogStore() {
             @Override
diff --git a/platform/src/main/java/org/conscrypt/ct/LogStoreImpl.java b/platform/src/main/java/org/conscrypt/ct/LogStoreImpl.java
index 8ab4efb..f9f24ac 100644
--- a/platform/src/main/java/org/conscrypt/ct/LogStoreImpl.java
+++ b/platform/src/main/java/org/conscrypt/ct/LogStoreImpl.java
@@ -19,6 +19,13 @@
 import static java.nio.charset.StandardCharsets.US_ASCII;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import org.conscrypt.ByteArray;
+import org.conscrypt.Internal;
+import org.conscrypt.OpenSSLKey;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -28,19 +35,17 @@
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Collections;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
-import org.conscrypt.ByteArray;
-import org.conscrypt.Internal;
-import org.conscrypt.OpenSSLKey;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 
 @Internal
 public class LogStoreImpl implements LogStore {
@@ -139,7 +144,10 @@
 
                     JSONObject stateObject = log.optJSONObject("state");
                     if (stateObject != null) {
-                        builder.setState(parseState(stateObject.keys().next()));
+                        String state = stateObject.keys().next();
+                        String stateTimestamp =
+                                stateObject.getJSONObject(state).getString("timestamp");
+                        builder.setState(parseState(state), parseStateTimestamp(stateTimestamp));
                     }
 
                     LogInfo logInfo = builder.build();
@@ -180,6 +188,19 @@
         }
     }
 
+    // ISO 8601
+    private static DateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX");
+
+    @SuppressWarnings("JavaUtilDate")
+    private static long parseStateTimestamp(String timestamp) {
+        try {
+            Date date = dateFormatter.parse(timestamp);
+            return date.getTime();
+        } catch (ParseException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
     private static PublicKey parsePubKey(String key) {
         byte[] pem = ("-----BEGIN PUBLIC KEY-----\n" + key + "\n-----END PUBLIC KEY-----")
                              .getBytes(US_ASCII);
diff --git a/platform/src/main/java/org/conscrypt/ct/PolicyImpl.java b/platform/src/main/java/org/conscrypt/ct/PolicyImpl.java
index a6aca5f..d453228 100644
--- a/platform/src/main/java/org/conscrypt/ct/PolicyImpl.java
+++ b/platform/src/main/java/org/conscrypt/ct/PolicyImpl.java
@@ -16,20 +16,38 @@
 
 package org.conscrypt.ct;
 
+import org.conscrypt.Internal;
+
 import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
-import org.conscrypt.Internal;
 
 @Internal
 public class PolicyImpl implements Policy {
     @Override
     public PolicyCompliance doesResultConformToPolicy(
             VerificationResult result, X509Certificate leaf) {
+        long now = System.currentTimeMillis();
+        return doesResultConformToPolicyAt(result, leaf, now);
+    }
+
+    public PolicyCompliance doesResultConformToPolicyAt(
+            VerificationResult result, X509Certificate leaf, long atTime) {
+        List<VerifiedSCT> validSCTs = new ArrayList<VerifiedSCT>(result.getValidSCTs());
+        /* While the log list supports logs without a state, these entries are
+         * not supported by the log policy. Filter them out. */
+        filterOutUnknown(validSCTs);
+        /* Filter out any SCT issued after a log was retired */
+        filterOutAfterRetired(validSCTs);
+
         Set<VerifiedSCT> embeddedValidSCTs = new HashSet<>();
         Set<VerifiedSCT> ocspOrTLSValidSCTs = new HashSet<>();
-        for (VerifiedSCT vsct : result.getValidSCTs()) {
+        for (VerifiedSCT vsct : validSCTs) {
             if (vsct.getSct().getOrigin() == SignedCertificateTimestamp.Origin.EMBEDDED) {
                 embeddedValidSCTs.add(vsct);
             } else {
@@ -37,20 +55,61 @@
             }
         }
         if (embeddedValidSCTs.size() > 0) {
-            return conformEmbeddedSCTs(embeddedValidSCTs, leaf);
+            return conformEmbeddedSCTs(embeddedValidSCTs, leaf, atTime);
         }
         return PolicyCompliance.NOT_ENOUGH_SCTS;
     }
 
+    private void filterOutUnknown(List<VerifiedSCT> scts) {
+        Iterator<VerifiedSCT> it = scts.iterator();
+        while (it.hasNext()) {
+            VerifiedSCT vsct = it.next();
+            if (vsct.getLogInfo().getState() == LogInfo.STATE_UNKNOWN) {
+                it.remove();
+            }
+        }
+    }
+
+    private void filterOutAfterRetired(List<VerifiedSCT> scts) {
+        /* From the policy:
+         *
+         * In order to contribute to a certificate’s CT Compliance, an SCT must
+         * have been issued before the Log’s Retired timestamp, if one exists.
+         * Chrome uses the earliest SCT among all SCTs presented to evaluate CT
+         * compliance against CT Log Retired timestamps. This accounts for edge
+         * cases in which a CT Log becomes Retired during the process of
+         * submitting certificate logging requests.
+         */
+
+        if (scts.size() < 1) {
+            return;
+        }
+        long minTimestamp = scts.get(0).getSct().getTimestamp();
+        for (VerifiedSCT vsct : scts) {
+            long ts = vsct.getSct().getTimestamp();
+            if (ts < minTimestamp) {
+                minTimestamp = ts;
+            }
+        }
+        Iterator<VerifiedSCT> it = scts.iterator();
+        while (it.hasNext()) {
+            VerifiedSCT vsct = it.next();
+            if (vsct.getLogInfo().getState() == LogInfo.STATE_RETIRED
+                    && minTimestamp > vsct.getLogInfo().getStateTimestamp()) {
+                it.remove();
+            }
+        }
+    }
+
     private PolicyCompliance conformEmbeddedSCTs(
-            Set<VerifiedSCT> embeddedValidSCTs, X509Certificate leaf) {
+            Set<VerifiedSCT> embeddedValidSCTs, X509Certificate leaf, long atTime) {
         /* 1. At least one Embedded SCT from a CT Log that was Qualified,
          *    Usable, or ReadOnly at the time of check;
          */
         boolean found = false;
         for (VerifiedSCT vsct : embeddedValidSCTs) {
             LogInfo log = vsct.getLogInfo();
-            switch (log.getState()) {
+            switch (log.getStateAt(atTime)) {
                 case LogInfo.STATE_QUALIFIED:
                 case LogInfo.STATE_USABLE:
                 case LogInfo.STATE_READONLY:
@@ -80,7 +139,7 @@
         }
         for (VerifiedSCT vsct : embeddedValidSCTs) {
             LogInfo log = vsct.getLogInfo();
-            switch (log.getState()) {
+            switch (log.getStateAt(atTime)) {
                 case LogInfo.STATE_QUALIFIED:
                 case LogInfo.STATE_USABLE:
                 case LogInfo.STATE_READONLY:
diff --git a/platform/src/test/java/org/conscrypt/ct/LogStoreImplTest.java b/platform/src/test/java/org/conscrypt/ct/LogStoreImplTest.java
index af3a2a1..b798d40 100644
--- a/platform/src/test/java/org/conscrypt/ct/LogStoreImplTest.java
+++ b/platform/src/test/java/org/conscrypt/ct/LogStoreImplTest.java
@@ -127,7 +127,7 @@
                         .setPublicKey(OpenSSLKey.fromPublicKeyPemInputStream(is).getPublicKey())
                         .setDescription("Operator 1 'Test2024' log")
                         .setUrl("https://operator1.example.com/logs/test2024/")
-                        .setState(LogInfo.STATE_USABLE)
+                        .setState(LogInfo.STATE_USABLE, 1667328840000L)
                         .setOperator("Operator 1")
                         .build();
         byte[] log1Id = Base64.getDecoder().decode("7s3QZNXbGs7FXLedtM0TojKHRny87N7DUUhZRnEftZs=");
diff --git a/platform/src/test/java/org/conscrypt/ct/PolicyImplTest.java b/platform/src/test/java/org/conscrypt/ct/PolicyImplTest.java
index 8c4e33e..7188d7c 100644
--- a/platform/src/test/java/org/conscrypt/ct/PolicyImplTest.java
+++ b/platform/src/test/java/org/conscrypt/ct/PolicyImplTest.java
@@ -33,13 +33,27 @@
 
 @RunWith(JUnit4.class)
 public class PolicyImplTest {
+    private static final String OPERATOR1 = "operator 1";
+    private static final String OPERATOR2 = "operator 2";
     private static LogInfo usableOp1Log1;
     private static LogInfo usableOp1Log2;
-    private static LogInfo retiredOp1Log;
+    private static LogInfo retiredOp1LogOld;
+    private static LogInfo retiredOp1LogNew;
     private static LogInfo usableOp2Log;
     private static LogInfo retiredOp2Log;
     private static SignedCertificateTimestamp embeddedSCT;
 
+    /* Some test dates. By default:
+     *  - The verification is occurring in January 2024;
+     *  - The SCTs were generated in January 2023; and
+     *  - The logs got into their state in January 2022.
+     * Other dates are used to exercise edge cases.
+     */
+    private static final long JAN2024 = 1704103200000L;
+    private static final long JUN2023 = 1672999200000L;
+    private static final long JAN2023 = 1672567200000L;
+    private static final long JAN2022 = 1641031200000L;
+
     private static class FakePublicKey implements PublicKey {
         static final long serialVersionUID = 1;
         final byte[] key;
@@ -73,56 +87,62 @@
         usableOp1Log1 = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x01}))
                                 .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_USABLE)
+                                .setOperator(OPERATOR1)
+                                .setState(LogInfo.STATE_USABLE, JAN2022)
                                 .build();
         usableOp1Log2 = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x02}))
                                 .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_USABLE)
+                                .setOperator(OPERATOR1)
+                                .setState(LogInfo.STATE_USABLE, JAN2022)
                                 .build();
-        retiredOp1Log = new LogInfo.Builder()
-                                .setPublicKey(new FakePublicKey(new byte[] {0x03}))
-                                .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_RETIRED)
-                                .build();
+        retiredOp1LogOld = new LogInfo.Builder()
+                                   .setPublicKey(new FakePublicKey(new byte[] {0x03}))
+                                   .setUrl("")
+                                   .setOperator(OPERATOR1)
+                                   .setState(LogInfo.STATE_RETIRED, JAN2022)
+                                   .build();
+        retiredOp1LogNew = new LogInfo.Builder()
+                                   .setPublicKey(new FakePublicKey(new byte[] {0x06}))
+                                   .setUrl("")
+                                   .setOperator(OPERATOR1)
+                                   .setState(LogInfo.STATE_RETIRED, JUN2023)
+                                   .build();
         usableOp2Log = new LogInfo.Builder()
                                .setPublicKey(new FakePublicKey(new byte[] {0x04}))
                                .setUrl("")
-                               .setOperator("operator 2")
-                               .setState(LogInfo.STATE_USABLE)
+                               .setOperator(OPERATOR2)
+                               .setState(LogInfo.STATE_USABLE, JAN2022)
                                .build();
         retiredOp2Log = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x05}))
                                 .setUrl("")
-                                .setOperator("operator 2")
-                                .setState(LogInfo.STATE_RETIRED)
+                                .setOperator(OPERATOR2)
+                                .setState(LogInfo.STATE_RETIRED, JAN2022)
                                 .build();
-        /* Only the origin of the SCT is used during the evaluation for policy
-         * compliance. The signature is validated at the previous step (see
-         * the Verifier class).
+        /* The origin of the SCT and its timestamp are used during the
+         * evaluation for policy compliance. The signature is validated at the
+         * previous step (see the Verifier class).
          */
-        embeddedSCT = new SignedCertificateTimestamp(SignedCertificateTimestamp.Version.V1, null, 0,
-                null, null, SignedCertificateTimestamp.Origin.EMBEDDED);
+        embeddedSCT = new SignedCertificateTimestamp(SignedCertificateTimestamp.Version.V1, null,
+                JAN2023, null, null, SignedCertificateTimestamp.Origin.EMBEDDED);
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void emptyVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
         VerificationResult result = new VerificationResult();
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("An empty VerificationResult", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void validVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -140,17 +160,17 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two valid SCTs from different operators", PolicyCompliance.COMPLY,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void validWithRetiredVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
-                                    .setLogInfo(retiredOp1Log)
+                                    .setLogInfo(retiredOp1LogNew)
                                     .build();
 
         VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
@@ -164,13 +184,37 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("One valid, one retired SCTs from different operators",
-                PolicyCompliance.COMPLY, p.doesResultConformToPolicy(result, leaf));
+                PolicyCompliance.COMPLY, p.doesResultConformToPolicyAt(result, leaf, JAN2024));
+    }
+
+    @Test
+    public void invalidWithRetiredVerificationResult() throws Exception {
+        PolicyImpl p = new PolicyImpl();
+
+        VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
+                                    .setStatus(VerifiedSCT.Status.VALID)
+                                    .setLogInfo(retiredOp1LogOld)
+                                    .build();
+
+        VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
+                                    .setStatus(VerifiedSCT.Status.VALID)
+                                    .setLogInfo(usableOp2Log)
+                                    .build();
+
+        VerificationResult result = new VerificationResult();
+        result.add(vsct1);
+        result.add(vsct2);
+
+        X509Certificate leaf = new FakeX509Certificate();
+        assertEquals("One valid, one retired (before SCT timestamp) SCTs from different operators",
+                PolicyCompliance.NOT_ENOUGH_SCTS,
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidOneSctVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -182,17 +226,17 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("One valid SCT", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidTwoSctsVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
-                                    .setLogInfo(retiredOp1Log)
+                                    .setLogInfo(retiredOp1LogNew)
                                     .build();
 
         VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
@@ -206,13 +250,13 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two retired SCTs from different operators", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidTwoSctsSameOperatorVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -230,6 +274,6 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two SCTs from the same operator", PolicyCompliance.NOT_ENOUGH_DIVERSE_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 }
diff --git a/repackaged/common/src/main/java/com/android/org/conscrypt/ct/LogInfo.java b/repackaged/common/src/main/java/com/android/org/conscrypt/ct/LogInfo.java
index 1d7aa91..c2a8498 100644
--- a/repackaged/common/src/main/java/com/android/org/conscrypt/ct/LogInfo.java
+++ b/repackaged/common/src/main/java/com/android/org/conscrypt/ct/LogInfo.java
@@ -47,6 +47,7 @@
     private final byte[] logId;
     private final PublicKey publicKey;
     private final int state;
+    private final long stateTimestamp;
     private final String description;
     private final String url;
     private final String operator;
@@ -63,6 +64,7 @@
         this.logId = builder.logId;
         this.publicKey = builder.publicKey;
         this.state = builder.state;
+        this.stateTimestamp = builder.stateTimestamp;
         this.description = builder.description;
         this.url = builder.url;
         this.operator = builder.operator;
@@ -75,6 +77,7 @@
         private byte[] logId;
         private PublicKey publicKey;
         private int state;
+        private long stateTimestamp;
         private String description;
         private String url;
         private String operator;
@@ -91,11 +94,12 @@
             return this;
         }
 
-        public Builder setState(int state) {
+        public Builder setState(int state, long timestamp) {
             if (state < 0 || state > STATE_REJECTED) {
                 throw new IllegalArgumentException("invalid state value");
             }
             this.state = state;
+            this.stateTimestamp = timestamp;
             return this;
         }
 
@@ -145,6 +149,17 @@
         return state;
     }
 
+    public int getStateAt(long when) {
+        if (when >= this.stateTimestamp) {
+            return state;
+        }
+        return STATE_UNKNOWN;
+    }
+
+    public long getStateTimestamp() {
+        return stateTimestamp;
+    }
+
     public String getOperator() {
         return operator;
     }
@@ -161,12 +176,14 @@
         LogInfo that = (LogInfo) other;
         return this.state == that.state && this.description.equals(that.description)
                 && this.url.equals(that.url) && this.operator.equals(that.operator)
+                && this.stateTimestamp == that.stateTimestamp
                 && Arrays.equals(this.logId, that.logId);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(Arrays.hashCode(logId), description, url, state, operator);
+        return Objects.hash(
+                Arrays.hashCode(logId), description, url, state, stateTimestamp, operator);
     }
 
     /**
diff --git a/repackaged/common/src/main/java/com/android/org/conscrypt/ct/VerificationResult.java b/repackaged/common/src/main/java/com/android/org/conscrypt/ct/VerificationResult.java
index a5af50b..7a2e5df 100644
--- a/repackaged/common/src/main/java/com/android/org/conscrypt/ct/VerificationResult.java
+++ b/repackaged/common/src/main/java/com/android/org/conscrypt/ct/VerificationResult.java
@@ -23,6 +23,12 @@
 import com.android.org.conscrypt.Internal;
 
 /**
+ * Container for verified SignedCertificateTimestamp.
+ *
+ * getValidSCTs returns SCTs which were found to match a known log and for
+ * which the signature has been verified. There is no guarantee on the state of
+ * the log (e.g., getLogInfo.getState() may return STATE_UNKNOWN). Further
+ * verification on the compliance with the policy is performed in PolicyImpl.
  * @hide This class is not part of the Android public SDK API
  */
 @Internal
diff --git a/repackaged/common/src/test/java/com/android/org/conscrypt/ct/VerifierTest.java b/repackaged/common/src/test/java/com/android/org/conscrypt/ct/VerifierTest.java
index 5e317ec..987177b 100644
--- a/repackaged/common/src/test/java/com/android/org/conscrypt/ct/VerifierTest.java
+++ b/repackaged/common/src/test/java/com/android/org/conscrypt/ct/VerifierTest.java
@@ -60,7 +60,7 @@
                                     .setDescription("Test Log")
                                     .setUrl("http://example.com")
                                     .setOperator("LogOperator")
-                                    .setState(LogInfo.STATE_USABLE)
+                                    .setState(LogInfo.STATE_USABLE, 1643709600000L)
                                     .build();
         LogStore store = new LogStore() {
             @Override
diff --git a/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/LogStoreImpl.java b/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/LogStoreImpl.java
index 60aa161..4edb113 100644
--- a/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/LogStoreImpl.java
+++ b/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/LogStoreImpl.java
@@ -37,9 +37,13 @@
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Collections;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.logging.Level;
@@ -145,7 +149,10 @@
 
                     JSONObject stateObject = log.optJSONObject("state");
                     if (stateObject != null) {
-                        builder.setState(parseState(stateObject.keys().next()));
+                        String state = stateObject.keys().next();
+                        String stateTimestamp =
+                                stateObject.getJSONObject(state).getString("timestamp");
+                        builder.setState(parseState(state), parseStateTimestamp(stateTimestamp));
                     }
 
                     LogInfo logInfo = builder.build();
@@ -186,6 +193,19 @@
         }
     }
 
+    // ISO 8601
+    private static DateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX");
+
+    @SuppressWarnings("JavaUtilDate")
+    private static long parseStateTimestamp(String timestamp) {
+        try {
+            Date date = dateFormatter.parse(timestamp);
+            return date.getTime();
+        } catch (ParseException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
     private static PublicKey parsePubKey(String key) {
         byte[] pem = ("-----BEGIN PUBLIC KEY-----\n" + key + "\n-----END PUBLIC KEY-----")
                              .getBytes(US_ASCII);
diff --git a/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/PolicyImpl.java b/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/PolicyImpl.java
index 595a3ca..3f590d2 100644
--- a/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/PolicyImpl.java
+++ b/repackaged/platform/src/main/java/com/android/org/conscrypt/ct/PolicyImpl.java
@@ -20,7 +20,11 @@
 import com.android.org.conscrypt.Internal;
 
 import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
@@ -32,9 +36,22 @@
     @Override
     public PolicyCompliance doesResultConformToPolicy(
             VerificationResult result, X509Certificate leaf) {
+        long now = System.currentTimeMillis();
+        return doesResultConformToPolicyAt(result, leaf, now);
+    }
+
+    public PolicyCompliance doesResultConformToPolicyAt(
+            VerificationResult result, X509Certificate leaf, long atTime) {
+        List<VerifiedSCT> validSCTs = new ArrayList<VerifiedSCT>(result.getValidSCTs());
+        /* While the log list supports logs without a state, these entries are
+         * not supported by the log policy. Filter them out. */
+        filterOutUnknown(validSCTs);
+        /* Filter out any SCT issued after a log was retired */
+        filterOutAfterRetired(validSCTs);
+
         Set<VerifiedSCT> embeddedValidSCTs = new HashSet<>();
         Set<VerifiedSCT> ocspOrTLSValidSCTs = new HashSet<>();
-        for (VerifiedSCT vsct : result.getValidSCTs()) {
+        for (VerifiedSCT vsct : validSCTs) {
             if (vsct.getSct().getOrigin() == SignedCertificateTimestamp.Origin.EMBEDDED) {
                 embeddedValidSCTs.add(vsct);
             } else {
@@ -42,20 +59,61 @@
             }
         }
         if (embeddedValidSCTs.size() > 0) {
-            return conformEmbeddedSCTs(embeddedValidSCTs, leaf);
+            return conformEmbeddedSCTs(embeddedValidSCTs, leaf, atTime);
         }
         return PolicyCompliance.NOT_ENOUGH_SCTS;
     }
 
+    private void filterOutUnknown(List<VerifiedSCT> scts) {
+        Iterator<VerifiedSCT> it = scts.iterator();
+        while (it.hasNext()) {
+            VerifiedSCT vsct = it.next();
+            if (vsct.getLogInfo().getState() == LogInfo.STATE_UNKNOWN) {
+                it.remove();
+            }
+        }
+    }
+
+    private void filterOutAfterRetired(List<VerifiedSCT> scts) {
+        /* From the policy:
+         *
+         * In order to contribute to a certificate’s CT Compliance, an SCT must
+         * have been issued before the Log’s Retired timestamp, if one exists.
+         * Chrome uses the earliest SCT among all SCTs presented to evaluate CT
+         * compliance against CT Log Retired timestamps. This accounts for edge
+         * cases in which a CT Log becomes Retired during the process of
+         * submitting certificate logging requests.
+         */
+
+        if (scts.size() < 1) {
+            return;
+        }
+        long minTimestamp = scts.get(0).getSct().getTimestamp();
+        for (VerifiedSCT vsct : scts) {
+            long ts = vsct.getSct().getTimestamp();
+            if (ts < minTimestamp) {
+                minTimestamp = ts;
+            }
+        }
+        Iterator<VerifiedSCT> it = scts.iterator();
+        while (it.hasNext()) {
+            VerifiedSCT vsct = it.next();
+            if (vsct.getLogInfo().getState() == LogInfo.STATE_RETIRED
+                    && minTimestamp > vsct.getLogInfo().getStateTimestamp()) {
+                it.remove();
+            }
+        }
+    }
+
     private PolicyCompliance conformEmbeddedSCTs(
-            Set<VerifiedSCT> embeddedValidSCTs, X509Certificate leaf) {
+            Set<VerifiedSCT> embeddedValidSCTs, X509Certificate leaf, long atTime) {
         /* 1. At least one Embedded SCT from a CT Log that was Qualified,
          *    Usable, or ReadOnly at the time of check;
          */
         boolean found = false;
         for (VerifiedSCT vsct : embeddedValidSCTs) {
             LogInfo log = vsct.getLogInfo();
-            switch (log.getState()) {
+            switch (log.getStateAt(atTime)) {
                 case LogInfo.STATE_QUALIFIED:
                 case LogInfo.STATE_USABLE:
                 case LogInfo.STATE_READONLY:
@@ -85,7 +143,7 @@
         }
         for (VerifiedSCT vsct : embeddedValidSCTs) {
             LogInfo log = vsct.getLogInfo();
-            switch (log.getState()) {
+            switch (log.getStateAt(atTime)) {
                 case LogInfo.STATE_QUALIFIED:
                 case LogInfo.STATE_USABLE:
                 case LogInfo.STATE_READONLY:
diff --git a/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/LogStoreImplTest.java b/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/LogStoreImplTest.java
index 58d6b02..4b4387f 100644
--- a/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/LogStoreImplTest.java
+++ b/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/LogStoreImplTest.java
@@ -131,7 +131,7 @@
                         .setPublicKey(OpenSSLKey.fromPublicKeyPemInputStream(is).getPublicKey())
                         .setDescription("Operator 1 'Test2024' log")
                         .setUrl("https://operator1.example.com/logs/test2024/")
-                        .setState(LogInfo.STATE_USABLE)
+                        .setState(LogInfo.STATE_USABLE, 1667328840000L)
                         .setOperator("Operator 1")
                         .build();
         byte[] log1Id = Base64.getDecoder().decode("7s3QZNXbGs7FXLedtM0TojKHRny87N7DUUhZRnEftZs=");
diff --git a/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/PolicyImplTest.java b/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/PolicyImplTest.java
index 6ad28e2..2ce9c7d 100644
--- a/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/PolicyImplTest.java
+++ b/repackaged/platform/src/test/java/com/android/org/conscrypt/ct/PolicyImplTest.java
@@ -38,13 +38,27 @@
  */
 @RunWith(JUnit4.class)
 public class PolicyImplTest {
+    private static final String OPERATOR1 = "operator 1";
+    private static final String OPERATOR2 = "operator 2";
     private static LogInfo usableOp1Log1;
     private static LogInfo usableOp1Log2;
-    private static LogInfo retiredOp1Log;
+    private static LogInfo retiredOp1LogOld;
+    private static LogInfo retiredOp1LogNew;
     private static LogInfo usableOp2Log;
     private static LogInfo retiredOp2Log;
     private static SignedCertificateTimestamp embeddedSCT;
 
+    /* Some test dates. By default:
+     *  - The verification is occurring in January 2024;
+     *  - The SCTs were generated in January 2023; and
+     *  - The logs got into their state in January 2022.
+     * Other dates are used to exercise edge cases.
+     */
+    private static final long JAN2024 = 1704103200000L;
+    private static final long JUN2023 = 1672999200000L;
+    private static final long JAN2023 = 1672567200000L;
+    private static final long JAN2022 = 1641031200000L;
+
     private static class FakePublicKey implements PublicKey {
         static final long serialVersionUID = 1;
         final byte[] key;
@@ -78,56 +92,62 @@
         usableOp1Log1 = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x01}))
                                 .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_USABLE)
+                                .setOperator(OPERATOR1)
+                                .setState(LogInfo.STATE_USABLE, JAN2022)
                                 .build();
         usableOp1Log2 = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x02}))
                                 .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_USABLE)
+                                .setOperator(OPERATOR1)
+                                .setState(LogInfo.STATE_USABLE, JAN2022)
                                 .build();
-        retiredOp1Log = new LogInfo.Builder()
-                                .setPublicKey(new FakePublicKey(new byte[] {0x03}))
-                                .setUrl("")
-                                .setOperator("operator 1")
-                                .setState(LogInfo.STATE_RETIRED)
-                                .build();
+        retiredOp1LogOld = new LogInfo.Builder()
+                                   .setPublicKey(new FakePublicKey(new byte[] {0x03}))
+                                   .setUrl("")
+                                   .setOperator(OPERATOR1)
+                                   .setState(LogInfo.STATE_RETIRED, JAN2022)
+                                   .build();
+        retiredOp1LogNew = new LogInfo.Builder()
+                                   .setPublicKey(new FakePublicKey(new byte[] {0x06}))
+                                   .setUrl("")
+                                   .setOperator(OPERATOR1)
+                                   .setState(LogInfo.STATE_RETIRED, JUN2023)
+                                   .build();
         usableOp2Log = new LogInfo.Builder()
                                .setPublicKey(new FakePublicKey(new byte[] {0x04}))
                                .setUrl("")
-                               .setOperator("operator 2")
-                               .setState(LogInfo.STATE_USABLE)
+                               .setOperator(OPERATOR2)
+                               .setState(LogInfo.STATE_USABLE, JAN2022)
                                .build();
         retiredOp2Log = new LogInfo.Builder()
                                 .setPublicKey(new FakePublicKey(new byte[] {0x05}))
                                 .setUrl("")
-                                .setOperator("operator 2")
-                                .setState(LogInfo.STATE_RETIRED)
+                                .setOperator(OPERATOR2)
+                                .setState(LogInfo.STATE_RETIRED, JAN2022)
                                 .build();
-        /* Only the origin of the SCT is used during the evaluation for policy
-         * compliance. The signature is validated at the previous step (see
-         * the Verifier class).
+        /* The origin of the SCT and its timestamp are used during the
+         * evaluation for policy compliance. The signature is validated at the
+         * previous step (see the Verifier class).
          */
-        embeddedSCT = new SignedCertificateTimestamp(SignedCertificateTimestamp.Version.V1, null, 0,
-                null, null, SignedCertificateTimestamp.Origin.EMBEDDED);
+        embeddedSCT = new SignedCertificateTimestamp(SignedCertificateTimestamp.Version.V1, null,
+                JAN2023, null, null, SignedCertificateTimestamp.Origin.EMBEDDED);
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void emptyVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
         VerificationResult result = new VerificationResult();
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("An empty VerificationResult", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void validVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -145,17 +165,17 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two valid SCTs from different operators", PolicyCompliance.COMPLY,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void validWithRetiredVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
-                                    .setLogInfo(retiredOp1Log)
+                                    .setLogInfo(retiredOp1LogNew)
                                     .build();
 
         VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
@@ -169,13 +189,37 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("One valid, one retired SCTs from different operators",
-                PolicyCompliance.COMPLY, p.doesResultConformToPolicy(result, leaf));
+                PolicyCompliance.COMPLY, p.doesResultConformToPolicyAt(result, leaf, JAN2024));
+    }
+
+    @Test
+    public void invalidWithRetiredVerificationResult() throws Exception {
+        PolicyImpl p = new PolicyImpl();
+
+        VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
+                                    .setStatus(VerifiedSCT.Status.VALID)
+                                    .setLogInfo(retiredOp1LogOld)
+                                    .build();
+
+        VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
+                                    .setStatus(VerifiedSCT.Status.VALID)
+                                    .setLogInfo(usableOp2Log)
+                                    .build();
+
+        VerificationResult result = new VerificationResult();
+        result.add(vsct1);
+        result.add(vsct2);
+
+        X509Certificate leaf = new FakeX509Certificate();
+        assertEquals("One valid, one retired (before SCT timestamp) SCTs from different operators",
+                PolicyCompliance.NOT_ENOUGH_SCTS,
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidOneSctVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -187,17 +231,17 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("One valid SCT", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidTwoSctsVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
-                                    .setLogInfo(retiredOp1Log)
+                                    .setLogInfo(retiredOp1LogNew)
                                     .build();
 
         VerifiedSCT vsct2 = new VerifiedSCT.Builder(embeddedSCT)
@@ -211,13 +255,13 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two retired SCTs from different operators", PolicyCompliance.NOT_ENOUGH_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 
     @Test
     @NonCts(reason = NonCtsReasons.INTERNAL_APIS)
     public void invalidTwoSctsSameOperatorVerificationResult() throws Exception {
-        Policy p = new PolicyImpl();
+        PolicyImpl p = new PolicyImpl();
 
         VerifiedSCT vsct1 = new VerifiedSCT.Builder(embeddedSCT)
                                     .setStatus(VerifiedSCT.Status.VALID)
@@ -235,6 +279,6 @@
 
         X509Certificate leaf = new FakeX509Certificate();
         assertEquals("Two SCTs from the same operator", PolicyCompliance.NOT_ENOUGH_DIVERSE_SCTS,
-                p.doesResultConformToPolicy(result, leaf));
+                p.doesResultConformToPolicyAt(result, leaf, JAN2024));
     }
 }