From e889c8dd2d7c0566fc95a103c2ca79ec2ac10ee0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:17:01 -0300 Subject: [PATCH 01/34] 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 | 30 +++++++++++++++++++++++++ tests/test_config_userprofile.cpp | 20 +++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 9bfca296b..8dbee3623 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 9cfceecc7..f47623cc9 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -26,10 +26,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`). @@ -329,6 +333,31 @@ class UserProfile : public ConfigBase { /// 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); + private: // Enables/disables a single profile feature flag in the synced "f" set. The set stores feature // *bit positions*, so `flag` must be a single-bit ProProfileFlags value (deflated here to its diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 67145d12c..8589859c2 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -225,6 +225,23 @@ void UserProfile::set_pro_access_expiry(std::optional data["E"].erase(); } +std::optional UserProfile::get_refund_requested() const { + if (auto* R = data["R"].integer()) + 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] = clock_now_s(); +} + extern "C" { using namespace session; @@ -404,4 +421,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 d31044832..9bfc5b721 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -630,4 +630,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{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 b21f31a0f4f892e8ed92c67cc7a7ce615ed156a3 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:17:13 -0300 Subject: [PATCH 02/34] 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/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 | 148 +-------------------------- tests/pro_backend/gen_kat.py | 5 +- tests/test_pro_backend.cpp | 133 +----------------------- 6 files changed, 6 insertions(+), 384 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 24c5209b7..2d229c34e 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 unsigned char* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const unsigned char* 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 e6af1cded..5bf97f80b 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. @@ -390,10 +390,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. - std::chrono::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. @@ -448,12 +444,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. - std::chrono::sys_seconds refund_requested_at; }; struct PaymentDetailsResponse : ResponseBase { @@ -479,40 +469,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) -b64 refund_sig( - const ed25519::PrivKeySpan& master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id); - -ProRequest refund_request( - const ed25519::PrivKeySpan& master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::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 1ec1d72c8..8a7d762c6 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -58,13 +58,11 @@ enum ProProofVersion { ProProofVersion_v0 }; 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 f0e19180b..f5ec52294 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -137,7 +137,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 @@ -194,8 +193,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(); @@ -586,7 +583,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); @@ -604,7 +600,6 @@ namespace { item.grace_period_duration = std::chrono::seconds(grace_period_duration); item.platform_refund_expiry_at = as_sys_seconds(platform_refund_expiry_ts); item.revoked_at = sys_ms_from_seconds(revoked_ts); - item.refund_requested_at = as_sys_seconds(refund_requested_ts); return item; } @@ -673,10 +668,8 @@ ProStatusResponse parse_pro_status(std::string_view json) { int64_t expiry_ts = json_require(result_obj, "expiry_ts", errs); int64_t grace_period_duration = json_require(result_obj, "grace_period_duration", errs); - int64_t refund_requested_ts = json_require(result_obj, "refund_requested_ts", errs); result.expiry_at = as_sys_seconds(expiry_ts); result.grace_period_duration = std::chrono::seconds(grace_period_duration); - result.refund_requested_at = as_sys_seconds(refund_requested_ts); // `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()) { @@ -745,91 +738,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 pro::signed_message( - SET_PAYMENT_REFUND_REQUESTED_DOMAIN, - master_pubkey, - epoch_seconds(unix_ts), - epoch_seconds(refund_requested_at), - provider_code, - to_string_view(payment_id)); - } - - 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 - -b64 refund_sig( - const ed25519::PrivKeySpan& 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 msg = refund_message( - master_privkey.pubkey(), unix_ts, refund_requested_at, provider_code, payment_id); - return ed25519::sign(master_privkey, msg); -} - -ProRequest refund_request( - const ed25519::PrivKeySpan& 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 sig = refund_sig(master_privkey, unix_ts, refund_requested_at, provider_code, payment_id); - return {set_refund_endpoint, - application_json, - refund_body( - master_privkey.pubkey(), - unix_ts, - refund_requested_at, - provider_code, - to_string_view(payment_id), - sig)}; -} - -SetPaymentRefundRequestedResponse parse_refund(std::string_view json) { - // Parse basics - 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; - } - - // Parse payload - 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; @@ -883,7 +791,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(), }; } @@ -899,7 +806,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) { @@ -1134,7 +1041,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); @@ -1181,53 +1087,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 unsigned char* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const unsigned char* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(refund_request( - ed25519::PrivKeySpan{master_privkey, master_privkey_len}, - session::as_sys_seconds(ts), - session::as_sys_seconds(refund_requested_ts), - provider_code, - to_byte_span(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) { @@ -1256,8 +1115,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 8212bc52a..dee4231c4 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, - to_byte_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); - } } } @@ -741,7 +652,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) { @@ -758,7 +669,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"; @@ -788,12 +698,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" @@ -853,16 +757,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, - to_byte_span(payment_id.data(), payment_id.size())); - 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") { ProProof proof; proof.version = 0; @@ -1213,13 +1107,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); @@ -1268,27 +1160,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, to_byte_span(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 b6c89d39e7b4ca92497603956114b404b4077c31 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:17:39 -0300 Subject: [PATCH 03/34] 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/user_profile.cpp | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index 72490eb6e..5a567aef7 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -235,7 +235,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/user_profile.cpp b/src/config/user_profile.cpp index 8589859c2..6d3913d93 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; } @@ -276,7 +276,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 { @@ -349,7 +349,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 d7160f8d5877450ff5a3e02d34ed2ed498c0a427 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:27:20 -0300 Subject: [PATCH 04/34] 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 8dbee3623..afc706bc4 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 f47623cc9..8b626e0d9 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -33,7 +33,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`). @@ -339,11 +340,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. + /// was 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 6d3913d93..9ddd9467f 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 >= clock_now_s() - std::chrono::weeks{1}) + return when; + } return std::nullopt; } diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 9bfc5b721..699d76b5c 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -634,11 +634,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); { @@ -647,6 +650,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 552d9ddb5dbd0532c5107a669f6cfe741729d9fd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:00:24 -0300 Subject: [PATCH 05/34] 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 | 115 +------------------- src/pro_message.hpp | 4 +- tests/test_pro_backend.cpp | 155 +++++++-------------------- 6 files changed, 73 insertions(+), 321 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 2d229c34e..1bc1a094e 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 unsigned char* master_privkey, - size_t master_privkey_len, - const unsigned char* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const unsigned char* 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 5bf97f80b..bff0b169e 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -19,17 +19,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"; @@ -108,7 +109,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.) @@ -160,7 +161,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; @@ -176,58 +177,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( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& rotating_privkey, - std::string_view provider_code, - std::span payment_id); - -ProRequest add_payment_request( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& 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 @@ -390,9 +356,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 8a7d762c6..1a55f0139 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -57,12 +57,10 @@ enum ProProofVersion { ProProofVersion_v0 }; // bytes. 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 f5ec52294..d7f285f81 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -132,7 +132,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"; @@ -143,48 +142,11 @@ namespace { // content type travels alongside the payload so clients never hardcode a format. constexpr char application_json[] = "application/json"; - // --- 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 pro::signed_message( - ADD_PRO_PAYMENT_DOMAIN, - master_pubkey, - rotating_pubkey, - provider_code, - to_string_view(payment_id)); - } - - // 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 = @@ -278,36 +240,6 @@ std::optional parse_plan_period(std::string_view code) { return ProPlanPeriod{count, unit}; } -MasterRotatingSignatures add_payment_sigs( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto msg = add_payment_message( - master_privkey.pubkey(), rotating_privkey.pubkey(), provider_code, payment_id); - MasterRotatingSignatures result = {}; - result.master_sig = ed25519::sign(master_privkey, msg); - result.rotating_sig = ed25519::sign(rotating_privkey, msg); - return result; -} - -ProRequest add_payment_request( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto sigs = add_payment_sigs(master_privkey, rotating_privkey, provider_code, payment_id); - return {add_payment_endpoint, - application_json, - add_payment_body( - master_privkey.pubkey(), - rotating_privkey.pubkey(), - 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. @@ -363,21 +295,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; @@ -514,7 +431,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 pro::signed_message(GET_PRO_STATUS_DOMAIN, master_pubkey, epoch_seconds(unix_ts)); } @@ -537,7 +454,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 pro::signed_message( @@ -867,27 +784,6 @@ LIBSESSION_C_API const char* const* session_pro_backend_visible_platforms(size_t return codes.data(); } -LIBSESSION_C_API session_pro_backend_request session_pro_backend_add_pro_payment_request_build( - const unsigned char* master_privkey, - size_t master_privkey_len, - const unsigned char* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const unsigned char* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(add_payment_request( - ed25519::PrivKeySpan{master_privkey, master_privkey_len}, - ed25519::PrivKeySpan{rotating_privkey, rotating_privkey_len}, - provider_code, - to_byte_span(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 unsigned char* master_privkey, size_t master_privkey_len, @@ -961,8 +857,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); @@ -978,7 +873,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; @@ -1098,7 +993,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 3d7687357..92168b49c 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -18,8 +18,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 dee4231c4..be407a063 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, - to_byte_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); @@ -652,8 +596,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{}; @@ -670,25 +613,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"; // 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"; @@ -722,20 +657,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, - to_byte_span(payment_id.data(), payment_id.size())); - 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); @@ -1069,7 +990,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", @@ -1083,29 +1005,27 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { auto now = session::clock_now_s(); - // 1) add_pro_payment -> signed proof. - ProRequest add_req = add_payment_request( - master_sk, rotating_sk, provider, to_byte_span(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))); @@ -1195,14 +1115,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, - to_byte_span(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::clock_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. @@ -1216,7 +1135,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 fa2be0bd1b9a4f4831373f0081fd86b9a88e7e67 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:01:10 -0300 Subject: [PATCH 06/34] 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 8b626e0d9..d785b6f61 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -29,8 +29,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 9ddd9467f..77a03529b 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 a99993819bad0b35c936c14e07c5d9b1da032f77 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:01:43 -0300 Subject: [PATCH 07/34] 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 bff0b169e..34bdd1bdf 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -177,15 +177,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 d7f285f81..38575d5c5 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -282,7 +282,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 778640700fea924011314e54df019fa02485a369 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:04:31 -0300 Subject: [PATCH 08/34] 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 739295a62..201a56f41 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 0a32299ab..9e70995d3 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -26,13 +26,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()) @@ -40,7 +37,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 77a03529b..1e14eeb14 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 851707066..089f0e09c 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -22,7 +22,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 = @@ -79,7 +80,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()}, @@ -91,7 +91,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); @@ -109,7 +109,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 699d76b5c..1316b9e2e 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -595,7 +595,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 53b422609a30f57573c30d39efd82076434a8fd1 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:09:05 -0300 Subject: [PATCH 09/34] 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 | 80 ++++++++++++------------- src/config/user_profile.cpp | 18 ++---- tests/test_config_pro.cpp | 40 +++---------- 5 files changed, 69 insertions(+), 99 deletions(-) diff --git a/include/session/config/pro.hpp b/include/session/config/pro.hpp index 201a56f41..c629f97cb 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 d785b6f61..defc04d48 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -26,7 +26,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 9e70995d3..c1af4efb6 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -1,60 +1,54 @@ #include #include +#include +#include +#include +#include #include #include #include -#include "internal.hpp" - 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() != 32) - 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. + ed25519::seed_keypair(proof.rotating_pubkey, rotating_privkey, seed); + 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 - ed25519::seed_keypair( - proof.rotating_pubkey, rotating_privkey, std::span{*maybe_rotating_seed}.first<32>()); - 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 1e14eeb14..90d65088b 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 089f0e09c..a1008ad5a 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -73,52 +73,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. { - b64 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 92f56989792df873644b625401fb462f0394105b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:13:35 -0300 Subject: [PATCH 10/34] 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 | 34 ++++++++++++++++++++++++++++ tests/test_session_protocol.cpp | 25 ++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 64b792c64..5dad3694f 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -221,6 +221,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 1a55f0139..6669255f5 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -177,6 +177,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_b32 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 4d1b15991..983b71562 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include #include #include @@ -165,6 +167,31 @@ std::vector ProProof::signed_message() const { return proof_signed_message(revocation_tag, rotating_pubkey, session::epoch_seconds(expiry_at)); } +cleared_b32 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); + std::string_view dec_sv{dec, static_cast(ptr - dec)}; + + // Unkeyed, personalised (salt=null) BLAKE2b-256 -- byte-for-byte the libsodium + // crypto_generichash_blake2b_salt_personal this replaces; the wrapper concatenates its args. + auto out = session::hash::blake2b_pers<32>( + "ProRotatingSeed_"_b2b_pers, master_seed.first(32), dec_sv); + + cleared_b32 result = {}; + std::memcpy(result.data(), out.data(), result.size()); + return result; +} + }; // namespace session namespace { @@ -821,6 +848,13 @@ LIBSESSION_C_API bool session_protocol_pro_proof_verify_signature( return ed25519::verify(to_byte_span(proof->sig.data), to_byte_span<32>(verify_pubkey), msg); } +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( + to_byte_span(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 3b47a586e..7fb8449d8 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -895,3 +896,27 @@ 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 = + oxenc::from_hex("0101010101010101010101010101010101010101010101010101010101010101"); + auto seed_hex = [&](int64_t unix_ts) { + auto s = ProProof::rotating_seed( + to_byte_span(master.data(), master.size()), + 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 8b8d7a14900f93ca6d3a789b40074cc8ed265371 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:14:38 -0300 Subject: [PATCH 11/34] 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 50f9a57f8..d556b48da 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -165,6 +165,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 e0d8aa0d1..5baba6d1a 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -234,6 +234,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(const session::config::dict& d, const char* key); From ee8c60f64309c00b24e39a9b993c6e7570ac3650 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:18:30 -0300 Subject: [PATCH 12/34] 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 afc706bc4..4eef38d77 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 defc04d48..b5a392441 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -37,6 +37,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`). @@ -364,6 +368,33 @@ class UserProfile : public ConfigBase { /// 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); + private: // Enables/disables a single profile feature flag in the synced "f" set. The set stores feature // *bit positions*, so `flag` must be a single-bit ProProfileFlags value (deflated here to its diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 90d65088b..12886136a 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] = clock_now_s(); } + + // A live proof means any in-flight purchase has resolved: clear the prepaid marker. + if (pro.proof.expiry_at > clock_now_s() && 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 > clock_now_s()) { + if (data["I"].exists()) + data["I"].erase(); + if (auto* R = data["R"].integer(); R && std::chrono::sys_seconds{std::chrono::seconds{*R}} < + clock_now_s() - std::chrono::weeks{1}) + data["R"].erase(); + } } std::optional UserProfile::get_refund_requested() const { @@ -249,6 +263,42 @@ void UserProfile::set_refund_requested(std::optional w data[target_timestamp] = clock_now_s(); } +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 >= clock_now_s() - 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 > clock_now_s()) + 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] = clock_now_s(); + } +} + extern "C" { using namespace session; @@ -441,4 +491,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 1316b9e2e..3b1d61a95 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -659,4 +659,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 e19506431aae12387e3aa911843092d55d6d6ebf Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:22:12 -0300 Subject: [PATCH 13/34] 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 4eef38d77..7c60d4089 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 b5a392441..f5963097e 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -395,6 +395,25 @@ class UserProfile : public ConfigBase { /// 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(std::chrono::sys_seconds now) const; + private: // Enables/disables a single profile feature flag in the synced "f" set. The set stores feature // *bit positions*, so `flag` must be a single-bit ProProfileFlags value (deflated here to its diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 6669255f5..eba99dce5 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -37,6 +37,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.) @@ -52,6 +54,15 @@ inline constexpr int COMMUNITY_OR_1O1_MSG_PADDING = 160; 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; + // Session Pro 16-byte signing domain prefixes; each prefixes the Ed25519-signed message for its // endpoint (pro-wire-protocol.md §2 proof, §3 signed requests). ASCII, `_`-right-padded to 16 // bytes. diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 12886136a..3c2641ed2 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -299,6 +299,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; @@ -504,4 +529,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 983b71562..2e4bf1999 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -176,10 +176,10 @@ cleared_b32 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 std::string_view dec_sv{dec, static_cast(ptr - dec)}; // Unkeyed, personalised (salt=null) BLAKE2b-256 -- byte-for-byte the libsodium diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 3b1d61a95..8757692a4 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -696,4 +696,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{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 9b6af9ba7e82d912b3120635bd5b706a165dae23 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:24:43 -0300 Subject: [PATCH 14/34] 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 8757692a4..198c15b46 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -641,7 +641,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); @@ -664,7 +664,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); @@ -675,7 +675,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. @@ -686,7 +686,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()); @@ -714,16 +714,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. @@ -731,7 +731,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 3f4e376c2b1ee3b5a35e9dfda468211a43546e3e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 28 Jul 2026 18:24:44 -0300 Subject: [PATCH 15/34] 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 92168b49c..38acd38e9 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -42,6 +43,7 @@ std::vector signed_message(std::string_view domain, const Fields&... buf.push_back(std::byte{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 put_chars({tmp, static_cast(ptr - tmp)}); prev_var = true; } else if constexpr (std::convertible_to) { From ed47068c1b246d5fbb8d0179f7b65fe2fafbc625 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 11:49:29 -0300 Subject: [PATCH 16/34] 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 | 40 ++++++++++++++-------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index eba99dce5..64cc79947 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -84,11 +84,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 @@ -169,17 +164,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, std::chrono::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 2e4bf1999..fbdb8e161 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -144,7 +144,8 @@ bool ProProof::is_active(std::chrono::sys_seconds unix_ts) const { ProStatus ProProof::status( std::span verify_pubkey, std::chrono::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) @@ -152,8 +153,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; } @@ -566,17 +567,17 @@ static void parse_content_and_pro( // Evaluate the pro status given the extracted components (was it signed, is it expired, // was the message signed validly?) - // pro_sig.size() validated == 64 above - ProSignedMessage signed_msg = { - .sig = to_byte_span<64>(pro_sig.data()), - .msg = to_span(envelope.content()), - }; // Note that we sign the envelope content wholesale. For 1o1 which are padded to 160 // bytes, this means that we expected the user to have signed the padding as well. auto unix_ts = std::chrono::floor( std::chrono::sys_time( std::chrono::milliseconds(content.sigtimestamp()))); - pro.status = pro.proof.status(pro_backend_pubkey, unix_ts, signed_msg); + // pro_sig.size() validated == 64 above + pro.status = pro.proof.status( + pro_backend_pubkey, + unix_ts, + to_byte_span<64>(pro_sig.data()), + to_span(envelope.content())); } } } @@ -772,9 +773,7 @@ DecodedCommunityMessage decode_for_community( // a pro signature. assert(result.envelope->flags & SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG); pro.status = pro.proof.status( - pro_backend_pubkey, - unix_ts, - ProSignedMessage{*result.pro_sig, result.content_plaintext}); + pro_backend_pubkey, unix_ts, *result.pro_sig, result.content_plaintext); } else { SessionProtos::Content content_copy_without_sig = content; assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); @@ -790,7 +789,8 @@ DecodedCommunityMessage decode_for_community( pro.status = pro.proof.status( pro_backend_pubkey, unix_ts, - ProSignedMessage{*result.pro_sig, to_span(content_copy_without_sig_payload)}); + *result.pro_sig, + to_span(content_copy_without_sig_payload)); } } @@ -886,19 +886,19 @@ LIBSESSION_C_API SESSION_PROTOCOL_PRO_STATUS session_protocol_pro_proof_status( if (verify_pubkey_len != 32) return SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG; - std::optional cpp_signed_msg; + std::optional> user_sig; + std::span user_msg; bool bad_user_sig = false; if (signed_msg) { - if (signed_msg->sig.size == 64) - cpp_signed_msg = ProSignedMessage{ - to_byte_span<64>(signed_msg->sig.data), - to_byte_span(signed_msg->msg.data, signed_msg->msg.size)}; - else + if (signed_msg->sig.size == 64) { + user_sig = to_byte_span<64>(signed_msg->sig.data); + user_msg = to_byte_span(signed_msg->msg.data, signed_msg->msg.size); + } else bad_user_sig = true; // a wrong-length signature can never verify } ProStatus status = proof_from_c(*proof).status( - to_byte_span<32>(verify_pubkey), as_sys_seconds(ts), cpp_signed_msg); + to_byte_span<32>(verify_pubkey), as_sys_seconds(ts), user_sig, user_msg); // ProProof::status can't see a wrong-length signature (it takes a fixed-size span), so surface // the C API's length check here while keeping the ordering: a bad user signature supersedes a From adad94e51702cace37598553df5cbe9ff76a164c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 12:12:24 -0300 Subject: [PATCH 17/34] pro_backend: fold the *_sig(s) helpers into their *_request builders pro_proof_sigs, pro_status_sig and payment_details_sig were public but called from exactly one place each -- their own *_request() -- and never from the C API or tests. They split every request build across two public functions for no benefit. Inline the (1-3 line) signing into each request builder and drop the helpers, along with MasterRotatingSignatures which only existed as pro_proof_sigs's return type. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/pro_backend.hpp | 34 ++++++--------------------- src/pro_backend.cpp | 41 ++++++++------------------------- 2 files changed, 16 insertions(+), 59 deletions(-) diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 34bdd1bdf..573d7b7ec 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -125,11 +125,6 @@ struct ResponseBase { explicit operator bool() const { return status == ResponseStatus::Ok; } }; -struct MasterRotatingSignatures { - b64 master_sig; - b64 rotating_sig; -}; - /// Per-provider support/management URLs (from provider_urls()). These are identical for every user /// (not translation data), so libsession owns them as the single source of truth rather than each /// client duplicating them; the human-readable provider/store *names* are translation data and @@ -192,18 +187,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( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& rotating_privkey, - std::chrono::sys_seconds unix_ts); - ProRequest pro_proof_request( const ed25519::PrivKeySpan& master_privkey, const ed25519::PrivKeySpan& rotating_privkey, @@ -250,22 +239,19 @@ 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 -b64 pro_status_sig(const ed25519::PrivKeySpan& master_privkey, std::chrono::sys_seconds unix_ts); - ProRequest pro_status_request( const ed25519::PrivKeySpan& master_privkey, std::chrono::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 @@ -274,12 +260,6 @@ ProRequest pro_status_request( /// - `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. -b64 payment_details_sig( - const ed25519::PrivKeySpan& master_privkey, - std::chrono::sys_seconds unix_ts, - uint32_t limit, - std::string_view before); - ProRequest payment_details_request( const ed25519::PrivKeySpan& master_privkey, std::chrono::sys_seconds unix_ts, diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 38575d5c5..8f4f060d8 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -341,30 +341,19 @@ namespace { } // namespace -MasterRotatingSignatures pro_proof_sigs( - const ed25519::PrivKeySpan& master_privkey, - const ed25519::PrivKeySpan& rotating_privkey, - std::chrono::sys_seconds unix_ts) { - auto msg = generate_proof_message(master_privkey.pubkey(), rotating_privkey.pubkey(), unix_ts); - MasterRotatingSignatures result = {}; - result.master_sig = ed25519::sign(master_privkey, msg); - result.rotating_sig = ed25519::sign(rotating_privkey, msg); - return result; -} - ProRequest pro_proof_request( const ed25519::PrivKeySpan& master_privkey, const ed25519::PrivKeySpan& rotating_privkey, std::chrono::sys_seconds unix_ts) { - auto sigs = pro_proof_sigs(master_privkey, rotating_privkey, unix_ts); + auto msg = generate_proof_message(master_privkey.pubkey(), rotating_privkey.pubkey(), unix_ts); return {generate_proof_endpoint, application_json, generate_proof_body( master_privkey.pubkey(), rotating_privkey.pubkey(), unix_ts, - sigs.master_sig, - sigs.rotating_sig)}; + ed25519::sign(master_privkey, msg), + ed25519::sign(rotating_privkey, msg))}; } ProRequest revocations_request(std::int64_t ticket) { @@ -522,26 +511,12 @@ namespace { } // namespace -b64 pro_status_sig(const ed25519::PrivKeySpan& master_privkey, std::chrono::sys_seconds unix_ts) { - auto msg = pro_status_message(master_privkey.pubkey(), unix_ts); - return ed25519::sign(master_privkey, msg); -} - ProRequest pro_status_request( const ed25519::PrivKeySpan& master_privkey, std::chrono::sys_seconds unix_ts) { - auto sig = pro_status_sig(master_privkey, unix_ts); + auto msg = pro_status_message(master_privkey.pubkey(), unix_ts); return {get_pro_status_endpoint, application_json, - pro_status_body(master_privkey.pubkey(), sig, unix_ts)}; -} - -b64 payment_details_sig( - const ed25519::PrivKeySpan& master_privkey, - std::chrono::sys_seconds unix_ts, - uint32_t limit, - std::string_view before) { - auto msg = payment_details_message(master_privkey.pubkey(), unix_ts, limit, before); - return ed25519::sign(master_privkey, msg); + pro_status_body(master_privkey.pubkey(), ed25519::sign(master_privkey, msg), unix_ts)}; } ProRequest payment_details_request( @@ -549,10 +524,12 @@ ProRequest payment_details_request( std::chrono::sys_seconds unix_ts, uint32_t limit, std::string_view before) { - auto sig = payment_details_sig(master_privkey, unix_ts, limit, before); + auto msg = payment_details_message(master_privkey.pubkey(), unix_ts, limit, before); return {get_payment_details_endpoint, application_json, - payment_details_body(master_privkey.pubkey(), sig, unix_ts, limit, before)}; + payment_details_body( + master_privkey.pubkey(), ed25519::sign(master_privkey, msg), unix_ts, limit, + before)}; } ProStatusResponse parse_pro_status(std::string_view json) { From 51743f7e201eeaeab744592b8924fecb2b0bc651 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 12:13:37 -0300 Subject: [PATCH 18/34] 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 64cc79947..627405c17 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -177,7 +177,7 @@ class ProProof { std::span verify_pubkey, std::chrono::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 fbdb8e161..bcc31b03c 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -145,7 +145,7 @@ ProStatus ProProof::status( std::span verify_pubkey, std::chrono::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 86e3101b32fa7f8408b8b37b42b6e94143687e28 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 12:16:51 -0300 Subject: [PATCH 19/34] session_protocol: fix stale doc comments - The decode doc block was written for an old unified decode_envelope(keys, ...) and left attached to decode_dm_envelope: retarget it (its API name, the nonexistent 'keys' input, the groups-v2 dual-mode language that now belongs to decode_group_envelope) and drop the duplicated one-line description that had been appended after it. - pro_features_for_utf8/16 documented a 'success' bool and 'features'/'bitset' outputs; the function returns a ProFeaturesForMsg with a 'status' enum and a 'flags' field. Update the Outputs to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/session_protocol.hpp | 67 ++++++++++------------------ 1 file changed, 24 insertions(+), 43 deletions(-) diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 627405c17..8202a7f66 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -366,12 +366,11 @@ struct DecodedCommunityMessage { /// - `msg` -- the UTF-8 string view to count the number of codepoints in to determine if it needs /// 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. +/// - `flags` -- 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(std::u8string_view msg); @@ -386,12 +385,11 @@ ProFeaturesForMsg pro_features_for_utf8(std::span msg); /// - `msg` -- the UTF-16 string view to count the number of codepoints in to determine if it needs /// 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. -/// - `bitset` -- 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. +/// - `flags` -- 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(std::u16string_view msg); @@ -524,15 +522,12 @@ std::vector encode_for_group( std::span group_enc_key, const ed25519::OptionalPrivKeySpan& pro_rotating_ed25519_privkey); -/// API: session_protocol/decode_envelope -/// -/// Given an envelope payload (i.e.: protobuf encoded stream of `WebsocketRequestMessage` which -/// wraps an `Envelope` for 1o1 messages/sync messages, or `Envelope` encrypted using a Groups v2 -/// key) parse (or decrypt) the envelope and return the envelope content decrypted if necessary. +/// API: session_protocol/decode_dm_envelope /// -/// A groups v2 envelope will get decrypted with the group keys. A non-groups v2 envelope will get -/// decrypted with the specified Ed25519 private key in the `keys` object. Only one of these keys -/// need to be set depending on the type of envelope payload passed into the function. +/// Decode a 1-on-1 (or legacy group) envelope: a WebSocket-wrapped protobuf `Envelope` whose inner +/// `Content` is encrypted with the Session protocol (Ed25519 DH). Parse the envelope, decrypt the +/// content, and return the plaintext along with any Session Pro metadata. (Groups v2 envelopes, +/// where the envelope itself is encrypted with a group key, are handled by decode_group_envelope.) /// /// If the message does not use Session Pro features, the `pro` object will be set to nil. Otherwise /// the pro fields will be populated with data about the Session Pro proof embedded in the envelope @@ -550,40 +545,26 @@ std::vector encode_for_group( /// field to verify if the Session Pro was present and/or valid or invalid. /// /// Inputs: -/// - `keys` -- the keys to decrypt either the envelope or the envelope contents. Groups v2 -/// envelopes where the envelope is encrypted must set the group key. Envelopes with an encrypted -/// content must set the the libsodium-style secret key of the receiver, 64 bytes. Can also be -/// passed as a 32-byte seed. -/// -/// If a group decryption key is specified, the recipient key is ignored and vice versa. Only one -/// of the keys should be set depending on the type of envelope. -/// -/// - `envelope_payload` -- the envelope payload either encrypted (groups v2 style) or unencrypted -/// (1o1 or legacy groups). +/// - `ed25519_privkey` -- the receiver's Ed25519 private key used to decrypt the envelope content; +/// a libsodium-style 64-byte secret key, or a 32-byte seed. +/// - `envelope_payload` -- the WebSocket-wrapped envelope payload (the inner content is encrypted). /// - `pro_backend_pubkey` -- the Session Pro backend public key to verify the signature embedded in /// the proof, validating whether or not the attached proof was indeed issued by an authorised /// issuer /// /// Outputs: -/// - `envelope` -- Envelope structure that was decrypted/parsed from the `envelope_plaintext` +/// - `envelope` -- Envelope structure that was parsed from the payload /// - `content_plaintext` -- Decrypted contents of the envelope structure. This is the protobuf /// encoded stream that can be parsed into a protobuf `Content` structure. /// - `sender_ed25519_pubkey` -- The sender's ed25519 public key embedded in the encrypted payload. -/// This is only set for session message envelopes. Groups envelopes only embed the sender's -/// x25519 public key in which case this field is set to the zero public key. -/// - `sender_x25519_pubkey` -- The sender's x25519 public key. It's always set on successful -/// decryption either by extracting the key from the encrypted groups envelope, or, by deriving -/// the x25519 key from the sender's ed25519 key in the case of a session message envelope. -/// - `pro` -- Optional object that is set if there was pro metadata associatd with the envelope, if -/// any. The `status` field in the decrypted pro object should be used to determine whether or not +/// - `sender_x25519_pubkey` -- The sender's x25519 public key, derived from the sender's ed25519 +/// key. +/// - `pro` -- Optional object that is set if there was pro metadata associated with the envelope, if +/// any. The `status` field in the decoded pro object should be used to determine whether or not /// the caller can respect the contents of the `proof` and `features`. /// -/// If the `status` is set to valid the the caller can proceed with entitling the envelope with +/// If the `status` is set to valid the caller can proceed with entitling the envelope with /// access to pro features if it's using any. -/// Decodes a 1-on-1 or legacy group envelope. The envelope payload is a WebSocket-wrapped -/// protobuf whose inner content is encrypted with the Session protocol (Ed25519 DH). -/// -/// Throws on parse or decryption failure. DecodedEnvelope decode_dm_envelope( const ed25519::PrivKeySpan& ed25519_privkey, std::span envelope_payload, From 69deab8d70f9cc2267e897ad933b17c7dfe6efe9 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 12:24:36 -0300 Subject: [PATCH 20/34] session_protocol: drop the unused sent_timestamp from encode_for_community_inbox encode_for_community_inbox encodes the message as a bare (padded) Content and blinded-encrypts it -- it never builds an Envelope, so the sent_timestamp had nowhere to go and was silently ignored. The pro-proof timestamp travels inside Content.sigTimestamp regardless. Drop the vestigial parameter from the C++ and C signatures, the C wrapper, and the two test call sites (and renumber the C NON_NULL_ARG list accordingly). 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 | 3 --- tests/test_session_protocol.cpp | 2 -- 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 5dad3694f..abe0f804c 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -437,7 +437,6 @@ session_protocol_encoded_for_destination session_protocol_encode_dm_v1( /// - `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 @@ -473,13 +472,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 cbytes33* recipient_pubkey, const cbytes32* 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 8202a7f66..f834a7130 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -443,7 +443,6 @@ std::vector encode_dm_v1( /// not be already encrypted and must not be padded. /// - ed25519_privkey -- The sender's Ed25519 private key; accepts a 32-byte seed or 64-byte /// libsodium key. 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 @@ -457,7 +456,6 @@ std::vector encode_dm_v1( std::vector encode_for_community_inbox( std::span plaintext, const ed25519::PrivKeySpan& ed25519_privkey, - std::chrono::milliseconds sent_timestamp, std::span recipient_pubkey, std::span community_pubkey, const ed25519::OptionalPrivKeySpan& pro_rotating_ed25519_privkey); diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index bcc31b03c..f1b94b25e 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -341,7 +341,6 @@ std::vector encode_for_community( std::vector encode_for_community_inbox( std::span plaintext, const ed25519::PrivKeySpan& ed25519_privkey, - std::chrono::milliseconds sent_timestamp, std::span recipient_pubkey, std::span community_pubkey, const ed25519::OptionalPrivKeySpan& pro_rotating_ed25519_privkey) { @@ -982,7 +981,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 cbytes33* recipient_pubkey, const cbytes32* community_pubkey, const void* pro_rotating_ed25519_privkey, @@ -994,7 +992,6 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i std::span{static_cast(plaintext), plaintext_len}, ed25519::PrivKeySpan{ static_cast(ed25519_privkey), ed25519_privkey_len}, - std::chrono::milliseconds(sent_timestamp_ms), to_byte_span(recipient_pubkey->data), to_byte_span(community_pubkey->data), ed25519::OptionalPrivKeySpan{ diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index 7fb8449d8..f4e914319 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -317,7 +317,6 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { protobuf_content.plaintext.size(), keys.ed_sk0.data(), keys.ed_sk0.size(), - base_sent_timestamp_ms, &blind15_recipient, &community_pubkey, user_pro_ed_sk.data(), @@ -868,7 +867,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 ce0a2cb9eed514956101746f313dd82b6c0a7991 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 12:45:23 -0300 Subject: [PATCH 21/34] session_protocol: use a cheap validly-encoded decoy signature attach_pro_sig_to_envelope always attaches 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 ed25519::decoy_signature(), which builds a value with the same structure and distribution as a real Ed25519 signature without signing anything: 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) --- include/session/crypto/ed25519.hpp | 7 +++++++ src/crypto/ed25519.cpp | 13 +++++++++++++ src/session_protocol.cpp | 14 ++++---------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/include/session/crypto/ed25519.hpp b/include/session/crypto/ed25519.hpp index bdb9cf8c3..965e189e2 100644 --- a/include/session/crypto/ed25519.hpp +++ b/include/session/crypto/ed25519.hpp @@ -161,6 +161,13 @@ void sign( /// Return-value form. b64 sign(const PrivKeySpan& ed25519_privkey, std::span msg); +/// Produces a random but validly-*encoded* Ed25519 signature: a uniformly random group element (the +/// `R` half) followed by a uniformly random scalar mod L (the `s` half). It is not a signature of +/// any message and verifies against nothing -- it exists only as a decoy, so a payload carrying a +/// real signature is indistinguishable on the wire from one carrying none. Far cheaper than signing +/// throwaway data, and independent of message size. +b64 decoy_signature(); + /// Verify a message and signature for a given pubkey. /// /// Inputs: diff --git a/src/crypto/ed25519.cpp b/src/crypto/ed25519.cpp index 2215cf672..7e65f7f36 100644 --- a/src/crypto/ed25519.cpp +++ b/src/crypto/ed25519.cpp @@ -213,6 +213,19 @@ b64 sign(const PrivKeySpan& ed25519_privkey, std::span msg) { return sig; } +b64 decoy_signature() { + b64 sig; + // R = r·B for a random scalar r: a canonical point in the prime-order (main) subgroup, exactly + // like a real signature's R. (A directly-sampled random group element lands off the main + // subgroup ~7/8 of the time -- detectable via an L-torsion check.) + std::array r; + crypto_core_ed25519_scalar_random(r.data()); + crypto_scalarmult_ed25519_base_noclamp(to_unsigned(sig.data()), r.data()); + // s = a second, independent random scalar in ]0, L[, used as-is. + crypto_core_ed25519_scalar_random(to_unsigned(sig.data() + crypto_core_ed25519_BYTES)); + return sig; +} + bool verify( std::span sig, std::span pubkey, diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index f1b94b25e..8c15a1ad6 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -278,20 +278,14 @@ static std::span unpad_message(std::span paylo return payload.first(size_without_padding); } -// Attaches a Session Pro signature to an envelope. If no pro key is provided, a dummy -// (unverifiable) signature from a throwaway key is used so that pro and non-pro messages are -// indistinguishable on the wire. +// Attaches a Session Pro signature to an envelope. With no pro key a decoy signature is attached +// instead, so that pro and non-pro messages are indistinguishable on the wire (see +// ed25519::decoy_signature). static void attach_pro_sig_to_envelope( SessionProtos::Envelope& envelope, std::span content, const ed25519::OptionalPrivKeySpan& pro_key) { - b64 signature; - if (!pro_key) { - auto [dummy_pk, dummy_sk] = ed25519::keypair(); - signature = ed25519::sign(dummy_sk, content); - } else { - signature = ed25519::sign(*pro_key, content); - } + b64 signature = pro_key ? ed25519::sign(*pro_key, content) : ed25519::decoy_signature(); std::string* pro_sig = envelope.mutable_prosig(); pro_sig->assign(reinterpret_cast(signature.data()), signature.size()); } From 3516c32a77a98837931324c2cdf2b7b6524c9d35 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:25:20 -0300 Subject: [PATCH 22/34] Fix unused parameters Most of these are just silenced, but at least a couple make changes to the API to drop parameters that have no point. Adds a WARN_UNUSED_PARAMETERS cmake option to turn these on. --- CMakeLists.txt | 1 + .../network/transport/network_transport.hpp | 4 +-- .../network/transport/quic_transport.hpp | 3 +- include/session/session_encrypt.h | 2 -- src/CMakeLists.txt | 6 ++++ src/blinding.cpp | 5 ++-- src/network/network_config.cpp | 8 +++--- 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 | 10 +++---- src/network/snode_pool.cpp | 2 +- src/network/transport/quic_transport.cpp | 18 ++++-------- src/session_encrypt.cpp | 2 -- tests/test_group_keys.cpp | 2 +- tests/test_onion_request_router.cpp | 28 +++++++++---------- tests/test_snode_pool.cpp | 4 +-- 17 files changed, 48 insertions(+), 53 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d9bd6bca1..c9b744936 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ else() set(use_lto_default ON) endif() +option(WARN_UNUSED_PARAMETERS "Enabled unused parameter warnings" ON) option(WARNINGS_AS_ERRORS "Treat all compiler warnings as errors" OFF) option(FATAL_MISSING_DECLARATIONS "Developer/CI option: fatal error on non-static definitions without prior declarations (-Werror=missing-declarations)" OFF) diff --git a/include/session/network/transport/network_transport.hpp b/include/session/network/transport/network_transport.hpp index d9cf04802..124262677 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) {} virtual void verify_connectivity( service_node node, std::chrono::milliseconds timeout, @@ -29,4 +29,4 @@ class ITransport { virtual void send_request(Request request, network_response_callback_t callback) = 0; }; -} // namespace session::network \ No newline at end of file +} // namespace session::network diff --git a/include/session/network/transport/quic_transport.hpp b/include/session/network/transport/quic_transport.hpp index fa039c3bb..f23767a69 100644 --- a/include/session/network/transport/quic_transport.hpp +++ b/include/session/network/transport/quic_transport.hpp @@ -92,9 +92,8 @@ class QuicTransport : public ITransport, public std::enable_shared_from_this conn_id, std::optional error_code, std::optional custom_error); }; -} // namespace session::network \ No newline at end of file +} // namespace session::network diff --git a/include/session/session_encrypt.h b/include/session/session_encrypt.h index 673f26d3c..1cf8bf11d 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/CMakeLists.txt b/src/CMakeLists.txt index 7d87c3799..50c11c71d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,6 +27,12 @@ if(FATAL_MISSING_DECLARATIONS) 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/blinding.cpp b/src/blinding.cpp index 86241fb78..ab2c8069e 100644 --- a/src/blinding.cpp +++ b/src/blinding.cpp @@ -47,7 +47,6 @@ namespace { void blind_id_impl( std::span session_id, - std::span server_pk, std::span blind_factor, std::span out, std::byte prefix) { @@ -65,7 +64,7 @@ namespace { std::span session_id, std::span server_pk, std::span out) { - blind_id_impl(session_id, server_pk, blind15_factor(server_pk), out, std::byte{0x15}); + blind_id_impl(session_id, blind15_factor(server_pk), out, std::byte{0x15}); } void blind25_id_impl( @@ -73,7 +72,7 @@ namespace { std::span server_pk, std::span out) { blind_id_impl( - session_id, server_pk, blind25_factor(session_id, server_pk), out, std::byte{0x25}); + session_id, blind25_factor(session_id, server_pk), out, std::byte{0x25}); } // Parses server_pk from either 32 raw bytes or 64 hex digits. diff --git a/src/network/network_config.cpp b/src/network/network_config.cpp index 33757c127..209a18a75 100644 --- a/src/network/network_config.cpp +++ b/src/network/network_config.cpp @@ -124,7 +124,7 @@ void Config::handle_config_opt(opt::quic_file_server_port qfp) { // 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"); } @@ -134,7 +134,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"); } @@ -279,12 +279,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 8c12bef00..1af3071f2 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 3bb6fda83..002aded39 100644 --- a/src/network/routing/onion_request_router.cpp +++ b/src/network/routing/onion_request_router.cpp @@ -2245,7 +2245,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 d6c251fbf..6eff8fc37 100644 --- a/src/network/routing/session_router_router.cpp +++ b/src/network/routing/session_router_router.cpp @@ -183,7 +183,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 ba233d07b..b2c638896 100644 --- a/src/network/session_network.cpp +++ b/src/network/session_network.cpp @@ -1004,7 +1004,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 = clock_now_ms(); @@ -1070,7 +1070,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); @@ -1542,7 +1542,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, @@ -1600,7 +1600,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&) { if constexpr (std::is_same_v) total_metadata_size += sizeof(session_onion_path_metadata); else { @@ -1909,7 +1909,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 14b34a4d0..95caca6d1 100644 --- a/src/network/snode_pool.cpp +++ b/src/network/snode_pool.cpp @@ -604,7 +604,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 f2a414816..23bf757fb 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) { @@ -286,7 +286,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 { @@ -361,18 +361,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()); } } @@ -556,7 +551,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 83fef355a..09ef4a4c5 100644 --- a/src/session_encrypt.cpp +++ b/src/session_encrypt.cpp @@ -1150,7 +1150,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, @@ -1258,7 +1257,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, diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 7dc89009c..6981905f1 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -45,7 +45,7 @@ struct pseudo_client { pseudo_client( std::span seed, - bool admin, + bool /*admin*/, std::span gpk, const ed25519::OptionalPrivKeySpan& gsk, std::optional> info_dump = std::nullopt, diff --git a/tests/test_onion_request_router.cpp b/tests/test_onion_request_router.cpp index 98000b2fc..c4f882806 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, + std::chrono::milliseconds /*timeout*/, + const std::string& /*request_id*/, + const RequestCategory, + 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&, std::function) override { func_called("add_failure_listener"); } - void remove_failure_listeners(const ed25519_pubkey& pubkey) override { + void remove_failure_listeners(const ed25519_pubkey&) override { func_called("remove_failure_listeners"); } - void send_request(Request request, network_response_callback_t callback) override { + void send_request(Request, network_response_callback_t) override { func_called("send_request"); } }; diff --git a/tests/test_snode_pool.cpp b/tests/test_snode_pool.cpp index 82eb5151d..6f7dd5f5c 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 e1bb841d5d0840d6ed6b9621dd26e668616ebeac Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 13:47:43 -0300 Subject: [PATCH 23/34] 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 8d001e3b2..30ead1be0 100644 --- a/src/config/convo_info_volatile.cpp +++ b/src/config/convo_info_volatile.cpp @@ -160,15 +160,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 7d4ee58ac..a3bb32324 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -228,7 +228,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) { auto [pk, sk] = ed25519::keypair(std::span{*seed}.first<32>()); if (id != "03{:x}"_format(pk)) secretkey.clear(); From 49f3a4e1e0b9d5be8633b2eb9caecb5f17a1722a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:05:33 -0300 Subject: [PATCH 24/34] 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 | 11 ++++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index abe0f804c..f50dce527 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -224,19 +224,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 f834a7130..546f59f93 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -189,7 +189,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 @@ -201,13 +201,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_b32 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 8c15a1ad6..e6262bcae 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -169,15 +169,15 @@ std::vector ProProof::signed_message() const { } cleared_b32 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 @@ -842,9 +842,10 @@ 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( - to_byte_span(master_seed, 32), std::chrono::sys_seconds{std::chrono::seconds{unix_ts}}); + to_byte_span(master_seed, 32), + std::chrono::sys_seconds{std::chrono::seconds{now_unix_ts}}); std::memcpy(rotating_seed_out, seed.data(), seed.size()); } From e95ab50ece51a1bd3112677428a451a9ff0571da Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:05:34 -0300 Subject: [PATCH 25/34] 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 3c2641ed2..ec7731e5f 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -315,12 +315,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 198c15b46..233be2749 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -733,5 +733,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 1fe662c239745f8df216182a200650499e373ba7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:19:55 -0300 Subject: [PATCH 26/34] attachments: mark process_chunk's is_final [[maybe_unused]] is_final is only referenced by the following assert, so under WARN_UNUSED_PARAMETERS a release (NDEBUG) build -- where the assert is stripped -- warns it is unused. Mark it [[maybe_unused]] to silence that while keeping it available to the assert in debug builds. (Parity with the dev-branch fix prompted by the clients agent's debug-build report.) 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 256b1a80b..7d022d2d1 100644 --- a/src/attachments.cpp +++ b/src/attachments.cpp @@ -434,7 +434,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 df61ae1fabc106829818b6588855307719a26115 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 18:33:32 -0300 Subject: [PATCH 27/34] 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 f50dce527..e19a018da 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -223,16 +223,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 546f59f93..321b48d1a 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -63,6 +63,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; + // Session Pro 16-byte signing domain prefixes; each prefixes the Ed25519-signed message for its // endpoint (pro-wire-protocol.md §2 proof, §3 signed requests). ASCII, `_`-right-padded to 16 // bytes. @@ -188,8 +198,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 @@ -201,9 +211,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 ec7731e5f..639452dcb 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -316,8 +316,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; @@ -329,12 +329,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 233be2749..6fa8d8721 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -735,14 +735,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 05a71432c97916e592ec2b792b36fe50ccf5e2a0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:13:28 -0300 Subject: [PATCH 28/34] 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 d1dfed9da..7ebf5e386 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -318,8 +319,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 e3f6688ff..9cd33f2e3 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -179,24 +179,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. @@ -208,43 +199,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()); } @@ -252,8 +231,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 1983484fe29d8ee8714a78129d59d4a1780331f9 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:13:29 -0300 Subject: [PATCH 29/34] 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 | 40 ++++------------ src/session_protocol.cpp | 69 ++++++---------------------- tests/test_session_protocol.cpp | 42 ++++++----------- 5 files changed, 45 insertions(+), 154 deletions(-) diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 573d7b7ec..f15c2c85c 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -35,7 +35,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 e19a018da..315485da1 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -94,7 +94,6 @@ extern const uint64_t SESSION_PROTOCOL_PRO_MESSAGE_FEATURE_10K_CHARACTER_LIMIT; 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; @@ -315,50 +314,25 @@ typedef struct session_protocol_pro_features_for_msg { /// there is no error. const char* error; uint64_t bitset; // Mask of SESSION_PROTOCOL_PRO_MESSAGE_FEATURE_* bits - 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_dm_v1 /// diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 321b48d1a..761ddc89b 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -228,10 +228,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, }; @@ -297,7 +294,6 @@ struct ProFeaturesForMsg { ProFeaturesForMsgStatus status; std::string_view error; ProMessageFlags flags; - size_t codepoint_count; }; struct Envelope { @@ -367,41 +363,21 @@ struct DecodedCommunityMessage { std::optional pro; }; -/// API: session_protocol/pro_features_for_utf8 -/// -/// Determine the Pro features that are used in a given conversation message. -/// -/// Inputs: -/// - `msg` -- the UTF-8 string view to count the number of codepoints in to determine if it needs -/// 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. -/// - `flags` -- 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(std::u8string_view msg); -ProFeaturesForMsg pro_features_for_utf8(std::string_view msg); -ProFeaturesForMsg pro_features_for_utf8(std::span msg); - -/// 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: -/// - `msg` -- the UTF-16 string view to count the number of codepoints in to determine if it needs -/// 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. /// - `flags` -- 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(std::u16string_view msg); +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 e6262bcae..4cdebb709 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include @@ -195,50 +194,22 @@ cleared_b32 ProProof::rotating_seed( }; // namespace session -namespace { +namespace session { -session::ProFeaturesForMsg pro_features_check( - const simdutf::result& validation, size_t codepoints) { - session::ProFeaturesForMsg result = {}; - if (validation.is_ok()) { - result.status = session::ProFeaturesForMsgStatus::Success; - result.codepoint_count = codepoints; - - if (result.codepoint_count > session::STANDARD_CHARACTER_LIMIT) { - if (result.codepoint_count <= session::PRO_HIGHER_CHARACTER_LIMIT) { - result.flags |= session::ProMessageFlags::CharLimit10k; - } 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.flags |= ProMessageFlags::CharLimit10k; + } 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(validation.error); } return result; } -} // namespace - -namespace session { - -ProFeaturesForMsg pro_features_for_utf8(std::span msg) { - auto v = simdutf::validate_utf8_with_errors(msg); - return pro_features_check(v, v.is_ok() ? simdutf::count_utf8(msg) : 0); -} -ProFeaturesForMsg pro_features_for_utf8(std::u8string_view msg) { - return pro_features_for_utf8({reinterpret_cast(msg.data()), msg.size()}); -} -ProFeaturesForMsg pro_features_for_utf8(std::string_view msg) { - return pro_features_for_utf8({reinterpret_cast(msg.data()), msg.size()}); -} - -ProFeaturesForMsg pro_features_for_utf16(std::u16string_view msg) { - auto v = simdutf::validate_utf16_with_errors(msg); - return pro_features_check(v, v.is_ok() ? simdutf::count_utf16(msg) : 0); -} - constexpr std::byte PADDING_TERMINATING_BYTE{0x80}; std::vector pad_message(std::span payload) { @@ -904,27 +875,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* msg, size_t msg_size) { - auto result_cpp = pro_features_for_utf8({msg, msg_size}); - return session_protocol_pro_features_for_msg{ - .status = static_cast(result_cpp.status), - .error = result_cpp.error.data(), - .bitset = static_cast(result_cpp.flags), - .codepoint_count = result_cpp.codepoint_count, - }; -} - -LIBSESSION_C_API -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf16( - const uint16_t* msg, size_t msg_size) { - auto result_cpp = pro_features_for_utf16( - {std::launder(reinterpret_cast(msg)), msg_size}); +session_protocol_pro_features_for_msg session_protocol_pro_features_for_message( + size_t codepoint_count) { + auto result_cpp = pro_features_for_message(codepoint_count); return session_protocol_pro_features_for_msg{ .status = static_cast(result_cpp.status), .error = result_cpp.error.data(), .bitset = static_cast(result_cpp.flags), - .codepoint_count = result_cpp.codepoint_count, }; } diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index f4e914319..173d98b02 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -87,57 +87,41 @@ 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 == 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((pro_msg.bitset & SESSION_PROTOCOL_PRO_MESSAGE_FEATURE_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((pro_msg.bitset & SESSION_PROTOCOL_PRO_MESSAGE_FEATURE_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 == 0); - REQUIRE(pro_msg.codepoint_count == msg.size()); } } @@ -407,7 +391,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((pro_msg.bitset & SESSION_PROTOCOL_PRO_MESSAGE_FEATURE_10K_CHARACTER_LIMIT)); uint64_t profile_bitset = 0; From 5efc327adb1c8c5584db669fca20912705ef91a2 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 14:04:51 -0300 Subject: [PATCH 30/34] session_protocol/user_profile: fix "weekly rotation" wording; add jitter TODO Same doc correction as dev: the 7-day rotating_seed quantization is not a "weekly rotation period" -- rotation cadence is the backend-issued proof expiry (min of subscription remaining, 30 days), and the 7-day bucket only makes every device agree on the derived seed. Also adds a TODO in pro_renewal_target to investigate device-count-aware renewal jitter (skewed so the first-order statistic is uniform, using PFS's multi-device account info) so the public renewal-time distribution can't leak the device count -- the dev branch can't (count unknown there) and accepts the lesser backend-only leak. 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 | 12 ++++++++++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index f5963097e..2f03f4513 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -404,7 +404,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 315485da1..ea43fc90d 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -222,15 +222,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 761ddc89b..42eb452c3 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -63,7 +63,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. @@ -198,9 +198,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 @@ -211,7 +213,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 639452dcb..64031bbea 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -316,8 +316,16 @@ 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. + // + // TODO: investigate adding renewal-time jitter here, skewed per device using the account's + // device count (the same multi-device-account information that drives PFS key rotation) so that + // the first-order statistic -- the earliest device's renewal time, which is what an observer + // actually sees -- is uniformly distributed regardless of N. Naive per-device i.i.d. jitter + // would instead publicly leak N, since the min of N jitters has an N-dependent distribution. + // dev cannot do this (device count unknown there) and deliberately accepts the lesser, + // backend-only leak instead of a public one. 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 b1c85de26c72c6eb547d468fc1840c4c21626bad Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 17:33:28 -0300 Subject: [PATCH 31/34] 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 2f03f4513..e71b98bf7 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -402,7 +402,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 64031bbea..4b840a8d1 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -302,11 +302,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 6fa8d8721..758caa625 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -701,8 +701,14 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { { session::config::UserProfile pr{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 = {}; @@ -734,8 +740,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 b136c2395bce0b10b605c78d6e004d95ade166ee Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 17:33:28 -0300 Subject: [PATCH 32/34] 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 | 18 +++++++++++++++ tests/test_pro_backend.cpp | 39 ++++++++++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 1bc1a094e..1940b0232 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 f15c2c85c..7b26bc64d 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -176,6 +176,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 8f4f060d8..d8e7c8d3c 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -292,6 +292,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 = as_sys_seconds(account_expiry_ts); } } // namespace @@ -302,6 +308,16 @@ 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 = as_sys_seconds(it->get()); + } return result; } fill_proof(result_obj, result, errs); @@ -849,6 +865,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 be407a063..2d58d09e8 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 e8511cca6356425243bfdcd86914f2442e2cf95b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 23:05:43 -0300 Subject: [PATCH 33/34] 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 42eb452c3..0976e39df 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -61,6 +61,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 fdb5826091d86ea0a1693a36bc931c7fb37d1a1a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 23:25:56 -0300 Subject: [PATCH 34/34] 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 | 21 +++++++-------- include/session/session_protocol.h | 5 ++-- include/session/session_protocol.hpp | 27 ++++++++++---------- src/blinding.cpp | 3 +-- src/config/pro.cpp | 4 +-- src/config/user_profile.cpp | 17 ++++++------ src/curve25519.cpp | 3 ++- src/network/routing/onion_request_router.cpp | 9 ++++--- src/pro_backend.cpp | 9 ++++--- src/session_protocol.cpp | 8 +++--- tests/test_compression.cpp | 2 +- tests/test_config_pro.cpp | 3 ++- tests/test_config_userprofile.cpp | 3 ++- tests/test_onion_request_router.cpp | 3 +-- tests/test_pro_backend.cpp | 1 - tests/test_session_protocol.cpp | 5 ++-- 19 files changed, 88 insertions(+), 79 deletions(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 7c60d4089..be9bb87a3 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 e71b98bf7..3dcade645 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -31,8 +31,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 @@ -343,8 +343,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. @@ -402,12 +402,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 1940b0232..56343f929 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 7b26bc64d..491842679 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"; @@ -109,9 +109,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; @@ -128,8 +128,8 @@ struct ResponseBase { /// Per-provider support/management URLs (from provider_urls()). These are identical for every user /// (not translation data), so libsession owns them as the single source of truth rather than each /// client duplicating them; the human-readable provider/store *names* are translation data and -/// remain the client's job. Each field is std::nullopt when that provider has no such URL -- clients -/// test the optional rather than an empty-string sentinel. Present views point at static, +/// remain the client's job. Each field is std::nullopt when that provider has no such URL -- +/// clients test the optional rather than an empty-string sentinel. Present views point at static, /// null-terminated string literals. struct ProviderURLs { std::optional refund_platform_url; ///< Native store refund flow @@ -156,7 +156,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 ea43fc90d..c5907e1d2 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -234,9 +234,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 0976e39df..7f8358f89 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -63,15 +63,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; @@ -207,12 +208,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 @@ -550,7 +550,8 @@ std::vector encode_for_group( /// - `sender_ed25519_pubkey` -- The sender's ed25519 public key embedded in the encrypted payload. /// - `sender_x25519_pubkey` -- The sender's x25519 public key, derived from the sender's ed25519 /// key. -/// - `pro` -- Optional object that is set if there was pro metadata associated with the envelope, if +/// - `pro` -- Optional object that is set if there was pro metadata associated with the envelope, +/// if /// any. The `status` field in the decoded pro object should be used to determine whether or not /// the caller can respect the contents of the `proof` and `features`. /// diff --git a/src/blinding.cpp b/src/blinding.cpp index ab2c8069e..44dbfc1ab 100644 --- a/src/blinding.cpp +++ b/src/blinding.cpp @@ -71,8 +71,7 @@ namespace { std::span session_id, std::span server_pk, std::span out) { - blind_id_impl( - session_id, blind25_factor(session_id, server_pk), out, std::byte{0x25}); + blind_id_impl(session_id, blind25_factor(session_id, server_pk), out, std::byte{0x25}); } // Parses server_pk from either 32 raw bytes or 64 hex digits. diff --git a/src/config/pro.cpp b/src/config/pro.cpp index c1af4efb6..e9b6ef73e 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 4b840a8d1..9dd8bad9a 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}} < - clock_now_s() - std::chrono::weeks{1}) + clock_now_s() - std::chrono::weeks{1}) data["R"].erase(); } } @@ -345,15 +345,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; @@ -566,7 +566,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/curve25519.cpp b/src/curve25519.cpp index 2be73c76b..5bc45bee2 100644 --- a/src/curve25519.cpp +++ b/src/curve25519.cpp @@ -1,8 +1,9 @@ +#include "session/curve25519.h" + #include #include "session/crypto/ed25519.hpp" #include "session/crypto/x25519.hpp" -#include "session/curve25519.h" #include "session/export.h" #include "session/util.hpp" diff --git a/src/network/routing/onion_request_router.cpp b/src/network/routing/onion_request_router.cpp index 002aded39..536bd62ee 100644 --- a/src/network/routing/onion_request_router.cpp +++ b/src/network/routing/onion_request_router.cpp @@ -6,10 +6,9 @@ #include #include #include -#include - #include #include +#include #include "session/file.hpp" #include "session/hash.hpp" @@ -2245,7 +2244,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 d8e7c8d3c..3656b8314 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -314,8 +314,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 = as_sys_seconds(it->get()); } return result; @@ -544,7 +543,10 @@ ProRequest payment_details_request( return {get_payment_details_endpoint, application_json, payment_details_body( - master_privkey.pubkey(), ed25519::sign(master_privkey, msg), unix_ts, limit, + master_privkey.pubkey(), + ed25519::sign(master_privkey, msg), + unix_ts, + limit, before)}; } @@ -977,7 +979,6 @@ session_pro_backend_get_payment_details_response_parse(const char* json, size_t return result; } - LIBSESSION_C_API void session_pro_backend_request_free(session_pro_backend_request* request) { if (request) { if (request->internal_) diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index 4cdebb709..0d6592240 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -5,7 +5,6 @@ #include #include - #include #include #include @@ -173,9 +172,10 @@ cleared_b32 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)); diff --git a/tests/test_compression.cpp b/tests/test_compression.cpp index 47560f284..c0eea2db6 100644 --- a/tests/test_compression.cpp +++ b/tests/test_compression.cpp @@ -1,11 +1,11 @@ #include -#include #include #include #include #include +#include #include #include "utils.hpp" diff --git a/tests/test_config_pro.cpp b/tests/test_config_pro.cpp index a1008ad5a..9ff2fd1f9 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -82,7 +82,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 758caa625..068198e16 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -661,7 +661,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_onion_request_router.cpp b/tests/test_onion_request_router.cpp index c4f882806..371a97d53 100644 --- a/tests/test_onion_request_router.cpp +++ b/tests/test_onion_request_router.cpp @@ -196,8 +196,7 @@ namespace { override { func_called("verify_connectivity"); } - void add_failure_listener( - const ed25519_pubkey&, std::function) override { + void add_failure_listener(const ed25519_pubkey&, std::function) override { func_called("add_failure_listener"); } void remove_failure_listeners(const ed25519_pubkey&) override { diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index 2d58d09e8..ad6919c01 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 173d98b02..8d28805ca 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 @@ -91,7 +91,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 == 0); }