diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx index d317110..baa053e 100644 --- a/advanced/signal-protocol.mdx +++ b/advanced/signal-protocol.mdx @@ -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)` 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. + + +**Breaking for direct consumers of these types.** `SessionMessageKeyMaterial::Seed` and `Derived` switched from `Vec` 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. + Conversions: @@ -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. + + +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. + 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 ``; only structural fields (roles, counters, indexes, session/chain counts) print plainly, the same convention as the canonical [`*Components` types](#debug-output-redacts-secrets).