Skip to content

Commit 06612ca

Browse files
committed
[compat] support PKey::RSA#sign_raw and verify_raw
1 parent 9b3de2b commit 06612ca

File tree

4 files changed

+341
-3
lines changed

4 files changed

+341
-3
lines changed

src/main/java/org/jruby/ext/openssl/PKey.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,7 @@ public IRubyObject verify(IRubyObject digest, IRubyObject sign, IRubyObject data
288288
final Ruby runtime = getRuntime();
289289
ByteList sigBytes = convertToString(runtime, sign, "OpenSSL::PKey::PKeyError", "invalid signature").getByteList();
290290
ByteList dataBytes = convertToString(runtime, data, "OpenSSL::PKey::PKeyError", "invalid data").getByteList();
291-
String digAlg = (digest instanceof Digest) ? ((Digest) digest).getShortAlgorithm() : digest.asJavaString();
292-
final String algorithm = digAlg + "WITH" + getAlgorithm();
291+
final String algorithm = getDigestAlgName(digest) + "WITH" + getAlgorithm();
293292
try {
294293
return runtime.newBoolean( verify(algorithm, getPublicKey(), dataBytes, sigBytes) );
295294
}
@@ -304,6 +303,12 @@ public IRubyObject verify(IRubyObject digest, IRubyObject sign, IRubyObject data
304303
}
305304
}
306305

306+
static String getDigestAlgName(IRubyObject digest) {
307+
if (digest.isNil()) return "SHA256";
308+
if (digest instanceof Digest) return ((Digest) digest).getShortAlgorithm();
309+
return digest.asJavaString();
310+
}
311+
307312
static RubyString convertToString(final Ruby runtime, final IRubyObject str, final String errorType, final CharSequence errorMsg) {
308313
try {
309314
return str.convertToString();

src/main/java/org/jruby/ext/openssl/PKeyRSA.java

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
import java.math.BigInteger;
3434
import java.security.GeneralSecurityException;
3535
import java.security.InvalidAlgorithmParameterException;
36+
import java.security.InvalidKeyException;
3637
import java.security.Key;
3738
import java.security.KeyFactory;
3839
import java.security.KeyPair;
3940
import java.security.KeyPairGenerator;
4041
import java.security.NoSuchAlgorithmException;
4142
import java.security.PrivateKey;
4243
import java.security.PublicKey;
44+
import java.security.SignatureException;
4345
import java.security.interfaces.RSAPrivateCrtKey;
4446
import java.security.interfaces.RSAPrivateKey;
4547
import java.security.interfaces.RSAPublicKey;
@@ -53,8 +55,21 @@
5355

5456
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
5557
import org.bouncycastle.asn1.ASN1Primitive;
58+
import org.bouncycastle.asn1.DERNull;
5659
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
5760
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
61+
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
62+
import org.bouncycastle.asn1.x509.DigestInfo;
63+
import org.bouncycastle.crypto.CryptoException;
64+
import org.bouncycastle.crypto.digests.SHA1Digest;
65+
import org.bouncycastle.crypto.digests.SHA256Digest;
66+
import org.bouncycastle.crypto.digests.SHA384Digest;
67+
import org.bouncycastle.crypto.digests.SHA512Digest;
68+
import org.bouncycastle.crypto.engines.RSABlindedEngine;
69+
import org.bouncycastle.crypto.params.ParametersWithRandom;
70+
import org.bouncycastle.crypto.params.RSAKeyParameters;
71+
import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters;
72+
import org.bouncycastle.crypto.signers.PSSSigner;
5873
import org.bouncycastle.operator.OutputEncryptor;
5974
import org.bouncycastle.operator.OperatorCreationException;
6075
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
@@ -71,13 +86,16 @@
7186
import org.jruby.RubyString;
7287
import org.jruby.anno.JRubyMethod;
7388
import org.jruby.exceptions.RaiseException;
89+
import org.jruby.ext.openssl.util.ByteArrayOutputStream;
7490
import org.jruby.runtime.Arity;
7591
import org.jruby.runtime.Block;
7692
import org.jruby.runtime.ObjectAllocator;
7793
import org.jruby.runtime.ThreadContext;
7894
import org.jruby.runtime.builtin.IRubyObject;
7995
import org.jruby.runtime.Visibility;
8096

97+
import org.jruby.util.ByteList;
98+
8199
import org.jruby.ext.openssl.impl.CipherSpec;
82100
import org.jruby.ext.openssl.x509store.PEMInputOutput;
83101
import static org.jruby.ext.openssl.OpenSSL.*;
@@ -671,6 +689,268 @@ public IRubyObject oid() {
671689
return getRuntime().newString("rsaEncryption");
672690
}
673691

692+
// sign_raw(digest, data [, opts]) -- signs already-hashed data with this RSA private key.
693+
// With no opts (or opts without rsa_padding_mode: "pss"), uses PKCS#1 v1.5 padding:
694+
// the hash is wrapped in a DigestInfo ASN.1 structure and signed with NONEwithRSA.
695+
// With opts containing rsa_padding_mode: "pss", uses RSA-PSS via BC's PSSSigner with
696+
// NullDigest (so the pre-hashed bytes are fed directly without re-hashing).
697+
@JRubyMethod(name = "sign_raw", required = 2, optional = 1)
698+
public IRubyObject sign_raw(ThreadContext context, IRubyObject[] args) {
699+
final Ruby runtime = context.runtime;
700+
if (privateKey == null) throw newPKeyError(runtime, "Private RSA key needed!");
701+
702+
final String digestAlg = getDigestAlgName(args[0]);
703+
final byte[] hashBytes = args[1].convertToString().getBytes();
704+
final IRubyObject opts = args.length > 2 ? args[2] : context.nil;
705+
706+
if (!opts.isNil()) {
707+
String paddingMode = Utils.extractStringOpt(context, opts, "rsa_padding_mode", true);
708+
if ("pss".equalsIgnoreCase(paddingMode)) {
709+
int saltLen = Utils.extractIntOpt(context, opts, "rsa_pss_saltlen", -1, true);
710+
String mgf1Alg = Utils.extractStringOpt(context, opts, "rsa_mgf1_md", true);
711+
if (mgf1Alg == null) mgf1Alg = digestAlg;
712+
if (saltLen < 0) saltLen = getDigestLength(digestAlg);
713+
try {
714+
return StringHelper.newString(runtime, signWithPSS(runtime, hashBytes, digestAlg, mgf1Alg, saltLen));
715+
} catch (CryptoException e) {
716+
throw newPKeyError(runtime, e.getMessage());
717+
}
718+
}
719+
}
720+
721+
// Default: PKCS#1 v1.5 — wrap hash in DigestInfo, then sign with NONEwithRSA
722+
try {
723+
byte[] digestInfoBytes = buildDigestInfo(digestAlg, hashBytes);
724+
ByteList signed = sign("NONEwithRSA", privateKey, new ByteList(digestInfoBytes, false));
725+
return RubyString.newString(runtime, signed);
726+
} catch (IOException e) {
727+
throw newPKeyError(runtime, "failed to encode DigestInfo: " + e.getMessage());
728+
} catch (NoSuchAlgorithmException e) {
729+
throw newPKeyError(runtime, "unsupported algorithm: NONEwithRSA");
730+
} catch (InvalidKeyException e) {
731+
throw newPKeyError(runtime, "invalid key");
732+
} catch (SignatureException e) {
733+
throw newPKeyError(runtime, e.getMessage());
734+
}
735+
}
736+
737+
// verify_raw(digest, signature, data [, opts]) -- verifies signature over already-hashed data.
738+
@JRubyMethod(name = "verify_raw", required = 3, optional = 1)
739+
public IRubyObject verify_raw(ThreadContext context, IRubyObject[] args) {
740+
final Ruby runtime = context.runtime;
741+
final String digestAlg = getDigestAlgName(args[0]);
742+
byte[] sigBytes = args[1].convertToString().getBytes();
743+
byte[] hashBytes = args[2].convertToString().getBytes();
744+
IRubyObject opts = args.length > 3 ? args[3] : runtime.getNil();
745+
746+
if (!opts.isNil()) {
747+
String paddingMode = Utils.extractStringOpt(context, opts, "rsa_padding_mode", true);
748+
if ("pss".equalsIgnoreCase(paddingMode)) {
749+
int saltLen = Utils.extractIntOpt(context, opts, "rsa_pss_saltlen", -1, true);
750+
String mgf1Alg = Utils.extractStringOpt(context, opts, "rsa_mgf1_md", true);
751+
if (mgf1Alg == null) mgf1Alg = digestAlg;
752+
if (saltLen < 0) saltLen = getDigestLength(digestAlg);
753+
try { // verify_raw: input is already the hash → use PreHashedDigest (pass-through phase 1)
754+
return runtime.newBoolean(verifyWithPSS(publicKey, hashBytes, digestAlg, true, mgf1Alg, saltLen, sigBytes));
755+
} catch (Exception e) {
756+
throw newPKeyError(runtime, e.getMessage());
757+
}
758+
}
759+
}
760+
761+
// Default: PKCS#1 v1.5 — verify against DigestInfo-wrapped hash bytes
762+
try {
763+
byte[] digestInfoBytes = buildDigestInfo(digestAlg, hashBytes);
764+
boolean ok = verify("NONEwithRSA", getPublicKey(),
765+
new ByteList(digestInfoBytes, false),
766+
new ByteList(sigBytes, false));
767+
return runtime.newBoolean(ok);
768+
} catch (IOException e) {
769+
throw newPKeyError(runtime, "failed to encode DigestInfo: " + e.getMessage());
770+
} catch (NoSuchAlgorithmException e) {
771+
throw newPKeyError(runtime, "unsupported algorithm: NONEwithRSA");
772+
} catch (InvalidKeyException e) {
773+
throw newPKeyError(runtime, "invalid key");
774+
} catch (SignatureException e) {
775+
return runtime.getFalse();
776+
}
777+
}
778+
779+
// Override verify to support optional 4th opts argument for PSS.
780+
// Without opts (or with non-PSS opts), delegates to the base PKey#verify logic.
781+
@JRubyMethod(name = "verify", required = 3, optional = 1)
782+
public IRubyObject verify(ThreadContext context, IRubyObject[] args) {
783+
final Ruby runtime = context.runtime;
784+
IRubyObject digest = args[0];
785+
IRubyObject sign = args[1];
786+
IRubyObject data = args[2];
787+
IRubyObject opts = args.length > 3 ? args[3] : runtime.getNil();
788+
789+
if (!opts.isNil()) {
790+
String paddingMode = Utils.extractStringOpt(context, opts, "rsa_padding_mode", true);
791+
if ("pss".equalsIgnoreCase(paddingMode)) {
792+
final String digestAlg = getDigestAlgName(digest);
793+
int saltLen = Utils.extractIntOpt(context, opts, "rsa_pss_saltlen", -1, true);
794+
String mgf1Alg = Utils.extractStringOpt(context, opts, "rsa_mgf1_md", true);
795+
if (mgf1Alg == null) mgf1Alg = digestAlg;
796+
if (saltLen < 0) saltLen = getDigestLength(digestAlg);
797+
byte[] sigBytes = sign.convertToString().getBytes();
798+
byte[] dataBytes = data.convertToString().getBytes();
799+
try { // verify (non-raw): feed raw data; PSSSigner will hash it internally via SHA-NNN
800+
return runtime.newBoolean(verifyWithPSS(publicKey, dataBytes, digestAlg, false, mgf1Alg, saltLen, sigBytes));
801+
} catch (Exception e) {
802+
throw newPKeyError(runtime, e.getMessage());
803+
}
804+
}
805+
}
806+
807+
// Fall back to standard PKey#verify (PKCS#1 v1.5)
808+
return super.verify(digest, sign, data);
809+
}
810+
811+
private static byte[] buildDigestInfo(String digestAlg, byte[] hashBytes) throws IOException {
812+
AlgorithmIdentifier algId = getDigestAlgId(digestAlg);
813+
return new DigestInfo(algId, hashBytes).getEncoded("DER");
814+
}
815+
816+
private static AlgorithmIdentifier getDigestAlgId(String digestAlg) {
817+
String upper = digestAlg.toUpperCase().replace("-", "");
818+
ASN1ObjectIdentifier oid;
819+
switch (upper) {
820+
case "SHA1": case "SHA": oid = new ASN1ObjectIdentifier("1.3.14.3.2.26"); break;
821+
case "SHA224": oid = NISTObjectIdentifiers.id_sha224; break;
822+
case "SHA256": oid = NISTObjectIdentifiers.id_sha256; break;
823+
case "SHA384": oid = NISTObjectIdentifiers.id_sha384; break;
824+
case "SHA512": oid = NISTObjectIdentifiers.id_sha512; break;
825+
default:
826+
throw new IllegalArgumentException("Unsupported digest for DigestInfo: " + digestAlg);
827+
}
828+
return new AlgorithmIdentifier(oid, DERNull.INSTANCE);
829+
}
830+
831+
private static org.bouncycastle.crypto.Digest createBCDigest(String digestAlg) {
832+
String upper = digestAlg.toUpperCase().replace("-", "");
833+
switch (upper) {
834+
case "SHA1": case "SHA": return new SHA1Digest();
835+
case "SHA256": return new SHA256Digest();
836+
case "SHA384": return new SHA384Digest();
837+
case "SHA512": return new SHA512Digest();
838+
default:
839+
throw new IllegalArgumentException("Unsupported digest for PSS: " + digestAlg);
840+
}
841+
}
842+
843+
private static int getDigestLength(String digestAlg) {
844+
String upper = digestAlg.toUpperCase().replace("-", "");
845+
switch (upper) {
846+
case "SHA1": case "SHA": return 20;
847+
case "SHA224": return 28;
848+
case "SHA256": return 32;
849+
case "SHA384": return 48;
850+
case "SHA512": return 64;
851+
default: return 32; // fallback
852+
}
853+
}
854+
855+
// Signs pre-hashed bytes using RSA-PSS. PSSSigner internally reuses the content digest for
856+
// BOTH hashing the message (phase 1) and hashing mDash (phase 2), so we use PreHashedDigest
857+
// which passes through pre-hashed bytes verbatim in phase 1 and runs a real SHA hash in phase 2.
858+
private byte[] signWithPSS(Ruby runtime, byte[] hashBytes, String digestAlg, String mgf1Alg, int saltLen)
859+
throws CryptoException {
860+
org.bouncycastle.crypto.Digest contentDigest = new PreHashedDigest(getDigestLength(digestAlg), digestAlg);
861+
org.bouncycastle.crypto.Digest mgf1Digest = createBCDigest(mgf1Alg);
862+
PSSSigner signer = new PSSSigner(new RSABlindedEngine(), contentDigest, mgf1Digest, saltLen);
863+
RSAKeyParameters bcKey = toBCPrivateKeyParams(privateKey);
864+
signer.init(true, new ParametersWithRandom(bcKey, getSecureRandom(runtime)));
865+
signer.update(hashBytes, 0, hashBytes.length);
866+
return signer.generateSignature();
867+
}
868+
869+
// Verifies an RSA-PSS signature. When isRaw=true the input is a pre-computed hash (verify_raw);
870+
// PreHashedDigest passes it through in phase 1 then uses a real SHA for hashing mDash in phase 2.
871+
// When isRaw=false the input is raw data (verify with opts); a real SHA digest is used throughout.
872+
private static boolean verifyWithPSS(RSAPublicKey pubKey, byte[] inputBytes,
873+
String digestAlg, boolean isRaw,
874+
String mgf1Alg, int saltLen, byte[] sigBytes) {
875+
org.bouncycastle.crypto.Digest contentDigest = isRaw
876+
? new PreHashedDigest(getDigestLength(digestAlg), digestAlg)
877+
: createBCDigest(digestAlg);
878+
org.bouncycastle.crypto.Digest mgf1Digest = createBCDigest(mgf1Alg);
879+
PSSSigner verifier = new PSSSigner(new RSABlindedEngine(), contentDigest, mgf1Digest, saltLen);
880+
RSAKeyParameters bcPubKey = new RSAKeyParameters(false, pubKey.getModulus(), pubKey.getPublicExponent());
881+
verifier.init(false, bcPubKey);
882+
verifier.update(inputBytes, 0, inputBytes.length);
883+
return verifier.verifySignature(sigBytes);
884+
}
885+
886+
/**
887+
* Two-phase Digest for PSS raw-sign/verify.
888+
*
889+
* PSSSigner internally calls the content digest twice:
890+
* Phase 1 - to hash the message content → we pass pre-computed hash bytes through verbatim.
891+
* Phase 2 - to hash mDash (needs a real hash) → we switch to the actual BC digest algorithm.
892+
*
893+
* getDigestSize() always returns the fixed hash length so PSSSigner can allocate its internal
894+
* buffers correctly even before any data has been accumulated.
895+
*/
896+
private static class PreHashedDigest implements org.bouncycastle.crypto.Digest {
897+
private final int hashLen;
898+
private final String digestAlg; // algorithm name for the real phase-2 digest
899+
private final ByteArrayOutputStream buf = new ByteArrayOutputStream();
900+
private org.bouncycastle.crypto.Digest realDigest; // non-null during phase 2
901+
902+
PreHashedDigest(int hashLen, String digestAlg) {
903+
this.hashLen = hashLen;
904+
this.digestAlg = digestAlg;
905+
}
906+
907+
public String getAlgorithmName() { return "PRE-HASHED"; }
908+
public int getDigestSize() { return hashLen; }
909+
910+
public void update(byte in) {
911+
if (realDigest != null) realDigest.update(in);
912+
else buf.write(in);
913+
}
914+
915+
public void update(byte[] in, int off, int len) {
916+
if (realDigest != null) realDigest.update(in, off, len);
917+
else buf.write(in, off, len);
918+
}
919+
920+
public int doFinal(byte[] out, final int off) {
921+
if (realDigest == null) {
922+
// Phase 1: emit the pre-hashed bytes verbatim, then arm the real digest for phase 2
923+
final int len = buf.size();
924+
System.arraycopy(buf.buffer(), 0, out, off, len);
925+
buf.reset();
926+
realDigest = createBCDigest(digestAlg);
927+
return len;
928+
} else {
929+
// Phase 2: emit the real hash of the mDash bytes that PSSSigner fed us
930+
final int len = realDigest.doFinal(out, off);
931+
realDigest = null; // back to phase 1 for reuse
932+
return len;
933+
}
934+
}
935+
936+
public void reset() {
937+
buf.reset();
938+
realDigest = null;
939+
}
940+
}
941+
942+
private static RSAKeyParameters toBCPrivateKeyParams(RSAPrivateKey privKey) {
943+
if (privKey instanceof RSAPrivateCrtKey) {
944+
RSAPrivateCrtKey crtKey = (RSAPrivateCrtKey) privKey;
945+
return new RSAPrivateCrtKeyParameters(
946+
crtKey.getModulus(), crtKey.getPublicExponent(), crtKey.getPrivateExponent(),
947+
crtKey.getPrimeP(), crtKey.getPrimeQ(),
948+
crtKey.getPrimeExponentP(), crtKey.getPrimeExponentQ(),
949+
crtKey.getCrtCoefficient());
950+
}
951+
return new RSAKeyParameters(true, privKey.getModulus(), privKey.getPrivateExponent());
952+
}
953+
674954
@JRubyMethod(name="d=")
675955
public synchronized IRubyObject set_d(final ThreadContext context, IRubyObject value) {
676956
if ( privateKey != null ) {

src/main/java/org/jruby/ext/openssl/Utils.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
import org.jruby.runtime.Block;
4040
import org.jruby.runtime.ThreadContext;
4141
import org.jruby.runtime.builtin.IRubyObject;
42-
import org.jruby.util.TypeConverter;
4342

4443
/**
4544
* @author <a href="mailto:[email protected]">Ola Bini</a>
@@ -192,6 +191,28 @@ public void visit(IRubyObject key, IRubyObject value) {
192191
return ret;
193192
}
194193

194+
static String extractStringOpt(ThreadContext context, IRubyObject opts,
195+
String key, boolean tryStringKey) {
196+
if (!(opts instanceof RubyHash)) return null;
197+
RubyHash hash = (RubyHash) opts;
198+
// OpenSSL option hashes may use string or symbol keys — try both.
199+
IRubyObject val = hash.fastARef(context.runtime.newSymbol(key));
200+
if (val == null && tryStringKey) val = hash.fastARef(context.runtime.newString(key));
201+
if (val == null || val.isNil()) return null;
202+
return val.convertToString().asJavaString();
203+
}
204+
205+
static int extractIntOpt(ThreadContext context, IRubyObject opts,
206+
String key, int defaultVal, boolean tryStringKey) {
207+
if (!(opts instanceof RubyHash)) return defaultVal;
208+
RubyHash hash = (RubyHash) opts;
209+
// OpenSSL option hashes may use string or symbol keys — try both.
210+
IRubyObject val = hash.fastARef(context.runtime.newSymbol(key));
211+
if (val == null && tryStringKey) val = hash.fastARef(context.runtime.newString(key));
212+
if (val == null || val.isNil()) return defaultVal;
213+
return RubyNumeric.fix2int(val);
214+
}
215+
195216
static ByteBuffer ensureCapacity(final ByteBuffer buffer, final int size) {
196217
if (size <= buffer.capacity()) return buffer;
197218
buffer.flip();

0 commit comments

Comments
 (0)