Skip to content

feat: cstoken (NCT) privacy token fallback + proto update - #433

Merged
jlucaso1 merged 12 commits into
mainfrom
feat/privacy-tokens-cstoken
Mar 26, 2026
Merged

feat: cstoken (NCT) privacy token fallback + proto update#433
jlucaso1 merged 12 commits into
mainfrom
feat/privacy-tokens-cstoken

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Update the proto to WhatsApp Web 2.3000.1035617621 and add the cstoken / NCT fallback path for privacy-token-gated 1:1 messaging.

This completes the send-side privacy-token chain to match WA Web:

tctoken -> cstoken -> no token (463)

Where:

  • tctoken: server-issued trusted-contact token from prior interaction
  • cstoken: HMAC-SHA256(nct_salt, recipient_account_lid) using the bare user@lid

Why this matters

WhatsApp's privacy-token rollout gates access to sensitive endpoints behind proof of relationship. Without a valid token, the server rejects requests with 463.

tctoken was already implemented. This PR adds the missing cstoken fallback so first-contact messaging can succeed when the server has provisioned nct_salt, even before any trusted-contact token exists.

Scope

  • waproto: proto update adds NctSaltSyncAction and HistorySync.nctSalt
  • wacore/store: persist nct_salt
  • wacore/history_sync: parse HistorySync.nctSalt
  • src/client.rs: handle nct_salt_sync set/remove mutations
  • src/send.rs: attach cstoken when no usable tctoken exists and the recipient LID can be resolved
  • sqlite-storage: add nct_salt migration/schema persistence
  • tests/e2e: add coverage for provisioned/unprovisioned salt flows and token-gated endpoints

Important behavior / precedence

  • App-state nct_salt_sync is authoritative.
  • History sync only backfills NCT salt when no authoritative app-state mutation has been seen yet. It does not resurrect a removed salt or overwrite a newer syncd value.
  • Empty salts are ignored instead of being persisted.
  • Empty tc tokens are treated as missing and are not cached, so they cannot suppress the cstoken fallback on later sends.
  • tc token refresh state is only updated after a successful post-send issuance flow.
  • Sent-node waiters are cleared on disconnect so transport-scoped test/diagnostic waiters cannot leak across reconnects.

Endpoint behavior

Endpoint Behavior after this PR
1:1 message send use valid tctoken, else cstoken if nct_salt + recipient LID are available, else no token / server may return 463
Profile picture still tctoken-gated
Presence subscribe still tctoken-gated

E2E coverage added

  • no salt -> first contact still gets 463
  • history sync salt -> first contact succeeds via cstoken
  • syncd salt -> first contact succeeds via cstoken
  • local salt clear/remove -> first contact fails again
  • nct_salt survives reconnect and still enables first contact
  • PN-target first contact still uses cstoken after PN -> LID resolution
  • tctoken-only reply path remains valid when cstoken is disabled
  • restricted profile picture and presence remain tctoken-gated

Live availability still depends on server AB props / rollout (wa_nct_token_*). Accounts that are not provisioned with nct_salt will still follow the no-salt / 463 branch.

Validation

  • cargo test -p wacore --lib
  • cargo test -p whatsapp-rust --lib
  • cargo test -p e2e-tests --no-run -q
  • live verification on accounts that actually receive nct_salt via server rollout

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced privacy token mechanism with cstoken fallback for improved message delivery reliability.
    • Server-provisioned NCT salt integration for privacy token computation.
    • Improved handling of first-contact scenarios with privacy-restricted recipients.
  • Bug Fixes

    • Corrected tc-token issuance timing to occur after message transmission.
  • Tests

    • Comprehensive test coverage for privacy tokens, cstoken scenarios, and restricted contact handling.

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@jlucaso1 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 26 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 475f9cbd-b04f-4f57-b570-0ffac5dd1344

📥 Commits

Reviewing files that changed from the base of the PR and between b078011 and 8cb0f7f.

📒 Files selected for processing (4)
  • src/client.rs
  • src/send.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/src/store/commands.rs
📝 Walkthrough

Walkthrough

This PR introduces NCT (Nonce-based Content Token) salt support for privacy token management, including database schema additions, history sync extraction, deferred tc-token issuance with cs-token fallback generation, an outgoing node waiter system, app-state mutation handling, and comprehensive E2E test coverage.

Changes

Cohort / File(s) Summary
Database Schema & Storage
storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/*, storages/sqlite-storage/src/schema.rs, storages/sqlite-storage/src/sqlite_store.rs
Added nct_salt BLOB column to device table via migration and Diesel schema. Extended DeviceRow struct and updated save_device_data_for_device, create_new_device, and load_device_data_for_device to persist and load nct_salt.
Device Model & Commands
wacore/src/store/device.rs, wacore/src/store/commands.rs
Extended Device struct with persisted nct_salt: Option<Vec<u8>> field and runtime-only nct_salt_sync_seen marker. Added DeviceCommand::SetNctSalt and SetNctSaltFromHistorySync variants with corresponding apply logic.
History Sync & Protocol
wacore/src/history_sync.rs, src/history_sync.rs
Extended HistorySyncResult with optional nct_salt field; added protobuf field 19 parsing. Updated Client::process_history_sync_task to persist extracted salt via DeviceCommand::SetNctSaltFromHistorySync.
Privacy Token Refactoring
src/send.rs, wacore/src/iq/tctoken.rs
Deferred tc-token issuance to occur after send_node() completes. Refactored maybe_include_tc_token to return token issuance flags instead of early-returning; added cs-token fallback computed from nct_salt via HMAC-SHA256. Added helpers: compute_cs_token, build_cs_token_node, issue_tc_token_after_send, store_issued_tc_tokens, mark_tc_token_used_after_send.
Outgoing Node Waiter System
src/client.rs
Added SentNodeWaiter struct, sent_node_waiters and sent_node_waiter_count fields. Implemented wait_for_sent_node(filter) public API and internal resolve_sent_node_waiters to match outgoing nodes against filters. Integrated into send_node flow before marshalling/encryption.
App State Mutation Handling
src/client.rs
Updated dispatch_app_state_mutation to handle "nct_salt_sync" mutations early, before operation-specific logic. Persists DeviceCommand::SetNctSalt(None) for Remove and SetNctSalt(Some(salt)) for others, ensuring processing regardless of operation type.
E2E Test Infrastructure
tests/e2e/Cargo.toml, tests/e2e/src/lib.rs
Added futures and wacore-binary dependencies. Extended TestClient with sent message waiters (sent_message_waiter, next_sent_message_waiter) and nct_salt accessors (nct_salt, wait_for_nct_salt). Added push-name generation helpers: unique_push_name, restricted_push_name, scenario_push_name.
E2E Test Coverage
tests/e2e/tests/privacy_tokens.rs
Added node helpers has_child and has_descendant. Introduced send_first_message_and_expect_463 and send_message_and_expect_463_with_id test utilities. Expanded test suite with 829 new lines covering: nct_salt sync scenarios, cs-token presence/absence, first-contact behavior with/without salt, restricted feature gating validation, and LID mapping persistence.
Contact Info LID Persistence
src/features/contacts.rs
Added persist_lid_mappings helper to store PN→LID mappings post-IQ execution. Updated get_info and get_user_info to call this helper as a best-effort side effect after fetching contact info.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hops skip and bound, with salts so fine,
TC tokens defer—now they align!
Cs-tokens bloom where salt is found,
Nodes await, then swiftly bound. 🔐

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: implementing cstoken (NCT) as a privacy token fallback mechanism. It is concise, directly related to the core feature being added, and would help developers scanning history understand the primary objective.
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 feat/privacy-tokens-cstoken

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.

@github-actions

github-actions Bot commented Mar 23, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfeat/privacy-tokens-cstoken
Testbedubuntu-latest

🚨 2 Alerts

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
47.13 x 1e3
(+10.38%)Baseline: 42.70 x 1e3
44.83 x 1e3
(105.13%)

reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
2.23 x 1e3
(+5.06%)Baseline: 2.12 x 1e3
2.23 x 1e3
(100.06%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-6.12%)Baseline: 6,600.78
6,930.82
(89.41%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-29.39%)Baseline: 742,580.58
779,709.61
(67.24%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-6.31%)Baseline: 22,274.09
23,387.79
(89.23%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-18.55%)Baseline: 120,560.50
126,588.53
(77.58%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-11.41%)Baseline: 110,880.68
116,424.71
(84.37%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.12%)Baseline: 533,607.05
560,287.41
(95.12%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-5.35%)Baseline: 16,767.76
17,606.15
(90.14%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-9.04%)Baseline: 16,176,795.22
16,985,634.98
(86.63%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-22.88%)Baseline: 153,463.76
161,136.95
(73.45%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.12%)Baseline: 535,026.61
561,777.94
(95.12%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-4.78%)Baseline: 18,819.40
19,760.37
(90.68%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-23.98%)Baseline: 36,919,961.09
38,765,959.14
(72.40%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.12%)Baseline: 534,046.05
560,748.36
(95.12%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-8.17%)Baseline: 17,253.33
18,115.99
(87.45%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-9.03%)Baseline: 16,177,872.55
16,986,766.17
(86.64%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-14.80%)Baseline: 126,694.91
133,029.65
(81.14%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-11.40%)Baseline: 110,952.68
116,500.31
(84.38%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.88%)Baseline: 96,655.77
101,488.56
(89.64%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.79%)Baseline: 7,668.79
8,052.23
(91.63%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-2.04%)Baseline: 92,904.20
97,549.41
(93.29%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.41%)Baseline: 7,370.49
7,739.02
(95.63%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.75%)Baseline: 108,689.20
114,123.66
(93.57%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.34%)Baseline: 8,882.49
9,326.62
(95.57%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-8.76%)Baseline: 46,018.95
48,319.89
(86.90%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-4.91%)Baseline: 2,857.35
3,000.22
(90.56%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+2.78%)Baseline: 541,066.06
568,119.37
(97.88%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.34%)Baseline: 773.62
812.30
(94.92%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,703,653.00
(-0.00%)Baseline: 27,704,525.66
29,089,751.95
(95.24%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,446.00
(-0.07%)Baseline: 5,548,229.99
5,825,641.49
(95.17%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,119.00
(-1.46%)Baseline: 177,708.67
186,594.10
(93.85%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,768.00
(-1.53%)Baseline: 178,500.12
187,425.13
(93.78%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,308,816.00
(+0.16%)Baseline: 17,280,358.16
18,144,376.07
(95.39%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.62%)Baseline: 296,573.72
311,402.40
(95.83%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,583,464.00
(-0.10%)Baseline: 12,595,692.43
13,225,477.05
(95.15%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.43%)Baseline: 716,538.57
752,365.50
(95.64%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+10.38%)Baseline: 42,696.10
44,830.90
(105.13%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,753.56
16,339,841.23
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,371,271.00
(-2.21%)Baseline: 5,492,678.03
5,767,311.93
(93.13%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
311,806.00
(-64.43%)Baseline: 876,555.92
920,383.71
(33.88%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.20%)Baseline: 2,824,802.18
2,966,042.29
(95.43%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.60%)Baseline: 3,473,603.16
3,647,283.32
(94.67%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
125,661,828.00
(+0.25%)Baseline: 125,343,498.12
131,610,673.02
(95.48%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.69%)Baseline: 11,802.02
12,392.12
(96.85%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.76%)Baseline: 3,824.51
4,015.73
(97.87%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.88%)Baseline: 87,848.98
92,241.43
(94.40%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-1.09%)Baseline: 79,893.89
83,888.58
(94.20%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.19%)Baseline: 50,980.90
53,529.94
(94.11%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.55%)Baseline: 5,751.93
6,039.53
(98.62%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
2,227.00
(+5.06%)Baseline: 2,119.75
2,225.74
(100.06%)

reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.02%)Baseline: 21,916.69
23,012.52
(95.25%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 23, 2026

Copy link
Copy Markdown

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


Code Review: cstoken (NCT) privacy token fallback + proto update

  • Read core implementation files
  • Review cstoken computation and token fallback logic
  • Review history sync changes
  • Review storage/migration changes
  • Review client-side dispatch
  • Post review feedback

Overall this is a well-structured PR with clear WA Web cross-references, good test coverage, and clean separation across crate boundaries. A few observations:

Duplicate LID resolution in maybe_include_tc_token

In src/send.rs:1099-1107, the recipient's LID is resolved via lid_pn_cache.get_current_lid() for the token_jid lookup. Then in the cstoken fallback path (src/send.rs:1176-1188), the same LID resolution is performed again. Since get_current_lid is an async cache lookup, this is a minor inefficiency — the first resolved LID (or the fact that it was None) could be reused.

Fix this →

cstoken fallback uses to.to_string() for LID JIDs vs Jid::new().to_string() for phone numbers

At src/send.rs:1177, when to is already a LID, the code uses to.to_string() directly. But at line 1181, for phone numbers, it constructs Jid::new(&lid_user, "lid").to_string(). This is fine — just noting that to.to_string() will include the device suffix (e.g. 12345:67@lid) while the phone-number path constructs a bare user@lid JID. Verify this matches WA Web's genCsTokenBody behavior — does it HMAC the full JID including device, or just the user part?

If WA Web uses the user-only form consistently, the LID branch should probably be Jid::new(&to.user, "lid").to_string() to strip the device.

Fix this →

History sync: field 19 is bytes in proto but parsed as raw bytes

In wacore/src/history_sync.rs:120-130, field 19 (nctSalt) is extracted as raw bytes from the protobuf buffer. In the proto definition, nct_salt is declared as optional bytes nct_salt = 19. The manual parser correctly handles this as a length-delimited field. One edge case: if the server sends an empty nct_salt field (zero-length bytes), the !salt.is_empty() check at line 126 correctly filters it out. Good.

Proto ..Default::default() changes

The chat_actions.rs, polls.rs, and handshake.rs changes replace explicit field: None with ..Default::default(). This is a good practice for forward-compat with proto updates adding new fields, but it silently drops compile-time exhaustiveness checking. Since proto structs change frequently, this tradeoff makes sense here.

App state dispatch handles both set and remove

src/client.rs:2628-2646: The nct_salt_sync mutation handler correctly differentiates between Remove (clears salt) and Set (stores salt) operations. The Remove path calls SetNctSalt(None) which is clean. The warning on malformed mutations is appropriate.

Down migration

down.sql uses the CREATE TABLE ... AS SELECT + DROP + recreate pattern, which is the correct approach for SQLite (no ALTER TABLE DROP COLUMN). The column list is complete and matches the pre-migration schema.

Benchmark alerts

The 2 Bencher alerts (bench_decrypt_with_previous_session +10.63%, bench_message_encoding +5.13%) are in unrelated benchmarks (libsignal session decryption and reporting token encoding). These are likely CI noise rather than regressions from this PR, since the changed code paths (HMAC computation, history sync parsing) don't touch signal protocol or reporting token logic.

Minor nits

  1. src/send.rs:1139: resolve_to_lid_jid is called to get to_lid for IssuePrivacyTokensSpec, but this is a third LID resolution path (after token_jid and the cstoken fallback). Consider consolidating these.

  2. Test coverage: The cstoken tests cover determinism, different LIDs, and different salts. Consider adding a known-answer test with a pre-computed HMAC-SHA256 value to catch accidental algorithm changes (e.g., if someone swaps the key/data arguments).


Verdict: Clean implementation that follows the crate architecture well. The main actionable item is verifying the HMAC input format consistency (full JID vs user-only) for the cstoken computation. The rest is solid.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
storages/sqlite-storage/src/sqlite_store.rs (1)

55-76: 🧹 Nitpick | 🔵 Trivial

Consider replacing DeviceRow tuple with a named struct.

This positional tuple is now very wide; a #[derive(Queryable)] struct would reduce schema-order coupling and make future column additions safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 55 - 76, The
DeviceRow type is a large positional tuple; replace it with a named struct
(e.g., struct DeviceRow { id: i32, name: String, ... }) that derives Queryable
(and maybe Debug/Clone) so fields are accessed by name instead of position.
Update any usages of DeviceRow in sqlite_store.rs (constructors, pattern
matches, mapping from query rows, and return types) to use the new struct field
names, and adjust query deserialization to derive or implement Queryable for the
struct to preserve compatibility with Diesel or your DB layer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 2634-2643: The app-state branch is persisting Some(vec![]) which
should be treated as absent; update the conditional handling of m.action_value
-> nct_salt_sync_action -> salt to reject empty salts (check salt.is_empty())
and only call
self.persistence_manager.process_command(DeviceCommand::SetNctSalt(Some(salt.clone())))
when salt is non-empty; if salt is empty, log a warning (similar to the existing
warn! for missing salt) and do not persist so the history-sync path can later
backfill a real salt.

In `@storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/down.sql`:
- Line 36: Replace the rollback INSERT that uses "INSERT INTO device SELECT *
FROM device_backup;" with an INSERT ... SELECT that specifies explicit column
names for both target and source (e.g., "INSERT INTO device (col1, col2, ...)
SELECT col1, col2, ... FROM device_backup") so the migration remains correct if
column order or schema changes; update the column list to match the current
device table columns and ensure the same order is used in the SELECT from
device_backup.

In `@wacore/src/history_sync.rs`:
- Around line 260-269: The test uses a phone-like value "5511999990000" in the
Pushname.id and the process_history_sync call; replace both occurrences with an
obviously synthetic identifier (e.g., "0000000000" or "example-jid-1") to avoid
PII. Locate the pushnames construction (wa::Pushname with id) and the
process_history_sync::<fn(Bytes)> invocation and update the id string and the
second argument to the same clearly fictitious value so the test remains
functionally identical but contains no phone-shaped data.

In `@wacore/src/store/device.rs`:
- Around line 161-165: The doc comment expands "NCT" as "Neuro-Computed Token"
which is misleading; update the comment above the field (the block that mentions
NCT, cstoken = HMAC-SHA256(salt, recipient_lid), WAWebNctSaltSync and the
#[serde(default)] attribute) to remove the expansion and instead use a neutral
phrase like "NCT salt" or the protocol's canonical term throughout the comment
while preserving the remaining technical details (usage, source, and fallback
behavior).

---

Outside diff comments:
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 55-76: The DeviceRow type is a large positional tuple; replace it
with a named struct (e.g., struct DeviceRow { id: i32, name: String, ... }) that
derives Queryable (and maybe Debug/Clone) so fields are accessed by name instead
of position. Update any usages of DeviceRow in sqlite_store.rs (constructors,
pattern matches, mapping from query rows, and return types) to use the new
struct field names, and adjust query deserialization to derive or implement
Queryable for the struct to preserve compatibility with Diesel or your DB layer.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: acdaf5e1-9e47-4857-9a3b-7dad07c6cd83

📥 Commits

Reviewing files that changed from the base of the PR and between b4fdac0 and dab378a.

📒 Files selected for processing (16)
  • src/client.rs
  • src/features/chat_actions.rs
  • src/features/polls.rs
  • src/history_sync.rs
  • src/send.rs
  • storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/down.sql
  • storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/noise/src/handshake.rs
  • wacore/src/history_sync.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • waproto/src/whatsapp.proto
  • waproto/src/whatsapp.rs

Comment thread src/client.rs
Comment thread src/history_sync.rs Outdated
Comment thread src/send.rs
Comment thread storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/down.sql Outdated
Comment thread wacore/src/history_sync.rs Outdated
Comment thread wacore/src/store/device.rs Outdated
Update proto to WhatsApp Web 2.3000.1035617621 (from wppconnect wa-proto)
and implement the cstoken fallback for first-contact messaging.

When no tctoken exists for a recipient, whatsapp-rust now computes
cstoken = HMAC-SHA256(nct_salt, recipient_lid) matching WA Web's
genCsTokenBody in MsgCreateFanoutStanza.js.

NCT salt is received via two paths:
- History sync (HistorySync.nctSalt field 19) during initial pairing
- App state sync (NctSaltSyncAction field 80) for ongoing updates

This addresses error 463 for first-message and post-restriction scenarios.
…ut, known-answer test

- Resolve LID once in maybe_include_tc_token and reuse for tctoken
  lookup and cstoken HMAC (was resolved twice via lid_pn_cache)
- Use Jid::new(user, "lid") consistently for HMAC input to strip
  device suffix, matching WA Web's accountLid.toString()
- Add known-answer HMAC-SHA256 test to catch accidental algorithm
  changes (e.g., swapped key/data arguments)
…hetic test data

- Reject empty salt bytes in nct_salt_sync handler (prevents persisting
  Some(vec![]) which would block history sync backfill)
- Use explicit column names in down migration INSERT instead of SELECT *
- Replace phone-shaped test data with synthetic "0000000000"
- Remove misleading "Neuro-Computed Token" expansion from doc comment
@jlucaso1
jlucaso1 force-pushed the feat/privacy-tokens-cstoken branch from e1f5489 to 2d86cd8 Compare March 25, 2026 21:25
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 25, 2026

Copy link
Copy Markdown

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


Code Review: cstoken (NCT) privacy token fallback + proto update

  • Read core implementation files
  • Review cstoken computation logic (wacore/src/iq/tctoken.rs)
  • Review send path and token fallback (src/send.rs)
  • Review history sync changes (wacore/src/history_sync.rs, src/history_sync.rs)
  • Review storage/migration changes
  • Review client dispatch (src/client.rs)
  • Review device state changes
  • Review test coverage
  • Post review feedback

Well-structured PR with clean separation across crate boundaries. The cstoken computation is correct, the fallback chain is clear, and test coverage is solid (including a known-answer HMAC test). Here are my findings:

Looks Good

  • cstoken computation (wacore/src/iq/tctoken.rs:259-265): Clean HMAC-SHA256 with proper new_from_slice (accepts any key length). Known-answer test at line 551 is excellent for catching accidental key/data swaps.
  • LID resolution consolidation (src/send.rs:1115-1120): The LID is resolved once and reused for tctoken lookup, issuance, and cstoken HMAC input. The previous review's concern about duplicate resolution has been addressed.
  • HMAC input format (src/send.rs:1159): Uses Jid::new(lid_user, "lid").to_string() which produces user@lid (no device suffix) — consistent for both the to.is_lid() and phone-number paths since to.user already strips the device part. This matches WA Web's accountLid.toString().
  • Empty salt rejection (src/client.rs:2648-2649): The app-state dispatch already checks salt.is_empty() and logs a warning. CodeRabbit's concern about persisting Some(vec![]) has been addressed.
  • Down migration (down.sql): Already uses explicit column lists in both SELECT and INSERT (lines 3-9, 36-50). The SELECT * concern from CodeRabbit was also already addressed.
  • Test data (wacore/src/history_sync.rs:261): Already uses "0000000000" — the phone-number-shaped data concern was already fixed.
  • Benchmark alerts: The 2 Bencher alerts are in unrelated code paths (libsignal session decryption, reporting token encoding). CI noise, not regressions from this PR.

Minor Observations (non-blocking)

  1. Empty tctoken from server could bypass cstoken fallback (src/send.rs:1137,1197-1206): If the server ever returns an empty token via IssuePrivacyTokensSpec, store_issued_tc_tokens stores it unconditionally. On the next send, line 1137 matches on !is_tc_token_expired(entry.token_timestamp) without checking !entry.token.is_empty(), so an empty <tctoken> would be attached instead of falling through to the cstoken path. In practice the server likely never returns empty tokens for issued requests, but adding && !entry.token.is_empty() to the match guard would make the fallback more robust.

    Fix this →

  2. History sync NCT salt write is not conditional on existing state (src/history_sync.rs:261-268): The history sync path unconditionally overwrites the NCT salt. If the app-state path has already set a more recent salt (or cleared it via Remove), the history sync could overwrite/resurrect it. Since history sync runs during initial bootstrap and app-state sync typically follows, this is unlikely to cause issues in practice. But if you want belt-and-suspenders, a "set only if absent" check would be cleaner.

  3. should_send_new_tc_token called twice (src/send.rs:1133-1134,1142): The result is computed at line 1133 into should_issue_after_send, but then should_send_new_tc_token is called again at line 1142 with the same input. The second call could just use should_issue_after_send since the arguments are identical.

    Fix this →

Verdict

Clean implementation that follows the crate architecture well. The cstoken HMAC input format is consistent, empty salts are properly rejected, and the fallback chain matches the documented WA Web behavior. The observations above are minor hardening suggestions, not blockers.


View job

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/send.rs (1)

1141-1149: ⚠️ Potential issue | 🟠 Major

Don't advance sender_timestamp before the refresh actually succeeds.

This write happens before both send_node() and issue_tc_token_after_send(). If the send fails, the cached entry still looks freshly re-issued, so later sends in the same bucket skip the refresh even though the recipient never got a new tc token. Move the sender_timestamp update into the successful post-send store path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1141 - 1149, The current logic calls
backend.put_tc_token to update TcTokenEntry.sender_timestamp when
should_send_new_tc_token(...) is true before performing send_node() and
issue_tc_token_after_send(), which can mark the token as refreshed even if the
send fails; change this by removing the pre-send update and instead update the
cached TcTokenEntry.sender_timestamp only after a successful send and after
issue_tc_token_after_send() completes (i.e., in the post-send store path), using
the same TcTokenEntry shape and backend.put_tc_token call so that
should_send_new_tc_token and subsequent sends behave correctly.
♻️ Duplicate comments (2)
src/history_sync.rs (1)

261-268: ⚠️ Potential issue | 🔴 Critical

Prevent stale history-sync salt from overwriting newer mutation state.

Line 261 triggers an unconditional write. If nct_salt_sync clear/replace happens first, this can resurrect stale salt and break token correctness. Make this an atomic, precedence-aware command (history-sync should not overwrite a newer mutation-derived value).

Suggested direction
- self.persistence_manager
-     .process_command(wacore::store::commands::DeviceCommand::SetNctSalt(Some(
-         salt,
-     )))
-     .await;
+ self.persistence_manager
+     .process_command(
+         wacore::store::commands::DeviceCommand::SetNctSaltFromHistorySync { salt },
+     )
+     .await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/history_sync.rs` around lines 261 - 268, The current code unconditionally
writes sync_result.nct_salt via
self.persistence_manager.process_command(DeviceCommand::SetNctSalt(...)), which
can resurrect stale history-sync salt over a newer mutation-derived value;
change this to a precedence-aware update by either (a) reading the current
device salt/metadata first (via the persistence manager query) and only issuing
DeviceCommand::SetNctSalt when the stored value is absent or older than the
history-sync value, or (b) add a new atomic command variant (e.g.,
DeviceCommand::SetNctSaltIfNotNewer or similar) implemented in the persistence
layer that compares timestamps/sequence numbers and only sets when it will not
overwrite a newer mutation-derived salt; update the call site to use that
conditional/atomic command instead of the unconditional SetNctSalt.
src/send.rs (1)

1136-1140: ⚠️ Potential issue | 🟠 Major

Treat zero-length tc tokens as missing.

An empty cached or newly issued token still enters the valid-token branch, attaches <tctoken>, and suppresses the cstoken fallback. Persisting empty responses here makes that state sticky across later sends. Gate the branch on !entry.token.is_empty() and skip storing empty tokens.

Also applies to: 1196-1205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1136 - 1140, The cached-token branch treats
zero-length tokens as valid; change the match arm for Some(entry) to also
require !entry.token.is_empty() (i.e., Some(entry) if
!is_tc_token_expired(entry.token_timestamp) && !entry.token.is_empty()) before
pushing build_tc_token_node into extra_nodes, and likewise update the code path
that persists newly issued tokens (the branch around the second block noted) to
skip writing/storing empty entry.token values so empty tokens are never saved or
used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 290-292: cleanup_connection_state() currently leaves
transport-scoped SentNodeWaiter entries live; grab the sent_node_waiters mutex
inside cleanup_connection_state() and cancel/resolve all pending SentNodeWaiter
instances (and clear the Vec) so they cannot match against a later connection,
and decrement/reset sent_node_waiter_count accordingly; apply the same pattern
wherever connection teardown logic runs (areas referencing sent_node_waiters /
sent_node_waiter_count / SentNodeWaiter) to ensure each waiter is
notified/faulted and removed on disconnect.

In `@tests/e2e/tests/privacy_tokens.rs`:
- Around line 372-389: The salt-negative assertions are using the PN JID from
TestClient::jid() (jid_a) which can be unresolved; instead obtain the
recipient's LID via the TestClient API (call client_b.client.get_lid(...) or the
existing get_lid helper for client_b) and assert against the sent node using
that LID—e.g., resolve the LID for the recipient before checking the sent node
stored in `sent` and replace has_child(&sent, "tctoken") / has_child(&sent,
"cstoken") checks to use the resolved LID so the test fails on missing salt
rather than missing identity; apply the same LID-based change to the other
occurrences mentioned (the blocks around the other line ranges).

In `@wacore/src/iq/tctoken.rs`:
- Around line 524-572: Tests for compute_cs_token use device-qualified numeric
LIDs that resemble real phone numbers; replace those with synthetic bare LIDs to
match production input shape and avoid PII. Update occurrences in tests
test_compute_cs_token_deterministic, test_compute_cs_token_different_lids,
test_compute_cs_token_different_salts, and test_compute_cs_token_known_answer to
use fictitious bare JIDs (e.g. "alice@lid" and "bob@lid" or "user@lid") instead
of values like "100000000000001:67@lid" or "236395184570386@lid"; ensure the
expected value in test_compute_cs_token_known_answer is recomputed to match the
new salt+lid combination, and keep build_cs_token_node test unchanged except for
using synthetic input if needed.

---

Outside diff comments:
In `@src/send.rs`:
- Around line 1141-1149: The current logic calls backend.put_tc_token to update
TcTokenEntry.sender_timestamp when should_send_new_tc_token(...) is true before
performing send_node() and issue_tc_token_after_send(), which can mark the token
as refreshed even if the send fails; change this by removing the pre-send update
and instead update the cached TcTokenEntry.sender_timestamp only after a
successful send and after issue_tc_token_after_send() completes (i.e., in the
post-send store path), using the same TcTokenEntry shape and
backend.put_tc_token call so that should_send_new_tc_token and subsequent sends
behave correctly.

---

Duplicate comments:
In `@src/history_sync.rs`:
- Around line 261-268: The current code unconditionally writes
sync_result.nct_salt via
self.persistence_manager.process_command(DeviceCommand::SetNctSalt(...)), which
can resurrect stale history-sync salt over a newer mutation-derived value;
change this to a precedence-aware update by either (a) reading the current
device salt/metadata first (via the persistence manager query) and only issuing
DeviceCommand::SetNctSalt when the stored value is absent or older than the
history-sync value, or (b) add a new atomic command variant (e.g.,
DeviceCommand::SetNctSaltIfNotNewer or similar) implemented in the persistence
layer that compares timestamps/sequence numbers and only sets when it will not
overwrite a newer mutation-derived salt; update the call site to use that
conditional/atomic command instead of the unconditional SetNctSalt.

In `@src/send.rs`:
- Around line 1136-1140: The cached-token branch treats zero-length tokens as
valid; change the match arm for Some(entry) to also require
!entry.token.is_empty() (i.e., Some(entry) if
!is_tc_token_expired(entry.token_timestamp) && !entry.token.is_empty()) before
pushing build_tc_token_node into extra_nodes, and likewise update the code path
that persists newly issued tokens (the branch around the second block noted) to
skip writing/storing empty entry.token values so empty tokens are never saved or
used.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7fc437a0-d902-4c83-a7ee-5b317a743cbb

📥 Commits

Reviewing files that changed from the base of the PR and between dab378a and 6625661.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • src/client.rs
  • src/history_sync.rs
  • src/send.rs
  • storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/down.sql
  • storages/sqlite-storage/migrations/2026-03-23-000000_add_nct_salt/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/e2e/Cargo.toml
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/src/history_sync.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs

Comment thread src/client.rs
Comment on lines +290 to +292
/// Waiters for raw outgoing nodes before encryption.
sent_node_waiters: std::sync::Mutex<Vec<SentNodeWaiter>>,
sent_node_waiter_count: AtomicUsize,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Cancel pending sent-node waiters on disconnect.

These waiters are transport-scoped, but cleanup_connection_state() never drops them. If the socket dies before a match arrives, the receiver can stay pending across reconnects and then resolve against a later stanza from the wrong connection.

Suggested cleanup
 async fn cleanup_connection_state(&self) {
+    {
+        let mut waiters = self
+            .sent_node_waiters
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
+        if !waiters.is_empty() {
+            waiters.clear(); // dropping senders cancels receivers
+            self.sent_node_waiter_count.store(0, Ordering::Release);
+        }
+    }
+
     self.is_logged_in.store(false, Ordering::Relaxed);
     self.is_ready.store(false, Ordering::Relaxed);

Also applies to: 593-594, 2994-3011, 3037-3055, 3208-3210

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 290 - 292, cleanup_connection_state() currently
leaves transport-scoped SentNodeWaiter entries live; grab the sent_node_waiters
mutex inside cleanup_connection_state() and cancel/resolve all pending
SentNodeWaiter instances (and clear the Vec) so they cannot match against a
later connection, and decrement/reset sent_node_waiter_count accordingly; apply
the same pattern wherever connection teardown logic runs (areas referencing
sent_node_waiters / sent_node_waiter_count / SentNodeWaiter) to ensure each
waiter is notified/faulted and removed on disconnect.

Comment thread tests/e2e/tests/privacy_tokens.rs Outdated
Comment thread wacore/src/iq/tctoken.rs
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 25, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 52s —— View job


Code Review: cstoken (NCT) privacy token fallback + proto update

  • Read core implementation files (send.rs, client.rs, tctoken.rs)
  • Review history sync changes
  • Review storage/migration changes
  • Review e2e test coverage
  • Post review feedback

This is a well-executed PR. The previous two reviews already caught the main issues (empty salt persistence, empty tctoken suppressing cstoken, history sync TOCTOU, PII in tests), and those have all been addressed. The current state of the code is solid. Here's what I found in this pass:

Addressed from prior reviews (confirmed fixed)

  • Empty salt rejection (src/client.rs:2649): Empty salts are now explicitly checked and warned about before persisting.
  • Empty tctoken guard (src/send.rs:1141): !entry.token.is_empty() is now in the match guard, correctly falling through to cstoken.
  • Empty issued tokens skipped (src/send.rs:1190-1192): store_issued_tc_tokens skips empty tokens with a warning.
  • History sync TOCTOU (src/history_sync.rs:268): Uses SetNctSaltFromHistorySync which checks nct_salt_sync_seen and nct_salt.is_none() atomically in apply_command_to_device (wacore/src/store/commands.rs:60-63).
  • Sent-node waiters cleared on disconnect (src/client.rs:1064): cleanup_connection_state() calls clear_sent_node_waiters().
  • Down migration (down.sql): Uses explicit column lists in both backup SELECT and INSERT.
  • Test data: cstoken tests use synthetic "alice@lid" / "bob@lid" identifiers, history sync test uses "0000000000".
  • Known-answer HMAC test (wacore/src/iq/tctoken.rs:551-561): Catches accidental key/data argument swaps.

Remaining observations

1. nct_salt_sync_seen resets on restart — history sync can always re-backfill after cold boot (wacore/src/store/device.rs:168)

nct_salt_sync_seen is #[serde(skip)], so it defaults to false after deserialization. If the device previously received a SetNctSalt(None) (remove), the salt is cleared and nct_salt_sync_seen = true. But on restart, nct_salt_sync_seen reverts to false while nct_salt remains None (persisted). The next history sync can then backfill a stale salt via SetNctSaltFromHistorySync since both conditions (!nct_salt_sync_seen && nct_salt.is_none()) are now true.

In practice this is unlikely to cause issues because: (a) history sync typically only delivers during initial pairing, not on reconnect, and (b) if the server cleared the salt, it probably won't re-deliver it via history sync. But it's worth noting as a semantic gap. If you want belt-and-suspenders, you could persist nct_salt_sync_seen (or use a tri-state: None / Some(salt) / Cleared).

2. resolve_to_lid_jid in issue_tc_token_after_send re-resolves LID (src/send.rs:1168)

maybe_include_tc_token already resolved the LID at line 1118-1122 into resolved_lid_user. Then issue_tc_token_after_send (called right after in the send path) calls resolve_to_lid_jid(to) which does another cache/usync lookup. This is a minor inefficiency — the resolved LID could be threaded through. Non-blocking since usync results are cached and this only fires once per bucket window.

3. contacts.rs: persist_lid_mappings is a good addition (src/features/contacts.rs:28-54)

Clean extraction that persists PN→LID mappings from both get_contact_info and get_user_info responses. This is important for cstoken because maybe_include_tc_token relies on lid_pn_cache.get_current_lid() to resolve phone-number targets to LIDs for the HMAC input.

4. Benchmark alerts are noise

The 2 Bencher alerts (bench_decrypt_with_previous_session +10.38%, bench_message_encoding +5.06%) are in libsignal session decryption and reporting token encoding — entirely unrelated code paths. The changed code (HMAC, history sync parsing, app state dispatch) doesn't touch signal protocol or reporting tokens. These are CI measurement variance.

Minor nit

  • compute_cs_token doc comment at wacore/src/iq/tctoken.rs:258 says the recipient_lid is e.g. "12345:67@lid" (device-qualified), but the actual production path at src/send.rs:1152 constructs a bare user@lid via Jid::new(lid_user, "lid"). The doc example should match the actual usage: "12345@lid" (bare, no device suffix).

    Fix this →

Verdict

The implementation is clean and production-ready. All prior review findings have been addressed. The fallback chain (tctoken → cstoken → no token), the precedence-aware history sync backfill, and the comprehensive e2e coverage are all well done. The nct_salt_sync_seen restart semantics (observation #1) is the only gap worth considering, but it's low-risk given how history sync delivery works in practice.


View job

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 25, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 32s —— View job


Code Review: cstoken (NCT) privacy token fallback + proto update

  • Read core implementation files (send.rs, client.rs, tctoken.rs)
  • Review storage/migration and device state changes
  • Review history sync and commands
  • Review e2e tests and remaining review comments
  • Post review feedback

Fourth pass — all prior review findings have been addressed. The code is in good shape. Here's my assessment of the current state:

Confirmed Fixed (from prior reviews)

  • Empty salt rejection (src/client.rs:2649): Empty salts rejected with warning before persisting.
  • Empty tctoken guard (src/send.rs:1154): !entry.token.is_empty() in match guard, correctly falling through to cstoken.
  • Empty issued tokens skipped (src/send.rs:1204-1206): store_issued_tc_tokens skips empty tokens.
  • History sync TOCTOU (src/history_sync.rs:268): Uses SetNctSaltFromHistorySync which atomically checks nct_salt_sync_seen && nct_salt.is_none() in apply_command_to_device (wacore/src/store/commands.rs:60-63).
  • Sent-node waiters cleared on disconnect (src/client.rs:1064): cleanup_connection_state() calls clear_sent_node_waiters() which clears the vec and resets the atomic count.
  • Down migration (down.sql): Explicit column lists in both backup SELECT and INSERT.
  • Test data: cstoken tests use synthetic "alice@lid" / "bob@lid", history sync test uses "0000000000".
  • Known-answer HMAC test (wacore/src/iq/tctoken.rs:551): Guards against key/data swaps.
  • Doc comment (wacore/src/store/device.rs:161-165): NCT terminology is now neutral.
  • LID consolidation (src/send.rs:1131-1136): LID resolved once and reused for tctoken lookup, issuance, and cstoken HMAC input.

One Minor Remaining Issue (non-blocking)

lookup_tc_token_for_jid doesn't check !entry.token.is_empty() (src/send.rs:1267)

The main send path (maybe_include_tc_token) now correctly guards against empty tokens at line 1154. But lookup_tc_token_for_jid — used by profile picture and presence subscribe features — only checks expiry, not emptiness:

Ok(Some(entry)) if !is_tc_token_expired(entry.token_timestamp) => Some(entry.token),

Since store_issued_tc_tokens now skips storing empty tokens (line 1204), this is very unlikely to be hit in practice. But for consistency with the send path's defensive approach, adding && !entry.token.is_empty() would be belt-and-suspenders.

Fix this →

nct_salt_sync_seen Restart Semantics (known, low-risk)

As noted in the prior review: nct_salt_sync_seen is #[serde(skip)], so it resets to false on deserialization. After a cold restart, if the salt was previously removed via app state, nct_salt is None and nct_salt_sync_seen is false — allowing history sync to backfill a stale salt. In practice this is a non-issue because history sync only delivers during initial pairing. Noted for completeness.

Benchmark Alerts

The 2 Bencher alerts (bench_decrypt_with_previous_session +10.38%, bench_message_encoding +5.06%) are in unrelated code paths — CI variance, not regressions from this PR.

Verdict

Clean and production-ready. All actionable findings from prior reviews have been addressed. The fallback chain (tctoken → cstoken → no token), the precedence-aware history sync backfill via SetNctSaltFromHistorySync, and the comprehensive e2e coverage are well done. The only remaining suggestion is the minor consistency nit on lookup_tc_token_for_jid.


View job

@jlucaso1
jlucaso1 marked this pull request as ready for review March 25, 2026 23:51

@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: 99c3d72267

ℹ️ 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 src/send.rs
Comment on lines +1149 to +1150
let should_issue_after_send =
should_send_new_tc_token(existing.as_ref().and_then(|entry| entry.sender_timestamp));

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 Force tc-token reissue when cached token is expired

should_issue_after_send is computed only from sender_timestamp, so an expired cached token can suppress reissuance if the sender timestamp is still in the current bucket. In that case the code falls through to cstoken/no-token and never calls IssuePrivacyTokensSpec until the next bucket boundary (up to 7 days), leaving an expired tc-token in storage and breaking tc-token-gated flows during that window (e.g., presence/profile-picture access or tc-only scenarios). Expired/empty tokens should force immediate reissue regardless of sender bucket state.

Useful? React with 👍 / 👎.

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

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

Inline comments:
In `@src/send.rs`:
- Around line 1085-1090: The code currently calls
mark_tc_token_used_after_send(&token_key).await regardless of whether
issue_tc_token_after_send(...) actually succeeded; change the flow so that
mark_tc_token_used_after_send is only invoked when issue_tc_token_after_send
returned a successful, non-empty issuance (or alternatively let
store_issued_tc_tokens() be responsible for stamping sender_timestamp only on
success). Concretely, check the result/value returned by
issue_tc_token_after_send (or the issued token container) and gate the call to
mark_tc_token_used_after_send on that success; apply the same guard to the other
similar blocks that call issue_tc_token_after_send and
mark_tc_token_used_after_send.

In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 567-568: The code always sets nct_salt_sync_seen = false when
hydrating state (using row.nct_salt), which loses the persisted
“authoritative-clear seen” information and lets SetNctSaltFromHistorySync
resurrect stale salts; update the storage/schema to persist a separate
seen/source bit (or tri-state) (e.g. add/persist row.nct_salt_sync_seen or
row.nct_salt_state), ensure SetNctSalt/SetNctSalt(None) correctly writes that
bit/state, and in sqlite_store.rs hydrate nct_salt_sync_seen from the DB row
instead of hard-coding false so the load path preserves the prior seen/source
value.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e341b84a-12d4-43c9-8535-662ddd7a93eb

📥 Commits

Reviewing files that changed from the base of the PR and between 6625661 and 99c3d72.

📒 Files selected for processing (9)
  • src/client.rs
  • src/features/contacts.rs
  • src/history_sync.rs
  • src/send.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs

Comment thread src/send.rs Outdated
Comment on lines +567 to +568
nct_salt: row.nct_salt,
nct_salt_sync_seen: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Persist the authoritative-clear state across restarts.

This load path always recreates nct_salt_sync_seen = false. If a prior SetNctSalt(None) is already reflected in SQLite and the app-state patch is not replayed after restart, the device becomes indistinguishable from “never saw app-state”, so SetNctSaltFromHistorySync can resurrect a stale salt. Persist a separate seen/source bit (or a tri-state field) and hydrate it here instead of hard-coding false.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 567 - 568, The code
always sets nct_salt_sync_seen = false when hydrating state (using
row.nct_salt), which loses the persisted “authoritative-clear seen” information
and lets SetNctSaltFromHistorySync resurrect stale salts; update the
storage/schema to persist a separate seen/source bit (or tri-state) (e.g.
add/persist row.nct_salt_sync_seen or row.nct_salt_state), ensure
SetNctSalt/SetNctSalt(None) correctly writes that bit/state, and in
sqlite_store.rs hydrate nct_salt_sync_seen from the DB row instead of
hard-coding false so the load path preserves the prior seen/source value.

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

♻️ Duplicate comments (2)
src/send.rs (1)

1085-1090: ⚠️ Potential issue | 🟠 Major

Don't advance sender_timestamp unless the refresh actually succeeded.

issue_tc_token_after_send() returns early on IQ failure, and store_issued_tc_tokens() already drops empty tokens, but the cached entry still gets stamped whenever a refresh was scheduled. One transient failure will therefore suppress retries for the rest of the bucket and leave the peer without a fresh tc-token. Return a success flag from the refresh path and gate mark_tc_token_used_after_send() on it.

Patch sketch
-        if should_issue_tc_token_after_send {
-            self.issue_tc_token_after_send(&tc_issue_target).await;
-        }
-        if let Some(token_key) = used_cached_tc_token_key {
+        let refresh_succeeded = if should_issue_tc_token_after_send {
+            self.issue_tc_token_after_send(&tc_issue_target).await
+        } else {
+            false
+        };
+        if refresh_succeeded && let Some(token_key) = used_cached_tc_token_key {
             self.mark_tc_token_used_after_send(&token_key).await;
         }
-    async fn issue_tc_token_after_send(&self, to: &Jid) {
+    async fn issue_tc_token_after_send(&self, to: &Jid) -> bool {
         use wacore::iq::tctoken::IssuePrivacyTokensSpec;
 
         let to_lid = self.resolve_to_lid_jid(to).await;
         let Ok(response) = self
             .execute(IssuePrivacyTokensSpec::new(std::slice::from_ref(&to_lid)))
             .await
         else {
             log::debug!(target: "Client/TcToken", "Failed to issue tc_token for {}", to_lid);
-            return;
+            return false;
         };
 
-        self.store_issued_tc_tokens(&response.tokens).await;
+        self.store_issued_tc_tokens(&response.tokens).await
     }
 
-    async fn store_issued_tc_tokens(&self, tokens: &[wacore::iq::tctoken::ReceivedTcToken]) {
+    async fn store_issued_tc_tokens(
+        &self,
+        tokens: &[wacore::iq::tctoken::ReceivedTcToken],
+    ) -> bool {
         use wacore::store::traits::TcTokenEntry;
 
         if tokens.is_empty() {
-            return;
+            return false;
         }
 
         let backend = self.persistence_manager.backend();
         let now = wacore::time::now_secs();
+        let mut stored_any = false;
         for received in tokens {
             if received.token.is_empty() {
                 log::warn!(target: "Client/TcToken", "Server returned empty tc_token for {}, skipping", received.jid);
                 continue;
             }
@@
             let store_jid = received.jid.user.clone();
             if let Err(e) = backend.put_tc_token(&store_jid, &entry).await {
                 log::warn!(target: "Client/TcToken", "Failed to store issued tc_token: {e}");
+            } else {
+                stored_any = true;
             }
         }
+        stored_any
     }

Also applies to: 1179-1247

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1085 - 1090, The refresh path is stamping cached
entries even when the TC refresh failed; change issue_tc_token_after_send to
return a bool (or Result) indicating success and only call
mark_tc_token_used_after_send(&token_key).await when that success flag is true;
keep should_issue_tc_token_after_send/used_cached_tc_token_key logic but gate
the mark on the returned success, and update both refresh locations (the blocks
around issue_tc_token_after_send/mark_tc_token_used_after_send, including the
other occurrence covering the 1179-1247 region); no other behavior
changes—store_issued_tc_tokens can remain as-is.
storages/sqlite-storage/src/sqlite_store.rs (1)

567-568: ⚠️ Potential issue | 🟠 Major

Persist the authoritative-clear bit across restarts.

nct_salt_sync_seen is reconstructed as false here, so a prior SetNctSalt(None) becomes indistinguishable from “never saw app-state” after restart. That lets SetNctSaltFromHistorySync resurrect a stale salt even though app-state is supposed to stay authoritative. Please persist a separate seen/source bit (or a tri-state field) and hydrate it instead of hard-coding false.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 567 - 568, The code
currently reconstructs nct_salt_sync_seen as false when hydrating rows, losing
the prior "authoritative-clear" state; update the storage schema to persist a
separate seen/source flag (or tri-state) for the salt, read that persisted
column instead of hard-coding false in the hydrate path (the struct field
nct_salt_sync_seen), and adjust the logic in SetNctSalt(None) /
SetNctSaltFromHistorySync handlers to honor the persisted flag so a prior
explicit clear remains authoritative across restarts; ensure all references to
nct_salt and nct_salt_sync_seen are updated to use the new persisted
bit/tri-state and migrate existing rows appropriately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 3005-3010: Cleanup must not unconditionally store(0) into
sent_node_waiter_count because wait_for_sent_node() increments the counter
before locking sent_node_waiters, which can race with cleanup and leave the
counter stale. Instead, when clearing or draining the sent_node_waiters vector
(inside the mutex protected block), compute how many entries you removed and
decrement sent_node_waiter_count accordingly (e.g. fetch_sub(removed_len,
Ordering::Release)) rather than store(0); locate the cleanup logic around
sent_node_waiters and sent_node_waiter_count and replace the unconditional
store(0) with a fetch_sub of the removed count so send_node(),
wait_for_sent_node(), and resolve_sent_node_waiters() remain consistent.
- Around line 1063-1065: cleanup_connection_state currently clears sent-node
waiters but does not cancel transport-scoped node waiters; add a call to a new
helper (e.g., clear_node_waiters) from cleanup_connection_state so that
node_waiters are locked, counted, cleared, and node_waiter_count is decremented
by the number cleared (use lock().unwrap_or_else(|p| p.into_inner()) and
fetch_update with saturating_sub). Update clear_sent_node_waiters to mirror the
same pattern for consistency and ensure wait_for_node cannot survive a teardown
by referencing the node_waiters and node_waiter_count fields in the helper and
invoking it from cleanup_connection_state alongside clear_sent_node_waiters and
is_logged_in.store(false, Ordering::Relaxed).

In `@src/features/contacts.rs`:
- Around line 36-45: The code currently filters invalid jid/lid pairs at the
callsite, but the invariant should live inside the persistence API so all
writers are protected; add validation logic at the start of
Client::add_lid_pn_mapping (or implement a shared validator function used by
that method) to assert/judge that the provided jid.is_pn() and lid.is_lid() are
true and return a descriptive error if not, and update callers (e.g., sites that
call add_lid_pn_mapping from usync and pair paths) to handle the returned error
instead of relying on local guards.
- Around line 48-52: The log::warn! call emitting "jid" and "lid" in
src/features/contacts.rs should not print raw identifiers; replace the direct
jid and lid interpolation with redacted or hashed representations (e.g., call a
helper like redact_jid(jid) and redact_id(lid) or compute a short hash) and use
those redacted values in the log::warn! invocation so the warning still conveys
context without leaking user-identifying data.

In `@tests/e2e/tests/privacy_tokens.rs`:
- Around line 960-1007: Add explicit assertions that "cstoken" is not present on
the tctoken-only endpoints: after you obtain denied_node and allowed_node, call
has_descendant(&denied_node, "cstoken") and has_descendant(&allowed_node,
"cstoken") and assert they are false (e.g. assert!(!has_descendant(...,
"cstoken"))). Place these checks alongside the existing tctoken assertions in
the denied and allowed branches (using the same denied_node and allowed_node
variables) so the test fails if a cstoken is ever attached to
profile-picture/presence IQs.

In `@wacore/src/store/commands.rs`:
- Around line 60-63: The DeviceCommand::SetNctSaltFromHistorySync arm currently
accepts and stores empty salts (Some(vec![])), violating the contract that empty
salts should be ignored; change the handler in commands.rs so that when matching
DeviceCommand::SetNctSaltFromHistorySync(salt) you only set device.nct_salt =
Some(salt) if salt is non-empty and device.nct_salt_sync_seen is false and
device.nct_salt.is_none(); otherwise leave nct_salt unchanged and still
mark/handle nct_salt_sync_seen as appropriate. Update or add a unit test that
invokes the SetNctSaltFromHistorySync command with an empty Vec and asserts that
device.nct_salt remains None (regression test for the empty-salt case).

---

Duplicate comments:
In `@src/send.rs`:
- Around line 1085-1090: The refresh path is stamping cached entries even when
the TC refresh failed; change issue_tc_token_after_send to return a bool (or
Result) indicating success and only call
mark_tc_token_used_after_send(&token_key).await when that success flag is true;
keep should_issue_tc_token_after_send/used_cached_tc_token_key logic but gate
the mark on the returned success, and update both refresh locations (the blocks
around issue_tc_token_after_send/mark_tc_token_used_after_send, including the
other occurrence covering the 1179-1247 region); no other behavior
changes—store_issued_tc_tokens can remain as-is.

In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 567-568: The code currently reconstructs nct_salt_sync_seen as
false when hydrating rows, losing the prior "authoritative-clear" state; update
the storage schema to persist a separate seen/source flag (or tri-state) for the
salt, read that persisted column instead of hard-coding false in the hydrate
path (the struct field nct_salt_sync_seen), and adjust the logic in
SetNctSalt(None) / SetNctSaltFromHistorySync handlers to honor the persisted
flag so a prior explicit clear remains authoritative across restarts; ensure all
references to nct_salt and nct_salt_sync_seen are updated to use the new
persisted bit/tri-state and migrate existing rows appropriately.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f419c22b-cc43-4987-a511-0a89ef4f3826

📥 Commits

Reviewing files that changed from the base of the PR and between 6625661 and b078011.

📒 Files selected for processing (9)
  • src/client.rs
  • src/features/contacts.rs
  • src/history_sync.rs
  • src/send.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs

Comment thread src/client.rs
Comment thread src/client.rs
Comment thread src/features/contacts.rs
Comment on lines +36 to +45
if !jid.is_pn() || !lid.is_lid() {
continue;
}
if let Err(err) = self
.client
.add_lid_pn_mapping(
&lid.user,
&jid.user,
crate::lid_pn_cache::LearningSource::Usync,
)

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

Enforce PN/LID validation in the persistence API, not only at this callsite.

This helper filters invalid pairs, but other write paths (src/usync.rs:38-46, src/pair.rs:250-256) persist without the same guard. Move/duplicate this invariant into Client::add_lid_pn_mapping (or a single shared validator) so cache integrity is consistent regardless of caller.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/contacts.rs` around lines 36 - 45, The code currently filters
invalid jid/lid pairs at the callsite, but the invariant should live inside the
persistence API so all writers are protected; add validation logic at the start
of Client::add_lid_pn_mapping (or implement a shared validator function used by
that method) to assert/judge that the provided jid.is_pn() and lid.is_lid() are
true and return a descriptive error if not, and update callers (e.g., sites that
call add_lid_pn_mapping from usync and pair paths) to handle the returned error
instead of relying on local guards.

Comment thread src/features/contacts.rs
Comment on lines +48 to +52
log::warn!(
"Failed to persist usync LID mapping {} -> {}: {err}",
jid,
lid
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact JIDs in warning logs to avoid identifier leakage.

Line 49 logs full jid/lid values on failure. These identifiers can contain user-linked data and should not be emitted in plain logs.

Suggested fix
-                log::warn!(
-                    "Failed to persist usync LID mapping {} -> {}: {err}",
-                    jid,
-                    lid
-                );
+                log::warn!("Failed to persist usync LID mapping (jid/lid redacted): {err}");
📝 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
log::warn!(
"Failed to persist usync LID mapping {} -> {}: {err}",
jid,
lid
);
log::warn!("Failed to persist usync LID mapping (jid/lid redacted): {err}");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/contacts.rs` around lines 48 - 52, The log::warn! call emitting
"jid" and "lid" in src/features/contacts.rs should not print raw identifiers;
replace the direct jid and lid interpolation with redacted or hashed
representations (e.g., call a helper like redact_jid(jid) and redact_id(lid) or
compute a short hash) and use those redacted values in the log::warn! invocation
so the warning still conveys context without leaking user-identifying data.

Comment thread tests/e2e/tests/privacy_tokens.rs
Comment thread wacore/src/store/commands.rs
- Fix race in clear_sent_node_waiters: use fetch_sub(count) instead of
  store(0) to avoid counter desync with concurrent wait_for_sent_node
- Drain node_waiters on disconnect: add clear_node_waiters() called from
  cleanup_connection_state so incoming-node waiters don't survive teardown
- Gate sender_timestamp mark on issuance success: match WA Web's
  TcTokenChatAction.js which only updates tcTokenSenderTimestamp after
  issuePrivacyToken resolves, allowing retry on next send if IQ failed
- Reject empty salt in SetNctSaltFromHistorySync command handler
- Assert cstoken absence on profile picture and presence IQ tests
node_waiters are cross-connection — callers may register a waiter
before an action whose response arrives on a subsequent connection
(e.g. after 515 reconnect). Clearing them in cleanup_connection_state
caused the connection e2e tests to fail because the PairSuccess/Connected
event flow spans the initial connect + 515 reconnect cycle.

sent_node_waiters are correctly cleared since they match pre-encryption
outgoing stanzas which are transport-scoped.

@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: 8cb0f7f563

ℹ️ 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".

props_hash: row.props_hash,
next_pre_key_id: row.next_pre_key_id as u32,
nct_salt: row.nct_salt,
nct_salt_sync_seen: false,

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 nct_salt sync tombstone across restarts

nct_salt_sync_seen is always reloaded as false, so a previously authoritative remove (SetNctSalt(None)) is forgotten after process restart. Because SetNctSaltFromHistorySync only checks !nct_salt_sync_seen && nct_salt.is_none(), any later history-sync payload containing nct_salt will repopulate a salt that had been explicitly removed, causing stale cstoken behavior until another syncd mutation arrives. Persisting a remove tombstone (or equivalent) is needed to keep the “do not resurrect after remove” guarantee across restarts.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error reporting when adding people to group: 463 error

1 participant