Skip to content

Commit 476d375

Browse files
karesclaude
andcommitted
[refactor] clear PKCS12 password arrays after use
Convert Ruby password strings to char[] via ByteList + Charset.decode, bypassing the immutable Java String that .to_java.to_char_array creates. Clear the char[] in ensure blocks after KeyStore operations, matching C OpenSSL's expectation that the caller manages password lifetime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9268593 commit 476d375

4 files changed

Lines changed: 297 additions & 37 deletions

File tree

lib/openssl/pkcs12.rb

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -28,50 +28,55 @@ def initialize(str = nil, password = '')
2828
@der = str
2929
end
3030

31-
store = SecurityHelper.getKeyStore("PKCS12")
32-
store.load(java.io.ByteArrayInputStream.new(@der.to_java_bytes), password.to_java.to_char_array)
31+
pass_chars = PEMUtils.toPasswordChars(password)
32+
begin
33+
store = SecurityHelper.getKeyStore("PKCS12")
34+
store.load(java.io.ByteArrayInputStream.new(@der.to_java_bytes), pass_chars)
3335

34-
aliases = store.aliases
35-
aliases.each do |alias_name|
36-
if store.is_key_entry(alias_name)
37-
if java_certificate = store.get_certificate(alias_name)
38-
der = String.from_java_bytes(java_certificate.get_encoded)
39-
@certificate = OpenSSL::X509::Certificate.new(der)
40-
end
36+
aliases = store.aliases
37+
aliases.each do |alias_name|
38+
if store.is_key_entry(alias_name)
39+
if java_certificate = store.get_certificate(alias_name)
40+
der = String.from_java_bytes(java_certificate.get_encoded)
41+
@certificate = OpenSSL::X509::Certificate.new(der)
42+
end
4143

42-
java_key = store.get_key(alias_name, password.to_java.to_char_array)
43-
if java_key
44-
der = String.from_java_bytes(java_key.get_encoded)
45-
algorithm = java_key.get_algorithm
46-
if algorithm == "RSA"
47-
@key = OpenSSL::PKey::RSA.new(der)
48-
elsif algorithm == "DSA"
49-
@key = OpenSSL::PKey::DSA.new(der)
50-
elsif algorithm == "DH"
51-
@key = OpenSSL::PKey::DH.new(der)
52-
elsif algorithm == "EC"
53-
@key = OpenSSL::PKey::EC.new(der)
54-
else
55-
raise PKCS12Error, "Unknown key algorithm #{algorithm}"
44+
java_key = store.get_key(alias_name, pass_chars)
45+
if java_key
46+
der = String.from_java_bytes(java_key.get_encoded)
47+
algorithm = java_key.get_algorithm
48+
if algorithm == "RSA"
49+
@key = OpenSSL::PKey::RSA.new(der)
50+
elsif algorithm == "DSA"
51+
@key = OpenSSL::PKey::DSA.new(der)
52+
elsif algorithm == "DH"
53+
@key = OpenSSL::PKey::DH.new(der)
54+
elsif algorithm == "EC"
55+
@key = OpenSSL::PKey::EC.new(der)
56+
else
57+
raise PKCS12Error, "Unknown key algorithm #{algorithm}"
58+
end
5659
end
57-
end
5860

59-
@ca_certs = Array.new
60-
java_ca_certs = store.get_certificate_chain(alias_name)
61-
if java_ca_certs
62-
java_ca_certs.each do |java_ca_cert|
63-
der = String.from_java_bytes(java_ca_cert.get_encoded)
64-
ruby_cert = OpenSSL::X509::Certificate.new(der)
65-
if (ruby_cert.to_pem != @certificate.to_pem)
66-
@ca_certs << ruby_cert
67-
end
61+
@ca_certs = Array.new
62+
java_ca_certs = store.get_certificate_chain(alias_name)
63+
if java_ca_certs
64+
java_ca_certs.each do |java_ca_cert|
65+
der = String.from_java_bytes(java_ca_cert.get_encoded)
66+
ruby_cert = OpenSSL::X509::Certificate.new(der)
67+
if (ruby_cert.to_pem != @certificate.to_pem)
68+
@ca_certs << ruby_cert
69+
end
70+
end
6871
end
72+
break
6973
end
70-
break
7174
end
75+
rescue java.lang.Exception => e
76+
raise PKCS12Error, e.inspect
77+
ensure
78+
PEMUtils.clearChars(pass_chars)
7279
end
73-
rescue java.lang.Exception => e
74-
raise PKCS12Error, e.inspect
7580
end
7681

7782
def generate(pass, alias_name, key, cert, ca = nil)
@@ -80,15 +85,18 @@ def generate(pass, alias_name, key, cert, ca = nil)
8085
certificates = cert.to_pem
8186
ca.each { |ca_cert| certificates << ca_cert.to_pem } if ca
8287

88+
pass_chars = PEMUtils.toPasswordChars(pass.nil? ? "" : pass)
8389
begin
8490
der_bytes = PEMUtils.generatePKCS12(
8591
java.io.StringReader.new(key.to_pem), certificates.to_java_bytes,
86-
alias_name, ( pass.nil? ? "" : pass ).to_java.to_char_array
92+
alias_name, pass_chars
8793
)
8894
rescue java.security.KeyStoreException, java.security.cert.CertificateException => e
8995
raise PKCS12Error, e.message
9096
rescue java.security.GeneralSecurityException, java.io.IOException => e
9197
raise PKCS12Error, e.inspect
98+
ensure
99+
PEMUtils.clearChars(pass_chars)
92100
end
93101

94102
@der = String.from_java_bytes(der_bytes)

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
import java.io.IOException;
2828
import java.io.Reader;
2929
import java.io.Writer;
30+
import java.nio.ByteBuffer;
31+
import java.nio.CharBuffer;
32+
import java.nio.charset.Charset;
33+
import java.nio.charset.StandardCharsets;
3034
import java.security.GeneralSecurityException;
3135

3236
import java.security.Key;
@@ -39,6 +43,7 @@
3943
import java.security.spec.AlgorithmParameterSpec;
4044
import java.security.spec.PKCS8EncodedKeySpec;
4145
import java.security.spec.X509EncodedKeySpec;
46+
import java.util.Arrays;
4247
import java.util.Collection;
4348
import javax.crypto.SecretKey;
4449
import javax.crypto.spec.IvParameterSpec;
@@ -59,6 +64,10 @@
5964
import org.bouncycastle.openssl.PEMWriter;
6065
import org.bouncycastle.operator.OperatorCreationException;
6166

67+
import org.jruby.RubyString;
68+
import org.jruby.runtime.builtin.IRubyObject;
69+
import org.jruby.util.ByteList;
70+
6271
import org.jruby.ext.openssl.impl.pem.MiscPEMGeneratorHelper;
6372
import org.jruby.ext.openssl.util.ByteArrayOutputStream;
6473
//import org.bouncycastle.util.io.pem.PemReader;
@@ -72,6 +81,58 @@
7281
*/
7382
public abstract class PEMUtils {
7483

84+
/**
85+
* Convert a Ruby string to a password char[] without creating an intermediate
86+
* Java String (which would be immutable and unclearable in memory).
87+
* <p>
88+
* This matches C OpenSSL's password handling where the password is used
89+
* in-place from the caller's buffer ({@code const char *pass}) with no
90+
* intermediate immutable copy.
91+
* <p>
92+
* The caller can clear the returned array after use via {@link #clearChars}.
93+
*
94+
* @param passwd a Ruby string (or nil)
95+
* @return password as char[], never null
96+
*/
97+
public static char[] toPasswordChars(final IRubyObject passwd) {
98+
if (passwd == null || passwd.isNil()) return new char[0];
99+
100+
final RubyString str = passwd.convertToString();
101+
final ByteList byteList = str.getByteList();
102+
103+
return toPasswordChars(
104+
byteList.getUnsafeBytes(), byteList.getBegin(), byteList.getRealSize(),
105+
byteList.getEncoding().getCharset()
106+
);
107+
}
108+
109+
// Package-private for testing
110+
static char[] toPasswordChars(final byte[] bytes, final int offset, final int length, Charset charset) {
111+
if (length == 0) return new char[0];
112+
113+
if (charset == null) charset = StandardCharsets.ISO_8859_1;
114+
115+
final ByteBuffer byteBuf = ByteBuffer.wrap(bytes, offset, length);
116+
final CharBuffer charBuf = charset.decode(byteBuf);
117+
118+
final char[] result = new char[charBuf.remaining()];
119+
charBuf.get(result);
120+
121+
// Clear the decoder's intermediate buffer
122+
if (charBuf.hasArray()) {
123+
Arrays.fill(charBuf.array(), '\0');
124+
}
125+
126+
return result;
127+
}
128+
129+
/**
130+
* Zero-fill a password char array. Safe to call with null.
131+
*/
132+
public static void clearChars(final char[] chars) {
133+
if (chars != null) Arrays.fill(chars, '\0');
134+
}
135+
75136
/*
76137
private static boolean bcPEMParser;
77138
private static Class<?> pemReaderImpl;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package org.jruby.ext.openssl;
2+
3+
import java.nio.charset.Charset;
4+
import java.nio.charset.StandardCharsets;
5+
6+
import org.junit.*;
7+
import static org.junit.Assert.*;
8+
9+
/**
10+
* Tests for {@link PEMUtils#toPasswordChars} and {@link PEMUtils#clearChars},
11+
* verifying password handling matches C OpenSSL's approach:
12+
* - No intermediate immutable Java String created
13+
* - Intermediate buffers are cleared after conversion
14+
* - Caller can (and must) clear the returned char[]
15+
*/
16+
public class PEMUtilsPasswordTest {
17+
18+
// C OpenSSL: const char *pass with ASCII bytes
19+
@Test
20+
public void asciiPassword() {
21+
byte[] bytes = "secret".getBytes(StandardCharsets.US_ASCII);
22+
char[] result = PEMUtils.toPasswordChars(bytes, 0, bytes.length, StandardCharsets.US_ASCII);
23+
assertArrayEquals(new char[] { 's', 'e', 'c', 'r', 'e', 't' }, result);
24+
}
25+
26+
// C OpenSSL: empty password (pass = "")
27+
@Test
28+
public void emptyPassword() {
29+
char[] result = PEMUtils.toPasswordChars(new byte[0], 0, 0, StandardCharsets.UTF_8);
30+
assertNotNull(result);
31+
assertEquals(0, result.length);
32+
}
33+
34+
// C OpenSSL: NULL password maps to empty char[]
35+
@Test
36+
public void nullRubyObjectReturnsEmpty() {
37+
char[] result = PEMUtils.toPasswordChars(null);
38+
assertNotNull(result);
39+
assertEquals(0, result.length);
40+
}
41+
42+
// UTF-8 multi-byte: "café" should decode correctly
43+
@Test
44+
public void utf8Password() {
45+
byte[] bytes = "caf\u00e9".getBytes(StandardCharsets.UTF_8);
46+
// UTF-8 encoding of é is 0xC3 0xA9 (2 bytes), so 5 bytes total
47+
assertEquals(5, bytes.length);
48+
char[] result = PEMUtils.toPasswordChars(bytes, 0, bytes.length, StandardCharsets.UTF_8);
49+
assertArrayEquals(new char[] { 'c', 'a', 'f', '\u00e9' }, result);
50+
}
51+
52+
// JRuby's ASCII-8BIT (binary): getCharset() returns null, should fall back to ISO-8859-1
53+
@Test
54+
public void nullCharsetFallsBackToLatin1() {
55+
byte[] bytes = new byte[] { (byte) 0xC0, (byte) 0xFF, (byte) 0x41 };
56+
char[] result = PEMUtils.toPasswordChars(bytes, 0, bytes.length, (Charset) null);
57+
assertEquals(3, result.length);
58+
assertEquals('\u00C0', result[0]); // 0xC0 -> U+00C0
59+
assertEquals('\u00FF', result[1]); // 0xFF -> U+00FF
60+
assertEquals('A', result[2]); // 0x41 -> 'A'
61+
}
62+
63+
// ISO-8859-1 encoding
64+
@Test
65+
public void latin1Password() {
66+
byte[] bytes = new byte[] { 'p', 'a', (byte) 0xDF, (byte) 0xF1 }; // "paßñ"
67+
char[] result = PEMUtils.toPasswordChars(bytes, 0, bytes.length, StandardCharsets.ISO_8859_1);
68+
assertArrayEquals(new char[] { 'p', 'a', '\u00DF', '\u00F1' }, result);
69+
}
70+
71+
// Offset and length: only slice of buffer is converted
72+
@Test
73+
public void offsetAndLength() {
74+
byte[] bytes = "XXhelloXX".getBytes(StandardCharsets.US_ASCII);
75+
char[] result = PEMUtils.toPasswordChars(bytes, 2, 5, StandardCharsets.US_ASCII);
76+
assertArrayEquals(new char[] { 'h', 'e', 'l', 'l', 'o' }, result);
77+
}
78+
79+
// clearChars zeroes the array
80+
@Test
81+
public void clearCharsZeroesArray() {
82+
char[] password = "secret".toCharArray();
83+
PEMUtils.clearChars(password);
84+
for (char c : password) {
85+
assertEquals('\0', c);
86+
}
87+
}
88+
89+
// clearChars is safe with null
90+
@Test
91+
public void clearCharsHandlesNull() {
92+
PEMUtils.clearChars(null); // should not throw
93+
}
94+
95+
// Returned array is independent — clearing it doesn't affect the source
96+
@Test
97+
public void returnedArrayIsACopy() {
98+
byte[] bytes = "test".getBytes(StandardCharsets.US_ASCII);
99+
char[] result = PEMUtils.toPasswordChars(bytes, 0, bytes.length, StandardCharsets.US_ASCII);
100+
PEMUtils.clearChars(result);
101+
// Source bytes are unchanged
102+
assertEquals('t', (char) bytes[0]);
103+
assertEquals('e', (char) bytes[1]);
104+
}
105+
106+
// Typical PKCS12 round-trip: password chars can be used with KeyStore
107+
@Test
108+
public void passwordCharsCompatibleWithKeyStore() throws Exception {
109+
byte[] passBytes = "pkcs12pass".getBytes(StandardCharsets.UTF_8);
110+
char[] passChars = PEMUtils.toPasswordChars(passBytes, 0, passBytes.length, StandardCharsets.UTF_8);
111+
try {
112+
// Verify the char[] works with Java KeyStore API
113+
java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12");
114+
ks.load(null, passChars); // initialize empty store with our password
115+
// No exception means the password format is valid
116+
} finally {
117+
PEMUtils.clearChars(passChars);
118+
}
119+
}
120+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# coding: US-ASCII
2+
require File.expand_path('../test_helper', File.dirname(__FILE__))
3+
4+
class TestPKCS12 < TestCase
5+
6+
def setup
7+
super
8+
9+
@key = OpenSSL::PKey::RSA.new(2048)
10+
@cert = issue_cert
11+
end
12+
13+
def test_create_and_parse_with_password
14+
p12 = OpenSSL::PKCS12.create("secret", "myalias", @key, @cert)
15+
assert p12.to_der.bytesize > 0
16+
17+
parsed = OpenSSL::PKCS12.new(p12.to_der, "secret")
18+
assert_instance_of OpenSSL::PKey::RSA, parsed.key
19+
assert_equal @cert.subject.to_s, parsed.certificate.subject.to_s
20+
end
21+
22+
def test_create_and_parse_with_empty_password
23+
p12 = OpenSSL::PKCS12.create("", "myalias", @key, @cert)
24+
parsed = OpenSSL::PKCS12.new(p12.to_der, "")
25+
assert_instance_of OpenSSL::PKey::RSA, parsed.key
26+
assert_equal @cert.subject.to_s, parsed.certificate.subject.to_s
27+
end
28+
29+
def test_create_and_parse_with_nil_password
30+
p12 = OpenSSL::PKCS12.create(nil, "myalias", @key, @cert)
31+
parsed = OpenSSL::PKCS12.new(p12.to_der)
32+
assert_instance_of OpenSSL::PKey::RSA, parsed.key
33+
assert_equal @cert.subject.to_s, parsed.certificate.subject.to_s
34+
end
35+
36+
def test_parse_with_wrong_password_raises
37+
p12 = OpenSSL::PKCS12.create("right", "myalias", @key, @cert)
38+
assert_raise(OpenSSL::PKCS12::PKCS12Error) do
39+
OpenSSL::PKCS12.new(p12.to_der, "wrong")
40+
end
41+
end
42+
43+
def test_create_and_parse_with_ca_certs
44+
ca_key = OpenSSL::PKey::RSA.new(2048)
45+
ca_cert = issue_cert(cn: "CA", key: ca_key)
46+
leaf_cert = issue_cert(cn: "leaf", issuer: ca_cert, issuer_key: ca_key)
47+
48+
p12 = OpenSSL::PKCS12.create("pass", "myalias", @key, leaf_cert, [ca_cert])
49+
parsed = OpenSSL::PKCS12.new(p12.to_der, "pass")
50+
assert_equal leaf_cert.subject.to_s, parsed.certificate.subject.to_s
51+
assert_equal 1, parsed.ca_certs.size
52+
assert_equal ca_cert.subject.to_s, parsed.ca_certs.first.subject.to_s
53+
end
54+
55+
private
56+
57+
def issue_cert(cn: "test", key: nil, issuer: nil, issuer_key: nil)
58+
key ||= @key
59+
cert = OpenSSL::X509::Certificate.new
60+
cert.version = 2
61+
cert.serial = 1
62+
cert.subject = OpenSSL::X509::Name.parse("/CN=#{cn}")
63+
cert.issuer = issuer ? issuer.subject : cert.subject
64+
cert.not_before = Time.now
65+
cert.not_after = Time.now + 3600
66+
cert.public_key = key.public_key
67+
cert.sign(issuer_key || key, OpenSSL::Digest::SHA256.new)
68+
cert
69+
end
70+
71+
end

0 commit comments

Comments
 (0)