Skip to content
Merged
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
58 changes: 58 additions & 0 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,60 @@ if record.has_usable_sender_chain()? {

Location: `wacore/libsignal/src/protocol/record_components.rs`

### Legacy session v1 interop

Behind the opt-in `legacy-session-interop` Cargo feature (default off, forwarded through `wacore` and the root `whatsapp-rust` crate), `wacore-libsignal` exposes a typed, transport-agnostic model of the **decoded** legacy libsignal `SessionRecord` v1 layout — the format this project's stores used before the canonical shapes above. It exists for migration tooling importing an externally produced v1 store into the canonical `SessionRecord`, or projecting a canonical record back into v1 terms; ordinary clients never enable it, so the model compiles out of native builds entirely.

Container decoding — turning a legacy store's transport bytes into these typed fields — is explicitly out of scope; callers own that step and hand this module owned values (`Bytes`, integers, enums). The module owns everything downstream: chain-role selection, counter translation, lifecycle ordering, pruning, ratchet reconstruction, and skipped-key derivation.

```rust
// wacore/libsignal/src/protocol/legacy_session.rs, re-exported from
// wacore/libsignal/src/protocol/mod.rs behind `legacy-session-interop`
pub use legacy_session::{
LegacyIndexedSessionV1, LegacySessionBaseKeyRoleV1, LegacySessionChainCounterV1,
LegacySessionChainKeyV1, LegacySessionChainRoleV1, LegacySessionChainV1,
LegacySessionDispositionV1, LegacySessionFieldV1, LegacySessionIndexV1,
LegacySessionInteropError, LegacySessionKeyPairV1, LegacySessionLocalContext,
LegacySessionMessageKeyV1, LegacySessionPendingPreKeyV1, LegacySessionRatchetV1,
LegacySessionRecordV1, LegacySessionUnrepresentableFieldV1, LegacySessionV1,
};
```

**Import — v1 into canonical:**

```rust
impl LegacySessionRecordV1 {
pub fn from_indexed_sessions(
sessions: Vec<LegacyIndexedSessionV1>,
) -> Result<Self, LegacySessionInteropError>;

pub fn into_session_record(
self,
context: LegacySessionLocalContext,
) -> Result<SessionRecord, LegacySessionInteropError>;
}
```

`from_indexed_sessions` validates that each entry's outer map key matches its own session's base key and rejects duplicate base keys or more than one `Current` session. `into_session_record` then validates every session — chain roles, key lengths, counters, skipped-key indexes, pending pre-keys, and the same canonical limits enforced in [Import validation](#import-validation) — retains archived sessions by close time, reorders them by last use to match the v1 decrypt search, truncates to `ARCHIVED_STATES_MAX_LENGTH`, and reuses `SessionRecord::from_components` to build the canonical record. `LegacySessionLocalContext` supplies the local identity key and registration ID, since v1 sessions never persisted them.

A v1 sending chain seeds `previous_counter` at `-1` for a ratchet step over a chain that has never sent; `LegacySessionChainCounterV1` floors that at zero on import instead of treating it as an error. Every other out-of-range counter fails typed as `InvalidChainCounter`.

**Export — canonical into v1 (operational, not byte-exact):**

```rust
impl SessionRecord {
pub fn into_legacy_session_v1_operational(
self,
) -> Result<LegacySessionRecordV1, LegacySessionInteropError>;
}
```

This is a deterministic operational projection, not a round trip: v1 lifecycle timestamps and base-key lookup roles are reconstructed from canonical search/eviction order rather than recovered verbatim, since the canonical record never persisted them in the first place. State the v1 format genuinely cannot represent is rejected with a typed error instead of being silently dropped or inferred — a session with no sender chain, a non-current `session_version`, a pending key exchange, or a `needs_refresh` flag all fail as `NotRepresentable`; a receiver chain holding a derived (non-seed) skipped-message key with no inverse to a v1 seed fails as `ChainNotRepresentable::DerivedMessageKey`; and a pending pre-key whose base key doesn't match the session's own base key fails as `PendingPreKeyBaseMismatch` rather than producing v1 state the importer would reject on the way back in.

Every type in the module redacts key material from `Debug` — root keys, chain keys, ratchet key pairs, skipped-message seeds, and identity keys all print as `<redacted>`; only structural fields (roles, counters, indexes, session/chain counts) print plainly, the same convention as the canonical [`*Components` types](#debug-output-redacts-secrets).

Location: `wacore/libsignal/src/protocol/legacy_session.rs`

## Security Considerations

### Identity key trust
Expand Down Expand Up @@ -1412,6 +1466,10 @@ if chain_index > counter {

Location: `wacore/libsignal/src/protocol/session_cipher.rs:822-827`

As of [#1072](https://github.com/oxidezap/whatsapp-rust/pull/1072), a `DuplicatedMessage` from one candidate session is not terminal — the search keeps trying the remaining current and archived sessions, including a **closed** receiver chain (one with no chain-key seed left, only leftover skipped keys). This covers re-initiations, which reuse the peer's signed pre-key as a ratchet key: a delayed message whose skipped key survives only in an archived session still decrypts, and a closed chain still recognizes a replay of an already-consumed counter instead of falling through to a generic failure.

Once every session has been tried, a recognized `DuplicatedMessage` outranks `BadMac` in the final classification: a sibling session that happens to share the same ratchet key derives different message keys for the same counter and fails its MAC as expected noise, but the decrypting client already knows this counter was consumed elsewhere. Classifying that as `BadMac` would trigger a retry receipt for a message the peer already delivered; the duplicate verdict wins instead, so the replay is acknowledged and silently dropped.

### Log level discipline

The protocol layer follows strict rules about what cryptographic material appears in logs and at which level:
Expand Down