Skip to content

feat(libsignal): let a consumer opt out of counter leasing - #1211

Merged
jlucaso1 merged 2 commits into
mainfrom
feat/counter-lease-opt-out
Aug 5, 2026
Merged

feat(libsignal): let a consumer opt out of counter leasing#1211
jlucaso1 merged 2 commits into
mainfrom
feat/counter-lease-opt-out

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Counters are leased in batches (SENDER_CHAIN_RESERVATION_BATCH = 64) so the send path needs one durable flush per batch, and a reload fast-forwards past the whole lease so a published counter is never re-derived. That only works because there is somewhere to persist the ceiling: the local field 100 that deserialize_for_store reads back. A consumer whose storage is a component export has nowhere to put it, and into_components has to materialize the reservation before handing the record over — so the whole batch burns on every export instead of once per batch. Four consecutive DM sends land on the wire at counters 0, 64, 128, 192, and the peer buffers 63 skipped keys for each one.

A consumer whose persistence is already synchronous and durable before the ciphertext reaches the wire gets nothing from the lease and pays all of that. SessionRecord::waive_counter_lease and its SenderKeyRecord counterpart are how it says so, per record load. Nothing is inferred from the stored representation.

Changes

  • CounterLease, shared by both records. Waived carries neither a ceiling nor a pending flag, so a record cannot hold a reservation the send path would gate on while having waived the lease that reservation implements. The incoherent state is unrepresentable in the type rather than avoided by convention, which is what the design note asked for.
  • The policy is the consumer's, never inferred. Not from the record shape, not from the absence of field 100, not from a build feature. The same representation can be persisted by a consumer that wants the lease and by one that does not, so deriving policy from representation would turn a storage change into a silent change of guarantee. It is runtime state for the same reason field 100 is written unconditionally: two binaries of one version must not disagree about what a record guarantees.
  • Creation, not just export. message_encrypt and group_encrypt reserve through the lease, so a waived record reserves nothing and no ciphertext is gated on a flush that no longer protects anything. Fixing only into_components would have left has_pending_reservation() / is_wire_gated() true under a waiver.
  • The batch guard moved into CounterLease::reserve. Both call sites used to duplicate if spent >= ceiling, and a bare call could lower a ceiling a durable snapshot already carried. Reservations now only ever rise, by construction.
  • Session and sender key together. One policy, two entry points, because the records are distinct types. Their waive_counter_lease signatures differ only in the way their exports already differ: the session drops an unadvanceable chain fail-closed, the sender key propagates the error.
  • Pre-existing reservations materialize once. A record loaded from a snapshot written under the lease still carries a reservation that may already have been published; waiving does not make that untrue. It burns once and then runs consecutively. Refusing instead would strand the address for good.
  • Archived states burn too. into_components advances every state the ceiling covered, so the waiver has to as well; otherwise a later promotion has nothing left telling it those counters may already be on the wire. This was a real hole in the first draft of this branch, caught by the test named for it.
  • Invariant comments corrected. SENDER_CHAIN_RESERVATION_BATCH, the note above the reservation in message_encrypt, and the outbound-advance note in group_encrypt all stated the fast-forward guarantee without qualification. Each now says the waived case does not reserve.

Trade-off

Without the lease, a crash between the encrypt and the write can reissue a counter, and with it the (key, IV) pair it derives. That is acceptable only for a consumer whose persistence is synchronous and durable before the ciphertext reaches the wire, which is precisely what it declares by waiving. The rationale lives on CounterLease alone, not repeated at the call sites.

The default is untouched. A consumer that asks for nothing keeps the lease, the wire gate and the fast-forward exactly as before.

Numbers

default waived
DM, 4 sends with a component export between each wire counters 0, 64, 128, 192; peer buffers 189 skipped keys 0, 1, 2, 3; zero skipped
Group, 8 sends with a component export between each iterations 0, 64, …, 448; receiver buffers 441 skipped keys 0..7; zero skipped

Both leased figures are the measured symptom, asserted by the tests that pin the default. The 441 matches what the reporting consumer measured.

Validation

cargo fmt --all
cargo test -p wacore-libsignal                                    # 219 lib + 17 counter_lease + 12
cargo test -p wacore-libsignal --features legacy-session-interop  # 243 lib
cargo clippy -p wacore-libsignal --all-targets --features legacy-session-interop -- -D warnings
cargo check --workspace --exclude e2e-tests --all-targets

Every existing lease test passes unedited, including tests/counter_lease.rs, crash_mid_lease_skips_spent_iterations_and_peer_decrypts and lease_amortizes_the_wire_gate. No contract change was needed there.

New coverage, happy and failure side by side:

  • a_waived_lease_keeps_exported_counters_consecutive / the_default_lease_still_burns_a_batch_per_export
  • a_waived_lease_keeps_group_iterations_consecutive / the_default_group_lease_still_burns_a_batch_per_export
  • a_waived_lease_never_gates_the_wire / the_default_lease_still_gates_the_wire
  • waiving_materializes_a_previously_reserved_ceiling_once and its group twin
  • waiving_burns_the_ceiling_into_archived_states_too — verified to fail (archived index 1 instead of 64) with the archived-state burn removed
  • unit tests on CounterLease itself for the waived no-ops and the rise-only reservation

Full matrix left to CI.

Counters are leased in batches so the send path needs one durable flush
per batch, and a reload fast-forwards past the whole lease. That only
works because there is somewhere to persist the ceiling. A consumer
whose storage is a component export has nowhere: into_components has to
materialize the reservation before handing the record over, so the whole
batch burns on every export rather than once per batch. Four consecutive
DM sends land on the wire at counters 0, 64, 128, 192, and the peer
buffers 63 skipped keys for each of them.

A consumer whose persistence is already synchronous and durable before
the ciphertext reaches the wire gets nothing from the lease and pays all
of that. SessionRecord::waive_counter_lease and its SenderKeyRecord
counterpart are how it says so. Nothing is inferred: the same record
shape can be persisted by a consumer that wants the lease and by one
that does not, so deriving the policy from the representation would turn
a storage change into a silent change of guarantee.

The two records now share a CounterLease enum whose Waived variant
carries neither ceiling nor pending flag, so a record cannot hold a
reservation the send path would gate on while having waived the lease
that reservation implements. That covers creation, not just export: a
waived record reserves nothing, so no ciphertext is gated on a flush
that no longer protects anything.

Waiving gives up a real guarantee. Without the lease, a crash between
the encrypt and the write can reissue a counter and with it the
(key, IV) pair. A record loaded from a snapshot written under the lease
still carries a reservation that may already have been published, so
waiving materializes it once, across archived states too, and then runs
consecutively. The default is untouched.
@coderabbitai

coderabbitai Bot commented Aug 5, 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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 98492120-c307-469f-998c-7a7e48724fd4

📥 Commits

Reviewing files that changed from the base of the PR and between 696d098 and 40e401d.

📒 Files selected for processing (4)
  • wacore/libsignal/src/protocol/counter_lease.rs
  • wacore/libsignal/src/protocol/group_cipher.rs
  • wacore/libsignal/src/protocol/sender_keys.rs
  • wacore/libsignal/src/protocol/state/session.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added support for waiving sender-chain counter reservations.
    • Waived reservations enable consecutive message counters without blocking transmission.
    • Existing reservations are materialized once when waived, preserving counter consistency.
    • Standard reservations continue to support batched allocation and durability gating.
  • Bug Fixes

    • Improved state handling across serialization, restoration, rotation, and archived sessions.
    • Prevented unnecessary receiver-key gaps when reservations are waived.
  • Documentation

    • Clarified behavior when sender-chain reservation leases are waived.
  • Tests

    • Added coverage for waiver behavior, persistence, restoration, reservation growth, and transmission gating.

Walkthrough

The PR adds CounterLease with leased and waived modes. Sender-key and session records use it for reservation, persistence, wire gating, archival transitions, and lease waiving. Encryption and component-round-trip tests verify consecutive waived counters and existing leased behavior.

Changes

Counter lease waiver

Layer / File(s) Summary
CounterLease state model
wacore/libsignal/src/protocol/{consts.rs,counter_lease.rs,mod.rs}
Adds leased and waived states, reservation growth, flush tracking, persistence helpers, waiver materialization, and unit tests.
SenderKeyRecord lease integration
wacore/libsignal/src/protocol/sender_keys.rs
Replaces separate reservation fields with CounterLease and routes sender-key lifecycle, serialization, component export, and wire gating through the lease.
SessionRecord lease integration
wacore/libsignal/src/protocol/state/session.rs
Uses CounterLease across session construction, reload, component transfer, waiver, ratchet transitions, archival promotion, and serialization.
Encryption flow and behavior coverage
wacore/libsignal/src/protocol/{group_cipher.rs,session_cipher.rs}, wacore/libsignal/tests/counter_lease.rs
Always records sent counters and tests waived leases, default lease gaps, flush gating, component round trips, and archived-state materialization.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Sender
  participant CounterLease
  participant SenderKeyRecord
  participant SessionRecord
  Sender->>SenderKeyRecord: encrypt and reserve counter
  SenderKeyRecord->>CounterLease: update reservation
  SenderKeyRecord->>SessionRecord: export components
  SessionRecord->>CounterLease: apply or waive lease
  SessionRecord-->>Sender: rebuilt sender state
Loading

Possibly related PRs

Suggested labels: api-design

Suggested reviewers: greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: allowing consumers to opt out of counter leasing.
Description check ✅ Passed The description directly explains the counter-lease opt-out, implementation changes, behavior, trade-offs, and validation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% 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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/counter-lease-opt-out

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.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a per-record counter-lease waiver for direct and group Signal sessions while preserving leasing as the default.

  • Introduces a shared CounterLease state type that makes leased and waived states explicit.
  • Materializes existing reservations before waiving and makes subsequent reservations inert.
  • Centralizes rise-only reservation logic for direct-message counters and group iterations.
  • Adds coverage for consecutive waived counters, default batch behavior, wire gating, existing reservations, and archived direct-message sessions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
wacore/libsignal/src/protocol/counter_lease.rs Introduces the shared leased/waived state machine and centralizes rise-only reservation and transient flush-gate behavior.
wacore/libsignal/src/protocol/state/session.rs Migrates direct-session reservation state to CounterLease and adds waiver materialization across current and archived sessions.
wacore/libsignal/src/protocol/sender_keys.rs Migrates sender-key reservation state to CounterLease and adds the fallible group-record waiver entry point.
wacore/libsignal/src/protocol/session_cipher.rs Delegates every direct-message reservation attempt to the lease state so waived records remain unreserved.
wacore/libsignal/src/protocol/group_cipher.rs Delegates group iteration reservations to the lease state and adds component-export waiver coverage.
wacore/libsignal/tests/counter_lease.rs Adds direct-session tests covering waived counters, default leasing, wire gates, persisted ceilings, and archived-state materialization.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Load[Load session or sender-key record] --> Choice{Consumer waives lease?}
    Choice -->|No| EncryptLeased[Encrypt and reserve a batch when ceiling is reached]
    EncryptLeased --> Gate{Reservation raised?}
    Gate -->|Yes| Flush[Durably flush reservation before wire]
    Gate -->|No| SendLeased[Send using existing durable lease]
    Flush --> SendLeased
    Choice -->|Yes| Materialize[Advance existing chains past persisted ceiling]
    Materialize --> Waived[Set lease to Waived]
    Waived --> EncryptWaived[Encrypt without reserving or wire gating]
    EncryptWaived --> Persist[Consumer persists synchronously and durably]
    Persist --> SendWaived[Send ciphertext]
Loading

Reviews (2): Last reviewed commit: "fix(libsignal): keep the lease when a wa..." | Re-trigger Greptile

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wacore/libsignal/src/protocol/sender_keys.rs`:
- Around line 583-591: The waive_counter_lease flow currently commits
lease.waive() before materializing the ceiling, leaving inconsistent state when
fast_forward_sender_chain rejects the gap. Update waive_counter_lease to call
fast_forward_sender_chain_or_drop with the ceiling first, and only waive the
lease after successful materialization, preserving the record when
materialization fails.
🪄 Autofix

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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7d0c619e-cae8-4ab2-a9c0-e2239fa73e65

📥 Commits

Reviewing files that changed from the base of the PR and between 37ae410 and 696d098.

📒 Files selected for processing (8)
  • wacore/libsignal/src/protocol/consts.rs
  • wacore/libsignal/src/protocol/counter_lease.rs
  • wacore/libsignal/src/protocol/group_cipher.rs
  • wacore/libsignal/src/protocol/mod.rs
  • wacore/libsignal/src/protocol/sender_keys.rs
  • wacore/libsignal/src/protocol/session_cipher.rs
  • wacore/libsignal/src/protocol/state/session.rs
  • wacore/libsignal/tests/counter_lease.rs

Comment thread wacore/libsignal/src/protocol/sender_keys.rs
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 9.99 MiB 9.99 MiB -256 B (-0.00%) 🔽
bin .text 8.01 MiB 8.01 MiB -256 B (-0.00%) 🔽
bin allocated (text+data+bss) 9.99 MiB 9.99 MiB 0
llvm-lines wacore 513,151 513,194 +43 (+0.01%) 🔺
llvm-lines wacore copies 16,747 16,746 -1 (-0.01%) 🔽
llvm-lines whatsapp-rust lib 736,306 736,311 +5 (+0.00%) 🔺
llvm-lines whatsapp-rust lib copies 23,179 23,177 -2 (-0.01%) 🔽
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.83 MiB 1.83 MiB -190 B (-0.01%) 🔽
.text wacore 689.47 KiB 689.31 KiB -168 B (-0.02%) 🔽
.text wacore_binary 91.42 KiB 91.42 KiB 0
.text wacore_libsignal 170.63 KiB 170.71 KiB +86 B (+0.05%) 🔺
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 21.36 KiB 21.36 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 515.62 KiB 515.62 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 11.83 KiB 11.83 KiB 0
.text std 988.75 KiB 988.75 KiB 0
.text other deps 1.90 MiB 1.90 MiB 0

Baseline: 37ae4107d (latest main run) · Head: 074016064 · Graphs

Two problems on the same path.

The doc link from the public waive_counter_lease pointed at the
crate-private CounterLease, which fails the rustdoc gate. The guarantee
a consumer gives up belongs on the public surface that offers the choice
anyway, so it moved to SessionRecord::waive_counter_lease and the group
counterpart links there.

More importantly, the sender-key waiver dropped the ceiling before
advancing past it. A chain too stale to advance returned the error with
the lease already gone, leaving the record free to reissue exactly the
iterations that ceiling said may already be on the wire. Materialize
first, waive only on success.

@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.

0 issues found across 4 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Introduces an opt-in durability tradeoff (waiving the lease can reissue a key/IV pair on crash). The implementation appears correct but the security implications and interface design warrant human review.

Re-trigger cubic

@jlucaso1
jlucaso1 merged commit 0f78723 into main Aug 5, 2026
25 of 26 checks passed
@jlucaso1
jlucaso1 deleted the feat/counter-lease-opt-out branch August 5, 2026 22:34
jlucaso1 added a commit to oxidezap/whatsapp-rust-docs that referenced this pull request Aug 5, 2026
* docs(signal-protocol): document counter-lease waiver opt-out

whatsapp-rust#1211 added SessionRecord::waive_counter_lease and
SenderKeyRecord::waive_counter_lease, letting a consumer whose own
persistence is already synchronous and durable before the wire opt out
of the batched counter lease that otherwise burns a full reservation
on every component export. Document the API, its trade-off, and the
per-record-type failure behavior, and cross-link it from the Record
components section it primarily benefits.

Ref: oxidezap/whatsapp-rust#1211

* docs(signal-protocol): fix trusted-reload comparison in lease waiver

SignalStoreCache's trusted reload is a matching live-cache incarnation,
not synchronous durability -- its warm sends use the coalesced
write-behind. Only a direct Device store's trusted-reload rationale
ties to synchronous durability. Corrects an inaccurate comparison
flagged by review.

* docs(signal-protocol): split dense lease-waiver sentence

AGENTS.md asks for one idea per sentence. Break the run-on covering
eligibility, the motivating case, and why into_components() re-burns
the reservation into separate sentences.

---------

Co-authored-by: Claude <noreply@anthropic.com>
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