Skip to content

feat: pluggable SignalCryptoProvider for AES-CBC/GCM + HMAC-SHA256 - #561

Merged
jlucaso1 merged 3 commits into
mainfrom
crypto-provider
Apr 17, 2026
Merged

feat: pluggable SignalCryptoProvider for AES-CBC/GCM + HMAC-SHA256#561
jlucaso1 merged 3 commits into
mainfrom
crypto-provider

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Summary

  • Adds wacore_libsignal::crypto::SignalCryptoProvider — a pluggable trait covering the 5 symmetric primitives actually used across the codebase: AES-256-CBC enc/dec, AES-256-GCM enc/dec, HMAC-SHA256. Same OnceLock + set_*_provider pattern already in use by wacore::time::TimeProvider.
  • Default RustCryptoProvider preserves current behavior. No call-site visible change; no new deps.
  • Unifies every caller onto the provider: libsignal CBC free fns, the ratchet hmac_sha256, and the GCM users in wacore/noise, wacore/src/{poll,pair,pair_code,media_retry}. The bridge can now install a node:crypto-backed provider in a follow-up PR without touching any consumer code.

Why

Profile of messaging steady-state showed ~5–6% CPU in AES/SHA soft primitives (fixslice AES + sha2::compress256). Most of the AES time is GCM (Noise rekey/encrypt/decrypt + media retry + pair) — moving to Node's OpenSSL-backed createCipheriv/createHmac is meaningfully faster, but only worthwhile if it can land without forking call sites. The provider trait lets the bridge install native primitives once at initWasmEngine and have every caller benefit transparently.

Changes

New

  • wacore/libsignal/src/crypto/provider.rs — trait, CryptoProviderError, RustCryptoProvider default, set_crypto_provider, internal provider().

Delegated to provider (behavior preserved)

  • aes_256_cbc_encrypt_into / _decrypt_into (free functions)
  • protocol::crypto::hmac_sha256
  • New free fns: aes_256_gcm_encrypt, aes_256_gcm_decrypt, hmac_sha256 in wacore_libsignal::crypto.

Migrated callers

  • wacore/noise/src/state.rs — hot path. To preserve the generic `decrypt_in_place_with_counter`, adds a small NoiseBuffer trait (impl'd for Vec<u8> and bytes::BytesMut) replacing the previous aes_gcm::aead::Buffer bound.
  • wacore/src/{poll,pair,pair_code,media_retry}.rs.

Dep cleanup (via cargo shear)

  • wacore-libsignal/Cargo.toml: drop aes-gcm (was never used — the crate name shadowed the local crypto::aes_gcm module, so cargo-shear had missed it earlier).
  • wacore-noise/Cargo.toml: drop aes-gcm.
  • wacore/Cargo.toml: aes-gcm moved to [dev-dependencies] (only tests/noise_handshake_test.rs keeps using it, as a cross-check reference vector).
  • Drop now-orphan pub use aes_gcm; re-exports in wacore::lib and wacore_noise::lib.

Non-goals (future PR)

  • The bridge's JsCryptoProvider (node:crypto over js_sys::Function). Trait is shaped for it but not wired yet.
  • Streaming CryptographicMac stays on RustCrypto. Appstate LT-Hash / ratchet HMAC inputs are too small (<128 B) for the WASM↔JS boundary cost to be worth it.

Test plan

  • cargo test --all --exclude e2e-tests — 1000+ tests pass (libsignal 107, noise 23, appstate 375, wacore 81, whatsapp-rust 515).
  • cargo clippy --all --tests — no new warnings.
  • cargo fmt --all, cargo sort -w clean.
  • NIST SP 800-38D TC14 reference vector added for the provider's GCM path.
  • CI green.

Introduces a global provider trait (wacore_libsignal::crypto::SignalCryptoProvider)
that centralizes the 5 symmetric primitives used across the codebase:
AES-256-CBC encrypt/decrypt, AES-256-GCM encrypt/decrypt, HMAC-SHA256.

Default RustCryptoProvider preserves current behavior byte-for-byte (all
1000+ tests green, incl. NIST KATs). The OnceLock-backed global follows
the same pattern as wacore::time::TimeProvider so the bridge can swap in
a native implementation (node:crypto over wasm-bindgen) without touching
any caller.

Callers unified:
- wacore/noise (hot path) — new NoiseBuffer trait keeps Vec+BytesMut
  callers working without exposing aes_gcm::aead::Buffer.
- wacore/src: poll, pair, pair_code, media_retry.

Drops the external aes-gcm crate from wacore-libsignal (was already
unused — name collided with the internal crypto::aes_gcm module), from
wacore-noise, and from wacore's [dependencies] (kept in [dev-dependencies]
for the noise_handshake_test reference vector). Also drops the now-unused
pub use aes_gcm re-export in wacore::lib and wacore_noise::lib.
@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Summary by CodeRabbit

  • Refactoring

    • Centralized cryptography behind a pluggable provider and migrated AES‑GCM/AES‑CBC/HMAC usage to shared crypto helpers.
    • Replaced direct cipher instances with key/nonce-based helper calls across the codebase; updated buffer sizing and in-place encryption/decryption handling.
  • Chores

    • Removed public re-exports of the previous AES‑GCM crate and moved aes‑gcm to dev-only dependencies.
  • Tests

    • Added unit tests for CBC/GCM roundtrips, authentication-failure behavior, and HMAC correctness.

Walkthrough

This PR adds a pluggable Signal crypto provider, moves AES‑GCM/AES‑CBC/HMAC logic to that provider, and updates callers across wacore and noise to use libsignal::crypto helpers; it also removes aes-gcm from production dependencies and adjusts manifests accordingly.

Changes

Cohort / File(s) Summary
Dependency manifests
wacore/Cargo.toml, wacore/libsignal/Cargo.toml, wacore/noise/Cargo.toml
Removed aes-gcm from normal [dependencies] (moved to dev-deps in wacore); added/adjusted workspace-managed deps (e.g., bytes) in libsignal manifest.
Crypto provider implementation
wacore/libsignal/src/crypto/provider.rs
New pluggable SignalCryptoProvider trait, CryptoProviderError, global OnceLock registry, set_crypto_provider/provider accessors, RustCryptoProvider default backend, CBC/GCM/HMAC implementations, in-place helpers, and unit tests.
Crypto module surface
wacore/libsignal/src/crypto/mod.rs
Exposes provider types/re-exports and adds wrapper APIs: aes_256_gcm_* (allocating + in-place), hmac_sha256; reduces aes_gcm visibility to pub(crate).
CBC implementation
wacore/libsignal/src/crypto/aes_cbc.rs
Replaced local AES‑256‑CBC/Pkcs7 implementation with delegations to provider().aes_256_cbc_* (converts slices to fixed-size arrays and maps provider errors).
Protocol HMAC usage
wacore/libsignal/src/protocol/crypto.rs
Removed local HMAC code; now delegates to crate::crypto::hmac_sha256.
Noise protocol migration
wacore/noise/src/lib.rs, wacore/noise/src/state.rs
Removed aes_gcm re-export; replaced Aes256Gcm fields with raw [u8;32] keys; updated encrypt/decrypt to call libsignal provider helpers and added NoiseBuffer trait aliasing GcmInPlaceBuffer.
wacore call sites
wacore/src/lib.rs, wacore/src/media_retry.rs, wacore/src/pair.rs, wacore/src/pair_code.rs, wacore/src/poll.rs
Removed crate-level aes_gcm re-export and replaced direct aes-gcm usage with libsignal::crypto helpers; adjusted buffer sizing, capacity planning, and error mappings at call sites.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Encryption Caller
    participant API as libsignal::crypto helper
    participant Registry as Provider Registry
    participant Provider as SignalCryptoProvider
    participant Backend as RustCrypto Primitives

    Caller->>API: aes_256_gcm_encrypt(key, nonce, aad, plaintext, &mut out)
    API->>Registry: provider()
    Registry-->>API: &dyn SignalCryptoProvider
    API->>Provider: aes_256_gcm_encrypt(key, nonce, aad, plaintext, out)
    Provider->>Backend: perform AES-GCM encrypt (primitive)
    Backend-->>Provider: ciphertext || tag
    Provider-->>API: Ok(()) / Err(CryptoProviderError)
    API-->>Caller: propagate result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: pluggable SignalCryptoProvider for AES-CBC/GCM + HMAC-SHA256' clearly and specifically summarizes the main change: adding a pluggable crypto provider trait covering the key symmetric primitives.
Description check ✅ Passed The description thoroughly relates to the changeset, explaining the motivation for the provider pattern, its implementation details, migrated call sites, and dependency cleanup—all present in the code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch crypto-provider

Comment @coderabbitai help to get the list of available commands and usage tips.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review this nitpicky

@claude

claude Bot commented Apr 17, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 13s —— View job


Nitpicky Code Review — PR #561

  • Read all changed files
  • Review provider.rs (new trait + default impl)
  • Review crypto/mod.rs changes
  • Review aes_cbc.rs changes
  • Review protocol/crypto.rs changes
  • Review noise/state.rs changes
  • Review caller migrations (poll, pair, pair_code, media_retry)
  • Review Cargo.toml dep changes
  • Review lib.rs cleanup
  • Post final review

Overall this is a clean, well-structured PR. The trait design is solid, the migration is mechanical and correct, and the dep cleanup is a nice bonus. Here are the nits:


1. CryptoProviderError::BackendFailed is dead code

wacore/libsignal/src/crypto/provider.rs:24BackendFailed is defined but never constructed anywhere in this PR. The RustCryptoProvider only returns BadInput and AuthFailed. If this is exclusively for the future JsCryptoProvider, consider leaving it out until that PR to avoid a #[allow(dead_code)] or a Clippy warning. Or add #[allow(dead_code)] with a note.


2. NoiseBuffer trait is pub but not re-exported

wacore/noise/src/state.rs:9NoiseBuffer is declared pub but wacore/noise/src/lib.rs:49 only re-exports NoiseCipher, NoiseKeys, NoiseState, generate_iv. External consumers that need to implement NoiseBuffer for a new buffer type can't reach it. Since the only callers pass Vec<u8> and BytesMut (both already impl'd), this is fine functionally, but the pub visibility is misleading — either re-export it from lib.rs or make it pub(crate).


3. encrypt_in_place_with_counter does a take + re-allocate

wacore/noise/src/state.rs:71:

let plaintext = std::mem::take(buffer);
aes_256_gcm_encrypt(&self.key, &iv, b"", &plaintext, buffer)

std::mem::take empties the vec (capacity goes to 0), then aes_256_gcm_encrypt appends to the now-empty buffer which triggers a fresh allocation. The old buffer's allocation is now in plaintext and gets dropped. This means every call allocates a new buffer and drops the old one, which wasn't the case with the prior encrypt_in_place that truly operated in-place. For the hot-path noise socket (noise_socket.rs:117), this matters.

A straightforward fix: after the encrypt, you could std::mem::swap back, or restructure to reuse the allocation. Alternatively, the provider trait could have an encrypt_in_place method that operates on a single buffer (extend with tag bytes, encrypt the prefix in-place).


4. decrypt_in_place_with_counter also allocates

wacore/noise/src/state.rs:85:

let mut out = Vec::with_capacity(buffer.as_slice().len().saturating_sub(TAG_LEN));
aes_256_gcm_decrypt(&self.key, &iv, b"", buffer.as_slice(), &mut out)?;
buffer.replace_contents(&out);

This allocates a scratch out, decrypts into it, then copies back into buffer. Two allocations + a copy where the old code did zero. Same perf concern as #3 for the noise socket hot path. The RustCryptoProvider::aes_256_gcm_decrypt also allocates a scratch internally (provider.rs:182), so a single decrypt is actually three buffer operations (scratch in provider → out in NoiseCipher → replace_contents into buffer).

I understand the trait needs to be &[u8] → Vec<u8> for the WASM bridge, but it might be worth having an in-place fast-path for RustCryptoProvider that the noise layer can call directly (or just accept the cost as temporary until the JS provider lands).


5. TOCTOU gap in set_crypto_provider vs provider()

wacore/libsignal/src/crypto/provider.rs:83-94 — If thread A calls set_crypto_provider(MyProvider) and thread B concurrently calls provider() (which does get_or_init), thread B might win the race and install RustCryptoProvider as the default, causing thread A's set to fail. The doc says "must be called before any crypto call" which is correct, but the error message "crypto provider already set" is misleading when the default fallback is what raced in. This matches the existing TimeProvider pattern so it's not new, but worth noting in the doc comment: "…including if the default was already lazily initialized by a concurrent crypto call."


6. hmac_sha256 panics on impossible failure

wacore/libsignal/src/crypto/provider.rs:194:

let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(key)
    .expect("HMAC-SHA256 accepts any key length");

This is fine — HMAC truly accepts any key length. But the trait signature returns [u8; 32] (infallible), which means a custom provider that can fail on HMAC has no way to report it. If the JsCryptoProvider can fail (e.g., JS exception), it would need to panic here. Consider whether hmac_sha256 should return Result<[u8; 32], CryptoProviderError> for future-proofing. (Counterargument: HMAC-SHA256 really can't fail with valid inputs, and the call sites are cleaner without ?. This is a judgment call.)


7. Minor: is_multiple_of is nightly-only

wacore/libsignal/src/crypto/provider.rs:131:

if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(16) {

is_multiple_of is #[unstable(feature = "unsigned_is_multiple_of")] — it requires nightly or a sufficiently new stable Rust (stabilized in 1.87). I see it's used elsewhere in the codebase (lthash.rs even has a comment about it), so this is consistent. Just flagging in case you target older MSRVs.


8. Doc comment on aes_256_cbc_decrypt says "out is cleared" — provider contract vs wrapper diverge

The trait doc at provider.rs:42 says:

out is cleared, then filled.

But the free function wrapper aes_cbc.rs:40-51 (aes_256_cbc_decrypt_into) does not clear out — it delegates to the provider which clears it internally. This is fine since the wrapper's doc says "The output buffer is cleared and filled", which matches. However, aes_256_cbc_encrypt in the trait (provider.rs:33) says "Ciphertext appended to out" (append semantics), while _decrypt says "out is cleared". This asymmetry (encrypt appends, decrypt clears) is a footgun for provider implementors. Consider either: (a) making both append, or (b) documenting the asymmetry more prominently in the trait-level doc.


9. NoiseState stores raw key bytes — no zeroization

wacore/noise/src/state.rs:48:

pub struct NoiseCipher {
    key: [u8; 32],
}

The old code stored Aes256Gcm (an expanded key schedule), now it stores raw key bytes. Neither version zeroizes on drop, but raw key material in a [u8; 32] is more likely to be copied/moved by the compiler (it's Copy-eligible). This is a pre-existing concern and not introduced by this PR, but since you're touching the struct, it might be worth adding a Drop impl or zeroize::Zeroize derive if the project cares about key hygiene. Not blocking.


10. Test coverage: no test for set_crypto_provider success path

The tests exercise RustCryptoProvider directly but never test the set_crypto_providerprovider() path. Since OnceLock is process-global, testing this in the standard test harness is awkward (test ordering matters), but a single integration test that calls set_crypto_provider with a mock and verifies the delegate is invoked would increase confidence. Not blocking.


Summary

This is well-executed. The trait boundary is clean, the migration is mechanical and correct, the NIST test vector is a nice touch, and the dep cleanup via cargo shear is appreciated. The main substantive concern is #3/#4 — the in-place operations now allocate where they didn't before, which regresses the hot-path noise socket. Everything else is nit-level.

@github-actions

github-actions Bot commented Apr 17, 2026

Copy link
Copy Markdown

Benchmark Results

59 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,514 43,414 +0.2%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,579 68,478 +0.1%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,679 76,578 +0.1%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 169,106 170,006 -0.5%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 190,994 191,898 -0.5%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 874,496 875,252 -0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 966,199 967,124 -0.1%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,452,456 1,453,945 -0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,575,169 2,585,284 -0.4%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,376,162 9,422,688 -0.5%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 44,285,083 44,520,388 -0.5%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,687,594 12,495,869 +1.5%
binary_benchmark::marshal_group::bench_marshal_allocating 71,247 71,247 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,300 71,300 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,367 98,367 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,801 78,801 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,347 71,347 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,518 7,518 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,561 7,561 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,273 9,273 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,504 530,504 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,072 530,072 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,427 531,427 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,160 8,506,160 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,412 8,450,412 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,677,947 19,677,947 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,468 2,468 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 33,558 33,558 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 787 787 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 526,732 526,732 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 4,986 4,986 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,315 5,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 61,874 61,874 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,347 5,347 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 61,942 61,942 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 85,564 85,564 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,563 11,563 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,337,513 17,299,311 +0.2%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,113 157,923 -0.5%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,510,200 5,511,084 -0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 157,827 158,737 -0.6%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,767 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 706,282 707,098 -0.1%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,507,978 12,652,333 -1.1%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,636,725 27,679,823 -0.2%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 125,461,643 124,567,483 +0.7%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,003 46,003 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,072,844 5,090,932 -0.4%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 316,083 316,987 -0.3%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,255,917 14,254,317 +0.0%
No significant changes detected.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a7232a2e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/noise/src/state.rs Outdated
Comment on lines +71 to +72
let plaintext = std::mem::take(buffer);
aes_256_gcm_encrypt(&self.key, &iv, b"", &plaintext, buffer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep in-place encrypt from reallocating every frame

encrypt_in_place_with_counter now calls std::mem::take(buffer), which replaces the caller’s reusable Vec with a fresh zero-capacity allocation before encryption. In the send hot path (src/socket/noise_socket.rs uses a reused enc_buf), this drops the preallocated capacity on every message and forces repeated allocate/free cycles, causing a significant throughput/latency regression under sustained traffic.

Useful? React with 👍 / 👎.

Comment thread wacore/noise/src/state.rs Outdated
Comment on lines +85 to +88
let mut out = Vec::with_capacity(buffer.as_slice().len().saturating_sub(TAG_LEN));
aes_256_gcm_decrypt(&self.key, &iv, b"", buffer.as_slice(), &mut out)
.map_err(|e| NoiseError::CryptoError(format!("Decrypt failed: {e}")))?;
buffer.replace_contents(&out);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore in-place decrypt instead of temp-buffer copying

decrypt_in_place_with_counter allocates a new Vec for every call and then copies plaintext back into the original buffer via replace_contents. This removes the previous true in-place behavior, so high-volume receive paths (e.g., decrypt_frame on BytesMut) now pay an extra allocation and full-buffer copy per frame, which is a production-facing performance regression.

Useful? React with 👍 / 👎.

Addresses Codex review P1/P2 on #561:

P1 - encrypt_in_place_with_counter was calling std::mem::take(buffer),
replacing the caller's preallocated Vec (e.g. enc_buf =
Vec::with_capacity(4096) in noise_socket) with a zero-capacity one.
Every subsequent send paid a reallocate/copy.

P2 - decrypt_in_place_with_counter now allocated a scratch Vec and
copied plaintext back via replace_contents, so BytesMut frames on the
hot receive path paid one allocation + a full-buffer memcpy per frame.

Fix: add GcmInPlaceBuffer trait (Vec<u8>, BytesMut) and provider
methods aes_256_gcm_encrypt_in_place / aes_256_gcm_decrypt_in_place.
Default impl keeps current allocating behavior for backward compat.
RustCryptoProvider overrides with truly in-place CTR-XOR + tag append
/ truncate on the caller buffer, preserving capacity.

Empirical before/after on the hot paths:
- encrypt: capacity dropped 4096 -> 120 every iter -> stays at 4096.
- decrypt: 2 allocs per call -> 0 allocs per call (1000 iters).

Tests: libsignal 107, noise 23, appstate 375, wacore 21 pass.
Clippy clean. No API change visible to external callers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/libsignal/src/crypto/mod.rs`:
- Around line 73-82: Update the documentation for the public function
aes_256_gcm_decrypt_in_place to explicitly state the provider contract: on
authentication failure the underlying provider
(provider::provider().aes_256_gcm_decrypt_in_place) may leave `buffer` in an
indeterminate or clobbered state, so callers must not assume the original
ciphertext or any plaintext is preserved and must discard or reinitialize the
buffer after an Err result; keep the existing signature and behavior unchanged,
only expand the doc comment to warn about this failure mode and recommended
caller behavior.

In `@wacore/libsignal/src/crypto/provider.rs`:
- Around line 181-185: The public function set_crypto_provider currently returns
Result<(), &'static str>; replace this with a typed error type (e.g., a new
CryptoProviderError enum) that derives thiserror::Error and Debug and add a
variant like AlreadySet; change the signature of set_crypto_provider(provider:
impl SignalCryptoProvider) -> Result<(), CryptoProviderError>, map the
CRYPTO_PROVIDER.set(...) failure into CryptoProviderError::AlreadySet (or other
variants if needed), and update any callers/tests to handle the new error type;
ensure you add the thiserror dependency and export the error type alongside
set_crypto_provider.

In `@wacore/noise/src/state.rs`:
- Around line 47-54: encrypt_in_place_with_counter is currently fixed to &mut
Vec<u8>, causing unnecessary copies for other buffer types; change its signature
to accept a generic buffer type (e.g., B: NoiseBuffer or whatever
trait/dependency decrypt uses) like pub fn encrypt_in_place_with_counter<B:
NoiseBuffer>(&self, counter: u32, buffer: &mut B) -> Result<()> and update the
body to call generate_iv(counter) and aes_256_gcm_encrypt_in_place(&self.key,
&iv, b"", buffer) (ensuring aes_256_gcm_encrypt_in_place accepts the same
NoiseBuffer trait) and keep the same
map_err(NoiseError::CryptoError(format!("{e}"))) and semantics (in-place
encryption, preserving allocation/capacity).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 67615d72-61e3-4be6-9b09-6bfb63d851f2

📥 Commits

Reviewing files that changed from the base of the PR and between 2a7232a and a4be836.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • wacore/libsignal/Cargo.toml
  • wacore/libsignal/src/crypto/mod.rs
  • wacore/libsignal/src/crypto/provider.rs
  • wacore/noise/src/state.rs

Comment thread wacore/libsignal/src/crypto/mod.rs
Comment on lines +181 to +185
pub fn set_crypto_provider(provider: impl SignalCryptoProvider) -> Result<(), &'static str> {
CRYPTO_PROVIDER
.set(Box::new(provider))
.map_err(|_| "crypto provider already set")
}

@coderabbitai coderabbitai Bot Apr 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use a typed error for provider installation failures.

This new public API falls back to &'static str, which is the wrong shape for a crypto initialization boundary. Make the failure matchable like the rest of this module.

♻️ Proposed fix
+#[derive(Debug, displaydoc::Display, thiserror::Error)]
+pub enum SetCryptoProviderError {
+    /// crypto provider already set
+    AlreadySet,
+}
+
 /// Install a custom provider. Must be called before any crypto call. Returns
 /// `Err` if a provider was already set (including by `get_or_init` of the
 /// default fallback).
-pub fn set_crypto_provider(provider: impl SignalCryptoProvider) -> Result<(), &'static str> {
+pub fn set_crypto_provider(
+    provider: impl SignalCryptoProvider,
+) -> Result<(), SetCryptoProviderError> {
     CRYPTO_PROVIDER
         .set(Box::new(provider))
-        .map_err(|_| "crypto provider already set")
+        .map_err(|_| SetCryptoProviderError::AlreadySet)
 }

As per coding guidelines "Use thiserror for typed errors; use anyhow for multi-failure functions; never use .unwrap() outside tests".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn set_crypto_provider(provider: impl SignalCryptoProvider) -> Result<(), &'static str> {
CRYPTO_PROVIDER
.set(Box::new(provider))
.map_err(|_| "crypto provider already set")
}
#[derive(Debug, displaydoc::Display, thiserror::Error)]
pub enum SetCryptoProviderError {
/// crypto provider already set
AlreadySet,
}
/// Install a custom provider. Must be called before any crypto call. Returns
/// `Err` if a provider was already set (including by `get_or_init` of the
/// default fallback).
pub fn set_crypto_provider(
provider: impl SignalCryptoProvider,
) -> Result<(), SetCryptoProviderError> {
CRYPTO_PROVIDER
.set(Box::new(provider))
.map_err(|_| SetCryptoProviderError::AlreadySet)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/libsignal/src/crypto/provider.rs` around lines 181 - 185, The public
function set_crypto_provider currently returns Result<(), &'static str>; replace
this with a typed error type (e.g., a new CryptoProviderError enum) that derives
thiserror::Error and Debug and add a variant like AlreadySet; change the
signature of set_crypto_provider(provider: impl SignalCryptoProvider) ->
Result<(), CryptoProviderError>, map the CRYPTO_PROVIDER.set(...) failure into
CryptoProviderError::AlreadySet (or other variants if needed), and update any
callers/tests to handle the new error type; ensure you add the thiserror
dependency and export the error type alongside set_crypto_provider.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates an inconsistency with set_time_provider (same signature Result<(), &'static str>). It would only make sense if both were migrated together.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlucaso1 That's a fair point — set_time_provider and set_crypto_provider are intentionally parallel APIs, so breaking their return-type symmetry here would be more confusing than helpful. Migrating both to a typed error in a single follow-up PR is the right call. I'll drop this suggestion.


✏️ Learnings added
Learnt from: jlucaso1
Repo: oxidezap/whatsapp-rust PR: 561
File: wacore/libsignal/src/crypto/provider.rs:181-185
Timestamp: 2026-04-17T17:30:44.592Z
Learning: In the whatsapp-rust repo, `set_crypto_provider` and `set_time_provider` are intentionally parallel APIs and both return `Result<(), &'static str>`. They should be kept consistent with each other; any migration to typed errors should cover both simultaneously.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread wacore/noise/src/state.rs
Addresses 2 of 3 inline suggestions from code review; third (typed
error on set_crypto_provider) rejected as inconsistent with the
identical set_time_provider signature.

- aes_256_gcm_decrypt_in_place: document that AuthFailed leaves the
  buffer with CTR-XOR pseudo-plaintext (not the original ciphertext),
  so callers must discard / treat session as compromised. Empirically
  confirmed: buffer[..pt_len] equals the derived plaintext after a
  corrupted-tag decrypt attempt.

- NoiseCipher::encrypt_in_place_with_counter: relax the fixed
  '&mut Vec<u8>' to '<B: NoiseBuffer>', matching the existing decrypt
  counterpart. Zero-cost via the existing GcmInPlaceBuffer trait;
  BytesMut roundtrips verified with capacity preserved.
@jlucaso1
jlucaso1 merged commit bd698ac into main Apr 17, 2026
13 of 14 checks passed
@jlucaso1
jlucaso1 deleted the crypto-provider branch April 17, 2026 17:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4fd63901d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +48 to +50
provider()
.aes_256_cbc_decrypt(key, iv, ctext, output)
.map_err(|_| DecryptionError::BadCiphertext("failed to decrypt"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve backend failures in CBC decrypt error mapping

When a custom SignalCryptoProvider returns CryptoProviderError::BackendFailed from CBC decrypt, this wrapper collapses it into BadCiphertext("failed to decrypt"), making provider/runtime outages indistinguishable from corrupted ciphertext. In downstream paths (for example protocol/session_cipher.rs), BadCiphertext is handled as an invalid incoming message, so a transient backend failure can cause valid messages to be dropped instead of surfacing an operational crypto-provider error that can be retried or failed fast.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant