Skip to content

Commit 882a84c

Browse files
karesclaude
andcommitted
[todo] Cipher#update flushes complete blocks (#183)
OpenSSL's EVP_EncryptUpdate outputs ciphertext as soon as a full block is available. Java's Cipher.update with PKCS5Padding holds back the last complete block for potential padding merge in doFinal; for padded block cipher encryption (CBC, ECB), switch the JCE cipher to NoPadding and manage partial-block buffering ourselves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9640a93 commit 882a84c

2 files changed

Lines changed: 250 additions & 9 deletions

File tree

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

Lines changed: 135 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,12 @@ private void doInitCipher(final Ruby runtime) {
10381038
if ( key == null ) { //key = emptyKey(keyLength);
10391039
throw newCipherError(runtime, "key not specified");
10401040
}
1041+
// If a previous encrypt pass swapped in a NoPadding cipher, restore
1042+
// the original PKCS5Padding cipher before re-initializing.
1043+
if ( updateBuffer != null ) {
1044+
this.cipher = getCipherInstance();
1045+
updateBuffer = null;
1046+
}
10411047
try {
10421048
// ECB mode is the only mode that does not require an IV
10431049
if ( "ECB".equalsIgnoreCase(cryptoMode) ) {
@@ -1084,6 +1090,35 @@ else if ( "RC4".equalsIgnoreCase(cryptoBase) ) {
10841090
}
10851091
cipherInited = true;
10861092
processedDataBytes = 0;
1093+
1094+
if ( needsManualBlockBuffering() ) {
1095+
// Switch JCE cipher to NoPadding so it flushes complete blocks
1096+
// immediately. We'll add PKCS7 padding ourselves in do_final.
1097+
final int blockSize = cipher.getBlockSize();
1098+
if ( blockSize > 0 ) {
1099+
final String noPadName = realName.replace("PKCS5Padding", "NoPadding");
1100+
try {
1101+
javax.crypto.Cipher noPad = getCipherInstance(noPadName, false);
1102+
if ( "ECB".equalsIgnoreCase(cryptoMode) ) {
1103+
noPad.init(ENCRYPT_MODE, new SimpleSecretKey(getCipherAlgorithm(), this.key));
1104+
} else {
1105+
noPad.init(ENCRYPT_MODE,
1106+
new SimpleSecretKey(getCipherAlgorithm(), this.key),
1107+
new IvParameterSpec(this.realIV));
1108+
}
1109+
this.cipher = noPad;
1110+
updateBuffer = new byte[blockSize];
1111+
updateBufferPos = 0;
1112+
}
1113+
catch (Exception e) {
1114+
// fall back to default behavior if NoPadding variant unavailable
1115+
debugStackTrace(runtime, e);
1116+
updateBuffer = null;
1117+
}
1118+
}
1119+
} else {
1120+
updateBuffer = null;
1121+
}
10871122
}
10881123

10891124
private String getCipherAlgorithm() {
@@ -1094,6 +1129,15 @@ private String getCipherAlgorithm() {
10941129
private int processedDataBytes = 0;
10951130
private byte[] lastIV;
10961131

1132+
// C OpenSSL's EVP_EncryptUpdate flushes complete blocks immediately,
1133+
// while Java's Cipher.update with PKCS5Padding holds back the last
1134+
// complete block for potential padding merge in doFinal. We buffer
1135+
// partial blocks ourselves and feed only complete blocks to Java
1136+
// (configured with NoPadding), adding PKCS7 padding manually in final.
1137+
// This is only active for padded block cipher encryption (CBC, ECB).
1138+
private byte[] updateBuffer;
1139+
private int updateBufferPos;
1140+
10971141
@JRubyMethod
10981142
public IRubyObject update(final ThreadContext context, final IRubyObject arg) {
10991143
return update(context, arg, null);
@@ -1122,19 +1166,26 @@ public IRubyObject update(final ThreadContext context, final IRubyObject arg, IR
11221166

11231167
final byte[] in = data.getUnsafeBytes();
11241168
final int offset = data.begin();
1125-
final byte[] out = cipher.update(in, offset, length);
1126-
if ( out != null ) {
1127-
str = new ByteList(out, false);
1128-
if ( realIV != null ) {
1129-
if ( encryptMode ) setLastIVIfNeeded( out );
1130-
else setLastIVIfNeeded( in, offset, length );
1131-
}
11321169

1133-
processedDataBytes += length;
1170+
if ( updateBuffer != null ) {
1171+
// Manual block buffering for padded block cipher encryption.
1172+
// Feed only complete blocks to NoPadding cipher, buffer the rest.
1173+
str = updateWithManualBuffering(in, offset, length);
11341174
}
11351175
else {
1136-
str = new ByteList(ByteList.NULL_ARRAY);
1176+
final byte[] out = cipher.update(in, offset, length);
1177+
if ( out != null ) {
1178+
str = new ByteList(out, false);
1179+
if ( realIV != null ) {
1180+
if ( encryptMode ) setLastIVIfNeeded( out );
1181+
else setLastIVIfNeeded( in, offset, length );
1182+
}
1183+
}
1184+
else {
1185+
str = new ByteList(ByteList.NULL_ARRAY);
1186+
}
11371187
}
1188+
processedDataBytes += length;
11381189
}
11391190
catch (Exception e) {
11401191
debugStackTrace( runtime, e );
@@ -1148,6 +1199,48 @@ public IRubyObject update(final ThreadContext context, final IRubyObject arg, IR
11481199
return buffer;
11491200
}
11501201

1202+
private ByteList updateWithManualBuffering(final byte[] in, int offset, int length) {
1203+
final int blockSize = updateBuffer.length;
1204+
// Merge input with any previously buffered partial block
1205+
final int total = updateBufferPos + length;
1206+
final int fullBlocks = total / blockSize;
1207+
final int remainder = total % blockSize;
1208+
1209+
if ( fullBlocks == 0 ) {
1210+
// Not enough for a complete block — just buffer
1211+
System.arraycopy(in, offset, updateBuffer, updateBufferPos, length);
1212+
updateBufferPos = total;
1213+
return new ByteList(ByteList.NULL_ARRAY);
1214+
}
1215+
1216+
// Build a byte array of complete blocks to feed to the cipher
1217+
final byte[] toEncrypt = new byte[fullBlocks * blockSize];
1218+
int pos = 0;
1219+
// First copy any previously buffered bytes
1220+
if ( updateBufferPos > 0 ) {
1221+
System.arraycopy(updateBuffer, 0, toEncrypt, 0, updateBufferPos);
1222+
pos = updateBufferPos;
1223+
}
1224+
// Then copy from input up to the full-block boundary
1225+
final int fromInput = fullBlocks * blockSize - pos;
1226+
System.arraycopy(in, offset, toEncrypt, pos, fromInput);
1227+
offset += fromInput;
1228+
1229+
// Buffer the remainder for next update/final
1230+
if ( remainder > 0 ) {
1231+
System.arraycopy(in, offset, updateBuffer, 0, remainder);
1232+
}
1233+
updateBufferPos = remainder;
1234+
1235+
// Encrypt complete blocks (NoPadding flushes immediately)
1236+
final byte[] out = cipher.update(toEncrypt);
1237+
if ( out != null ) {
1238+
if ( realIV != null ) setLastIVIfNeeded(out);
1239+
return new ByteList(out, false);
1240+
}
1241+
return new ByteList(ByteList.NULL_ARRAY);
1242+
}
1243+
11511244
@JRubyMethod(name = "<<")
11521245
public IRubyObject update_deprecated(final ThreadContext context, final IRubyObject data) {
11531246
context.runtime.getWarnings().warn(ID.DEPRECATED_METHOD, getMetaClass().getRealClass().getName() + "#<< is deprecated; use #update instead");
@@ -1170,6 +1263,9 @@ public IRubyObject do_final(final ThreadContext context) {
11701263
if ( isAuthDataMode() ) {
11711264
str = do_final_with_auth(runtime);
11721265
}
1266+
else if ( updateBuffer != null ) {
1267+
str = do_final_with_manual_padding();
1268+
}
11731269
else {
11741270
final byte[] out = cipher.doFinal();
11751271
if ( out != null ) {
@@ -1209,6 +1305,27 @@ public IRubyObject do_final(final ThreadContext context) {
12091305
return RubyString.newString(runtime, str);
12101306
}
12111307

1308+
private ByteList do_final_with_manual_padding() throws GeneralSecurityException {
1309+
// Add PKCS7 padding to buffered partial block and encrypt.
1310+
// If buffer is empty (0 bytes remaining), a full padding block is added.
1311+
final int blockSize = updateBuffer.length;
1312+
final int padLen = blockSize - updateBufferPos;
1313+
final byte[] lastBlock = new byte[blockSize];
1314+
if ( updateBufferPos > 0 ) {
1315+
System.arraycopy(updateBuffer, 0, lastBlock, 0, updateBufferPos);
1316+
}
1317+
// PKCS7: fill remaining bytes with the pad length value
1318+
java.util.Arrays.fill(lastBlock, updateBufferPos, blockSize, (byte) padLen);
1319+
1320+
final byte[] out = cipher.doFinal(lastBlock);
1321+
updateBufferPos = 0;
1322+
if ( out != null ) {
1323+
if ( realIV != null ) setLastIVIfNeeded(out);
1324+
return new ByteList(out, false);
1325+
}
1326+
return new ByteList(ByteList.NULL_ARRAY);
1327+
}
1328+
12121329
private ByteList do_final_with_auth(final Ruby runtime) throws GeneralSecurityException {
12131330
updateAuthData(runtime); // if any
12141331

@@ -1387,6 +1504,15 @@ private boolean isStreamCipher() {
13871504
return cipher.getBlockSize() == 0;
13881505
}
13891506

1507+
// True when we need manual block buffering to match C OpenSSL's
1508+
// EVP_EncryptUpdate flush behavior (see updateBuffer field comment).
1509+
private boolean needsManualBlockBuffering() {
1510+
return encryptMode
1511+
&& "PKCS5Padding".equals(paddingType)
1512+
&& !isStreamCipher()
1513+
&& !isAuthDataMode();
1514+
}
1515+
13901516
private static RaiseException newCipherError(Ruby runtime, Exception e) {
13911517
return Utils.newError(runtime, _Cipher(runtime).getClass("CipherError"), e);
13921518
}

src/test/ruby/test_cipher.rb

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,121 @@ def test_reset_produces_same_ciphertext
158158
assert_equal ct1, ct2
159159
end
160160

161+
# GH-183: cipher.update should flush complete blocks immediately,
162+
# matching C OpenSSL's EVP_EncryptUpdate behavior.
163+
def test_update_flushes_complete_blocks_cbc
164+
key = "0123456789abcdef"
165+
iv = "fedcba9876543210"
166+
[
167+
[1, 0], [15, 0], [16, 16], [17, 16],
168+
[32, 32], [48, 48], [31, 16], [33, 32],
169+
].each do |input_len, expected_output_len|
170+
c = OpenSSL::Cipher.new("AES-128-CBC")
171+
c.encrypt; c.key = key; c.iv = iv
172+
out = c.update("x" * input_len)
173+
assert_equal expected_output_len, out.length,
174+
"update(#{input_len}).length should be #{expected_output_len}"
175+
end
176+
end
177+
178+
def test_update_flushes_complete_blocks_ecb
179+
key = "0123456789abcdef"
180+
[16, 32, 48].each do |n|
181+
c = OpenSSL::Cipher.new("AES-128-ECB")
182+
c.encrypt; c.key = key
183+
assert_equal n, c.update("x" * n).length,
184+
"ECB update(#{n}).length should be #{n}"
185+
end
186+
end
187+
188+
def test_update_multi_chunk_accumulation
189+
key = "0123456789abcdef"
190+
iv = "fedcba9876543210"
191+
c = OpenSSL::Cipher.new("AES-128-CBC")
192+
c.encrypt; c.key = key; c.iv = iv
193+
# 5 bytes: no output (partial block)
194+
assert_equal 0, c.update("x" * 5).length
195+
# 11 more bytes (total 16): flush one block
196+
assert_equal 16, c.update("y" * 11).length
197+
# 3 more bytes: no output (partial block)
198+
assert_equal 0, c.update("z" * 3).length
199+
# final: flush padded last block
200+
assert_equal 16, c.final.length
201+
end
202+
203+
def test_update_roundtrip_after_fix
204+
key = "0123456789abcdef"
205+
iv = "fedcba9876543210"
206+
data = "The quick brown fox jumps over the lazy dog!"
207+
c = OpenSSL::Cipher.new("AES-128-CBC")
208+
c.encrypt; c.key = key; c.iv = iv
209+
ct = c.update(data) + c.final
210+
211+
d = OpenSSL::Cipher.new("AES-128-CBC")
212+
d.decrypt; d.key = key; d.iv = iv
213+
pt = d.update(ct) + d.final
214+
assert_equal data, pt
215+
end
216+
217+
# Ensure encrypt→decrypt on the same object still works
218+
def test_encrypt_then_decrypt_same_object
219+
key = "0123456789abcdef"
220+
iv = "fedcba9876543210"
221+
data = "hello world!!!!!" # 16 bytes
222+
223+
c = OpenSSL::Cipher.new("AES-128-CBC")
224+
c.encrypt; c.key = key; c.iv = iv
225+
ct = c.update(data) + c.final
226+
227+
c.decrypt; c.key = key; c.iv = iv
228+
pt = c.update(ct) + c.final
229+
assert_equal data, pt
230+
end
231+
232+
# GCM round-trip should not be affected by the buffering change
233+
def test_gcm_roundtrip_not_affected
234+
key = "0123456789abcdef"
235+
iv = "0123456789ab"
236+
data = "hello world"
237+
c = OpenSSL::Cipher.new("AES-128-GCM")
238+
c.encrypt; c.key = key; c.iv = iv; c.auth_data = "aad"
239+
ct = c.update(data) + c.final
240+
tag = c.auth_tag
241+
242+
d = OpenSSL::Cipher.new("AES-128-GCM")
243+
d.decrypt; d.key = key; d.iv = iv; d.auth_data = "aad"; d.auth_tag = tag
244+
pt = d.update(ct) + d.final
245+
assert_equal data, pt
246+
end
247+
248+
# Workaround from rubyzip (hainesr/rubyzip@3567ff4) added cipher.final
249+
# after block-by-block decryption on JRuby. With our fix this workaround
250+
# must remain harmless — final should return empty on both platforms.
251+
def test_block_decrypt_with_extra_final_workaround
252+
key = "0123456789abcdef"
253+
iv = "fedcba9876543210"
254+
plaintext = "Hello World!! :)" * 4 # 64 bytes = 4 blocks
255+
256+
enc = OpenSSL::Cipher.new("AES-128-CBC")
257+
enc.encrypt; enc.key = key; enc.iv = iv
258+
ct = enc.update(plaintext) + enc.final
259+
260+
# Block-by-block decrypt with padding=0 and explicit final (the workaround)
261+
dec = OpenSSL::Cipher.new("AES-128-CBC")
262+
dec.decrypt; dec.key = key; dec.iv = iv; dec.padding = 0
263+
result = ""
264+
(0...ct.length).step(16) { |i| result << dec.update(ct[i, 16]) }
265+
result << dec.final # should be harmless (empty)
266+
assert_equal plaintext, result[0...plaintext.length]
267+
268+
# Same without final — should also work
269+
dec2 = OpenSSL::Cipher.new("AES-128-CBC")
270+
dec2.decrypt; dec2.key = key; dec2.iv = iv; dec2.padding = 0
271+
result2 = ""
272+
(0...ct.length).step(16) { |i| result2 << dec2.update(ct[i, 16]) }
273+
assert_equal plaintext, result2[0...plaintext.length]
274+
end
275+
161276
@@test_encrypt_decrypt_des_variations = nil
162277

163278
def test_encrypt_decrypt_des_variations

0 commit comments

Comments
 (0)