Skip to content
Merged
Changes from 2 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
28 changes: 26 additions & 2 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1375,7 +1375,25 @@ pub struct SenderKeyStateComponents {
}
```

`SessionMessageKeyComponents` and `SenderMessageKeyComponents` hold skipped out-of-order message keys, keyed by chain index/iteration. A session message key's secret material is `SessionMessageKeyMaterial`, either the compact `Seed(Vec<u8>)` import form (expanded through the same canonical derivation used elsewhere — see [`MessageKeyGenerator`](#chain-key-ratcheting)) or the `Derived { cipher_key, mac_key, iv }` form that `into_components()` always produces on export.
`SessionMessageKeyComponents` and `SenderMessageKeyComponents` hold skipped out-of-order message keys, keyed by chain index/iteration. A session message key's secret material is `SessionMessageKeyMaterial`:

```rust
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SessionMessageKeyMaterial {
Seed([u8; 32]),
Derived {
cipher_key: [u8; 32],
mac_key: [u8; 32],
iv: [u8; 16],
},
}
```

As of [#1210](https://github.com/oxidezap/whatsapp-rust/pull/1210), `Seed` is no longer just a compact *import* form expanded through the same canonical derivation used elsewhere (see [`MessageKeyGenerator`](#chain-key-ratcheting)) — the seed is now persisted, so a skipped key round-trips back out as `Seed` too. `session_structure::chain::MessageKey` carries an additive `seed` field (local field 100) alongside the pre-existing `cipher_key`/`mac_key`/`iv`; `from_structure` prefers it when present, re-deriving from it and rejecting the record if the result disagrees with the stored triple (the seed supersedes the triple on export, so a mismatch means a corrupt record). `Derived` is now what comes back only for a key persisted *before* this change, which never wrote a seed. Both variants are fixed-width and `Copy`, so `into_structure()` — the reverse direction — is infallible where it used to validate a `Vec` length.

<Note>
**Breaking for direct consumers of these types.** `SessionMessageKeyMaterial::Seed`/`Derived` switched from `Vec<u8>` fields to fixed-width arrays — match both variants rather than assuming `Derived` is the only exported form. `MessageKeyGenerator::Keys(MessageKeys)` was removed in the same change (nothing in the workspace constructed it); build a seeded key with `MessageKeyGenerator::new_from_seed` instead. Downgrading to a build predating #1210 and writing a record back out drops the seed silently — decrypt is unaffected, but the key becomes unexportable again.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</Note>

Conversions:

Expand Down Expand Up @@ -1492,7 +1510,13 @@ impl SessionRecord {
}
```

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

A receiver chain holding a *derived* (seedless) skipped-message key, with no inverse to a v1 seed, fails as `ChainNotRepresentable::DerivedMessageKey`. Before [#1210](https://github.com/oxidezap/whatsapp-rust/pull/1210) this was every skipped key without exception — the projection was lossy from the first cycle, since import expanded a v1 seed into derived keys and export could never recover it, so a v1 record with a skipped key failed to round-trip even immediately. Now that the seed rides along with the keys it derives (see [`SessionMessageKeyMaterial`](#session-and-sender-key-shapes) above), only a key persisted *before* that change — which never had a seed to retain — still hits this error; a skipped key imported or received after #1210 carries its seed through export and projects normally, permanently, until it is consumed or evicted.

<Note>
CI runs a dedicated `cargo nextest run --features legacy-session-interop` job as of #1210. The feature was previously off in every job, so this entire module's test suite — including the regression test for the bug above — compiled away and never ran.
</Note>

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

Expand Down