diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index c5907e1d..418e9f1f 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -62,6 +62,9 @@ typedef enum SESSION_PROTOCOL_PRO_STATUS { // See session::ProStatus SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG, SESSION_PROTOCOL_PRO_STATUS_VALID, SESSION_PROTOCOL_PRO_STATUS_EXPIRED, + // Proof carried a version this client doesn't understand; it could not be verified and the + // message is treated as non-pro (see session::ProProofVersion). + SESSION_PROTOCOL_PRO_STATUS_UNSUPPORTED_VERSION, } SESSION_PROTOCOL_PRO_STATUS; typedef struct session_protocol_pro_signed_message { @@ -70,7 +73,6 @@ typedef struct session_protocol_pro_signed_message { } session_protocol_pro_signed_message; typedef struct session_protocol_pro_proof { - uint8_t version; cbytes32 revocation_tag; cbytes32 rotating_pubkey; int64_t expiry_ts; diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 7f8358f8..eba50a50 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -52,7 +52,10 @@ inline constexpr int STANDARD_PINNED_CONVERSATION_LIMIT = 5; /// envelope. inline constexpr int COMMUNITY_OR_1O1_MSG_PADDING = 160; -enum ProProofVersion { ProProofVersion_v0 }; +/// The Session Pro proof wire-format version. This is a *selector*: it says which ProProof_vN +/// layout a serialized proof uses; it is deliberately NOT stored as a member of the proof struct +/// (see ProProof_v0 / the ProProof alias below). Only v0 exists today. +enum class ProProofVersion : std::uint8_t { v0 = 0 }; /// Rotation window for the Session Pro rotating key: ProProof::rotating_seed yields the same seed /// for all timestamps within one such period and a fresh one at each boundary. @@ -99,13 +102,13 @@ enum class ProStatus { InvalidUserSig = SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG, Valid = SESSION_PROTOCOL_PRO_STATUS_VALID, // Proof is verified; has not expired Expired = SESSION_PROTOCOL_PRO_STATUS_EXPIRED, // Proof is verified; has expired + // Proof's wire version is not one we understand, so it can't be verified; the message is + // delivered as non-pro rather than dropped (see ProProofVersion). + UnsupportedVersion = SESSION_PROTOCOL_PRO_STATUS_UNSUPPORTED_VERSION, }; -class ProProof { +class ProProof_v0 { public: - /// Version of the proof set by the Session Pro Backend - std::uint8_t version; - /// Opaque revocation tag identifying this proof (from the Session Pro backend) b32 revocation_tag; @@ -226,13 +229,21 @@ class ProProof { static cleared_b32 rotating_seed( std::span master_seed, std::chrono::sys_seconds now); - bool operator==(const ProProof& other) const { - return version == other.version && revocation_tag == other.revocation_tag && - rotating_pubkey == other.rotating_pubkey && expiry_at == other.expiry_at && - sig == other.sig; + bool operator==(const ProProof_v0& other) const { + return revocation_tag == other.revocation_tag && rotating_pubkey == other.rotating_pubkey && + expiry_at == other.expiry_at && sig == other.sig; } }; +/// The Session Pro proof layout this codebase currently speaks. The wire `version` +/// (ProProofVersion) selects *which* ProProof_vN a serialized proof is, rather than being a field +/// of the proof: today only v0 exists, so this alias points at it. A future format adds its own +/// struct -- `struct ProProof_v1 : ProProof_v0 { ... }` to extend, or a fresh `struct ProProof_v2 { +/// ... }` to replace +/// -- at which point this alias becomes a std::variant (or a virtual base) over the supported +/// versions, chosen from the wire version at parse time. +using ProProof = ProProof_v0; + enum class ProFeaturesForMsgStatus { Success = SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS, diff --git a/src/config/pro.cpp b/src/config/pro.cpp index e9b6ef73..949069f7 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -25,10 +25,9 @@ bool ProConfig::load(std::string_view bt_encoded) { auto seed = d.require_span("r"); auto sig = d.require_span("s"); - // The config proof format is v0 by definition (a future format takes a new key, not an - // in-dict version marker -- an opaque value can't carry a version that describes itself - // across a per-key merge). - proof.version = ProProofVersion_v0; + // The config proof is v0 by definition: a future format would take a new config key, not an + // in-dict version marker (an opaque per-key-merged value can't carry a version describing + // itself), so there is nothing to select here -- load() only ever produces a ProProof_v0. proof.expiry_at = std::chrono::sys_seconds{std::chrono::seconds{expiry}}; std::memcpy(proof.revocation_tag.data(), tag.data(), proof.revocation_tag.size()); std::memcpy(proof.sig.data(), sig.data(), proof.sig.size()); diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 738f5743..af54b03e 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -519,7 +519,6 @@ LIBSESSION_C_API bool user_profile_get_pro_config(const config_object* conf, pro static_assert(sizeof pro->proof.revocation_tag == sizeof(val->proof.revocation_tag)); static_assert(sizeof pro->proof.rotating_pubkey == sizeof(val->proof.rotating_pubkey)); static_assert(sizeof pro->proof.sig == sizeof(val->proof.sig)); - pro->proof.version = val->proof.version; std::memcpy( pro->proof.revocation_tag.data, val->proof.revocation_tag.data(), @@ -541,7 +540,6 @@ LIBSESSION_C_API bool user_profile_get_pro_config(const config_object* conf, pro LIBSESSION_C_API void user_profile_set_pro_config(config_object* conf, const pro_pro_config* pro) { ProConfig val = {}; - val.proof.version = pro->proof.version; std::memcpy( val.proof.revocation_tag.data(), pro->proof.revocation_tag.data, diff --git a/src/json_parser.hpp b/src/json_parser.hpp index d5d5ccc6..482f6d5c 100644 --- a/src/json_parser.hpp +++ b/src/json_parser.hpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace session::json { @@ -38,6 +39,11 @@ std::pair is(const nlohmann::json& v) { else if constexpr (std::integral) // is_number_integer() (not is_number()) so a fractional value is rejected, not truncated. return {v.is_number_integer(), "an integer"}; + else if constexpr (std::is_enum_v) + // A (scoped) enum reads as its underlying integer -- nlohmann's default serializer converts + // through the underlying type (extract's get_to) -- so callers can request the enum + // directly, e.g. require(obj, "version"). + return {v.is_number_integer(), "an integer"}; else if constexpr (is_one_of) return {v.is_string(), "a string"}; else if constexpr (std::same_as) diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index d611ea97..0681c86a 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -182,7 +182,10 @@ namespace { // Fills the common proof payload (add-payment and generate-proof both reply with exactly a // proof) from the already-extracted `result` object. void fill_proof(const nlohmann::json::object_t& result_obj, GenerateProProofResponse& result) { - result.proof.version = json::require(result_obj, "version"); + // No wire `version` to read: this endpoint returns a ProProof_v0 by construction -- the + // format is fixed by the endpoint we asked, and the proof's version is bound into its + // signature via the personalisation, not carried as a field. A future format is a new + // endpoint returning a new ProProof_vN, not a version bump on this response. result.proof.expiry_at = json::require(result_obj, "expiry_ts"); json::require_binary(result_obj, "revocation_tag", result.proof.revocation_tag); json::require_binary(result_obj, "rotating_pkey", result.proof.rotating_pubkey); @@ -739,7 +742,6 @@ session_pro_backend_pro_proof_response_parse(const char* json, size_t json_len) // Success and error responses fold into one path -- different fields populated. const auto& p = owned->proof; - result.proof.version = p.version; result.proof.expiry_ts = session::epoch_seconds(p.expiry_at); std::memcpy( result.proof.revocation_tag.data, p.revocation_tag.data(), p.revocation_tag.size()); diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 0d659224..f21d08cc 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -89,7 +89,6 @@ static session_protocol_envelope envelope_from_cpp(const session::Envelope& cpp) static session_protocol_decoded_pro decoded_pro_from_cpp(const session::DecodedPro& cpp) { session_protocol_decoded_pro result = {}; result.status = static_cast(cpp.status); - result.proof.version = cpp.proof.version; std::memcpy( result.proof.revocation_tag.data, cpp.proof.revocation_tag.data(), @@ -109,7 +108,6 @@ static session_protocol_decoded_pro decoded_pro_from_cpp(const session::DecodedP // decoded_pro_from_cpp). static session::ProProof proof_from_c(const session_protocol_pro_proof& c) { session::ProProof proof = {}; - proof.version = c.version; std::memcpy( proof.revocation_tag.data(), c.revocation_tag.data, proof.revocation_tag.max_size()); std::memcpy( @@ -445,19 +443,32 @@ static void parse_envelope_fields( parse_common_envelope_fields(result.envelope, envelope); } -// Parses and validates the proof and feature flags embedded in a protobuf ProMessage into a -// DecodedPro. Throws if the proof is missing or malformed. The caller is responsible for evaluating -// the resulting proof's `.status`. +// Parses the proof and feature flags embedded in a protobuf ProMessage into a DecodedPro. A proof +// whose wire `version` we don't recognize is NOT fatal: it degrades to a non-pro message flagged +// ProStatus::UnsupportedVersion (the caller then skips signature evaluation), so a future proof +// format cannot make an older client silently drop the whole message. Throws only when the proof is +// absent, or when a v0 proof is structurally malformed (i.e. corruption, not forward-compat). The +// caller evaluates `.status` for a proof that parsed. static DecodedPro parse_pro_message(const SessionProtos::ProMessage& pro_msg) { DecodedPro pro = {}; if (!pro_msg.has_proof()) throw std::runtime_error{"Parse failed, pro config missing proof"}; const SessionProtos::ProProof& proto_proof = pro_msg.proof(); + pro.msg_flags = static_cast(pro_msg.msgbitset()); + pro.profile_flags = static_cast(pro_msg.profilebitset()); + + // The wire `version` selects which proof layout this is; we only understand v0. Any other (or a + // missing) version is a proof we can't verify -- degrade to non-pro rather than throw. + if (!proto_proof.has_version() || + static_cast(proto_proof.version()) != ProProofVersion::v0) { + pro.status = ProStatus::UnsupportedVersion; + return pro; + } + + // A v0 proof with the wrong shape is corruption, not forward-compat: hard error. ProProof& proof = pro.proof; - bool valid = proto_proof.has_version() && - proto_proof.version() == static_cast(ProProofVersion_v0) && - proto_proof.has_revocationtag() && + bool valid = proto_proof.has_revocationtag() && proto_proof.revocationtag().size() == proof.revocation_tag.max_size() && proto_proof.has_rotatingpublickey() && proto_proof.rotatingpublickey().size() == proof.rotating_pubkey.max_size() && @@ -466,8 +477,6 @@ static DecodedPro parse_pro_message(const SessionProtos::ProMessage& pro_msg) { if (!valid) throw std::runtime_error{"Parse failed, pro metadata was malformed"}; - pro.msg_flags = static_cast(pro_msg.msgbitset()); - pro.profile_flags = static_cast(pro_msg.profilebitset()); std::memcpy( proof.revocation_tag.data(), proto_proof.revocationtag().data(), @@ -529,19 +538,23 @@ static void parse_content_and_pro( result.envelope.flags |= SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG; DecodedPro& pro = result.pro.emplace(parse_pro_message(content.promessage())); - // Evaluate the pro status given the extracted components (was it signed, is it expired, - // was the message signed validly?) - // Note that we sign the envelope content wholesale. For 1o1 which are padded to 160 - // bytes, this means that we expected the user to have signed the padding as well. - auto unix_ts = std::chrono::floor( - std::chrono::sys_time( - std::chrono::milliseconds(content.sigtimestamp()))); - // pro_sig.size() validated == 64 above - pro.status = pro.proof.status( - pro_backend_pubkey, - unix_ts, - to_byte_span<64>(pro_sig.data()), - to_span(envelope.content())); + // A proof we couldn't parse (unknown version) is already flagged + // ProStatus::UnsupportedVersion, with no v0 proof to check -- leave it as non-pro. + if (pro.status != ProStatus::UnsupportedVersion) { + // Evaluate the pro status given the extracted components (was it signed, is it + // expired, was the message signed validly?) + // Note that we sign the envelope content wholesale. For 1o1 which are padded to 160 + // bytes, this means that we expected the user to have signed the padding as well. + auto unix_ts = std::chrono::floor( + std::chrono::sys_time( + std::chrono::milliseconds(content.sigtimestamp()))); + // pro_sig.size() validated == 64 above + pro.status = pro.proof.status( + pro_backend_pubkey, + unix_ts, + to_byte_span<64>(pro_sig.data()), + to_span(envelope.content())); + } } } } @@ -725,36 +738,40 @@ DecodedCommunityMessage decode_for_community( if (result.pro_sig && content.has_promessage()) { DecodedPro& pro = result.pro.emplace(parse_pro_message(content.promessage())); - // Evaluate the pro status given the extracted components (was it signed, is it expired, - // was the message signed validly?) - // - // IMPORTANT: We have to bit-manipulate the content because we're including the signature - // inside the payload itself that we had to sign. But we originally signed the payload - // without a signature set in it. This is only the case if we're dealing with a `Content` - // message that had the signature inside the content instead of the envelope. - if (result.envelope) { - // Entering the `pro_sig` and `result.envelope` branch means that the envelope must have - // a pro signature. - assert(result.envelope->flags & SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG); - pro.status = pro.proof.status( - pro_backend_pubkey, unix_ts, *result.pro_sig, result.content_plaintext); - } else { - SessionProtos::Content content_copy_without_sig = content; - assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); - - // Remove signature from the payload - content_copy_without_sig.clear_prosigforcommunitymessageonly(); - assert(!content_copy_without_sig.has_prosigforcommunitymessageonly()); - - // Reserialise the payload without the signature, repad it then verify the signature - std::vector content_copy_without_sig_payload = - pad_message(to_span(content_copy_without_sig.SerializeAsString())); - - pro.status = pro.proof.status( - pro_backend_pubkey, - unix_ts, - *result.pro_sig, - to_span(content_copy_without_sig_payload)); + // A proof we couldn't parse (unknown version) is already flagged + // ProStatus::UnsupportedVersion, with no v0 proof to check -- leave it as non-pro. + if (pro.status != ProStatus::UnsupportedVersion) { + // Evaluate the pro status given the extracted components (was it signed, is it expired, + // was the message signed validly?) + // + // IMPORTANT: We have to bit-manipulate the content because we're including the + // signature inside the payload itself that we had to sign. But we originally signed the + // payload without a signature set in it. This is only the case if we're dealing with a + // `Content` message that had the signature inside the content instead of the envelope. + if (result.envelope) { + // Entering the `pro_sig` and `result.envelope` branch means that the envelope must + // have a pro signature. + assert(result.envelope->flags & SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG); + pro.status = pro.proof.status( + pro_backend_pubkey, unix_ts, *result.pro_sig, result.content_plaintext); + } else { + SessionProtos::Content content_copy_without_sig = content; + assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); + + // Remove signature from the payload + content_copy_without_sig.clear_prosigforcommunitymessageonly(); + assert(!content_copy_without_sig.has_prosigforcommunitymessageonly()); + + // Reserialise the payload without the signature, repad it then verify the signature + std::vector content_copy_without_sig_payload = + pad_message(to_span(content_copy_without_sig.SerializeAsString())); + + pro.status = pro.proof.status( + pro_backend_pubkey, + unix_ts, + *result.pro_sig, + to_span(content_copy_without_sig_payload)); + } } } diff --git a/tests/test_config_pro.cpp b/tests/test_config_pro.cpp index 9ff2fd1f..d07d236d 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -22,8 +22,6 @@ TEST_CASE("Pro", "[config][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - // Config never persists the proof version (see ProConfig::load); a loaded proof is v0. - pro_cpp.proof.version = session::ProProofVersion_v0; pro_cpp.proof.rotating_pubkey = rotating_pk; pro_cpp.proof.expiry_at = std::chrono::sys_seconds(1s); constexpr auto revocation_tag = @@ -34,7 +32,6 @@ TEST_CASE("Pro", "[config][pro]") { // C std::memcpy(pro.rotating_privkey.data, rotating_sk.data(), rotating_sk.size()); - pro.proof.version = pro_cpp.proof.version; std::memcpy(pro.proof.rotating_pubkey.data, rotating_pk.data(), rotating_pk.size()); pro.proof.expiry_ts = pro_cpp.proof.expiry_at.time_since_epoch().count(); std::memcpy(pro.proof.revocation_tag.data, revocation_tag.data(), revocation_tag.size()); @@ -80,7 +77,6 @@ TEST_CASE("Pro", "[config][pro]") { session::config::ProConfig loaded_pro = {}; CHECK(loaded_pro.load(encoded)); CHECK(loaded_pro.rotating_privkey == pro_cpp.rotating_privkey); - CHECK(loaded_pro.proof.version == session::ProProofVersion_v0); // never persisted CHECK(loaded_pro.proof.revocation_tag == pro_cpp.proof.revocation_tag); CHECK(loaded_pro.proof.rotating_pubkey == pro_cpp.proof.rotating_pubkey); // derived from seed diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index e19459b4..06c5dacd 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -613,9 +613,6 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - // The config does not persist the proof version (dicts merge per-key, so an in-dict version - // can't reliably describe its sibling fields); a config-loaded proof is always v0. - pro_cpp.proof.version = session::ProProofVersion_v0; pro_cpp.proof.rotating_pubkey = rotating_pk; pro_cpp.proof.expiry_at = std::chrono::sys_seconds(1s); constexpr auto revocation_tag = @@ -626,7 +623,6 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // C std::memcpy(pro.rotating_privkey.data, rotating_sk.data(), rotating_sk.size()); - pro.proof.version = pro_cpp.proof.version; std::memcpy(pro.proof.rotating_pubkey.data, rotating_pk.data(), rotating_pk.size()); pro.proof.expiry_ts = pro_cpp.proof.expiry_at.time_since_epoch().count(); std::memcpy(pro.proof.revocation_tag.data, revocation_tag.data(), revocation_tag.size()); @@ -788,7 +784,6 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { auto store_proof = [&](std::chrono::sys_seconds expiry) { session::config::ProConfig pc = {}; pc.rotating_privkey = rotating_sk; // any valid 64-byte key (only sizes matter here) - pc.proof.version = session::ProProofVersion_v0; pc.proof.rotating_pubkey = rotating_pk; pc.proof.expiry_at = expiry; pr.set_pro_config(pc); diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index 2facd806..6eeebacf 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -144,7 +144,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { nlohmann::json j; j["status"] = "ok"; j["result"] = { - {"version", 0}, {"expiry_ts", unix_ts}, {"revocation_tag", oxenc::to_hex(fake_revocation_tag)}, {"rotating_pkey", oxenc::to_hex(rotating_pubkey.data)}, @@ -186,7 +185,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { auto result_cpp = parse_pro_proof(json); // Validate C and CPP variants - REQUIRE(result.proof.version == result_cpp.proof.version); REQUIRE(std::memcmp( result.proof.revocation_tag.data, result_cpp.proof.revocation_tag.data(), @@ -811,7 +809,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { } SECTION("pro proof") { ProProof proof; - proof.version = 0; std::memset(proof.revocation_tag.data(), 0x11, proof.revocation_tag.size()); std::memcpy(proof.rotating_pubkey.data(), rotating_pk.data(), 32); proof.expiry_at = expiry; diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index 8d28805c..5bc80c2e 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -30,7 +30,8 @@ static SerialisedProtobufContentWithProForTesting build_protobuf_content_with_se std::chrono::sys_seconds content_at, std::chrono::sys_seconds pro_expiry_at, uint64_t msg_bitset, - uint64_t profile_bitset) { + uint64_t profile_bitset, + uint32_t proof_version = static_cast(session::ProProofVersion::v0)) { SerialisedProtobufContentWithProForTesting result = {}; // Create protobuf `Content.dataMessage` @@ -56,7 +57,7 @@ static SerialisedProtobufContentWithProForTesting build_protobuf_content_with_se // Create protobuf `Content.proMessage.proof` SessionProtos::ProProof* proto_proof = pro->mutable_proof(); - proto_proof->set_version(result.proof.version); + proto_proof->set_version(proof_version); proto_proof->set_revocationtag( result.proof.revocation_tag.data(), result.proof.revocation_tag.size()); proto_proof->set_rotatingpublickey( @@ -387,6 +388,61 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { session_protocol_decode_envelope_free(&decrypt_result); } + SECTION("A future/unknown proof version degrades to non-pro, not a dropped message") { + // Same message, but the embedded proof claims a version this client doesn't understand. + SerialisedProtobufContentWithProForTesting future_content = + build_protobuf_content_with_session_pro( + /*data_body*/ data_body, + /*user_rotating_privkey*/ user_pro_ed_sk, + /*pro_backend_privkey*/ keys.ed_sk1, + /*content_at=*/timestamp_s, + /*pro_expiry_at*/ timestamp_s, + /*msg_bitset*/ {}, + /*profile_bitset*/ {}, + /*proof_version*/ 99); + + session_protocol_encoded_for_destination encrypt_result = session_protocol_encode_dm_v1( + future_content.plaintext.data(), + future_content.plaintext.size(), + keys.ed_sk0.data(), + keys.ed_sk0.size(), + base_sent_timestamp_ms, + &base_recipient_pubkey, + user_pro_ed_sk.data(), + user_pro_ed_sk.size(), + error, + sizeof(error)); + REQUIRE(encrypt_result.error_len_incl_null_terminator == 0); + + span_u8 key = {keys.ed_sk1.data(), keys.ed_sk1.size()}; + session_protocol_decode_envelope_keys decrypt_keys = {}; + decrypt_keys.decrypt_keys = &key; + decrypt_keys.decrypt_keys_len = 1; + session_protocol_decoded_envelope decrypt_result = session_protocol_decode_envelope( + &decrypt_keys, + encrypt_result.ciphertext.data, + encrypt_result.ciphertext.size, + keys.ed_pk1.data(), + keys.ed_pk1.size(), + error, + sizeof(error)); + // The message is delivered rather than silently swallowed by the unknown version... + REQUIRE(decrypt_result.success); + REQUIRE(decrypt_result.error_len_incl_null_terminator == 0); + session_protocol_encode_for_destination_free(&encrypt_result); + + // ...but the proof degrades to non-pro with an explicit "unsupported version" status. + REQUIRE(decrypt_result.pro.status == SESSION_PROTOCOL_PRO_STATUS_UNSUPPORTED_VERSION); + + // The underlying message content survives intact. + SessionProtos::Content decrypt_content = {}; + REQUIRE(decrypt_content.ParseFromArray( + decrypt_result.content_plaintext.data, decrypt_result.content_plaintext.size)); + REQUIRE(decrypt_content.has_datamessage()); + REQUIRE(decrypt_content.datamessage().body() == data_body); + session_protocol_decode_envelope_free(&decrypt_result); + } + SECTION("Encrypt/decrypt for contact in default namespace with Pro + features") { std::string large_message; large_message.resize(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1);