From eef57c4990afc6187e6be9252c3c5ac385f0668f Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 8 Sep 2026 09:19:41 -0400 Subject: [PATCH 1/3] fix(server): version the persisted session blob and decode the older layouts The blob was a bare SCALE struct, so its layout was positional: inserting a field moved every later field and invalidated everything already on disk. Sessions written before the identity material was added fail to decode against the current order, and the pairing host drops them, so the user has to re-pair. Written blobs now carry a leading version byte, so a future field can be added by minting a version instead of by breaking readers. Decoding takes the first layout that consumes the blob exactly: the tagged one, the untagged current one written between the identity-material change and this tag, then the untagged one that predates it, whose two absent fields read as None. Exact consumption is what makes probing sound, and a whole session followed by more bytes is reported as corruption rather than as an unrecognized layout, because the older layout carries fewer fields and can only ever fail by running out of data. The older arm is removable once no untagged blob remains in the field, which the version byte makes checkable. --- .../truapi-server/src/host_logic/session.rs | 198 +++++++++++++++++- 1 file changed, 191 insertions(+), 7 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 99a989128..7048c9f3a 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -138,21 +138,95 @@ pub fn encode_external_paired_session(info: ExternalPairedSession) -> Vec { }) } +/// Leading byte on every session blob this core writes. +/// +/// The blob is a bare SCALE struct, so its layout is positional: inserting a +/// field changes where every later field starts and silently invalidates +/// everything already on disk. The tag makes the layout explicit, so a future +/// field can be added by minting a new version rather than by breaking readers. +const PERSISTED_SESSION_V1: u8 = 1; + +/// The field order that shipped before the identity material was added, kept +/// only so blobs written by those builds still decode. +/// +/// `public_key` leads and any byte is a legal first byte of a key, so an +/// untagged blob cannot be recognised by inspection - it is found by decoding. +/// Once no untagged blob remains in the field, this and its arm in +/// [`decode_persisted_session`] can go. +#[derive(Decode)] +struct UntaggedSessionInfoBeforeIdentityMaterial { + public_key: [u8; 32], + sso: Option, + root_entropy_source: Option<[u8; 32]>, + identity_account_id: Option<[u8; 32]>, + lite_username: Option, + full_username: Option, +} + +impl From for SessionInfo { + fn from(old: UntaggedSessionInfoBeforeIdentityMaterial) -> Self { + Self { + public_key: old.public_key, + sso: old.sso, + root_entropy_source: old.root_entropy_source, + identity_account_id: old.identity_account_id, + // Absent from the layout that wrote this blob. A pairing host cannot + // recompute either, so chat and device-addressed features stay + // unavailable for this session until it re-pairs. + identity_chat_private_key: None, + device_enc_public_key: None, + lite_username: old.lite_username, + full_username: old.full_username, + } + } +} + +/// Decode `T` from exactly `blob`, so a layout that happens to parse a prefix +/// is still rejected. Exact consumption is what makes probing layouts safe. +fn decode_exact(blob: &[u8]) -> Result { + let mut input = blob; + let decoded = T::decode(&mut input).map_err(|err| err.to_string())?; + if !input.is_empty() { + return Err("trailing bytes".to_string()); + } + Ok(decoded) +} + /// Encode the active-session fields the core currently understands into an /// opaque host-global session blob. pub fn encode_persisted_session(info: &SessionInfo) -> Vec { - info.encode() + let mut blob = Vec::new(); + blob.push(PERSISTED_SESSION_V1); + info.encode_to(&mut blob); + blob } /// Decode a core-owned persisted session blob. +/// +/// Tries the tagged layout this core writes, then the two untagged layouts that +/// shipped before the tag existed, and takes the first that decodes exactly. +/// The tag is checked first but is not decisive: an untagged blob whose +/// `public_key` happens to begin with the tag byte reaches the untagged arms. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let mut input = blob; - let decoded = - SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; - if !input.is_empty() { - return Err("invalid session blob: trailing bytes".to_string()); + let tagged = match blob.split_first() { + Some((&PERSISTED_SESSION_V1, body)) => Some(body), + _ => None, + }; + for candidate in [tagged, Some(blob)].into_iter().flatten() { + let mut input = candidate; + match SessionInfo::decode(&mut input) { + Ok(info) if input.is_empty() => return Ok(info), + // A whole session followed by more bytes is corruption, not an older + // layout: the older layout carries two fewer fields, so it can only + // ever fail by running out of data. Diagnose it as such rather than + // reporting that no layout matched. + Ok(_) => return Err("invalid session blob: trailing bytes".to_string()), + Err(_) => {} + } } - Ok(decoded) + decode_exact::(blob) + .map(SessionInfo::from) + .map_err(|err| format!("invalid session blob: no known layout decodes it: {err}")) } /// Holds the currently-active session and broadcasts connection-status @@ -276,6 +350,116 @@ mod tests { } } + /// Build the untagged layout that shipped before the identity material was + /// added, so the test does not depend on that struct still existing. + fn blob_before_identity_material( + public_key: u8, + lite_username: Option<&str>, + full_username: Option<&str>, + ) -> Vec { + let mut blob = Vec::new(); + blob.extend_from_slice(&[public_key; 32]); + None::.encode_to(&mut blob); + None::<[u8; 32]>.encode_to(&mut blob); + Some([0x22u8; 32]).encode_to(&mut blob); + lite_username.map(str::to_owned).encode_to(&mut blob); + full_username.map(str::to_owned).encode_to(&mut blob); + blob + } + + /// Blobs written before the identity material was inserted mid-struct still + /// decode. Without this the pairing host drops its session on upgrade and + /// the user has to re-pair. + #[test] + fn a_session_written_before_the_identity_material_still_decodes() { + for (label, lite, full) in [ + ("both absent", None, None), + ("lite only", Some("alice.dot"), None), + ("both present", Some("alice.dot"), Some("Alice Smith")), + ] { + let decoded = + decode_persisted_session(&blob_before_identity_material(0x11, lite, full)) + .unwrap_or_else(|err| panic!("{label}: {err}")); + assert_eq!( + ( + decoded.public_key, + decoded.lite_username.as_deref(), + decoded.full_username.as_deref(), + decoded.identity_account_id, + decoded.identity_chat_private_key, + decoded.device_enc_public_key, + ), + ([0x11; 32], lite, full, Some([0x22; 32]), None, None), + "{label}: fields did not survive the older layout" + ); + } + } + + /// The layout that shipped after the identity material was added but before + /// the tag existed. Every session paired in that window is on disk in this + /// shape, so it decodes with the identity material intact rather than being + /// mistaken for the older layout and losing it. + #[test] + fn a_session_written_after_the_identity_material_but_before_the_tag_decodes() { + let mut original = info(0x77); + original.identity_chat_private_key = Some([0x88; 32]); + original.device_enc_public_key = Some([0x99; 32]); + let untagged = original.encode(); + assert_ne!( + untagged.first(), + Some(&PERSISTED_SESSION_V1), + "the fixture must not accidentally look tagged" + ); + + let decoded = decode_persisted_session(&untagged).expect("untagged current layout decodes"); + + assert_eq!( + decoded, original, + "the identity material was dropped, so this blob was read as the older layout" + ); + } + + /// The tag byte is not decisive on its own: `public_key` leads the untagged + /// layout and may legitimately begin with the same byte, so such a blob has + /// to reach the untagged arms rather than fail as a corrupt tagged one. + #[test] + fn an_untagged_session_whose_key_starts_with_the_tag_byte_still_decodes() { + let blob = blob_before_identity_material(PERSISTED_SESSION_V1, Some("alice.dot"), None); + assert_eq!(blob[0], PERSISTED_SESSION_V1); + let decoded = decode_persisted_session(&blob).expect("decodes despite the leading byte"); + assert_eq!(decoded.public_key, [PERSISTED_SESSION_V1; 32]); + } + + /// A blob this core writes carries the tag and round-trips unchanged. + #[test] + fn a_persisted_session_round_trips_through_the_tagged_layout() { + let mut original = info(0x33); + original.identity_chat_private_key = Some([0x44; 32]); + original.device_enc_public_key = Some([0x55; 32]); + + let blob = encode_persisted_session(&original); + + assert_eq!( + blob.first(), + Some(&PERSISTED_SESSION_V1), + "a written blob must carry the version tag" + ); + assert_eq!( + decode_persisted_session(&blob).expect("round trip"), + original + ); + } + + #[test] + fn a_blob_matching_no_known_layout_is_rejected() { + assert!(decode_persisted_session(&[]).is_err()); + assert!(decode_persisted_session(&[PERSISTED_SESSION_V1, 0x00]).is_err()); + // A tagged blob with one byte too many is not silently truncated. + let mut trailing = encode_persisted_session(&info(0x66)); + trailing.push(0x00); + assert!(decode_persisted_session(&trailing).is_err()); + } + #[test] fn session_username_helpers_check_and_apply_non_empty_values() { let mut session = info(0x42); From 717d1337ad66c77e08c5667dc014e69387284426 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 8 Sep 2026 10:03:22 -0400 Subject: [PATCH 2/3] fix(server): freeze the untagged session layouts and try every one before failing Each untagged layout is its own read-only decoder describing bytes on disk, so a later change to SessionInfo cannot follow them and stop decoding the blobs they exist to read. Only the tagged arm reads the live struct, and a test pins the two shapes together so adding a field fails loudly with what to do instead. Decoding runs every layout before reporting anything. A blob that parses with bytes left over no longer ends the search, because the caller deletes the stored session when decoding fails and a false negative is therefore unrecoverable. The final error names each layout tried and its own failure, so the log left behind identifies which shapes were rejected. --- .../truapi-server/src/host_logic/session.rs | 226 +++++++++++++----- 1 file changed, 166 insertions(+), 60 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 7048c9f3a..cd67f7b0e 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -146,13 +146,27 @@ pub fn encode_external_paired_session(info: ExternalPairedSession) -> Vec { /// field can be added by minting a new version rather than by breaking readers. const PERSISTED_SESSION_V1: u8 = 1; -/// The field order that shipped before the identity material was added, kept -/// only so blobs written by those builds still decode. +/// An untagged blob's eight fields, in the order an untagged blob carries them. /// -/// `public_key` leads and any byte is a legal first byte of a key, so an -/// untagged blob cannot be recognised by inspection - it is found by decoding. -/// Once no untagged blob remains in the field, this and its arm in -/// [`decode_persisted_session`] can go. +/// Read only, and frozen: it is a snapshot of a byte layout that exists on disk, +/// not a view of [`SessionInfo`]. Probing the live struct instead would follow +/// every future field change and stop decoding the blobs this exists to read. +#[derive(Decode)] +struct UntaggedSessionInfoWithIdentityMaterial { + public_key: [u8; 32], + sso: Option, + root_entropy_source: Option<[u8; 32]>, + identity_account_id: Option<[u8; 32]>, + identity_chat_private_key: Option<[u8; 32]>, + device_enc_public_key: Option<[u8; 32]>, + lite_username: Option, + full_username: Option, +} + +/// An untagged blob's six fields, carrying no identity material. +/// +/// Read only, and frozen for the same reason as +/// [`UntaggedSessionInfoWithIdentityMaterial`]. #[derive(Decode)] struct UntaggedSessionInfoBeforeIdentityMaterial { public_key: [u8; 32], @@ -163,33 +177,45 @@ struct UntaggedSessionInfoBeforeIdentityMaterial { full_username: Option, } +impl From for SessionInfo { + fn from(blob: UntaggedSessionInfoWithIdentityMaterial) -> Self { + Self { + public_key: blob.public_key, + sso: blob.sso, + root_entropy_source: blob.root_entropy_source, + identity_account_id: blob.identity_account_id, + identity_chat_private_key: blob.identity_chat_private_key, + device_enc_public_key: blob.device_enc_public_key, + lite_username: blob.lite_username, + full_username: blob.full_username, + } + } +} + impl From for SessionInfo { - fn from(old: UntaggedSessionInfoBeforeIdentityMaterial) -> Self { + fn from(blob: UntaggedSessionInfoBeforeIdentityMaterial) -> Self { Self { - public_key: old.public_key, - sso: old.sso, - root_entropy_source: old.root_entropy_source, - identity_account_id: old.identity_account_id, - // Absent from the layout that wrote this blob. A pairing host cannot - // recompute either, so chat and device-addressed features stay - // unavailable for this session until it re-pairs. + public_key: blob.public_key, + sso: blob.sso, + root_entropy_source: blob.root_entropy_source, + identity_account_id: blob.identity_account_id, + // This layout carries no identity material, and a pairing host cannot + // recompute either value, so chat and device-addressed features are + // unavailable for the session until it pairs again. identity_chat_private_key: None, device_enc_public_key: None, - lite_username: old.lite_username, - full_username: old.full_username, + lite_username: blob.lite_username, + full_username: blob.full_username, } } } -/// Decode `T` from exactly `blob`, so a layout that happens to parse a prefix -/// is still rejected. Exact consumption is what makes probing layouts safe. -fn decode_exact(blob: &[u8]) -> Result { +/// Decode `T` from `blob`. `Ok(None)` reports that `T` parsed a prefix and left +/// bytes over, which is what distinguishes a corrupt blob from a shorter layout. +fn decode_exact(blob: &[u8]) -> Result, String> { let mut input = blob; let decoded = T::decode(&mut input).map_err(|err| err.to_string())?; - if !input.is_empty() { - return Err("trailing bytes".to_string()); - } - Ok(decoded) + Ok(input.is_empty().then_some(decoded)) } /// Encode the active-session fields the core currently understands into an @@ -203,30 +229,43 @@ pub fn encode_persisted_session(info: &SessionInfo) -> Vec { /// Decode a core-owned persisted session blob. /// -/// Tries the tagged layout this core writes, then the two untagged layouts that -/// shipped before the tag existed, and takes the first that decodes exactly. -/// The tag is checked first but is not decisive: an untagged blob whose -/// `public_key` happens to begin with the tag byte reaches the untagged arms. +/// Takes the first layout that consumes the blob exactly: the tagged one, then +/// each untagged one. The tag is checked first but is not decisive, because +/// `public_key` leads an untagged blob and may legitimately begin with the tag +/// byte, so a failed tagged read still reaches the untagged layouts. +/// +/// A failure here deletes the stored blob (see the caller in `pairing_host`), so +/// every layout is tried before any is reported, and no single arm can end the +/// search early. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let tagged = match blob.split_first() { - Some((&PERSISTED_SESSION_V1, body)) => Some(body), - _ => None, - }; - for candidate in [tagged, Some(blob)].into_iter().flatten() { - let mut input = candidate; - match SessionInfo::decode(&mut input) { - Ok(info) if input.is_empty() => return Ok(info), - // A whole session followed by more bytes is corruption, not an older - // layout: the older layout carries two fewer fields, so it can only - // ever fail by running out of data. Diagnose it as such rather than - // reporting that no layout matched. - Ok(_) => return Err("invalid session blob: trailing bytes".to_string()), - Err(_) => {} + let mut trailing = false; + let mut failures = Vec::new(); + + if let Some((&PERSISTED_SESSION_V1, body)) = blob.split_first() { + match decode_exact::(body) { + Ok(Some(info)) => return Ok(info), + Ok(None) => trailing = true, + Err(err) => failures.push(format!("tagged v{PERSISTED_SESSION_V1}: {err}")), } } - decode_exact::(blob) - .map(SessionInfo::from) - .map_err(|err| format!("invalid session blob: no known layout decodes it: {err}")) + match decode_exact::(blob) { + Ok(Some(blob)) => return Ok(blob.into()), + Ok(None) => trailing = true, + Err(err) => failures.push(format!("untagged, eight fields: {err}")), + } + match decode_exact::(blob) { + Ok(Some(blob)) => return Ok(blob.into()), + Ok(None) => trailing = true, + Err(err) => failures.push(format!("untagged, six fields: {err}")), + } + + if trailing { + return Err("invalid session blob: trailing bytes".to_string()); + } + Err(format!( + "invalid session blob: no known layout decodes it ({})", + failures.join("; ") + )) } /// Holds the currently-active session and broadcasts connection-status @@ -350,18 +389,44 @@ mod tests { } } - /// Build the untagged layout that shipped before the identity material was - /// added, so the test does not depend on that struct still existing. - fn blob_before_identity_material( - public_key: u8, - lite_username: Option<&str>, - full_username: Option<&str>, - ) -> Vec { + /// The leading four fields every untagged layout shares. + /// + /// Both helpers lay bytes down field by field rather than encoding a struct. + /// Encoding `SessionInfo` would make the fixtures follow it, so a field added + /// mid-struct would move the fixture and the assertion in step and the test + /// would keep passing while real blobs stopped decoding. + fn untagged_prefix(public_key: u8) -> Vec { let mut blob = Vec::new(); blob.extend_from_slice(&[public_key; 32]); None::.encode_to(&mut blob); None::<[u8; 32]>.encode_to(&mut blob); Some([0x22u8; 32]).encode_to(&mut blob); + blob + } + + /// An untagged blob carrying six fields and no identity material. + fn blob_before_identity_material( + public_key: u8, + lite_username: Option<&str>, + full_username: Option<&str>, + ) -> Vec { + let mut blob = untagged_prefix(public_key); + lite_username.map(str::to_owned).encode_to(&mut blob); + full_username.map(str::to_owned).encode_to(&mut blob); + blob + } + + /// An untagged blob carrying all eight fields. + fn blob_with_identity_material( + public_key: u8, + chat_private_key: Option<[u8; 32]>, + device_enc_public_key: Option<[u8; 32]>, + lite_username: Option<&str>, + full_username: Option<&str>, + ) -> Vec { + let mut blob = untagged_prefix(public_key); + chat_private_key.encode_to(&mut blob); + device_enc_public_key.encode_to(&mut blob); lite_username.map(str::to_owned).encode_to(&mut blob); full_username.map(str::to_owned).encode_to(&mut blob); blob @@ -399,23 +464,64 @@ mod tests { /// the tag existed. Every session paired in that window is on disk in this /// shape, so it decodes with the identity material intact rather than being /// mistaken for the older layout and losing it. + /// `SessionInfo` still has the shape an untagged eight-field blob carries. + /// + /// This is the canary for adding a field. The untagged decoders are frozen + /// snapshots of bytes on disk, so a new field must arrive as a new tagged + /// version and must not be added to them. Nothing else fails when the two + /// drift, because the untagged arms would keep decoding while quietly + /// dropping whatever the new field carries. #[test] - fn a_session_written_after_the_identity_material_but_before_the_tag_decodes() { - let mut original = info(0x77); - original.identity_chat_private_key = Some([0x88; 32]); - original.device_enc_public_key = Some([0x99; 32]); - let untagged = original.encode(); + fn the_live_session_still_matches_the_untagged_eight_field_layout() { + let mut live = info(0xa1); + live.identity_chat_private_key = Some([0xa2; 32]); + live.device_enc_public_key = Some([0xa3; 32]); + + let decoded: SessionInfo = + decode_exact::(&live.encode()) + .expect("the live layout decodes as eight untagged fields") + .expect("and consumes the blob exactly") + .into(); + + assert_eq!( + decoded, live, + "SessionInfo no longer matches the untagged eight-field layout. Mint a new \ + PERSISTED_SESSION version for the new shape; do not change the frozen \ + untagged decoders, which describe bytes already on disk." + ); + } + + #[test] + fn an_untagged_eight_field_session_keeps_its_identity_material() { + let blob = blob_with_identity_material( + 0x77, + Some([0x88; 32]), + Some([0x99; 32]), + Some("alice.dot"), + None, + ); assert_ne!( - untagged.first(), + blob.first(), Some(&PERSISTED_SESSION_V1), "the fixture must not accidentally look tagged" ); - let decoded = decode_persisted_session(&untagged).expect("untagged current layout decodes"); + let decoded = decode_persisted_session(&blob).expect("eight-field untagged blob decodes"); assert_eq!( - decoded, original, - "the identity material was dropped, so this blob was read as the older layout" + ( + decoded.public_key, + decoded.identity_chat_private_key, + decoded.device_enc_public_key, + decoded.lite_username.as_deref(), + ), + ( + [0x77; 32], + Some([0x88; 32]), + Some([0x99; 32]), + Some("alice.dot") + ), + "identity material was dropped, so the blob was read as the six-field layout" ); } From 915f64f4a92e6da4fa1fc68dccb4e7acaa4c0e3f Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 8 Sep 2026 12:42:02 -0400 Subject: [PATCH 3/3] fix(server): decode every session version through a frozen layout The tagged arm reads the eight-field layout rather than the live struct, so SessionInfo is written and never decoded and a field added to it cannot change how any stored blob is read. One test holds the written form and that layout together, and reports what to do when they part. Trailing bytes are recorded per layout instead of as one verdict, so a blob that fails its own layout and then leaves bytes over in another still reports why each was rejected. A tagged blob keeps its own trailing-bytes diagnosis, which the callers assert. The SSO block is embedded by type rather than frozen field by field, so its encoded length is pinned and a populated fixture exercises those bytes. Activating an untagged stored session rewrites the slot in the written form, which is now covered end to end: the pairing host upgrades its stored session by activating once. --- .../truapi-server/src/host_logic/session.rs | 114 +++++++++++------- .../crates/truapi-server/src/runtime/tests.rs | 35 ++++++ 2 files changed, 107 insertions(+), 42 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index cd67f7b0e..754bcd50a 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -152,7 +152,7 @@ const PERSISTED_SESSION_V1: u8 = 1; /// not a view of [`SessionInfo`]. Probing the live struct instead would follow /// every future field change and stop decoding the blobs this exists to read. #[derive(Decode)] -struct UntaggedSessionInfoWithIdentityMaterial { +struct EightFieldSessionLayout { public_key: [u8; 32], sso: Option, root_entropy_source: Option<[u8; 32]>, @@ -166,9 +166,9 @@ struct UntaggedSessionInfoWithIdentityMaterial { /// An untagged blob's six fields, carrying no identity material. /// /// Read only, and frozen for the same reason as -/// [`UntaggedSessionInfoWithIdentityMaterial`]. +/// [`EightFieldSessionLayout`]. #[derive(Decode)] -struct UntaggedSessionInfoBeforeIdentityMaterial { +struct SixFieldSessionLayout { public_key: [u8; 32], sso: Option, root_entropy_source: Option<[u8; 32]>, @@ -177,8 +177,8 @@ struct UntaggedSessionInfoBeforeIdentityMaterial { full_username: Option, } -impl From for SessionInfo { - fn from(blob: UntaggedSessionInfoWithIdentityMaterial) -> Self { +impl From for SessionInfo { + fn from(blob: EightFieldSessionLayout) -> Self { Self { public_key: blob.public_key, sso: blob.sso, @@ -192,8 +192,8 @@ impl From for SessionInfo { } } -impl From for SessionInfo { - fn from(blob: UntaggedSessionInfoBeforeIdentityMaterial) -> Self { +impl From for SessionInfo { + fn from(blob: SixFieldSessionLayout) -> Self { Self { public_key: blob.public_key, sso: blob.sso, @@ -238,28 +238,31 @@ pub fn encode_persisted_session(info: &SessionInfo) -> Vec { /// every layout is tried before any is reported, and no single arm can end the /// search early. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let mut trailing = false; let mut failures = Vec::new(); + // A tagged blob names its own layout, so bytes left over after it are + // corruption of a known shape rather than a hint to try another. That + // verdict is reported on its own, and only once every layout has been tried. + let mut tagged_trailing = false; if let Some((&PERSISTED_SESSION_V1, body)) = blob.split_first() { - match decode_exact::(body) { - Ok(Some(info)) => return Ok(info), - Ok(None) => trailing = true, + match decode_exact::(body) { + Ok(Some(layout)) => return Ok(layout.into()), + Ok(None) => tagged_trailing = true, Err(err) => failures.push(format!("tagged v{PERSISTED_SESSION_V1}: {err}")), } } - match decode_exact::(blob) { - Ok(Some(blob)) => return Ok(blob.into()), - Ok(None) => trailing = true, + match decode_exact::(blob) { + Ok(Some(layout)) => return Ok(layout.into()), + Ok(None) => failures.push("untagged, eight fields: trailing bytes".to_string()), Err(err) => failures.push(format!("untagged, eight fields: {err}")), } - match decode_exact::(blob) { - Ok(Some(blob)) => return Ok(blob.into()), - Ok(None) => trailing = true, + match decode_exact::(blob) { + Ok(Some(layout)) => return Ok(layout.into()), + Ok(None) => failures.push("untagged, six fields: trailing bytes".to_string()), Err(err) => failures.push(format!("untagged, six fields: {err}")), } - if trailing { + if tagged_trailing { return Err("invalid session blob: trailing bytes".to_string()); } Err(format!( @@ -432,11 +435,10 @@ mod tests { blob } - /// Blobs written before the identity material was inserted mid-struct still - /// decode. Without this the pairing host drops its session on upgrade and - /// the user has to re-pair. + /// A six-field blob decodes, in every username shape. The pairing host + /// deletes a session it cannot decode, so failing here costs the pairing. #[test] - fn a_session_written_before_the_identity_material_still_decodes() { + fn a_six_field_session_decodes_in_every_username_shape() { for (label, lite, full) in [ ("both absent", None, None), ("lite only", Some("alice.dot"), None), @@ -460,37 +462,65 @@ mod tests { } } - /// The layout that shipped after the identity material was added but before - /// the tag existed. Every session paired in that window is on disk in this - /// shape, so it decodes with the identity material intact rather than being - /// mistaken for the older layout and losing it. - /// `SessionInfo` still has the shape an untagged eight-field blob carries. + /// What a written blob encodes to is what the eight-field decoder reads. /// - /// This is the canary for adding a field. The untagged decoders are frozen - /// snapshots of bytes on disk, so a new field must arrive as a new tagged - /// version and must not be added to them. Nothing else fails when the two - /// drift, because the untagged arms would keep decoding while quietly - /// dropping whatever the new field carries. + /// The decoders are frozen descriptions of bytes on disk, so a field added + /// to [`SessionInfo`] must arrive as a new tagged version and must not be + /// added to them. Nothing else fails when the two drift: the decoders would + /// keep decoding and quietly drop whatever the new field carries. #[test] - fn the_live_session_still_matches_the_untagged_eight_field_layout() { + fn a_written_session_matches_the_eight_field_layout() { let mut live = info(0xa1); live.identity_chat_private_key = Some([0xa2; 32]); live.device_enc_public_key = Some([0xa3; 32]); - let decoded: SessionInfo = - decode_exact::(&live.encode()) - .expect("the live layout decodes as eight untagged fields") - .expect("and consumes the blob exactly") - .into(); + const GUIDANCE: &str = "SessionInfo no longer matches the eight-field layout. Mint a \ + new PERSISTED_SESSION version and give it its own frozen layout; do not change \ + the existing ones, which describe bytes already on disk."; + match decode_exact::(&live.encode()) { + Ok(Some(layout)) => assert_eq!(SessionInfo::from(layout), live, "{GUIDANCE}"), + Ok(None) => panic!("{GUIDANCE} (it encodes to more bytes than the layout reads)"), + Err(err) => panic!("{GUIDANCE} (the layout no longer decodes it: {err})"), + } + } + + /// A written blob carries every field of the live struct through the SSO + /// block, which the layouts embed by type rather than freezing field by + /// field. Pinning its encoded length catches a field added to it, which + /// would move every later field of both layouts. + #[test] + fn the_sso_block_is_the_length_both_layouts_expect() { + const SSO_ENCODED_LEN: usize = 352; + + let sso = SsoSessionInfo { + ss_secret: [0xb1; 64], + ss_public_key: [0xb2; 32], + enc_secret: [0xb3; 32], + peer_enc_pubkey: [0xb4; 32], + identity_account_id: [0xb5; 32], + session_id_own: [0xb6; 32], + session_id_peer: [0xb7; 32], + request_channel: [0xb8; 32], + response_channel: [0xb9; 32], + peer_request_channel: [0xba; 32], + }; assert_eq!( - decoded, live, - "SessionInfo no longer matches the untagged eight-field layout. Mint a new \ - PERSISTED_SESSION version for the new shape; do not change the frozen \ - untagged decoders, which describe bytes already on disk." + sso.encode().len(), + SSO_ENCODED_LEN, + "the SSO block changed size, so both session layouts read different bytes; \ + mint a new PERSISTED_SESSION version rather than letting the layouts follow it" ); + + let mut live = info(0xbb); + live.sso = Some(sso); + let restored = decode_persisted_session(&encode_persisted_session(&live)) + .expect("a session carrying SSO material round-trips"); + assert_eq!(restored, live); } + /// An untagged blob carrying all eight fields keeps its identity material + /// rather than being read as the six-field layout and losing it. #[test] fn an_untagged_eight_field_session_keeps_its_identity_material() { let blob = blob_with_identity_material( diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index c790cbd9b..f900e821b 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -3211,6 +3211,41 @@ fn stored_session_activation_rejects_invalid_blob_and_disconnects() { ); } +/// An untagged blob restores and the slot is rewritten in the written form, so +/// a host upgrades its stored session by activating once rather than by pairing +/// again. `SessionInfo::encode` is the untagged eight-field layout; the canary in +/// `host_logic::session` is what keeps that true. +#[test] +fn activating_an_untagged_stored_session_restores_it_and_rewrites_the_slot() { + let stored = sso_session_info(); + let untagged = stored.encode(); + let platform = Arc::new(StubPlatform { + session_blob: Some(untagged.clone()), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + futures::executor::block_on(pairing_host.activate_stored_session()) + .expect("an untagged stored session activates"); + + assert_eq!(host.test_session_state().current(), Some(stored.clone())); + let written = crate::host_logic::session::encode_persisted_session(&stored); + assert_ne!( + untagged, written, + "the fixture must not already be in the written form, or this proves nothing" + ); + assert_eq!( + platform + .session_writes + .lock() + .expect("session write list mutex poisoned") + .last(), + Some(&written), + "the slot still holds the untagged blob, so it would be re-read on every start" + ); +} + #[test] fn session_store_sync_restores_valid_blob_from_tick() { let stored = sso_session_info();