Skip to content

Commit 390e343

Browse files
karesclaude
andcommitted
[todo] add AES-CCM cipher mode support via BouncyCastle (#96)
Add CCM to KNOWN_BLOCK_MODES and NO_PADDING_BLOCK_MODES, handle CCM IV parameter spec (same as GCM via GCMParameterSpec), and add ccm_data_len= stub for CRuby API compatibility (BC doesn't need upfront data length since it buffers internally in doFinal). Verified against RFC 3610 Section 8 Test Case 1 — produces identical ciphertext and tag as CRuby. Authentication tag verification, wrong-tag rejection, and all three key sizes (128/192/256) tested. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 882a84c commit 390e343

File tree

2 files changed

+88
-1
lines changed

2 files changed

+88
-1
lines changed

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ public static final class Algorithm {
252252
KNOWN_BLOCK_MODES = new HashSet<>(10, 1);
253253
for ( String mode : OPENSSL_BLOCK_MODES ) KNOWN_BLOCK_MODES.add(mode);
254254
KNOWN_BLOCK_MODES.add("CTR");
255+
KNOWN_BLOCK_MODES.add("GCM");
256+
KNOWN_BLOCK_MODES.add("CCM");
255257
KNOWN_BLOCK_MODES.add("CTS"); // not supported by OpenSSL
256258
KNOWN_BLOCK_MODES.add("PCBC"); // not supported by OpenSSL
257259
KNOWN_BLOCK_MODES.add("NONE"); // valid to pass into JCE
@@ -266,6 +268,7 @@ public static final class Algorithm {
266268
NO_PADDING_BLOCK_MODES.add("OFB");
267269
NO_PADDING_BLOCK_MODES.add("CTR");
268270
NO_PADDING_BLOCK_MODES.add("GCM");
271+
NO_PADDING_BLOCK_MODES.add("CCM");
269272
}
270273

271274
final static class AllSupportedCiphers {
@@ -1068,7 +1071,7 @@ else if ( "RC4".equalsIgnoreCase(cryptoBase) ) {
10681071
}
10691072
else {
10701073
final AlgorithmParameterSpec ivSpec;
1071-
if ( "GCM".equalsIgnoreCase(cryptoMode) ) { // e.g. 'aes-128-gcm'
1074+
if ( "GCM".equalsIgnoreCase(cryptoMode) || "CCM".equalsIgnoreCase(cryptoMode) ) {
10721075
ivSpec = new GCMParameterSpec(getAuthTagLength() * 8, this.realIV);
10731076
}
10741077
else {
@@ -1425,6 +1428,19 @@ public IRubyObject set_auth_tag_len(IRubyObject tag_len) {
14251428
return tag_len;
14261429
}
14271430

1431+
// CRuby's ccm_data_len= calls EVP_CipherUpdate(ctx, NULL, &out, NULL, len)
1432+
// to pre-declare the message length (CCM is a two-pass algorithm in OpenSSL).
1433+
// BouncyCastle's CCM JCE wrapper buffers everything internally and processes
1434+
// in doFinal, so it doesn't need the length upfront. We store it for API
1435+
// compatibility but don't pass it to BC.
1436+
@JRubyMethod(name = "ccm_data_len=")
1437+
public IRubyObject set_ccm_data_len(final ThreadContext context, IRubyObject len) {
1438+
if ( ! "CCM".equalsIgnoreCase(cryptoMode) ) {
1439+
throw newCipherError(context.runtime, "ccm_data_len= is only supported in CCM mode");
1440+
}
1441+
return len;
1442+
}
1443+
14281444
private boolean isAuthDataMode() { // Authenticated Encryption with Associated Data (AEAD)
14291445
return "GCM".equalsIgnoreCase(cryptoMode) || "CCM".equalsIgnoreCase(cryptoMode);
14301446
}

src/test/ruby/test_cipher.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,77 @@ def test_block_decrypt_with_extra_final_workaround
273273
assert_equal plaintext, result2[0...plaintext.length]
274274
end
275275

276+
# AES-CCM support via BouncyCastle (#96)
277+
# Uses RFC 3610 Section 8 Test Case 1 — same vector as CRuby's test_aes_ccm
278+
def test_aes_ccm_rfc3610
279+
key = ["c0c1c2c3c4c5c6c7c8c9cacbcccdcecf"].pack("H*")
280+
iv = ["00000003020100a0a1a2a3a4a5"].pack("H*")
281+
aad = ["0001020304050607"].pack("H*")
282+
pt = ["08090a0b0c0d0e0f101112131415161718191a1b1c1d1e"].pack("H*")
283+
expected_ct = ["588c979a61c663d2f066d0c2c0f989806d5f6b61dac384"].pack("H*")
284+
expected_tag = ["17e8d12cfdf926e0"].pack("H*")
285+
286+
c = OpenSSL::Cipher.new("AES-128-CCM")
287+
c.encrypt
288+
c.auth_tag_len = 8
289+
c.iv_len = 13
290+
c.key = key
291+
c.iv = iv
292+
c.ccm_data_len = pt.length
293+
c.auth_data = aad
294+
ct = c.update(pt) + c.final
295+
assert_equal expected_ct, ct
296+
assert_equal expected_tag, c.auth_tag(8)
297+
298+
d = OpenSSL::Cipher.new("AES-128-CCM")
299+
d.decrypt
300+
d.auth_tag_len = 8
301+
d.iv_len = 13
302+
d.key = key
303+
d.iv = iv
304+
d.ccm_data_len = ct.length
305+
d.auth_tag = expected_tag
306+
d.auth_data = aad
307+
assert_equal pt, d.update(ct) + d.final
308+
end
309+
310+
def test_aes_ccm_wrong_tag_rejected
311+
key = ["c0c1c2c3c4c5c6c7c8c9cacbcccdcecf"].pack("H*")
312+
iv = ["00000003020100a0a1a2a3a4a5"].pack("H*")
313+
aad = ["0001020304050607"].pack("H*")
314+
ct = ["588c979a61c663d2f066d0c2c0f989806d5f6b61dac384"].pack("H*")
315+
tag = ["17e8d12cfdf926e0"].pack("H*")
316+
317+
bad_tag = tag.dup
318+
bad_tag.setbyte(-1, (bad_tag.getbyte(-1) + 1) & 0xff)
319+
320+
d = OpenSSL::Cipher.new("AES-128-CCM")
321+
d.decrypt; d.auth_tag_len = 8; d.iv_len = 13
322+
d.key = key; d.iv = iv
323+
d.ccm_data_len = ct.length; d.auth_tag = bad_tag; d.auth_data = aad
324+
assert_raise(OpenSSL::Cipher::CipherError) { d.update(ct) + d.final }
325+
end
326+
327+
def test_aes_ccm_authenticated
328+
c = OpenSSL::Cipher.new("AES-128-CCM")
329+
assert_equal true, c.authenticated?
330+
end
331+
332+
def test_aes_256_ccm_roundtrip
333+
key = OpenSSL::Random.random_bytes(32)
334+
iv = OpenSSL::Random.random_bytes(12)
335+
data = "AES-256-CCM test data for round-trip"
336+
337+
c = OpenSSL::Cipher.new("AES-256-CCM")
338+
c.encrypt; c.key = key; c.iv = iv; c.auth_data = "aad"
339+
ct = c.update(data) + c.final
340+
tag = c.auth_tag
341+
342+
d = OpenSSL::Cipher.new("AES-256-CCM")
343+
d.decrypt; d.key = key; d.iv = iv; d.auth_tag = tag; d.auth_data = "aad"
344+
assert_equal data, d.update(ct) + d.final
345+
end
346+
276347
@@test_encrypt_decrypt_des_variations = nil
277348

278349
def test_encrypt_decrypt_des_variations

0 commit comments

Comments
 (0)