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/config/pro.hpp b/include/session/config/pro.hpp index 739295a62..c629f97cb 100644 --- a/include/session/config/pro.hpp +++ b/include/session/config/pro.hpp @@ -8,16 +8,15 @@ namespace session::config { /// keys used currently or in the past (so that we don't reuse): /// -/// s + session pro data -/// | -/// +-- p + proof -/// | | -/// | +-- @ - version -/// | +-- e - expiry unix timestamp (in milliseconds) -/// | +-- 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 @@ -27,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.h b/include/session/config/user_profile.h index 9bfca296b..be9bb87a3 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -399,6 +399,73 @@ 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). +/// 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 (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 +/// +/// 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); + +/// 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); + +/// 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 9cfceecc7..3dcade645 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -26,10 +26,21 @@ 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 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 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 +/// 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`). @@ -329,6 +340,84 @@ 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. + /// + /// 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 (or the stored value is stale). + 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); + + /// 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); + + /// 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: + /// - 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; + /// - 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/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/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/pro_backend.h b/include/session/pro_backend.h index 24c5209b7..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 value transmitted on the wire and folded into -/// the add-payment / set-refund signed messages (§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,13 +26,10 @@ 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; 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 @@ -142,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 @@ -209,11 +213,9 @@ 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). - /// 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; @@ -226,7 +228,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,41 +263,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 + -/// 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 + @@ -411,42 +377,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..491842679 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -19,22 +19,23 @@ /// /// 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 /// `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 @@ -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 / set-refund signed messages (§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,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. - /// "unknown_payment", "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; @@ -124,16 +125,11 @@ 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 -/// 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 @@ -160,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. "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,71 +173,37 @@ 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. -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 `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 {}; + /// 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 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 -/// 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, @@ -287,22 +250,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 @@ -311,12 +271,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, @@ -390,13 +344,8 @@ 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. + /// 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; }; @@ -448,12 +397,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 +422,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_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/include/session/session_protocol.h b/include/session/session_protocol.h index 64b792c64..c5907e1d2 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; @@ -221,6 +220,23 @@ 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 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. 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 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); + /// 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 @@ -298,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 /// @@ -419,7 +410,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 @@ -455,13 +445,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 1ec1d72c8..7f8358f89 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,19 +54,41 @@ 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. +/// +/// 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 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 +/// 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. 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); @@ -77,11 +101,6 @@ enum class ProStatus { Expired = SESSION_PROTOCOL_PRO_STATUS_EXPIRED, // Proof is verified; has expired }; -struct ProSignedMessage { - std::span sig; - std::span msg; -}; - class ProProof { public: /// Version of the proof set by the Session Pro Backend @@ -162,17 +181,20 @@ class ProProof { /// they are the original signatory of the proof. /// - `unix_ts` -- Unix timestamp to compared against the embedded `expiry_at` /// to determine if the proof has expired or not - /// - `signed_msg` -- Optionally set the payload to the message with the signature to verify if - /// the embedded `rotating_pubkey` in the proof signed the given message. + /// - `user_sig` -- optionally, the user's 64-byte signature over `signed_msg`; when set, this + /// verifies that the proof's embedded `rotating_pubkey` produced it. Omit (the default) to + /// skip the user-signature check. + /// - `signed_msg` -- the message bytes that `user_sig` signs; ignored when `user_sig` is unset. /// /// Outputs: - /// - `ProStatus` - The derived status given the components of the message. If `signed_msg` is + /// - `ProStatus` - The derived status given the components of the message. If `user_sig` is /// not set then this function can never return `ProStatus::InvalidUserSig` from the set of /// possible enum values. Otherwise this funtion can return all possible values. ProStatus status( std::span verify_pubkey, std::chrono::sys_seconds unix_ts, - const std::optional& signed_msg); + std::optional> user_sig = std::nullopt, + std::span signed_msg = {}) const; /// API: pro/Proof::signed_message /// @@ -181,6 +203,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 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. + /// + /// 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. + /// - `now` -- the current time (floored to the 7-day seed period internally). + /// + /// Outputs: + /// - The 32-byte rotating seed (secret; zeroed on destruction). + static cleared_b32 rotating_seed( + std::span master_seed, std::chrono::sys_seconds now); + bool operator==(const ProProof& other) const { return version == other.version && revocation_tag == other.revocation_tag && rotating_pubkey == other.rotating_pubkey && expiry_at == other.expiry_at && @@ -191,10 +236,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, }; @@ -260,7 +302,6 @@ struct ProFeaturesForMsg { ProFeaturesForMsgStatus status; std::string_view error; ProMessageFlags flags; - size_t codepoint_count; }; struct Envelope { @@ -330,43 +371,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: -/// - `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 -/// `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 -/// -/// 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 +/// - `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 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 /// @@ -417,7 +436,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 @@ -431,7 +449,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); @@ -496,15 +513,12 @@ std::vector encode_for_group( std::span group_enc_key, const ed25519::OptionalPrivKeySpan& pro_rotating_ed25519_privkey); -/// API: session_protocol/decode_envelope +/// API: session_protocol/decode_dm_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. -/// -/// 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 @@ -522,40 +536,27 @@ 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, 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/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/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; diff --git a/src/blinding.cpp b/src/blinding.cpp index 86241fb78..44dbfc1ab 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,15 +64,14 @@ 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( std::span session_id, std::span server_pk, std::span out) { - blind_id_impl( - session_id, server_pk, 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/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/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/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); diff --git a/src/config/pro.cpp b/src/config/pro.cpp index 0a32299ab..e9b6ef73e 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -1,59 +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) + 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; + 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; - - // 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()) - return false; - if (!maybe_expiry) - return false; - - proof.version = *version; - 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()); } +} - // 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_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(); diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 67145d12c..9dd8bad9a 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; } @@ -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,20 +163,19 @@ 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"]; - proof_dict["@"] = pro.proof.version; - 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"); 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() { @@ -213,8 +212,15 @@ void UserProfile::set_animated_avatar(bool enabled) { } std::optional UserProfile::get_pro_access_expiry() const { - if (auto* E = data["E"].integer(); E) - 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; } @@ -223,6 +229,136 @@ 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 { + 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; +} + +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(); +} + +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(); + } +} + +std::optional UserProfile::pro_renewal_target( + std::chrono::sys_seconds now) const { + auto pro = get_pro_config(); + 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) + // 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 + // 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; + + // The nudges below are best-effort: they only make it *less likely* that two devices near a + // 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; + }; + + 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. + 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 (target <= now && near_boundary(now) && + expiry - (now + PRO_RENEWAL_BOUNDARY_DEFER) >= PRO_RENEWAL_BOUNDARY_MIN_VALIDITY) + return now + PRO_RENEWAL_BOUNDARY_DEFER; + + return target; } extern "C" { @@ -259,7 +395,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 { @@ -332,7 +468,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)); @@ -404,4 +540,37 @@ 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)); +} + +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)); +} + +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/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/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/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..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/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/pro_backend.cpp b/src/pro_backend.cpp index f0e19180b..3656b8314 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -132,60 +132,21 @@ 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"; 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 // 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 = @@ -194,8 +155,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(); @@ -281,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. @@ -353,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); @@ -363,23 +292,14 @@ 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); - } -} // 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; + // 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); } - fill_proof(result_obj, result, errs); - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; -} +} // namespace GenerateProProofResponse parse_pro_proof(std::string_view json) { GenerateProProofResponse result = {}; @@ -388,6 +308,15 @@ 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); @@ -427,30 +356,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) { @@ -517,7 +435,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)); } @@ -540,7 +458,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( @@ -586,7 +504,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,32 +521,17 @@ 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; } } // 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( @@ -637,10 +539,15 @@ 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) { @@ -673,10 +580,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 +650,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 +703,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 +718,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) { @@ -960,27 +779,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, @@ -1054,8 +852,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); @@ -1070,8 +867,10 @@ 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_); + delete static_cast(result.header.internal_); result = {}; result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; result.header.error_code = C_INVALID_RESPONSE_CODE; @@ -1134,7 +933,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,54 +979,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) { if (request->internal_) @@ -1239,7 +989,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( @@ -1256,8 +1006,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/src/pro_message.hpp b/src/pro_message.hpp index 3d7687357..38acd38e9 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -18,8 +19,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 @@ -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) { 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/src/session_protocol.cpp b/src/session_protocol.cpp index 4d1b15991..0d6592240 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -1,10 +1,10 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -142,7 +142,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) 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) @@ -150,8 +151,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; } @@ -165,50 +166,48 @@ std::vector ProProof::signed_message() const { return proof_signed_message(revocation_tag, rotating_pubkey, session::epoch_seconds(expiry_at)); } -}; // namespace session - -namespace { - -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; - } - } - } else { - result.status = session::ProFeaturesForMsgStatus::UTFDecodingError; - result.error = simdutf::error_to_string(validation.error); - } +cleared_b32 ProProof::rotating_seed( + 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 `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)); + 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 + // 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 +}; // namespace session 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); +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; + } + } + return result; } constexpr std::byte PADDING_TERMINATING_BYTE{0x80}; @@ -250,20 +249,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()); } @@ -313,7 +306,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) { @@ -539,17 +531,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())); } } } @@ -745,9 +737,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()); @@ -763,7 +753,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)); } } @@ -821,6 +812,14 @@ 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 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{now_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, @@ -852,19 +851,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 @@ -876,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}); +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, - }; -} - -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}); - 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, }; } @@ -948,7 +933,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, @@ -960,7 +944,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/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/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_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 851707066..9ff2fd1f9 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 = @@ -72,54 +73,29 @@ 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{ - /*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()}, - /*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 == 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.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{ - /*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()}, - /*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)); } } diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index d31044832..068198e16 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 = @@ -630,4 +632,128 @@ 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()); + + 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 = 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); + + { + // Round-trips through a dump/reload. + session::config::UserProfile profile2{seed, profile.dump()}; + 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()); + + // 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 - 1h; + 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 + 1h); + 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 + 1h; + 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()); + + // pro_renewal_target: centralised "when to renew" decision. + { + session::config::UserProfile pr{seed, std::nullopt}; + + // 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 = {}; + 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 + 10 * 24h; + store_proof(expiry); + pr.set_pro_access_expiry(now + 30 * 24h); + auto target = pr.pro_renewal_target(now); + REQUIRE(target.has_value()); + 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 + 30min); + 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 - 1h); + CHECK(pr.pro_renewal_target(now) == now); + + // 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); + 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); + + // 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); + } } 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..371a97d53 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,27 @@ 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 { + void add_failure_listener(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_pro_backend.cpp b/tests/test_pro_backend.cpp index 8212bc52a..ad6919c01 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( @@ -202,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 @@ -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); @@ -254,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 @@ -368,7 +345,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 +361,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 +373,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 +395,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); } @@ -560,92 +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); } - - 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,8 +628,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", -// 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{}; @@ -758,27 +644,18 @@ 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"; // 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"; @@ -788,12 +665,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" @@ -818,20 +689,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); @@ -853,16 +710,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; @@ -1175,7 +1022,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", @@ -1189,37 +1037,33 @@ 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); - // 2) get_pro_status: account ACTIVE, no refund yet, and the single latest payment - // is our redeemed one. + // 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))); 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 +1112,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 @@ -1324,14 +1147,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. @@ -1345,7 +1167,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); } diff --git a/tests/test_session_protocol.cpp b/tests/test_session_protocol.cpp index 3b47a586e..8d28805ca 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -86,57 +87,42 @@ 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()); } } @@ -316,7 +302,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(), @@ -407,7 +392,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; @@ -867,7 +852,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, @@ -895,3 +879,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)); +} 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) } 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); +}