feat(ext/node): add AES-CCM cipher support#33089
Conversation
Implements full AES-CCM (RFC 3610) authenticated encryption for 128/192/256-bit keys with streaming support via the node:crypto createCipheriv/createDecipheriv API. Key changes: - Add AesCcmCipher struct implementing RFC 3610 (CBC-MAC + CTR mode) - Add AesBlock wrapper for AES block encryption across key sizes - Support CCM-specific setAAD with plaintextLength option - Buffer data in update() and process in final() (CCM requires knowing plaintext length for CBC-MAC before encryption) - Validate nonce length (7-13 bytes), tag length (4-16 even), and message size limits per RFC 3610 - Enable test-crypto-authenticated.js compat test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…final()
- CipherError now sets `reason` property matching Node.js opensslError
behavior (fixes test-crypto-padding.js)
- Allow decoder re-initialization when encoding changes between update()
and final(), matching Node.js behavior (fixes test-crypto-authenticated.js
which calls decrypt.final('hex') after update with a different encoding)
- Update unit test to match Node.js behavior (no throw on encoding change)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add aes-128-ccm, aes-192-ccm, aes-256-ccm to supportedCiphers list so crypto.getCiphers() includes them and compat tests don't skip CCM - Throw ERR_OSSL_TAG_NOT_SET when CCM cipher.final() is called without setAAD and without data (matching Node.js behavior) - Update getCiphers unit test to handle CCM ciphers (need authTagLength and setAAD with plaintextLength) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Check IV validity before authTagLength requirement for CCM, matching Node.js error priority (bad IV -> "Invalid initialization vector") - Add CCM cipher entries to cipherInfoTable so getCipherInfo returns correct info for aes-128/192/256-ccm - Fix no-explicit-any lint in test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test expects 'authTagLength required for aes-256-ccm' but we were producing ERR_INVALID_ARG_VALUE with a longer message. Use a simple TypeError matching Node.js behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- setAAD plaintextLength validation: distinguish missing (TypeError with 'options.plaintextLength required for CCM mode with AAD'), invalid type (ERR_INVALID_ARG_VALUE), and too large (Error 'Invalid message length') - update() message too long throws 'Invalid message length' - decrypt.final() without setAuthTag throws 'Unsupported state or unable to authenticate data' matching Node.js / state/ regex Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add #[property("code")] to CipherError enum so errors like
InvalidAuthTag include ERR_CRYPTO_INVALID_AUTH_TAG code property
- Fix CCM setAAD plaintextLength validation: missing -> TypeError with
'options.plaintextLength required for CCM mode with AAD', invalid
type/value -> ERR_INVALID_ARG_VALUE, too large -> 'Invalid message
length'
- Fix CCM update() message too long -> 'Invalid message length'
- Fix CCM decrypt.final() without setAuthTag -> 'Unsupported state or
unable to authenticate data'
- Panic when test-compat filter matches no tests instead of silently
passing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
security review pass: • i don't see any new non-constant-time auth tag comparisons in the rust side. the tag checks that are in scope are using • i did find a blocking correctness/security issue though: the PR wires a lot of AES-CCM behavior into the js polyfill, but the rust backend ops don't appear to implement CCM at all. for an AEAD mode that's a problem, because so: constant-time-wise this looks fine, but i don't think the PR is ready yet until CCM is implemented end-to-end in the backend and the js |
|
Thanks for the security review! The constant-time check is good to hear. On the CCM concerns -- I think you may have been looking at
|
| // CCM: buffer data and return empty; actual encryption in final() | ||
| if (this._isCCM) { | ||
| const maxSize = ccmMaxMessageSize(this._ccmIvLength); | ||
| if (this._ccmDataLength + buf.length > maxSize) { |
There was a problem hiding this comment.
Problem: This accepts multiple update() calls in CCM mode by buffering all chunks and processing them in final(), but Node documents/tests CCM as single-shot (update() must be called exactly once).
Why here: The CCM-specific update() path just accumulates into _ccmDataLength / push_data() with no guard against a second call.
Impact: That creates a Node-compat divergence in observable behavior for callers that accidentally stream/chunk CCM input. Node rejects that usage; Deno would silently accept it, so compat tests may still miss a real behavioral mismatch.
Ask: Please add a one-shot guard for CCM update() (cipher + decipher), or add a targeted Node-compat test if the divergence is intentional.
There was a problem hiding this comment.
Good catch. Added a one-shot guard in f528765 -- both Cipher and Decipher now throw "message cannot be fragmented across multiple calls to update" on a second update() call in CCM mode, matching Node's behavior.
miracatbot
left a comment
There was a problem hiding this comment.
Overall, the AES-CCM implementation looks coherent and the Node docs/tests alignment is mostly good, but I found one Node-compat behavior gap around CCM chunking.
Recommendation: comment
Risk: moderate
Prioritized findings:
- CCM currently appears to allow multiple
update()calls by buffering all chunks untilfinal(), while Node documents/tests CCM as single-shot (update()exactly once). I left an inline comment on that path because it changes observable compatibility behavior for callers that stream/chunk input.
I didn’t spot an obvious logic bug in the core AES-CCM flow beyond that compatibility concern.
Node requires exactly one update() call for CCM mode. A second call now throws "message cannot be fragmented across multiple calls to update" matching Node behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
miracatbot
left a comment
There was a problem hiding this comment.
Re-reviewed the updated AES-CCM PR.
The Node-compat gap I called out earlier is addressed now: CCM update() is guarded as single-shot on both cipher and decipher, which matches the Node CCM contract much better.
I don’t see a new high-confidence issue in the updated revision.
Recommendation: approve
Risk: moderate
|
One compatibility concern I’d flag: this replaces the old That means errors like:
no longer have the normal Node error abstraction/prototype behavior, and instead become plain The crypto logic itself looks reasonable to me on this pass, but I think the JS-visible error shape change is worth calling out because callers sometimes do inspect these as Node-style errors, not just by message text. |
Summary
AesCcmCipherstruct with CBC-MAC + CTR mode per RFC 3610setAADwithplaintextLengthoption (required by Node.js API)update()and processes infinal()since CCM requires knowing plaintext length upfront for CBC-MACThis is the last extraction from #32745 -- sign/verify (#33083), GCM (#33079), and ChaCha20-Poly1305 (#33084) are already landed.
Test plan
./x test-compat parallel/test-crypto-authenticated.jspasses./x test-node crypto_cipherpasses (existing GCM + cipher tests)cargo clippy -p deno_node_cryptoclean./tools/format.jsclean./tools/lint.js --jsclean🤖 Generated with Claude Code