From 5e908250bf3171af70a8ca5638cdc490a4256dd8 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 20:53:41 -0300 Subject: [PATCH 01/35] config: add synced refund-requested flag to UserProfile Add a top-level `R` key to UserProfile holding the unix timestamp at which the user requested a refund of their Session Pro subscription. This lets a device that initiated a refund propagate the state to the user's other devices through config sync, rather than round-tripping it through the Pro backend (which does nothing with it). The setter stamps the profile-updated timestamp so the change is time-ordered across devices; the client clears it (nullopt/0) when a new subscription begins. Also documents the previously-undocumented `s` (Session Pro data) key in the key ledger. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 25 ++++++++++++++++++++ include/session/config/user_profile.hpp | 29 +++++++++++++++++++++++ src/config/user_profile.cpp | 31 +++++++++++++++++++++++++ tests/test_config_userprofile.cpp | 20 ++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index d107de028..198fb6d53 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -399,6 +399,31 @@ LIBSESSION_EXPORT int64_t user_profile_get_pro_access_expiry(const config_object LIBSESSION_EXPORT void user_profile_set_pro_access_expiry( config_object* conf, int64_t access_expiry_ts); +/// API: user_profile/user_profile_get_refund_requested +/// +/// Retrieves the timestamp at which the user requested a refund of their current Session Pro +/// subscription. This state is synced across the user's devices via config (not the Pro backend). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` - the unix timestamp (seconds) at which a refund was requested, or 0 if no refund +/// has been requested. +LIBSESSION_EXPORT int64_t user_profile_get_refund_requested(const config_object* conf); + +/// API: user_profile/user_profile_set_refund_requested +/// +/// Records (or clears) that the user has requested a refund of their current Session Pro +/// subscription, propagating it to the user's other devices via config sync. The client should +/// clear it (passing 0) when a new subscription begins. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `refund_ts` -- the timestamp (unix epoch seconds) at which the refund was requested, or 0 to +/// clear the refund-requested state. +LIBSESSION_EXPORT void user_profile_set_refund_requested(config_object* conf, int64_t refund_ts); + #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index c9a105405..c963c21a9 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -25,10 +25,14 @@ using namespace std::literals; /// omitted if the setting has not been explicitly set (or has been explicitly cleared for some /// reason). /// f - session pro features bitset +/// s - session pro data (rotating seed + proof); see config/pro.hpp for the sub-dict layout. /// t - The unix timestamp (seconds) that the user last explicitly updated their profile information /// (automatically updates when changing `name`, `profile_pic` or `set_blinded_msgreqs`). /// E - user pro access expiry unix timestamp (in milliseconds). Note: This can be different from /// the pro proof expiry which can be sooner. +/// R - unix timestamp (seconds) at which the user requested a refund of their current Session Pro +/// subscription, for cross-device sync of the refund-requested state. Omitted when no refund +/// has been requested; cleared by the client when a new subscription begins. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -327,6 +331,31 @@ class UserProfile : public ConfigBase { /// - `access_expiry_ts` -- The timestamp (unix epoch seconds) that the users Session Pro access /// will expire, or nullopt to remove the value. void set_pro_access_expiry(std::optional access_expiry_ts); + + /// API: user_profile/UserProfile::get_refund_requested + /// + /// Retrieves the timestamp at which the user requested a refund of their current Session Pro + /// subscription, if any. This is synced across the user's devices so that a refund requested on + /// one device is reflected on the others; it does not go through the Pro backend. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` - the unix timestamp (seconds) at which a refund was + /// requested, or nullopt if no refund has been requested. + std::optional get_refund_requested() const; + + /// API: user_profile/UserProfile::set_refund_requested + /// + /// Records (or clears) that the user has requested a refund of their current Session Pro + /// subscription. Setting this propagates the refund-requested state to the user's other + /// devices via config sync. The client is responsible for clearing it (passing nullopt) when a + /// new subscription begins so a future subscription does not inherit a stale value. + /// + /// Inputs: + /// - `when` -- the timestamp (unix epoch seconds) at which the refund was requested, or nullopt + /// to clear the refund-requested state. + void set_refund_requested(std::optional when); }; } // namespace session::config diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index e689dd0af..02fe2dfd2 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -225,6 +225,24 @@ void UserProfile::set_pro_access_expiry(std::optional data["E"].erase(); } +std::optional UserProfile::get_refund_requested() const { + if (auto* R = data["R"].integer(); R) + return std::chrono::sys_seconds{std::chrono::seconds{*R}}; + return std::nullopt; +} + +void UserProfile::set_refund_requested(std::optional when) { + if (when) + data["R"] = epoch_seconds(*when); + else + data["R"].erase(); + + // Stamp the profile-updated timestamp so the change is time-ordered across devices. + const auto target_timestamp = + (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); +} + extern "C" { using namespace session; @@ -407,4 +425,17 @@ LIBSESSION_C_API void user_profile_set_pro_access_expiry( unbox(conf)->set_pro_access_expiry(as_sys_seconds(access_expiry_ts)); } +LIBSESSION_C_API int64_t user_profile_get_refund_requested(const config_object* conf) { + if (auto when = unbox(conf)->get_refund_requested()) + return epoch_seconds(*when); + return 0; +} + +LIBSESSION_C_API void user_profile_set_refund_requested(config_object* conf, int64_t refund_ts) { + if (refund_ts <= 0) + unbox(conf)->set_refund_requested(std::nullopt); + else + unbox(conf)->set_refund_requested(as_sys_seconds(refund_ts)); +} + } // extern "C" diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index eb458346f..03d746dbc 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -651,4 +651,24 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { auto access_expiry = std::chrono::sys_seconds{std::chrono::seconds{500}}; profile.set_pro_access_expiry(access_expiry); CHECK(profile.get_pro_access_expiry() == access_expiry); + + // Refund-requested flag (synced via config, not the Pro backend) + CHECK_FALSE(profile.get_refund_requested().has_value()); + + UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); + auto refund_at = std::chrono::sys_seconds{std::chrono::seconds{789}}; + profile.set_refund_requested(refund_at); + CHECK(profile.get_refund_requested() == refund_at); + // Setting stamps the profile-updated timestamp so it time-orders across devices. + CHECK(profile.get_profile_updated().time_since_epoch().count() != 456); + + { + // Round-trips through a dump/reload. + session::config::UserProfile profile2{std::span{seed}, profile.dump()}; + CHECK(profile2.get_refund_requested() == refund_at); + } + + // Clearing removes it entirely. + profile.set_refund_requested(std::nullopt); + CHECK_FALSE(profile.get_refund_requested().has_value()); } From ac16b890474cdd951ad8f08115f04d2de086613e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:01:49 -0300 Subject: [PATCH 02/35] pro_backend: remove set_payment_refund_requested endpoint The refund-requested timestamp did nothing on the Pro backend side; its only purpose was cross-device sync, which now lives in the config layer (UserProfile `R` key). Remove the whole set-refund path from libsession: - the `set_payment_refund_requested` endpoint constant and the `ProSetRefundReq_` signing domain; - refund_sig / refund_request (+ internal message/sign/body helpers) and SetPaymentRefundRequestedResponse / parse_refund; - the `refund_requested_at` field on ProStatusResponse and ProPaymentItem and its JSON (de)serialization; - the C mirrors (endpoint const, struct fields, request_build / response_parse / response_free); - the corresponding tests and the KAT generator's refund vector. Kept: platform_refund_expiry_* (the provider-store refund deadline, a distinct concept) and the refund_*_url support links. Removing the read-side parse is safe regardless of backend timing: an absent field can only break a json_require, and those calls are gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.h | 54 +-------- include/session/pro_backend.hpp | 48 +------- include/session/session_protocol.hpp | 2 - src/pro_backend.cpp | 161 +-------------------------- tests/pro_backend/gen_kat.py | 5 +- tests/test_pro_backend.cpp | 133 +--------------------- 6 files changed, 6 insertions(+), 397 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index ca416a462..036277a18 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -12,7 +12,7 @@ extern "C" { #endif /// Canonical payment-provider `code` strings — the value transmitted on the wire and folded into -/// the add-payment / set-refund signed messages (§3). Reference these rather than hardcoding the +/// the add-payment signed message (§3). Reference these rather than hardcoding the /// slug so a sent code cannot drift from what libsession signs/parses. Unknown codes (e.g. a future /// provider) are still valid on the wire and pass through as opaque strings. Each points at the /// C++ `session::pro_backend::PAYMENT_PROVIDER_*` value (defined there — the primary; C @@ -31,8 +31,6 @@ LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GENERATE_PRO_PROO LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_STATUS_ENDPOINT; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PAYMENT_DETAILS_ENDPOINT; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_REVOCATIONS_ENDPOINT; -LIBSESSION_EXPORT extern const char* const - SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT; /// The Session Pro Backend's production base URL (POST a request body to `/`). /// Canonical production value; clients may override with a dev/test server. Points at the single @@ -209,7 +207,6 @@ typedef struct session_pro_backend_pro_payment_item { int64_t platform_refund_expiry_ts; /// Provider revocation instant, fractional UNIX seconds (millisecond-precise; 0 if not revoked) double revoked_ts; - int64_t refund_requested_ts; /// Opaque payment identifier (the value passed at add-payment; multi-part providers fold their /// parts in per the backend-defined composite -- libsession does not interpret it). @@ -226,7 +223,6 @@ typedef struct session_pro_backend_get_pro_status_response { bool auto_renewing; int64_t expiry_ts; int64_t grace_period_duration; - int64_t refund_requested_ts; /// True if the account has at least one payment, in which case `latest_payment` is populated /// with the most recent one; false means the account has no payments and `latest_payment` is /// unset. @@ -262,18 +258,6 @@ LIBSESSION_EXPORT void session_pro_backend_get_payment_details_response_free( session_pro_backend_get_payment_details_response* response); -typedef struct session_pro_backend_set_payment_refund_requested_response { - session_pro_backend_response_header header; - bool updated; -} session_pro_backend_set_payment_refund_requested_response; - -/// API: session_pro_backend/set_payment_refund_requested_response_free -/// -/// Frees the response. -LIBSESSION_EXPORT -void session_pro_backend_set_payment_refund_requested_response_free( - session_pro_backend_set_payment_refund_requested_response* response); - /// API: session_pro_backend/add_pro_payment_request_build /// /// Builds the add-payment request to POST, as a session_pro_backend_request (endpoint + @@ -411,42 +395,6 @@ LIBSESSION_EXPORT session_pro_backend_get_payment_details_response session_pro_backend_get_payment_details_response_parse(const char* json, size_t json_len); -/// API: session_pro_backend/set_payment_refund_requested_request_build -/// -/// Builds the set-refund-requested request to POST, as a session_pro_backend_request (endpoint + -/// content_type + opaque data). Free it with `session_pro_backend_request_free`. On a key-size -/// error `success` is false and `error`/`error_count` describe it. -/// -/// Inputs: -/// - `master_privkey` / `master_privkey_len` -- Ed25519 master private key (32 or 64-byte -/// libsodium). -/// - `ts` -- Unix timestamp (seconds) for the request. -/// - `refund_requested_ts` -- Unix timestamp (seconds) to record the refund request at. -/// - `provider_code` -- null-terminated provider code -/// (SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*). -/// - `payment_id` / `payment_id_len` -- opaque provider payment identifier. -LIBSESSION_EXPORT -session_pro_backend_request session_pro_backend_set_payment_refund_requested_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) NON_NULL_ARG(1, 5, 6); - -/// API: session_pro_backend/set_payment_refund_requested_response_parse -/// -/// Parses a JSON string into a GetProPaymentsResponse struct. -/// The caller must free the response using -/// `session_pro_backend_set_payment_refund_requested_response_free`. -/// -/// Inputs: -/// - `json` -- JSON string to parse. -/// - `json_len` -- Length of the JSON string. -LIBSESSION_EXPORT session_pro_backend_set_payment_refund_requested_response -session_pro_backend_set_payment_refund_requested_response_parse(const char* json, size_t json_len); - #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 72dc9b9c9..6bf202215 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -80,7 +80,7 @@ static_assert(PUBKEY_X25519.size() == 32); constexpr std::string_view URL = "https://pro.session.codes"; /// Canonical payment-provider code strings — the value transmitted on the wire and folded into the -/// add-payment / set-refund signed messages (§3). Reference these rather than hardcoding the slug +/// add-payment signed message (§3). Reference these rather than hardcoding the slug /// so a sent code cannot drift from what libsession signs/parses. The C /// `SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*` symbols point at these. An unknown code is still /// valid on the wire and passes through as an opaque string. @@ -385,10 +385,6 @@ struct ProPaymentItem { /// sends it as a float of seconds. sys_ms revoked_at; - /// UNIX timestamp at which a refund request was requested for this payment. This is set to 0 - /// if no refund has been requested for this payment yet. - sys_seconds refund_requested_at; - /// Opaque payment identifier (the value passed at add-payment; multi-part providers fold their /// parts in per the backend-defined composite -- libsession does not interpret it). /// Confidential; store appropriately. @@ -443,12 +439,6 @@ struct ProStatusResponse : ResponseBase { /// `auto_renewing` is false. It can be used to calculate the subscription expiry timestamp by /// subtracting it from `expiry_at`. std::chrono::seconds grace_period_duration; - - /// UNIX timestamp at which a refund request was requested by this user. This timestamp comes - /// from the latest payment that the backend has deemed to be active for the user (e.g. the - /// payment associated with the `expiry_at`). This value is 0 if no refund has been - /// requested on the active payment. - sys_seconds refund_requested_at; }; struct PaymentDetailsResponse : ResponseBase { @@ -474,40 +464,4 @@ ProStatusResponse parse_pro_status(std::string_view json); /// Parse the reply to a `payment_details_request`. On failure `status` is set to an error state and /// `errors` is populated. PaymentDetailsResponse parse_payment_details(std::string_view json); - -/// Record a refund request against an existing Session Pro payment (endpoint -/// `set_payment_refund_requested`). `refund_sig` computes the master signature; `refund_request` -/// builds the whole request (signing internally) and returns the endpoint + JSON body. Both throw -/// on an incorrectly-sized key. -/// -/// Inputs: -/// - `master_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium master private key -/// - `unix_ts` -- Unix timestamp for the request -/// - `refund_requested_at` -- timestamp to record as when the refund was requested -/// - `provider_code` -- provider code string the payment is from (see -/// SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*) -/// - `payment_id` -- opaque payment identifier from the provider (hashed verbatim) -array_uc64 refund_sig( - std::span master_privkey, - sys_seconds unix_ts, - sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id); - -ProRequest refund_request( - std::span master_privkey, - sys_seconds unix_ts, - sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id); - -struct SetPaymentRefundRequestedResponse : ResponseBase { - /// True if a payment was found matching the given payment information and that the refund - /// request unix timestamp was set - bool updated; -}; - -/// Parse the reply to a `refund_request`. On failure `status` is set to an error state and `errors` -/// is populated. -SetPaymentRefundRequestedResponse parse_refund(std::string_view json); } // namespace session::pro_backend diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 7c3621020..d92d70a78 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -73,13 +73,11 @@ inline constexpr int COMMUNITY_OR_1O1_MSG_PADDING = 160; inline constexpr std::string_view GENERATE_PROOF_DOMAIN = "ProGenerateProof"; inline constexpr std::string_view BUILD_PROOF_DOMAIN = "ProProof_v0_____"; inline constexpr std::string_view ADD_PRO_PAYMENT_DOMAIN = "ProAddPayment___"; -inline constexpr std::string_view SET_PAYMENT_REFUND_REQUESTED_DOMAIN = "ProSetRefundReq_"; inline constexpr std::string_view GET_PRO_STATUS_DOMAIN = "ProGetProStatus_"; inline constexpr std::string_view GET_PAYMENT_DETAILS_DOMAIN = "ProGetPayDetails"; static_assert(GENERATE_PROOF_DOMAIN.size() == 16); static_assert(BUILD_PROOF_DOMAIN.size() == 16); static_assert(ADD_PRO_PAYMENT_DOMAIN.size() == 16); -static_assert(SET_PAYMENT_REFUND_REQUESTED_DOMAIN.size() == 16); static_assert(GET_PRO_STATUS_DOMAIN.size() == 16); static_assert(GET_PAYMENT_DETAILS_DOMAIN.size() == 16); diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 37d54cb8f..15c1907cc 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -134,7 +134,6 @@ namespace { constexpr char get_pro_status_endpoint[] = "get_pro_status"; constexpr char get_payment_details_endpoint[] = "get_payment_details"; constexpr char get_pro_revocations_endpoint[] = "get_pro_revocations"; - constexpr char set_refund_endpoint[] = "set_payment_refund_requested"; // Content type for the request payload (single master storage, shared with the C API's // session_pro_backend_request.content_type). The wire encoding is libsession's to change; the @@ -228,8 +227,6 @@ LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PAYMENT_DETAI get_payment_details_endpoint; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_REVOCATIONS_ENDPOINT = get_pro_revocations_endpoint; -LIBSESSION_EXPORT extern const char* const - SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT = set_refund_endpoint; // Backend base URL + Ed25519 pubkey: C symbols pointing at the single C++ definitions above. LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_URL = URL.data(); @@ -688,7 +685,6 @@ namespace { auto platform_refund_expiry_ts = json_require(obj, "platform_refund_expiry_ts", errs); auto revoked_ts = json_require_number(obj, "revoked_ts", errs); - auto refund_requested_ts = json_require(obj, "refund_requested_ts", errs); ProPaymentItem item = {}; item.status = std::move(status); @@ -704,8 +700,6 @@ namespace { item.platform_refund_expiry_at = std::chrono::sys_seconds(std::chrono::seconds(platform_refund_expiry_ts)); item.revoked_at = sys_ms_from_seconds(revoked_ts); - item.refund_requested_at = - std::chrono::sys_seconds(std::chrono::seconds(refund_requested_ts)); return item; } @@ -777,8 +771,6 @@ ProStatusResponse parse_pro_status(std::string_view json) { std::chrono::seconds(json_require(result_obj, "expiry_ts", errs))); result.grace_period_duration = std::chrono::seconds(json_require(result_obj, "grace_period_duration", errs)); - result.refund_requested_at = std::chrono::sys_seconds( - std::chrono::seconds(json_require(result_obj, "refund_requested_ts", errs))); // `latest_payment` is a single payment item, or null when the account has no payments. if (auto it = result_obj.find("latest_payment"); it == result_obj.end()) { @@ -846,102 +838,6 @@ PaymentDetailsResponse parse_payment_details(std::string_view json) { return result; } -namespace { - - // --- refund / set-payment-refund-requested (endpoint set_payment_refund_requested) --- - - std::vector refund_message( - std::span master_pubkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - // Must match the set-payment-refund-requested signed-request message in - // pro-wire-protocol.md §3.3 (+ §3.5 for payment_id), built per §1.1. - return session::pro::signed_message( - session::SET_PAYMENT_REFUND_REQUESTED_DOMAIN, - master_pubkey, - epoch_seconds(unix_ts), - epoch_seconds(refund_requested_at), - provider_code, - to_string_view(payment_id)); - } - - array_uc64 refund_sign( - const cleared_uc64& master, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto msg = refund_message( - pubkey_of(master), unix_ts, refund_requested_at, provider_code, payment_id); - array_uc64 sig = {}; - crypto_sign_ed25519_detached(sig.data(), nullptr, msg.data(), msg.size(), master.data()); - return sig; - } - - std::string refund_body( - std::span master_pubkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::string_view payment_id, - std::span master_sig) { - return nlohmann::json{ - {"master_pkey", oxenc::to_hex(master_pubkey)}, - {"ts", epoch_seconds(unix_ts)}, - {"refund_requested_ts", epoch_seconds(refund_requested_at)}, - {"payment_tx", {{"provider", provider_code}, {"payment_id", payment_id}}}, - {"master_sig", oxenc::to_hex(master_sig)}} - .dump(); - } - -} // namespace - -array_uc64 refund_sig( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return refund_sign(master, unix_ts, refund_requested_at, provider_code, payment_id); -} - -ProRequest refund_request( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto sig = refund_sign(master, unix_ts, refund_requested_at, provider_code, payment_id); - return {set_refund_endpoint, - application_json, - refund_body( - pubkey_of(master), - unix_ts, - refund_requested_at, - provider_code, - to_string_view(payment_id), - sig)}; -} - -SetPaymentRefundRequestedResponse parse_refund(std::string_view json) { - SetPaymentRefundRequestedResponse result = {}; - std::vector errs; - auto result_obj = read_envelope(json, result, errs); - if (!result || !errs.empty()) { - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; - } - - result.updated = json_require(result_obj, "updated", errs); - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; -} } // namespace session::pro_backend using namespace session::pro_backend; @@ -994,7 +890,6 @@ static session_pro_backend_pro_payment_item to_c(ProPaymentItem& src) { .grace_period_duration = src.grace_period_duration.count(), .platform_refund_expiry_ts = session::epoch_seconds(src.platform_refund_expiry_at), .revoked_ts = epoch_seconds_double(src.revoked_at), - .refund_requested_ts = session::epoch_seconds(src.refund_requested_at), .payment_id = src.payment_id.c_str(), }; } @@ -1010,7 +905,7 @@ struct GetPaymentDetailsCResponse : PaymentDetailsResponse { }; // Free any C response: delete the owned object (of concrete type `Owned`, as stored by the matching -// *_parse) behind header.internal_, then zero the struct. `Owned` is a proof/refund response or a +// *_parse) behind header.internal_, then zero the struct. `Owned` is a proof response or a // *CResponse holder -- all deriving from ResponseBase. template Owned, typename Response> static void c_free_response(Response* response) { @@ -1218,7 +1113,6 @@ session_pro_backend_get_pro_status_response_parse(const char* json, size_t json_ result.auto_renewing = owned->auto_renewing; result.expiry_ts = epoch_seconds(owned->expiry_at); result.grace_period_duration = owned->grace_period_duration.count(); - result.refund_requested_ts = epoch_seconds(owned->refund_requested_at); result.has_latest_payment = owned->latest_payment.has_value(); if (owned->latest_payment) result.latest_payment = to_c(*owned->latest_payment); @@ -1265,54 +1159,6 @@ session_pro_backend_get_payment_details_response_parse(const char* json, size_t return result; } -LIBSESSION_C_API session_pro_backend_request -session_pro_backend_set_payment_refund_requested_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(refund_request( - {master_privkey, master_privkey_len}, - session::as_sys_seconds(ts), - session::as_sys_seconds(refund_requested_ts), - provider_code, - {payment_id, payment_id_len})); - } catch (const std::exception& e) { - c_request_error(result, e); - } - return result; -} - -LIBSESSION_C_API session_pro_backend_set_payment_refund_requested_response -session_pro_backend_set_payment_refund_requested_response_parse(const char* json, size_t json_len) { - session_pro_backend_set_payment_refund_requested_response result = {}; - if (!json) { - result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; - result.header.error_code = C_INVALID_RESPONSE_CODE; - result.header.error = C_PARSE_ERROR_INVALID_ARGS; - return result; - } - - try { - auto* owned = new SetPaymentRefundRequestedResponse(parse_refund({json, json_len})); - result.header.internal_ = owned; - fill_c_header(result.header, *owned); - result.updated = owned->updated; - } catch (const std::exception&) { - delete static_cast(result.header.internal_); - result = {}; - result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; - result.header.error_code = C_INVALID_RESPONSE_CODE; - result.header.error = C_PARSE_ERROR_OUT_OF_MEMORY; - } - return result; -} - LIBSESSION_C_API void session_pro_backend_request_free(session_pro_backend_request* request) { if (request) { if (request->internal_) @@ -1340,8 +1186,3 @@ LIBSESSION_C_API void session_pro_backend_get_payment_details_response_free( session_pro_backend_get_payment_details_response* response) { c_free_response(response); } - -LIBSESSION_C_API void session_pro_backend_set_payment_refund_requested_response_free( - session_pro_backend_set_payment_refund_requested_response* response) { - c_free_response(response); -} diff --git a/tests/pro_backend/gen_kat.py b/tests/pro_backend/gen_kat.py index bb43784bd..3f6cf2acc 100644 --- a/tests/pro_backend/gen_kat.py +++ b/tests/pro_backend/gen_kat.py @@ -37,7 +37,6 @@ def main(): mpk, rpk = master.verify_key, rotating.verify_key ts = datetime.datetime.fromtimestamp(1700000000, datetime.timezone.utc) - refund = datetime.datetime.fromtimestamp(1700000123, datetime.timezone.utc) expiry = datetime.datetime.fromtimestamp(1704067200, datetime.timezone.utc) limit = 10 before_cursor = "0a1b2c3d4e5f" # opaque to the signed input; any fixed non-empty string works @@ -54,8 +53,6 @@ def main(): # a non-empty `before` appends the opaque cursor verbatim as the final field (no trailing sep). details_empty_msg = backend.make_get_payment_details_message(mpk, ts, limit, "") details_cursor_msg = backend.make_get_payment_details_message(mpk, ts, limit, before_cursor) - ref_msg = backend.signed_message( - backend.SET_PAYMENT_REFUND_REQUESTED_DOMAIN, mpk, ts, refund, provider.value, pid) prf_msg = backend.build_proof_message(rtag, rpk, expiry) print("master_pkey =", hx(mpk.encode())) @@ -64,7 +61,7 @@ def main(): print() for name, m in [("add", add_msg), ("gen", gen_msg), ("status", status_msg), ("details_empty", details_empty_msg), ("details_cursor", details_cursor_msg), - ("refund", ref_msg), ("proof", prf_msg)]: + ("proof", prf_msg)]: print(f"{name}_msg_hex = {hx(m)}") print(f" repr = {m!r}") print() diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index ca5275010..cec58a88d 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -368,7 +368,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { {"grace_period_duration", 1001}, {"platform_refund_expiry_ts", unix_ts + 1}, {"revoked_ts", unix_ts + 3600 + 0.75}, - {"refund_requested_ts", unix_ts + 3601}, {"payment_id", std::move(payment_id)}}; }; auto check_payment_item = [&](const session_pro_backend_pro_payment_item& item, @@ -385,7 +384,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { REQUIRE(item.grace_period_duration == 1001); REQUIRE(item.platform_refund_expiry_ts == unix_ts + 1); REQUIRE(item.revoked_ts == unix_ts + 3600 + 0.75); // 750ms preserved - REQUIRE(item.refund_requested_ts == unix_ts + 3601); REQUIRE(std::string_view(item.payment_id) == payment_id); }; @@ -398,7 +396,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { {"auto_renewing", true}, {"expiry_ts", unix_ts + 2}, {"grace_period_duration", 1000}, - {"refund_requested_ts", unix_ts + 3602}, {"latest_payment", make_payment_item( std::string(fake_payment_id.data(), fake_payment_id.size()))}}; @@ -421,7 +418,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { REQUIRE(result.auto_renewing == true); REQUIRE(result.grace_period_duration == 1000); REQUIRE(result.expiry_ts == unix_ts + 2); - REQUIRE(result.refund_requested_ts == unix_ts + 3602); REQUIRE(result.has_latest_payment); check_payment_item(result.latest_payment, fake_payment_id); } @@ -561,91 +557,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { REQUIRE(pay_response.header.internal_ == nullptr); } - SECTION("session_pro_backend_set_payment_refund_requested_request_build") { - auto result = session_pro_backend_set_payment_refund_requested_request_build( - master_privkey.data, - sizeof(master_privkey.data), - unix_ts, - unix_ts + 1, - provider_code, - payment_id, - payment_id_len); - { - scope_exit result_free{[&]() { session_pro_backend_request_free(&result); }}; - REQUIRE(result.success); - REQUIRE(result.data.size > 0); - - auto cpp = refund_request( - master_privkey.data, - session::as_sys_seconds(unix_ts), - session::as_sys_seconds(unix_ts + 1), - provider_code, - std::span(payment_id, payment_id_len)); - REQUIRE(std::string_view(result.endpoint) == cpp.endpoint); - REQUIRE(cpp.endpoint == SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT); - REQUIRE(span_u8_equals(result.data, cpp.data)); - } - REQUIRE(result.data.data == nullptr); - - // Invalid key size - result = session_pro_backend_set_payment_refund_requested_request_build( - master_privkey.data, - sizeof(master_privkey.data) - 1, - unix_ts, - unix_ts + 1, - provider_code, - payment_id, - payment_id_len); - REQUIRE(!result.success); - REQUIRE(result.error_count > 0); - } - - SECTION("session_pro_backend_set_payment_refund_requested_response_parse") { - nlohmann::json j; - j["status"] = "ok"; - j["result"]["updated"] = true; - std::string json = j.dump(); - - // Valid JSON - auto result = session_pro_backend_set_payment_refund_requested_response_parse( - json.data(), json.size()); - { - scope_exit result_free{[&]() { - session_pro_backend_set_payment_refund_requested_response_free(&result); - }}; - if (result.header.error) - INFO(result.header.error); - REQUIRE(result.header.status == SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.status == SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error == nullptr); - REQUIRE(result.updated); - } - - // After freeing - REQUIRE(result.header.internal_ == nullptr); - - // Invalid JSON - json = "{invalid}"; - { - result = session_pro_backend_set_payment_refund_requested_response_parse( - json.data(), json.size()); - scope_exit result_free{[&]() { - session_pro_backend_set_payment_refund_requested_response_free(&result); - }}; - REQUIRE(result.header.status != SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error_code != nullptr); - REQUIRE(result.header.error_code != nullptr); - } - - // After freeing - REQUIRE(result.header.internal_ == nullptr); - - // Null JSON - result = session_pro_backend_set_payment_refund_requested_response_parse(nullptr, 0); - REQUIRE(result.header.status != SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error_code != nullptr); - REQUIRE(result.header.error_code != nullptr); - } } } @@ -740,7 +651,7 @@ TEST_CASE("Pro Backend parse_plan_period (closed grammar)", "[pro_backend]") { // answer. // // Fixed inputs: keypairs from 32-byte seeds 0x01.. / 0x02.. (backend key 0x03..), ts=1700000000, -// refund_ts=1700000123, expiry=1704067200, count=10, provider="google_play", +// expiry=1704067200, count=10, provider="google_play", // payment_id="test-payment-id-123", revocation_tag=0x11 x32. TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { auto keypair = [](unsigned char seed_byte) { @@ -757,7 +668,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { (void)backend_sk; const auto ts = session::as_sys_seconds(1700000000); - const auto refund_ts = session::as_sys_seconds(1700000123); const auto expiry = session::as_sys_seconds(1704067200); const std::string provider = "google_play"; const std::string payment_id = "test-payment-id-123"; @@ -791,12 +701,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { const std::string details_cursor_msg_hex = "50726f47657450617944657461696c738a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" "01b40f6f5c3137303030303030303000313000306131623263336434653566"; - const std::string refund_msg_hex = - "50726f536574526566756e645265715f8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" - "01" - "b40f6f5c31373030303030303030003137303030303031323300676f6f676c655f706c617900746573742d" - "70" - "61796d656e742d69642d313233"; const std::string proof_msg_hex = "50726f50726f6f665f76305f5f5f5f5f111111111111111111111111111111111111111111111111111111" "1111" @@ -852,11 +756,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { CHECK(req.endpoint == SESSION_PRO_BACKEND_GET_PAYMENT_DETAILS_ENDPOINT); CHECK(sig_covers(req.data, "master_sig", details_cursor_msg_hex, master_pk)); } - SECTION("set_payment_refund_requested") { - auto req = refund_request(master_sk, ts, refund_ts, provider, pid_span()); - CHECK(req.endpoint == SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT); - CHECK(sig_covers(req.data, "master_sig", refund_msg_hex, master_pk)); - } SECTION("pro proof") { session::ProProof proof; proof.version = 0; @@ -1209,13 +1108,11 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { // Same subscription epoch -> the fresh proof shares the add-payment proof's revocation_tag. CHECK(gen.proof.revocation_tag == add.proof.revocation_tag); - // 2) get_pro_status: account ACTIVE, no refund yet, and the single latest payment - // is our redeemed one. + // 2) get_pro_status: account ACTIVE and the single latest payment is our redeemed one. ProStatusResponse status = parse_pro_status(send(onion, pro_status_request(master_sk, now))); INFO("get_pro_status " << status.error.value_or("")); REQUIRE(status.status == ResponseStatus::Ok); CHECK(status.user_status == "active"); - CHECK(status.refund_requested_at.time_since_epoch().count() == 0); // no refund requested yet REQUIRE(status.latest_payment.has_value()); CHECK(status.latest_payment->payment_id == payment_id); CHECK(status.latest_payment->payment_provider == provider); @@ -1264,32 +1161,6 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { CHECK(page2.items.empty()); // no more payments CHECK_FALSE(page2.next_cursor.has_value()); // end of data } - - // 4) set_payment_refund_requested. Apple-only at the HTTP layer: app_store -> updated + - // reflected - // in get_pro_status; google_play -> rejected (Google refunds are handled out-of-band). - ProRequest refund_req = refund_request( - master_sk, - now, - now, - provider, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - SetPaymentRefundRequestedResponse refund = parse_refund(send(onion, refund_req)); - if (provider == SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE) { - INFO("refund" << refund.error.value_or("")); - REQUIRE(refund.status == ResponseStatus::Ok); - CHECK(refund.updated); - // The soft "refund requested" marker is now reflected back in get_pro_status. - ProStatusResponse status2 = - parse_pro_status(send(onion, pro_status_request(master_sk, now))); - REQUIRE(status2.status == ResponseStatus::Ok); - CHECK(status2.refund_requested_at.time_since_epoch().count() != 0); - } else { - // The endpoint rejects non-App-Store providers. - CHECK_FALSE(refund.updated); - CHECK(refund.status != ResponseStatus::Ok); - } } // Revocation-list round-trip: seed + redeem a payment, seed a revocation for that generation, then From f127f9197ab0e0456e570fde061f43fc02acf59f Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:11:23 -0300 Subject: [PATCH 03/35] config: drop redundant condition in if-init statements An `if (auto x = f(); x)` where the init variable is itself the whole condition is just `if (auto x = f())`. Unify the UserProfile and group config sites on the shorter form. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/groups/info.cpp | 2 +- src/config/groups/keys.cpp | 2 +- src/config/user_profile.cpp | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index fb6b6e433..0ce34a036 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -236,7 +236,7 @@ LIBSESSION_C_API int groups_info_set_description(config_object* conf, const char /// the struct name, this is the group's profile pic). LIBSESSION_C_API user_profile_pic groups_info_get_pic(const config_object* conf) { user_profile_pic p; - if (auto pic = unbox(conf)->get_profile_pic(); pic) { + if (auto pic = unbox(conf)->get_profile_pic()) { copy_c_str(p.url, pic.url); std::memcpy(p.key, pic.key.data(), 32); } else { diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index e35b4d04d..5e1bdd13e 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1209,7 +1209,7 @@ std::pair> Keys::decrypt_message( // DecryptGroupMessage decrypt = {}; bool decrypt_success = false; - if (auto pending = pending_key(); pending) { + if (auto pending = pending_key()) { try { std::span> key_list = {&(*pending), 1}; decrypt = decrypt_group_message(key_list, *_sign_pk, ciphertext); diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 02fe2dfd2..273052abe 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -136,7 +136,7 @@ void UserProfile::set_blinded_msgreqs(std::optional value) { } std::optional UserProfile::get_blinded_msgreqs() const { - if (auto* M = data["M"].integer(); M) + if (auto* M = data["M"].integer()) return static_cast(*M); return std::nullopt; } @@ -213,7 +213,7 @@ void UserProfile::set_animated_avatar(bool enabled) { } std::optional UserProfile::get_pro_access_expiry() const { - if (auto* E = data["E"].integer(); E) + if (auto* E = data["E"].integer()) return std::chrono::sys_seconds{std::chrono::seconds{*E}}; return std::nullopt; } @@ -226,7 +226,7 @@ void UserProfile::set_pro_access_expiry(std::optional } std::optional UserProfile::get_refund_requested() const { - if (auto* R = data["R"].integer(); R) + if (auto* R = data["R"].integer()) return std::chrono::sys_seconds{std::chrono::seconds{*R}}; return std::nullopt; } @@ -277,7 +277,7 @@ LIBSESSION_C_API int user_profile_set_name(config_object* conf, const char* name LIBSESSION_C_API user_profile_pic user_profile_get_pic(const config_object* conf) { user_profile_pic p; - if (auto pic = unbox(conf)->get_profile_pic(); pic) { + if (auto pic = unbox(conf)->get_profile_pic()) { copy_c_str(p.url, pic.url); std::memcpy(p.key, pic.key.data(), 32); } else { @@ -350,7 +350,7 @@ LIBSESSION_C_API int64_t user_profile_get_profile_updated(config_object* conf) { } LIBSESSION_C_API bool user_profile_get_pro_config(const config_object* conf, pro_pro_config* pro) { - if (auto val = unbox(conf)->get_pro_config(); val) { + if (auto val = unbox(conf)->get_pro_config()) { 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)); From e95f57482be9dc09a53b43e8a33afcb57c300938 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:25:41 -0300 Subject: [PATCH 04/35] config: ignore refund-requested values older than a week get_refund_requested() now returns nullopt when the stored timestamp is more than a week in the past. This bounds the blast radius of the client-cleared design: if a device forgets to clear the flag on re-subscribe (or an old device never learns of it), the stale value self-expires instead of showing 'refund requested' forever. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 3 ++- include/session/config/user_profile.hpp | 8 ++++++-- src/config/user_profile.cpp | 9 +++++++-- tests/test_config_userprofile.cpp | 11 +++++++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 198fb6d53..24b35efaf 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -403,13 +403,14 @@ LIBSESSION_EXPORT void user_profile_set_pro_access_expiry( /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro /// subscription. This state is synced across the user's devices via config (not the Pro backend). +/// A stored value more than a week in the past is ignored (returns 0). /// /// Inputs: /// - `conf` -- [in] Pointer to the config object /// /// Outputs: /// - `int64_t` - the unix timestamp (seconds) at which a refund was requested, or 0 if no refund -/// has been requested. +/// has been requested (or the stored value is stale). LIBSESSION_EXPORT int64_t user_profile_get_refund_requested(const config_object* conf); /// API: user_profile/user_profile_set_refund_requested diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index c963c21a9..ef9812962 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -32,7 +32,8 @@ using namespace std::literals; /// the pro proof expiry which can be sooner. /// R - unix timestamp (seconds) at which the user requested a refund of their current Session Pro /// subscription, for cross-device sync of the refund-requested state. Omitted when no refund -/// has been requested; cleared by the client when a new subscription begins. +/// has been requested; cleared by the client when a new subscription begins. Values more than +/// a week in the past are ignored on read (treated as if unset). /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -338,11 +339,14 @@ class UserProfile : public ConfigBase { /// subscription, if any. This is synced across the user's devices so that a refund requested on /// one device is reflected on the others; it does not go through the Pro backend. /// + /// A stored value more than a week in the past is ignored (returns nullopt), so that a + /// refund-requested flag some client neglected to clear cannot linger indefinitely. + /// /// Inputs: None /// /// Outputs: /// - `std::optional` - the unix timestamp (seconds) at which a refund was - /// requested, or nullopt if no refund has been requested. + /// requested, or nullopt if no refund has been requested (or the stored value is stale). std::optional get_refund_requested() const; /// API: user_profile/UserProfile::set_refund_requested diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 273052abe..27f331d2f 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -226,8 +226,13 @@ void UserProfile::set_pro_access_expiry(std::optional } std::optional UserProfile::get_refund_requested() const { - if (auto* R = data["R"].integer()) - return std::chrono::sys_seconds{std::chrono::seconds{*R}}; + if (auto* R = data["R"].integer()) { + std::chrono::sys_seconds when{std::chrono::seconds{*R}}; + // Ignore stale values: a request more than a week old is treated as absent, so a flag some + // client forgot to clear cannot linger indefinitely across the account's devices. + if (when >= ts_now() - std::chrono::weeks{1}) + return when; + } return std::nullopt; } diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 03d746dbc..cc759550b 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -655,11 +655,14 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // Refund-requested flag (synced via config, not the Pro backend) CHECK_FALSE(profile.get_refund_requested().has_value()); + const auto now = std::chrono::floor(std::chrono::system_clock::now()); + + // A recent request is returned as-is, and stamps the profile-updated timestamp so it + // time-orders across devices. UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); - auto refund_at = std::chrono::sys_seconds{std::chrono::seconds{789}}; + auto refund_at = now - std::chrono::hours{1}; profile.set_refund_requested(refund_at); CHECK(profile.get_refund_requested() == refund_at); - // Setting stamps the profile-updated timestamp so it time-orders across devices. CHECK(profile.get_profile_updated().time_since_epoch().count() != 456); { @@ -668,6 +671,10 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK(profile2.get_refund_requested() == refund_at); } + // A stored value more than a week old is ignored on read (treated as absent). + profile.set_refund_requested(now - std::chrono::weeks{2}); + CHECK_FALSE(profile.get_refund_requested().has_value()); + // Clearing removes it entirely. profile.set_refund_requested(std::nullopt); CHECK_FALSE(profile.get_refund_requested().has_value()); From e28af599b42216eec5bb2fd294c40f3906eb25d4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 22:46:17 -0300 Subject: [PATCH 05/35] pro_backend: remove /add_pro_payment; redemption is now implicit The backend deleted the add_pro_payment signed request: the store now notifies the backend of a purchase out-of-band and any master-signed request binds the account's unbound payments before answering, so there is no client-submitted redemption step. Client-side removals: - ADD_PRO_PAYMENT_DOMAIN signing domain + the add_pro_payment endpoint; - add_payment_sigs / add_payment_request (+ message/sign/body helpers), AddProPaymentResponse, parse_add_payment, and the C request_build; - client-side payment_id composite construction -- payment_id is no longer a signed input, only an opaque output on payment items; - the corresponding KAT vector, C-API test, and add-payment live steps (reworked to seed -> generate_pro_proof implicit binding). The shared proof-response path (generate_pro_proof) is unchanged; its C parse/free now allocate GenerateProProofResponse directly. Spec-section citations remapped for the renumbered wire doc (get_pro_status 3.4->3.2, get_payment_details 3.4->3.3; add-payment 3.2 removed). Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.h | 39 ++----- include/session/pro_backend.hpp | 79 ++++--------- include/session/session_protocol.hpp | 2 - src/pro_backend.cpp | 129 +-------------------- src/pro_message.hpp | 4 +- tests/test_pro_backend.cpp | 160 +++++++-------------------- 6 files changed, 73 insertions(+), 340 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 036277a18..31f7accd6 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -11,12 +11,12 @@ extern "C" { #endif -/// Canonical payment-provider `code` strings — the value transmitted on the wire and folded into -/// the add-payment signed message (§3). Reference these rather than hardcoding the -/// slug so a sent code cannot drift from what libsession signs/parses. Unknown codes (e.g. a future -/// provider) are still valid on the wire and pass through as opaque strings. Each points at the -/// C++ `session::pro_backend::PAYMENT_PROVIDER_*` value (defined there — the primary; C -/// references). +/// Canonical payment-provider `code` strings — the `payment_provider` value the backend reports on a +/// payment item (§5.2) and the slug clients key their store/display logic on. Reference these rather +/// than hardcoding the slug so client code cannot drift from what libsession parses. Unknown codes +/// (e.g. a future provider) are still valid on the wire and pass through as opaque strings. Each +/// points at the C++ `session::pro_backend::PAYMENT_PROVIDER_*` value (defined there — the primary; +/// C references). LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_GOOGLE_PLAY; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_RANGEPROOF; @@ -26,7 +26,6 @@ LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_ /// The C++ `*_request()` helpers return the matching endpoint alongside the body; C callers read /// the constant beside each request's build function. Each is a pointer to the single master string /// owned in the C++ implementation (defined once, shared by the C and C++ sides). -LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GENERATE_PRO_PROOF_ENDPOINT; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_STATUS_ENDPOINT; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PAYMENT_DETAILS_ENDPOINT; @@ -208,8 +207,7 @@ typedef struct session_pro_backend_pro_payment_item { /// Provider revocation instant, fractional UNIX seconds (millisecond-precise; 0 if not revoked) double revoked_ts; - /// Opaque payment identifier (the value passed at add-payment; multi-part providers fold their - /// parts in per the backend-defined composite -- libsession does not interpret it). + /// Opaque, backend-owned payment identifier (§5.2): store and compare for equality, never parse. /// NUL-terminated; points into the response's `internal_`. const char* payment_id; } session_pro_backend_pro_payment_item; @@ -258,29 +256,6 @@ LIBSESSION_EXPORT void session_pro_backend_get_payment_details_response_free( session_pro_backend_get_payment_details_response* response); -/// API: session_pro_backend/add_pro_payment_request_build -/// -/// Builds the add-payment request to POST, as a session_pro_backend_request (endpoint + -/// content_type + opaque data). Free it with `session_pro_backend_request_free`. On a key-size -/// error `success` is false and `error`/`error_count` describe it. -/// -/// Inputs: -/// - `master_privkey` / `master_privkey_len` -- Ed25519 master private key (32 or 64-byte -/// libsodium). -/// - `rotating_privkey` / `rotating_privkey_len` -- Ed25519 rotating private key (32 or 64-byte). -/// - `provider_code` -- null-terminated provider code -/// (SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*). -/// - `payment_id` / `payment_id_len` -- opaque provider payment identifier. -LIBSESSION_EXPORT -session_pro_backend_request session_pro_backend_add_pro_payment_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - const uint8_t* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) NON_NULL_ARG(1, 3, 5, 6); - /// API: session_pro_backend/generate_pro_proof_request_build /// /// Builds the generate-proof request to POST, as a session_pro_backend_request (endpoint + diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 6bf202215..afe0bb39d 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -17,17 +17,18 @@ /// /// The high level summary of the functionality in this file. Clients can: /// -/// 1. Build a request with `add_payment_request(...)` from a Session Pro payment and submit its -/// `{endpoint, content_type, data}` to the backend to register the specified Ed25519 keys. +/// 1. Obtain a Session Pro proof with `pro_proof_request(...)` and submit its +/// `{endpoint, content_type, data}` to the backend, which authorises the specified rotating +/// Ed25519 key. Redemption is implicit -- there is no client-submitted "add payment" step: the +/// store notifies the backend of a purchase out-of-band and any master-signed request binds the +/// account's unbound payments before answering (pro-wire-protocol.md §3). After a purchase the +/// client just requests a proof and, until the store notification has reached the backend, gets +/// a not-yet-entitled answer and retries. /// -/// Parse the server's reply with `parse_add_payment(...)`. Clients should validate the response +/// Parse the server's reply with `parse_pro_proof(...)`. Clients should validate the response /// and update their `UserProfile` by constructing a `ProConfig` with the `proof` from the /// response and filling in the relevant rotating private key that the proof was authorised for. /// -/// The server will only respond successfully if it can also independently verify the purchase -/// otherwise an error is returned and can be read from the `ResponseBase` after parsing the -/// raw response. -/// /// 2. Attach the `ProProof` constructed from (1) into their messages. Libsession has helper /// functions to embed the proof into their messages via the helper functions in the Session /// Protocol header file. This is done by assigning the `ProProof` into the @@ -79,9 +80,9 @@ static_assert(PUBKEY_X25519.size() == 32); /// point at a different dev/test server if it chooses. constexpr std::string_view URL = "https://pro.session.codes"; -/// Canonical payment-provider code strings — the value transmitted on the wire and folded into the -/// add-payment signed message (§3). Reference these rather than hardcoding the slug -/// so a sent code cannot drift from what libsession signs/parses. The C +/// Canonical payment-provider code strings — the `payment_provider` value the backend reports on a +/// payment item (§5.2) and the slug clients key their store/display logic on. Reference these rather +/// than hardcoding the slug so client code cannot drift from what libsession parses. The C /// `SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*` symbols point at these. An unknown code is still /// valid on the wire and passes through as an opaque string. constexpr std::string_view PAYMENT_PROVIDER_GOOGLE_PLAY = "google_play"; @@ -104,7 +105,7 @@ struct ResponseBase { ResponseStatus status = ResponseStatus::Ok; /// On non-Ok, a stable machine-readable slug identifying the outcome (spec §5.1), e.g. - /// "unknown_payment", "expired", "stale_request". std::nullopt on success. Opaque and + /// "not_subscribed", "subscription_expired", "stale_request". std::nullopt on success. Opaque and /// forward-compatible: map known slugs to localized text; for an unrecognized one fall back to /// `error` / the `status` category. (libsession synthesizes "invalid_response" if it cannot /// parse the backend's reply at all.) @@ -156,7 +157,7 @@ std::span visible_platforms(); /// returned by the `*_request()` helpers below. Callers relay `data` verbatim under `content_type` /// and never inspect or assume its format -- libsession owns the wire encoding. struct ProRequest { - /// Endpoint path relative to the backend base URL, e.g. "add_pro_payment". As returned from a + /// Endpoint path relative to the backend base URL, e.g. "generate_pro_proof". As returned from a /// `*_request()` function this points at a static, null-terminated string, so using /// `endpoint.data()` as a C string is valid. std::string_view endpoint; @@ -172,58 +173,23 @@ struct ProRequest { std::string data; }; -/// Register a new Session Pro payment with the backend (endpoint `add_pro_payment`). The payment is -/// registered under the master key and authorises the rotating key to use the resulting proof, so -/// that proof can be attached to messages signed by the rotating key to entitle them to Pro. -/// -/// `add_payment_sigs` computes just the master+rotating signatures over the request; -/// `add_payment_request` builds the whole request (computing the signatures internally) and returns -/// the endpoint + JSON body. Both throw if a key is not a 32-byte Ed25519 seed or 64-byte libsodium -/// key. -/// -/// Inputs: -/// - `master_privkey` / `rotating_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium private key -/// - `provider_code` -- provider code string the payment is coming from (see -/// SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*) -/// - `payment_id` -- opaque payment identifier from the provider (multi-part providers fold their -/// parts into this one value per a backend-defined composite; hashed verbatim) -MasterRotatingSignatures add_payment_sigs( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id); - -ProRequest add_payment_request( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id); - -/// Common base for the responses that carry a freshly-issued Session Pro proof: both add-payment -/// and generate-proof reply with exactly a proof. `AddProPaymentResponse` and -/// `GenerateProProofResponse` are distinct types (each parsed by its own free function below) that -/// share `proof` today but can diverge independently. `proof` is the raw parse result, convertible -/// to a config::ProProof. +/// Common base for the responses that carry a freshly-issued Session Pro proof. `proof` is the raw +/// parse result, convertible to a config::ProProof. struct ProProofResponse : ResponseBase { ProProof proof; }; -/// Response to `add_payment_request` (endpoint `add_pro_payment`). On failure the reason(s) are in -/// `errors` (the backend sends a human-readable message, including for already-redeemed / -/// unknown-payment); check `success()`. -struct AddProPaymentResponse : ProProofResponse {}; - /// Response to `pro_proof_request` (endpoint `generate_pro_proof`). struct GenerateProProofResponse : ProProofResponse {}; -/// Parse the reply to an add-payment / generate-proof request. On failure `status` is set to an -/// error state and `errors` is populated; on success `proof` holds the issued proof. -AddProPaymentResponse parse_add_payment(std::string_view json); +/// Parse the reply to a generate-proof request. On failure `status` is set to an error state and +/// `errors` is populated; on success `proof` holds the issued proof. GenerateProProofResponse parse_pro_proof(std::string_view json); /// Request a new Session Pro proof from the backend (endpoint `generate_pro_proof`). The master key -/// must already have a prior, still-active payment registered; this pairs a (new) rotating key to a -/// freshly-issued proof. +/// must have a still-active payment on record -- redemption is implicit, so any master-signed +/// request (this one included) binds the account's unbound payments before answering; there is no +/// separate "add payment" step. This pairs a (new) rotating key to a freshly-issued proof. /// /// `pro_proof_sigs` computes the master+rotating signatures; `pro_proof_request` builds the whole /// request (signing internally) and returns the endpoint + JSON body. Both throw on an @@ -385,9 +351,8 @@ struct ProPaymentItem { /// sends it as a float of seconds. sys_ms revoked_at; - /// Opaque payment identifier (the value passed at add-payment; multi-part providers fold their - /// parts in per the backend-defined composite -- libsession does not interpret it). - /// Confidential; store appropriately. + /// Opaque, backend-owned payment identifier (§5.2). Store and compare it for equality, but never + /// parse it -- libsession does not interpret it. Confidential; store appropriately. std::string payment_id; }; diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index d92d70a78..cda6d0503 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -72,12 +72,10 @@ inline constexpr int COMMUNITY_OR_1O1_MSG_PADDING = 160; // bytes (formerly the BLAKE2b personalisation, back when messages were pre-hashed). inline constexpr std::string_view GENERATE_PROOF_DOMAIN = "ProGenerateProof"; inline constexpr std::string_view BUILD_PROOF_DOMAIN = "ProProof_v0_____"; -inline constexpr std::string_view ADD_PRO_PAYMENT_DOMAIN = "ProAddPayment___"; inline constexpr std::string_view GET_PRO_STATUS_DOMAIN = "ProGetProStatus_"; inline constexpr std::string_view GET_PAYMENT_DETAILS_DOMAIN = "ProGetPayDetails"; static_assert(GENERATE_PROOF_DOMAIN.size() == 16); static_assert(BUILD_PROOF_DOMAIN.size() == 16); -static_assert(ADD_PRO_PAYMENT_DOMAIN.size() == 16); static_assert(GET_PRO_STATUS_DOMAIN.size() == 16); static_assert(GET_PAYMENT_DETAILS_DOMAIN.size() == 16); diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 15c1907cc..82107f893 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -129,7 +129,6 @@ namespace { // Endpoint paths (single master storage). Both the C `SESSION_PRO_BACKEND_*_ENDPOINT` symbols // and the C++ `*_request()` return values point at these — the path is defined exactly once. - constexpr char add_payment_endpoint[] = "add_pro_payment"; constexpr char generate_proof_endpoint[] = "generate_pro_proof"; constexpr char get_pro_status_endpoint[] = "get_pro_status"; constexpr char get_payment_details_endpoint[] = "get_payment_details"; @@ -162,63 +161,11 @@ namespace { priv64.data() + crypto_sign_ed25519_SEEDBYTES, crypto_sign_ed25519_PUBLICKEYBYTES); } - // --- add-payment (endpoint add_pro_payment) --- - - std::vector add_payment_message( - std::span master_pubkey, - std::span rotating_pubkey, - std::string_view provider_code, - std::span payment_id) { - // Must match the add-payment signed-request message in pro-wire-protocol.md §3.2 - // (+ §3.5), built per §1.1. - return session::pro::signed_message( - session::ADD_PRO_PAYMENT_DOMAIN, - master_pubkey, - rotating_pubkey, - provider_code, - to_string_view(payment_id)); - } - - MasterRotatingSignatures add_payment_sign( - const cleared_uc64& master, - const cleared_uc64& rotating, - std::string_view provider_code, - std::span payment_id) { - auto msg = add_payment_message( - pubkey_of(master), pubkey_of(rotating), provider_code, payment_id); - MasterRotatingSignatures result = {}; - crypto_sign_ed25519_detached( - result.master_sig.data(), nullptr, msg.data(), msg.size(), master.data()); - crypto_sign_ed25519_detached( - result.rotating_sig.data(), nullptr, msg.data(), msg.size(), rotating.data()); - return result; - } - - // Serialise an add-payment request body from already-computed fields (shared by the C++ - // add_payment_request and the C ..._request_build wrapper). - std::string add_payment_body( - std::span master_pubkey, - std::span rotating_pubkey, - std::string_view provider_code, - std::string_view payment_id, - std::span master_sig, - std::span rotating_sig) { - return nlohmann::json{ - {"master_pkey", oxenc::to_hex(master_pubkey)}, - {"rotating_pkey", oxenc::to_hex(rotating_pubkey)}, - {"payment_tx", {{"provider", provider_code}, {"payment_id", payment_id}}}, - {"master_sig", oxenc::to_hex(master_sig)}, - {"rotating_sig", oxenc::to_hex(rotating_sig)}} - .dump(); - } - } // namespace // C endpoint symbols: each points at the single master endpoint string defined above, so the C API // and the C++ `ProRequest::endpoint` values are backed by one definition. extern "C" { -LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT = - add_payment_endpoint; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GENERATE_PRO_PROOF_ENDPOINT = generate_proof_endpoint; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_STATUS_ENDPOINT = @@ -343,35 +290,6 @@ LIBSESSION_C_API const char* const* session_pro_backend_visible_platforms(size_t return codes.data(); } -MasterRotatingSignatures add_payment_sigs( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - return add_payment_sign(master, rotating, provider_code, payment_id); -} - -ProRequest add_payment_request( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - auto sigs = add_payment_sign(master, rotating, provider_code, payment_id); - return {add_payment_endpoint, - application_json, - add_payment_body( - pubkey_of(master), - pubkey_of(rotating), - provider_code, - to_string_view(payment_id), - sigs.master_sig, - sigs.rotating_sig)}; -} - namespace { // libsession-side slug when the backend's reply can't be parsed at all (malformed envelope, // missing/unrecognized status). Distinct from any backend error_code slug. @@ -427,21 +345,6 @@ namespace { } } // namespace -AddProPaymentResponse parse_add_payment(std::string_view json) { - AddProPaymentResponse result = {}; - std::vector errs; - auto result_obj = read_envelope(json, result, errs); - if (!result || !errs.empty()) { - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; - } - fill_proof(result_obj, result, errs); - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; -} - GenerateProProofResponse parse_pro_proof(std::string_view json) { GenerateProProofResponse result = {}; std::vector errs; @@ -593,7 +496,7 @@ namespace { std::vector pro_status_message( std::span master_pubkey, std::chrono::sys_seconds unix_ts) { - // Must match the get-pro-status signed-request message in pro-wire-protocol.md §3.4, built + // Must match the get-pro-status signed-request message in pro-wire-protocol.md §3.2, built // per §1.1. return session::pro::signed_message( session::GET_PRO_STATUS_DOMAIN, master_pubkey, epoch_seconds(unix_ts)); @@ -624,7 +527,7 @@ namespace { std::chrono::sys_seconds unix_ts, uint32_t limit, std::string_view before) { - // Must match the get-payment-details signed-request message in pro-wire-protocol.md §3.4, + // Must match the get-payment-details signed-request message in pro-wire-protocol.md §3.3, // built per §1.1. `before` is the opaque pagination cursor (§5.3), empty for the newest // page. return session::pro::signed_message( @@ -940,27 +843,6 @@ static void c_request_error(session_pro_backend_request& result, const std::exce error.data()); } -LIBSESSION_C_API session_pro_backend_request session_pro_backend_add_pro_payment_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - const uint8_t* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(add_payment_request( - {master_privkey, master_privkey_len}, - {rotating_privkey, rotating_privkey_len}, - provider_code, - {payment_id, payment_id_len})); - } catch (const std::exception& e) { - c_request_error(result, e); - } - return result; -} - LIBSESSION_C_API session_pro_backend_request session_pro_backend_generate_pro_proof_request_build( const uint8_t* master_privkey, size_t master_privkey_len, @@ -1033,8 +915,7 @@ session_pro_backend_pro_proof_response_parse(const char* json, size_t json_len) } try { - // add-payment and generate-proof share the proof-response shape; either parser works. - auto* owned = new AddProPaymentResponse(parse_add_payment({json, json_len})); + auto* owned = new GenerateProProofResponse(parse_pro_proof({json, json_len})); result.header.internal_ = owned; fill_c_header(result.header, *owned); @@ -1050,7 +931,7 @@ session_pro_backend_pro_proof_response_parse(const char* json, size_t json_len) p.rotating_pubkey.size()); std::memcpy(result.proof.sig.data, p.sig.data(), p.sig.size()); } catch (const std::exception&) { - delete static_cast(result.header.internal_); + delete static_cast(result.header.internal_); result = {}; result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; result.header.error_code = C_INVALID_RESPONSE_CODE; @@ -1169,7 +1050,7 @@ LIBSESSION_C_API void session_pro_backend_request_free(session_pro_backend_reque LIBSESSION_C_API void session_pro_backend_pro_proof_response_free( session_pro_backend_pro_proof_response* response) { - c_free_response(response); + c_free_response(response); } LIBSESSION_C_API void session_pro_backend_get_pro_revocations_response_free( diff --git a/src/pro_message.hpp b/src/pro_message.hpp index 48b4403cb..6254488ec 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -17,8 +17,8 @@ namespace session::pro { /// and self-delimiting; /// - **integer**: canonical decimal ASCII (`std::to_chars`, base 10, locale-independent — never a /// locale-aware formatter); -/// - **string_view** (`provider_code`, and the opaque variable-length `payment_id` via -/// `to_string_view`): its bytes verbatim. +/// - **string_view** (e.g. the opaque `before` pagination cursor of get_payment_details): its bytes +/// verbatim. /// /// A single NUL byte is inserted between two *adjacent* variable-length fields (integer/string); /// raw fields need no separator, and the domain prefix (also raw) never precedes one. The message diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index cec58a88d..84c8caaa7 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -28,67 +28,11 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { crypto_sign_ed25519_keypair(rotating_pubkey.data, rotating_privkey.data); { - std::array fake_google_payment_token; - randombytes_buf(fake_google_payment_token.data(), fake_google_payment_token.size()); - std::array fake_google_order_id; - randombytes_buf(fake_google_order_id.data(), fake_google_order_id.size()); - // Google's composite payment_id is "|"; libsession treats the - // whole thing as one opaque value. - std::string fake_payment_id = "DEV." + oxenc::to_hex(fake_google_payment_token) + "|DEV." + - oxenc::to_hex(fake_google_order_id); - - const char* provider_code = SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_GOOGLE_PLAY; - auto payment_id = reinterpret_cast(fake_payment_id.data()); - size_t payment_id_len = fake_payment_id.size(); - int64_t unix_ts = 1698765432; // Arbitrary timestamp (unix epoch seconds) - SECTION("session_pro_backend_add_pro_payment_request_build") { - auto result = session_pro_backend_add_pro_payment_request_build( - master_privkey.data, - sizeof(master_privkey.data), - rotating_privkey.data, - sizeof(rotating_privkey.data), - provider_code, - payment_id, - payment_id_len); - { - scope_exit result_free{[&]() { session_pro_backend_request_free(&result); }}; - INFO(result.error); - REQUIRE(result.success); - REQUIRE(result.data.data != nullptr); - REQUIRE(result.data.size > 0); - - // Matches the C++ free-function implementation end to end. - auto cpp = add_payment_request( - master_privkey.data, - rotating_privkey.data, - provider_code, - std::span(payment_id, payment_id_len)); - REQUIRE(std::string_view(result.endpoint) == cpp.endpoint); - REQUIRE(cpp.endpoint == SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT); - REQUIRE(std::string_view(result.content_type) == cpp.content_type); - REQUIRE(cpp.content_type == "application/json"); - REQUIRE(span_u8_equals(result.data, cpp.data)); - } - - // After freeing - REQUIRE(result.data.data == nullptr); - REQUIRE(result.data.size == 0); - - // Invalid key size -> failure, no allocation - result = session_pro_backend_add_pro_payment_request_build( - master_privkey.data, - sizeof(master_privkey.data) - 1, - rotating_privkey.data, - sizeof(rotating_privkey.data), - provider_code, - payment_id, - payment_id_len); - REQUIRE(!result.success); - REQUIRE(result.error_count > 0); - REQUIRE(result.data.data == nullptr); - } + // Opaque, backend-owned payment identifier as it appears on a payment item (§5.2): the + // client stores and compares it for equality but never parses it. + std::string fake_payment_id = "DEV.opaque-payment-id-0123456789"; SECTION("session_pro_backend_generate_pro_proof_request_build") { auto result = session_pro_backend_generate_pro_proof_request_build( @@ -236,7 +180,7 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { // Here we also create the CPP version, we will run the conversion functions into // pro proofs (both C and CPP variants) and then compare the two structures to make // sure the conversion functions are sound. - auto result_cpp = parse_add_payment(json); + auto result_cpp = parse_pro_proof(json); // Validate C and CPP variants REQUIRE(result.proof.version == result_cpp.proof.version); @@ -651,8 +595,7 @@ TEST_CASE("Pro Backend parse_plan_period (closed grammar)", "[pro_backend]") { // answer. // // Fixed inputs: keypairs from 32-byte seeds 0x01.. / 0x02.. (backend key 0x03..), ts=1700000000, -// expiry=1704067200, count=10, provider="google_play", -// payment_id="test-payment-id-123", revocation_tag=0x11 x32. +// expiry=1704067200, count=10, revocation_tag=0x11 x32. TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { auto keypair = [](unsigned char seed_byte) { std::array pk{}; @@ -669,29 +612,17 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { const auto ts = session::as_sys_seconds(1700000000); const auto expiry = session::as_sys_seconds(1704067200); - const std::string provider = "google_play"; - const std::string payment_id = "test-payment-id-123"; - auto pid_span = [&]() { - return std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}; - }; // Expected signed-message bytes (hex), each hand-verified against the spec field layout. - const std::string add_msg_hex = - "50726f4164645061796d656e745f5f5f8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" - "01" - "b40f6f5c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394676f6f676c655f" - "70" - "6c617900746573742d7061796d656e742d69642d313233"; const std::string gen_msg_hex = "50726f47656e657261746550726f6f668a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" "01" "b40f6f5c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39431373030303030" "303030"; - // get_pro_details is split (§3.4) into get_pro_status (master + ts) and paginated - // get_payment_details (master + ts + limit + before). Two details vectors pin the `before` - // framing: empty `before` (newest page) ends in the adjacency \0; a non-empty cursor is the - // opaque final field (no trailing separator). + // The two read requests: get_pro_status (§3.2; master + ts) and paginated get_payment_details + // (§3.3; master + ts + limit + before). Two details vectors pin the `before` framing: empty + // `before` (newest page) ends in the adjacency \0; a non-empty cursor is the opaque final field + // (no trailing separator). const std::string status_msg_hex = "50726f47657450726f5374617475735f8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" "01b40f6f5c31373030303030303030"; @@ -725,16 +656,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { pubkey.data()) == 0; }; - SECTION("add_pro_payment") { - auto req = add_payment_request(master_sk, rotating_sk, provider, pid_span()); - CHECK(req.endpoint == SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT); - CHECK(sig_covers(req.data, "master_sig", add_msg_hex, master_pk)); - CHECK(sig_covers(req.data, "rotating_sig", add_msg_hex, rotating_pk)); - // Determinism pin: the exact master signature is itself a known answer. - CHECK(nlohmann::json::parse(req.data).at("master_sig").get() == - "746861ae1681d996e837d603dbd21c06c9544c5fc536eb4d3e3cad4f13692b79" - "0ef2b1282b729aa42f537d560b3b97b231b7f940be7021c0e16f894817a57609"); - } SECTION("generate_pro_proof") { auto req = pro_proof_request(master_sk, rotating_sk, ts); CHECK(req.endpoint == SESSION_PRO_BACKEND_GENERATE_PRO_PROOF_ENDPOINT); @@ -1066,7 +987,8 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { ? master_hex.substr(0, 16) + "|GPA." + master_hex.substr(16, 12) : "20000000" + master_hex.substr(0, 10); - // Seed the witnessed-unredeemed payment; the client's real add_pro_payment (below) redeems it. + // Seed the witnessed-unredeemed payment; a subsequent master-signed request (generate_pro_proof + // below) binds/redeems it implicitly -- there is no client-submitted add-payment step. run_seed_helper( {"payment", "--provider", @@ -1080,33 +1002,27 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { auto now = session::sysclock_now_s(); - // 1) add_pro_payment -> signed proof. - ProRequest add_req = add_payment_request( - master_sk, - rotating_sk, - provider, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - AddProPaymentResponse add = parse_add_payment(send(onion, add_req)); - INFO("add_pro_payment" << add.error.value_or("")); - REQUIRE(add.status == ResponseStatus::Ok); - CHECK(add.status == ResponseStatus::Ok); - CHECK(add.proof.verify_signature(backend_pubkey)); - CHECK(std::memcmp(add.proof.rotating_pubkey.data(), rotating_pk.data(), 32) == 0); - - // 1b) generate_pro_proof: pair a NEW rotating key to the same subscription -> a fresh proof. - std::array rotating2_pk{}; - std::array rotating2_sk{}; - crypto_sign_ed25519_keypair(rotating2_pk.data(), rotating2_sk.data()); - ProRequest gen_req = pro_proof_request(master_sk, rotating2_sk, now); + // 1) generate_pro_proof: redemption is implicit, so this master-signed request binds the seeded + // payment before answering and returns a signed proof for the paired rotating key. + ProRequest gen_req = pro_proof_request(master_sk, rotating_sk, now); GenerateProProofResponse gen = parse_pro_proof(send(onion, gen_req)); - INFO("generate_pro_proof" << gen.error.value_or("")); + INFO("generate_pro_proof " << gen.error.value_or("")); REQUIRE(gen.status == ResponseStatus::Ok); - CHECK(gen.status == ResponseStatus::Ok); CHECK(gen.proof.verify_signature(backend_pubkey)); - CHECK(std::memcmp(gen.proof.rotating_pubkey.data(), rotating2_pk.data(), 32) == 0); - // Same subscription epoch -> the fresh proof shares the add-payment proof's revocation_tag. - CHECK(gen.proof.revocation_tag == add.proof.revocation_tag); + CHECK(std::memcmp(gen.proof.rotating_pubkey.data(), rotating_pk.data(), 32) == 0); + + // 1b) generate_pro_proof again with a NEW rotating key -> a fresh proof for the same + // subscription, so it shares the first proof's revocation_tag (same subscription epoch). + std::array rotating2_pk{}; + std::array rotating2_sk{}; + crypto_sign_ed25519_keypair(rotating2_pk.data(), rotating2_sk.data()); + ProRequest gen2_req = pro_proof_request(master_sk, rotating2_sk, now); + GenerateProProofResponse gen2 = parse_pro_proof(send(onion, gen2_req)); + INFO("generate_pro_proof(2) " << gen2.error.value_or("")); + REQUIRE(gen2.status == ResponseStatus::Ok); + CHECK(gen2.proof.verify_signature(backend_pubkey)); + CHECK(std::memcmp(gen2.proof.rotating_pubkey.data(), rotating2_pk.data(), 32) == 0); + CHECK(gen2.proof.revocation_tag == gen.proof.revocation_tag); // 2) get_pro_status: account ACTIVE and the single latest payment is our redeemed one. ProStatusResponse status = parse_pro_status(send(onion, pro_status_request(master_sk, now))); @@ -1196,15 +1112,13 @@ TEST_CASE("Pro backend live get_pro_revocations", "[pro_backend][pro_live]") { master_hex, "--plan", "1m"}); - ProRequest add_req = add_payment_request( - master_sk, - rotating_sk, - SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - AddProPaymentResponse add = parse_add_payment(send(onion, add_req)); - REQUIRE(add.status == ResponseStatus::Ok); - CHECK(add.status == ResponseStatus::Ok); + // Bind + redeem it implicitly (any master-signed request binds unbound payments) and get a + // proof to revoke. + ProRequest gen_req = pro_proof_request(master_sk, rotating_sk, session::sysclock_now_s()); + GenerateProProofResponse gen = parse_pro_proof(send(onion, gen_req)); + INFO("generate_pro_proof " << gen.error.value_or("")); + REQUIRE(gen.status == ResponseStatus::Ok); + CHECK(gen.status == ResponseStatus::Ok); // Revoke that payment's generation (revocation is terminal now -- revoked_at set once, no // per-entry expiry), then poll: the list must carry our proof's revocation_tag. @@ -1218,7 +1132,7 @@ TEST_CASE("Pro backend live get_pro_revocations", "[pro_backend][pro_live]") { CHECK_FALSE(rev.items.empty()); bool found = false; for (const auto& it : rev.items) - if (it.revocation_tag == add.proof.revocation_tag) + if (it.revocation_tag == gen.proof.revocation_tag) found = true; CHECK(found); } From 7c0fde4f028ef6545d834737b3ff057a04f1d71d Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 22:46:17 -0300 Subject: [PATCH 06/35] config: migrate legacy millisecond pro_access_expiry on read pro_access_expiry (key E) is stored in epoch seconds, but older clients (commit 09d2a372) stored it in milliseconds and left no migration, so get_pro_access_expiry() read a ~1.7e12 ms value as seconds (a year-33000 date). Treat any stored value > 1e12 as milliseconds and divide by 1000 on read. Also corrects the stale 'in milliseconds' key-ledger comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.hpp | 5 +++-- src/config/user_profile.cpp | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index ef9812962..12a39603a 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -28,8 +28,9 @@ using namespace std::literals; /// s - session pro data (rotating seed + proof); see config/pro.hpp for the sub-dict layout. /// t - The unix timestamp (seconds) that the user last explicitly updated their profile information /// (automatically updates when changing `name`, `profile_pic` or `set_blinded_msgreqs`). -/// E - user pro access expiry unix timestamp (in milliseconds). Note: This can be different from -/// the pro proof expiry which can be sooner. +/// E - user pro access expiry unix timestamp (in seconds). Note: This can be different from the pro +/// proof expiry which can be sooner. (Some (unreleased) testing clients stored this in milliseconds; the getter +/// migrates any such value on read -- see get_pro_access_expiry.) /// R - unix timestamp (seconds) at which the user requested a refund of their current Session Pro /// subscription, for cross-device sync of the refund-requested state. Omitted when no refund /// has been requested; cleared by the client when a new subscription begins. Values more than diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 27f331d2f..2f986561b 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -213,8 +213,15 @@ void UserProfile::set_animated_avatar(bool enabled) { } std::optional UserProfile::get_pro_access_expiry() const { - if (auto* E = data["E"].integer()) - return std::chrono::sys_seconds{std::chrono::seconds{*E}}; + if (auto* E = data["E"].integer()) { + int64_t secs = *E; + // Backwards compatibility: older clients stored this as epoch *milliseconds*. A seconds + // value won't reach 1e12 until the year ~33658, while a millisecond value is ~1.7e12 today, + // so treat anything past that threshold as milliseconds and convert. + if (secs > 1'000'000'000'000) + secs /= 1000; + return std::chrono::sys_seconds{std::chrono::seconds{secs}}; + } return std::nullopt; } From d48acb9b71fd771b1a2f5d7bd1e3f3d5860b6663 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 23:04:37 -0300 Subject: [PATCH 07/35] pro_backend: collapse redundant ProProofResponse base ProProofResponse existed to unify the add-payment and generate-proof responses; with add-payment gone it's a vestigial one-child base. Fold its `proof` member into GenerateProProofResponse (now derives directly from ResponseBase) and retype fill_proof accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.hpp | 9 +++------ src/pro_backend.cpp | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index afe0bb39d..9b634ab39 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -173,15 +173,12 @@ struct ProRequest { std::string data; }; -/// Common base for the responses that carry a freshly-issued Session Pro proof. `proof` is the raw -/// parse result, convertible to a config::ProProof. -struct ProProofResponse : ResponseBase { +/// Response to `pro_proof_request` (endpoint `generate_pro_proof`): carries the freshly-issued +/// Session Pro proof. `proof` is the raw parse result, convertible to a config::ProProof. +struct GenerateProProofResponse : ResponseBase { ProProof proof; }; -/// Response to `pro_proof_request` (endpoint `generate_pro_proof`). -struct GenerateProProofResponse : ProProofResponse {}; - /// Parse the reply to a generate-proof request. On failure `status` is set to an error state and /// `errors` is populated; on success `proof` holds the issued proof. GenerateProProofResponse parse_pro_proof(std::string_view json); diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 82107f893..3938b9e6c 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -332,7 +332,7 @@ namespace { // proof) from the already-extracted `result` object. void fill_proof( const nlohmann::json::object_t& result_obj, - ProProofResponse& result, + GenerateProProofResponse& result, std::vector& errs) { result.proof.version = json_require(result_obj, "version", errs); auto expiry_ts = json_require(result_obj, "expiry_ts", errs); From 470d7eb8ffe21176e01d6b191502322d6cfcdd46 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 00:34:59 -0300 Subject: [PATCH 08/35] config: stop persisting the proof version (dicts aren't atomic) The proof `version` was stored at s.p.@ but nothing read it back (verify_signature hardcodes v0) and it actively bloated the config. It was also unsound: config dicts merge per-key, non-atomically, so an in-dict version can't describe its sibling fields -- a concurrent edit could stitch one update's version onto another update's fields. Config formats must be versioned by key, not an in-dict marker. ProConfig::load no longer reads @ and assigns v0 (the only config proof format) directly; set_pro_config stops writing @ and erases any legacy value on rewrite. The wire/protobuf proof version (a real, atomic discriminator) is untouched. Also fixes the stale 'milliseconds' note on the proof expiry (e) key, which is seconds. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/pro.hpp | 3 +-- src/config/pro.cpp | 9 +++++---- src/config/user_profile.cpp | 5 ++++- tests/test_config_pro.cpp | 7 +++---- tests/test_config_userprofile.cpp | 4 +++- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/include/session/config/pro.hpp b/include/session/config/pro.hpp index 6597fcf27..fafdc8b97 100644 --- a/include/session/config/pro.hpp +++ b/include/session/config/pro.hpp @@ -12,8 +12,7 @@ namespace session::config { /// | /// +-- p + proof /// | | -/// | +-- @ - version -/// | +-- e - expiry unix timestamp (in milliseconds) +/// | +-- e - expiry unix timestamp (in seconds) /// | +-- g - revocation_tag /// | +-- s - proof signature, signed by the Session Pro Backend's ed25519 key /// | diff --git a/src/config/pro.cpp b/src/config/pro.cpp index 1818ebe34..0f1b29e62 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -27,13 +27,10 @@ bool ProConfig::load(const dict& root) { // NOTE: Load into the proof object { - std::optional version = maybe_int(*p, "@"); std::optional> maybe_revocation_tag = maybe_vector(*p, "g"); std::optional maybe_expiry = maybe_ts(*p, "e"); std::optional> maybe_sig = maybe_vector(*p, "s"); - if (!version) - return false; if (!maybe_revocation_tag || maybe_revocation_tag->size() != proof.revocation_tag.size()) return false; if (!maybe_sig || maybe_sig->size() != proof.sig.max_size()) @@ -41,7 +38,11 @@ bool ProConfig::load(const dict& root) { if (!maybe_expiry) return false; - proof.version = *version; + // The proof version is NOT persisted in the config: dicts merge per-key/non-atomically, so + // an in-dict version field can't reliably describe its sibling fields (a concurrent edit + // could stitch one update's version onto another's fields). The config proof format is v0 + // by definition; a future format would take a new key, not a version marker here. + proof.version = ProProofVersion_v0; std::memcpy( proof.revocation_tag.data(), maybe_revocation_tag->data(), diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 2f986561b..edbe07ccb 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -168,7 +168,10 @@ void UserProfile::set_pro_config(const ProConfig& pro) { pro.rotating_privkey.data(), crypto_sign_ed25519_SEEDBYTES); auto proof_dict = root["p"]; - proof_dict["@"] = pro.proof.version; + // The proof version is not persisted (see ProConfig::load): a per-key-merged dict can't + // carry a version that reliably describes its siblings. Erase any legacy "@" left by an + // older client so it stops bloating the config. + proof_dict["@"].erase(); proof_dict["g"] = pro.proof.revocation_tag; proof_dict["e"] = epoch_seconds(pro.proof.expiry_at); proof_dict["s"] = pro.proof.sig; diff --git a/tests/test_config_pro.cpp b/tests/test_config_pro.cpp index 5388d6782..00f3c3b33 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -23,7 +23,8 @@ TEST_CASE("Pro", "[config][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - pro_cpp.proof.version = 2; + // 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 = @@ -101,7 +102,6 @@ TEST_CASE("Pro", "[config][pro]") { session::config::dict good_dict = { {"r", std::string(reinterpret_cast(rotating_sk.data()), crypto_sign_ed25519_SEEDBYTES)}, {"p", session::config::dict{ - /*version*/ {"@", proof.version}, /*revocation_tag*/ {"g", std::string(reinterpret_cast(proof.revocation_tag.data()), proof.revocation_tag.size())}, /*rotating pubkey*/ {"r", std::string(reinterpret_cast(proof.rotating_pubkey.data()), proof.rotating_pubkey.size())}, /*expiry unix ts*/ {"e", proof.expiry_at.time_since_epoch().count()}, @@ -113,7 +113,7 @@ TEST_CASE("Pro", "[config][pro]") { session::config::ProConfig loaded_pro = {}; CHECK(loaded_pro.load(good_dict)); CHECK(loaded_pro.rotating_privkey == pro_cpp.rotating_privkey); - CHECK(loaded_pro.proof.version == pro_cpp.proof.version); + 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); CHECK(loaded_pro.proof.expiry_at == pro_cpp.proof.expiry_at); @@ -131,7 +131,6 @@ TEST_CASE("Pro", "[config][pro]") { session::config::dict bad_dict = { {"r", std::string(reinterpret_cast(rotating_sk.data()), crypto_sign_ed25519_SEEDBYTES)}, {"p", session::config::dict{ - /*version*/ {"@", proof.version}, /*revocation_tag*/ {"g", std::string(reinterpret_cast(proof.revocation_tag.data()), proof.revocation_tag.size())}, /*rotating pubkey*/ {"r", std::string(reinterpret_cast(proof.rotating_pubkey.data()), proof.rotating_pubkey.size())}, /*expiry unix ts*/ {"e", proof.expiry_at.time_since_epoch().count()}, diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index cc759550b..aff888fde 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -616,7 +616,9 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - pro_cpp.proof.version = 2; + // 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 = From 3029ea5acc9292d291f741d0929354412040488c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 15:59:47 -0300 Subject: [PATCH 09/35] config: store the pro credential as one atomic bt-encoded value The proof was scattered across sibling config keys (s.p.{g,e,s} + the seed s.r), but config dicts merge per-key and non-atomically -- so a concurrent update could stitch a signature onto a different update's fields, or desync the seed from the pubkey the sig authorizes. Collapse the whole credential into a single opaque bt-encoded string at data["s"] ({e,g,r,s}; rotating pubkey derived from the seed), so it can only merge as an indivisible unit. ProConfig gains serialize()/load(string_view); get/set_pro_config marshal the one value. An older dict-shaped "s" no longer parses -> treated as 'no proof' -> re-fetched (self-healing; flag for clients). Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/pro.hpp | 27 ++++++--- include/session/config/user_profile.hpp | 3 +- src/config/pro.cpp | 81 +++++++++++-------------- src/config/user_profile.cpp | 18 ++---- tests/test_config_pro.cpp | 40 +++--------- 5 files changed, 69 insertions(+), 100 deletions(-) diff --git a/include/session/config/pro.hpp b/include/session/config/pro.hpp index fafdc8b97..ac37647aa 100644 --- a/include/session/config/pro.hpp +++ b/include/session/config/pro.hpp @@ -8,15 +8,15 @@ namespace session::config { /// keys used currently or in the past (so that we don't reuse): /// -/// s + session pro data -/// | -/// +-- p + proof -/// | | -/// | +-- e - expiry unix timestamp (in seconds) -/// | +-- g - revocation_tag -/// | +-- s - proof signature, signed by the Session Pro Backend's ed25519 key -/// | -/// +-- r - rotating ed25519 seed (32b) +/// s - session pro credential: a single opaque, bt-encoded string (NOT a config sub-dict). Storing +/// it as one value is deliberate: config dicts merge per-key and non-atomically, so a proof +/// split across sibling keys could end up with its signature stitched onto a *different* +/// update's fields (or its seed desynced from the pubkey the sig authorizes). As one lone +/// string the whole credential moves as an indivisible unit. The bt-encoded dict inside is: +/// e - proof expiry unix timestamp (seconds) +/// g - revocation_tag (32 bytes) +/// r - rotating ed25519 seed (32 bytes); the rotating pubkey is derived from it +/// s - proof signature by the Session Pro Backend's ed25519 key (64 bytes) class ProConfig { public: /// Rotating private key for the public key specified in the proof. On the wire we store the @@ -26,7 +26,14 @@ class ProConfig { /// A cryptographic proof for entitling an Ed25519 key to Session Pro ProProof proof; - bool load(const dict& root); + /// Parse the credential from its bt-encoded config value (the "s" key). Returns false if the + /// value is empty, not well-formed bt, or missing/wrong-sized fields -- the caller treats that + /// as "no usable proof" (e.g. an older client's dict-shaped value, which self-heals on the next + /// proof fetch). + bool load(std::string_view bt_encoded); + + /// Serialise the credential to the bt-encoded string stored at the "s" config key. + std::string serialize() const; bool operator==(const ProConfig& other) const { return rotating_privkey == other.rotating_privkey && proof == other.proof; diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 12a39603a..d78c69242 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -25,7 +25,8 @@ using namespace std::literals; /// omitted if the setting has not been explicitly set (or has been explicitly cleared for some /// reason). /// f - session pro features bitset -/// s - session pro data (rotating seed + proof); see config/pro.hpp for the sub-dict layout. +/// s - session pro credential (rotating seed + proof) as a single opaque bt-encoded string, stored +/// as one value so it merges atomically; see config/pro.hpp. /// t - The unix timestamp (seconds) that the user last explicitly updated their profile information /// (automatically updates when changing `name`, `profile_pic` or `set_blinded_msgreqs`). /// E - user pro access expiry unix timestamp (in seconds). Note: This can be different from the pro diff --git a/src/config/pro.cpp b/src/config/pro.cpp index 0f1b29e62..b43f020f8 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -1,61 +1,54 @@ #include #include -#include #include +#include +#include #include #include - -#include "internal.hpp" +#include namespace session::config { -bool ProConfig::load(const dict& root) { - // Get proof fields from session pro data sitting in the 'p' (proof) dictionary - auto p_it = root.find("p"); - if (p_it == root.end()) - return false; - - // Lookup and get 'p' - const config::dict* p = std::get_if(&p_it->second); - if (!p) +bool ProConfig::load(std::string_view bt_encoded) { + if (bt_encoded.empty()) return false; - std::optional> maybe_rotating_seed = maybe_vector(root, "r"); - if (!maybe_rotating_seed || maybe_rotating_seed->size() != crypto_sign_ed25519_SEEDBYTES) - return false; - - // NOTE: Load into the proof object - { - std::optional> maybe_revocation_tag = maybe_vector(*p, "g"); - std::optional maybe_expiry = maybe_ts(*p, "e"); - std::optional> maybe_sig = maybe_vector(*p, "s"); - - if (!maybe_revocation_tag || maybe_revocation_tag->size() != proof.revocation_tag.size()) - return false; - if (!maybe_sig || maybe_sig->size() != proof.sig.max_size()) - return false; - if (!maybe_expiry) - return false; - - // The proof version is NOT persisted in the config: dicts merge per-key/non-atomically, so - // an in-dict version field can't reliably describe its sibling fields (a concurrent edit - // could stitch one update's version onto another's fields). The config proof format is v0 - // by definition; a future format would take a new key, not a version marker here. + try { + // A parse failure here -- including an older dict-shaped "s" from before the credential + // became one atomic value -- is caught and treated as "no credential", self-healing on the + // next proof fetch. + oxenc::bt_dict_consumer d{bt_encoded}; + auto expiry = d.require("e"); + auto tag = d.require_span("g"); + 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; - std::memcpy( - proof.revocation_tag.data(), - maybe_revocation_tag->data(), - proof.revocation_tag.size()); - proof.expiry_at = *maybe_expiry; - std::memcpy(proof.sig.data(), maybe_sig->data(), proof.sig.size()); + 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()); + + // Derive the rotating public key + full private key from the stored seed. + crypto_sign_ed25519_seed_keypair( + proof.rotating_pubkey.data(), rotating_privkey.data(), seed.data()); + return true; + } catch (const std::exception&) { + return false; } +} - // Derive the rotating public key from the seed and populate the proof's pubkey and the outer - // private key - crypto_sign_ed25519_seed_keypair( - proof.rotating_pubkey.data(), rotating_privkey.data(), maybe_rotating_seed->data()); - return true; +std::string ProConfig::serialize() const { + oxenc::bt_dict_producer d; + // bt dict keys MUST be appended in sorted order: e, g, r, s. + d.append("e", epoch_seconds(proof.expiry_at)); + d.append("g", proof.revocation_tag); + d.append("r", std::span{rotating_privkey}.first()); + d.append("s", proof.sig); + return std::string{d.view()}; } }; // namespace session::config diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index edbe07ccb..07f9d963a 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -152,7 +152,7 @@ std::chrono::sys_seconds UserProfile::get_profile_updated() const { std::optional UserProfile::get_pro_config() const { std::optional result = {}; - if (const config::dict* s = data["s"].dict()) { + if (auto* s = data["s"].string()) { ProConfig pro = {}; if (pro.load(*s)) result = std::move(pro); @@ -163,18 +163,10 @@ std::optional UserProfile::get_pro_config() const { void UserProfile::set_pro_config(const ProConfig& pro) { std::optional curr = get_pro_config(); if (!curr || *curr != pro) { - auto root = data["s"]; - root["r"] = std::span( - pro.rotating_privkey.data(), crypto_sign_ed25519_SEEDBYTES); - - auto proof_dict = root["p"]; - // The proof version is not persisted (see ProConfig::load): a per-key-merged dict can't - // carry a version that reliably describes its siblings. Erase any legacy "@" left by an - // older client so it stops bloating the config. - proof_dict["@"].erase(); - proof_dict["g"] = pro.proof.revocation_tag; - proof_dict["e"] = epoch_seconds(pro.proof.expiry_at); - proof_dict["s"] = pro.proof.sig; + // Store the whole credential as one opaque, bt-encoded value so it merges atomically: a + // proof split across sibling config keys could have its signature stitched onto a different + // update's fields (see ProConfig). A single string swap can't slice. + data["s"] = pro.serialize(); const auto target_timestamp = (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); diff --git a/tests/test_config_pro.cpp b/tests/test_config_pro.cpp index 00f3c3b33..bd52841ec 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -95,52 +95,28 @@ TEST_CASE("Pro", "[config][pro]") { body.size())); } - // Try loading the proof from dict + // Round-trip the proof through its bt-encoded config value. { - const session::ProProof& proof = pro_cpp.proof; - // clang-format off - session::config::dict good_dict = { - {"r", std::string(reinterpret_cast(rotating_sk.data()), crypto_sign_ed25519_SEEDBYTES)}, - {"p", session::config::dict{ - /*revocation_tag*/ {"g", std::string(reinterpret_cast(proof.revocation_tag.data()), proof.revocation_tag.size())}, - /*rotating pubkey*/ {"r", std::string(reinterpret_cast(proof.rotating_pubkey.data()), proof.rotating_pubkey.size())}, - /*expiry unix ts*/ {"e", proof.expiry_at.time_since_epoch().count()}, - /*signature*/ {"s", std::string{reinterpret_cast(proof.sig.data()), proof.sig.size()}}, - }} - }; - // clang-format on + std::string encoded = pro_cpp.serialize(); session::config::ProConfig loaded_pro = {}; - CHECK(loaded_pro.load(good_dict)); + 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); + CHECK(loaded_pro.proof.rotating_pubkey == pro_cpp.proof.rotating_pubkey); // derived from seed CHECK(loaded_pro.proof.expiry_at == pro_cpp.proof.expiry_at); CHECK(loaded_pro.proof.sig == pro_cpp.proof.sig); CHECK(loaded_pro.proof.verify_signature(signing_pk)); } - // Try loading a proof with a bad signature in it from dict + // A tampered signature still loads (load doesn't verify) but fails verify_signature. { - std::array broken_sig = pro_cpp.proof.sig; - broken_sig[0] = ~broken_sig[0]; // Break the sig - const session::ProProof& proof = pro_cpp.proof; - - // clang-format off - session::config::dict bad_dict = { - {"r", std::string(reinterpret_cast(rotating_sk.data()), crypto_sign_ed25519_SEEDBYTES)}, - {"p", session::config::dict{ - /*revocation_tag*/ {"g", std::string(reinterpret_cast(proof.revocation_tag.data()), proof.revocation_tag.size())}, - /*rotating pubkey*/ {"r", std::string(reinterpret_cast(proof.rotating_pubkey.data()), proof.rotating_pubkey.size())}, - /*expiry unix ts*/ {"e", proof.expiry_at.time_since_epoch().count()}, - /*signature*/ {"s", std::string{reinterpret_cast(broken_sig.data()), broken_sig.size()}}, - }} - }; - // clang-format on + pro_cpp.proof.sig.data()[0] = ~pro_cpp.proof.sig.data()[0]; // break the sig + std::string encoded = pro_cpp.serialize(); session::config::ProConfig loaded_pro = {}; - CHECK(loaded_pro.load(bad_dict)); + CHECK(loaded_pro.load(encoded)); CHECK_FALSE(loaded_pro.proof.verify_signature(signing_pk)); } } From 8f516c20b6beccc8a1540cf7b2b1b3e0cecd1f04 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 16:43:58 -0300 Subject: [PATCH 10/35] pro: add ProProof::rotating_seed deterministic generator Derive the rotating Session Pro seed deterministically from the Pro master seed and the (weekly) rotation period containing a timestamp: seed = BLAKE2b-256(personal="ProRotatingSeed_", master_seed[0:32] || dec(floor(unix/604800)*604800)) where dec() is decimal ASCII (the Pro wire's existing integer convention, so no endianness). Because every device derives the same seed for the same period with no coordination, concurrent proof (re)generations converge on one credential instead of racing -- replacing the current per-client rotating-key logic (Android mints per-proof, iOS/desktop never rotate). Rooted at the Pro master (not the session-id seed) so all Pro key material shares one hierarchy and the Pro subsystem never touches the account identity seed. Static member of ProProof; C API session_protocol_pro_rotating_seed (takes a sys_seconds/unix_ts and floors internally -- no epoch exposed to callers). KAT vectors cross-checked against an independent Python BLAKE2b. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.h | 18 +++++++++++ include/session/session_protocol.hpp | 23 ++++++++++++++ src/session_protocol.cpp | 47 ++++++++++++++++++++++++++++ tests/test_session_protocol.cpp | 23 ++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 79383cc96..53f6336f7 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -289,6 +289,24 @@ LIBSESSION_EXPORT bool session_protocol_pro_proof_verify_signature( uint8_t const* verify_pubkey, size_t verify_pubkey_len) NON_NULL_ARG(1, 2); +/// API: session_protocol/session_protocol_pro_rotating_seed +/// +/// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that +/// contains `unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the +/// same 32-byte seed for the same period, so concurrent proof (re)generations converge rather than +/// racing. +/// +/// Inputs: +/// - `master_seed` -- the account's Session Pro master key/seed (from +/// session_ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; first 32 bytes used. +/// - `unix_ts` -- any unix timestamp (seconds) within the desired rotation period; it is floored to +/// the period internally, so the caller never computes epochs. +/// - `rotating_seed_out` -- [out] 32-byte buffer to receive the derived seed. +LIBSESSION_EXPORT void session_protocol_pro_rotating_seed( + const unsigned char* master_seed, + int64_t unix_ts, + unsigned char* rotating_seed_out) NON_NULL_ARG(1, 3); + /// API: session_protocol/session_protocol_pro_proof_verify_message /// /// Check if the `rotating_pubkey` in the proof was the signatory of the message and signature diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index cda6d0503..fb34778d1 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -194,6 +194,29 @@ class ProProof { /// Ed25519-signed directly — there is no pre-hash. std::vector signed_message() const; + /// API: pro/Proof::rotating_seed + /// + /// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that + /// contains `timestamp`. Because every device derives the same seed for the same period with no + /// coordination, concurrent proof (re)generations converge on one credential instead of racing. + /// The seed is the private counterpart of the `rotating_pubkey` a proof for this period + /// authorizes: it is fed to generate_pro_proof, and once the backend returns a signed proof for + /// it the same seed is persisted in the config credential (its `r`) and is what subsequently + /// signs Pro messages. + /// + /// Inputs: + /// - `master_seed` -- the account's Session Pro *master* key/seed (as produced by + /// ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; its first 32 bytes are + /// used. Rooting rotating keys under the Pro master keeps all Pro key material in one + /// hierarchy and lets the Pro subsystem avoid ever touching the account's identity seed. + /// - `timestamp` -- any time within the desired rotation period; it is floored to the period + /// (7 days) internally, so callers never compute epochs themselves. + /// + /// Outputs: + /// - The 32-byte rotating seed (secret; zeroed on destruction). + static cleared_uc32 rotating_seed( + std::span master_seed, std::chrono::sys_seconds timestamp); + 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 && diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index c9d589a8a..760e8e3bb 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -3,9 +3,13 @@ #include #include #include +#include #include #include +#include +#include + #include #include #include @@ -178,6 +182,42 @@ std::vector ProProof::signed_message() const { return proof_signed_message(revocation_tag, rotating_pubkey, epoch_seconds(expiry_at)); } +cleared_uc32 ProProof::rotating_seed( + std::span master_seed, std::chrono::sys_seconds timestamp) { + if (master_seed.size() != 32 && master_seed.size() != 64) + throw std::invalid_argument{ + "Invalid master_seed: expected a 32-byte Ed25519 seed or 64-byte libsodium key"}; + + // Floor the timestamp to the rotation period, then hash master_seed[0:32] ‖ dec(period_start), + // where dec() is canonical decimal ASCII -- the same integer encoding the rest of the Pro wire + // uses (signed_message, §1.1), so there's one integer convention and no endianness to pin. + constexpr std::int64_t period = 7 * 24 * 60 * 60; // 604800 seconds + std::int64_t period_start = session::epoch_seconds(timestamp) / period * period; + char dec[20]; + auto [ptr, ec] = std::to_chars(dec, dec + sizeof(dec), period_start); + + auto seed = master_seed.first(32); + std::vector msg; + msg.reserve(seed.size() + static_cast(ptr - dec)); + msg.insert(msg.end(), seed.begin(), seed.end()); + msg.insert(msg.end(), dec, ptr); + + static constexpr std::string_view personal = "ProRotatingSeed_"; + static_assert(personal.size() == crypto_generichash_blake2b_PERSONALBYTES); + + cleared_uc32 out = {}; + crypto_generichash_blake2b_salt_personal( + out.data(), + out.size(), + msg.data(), + msg.size(), + nullptr, // key + 0, + nullptr, // salt + reinterpret_cast(personal.data())); + return out; +} + void ProProfileBitset::set(SESSION_PROTOCOL_PRO_PROFILE_FEATURES features) { data |= (1ULL << static_cast(features)); } @@ -1144,6 +1184,13 @@ LIBSESSION_C_API bool session_protocol_pro_proof_verify_signature( return result; } +LIBSESSION_C_API void session_protocol_pro_rotating_seed( + const unsigned char* master_seed, int64_t unix_ts, unsigned char* rotating_seed_out) { + auto seed = session::ProProof::rotating_seed( + {master_seed, 32}, std::chrono::sys_seconds{std::chrono::seconds{unix_ts}}); + std::memcpy(rotating_seed_out, seed.data(), seed.size()); +} + LIBSESSION_C_API bool session_protocol_pro_proof_verify_message( session_protocol_pro_proof const* proof, uint8_t const* sig, diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index 4980afb8a..ebc924a72 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -910,3 +911,25 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { REQUIRE(!decoded.has_pro); } } + +TEST_CASE("Pro rotating-seed derivation", "[session-protocol][pro][pro_kat]") { + // Deterministic BLAKE2b of the Pro master seed and the floored rotation period, so every device + // derives the same seed for the same period. Vectors computed independently (Python + // hashlib.blake2b, person="ProRotatingSeed_", input = seed || decimal-ASCII(period_start)). + auto master = "0101010101010101010101010101010101010101010101010101010101010101"_hexbytes; + auto seed_hex = [&](int64_t unix_ts) { + auto s = ProProof::rotating_seed( + master, std::chrono::sys_seconds{std::chrono::seconds{unix_ts}}); + return oxenc::to_hex(s.begin(), s.end()); + }; + + // KAT: 1700000000 floors to period start 1699488000; the next period starts at 1700092800. + CHECK(seed_hex(1700000000) == + "e617ee563883b95a736a4e375e581f578150346046b08fdb58d07f6a317c2ff7"); + CHECK(seed_hex(1700604800) == + "01887cd6b6827c3b335c5ab677ce831a6b253016e3d23646639188036d97bd91"); + + // Idempotent within a rotation period (any ts in the same 7-day window), distinct across them. + CHECK(seed_hex(1700000000 + 3600) == seed_hex(1700000000)); + CHECK(seed_hex(1700604800) != seed_hex(1700000000)); +} From ef91a6659486602162ce899b1e718d10a56226da Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 16:48:23 -0300 Subject: [PATCH 11/35] config: add maybe_span dict helper Like maybe_vector, but returns a std::span into the dict's stored string value instead of allocating a copy. Not yet used; provided for callers that only need to read the bytes and don't need maybe_vector's allocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/internal.cpp | 8 ++++++++ src/config/internal.hpp | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/src/config/internal.cpp b/src/config/internal.cpp index d386dad31..6125f7ba6 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -187,6 +187,14 @@ std::string_view sv_or_empty(const session::config::dict& d, const char* key) { return ""sv; } +std::optional> maybe_span( + const session::config::dict& d, const char* key) { + std::optional> ret; + if (auto* s = maybe_scalar(d, key)) + ret.emplace(reinterpret_cast(s->data()), s->size()); + return ret; +} + std::optional> maybe_vector( const session::config::dict& d, const char* key) { std::optional> result; diff --git a/src/config/internal.hpp b/src/config/internal.hpp index e64e699f9..499e2eaa0 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -213,6 +213,11 @@ std::optional maybe_sv(const session::config::dict& d, const c // string view is only valid as long as the dict stays unchanged. std::string_view sv_or_empty(const session::config::dict& d, const char* key); +// Digs into a config `dict` to get out a std::span; nullopt if not there (or +// not string) +std::optional> maybe_span( + const session::config::dict& d, const char* key); + // Digs into a config `dict` to get out a std::vector; nullopt if not there (or not // string) std::optional> maybe_vector( From 33c6dc95ee6a166023537ea27ee16a51c2b7fd90 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 16:54:53 -0300 Subject: [PATCH 12/35] config: add pro_prepaid (purchase-in-flight) flag + auto-clears New top-level UserProfile key `I`: a timestamp marking that a Session Pro purchase was initiated, synced so every device polls the backend to pull the entitlement through (now possible since redemption is implicit and needs no client-held payment_id). get/set_pro_prepaid: - set inserts only if the account isn't already Pro (live proof or future access-expiry) -- otherwise there's nothing to poll for; - get applies the same 1-week read gate as refund (a purchase that never propagates stops nagging devices to poll); - it's the shared anchor for deriving the first rotating seed before any proof exists. Cleared automatically once entitlement lands: set_pro_config clears it when the stored proof is still valid, and set_pro_access_expiry clears it (and any >1wk-stale refund R) when E is in the future. All clears are no-ops when there's nothing to clear. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 26 ++++++++++ include/session/config/user_profile.hpp | 31 ++++++++++++ src/config/user_profile.cpp | 63 +++++++++++++++++++++++++ tests/test_config_userprofile.cpp | 37 +++++++++++++++ 4 files changed, 157 insertions(+) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 24b35efaf..52dc7bcce 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -425,6 +425,32 @@ LIBSESSION_EXPORT int64_t user_profile_get_refund_requested(const config_object* /// clear the refund-requested state. LIBSESSION_EXPORT void user_profile_set_refund_requested(config_object* conf, int64_t refund_ts); +/// API: user_profile/user_profile_get_pro_prepaid +/// +/// Retrieves the timestamp at which a Session Pro purchase was initiated (the "purchase in flight" +/// marker), synced across the user's devices. A stored value more than a week in the past is +/// ignored (returns 0). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` - the unix timestamp (seconds) at which a purchase was initiated, or 0 if none is +/// pending (or the stored value is stale). +LIBSESSION_EXPORT int64_t user_profile_get_pro_prepaid(const config_object* conf); + +/// API: user_profile/user_profile_set_pro_prepaid +/// +/// Records (or clears) that a Session Pro purchase is in flight so the user's other devices poll +/// the backend to pull the entitlement through. A no-op if the account is already Pro; cleared +/// automatically once entitlement lands, or explicitly by passing 0. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `prepaid_ts` -- the timestamp (unix epoch seconds) at which the purchase was initiated, or 0 +/// to clear the marker. +LIBSESSION_EXPORT void user_profile_set_pro_prepaid(config_object* conf, int64_t prepaid_ts); + #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index d78c69242..6a0aa38ed 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -36,6 +36,10 @@ using namespace std::literals; /// subscription, for cross-device sync of the refund-requested state. Omitted when no refund /// has been requested; cleared by the client when a new subscription begins. Values more than /// a week in the past are ignored on read (treated as if unset). +/// I - unix timestamp (seconds) at which a Session Pro purchase was initiated ("purchase in +/// flight"), so all the account's devices poll the backend to pull the entitlement through. +/// Inserted only when not already pro; cleared automatically when entitlement lands; values +/// more than a week in the past are ignored on read. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -362,6 +366,33 @@ class UserProfile : public ConfigBase { /// - `when` -- the timestamp (unix epoch seconds) at which the refund was requested, or nullopt /// to clear the refund-requested state. void set_refund_requested(std::optional when); + + /// API: user_profile/UserProfile::get_pro_prepaid + /// + /// Retrieves the timestamp at which a Session Pro purchase was initiated (the "purchase in + /// flight" marker), synced across the user's devices so that any device can drive the backend + /// redemption to completion. A stored value more than a week in the past is ignored (returns + /// nullopt) so a purchase that never propagated doesn't make devices poll forever. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` - the unix timestamp (seconds) at which a purchase was + /// initiated, or nullopt if none is pending (or the stored value is stale). + std::optional get_pro_prepaid() const; + + /// API: user_profile/UserProfile::set_pro_prepaid + /// + /// Records (or clears) that a Session Pro purchase is in flight, propagating it to the user's + /// other devices so they poll the backend to pull the new entitlement through. Setting is a + /// no-op if the account is already entitled to Pro (there would be nothing to poll for); it is + /// otherwise cleared automatically once entitlement lands (see set_pro_config / + /// set_pro_access_expiry), or explicitly by passing nullopt. + /// + /// Inputs: + /// - `when` -- the timestamp (unix epoch seconds) at which the purchase was initiated, or + /// nullopt to clear the marker. + void set_pro_prepaid(std::optional when); }; } // namespace session::config diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 07f9d963a..98bf2a577 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -172,6 +172,10 @@ void UserProfile::set_pro_config(const ProConfig& pro) { (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); data[target_timestamp] = ts_now(); } + + // A live proof means any in-flight purchase has resolved: clear the prepaid marker. + if (pro.proof.expiry_at > ts_now() && data["I"].exists()) + data["I"].erase(); } bool UserProfile::remove_pro_config() { @@ -225,6 +229,16 @@ void UserProfile::set_pro_access_expiry(std::optional data["E"] = epoch_seconds(*access_expiry_ts); else data["E"].erase(); + + // Confirming a live entitlement means any in-flight purchase resolved, and any long-stale + // refund request is moot -- opportunistically clear both (we're already writing E anyway). + if (access_expiry_ts && *access_expiry_ts > ts_now()) { + if (data["I"].exists()) + data["I"].erase(); + if (auto* R = data["R"].integer(); R && std::chrono::sys_seconds{std::chrono::seconds{*R}} < + ts_now() - std::chrono::weeks{1}) + data["R"].erase(); + } } std::optional UserProfile::get_refund_requested() const { @@ -250,6 +264,42 @@ void UserProfile::set_refund_requested(std::optional w data[target_timestamp] = ts_now(); } +std::optional UserProfile::get_pro_prepaid() const { + if (auto* I = data["I"].integer()) { + std::chrono::sys_seconds when{std::chrono::seconds{*I}}; + // Ignore a stale marker (a purchase that never propagated) so devices don't poll forever. + if (when >= ts_now() - std::chrono::weeks{1}) + return when; + } + return std::nullopt; +} + +void UserProfile::set_pro_prepaid(std::optional when) { + bool changed = false; + if (!when) { + if (data["I"].exists()) { + data["I"].erase(); + changed = true; + } + } else { + // Only mark a purchase pending if the account isn't already entitled to Pro (a live proof + // or a still-future access expiry); otherwise there's nothing to poll for. + bool already_pro = get_pro_config().has_value(); + if (!already_pro) + if (auto e = get_pro_access_expiry(); e && *e > ts_now()) + already_pro = true; + if (!already_pro) { + data["I"] = epoch_seconds(*when); + changed = true; + } + } + if (changed) { + const auto target_timestamp = + (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); + } +} + extern "C" { using namespace session; @@ -445,4 +495,17 @@ LIBSESSION_C_API void user_profile_set_refund_requested(config_object* conf, int unbox(conf)->set_refund_requested(as_sys_seconds(refund_ts)); } +LIBSESSION_C_API int64_t user_profile_get_pro_prepaid(const config_object* conf) { + if (auto when = unbox(conf)->get_pro_prepaid()) + return epoch_seconds(*when); + return 0; +} + +LIBSESSION_C_API void user_profile_set_pro_prepaid(config_object* conf, int64_t prepaid_ts) { + if (prepaid_ts <= 0) + unbox(conf)->set_pro_prepaid(std::nullopt); + else + unbox(conf)->set_pro_prepaid(as_sys_seconds(prepaid_ts)); +} + } // extern "C" diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index aff888fde..524be86eb 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -680,4 +680,41 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // Clearing removes it entirely. profile.set_refund_requested(std::nullopt); CHECK_FALSE(profile.get_refund_requested().has_value()); + + // Pro-prepaid ("purchase in flight") marker: insert-only-if-not-pro, 1-week read gate, and + // auto-clear when entitlement lands. (No proof, access expiry in the past -> not currently pro.) + CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + auto prepaid_at = now - std::chrono::hours{1}; + profile.set_pro_prepaid(prepaid_at); + CHECK(profile.get_pro_prepaid() == prepaid_at); + + // A stale (>1wk) marker is ignored on read. + profile.set_pro_prepaid(now - std::chrono::weeks{2}); + CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + // Confirming a live access expiry clears the marker (entitlement arrived). + profile.set_pro_prepaid(prepaid_at); + REQUIRE(profile.get_pro_prepaid().has_value()); + profile.set_pro_access_expiry(now + std::chrono::hours{1}); + CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + // While pro (live access expiry), setting the marker is a no-op. + profile.set_pro_prepaid(prepaid_at); + CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + // A landed (still-valid) proof also clears it: back to not-pro, mark pending, store a proof. + profile.set_pro_access_expiry(std::nullopt); + profile.set_pro_prepaid(prepaid_at); + REQUIRE(profile.get_pro_prepaid().has_value()); + pro_cpp.proof.expiry_at = now + std::chrono::hours{1}; + profile.set_pro_config(pro_cpp); + CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + // Explicit clear via nullopt. + profile.remove_pro_config(); + profile.set_pro_prepaid(prepaid_at); + REQUIRE(profile.get_pro_prepaid().has_value()); + profile.set_pro_prepaid(std::nullopt); + CHECK_FALSE(profile.get_pro_prepaid().has_value()); } From 495b3e68a14e96817302155cb9d519ef705988a3 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 17:00:47 -0300 Subject: [PATCH 13/35] config: add UserProfile::pro_renewal_target (when to re-request a proof) Centralise the "when should I renew my Pro proof?" decision that the three clients each implemented inconsistently (Android per-proof, iOS missing the expired case entirely). Given the stored proof + access expiry, returns the timestamp to attempt a renewal (renew now if it's <= the caller's clock, else schedule), or nullopt for "no renewal": - no proof, or proof already expired -> now - valid proof, access expiry >1h ahead -> ~1h before proof expiry - otherwise -> nullopt The preemptive target is nudged off a rotation-period boundary so every device floors the (shared) target into the same period and derives the same rotating seed. No auto_renewing input -- 'entitlement still ahead' (access expiry) already gates it, and gating on auto_renewing would strand lifetime/paid-through users mid-subscription. Extracts the shared PRO_ROTATING_SEED_PERIOD constant used by both the nudge and ProProof::rotating_seed. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 14 +++++++++ include/session/config/user_profile.hpp | 19 +++++++++++++ include/session/session_protocol.hpp | 11 +++++++ src/config/user_profile.cpp | 31 ++++++++++++++++++++ src/session_protocol.cpp | 6 ++-- tests/test_config_userprofile.cpp | 38 +++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 3 deletions(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 52dc7bcce..a1c248730 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -451,6 +451,20 @@ LIBSESSION_EXPORT int64_t user_profile_get_pro_prepaid(const config_object* conf /// to clear the marker. LIBSESSION_EXPORT void user_profile_set_pro_prepaid(config_object* conf, int64_t prepaid_ts); +/// API: user_profile/user_profile_get_pro_renewal_target +/// +/// Decide when to (re)request a Session Pro proof (see the C++ pro_renewal_target). Given `now`, +/// returns the unix timestamp (seconds) at which a renewal should be attempted -- renew now if it +/// is <= now, otherwise schedule for then -- or 0 if no renewal is needed. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `now` -- the caller's current unix timestamp (seconds) +/// +/// Outputs: +/// - `int64_t` - the renewal-target unix timestamp, or 0 for "no renewal needed". +LIBSESSION_EXPORT int64_t user_profile_get_pro_renewal_target(const config_object* conf, int64_t now); + #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 6a0aa38ed..59a405540 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -393,6 +393,25 @@ class UserProfile : public ConfigBase { /// - `when` -- the timestamp (unix epoch seconds) at which the purchase was initiated, or /// nullopt to clear the marker. void set_pro_prepaid(std::optional when); + + /// API: user_profile/UserProfile::pro_renewal_target + /// + /// Decide when the client should (re)request a Session Pro proof, centralising logic clients + /// previously each implemented (and got inconsistently wrong). Returns the timestamp at which a + /// renewal should be attempted -- if it is <= the caller's clock, renew now; a future value can + /// be scheduled -- or nullopt when no renewal is needed. Given the stored proof and access + /// expiry: + /// - no proof, or the proof already expired -> `now` (fetch immediately); + /// - a valid proof with access expiry still more than an hour ahead -> an hour before the + /// proof expires (preemptive), nudged off a rotation-period boundary so all devices agree; + /// - otherwise (valid proof, entitlement ending or unknown) -> nullopt. + /// + /// Inputs: + /// - `now` -- the caller's current time. + /// + /// Outputs: + /// - `std::optional` - when to renew, or nullopt for "no renewal needed". + std::optional pro_renewal_target(sys_seconds now) const; }; } // namespace session::config diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index fb34778d1..b91c1edd7 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -54,6 +54,8 @@ namespace session { +using namespace std::literals; + /// Maximum number of UTF-16 code points a standard (non-Pro) message can use; a longer message must /// activate the Session Pro higher-character-limit feature. (The C `SESSION_PROTOCOL_*` symbols /// point at these.) @@ -81,6 +83,15 @@ static_assert(GET_PAYMENT_DETAILS_DOMAIN.size() == 16); enum ProProofVersion { ProProofVersion_v0 }; +/// 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. +inline constexpr auto PRO_ROTATING_SEED_PERIOD = 7 * 24h; + +/// How long before a proof's expiry a client preemptively renews it -- and, correspondingly, the +/// minimum remaining entitlement (access expiry beyond now) that makes a preemptive renewal worth +/// doing. See UserProfile::pro_renewal_target. +inline constexpr auto PRO_RENEWAL_LEAD = 60min; + enum class ProStatus { // Pro proof sig was not signed by the Pro backend key InvalidProBackendSig = SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG, diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 98bf2a577..949b3c8ce 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -300,6 +300,31 @@ void UserProfile::set_pro_prepaid(std::optional when) } } +std::optional UserProfile::pro_renewal_target( + std::chrono::sys_seconds now) const { + auto pro = get_pro_config(); + if (!pro) + return now; // no proof yet -> request one immediately + auto expiry = pro->proof.expiry_at; + if (expiry <= now) + return now; // proof expired -> request one immediately + + // Otherwise renew preemptively PRO_RENEWAL_LEAD before the proof expires, but only while + // entitlement clearly continues (access expiry at least that far ahead); a still-valid proof + // with no continuing entitlement is left to ride out. + auto access = get_pro_access_expiry(); + if (!access || *access - now <= PRO_RENEWAL_LEAD) + return std::nullopt; + + auto target = expiry - PRO_RENEWAL_LEAD; + // Nudge off a rotation-period boundary so every device floors this (shared) target into the + // same period and derives the same rotating seed for the renewal, even with small clock skew. + if (auto off = target.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; + off <= 15s || off >= PRO_ROTATING_SEED_PERIOD - 15s) + target -= 30s; + return target; +} + extern "C" { using namespace session; @@ -508,4 +533,10 @@ LIBSESSION_C_API void user_profile_set_pro_prepaid(config_object* conf, int64_t unbox(conf)->set_pro_prepaid(as_sys_seconds(prepaid_ts)); } +LIBSESSION_C_API int64_t user_profile_get_pro_renewal_target(const config_object* conf, int64_t now) { + if (auto t = unbox(conf)->pro_renewal_target(as_sys_seconds(now))) + return epoch_seconds(*t); + return 0; +} + } // extern "C" diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 760e8e3bb..74935a853 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -191,10 +191,10 @@ cleared_uc32 ProProof::rotating_seed( // Floor the timestamp to the rotation period, then hash master_seed[0:32] ‖ dec(period_start), // where dec() is canonical decimal ASCII -- the same integer encoding the rest of the Pro wire // uses (signed_message, §1.1), so there's one integer convention and no endianness to pin. - constexpr std::int64_t period = 7 * 24 * 60 * 60; // 604800 seconds - std::int64_t period_start = session::epoch_seconds(timestamp) / period * period; + auto period_start = timestamp - timestamp.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; char dec[20]; - auto [ptr, ec] = std::to_chars(dec, dec + sizeof(dec), period_start); + auto [ptr, ec] = std::to_chars(dec, dec + sizeof(dec), epoch_seconds(period_start)); + assert(ec == std::errc{}); // dec is large enough for any int64, so this cannot fail auto seed = master_seed.first(32); std::vector msg; diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 524be86eb..f864f3b97 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -717,4 +717,42 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { REQUIRE(profile.get_pro_prepaid().has_value()); profile.set_pro_prepaid(std::nullopt); CHECK_FALSE(profile.get_pro_prepaid().has_value()); + + // pro_renewal_target: centralised "when to renew" decision. + { + session::config::UserProfile pr{std::span{seed}, std::nullopt}; + + // No proof yet -> renew immediately. + CHECK(pr.pro_renewal_target(now) == now); + + 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); + }; + + // Valid proof (10 days out), entitlement a month out -> preemptive ~1h before expiry. + auto expiry = now + std::chrono::hours{24 * 10}; + store_proof(expiry); + pr.set_pro_access_expiry(now + std::chrono::hours{24 * 30}); + auto target = pr.pro_renewal_target(now); + REQUIRE(target.has_value()); + CHECK(*target <= expiry - std::chrono::minutes{59}); + CHECK(*target >= expiry - std::chrono::minutes{61}); + + // Same proof but entitlement ending within the hour -> don't renew. + pr.set_pro_access_expiry(now + std::chrono::minutes{30}); + CHECK_FALSE(pr.pro_renewal_target(now).has_value()); + + // No access expiry at all -> don't preemptively renew. + pr.set_pro_access_expiry(std::nullopt); + CHECK_FALSE(pr.pro_renewal_target(now).has_value()); + + // Expired proof -> renew immediately, regardless of entitlement. + store_proof(now - std::chrono::hours{1}); + CHECK(pr.pro_renewal_target(now) == now); + } } From a8311149523a4e155a6478489ea431a31d9a29e8 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 17:23:06 -0300 Subject: [PATCH 14/35] tests: use chrono duration literals for pro/refund timestamps Replace the verbose std::chrono::hours{...}/minutes{...} constructs in the refund/pro_prepaid/pro_renewal_target tests with duration literals (1h, 30min, 10 * 24h, ...). (weeks kept as std::chrono::weeks{2} -- no literal suffix, and it reads clearly.) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_config_userprofile.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index f864f3b97..7a722868d 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -662,7 +662,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // A recent request is returned as-is, and stamps the profile-updated timestamp so it // time-orders across devices. UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); - auto refund_at = now - std::chrono::hours{1}; + auto refund_at = now - 1h; profile.set_refund_requested(refund_at); CHECK(profile.get_refund_requested() == refund_at); CHECK(profile.get_profile_updated().time_since_epoch().count() != 456); @@ -685,7 +685,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // auto-clear when entitlement lands. (No proof, access expiry in the past -> not currently pro.) CHECK_FALSE(profile.get_pro_prepaid().has_value()); - auto prepaid_at = now - std::chrono::hours{1}; + auto prepaid_at = now - 1h; profile.set_pro_prepaid(prepaid_at); CHECK(profile.get_pro_prepaid() == prepaid_at); @@ -696,7 +696,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // Confirming a live access expiry clears the marker (entitlement arrived). profile.set_pro_prepaid(prepaid_at); REQUIRE(profile.get_pro_prepaid().has_value()); - profile.set_pro_access_expiry(now + std::chrono::hours{1}); + profile.set_pro_access_expiry(now + 1h); CHECK_FALSE(profile.get_pro_prepaid().has_value()); // While pro (live access expiry), setting the marker is a no-op. @@ -707,7 +707,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { profile.set_pro_access_expiry(std::nullopt); profile.set_pro_prepaid(prepaid_at); REQUIRE(profile.get_pro_prepaid().has_value()); - pro_cpp.proof.expiry_at = now + std::chrono::hours{1}; + pro_cpp.proof.expiry_at = now + 1h; profile.set_pro_config(pro_cpp); CHECK_FALSE(profile.get_pro_prepaid().has_value()); @@ -735,16 +735,16 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { }; // Valid proof (10 days out), entitlement a month out -> preemptive ~1h before expiry. - auto expiry = now + std::chrono::hours{24 * 10}; + auto expiry = now + 10 * 24h; store_proof(expiry); - pr.set_pro_access_expiry(now + std::chrono::hours{24 * 30}); + pr.set_pro_access_expiry(now + 30 * 24h); auto target = pr.pro_renewal_target(now); REQUIRE(target.has_value()); - CHECK(*target <= expiry - std::chrono::minutes{59}); - CHECK(*target >= expiry - std::chrono::minutes{61}); + CHECK(*target <= expiry - 59min); + CHECK(*target >= expiry - 61min); // Same proof but entitlement ending within the hour -> don't renew. - pr.set_pro_access_expiry(now + std::chrono::minutes{30}); + pr.set_pro_access_expiry(now + 30min); CHECK_FALSE(pr.pro_renewal_target(now).has_value()); // No access expiry at all -> don't preemptively renew. @@ -752,7 +752,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK_FALSE(pr.pro_renewal_target(now).has_value()); // Expired proof -> renew immediately, regardless of entitlement. - store_proof(now - std::chrono::hours{1}); + store_proof(now - 1h); CHECK(pr.pro_renewal_target(now) == now); } } From f10f1e84aa0058dc2f504ea5bbbf91bd292003e4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 17:23:06 -0300 Subject: [PATCH 15/35] pro_message: assert the previously-ignored to_chars ec The signed-message builder discarded std::to_chars's error code. The buffer is sized for any integer so it cannot actually fail, but assert the invariant rather than silently trusting it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pro_message.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pro_message.hpp b/src/pro_message.hpp index 6254488ec..09614ad2d 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -35,6 +36,7 @@ std::vector signed_message(std::string_view domain, const Fields& buf.push_back('\0'); char tmp[24]; // enough for -9223372036854775808 (20 chars) auto [ptr, ec] = std::to_chars(tmp, tmp + sizeof(tmp), field); + assert(ec == std::errc{}); // tmp is large enough for any integer, so this cannot fail buf.insert(buf.end(), tmp, ptr); prev_var = true; } else if constexpr (std::convertible_to) { From 6c052575dd466867c5503d57967c8609669d0eb0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:45:37 -0300 Subject: [PATCH 16/35] config: use maybe_span instead of maybe_vector for pro/group key loads These load paths only read the dict bytes to memcpy them into a fixed buffer, so the maybe_vector heap allocation was unnecessary; maybe_span returns a view into the config dict value instead. --- src/config/convo_info_volatile.cpp | 10 +++------- src/config/user_groups.cpp | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/config/convo_info_volatile.cpp b/src/config/convo_info_volatile.cpp index 4b2ee13f1..aa47a789c 100644 --- a/src/config/convo_info_volatile.cpp +++ b/src/config/convo_info_volatile.cpp @@ -159,15 +159,11 @@ namespace convo { base::load(info_dict); auto pro_expiry = int_or_0(info_dict, "e"); - std::optional> maybe_pro_revocation_tag = - maybe_vector(info_dict, "g"); - if (pro_expiry > 0 && maybe_pro_revocation_tag && maybe_pro_revocation_tag->size() == 32) { + auto tag = maybe_span(info_dict, "g"); + if (pro_expiry > 0 && tag && tag->size() == 32) { pro_expiry_at = as_sys_seconds(pro_expiry); pro_revocation_tag.emplace(); - std::memcpy( - pro_revocation_tag->data(), - maybe_pro_revocation_tag->data(), - pro_revocation_tag->size()); + std::memcpy(pro_revocation_tag->data(), tag->data(), pro_revocation_tag->size()); } } diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index a87638d7f..aa9257b4a 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -230,7 +230,7 @@ void group_info::load(const dict& info_dict) { else name.clear(); - if (auto seed = maybe_vector(info_dict, "K"); seed && seed->size() == 32) { + if (auto seed = maybe_span(info_dict, "K"); seed && seed->size() == 32) { std::array pk; pk[0] = 0x03; secretkey.resize(64); From 77c15ff586d430f23de18f6f9246aa41443ea76b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:55:40 -0300 Subject: [PATCH 17/35] session_protocol: drop the ProSignedMessage struct ProSignedMessage bundled exactly verify_message's two arguments (a 64-byte signature and the message it signs) and existed only to be passed, wrapped in std::optional, as the last argument of ProProof::status -- every call site had to populate the struct first. Pass the two directly instead: an optional 64-byte signature span plus the message span. Call sites hand the values over without the wrapper, and the C API stops building an intermediate C++ struct. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.hpp | 16 +++++++-------- src/session_protocol.cpp | 30 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index b91c1edd7..3ab8f1ed1 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -101,11 +101,6 @@ enum class ProStatus { Expired = SESSION_PROTOCOL_PRO_STATUS_EXPIRED, // Proof is verified; has expired }; -struct ProSignedMessage { - std::span sig; - std::span msg; -}; - class ProProof { public: /// Version of the proof set by the Session Pro Backend @@ -186,17 +181,20 @@ class ProProof { /// they are the original signatory of the proof. /// - `unix_ts` -- Unix timestamp to compared against the embedded `expiry_at` /// to determine if the proof has expired or not - /// - `signed_msg` -- Optionally set the payload to the message with the signature to verify if - /// the embedded `rotating_pubkey` in the proof signed the given message. + /// - `user_sig` -- optionally, the user's 64-byte signature over `signed_msg`; when set, this + /// verifies that the proof's embedded `rotating_pubkey` produced it. Omit (the default) to + /// skip the user-signature check. + /// - `signed_msg` -- the message bytes that `user_sig` signs; ignored when `user_sig` is unset. /// /// Outputs: - /// - `ProStatus` - The derived status given the components of the message. If `signed_msg` is + /// - `ProStatus` - The derived status given the components of the message. If `user_sig` is /// not set then this function can never return `ProStatus::InvalidUserSig` from the set of /// possible enum values. Otherwise this funtion can return all possible values. ProStatus status( std::span verify_pubkey, sys_seconds unix_ts, - const std::optional& signed_msg); + std::optional> user_sig = std::nullopt, + std::span signed_msg = {}); /// API: pro/Proof::signed_message /// diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 74935a853..aa2bbe0e6 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -159,7 +159,8 @@ bool ProProof::is_active(sys_seconds unix_ts) const { ProStatus ProProof::status( std::span verify_pubkey, sys_seconds unix_ts, - const std::optional& signed_msg) { + std::optional> user_sig, + std::span signed_msg) { ProStatus result = ProStatus::Valid; // Verify the at the proof is verified by the Session Pro Backend key (e.g.: It was // issued by an authoritative backend) @@ -167,8 +168,8 @@ ProStatus ProProof::status( result = ProStatus::InvalidProBackendSig; // Check if the message was signed if the user passed one in to verify against - if (result == ProStatus::Valid && signed_msg) { - if (!verify_message(signed_msg->sig, signed_msg->msg)) + if (result == ProStatus::Valid && user_sig) { + if (!verify_message(*user_sig, signed_msg)) result = ProStatus::InvalidUserSig; } @@ -908,16 +909,14 @@ DecodedEnvelope decode_envelope( // Evaluate the pro status given the extracted components (was it signed, is it expired, // was the message signed validly?) - ProSignedMessage signed_msg = {}; - signed_msg.sig = to_span(pro_sig); - + // // 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()))); - signed_msg.msg = to_span(envelope.content()); - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, unix_ts, to_span(pro_sig), to_span(envelope.content())); } } return result; @@ -1068,9 +1067,7 @@ DecodedCommunityMessage decode_for_community( // Evaluate the pro status given the extracted components (was it signed, is it expired, // was the message signed validly?) - ProSignedMessage signed_msg = {}; - signed_msg.sig = to_span(*result.pro_sig); - + // // 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` @@ -1079,8 +1076,8 @@ DecodedCommunityMessage decode_for_community( // 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); - signed_msg.msg = result.content_plaintext; - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, unix_ts, to_span(*result.pro_sig), result.content_plaintext); } else { SessionProtos::Content content_copy_without_sig = content; assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); @@ -1093,8 +1090,11 @@ DecodedCommunityMessage decode_for_community( std::vector content_copy_without_sig_payload = pad_message(to_span(content_copy_without_sig.SerializeAsString())); - signed_msg.msg = to_span(content_copy_without_sig_payload); - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, + unix_ts, + to_span(*result.pro_sig), + to_span(content_copy_without_sig_payload)); } } From 0d6c0a5f84cc614c6b71a7064a17a2a8aa3765d6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:57:02 -0300 Subject: [PATCH 18/35] pro_backend: drop the redundant public *_sig(s) helpers pro_proof_sigs, pro_status_sig and payment_details_sig were public but unused: each *_request() already signs internally (via the private *_sign helpers), and nothing in the C API or tests called the public wrappers. They split the request-building API across two public functions for no benefit. Drop the three wrappers; the request builders and the private signing helpers they use are unchanged. (MasterRotatingSignatures stays, unlike on the source branch, because here it is also the private generate_proof_sign helper's return type.) Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.hpp | 29 +++++++---------------------- src/pro_backend.cpp | 24 ------------------------ 2 files changed, 7 insertions(+), 46 deletions(-) diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 9b634ab39..8234ed519 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -188,18 +188,12 @@ GenerateProProofResponse parse_pro_proof(std::string_view json); /// request (this one included) binds the account's unbound payments before answering; there is no /// separate "add payment" step. This pairs a (new) rotating key to a freshly-issued proof. /// -/// `pro_proof_sigs` computes the master+rotating signatures; `pro_proof_request` builds the whole -/// request (signing internally) and returns the endpoint + JSON body. Both throw on an -/// incorrectly-sized key. +/// Builds the whole request, signing internally with the master and rotating keys, and returns the +/// endpoint + JSON body. Throws on an incorrectly-sized key. /// /// Inputs: /// - `master_privkey` / `rotating_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium private key /// - `unix_ts` -- Unix timestamp for the request -MasterRotatingSignatures pro_proof_sigs( - std::span master_privkey, - std::span rotating_privkey, - sys_seconds unix_ts); - ProRequest pro_proof_request( std::span master_privkey, std::span rotating_privkey, @@ -246,21 +240,18 @@ struct GetProRevocationsResponse : ResponseBase { GetProRevocationsResponse parse_revocations(std::string_view json); /// Query a master key's Session Pro status (endpoint `get_pro_status`): the account entitlement -/// state plus its single latest payment -- the hot "am I Pro?" path. `pro_status_sig` computes the -/// master signature; `pro_status_request` builds the whole request (signing internally) and returns -/// the endpoint + JSON body. Both throw on an incorrectly-sized key. +/// state plus its single latest payment -- the hot "am I Pro?" path. Builds the whole request, +/// signing internally with the master key, and returns the endpoint + JSON body. Throws on an +/// incorrectly-sized key. /// /// Inputs: /// - `master_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium master private key /// - `unix_ts` -- Unix timestamp for the request -array_uc64 pro_status_sig(std::span master_privkey, sys_seconds unix_ts); - ProRequest pro_status_request(std::span master_privkey, sys_seconds unix_ts); /// Query a master key's Session Pro payment history (endpoint `get_payment_details`), one keyset -/// page at a time. `payment_details_sig` computes the master signature; `payment_details_request` -/// builds the whole request (signing internally) and returns the endpoint + JSON body. Both throw -/// on an incorrectly-sized key. +/// page at a time. Builds the whole request, signing internally with the master key, and returns +/// the endpoint + JSON body. Throws on an incorrectly-sized key. /// /// Inputs: /// - `master_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium master private key @@ -269,12 +260,6 @@ ProRequest pro_status_request(std::span master_privkey, sys_secon /// - `before` -- opaque pagination cursor from a previous page's `next_cursor` (see /// PaymentDetailsResponse); the empty string requests the newest page. Pass it through verbatim; /// it must not be parsed or synthesized. -array_uc64 payment_details_sig( - std::span master_privkey, - sys_seconds unix_ts, - uint32_t limit, - std::string_view before); - ProRequest payment_details_request( std::span master_privkey, sys_seconds unix_ts, diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 3938b9e6c..eaed14740 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -407,15 +407,6 @@ namespace { } // namespace -MasterRotatingSignatures pro_proof_sigs( - std::span master_privkey, - std::span rotating_privkey, - std::chrono::sys_seconds unix_ts) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - return generate_proof_sign(master, rotating, unix_ts); -} - ProRequest pro_proof_request( std::span master_privkey, std::span rotating_privkey, @@ -608,12 +599,6 @@ namespace { } // namespace -array_uc64 pro_status_sig( - std::span master_privkey, std::chrono::sys_seconds unix_ts) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return pro_status_sign(master, unix_ts); -} - ProRequest pro_status_request( std::span master_privkey, std::chrono::sys_seconds unix_ts) { auto master = normalize_privkey(master_privkey, "master_privkey"); @@ -623,15 +608,6 @@ ProRequest pro_status_request( pro_status_body(pubkey_of(master), sig, unix_ts)}; } -array_uc64 payment_details_sig( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - uint32_t limit, - std::string_view before) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return payment_details_sign(master, unix_ts, limit, before); -} - ProRequest payment_details_request( std::span master_privkey, std::chrono::sys_seconds unix_ts, From bb388ba6835984e4e035e09b447fd8cf4438f5e6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:57:24 -0300 Subject: [PATCH 19/35] session_protocol: make ProProof::status const status only reads the proof (verify_signature, verify_message and is_active are all const), so mark it const and let callers evaluate a const ProProof&. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.hpp | 2 +- src/session_protocol.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 3ab8f1ed1..1d23b1aa1 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -194,7 +194,7 @@ class ProProof { std::span verify_pubkey, sys_seconds unix_ts, std::optional> user_sig = std::nullopt, - std::span signed_msg = {}); + std::span signed_msg = {}) const; /// API: pro/Proof::signed_message /// diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index aa2bbe0e6..f75cde7c4 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -160,7 +160,7 @@ ProStatus ProProof::status( std::span verify_pubkey, sys_seconds unix_ts, std::optional> user_sig, - std::span signed_msg) { + std::span signed_msg) const { ProStatus result = ProStatus::Valid; // Verify the at the proof is verified by the Session Pro Backend key (e.g.: It was // issued by an authoritative backend) From 41c05ee2ea3b597376617837388e8be743cfa0de Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:58:12 -0300 Subject: [PATCH 20/35] session_protocol: fix stale doc comments pro_features_for_utf8/16 documented a 'success' bool and a 'features'/'bitset' output; the function returns a ProFeaturesForMsg with a 'status' enum and a 'bitset' field. Update the Outputs to match. (The pfs source commit also retargeted a stale decode_envelope doc block, but that drift is not present on this branch: decode_envelope is still the unified keys-object entry point the block describes.) Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.hpp | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 1d23b1aa1..71bdd7830 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -404,12 +404,11 @@ struct DecodeEnvelopeKey { /// - `text_size` -- the size of the message in UTF8 code units to determine if the message requires /// access to the higher character limit available in Session Pro /// -/// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. This string is read-only and should not be modified. -/// - `features` -- Feature flags suitable for writing directly into the protobuf +/// Outputs (a ProFeaturesForMsg): +/// - `status` -- Success, or the reason evaluation failed (UTF decoding error, or over the character +/// limit). When not Success, only `error` is meaningful. +/// - `error` -- On a non-Success `status`, a read-only description of the failure; empty otherwise. +/// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` /// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. ProFeaturesForMsg pro_features_for_utf8(const char* text, size_t text_size); @@ -425,11 +424,10 @@ ProFeaturesForMsg pro_features_for_utf8(const char* text, size_t text_size); /// requires /// access to the higher character limit available in Session Pro /// -/// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. This string is read-only and should not be modified. +/// Outputs (a ProFeaturesForMsg): +/// - `status` -- Success, or the reason evaluation failed (UTF decoding error, or over the character +/// limit). When not Success, only `error` is meaningful. +/// - `error` -- On a non-Success `status`, a read-only description of the failure; empty otherwise. /// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` /// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. From 3c9f266234b046b5f472bfdc5929e3ec7d309852 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:59:10 -0300 Subject: [PATCH 21/35] session_protocol: use a cheap validly-encoded decoy signature The pro-signature attachment always writes a 64-byte prosig so Pro and non-Pro envelopes are indistinguishable on the wire. The no-Pro branch was generating a throwaway keypair and signing the message content -- two scalarmults plus hashing the whole content. Replace it with a decoy signature built inline, with the same structure and distribution as a real Ed25519 signature but signing nothing: R = r*B for a random scalar r (a prime-order-subgroup point, like a real R, via base_noclamp -- a directly-sampled group element would be off the main subgroup ~7/8 of the time and thus detectable), and s a second random scalar in ]0, L[. It is never verified or relied on; it exists only as a wire decoy. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/session_protocol.cpp | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index f75cde7c4..99d8d1518 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include #include @@ -486,22 +488,23 @@ static EncryptedForDestinationInternal encode_for_destination_internal( envelope.set_content(content.data(), content.size()); // Generate the session pro signature. If there's no pro ed25519 key specified, we still - // fill out the pro signature with a valid but unverifiable signature by creating a - // throw-away key. This makes pro and non-pro messages indistinguishable on the wire. + // fill out the pro signature with a decoy (validly-encoded but unverifiable) signature. + // This makes pro and non-pro messages indistinguishable on the wire. { std::string* pro_sig = envelope.mutable_prosig(); pro_sig->resize(crypto_sign_ed25519_BYTES); if (dest_pro_rotating_ed25519_privkey.empty()) { - uc32 ignore_pk; - cleared_uc64 dummy_pro_ed_sk; - crypto_sign_ed25519_keypair(ignore_pk.data(), dummy_pro_ed_sk.data()); - crypto_sign_ed25519_detached( - reinterpret_cast(pro_sig->data()), - nullptr, - content.data(), - content.size(), - dummy_pro_ed_sk.data()); + // No pro key: attach a decoy signature -- a validly-encoded Ed25519 signature + // (R = r·B for a random scalar r, so a prime-order-subgroup point like a real R; + // s a second random scalar) that verifies against nothing. Keeps pro and non-pro + // envelopes indistinguishable on the wire without signing throwaway data. NOT a + // real signature; never verified. + auto* sig = reinterpret_cast(pro_sig->data()); + std::array r; + crypto_core_ed25519_scalar_random(r.data()); + crypto_scalarmult_ed25519_base_noclamp(sig, r.data()); + crypto_core_ed25519_scalar_random(sig + crypto_core_ed25519_BYTES); } else { crypto_sign_ed25519_detached( reinterpret_cast(pro_sig->data()), From 699f823cab79210f5a743d545eecb60267f8b63f Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 14:05:33 -0300 Subject: [PATCH 22/35] Fix unused parameters These are all silenced by commenting out (or, for the tag-dispatch opt overloads where the type is the tag, dropping) the parameter name; no parameter that is actually used was removed. Adds a WARN_UNUSED_PARAMETERS cmake option to turn these on. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 1 + .../network/transport/network_transport.hpp | 2 +- src/CMakeLists.txt | 6 ++++ src/attachments.cpp | 4 +-- src/network/network_config.cpp | 10 +++---- src/network/routing/direct_router.cpp | 2 +- src/network/routing/onion_request_router.cpp | 2 +- src/network/routing/session_router_router.cpp | 2 +- src/network/session_network.cpp | 12 ++++---- src/network/snode_pool.cpp | 2 +- src/network/transport/quic_transport.cpp | 8 +++--- tests/test_onion_request_router.cpp | 28 +++++++++---------- tests/test_snode_pool.cpp | 4 +-- 13 files changed, 45 insertions(+), 38 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f12f105e3..8820384b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ else() endif() option(WARNINGS_AS_ERRORS "Treat all compiler warnings as errors" OFF) +option(WARN_UNUSED_PARAMETERS "Enabled unused parameter warnings" ON) option(STATIC_LIBSTD "Statically link libstdc++/libgcc" ${default_static_libstd}) diff --git a/include/session/network/transport/network_transport.hpp b/include/session/network/transport/network_transport.hpp index d9cf04802..fd914a39e 100644 --- a/include/session/network/transport/network_transport.hpp +++ b/include/session/network/transport/network_transport.hpp @@ -15,7 +15,7 @@ class ITransport { virtual void close_connections() = 0; virtual ConnectionStatus get_status() const = 0; - virtual void set_node_failure_reporter(node_failure_reporter_t reporter) {} + virtual void set_node_failure_reporter(node_failure_reporter_t /*reporter*/) {} virtual void verify_connectivity( service_node node, std::chrono::milliseconds timeout, diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7530af298..e6815bc3a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,12 @@ if(WARNINGS_AS_ERRORS) endif() endif() +# -Wunused-parameter isn't in the default warning set (we don't pass -Wextra), so enable it +# explicitly. WARNINGS_AS_ERRORS's -Werror (above) then promotes it to an error. +if(WARN_UNUSED_PARAMETERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(common INTERFACE -Wunused-parameter) +endif() + set(export_targets) macro(add_libsession_util_library name) diff --git a/src/attachments.cpp b/src/attachments.cpp index 9ad0b1337..7b66ed9ad 100644 --- a/src/attachments.cpp +++ b/src/attachments.cpp @@ -301,7 +301,7 @@ std::array encrypt( const std::filesystem::path& file, Domain domain, std::function(size_t enc_size)> make_buffer, - bool allow_large) { + bool /*allow_large*/) { std::ifstream in; in.exceptions(std::ios::badbit); @@ -660,7 +660,7 @@ void Decryptor::process_header(std::span hd header = true; } -void Decryptor::process_chunk(std::span chunk, bool is_final) { +void Decryptor::process_chunk(std::span chunk, bool /*is_final*/) { if (hit_final) { failed = true; return; diff --git a/src/network/network_config.cpp b/src/network/network_config.cpp index dde0b2b20..e97aca9ea 100644 --- a/src/network/network_config.cpp +++ b/src/network/network_config.cpp @@ -107,7 +107,7 @@ void Config::handle_config_opt(opt::file_server_use_stream_encryption fsuse) { // MARK: General options -void Config::handle_config_opt(opt::increase_no_file_limit dsd) { +void Config::handle_config_opt(opt::increase_no_file_limit) { increase_no_file_limit = true; log::debug(cat, "Network config will attempt to increase the NOFILE limit"); } @@ -117,7 +117,7 @@ void Config::handle_config_opt(opt::path_length pl) { log::debug(cat, "Network config path length set to {}", pl.length); } -void Config::handle_config_opt(opt::disable_subnet_diversity dsd) { +void Config::handle_config_opt(opt::disable_subnet_diversity) { enforce_subnet_diversity = false; log::debug(cat, "Network config disabled subnet diversity"); } @@ -235,7 +235,7 @@ void Config::handle_config_opt(opt::quic_keep_alive qka) { log::debug(cat, "Network config quic keep alive set to {}s", qka.duration.count()); } -void Config::handle_config_opt(opt::quic_disable_mtu_discovery qdmd) { +void Config::handle_config_opt(opt::quic_disable_mtu_discovery) { quic_disable_mtu_discovery = true; log::debug(cat, "Network config disabled MTU discovery for Quic"); } @@ -262,12 +262,12 @@ void Config::handle_config_opt(opt::onionreq_min_path_count mpc) { mpc.min_count); } -void Config::handle_config_opt(opt::onionreq_single_path_mode spm) { +void Config::handle_config_opt(opt::onionreq_single_path_mode) { onionreq_single_path_mode = true; log::debug(cat, "Network config onion requests set to single path mode"); } -void Config::handle_config_opt(opt::onionreq_disable_pre_build_paths dpbp) { +void Config::handle_config_opt(opt::onionreq_disable_pre_build_paths) { onionreq_disable_pre_build_paths = true; log::debug(cat, "Network config disabled pre-building onion request paths"); } diff --git a/src/network/routing/direct_router.cpp b/src/network/routing/direct_router.cpp index acba03026..6cc09f768 100644 --- a/src/network/routing/direct_router.cpp +++ b/src/network/routing/direct_router.cpp @@ -62,7 +62,7 @@ void DirectRouter::suspend() { }); } -void DirectRouter::resume(bool automatically_reconnect) { +void DirectRouter::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { if (!_suspended) diff --git a/src/network/routing/onion_request_router.cpp b/src/network/routing/onion_request_router.cpp index fe2147b90..9c14cf196 100644 --- a/src/network/routing/onion_request_router.cpp +++ b/src/network/routing/onion_request_router.cpp @@ -2177,7 +2177,7 @@ void OnionRequestRouter::_rotate_path(const std::string& path_id, PathCategory c pending_rotation.new_path, std::move(info_request), [weak_self = weak_from_this(), this, new_path_id, rotate_at = std::move(rotate_at)]( - bool success, bool timeout, int16_t status, auto headers, auto response) { + bool success, bool timeout, int16_t status, auto /*headers*/, auto /*response*/) { auto self = weak_self.lock(); if (!self) return; diff --git a/src/network/routing/session_router_router.cpp b/src/network/routing/session_router_router.cpp index 73c9e3106..cf1606d05 100644 --- a/src/network/routing/session_router_router.cpp +++ b/src/network/routing/session_router_router.cpp @@ -186,7 +186,7 @@ void SessionRouter::suspend() { }); } -void SessionRouter::resume(bool automatically_reconnect) { +void SessionRouter::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { if (!_suspended) diff --git a/src/network/session_network.cpp b/src/network/session_network.cpp index 823c26b22..d5c57ebf4 100644 --- a/src/network/session_network.cpp +++ b/src/network/session_network.cpp @@ -85,7 +85,7 @@ namespace { } config::DirectRouter build_direct_router_config( - const config::Config& main_config, const config::FileServer& file_server_config) { + const config::Config& /*main_config*/, const config::FileServer& file_server_config) { return {file_server_config}; } @@ -965,7 +965,7 @@ void Network::_launch_next_clock_out_of_sync_request( bool success, bool timeout, int16_t status_code, - std::vector> headers, + std::vector> /*headers*/, std::optional response) { auto end_steady = std::chrono::steady_clock::now(); auto end_system = sysclock_now_ms(); @@ -1031,7 +1031,7 @@ void Network::_launch_next_clock_out_of_sync_request( }); } -void Network::_on_clock_resync_complete(const uint8_t total_requests) { +void Network::_on_clock_resync_complete(const uint8_t /*total_requests*/) { auto raw_results = std::move(_clock_resync_results); auto refresh_id = std::move(*_current_clock_resync_id); @@ -1508,7 +1508,7 @@ LIBSESSION_C_API void session_network_set_network_info_changed_callback( } LIBSESSION_C_API void session_network_callbacks_respond( - network_object* network, + network_object* /*network*/, session_response_handle_t* response_handle, bool success, bool timeout, @@ -1566,7 +1566,7 @@ LIBSESSION_C_API void session_network_get_active_paths( size_t total_metadata_size = 0; for (const auto& p : cpp_paths) { std::visit( - [&](const T& md) { + [&](const T& /*md*/) { if constexpr (std::is_same_v) total_metadata_size += sizeof(session_onion_path_metadata); else { @@ -1872,7 +1872,7 @@ LIBSESSION_C_API session_download_handle_t* session_network_download( int64_t stall_timeout_ms, int64_t request_timeout_ms, int64_t overall_timeout_ms, - int64_t partial_min_interval_ms, + int64_t /*partial_min_interval_ms*/, int8_t desired_path_index) { if (!network || !download_url || !callbacks) diff --git a/src/network/snode_pool.cpp b/src/network/snode_pool.cpp index 41f2c6926..d96a7323f 100644 --- a/src/network/snode_pool.cpp +++ b/src/network/snode_pool.cpp @@ -602,7 +602,7 @@ void SnodePool::_launch_next_refresh_request( bool success, bool timeout, int16_t status_code, - std::vector> headers, + std::vector> /*headers*/, std::optional response) { // If the refresh was cancelled or completed while we were in-flight, do nothing if (!_current_snode_cache_refresh_id || diff --git a/src/network/transport/quic_transport.cpp b/src/network/transport/quic_transport.cpp index c9c161273..d0b120404 100644 --- a/src/network/transport/quic_transport.cpp +++ b/src/network/transport/quic_transport.cpp @@ -60,7 +60,7 @@ void QuicTransport::suspend() { }); } -void QuicTransport::resume(bool automatically_reconnect) { +void QuicTransport::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { // Recreate the endpoint before updating the `_suspended` flag to avoid the chance that @@ -85,7 +85,7 @@ void QuicTransport::set_node_failure_reporter(node_failure_reporter_t reporter) void QuicTransport::verify_connectivity( service_node node, - std::chrono::milliseconds timeout, + std::chrono::milliseconds /*timeout*/, const std::string& request_id, const RequestCategory category, std::function error_code)> callback) { @@ -285,7 +285,7 @@ void QuicTransport::_send_request_internal(Request request, network_response_cal void QuicTransport::_establish_connection( const oxen::quic::RemoteAddress& address, const std::string& initiating_req_id, - const RequestCategory category) { + const RequestCategory /*category*/) { const auto address_pubkey_hex = oxenc::to_hex(address.view_remote_key()); try { @@ -555,7 +555,7 @@ void QuicTransport::_send_on_connection( void QuicTransport::_fail_connection( const std::string& address_pubkey_hex, const std::string& initiating_req_id, - std::optional conn_id, + std::optional /*conn_id*/, std::optional error_code, std::optional custom_error) { if (error_code == NGTCP2_NO_ERROR) diff --git a/tests/test_onion_request_router.cpp b/tests/test_onion_request_router.cpp index e48e31698..8913315d8 100644 --- a/tests/test_onion_request_router.cpp +++ b/tests/test_onion_request_router.cpp @@ -153,16 +153,16 @@ namespace { } void refresh_if_needed( - const std::vector& in_use_nodes, - std::function on_refresh_complete = nullptr) override { + const std::vector& /*in_use_nodes*/, + std::function /*on_refresh_complete*/ = nullptr) override { func_called("refresh_if_needed"); // Do nothing (don't want to trigger a cache refresh) } void get_swarm( - session::network::x25519_pubkey swarm_pubkey, - bool ignore_strike_count, - std::function)> callback) + session::network::x25519_pubkey /*swarm_pubkey*/, + bool /*ignore_strike_count*/, + std::function)> /*callback*/) override { func_called("get_swarm"); // Do nothing (don't want to trigger a cache refresh) @@ -183,28 +183,28 @@ namespace { class TestTransport : public ITransport, public CallTracker { public: void suspend() override { func_called("suspend"); }; - void resume(bool automatically_reconnect = true) override { func_called("resume"); }; + void resume(bool /*automatically_reconnect*/ = true) override { func_called("resume"); }; void close_connections() override { func_called("close_connections"); }; ConnectionStatus get_status() const override { return ConnectionStatus::unknown; }; void verify_connectivity( - service_node node, - std::chrono::milliseconds timeout, - const std::string& request_id, - const RequestCategory category, - std::function error_code)> callback) + service_node /*node*/, + std::chrono::milliseconds /*timeout*/, + const std::string& /*request_id*/, + const RequestCategory /*category*/, + std::function error_code)> /*callback*/) override { func_called("verify_connectivity"); } void add_failure_listener( - const ed25519_pubkey& pubkey, std::function listener) override { + const ed25519_pubkey& /*pubkey*/, std::function /*listener*/) override { func_called("add_failure_listener"); } - void remove_failure_listeners(const ed25519_pubkey& pubkey) override { + void remove_failure_listeners(const ed25519_pubkey& /*pubkey*/) override { func_called("remove_failure_listeners"); } - void send_request(Request request, network_response_callback_t callback) override { + void send_request(Request /*request*/, network_response_callback_t /*callback*/) override { func_called("send_request"); } }; diff --git a/tests/test_snode_pool.cpp b/tests/test_snode_pool.cpp index 107233ecc..7fa4e3ebb 100644 --- a/tests/test_snode_pool.cpp +++ b/tests/test_snode_pool.cpp @@ -31,8 +31,8 @@ class TestSnodePool : public SnodePool { } void refresh_if_needed( - const std::vector& in_use_nodes, - std::function on_refresh_complete = nullptr) override { + const std::vector& /*in_use_nodes*/, + std::function /*on_refresh_complete*/ = nullptr) override { // Do nothing (don't want to trigger a cache refresh) } }; From cd9384790b9f602a567c98008a8af4123430f7dd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 14:13:30 -0300 Subject: [PATCH 23/35] session_protocol: drop the unused sent_timestamp from encode_for_community_inbox encode_for_community_inbox threads sent_timestamp into the shared Destination struct, but encode_for_destination_internal only consumes dest.sent_timestamp_ms on the envelope-based Group/1o1 path -- the CommunityInbox branch never reads it. So the parameter was dead here, laundered through the struct (which is why -Wunused-parameter didn't flag it). Drop it from the C++ and C signatures and the test caller; the Destination field is left explicitly zeroed with a note since the struct is shared with the paths that do use it. Matches the pfs branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.h | 4 +--- include/session/session_protocol.hpp | 2 -- src/session_protocol.cpp | 8 ++++---- tests/test_session_protocol.cpp | 1 - 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 53f6336f7..d71d8b98b 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -505,7 +505,6 @@ session_protocol_encoded_for_destination session_protocol_encode_for_1o1( /// - `ed25519_privkey` -- The sender's libsodium-style secret key (64 bytes). Can also be passed as /// a 32-byte seed. Used to encrypt the plaintext. /// - `ed25519_privkey_len` -- The length of the ed25519_privkey buffer in bytes (32 or 64). -/// - `sent_timestamp_ms` -- The timestamp to assign to the message envelope, in milliseconds. /// - `recipient_pubkey` -- The recipient's Session public key (33 bytes). /// - `community_pubkey` -- The community inbox server's public key (32 bytes). /// - `pro_rotating_ed25519_privkey` -- Optional rotating Session Pro Ed25519 key (64-bytes or @@ -541,13 +540,12 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i size_t plaintext_len, const void* ed25519_privkey, size_t ed25519_privkey_len, - uint64_t sent_timestamp_ms, const bytes33* recipient_pubkey, const bytes32* community_pubkey, OPTIONAL const void* pro_rotating_ed25519_privkey, size_t pro_rotating_ed25519_privkey_len, OPTIONAL char* error, - size_t error_len) NON_NULL_ARG(1, 3, 6, 7); + size_t error_len) NON_NULL_ARG(1, 3, 5, 6); /// API: session_protocol_encode_for_community /// diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 71bdd7830..cd00aa25a 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -489,7 +489,6 @@ std::vector encode_for_1o1( /// not be already encrypted and must not be padded. /// - ed25519_privkey -- The sender's libsodium-style secret key (64 bytes). Can also be passed as /// a 32-byte seed. Used to encrypt the plaintext. -/// - sent_timestamp -- The timestamp to assign to the message envelope, in milliseconds. /// - recipient_pubkey -- The recipient's Session public key (33 bytes). /// - community_pubkey -- The community inbox server's public key (32 bytes). /// - pro_rotating_ed25519_privkey -- Optional libsodium-style secret key (64 bytes) that is the @@ -503,7 +502,6 @@ std::vector encode_for_1o1( std::vector encode_for_community_inbox( std::span plaintext, std::span ed25519_privkey, - std::chrono::milliseconds sent_timestamp, const array_uc33& recipient_pubkey, const array_uc32& community_pubkey, std::optional> pro_rotating_ed25519_privkey); diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 99d8d1518..532d77dc5 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -308,7 +308,6 @@ std::vector encode_for_1o1( std::vector encode_for_community_inbox( std::span plaintext, std::span ed25519_privkey, - std::chrono::milliseconds sent_timestamp, const array_uc33& recipient_pubkey, const array_uc32& community_pubkey, std::optional> pro_rotating_ed25519_privkey) { @@ -316,7 +315,8 @@ std::vector encode_for_community_inbox( dest.type = DestinationType::CommunityInbox; dest.pro_rotating_ed25519_privkey = pro_rotating_ed25519_privkey ? *pro_rotating_ed25519_privkey : std::span{}; - dest.sent_timestamp_ms = sent_timestamp; + // Unused on the CommunityInbox path (only the envelope-based Group/1o1 path consumes it). + dest.sent_timestamp_ms = std::chrono::milliseconds{0}; dest.recipient_pubkey = recipient_pubkey; dest.community_inbox_server_pubkey = community_pubkey; std::vector result = encode_for_destination(plaintext, ed25519_privkey, dest); @@ -1302,7 +1302,6 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i size_t plaintext_len, const void* ed25519_privkey, size_t ed25519_privkey_len, - uint64_t sent_timestamp_ms, const bytes33* recipient_pubkey, const bytes32* community_pubkey, const void* pro_rotating_ed25519_privkey, @@ -1314,7 +1313,8 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i dest.type = SESSION_PROTOCOL_DESTINATION_TYPE_COMMUNITY_INBOX; dest.pro_rotating_ed25519_privkey = pro_rotating_ed25519_privkey; dest.pro_rotating_ed25519_privkey_len = pro_rotating_ed25519_privkey_len; - dest.sent_timestamp_ms = sent_timestamp_ms; + // Unused on the CommunityInbox path (only the envelope-based Group/1o1 path consumes it). + dest.sent_timestamp_ms = 0; dest.recipient_pubkey = *recipient_pubkey; dest.community_inbox_server_pubkey = *community_pubkey; diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index ebc924a72..7771f97ae 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -883,7 +883,6 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { protobuf_content.plaintext.size(), keys.ed_sk0.data(), keys.ed_sk0.size(), - timestamp_ms.time_since_epoch().count(), &recipient_pubkey, &community_pubkey, nullptr, From 786551a731e96b15525107a4644f0eec3d5086b6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 14:26:08 -0300 Subject: [PATCH 24/35] session_encrypt/quic: drop parameters missed in the unused-parameters backport The unused-parameters backport (699f823c) only silenced dev's actual -Wunused-parameter warnings, so it missed the deliberate parameter drops from the pfs Fix-unused-parameters commit that don't surface as warnings on dev: - session_encrypt_for_group / session_decrypt_group_message: drop the group_ed25519_pubkey_len C-API argument. The group Ed25519 pubkey is always 32 bytes, so the length was pointless; the C wrappers now pass a fixed-size span. - QuicTransport::_fail_connection: drop the unused conn_id parameter (and the conn argument threaded to it at the call sites). (The pfs commit's blind_id_impl server_pk drop does not apply: dev has no shared blind_id_impl -- that helper is a pfs-only refactor.) Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/network/transport/quic_transport.hpp | 1 - include/session/session_encrypt.h | 2 -- src/network/transport/quic_transport.cpp | 12 +++--------- src/session_encrypt.cpp | 6 ++---- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/include/session/network/transport/quic_transport.hpp b/include/session/network/transport/quic_transport.hpp index fbccb7687..12f220b71 100644 --- a/include/session/network/transport/quic_transport.hpp +++ b/include/session/network/transport/quic_transport.hpp @@ -92,7 +92,6 @@ class QuicTransport : public ITransport, public std::enable_shared_from_this conn_id, std::optional error_code, std::optional custom_error); }; diff --git a/include/session/session_encrypt.h b/include/session/session_encrypt.h index 4aa45e9c1..6ded17298 100644 --- a/include/session/session_encrypt.h +++ b/include/session/session_encrypt.h @@ -126,7 +126,6 @@ LIBSESSION_EXPORT session_encrypt_group_message session_encrypt_for_group( const unsigned char* user_ed25519_privkey, size_t user_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* group_enc_key, size_t group_enc_key_len, const unsigned char* plaintext, @@ -285,7 +284,6 @@ LIBSESSION_EXPORT session_decrypt_group_message_result session_decrypt_group_mes const span_u8* decrypt_ed25519_privkey_list, size_t decrypt_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* ciphertext, size_t ciphertext_len, char* error, diff --git a/src/network/transport/quic_transport.cpp b/src/network/transport/quic_transport.cpp index d0b120404..392cbb88d 100644 --- a/src/network/transport/quic_transport.cpp +++ b/src/network/transport/quic_transport.cpp @@ -360,18 +360,13 @@ void QuicTransport::_establish_connection( } }, [weak_self = weak_from_this(), address_pubkey_hex, initiating_req_id]( - oxen::quic::Connection& conn, uint64_t error_code) { + oxen::quic::Connection&, uint64_t error_code) { if (auto self = weak_self.lock()) self->_fail_connection( - address_pubkey_hex, - initiating_req_id, - conn.reference_id(), - error_code, - std::nullopt); + address_pubkey_hex, initiating_req_id, error_code, std::nullopt); }); } catch (const std::exception& e) { - _fail_connection( - address_pubkey_hex, initiating_req_id, std::nullopt, std::nullopt, e.what()); + _fail_connection(address_pubkey_hex, initiating_req_id, std::nullopt, e.what()); } } @@ -555,7 +550,6 @@ void QuicTransport::_send_on_connection( void QuicTransport::_fail_connection( const std::string& address_pubkey_hex, const std::string& initiating_req_id, - std::optional /*conn_id*/, std::optional error_code, std::optional custom_error) { if (error_code == NGTCP2_NO_ERROR) diff --git a/src/session_encrypt.cpp b/src/session_encrypt.cpp index 56eba9de7..ebc0c68b2 100644 --- a/src/session_encrypt.cpp +++ b/src/session_encrypt.cpp @@ -1087,7 +1087,6 @@ LIBSESSION_C_API session_encrypt_group_message session_encrypt_for_group( const unsigned char* user_ed25519_privkey, size_t user_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* group_enc_key, size_t group_enc_key_len, const unsigned char* plaintext, @@ -1100,7 +1099,7 @@ LIBSESSION_C_API session_encrypt_group_message session_encrypt_for_group( try { std::vector result_cpp = encrypt_for_group( {user_ed25519_privkey, user_ed25519_privkey_len}, - {group_ed25519_pubkey, group_ed25519_pubkey_len}, + {group_ed25519_pubkey, crypto_sign_ed25519_PUBLICKEYBYTES}, {group_enc_key, group_enc_key_len}, {plaintext, plaintext_len}, compress, @@ -1200,7 +1199,6 @@ LIBSESSION_C_API session_decrypt_group_message_result session_decrypt_group_mess const span_u8* decrypt_ed25519_privkey_list, size_t decrypt_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* ciphertext, size_t ciphertext_len, char* error, @@ -1214,7 +1212,7 @@ LIBSESSION_C_API session_decrypt_group_message_result session_decrypt_group_mess try { result_cpp = decrypt_group_message( {&key, 1}, - {group_ed25519_pubkey, group_ed25519_pubkey_len}, + {group_ed25519_pubkey, crypto_sign_ed25519_PUBLICKEYBYTES}, {ciphertext, ciphertext_len}); result = { .success = true, From d5bb6d15e38946c57e80726c46d3bc0a2f94051b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:01:57 -0300 Subject: [PATCH 25/35] session_protocol: rename ProProof::rotating_seed's timestamp param to now The argument must be the current time -- the seed is for the rotation period containing it -- so `now` states that constraint precisely, where `timestamp` misleadingly suggested any point would do. The C API param is renamed unix_ts -> now_unix_ts to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.h | 8 ++++---- include/session/session_protocol.hpp | 9 +++++---- src/session_protocol.cpp | 10 +++++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index d71d8b98b..8816ff7a8 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -292,19 +292,19 @@ LIBSESSION_EXPORT bool session_protocol_pro_proof_verify_signature( /// API: session_protocol/session_protocol_pro_rotating_seed /// /// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that -/// contains `unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the +/// contains `now_unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the /// same 32-byte seed for the same period, so concurrent proof (re)generations converge rather than /// racing. /// /// Inputs: /// - `master_seed` -- the account's Session Pro master key/seed (from /// session_ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; first 32 bytes used. -/// - `unix_ts` -- any unix timestamp (seconds) within the desired rotation period; it is floored to -/// the period internally, so the caller never computes epochs. +/// - `now_unix_ts` -- the current unix time (seconds); the seed is for the period containing it +/// (floored internally, so the caller never computes epochs). /// - `rotating_seed_out` -- [out] 32-byte buffer to receive the derived seed. LIBSESSION_EXPORT void session_protocol_pro_rotating_seed( const unsigned char* master_seed, - int64_t unix_ts, + int64_t now_unix_ts, unsigned char* rotating_seed_out) NON_NULL_ARG(1, 3); /// API: session_protocol/session_protocol_pro_proof_verify_message diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index cd00aa25a..c424c1079 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -206,7 +206,7 @@ class ProProof { /// API: pro/Proof::rotating_seed /// /// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that - /// contains `timestamp`. Because every device derives the same seed for the same period with no + /// contains `now`. Because every device derives the same seed for the same period with no /// coordination, concurrent proof (re)generations converge on one credential instead of racing. /// The seed is the private counterpart of the `rotating_pubkey` a proof for this period /// authorizes: it is fed to generate_pro_proof, and once the backend returns a signed proof for @@ -218,13 +218,14 @@ class ProProof { /// ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; its first 32 bytes are /// used. Rooting rotating keys under the Pro master keeps all Pro key material in one /// hierarchy and lets the Pro subsystem avoid ever touching the account's identity seed. - /// - `timestamp` -- any time within the desired rotation period; it is floored to the period - /// (7 days) internally, so callers never compute epochs themselves. + /// - `now` -- the current time; the seed is for the rotation period that contains it (floored to + /// the 7-day period internally, so callers never compute epochs). Named `now` because the + /// current time is what a caller passes -- there is no reason to derive for another period. /// /// Outputs: /// - The 32-byte rotating seed (secret; zeroed on destruction). static cleared_uc32 rotating_seed( - std::span master_seed, std::chrono::sys_seconds timestamp); + std::span master_seed, std::chrono::sys_seconds now); bool operator==(const ProProof& other) const { return version == other.version && revocation_tag == other.revocation_tag && diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 532d77dc5..65294d21a 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -186,15 +186,15 @@ std::vector ProProof::signed_message() const { } cleared_uc32 ProProof::rotating_seed( - std::span master_seed, std::chrono::sys_seconds timestamp) { + std::span master_seed, std::chrono::sys_seconds now) { if (master_seed.size() != 32 && master_seed.size() != 64) throw std::invalid_argument{ "Invalid master_seed: expected a 32-byte Ed25519 seed or 64-byte libsodium key"}; - // Floor the timestamp to the rotation period, then hash master_seed[0:32] ‖ dec(period_start), + // Floor `now` to the start of its rotation period, then hash master_seed[0:32] ‖ dec(period_start), // where dec() is canonical decimal ASCII -- the same integer encoding the rest of the Pro wire // uses (signed_message, §1.1), so there's one integer convention and no endianness to pin. - auto period_start = timestamp - timestamp.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; + auto period_start = now - now.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; char dec[20]; auto [ptr, ec] = std::to_chars(dec, dec + sizeof(dec), epoch_seconds(period_start)); assert(ec == std::errc{}); // dec is large enough for any int64, so this cannot fail @@ -1188,9 +1188,9 @@ LIBSESSION_C_API bool session_protocol_pro_proof_verify_signature( } LIBSESSION_C_API void session_protocol_pro_rotating_seed( - const unsigned char* master_seed, int64_t unix_ts, unsigned char* rotating_seed_out) { + const unsigned char* master_seed, int64_t now_unix_ts, unsigned char* rotating_seed_out) { auto seed = session::ProProof::rotating_seed( - {master_seed, 32}, std::chrono::sys_seconds{std::chrono::seconds{unix_ts}}); + {master_seed, 32}, std::chrono::sys_seconds{std::chrono::seconds{now_unix_ts}}); std::memcpy(rotating_seed_out, seed.data(), seed.size()); } From fe277e084701e9c9094ae92d7ffc205ec1075f36 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:01:58 -0300 Subject: [PATCH 26/35] config: defer pro_renewal_target briefly at a rotation boundary When a renewal is already due and now sits right at a weekly rotation boundary, return now+2min rather than "renew now", so a device cleanly on one side of the boundary can renew and propagate its config first (and if none does, we re-poll a couple minutes on, unambiguously past the boundary). Guarded so the deferred time still leaves >=5min of validity; otherwise renew now. This and the existing target nudge are best-effort collision-avoidance only -- a genuine collision still resolves fine via config last-writer-wins/refetch. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/user_profile.cpp | 23 +++++++++++++++++++---- tests/test_config_userprofile.cpp | 13 +++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 949b3c8ce..47bdb6125 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -316,12 +316,27 @@ std::optional UserProfile::pro_renewal_target( if (!access || *access - now <= PRO_RENEWAL_LEAD) return std::nullopt; + // The nudges below are best-effort: they only make it *less likely* that two devices near a + // weekly rotation boundary race on the same renewal. A genuine collision still resolves fine + // (config is last-writer-wins and re-fetched), so none of this needs to be airtight. + auto near_boundary = [](std::chrono::sys_seconds t) { + auto off = t.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; + return off <= 15s || off >= PRO_ROTATING_SEED_PERIOD - 15s; + }; + auto target = expiry - PRO_RENEWAL_LEAD; - // Nudge off a rotation-period boundary so every device floors this (shared) target into the - // same period and derives the same rotating seed for the renewal, even with small clock skew. - if (auto off = target.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; - off <= 15s || off >= PRO_ROTATING_SEED_PERIOD - 15s) + // The scheduled target is shared (derived from the proof's expiry), so nudging it off a boundary + // keeps every device on the same side of it when the renewal comes due. + if (near_boundary(target)) target -= 30s; + + // If the renewal is already due but *now* sits right at a boundary, defer briefly instead of + // renewing this instant: it gives a device cleanly on one side time to renew and propagate its + // config first, and failing that we re-poll a couple of minutes on, unambiguously past the + // boundary. Only while the deferred time would still leave comfortable (>=5min) validity. + if (target <= now && near_boundary(now) && expiry - (now + 2min) >= 5min) + return now + 2min; + return target; } diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 7a722868d..1a2a5db86 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -754,5 +754,18 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // Expired proof -> renew immediately, regardless of entitlement. store_proof(now - 1h); CHECK(pr.pro_renewal_target(now) == now); + + // Near-boundary deferral: when renewal is already due and `now` sits at a weekly rotation + // boundary, defer ~2min (a best-effort collision nudge) as long as that still leaves + // comfortable validity; otherwise just renew now. + auto at_boundary = now - now.time_since_epoch() % session::PRO_ROTATING_SEED_PERIOD; + pr.set_pro_access_expiry(at_boundary + 30 * 24h); + store_proof(at_boundary + 30min); // due (within lead) with >7min validity left + CHECK(pr.pro_renewal_target(at_boundary) == at_boundary + 2min); + + store_proof(at_boundary + 6min); // due, but now+2min would leave <5min -> no defer + auto r = pr.pro_renewal_target(at_boundary); + REQUIRE(r.has_value()); + CHECK(*r <= at_boundary); } } From 0c6121bb5e3d1787145c0ea06aaf406d6429f388 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:18:33 -0300 Subject: [PATCH 27/35] attachments: keep is_final for the assert in process_chunk (fix debug build) The unused-parameter backport commented out process_chunk's is_final parameter name, but the assert on the next line still uses it -- so with asserts active (debug / no NDEBUG) the build fails with "undeclared identifier is_final". In release NDEBUG strips the assert, which is why it read as unused and slipped through. Restore the name as [[maybe_unused]] bool is_final: available to the assert in debug, and no unused-parameter warning in release. Reported by the clients agent (Android debug glue build). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/attachments.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attachments.cpp b/src/attachments.cpp index 7b66ed9ad..d2a32283b 100644 --- a/src/attachments.cpp +++ b/src/attachments.cpp @@ -660,7 +660,7 @@ void Decryptor::process_header(std::span hd header = true; } -void Decryptor::process_chunk(std::span chunk, bool /*is_final*/) { +void Decryptor::process_chunk(std::span chunk, [[maybe_unused]] bool is_final) { if (hit_final) { failed = true; return; From 58bafc4a95b0f5bf876a6c81da85d5db571e6bb7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:30:39 -0300 Subject: [PATCH 28/35] session_protocol/config: apply review to the rotating-seed rename + renewal deferral - rotating_seed doc (C++ and C): "as of now" instead of "contains now"; drop the epoch-computation narration and the redundant naming rationale. - pro_renewal_target: promote the boundary defer / min-validity magic values to documented constants -- PRO_RENEWAL_BOUNDARY_DEFER (reduced 2min -> 1min) and PRO_RENEWAL_BOUNDARY_MIN_VALIDITY (5min) -- and reword the collision comment to just say a collision is resolved by config resolution rather than (incorrectly) describing how config resolves it. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.h | 7 +++---- include/session/session_protocol.hpp | 18 +++++++++++++----- src/config/user_profile.cpp | 17 +++++++++-------- tests/test_config_userprofile.cpp | 12 +++++++----- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 8816ff7a8..776abf521 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -291,16 +291,15 @@ LIBSESSION_EXPORT bool session_protocol_pro_proof_verify_signature( /// API: session_protocol/session_protocol_pro_rotating_seed /// -/// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that -/// contains `now_unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the +/// Deterministically derive the rotating Session Pro seed for the weekly rotation period as of +/// `now_unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the /// same 32-byte seed for the same period, so concurrent proof (re)generations converge rather than /// racing. /// /// Inputs: /// - `master_seed` -- the account's Session Pro master key/seed (from /// session_ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; first 32 bytes used. -/// - `now_unix_ts` -- the current unix time (seconds); the seed is for the period containing it -/// (floored internally, so the caller never computes epochs). +/// - `now_unix_ts` -- the current unix time (seconds; floored to its rotation period internally). /// - `rotating_seed_out` -- [out] 32-byte buffer to receive the derived seed. LIBSESSION_EXPORT void session_protocol_pro_rotating_seed( const unsigned char* master_seed, diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index c424c1079..382cecccd 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -92,6 +92,16 @@ inline constexpr auto PRO_ROTATING_SEED_PERIOD = 7 * 24h; /// doing. See UserProfile::pro_renewal_target. inline constexpr auto PRO_RENEWAL_LEAD = 60min; +/// When a renewal has come due but the current time lands right at a rotation-period boundary, +/// pro_renewal_target defers it by this long rather than renewing at the ambiguous instant, giving a +/// device cleanly on one side of the boundary a chance to renew first. Best-effort collision +/// avoidance only. +inline constexpr auto PRO_RENEWAL_BOUNDARY_DEFER = 1min; + +/// The boundary deferral above is skipped (renew now instead) unless the deferred renewal would +/// still leave at least this much of the current proof's validity. +inline constexpr auto PRO_RENEWAL_BOUNDARY_MIN_VALIDITY = 5min; + enum class ProStatus { // Pro proof sig was not signed by the Pro backend key InvalidProBackendSig = SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG, @@ -205,8 +215,8 @@ class ProProof { /// API: pro/Proof::rotating_seed /// - /// Deterministically derive the rotating Session Pro seed for the (weekly) rotation period that - /// contains `now`. Because every device derives the same seed for the same period with no + /// Deterministically derive the rotating Session Pro seed for the weekly rotation period as of + /// `now`. Because every device derives the same seed for the same period with no /// coordination, concurrent proof (re)generations converge on one credential instead of racing. /// The seed is the private counterpart of the `rotating_pubkey` a proof for this period /// authorizes: it is fed to generate_pro_proof, and once the backend returns a signed proof for @@ -218,9 +228,7 @@ class ProProof { /// ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; its first 32 bytes are /// used. Rooting rotating keys under the Pro master keeps all Pro key material in one /// hierarchy and lets the Pro subsystem avoid ever touching the account's identity seed. - /// - `now` -- the current time; the seed is for the rotation period that contains it (floored to - /// the 7-day period internally, so callers never compute epochs). Named `now` because the - /// current time is what a caller passes -- there is no reason to derive for another period. + /// - `now` -- the current time (floored to its 7-day rotation period internally). /// /// Outputs: /// - The 32-byte rotating seed (secret; zeroed on destruction). diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 47bdb6125..f15e14cda 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -317,8 +317,8 @@ std::optional UserProfile::pro_renewal_target( return std::nullopt; // The nudges below are best-effort: they only make it *less likely* that two devices near a - // weekly rotation boundary race on the same renewal. A genuine collision still resolves fine - // (config is last-writer-wins and re-fetched), so none of this needs to be airtight. + // weekly rotation boundary race on the same renewal. A genuine collision is still resolved by + // config resolution, so none of this needs to be airtight. auto near_boundary = [](std::chrono::sys_seconds t) { auto off = t.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; return off <= 15s || off >= PRO_ROTATING_SEED_PERIOD - 15s; @@ -330,12 +330,13 @@ std::optional UserProfile::pro_renewal_target( if (near_boundary(target)) target -= 30s; - // If the renewal is already due but *now* sits right at a boundary, defer briefly instead of - // renewing this instant: it gives a device cleanly on one side time to renew and propagate its - // config first, and failing that we re-poll a couple of minutes on, unambiguously past the - // boundary. Only while the deferred time would still leave comfortable (>=5min) validity. - if (target <= now && near_boundary(now) && expiry - (now + 2min) >= 5min) - return now + 2min; + // If the renewal is already due but *now* sits right at a boundary, defer it instead of renewing + // at the ambiguous instant, so a device cleanly on one side can renew and propagate its config + // first; failing that we re-poll past the boundary. Only while the deferred time still leaves + // enough of the current proof's validity. + if (target <= now && near_boundary(now) && + expiry - (now + PRO_RENEWAL_BOUNDARY_DEFER) >= PRO_RENEWAL_BOUNDARY_MIN_VALIDITY) + return now + PRO_RENEWAL_BOUNDARY_DEFER; return target; } diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 1a2a5db86..34476b8c2 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -756,14 +756,16 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK(pr.pro_renewal_target(now) == now); // Near-boundary deferral: when renewal is already due and `now` sits at a weekly rotation - // boundary, defer ~2min (a best-effort collision nudge) as long as that still leaves - // comfortable validity; otherwise just renew now. + // boundary, defer by PRO_RENEWAL_BOUNDARY_DEFER as long as that leaves at least + // PRO_RENEWAL_BOUNDARY_MIN_VALIDITY of proof validity; otherwise just renew now. auto at_boundary = now - now.time_since_epoch() % session::PRO_ROTATING_SEED_PERIOD; pr.set_pro_access_expiry(at_boundary + 30 * 24h); - store_proof(at_boundary + 30min); // due (within lead) with >7min validity left - CHECK(pr.pro_renewal_target(at_boundary) == at_boundary + 2min); + store_proof(at_boundary + 30min); // due (within lead), plenty of validity left + CHECK(pr.pro_renewal_target(at_boundary) == + at_boundary + session::PRO_RENEWAL_BOUNDARY_DEFER); - store_proof(at_boundary + 6min); // due, but now+2min would leave <5min -> no defer + // Expiry only MIN_VALIDITY out: after any defer, < MIN_VALIDITY remains -> renew now. + store_proof(at_boundary + session::PRO_RENEWAL_BOUNDARY_MIN_VALIDITY); auto r = pr.pro_renewal_target(at_boundary); REQUIRE(r.has_value()); CHECK(*r <= at_boundary); From ba39bcfc0e6d98221cff02f78a476d2a1701666a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:13:28 -0300 Subject: [PATCH 29/35] util: view-based utf16_truncate/utf16_count; drop asserts on user input Replace the C-style utf16_count_truncated_to_codepoints (span in, code-unit index out) with utf16_truncate(u16string_view) -> u16string_view returning the truncated prefix directly, and take utf16_count on a u16string_view. Remove the assert()s on the surrogate state (invalid UTF-16 is documented-but-UB *input*, not a programming invariant, so asserting on it is wrong) and the now-dead surrogate helpers. Rename utf8_truncate's `n` to `max_bytes`. Tests use native u"..." literals instead of a UTF-8->UTF-16 round-trip UDL. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/util.hpp | 21 +++++---- src/util.cpp | 71 +++++++++++-------------------- tests/test_unicode_operations.cpp | 45 +++++++++----------- 3 files changed, 55 insertions(+), 82 deletions(-) diff --git a/include/session/util.hpp b/include/session/util.hpp index d7e6d2701..d818fdc28 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -301,8 +302,8 @@ std::tuple, std::optional 0 && (val[n] & 0b1100'0000) == 0b1000'0000) - --n; + while (max_bytes > 0 && (val[max_bytes] & 0b1100'0000) == 0b1000'0000) + --max_bytes; - val.resize(n); + val.resize(max_bytes); return val; } -/// Truncates an utf-16 encoded string to at most `codepoint_len` codepoints long, taking care to -/// not truncate in the middle of a surrogate pair. Notes that if the input string contains invalid -/// UTF-16 sequences (e.g. unpaired surrogates) the behavior here is undefined. -size_t utf16_count_truncated_to_codepoints( - std::span utf16_string, size_t codepoint_len); +/// Truncates a UTF-16 encoded string view and returns that same string view truncated to be no more +/// than `max_codepoints` codepoints long. +std::u16string_view utf16_truncate(std::u16string_view in, size_t max_codepoints); /// Returns the number of unicode codepoints in a utf-16 encoded string. -size_t utf16_count(std::span utf16_string); +size_t utf16_count(std::u16string_view utf16_string); // Helper function to transform a timestamp provided in seconds, milliseconds or microseconds to // seconds diff --git a/src/util.cpp b/src/util.cpp index afe3d4411..a5e548193 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -161,24 +161,15 @@ std::optional> zstd_decompress( return decompressed; } -inline bool is_utf16_low_surrogate(char16_t c) { - return c >= 0xDC00 && c <= 0xDFFF; -} - -inline bool is_utf16_high_surrogate(char16_t c) { - return c >= 0xD800 && c <= 0xDBFF; -} - -size_t utf16_count_truncated_to_codepoints( - std::span utf16_string, size_t codepoint_len) { +std::u16string_view utf16_truncate(std::u16string_view in, size_t max_codepoints) { // If the requested codepoint length is longer than the UTF-16 string length, // we can safely assume the entire string is needed. - if (utf16_string.size() <= codepoint_len) { - return utf16_string.size(); + if (in.size() <= max_codepoints) { + return in; } - if (codepoint_len == 0) { - return 0; + if (max_codepoints == 0) { + return {}; } // Call simdutf to count the codepoint for the entirety of the UTF-16 string. @@ -190,43 +181,31 @@ size_t utf16_count_truncated_to_codepoints( // Hence the overall cost to pay for the optimization: // * Truncation not needed: fast simdutf counting. // * Truncation needed: fast simdutf counting + slower iteration. - auto current_codepoint_len = simdutf::count_utf16(utf16_string.data(), utf16_string.size()); - if (current_codepoint_len <= codepoint_len) { - return utf16_string.size(); + auto current_codepoint_len = simdutf::count_utf16(in.data(), in.size()); + if (current_codepoint_len <= max_codepoints) { + return in; } - // Fallback: iterate through the UTF-16 string and count codepoints properly + // Fallback: iterate through the UTF-16 string and count codepoints properly, taking care not + // to split a surrogate pair. size_t counted_codepoints = 0; - bool expecting_low_surrogate = false; - for (size_t i = 0; i < utf16_string.size(); ++i) { - if (const char16_t c = utf16_string[i]; is_utf16_high_surrogate(c)) { - assert(!expecting_low_surrogate); - - // Start of a surrogate pair. Only count the codepoint when we see the low surrogate. - expecting_low_surrogate = true; - } else if (is_utf16_low_surrogate(c)) { - assert(expecting_low_surrogate); - - counted_codepoints++; - expecting_low_surrogate = false; - } else { - // Regular BMP character - assert(!expecting_low_surrogate); - counted_codepoints++; - } - - if (counted_codepoints == codepoint_len) { - return i + 1; - } + for (size_t i = 0; i < in.size(); ++i) { + // A high surrogate is the leading half of a surrogate pair: skip it so a pair is only + // counted (and only ever truncated) at its trailing low surrogate. + if (const char16_t c = in[i]; c >= 0xD800 && c <= 0xDBFF) + continue; + + if (++counted_codepoints == max_codepoints) + return in.substr(0, i + 1); } - // Should not be here, as the case of codepoint_len >= actual codepoint count should have + // Should not be here, as the case of max_codepoints >= actual codepoint count should have // been handled at the start of the function. As this indicates an invalid UTF-16 string, - // we will treat it as UB and return the whole string length. - return utf16_string.size(); + // we will treat it as UB and return the whole string. + return in; } -size_t utf16_count(std::span utf16_string) { +size_t utf16_count(std::u16string_view utf16_string) { return simdutf::count_utf16(utf16_string.data(), utf16_string.size()); } @@ -234,8 +213,10 @@ size_t utf16_count(std::span utf16_string) { LIBSESSION_C_API size_t utf16_count_truncated_to_codepoints( const uint16_t* utf16_string, size_t utf16_string_len, size_t codepoint_len) { - return session::utf16_count_truncated_to_codepoints( - {reinterpret_cast(utf16_string), utf16_string_len}, codepoint_len); + return session::utf16_truncate( + {reinterpret_cast(utf16_string), utf16_string_len}, + codepoint_len) + .size(); } LIBSESSION_C_API size_t utf16_count(const uint16_t* utf16_string, size_t utf16_string_len) { diff --git a/tests/test_unicode_operations.cpp b/tests/test_unicode_operations.cpp index e38a1136a..205330c8c 100644 --- a/tests/test_unicode_operations.cpp +++ b/tests/test_unicode_operations.cpp @@ -1,37 +1,30 @@ -#include - #include #include -static std::vector operator""_utf16(const char* str, size_t len) { - std::vector out(simdutf::utf16_length_from_utf8(str, len)); - out.resize(simdutf::convert_utf8_to_utf16(str, len, out.data())); - return out; -} - -TEST_CASE("utf16_count_truncated_to_codepoints works", "[util]") { - // Given simple ASCII string, should return length equal to codepoints requested - CHECK(session::utf16_count_truncated_to_codepoints("hello_world"_utf16, 11) == 11); +TEST_CASE("utf16_truncate works", "[util]") { + // Given simple ASCII string requesting all codepoints, returns the whole string + CHECK(session::utf16_truncate(u"hello_world", 11) == u"hello_world"); - // Given zero codepoints requested, should return zero length - CHECK(session::utf16_count_truncated_to_codepoints("hello_world"_utf16, 0) == 0); + // Given zero codepoints requested, returns an empty string + CHECK(session::utf16_truncate(u"hello_world", 0) == u""); - // Given UTF-16 has surrogate pairs, should count them as single codepoints - CHECK(session::utf16_count_truncated_to_codepoints("hello🎂world"_utf16, 11) == 12); + // 🎂 is a surrogate pair counting as a single codepoint, so all 11 codepoints is the whole + // (12-code-unit) string + CHECK(session::utf16_truncate(u"hello🎂world", 11) == u"hello🎂world"); - // Given UTF-16 has more codepoints than requested, should return length up to that point - CHECK(session::utf16_count_truncated_to_codepoints("hello🎂world"_utf16, 6) == 7); + // Requesting 6 codepoints keeps "hello🎂" without splitting the surrogate pair + CHECK(session::utf16_truncate(u"hello🎂world", 6) == u"hello🎂"); - // Given UTF-16 has exactly the requested number of codepoints, should return full length - CHECK(session::utf16_count_truncated_to_codepoints("hello🎂world"_utf16, 5) == 5); + // Requesting 5 codepoints keeps "hello" + CHECK(session::utf16_truncate(u"hello🎂world", 5) == u"hello"); - // Given UTF-16 has less codepoints than requested, should return full length - CHECK(session::utf16_count_truncated_to_codepoints("hello🎂world"_utf16, 13) == 12); + // Requesting more codepoints than present returns the whole string + CHECK(session::utf16_truncate(u"hello🎂world", 13) == u"hello🎂world"); } TEST_CASE("utf16_count works", "[util]") { - CHECK(session::utf16_count("hello_world"_utf16) == 11); - CHECK(session::utf16_count("hello🎂world"_utf16) == 11); - CHECK(session::utf16_count("🎂🎉🎈🎁"_utf16) == 4); - CHECK(session::utf16_count(""_utf16) == 0); -} \ No newline at end of file + CHECK(session::utf16_count(u"hello_world") == 11); + CHECK(session::utf16_count(u"hello🎂world") == 11); + CHECK(session::utf16_count(u"🎂🎉🎈🎁") == 4); + CHECK(session::utf16_count(u"") == 0); +} From a4809f7c51cd365682e9af59cf5fd502e0396bdf Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:13:28 -0300 Subject: [PATCH 30/35] session_protocol: pro_features_for_utf8/16 -> pro_features_for_message The utf8/16 variants forced libsession to validate and codepoint-count the message text, but every client already counts codepoints natively. Take the count directly via pro_features_for_message(size_t codepoint_count) and keep only the policy libsession should own: the character-limit thresholds and the count->flag mapping. Drops the simdutf validation/count (and the simdutf include, its only user in this TU), the redundant codepoint_count output field, and the now-impossible UTFDecodingError status. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.hpp | 2 +- include/session/session_protocol.h | 46 ++++--------------- include/session/session_protocol.hpp | 43 ++++-------------- src/session_protocol.cpp | 68 ++++++---------------------- tests/test_session_protocol.cpp | 42 ++++++----------- 5 files changed, 45 insertions(+), 156 deletions(-) diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 8234ed519..675f81259 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -33,7 +33,7 @@ /// functions to embed the proof into their messages via the helper functions in the Session /// Protocol header file. This is done by assigning the `ProProof` into the /// `Content.proMessage.proof` protobuf structure. Additionally the caller will use -/// `pro_features_for_utf8/16` to determine the correct flags to assign the `features` to +/// `pro_features_for_message` to determine the correct flags to assign the `features` to /// `Content.proMessage.flags` in the protobuf structure. /// /// Lastly the high-level libsession encoding functions accept the rotating private key to which diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 776abf521..ddbe0f96b 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -113,7 +113,6 @@ typedef struct session_protocol_pro_message_bitset { typedef enum SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS { // See session::ProFeaturesForMsgStatus SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS, - SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR, SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT, } SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS; @@ -383,50 +382,25 @@ typedef struct session_protocol_pro_features_for_msg { /// there is no error. const char* error; session_protocol_pro_message_bitset bitset; - size_t codepoint_count; } session_protocol_pro_features_for_msg; -/// API: session_protocol/session_protocol_get_pro_features_for_utf8 +/// API: session_protocol/session_protocol_pro_features_for_message /// -/// Determine the Pro features that are used in a given UTF8 message. +/// Determine the Pro features required for a message of the given length. /// /// Inputs: -/// - `text` -- the UTF8 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the number of code units (aka. bytes) the string has +/// - `codepoint_count` -- the number of Unicode codepoints in the message. Callers count this +/// themselves (every platform's native string type counts codepoints directly). /// /// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. This string is read-only and should not be modified. -/// - `features` -- Feature flags suitable for writing directly into the protobuf +/// - `status` -- Success, or EXCEEDS_CHARACTER_LIMIT when over the maximum. When not Success, only +/// `error` is meaningful. +/// - `error` -- On a non-Success status, a read-only diagnostic string; NULL otherwise. +/// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. LIBSESSION_EXPORT -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf8( - char const* text, size_t text_size) NON_NULL_ARG(1); - -/// API: session_protocol/session_protocol_get_pro_features_for_utf16 -/// -/// Determine the Pro features that are used in a given UTF16 message. -/// -/// Inputs: -/// - `text` -- the UTF16 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the number of code units (aka. bytes) the string has -/// -/// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. -/// - `features` -- Feature flags suitable for writing directly into the protobuf -/// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. -LIBSESSION_EXPORT -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf16( - uint16_t const* text, size_t text_size) NON_NULL_ARG(1); +session_protocol_pro_features_for_msg session_protocol_pro_features_for_message( + size_t codepoint_count); /// API: session_protocol_encode_for_1o1 /// diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 382cecccd..70ac1f0a5 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -245,10 +245,7 @@ class ProProof { enum class ProFeaturesForMsgStatus { Success = SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS, - /// Message byte stream to classify could not be decoded into a valid UTF8/16 string - UTFDecodingError = SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR, - - /// Decoded UTF8/16 string exceeded the maximum character limit allowed for Session Pro + /// Message exceeded the maximum character limit allowed for Session Pro ExceedsCharacterLimit = SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT, }; @@ -270,7 +267,6 @@ struct ProFeaturesForMsg { ProFeaturesForMsgStatus status; std::string_view error; ProMessageBitset bitset; - size_t codepoint_count; }; enum class DestinationType { @@ -403,44 +399,21 @@ struct DecodeEnvelopeKey { std::span> decrypt_keys; }; -/// API: session_protocol/pro_features_for_utf8 -/// -/// Determine the Pro features that are used in a given conversation message. -/// -/// Inputs: -/// - `text` -- the UTF8 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the size of the message in UTF8 code units to determine if the message requires -/// access to the higher character limit available in Session Pro -/// -/// Outputs (a ProFeaturesForMsg): -/// - `status` -- Success, or the reason evaluation failed (UTF decoding error, or over the character -/// limit). When not Success, only `error` is meaningful. -/// - `error` -- On a non-Success `status`, a read-only description of the failure; empty otherwise. -/// - `bitset` -- Feature flags suitable for writing directly into the protobuf -/// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. -ProFeaturesForMsg pro_features_for_utf8(const char* text, size_t text_size); - -/// API: session_protocol/pro_features_for_utf16 +/// API: session_protocol/pro_features_for_message /// -/// Determine the Pro features that are used in a given conversation message. +/// Determine the Pro features required for a conversation message of a given length. /// /// Inputs: -/// - `text` -- the UTF16 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the size of the message in UTF16 code units to determine if the message -/// requires -/// access to the higher character limit available in Session Pro +/// - `codepoint_count` -- the number of Unicode codepoints in the message. Callers count this +/// themselves (every platform's native string type counts codepoints directly). /// /// Outputs (a ProFeaturesForMsg): -/// - `status` -- Success, or the reason evaluation failed (UTF decoding error, or over the character -/// limit). When not Success, only `error` is meaningful. +/// - `status` -- Success, or ExceedsCharacterLimit if the message is over the maximum limit. When +/// not Success, only `error` is meaningful. /// - `error` -- On a non-Success `status`, a read-only description of the failure; empty otherwise. /// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. -ProFeaturesForMsg pro_features_for_utf16(const char16_t* text, size_t text_size); +ProFeaturesForMsg pro_features_for_message(size_t codepoint_count); /// API: session_protocol/pad_message /// diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 65294d21a..7a5f25ff4 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -10,6 +9,7 @@ #include #include +#include #include #include @@ -247,47 +247,19 @@ bool ProMessageBitset::is_set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES features) co return result; } -session::ProFeaturesForMsg pro_features_for_utf8_or_16( - const void* text, size_t text_size, bool is_utf8) { - session::ProFeaturesForMsg result = {}; - simdutf::result validate = is_utf8 - ? simdutf::validate_utf8_with_errors( - reinterpret_cast(text), text_size) - : simdutf::validate_utf16_with_errors( - reinterpret_cast(text), text_size); - if (validate.is_ok()) { - result.status = session::ProFeaturesForMsgStatus::Success; - result.codepoint_count = - is_utf8 ? simdutf::count_utf8(reinterpret_cast(text), text_size) - : simdutf::count_utf16(reinterpret_cast(text), text_size); - - if (result.codepoint_count > STANDARD_CHARACTER_LIMIT) { - if (result.codepoint_count <= PRO_HIGHER_CHARACTER_LIMIT) { - result.bitset.set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT); - } else { - result.error = "Message exceeds the maximum character limit allowed"; - result.status = session::ProFeaturesForMsgStatus::ExceedsCharacterLimit; - } +ProFeaturesForMsg pro_features_for_message(size_t codepoint_count) { + ProFeaturesForMsg result = {}; + result.status = ProFeaturesForMsgStatus::Success; + if (codepoint_count > STANDARD_CHARACTER_LIMIT) { + if (codepoint_count <= PRO_HIGHER_CHARACTER_LIMIT) { + result.bitset.set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT); + } else { + result.error = "Message exceeds the maximum character limit allowed"; + result.status = ProFeaturesForMsgStatus::ExceedsCharacterLimit; } - } else { - result.status = session::ProFeaturesForMsgStatus::UTFDecodingError; - result.error = simdutf::error_to_string(validate.error); } return result; } -}; // namespace session - -namespace session { - -ProFeaturesForMsg pro_features_for_utf8(const char* text, size_t text_size) { - ProFeaturesForMsg result = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ true); - return result; -} - -ProFeaturesForMsg pro_features_for_utf16(const char16_t* text, size_t text_size) { - ProFeaturesForMsg result = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ false); - return result; -} std::vector encode_for_1o1( std::span plaintext, @@ -1240,27 +1212,13 @@ LIBSESSION_C_API SESSION_PROTOCOL_PRO_STATUS session_protocol_pro_proof_status( } LIBSESSION_C_API -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf8( - const char* text, size_t text_size) { - ProFeaturesForMsg result_cpp = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ true); - session_protocol_pro_features_for_msg result = { - .status = static_cast(result_cpp.status), - .error = result_cpp.error.data(), - .bitset = {result_cpp.bitset.data}, - .codepoint_count = result_cpp.codepoint_count, - }; - return result; -} - -LIBSESSION_C_API -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf16( - const uint16_t* text, size_t text_size) { - ProFeaturesForMsg result_cpp = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ false); +session_protocol_pro_features_for_msg session_protocol_pro_features_for_message( + size_t codepoint_count) { + ProFeaturesForMsg result_cpp = pro_features_for_message(codepoint_count); session_protocol_pro_features_for_msg result = { .status = static_cast(result_cpp.status), .error = result_cpp.error.data(), .bitset = {result_cpp.bitset.data}, - .codepoint_count = result_cpp.codepoint_count, }; return result; } diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index 7771f97ae..bba49208c 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -102,59 +102,43 @@ static SerialisedProtobufContentWithProForTesting build_protobuf_content_with_se TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { // Do tests that require no setup - SECTION("Ensure get pro fetaures detects large message") { - // Try a message below the size threshold + SECTION("Ensure get pro features detects large message") { + // Below the size threshold { - auto msg = std::string(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(pro_msg.bitset.data == 0); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try an invalid message + // Exceeding the standard size threshold { - std::string_view msg = "\xFF"; session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); - REQUIRE(pro_msg.status == - SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR); - REQUIRE(pro_msg.error != nullptr); - REQUIRE(pro_msg.error[0] != '\0'); - } - - // Try a message exceeding the standard size threshold - { - auto msg = std::string(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1, 'a'); - session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try a message at the max size threshold + // At the max size threshold { - auto msg = std::string(SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try a message at the (max size + 1) threshold + // Over the max size threshold { - auto msg = std::string(SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT + 1, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT + 1); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT); REQUIRE(pro_msg.bitset.data == 0); - REQUIRE(pro_msg.codepoint_count == msg.size()); } } @@ -422,7 +406,7 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { large_message.resize(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(large_message.data(), large_message.size()); + session_protocol_pro_features_for_message(large_message.size()); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); From 88fbadd8486ddcce0c8b839c3e31115153d89ab4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 14:04:50 -0300 Subject: [PATCH 31/35] session_protocol/user_profile: fix "weekly rotation" mischaracterization in docs The rotating_seed / PRO_ROTATING_SEED_PERIOD comments called the 7-day seed quantization a "weekly rotation period", conflating it with the key-rotation cadence. Rotation is driven by the backend-issued proof expiry (min of subscription remaining, 30 days), not a fixed weekly interval; the 7-day bucket only exists so every device agrees on the derived seed. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.hpp | 2 +- include/session/session_protocol.h | 7 ++++--- include/session/session_protocol.hpp | 12 +++++++----- src/config/user_profile.cpp | 4 ++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 59a405540..6afb14ff9 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -403,7 +403,7 @@ class UserProfile : public ConfigBase { /// expiry: /// - no proof, or the proof already expired -> `now` (fetch immediately); /// - a valid proof with access expiry still more than an hour ahead -> an hour before the - /// proof expires (preemptive), nudged off a rotation-period boundary so all devices agree; + /// proof expires (preemptive), nudged off a rotating-seed period boundary so all devices agree; /// - otherwise (valid proof, entitlement ending or unknown) -> nullopt. /// /// Inputs: diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index ddbe0f96b..789949b89 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -290,15 +290,16 @@ LIBSESSION_EXPORT bool session_protocol_pro_proof_verify_signature( /// API: session_protocol/session_protocol_pro_rotating_seed /// -/// Deterministically derive the rotating Session Pro seed for the weekly rotation period as of +/// Deterministically derive the rotating Session Pro seed for the 7-day seed period containing /// `now_unix_ts` (see the C++ ProProof::rotating_seed). Every device on an account derives the /// same 32-byte seed for the same period, so concurrent proof (re)generations converge rather than -/// racing. +/// racing. The 7-day quantization is a property of the derivation only, NOT the key-rotation +/// cadence (which the backend dictates via the proof expiry). /// /// Inputs: /// - `master_seed` -- the account's Session Pro master key/seed (from /// session_ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; first 32 bytes used. -/// - `now_unix_ts` -- the current unix time (seconds; floored to its rotation period internally). +/// - `now_unix_ts` -- the current unix time (seconds; floored to the 7-day seed period internally). /// - `rotating_seed_out` -- [out] 32-byte buffer to receive the derived seed. LIBSESSION_EXPORT void session_protocol_pro_rotating_seed( const unsigned char* master_seed, diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 70ac1f0a5..f3c925e4c 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -92,7 +92,7 @@ inline constexpr auto PRO_ROTATING_SEED_PERIOD = 7 * 24h; /// doing. See UserProfile::pro_renewal_target. inline constexpr auto PRO_RENEWAL_LEAD = 60min; -/// When a renewal has come due but the current time lands right at a rotation-period boundary, +/// When a renewal has come due but the current time lands right at a rotating-seed period boundary, /// pro_renewal_target defers it by this long rather than renewing at the ambiguous instant, giving a /// device cleanly on one side of the boundary a chance to renew first. Best-effort collision /// avoidance only. @@ -215,9 +215,11 @@ class ProProof { /// API: pro/Proof::rotating_seed /// - /// Deterministically derive the rotating Session Pro seed for the weekly rotation period as of - /// `now`. Because every device derives the same seed for the same period with no - /// coordination, concurrent proof (re)generations converge on one credential instead of racing. + /// Deterministically derive the rotating Session Pro seed for the 7-day seed period containing + /// `now`. Every device deriving "as of `now`" gets the same seed with no coordination, so + /// concurrent proof (re)generations converge on one credential instead of racing. The 7-day + /// quantization is a property of this derivation only; it is NOT the key-rotation cadence, which + /// is dictated by the backend via the proof expiry. /// The seed is the private counterpart of the `rotating_pubkey` a proof for this period /// authorizes: it is fed to generate_pro_proof, and once the backend returns a signed proof for /// it the same seed is persisted in the config credential (its `r`) and is what subsequently @@ -228,7 +230,7 @@ class ProProof { /// ed25519_pro_privkey_for_ed25519_seed), NOT the session-id seed; its first 32 bytes are /// used. Rooting rotating keys under the Pro master keeps all Pro key material in one /// hierarchy and lets the Pro subsystem avoid ever touching the account's identity seed. - /// - `now` -- the current time (floored to its 7-day rotation period internally). + /// - `now` -- the current time (floored to the 7-day seed period internally). /// /// Outputs: /// - The 32-byte rotating seed (secret; zeroed on destruction). diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index f15e14cda..452ede7b9 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -317,8 +317,8 @@ std::optional UserProfile::pro_renewal_target( return std::nullopt; // The nudges below are best-effort: they only make it *less likely* that two devices near a - // weekly rotation boundary race on the same renewal. A genuine collision is still resolved by - // config resolution, so none of this needs to be airtight. + // rotating-seed period boundary race on the same renewal. A genuine collision is still resolved + // by config resolution, so none of this needs to be airtight. auto near_boundary = [](std::chrono::sys_seconds t) { auto off = t.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; return off <= 15s || off >= PRO_ROTATING_SEED_PERIOD - 15s; From c7d2aa8f21bfbc571cca0588bfc37aa703eed7d5 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 17:33:27 -0300 Subject: [PATCH 32/35] user_profile: only schedule a proof fetch when there's an entitlement signal pro_renewal_target returned `now` (fetch immediately) for *any* missing or expired proof, which told a non-Pro account -- and a lapsed one -- to hammer the backend indefinitely. Split the two cases: - no proof at all: fetch only if a purchase is in flight (the prepaid marker), else nullopt. An entitled account carries its proof in synced config `s`, so genuinely having none means the account isn't Pro. - present-but-expired proof: always re-check. The subscription may have auto-renewed without this device's knowledge (a stale cached access expiry can't be trusted), and on an authoritative not-Pro the client clears the credential -- which terminates the loop. Also corrects a stray "weekly rotation" mischaracterization in a test comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.hpp | 5 ++++- src/config/user_profile.cpp | 19 ++++++++++++++++--- tests/test_config_userprofile.cpp | 12 +++++++++--- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 6afb14ff9..d9d2385ac 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -401,7 +401,10 @@ class UserProfile : public ConfigBase { /// renewal should be attempted -- if it is <= the caller's clock, renew now; a future value can /// be scheduled -- or nullopt when no renewal is needed. Given the stored proof and access /// expiry: - /// - no proof, or the proof already expired -> `now` (fetch immediately); + /// - a present-but-expired proof -> `now` (always re-check; the backend may have auto-renewed, + /// and if it authoritatively reports not-Pro the client clears the credential and pushes it); + /// - no proof at all but a purchase in flight (prepaid marker) -> `now`; + /// - no proof and no purchase in flight -> nullopt (the account isn't Pro); /// - a valid proof with access expiry still more than an hour ahead -> an hour before the /// proof expires (preemptive), nudged off a rotating-seed period boundary so all devices agree; /// - otherwise (valid proof, entitlement ending or unknown) -> nullopt. diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 452ede7b9..8fbf9e4eb 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -303,11 +303,24 @@ void UserProfile::set_pro_prepaid(std::optional when) std::optional UserProfile::pro_renewal_target( std::chrono::sys_seconds now) const { auto pro = get_pro_config(); - if (!pro) - return now; // no proof yet -> request one immediately + if (!pro) { + // No credential on the account at all. Only fetch if a purchase is in flight (the prepaid + // marker); otherwise the account simply isn't Pro. An entitled account carries its proof in + // config `s` (synced from whichever device obtained it), so genuinely having no proof means + // there's none to renew. The prepaid marker ages out via its 1-week read gate, so a + // purchase that never lands self-terminates the acquire loop. + if (get_pro_prepaid()) + return now; + return std::nullopt; + } auto expiry = pro->proof.expiry_at; if (expiry <= now) - return now; // proof expired -> request one immediately + // Expired proof: always re-check with the backend. The subscription may have auto-renewed + // (possibly without this device's knowledge -- our cached access expiry can't be trusted to + // reflect a renewal we were offline for), so E is not a reliable gate here. If the backend + // authoritatively reports the account is not Pro, the client clears the config credential + // and pushes that, ending the loop (next evaluation: no proof, no prepaid -> nullopt). + return now; // Otherwise renew preemptively PRO_RENEWAL_LEAD before the proof expires, but only while // entitlement clearly continues (access expiry at least that far ahead); a still-valid proof diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 34476b8c2..ee643160e 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -722,8 +722,14 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { { session::config::UserProfile pr{std::span{seed}, std::nullopt}; - // No proof yet -> renew immediately. + // No proof and no purchase in flight -> not Pro, nothing to fetch. + CHECK_FALSE(pr.pro_renewal_target(now).has_value()); + + // No proof but a purchase in flight (prepaid) -> fetch immediately. + pr.set_pro_prepaid(now - 1h); + REQUIRE(pr.get_pro_prepaid().has_value()); CHECK(pr.pro_renewal_target(now) == now); + pr.set_pro_prepaid(std::nullopt); auto store_proof = [&](std::chrono::sys_seconds expiry) { session::config::ProConfig pc = {}; @@ -755,8 +761,8 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { store_proof(now - 1h); CHECK(pr.pro_renewal_target(now) == now); - // Near-boundary deferral: when renewal is already due and `now` sits at a weekly rotation - // boundary, defer by PRO_RENEWAL_BOUNDARY_DEFER as long as that leaves at least + // Near-boundary deferral: when renewal is already due and `now` sits at a rotating-seed + // period boundary, defer by PRO_RENEWAL_BOUNDARY_DEFER as long as that leaves at least // PRO_RENEWAL_BOUNDARY_MIN_VALIDITY of proof validity; otherwise just renew now. auto at_boundary = now - now.time_since_epoch() % session::PRO_ROTATING_SEED_PERIOD; pr.set_pro_access_expiry(at_boundary + 30 * 24h); From 078c611bdb3f53f3182fc377a13e50e09b66a1df Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 17:33:27 -0300 Subject: [PATCH 33/35] pro_backend: surface advisory account_expiry_ts from the proof response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_pro_proof responses now carry account_expiry_ts -- the account's true, grace-inclusive entitlement end (the same value get_pro_status returns), distinct from the proof's own clamped <=30d expiry_ts. It rides the response so a proof fetch also refreshes the client's cached access expiry. Exposed on GenerateProProofResponse (C++, optional) and session_pro_backend_pro_proof_response (C, 0 when absent). Advisory and unsigned: never fed into signature verification -- M is reconstructed from version/revocation_tag/rotating_pkey/expiry_ts only (pro-wire-protocol.md §2.2). Required on a successful proof; also read top-level off a subscription_expired failure so an expired-sub client can refresh its horizon without a separate get_pro_status; absent on not_subscribed / revoked. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.h | 7 ++++++ include/session/pro_backend.hpp | 10 +++++++++ src/pro_backend.cpp | 19 ++++++++++++++++ tests/test_pro_backend.cpp | 39 ++++++++++++++++++++++++++++++--- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 31f7accd6..7acdcddb1 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -139,6 +139,13 @@ typedef struct session_pro_backend_response_header { typedef struct session_pro_backend_pro_proof_response { session_pro_backend_response_header header; session_protocol_pro_proof proof; + /// The account's true, grace-inclusive subscription entitlement end (unix seconds), or 0 if this + /// response carries no horizon. Advisory and unsigned (pro-wire-protocol.md §2.2): use for + /// display / refreshing the cached access expiry only -- NOT an entitlement authority and NOT + /// part of the proof signature. Distinct from `proof.expiry_ts` (the clamped <=30d proof-validity + /// window). Populated on a successful proof and on a `subscription_expired` failure (a now-past + /// value); 0 on `not_subscribed` / `revoked` / protocol errors. + int64_t account_expiry_ts; } session_pro_backend_pro_proof_response; /// API: session_pro_backend/pro_proof_response_free diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 675f81259..8bd9a1e6e 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -177,6 +177,16 @@ struct ProRequest { /// Session Pro proof. `proof` is the raw parse result, convertible to a config::ProProof. struct GenerateProProofResponse : ResponseBase { ProProof proof; + + /// The account's true, grace-inclusive subscription entitlement end -- the same value + /// `get_pro_status` reports as its `expiry_ts` (pro-wire-protocol.md §2.2). Advisory and + /// UNSIGNED: not part of the proof's signed message and never fed into signature verification; + /// it rides on the response so a proof fetch also refreshes the client's cached access expiry. + /// Distinct from `proof.expiry_at` (the clamped, rolling <=30d proof-validity window). Present + /// (and required) on a successful proof, and on a `subscription_expired` failure (a now-past + /// value carried top-level on the envelope); nullopt on other outcomes (`not_subscribed`, + /// `revoked`, protocol errors). + std::optional account_expiry; }; /// Parse the reply to a generate-proof request. On failure `status` is set to an error state and diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index eaed14740..09a6ae762 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -342,6 +342,12 @@ namespace { json_require_fixed_bytes_from_hex( result_obj, "rotating_pkey", errs, result.proof.rotating_pubkey); json_require_fixed_bytes_from_hex(result_obj, "sig", errs, result.proof.sig); + + // Advisory and unsigned (pro-wire-protocol.md §2.2) -- never fed into signature + // verification -- but required: a proof response without it can't refresh the cached access + // expiry, which breaks renewal, so treat a missing value as a malformed response. + auto account_expiry_ts = json_require(result_obj, "account_expiry_ts", errs); + result.account_expiry = std::chrono::sys_seconds(std::chrono::seconds(account_expiry_ts)); } } // namespace @@ -352,6 +358,17 @@ GenerateProProofResponse parse_pro_proof(std::string_view json) { if (!result || !errs.empty()) { if (!errs.empty()) set_protocol_error(result, errs.front()); + // On a subscription_expired failure the account's (now-past) true expiry rides top-level on + // the envelope (pro-wire-protocol.md §2.2 / §5.1) so the client can refresh its cached + // horizon / access expiry without a separate get_pro_status. Advisory -- read leniently. + else if (result.error_code == "subscription_expired") { + std::vector ignore; + auto j = json_parse(json, ignore); + if (auto it = j.find("account_expiry_ts"); + it != j.end() && it->is_number_integer()) + result.account_expiry = + std::chrono::sys_seconds(std::chrono::seconds(it->get())); + } return result; } fill_proof(result_obj, result, errs); @@ -906,6 +923,8 @@ session_pro_backend_pro_proof_response_parse(const char* json, size_t json_len) p.rotating_pubkey.data(), p.rotating_pubkey.size()); std::memcpy(result.proof.sig.data, p.sig.data(), p.sig.size()); + result.account_expiry_ts = + owned->account_expiry ? session::epoch_seconds(*owned->account_expiry) : 0; } catch (const std::exception&) { delete static_cast(result.header.internal_); result = {}; diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index 84c8caaa7..fbefe5642 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -146,9 +146,9 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { {"version", 0}, {"expiry_ts", unix_ts}, {"revocation_tag", oxenc::to_hex(fake_revocation_tag)}, - {"rotating_pkey", - oxenc::to_hex(rotating_pubkey.data, std::end(rotating_pubkey.data))}, - {"sig", oxenc::to_hex(master_privkey.data, std::end(master_privkey.data))}}; + {"rotating_pkey", oxenc::to_hex(rotating_pubkey.data)}, + {"sig", oxenc::to_hex(master_privkey.data)}, + {"account_expiry_ts", unix_ts + 90 * 24 * 3600}}; std::string json = j.dump(); // Valid JSON @@ -198,6 +198,39 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { result.proof.sig.data, result_cpp.proof.sig.data(), result_cpp.proof.sig.size()) == 0); + + // account_expiry_ts (advisory, unsigned): surfaced by both the C and C++ parses, + // and distinct from the proof's own expiry_ts. + REQUIRE(result.account_expiry_ts == unix_ts + 90 * 24 * 3600); + REQUIRE(result_cpp.account_expiry.has_value()); + REQUIRE(result_cpp.account_expiry->time_since_epoch().count() == + result.account_expiry_ts); + + // Required on success: a proof response missing it is treated as malformed. + nlohmann::json j_no_ae = j; + j_no_ae["result"].erase("account_expiry_ts"); + REQUIRE(parse_pro_proof(j_no_ae.dump()).error.has_value()); + + // It also rides a subscription_expired failure (top-level, now-past value) so the + // client can refresh its cached horizon without a separate status call. + nlohmann::json j_exp; + j_exp["status"] = "fail"; + j_exp["error_code"] = "subscription_expired"; + j_exp["error"] = "expired"; + j_exp["account_expiry_ts"] = unix_ts - 24 * 3600; + auto exp = parse_pro_proof(j_exp.dump()); + REQUIRE(exp.error_code == "subscription_expired"); + REQUIRE(exp.account_expiry.has_value()); + REQUIRE(exp.account_expiry->time_since_epoch().count() == unix_ts - 24 * 3600); + + // Other failures (e.g. not_subscribed) carry no horizon. + nlohmann::json j_ns; + j_ns["status"] = "fail"; + j_ns["error_code"] = "not_subscribed"; + j_ns["error"] = "no"; + auto ns = parse_pro_proof(j_ns.dump()); + REQUIRE(ns.error_code == "not_subscribed"); + REQUIRE_FALSE(ns.account_expiry.has_value()); } // After freeing From af58b0c823fc6306406b9e2c6977766a829d32a8 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 23:05:42 -0300 Subject: [PATCH 34/35] session_protocol: document PRO_RENEWAL_LEAD as a locked backend contract The Pro backend (>= 0.3.0) sizes the padding on its per-account proof-expiry grid around exactly this 1h renewal lead, so `expiry_ts - PRO_RENEWAL_LEAD` lands just after the subscription's grace-inclusive true end. Increasing the lead (renewing earlier than 1h before expiry) would reach the upstream store before its final chance to report a renewal and get a spurious subscription_expired, so it now requires a coordinated backend change. Comment only. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index f3c925e4c..dba53f82a 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -90,6 +90,12 @@ inline constexpr auto PRO_ROTATING_SEED_PERIOD = 7 * 24h; /// How long before a proof's expiry a client preemptively renews it -- and, correspondingly, the /// minimum remaining entitlement (access expiry beyond now) that makes a preemptive renewal worth /// doing. See UserProfile::pro_renewal_target. +/// +/// Do NOT increase this beyond 60min without a coordinating backend change: the Pro backend sizes +/// the padding on its proof-expiry grid around exactly this 1h lead, so `expiry_ts - PRO_RENEWAL_LEAD` +/// lands just after the subscription's grace-inclusive true end. Renewing any earlier than 1h before +/// expiry would reach the upstream store before its final chance to report a renewal, yielding a +/// spurious `subscription_expired` on a subscription that is in fact renewing. inline constexpr auto PRO_RENEWAL_LEAD = 60min; /// When a renewal has come due but the current time lands right at a rotating-seed period boundary, From 4da5198ffa3c0d115ba987edf5d3328d733c44d1 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 23:23:22 -0300 Subject: [PATCH 35/35] formatting --- include/session/config/user_profile.h | 3 ++- include/session/config/user_profile.hpp | 15 +++++------ include/session/pro_backend.h | 26 ++++++++++---------- include/session/pro_backend.hpp | 17 +++++++------ include/session/session_protocol.h | 5 ++-- include/session/session_protocol.hpp | 24 +++++++++--------- src/config/pro.cpp | 4 +-- src/config/user_profile.cpp | 20 +++++++-------- src/network/routing/onion_request_router.cpp | 6 ++++- src/pro_backend.cpp | 3 +-- src/session_protocol.cpp | 25 ++++++++++--------- tests/test_config_pro.cpp | 3 ++- tests/test_config_userprofile.cpp | 3 ++- tests/test_pro_backend.cpp | 1 - tests/test_session_protocol.cpp | 5 ++-- 15 files changed, 85 insertions(+), 75 deletions(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index a1c248730..17e3a5782 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -463,7 +463,8 @@ LIBSESSION_EXPORT void user_profile_set_pro_prepaid(config_object* conf, int64_t /// /// Outputs: /// - `int64_t` - the renewal-target unix timestamp, or 0 for "no renewal needed". -LIBSESSION_EXPORT int64_t user_profile_get_pro_renewal_target(const config_object* conf, int64_t now); +LIBSESSION_EXPORT int64_t +user_profile_get_pro_renewal_target(const config_object* conf, int64_t now); #ifdef __cplusplus } // extern "C" diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index d9d2385ac..ea20306c6 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -30,8 +30,8 @@ using namespace std::literals; /// t - The unix timestamp (seconds) that the user last explicitly updated their profile information /// (automatically updates when changing `name`, `profile_pic` or `set_blinded_msgreqs`). /// E - user pro access expiry unix timestamp (in seconds). Note: This can be different from the pro -/// proof expiry which can be sooner. (Some (unreleased) testing clients stored this in milliseconds; the getter -/// migrates any such value on read -- see get_pro_access_expiry.) +/// proof expiry which can be sooner. (Some (unreleased) testing clients stored this in +/// milliseconds; the getter migrates any such value on read -- see get_pro_access_expiry.) /// R - unix timestamp (seconds) at which the user requested a refund of their current Session Pro /// subscription, for cross-device sync of the refund-requested state. Omitted when no refund /// has been requested; cleared by the client when a new subscription begins. Values more than @@ -342,8 +342,8 @@ class UserProfile : public ConfigBase { /// API: user_profile/UserProfile::get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro - /// subscription, if any. This is synced across the user's devices so that a refund requested on - /// one device is reflected on the others; it does not go through the Pro backend. + /// subscription, if any. This is synced across the user's devices so that a refund requested + /// on one device is reflected on the others; it does not go through the Pro backend. /// /// A stored value more than a week in the past is ignored (returns nullopt), so that a /// refund-requested flag some client neglected to clear cannot linger indefinitely. @@ -401,12 +401,13 @@ class UserProfile : public ConfigBase { /// renewal should be attempted -- if it is <= the caller's clock, renew now; a future value can /// be scheduled -- or nullopt when no renewal is needed. Given the stored proof and access /// expiry: - /// - a present-but-expired proof -> `now` (always re-check; the backend may have auto-renewed, - /// and if it authoritatively reports not-Pro the client clears the credential and pushes it); + /// - a present-but-expired proof -> `now` (always re-check with the backend; it may have + /// auto-renewed, and an authoritative not-Pro then clears the credential); /// - no proof at all but a purchase in flight (prepaid marker) -> `now`; /// - no proof and no purchase in flight -> nullopt (the account isn't Pro); /// - a valid proof with access expiry still more than an hour ahead -> an hour before the - /// proof expires (preemptive), nudged off a rotating-seed period boundary so all devices agree; + /// proof expires (preemptive), nudged off a rotating-seed period boundary so all devices + /// agree; /// - otherwise (valid proof, entitlement ending or unknown) -> nullopt. /// /// Inputs: diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 7acdcddb1..c21a77739 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -11,12 +11,12 @@ extern "C" { #endif -/// Canonical payment-provider `code` strings — the `payment_provider` value the backend reports on a -/// payment item (§5.2) and the slug clients key their store/display logic on. Reference these rather -/// than hardcoding the slug so client code cannot drift from what libsession parses. Unknown codes -/// (e.g. a future provider) are still valid on the wire and pass through as opaque strings. Each -/// points at the C++ `session::pro_backend::PAYMENT_PROVIDER_*` value (defined there — the primary; -/// C references). +/// Canonical payment-provider `code` strings — the `payment_provider` value the backend reports on +/// a payment item (§5.2) and the slug clients key their store/display logic on. Reference these +/// rather than hardcoding the slug so client code cannot drift from what libsession parses. Unknown +/// codes (e.g. a future provider) are still valid on the wire and pass through as opaque strings. +/// Each points at the C++ `session::pro_backend::PAYMENT_PROVIDER_*` value (defined there — the +/// primary; C references). LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_GOOGLE_PLAY; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_RANGEPROOF; @@ -139,12 +139,12 @@ typedef struct session_pro_backend_response_header { typedef struct session_pro_backend_pro_proof_response { session_pro_backend_response_header header; session_protocol_pro_proof proof; - /// The account's true, grace-inclusive subscription entitlement end (unix seconds), or 0 if this - /// response carries no horizon. Advisory and unsigned (pro-wire-protocol.md §2.2): use for + /// The account's true, grace-inclusive subscription entitlement end (unix seconds), or 0 if + /// this response carries no horizon. Advisory and unsigned (pro-wire-protocol.md §2.2): use for /// display / refreshing the cached access expiry only -- NOT an entitlement authority and NOT - /// part of the proof signature. Distinct from `proof.expiry_ts` (the clamped <=30d proof-validity - /// window). Populated on a successful proof and on a `subscription_expired` failure (a now-past - /// value); 0 on `not_subscribed` / `revoked` / protocol errors. + /// part of the proof signature. Distinct from `proof.expiry_ts` (the clamped <=30d + /// proof-validity window). Populated on a successful proof and on a `subscription_expired` + /// failure (a now-past value); 0 on `not_subscribed` / `revoked` / protocol errors. int64_t account_expiry_ts; } session_pro_backend_pro_proof_response; @@ -214,8 +214,8 @@ typedef struct session_pro_backend_pro_payment_item { /// Provider revocation instant, fractional UNIX seconds (millisecond-precise; 0 if not revoked) double revoked_ts; - /// Opaque, backend-owned payment identifier (§5.2): store and compare for equality, never parse. - /// NUL-terminated; points into the response's `internal_`. + /// Opaque, backend-owned payment identifier (§5.2): store and compare for equality, never + /// parse. NUL-terminated; points into the response's `internal_`. const char* payment_id; } session_pro_backend_pro_payment_item; diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 8bd9a1e6e..474ae2652 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -81,8 +81,8 @@ static_assert(PUBKEY_X25519.size() == 32); constexpr std::string_view URL = "https://pro.session.codes"; /// Canonical payment-provider code strings — the `payment_provider` value the backend reports on a -/// payment item (§5.2) and the slug clients key their store/display logic on. Reference these rather -/// than hardcoding the slug so client code cannot drift from what libsession parses. The C +/// payment item (§5.2) and the slug clients key their store/display logic on. Reference these +/// rather than hardcoding the slug so client code cannot drift from what libsession parses. The C /// `SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*` symbols point at these. An unknown code is still /// valid on the wire and passes through as an opaque string. constexpr std::string_view PAYMENT_PROVIDER_GOOGLE_PLAY = "google_play"; @@ -105,9 +105,9 @@ struct ResponseBase { ResponseStatus status = ResponseStatus::Ok; /// On non-Ok, a stable machine-readable slug identifying the outcome (spec §5.1), e.g. - /// "not_subscribed", "subscription_expired", "stale_request". std::nullopt on success. Opaque and - /// forward-compatible: map known slugs to localized text; for an unrecognized one fall back to - /// `error` / the `status` category. (libsession synthesizes "invalid_response" if it cannot + /// "not_subscribed", "subscription_expired", "stale_request". std::nullopt on success. Opaque + /// and forward-compatible: map known slugs to localized text; for an unrecognized one fall back + /// to `error` / the `status` category. (libsession synthesizes "invalid_response" if it cannot /// parse the backend's reply at all.) std::optional error_code; @@ -157,7 +157,8 @@ std::span visible_platforms(); /// returned by the `*_request()` helpers below. Callers relay `data` verbatim under `content_type` /// and never inspect or assume its format -- libsession owns the wire encoding. struct ProRequest { - /// Endpoint path relative to the backend base URL, e.g. "generate_pro_proof". As returned from a + /// Endpoint path relative to the backend base URL, e.g. "generate_pro_proof". As returned from + /// a /// `*_request()` function this points at a static, null-terminated string, so using /// `endpoint.data()` as a C string is valid. std::string_view endpoint; @@ -343,8 +344,8 @@ struct ProPaymentItem { /// sends it as a float of seconds. sys_ms revoked_at; - /// Opaque, backend-owned payment identifier (§5.2). Store and compare it for equality, but never - /// parse it -- libsession does not interpret it. Confidential; store appropriately. + /// Opaque, backend-owned payment identifier (§5.2). Store and compare it for equality, but + /// never parse it -- libsession does not interpret it. Confidential; store appropriately. std::string payment_id; }; diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 789949b89..de0a017a5 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -302,9 +302,8 @@ LIBSESSION_EXPORT bool session_protocol_pro_proof_verify_signature( /// - `now_unix_ts` -- the current unix time (seconds; floored to the 7-day seed period internally). /// - `rotating_seed_out` -- [out] 32-byte buffer to receive the derived seed. LIBSESSION_EXPORT void session_protocol_pro_rotating_seed( - const unsigned char* master_seed, - int64_t now_unix_ts, - unsigned char* rotating_seed_out) NON_NULL_ARG(1, 3); + const unsigned char* master_seed, int64_t now_unix_ts, unsigned char* rotating_seed_out) + NON_NULL_ARG(1, 3); /// API: session_protocol/session_protocol_pro_proof_verify_message /// diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index dba53f82a..af17fa9b8 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -92,15 +92,16 @@ inline constexpr auto PRO_ROTATING_SEED_PERIOD = 7 * 24h; /// doing. See UserProfile::pro_renewal_target. /// /// Do NOT increase this beyond 60min without a coordinating backend change: the Pro backend sizes -/// the padding on its proof-expiry grid around exactly this 1h lead, so `expiry_ts - PRO_RENEWAL_LEAD` -/// lands just after the subscription's grace-inclusive true end. Renewing any earlier than 1h before -/// expiry would reach the upstream store before its final chance to report a renewal, yielding a -/// spurious `subscription_expired` on a subscription that is in fact renewing. +/// the padding on its proof-expiry grid around exactly this 1h lead, so a client that begins +/// renewing at the lead always reaches the backend after the subscription's grace-inclusive true +/// end. Renewing earlier than 1h before expiry would arrive before the upstream store's final +/// chance to report a renewal, yielding a spurious `subscription_expired` on a subscription that is +/// in fact renewing. inline constexpr auto PRO_RENEWAL_LEAD = 60min; /// When a renewal has come due but the current time lands right at a rotating-seed period boundary, -/// pro_renewal_target defers it by this long rather than renewing at the ambiguous instant, giving a -/// device cleanly on one side of the boundary a chance to renew first. Best-effort collision +/// pro_renewal_target defers it by this long rather than renewing at the ambiguous instant, giving +/// a device cleanly on one side of the boundary a chance to renew first. Best-effort collision /// avoidance only. inline constexpr auto PRO_RENEWAL_BOUNDARY_DEFER = 1min; @@ -224,12 +225,11 @@ class ProProof { /// Deterministically derive the rotating Session Pro seed for the 7-day seed period containing /// `now`. Every device deriving "as of `now`" gets the same seed with no coordination, so /// concurrent proof (re)generations converge on one credential instead of racing. The 7-day - /// quantization is a property of this derivation only; it is NOT the key-rotation cadence, which - /// is dictated by the backend via the proof expiry. - /// The seed is the private counterpart of the `rotating_pubkey` a proof for this period - /// authorizes: it is fed to generate_pro_proof, and once the backend returns a signed proof for - /// it the same seed is persisted in the config credential (its `r`) and is what subsequently - /// signs Pro messages. + /// quantization is a property of this derivation only; it is NOT the key-rotation cadence, + /// which is dictated by the backend via the proof expiry. The seed is the private counterpart + /// of the `rotating_pubkey` a proof for this period authorizes: it is fed to + /// generate_pro_proof, and once the backend returns a signed proof for it the same seed is + /// persisted in the config credential (its `r`) and is what subsequently signs Pro messages. /// /// Inputs: /// - `master_seed` -- the account's Session Pro *master* key/seed (as produced by diff --git a/src/config/pro.cpp b/src/config/pro.cpp index b43f020f8..6b41cd571 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -1,9 +1,9 @@ +#include +#include #include #include #include -#include -#include #include #include #include diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 8fbf9e4eb..9f062d1dc 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -236,7 +236,7 @@ void UserProfile::set_pro_access_expiry(std::optional if (data["I"].exists()) data["I"].erase(); if (auto* R = data["R"].integer(); R && std::chrono::sys_seconds{std::chrono::seconds{*R}} < - ts_now() - std::chrono::weeks{1}) + ts_now() - std::chrono::weeks{1}) data["R"].erase(); } } @@ -259,8 +259,7 @@ void UserProfile::set_refund_requested(std::optional w data["R"].erase(); // Stamp the profile-updated timestamp so the change is time-ordered across devices. - const auto target_timestamp = - (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + const auto target_timestamp = (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); data[target_timestamp] = ts_now(); } @@ -338,15 +337,15 @@ std::optional UserProfile::pro_renewal_target( }; auto target = expiry - PRO_RENEWAL_LEAD; - // The scheduled target is shared (derived from the proof's expiry), so nudging it off a boundary - // keeps every device on the same side of it when the renewal comes due. + // The scheduled target is shared (derived from the proof's expiry), so nudging it off a + // boundary keeps every device on the same side of it when the renewal comes due. if (near_boundary(target)) target -= 30s; - // If the renewal is already due but *now* sits right at a boundary, defer it instead of renewing - // at the ambiguous instant, so a device cleanly on one side can renew and propagate its config - // first; failing that we re-poll past the boundary. Only while the deferred time still leaves - // enough of the current proof's validity. + // If the renewal is already due but *now* sits right at a boundary, defer it instead of + // renewing at the ambiguous instant, so a device cleanly on one side can renew and propagate + // its config first; failing that we re-poll past the boundary. Only while the deferred time + // still leaves enough of the current proof's validity. if (target <= now && near_boundary(now) && expiry - (now + PRO_RENEWAL_BOUNDARY_DEFER) >= PRO_RENEWAL_BOUNDARY_MIN_VALIDITY) return now + PRO_RENEWAL_BOUNDARY_DEFER; @@ -562,7 +561,8 @@ LIBSESSION_C_API void user_profile_set_pro_prepaid(config_object* conf, int64_t unbox(conf)->set_pro_prepaid(as_sys_seconds(prepaid_ts)); } -LIBSESSION_C_API int64_t user_profile_get_pro_renewal_target(const config_object* conf, int64_t now) { +LIBSESSION_C_API int64_t +user_profile_get_pro_renewal_target(const config_object* conf, int64_t now) { if (auto t = unbox(conf)->pro_renewal_target(as_sys_seconds(now))) return epoch_seconds(*t); return 0; diff --git a/src/network/routing/onion_request_router.cpp b/src/network/routing/onion_request_router.cpp index 9c14cf196..99f6c4549 100644 --- a/src/network/routing/onion_request_router.cpp +++ b/src/network/routing/onion_request_router.cpp @@ -2177,7 +2177,11 @@ void OnionRequestRouter::_rotate_path(const std::string& path_id, PathCategory c pending_rotation.new_path, std::move(info_request), [weak_self = weak_from_this(), this, new_path_id, rotate_at = std::move(rotate_at)]( - bool success, bool timeout, int16_t status, auto /*headers*/, auto /*response*/) { + bool success, + bool timeout, + int16_t status, + auto /*headers*/, + auto /*response*/) { auto self = weak_self.lock(); if (!self) return; diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 09a6ae762..18505e50f 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -364,8 +364,7 @@ GenerateProProofResponse parse_pro_proof(std::string_view json) { else if (result.error_code == "subscription_expired") { std::vector ignore; auto j = json_parse(json, ignore); - if (auto it = j.find("account_expiry_ts"); - it != j.end() && it->is_number_integer()) + if (auto it = j.find("account_expiry_ts"); it != j.end() && it->is_number_integer()) result.account_expiry = std::chrono::sys_seconds(std::chrono::seconds(it->get())); } diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 7a5f25ff4..4113b4e73 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -9,15 +9,14 @@ #include #include -#include -#include - #include #include #include #include #include #include +#include +#include #include "SessionProtos.pb.h" #include "WebSocketResources.pb.h" @@ -191,9 +190,10 @@ cleared_uc32 ProProof::rotating_seed( throw std::invalid_argument{ "Invalid master_seed: expected a 32-byte Ed25519 seed or 64-byte libsodium key"}; - // Floor `now` to the start of its rotation period, then hash master_seed[0:32] ‖ dec(period_start), - // where dec() is canonical decimal ASCII -- the same integer encoding the rest of the Pro wire - // uses (signed_message, §1.1), so there's one integer convention and no endianness to pin. + // Floor `now` to the start of its rotation period, then hash master_seed[0:32] ‖ + // dec(period_start), where dec() is canonical decimal ASCII -- the same integer encoding the + // rest of the Pro wire uses (signed_message, §1.1), so there's one integer convention and no + // endianness to pin. auto period_start = now - now.time_since_epoch() % PRO_ROTATING_SEED_PERIOD; char dec[20]; auto [ptr, ec] = std::to_chars(dec, dec + sizeof(dec), epoch_seconds(period_start)); @@ -468,10 +468,10 @@ static EncryptedForDestinationInternal encode_for_destination_internal( if (dest_pro_rotating_ed25519_privkey.empty()) { // No pro key: attach a decoy signature -- a validly-encoded Ed25519 signature - // (R = r·B for a random scalar r, so a prime-order-subgroup point like a real R; - // s a second random scalar) that verifies against nothing. Keeps pro and non-pro - // envelopes indistinguishable on the wire without signing throwaway data. NOT a - // real signature; never verified. + // (R = r·B for a random scalar r, so a prime-order-subgroup point like a real + // R; s a second random scalar) that verifies against nothing. Keeps pro and + // non-pro envelopes indistinguishable on the wire without signing throwaway + // data. NOT a real signature; never verified. auto* sig = reinterpret_cast(pro_sig->data()); std::array r; crypto_core_ed25519_scalar_random(r.data()); @@ -1052,7 +1052,10 @@ DecodedCommunityMessage decode_for_community( // a pro signature. assert(result.envelope->flags & SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG); pro.status = proof.status( - pro_backend_pubkey, unix_ts, to_span(*result.pro_sig), result.content_plaintext); + pro_backend_pubkey, + unix_ts, + to_span(*result.pro_sig), + result.content_plaintext); } else { SessionProtos::Content content_copy_without_sig = content; assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); diff --git a/tests/test_config_pro.cpp b/tests/test_config_pro.cpp index bd52841ec..96513b784 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -104,7 +104,8 @@ TEST_CASE("Pro", "[config][pro]") { 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 + CHECK(loaded_pro.proof.rotating_pubkey == + pro_cpp.proof.rotating_pubkey); // derived from seed CHECK(loaded_pro.proof.expiry_at == pro_cpp.proof.expiry_at); CHECK(loaded_pro.proof.sig == pro_cpp.proof.sig); CHECK(loaded_pro.proof.verify_signature(signing_pk)); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index ee643160e..856386b95 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -682,7 +682,8 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK_FALSE(profile.get_refund_requested().has_value()); // Pro-prepaid ("purchase in flight") marker: insert-only-if-not-pro, 1-week read gate, and - // auto-clear when entitlement lands. (No proof, access expiry in the past -> not currently pro.) + // auto-clear when entitlement lands. (No proof, access expiry in the past -> not currently + // pro.) CHECK_FALSE(profile.get_pro_prepaid().has_value()); auto prepaid_at = now - 1h; diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index fbefe5642..2ed051399 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -533,7 +533,6 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { session_pro_backend_get_payment_details_response_free(&pay_response); REQUIRE(pay_response.header.internal_ == nullptr); } - } } diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index bba49208c..efb0d23f9 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -1,8 +1,8 @@ +#include #include #include #include -#include #include #include #include @@ -106,7 +106,8 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { // Below the size threshold { session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_message(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(pro_msg.bitset.data == 0); }