diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 355cf968e..73b43dae9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
# test against latest update of each major Java version, as well as specific updates of LTS versions:
- java: [ 11, 17 ]
+ java: [ 11, 17, 21 ]
name: Java ${{ matrix.java }}
steps:
- uses: actions/checkout@v2
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e4b714fde..c846a94a0 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- java: [ 17 ]
+ java: [ 21 ]
steps:
- name: Setup java
diff --git a/README.md b/README.md
index efe6ef71b..647494922 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,9 @@ next (snapshot) release, e.g. `1.1-SNAPSHOT` after releasing `1.0`.
## Changelog
+## 2023-10-31 1.36
+ * Access the certificate for the generic signed object parser.
+
## 2023-10-03 1.35
* Build targets JDK 11
* Prefixes in ROAs are sorted by (prefix, maxlength - missing first)
diff --git a/pom.xml b/pom.xml
index 449370065..a817d3b17 100644
--- a/pom.xml
+++ b/pom.xml
@@ -47,11 +47,11 @@
1.52
1.74
- 32.0.0-jre
+ 32.1.2-jre
2.10.13
1.4.20
2.11.0
- 1.18.22
+ 1.18.30
@@ -138,7 +138,7 @@
org.mockito
mockito-core
- 4.2.0
+ 5.6.0
test
@@ -353,7 +353,7 @@
org.owasp
dependency-check-maven
- 6.5.0
+ 8.4.2
diff --git a/src/main/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParser.java b/src/main/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParser.java
index 68a70e68e..cd0372181 100644
--- a/src/main/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParser.java
+++ b/src/main/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParser.java
@@ -4,10 +4,12 @@
import net.ripe.rpki.commons.crypto.cms.ghostbuster.GhostbustersCms;
import net.ripe.rpki.commons.crypto.cms.manifest.ManifestCms;
import net.ripe.rpki.commons.crypto.cms.roa.RoaCms;
+import net.ripe.rpki.commons.crypto.x509cert.X509ResourceCertificate;
import net.ripe.rpki.commons.util.RepositoryObjectType;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.joda.time.DateTime;
+import java.security.cert.Certificate;
import java.util.Optional;
import static net.ripe.rpki.commons.util.RepositoryObjectType.*;
@@ -17,6 +19,15 @@ public DateTime getSigningTime() {
return super.getSigningTime();
}
+ /**
+ * Extend visibility of the certificate to make it public.
+ * @return the certificate.
+ */
+ @Override
+ public X509ResourceCertificate getCertificate() {
+ return super.getCertificate();
+ }
+
public Optional getRepositoryObjectType() {
final ASN1ObjectIdentifier contentType = getContentType();
if (AspaCms.CONTENT_TYPE.equals(contentType)) {
diff --git a/src/main/java/net/ripe/rpki/commons/crypto/util/SignedObjectUtil.java b/src/main/java/net/ripe/rpki/commons/crypto/util/SignedObjectUtil.java
new file mode 100644
index 000000000..9883d4004
--- /dev/null
+++ b/src/main/java/net/ripe/rpki/commons/crypto/util/SignedObjectUtil.java
@@ -0,0 +1,87 @@
+package net.ripe.rpki.commons.crypto.util;
+
+import lombok.Getter;
+import lombok.experimental.UtilityClass;
+import net.ripe.rpki.commons.crypto.cms.GenericRpkiSignedObjectParser;
+import net.ripe.rpki.commons.crypto.crl.X509Crl;
+import net.ripe.rpki.commons.crypto.x509cert.X509ResourceCertificateParser;
+import net.ripe.rpki.commons.util.RepositoryObjectType;
+import net.ripe.rpki.commons.validation.ValidationResult;
+import org.joda.time.Instant;
+
+import java.net.URI;
+
+@UtilityClass
+public class SignedObjectUtil {
+ /**
+ * Extract the creation time from an object. This does not yet follow the method described in
+ * https://datatracker.ietf.org/doc/draft-timbru-sidrops-publication-server-bcp/00/. It differs in that it uses
+ * the signing time for RPKI signed objects. This is a trade-off:
+ * * signing-time is more correct when multi-use EE certificates are present.
+ * * signing-time likely does not match the modification time of the CRL.
+ *
+ * This needs to be revisited in 2024.
+ *
+ * @param uri URL of the object
+ * @param decoded object bytes
+ * @return the file creation time of the object
+ * @throws NoTimeParsedException if creation time could not be extracted.
+ */
+ public static Instant getFileCreationTime(URI uri, byte[] decoded) throws NoTimeParsedException {
+
+ final RepositoryObjectType objectType = RepositoryObjectType.parse(uri.toString());
+ try {
+ switch (objectType) {
+ case Manifest:
+ case Aspa:
+ case Roa:
+ case Gbr:
+ var signedObjectParser = new GenericRpkiSignedObjectParser();
+
+ signedObjectParser.parse(ValidationResult.withLocation(uri), decoded);
+ var signingTime = signedObjectParser.getSigningTime();
+
+ if (signingTime == null) {
+ return signedObjectParser.getCertificate().getValidityPeriod().getNotValidBefore().toInstant();
+ }
+ return signingTime.toInstant();
+ case Certificate:
+ X509ResourceCertificateParser x509CertificateParser = new X509ResourceCertificateParser();
+ x509CertificateParser.parse(ValidationResult.withLocation(uri), decoded);
+ final var cert = x509CertificateParser.getCertificate().getCertificate();
+ return Instant.ofEpochMilli(cert.getNotBefore().getTime());
+ case Crl:
+ var x509Crl = X509Crl.parseDerEncoded(decoded, ValidationResult.withLocation(uri));
+ var crl = x509Crl.getCrl();
+ return Instant.ofEpochMilli(crl.getThisUpdate().getTime());
+ case Unknown:
+ default:
+ throw new NoTimeParsedException(decoded, uri, "Could not determine file type");
+ }
+ } catch (Exception e) {
+ if (e instanceof NoTimeParsedException) {
+ throw e;
+ }
+ throw new NoTimeParsedException(decoded, uri, "Could not parse object", e);
+ }
+ }
+
+ @Getter
+ public static class NoTimeParsedException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private byte[] decoded;
+ private URI uri;
+ public NoTimeParsedException(byte[] decoded, URI uri, String message) {
+ super(uri.toString() + ": " + message);
+ this.decoded = decoded;
+ this.uri = uri;
+ }
+
+ public NoTimeParsedException(byte[] decoded, URI uri, String message, Throwable cause) {
+ super(uri.toString() + ": " + message, cause);
+ this.decoded = decoded;
+ this.uri = uri;
+ }
+ }
+}
diff --git a/src/test/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParserTest.java b/src/test/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParserTest.java
index b425536d3..9f42d9aff 100644
--- a/src/test/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParserTest.java
+++ b/src/test/java/net/ripe/rpki/commons/crypto/cms/GenericRpkiSignedObjectParserTest.java
@@ -45,6 +45,18 @@ void should_parse_roa() throws IOException {
assertThat(parser.getSigningTime()).isEqualTo(DateTime.parse("2011-11-11T01:55:18+00:00"));
}
+ /**
+ * Parse an invalid object, but still extract validity period and signing time.
+ */
+ @Test
+ void should_parse_generic() throws IOException {
+ GenericRpkiSignedObjectParser parser = parse("interop/aspa/BAD-profile-13-AS211321-profile-13.asa");
+
+ assertThat(parser.getSigningTime()).isEqualTo(DateTime.parse("2021-11-11T11:19:00Z"));
+
+ assertThat(parser.getCertificate().getValidityPeriod().getNotValidBefore()).isEqualTo(DateTime.parse("2021-11-11T11:14:00Z"));
+ }
+
private GenericRpkiSignedObjectParser parse(String path) throws IOException {
byte[] bytes = Resources.toByteArray(Resources.getResource(path));
diff --git a/src/test/java/net/ripe/rpki/commons/util/SignedObjectUtilTest.java b/src/test/java/net/ripe/rpki/commons/util/SignedObjectUtilTest.java
new file mode 100644
index 000000000..611eeb826
--- /dev/null
+++ b/src/test/java/net/ripe/rpki/commons/util/SignedObjectUtilTest.java
@@ -0,0 +1,46 @@
+package net.ripe.rpki.commons.util;
+
+import com.google.common.io.Resources;
+import net.ripe.rpki.commons.crypto.util.SignedObjectUtil;
+import org.joda.time.DateTime;
+import org.joda.time.Instant;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.IOException;
+import java.net.URI;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class SignedObjectUtilTest {
+ @DisplayName("Should parse the file creation time from RPKI objects")
+ @ParameterizedTest(name = "{index} => {0} filename={1} expected-creation-time={3} path={2}")
+ @CsvSource({
+ "ASPA, sample.asa, interop/aspa/GOOD-profile-15-draft-ietf-sidrops-profile-15-sample.asa, 2023-06-07T09:08:41Z",
+ // GBR parser has issues
+ // "GBR, sample.gbr, conformance/root/goodRealGbrNothingIsWrong.gbr, 2023-06-07T09:01:01Z",
+ // router certificate case is missing due to lack of samples.
+ "Manifest, sample.mft, conformance/root/root.mft, 2013-10-28T21:24:39Z",
+ "ROA, sample.roa, interop/rpkid-objects/nI2bsx18I5mlex8lBpY0WSJUYio.roa, 2011-11-11T01:55:18Z",
+ "'Generic signed object (that does not match object profile)', generic-signed-object.gbr, interop/aspa/BAD-profile-13-AS211321-profile-13.asa, 2021-11-11T11:19:00Z",
+ })
+ void shouldParseObject(String description, String fileName, String path, String modified) throws IOException, SignedObjectUtil.NoTimeParsedException {
+ Instant creationTime = SignedObjectUtil.getFileCreationTime(URI.create(fileName), Resources.toByteArray(Resources.getResource(path)));
+
+ assertThat(creationTime).isEqualTo(DateTime.parse(modified));
+ }
+
+ @Test
+ void shouldThrowOnUnknown_payload() {
+ assertThatThrownBy(() -> SignedObjectUtil.getFileCreationTime(URI.create("foo.cer"), new byte[] {(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF}))
+ .isInstanceOf(SignedObjectUtil.NoTimeParsedException.class);
+ }
+ @Test
+ void shouldThrowOnUnknown_extension() {
+ assertThatThrownBy(() -> SignedObjectUtil.getFileCreationTime(URI.create("foo.xxx"), Resources.toByteArray(Resources.getResource("interop/aspa/BAD-profile-13-AS211321-profile-13.asa"))))
+ .isInstanceOf(SignedObjectUtil.NoTimeParsedException.class);
+ }
+}