From c9678dd7c632232107565608719136863da9a000 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 3 Oct 2026 06:25:06 -0400 Subject: [PATCH] feat(session): add recipient and SPQR 1.6 parity Bind resumed pre-key sessions to their established identity and clone entire records before authentication. Require SPQR V1 for fresh sessions while retaining explicit legacy-state compatibility. Genuine Rust oracles guard negotiation and parser parity. --- README.md | 9 + compat/rust-harness/README.md | 83 ++++- compat/rust-harness/src/main.rs | 474 ++++++++++++++++++++++++++++- compat/session_interop_test.go | 121 +++----- compat/spqr_v16_test.go | 86 ++++++ compat/vectors/spqr-v16.json | 285 +++++++++++++++++ session/builder.go | 18 +- session/cipher_test.go | 51 +++- session/prekey_decrypt.go | 215 +++++++++++++ session/prekey_decrypt_api_test.go | 27 ++ session/prekey_decrypt_test.go | 368 ++++++++++++++++++++++ session/state.go | 13 +- spqr/chain.go | 7 +- spqr/chain_test.go | 29 ++ spqr/message.go | 42 ++- spqr/spqr.go | 106 ++++--- spqr/spqr_test.go | 128 ++++++++ spqr/state.go | 2 +- 18 files changed, 1890 insertions(+), 174 deletions(-) create mode 100644 compat/spqr_v16_test.go create mode 100644 compat/vectors/spqr-v16.json create mode 100644 session/prekey_decrypt.go create mode 100644 session/prekey_decrypt_api_test.go create mode 100644 session/prekey_decrypt_test.go diff --git a/README.md b/README.md index 355bd27e3..95115115f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,15 @@ Runnable examples live alongside the packages they document (Go renders them in - [`session.Example_sessionRoundTrip`](session/example_test.go) — a PQXDH handshake and the first encrypted message between two parties. +- [`session.DecryptPreKey`](session/prekey_decrypt.go) — recipient-side PQXDH + establishment or session resumption using `PreKeyDecryptStores`. It verifies + identity trust and the embedded message before saving identity/session state, + marking Kyber use against the signed EC pre-key, and removing an optional + one-time pre-key. Existing sessions must match both base key and identity; + rejected messages leave all stores unchanged. Hosts must serialize operations + per peer; store-write errors may leave earlier writes committed, so atomic + persistence requires a host transaction across the stores. Fresh sessions + require SPQR V1; explicitly stored legacy V0-negotiation states remain readable. - [`groups.Example_groupMessaging`](groups/example_test.go) — sender-key distribution and a group-encrypted message. - [`sealedsender.Example_sealedSender`](sealedsender/example_test.go) — a sealed diff --git a/compat/rust-harness/README.md b/compat/rust-harness/README.md index 5873cd7df..14f18de29 100644 --- a/compat/rust-harness/README.md +++ b/compat/rust-harness/README.md @@ -102,14 +102,89 @@ Domains: the erasure property test alone is blind to a uniformly-wrong endianness, so the golden bytes are required. Committed at `internal/spqr/chunked/testdata/spqr_chunks.json`. +- `spqr-v16` — real SPQR v1.6.0 receive results for version negotiation, + directional chain-seed lengths, and epoch-zero/index-zero keys. Committed at + `compat/vectors/spqr-v16.json`; see the schema below. - `account-keys` — upstream account entropy/backup derivations, SVR-key derivations, PIN master-key HMAC-SHA256-SIV, media key splits, and deterministic MFA metadata encryption/decryption. See the schema below. -The dependency pin alone does not upgrade the Go protocol/SPQR implementation or -the provenance of previously committed fixtures. Only -`compat/vectors/account-keys.json` is regenerated here; the remaining protocol, -SPQR, and fixture refresh work is tracked separately. +The dependency pin does not change the provenance of previously committed +fixtures. Task31 extended `compat/vectors/account-keys.json`; Task32 adds the +separate `spqr-v16.json` oracle. Existing protocol, session, Sender Key, and PQ +fixtures retain their bytes and historical provenance. + +### SPQR v1.6 Schema And Replay + +`spqr-v16.json` records the immutable SPQR/libsignal commits in `_note`. Each +`cases` entry contains `label`, input `state` and `message` (lowercase hex), and +either `result: {state, key}` or the upstream error variant in `error`. +`key: null` is `None`; `key: ""` is `Some(empty)` and must not be collapsed to +null. Successful states and keys come directly from upstream `spqr::recv`. + +Cases cover matching v1; higher versions 2/255 during open negotiation and after +completion; replay; below-minimum precedence; malformed preambles even for +disabled states; downgrade to v0; and `(epoch, index) = (0, 0)`. Higher-version +messages draw keys from chain epoch zero using the preamble index, retain +negotiation and the v1 inner state, and do not interpret the remaining payload. +The nonempty directional chain `next` seeds must be 32 bytes; 0/32-byte positive +and 31/33-byte negative cases cover both send and receive seeds. Rust does not +add a `next_root` length check. + +Stored minimum-version enum values use Rust's low-byte conversion. The fixture +also covers negative and greater-than-255 values without introducing stricter +Go-only rejection rules. All 36 cases replay against the actual Rust crate. + +Adversarial chain inputs are constructed using only the unchanged protobuf +field tags/lengths in `pq_ratchet.proto`. The harness does not reimplement the +ratchet, key derivation, state validation, or expected result. + +With `RUSTC`/`RUSTDOC` selected as above, from this directory: + +```sh +rustup run 1.98.1 cargo build --release --locked -j 2 +target/release/rust-harness gen-vectors spqr-v16 > ../vectors/spqr-v16.json +rustup run 1.98.1 cargo test --release --locked -j 2 task32_oracle_tests +``` + +If using `CARGO_TARGET_DIR`, substitute that directory for `target` in the +binary path. The Go consumer is `go test ./compat -run TestSPQRV16Oracle -count=1` +from the module root. + +### Recipient Prekey Identity Oracle + +The additive `session.prekey-identity-mismatch` RPC accepts +`{with_one_time: bool}` (default true). It runs the existing genuine upstream +bundle/process/encrypt/decrypt path to establish a fresh recipient session, then +changes only the duplicate prekey wrapper's identity. The base key and inner +SignalMessage are preserved using upstream constructors. + +The probe resets upstream's in-memory known-identity map before the duplicate +attempt, so TOFU accepts the replacement and the matching-session identity +consistency check is reached. Otherwise trust rejection would mask that check. +This setup is explicit in `trust_setup`/`trust_accepted`; it does not change the +existing session RPCs or any production API. The RPC uses two reserved internal +store handles and removes them on successful completion. + +The result contains `upstream_commit`, `with_one_time`, `fresh` (plaintext hex, +session creation, identity save, optional prekey consumption, and Kyber-base-key +use), `base_key_matches`, `inner_message_matches`, `error_variant`, diagnostic +`error`, `unchanged`, and `kyber_mark_used_calls`. `unchanged` compares identity +(local pair, registration ID, and remote mapping), prekey, signed-prekey, +Kyber-record, and serialized-session observations immediately before/after the +failed attempt. Kyber usage is private upstream, so a delegating store observer +counts mark-used calls separately; the expected count is zero. + +```sh +printf '%s\n' \ + '{"method":"session.prekey-identity-mismatch","params":{"with_one_time":true}}' \ + '{"method":"session.prekey-identity-mismatch","params":{"with_one_time":false}}' \ + | target/release/rust-harness interop +``` + +Both runs must return `InvalidMessage(PreKey)`, all `unchanged` values true, and +zero Kyber mark-used calls. This is runtime evidence, not deterministic +ciphertext generation: the existing session path uses OS randomness. ### Account-Key Schema And Replay diff --git a/compat/rust-harness/src/main.rs b/compat/rust-harness/src/main.rs index 4942c7354..a4281f702 100644 --- a/compat/rust-harness/src/main.rs +++ b/compat/rust-harness/src/main.rs @@ -53,7 +53,7 @@ use libsignal_protocol::{ PreKeySignalMessage, PreKeyStore, PrivateKey, ProtocolAddress, PublicKey, SealedSenderV2SentMessage, SenderCertificate, SenderKeyDistributionMessage, SenderKeyMessage, SenderKeyRecord, ServerCertificate, ServiceId, SessionRecord, SessionStore, SignalMessage, - SignedPreKeyId, SignedPreKeyRecord, SignedPreKeyStore, Timestamp, + SignalProtocolError, SignedPreKeyId, SignedPreKeyRecord, SignedPreKeyStore, Timestamp, UnidentifiedSenderMessageContent, }; @@ -244,6 +244,18 @@ fn gen_vectors(domain: &str) -> Result<(), String> { obj.insert("cases".into(), Value::Array(gen_spqr_chunks())); obj.insert("gf_triples".into(), Value::Array(gen_gf16_triples())); } + "spqr-v16" => { + obj.insert( + "_note".into(), + json!({ + "spqr_reference": "1.6.0 (git tag v1.6.0, rev 06959b4708f9b7b1e94d0f8cc835f3958c077e94, test-utils feature)", + "libsignal_reference": "v0.104.0, rev 257105c55a7389ca6b1e85185e2769465e6729f1", + "oracle": "spqr::initial_state, send, recv; malformed chain inputs use the unchanged pq_ratchet.proto wire schema", + "key": "null is None; empty hex is Some(empty), including higher-version epoch-zero/index-zero receive", + }), + ); + obj.insert("cases".into(), Value::Array(gen_spqr_v16())); + } "username-links" => { obj.insert("cases".into(), Value::Array(gen_username_links())); } @@ -253,7 +265,7 @@ fn gen_vectors(domain: &str) -> Result<(), String> { other => { return Err(format!( "unknown domain {other:?}; \ - expected curve|kem-decaps|hkdf|messages|fingerprint|sessions|groups|sealedsender|mlkem-incremental|spqr-chunks|username-links|account-keys" + expected curve|kem-decaps|hkdf|messages|fingerprint|sessions|groups|sealedsender|mlkem-incremental|spqr-chunks|spqr-v16|username-links|account-keys" )); } }; @@ -498,6 +510,54 @@ mod account_oracle_tests { } } +#[cfg(test)] +mod task32_oracle_tests { + use super::*; + + #[test] + fn spqr_v16_fixture_replays() { + let fixture: Value = serde_json::from_str(include_str!("../../vectors/spqr-v16.json")) + .expect("SPQR v1.6 fixture JSON"); + let generated = gen_spqr_v16(); + let cases = fixture["cases"].as_array().unwrap(); + assert_eq!(generated.len(), cases.len(), "missing SPQR oracle cases"); + assert_eq!(&generated, cases); + } + + #[test] + fn recipient_prekey_identity_mismatch() { + for with_one_time in [false, true] { + let result = dispatch( + "session.prekey-identity-mismatch", + &json!({ "with_one_time": with_one_time }), + ) + .expect("recipient oracle RPC"); + assert_eq!(result["error_variant"], "InvalidMessage(PreKey)"); + assert_eq!(result["trust_accepted"], true); + assert_eq!(result["base_key_matches"], true); + assert_eq!(result["inner_message_matches"], true); + assert_eq!( + result["fresh"]["plaintext"], + hex(b"Task32 recipient oracle") + ); + assert_eq!(result["fresh"]["session_created"], true); + assert_eq!(result["fresh"]["identity_saved"], true); + assert_eq!(result["fresh"]["pre_key_consumed"], with_one_time); + assert_eq!(result["fresh"]["kyber_base_key_used"], true); + for name in [ + "identity", + "pre_key", + "signed_pre_key", + "kyber_record", + "session", + ] { + assert_eq!(result["unchanged"][name], true, "{name}"); + } + assert_eq!(result["kyber_mark_used_calls"], 0); + } + } +} + /// curve domain: XEdDSA sign/verify with a deterministic 64-byte nonce (the /// nonce is drawn from the seeded CSPRNG, making the signature reproducible) /// and X25519 ECDH agreement. @@ -1434,6 +1494,193 @@ fn gen_gf16_triples() -> Vec { out } +fn gen_spqr_v16() -> Vec { + let initial = |direction, min_version| { + spqr::initial_state(spqr::Params { + direction, + version: spqr::Version::V1, + min_version, + auth_key: &[41; 32], + chain_params: spqr::ChainParams::default(), + }) + .expect("SPQR initial state") + }; + let a = initial(spqr::Direction::A2B, spqr::Version::V1); + let a_min0 = initial(spqr::Direction::A2B, spqr::Version::V0); + let b = initial(spqr::Direction::B2A, spqr::Version::V1); + let sent = spqr::send(&b, &mut seeded_rng()).expect("SPQR send"); + let closed = spqr::recv(&a, &sent.msg).expect("SPQR matching receive"); + assert_eq!(closed.key, sent.key); + let mut out = Vec::new(); + let mut record = |label: &str, state: &[u8], message: &[u8], error: Option<&str>| { + let mut case = json!({ "label": label, "state": hex(state), "message": hex(message) }); + match spqr::recv(&state.to_vec(), &message.to_vec()) { + Ok(received) => { + assert!(error.is_none(), "{label}: expected {error:?}"); + case["result"] = json!({ + "state": hex(&received.state), + "key": received.key.as_deref().map(hex), + }); + } + Err(actual) => { + let actual = format!("{actual:?}"); + assert_eq!(Some(actual.as_str()), error, "{label}"); + case["error"] = json!(actual); + } + } + out.push(case); + }; + record("matching_v1", &a, &sent.msg, None); + for version in [2u8, 255] { + let mut message = sent.msg.clone(); + message[0] = version; + record(&format!("open_higher_v{version}"), &a, &message, None); + let higher = spqr::recv(&a, &message).expect("SPQR higher version"); + assert_eq!(higher.key, sent.key); + assert!(matches!( + spqr::current_version(&higher.state).unwrap(), + spqr::CurrentVersion::StillNegotiating { .. } + )); + record( + &format!("open_higher_v{version}_replay"), + &higher.state, + &message, + Some("KeyAlreadyRequested(1)"), + ); + record( + &format!("closed_higher_v{version}"), + &closed.state, + &message, + Some("VersionMismatch"), + ); + } + record("open_below_minimum", &a, &[], Some("MinimumVersion")); + record( + "closed_below_minimum", + &closed.state, + &[], + Some("MinimumVersion"), + ); + record("open_downgrade_v0", &a_min0, &[], None); + record("disabled_empty", &[], &[], None); + record("disabled_higher", &[], &[2, 1, 1, 0], None); + for (label, state, message) in [ + ( + "closed_truncated_preamble", + closed.state.as_slice(), + &[2u8][..], + ), + ("disabled_truncated_preamble", &[][..], &[2u8][..]), + ("open_zero_epoch", a.as_slice(), &[2u8, 0, 1][..]), + ("open_truncated_index", a.as_slice(), &[2u8, 1][..]), + ] { + record(label, state, message, Some("MsgDecode")); + } + record("matching_zero_index", &a_min0, &[1, 1, 0, 0], None); + let empty = spqr::recv(&a_min0, &vec![2, 1, 0, 0]).expect("SPQR zero index"); + assert_eq!(empty.key, Some(vec![])); + record("higher_zero_index", &a_min0, &[2, 1, 0, 0], None); + // Unknown-version payloads are deliberately not interpreted by the v1 machine. + record("higher_preamble_only", &a, &[2, 1, 1], None); + for size in [0usize, 31, 32, 33] { + for recv in [false, true] { + let state = spqr_state_with_chain_next( + &a, + if recv { 32 } else { size }, + if recv { size } else { 32 }, + ); + record( + &format!("chain_{}_next_{size}", if recv { "recv" } else { "send" }), + &state, + &[2, 1, 0, 0], + if size == 0 || size == 32 { + None + } else { + Some("StateDecode") + }, + ); + } + } + for minimum in [-1, 256, 257] { + let state = spqr_state_with_minimum(&a, minimum); + // current_version uses TryInto, unlike recv's deliberately wrapping + // `vn.min_version as u8`. Keep those two upstream behaviors distinct. + assert!(matches!(spqr::current_version(&state), Err(spqr::Error::StateDecode))); + record( + &format!("stored_minimum_{minimum}_empty"), + &state, + &[], + if minimum == 256 { None } else { Some("MinimumVersion") }, + ); + record( + &format!("stored_minimum_{minimum}_matching"), + &state, + &sent.msg, + if minimum == -1 { Some("MinimumVersion") } else { None }, + ); + record( + &format!("stored_minimum_{minimum}_highest"), + &state, + &[255, 1, 1, 0], + None, + ); + } + out +} + +// Replace the known initial A2B/V1 negotiation block's min_version scalar, +// preserving its auth key, params, and genuine upstream v1 inner state. +fn spqr_state_with_minimum(initial: &[u8], minimum: i32) -> Vec { + assert_eq!(initial[0], 0x0a); + let end = usize::from(initial[1]) + 2; + assert_eq!(&initial[end - 4..end], &[0x18, 1, 0x22, 0]); + let mut negotiation = initial[2..end - 3].to_vec(); + let mut value = i64::from(minimum) as u64; + loop { + let low = (value & 0x7f) as u8; + value >>= 7; + negotiation.push(if value == 0 { low } else { low | 0x80 }); + if value == 0 { + break; + } + } + negotiation.extend([0x22, 0]); + let mut state = vec![0x0a, negotiation.len().try_into().expect("short negotiation")]; + state.extend(negotiation); + state.extend_from_slice(&initial[end..]); + state +} + +// Construct only adversarial fixture input, not expected output. The initial +// state has no chain; append Chain(2) with one Epoch(3), send(1)/recv(2) Next(2), +// and empty Params(6). Lengths fit a single byte. No extra prost dependency is +// needed; all decoding, validation, and result generation is upstream recv. +fn spqr_state_with_chain_next(initial: &[u8], send_len: usize, recv_len: usize) -> Vec { + let direction = |len: usize| { + assert!(len <= 33); + if len == 0 { + vec![] + } else { + let mut bytes = vec![0x12, len as u8]; + bytes.extend(vec![41; len]); + bytes + } + }; + let send = direction(send_len); + let recv = direction(recv_len); + let mut epoch = vec![0x0a, send.len() as u8]; + epoch.extend(send); + epoch.extend([0x12, recv.len() as u8]); + epoch.extend(recv); + let mut chain = vec![0x1a, epoch.len() as u8]; + chain.extend(epoch); + chain.extend([0x32, 0]); + let mut state = initial.to_vec(); + state.extend([0x12, chain.len() as u8]); + state.extend(chain); + state +} + /// The interop CSPRNG. Session key generation and Kyber encapsulation must use a /// real CSPRNG (the handshake is not vector-locked here — agreement is checked /// by decrypting, not by byte equality), so this draws from the OS. rand 0.9's @@ -1765,6 +2012,228 @@ fn session_decrypt(params: &Value) -> Result { }) } +type StoreFuture<'a, T> = + std::pin::Pin> + 'a>>; + +struct KyberUseObserver<'a> { + inner: &'a mut dyn KyberPreKeyStore, + marks: usize, +} + +// Match async_trait(?Send)'s erased signatures without adding a dependency. +// All cryptographic/storage operations still delegate to upstream's store. +impl KyberPreKeyStore for KyberUseObserver<'_> { + fn get_kyber_pre_key<'life0, 'async_trait>( + &'life0 self, + id: KyberPreKeyId, + ) -> StoreFuture<'async_trait, KyberPreKeyRecord> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { self.inner.get_kyber_pre_key(id).await }) + } + + fn save_kyber_pre_key<'life0, 'life1, 'async_trait>( + &'life0 mut self, + id: KyberPreKeyId, + record: &'life1 KyberPreKeyRecord, + ) -> StoreFuture<'async_trait, ()> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { self.inner.save_kyber_pre_key(id, record).await }) + } + + fn mark_kyber_pre_key_used<'life0, 'life1, 'async_trait>( + &'life0 mut self, + id: KyberPreKeyId, + ec_id: SignedPreKeyId, + base_key: &'life1 PublicKey, + ) -> StoreFuture<'async_trait, ()> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.marks += 1; + self.inner + .mark_kyber_pre_key_used(id, ec_id, base_key) + .await + }) + } +} + +fn recipient_snapshot( + store: &InMemSignalProtocolStore, + remote: &ProtocolAddress, + message: &PreKeySignalMessage, +) -> Result { + let snapshot = || -> Result { + let identity = block_on(store.identity_store.get_identity(remote))?; + let pre_key = match message.pre_key_id() { + Some(id) => match block_on(store.pre_key_store.get_pre_key(id)) { + Ok(record) => Some(hex(&record.serialize()?)), + Err(SignalProtocolError::InvalidPreKeyId) => None, + Err(error) => return Err(error), + }, + None => None, + }; + let signed = block_on( + store + .signed_pre_key_store + .get_signed_pre_key(message.signed_pre_key_id()), + )?; + let kyber = block_on( + store + .kyber_pre_key_store + .get_kyber_pre_key(message.kyber_pre_key_id().expect("PQXDH")), + )?; + let session = block_on(store.session_store.load_session(remote))?; + Ok(json!({ + "identity": { + "remote": identity.map(|key| hex(&key.serialize())), + "local": hex(&block_on(store.identity_store.get_identity_key_pair())?.serialize()), + "registration_id": block_on(store.identity_store.get_local_registration_id())?, + }, + "pre_key": pre_key, + "signed_pre_key": hex(&signed.serialize()?), + "kyber_record": hex(&kyber.serialize()?), + "session": session.map(|record| record.serialize().map(|bytes| hex(&bytes))).transpose()?, + })) + }; + snapshot().map_err(|error| error.to_string()) +} + +/// Self-contained recipient oracle. Only the outer duplicate identity changes; +/// TOFU's known-key map is reset before the attempt so trust does not mask the +/// established-session identity consistency check. Reserved stores are local +/// to this RPC; existing session RPC behavior is unchanged. +fn session_prekey_identity_mismatch(params: &Value) -> Result { + const ALICE: &str = "__task32_mismatch_alice"; + const BOB: &str = "__task32_mismatch_bob"; + let with_one_time = params + .get("with_one_time") + .and_then(Value::as_bool) + .unwrap_or(true); + let mut bundle = session_create_prekey_bundle(&json!({ + "handle": BOB, "with_one_time": with_one_time, + }))?; + bundle["handle"] = json!(ALICE); + bundle["remote_name"] = json!(BOB); + session_process_bundle_as_alice(&bundle)?; + let encrypted = session_encrypt(&json!({ + "handle": ALICE, "remote_name": BOB, "plaintext": hex(b"Task32 recipient oracle"), + }))?; + assert_eq!(encrypted["type"], CiphertextMessageType::PreKey as u8); + let message = PreKeySignalMessage::try_from(param_bytes(&encrypted, "serialized")?.as_slice()) + .map_err(|error| error.to_string())?; + let remote = protocol_address(ALICE); + let before_fresh = with_store(BOB, |store| recipient_snapshot(store, &remote, &message))?; + let decrypted = session_decrypt(&json!({ + "handle": BOB, "remote_name": ALICE, "type": 3, "serialized": encrypted["serialized"], + }))?; + let result = with_store(BOB, |store| { + let after_fresh = recipient_snapshot(store, &remote, &message)?; + let mut usage_probe = store.kyber_pre_key_store.clone(); + let used = matches!( + block_on(usage_probe.mark_kyber_pre_key_used( + message.kyber_pre_key_id().expect("PQXDH"), + message.signed_pre_key_id(), + message.base_key(), + )), + Err(SignalProtocolError::InvalidMessage(CiphertextMessageType::PreKey, ref reason)) if reason == "reused base key" + ); + let replacement = IdentityKeyPair::generate(&mut interop_rng()); + let mismatched = PreKeySignalMessage::new( + message.message_version(), + message.registration_id(), + message.pre_key_id(), + message.signed_pre_key_id(), + message + .kyber_pre_key_id() + .zip(message.kyber_ciphertext()) + .map(|(id, ct)| KyberPayload::new(id, ct.clone())), + *message.base_key(), + *replacement.identity_key(), + message.message().clone(), + ) + .map_err(|error| error.to_string())?; + store.identity_store.reset(); + let trusted = block_on(store.identity_store.is_trusted_identity( + &remote, + mismatched.identity_key(), + libsignal_protocol::Direction::Receiving, + )) + .map_err(|error| error.to_string())?; + let before = recipient_snapshot(store, &remote, &message)?; + let (failure, marks) = { + let mut kyber = KyberUseObserver { + inner: &mut store.kyber_pre_key_store, + marks: 0, + }; + let failure = block_on(message_decrypt( + &CiphertextMessage::PreKeySignalMessage(mismatched.clone()), + &remote, + &protocol_address("self"), + &mut store.session_store, + &mut store.identity_store, + &mut store.pre_key_store, + &store.signed_pre_key_store, + &mut kyber, + &mut interop_rng(), + )) + .expect_err("mismatched identity must be rejected"); + (failure, kyber.marks) + }; + let variant = match &failure { + SignalProtocolError::InvalidMessage(CiphertextMessageType::PreKey, _) => { + "InvalidMessage(PreKey)" + } + _ => return Err(format!("unexpected recipient failure: {failure:?}")), + }; + let after = recipient_snapshot(store, &remote, &message)?; + let mut unchanged = serde_json::Map::new(); + for name in [ + "identity", + "pre_key", + "signed_pre_key", + "kyber_record", + "session", + ] { + unchanged.insert(name.to_string(), json!(before[name] == after[name])); + } + Ok(json!({ + "upstream_commit": "257105c55a7389ca6b1e85185e2769465e6729f1", + "with_one_time": with_one_time, + "fresh": { + "plaintext": decrypted["plaintext"], + "session_created": before_fresh["session"].is_null() && !after_fresh["session"].is_null(), + "identity_saved": after_fresh["identity"]["remote"] == hex(&message.identity_key().serialize()), + "pre_key_consumed": !before_fresh["pre_key"].is_null() && after_fresh["pre_key"].is_null(), + "kyber_base_key_used": used, + }, + "trust_setup": "upstream InMemIdentityKeyStore::reset (unknown peer accepted by TOFU)", + "trust_accepted": trusted, + "base_key_matches": message.base_key() == mismatched.base_key(), + "inner_message_matches": message.message().as_ref() == mismatched.message().as_ref(), + "error_variant": variant, + "error": failure.to_string(), + "unchanged": unchanged, + "kyber_mark_used_calls": marks, + })) + }); + STORES.with(|stores| { + let mut stores = stores.borrow_mut(); + stores.remove(ALICE); + stores.remove(BOB); + }); + result +} + /// Byte-exact KAT for the sender-key primitives, reproduced from the v0.91.0 /// source (sender_keys.rs SenderChainKey / SenderMessageKey, which are /// `pub(crate)`) against the same pinned `hmac`/`hkdf`/`sha2` crates — the same @@ -2213,6 +2682,7 @@ fn dispatch(method: &str, params: &Value) -> Result { "session.process-bundle-as-alice" => session_process_bundle_as_alice(params), "session.encrypt" => session_encrypt(params), "session.decrypt" => session_decrypt(params), + "session.prekey-identity-mismatch" => session_prekey_identity_mismatch(params), // --- sealed sender (v1 + v2 seal; unseal handles v1 + v2) --- "sealed.seal-v1" => sealed_seal_v1(params), diff --git a/compat/session_interop_test.go b/compat/session_interop_test.go index 378ced855..c0a5f5c33 100644 --- a/compat/session_interop_test.go +++ b/compat/session_interop_test.go @@ -4,26 +4,17 @@ //go:build interop // Session interop: drives a full PQXDH / Double Ratchet conversation between the -// pure-Go session layer and the genuine upstream session API (v0.91.0) exposed +// pure-Go session layer and the genuine upstream session API (v0.104.0) exposed // by the Rust harness over JSON-RPC. This is the protocol's core cross-impl // proof — both impls must agree on the handshake and on every message key, // including out-of-order and skipped-key delivery, in BOTH role assignments // (Go=Alice/Rust=Bob and Rust=Alice/Go=Bob) and with AND without a one-time // pre-key (the no-OPK case exercises the optional-DH4 PQXDH path). // -// v0.96.4 sessions are PQXDH/v4 only — X3DH/v3 is removed upstream (the decrypt -// path returns "X3DH no longer supported"). At v0.96.4 the Sparse Post-Quantum -// Ratchet (SPQR, spqr v1.5.1) is MANDATORY: initialize_{alice,bob}_session set -// min_version V1 ("require all clients speak SPQR"). The Go port keeps -// min_version V0 (it accepts a V0 peer too) but SPEAKS V1, so against the -// v0.96.4 (min V1) harness the session negotiates V1 both roles — every v4 -// SignalMessage carries a non-empty pq_ratchet field, and the prekey message is -// accepted by the min-V1 upstream (no floor-mismatch refusal). This suite -// asserts both (SPQR on the wire both directions + the Go port mixes the SPQR -// key) — at v0.96.4 it is the REQUIRED path, not a negotiable-down option. A v3 -// decrypt-vector suite is not achievable with the v0.96.4 public API (documented -// limitation — see compat/README.md); the v4 interop here is the full surface -// pinned upstream supports. +// Both implementations establish PQXDH/v4 sessions with a mandatory SPQR V1 +// floor. Recipient messages go through DecryptPreKey, including trust checks, +// pre-key consumption and durable session writes, rather than manual setup. +// Every message must carry SPQR data and derive the same keys across languages. // // Like the other interop tests this is gated behind the `interop` build tag and // driven via COMPAT_HARNESS_BIN (see interop_test.go for the client). @@ -39,10 +30,12 @@ import ( "github.com/GoCodeAlone/libsignal-go/address" "github.com/GoCodeAlone/libsignal-go/curve" "github.com/GoCodeAlone/libsignal-go/kem" + pb "github.com/GoCodeAlone/libsignal-go/proto" "github.com/GoCodeAlone/libsignal-go/protocol" "github.com/GoCodeAlone/libsignal-go/session" "github.com/GoCodeAlone/libsignal-go/stores" "github.com/GoCodeAlone/libsignal-go/stores/inmem" + googleproto "google.golang.org/protobuf/proto" ) // Ciphertext type tags, matching CiphertextMessageType (protocol.rs) and the @@ -157,8 +150,8 @@ func rustDecrypt(t *testing.T, h *harness, handle, remoteName string, m ctMsg) [ // --- Go-side bundle + recipient helpers ---------------------------------- // goBob holds the recipient key material the Go side keeps when it plays Bob, so -// it can both publish a bundle (for a Rust=Alice) and run InitializeBobSession -// once it receives a PreKeySignalMessage. v0.91.0 PreKeyBundle requires a Kyber +// it can publish a bundle (for a Rust=Alice) and populate the recipient stores +// used by DecryptPreKey. PreKeyBundle requires a Kyber // pre-key; the one-time EC pre-key is optional. type goBob struct { identity curve.KeyPair @@ -227,61 +220,30 @@ func (b *goBob) bundle(t *testing.T) bundleJSON { return out } -// initBobSession establishes Go-Bob's session from a received PreKeySignalMessage -// and stores it under the remote (Alice) address. It mirrors what an upstream -// message_decrypt_prekey does before decrypting the inner SignalMessage: resolve -// the recipient pre-keys (here held directly), run InitializeBobSession from the -// initiator's base key + Kyber ciphertext, and persist the session. -// -// The Go session package has no single public message_decrypt_prekey entry; the -// recipient flow is composed here from its public building blocks -// (DeserializePreKeySignalMessage + InitializeBobSession + Decrypt), exactly as -// session/cipher_test.go's setupConvo does. No production code is added. -func (b *goBob) initBobSession(t *testing.T, sessStore session.Store, aliceAddr address.ProtocolAddress, pkMsg ctMsg, withOneTime bool) { +// recipientStores publishes records without initializing a session. The real +// recipient API must establish it from the incoming pre-key message. +func (b *goBob) recipientStores(t *testing.T, sessStore session.Store) session.PreKeyDecryptStores { t.Helper() - if pkMsg.Type != typePreKey { - t.Fatalf("Go=Bob expected a PreKey message (type %d), got type %d", typePreKey, pkMsg.Type) + marshal := func(record googleproto.Message) []byte { + raw, err := googleproto.Marshal(record) + if err != nil { + t.Fatal(err) + } + return raw } - raw := mustDecodeHex(t, pkMsg.Serialized) - m, err := protocol.DeserializePreKeySignalMessage(raw) - if err != nil { - t.Fatalf("DeserializePreKeySignalMessage: %v", err) + pre, signed, kyber := inmem.NewPreKeyStore(), inmem.NewSignedPreKeyStore(), inmem.NewKyberPreKeyStore() + if err := signed.SaveSignedPreKey(t.Context(), 55, marshal(&pb.SignedPreKeyRecordStructure{Id: 55, PublicKey: b.signedPre.PublicKey.Serialize(), PrivateKey: b.signedPre.PrivateKey.Serialize()})); err != nil { + t.Fatal(err) } - - // Assert the message's one-time-prekey use matches the bundle we published. - // This is what proves the without_one_time case truly drives the DH4-ABSENT - // path: the upstream initiator must have omitted the one-time prekey (no - // PreKeyID on the wire), so InitializeBobSession runs with OurOneTime=nil and - // computes the master secret without DH4. If upstream had included an OPK - // here, PreKeyID would be set and this would fail loudly. - usedOneTime := m.PreKeyID() != nil - if usedOneTime != withOneTime { - t.Fatalf("incoming PreKey message one-time-prekey use = %v, want %v (DH4-absent path requires no PreKeyID)", usedOneTime, withOneTime) + if err := kyber.SaveKyberPreKey(t.Context(), 66, marshal(&pb.SignedPreKeyRecordStructure{Id: 66, PublicKey: b.kyber.PublicKey.Serialize(), PrivateKey: b.kyber.SecretKey.Serialize()})); err != nil { + t.Fatal(err) } - - var oneTime *curve.KeyPair - if usedOneTime { - if b.oneTime == nil { - t.Fatal("incoming message used a one-time pre-key but Go=Bob has none") + if b.oneTime != nil { + if err := pre.SavePreKey(t.Context(), 77, marshal(&pb.PreKeyRecordStructure{Id: 77, PublicKey: b.oneTime.PublicKey.Serialize(), PrivateKey: b.oneTime.PrivateKey.Serialize()})); err != nil { + t.Fatal(err) } - oneTime = b.oneTime - } - - state, err := session.InitializeBobSession(session.BobParams{ - OurIdentity: b.identity, - OurSignedPre: b.signedPre, - OurOneTime: oneTime, - OurKyber: b.kyber, - TheirIdentity: m.IdentityKey(), - TheirBaseKey: m.BaseKey(), - KyberCipher: m.KyberCiphertext(), - }) - if err != nil { - t.Fatalf("InitializeBobSession: %v", err) - } - if err := sessStore.StoreSession(context.Background(), aliceAddr, session.NewSessionRecord(state)); err != nil { - t.Fatalf("store Go=Bob session: %v", err) } + return session.PreKeyDecryptStores{Sessions: sessStore, Identities: inmem.NewIdentityKeyStore(b.identity, b.regID), PreKeys: pre, SignedKeys: signed, KyberPreKeys: kyber} } // --- the suite ----------------------------------------------------------- @@ -394,18 +356,26 @@ func TestSessionInteropRustAliceGoBob(t *testing.T) { rustProcessBundleAsAlice(t, h, aliceHandle, bobAddr.Name(), bob.bundle(t)) bobSess := inmem.NewSessionStore() + recipient := bob.recipientStores(t, bobSess) // Rust=Alice's first message is a PreKey message; Go=Bob establishes its - // session from it, then decrypts the inner SignalMessage. + // session and authenticates it through the public recipient API. first := rustEncrypt(t, h, aliceHandle, bobAddr.Name(), []byte("alice msg 0")) - bob.initBobSession(t, bobSess, aliceAddr, first, withOneTime) - if got := goDecryptPreKey(ctx, t, bobSess, aliceAddr, first); !bytes.Equal(got, []byte("alice msg 0")) { + firstMsg, err := protocol.DeserializePreKeySignalMessage(mustDecodeHex(t, first.Serialized)) + if err != nil || (firstMsg.PreKeyID() != nil) != withOneTime { + t.Fatalf("incoming pre-key message does not match the offered one-time key: %v", err) + } + if got := goDecryptPreKey(ctx, t, recipient, aliceAddr, first); !bytes.Equal(got, []byte("alice msg 0")) { t.Fatalf("Go=Bob decrypt msg 0: got %q", got) } + // Repeat before acknowledgment: the one-time key is already consumed. + second := rustEncrypt(t, h, aliceHandle, bobAddr.Name(), []byte("alice pre-key msg 1")) + if got := goDecryptPreKey(ctx, t, recipient, aliceAddr, second); !bytes.Equal(got, []byte("alice pre-key msg 1")) { + t.Fatalf("Go=Bob duplicate pre-key session: got %q", got) + } // Go=Bob replies so Rust=Alice's session is acknowledged. - bobID := inmem.NewIdentityKeyStore(bob.identity, bob.regID) - reply := goEncrypt(ctx, t, bobSess, bobID, aliceAddr, []byte("bob reply 0")) + reply := goEncrypt(ctx, t, bobSess, recipient.Identities, aliceAddr, []byte("bob reply 0")) // SPQR cross-impl proof (the producing direction): the Go reply carries // a non-empty pq_ratchet field, and Rust=Alice decrypting it means the // upstream side accepted and mixed the SPQR key Go produced. @@ -578,11 +548,8 @@ func goDecryptWhisper(ctx context.Context, t *testing.T, sess session.Store, rem return pt } -// goDecryptPreKey decrypts the inner SignalMessage of a received -// PreKeySignalMessage. The recipient session must already be established (via -// goBob.initBobSession); this then runs the ordinary Double Ratchet receive on -// the inner message, mirroring how upstream decrypts after process_prekey. -func goDecryptPreKey(ctx context.Context, t *testing.T, sess session.Store, remote address.ProtocolAddress, m ctMsg) []byte { +// goDecryptPreKey uses the public recipient API against actual pre-key stores. +func goDecryptPreKey(ctx context.Context, t *testing.T, recipient session.PreKeyDecryptStores, remote address.ProtocolAddress, m ctMsg) []byte { t.Helper() if m.Type != typePreKey { t.Fatalf("goDecryptPreKey: expected PreKey (%d), got type %d", typePreKey, m.Type) @@ -591,9 +558,9 @@ func goDecryptPreKey(ctx context.Context, t *testing.T, sess session.Store, remo if err != nil { t.Fatalf("DeserializePreKeySignalMessage: %v", err) } - pt, err := session.Decrypt(ctx, pk.Message(), remote, sess, cryptorand.Reader) + pt, err := session.DecryptPreKey(ctx, pk, remote, recipient, cryptorand.Reader) if err != nil { - t.Fatalf("Go Decrypt (inner SignalMessage): %v", err) + t.Fatalf("Go DecryptPreKey: %v", err) } return pt } diff --git a/compat/spqr_v16_test.go b/compat/spqr_v16_test.go new file mode 100644 index 000000000..6bab48e82 --- /dev/null +++ b/compat/spqr_v16_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 libsignal-go contributors. +// SPDX-License-Identifier: AGPL-3.0-only + +package compat + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/GoCodeAlone/libsignal-go/spqr" +) + +func TestSPQRV16Oracle(t *testing.T) { + data, err := os.ReadFile("vectors/spqr-v16.json") + if err != nil { + t.Fatal(err) + } + var batch struct { + Domain string `json:"domain"` + Cases []struct { + Label string `json:"label"` + State string `json:"state"` + Message string `json:"message"` + Error string `json:"error"` + Result *struct { + State string `json:"state"` + Key *string `json:"key"` + } `json:"result"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &batch); err != nil { + t.Fatal(err) + } + if batch.Domain != "spqr-v16" || len(batch.Cases) != 36 { + t.Fatalf("unexpected fixture domain=%q cases=%d", batch.Domain, len(batch.Cases)) + } + decode := func(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return decoded + } + errorMap := map[string]error{ + "VersionMismatch": spqr.ErrVersionMismatch, + "MinimumVersion": spqr.ErrMinimumVersion, + "MsgDecode": spqr.ErrMsgDecode, + "StateDecode": spqr.ErrChainDecode, + "KeyAlreadyRequested(1)": spqr.ErrKeyAlreadyRequested, + } + for _, tc := range batch.Cases { + t.Run(tc.Label, func(t *testing.T) { + state, message := decode(t, tc.State), decode(t, tc.Message) + beforeState, beforeMessage := bytes.Clone(state), bytes.Clone(message) + got, err := spqr.Recv(state, message) + if !bytes.Equal(state, beforeState) || !bytes.Equal(message, beforeMessage) { + t.Fatal("Recv mutated its inputs") + } + if tc.Error != "" { + want, ok := errorMap[tc.Error] + if !ok || !errors.Is(err, want) || got != nil || tc.Result != nil { + t.Fatalf("got result=%v error=%v; want %q", got, err, tc.Error) + } + return + } + if err != nil || got == nil || tc.Result == nil { + t.Fatalf("got result=%v error=%v; want success", got, err) + } + if !bytes.Equal(got.State, decode(t, tc.Result.State)) { + t.Fatalf("state: got %x want %s", got.State, tc.Result.State) + } + if tc.Result.Key == nil { + if got.Key != nil { + t.Fatalf("got key %x want absent", got.Key) + } + } else if got.Key == nil || !bytes.Equal(got.Key, decode(t, *tc.Result.Key)) { + t.Fatalf("key: got %#v want %s", got.Key, *tc.Result.Key) + } + }) + } +} diff --git a/compat/vectors/spqr-v16.json b/compat/vectors/spqr-v16.json new file mode 100644 index 000000000..1232c4e39 --- /dev/null +++ b/compat/vectors/spqr-v16.json @@ -0,0 +1,285 @@ +{ + "_note": { + "key": "null is None; empty hex is Some(empty), including higher-version epoch-zero/index-zero receive", + "libsignal_reference": "v0.104.0, rev 257105c55a7389ca6b1e85185e2769465e6729f1", + "oracle": "spqr::initial_state, send, recv; malformed chain inputs use the unchanged pq_ratchet.proto wire schema", + "spqr_reference": "1.6.0 (git tag v1.6.0, rev 06959b4708f9b7b1e94d0f8cc835f3958c077e94, test-utils feature)" + }, + "cases": [ + { + "label": "matching_v1", + "message": "01010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "open_higher_v2", + "message": "02010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a260a2029292929292929292929292929292929292929292929292929292929292929291801220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "KeyAlreadyRequested(1)", + "label": "open_higher_v2_replay", + "message": "02010100", + "state": "0a260a2029292929292929292929292929292929292929292929292929292929292929291801220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "VersionMismatch", + "label": "closed_higher_v2", + "message": "02010100", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "open_higher_v255", + "message": "ff010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a260a2029292929292929292929292929292929292929292929292929292929292929291801220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "KeyAlreadyRequested(1)", + "label": "open_higher_v255_replay", + "message": "ff010100", + "state": "0a260a2029292929292929292929292929292929292929292929292929292929292929291801220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "VersionMismatch", + "label": "closed_higher_v255", + "message": "ff010100", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MinimumVersion", + "label": "open_below_minimum", + "message": "", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MinimumVersion", + "label": "closed_below_minimum", + "message": "", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "open_downgrade_v0", + "message": "", + "result": { + "key": null, + "state": "" + }, + "state": "0a240a20292929292929292929292929292929292929292929292929292929292929292922001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "disabled_empty", + "message": "", + "result": { + "key": null, + "state": "" + }, + "state": "" + }, + { + "label": "disabled_higher", + "message": "02010100", + "result": { + "key": null, + "state": "" + }, + "state": "" + }, + { + "error": "MsgDecode", + "label": "closed_truncated_preamble", + "message": "02", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MsgDecode", + "label": "disabled_truncated_preamble", + "message": "02", + "state": "" + }, + { + "error": "MsgDecode", + "label": "open_zero_epoch", + "message": "020001", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MsgDecode", + "label": "open_truncated_index", + "message": "0201", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "matching_zero_index", + "message": "01010000", + "result": { + "key": null, + "state": "126e1a480a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f372638412221220598c199754146d732fe1bf89926833f0ed44b8a5220b662bdf0398c28c4f49ab2220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a240a20292929292929292929292929292929292929292929292929292929292929292922001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "higher_zero_index", + "message": "02010000", + "result": { + "key": "", + "state": "0a240a2029292929292929292929292929292929292929292929292929292929292929292200126e1a480a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f372638412221220598c199754146d732fe1bf89926833f0ed44b8a5220b662bdf0398c28c4f49ab2220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a240a20292929292929292929292929292929292929292929292929292929292929292922001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "higher_preamble_only", + "message": "020101", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a260a2029292929292929292929292929292929292929292929292929292929292929291801220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "chain_send_next_0", + "message": "02010000", + "result": { + "key": "", + "state": "0a260a20292929292929292929292929292929292929292929292929292929292929292918012200122a1a260a0012221220292929292929292929292929292929292929292929292929292929292929292932001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e122a1a260a001222122029292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "label": "chain_recv_next_0", + "message": "02010000", + "result": { + "key": "", + "state": "0a260a20292929292929292929292929292929292929292929292929292929292929292918012200122a1a260a2212202929292929292929292929292929292929292929292929292929292929292929120032001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e122a1a260a221220292929292929292929292929292929292929292929292929292929292929292912003200" + }, + { + "error": "StateDecode", + "label": "chain_send_next_31", + "message": "02010000", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124b1a470a21121f292929292929292929292929292929292929292929292929292929292929291222122029292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "error": "StateDecode", + "label": "chain_recv_next_31", + "message": "02010000", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124b1a470a22122029292929292929292929292929292929292929292929292929292929292929291221121f292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "label": "chain_send_next_32", + "message": "02010000", + "result": { + "key": "", + "state": "0a260a20292929292929292929292929292929292929292929292929292929292929292918012200124c1a480a221220292929292929292929292929292929292929292929292929292929292929292912221220292929292929292929292929292929292929292929292929292929292929292932001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124c1a480a22122029292929292929292929292929292929292929292929292929292929292929291222122029292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "label": "chain_recv_next_32", + "message": "02010000", + "result": { + "key": "", + "state": "0a260a20292929292929292929292929292929292929292929292929292929292929292918012200124c1a480a221220292929292929292929292929292929292929292929292929292929292929292912221220292929292929292929292929292929292929292929292929292929292929292932001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124c1a480a22122029292929292929292929292929292929292929292929292929292929292929291222122029292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "error": "StateDecode", + "label": "chain_send_next_33", + "message": "02010000", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124d1a490a2312212929292929292929292929292929292929292929292929292929292929292929291222122029292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "error": "StateDecode", + "label": "chain_recv_next_33", + "message": "02010000", + "state": "0a260a202929292929292929292929292929292929292929292929292929292929292929180122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e124d1a490a2212202929292929292929292929292929292929292929292929292929292929292929122312212929292929292929292929292929292929292929292929292929292929292929293200" + }, + { + "error": "MinimumVersion", + "label": "stored_minimum_-1_empty", + "message": "", + "state": "0a2f0a20292929292929292929292929292929292929292929292929292929292929292918ffffffffffffffffff0122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MinimumVersion", + "label": "stored_minimum_-1_matching", + "message": "01010100", + "state": "0a2f0a20292929292929292929292929292929292929292929292929292929292929292918ffffffffffffffffff0122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_-1_highest", + "message": "ff010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a2f0a20292929292929292929292929292929292929292929292929292929292929292918ffffffffffffffffff01220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a2f0a20292929292929292929292929292929292929292929292929292929292929292918ffffffffffffffffff0122001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_256_empty", + "message": "", + "result": { + "key": null, + "state": "" + }, + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918800222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_256_matching", + "message": "01010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918800222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_256_highest", + "message": "ff010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a270a202929292929292929292929292929292929292929292929292929292929292929188002220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918800222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "error": "MinimumVersion", + "label": "stored_minimum_257_empty", + "message": "", + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918810222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_257_matching", + "message": "01010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "12701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918810222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + { + "label": "stored_minimum_257_highest", + "message": "ff010100", + "result": { + "key": "8ede114ee798830a3d35a383e72e96d82a9adca0ac699c12fdf99069220e2da8", + "state": "0a270a202929292929292929292929292929292929292929292929292929292929292929188102220012701a4a0a221220d45d5d9ebca8f45f591019cfe4231f37663b50de7c9d419e8eb4dd50f3726384122408011220898e3157e2b57c8ca6f6e914973b2092c80ddfaac52ed032874de4041ededde02220c6f396ad3b038d7bb7eedbabd7c45c83eef0c9c42fca9f44d6f056e4b68be30c32001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + }, + "state": "0a270a20292929292929292929292929292929292929292929292929292929292929292918810222001a4c0a4a0a48080112440a20486eb283e739a2b7baea016d6a70df801616dfbf3f022b77a27cfaa3360348a012205a368111ae4f8d5d407e4b9a369e67af9e01d9449cf2b5e50cede3f7ab525a1e" + } + ], + "domain": "spqr-v16", + "seed": "0x51636e616c476f00" +} diff --git a/session/builder.go b/session/builder.go index 324e1265b..957069e58 100644 --- a/session/builder.go +++ b/session/builder.go @@ -187,9 +187,9 @@ type aliceParams struct { } // initializeAliceSession runs the PQXDH initiator agreement and Double Ratchet -// alice init (ratchet::initialize_alice_session, minus SPQR per ADR 0001 Stage -// 1 — pq_ratchet_state is left empty). The recipient's signed pre-key is the -// initiator's "their ratchet key" for the first DH ratchet step. +// alice init, including SPQR (ratchet::initialize_alice_session). The +// recipient's signed pre-key is the initiator's "their ratchet key" for the +// first DH ratchet step. func initializeAliceSession(rng io.Reader, p aliceParams) (*SessionState, error) { // DH agreements in upstream order (pqxdh_initiate): // DH1 = our_identity.priv x their_signed_pre @@ -270,8 +270,8 @@ func initializeAliceSession(rng io.Reader, p aliceParams) (*SessionState, error) // authKey is the PQXDH-derived PQR seed. The chain params follow upstream // spqr_chain_params: max_jump is u32 max for a self-session (a session to one's // own identity, where message ordering is unconstrained) and MaxForwardJumps -// otherwise; max_ooo_keys is MaxMessageKeys. version V1 / min_version V0 enables -// SPQR while allowing fallback for peers that do not yet speak it. Mirrors +// otherwise; max_ooo_keys is MaxMessageKeys. Version V1 / min_version V1 +// requires SPQR, matching upstream's current session floor. Mirrors // ratchet::initialize_{alice,bob}_session's spqr::initial_state call. func pqrInitialState(dir proto.Direction, authKey [32]byte, localIdentity, remoteIdentity curve.PublicKey) ([]byte, error) { selfSession := localIdentity.Equal(remoteIdentity) @@ -282,7 +282,7 @@ func pqrInitialState(dir proto.Direction, authKey [32]byte, localIdentity, remot state, err := spqr.InitialState(spqr.Params{ Direction: dir, Version: proto.Version_V_1, - MinVersion: proto.Version_V_0, + MinVersion: proto.Version_V_1, AuthKey: authKey[:], ChainParams: &proto.ChainParams{MaxJump: maxJump, MaxOooKeys: MaxMessageKeys}, }) @@ -293,7 +293,7 @@ func pqrInitialState(dir proto.Direction, authKey [32]byte, localIdentity, remot } // BobParams carries the resolved key material for the recipient agreement. The -// session cipher (T18) supplies these from the recipient's stores plus the +// session cipher supplies these from the recipient's stores plus the // incoming PreKeySignalMessage's base key and Kyber ciphertext. type BobParams struct { OurIdentity curve.KeyPair @@ -306,8 +306,8 @@ type BobParams struct { } // InitializeBobSession performs the recipient (Bob) side of the PQXDH agreement -// and Double Ratchet bob init (ratchet::initialize_bob_session, minus SPQR per -// ADR 0001 Stage 1). It is the seam the decrypt path (T18) calls once it has +// and Double Ratchet bob init, including SPQR (ratchet::initialize_bob_session). +// DecryptPreKey calls this once it has // resolved the recipient's pre-keys from its stores and the initiator's base // key + Kyber ciphertext from the PreKeySignalMessage. The returned state has // no receiver chain (the first incoming message establishes it) and a sender diff --git a/session/cipher_test.go b/session/cipher_test.go index aff503334..f350da224 100644 --- a/session/cipher_test.go +++ b/session/cipher_test.go @@ -14,7 +14,10 @@ import ( "github.com/GoCodeAlone/libsignal-go/address" "github.com/GoCodeAlone/libsignal-go/curve" + pb "github.com/GoCodeAlone/libsignal-go/proto" "github.com/GoCodeAlone/libsignal-go/protocol" + "github.com/GoCodeAlone/libsignal-go/spqr" + googleproto "google.golang.org/protobuf/proto" ) // convo wires an established Alice<->Bob pair through the stores, ready to @@ -354,14 +357,31 @@ func TestCipherTripleRatchetOnWire(t *testing.T) { } // TestCipherMixedVersionFallback simulates a peer that does not speak SPQR (an -// older client): its stored SPQR state is empty (V0). With min_version V0 on -// both sides, the conversation must still work — the V0 side sends no pq_ratchet +// older client): its stored SPQR state is empty (V0). With an explicitly restored +// legacy min_version V0 state, the conversation still works — the V0 side sends no pq_ratchet // field and contributes no key, and the V1 side negotiates down rather than -// failing. This is the staged-rollout fallback (min_version V0) the integration -// is designed around. +// failing. Fresh sessions require V1; this only proves legacy-state compatibility. func TestCipherMixedVersionFallback(t *testing.T) { c := setupConvo(t) - ctx := context.Background() + ctx := t.Context() + + aliceRec, err := c.aliceSess.LoadSession(ctx, c.bobAddr) + if err != nil { + t.Fatal(err) + } + var legacy pb.PqRatchetState + if err := googleproto.Unmarshal(aliceRec.CurrentState().PQRatchetState(), &legacy); err != nil { + t.Fatal(err) + } + legacy.VersionNegotiation.MinVersion = pb.Version_V_0 + encoded, err := googleproto.Marshal(&legacy) + if err != nil { + t.Fatal(err) + } + aliceRec.CurrentState().SetPQRatchetState(encoded) + if err := c.aliceSess.StoreSession(ctx, c.bobAddr, aliceRec); err != nil { + t.Fatal(err) + } // Make Bob a "V0" peer by clearing his SPQR state (as if he never // initialized one). Alice keeps her V1 (min V0) state. @@ -400,6 +420,27 @@ func TestCipherMixedVersionFallback(t *testing.T) { } } +func TestCipherFreshSessionRejectsSPQRDowngradeWithoutStoreMutation(t *testing.T) { + c := setupConvo(t) + bob, err := c.bobSess.LoadSession(t.Context(), c.aliceAddr) + if err != nil { + t.Fatal(err) + } + bob.CurrentState().SetPQRatchetState(nil) + if err := c.bobSess.StoreSession(t.Context(), c.aliceAddr, bob); err != nil { + t.Fatal(err) + } + message := c.bobEncrypt(t, []byte("legacy peer cannot downgrade fresh PQXDH")) + before := bytes.Clone(c.aliceSess.records[c.bobAddr.String()]) + plaintext, err := Decrypt(t.Context(), message, c.bobAddr, c.aliceSess, cryptorand.Reader) + if !errors.Is(err, spqr.ErrMinimumVersion) || len(plaintext) != 0 { + t.Fatalf("fresh SPQR floor not enforced: %v", err) + } + if !bytes.Equal(before, c.aliceSess.records[c.bobAddr.String()]) { + t.Fatal("SPQR downgrade rejection mutated session state") + } +} + // TestCipherForwardJumpCap rejects a message whose counter is more than // MaxForwardJumps beyond the current chain index. The jump check fires before // MAC verification, so a crafted far-future counter is rejected as diff --git a/session/prekey_decrypt.go b/session/prekey_decrypt.go new file mode 100644 index 000000000..88e3eae77 --- /dev/null +++ b/session/prekey_decrypt.go @@ -0,0 +1,215 @@ +package session + +import ( + "context" + "crypto/subtle" + "fmt" + "io" + + "github.com/GoCodeAlone/libsignal-go/address" + "github.com/GoCodeAlone/libsignal-go/curve" + "github.com/GoCodeAlone/libsignal-go/kem" + pb "github.com/GoCodeAlone/libsignal-go/proto" + "github.com/GoCodeAlone/libsignal-go/protocol" + "github.com/GoCodeAlone/libsignal-go/stores" + googleproto "google.golang.org/protobuf/proto" +) + +// PreKeyDecryptStores provides the recipient's identity, sessions and pre-keys. +type PreKeyDecryptStores struct { + Sessions Store + Identities stores.IdentityKeyStore + PreKeys stores.PreKeyStore + SignedKeys stores.SignedPreKeyStore + KyberPreKeys stores.KyberPreKeyStore +} + +// DecryptPreKey establishes or resumes the recipient's PQXDH session and +// decrypts the embedded SignalMessage. Authentication failures leave all stores +// unchanged, including archived sessions and one-time pre-keys. +// +// Successful decryption saves the identity, marks Kyber use against the signed +// EC pre-key, removes the optional one-time pre-key, then saves the session, in +// upstream order. Store-write failures may leave earlier writes committed; +// hosts must serialize operations per peer and provide a transaction boundary +// across these stores if they require atomic durable writes. +func DecryptPreKey(ctx context.Context, ciphertext *protocol.PreKeySignalMessage, remote address.ProtocolAddress, storage PreKeyDecryptStores, rng io.Reader) ([]byte, error) { + if ciphertext == nil || ciphertext.Message() == nil || rng == nil { + return nil, fmt.Errorf("%w: missing pre-key message or random source", ErrInvalidMessage) + } + if storage.Sessions == nil || storage.Identities == nil || storage.PreKeys == nil || storage.SignedKeys == nil || storage.KyberPreKeys == nil { + return nil, fmt.Errorf("%w: missing recipient store", ErrInvalidMessage) + } + theirIdentity := ciphertext.IdentityKey() + trusted, err := storage.Identities.IsTrustedIdentity(ctx, remote, theirIdentity, stores.Receiving) + if err != nil { + return nil, fmt.Errorf("session: trust check: %w", err) + } + if !trusted { + return nil, fmt.Errorf("%w: %s", ErrUntrustedIdentity, remote.String()) + } + stored, err := storage.Sessions.LoadSession(ctx, remote) + if err != nil { + return nil, fmt.Errorf("session: load session: %w", err) + } + working := NewFreshSessionRecord() + if stored != nil { + // A host store may return an alias: clone the entire record before + // promoting an archive, not only the state used by the cipher. + raw, err := stored.Serialize() + if err != nil { + return nil, err + } + working, err = DeserializeSessionRecord(raw) + clear(raw) + if err != nil { + return nil, err + } + } + matched, err := promoteMatchingPreKeySession(working, ciphertext) + if err != nil { + return nil, err + } + if matched { + identity, err := curve.DeserializePublicKey(working.CurrentState().RemoteIdentityPublic()) + if err != nil || !identity.Equal(theirIdentity) { + return nil, fmt.Errorf("%w: identity inconsistent with established session", ErrInvalidMessage) + } + } else { + state, err := recipientPreKeyState(ctx, ciphertext, storage) + if err != nil { + return nil, err + } + if err := working.PromoteState(state); err != nil { + return nil, err + } + } + plaintext, err := decryptWithState(working.CurrentState(), ciphertext.Message(), rng) + if err != nil { + return nil, err + } + commit := func() error { + if _, err := storage.Identities.SaveIdentity(ctx, remote, theirIdentity); err != nil { + return fmt.Errorf("session: save identity: %w", err) + } + if !matched { + if err := storage.KyberPreKeys.MarkKyberPreKeyUsed(ctx, *ciphertext.KyberPreKeyID(), ciphertext.SignedPreKeyID(), ciphertext.BaseKey()); err != nil { + return fmt.Errorf("session: mark Kyber pre-key used: %w", err) + } + if id := ciphertext.PreKeyID(); id != nil { + if err := storage.PreKeys.RemovePreKey(ctx, *id); err != nil { + return fmt.Errorf("session: remove pre-key: %w", err) + } + } + } + if err := storage.Sessions.StoreSession(ctx, remote, working); err != nil { + return fmt.Errorf("session: store session: %w", err) + } + return nil + } + if err := commit(); err != nil { + clear(plaintext) + return nil, err + } + return plaintext, nil +} + +func promoteMatchingPreKeySession(record *SessionRecord, message *protocol.PreKeySignalMessage) (bool, error) { + base := message.BaseKey().Serialize() + matches := func(state *SessionState) bool { + return state != nil && state.SessionVersion() == uint32(message.MessageVersion()) && subtle.ConstantTimeCompare(state.AliceBaseKey(), base) == 1 + } + if matches(record.CurrentState()) { + return true, nil + } + for i, raw := range record.previousSessions { + var state pb.SessionStructure + if err := googleproto.Unmarshal(raw, &state); err != nil { + return false, fmt.Errorf("%w: malformed archived session", ErrInvalidMessage) + } + if matches(NewSessionState(&state)) { + return true, record.PromoteOldSession(i) + } + } + return false, nil +} + +func recipientPreKeyState(ctx context.Context, message *protocol.PreKeySignalMessage, storage PreKeyDecryptStores) (*SessionState, error) { + if message.MessageVersion() != signalMessageCurrentVersion { + return nil, fmt.Errorf("%w: new sessions require PQXDH", ErrInvalidMessage) + } + kyberID := message.KyberPreKeyID() + if kyberID == nil || len(message.KyberCiphertext()) == 0 { + return nil, ErrNoKyberPreKey + } + signedRaw, err := storage.SignedKeys.GetSignedPreKey(ctx, message.SignedPreKeyID()) + if err != nil { + return nil, fmt.Errorf("session: load signed pre-key: %w", err) + } + var signed pb.SignedPreKeyRecordStructure + if err := googleproto.Unmarshal(signedRaw, &signed); err != nil { + return nil, fmt.Errorf("%w: malformed signed pre-key record", ErrInvalidKey) + } + defer clear(signed.PrivateKey) + signedPair, err := recipientCurveKeyPair(signed.PublicKey, signed.PrivateKey) + if err != nil { + return nil, err + } + kyberRaw, err := storage.KyberPreKeys.GetKyberPreKey(ctx, *kyberID) + if err != nil { + return nil, fmt.Errorf("session: load Kyber pre-key: %w", err) + } + var kyber pb.SignedPreKeyRecordStructure + if err := googleproto.Unmarshal(kyberRaw, &kyber); err != nil { + return nil, fmt.Errorf("%w: malformed Kyber pre-key record", ErrInvalidKey) + } + defer clear(kyber.PrivateKey) + kyberPair, err := kem.KeyPairFromPublicAndSecret(kyber.PublicKey, kyber.PrivateKey) + if err != nil { + return nil, fmt.Errorf("%w: unusable Kyber pre-key record", ErrInvalidKey) + } + var oneTime *curve.KeyPair + if id := message.PreKeyID(); id != nil { + raw, err := storage.PreKeys.GetPreKey(ctx, *id) + if err != nil { + return nil, fmt.Errorf("session: load one-time pre-key: %w", err) + } + var pre pb.PreKeyRecordStructure + if err := googleproto.Unmarshal(raw, &pre); err != nil { + return nil, fmt.Errorf("%w: malformed one-time pre-key record", ErrInvalidKey) + } + defer clear(pre.PrivateKey) + pair, err := recipientCurveKeyPair(pre.PublicKey, pre.PrivateKey) + if err != nil { + return nil, err + } + oneTime = &pair + } + identity, err := storage.Identities.GetIdentityKeyPair(ctx) + if err != nil { + return nil, fmt.Errorf("session: identity key pair: %w", err) + } + registration, err := storage.Identities.GetLocalRegistrationID(ctx) + if err != nil { + return nil, fmt.Errorf("session: local registration id: %w", err) + } + state, err := InitializeBobSession(BobParams{OurIdentity: identity, OurSignedPre: signedPair, OurOneTime: oneTime, OurKyber: kyberPair, TheirIdentity: message.IdentityKey(), TheirBaseKey: message.BaseKey(), KyberCipher: message.KyberCiphertext()}) + if err != nil { + return nil, err + } + state.SetLocalRegistrationID(registration) + state.SetRemoteRegistrationID(message.RegistrationID()) + return state, nil +} + +func recipientCurveKeyPair(public, private []byte) (curve.KeyPair, error) { + pub, err := curve.DeserializePublicKey(public) + if err != nil { + return curve.KeyPair{}, fmt.Errorf("%w: unusable pre-key public key", ErrInvalidKey) + } + priv, err := curve.DeserializePrivateKey(private) + if err != nil { + return curve.KeyPair{}, fmt.Errorf("%w: unusable pre-key private key", ErrInvalidKey) + } + return curve.KeyPair{PublicKey: pub, PrivateKey: priv}, nil +} diff --git a/session/prekey_decrypt_api_test.go b/session/prekey_decrypt_api_test.go new file mode 100644 index 000000000..9418f60e0 --- /dev/null +++ b/session/prekey_decrypt_api_test.go @@ -0,0 +1,27 @@ +package session_test + +import ( + "context" + "io" + "reflect" + "testing" + + "github.com/GoCodeAlone/libsignal-go/address" + "github.com/GoCodeAlone/libsignal-go/protocol" + "github.com/GoCodeAlone/libsignal-go/session" +) + +var _ func(context.Context, *protocol.PreKeySignalMessage, address.ProtocolAddress, session.PreKeyDecryptStores, io.Reader) ([]byte, error) = session.DecryptPreKey + +func TestPreKeyDecryptStoresPublicContract(t *testing.T) { + typeOf := reflect.TypeFor[session.PreKeyDecryptStores]() + want := []string{"Sessions", "Identities", "PreKeys", "SignedKeys", "KyberPreKeys"} + if typeOf.NumField() != len(want) { + t.Fatal("recipient store bundle deviated from the approved public contract") + } + for i, name := range want { + if typeOf.Field(i).Name != name || !typeOf.Field(i).IsExported() { + t.Fatalf("recipient store field %d must remain %s", i, name) + } + } +} diff --git a/session/prekey_decrypt_test.go b/session/prekey_decrypt_test.go new file mode 100644 index 000000000..ba6217213 --- /dev/null +++ b/session/prekey_decrypt_test.go @@ -0,0 +1,368 @@ +package session + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "errors" + "fmt" + "maps" + "reflect" + "testing" + + "github.com/GoCodeAlone/libsignal-go/address" + "github.com/GoCodeAlone/libsignal-go/curve" + pb "github.com/GoCodeAlone/libsignal-go/proto" + "github.com/GoCodeAlone/libsignal-go/protocol" + googleproto "google.golang.org/protobuf/proto" +) + +type preKeyRecordStore struct { + pre, signed, kyber map[uint32][]byte + used map[string]bool + writeErr error +} + +func (s *preKeyRecordStore) GetPreKey(_ context.Context, id uint32) ([]byte, error) { + return s.get(s.pre, id) +} +func (s *preKeyRecordStore) GetSignedPreKey(_ context.Context, id uint32) ([]byte, error) { + return s.get(s.signed, id) +} +func (s *preKeyRecordStore) GetKyberPreKey(_ context.Context, id uint32) ([]byte, error) { + return s.get(s.kyber, id) +} +func (s *preKeyRecordStore) get(records map[uint32][]byte, id uint32) ([]byte, error) { + raw, ok := records[id] + if !ok { + return nil, errors.New("missing test pre-key") + } + return bytes.Clone(raw), nil +} +func (s *preKeyRecordStore) SavePreKey(_ context.Context, id uint32, raw []byte) error { + s.pre[id] = bytes.Clone(raw) + return nil +} +func (s *preKeyRecordStore) SaveSignedPreKey(_ context.Context, id uint32, raw []byte) error { + s.signed[id] = bytes.Clone(raw) + return nil +} +func (s *preKeyRecordStore) SaveKyberPreKey(_ context.Context, id uint32, raw []byte) error { + s.kyber[id] = bytes.Clone(raw) + return nil +} +func (s *preKeyRecordStore) RemovePreKey(_ context.Context, id uint32) error { + if s.writeErr != nil { + return s.writeErr + } + delete(s.pre, id) + return nil +} +func (s *preKeyRecordStore) MarkKyberPreKeyUsed(_ context.Context, kyber, signed uint32, base curve.PublicKey) error { + if s.writeErr != nil { + return s.writeErr + } + id := fmt.Sprintf("%d/%d/%x", kyber, signed, base.Serialize()) + if s.used[id] { + return errors.New("test Kyber pre-key reused") + } + s.used[id] = true + return nil +} + +func preKeyFixture(t *testing.T, oneTime bool) (*convo, PreKeyDecryptStores, *preKeyRecordStore) { + t.Helper() + ctx := t.Context() + bob := makeBobBundle(t, oneTime, tamperNone) + c := &convo{ + aliceID: newFakeIdentityStore(t, 30, 1001), aliceSess: newFakeSessionStore(), + bobAddr: protoAddr(t, "recipient"), aliceAddr: protoAddr(t, "initiator"), + bobSess: newFakeSessionStore(), + bobIDStore: &fakeIdentityStore{identity: bob.identity, regID: 4242, trusted: map[string]curve.PublicKey{}, trustAll: true}, + } + if err := ProcessPreKeyBundle(ctx, cryptorand.Reader, c.bobAddr, bob.bundle, c.aliceSess, c.aliceID); err != nil { + t.Fatal(err) + } + records := &preKeyRecordStore{pre: map[uint32][]byte{}, signed: map[uint32][]byte{}, kyber: map[uint32][]byte{}, used: map[string]bool{}} + marshal := func(message googleproto.Message) []byte { + raw, err := googleproto.Marshal(message) + if err != nil { + t.Fatal(err) + } + return raw + } + records.signed[55] = marshal(&pb.SignedPreKeyRecordStructure{Id: 55, PublicKey: bob.signedPre.PublicKey.Serialize(), PrivateKey: bob.signedPre.PrivateKey.Serialize()}) + records.kyber[66] = marshal(&pb.SignedPreKeyRecordStructure{Id: 66, PublicKey: bob.kyber.PublicKey.Serialize(), PrivateKey: bob.kyber.SecretKey.Serialize()}) + if oneTime { + records.pre[77] = marshal(&pb.PreKeyRecordStructure{Id: 77, PublicKey: bob.oneTime.PublicKey.Serialize(), PrivateKey: bob.oneTime.PrivateKey.Serialize()}) + } + return c, PreKeyDecryptStores{Sessions: c.bobSess, Identities: c.bobIDStore, PreKeys: records, SignedKeys: records, KyberPreKeys: records}, records +} + +func outgoingPreKey(t *testing.T, c *convo, text string) *protocol.PreKeySignalMessage { + t.Helper() + _, message, err := Encrypt(t.Context(), []byte(text), c.bobAddr, c.aliceSess, c.aliceID, nil, cryptorand.Reader) + if err != nil || message == nil { + t.Fatalf("pre-key encryption failed: %v", err) + } + return message +} + +func preKeySnapshot(c *convo, records *preKeyRecordStore) any { + clone := func(source map[uint32][]byte) map[uint32][]byte { + out := maps.Clone(source) + for id, raw := range out { + out[id] = bytes.Clone(raw) + } + return out + } + return struct { + Session []byte + Identity map[string]curve.PublicKey + Pre, Signed, Kyber map[uint32][]byte + Used map[string]bool + IdentityWrites int + }{bytes.Clone(c.bobSess.records[c.aliceAddr.String()]), maps.Clone(c.bobIDStore.trusted), clone(records.pre), clone(records.signed), clone(records.kyber), maps.Clone(records.used), c.bobIDStore.saveCalls} +} + +func TestDecryptPreKeyEstablishesRecipientAndReusesSession(t *testing.T) { + for _, oneTime := range []bool{false, true} { + t.Run(fmt.Sprint(oneTime), func(t *testing.T) { + c, stores, records := preKeyFixture(t, oneTime) + message := outgoingPreKey(t, c, "first encrypted message") + plaintext, err := DecryptPreKey(t.Context(), message, c.aliceAddr, stores, cryptorand.Reader) + if err != nil || string(plaintext) != "first encrypted message" { + t.Fatalf("recipient pre-key decryption failed: %v", err) + } + if len(records.pre) != 0 || len(records.used) != 1 || len(records.signed) != 1 || len(records.kyber) != 1 { + t.Fatal("recipient did not consume the one-time key and mark Kyber use while retaining signed/Kyber records") + } + if !records.used[fmt.Sprintf("66/55/%x", message.BaseKey().Serialize())] { + t.Fatal("Kyber use was not bound to the signed EC pre-key and base key") + } + record, err := c.bobSess.LoadSession(t.Context(), c.aliceAddr) + if err != nil || record.CurrentState().LocalRegistrationID() != 4242 || record.CurrentState().RemoteRegistrationID() != 1001 { + t.Fatalf("recipient registration IDs not persisted: %v", err) + } + if key, ok := c.bobIDStore.trusted[c.aliceAddr.String()]; !ok || !key.Equal(c.aliceID.identity.PublicKey) { + t.Fatal("recipient identity was not saved after successful decryption") + } + plaintext, err = DecryptPreKey(t.Context(), outgoingPreKey(t, c, "second encrypted message"), c.aliceAddr, stores, cryptorand.Reader) + if err != nil || string(plaintext) != "second encrypted message" || len(records.used) != 1 { + t.Fatalf("matching session incorrectly reused consumed pre-keys: %v", err) + } + _, err = DecryptPreKey(t.Context(), message, c.aliceAddr, stores, cryptorand.Reader) + if !errors.Is(err, ErrDuplicateMessage) { + t.Fatalf("replayed inner message error = %v, want ErrDuplicateMessage", err) + } + }) + } +} + +func TestDecryptPreKeyIdentityMismatchLeavesAllStoresUnchanged(t *testing.T) { + for _, archived := range []bool{false, true} { + t.Run(fmt.Sprint(archived), func(t *testing.T) { + c, stores, records := preKeyFixture(t, true) + if _, err := DecryptPreKey(t.Context(), outgoingPreKey(t, c, "establish"), c.aliceAddr, stores, cryptorand.Reader); err != nil { + t.Fatal(err) + } + if archived { + record, err := c.bobSess.LoadSession(t.Context(), c.aliceAddr) + if err != nil { + t.Fatal(err) + } + next := record.CurrentState().Clone() + next.SetAliceBaseKey(genCurve(t, 222).PublicKey.Serialize()) + if err := record.PromoteState(next); err != nil { + t.Fatal(err) + } + if err := c.bobSess.StoreSession(t.Context(), c.aliceAddr, record); err != nil { + t.Fatal(err) + } + } + message := outgoingPreKey(t, c, "must not be accepted under another identity") + foreign := genCurve(t, 223).PublicKey + // Model an independently trusted key, so the matching-session identity + // check is reached instead of only testing the host trust policy. + c.bobIDStore.trusted[c.aliceAddr.String()] = foreign + forged, err := protocol.NewPreKeySignalMessage(message.MessageVersion(), message.RegistrationID(), message.PreKeyID(), message.SignedPreKeyID(), message.KyberPreKeyID(), message.KyberCiphertext(), message.BaseKey(), foreign, message.Message()) + if err != nil { + t.Fatal(err) + } + before := preKeySnapshot(c, records) + plaintext, err := DecryptPreKey(t.Context(), forged, c.aliceAddr, stores, cryptorand.Reader) + if !errors.Is(err, ErrInvalidMessage) || len(plaintext) != 0 { + t.Fatalf("matching-session identity mismatch returned plaintext or wrong error: %v", err) + } + if !reflect.DeepEqual(before, preKeySnapshot(c, records)) { + t.Fatal("identity mismatch mutated identity, pre-key, signed-pre-key, Kyber-use or session state") + } + }) + } +} + +func TestDecryptPreKeyMalformedOrUnauthenticatedLeavesStoresUnchanged(t *testing.T) { + for _, mode := range []string{"nil", "bad MAC", "bad signed record", "missing Kyber", "untrusted", "nil stores", "nil random"} { + t.Run(mode, func(t *testing.T) { + c, stores, records := preKeyFixture(t, true) + message := outgoingPreKey(t, c, "must not escape") + switch mode { + case "nil": + message = nil + case "bad MAC": + var err error + message, err = protocol.NewPreKeySignalMessage(message.MessageVersion(), message.RegistrationID(), message.PreKeyID(), message.SignedPreKeyID(), message.KyberPreKeyID(), message.KyberCiphertext(), message.BaseKey(), message.IdentityKey(), tamperBody(t, message.Message())) + if err != nil { + t.Fatal(err) + } + case "bad signed record": + records.signed[55] = []byte{0xff} + case "missing Kyber": + delete(records.kyber, 66) + case "untrusted": + c.bobIDStore.trusted[c.aliceAddr.String()] = genCurve(t, 222).PublicKey + case "nil stores": + stores = PreKeyDecryptStores{} + } + before := preKeySnapshot(c, records) + var rng = cryptorand.Reader + if mode == "nil random" { + rng = nil + } + plaintext, err := DecryptPreKey(t.Context(), message, c.aliceAddr, stores, rng) + if err == nil || len(plaintext) != 0 || !reflect.DeepEqual(before, preKeySnapshot(c, records)) { + t.Fatalf("rejected pre-key input escaped plaintext or mutated stores: %v", err) + } + }) + } +} + +func TestNewSessionsRequireSPQRVersionOne(t *testing.T) { + local, remote := genCurve(t, 230).PublicKey, genCurve(t, 231).PublicKey + for _, direction := range []pb.Direction{pb.Direction_A_2_B, pb.Direction_B_2_A} { + raw, err := pqrInitialState(direction, [32]byte{}, local, remote) + if err != nil { + t.Fatal(err) + } + var state pb.PqRatchetState + if err := googleproto.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if state.GetVersionNegotiation().GetMinVersion() != pb.Version_V_1 || state.GetV1() == nil { + t.Fatal("fresh PQXDH session permits an SPQR downgrade below upstream's V1 floor") + } + } +} + +func TestDecryptPreKeyArchivedSessionResumesWithoutPreKeyReload(t *testing.T) { + c, storage, records := preKeyFixture(t, true) + if _, err := DecryptPreKey(t.Context(), outgoingPreKey(t, c, "first"), c.aliceAddr, storage, cryptorand.Reader); err != nil { + t.Fatal(err) + } + record, err := c.bobSess.LoadSession(t.Context(), c.aliceAddr) + if err != nil { + t.Fatal(err) + } + next := record.CurrentState().Clone() + next.SetAliceBaseKey(genCurve(t, 224).PublicKey.Serialize()) + if err := record.PromoteState(next); err != nil { + t.Fatal(err) + } + if err := c.bobSess.StoreSession(t.Context(), c.aliceAddr, record); err != nil { + t.Fatal(err) + } + clear(records.signed) + clear(records.kyber) + plaintext, err := DecryptPreKey(t.Context(), outgoingPreKey(t, c, "resume archived session"), c.aliceAddr, storage, cryptorand.Reader) + if err != nil || string(plaintext) != "resume archived session" || len(records.used) != 1 { + t.Fatalf("archived session re-read or re-consumed pre-keys: %v", err) + } +} + +func TestDecryptPreKeyStoreWriteFailureDoesNotReturnPlaintext(t *testing.T) { + c, storage, records := preKeyFixture(t, true) + records.writeErr = errors.New("injected pre-key commit failure") + plaintext, err := DecryptPreKey(t.Context(), outgoingPreKey(t, c, "plaintext must not escape failed commit"), c.aliceAddr, storage, cryptorand.Reader) + if !errors.Is(err, records.writeErr) || len(plaintext) != 0 { + t.Fatalf("write failure returned plaintext or lost the host error: %v", err) + } + if len(c.bobSess.records) != 0 || len(records.pre) != 1 || len(records.used) != 0 { + t.Fatal("failed Kyber mark continued to consume pre-key or commit session") + } + // Upstream saves identity first. This failure is not an atomic store denial. + if c.bobIDStore.saveCalls != 1 { + t.Fatal("store commit did not follow upstream identity-first ordering") + } +} + +type aliasSessionStore struct{ record *SessionRecord } + +func (s *aliasSessionStore) LoadSession(context.Context, address.ProtocolAddress) (*SessionRecord, error) { + return s.record, nil +} +func (s *aliasSessionStore) StoreSession(_ context.Context, _ address.ProtocolAddress, record *SessionRecord) error { + s.record = record + return nil +} + +func TestDecryptPreKeyRejectedArchiveDoesNotMutateAliasedRecord(t *testing.T) { + c, storage, _ := preKeyFixture(t, true) + if _, err := DecryptPreKey(t.Context(), outgoingPreKey(t, c, "first"), c.aliceAddr, storage, cryptorand.Reader); err != nil { + t.Fatal(err) + } + record, err := c.bobSess.LoadSession(t.Context(), c.aliceAddr) + if err != nil { + t.Fatal(err) + } + next := record.CurrentState().Clone() + next.SetAliceBaseKey(genCurve(t, 224).PublicKey.Serialize()) + if err := record.PromoteState(next); err != nil { + t.Fatal(err) + } + storage.Sessions = &aliasSessionStore{record} + message := outgoingPreKey(t, c, "authenticated archive must stay isolated") + forged, err := protocol.NewPreKeySignalMessage(message.MessageVersion(), message.RegistrationID(), message.PreKeyID(), message.SignedPreKeyID(), message.KyberPreKeyID(), message.KyberCiphertext(), message.BaseKey(), message.IdentityKey(), tamperBody(t, message.Message())) + if err != nil { + t.Fatal(err) + } + before, err := record.Serialize() + if err != nil { + t.Fatal(err) + } + if plaintext, err := DecryptPreKey(t.Context(), forged, c.aliceAddr, storage, cryptorand.Reader); err == nil || len(plaintext) != 0 { + t.Fatal("corrupt archived message was accepted") + } + after, err := record.Serialize() + if err != nil || !bytes.Equal(before, after) { + t.Fatal("archive promotion mutated the record returned by an aliasing host store") + } +} + +func FuzzDecryptPreKeyCiphertext(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{1, 2, 3}) + f.Fuzz(func(t *testing.T, changes []byte) { + if len(changes) > 1024 { + t.Skip() + } + c, storage, records := preKeyFixture(t, true) + message := outgoingPreKey(t, c, "fuzz pre-key plaintext") + raw := message.Serialize() + for i, delta := range changes { + raw[i%len(raw)] ^= delta + } + message, err := protocol.DeserializePreKeySignalMessage(raw) + if err != nil { + return + } + before := preKeySnapshot(c, records) + plaintext, err := DecryptPreKey(t.Context(), message, c.aliceAddr, storage, cryptorand.Reader) + if err != nil { + if len(plaintext) != 0 || !reflect.DeepEqual(before, preKeySnapshot(c, records)) { + t.Fatal("failed fuzz decryption returned plaintext or changed a store") + } + } else if string(plaintext) != "fuzz pre-key plaintext" { + t.Fatal("fuzz decryption returned unauthenticated plaintext") + } + }) +} diff --git a/session/state.go b/session/state.go index c85680b64..d4fc6b48f 100644 --- a/session/state.go +++ b/session/state.go @@ -2,17 +2,16 @@ // establishment + message cipher: SessionState (a thin wrapper over the // generated proto.SessionStructure), SessionRecord (the current state plus a // bounded list of archived states), ProcessPreKeyBundle / InitializeBobSession -// (handshake), and Encrypt / Decrypt (the message cipher). It is a pure-Go port +// (handshake), and Encrypt / Decrypt / DecryptPreKey (the message cipher). It is a pure-Go port // of rust/protocol/src/state/session.rs, session.rs, and ratchet.rs, and // serializes to the same SessionStructure / RecordStructure protobufs as -// upstream libsignal v0.91.0. +// upstream libsignal. // -// Compatibility staging: sessions negotiate at the v0.91.0 surface, which ships -// the Sparse Post-Quantum Ratchet (SPQR) as an optional layer. The session -// drives SPQR through PQRatchetSend / PQRatchetRecv (the pq_ratchet message +// Fresh PQXDH sessions require Sparse Post-Quantum Ratchet (SPQR) V1. The +// session drives SPQR through PQRatchetSend / PQRatchetRecv (the pq_ratchet message // field and pq_ratchet_state), mixing the SPQR key into the Double Ratchet -// message keys; min_version V0 means a peer that does not speak SPQR still -// interoperates (the key contribution is empty and the derivation is unchanged). +// message keys. Explicitly persisted legacy min_version V0 states remain +// compatible with peers that do not speak SPQR; fresh sessions cannot downgrade. // See decisions/0001-spqr-staged-compat.md and the README scope matrix. package session diff --git a/spqr/chain.go b/spqr/chain.go index 56ad5681f..17028a28f 100644 --- a/spqr/chain.go +++ b/spqr/chain.go @@ -3,7 +3,7 @@ // // The SPQR epoch key Chain — the Double-Ratchet-style symmetric key schedule // that turns each KEM epoch secret into a stream of per-message keys. Ported -// from SparsePostQuantumRatchet v1.5.1 src/chain.rs. This is part of Slice C +// from SparsePostQuantumRatchet v1.6.0 src/chain.rs. This is part of Slice C // (the SPQR state machine): send/recv drive the Chain, feeding it each new KEM // shared secret as an epoch secret and drawing send/recv message keys from it. @@ -213,7 +213,7 @@ func (d chainEpochDirection) toProto() *proto.Chain_Epoch_EpochDirection { } func chainEpochDirectionFromProto(pb *proto.Chain_Epoch_EpochDirection) (chainEpochDirection, error) { - if pb == nil { + if pb == nil || (len(pb.GetNext()) != 0 && len(pb.GetNext()) != chainKeyLen) { return chainEpochDirection{}, ErrChainDecode } return chainEpochDirection{ @@ -344,6 +344,9 @@ func (c *chain) sendKey(epoch uint64) (uint32, []byte, error) { // recvKey returns the recv-chain key at (epoch, index). Mirrors Chain::recv_key. func (c *chain) recvKey(epoch uint64, index uint32) ([]byte, error) { + if epoch == 0 && index == 0 { + return []byte{}, nil + } epochIndex, err := c.epochIdx(epoch) if err != nil { return nil, err diff --git a/spqr/chain_test.go b/spqr/chain_test.go index 2ac5de1e3..4d90bbfde 100644 --- a/spqr/chain_test.go +++ b/spqr/chain_test.go @@ -13,6 +13,35 @@ import ( // ResolveMaxJump/resolveMaxOOO apply the 25000/2000 library defaults. func defaultParams() *proto.ChainParams { return &proto.ChainParams{} } +func TestV16ChainNextLength(t *testing.T) { + for _, size := range []int{0, 31, 32, 33} { + for _, recv := range []bool{false, true} { + pb := newChain([]byte("seed"), proto.Direction_A_2_B, defaultParams()).toProto() + if recv { + pb.Links[0].Recv.Next = make([]byte, size) + } else { + pb.Links[0].Send.Next = make([]byte, size) + } + _, err := chainFromProto(pb) + if size == 0 || size == 32 { + if err != nil { + t.Fatalf("size=%d recv=%t: %v", size, recv, err) + } + } else if !errors.Is(err, ErrChainDecode) { + t.Errorf("size=%d recv=%t: got %v want ErrChainDecode", size, recv, err) + } + } + } +} + +func TestV16ChainZeroReceive(t *testing.T) { + c := &chain{} + got, err := c.recvKey(0, 0) + if err != nil || got == nil || len(got) != 0 { + t.Fatalf("(0,0) must return an empty key before epoch lookup: key=%#v err=%v", got, err) + } +} + // TestChainDirectionsMatch ports chain.rs directions_match: an A2B chain's // send_key must equal the mirror B2A chain's recv_key at the same (epoch,index), // across an epoch advance and a run of sends. This is the cross-direction diff --git a/spqr/message.go b/spqr/message.go index f6738dab9..a2811d671 100644 --- a/spqr/message.go +++ b/spqr/message.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // // The SPQR v1 message wire codec, ported from -// SparsePostQuantumRatchet v1.5.1 src/v1/chunked/states/serialize.rs +// SparsePostQuantumRatchet v1.6.0 src/v1/chunked/states/serialize.rs // (Message::serialize / deserialize). A serialized message is: // // [version] - 1 byte (always Version_V_1 for a v1 message) @@ -101,24 +101,18 @@ func deserializeMessage(b []byte) (m v1Message, index uint32, at int, err error) if len(b) == 0 || b[0] != versionByteV1 { return v1Message{}, 0, 0, ErrMsgDecode } - at = 1 - epoch, n, ok := decodeVarint(b, at) - if !ok || epoch == 0 { - return v1Message{}, 0, 0, ErrMsgDecode - } - at = n - idx64, n, ok := decodeVarint(b, at) - if !ok || idx64 > 0xFFFFFFFF { + preamble, err := deserializePreamble(b) + if err != nil { return v1Message{}, 0, 0, ErrMsgDecode } - at = n + at = preamble.at if at >= len(b) { return v1Message{}, 0, 0, ErrMsgDecode } mt := b[at] at++ - m = v1Message{epoch: epoch} + m = v1Message{epoch: preamble.epoch} switch mt { case msgTypeNone: m.kind = payloadNone @@ -146,7 +140,31 @@ func deserializeMessage(b []byte) (m v1Message, index uint32, at int, err error) default: return v1Message{}, 0, 0, ErrMsgDecode } - return m, uint32(idx64), at, nil + return m, preamble.index, at, nil +} + +type messagePreamble struct { + version byte + epoch uint64 + index uint32 + at int +} + +// deserializePreamble matches v1.6's version-independent msg_preamble. Even an +// unsupported version must carry a valid epoch/index before negotiation. +func deserializePreamble(b []byte) (messagePreamble, error) { + if len(b) == 0 { + return messagePreamble{}, nil + } + epoch, at, ok := decodeVarint(b, 1) + if !ok || epoch == 0 { + return messagePreamble{}, ErrMsgDecode + } + index, at, ok := decodeVarint(b, at) + if !ok || index > 0xFFFFFFFF { + return messagePreamble{}, ErrMsgDecode + } + return messagePreamble{version: b[0], epoch: epoch, index: uint32(index), at: at}, nil } // decodeChunk reads a chunk: a varint index (must fit u16) followed by exactly 32 diff --git a/spqr/spqr.go b/spqr/spqr.go index 41aaf23c8..91883c0c8 100644 --- a/spqr/spqr.go +++ b/spqr/spqr.go @@ -1,7 +1,7 @@ // Copyright 2026 libsignal-go contributors. // SPDX-License-Identifier: AGPL-3.0-only // -// Top-level SPQR orchestration, ported from SparsePostQuantumRatchet v1.5.1 +// Top-level SPQR orchestration, ported from SparsePostQuantumRatchet v1.6.0 // src/lib.rs. This is the public surface the Double Ratchet layer calls: it // decodes the serialized PqRatchetState, drives the v1 chunked state machine one // step (v1.go), folds the per-epoch SCKA secret into the epoch Chain (chain.go), @@ -11,8 +11,9 @@ // Version negotiation: a state initialized at version V1 with min_version V0 may // be negotiated DOWN to V0 by a peer that only speaks V0 (it sends an empty/V0 // message). Receiving a lower version than ours triggers the downgrade (unless -// it is below our min_version, which is an error). Once we negotiate or once we -// receive any message, negotiation is closed. +// it is below our min_version, which is an error). A higher-version peer draws +// epoch-zero keys without interpreting its payload and keeps negotiation open. +// A matching-version receive or a downgrade closes negotiation. // // The chain-epoch bridge: the v1 machine numbers epochs from 1 (the first KEM // exchange completes epoch 1), but the Chain folds epoch secrets starting at @@ -63,7 +64,7 @@ type SendResult struct { // message key. type RecvResult struct { State SerializedState - Key []byte // nil when no message key was produced + Key []byte // nil for None; may be present but empty during higher-version negotiation } // VersionStatus reports a state's negotiation status. Mirrors lib.rs @@ -77,8 +78,8 @@ type VersionStatus struct { // Orchestration errors, mirroring the relevant lib.rs Error variants. var ( - // ErrVersionMismatch is returned when a peer presents a lower version than - // ours and we are not allowed to negotiate. Mirrors Error::VersionMismatch. + // ErrVersionMismatch is returned when a peer presents a different version + // after negotiation (unless below the minimum). Mirrors Error::VersionMismatch. ErrVersionMismatch = errors.New("spqr: version mismatch after negotiation") // ErrMinimumVersion is returned when a peer's version is below our configured // minimum. Mirrors Error::MinimumVersion. @@ -215,25 +216,41 @@ func Send(state SerializedState, rng io.Reader) (*SendResult, error) { // Recv folds an inbound SPQR message into the state. It first performs version // negotiation (a lower-version message may downgrade us, or be rejected), then // drives the v1 recv step, folds any epoch secret into the Chain, and derives the -// message key. Mirrors lib.rs recv. +// message key. Higher versions retain negotiation and draw epoch-zero chain +// keys without interpreting their payload. Mirrors lib.rs recv. func Recv(state SerializedState, msg SerializedMessage) (*RecvResult, error) { + preamble, err := deserializePreamble(msg) + if err != nil { + return nil, err + } prest, err := DecodeState(state) if err != nil { return nil, err } - st, err := negotiateRecv(prest, msg) + st, err := negotiateRecv(prest, preamble.version) if err != nil { return nil, err } - if st == nil { - // Their version is too high for us; ignore the message, keep our state. - return &RecvResult{State: append([]byte(nil), state...), Key: nil}, nil - } - if st.GetV1() == nil { return &RecvResult{State: nil, Key: nil}, nil } + if proto.Version(preamble.version) > stateVersion(st) { + ch, err := chainFrom(st.GetChain(), st.GetVersionNegotiation()) + if err != nil { + return nil, err + } + key, err := ch.recvKey(0, preamble.index) + if err != nil { + return nil, err + } + st.Chain = ch.toProto() + encoded, err := EncodeState(st) + if err != nil { + return nil, err + } + return &RecvResult{State: encoded, Key: key}, nil + } sckaMsg, index, _, derr := deserializeMessage(msg) if derr != nil { @@ -259,16 +276,9 @@ func Recv(state SerializedState, msg SerializedMessage) (*RecvResult, error) { } } - keyEpoch := sckaMsg.epoch - 1 - var msgKey []byte - if keyEpoch == 0 && index == 0 { - msgKey = nil - } else { - mk, kerr := ch.recvKey(keyEpoch, index) - if kerr != nil { - return nil, kerr - } - msgKey = mk + msgKey, err := ch.recvKey(sckaMsg.epoch-1, index) + if err != nil { + return nil, err } out := &proto.PqRatchetState{ @@ -284,26 +294,28 @@ func Recv(state SerializedState, msg SerializedMessage) (*RecvResult, error) { } // negotiateRecv performs version negotiation for an inbound message and returns -// the state to process the message against. A nil state means the message's -// version is higher than ours and should be ignored. Mirrors the negotiation -// block at the top of lib.rs recv. -func negotiateRecv(prest *proto.PqRatchetState, msg SerializedMessage) (*proto.PqRatchetState, error) { - mv, ok := msgVersion(msg) - if !ok { - return nil, nil // their version is too high for us; ignore - } +// the state to process the message against. Mirrors the negotiation block at +// the top of lib.rs recv; higher versions retain the negotiation block. +func negotiateRecv(prest *proto.PqRatchetState, mv byte) (*proto.PqRatchetState, error) { + version := proto.Version(mv) ourV := stateVersion(prest) - if mv >= ourV { - return prest, nil // equal or higher-than-ours-but-recognized: proceed as-is - } - // Their version is lower than ours: negotiate down if allowed. vn := prest.GetVersionNegotiation() - if vn == nil { - return nil, ErrVersionMismatch + minimum, negotiating := ourV, ourV == 0 + if vn != nil { + // Rust compares the low byte of the stored i32 enum, even for an + // unknown value. Mask explicitly to preserve that wire behavior. + minimum, negotiating = vn.GetMinVersion()&0xff, true } - if mv < vn.GetMinVersion() { + if version < minimum { return nil, ErrMinimumVersion } + if version != ourV && !negotiating { + return nil, ErrVersionMismatch + } + if ourV == 0 || version >= ourV { + return prest, nil + } + // Their version is lower than ours: negotiate down if allowed. ch, err := chainFrom(prest.GetChain(), vn) if err != nil { return nil, err @@ -312,28 +324,12 @@ func negotiateRecv(prest *proto.PqRatchetState, msg SerializedMessage) (*proto.P VersionNegotiation: nil, // our negotiation; disallow further Chain: ch.toProto(), } - if inner := initInner(mv, vn.GetDirection(), vn.GetAuthKey()); inner != nil { + if inner := initInner(proto.Version(mv), vn.GetDirection(), vn.GetAuthKey()); inner != nil { out.Inner = inner } return out, nil } -// msgVersion returns the version byte of a serialized message (V0 for empty), or -// ok=false when the version is unrecognized (too high for us). Mirrors msg_version. -func msgVersion(msg SerializedMessage) (proto.Version, bool) { - if len(msg) == 0 { - return proto.Version_V_0, true - } - switch msg[0] { - case byte(proto.Version_V_0): - return proto.Version_V_0, true - case byte(proto.Version_V_1): - return proto.Version_V_1, true - default: - return 0, false - } -} - // resolveSendChain resolves the Chain for a Send: the stored chain, or one built // from the version-negotiation block when min_version > V0, or nil (no chain yet) // when negotiation is still open at min_version V0. Mirrors the chain resolution diff --git a/spqr/spqr_test.go b/spqr/spqr_test.go index 92557a937..e3b0d5936 100644 --- a/spqr/spqr_test.go +++ b/spqr/spqr_test.go @@ -6,10 +6,13 @@ package spqr import ( "bytes" "crypto/rand" + "errors" + "strconv" "testing" "github.com/GoCodeAlone/libsignal-go/internal/spqr/chunked" "github.com/GoCodeAlone/libsignal-go/proto" + gproto "google.golang.org/protobuf/proto" ) func testAuthKey() []byte { @@ -235,3 +238,128 @@ func TestNegotiationRefused(t *testing.T) { t.Fatalf("expected ErrMinimumVersion, got %v", err) } } + +func TestV16HigherVersionNegotiation(t *testing.T) { + for _, version := range []byte{2, 255} { + t.Run(strconv.Itoa(int(version)), func(t *testing.T) { + a, _ := InitialState(v1Params(proto.Direction_A_2_B, proto.Version_V_1, proto.Version_V_1)) + b, _ := InitialState(v1Params(proto.Direction_B_2_A, proto.Version_V_1, proto.Version_V_1)) + sent, err := Send(b, rand.Reader) + if err != nil { + t.Fatal(err) + } + sent.Msg[0] = version + got, err := Recv(a, sent.Msg) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Key, sent.Key) || got.Key == nil { + t.Fatalf("higher-version key: got %x want %x", got.Key, sent.Key) + } + before, _ := DecodeState(a) + after, _ := DecodeState(got.State) + if after.GetChain() == nil || after.GetVersionNegotiation() == nil { + t.Fatal("higher-version receive must advance the chain and keep negotiation open") + } + if !gproto.Equal(before.GetV1(), after.GetV1()) { + t.Fatal("higher-version payload must not advance the v1 state machine") + } + // Replaying the same index must exercise the retained receive chain. + if _, err := Recv(got.State, sent.Msg); !errors.Is(err, ErrKeyAlreadyRequested) { + t.Fatalf("replay: got %v want ErrKeyAlreadyRequested", err) + } + }) + } +} + +func TestV16VersionErrors(t *testing.T) { + initial, _ := InitialState(v1Params(proto.Direction_A_2_B, proto.Version_V_1, proto.Version_V_0)) + st, _ := DecodeState(initial) + st.Chain = newChain(testAuthKey(), proto.Direction_A_2_B, &proto.ChainParams{}).toProto() + st.VersionNegotiation = nil + closed, _ := EncodeState(st) + for _, tc := range []struct { + name string + state []byte + msg []byte + want error + }{ + {"closed_higher", closed, []byte{2, 1, 1, msgTypeNone}, ErrVersionMismatch}, + {"closed_highest", closed, []byte{255, 1, 1, msgTypeNone}, ErrVersionMismatch}, + {"closed_below_minimum", closed, nil, ErrMinimumVersion}, + {"closed_malformed_higher", closed, []byte{2}, ErrMsgDecode}, + {"disabled_malformed", nil, []byte{2}, ErrMsgDecode}, + {"open_zero_epoch", initial, []byte{2, 0, 1}, ErrMsgDecode}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := Recv(tc.state, tc.msg); !errors.Is(err, tc.want) { + t.Fatalf("got %v want %v", err, tc.want) + } + }) + } +} + +func TestV16HigherVersionZeroIndex(t *testing.T) { + initial, _ := InitialState(v1Params(proto.Direction_A_2_B, proto.Version_V_1, proto.Version_V_0)) + got, err := Recv(initial, []byte{2, 1, 0, msgTypeNone}) + if err != nil { + t.Fatal(err) + } + if got.Key == nil || len(got.Key) != 0 { + t.Fatalf("expected present empty key, got %#v", got.Key) + } + st, _ := DecodeState(got.State) + if st.GetChain() == nil || st.GetVersionNegotiation() == nil { + t.Fatal("expected chain and open negotiation") + } +} + +func TestV16StoredMinimumWraps(t *testing.T) { + for _, tc := range []struct { + stored int32 + floor byte + }{ + {-1, 255}, + {256, 0}, + {257, 1}, + } { + for _, version := range []byte{0, 1, 255} { + t.Run(strconv.FormatInt(int64(tc.stored), 10)+"/"+strconv.Itoa(int(version)), func(t *testing.T) { + initial, err := InitialState(v1Params(proto.Direction_A_2_B, proto.Version_V_1, proto.Version_V_1)) + if err != nil { + t.Fatal(err) + } + st, err := DecodeState(initial) + if err != nil { + t.Fatal(err) + } + st.VersionNegotiation.MinVersion = proto.Version(tc.stored) + initial, err = EncodeState(st) + if err != nil { + t.Fatal(err) + } + var message []byte + if version != 0 { + message = []byte{version, 1, 1, msgTypeNone} + } + got, err := Recv(initial, message) + if version < tc.floor { + if !errors.Is(err, ErrMinimumVersion) || got != nil { + t.Fatalf("got result=%v err=%v; want ErrMinimumVersion", got, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if version == 0 { + if len(got.State) != 0 || got.Key != nil { + t.Fatal("wrapped minimum zero must allow downgrade to v0") + } + } else if len(got.Key) != chainKeyLen { + t.Fatalf("got key length %d want %d", len(got.Key), chainKeyLen) + } + }) + } + } +} diff --git a/spqr/state.go b/spqr/state.go index c6c4736fe..ef3ab2f96 100644 --- a/spqr/state.go +++ b/spqr/state.go @@ -1,7 +1,7 @@ // Package spqr implements Signal's Sparse Post-Quantum Ratchet (SPQR), the // Stage-2 post-quantum layer that augments the Double Ratchet with chunked // ML-KEM-768 key agreement. It is a pure-Go port of the SPQR reference crate -// (sparsepostquantumratchet v1.5.1) layered on the incremental ML-KEM-768 KEM +// (sparsepostquantumratchet v1.6.0) layered on the incremental ML-KEM-768 KEM // in internal/mlkem768incr. // // This file is Slice A: the ratchet-state codec. A SPQR ratchet state is