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
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), the seed is persisted alongside the keys it derives, not just accepted on import. Previously `Seed` was only a compact import form, expanded through the same canonical derivation used elsewhere (see [`MessageKeyGenerator`](#chain-key-ratcheting)). Now a skipped key's seed round-trips back out on export 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 the seed when present: it re-derives from the seed and rejects the record if the result disagrees with the stored triple, since the seed supersedes the triple on export and a mismatch means a corrupt record. `Derived` now comes back only for a key persisted *before* this change — one that never wrote a seed. Both variants are fixed-width and `Copy`, so `into_structure()`, the reverse direction, is now infallible; it used to validate a `Vec` length.

<Note>
**Breaking for direct consumers of these types.** `SessionMessageKeyMaterial::Seed` and `Derived` switched from `Vec<u8>` fields to fixed-width arrays. Match both variants — don't assume `Derived` is the only exported form. `MessageKeyGenerator::Keys(MessageKeys)` was also removed; nothing in the workspace constructed it. Build a seeded key with `MessageKeyGenerator::new_from_seed` instead. If you downgrade to a build predating #1210 and write a record back out, the seed drops silently — decrypt is unaffected, but the key becomes unexportable again.
</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 fails as `ChainNotRepresentable::DerivedMessageKey` — it has no inverse to a v1 seed. Before [#1210](https://github.com/oxidezap/whatsapp-rust/pull/1210), this was every skipped key without exception. 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 on the very first cycle. 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 still hits this error — one that never had a seed to retain. A skipped key imported or received after #1210 carries its seed through export and projects normally. That holds permanently, until the key 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. 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