Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/message/receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,27 @@ impl Client {
);

// Send retry receipt with fresh prekeys
outcome.had_failure = true;
outcome.undecryptable |= self
.handle_decrypt_failure(
info,
RetryReason::InvalidKeyId,
decrypt_fail_mode,
)
.await;
continue;
} else if matches!(e, SignalProtocolError::InvalidSignedPreKeyId) {
// WA Web classifies this as SignalRetryable; the catch-all
// nack would permanently drop the stanza from the offline
// queue. Mirrors the sibling InvalidPreKeyId arm.
log::debug!(
"[msg:{}] Decryption failed for {} message from {} due to InvalidSignedPreKeyId. \
Sender used a signed prekey we've rotated out. Sending retry receipt with fresh prekeys.",
info.id,
enc_type,
info.source.sender.observe()
);

outcome.had_failure = true;
outcome.undecryptable |= self
.handle_decrypt_failure(
Expand Down
75 changes: 74 additions & 1 deletion src/message/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,13 @@ async fn ensure_bob_paired(client: &Arc<Client>) {
/// keys back to the sender — assembled through the same
/// `SignalProtocolStoreAdapter` traits production uses.
async fn bobs_prekey_bundle(client: &Arc<Client>) -> (PreKeyBundle, Jid) {
bobs_prekey_bundle_with_spk_id(client, 1).await
}

/// Like [`bobs_prekey_bundle`] but advertises an arbitrary `spk_id` so callers
/// can trigger `InvalidSignedPreKeyId` on Bob's decrypt path (the signed-prekey
/// signature covers only the public key, so an unprovisioned id still verifies).
async fn bobs_prekey_bundle_with_spk_id(client: &Arc<Client>, spk_id: u32) -> (PreKeyBundle, Jid) {
use wacore::libsignal::protocol::GenericSignedPreKey;
ensure_bob_paired(client).await;
let snapshot = client.persistence_manager.get_device_snapshot();
Expand Down Expand Up @@ -799,7 +806,7 @@ async fn bobs_prekey_bundle(client: &Arc<Client>) -> (PreKeyBundle, Jid) {
reg_id,
u32::from(own_device_jid.device).into(),
Some((pk_id_u32.into(), pk_pair.public_key)),
1.into(),
spk_id.into(),
spk_pub,
spk_sig_vec,
IdentityKey::new(identity_kp.public_key),
Expand Down Expand Up @@ -10450,3 +10457,69 @@ fn test_group_decrypt_retry_reason_classification() {
None
);
}

/// F4: a PreKeySignalMessage naming a signed prekey we've rotated past
/// SIGNED_PRE_KEY_RETENTION surfaces `InvalidSignedPreKeyId`. WA Web classifies
/// this as `SignalRetryable`, so it must send a retry receipt (carrying our live
/// bundle) rather than a terminal NACK that drops the 1:1 message. The sibling
/// `InvalidPreKeyId` arm already retries; this locks the same for signed prekeys.
#[tokio::test]
async fn test_invalid_signed_prekey_id_sends_retry_receipt() {
let client = crate::test_utils::create_test_client_with_name("invalid_spk_id").await;

// Bundle advertises a signed-prekey id the client never provisioned, so
// Bob's decrypt hits InvalidSignedPreKeyId (as a rotated-out signed prekey
// would). Alice still accepts the bundle because the id is not signed.
let (bundle, bob_jid) = bobs_prekey_bundle_with_spk_id(&client, 4242).await;
let bob_addr = bob_jid.to_protocol_address();

let mut alice = AlicePeer::new("15550002002@s.whatsapp.net").await;
alice.install_bob_session(&bob_addr, &bundle).await;
let pkmsg = alice
.encrypt_text(&bob_addr, "signed prekey rotated out")
.await;
assert!(
matches!(pkmsg, CiphertextMessage::PreKeySignalMessage(_)),
"must be a pkmsg so decrypt looks up the signed prekey"
);

let bytes = match &pkmsg {
CiphertextMessage::PreKeySignalMessage(m) => m.serialized().to_vec(),
_ => unreachable!(),
};
let enc_node = NodeBuilder::new("enc")
.attr("type", "pkmsg")
.bytes(bytes)
.build();
let enc_ref = enc_node.as_node_ref();
let payloads: Vec<EncPayload> = vec![EncPayload::from_node_ref(&enc_ref).unwrap()];
let info = Arc::new(MessageInfo {
id: "INVALID_SPK_ID_MSG".to_string(),
source: crate::types::message::MessageSource {
sender: alice.jid.clone(),
chat: alice.jid.clone(),
..Default::default()
},
..Default::default()
});

let outcome = client
.clone()
.process_session_enc_batch(
&payloads,
&info,
&alice.jid,
crate::types::events::DecryptFailMode::Show,
)
.await;
assert!(
!outcome.decrypted,
"must not decrypt with a missing signed prekey"
);
assert!(outcome.had_failure, "must record a decrypt failure");

// The fix routes InvalidSignedPreKeyId to handle_decrypt_failure -> retry
// receipt with RetryReason::InvalidKeyId. Pre-fix this took the terminal
// NACK path, which never bumps the retry caches.
await_retry_receipt(&client, &info, 1, RetryReason::InvalidKeyId).await;
}
Loading