diff --git a/src/main/java/org/jruby/ext/openssl/ASN1.java b/src/main/java/org/jruby/ext/openssl/ASN1.java index 00f6a3da..2ab8b1c8 100644 --- a/src/main/java/org/jruby/ext/openssl/ASN1.java +++ b/src/main/java/org/jruby/ext/openssl/ASN1.java @@ -755,7 +755,7 @@ static ASN1ObjectIdentifier getObjectID(final Ruby runtime, final String nameOrO return new ASN1ObjectIdentifier( nameOrOid ); } - static ASN1ObjectIdentifier toObjectID(final String oid, final boolean silent) + private static ASN1ObjectIdentifier toObjectID(final String oid, final boolean silent) throws IllegalArgumentException { try { return new ASN1ObjectIdentifier(oid); @@ -766,6 +766,11 @@ static ASN1ObjectIdentifier toObjectID(final String oid, final boolean silent) } } + static String shortName(final Ruby runtime, final ASN1ObjectIdentifier oid) { + final String name = oid2name(runtime, oid, true); + return name == null ? oid.getId() : name; + } + @JRubyMethod(name="Boolean", module=true, rest=true) public static IRubyObject fact_Boolean(ThreadContext context, IRubyObject self, IRubyObject[] args) { return newInstance(context, self, "Boolean", args); @@ -1554,7 +1559,7 @@ boolean isEoc() { } public static RaiseException newASN1Error(Ruby runtime, String message) { - return newError(runtime, _ASN1(runtime).getClass("ASN1Error"), message, false); + return newError(runtime, _ASN1Error(runtime), message, false); } static RaiseException newASN1Error(Ruby runtime, Throwable ex) { @@ -1565,6 +1570,10 @@ static RubyModule _ASN1(final Ruby runtime) { return (RubyModule) runtime.getModule("OpenSSL").getConstant("ASN1"); } + static RubyClass _ASN1Error(final Ruby runtime) { + return _ASN1(runtime).getClass("ASN1Error"); + } + static org.bouncycastle.asn1.ASN1Primitive readObject(final byte[] bytes) throws IOException { return new ASN1InputStream(new ByteArrayInputStream(bytes)).readObject(); diff --git a/src/main/java/org/jruby/ext/openssl/OpenSSL.java b/src/main/java/org/jruby/ext/openssl/OpenSSL.java index 0972bcb4..2917f051 100644 --- a/src/main/java/org/jruby/ext/openssl/OpenSSL.java +++ b/src/main/java/org/jruby/ext/openssl/OpenSSL.java @@ -106,6 +106,7 @@ public static void createOpenSSL(final Ruby runtime) { PKCS5.createPKCS5(runtime, _OpenSSL); OCSP.createOCSP(runtime, _OpenSSL, OpenSSLError); KDF.createKDF(runtime, _OpenSSL, OpenSSLError); + Timestamp.createTimestamp(runtime, _OpenSSL, OpenSSLError); runtime.getLoadService().require("jopenssl/version"); diff --git a/src/main/java/org/jruby/ext/openssl/PKCS7.java b/src/main/java/org/jruby/ext/openssl/PKCS7.java index 6ba0898c..b6c8d428 100644 --- a/src/main/java/org/jruby/ext/openssl/PKCS7.java +++ b/src/main/java/org/jruby/ext/openssl/PKCS7.java @@ -314,6 +314,7 @@ public IRubyObject initialize(final ThreadContext context, IRubyObject[] args) { } return this; } + IRubyObject arg = to_der_if_possible(context, args[0]); BIO input = obj2bio(context, arg); try { diff --git a/src/main/java/org/jruby/ext/openssl/Timestamp.java b/src/main/java/org/jruby/ext/openssl/Timestamp.java new file mode 100644 index 00000000..f5c152d5 --- /dev/null +++ b/src/main/java/org/jruby/ext/openssl/Timestamp.java @@ -0,0 +1,823 @@ +/* + * The MIT License + * + * Copyright (C) 2026 Karol Bucek + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jruby.ext.openssl; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.Provider; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.bouncycastle.asn1.ASN1Boolean; +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.DERTaggedObject; +import org.bouncycastle.asn1.cmp.PKIFailureInfo; +import org.bouncycastle.asn1.tsp.MessageImprint; +import org.bouncycastle.asn1.tsp.TimeStampReq; +import org.bouncycastle.asn1.tsp.TimeStampResp; +import org.bouncycastle.asn1.tsp.TSTInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.SignerInfoGenerator; +import org.bouncycastle.cms.SignerInformationVerifier; +import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoGeneratorBuilder; +import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; +import org.bouncycastle.tsp.TimeStampRequest; +import org.bouncycastle.tsp.TimeStampResponse; +import org.bouncycastle.tsp.TimeStampResponseGenerator; +import org.bouncycastle.tsp.TimeStampToken; +import org.bouncycastle.tsp.TimeStampTokenGenerator; +import org.bouncycastle.tsp.TSPAlgorithms; +import org.bouncycastle.tsp.TSPValidationException; +import org.bouncycastle.operator.DigestCalculator; +import org.bouncycastle.operator.DigestCalculatorProvider; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.util.encoders.Hex; + +import org.jruby.Ruby; +import org.jruby.RubyArray; +import org.jruby.RubyClass; +import org.jruby.RubyModule; +import org.jruby.RubyNumeric; +import org.jruby.RubyObject; +import org.jruby.RubyString; +import org.jruby.RubyTime; +import org.jruby.anno.JRubyMethod; +import org.jruby.exceptions.RaiseException; +import org.jruby.ext.openssl.log.Logger; +import org.jruby.ext.openssl.util.RubySupport; +import org.jruby.ext.openssl.x509store.X509AuxCertificate; +import org.jruby.ext.openssl.x509store.Purpose; +import org.jruby.runtime.Arity; +import org.jruby.runtime.Block; +import org.jruby.runtime.ThreadContext; +import org.jruby.runtime.Visibility; +import org.jruby.runtime.builtin.IRubyObject; + +import static org.jruby.ext.openssl.ASN1.newASN1Error; +import static org.jruby.ext.openssl.PKCS7._PKCS7; +import static org.jruby.ext.openssl.X509._X509; +import static org.jruby.ext.openssl.x509store.X509Utils.X509_PURPOSE_TIMESTAMP_SIGN; +import static org.jruby.ext.openssl.util.RubySupport.newError; + +public final class Timestamp { + private static final Logger LOG = Logger.getLogger(Timestamp.class); + + private Timestamp() { } + + static void createTimestamp(final Ruby runtime, final RubyModule OpenSSL, final RubyClass OpenSSLError) { + final RubyModule timestamp = OpenSSL.defineModuleUnder("Timestamp"); + timestamp.defineClassUnder("TimestampError", OpenSSLError, OpenSSLError.getAllocator()); + + final RubyClass request = timestamp.defineClassUnder("Request", runtime.getObject(), Request::new); + request.defineAnnotatedMethods(Request.class); + + final RubyClass response = timestamp.defineClassUnder("Response", runtime.getObject(), Response::new); + response.defineAnnotatedMethods(Response.class); + + response.setConstant("GRANTED", runtime.newFixnum(0)); + response.setConstant("GRANTED_WITH_MODS", runtime.newFixnum(1)); + response.setConstant("REJECTION", runtime.newFixnum(2)); + response.setConstant("WAITING", runtime.newFixnum(3)); + response.setConstant("REVOCATION_WARNING", runtime.newFixnum(4)); + response.setConstant("REVOCATION_NOTIFICATION", runtime.newFixnum(5)); + + final RubyClass tokenInfo = timestamp.defineClassUnder("TokenInfo", runtime.getObject(), TokenInfo::new); + tokenInfo.defineAnnotatedMethods(TokenInfo.class); + + final RubyClass factory = timestamp.defineClassUnder("Factory", runtime.getObject(), Factory::new); + factory.defineAnnotatedMethods(Factory.class); + } + + static RubyModule _Timestamp(final Ruby runtime) { + return (RubyModule) runtime.getModule("OpenSSL").getConstantAt("Timestamp"); + } + + static RubyClass _TimestampError(final Ruby runtime) { + return _Timestamp(runtime).getClass("TimestampError"); + } + + static RaiseException newTimestampError(final Ruby runtime, final Throwable cause) { + return newError(runtime, _TimestampError(runtime), cause); + } + + static RaiseException newTimestampError(final Ruby runtime, final String message) { + return newError(runtime, _TimestampError(runtime), message); + } + + static ASN1ObjectIdentifier oid(final Ruby runtime, final IRubyObject value) { + try { + return ASN1.getObjectID(runtime, value.convertToString().asJavaString()); + } catch (IllegalArgumentException e) { + throw newASN1Error(runtime, e); + } + } + + public static final class Request extends RubyObject { + + private TimeStampReq timeStampReq; + private int version = 1; + private ASN1ObjectIdentifier algorithm; + private byte[] messageImprint; + private ASN1ObjectIdentifier policyId; + private BigInteger nonce; + private boolean certRequested = true; + private Extensions extensions; + + Request(final Ruby runtime, final RubyClass type) { super(runtime, type); } + + @JRubyMethod(name = "initialize", rest = true, visibility = Visibility.PRIVATE) + public IRubyObject initialize(final ThreadContext context, final IRubyObject[] args) { + if (Arity.checkArgumentCount(context.runtime, args, 0, 1) == 0) return this; + + final RubyString input = StringHelper.readPossibleDERInput(context, args[0]); + try { + timeStampReq = TimeStampReq.getInstance(ASN1Primitive.fromByteArray(input.getBytes())); + version = timeStampReq.getVersion().intValueExact(); + final MessageImprint imprint = timeStampReq.getMessageImprint(); + algorithm = imprint.getHashAlgorithm().getAlgorithm(); + messageImprint = imprint.getHashedMessage(); + policyId = timeStampReq.getReqPolicy(); + nonce = timeStampReq.getNonce() == null ? null : timeStampReq.getNonce().getValue(); + certRequested = timeStampReq.getCertReq() != null && timeStampReq.getCertReq().isTrue(); + extensions = timeStampReq.getExtensions(); + return this; + } catch (IOException e) { + LOG.debug(context.runtime, "timestamp request decode failed", e); + throw newTimestampError(context.runtime, "Error when decoding the timestamp request: " + e.getMessage()); + } catch (RuntimeException e) { + LOG.debug(context.runtime, "timestamp request decode failed", e); + throw newTimestampError(context.runtime, e); + } + } + + TimeStampReq timeStampReq() { + if (timeStampReq != null) return timeStampReq; + + if (algorithm == null) { + throw newTimestampError(getRuntime(), "Message imprint missing algorithm"); + } + if (messageImprint == null || messageImprint.length == 0) { + throw newTimestampError(getRuntime(), "Message imprint missing hashed message"); + } + + final MessageImprint imprint = new MessageImprint(new AlgorithmIdentifier(algorithm), messageImprint); + final TimeStampReq result; + if (version == 1) { + result = new TimeStampReq(imprint, policyId, + nonce == null ? null : new ASN1Integer(nonce), + certRequested ? ASN1Boolean.TRUE : null, extensions + ); + } + else { + final ASN1EncodableVector values = new ASN1EncodableVector(); + values.add(new ASN1Integer(BigInteger.valueOf(version))); + values.add(imprint); + if (policyId != null) values.add(policyId); + if (nonce != null) values.add(new ASN1Integer(nonce)); + if (certRequested) values.add(ASN1Boolean.TRUE); + if (extensions != null) values.add(new DERTaggedObject(false, 0, extensions)); + result = TimeStampReq.getInstance(new DERSequence(values)); + } + timeStampReq = result; + return result; + } + + TimeStampRequest asn1RequestObject() throws IOException { + return new TimeStampRequest(timeStampReq().getEncoded()); + } + + private void markChanged() { timeStampReq = null; } + + @JRubyMethod + public IRubyObject algorithm(ThreadContext context) { + if (algorithm == null) return context.runtime.newString("NULL"); + return context.runtime.newString(ASN1.shortName(context.runtime, algorithm)); + } + + @JRubyMethod(name = "algorithm=") + public IRubyObject set_algorithm(final IRubyObject value) { + algorithm = oid(getRuntime(), value); + markChanged(); + return value; + } + + @JRubyMethod(name = "cert_requested?") + public IRubyObject cert_requested_p() { + return getRuntime().newBoolean(certRequested); + } + + @JRubyMethod(name = "cert_requested=") + public IRubyObject set_cert_requested(final IRubyObject value) { + certRequested = value.isTrue(); + markChanged(); + return value; + } + + @JRubyMethod + public IRubyObject message_imprint(ThreadContext context) { + if (messageImprint == null) return context.runtime.newString(""); + return RubyString.newString(context.runtime, messageImprint); + } + + @JRubyMethod(name = "message_imprint=") + public IRubyObject set_message_imprint(final IRubyObject value) { + messageImprint = value.convertToString().getBytes(); + markChanged(); + return value; + } + + @JRubyMethod + public IRubyObject nonce(ThreadContext context) { + if (nonce == null) return context.nil; + return BN.newBN(context.runtime, nonce); + } + + @JRubyMethod(name = "nonce=") + public IRubyObject set_nonce(final IRubyObject value) { + if (value.isNil()) throw getRuntime().newTypeError("can't convert nil into Integer"); + nonce = BN.asBigInteger(value); + markChanged(); + return value; + } + + @JRubyMethod + public IRubyObject policy_id(ThreadContext context) { + final Ruby runtime = context.runtime; + return policyId == null ? context.nil : runtime.newString(ASN1.shortName(runtime, policyId)); + } + + @JRubyMethod(name = "policy_id=") + public IRubyObject set_policy_id(final IRubyObject value) { + if (value.isNil()) throw getRuntime().newTypeError(value, "String"); + policyId = oid(getRuntime(), value); + markChanged(); + return value; + } + + @JRubyMethod + public IRubyObject to_der(ThreadContext context) { + try { + return RubySupport.newString(context.runtime, timeStampReq().getEncoded()); + } catch (Exception e) { + throw newTimestampError(getRuntime(), e); + } + } + + @JRubyMethod(name = "to_text") + public IRubyObject to_text() { + final StringBuilder text = new StringBuilder(); + text.append("Version: ").append(version).append('\n'); + text.append("Hash Algorithm: ").append(algorithm == null ? "(none)" : ASN1.shortName(getRuntime(), algorithm)).append('\n'); + if (policyId != null) text.append("Policy OID: ").append(policyId.getId()).append('\n'); + if (nonce != null) text.append("Nonce: ").append(nonce).append('\n'); + text.append("Certificate required: ").append(certRequested).append('\n'); + return getRuntime().newString(text.toString()); + } + + @JRubyMethod + public IRubyObject version() { return getRuntime().newFixnum(version); } + + @JRubyMethod(name = "version=") + public IRubyObject set_version(final IRubyObject value) { + final int v = RubyNumeric.num2int(value); + if (v < 0) throw newTimestampError(getRuntime(), "version must be >= 0!"); + version = v; + markChanged(); + return value; + } + } + + public static final class Response extends RubyObject { + + private TimeStampResponse response; + + Response(final Ruby runtime, final RubyClass type) { super(runtime, type); } + + Response(final Ruby runtime, TimeStampResponse response) { + this(runtime, (RubyClass) _Timestamp(runtime).getConstantAt("Response")); + this.response = response; + } + + @JRubyMethod(name = "initialize", required = 1, visibility = Visibility.PRIVATE) + public IRubyObject initialize(final ThreadContext context, final IRubyObject value) { + final RubyString input = StringHelper.readPossibleDERInput(context, value); + try { + response = new TimeStampResponse(TimeStampResp.getInstance(ASN1Primitive.fromByteArray(input.getBytes()))); + return this; + } catch (Exception e) { + LOG.debug(context.runtime, "response decoding failed", e); + throw newTimestampError(context.runtime, "Error when decoding the timestamp response: " + e.getMessage()); + } + } + + @JRubyMethod + public IRubyObject failure_info() { + if (response.getFailInfo() == null) return getRuntime().getNil(); + final int bits = response.getFailInfo().intValue(); + if ((bits & PKIFailureInfo.badAlg) != 0) return getRuntime().newSymbol("BAD_ALG"); + if ((bits & PKIFailureInfo.badRequest) != 0) return getRuntime().newSymbol("BAD_REQUEST"); + if ((bits & PKIFailureInfo.badDataFormat) != 0) return getRuntime().newSymbol("BAD_DATA_FORMAT"); + if ((bits & PKIFailureInfo.timeNotAvailable) != 0) return getRuntime().newSymbol("TIME_NOT_AVAILABLE"); + if ((bits & PKIFailureInfo.unacceptedPolicy) != 0) return getRuntime().newSymbol("UNACCEPTED_POLICY"); + if ((bits & PKIFailureInfo.unacceptedExtension) != 0) return getRuntime().newSymbol("UNACCEPTED_EXTENSION"); + if ((bits & PKIFailureInfo.addInfoNotAvailable) != 0) return getRuntime().newSymbol("ADD_INFO_NOT_AVAILABLE"); + if ((bits & PKIFailureInfo.systemFailure) != 0) return getRuntime().newSymbol("SYSTEM_FAILURE"); + throw newTimestampError(getRuntime(), "Unrecognized failure info"); + } + + @JRubyMethod + public IRubyObject status(ThreadContext context) { + return BN.newBN(context.runtime, BigInteger.valueOf(response.getStatus())); + } + + @JRubyMethod + public IRubyObject status_text(ThreadContext context) { + final Ruby runtime = context.runtime; + try { + final TimeStampResp encoded = TimeStampResp.getInstance(ASN1Primitive.fromByteArray(response.getEncoded())); + final RubyArray result = runtime.newArray(); + if (encoded.getStatus().getStatusString() != null) { + final ASN1Sequence strings = (ASN1Sequence) encoded.getStatus().getStatusString().toASN1Primitive(); + for (int i = 0; i < strings.size(); i++) { + result.append(runtime.newString(((ASN1String) strings.getObjectAt(i)).getString())); + } + } + return result; + } catch (IOException e) { + throw newTimestampError(runtime, e); + } + } + + @JRubyMethod + public IRubyObject to_der(ThreadContext context) { + try { + return RubySupport.newString(context.runtime, response.getEncoded()); + } catch (Exception e) { + throw newTimestampError(context.runtime, e); + } + } + + @JRubyMethod(name = "to_text") + public IRubyObject to_text() { + final StringBuilder text = new StringBuilder(); + text.append("Status: ").append(response.getStatus()).append('\n'); + if (response.getStatusString() != null) text.append(response.getStatusString()).append('\n'); + final TimeStampToken token = response.getTimeStampToken(); + if (token != null) { + final TokenInfo info = new TokenInfo(getRuntime(), (RubyClass) _Timestamp(getRuntime()).getConstantAt("TokenInfo")); + info.info = token.getTimeStampInfo().toASN1Structure(); + text.append(info.text()); + } + return RubyString.newString(getRuntime(), text); + } + + @JRubyMethod + public IRubyObject token(ThreadContext context) { + final TimeStampToken token = response.getTimeStampToken(); + if (token == null) return context.nil; + try { + final byte[] tokenData = token.toCMSSignedData().getEncoded(); + return _PKCS7(context.runtime).newInstance(context, // OpenSSL::PKCS7.new + RubySupport.newString(context.runtime, tokenData), + Block.NULL_BLOCK + ); + } + catch (Exception e) { throw newTimestampError(context.runtime, e); } + } + + @JRubyMethod + public IRubyObject token_info(ThreadContext context) { + final TimeStampToken token = response.getTimeStampToken(); + if (token == null) return context.nil; + final Ruby runtime = context.runtime; + try { + final TokenInfo result = new TokenInfo(runtime, (RubyClass) _Timestamp(runtime).getConstantAt("TokenInfo")); + result.info = token.getTimeStampInfo().toASN1Structure(); + return result; + } + catch (Exception e) { throw newTimestampError(runtime, e); } + } + + @JRubyMethod + public IRubyObject tsa_certificate(final ThreadContext context) { + final X509CertificateHolder holder = signerCertificate(); + if (holder == null) return context.nil; + try { return X509Cert.wrap(context, holder.getEncoded()); } + catch (Exception e) { throw newTimestampError(context.runtime, e); } + } + + private X509CertificateHolder signerCertificate() { + final TimeStampToken token = response.getTimeStampToken(); + if (token == null) return null; + final Collection matches = token.getCertificates().getMatches(token.getSID()); + return matches.isEmpty() ? null : (X509CertificateHolder) matches.iterator().next(); + } + + @JRubyMethod(name = "verify", required = 2, optional = 1) + public IRubyObject verify(final ThreadContext context, final IRubyObject[] args) { + if (!(args[0] instanceof Request)) throw context.runtime.newTypeError(args[0], "OpenSSL::Timestamp::Request"); + if (!(args[1] instanceof X509Store)) throw context.runtime.newTypeError(args[1], "OpenSSL::X509::Store"); + final Request request = (Request) args[0]; + final X509Store store = (X509Store) args[1]; + final IRubyObject intermediates = args.length == 3 ? args[2] : context.nil; + + final RubyArray chain = context.runtime.newArray(); + if (!intermediates.isNil()) { + if (!(intermediates instanceof RubyArray)) throw context.runtime.newTypeError(intermediates, "Array"); + final RubyArray certs = (RubyArray) intermediates; + for (int i = 0; i < certs.size(); i++) { + final IRubyObject cert = certs.eltInternal(i); + if (!(cert instanceof X509Cert)) throw context.runtime.newTypeError(cert, "OpenSSL::X509::Certificate"); + chain.append(cert); + } + } + try { + final TimeStampToken token = response.getTimeStampToken(); + if (token == null) throw newTimestampError(context.runtime, "timestamp response contains no token"); + response.validate(request.asn1RequestObject()); + if (token.getTimeStampInfo().toASN1Structure().getVersion().intValueExact() != 1) { + throw newTimestampError(context.runtime, "unsupported timestamp token version"); + } + + for (Object entry : token.getCertificates().getMatches(null)) { + chain.append(X509Cert.wrap(context, ((X509CertificateHolder) entry).getEncoded())); + } + X509Cert tsa = null; + X509CertificateHolder signer = null; + for (int i = 0; i < chain.size(); i++) { + final X509Cert candidate = (X509Cert) chain.eltInternal(i); + final X509CertificateHolder holder = new X509CertificateHolder(candidate.getCert().getEncoded()); + if (token.getSID().match(holder)) { + tsa = candidate; + signer = holder; + break; + } + } + if (signer == null) throw newTimestampError(context.runtime, "timestamp response contains no signer certificate"); + if (!matchesTsaName(token, signer)) { + throw newTimestampError(context.runtime, "timestamp authority name does not match signer certificate"); + } + final Provider provider = SecurityHelper.getSecurityProvider(); + final JcaSimpleSignerInfoVerifierBuilder verifierBuilder = new JcaSimpleSignerInfoVerifierBuilder(); + if (provider != null) verifierBuilder.setProvider(provider); + final SignerInformationVerifier verifier = verifierBuilder.build(signer); + token.validate(verifier); + + final X509StoreContext storeContext = X509StoreContext.newStoreContext(context, store, tsa, chain); + storeContext.set_purpose(context, context.runtime.newFixnum(X509_PURPOSE_TIMESTAMP_SIGN)); + if (!storeContext.verify(context).isTrue()) { + throw newTimestampError(context.runtime, "timestamp certificate chain validation failed: " + + storeContext.error_string(context).asJavaString()); + } + return this; + } + catch (RaiseException e) { throw e; } + catch (Exception e) { + LOG.debug(context.runtime, "response verification failed", e); + throw newTimestampError(context.runtime, e); + } + } + + private boolean matchesTsaName(final TimeStampToken token, final X509CertificateHolder signer) { + final GeneralName tsa = token.getTimeStampInfo().toASN1Structure().getTsa(); + if (tsa == null) return true; + if (tsa.getTagNo() == GeneralName.directoryName) { + return X500Name.getInstance(tsa.getName()).equals(signer.getSubject()); + } + + final Extension subjectAltName = signer.getExtension(Extension.subjectAlternativeName); + if (subjectAltName == null) return false; + for (GeneralName name : GeneralNames.getInstance(subjectAltName.getParsedValue()).getNames()) { + if (tsa.equals(name)) return true; + } + return false; + } + + } + + public static final class TokenInfo extends RubyObject { + + private TSTInfo info; + + TokenInfo(final Ruby runtime, final RubyClass type) { super(runtime, type); } + + @JRubyMethod(name = "initialize", required = 1, visibility = Visibility.PRIVATE) + public IRubyObject initialize(final ThreadContext context, final IRubyObject value) { + final RubyString input = StringHelper.readPossibleDERInput(context, value); + try { + info = TSTInfo.getInstance(ASN1Primitive.fromByteArray(input.getBytes())); + if (info == null) throw new IOException("empty timestamp token info"); + return this; + } catch (Exception e) { + LOG.debug(context.runtime, "token info decode failed", e); + throw newTimestampError(context.runtime, "Error when decoding the timestamp token info: " + e.getMessage()); + } + } + + @JRubyMethod + public IRubyObject algorithm() { + AlgorithmIdentifier algId = info.getMessageImprint().getHashAlgorithm(); + return getRuntime().newString(ASN1.shortName(getRuntime(), algId.getAlgorithm())); + } + + @JRubyMethod + public IRubyObject gen_time(ThreadContext context) { + try { + final Date genTime = info.getGenTime().getDate(); + return genTime == null ? context.nil : RubyTime.newTime(context.runtime, genTime.getTime()).gmtime(); + } catch (Exception e) { // ParseException + throw newTimestampError(context.runtime, e); + } + } + + @JRubyMethod(name = "message_imprint", alias = "msg_imprint") + public IRubyObject message_imprint() { return RubyString.newString(getRuntime(), info.getMessageImprint().getHashedMessage()); } + + @JRubyMethod + public IRubyObject nonce(ThreadContext context) { + if (info.getNonce() == null) return context.nil; + return BN.newBN(context.runtime, info.getNonce().getValue()); + } + + @JRubyMethod + public IRubyObject ordering() { + return getRuntime().newBoolean(info.getOrdering() != null && info.getOrdering().isTrue()); + } + + @JRubyMethod + public IRubyObject policy_id() { + return getRuntime().newString(ASN1.shortName(getRuntime(), info.getPolicy())); + } + + @JRubyMethod + public IRubyObject serial_number() { + return BN.newBN(getRuntime(), info.getSerialNumber().getValue()); + } + + @JRubyMethod + public IRubyObject to_der(ThreadContext context) { + try { + return RubySupport.newString(context.runtime, info.getEncoded()); + } catch (IOException e) { + throw newTimestampError(context.runtime, e); + } + } + + @JRubyMethod(name = "to_text") + public RubyString to_text() { + return getRuntime().newString(text()); + } + + private String text() { + final StringBuilder text = new StringBuilder(); + text.append("Version: ").append(info.getVersion().getValue()).append('\n'); + text.append("Policy OID: ").append(info.getPolicy().getId()).append('\n'); + text.append("Hash Algorithm: ").append(ASN1.shortName(getRuntime(), info.getMessageImprint().getHashAlgorithm().getAlgorithm())).append('\n'); + text.append("Message data: ").append(Hex.toHexString(info.getMessageImprint().getHashedMessage())).append('\n'); + text.append("Serial number: ").append(info.getSerialNumber().getValue()).append('\n'); + text.append("Time stamp: ").append(info.getGenTime().getTimeString()).append('\n'); + return text.toString(); + } + + @JRubyMethod + public IRubyObject version() { + return getRuntime().newFixnum(info.getVersion().intValueExact()); + } + } + + public static final class Factory extends RubyObject { + Factory(final Ruby runtime, final RubyClass type) { + super(runtime, type); + } + + @JRubyMethod(visibility = Visibility.PRIVATE) + public IRubyObject initialize() { return this; } + + @JRubyMethod + public IRubyObject default_policy_id() { + return attribute("@default_policy_id"); + } + + @JRubyMethod(name = "default_policy_id=") + public IRubyObject set_default_policy_id(final IRubyObject value) { + setInstanceVariable("@default_policy_id", value); + return value; + } + + @JRubyMethod + public IRubyObject serial_number() { + return attribute("@serial_number"); + } + + @JRubyMethod(name = "serial_number=") + public IRubyObject set_serial_number(final IRubyObject value) { + setInstanceVariable("@serial_number", value); + return value; + } + + @JRubyMethod + public IRubyObject gen_time() { + return attribute("@gen_time"); + } + + @JRubyMethod(name = "gen_time=") + public IRubyObject set_gen_time(final IRubyObject value) { + setInstanceVariable("@gen_time", value); + return value; + } + + @JRubyMethod + public IRubyObject additional_certs() { + return attribute("@additional_certs"); + } + + @JRubyMethod(name = "additional_certs=") + public IRubyObject set_additional_certs(final IRubyObject value) { + setInstanceVariable("@additional_certs", value); + return value; + } + + @JRubyMethod + public IRubyObject allowed_digests() { + return attribute("@allowed_digests"); + } + + @JRubyMethod(name = "allowed_digests=") + public IRubyObject set_allowed_digests(final IRubyObject value) { + setInstanceVariable("@allowed_digests", value); + return value; + } + + private IRubyObject attribute(final String name) { + final IRubyObject value = getInstanceVariable(name); + return value == null ? getRuntime().getNil() : value; + } + + @JRubyMethod + public IRubyObject create_timestamp(final ThreadContext context, final IRubyObject key, + final IRubyObject certificate, final IRubyObject request) { + final Ruby runtime = context.runtime; + if (!(key instanceof PKey) || !(certificate instanceof X509Cert) || !(request instanceof Request)) { + throw runtime.newTypeError("expected OpenSSL::PKey, OpenSSL::X509::Certificate, OpenSSL::Timestamp::Request"); + } + + final PKey pkey = (PKey) key; + final X509Cert cert = (X509Cert) certificate; + final Request req = (Request) request; + final IRubyObject serialNumber = serial_number(); + final IRubyObject genTime = gen_time(); + final IRubyObject defaultPolicy = default_policy_id(); + final IRubyObject additionalCerts = additional_certs(); + final IRubyObject allowedDigests = allowed_digests(); + + final X509AuxCertificate auxCert = cert.getAuxCert(); + if (!isTimestampingCertificate(auxCert)) { + throw newTimestampError(runtime, "Certificate does not contain the timestamping extension"); + } + if (serialNumber == null || serialNumber.isNil()) throw newTimestampError(runtime, "@serial_number must be set"); + if (genTime == null || genTime.isNil()) throw newTimestampError(runtime, "@gen_time must be set"); + + if (req.policyId == null && (defaultPolicy == null || defaultPolicy.isNil())) { + throw newTimestampError(runtime, "No policy id in the request and no default policy set"); + } + + try { + final Provider provider = SecurityHelper.getSecurityProvider(); + if (pkey.getPrivateKey() == null) throw newTimestampError(runtime, "private key is required"); + + final String signatureAlgorithm = signatureAlgorithm("SHA256", pkey.getAlgorithm()); + final JcaSimpleSignerInfoGeneratorBuilder signerBuilder = new JcaSimpleSignerInfoGeneratorBuilder(); + if (provider != null) signerBuilder.setProvider(provider); + final X509CertificateHolder certHolder = new X509CertificateHolder(auxCert.getEncoded()); + final SignerInfoGenerator signer = signerBuilder.build(signatureAlgorithm, pkey.getPrivateKey(), certHolder); + + final JcaDigestCalculatorProviderBuilder digestBuilder = new JcaDigestCalculatorProviderBuilder(); + if (provider != null) digestBuilder.setProvider(provider); + final DigestCalculatorProvider digestProvider = digestBuilder.build(); + final DigestCalculator digestCalculator = digestProvider.get(new AlgorithmIdentifier(TSPAlgorithms.SHA256)); + final ASN1ObjectIdentifier policy = req.policyId != null ? req.policyId : oid(runtime, defaultPolicy); + final TimeStampTokenGenerator tokenGenerator = new TimeStampTokenGenerator(signer, digestCalculator, policy); + if (req.certRequested) { + tokenGenerator.addCertificates(new JcaCertStore(Collections.singletonList(auxCert))); + addAdditionalCertificates(runtime, tokenGenerator, additionalCerts); + } + + final TimeStampRequest timestampRequest = req.asn1RequestObject(); + final BigInteger serial = BN.asBigInteger(serialNumber); + final Date timeValue = RubySupport.timeToJavaDate(context, genTime); + final Date time = new Date(Math.floorDiv(timeValue.getTime(), 1000L) * 1000L); + final TimeStampResponseGenerator generator = new TimeStampResponseGenerator( + tokenGenerator, + acceptedAlgorithms(runtime, allowedDigests), + null, + Collections.emptySet() + ); + + TimeStampResponse response; + try { + if (timestampRequest.getVersion() != 1) { + throw new TSPValidationException("unsupported request version", PKIFailureInfo.badRequest); + } + if (!cert.check_private_key(key).isTrue()) { + response = generator.generateFailResponse(2, 0, "Error during signature generation."); + } else { + response = generator.generateGrantedResponse(timestampRequest, serial, time); + } + } catch (TSPValidationException e) { + response = generator.generateRejectedResponse(e); + } + + return new Response(runtime, response); + } catch (RaiseException e) { + throw e; + } catch (Exception e) { + LOG.debugStack(runtime, "timestamp creation failed", e); + throw newTimestampError(runtime, e); + } + } + + private void addAdditionalCertificates(final Ruby runtime, + final TimeStampTokenGenerator generator, + final IRubyObject additionalCerts) throws CertificateEncodingException { + if (!(additionalCerts instanceof RubyArray)) return; + + final RubyArray certs = additionalCerts.convertToArray(); + final List values = new ArrayList<>(certs.size()); + for (int i = 0; i < certs.size(); i++) { + if (!(certs.eltInternal(i) instanceof X509Cert)) { + throw runtime.newTypeError(certs.eltInternal(i), _X509(runtime).getClass("Certificate")); + } + values.add(((X509Cert) certs.eltInternal(i)).getAuxCert()); + } + generator.addCertificates(new JcaCertStore(values)); + } + + private boolean isTimestampingCertificate(final X509AuxCertificate certificate) { + try { + return Purpose.checkPurpose(certificate, X509_PURPOSE_TIMESTAMP_SIGN, 0) == 1; + } catch (CertificateException e) { + LOG.debug(getRuntime(), "timestamp certificate purpose check failed", e); + throw newTimestampError(getRuntime(), e); + } + } + + private Set acceptedAlgorithms(final Ruby runtime, final IRubyObject allowedDigests) { + final Set result = new HashSet<>(); + if (!(allowedDigests instanceof RubyArray)) return result; + final RubyArray values = (RubyArray) allowedDigests; + for (int i = 0; i < values.size(); i++) { + final IRubyObject value = values.eltInternal(i); + final String name = value instanceof Digest ? ((Digest) value).getShortAlgorithm() : value.convertToString().asJavaString(); + result.add(oid(runtime, runtime.newString(name))); + } + return result; + } + + private static String signatureAlgorithm(final String digest, final String keyAlgorithm) { + final String key = "EC".equalsIgnoreCase(keyAlgorithm) ? "ECDSA" : keyAlgorithm; + return digest + "WITH" + key; + } + } +} diff --git a/src/main/java/org/jruby/ext/openssl/X509.java b/src/main/java/org/jruby/ext/openssl/X509.java index c3cba4fd..53f0e399 100644 --- a/src/main/java/org/jruby/ext/openssl/X509.java +++ b/src/main/java/org/jruby/ext/openssl/X509.java @@ -149,6 +149,7 @@ static void createX509(final Ruby runtime, final RubyModule _OpenSSL, final Ruby _X509.setConstant("PURPOSE_CRL_SIGN",_6); _X509.setConstant("PURPOSE_ANY",_7); _X509.setConstant("PURPOSE_OCSP_HELPER",_8); + _X509.setConstant("PURPOSE_TIMESTAMP_SIGN",runtime.newFixnum(X509Utils.X509_PURPOSE_TIMESTAMP_SIGN)); _X509.setConstant("TRUST_COMPAT",_1); _X509.setConstant("TRUST_SSL_CLIENT",_2); _X509.setConstant("TRUST_SSL_SERVER",_3); @@ -156,6 +157,7 @@ static void createX509(final Ruby runtime, final RubyModule _OpenSSL, final Ruby _X509.setConstant("TRUST_OBJECT_SIGN",_5); _X509.setConstant("TRUST_OCSP_SIGN",_6); _X509.setConstant("TRUST_OCSP_REQUEST",_7); + _X509.setConstant("TRUST_TSA",_8); // These should eventually point to correct things. _X509.setConstant("DEFAULT_CERT_AREA", runtime.newString(X509Utils.X509_CERT_AREA)); diff --git a/src/main/java/org/jruby/ext/openssl/X509Cert.java b/src/main/java/org/jruby/ext/openssl/X509Cert.java index 9b702d1c..4172e736 100644 --- a/src/main/java/org/jruby/ext/openssl/X509Cert.java +++ b/src/main/java/org/jruby/ext/openssl/X509Cert.java @@ -158,6 +158,10 @@ final X509AuxCertificate getAuxCert() { return new X509AuxCertificate(cert); } + final X509Certificate getCert() { + return cert; + } + public static IRubyObject wrap(Ruby runtime, Certificate cert) throws CertificateEncodingException { return wrap(runtime.getCurrentContext(), cert); diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKCS7.java b/src/main/java/org/jruby/ext/openssl/impl/PKCS7.java index 138064fd..20bc6759 100644 --- a/src/main/java/org/jruby/ext/openssl/impl/PKCS7.java +++ b/src/main/java/org/jruby/ext/openssl/impl/PKCS7.java @@ -108,8 +108,8 @@ public boolean isDetached() throws PKCS7Exception { return isSigned() && getDetached() != 0; } - private void initiateWith(int nid, ASN1Encodable content) throws PKCS7Exception { - this.data = PKCS7Data.fromASN1(nid, content); + private void initiateWith(ASN1ObjectIdentifier contentType, ASN1Encodable content) throws PKCS7Exception { + this.data = PKCS7Data.fromASN1(contentType, content); } public static PKCS7 newEmpty() { @@ -139,14 +139,12 @@ public static PKCS7 fromASN1(ASN1Encodable obj) throws PKCS7Exception { p7.setType(ASN1Registry.NID_undef); } else { - final int nid = ASN1Registry.oid2nid(contentType); - ASN1Encodable content = size == 1 ? null : ((ASN1Sequence) obj).getObjectAt(1); if (content != null && content instanceof ASN1TaggedObject && ((ASN1TaggedObject) content).getTagNo() == 0) { content = ASN1Shim.getTaggedObject((ASN1TaggedObject) content); } - p7.initiateWith(nid, content); + p7.initiateWith(contentType, content); } } // somewhere the object does not obey to be PKCS7 object @@ -172,11 +170,12 @@ public ASN1Encodable asASN1() { // OpenSSL behavior contentType = new ASN1ObjectIdentifier(EMPTY_PKCS7_OID); } else { - contentType = ASN1Registry.nid2obj(getType()); + contentType = data.getContentType(); } vector.add(contentType); if (data != null) { - vector.add(new DERTaggedObject(0, data.asASN1())); + ASN1Encodable content = data.asASN1(); + if (content != null) vector.add(new DERTaggedObject(0, content)); } return new DLSequence(vector); } diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKCS7Data.java b/src/main/java/org/jruby/ext/openssl/impl/PKCS7Data.java index 9652ab7f..151aa54a 100644 --- a/src/main/java/org/jruby/ext/openssl/impl/PKCS7Data.java +++ b/src/main/java/org/jruby/ext/openssl/impl/PKCS7Data.java @@ -30,6 +30,7 @@ import java.security.cert.X509CRL; import java.util.Collection; import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1OctetString; import org.jruby.ext.openssl.x509store.X509AuxCertificate; @@ -39,6 +40,10 @@ public abstract class PKCS7Data { public abstract int getType(); + public ASN1ObjectIdentifier getContentType() { + return ASN1Registry.nid2obj(getType()); + } + public Object ctrl(int cmd, Object v, Object ignored) throws PKCS7Exception { switch(cmd) { case PKCS7.OP_SET_DETACHED_SIGNATURE: @@ -141,6 +146,12 @@ public void addCRL(X509CRL crl) throws PKCS7Exception { } public static PKCS7Data fromASN1(final int nid, ASN1Encodable content) throws PKCS7Exception { + return fromASN1(ASN1Registry.nid2obj(nid), content); + } + + public static PKCS7Data fromASN1(final ASN1ObjectIdentifier contentType, ASN1Encodable content) throws PKCS7Exception { + final Integer registeredNid = ASN1Registry.oid2nid(contentType); + final int nid = registeredNid == null ? ASN1Registry.NID_undef : registeredNid; switch (nid) { case ASN1Registry.NID_pkcs7_data: return PKCS7DataData.fromASN1(content); @@ -155,7 +166,7 @@ public static PKCS7Data fromASN1(final int nid, ASN1Encodable content) throws PK case ASN1Registry.NID_pkcs7_encrypted: return PKCS7DataEncrypted.fromASN1(content); default: - throw new UnsupportedOperationException("can't handle PKCS#7 with content type " + ASN1Registry.nid2ln(nid)); + return new PKCS7DataOther(contentType, content); } } diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataOther.java b/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataOther.java new file mode 100644 index 00000000..c2e7151c --- /dev/null +++ b/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataOther.java @@ -0,0 +1,63 @@ +/* + * The MIT License + * + * Copyright (C) 2026 Karol Bucek + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jruby.ext.openssl.impl; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; + +public class PKCS7DataOther extends PKCS7Data { + private final ASN1ObjectIdentifier contentType; + private final ASN1Encodable content; + + public PKCS7DataOther(ASN1ObjectIdentifier contentType, ASN1Encodable content) { + this.contentType = contentType; + this.content = content; + } + + @Override + public int getType() { + Integer nid = ASN1Registry.oid2nid(contentType); + return nid == null ? ASN1Registry.NID_undef : nid; + } + + @Override + public ASN1ObjectIdentifier getContentType() { + return contentType; + } + + @Override + public boolean isOther() { + return true; + } + + @Override + public ASN1Encodable getOther() { + return content; + } + + @Override + public ASN1Encodable asASN1() { + return content; + } +} diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataSigned.java b/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataSigned.java index ef996069..947446ca 100644 --- a/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataSigned.java +++ b/src/main/java/org/jruby/ext/openssl/impl/PKCS7DataSigned.java @@ -115,7 +115,7 @@ public Collection getSignerInfo() { @Override public void addCertificate(X509AuxCertificate cert) { - this.sign.getCert().add(cert); + this.sign.addCert(cert); } @Override diff --git a/src/main/java/org/jruby/ext/openssl/impl/Signed.java b/src/main/java/org/jruby/ext/openssl/impl/Signed.java index f6fa5ff5..3fd94e35 100644 --- a/src/main/java/org/jruby/ext/openssl/impl/Signed.java +++ b/src/main/java/org/jruby/ext/openssl/impl/Signed.java @@ -178,6 +178,11 @@ public final void setCert(final Collection newCert) { this.cert = newCert; } + public final void addCert(final X509AuxCertificate newCert) { + if (cert == null) cert = new ArrayList(); + cert.add(newCert); + } + /** * Get the Crl value. * @@ -310,9 +315,7 @@ public static Signed fromASN1(ASN1Encodable content) throws PKCS7Exception{ signed.setVersion(version.getValue().intValue()); signed.setMdAlgs(algorithmIdentifiersFromASN1Set(digestAlgos)); signed.setContents(PKCS7.fromASN1(contentInfo)); - if(certificates != null) { - signed.setCert(certificatesFromASN1Set(certificates)); - } + signed.setCert(certificates == null ? null : certificatesFromASN1Set(certificates)); if(crls != null) { throw new RuntimeException("TODO: implement CRL part"); } diff --git a/src/main/java/org/jruby/ext/openssl/util/RubySupport.java b/src/main/java/org/jruby/ext/openssl/util/RubySupport.java index 426c1ad0..00ace55c 100644 --- a/src/main/java/org/jruby/ext/openssl/util/RubySupport.java +++ b/src/main/java/org/jruby/ext/openssl/util/RubySupport.java @@ -24,6 +24,7 @@ package org.jruby.ext.openssl.util; import java.io.IOException; +import java.util.Date; import java.util.HashSet; import java.util.function.Function; @@ -37,6 +38,7 @@ import org.jruby.RubyNumeric; import org.jruby.RubyString; import org.jruby.RubySymbol; +import org.jruby.RubyTime; import org.jruby.exceptions.RaiseException; import org.jruby.util.ByteList; import org.jruby.runtime.Block; @@ -81,6 +83,13 @@ public static RubyString newStringFrozen(final Ruby runtime, final CharSequence return str; } + public static Date timeToJavaDate(final ThreadContext context, final IRubyObject value) { + if (!(value instanceof RubyTime)) { + throw context.runtime.newTypeError(value, "Time"); + } + return ((RubyTime) value).getJavaDate(); + } + // error/exception factory helpers public static RaiseException newIOError(Ruby runtime, IOException e) { diff --git a/src/main/java/org/jruby/ext/openssl/x509store/Purpose.java b/src/main/java/org/jruby/ext/openssl/x509store/Purpose.java index 682708bc..a46880bc 100644 --- a/src/main/java/org/jruby/ext/openssl/x509store/Purpose.java +++ b/src/main/java/org/jruby/ext/openssl/x509store/Purpose.java @@ -446,6 +446,27 @@ public int call(Purpose purpose, X509AuxCertificate x, Integer ca) throws Certif } }; + final static CheckPurposeFunction checkPurposeTimestampSign = new CheckPurposeFunction() { + public int call(Purpose purpose, X509AuxCertificate x, Integer ca) throws CertificateException { + if (ca != 0) return checkCA(x); + + final boolean[] keyUsage = x.getKeyUsage(); + if (keyUsage != null) { + if (!keyUsage[0] && !keyUsage[1]) return 0; + for (int i = 2; i < keyUsage.length; i++) { + if (keyUsage[i]) return 0; + } + } + + final List extendedKeyUsage = x.getExtendedKeyUsage(); + if (extendedKeyUsage == null || extendedKeyUsage.size() != 1 || + !extendedKeyUsage.contains("1.3.6.1.5.5.7.3.8")) return 0; + + return x.getCriticalExtensionOIDs() != null && + x.getCriticalExtensionOIDs().contains("2.5.29.37") ? 1 : 0; + } + }; + private final static Purpose[] xstandard = new Purpose[] { new Purpose(X509Utils.X509_PURPOSE_SSL_CLIENT, X509Utils.X509_TRUST_SSL_CLIENT, 0, checkPurposeSSLClient, "SSL client", "sslclient", null), new Purpose(X509Utils.X509_PURPOSE_SSL_SERVER, X509Utils.X509_TRUST_SSL_SERVER, 0, checkPurposeSSLServer, "SSL server", "sslserver", null), @@ -455,5 +476,6 @@ public int call(Purpose purpose, X509AuxCertificate x, Integer ca) throws Certif new Purpose(X509Utils.X509_PURPOSE_CRL_SIGN, X509Utils.X509_TRUST_COMPAT, 0, checkPurposeCRLSign, "CRL signing", "crlsign", null), new Purpose(X509Utils.X509_PURPOSE_ANY, X509Utils.X509_TRUST_DEFAULT, 0, noCheck, "Any Purpose", "any", null), new Purpose(X509Utils.X509_PURPOSE_OCSP_HELPER, X509Utils.X509_TRUST_COMPAT, 0, oscpHelper, "OCSP helper", "ocsphelper", null), + new Purpose(X509Utils.X509_PURPOSE_TIMESTAMP_SIGN, X509Utils.X509_TRUST_TSA, 0, checkPurposeTimestampSign, "Time Stamp signing", "timestampsign", null), }; }// X509_PURPOSE diff --git a/src/main/java/org/jruby/ext/openssl/x509store/Trust.java b/src/main/java/org/jruby/ext/openssl/x509store/Trust.java index 054b375c..12b064e2 100644 --- a/src/main/java/org/jruby/ext/openssl/x509store/Trust.java +++ b/src/main/java/org/jruby/ext/openssl/x509store/Trust.java @@ -331,6 +331,7 @@ public String toString() { new Trust(X509Utils.X509_TRUST_OBJECT_SIGN, 0, trust1OIDAny, "Object Signer", "1.3.6.1.5.5.7.3.3", null), new Trust(X509Utils.X509_TRUST_OCSP_SIGN, 0, trust1OID, "OCSP responder", "1.3.6.1.5.5.7.3.9", null), new Trust(X509Utils.X509_TRUST_OCSP_REQUEST, 0, trust1OID, "OCSP request", "1.3.6.1.5.5.7.48.1", null), + new Trust(X509Utils.X509_TRUST_TSA, 0, trust1OIDAny, "TSA server", "1.3.6.1.5.5.7.3.8", null), }; }// X509_TRUST diff --git a/src/main/java/org/jruby/ext/openssl/x509store/X509Utils.java b/src/main/java/org/jruby/ext/openssl/x509store/X509Utils.java index 0353cd24..7f43ec55 100644 --- a/src/main/java/org/jruby/ext/openssl/x509store/X509Utils.java +++ b/src/main/java/org/jruby/ext/openssl/x509store/X509Utils.java @@ -545,12 +545,13 @@ else if (maybeCertFile != null && new File(maybeCertFile).exists()) { public static final int X509_PURPOSE_CRL_SIGN = 6; public static final int X509_PURPOSE_ANY = 7; public static final int X509_PURPOSE_OCSP_HELPER = 8; + public static final int X509_PURPOSE_TIMESTAMP_SIGN = 9; public static final int X509_PURPOSE_DYNAMIC = 0x1; public static final int X509_PURPOSE_DYNAMIC_NAME = 0x2; public static final int X509_PURPOSE_MIN = 1; - public static final int X509_PURPOSE_MAX = 8; + public static final int X509_PURPOSE_MAX = 9; public static final int X509_TRUST_DEFAULT = 0; /* Only valid in purpose settings */ diff --git a/test/test_timestamp.rb b/test/test_timestamp.rb new file mode 100644 index 00000000..f4fdbb8e --- /dev/null +++ b/test/test_timestamp.rb @@ -0,0 +1,429 @@ +# coding: US-ASCII +require File.expand_path('test_helper', File.dirname(__FILE__)) + +class TestTimestamp < TestCase + def setup + super + now = Time.at(Time.now.to_i) + ca_name = OpenSSL::X509::Name.parse('/CN=Timestamp CA') + @ca_key = Fixtures.pkey('rsa2048') + @ca_cert = issue_cert(ca_name, @ca_key, 1, + [['basicConstraints', 'CA:TRUE', true], ['keyUsage', 'keyCertSign,cRLSign', true]], + nil, nil, not_before: now - 60, not_after: now + 3600) + + tsa_name = OpenSSL::X509::Name.parse('/CN=Timestamp TSA') + @tsa_key = Fixtures.pkey('custom/rsa-2048-private.pem') + @tsa_cert = issue_cert(tsa_name, @tsa_key, 2, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', 'timeStamping', true]], + @ca_cert, @ca_key, not_before: now - 60, not_after: now + 3600) + end + + def test_request_round_trip + request = OpenSSL::Timestamp::Request.new + assert_equal 1, request.version + assert_equal true, request.cert_requested? + assert_equal 'NULL', request.algorithm + assert_equal '', request.message_imprint + + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + request.nonce = 42 + der = request.to_der + + parsed = OpenSSL::Timestamp::Request.new(der) + assert_equal 'SHA256', parsed.algorithm + assert_equal request.message_imprint, parsed.message_imprint + assert_equal '1.2.3.4.5', parsed.policy_id + assert_equal 42, parsed.nonce + assert_equal der, parsed.to_der + assert_match(/SHA256/i, parsed.to_text) + end + + def test_request_mandatory_fields + request = OpenSSL::Timestamp::Request.new + assert_raise(OpenSSL::Timestamp::TimestampError) { request.to_der } + request.algorithm = 'SHA256' + assert_raise(OpenSSL::Timestamp::TimestampError) { request.to_der } + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + assert_nothing_raised { request.to_der } + end + + def test_factory_and_response_round_trip + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + request.nonce = 42 + + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.at(Time.now.to_i) + factory.serial_number = 7 + factory.allowed_digests = ['SHA256'] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::GRANTED, response.status + assert_nil response.failure_info + assert_equal '1.2.3.4.5', response.token_info.policy_id + assert_equal 'SHA256', response.token_info.algorithm + assert_equal request.message_imprint, response.token_info.message_imprint + assert_equal 7, response.token_info.serial_number + assert_equal 42, response.token_info.nonce + assert_equal @tsa_cert.to_der, response.tsa_certificate.to_der + + parsed = OpenSSL::Timestamp::Response.new(response.to_der) + assert_equal response.to_der, parsed.to_der + assert_equal OpenSSL::Timestamp::Response::GRANTED, parsed.status + assert_equal @tsa_cert.to_der, parsed.tsa_certificate.to_der + assert_match(/1\.2\.3\.4\.5/, parsed.to_text) + assert_match(/1\.2\.3\.4\.5/, parsed.token_info.to_text) + end + + def test_response_failure_info + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.now + factory.serial_number = 1 + factory.allowed_digests = ['SHA384'] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_equal :BAD_ALG, response.failure_info + assert_nil response.token + assert_nil response.token_info + end + + def test_factory_rejects_non_timestamp_certificate + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.now + factory.serial_number = 1 + factory.allowed_digests = ['SHA256'] + assert_raise(OpenSSL::Timestamp::TimestampError) do + factory.create_timestamp(@tsa_key, @ca_cert, request) + end + end + + def test_response_verify + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.now + factory.serial_number = 1 + factory.allowed_digests = ['SHA256'] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + + store = OpenSSL::X509::Store.new + store.add_cert(@ca_cert) + assert_same response, response.verify(request, store) + end + + def test_factory_without_certificate_request + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + request.cert_requested = false + + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.now + factory.serial_number = 1 + factory.allowed_digests = ['SHA256'] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::GRANTED, response.status + assert_nil response.tsa_certificate + assert_nil response.token.certificates + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + assert_same response, response.verify(request, trusted_store, [@tsa_cert]) + end + + def test_response_verify_embedded_intermediate + intermediate, tsa = intermediate_chain + request = timestamp_request + factory = timestamp_factory + factory.additional_certs = [intermediate] + response = factory.create_timestamp(@tsa_key, tsa, request) + + assert_same response, response.verify(request, trusted_store) + end + + def test_response_verify_supplied_intermediate + intermediate, tsa = intermediate_chain + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, tsa, request) + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + assert_same response, response.verify(request, trusted_store, [intermediate]) + end + + def test_response_verify_supplied_signer_and_intermediate + intermediate, tsa = intermediate_chain + request = timestamp_request + request.cert_requested = false + response = timestamp_factory.create_timestamp(@tsa_key, tsa, request) + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store, [tsa]) } + assert_same response, response.verify(request, trusted_store, [tsa, intermediate]) + end + + def test_response_verify_untrusted_chain + request = timestamp_request + factory = timestamp_factory + factory.additional_certs = [@ca_cert] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_raise(OpenSSL::Timestamp::TimestampError) do + response.verify(request, OpenSSL::X509::Store.new) + end + end + + def test_response_verify_wrong_imprint + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'other data') + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + end + + def test_response_verify_wrong_digest + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + request.algorithm = 'SHA384' + request.message_imprint = OpenSSL::Digest.digest('SHA384', 'data') + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + end + + def test_response_verify_wrong_nonce + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + request.nonce = 43 + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + end + + def test_response_verify_wrong_policy + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + request.policy_id = '1.2.3.4.6' + + assert_raise(OpenSSL::Timestamp::TimestampError) { response.verify(request, trusted_store) } + end + + def test_response_verify_tampered_signature + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + asn1 = OpenSSL::ASN1.decode(response.to_der) + signed_data = asn1.value[1].value[1].value[0] + signer_info = signed_data.value.last.value.first + signature = signer_info.value.find { |value| value.is_a?(OpenSSL::ASN1::OctetString) } + signature.value.setbyte(0, signature.value.getbyte(0) ^ 1) + tampered = OpenSSL::Timestamp::Response.new(asn1.to_der) + + assert_raise(OpenSSL::Timestamp::TimestampError) { tampered.verify(request, trusted_store) } + end + + def test_malformed_der + [OpenSSL::Timestamp::Request, OpenSSL::Timestamp::Response, OpenSSL::Timestamp::TokenInfo].each do |type| + ['', 'invalid', OpenSSL::ASN1::Sequence.new([]).to_der].each do |der| + assert_raise(OpenSSL::Timestamp::TimestampError) { type.new(der) } + end + end + end + + def test_factory_invalid_request_version + request = timestamp_request + request.version = 2 + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_equal :BAD_REQUEST, response.failure_info + assert_nil response.token + end + + def test_factory_invalid_imprint_length + request = timestamp_request + request.message_imprint = 'too short' + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_equal :BAD_DATA_FORMAT, response.failure_info + assert_nil response.token + end + + def test_factory_rejects_mismatched_key + response = timestamp_factory.create_timestamp(@ca_key, @tsa_cert, timestamp_request) + + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_nil response.token + end + + def test_factory_nil_allowed_digests + factory = timestamp_factory + factory.allowed_digests = nil + response = factory.create_timestamp(@tsa_key, @tsa_cert, timestamp_request) + + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_equal :BAD_ALG, response.failure_info + assert_nil response.token + end + + def test_response_token_pkcs7 + factory = timestamp_factory + factory.additional_certs = [@ca_cert] + response = factory.create_timestamp(@tsa_key, @tsa_cert, timestamp_request) + token = response.token + + assert_kind_of OpenSSL::PKCS7, token + assert_equal :signed, token.type + assert_equal [@ca_cert.to_der, @tsa_cert.to_der].sort, token.certificates.map(&:to_der).sort + parsed = OpenSSL::PKCS7.new(token.to_der) + assert_equal token.to_der, parsed.to_der + assert_equal :signed, parsed.type + end + + def test_token_info_round_trip + request = timestamp_request + factory = timestamp_factory + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + info = OpenSSL::Timestamp::TokenInfo.new(response.token_info.to_der) + + assert_equal response.token_info.to_der, info.to_der + assert_equal 1, info.version + assert_equal request.policy_id, info.policy_id + assert_equal request.algorithm, info.algorithm + assert_equal request.message_imprint, info.message_imprint + assert_equal request.nonce, info.nonce + assert_equal factory.serial_number, info.serial_number + assert_equal factory.gen_time, info.gen_time + assert_equal false, info.ordering + end + + def test_request_assignment_errors + request = timestamp_request + [:algorithm, :policy_id, :message_imprint, :nonce, :version].each do |attribute| + assert_raise(TypeError) { request.public_send("#{attribute}=", nil) } + end + [:algorithm, :policy_id].each do |attribute| + assert_raise(OpenSSL::ASN1::ASN1Error) { request.public_send("#{attribute}=", 'invalid') } + end + assert_raise(TypeError) { request.algorithm = 4 } + assert_raise(TypeError) { request.nonce = '123' } + assert_raise(OpenSSL::Timestamp::TimestampError) { request.version = -1 } + request.cert_requested = nil + assert_equal false, request.cert_requested? + end + + def test_sha1_imprint_with_sha256_signature + request = timestamp_request + request.algorithm = 'SHA1' + request.message_imprint = OpenSSL::Digest.digest('SHA1', 'data') + factory = timestamp_factory + factory.allowed_digests = ['SHA1'] + response = factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::GRANTED, response.status + assert_equal 'SHA1', response.token_info.algorithm + signed_data = OpenSSL::ASN1.decode(response.token.to_der).value[1].value[0] + signer_info = signed_data.value.last.value.first + assert_equal '2.16.840.1.101.3.4.2.1', signer_info.value[2].value[0].oid + assert_same response, response.verify(request, trusted_store) + end + + def test_factory_rejects_request_extensions + request = timestamp_request + extension = OpenSSL::X509::Extension.new('1.2.3.4.9', 'unsupported') + asn1 = OpenSSL::ASN1.decode(request.to_der) + asn1.value << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1.decode(extension.to_der)], 0, :CONTEXT_SPECIFIC) + request = OpenSSL::Timestamp::Request.new(asn1.to_der) + response = timestamp_factory.create_timestamp(@tsa_key, @tsa_cert, request) + + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + assert_equal :UNACCEPTED_EXTENSION, response.failure_info + assert_nil response.token + end + + def test_factory_rejects_invalid_certificate_purpose + [ + [['keyUsage', 'keyEncipherment', true], ['extendedKeyUsage', 'timeStamping', true]], + [['keyUsage', 'digitalSignature,keyEncipherment', true], ['extendedKeyUsage', 'timeStamping', true]], + [['extendedKeyUsage', 'timeStamping', false]], + [['extendedKeyUsage', 'timeStamping,serverAuth', true]] + ].each do |extensions| + cert = issue_cert(@tsa_cert.subject, @tsa_key, 5, extensions, @ca_cert, @ca_key) + assert_raise(OpenSSL::Timestamp::TimestampError) do + timestamp_factory.create_timestamp(@tsa_key, cert, timestamp_request) + end + end + end + + def test_factory_accepts_certificate_purpose + [ + [['keyUsage', 'nonRepudiation', true], ['extendedKeyUsage', 'timeStamping', true]], + [['extendedKeyUsage', 'timeStamping', true]] + ].each do |extensions| + cert = issue_cert(@tsa_cert.subject, @tsa_key, 5, extensions, @ca_cert, @ca_key) + request = timestamp_request + response = timestamp_factory.create_timestamp(@tsa_key, cert, request) + assert_equal OpenSSL::Timestamp::Response::GRANTED, response.status + assert_same response, response.verify(request, trusted_store) + end + end + + def test_response_status_text + text = ['first status message', 'second status message'] + status = OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::Integer.new(2), + OpenSSL::ASN1::Sequence.new(text.map { |value| OpenSSL::ASN1::UTF8String.new(value) }) + ]) + response = OpenSSL::Timestamp::Response.new(OpenSSL::ASN1::Sequence.new([status]).to_der) + + assert_equal text, response.status_text + assert_equal OpenSSL::Timestamp::Response::REJECTION, response.status + end + + private + + def timestamp_request + request = OpenSSL::Timestamp::Request.new + request.algorithm = 'SHA256' + request.message_imprint = OpenSSL::Digest.digest('SHA256', 'data') + request.policy_id = '1.2.3.4.5' + request.nonce = 42 + request + end + + def timestamp_factory + factory = OpenSSL::Timestamp::Factory.new + factory.gen_time = Time.at(Time.now.to_i) + factory.serial_number = 7 + factory.allowed_digests = ['SHA256'] + factory + end + + def trusted_store + store = OpenSSL::X509::Store.new + store.add_cert(@ca_cert) + store + end + + def intermediate_chain + intermediate = issue_cert(OpenSSL::X509::Name.parse('/CN=Timestamp Intermediate'), @ca_key, 3, + [['basicConstraints', 'CA:TRUE', true], ['keyUsage', 'keyCertSign,cRLSign', true]], + @ca_cert, @ca_key) + tsa = issue_cert(OpenSSL::X509::Name.parse('/CN=Intermediate Timestamp TSA'), @tsa_key, 4, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', 'timeStamping', true]], + intermediate, @ca_key) + [intermediate, tsa] + end +end