Skip to content

perf(libsignal): cache the verify-side Edwards derivations per sender key - #839

Merged
jlucaso1 merged 3 commits into
mainfrom
perf/verify-edwards-cache
Jun 11, 2026
Merged

perf(libsignal): cache the verify-side Edwards derivations per sender key#839
jlucaso1 merged 3 commits into
mainfrom
perf/verify-edwards-cache

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Problem

The receive-side twin of #838. The CodSpeed flamegraph for bench_signature_verification (pulled via the MCP) shows MontgomeryPoint::to_edwards at 267.7 µs and the compression of A at ~120 µs per verification, both built on field inversions, together ~24% of a verify. Both derive solely from the signer's public key and the signature's sign bit, and a given signer always produces the same sign bit. But group_decrypt called signing_key_public() per message, rebuilding the PublicKey from the record's protobuf bytes, so every incoming group message re-derived them.

Change

  • PreparedVerifyingKey: a verifying view of a PublicKey caching (-A, A_compressed) per sign bit behind OnceLocks (clones carry initialized entries). Lives in core::curve, exported from protocol.
  • The XEdDSA verify equation is extracted into a shared verify_signature_prepared in curve25519.rs; the existing PrivateKey::verify_signature now derives its inputs and delegates, so the plain and cached paths share one implementation and cannot drift.
  • SenderKeyState memoizes the verifier next to the signing-key memo from perf(libsignal): memoize the sender signing key with a pre-warmed XEdDSA cache #838 (same lifecycle: seeded at creation, never persisted, lazily rebuilt after a cold load, persists for the record-cache lifetime), exposed as signing_key_verifier() returning a reference (no clone per message).
  • group_decrypt verifies via SenderKeyMessage::verify_signature_prepared. The plain verify_signature(&PublicKey) remains for other callers.

Measurements

2000-message group_decrypt loop, core-pinned perf stat, A/B against main:

main this PR
wall per decrypt 45.0 µs 33.9 µs (-25%)
instructions per decrypt 682K 571K (-16.2%)

Expect bench_group_decrypt_message and bench_group_recv to drop on the CodSpeed report.

Tests

  • Differential test pinning prepared == plain across: valid signatures under BOTH sign bits (corpus asserts both were exercised), corrupted signatures, corrupted messages, flipped sign bits, wrong-length signatures, garbage keys, and warmed clones.
  • Memo lifecycle test (seeded at creation, cold after protobuf roundtrip, warm after first use, clones carry it).
  • Full workspace suite, strict clippy, wasm32 CI builds pass.

Notes

The verify hot core (vartime_double_scalar_mul_basepoint, ~59% of a verify) is irreducible crypto and untouched. Per-signature data (R, s, the message hash and the final compression of R') is computed per call as before.

… key

The receive-side mirror of the signing memo: every incoming group
message verified its SKMSG signature by re-deriving the signer's Edwards
form from the Montgomery public key (a field inversion in to_edwards
plus another in compressing A), because signing_key_public() rebuilt the
key from bytes per message. Found via the CodSpeed flamegraph of
bench_signature_verification: the two derivations are ~24% of a verify
and depend only on (key, sign bit), both stable per sender.

PreparedVerifyingKey caches (-A, A_compressed) per sign bit behind
OnceLocks; the verify equation moves to a shared
verify_signature_prepared so the plain and cached paths cannot drift.
SenderKeyState memoizes the verifier next to the signing memo (seeded at
creation, lazily rebuilt after a cold load, clones carry warm entries),
and group_decrypt verifies through it.

Measured on a 2000-message group_decrypt loop (core-pinned perf stat):
45.0 -> 33.9 us/decrypt wall, 682K -> 571K instructions per decrypt
(-16.2%).

A differential test pins prepared == plain across valid signatures under
both sign bits, corrupted signatures/messages, flipped sign bits,
wrong-length signatures and garbage keys; the state memo lifecycle is
covered next to the signing memo's test.
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 89b372b6-b787-4181-9469-5f82a4314ba6

📥 Commits

Reviewing files that changed from the base of the PR and between 6e73467 and b69ecb3.

📒 Files selected for processing (6)
  • wacore/libsignal/src/core/curve.rs
  • wacore/libsignal/src/core/curve/curve25519.rs
  • wacore/libsignal/src/protocol/group_cipher.rs
  • wacore/libsignal/src/protocol/mod.rs
  • wacore/libsignal/src/protocol/protocol.rs
  • wacore/libsignal/src/protocol/sender_keys.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Introduced pre-computed signature verification to optimize repeated verifications with the same signing key, reducing computational overhead.
  • Tests

    • Comprehensive testing ensures pre-computed verification produces identical results to standard verification in all scenarios, including edge cases.

Walkthrough

This PR optimizes XEdDSA signature verification by introducing PreparedVerifyingKey, which caches expensive per-sign-bit Edwards curve transformations. The core verification logic is refactored into a reusable helper, integrated into SenderKeyState's memoization lifecycle, and applied to group message decryption.

Changes

XEdDSA Signature Verification Caching

Layer / File(s) Summary
Verification algorithm refactoring
wacore/libsignal/src/core/curve/curve25519.rs
verify_signature_prepared helper accepts precomputed Edwards key data and implements XEdDSA verification for message arrays. PrivateKey::verify_signature now delegates to this helper instead of inlining the computation.
PreparedVerifyingKey caching struct
wacore/libsignal/src/core/curve.rs
New PreparedVerifyingKey type caches per-sign-bit Edwards transformations using OnceLock within Arc. Implements precompute(), verify_signature(), verify_signature_for_multipart_message(), From<&PublicKey>, and Debug. Comprehensive test validates cached verification matches plain verification across valid/corrupted signatures, sign-bit flips, and clone behavior.
SenderKeyState verifier memoization
wacore/libsignal/src/protocol/sender_keys.rs
SenderKeyState gains verifying_key_memo field and signing_key_verifier() accessor. Initialization conditionally precomputes for receive-side states; from_protobuf resets memos. Tests validate memo lifecycle including lazy warming, protobuf reload, and clone behavior.
Public API exposure and SenderKeyMessage integration
wacore/libsignal/src/protocol/mod.rs, wacore/libsignal/src/protocol/protocol.rs
PreparedVerifyingKey is re-exported publicly. SenderKeyMessage::verify_signature_prepared accepts a prepared verifier and validates the signature over serialized payload.
Group cipher decryption integration
wacore/libsignal/src/protocol/group_cipher.rs
group_decrypt replaces signing_key_public() + verify_signature() with signing_key_verifier() + verify_signature_prepared() for signature validation.

Sequence Diagram

sequenceDiagram
  participant App
  participant GroupCipher
  participant SenderKeyState
  participant PreparedVerifyingKey
  participant curve25519

  App->>GroupCipher: group_decrypt(encrypted_msg)
  GroupCipher->>SenderKeyState: signing_key_verifier()
  activate SenderKeyState
  alt verifying_key_memo populated
    SenderKeyState->>SenderKeyState: return cached PreparedVerifyingKey
  else first access
    SenderKeyState->>SenderKeyState: derive from signing_key_public
    SenderKeyState->>SenderKeyState: store in verifying_key_memo
  end
  deactivate SenderKeyState
  GroupCipher->>PreparedVerifyingKey: verify_signature_prepared(signature)
  activate PreparedVerifyingKey
  PreparedVerifyingKey->>PreparedVerifyingKey: check sign_bit cache entry
  alt cache hit
    PreparedVerifyingKey->>PreparedVerifyingKey: use cached Edwards point
  else cache miss
    PreparedVerifyingKey->>PreparedVerifyingKey: Montgomery→Edwards conversion
    PreparedVerifyingKey->>PreparedVerifyingKey: compress and cache result
  end
  PreparedVerifyingKey->>curve25519: verify_signature_prepared(precomputed_data, sig)
  curve25519-->>PreparedVerifyingKey: bool result
  deactivate PreparedVerifyingKey
  PreparedVerifyingKey-->>GroupCipher: bool result
  GroupCipher-->>App: decrypted message or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This requires careful inspection of Edwards curve math correctness, constant-time comparison guarantees, OnceLock benign-race semantics in multi-threaded contexts, cache invalidation across protobuf reloads, and integration consistency across three distinct call sites. The refactored verification logic must be traced through all paths to confirm no regressions.

Possibly related PRs

  • oxidezap/whatsapp-rust#838: Both PRs update SenderKeyState to cache precomputed XEdDSA key material—this PR caches PreparedVerifyingKey for signature verification while the other PR caches a pre-warmed signing PrivateKey for signing operations.

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title precisely describes the main change: caching Edwards derivations for signature verification per sender key, which is the core performance optimization in this PR.
Description check ✅ Passed The description is comprehensive and directly relevant to the changeset, detailing the problem, the solution architecture, performance measurements, and testing approach.
Docstring Coverage ✅ Passed Docstring coverage is 91.30% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 perf/verify-edwards-cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 6 files

Re-trigger cubic

@codspeed-hq

codspeed-hq Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 1 (👁 1) regressed benchmark
✅ 138 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation bench_group_decrypt_message 229 µs 186.4 µs +22.82%
Simulation bench_group_recv 242.4 µs 202.7 µs +19.62%
👁 Memory bench_group_create_distribution_message 1.8 KB 2 KB -11.73%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/verify-edwards-cache (b69ecb3) with main (6e73467)

Open in CodSpeed

The CodSpeed memory instrument flagged the first design: the Edwards
entries lived inline in each SenderKeyState, so every record clone in
the send/receive cycle copied ~460 B (and the create path several times
over). The entries now sit behind an Arc shared by all clones: 8 bytes
per state, one allocation per key, and a warm entry propagates to every
holder including the cached master.

The simulation instrument also showed no improvement on the decrypt
benches: their fixtures are fresh per iteration, so the lazy warm landed
inside the measured window (production amortizes it across the record
cache lifetime, as the 2000-message loop shows). Receive-side states
(no private key) now pre-derive both sign-bit entries at SKDM
processing, once per sender rotation, mirroring the creation pre-warm of
the signing memo. Send-side states keep them cold; they never verify
their own messages.

2000-message decrypt loop: 1363.5M (main) -> 1140.8M instructions, with
no per-clone allocation.
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

Pushed a06540f addressing the first CodSpeed report: the Edwards entries now live behind an Arc shared by all clones (8 bytes per state, one allocation per key, warmth propagates to every holder including the cached master), which removes the ~460 B-per-clone memory cost the instrument flagged across the group benches. Receive-side states also pre-derive both sign-bit entries at SKDM processing (once per sender rotation), so the decrypt benches measure the warm steady state like production does instead of paying the first-use derivation inside their fresh-fixture window. Local 2000-message decrypt loop: 1363.5M (main) -> 1140.8M instructions.

Also investigated going further with dalek's VartimeEdwardsPrecomputation (per-key NAF8 tables for [-A, B]): measured 2.8x SLOWER than the specialized vartime_double_scalar_mul_basepoint (6.5 -> 18.1 us/op), so the specialized routine stays. The remaining verify core is irreducible.

The remaining CodSpeed memory flag on create_distribution was the
verifier's Arc allocated for the sender's own state, which never
verifies its own messages. Receive-side creation still builds and
pre-warms it; send-side builds lazily only if ever asked. Test updated
to pin the asymmetry.
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