> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signal

> Low-level Signal protocol operations for encryption, decryption, and session management

The `Signal` struct provides direct access to Signal protocol operations including message encryption/decryption for both 1:1 and group conversations, session management, and participant node creation.

<Warning>
  These are low-level APIs that bypass the high-level message sending pipeline. Most users should use [`client.send_message()`](/api/client#send_message) which handles encryption automatically. Use these methods only when you need direct control over the Signal protocol layer.
</Warning>

## Access

Access Signal protocol operations through the client:

```rust theme={null}
let signal = client.signal();
```

## Methods

### encrypt\_message

Encrypt plaintext for a single recipient using the Signal protocol.

```rust theme={null}
pub async fn encrypt_message(
    &self,
    jid: &Jid,
    plaintext: &[u8],
) -> Result<(EncType, Vec<u8>), SignalError>
```

**Parameters:**

* `jid` - Recipient JID. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists, matching WA Web's `SignalAddress.toString()` and the internal send path. See [Signal address resolution](/advanced/signal-protocol#signal-address-resolution).
* `plaintext` - Raw bytes to encrypt. The caller is responsible for padding if needed.

**Returns:**

* `(EncType, Vec<u8>)` - The encryption type and ciphertext bytes

**EncType variants:**

* `EncType::PreKeyMessage` - Session was just established (includes prekey bundle)
* `EncType::Message` - Standard encrypted message

**Example:**

```rust theme={null}
use whatsapp_rust::EncType;

let plaintext = b"Hello, world!";
let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?;

match enc_type {
    EncType::PreKeyMessage => println!("New session established"),
    EncType::Message => println!("Existing session used"),
    _ => {}
}
```

### decrypt\_message

Decrypt a Signal protocol message from a sender.

```rust theme={null}
pub async fn decrypt_message(
    &self,
    jid: &Jid,
    enc_type: EncType,
    ciphertext: &[u8],
) -> Result<Vec<u8>, SignalError>
```

**Parameters:**

* `jid` - Sender JID. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists.
* `enc_type` - The encryption type (`EncType::PreKeyMessage` or `EncType::Message`)
* `ciphertext` - Encrypted bytes to decrypt

**Returns:**

* `Vec<u8>` - Raw padded plaintext. Use `MessageUtils::unpad_message_ref` with the stanza's `v` attribute if WhatsApp message unpadding is needed.

<Note>
  Passing `EncType::SenderKey` returns an error — use [`decrypt_group_message`](#decrypt_group_message) for sender-key encrypted group messages.
</Note>

**Example:**

```rust theme={null}
let plaintext = client.signal().decrypt_message(
    &sender_jid,
    EncType::Message,
    &ciphertext,
).await?;
```

### encrypt\_group\_message

Encrypt plaintext for a group using sender keys.

```rust theme={null}
pub async fn encrypt_group_message(
    &self,
    group_jid: &Jid,
    plaintext: &[u8],
) -> Result<(Option<Vec<u8>>, Vec<u8>), SignalError>
```

**Parameters:**

* `group_jid` - Group JID (`@g.us`)
* `plaintext` - Raw bytes to encrypt

**Returns:**

* `(Option<Vec<u8>>, Vec<u8>)` - A tuple of optional SKDM bytes and ciphertext bytes. The SKDM is `Some` only when a new sender key was created (first encrypt for this group or after key rotation). You must distribute the SKDM to all group participants when present.

<Note>
  Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), keyed here by your own JID as the sender. Two overlapping `encrypt_group_message` calls for the same group safely queue behind one another. A concurrent `decrypt_group_message` call only shares this lock when its `sender_jid` is your own JID (an unusual case) — decrypting messages from other participants uses a different chain and runs fully in parallel. See [sender-key chain locking](/advanced/signal-protocol#parallelized-group-encrypt-fan-out).
</Note>

**Example:**

```rust theme={null}
let (skdm, ciphertext) = client.signal().encrypt_group_message(
    &group_jid,
    &plaintext,
).await?;

if let Some(skdm_bytes) = skdm {
    // Distribute SKDM to all group participants
    println!("New sender key created, SKDM must be distributed");
}
```

### decrypt\_group\_message

Decrypt a group (sender-key) message.

```rust theme={null}
pub async fn decrypt_group_message(
    &self,
    group_jid: &Jid,
    sender_jid: &Jid,
    ciphertext: &[u8],
) -> Result<Vec<u8>, SignalError>
```

**Parameters:**

* `group_jid` - Group JID
* `sender_jid` - Sender's JID within the group
* `ciphertext` - Encrypted bytes to decrypt

**Returns:**

* `Vec<u8>` - Raw padded plaintext. Use `MessageUtils::unpad_message_ref` with the stanza's `v` attribute if WhatsApp message unpadding is needed.

<Note>
  Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), so two overlapping `decrypt_group_message` calls for the same sender safely queue behind one another. Calls for different senders in the same group — or a concurrent `encrypt_group_message` call, which uses your own JID as the chain identity — touch a different chain and run in parallel. See [sender-key chain lock (group receive)](/concepts/architecture#sender-key-chain-lock-group-receive).
</Note>

**Example:**

```rust theme={null}
let plaintext = client.signal().decrypt_group_message(
    &group_jid,
    &sender_jid,
    &ciphertext,
).await?;
```

### sender\_key\_distribution

Create (or lazily initialize) and serialize the current outgoing sender-key distribution message for a group.

```rust theme={null}
pub async fn sender_key_distribution(
    &self,
    group_jid: &Jid,
    sender_jid: &Jid,
) -> Result<Vec<u8>, SignalError>
```

**Parameters:**

* `group_jid` - Group JID
* `sender_jid` - Your own JID as it should appear to other group members (the sender key chain owner)

**Returns:**

* `Vec<u8>` - Serialized `SenderKeyDistributionMessage` bytes, ready to send to a new or existing group member (e.g. when adding a participant who needs to decrypt future messages)

This is the same distribution payload [`encrypt_group_message`](#encrypt_group_message) returns automatically on first use — call it directly when you need to (re)distribute a sender key out of band, such as when a new member joins and needs the current chain without waiting for the next group message. The distribution is durably persisted before this method returns.

**Example:**

```rust theme={null}
let distribution = client.signal().sender_key_distribution(&group_jid, &my_jid).await?;
// Send `distribution` to the new participant, e.g. wrapped in a SenderKeyDistributionMessage protocol node
```

### process\_sender\_key\_distribution

Process an incoming sender-key distribution message for a group, installing the sender's chain so future `skmsg` stanzas from them can be decrypted.

```rust theme={null}
pub async fn process_sender_key_distribution(
    &self,
    group_jid: &Jid,
    sender_jid: &Jid,
    distribution: &[u8],
) -> Result<(), SignalError>
```

**Parameters:**

* `group_jid` - Group JID
* `sender_jid` - JID of the participant who distributed the sender key
* `distribution` - Serialized `SenderKeyDistributionMessage` bytes received from the sender (typically extracted from an incoming SKDM node)

The sender-key chain is durably persisted before this method returns.

**Example:**

```rust theme={null}
client.signal().process_sender_key_distribution(
    &group_jid,
    &sender_jid,
    &distribution_bytes,
).await?;
```

### has\_sender\_key

Check whether sender-key state already exists for a group and sender.

```rust theme={null}
pub async fn has_sender_key(
    &self,
    group_jid: &Jid,
    sender_jid: &Jid,
) -> Result<bool, SignalError>
```

**Parameters:**

* `group_jid` - Group JID
* `sender_jid` - Sender's JID within the group

**Returns:**

* `bool` - `true` if a sender-key chain is already stored for this `(group_jid, sender_jid)` pair

**Example:**

```rust theme={null}
if !client.signal().has_sender_key(&group_jid, &author_jid).await? {
    // No chain yet — process the SKDM before decrypting skmsg from this sender
}
```

### delete\_sender\_key

Durably delete a sender-key chain for a group and sender, e.g. on group exit or key rotation.

```rust theme={null}
pub async fn delete_sender_key(
    &self,
    group_jid: &Jid,
    sender_jid: &Jid,
) -> Result<(), SignalError>
```

**Parameters:**

* `group_jid` - Group JID
* `sender_jid` - Sender's JID within the group

The deletion waits for any in-flight chain mutation (e.g. a concurrent `encrypt_group_message` ratchet advance) to finish before removing the chain, and is flushed to the persistent backend before returning.

**Example:**

```rust theme={null}
// On leaving a group, drop the local copy of your own sender key chain
client.signal().delete_sender_key(&group_jid, &my_jid).await?;
```

### validate\_session

Check whether a Signal session exists for a JID.

```rust theme={null}
pub async fn validate_session(&self, jid: &Jid) -> Result<bool, SignalError>
```

**Parameters:**

* `jid` - JID to check. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists.

**Returns:**

* `bool` - `true` if a session exists, `false` otherwise

**Example:**

```rust theme={null}
if client.signal().validate_session(&jid).await? {
    println!("Session exists for {}", jid);
} else {
    println!("No session — need to establish one first");
}
```

### session\_info

Inspect an existing pairwise Signal session, migrating legacy PN-addressed state to its resolved LID namespace when needed.

```rust theme={null}
pub async fn session_info(&self, jid: &Jid) -> Result<Option<SignalSessionInfo>, SignalError>
```

**Parameters:**

* `jid` - JID to inspect. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists.

**Returns:**

* `Option<SignalSessionInfo>` - `Some` with the session's base key and remote registration id if a session exists, `None` otherwise

<Note>
  If only a legacy PN-addressed session exists and the resolved address is a LID, this method migrates it first (moving session and identity state to the LID namespace) and then reports on the migrated session — mirroring the on-the-fly migration used by the decrypt path. This means `session_info` is not a purely read-only probe: it can itself perform the migration, so a subsequent [`migrate_sessions`](#migrate_sessions) call on the same pair may find there is nothing left to move. See [`SignalSessionInfo`](#signalsessioninfo) and [PN→LID session migration](/advanced/signal-protocol).
</Note>

**Example:**

```rust theme={null}
if let Some(info) = client.signal().session_info(&jid).await? {
    println!("registration_id={} base_key_len={}", info.registration_id, info.base_key.len());
}
```

### delete\_sessions

Delete Signal sessions and identity keys for the given JIDs.

```rust theme={null}
pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<(), SignalError>
```

**Parameters:**

* `jids` - JIDs whose sessions and identity keys should be deleted. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists.

This matches WhatsApp Web's `deleteRemoteSession` behavior, which removes both the session and identity key as a paired operation. Changes are flushed to the persistent backend before returning.

**Example:**

```rust theme={null}
// Delete sessions for specific contacts
client.signal().delete_sessions(&[jid1, jid2]).await?;
```

### install\_prekey\_bundle

Durably install a supplied pre-key bundle for a JID, establishing (or replacing) a pairwise session from it.

```rust theme={null}
pub async fn install_prekey_bundle(
    &self,
    jid: &Jid,
    bundle: &PreKeyBundle,
) -> Result<IdentityChange, SignalError>
```

**Parameters:**

* `jid` - JID to install the session for. Resolved the same way as [`encrypt_message`](#encrypt_message).
* `bundle` - A `PreKeyBundle` obtained out of band — e.g. from a manual/custom prekey fetch — rather than through [`assert_sessions`](#assert_sessions)'s normal usync + fetch flow

**Returns:**

* `IdentityChange` - `IdentityChange::NewOrUnchanged` if the peer had no identity key or it matched, or `IdentityChange::ReplacedExisting` if this bundle's identity key replaced a previously trusted one

The session is durably persisted before this method returns.

**Example:**

```rust theme={null}
let bundle: PreKeyBundle = /* fetched via a custom IQ */;
let identity_change = client.signal().install_prekey_bundle(&jid, &bundle).await?;

if identity_change == IdentityChange::ReplacedExisting {
    println!("warning: {jid}'s identity key changed");
}
```

### migrate\_sessions

Move pairwise session and identity state from one JID namespace to another for the same underlying account (PN→LID, or Hosted→HostedLid).

```rust theme={null}
pub async fn migrate_sessions(
    &self,
    from: &Jid,
    to: &Jid,
) -> Result<SignalSessionMigration, SignalError>
```

**Parameters:**

* `from` - Source JID namespace (must be `Pn` or `Hosted`)
* `to` - Destination JID namespace (must be `Lid` for a `Pn` source, or `HostedLid` for a `Hosted` source)

**Returns:**

* `SignalSessionMigration` - Counts of sessions and identities moved, discarded, or skipped. See [`SignalSessionMigration`](#signalsessionmigration).

Scans known device slots under `from`, moving each pairwise session and identity to `to` when the destination doesn't already have one, and discarding the stale source entry when it does. This is the same logic the client runs automatically on LID discovery and on-the-fly during decryption — exposed here for callers that want to trigger it manually. See [PN→LID session migration](/advanced/signal-protocol).

Mismatched namespace pairs (e.g. a `Pn` source with a `HostedLid` destination, or two otherwise unrelated JIDs) are rejected with `SignalError::InvalidInput`.

**Example:**

```rust theme={null}
let outcome = client.signal().migrate_sessions(&pn_jid, &lid_jid).await?;

if outcome.has_state_changes() {
    println!(
        "migrated {} sessions, {} identities",
        outcome.migrated, outcome.migrated_identities
    );
}
```

### create\_participant\_nodes

Create encrypted participant `<to>` nodes for the given recipient JIDs.

```rust theme={null}
pub async fn create_participant_nodes(
    &self,
    recipient_jids: &[Jid],
    message: &waproto::whatsapp::Message,
) -> Result<(Vec<Node>, bool), SignalError>
```

**Parameters:**

* `recipient_jids` - JIDs to encrypt for
* `message` - Protobuf message to encrypt

**Returns:**

* `(Vec<Node>, bool)` - The encrypted participant XML nodes and a boolean indicating whether a device identity node should be included in the stanza (true when any participant received a PreKey message).

This method resolves devices, ensures Signal sessions exist, encrypts the message for each device, and returns the resulting XML nodes. It acquires session locks matching the DM send path via `session_mutexes_for()` (bare recipient JID for the recipient, per-device for own companion devices).

**Example:**

```rust theme={null}
use waproto::whatsapp as wa;

let message = wa::Message {
    conversation: Some("Hello!".to_string()),
    ..Default::default()
};

let (nodes, include_identity) = client.signal().create_participant_nodes(
    &[recipient_jid],
    &message,
).await?;
```

### assert\_sessions

Ensure E2E sessions exist for the given JIDs.

```rust theme={null}
pub async fn assert_sessions(&self, jids: &[Jid]) -> Result<(), SignalError>
```

**Parameters:**

* `jids` - JIDs to ensure sessions for

If sessions do not exist, this method fetches prekey bundles from the server and establishes new sessions.

**Example:**

```rust theme={null}
// Ensure sessions exist before manual encryption
client.signal().assert_sessions(&[jid1, jid2]).await?;
```

### get\_user\_devices

Get all known device JIDs for the given user JIDs via usync.

```rust theme={null}
pub async fn get_user_devices(&self, jids: &[Jid]) -> Result<Vec<Jid>, SignalError>
```

**Parameters:**

* `jids` - User JIDs to query

**Returns:**

* `Vec<Jid>` - All device JIDs for the given users

**Example:**

```rust theme={null}
let devices = client.signal().get_user_devices(&[user_jid]).await?;
println!("User has {} devices", devices.len());
```

## EncType

The `EncType` enum represents the Signal protocol encryption type used for a message:

```rust theme={null}
pub enum EncType {
    /// Standard Signal message (existing session)
    Message,
    /// PreKey Signal message (new session establishment)
    PreKeyMessage,
    /// Sender key message (group encryption)
    SenderKey,
    /// Bot message secret (`<enc type="msmsg">`) — Meta AI / fbid bot
    /// replies. Decrypted with the outbound `messageSecret`, not a Signal
    /// session. See [Bot message decryption](#bot-message-decryption-msmsg).
    MessageSecret,
}
```

`EncType` exposes two predicate helpers: `is_session()` (true for `Message` / `PreKeyMessage`, **excludes** `MessageSecret`) and `is_bot_secret()` (true only for `MessageSecret`).

<Note>
  `EncType` is re-exported from the crate root as `whatsapp_rust::EncType` — prefer that over the internal `wacore::message_processing::EncType` path shown in older examples.
</Note>

## SignalSessionInfo

Read-only information from a currently open pairwise session, returned by [`session_info`](#session_info):

```rust theme={null}
pub struct SignalSessionInfo {
    /// Local base key identifying the active session state.
    pub base_key: Vec<u8>,
    /// Remote registration identifier recorded by the session.
    pub registration_id: u32,
}
```

`SignalSessionInfo` is re-exported from the crate root, so it's also available as `whatsapp_rust::SignalSessionInfo`.

## SignalSessionMigration

Result of moving pairwise session state between address namespaces, returned by [`migrate_sessions`](#migrate_sessions):

```rust theme={null}
#[non_exhaustive]
pub struct SignalSessionMigration {
    /// Pairwise sessions moved to the destination namespace.
    pub migrated: usize,
    /// Pairwise session lookups skipped after a storage error.
    pub skipped: usize,
    /// Pairwise sessions found or unsuccessfully queried.
    pub total: usize,
    /// Identity records moved when the destination had no identity.
    pub migrated_identities: usize,
    /// Source identity records removed in favor of an existing destination.
    pub discarded_identities: usize,
    /// Identity lookups skipped after a storage error.
    pub skipped_identities: usize,
}
```

**Methods:**

* `has_state_changes(self) -> bool` - `true` if any source state was moved or removed (`migrated != 0 || migrated_identities != 0 || discarded_identities != 0`). Useful for deciding whether a migration was a meaningful no-op (nothing to move) versus one that changed persisted state.

`SignalSessionMigration` is `#[non_exhaustive]` and re-exported from the crate root as `whatsapp_rust::SignalSessionMigration`.

## Bot message decryption (msmsg)

When you message Meta AI or another `@bot` account, the bot's replies arrive as `<enc type="msmsg">` stanzas. These are **not** Signal-session encrypted — they use a dual-HKDF derivation over the 32-byte `messageSecret` from the prompt you sent, then AES-256-GCM.

The client handles this end to end and **transparently**:

1. **On send to a bot**, the outbound `MessageContextInfo.messageSecret` is persisted (keyed by `(chat, sender, msg_id)`) so the reply can be decrypted later.
2. **On receive**, an `msmsg` stanza is decrypted and decoded into a `wa::Message`, then dispatched as a normal [`Event::Messages`](/concepts/events#messages) — there is no separate bot event. The sender is the bot JID (e.g. `…@bot`) and `MsgMetaInfo.target_id` points back at your original prompt.
3. **On failure** (missing secret, GCM tag mismatch, malformed proto) the client nacks with reason `495` (`MissingMessageSecret`) instead of silently dropping, and group bot replies are acked with a bare `<ack class="message">` matching WA Web.

You don't need to call anything — receiving bot replies works as soon as you've sent a message to the bot from the same client. The low-level primitive is `wacore::bot_message::decrypt_bot_message(message_secret, enc_iv, enc_payload, ctx)`, and persistence is backed by the [`MsgSecretStore`](/api/store#msgsecretstore) trait.

## Usage examples

### Manual 1:1 encryption round-trip

```rust theme={null}
// Ensure a session exists
client.signal().assert_sessions(&[recipient_jid.clone()]).await?;

// Encrypt
let plaintext = b"Secret message";
let (enc_type, ciphertext) = client.signal().encrypt_message(
    &recipient_jid,
    plaintext,
).await?;

// The recipient would decrypt with:
// let decrypted = client.signal().decrypt_message(&sender_jid, enc_type, &ciphertext).await?;
```

### Check session before sending

```rust theme={null}
let has_session = client.signal().validate_session(&jid).await?;

if !has_session {
    // Establish session first
    client.signal().assert_sessions(&[jid.clone()]).await?;
}

let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?;
```

### Group encryption with SKDM handling

```rust theme={null}
let (skdm, ciphertext) = client.signal().encrypt_group_message(
    &group_jid,
    &plaintext,
).await?;

if skdm.is_some() {
    // First message in this group or after key rotation.
    // The SKDM must be distributed to all participants
    // so they can decrypt future messages.
}
```

### Add a participant to an existing sender-key group

```rust theme={null}
// New member needs the current sender key chain to decrypt future skmsg
if !client.signal().has_sender_key(&group_jid, &my_jid).await? {
    // Nothing sent to this group yet — no chain to distribute
} else {
    let distribution = client.signal().sender_key_distribution(&group_jid, &my_jid).await?;
    // Send `distribution` to the new participant
}
```

### Reset a broken session

```rust theme={null}
// Delete the corrupted session
client.signal().delete_sessions(&[jid.clone()]).await?;

// Re-establish
client.signal().assert_sessions(&[jid.clone()]).await?;

// Now encryption should work again
let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?;
```

### Manually migrate a session to LID addressing

```rust theme={null}
// Migrate first — session_info(&pn_jid) would trigger this same migration as a
// side effect, which would leave nothing here for migrate_sessions to move.
let outcome = client.signal().migrate_sessions(&pn_jid, &lid_jid).await?;
if outcome.has_state_changes() {
    println!("moved {} session(s) to LID addressing", outcome.migrated);
}

// Inspect the session under its new LID address
if let Some(info) = client.signal().session_info(&lid_jid).await? {
    println!("registration_id={}", info.registration_id);
}
```

## Error types

### `SignalError`

All signal methods return `Result<T, SignalError>`:

```rust theme={null}
#[non_exhaustive]
pub enum SignalError {
    #[error("{0}")]
    Protocol(#[from] SignalProtocolError),
    #[error("unsupported signal operation: {0}")]
    Unsupported(String),
    #[error("invalid signal input: {0}")]
    InvalidInput(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

**Variants:**

* `Protocol` — Signal protocol error (session mismatch, decode failure, etc.)
* `Unsupported` — Operation not supported for the given parameters
* `InvalidInput` — The operation is supported but one of its inputs is malformed — e.g. a sender-key distribution message that fails to decode, or a [`migrate_sessions`](#migrate_sessions) call with a source/destination pair that isn't a valid PN→LID or Hosted→HostedLid namespace match
* `Internal` — Catch-all for other errors

## See also

* [Signal Protocol implementation](/advanced/signal-protocol) - Deep dive into the protocol internals
* [Client](/api/client) - Core client API
* [Send](/api/send) - High-level message sending (handles encryption automatically)
