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); + } +}