diff --git a/CMakeLists.txt b/CMakeLists.txt index f12f105e3..8820384b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ else() endif() option(WARNINGS_AS_ERRORS "Treat all compiler warnings as errors" OFF) +option(WARN_UNUSED_PARAMETERS "Enabled unused parameter warnings" ON) option(STATIC_LIBSTD "Statically link libstdc++/libgcc" ${default_static_libstd}) diff --git a/include/session/config/pro.hpp b/include/session/config/pro.hpp index 6597fcf27..ac37647aa 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 d107de028..17e3a5782 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 c9a105405..ea20306c6 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -25,10 +25,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`). @@ -327,6 +338,84 @@ class UserProfile : public ConfigBase { /// - `access_expiry_ts` -- The timestamp (unix epoch seconds) that the users Session Pro access /// will expire, or nullopt to remove the value. void set_pro_access_expiry(std::optional access_expiry_ts); + + /// API: user_profile/UserProfile::get_refund_requested + /// + /// Retrieves the timestamp at which the user requested a refund of their current Session Pro + /// subscription, if any. This is synced across the user's devices so that a refund requested + /// on one device is reflected on the others; it does not go through the Pro backend. + /// + /// 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(sys_seconds now) const; }; } // namespace session::config diff --git a/include/session/network/transport/network_transport.hpp b/include/session/network/transport/network_transport.hpp index d9cf04802..fd914a39e 100644 --- a/include/session/network/transport/network_transport.hpp +++ b/include/session/network/transport/network_transport.hpp @@ -15,7 +15,7 @@ class ITransport { virtual void close_connections() = 0; virtual ConnectionStatus get_status() const = 0; - virtual void set_node_failure_reporter(node_failure_reporter_t reporter) {} + virtual void set_node_failure_reporter(node_failure_reporter_t /*reporter*/) {} virtual void verify_connectivity( service_node node, std::chrono::milliseconds timeout, diff --git a/include/session/network/transport/quic_transport.hpp b/include/session/network/transport/quic_transport.hpp index fbccb7687..12f220b71 100644 --- a/include/session/network/transport/quic_transport.hpp +++ b/include/session/network/transport/quic_transport.hpp @@ -92,7 +92,6 @@ class QuicTransport : public ITransport, public std::enable_shared_from_this conn_id, std::optional error_code, std::optional custom_error); }; diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index ca416a462..c21a77739 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -11,12 +11,12 @@ extern "C" { #endif -/// Canonical payment-provider `code` strings — the 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 uint8_t* master_privkey, - size_t master_privkey_len, - const uint8_t* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) NON_NULL_ARG(1, 3, 5, 6); - /// API: session_pro_backend/generate_pro_proof_request_build /// /// Builds the generate-proof request to POST, as a session_pro_backend_request (endpoint + @@ -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 uint8_t* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) NON_NULL_ARG(1, 5, 6); - -/// API: session_pro_backend/set_payment_refund_requested_response_parse -/// -/// Parses a JSON string into a GetProPaymentsResponse struct. -/// The caller must free the response using -/// `session_pro_backend_set_payment_refund_requested_response_free`. -/// -/// Inputs: -/// - `json` -- JSON string to parse. -/// - `json_len` -- Length of the JSON string. -LIBSESSION_EXPORT session_pro_backend_set_payment_refund_requested_response -session_pro_backend_set_payment_refund_requested_response_parse(const char* json, size_t json_len); - #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 72dc9b9c9..474ae2652 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -17,22 +17,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"; @@ -104,9 +105,9 @@ struct ResponseBase { ResponseStatus status = ResponseStatus::Ok; /// On non-Ok, a stable machine-readable slug identifying the outcome (spec §5.1), e.g. - /// "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; @@ -156,7 +157,8 @@ std::span visible_platforms(); /// returned by the `*_request()` helpers below. Callers relay `data` verbatim under `content_type` /// and never inspect or assume its format -- libsession owns the wire encoding. struct ProRequest { - /// Endpoint path relative to the backend base URL, e.g. "add_pro_payment". As returned from a + /// Endpoint path relative to the backend base URL, e.g. "generate_pro_proof". As returned from + /// a /// `*_request()` function this points at a static, null-terminated string, so using /// `endpoint.data()` as a C string is valid. std::string_view endpoint; @@ -172,71 +174,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( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id); - -ProRequest add_payment_request( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id); - -/// Common base for the responses that carry a freshly-issued Session Pro proof: both add-payment -/// and generate-proof reply with exactly a proof. `AddProPaymentResponse` and -/// `GenerateProProofResponse` are distinct types (each parsed by its own free function below) that -/// share `proof` today but can diverge independently. `proof` is the raw parse result, convertible -/// to a config::ProProof. -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( - std::span master_privkey, - std::span rotating_privkey, - sys_seconds unix_ts); - ProRequest pro_proof_request( std::span master_privkey, std::span rotating_privkey, @@ -283,21 +251,18 @@ struct GetProRevocationsResponse : ResponseBase { GetProRevocationsResponse parse_revocations(std::string_view json); /// Query a master key's Session Pro status (endpoint `get_pro_status`): the account entitlement -/// state plus its single latest payment -- the hot "am I Pro?" path. `pro_status_sig` computes the -/// master signature; `pro_status_request` builds the whole request (signing internally) and returns -/// the endpoint + JSON body. Both throw on an incorrectly-sized key. +/// state plus its single latest payment -- the hot "am I Pro?" path. Builds the whole request, +/// signing internally with the master key, and returns the endpoint + JSON body. Throws on an +/// incorrectly-sized key. /// /// Inputs: /// - `master_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium master private key /// - `unix_ts` -- Unix timestamp for the request -array_uc64 pro_status_sig(std::span master_privkey, sys_seconds unix_ts); - ProRequest pro_status_request(std::span master_privkey, sys_seconds unix_ts); /// Query a master key's Session Pro payment history (endpoint `get_payment_details`), one keyset -/// page at a time. `payment_details_sig` computes the master signature; `payment_details_request` -/// builds the whole request (signing internally) and returns the endpoint + JSON body. Both throw -/// on an incorrectly-sized key. +/// page at a time. Builds the whole request, signing internally with the master key, and returns +/// the endpoint + JSON body. Throws on an incorrectly-sized key. /// /// Inputs: /// - `master_privkey` -- 32-byte Ed25519 seed or 64-byte libsodium master private key @@ -306,12 +271,6 @@ ProRequest pro_status_request(std::span master_privkey, sys_secon /// - `before` -- opaque pagination cursor from a previous page's `next_cursor` (see /// PaymentDetailsResponse); the empty string requests the newest page. Pass it through verbatim; /// it must not be parsed or synthesized. -array_uc64 payment_details_sig( - std::span master_privkey, - sys_seconds unix_ts, - uint32_t limit, - std::string_view before); - ProRequest payment_details_request( std::span master_privkey, sys_seconds unix_ts, @@ -385,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. - 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; }; @@ -443,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. - sys_seconds refund_requested_at; }; struct PaymentDetailsResponse : ResponseBase { @@ -474,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) -array_uc64 refund_sig( - std::span master_privkey, - sys_seconds unix_ts, - sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id); - -ProRequest refund_request( - std::span master_privkey, - sys_seconds unix_ts, - sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id); - -struct SetPaymentRefundRequestedResponse : ResponseBase { - /// True if a payment was found matching the given payment information and that the refund - /// request unix timestamp was set - bool updated; -}; - -/// Parse the reply to a `refund_request`. On failure `status` is set to an error state and `errors` -/// is populated. -SetPaymentRefundRequestedResponse parse_refund(std::string_view json); } // namespace session::pro_backend diff --git a/include/session/session_encrypt.h b/include/session/session_encrypt.h index 4aa45e9c1..6ded17298 100644 --- a/include/session/session_encrypt.h +++ b/include/session/session_encrypt.h @@ -126,7 +126,6 @@ LIBSESSION_EXPORT session_encrypt_group_message session_encrypt_for_group( const unsigned char* user_ed25519_privkey, size_t user_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* group_enc_key, size_t group_enc_key_len, const unsigned char* plaintext, @@ -285,7 +284,6 @@ LIBSESSION_EXPORT session_decrypt_group_message_result session_decrypt_group_mes const span_u8* decrypt_ed25519_privkey_list, size_t decrypt_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* ciphertext, size_t ciphertext_len, char* error, diff --git a/include/session/session_protocol.h b/include/session/session_protocol.h index 79383cc96..de0a017a5 100644 --- a/include/session/session_protocol.h +++ b/include/session/session_protocol.h @@ -113,7 +113,6 @@ typedef struct session_protocol_pro_message_bitset { typedef enum SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS { // See session::ProFeaturesForMsgStatus SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS, - SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR, SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT, } SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS; @@ -289,6 +288,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 @@ -366,50 +382,25 @@ typedef struct session_protocol_pro_features_for_msg { /// there is no error. const char* error; session_protocol_pro_message_bitset bitset; - size_t codepoint_count; } session_protocol_pro_features_for_msg; -/// API: session_protocol/session_protocol_get_pro_features_for_utf8 +/// API: session_protocol/session_protocol_pro_features_for_message /// -/// Determine the Pro features that are used in a given UTF8 message. +/// Determine the Pro features required for a message of the given length. /// /// Inputs: -/// - `text` -- the UTF8 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the number of code units (aka. bytes) the string has +/// - `codepoint_count` -- the number of Unicode codepoints in the message. Callers count this +/// themselves (every platform's native string type counts codepoints directly). /// /// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. This string is read-only and should not be modified. -/// - `features` -- Feature flags suitable for writing directly into the protobuf +/// - `status` -- Success, or EXCEEDS_CHARACTER_LIMIT when over the maximum. When not Success, only +/// `error` is meaningful. +/// - `error` -- On a non-Success status, a read-only diagnostic string; NULL otherwise. +/// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. LIBSESSION_EXPORT -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf8( - char const* text, size_t text_size) NON_NULL_ARG(1); - -/// API: session_protocol/session_protocol_get_pro_features_for_utf16 -/// -/// Determine the Pro features that are used in a given UTF16 message. -/// -/// Inputs: -/// - `text` -- the UTF16 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the number of code units (aka. bytes) the string has -/// -/// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. -/// - `features` -- Feature flags suitable for writing directly into the protobuf -/// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. -LIBSESSION_EXPORT -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf16( - uint16_t const* text, size_t text_size) NON_NULL_ARG(1); +session_protocol_pro_features_for_msg session_protocol_pro_features_for_message( + size_t codepoint_count); /// API: session_protocol_encode_for_1o1 /// @@ -487,7 +478,6 @@ session_protocol_encoded_for_destination session_protocol_encode_for_1o1( /// - `ed25519_privkey` -- The sender's libsodium-style secret key (64 bytes). Can also be passed as /// a 32-byte seed. Used to encrypt the plaintext. /// - `ed25519_privkey_len` -- The length of the ed25519_privkey buffer in bytes (32 or 64). -/// - `sent_timestamp_ms` -- The timestamp to assign to the message envelope, in milliseconds. /// - `recipient_pubkey` -- The recipient's Session public key (33 bytes). /// - `community_pubkey` -- The community inbox server's public key (32 bytes). /// - `pro_rotating_ed25519_privkey` -- Optional rotating Session Pro Ed25519 key (64-bytes or @@ -523,13 +513,12 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i size_t plaintext_len, const void* ed25519_privkey, size_t ed25519_privkey_len, - uint64_t sent_timestamp_ms, const bytes33* recipient_pubkey, const bytes32* community_pubkey, OPTIONAL const void* pro_rotating_ed25519_privkey, size_t pro_rotating_ed25519_privkey_len, OPTIONAL char* error, - size_t error_len) NON_NULL_ARG(1, 3, 6, 7); + size_t error_len) NON_NULL_ARG(1, 3, 5, 6); /// API: session_protocol_encode_for_community /// diff --git a/include/session/session_protocol.hpp b/include/session/session_protocol.hpp index 7c3621020..af17fa9b8 100644 --- a/include/session/session_protocol.hpp +++ b/include/session/session_protocol.hpp @@ -54,6 +54,8 @@ namespace session { +using namespace std::literals; + /// Maximum number of UTF-16 code points a standard (non-Pro) message can use; a longer message must /// activate the Session Pro higher-character-limit feature. (The C `SESSION_PROTOCOL_*` symbols /// point at these.) @@ -72,19 +74,41 @@ inline constexpr int COMMUNITY_OR_1O1_MSG_PADDING = 160; // bytes (formerly the BLAKE2b personalisation, back when messages were pre-hashed). inline constexpr std::string_view GENERATE_PROOF_DOMAIN = "ProGenerateProof"; inline constexpr std::string_view BUILD_PROOF_DOMAIN = "ProProof_v0_____"; -inline constexpr std::string_view ADD_PRO_PAYMENT_DOMAIN = "ProAddPayment___"; -inline constexpr std::string_view 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); 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; + enum class ProStatus { // Pro proof sig was not signed by the Pro backend key InvalidProBackendSig = SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG, @@ -94,11 +118,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 @@ -179,17 +198,20 @@ class ProProof { /// they are the original signatory of the proof. /// - `unix_ts` -- Unix timestamp to compared against the embedded `expiry_at` /// to determine if the proof has expired or not - /// - `signed_msg` -- Optionally set the payload to the message with the signature to verify if - /// the embedded `rotating_pubkey` in the proof signed the given message. + /// - `user_sig` -- optionally, the user's 64-byte signature over `signed_msg`; when set, this + /// verifies that the proof's embedded `rotating_pubkey` produced it. Omit (the default) to + /// skip the user-signature check. + /// - `signed_msg` -- the message bytes that `user_sig` signs; ignored when `user_sig` is unset. /// /// Outputs: - /// - `ProStatus` - The derived status given the components of the message. If `signed_msg` is + /// - `ProStatus` - The derived status given the components of the message. If `user_sig` is /// not set then this function can never return `ProStatus::InvalidUserSig` from the set of /// possible enum values. Otherwise this funtion can return all possible values. ProStatus status( std::span verify_pubkey, sys_seconds unix_ts, - const std::optional& signed_msg); + std::optional> user_sig = std::nullopt, + std::span signed_msg = {}) const; /// API: pro/Proof::signed_message /// @@ -198,6 +220,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_uc32 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 && @@ -208,10 +253,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, }; @@ -233,7 +275,6 @@ struct ProFeaturesForMsg { ProFeaturesForMsgStatus status; std::string_view error; ProMessageBitset bitset; - size_t codepoint_count; }; enum class DestinationType { @@ -366,46 +407,21 @@ struct DecodeEnvelopeKey { std::span> decrypt_keys; }; -/// API: session_protocol/pro_features_for_utf8 -/// -/// Determine the Pro features that are used in a given conversation message. -/// -/// Inputs: -/// - `text` -- the UTF8 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the size of the message in UTF8 code units to determine if the message requires -/// access to the higher character limit available in Session Pro -/// -/// Outputs: -/// - `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(const char* text, size_t text_size); - -/// API: session_protocol/pro_features_for_utf16 +/// API: session_protocol/pro_features_for_message /// -/// Determine the Pro features that are used in a given conversation message. +/// Determine the Pro features required for a conversation message of a given length. /// /// Inputs: -/// - `text` -- the UTF16 string to count the number of codepoints in to determine if it needs the -/// higher character limit available in Session Pro -/// - `text_size` -- the size of the message in UTF16 code units to determine if the message -/// requires -/// access to the higher character limit available in Session Pro +/// - `codepoint_count` -- the number of Unicode codepoints in the message. Callers count this +/// themselves (every platform's native string type counts codepoints directly). /// -/// Outputs: -/// - `success` -- True if the message was evaluated successfully for PRO features false otherwise. -/// When false, all fields except for `error` should be ignored from the result object. -/// - `error` -- If `success` is false, this is populated with an error code describing the error, -/// otherwise it's empty. This string is read-only and should not be modified. +/// Outputs (a ProFeaturesForMsg): +/// - `status` -- Success, or ExceedsCharacterLimit if the message is over the maximum limit. When +/// not Success, only `error` is meaningful. +/// - `error` -- On a non-Success `status`, a read-only description of the failure; empty otherwise. /// - `bitset` -- Feature flags suitable for writing directly into the protobuf /// `ProMessage.messageFeatures` -/// - `codepoint_count` -- Counts the number of unicode codepoints that were in the message. -ProFeaturesForMsg pro_features_for_utf16(const char16_t* text, size_t text_size); +ProFeaturesForMsg pro_features_for_message(size_t codepoint_count); /// API: session_protocol/pad_message /// @@ -463,7 +479,6 @@ std::vector encode_for_1o1( /// not be already encrypted and must not be padded. /// - ed25519_privkey -- The sender's libsodium-style secret key (64 bytes). Can also be passed as /// a 32-byte seed. Used to encrypt the plaintext. -/// - sent_timestamp -- The timestamp to assign to the message envelope, in milliseconds. /// - recipient_pubkey -- The recipient's Session public key (33 bytes). /// - community_pubkey -- The community inbox server's public key (32 bytes). /// - pro_rotating_ed25519_privkey -- Optional libsodium-style secret key (64 bytes) that is the @@ -477,7 +492,6 @@ std::vector encode_for_1o1( std::vector encode_for_community_inbox( std::span plaintext, std::span ed25519_privkey, - std::chrono::milliseconds sent_timestamp, const array_uc33& recipient_pubkey, const array_uc32& community_pubkey, std::optional> pro_rotating_ed25519_privkey); diff --git a/include/session/util.hpp b/include/session/util.hpp index d7e6d2701..d818fdc28 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -301,8 +302,8 @@ std::tuple, std::optional 0 && (val[n] & 0b1100'0000) == 0b1000'0000) - --n; + while (max_bytes > 0 && (val[max_bytes] & 0b1100'0000) == 0b1000'0000) + --max_bytes; - val.resize(n); + val.resize(max_bytes); return val; } -/// Truncates an utf-16 encoded string to at most `codepoint_len` codepoints long, taking care to -/// not truncate in the middle of a surrogate pair. Notes that if the input string contains invalid -/// UTF-16 sequences (e.g. unpaired surrogates) the behavior here is undefined. -size_t utf16_count_truncated_to_codepoints( - std::span utf16_string, size_t codepoint_len); +/// Truncates a UTF-16 encoded string view and returns that same string view truncated to be no more +/// than `max_codepoints` codepoints long. +std::u16string_view utf16_truncate(std::u16string_view in, size_t max_codepoints); /// Returns the number of unicode codepoints in a utf-16 encoded string. -size_t utf16_count(std::span utf16_string); +size_t utf16_count(std::u16string_view utf16_string); // Helper function to transform a timestamp provided in seconds, milliseconds or microseconds to // seconds diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7530af298..e6815bc3a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,12 @@ if(WARNINGS_AS_ERRORS) endif() endif() +# -Wunused-parameter isn't in the default warning set (we don't pass -Wextra), so enable it +# explicitly. WARNINGS_AS_ERRORS's -Werror (above) then promotes it to an error. +if(WARN_UNUSED_PARAMETERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(common INTERFACE -Wunused-parameter) +endif() + set(export_targets) macro(add_libsession_util_library name) diff --git a/src/attachments.cpp b/src/attachments.cpp index 9ad0b1337..d2a32283b 100644 --- a/src/attachments.cpp +++ b/src/attachments.cpp @@ -301,7 +301,7 @@ std::array encrypt( const std::filesystem::path& file, Domain domain, std::function(size_t enc_size)> make_buffer, - bool allow_large) { + bool /*allow_large*/) { std::ifstream in; in.exceptions(std::ios::badbit); @@ -660,7 +660,7 @@ void Decryptor::process_header(std::span hd header = true; } -void Decryptor::process_chunk(std::span chunk, bool is_final) { +void Decryptor::process_chunk(std::span chunk, [[maybe_unused]] bool is_final) { if (hit_final) { failed = true; return; diff --git a/src/config/convo_info_volatile.cpp b/src/config/convo_info_volatile.cpp index 4b2ee13f1..aa47a789c 100644 --- a/src/config/convo_info_volatile.cpp +++ b/src/config/convo_info_volatile.cpp @@ -159,15 +159,11 @@ namespace convo { base::load(info_dict); auto pro_expiry = int_or_0(info_dict, "e"); - std::optional> maybe_pro_revocation_tag = - maybe_vector(info_dict, "g"); - if (pro_expiry > 0 && maybe_pro_revocation_tag && maybe_pro_revocation_tag->size() == 32) { + auto tag = maybe_span(info_dict, "g"); + if (pro_expiry > 0 && tag && tag->size() == 32) { pro_expiry_at = as_sys_seconds(pro_expiry); pro_revocation_tag.emplace(); - std::memcpy( - pro_revocation_tag->data(), - maybe_pro_revocation_tag->data(), - pro_revocation_tag->size()); + std::memcpy(pro_revocation_tag->data(), tag->data(), pro_revocation_tag->size()); } } diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index fb6b6e433..0ce34a036 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -236,7 +236,7 @@ LIBSESSION_C_API int groups_info_set_description(config_object* conf, const char /// the struct name, this is the group's profile pic). LIBSESSION_C_API user_profile_pic groups_info_get_pic(const config_object* conf) { user_profile_pic p; - if (auto pic = unbox(conf)->get_profile_pic(); pic) { + if (auto pic = unbox(conf)->get_profile_pic()) { copy_c_str(p.url, pic.url); std::memcpy(p.key, pic.key.data(), 32); } else { diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index e35b4d04d..5e1bdd13e 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1209,7 +1209,7 @@ std::pair> Keys::decrypt_message( // DecryptGroupMessage decrypt = {}; bool decrypt_success = false; - if (auto pending = pending_key(); pending) { + if (auto pending = pending_key()) { try { std::span> key_list = {&(*pending), 1}; decrypt = decrypt_group_message(key_list, *_sign_pk, ciphertext); diff --git a/src/config/internal.cpp b/src/config/internal.cpp index d386dad31..6125f7ba6 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -187,6 +187,14 @@ std::string_view sv_or_empty(const session::config::dict& d, const char* key) { return ""sv; } +std::optional> maybe_span( + const session::config::dict& d, const char* key) { + std::optional> ret; + if (auto* s = maybe_scalar(d, key)) + ret.emplace(reinterpret_cast(s->data()), s->size()); + return ret; +} + std::optional> maybe_vector( const session::config::dict& d, const char* key) { std::optional> result; diff --git a/src/config/internal.hpp b/src/config/internal.hpp index e64e699f9..499e2eaa0 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -213,6 +213,11 @@ std::optional maybe_sv(const session::config::dict& d, const c // string view is only valid as long as the dict stays unchanged. std::string_view sv_or_empty(const session::config::dict& d, const char* key); +// Digs into a config `dict` to get out a std::span; nullopt if not there (or +// not string) +std::optional> maybe_span( + const session::config::dict& d, const char* key); + // Digs into a config `dict` to get out a std::vector; nullopt if not there (or not // string) std::optional> maybe_vector( diff --git a/src/config/pro.cpp b/src/config/pro.cpp index 1818ebe34..6b41cd571 100644 --- a/src/config/pro.cpp +++ b/src/config/pro.cpp @@ -1,60 +1,54 @@ +#include +#include #include #include -#include #include #include #include - -#include "internal.hpp" +#include namespace session::config { -bool ProConfig::load(const dict& root) { - // Get proof fields from session pro data sitting in the 'p' (proof) dictionary - auto p_it = root.find("p"); - if (p_it == root.end()) - return false; - - // Lookup and get 'p' - const config::dict* p = std::get_if(&p_it->second); - if (!p) +bool ProConfig::load(std::string_view bt_encoded) { + if (bt_encoded.empty()) return false; - std::optional> maybe_rotating_seed = maybe_vector(root, "r"); - if (!maybe_rotating_seed || maybe_rotating_seed->size() != crypto_sign_ed25519_SEEDBYTES) + 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. + crypto_sign_ed25519_seed_keypair( + proof.rotating_pubkey.data(), rotating_privkey.data(), seed.data()); + 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 - crypto_sign_ed25519_seed_keypair( - proof.rotating_pubkey.data(), rotating_privkey.data(), maybe_rotating_seed->data()); - return true; +std::string ProConfig::serialize() const { + oxenc::bt_dict_producer d; + // bt dict keys MUST be appended in sorted order: e, g, r, s. + d.append("e", epoch_seconds(proof.expiry_at)); + d.append("g", proof.revocation_tag); + d.append("r", std::span{rotating_privkey}.first()); + d.append("s", proof.sig); + return std::string{d.view()}; } }; // namespace session::config diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index a87638d7f..aa9257b4a 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -230,7 +230,7 @@ void group_info::load(const dict& info_dict) { else name.clear(); - if (auto seed = maybe_vector(info_dict, "K"); seed && seed->size() == 32) { + if (auto seed = maybe_span(info_dict, "K"); seed && seed->size() == 32) { std::array pk; pk[0] = 0x03; secretkey.resize(64); diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index e689dd0af..9f062d1dc 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] = ts_now(); } + + // A live proof means any in-flight purchase has resolved: clear the prepaid marker. + if (pro.proof.expiry_at > ts_now() && data["I"].exists()) + data["I"].erase(); } bool UserProfile::remove_pro_config() { @@ -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,128 @@ void UserProfile::set_pro_access_expiry(std::optional data["E"] = epoch_seconds(*access_expiry_ts); else data["E"].erase(); + + // Confirming a live entitlement means any in-flight purchase resolved, and any long-stale + // refund request is moot -- opportunistically clear both (we're already writing E anyway). + if (access_expiry_ts && *access_expiry_ts > ts_now()) { + if (data["I"].exists()) + data["I"].erase(); + if (auto* R = data["R"].integer(); R && std::chrono::sys_seconds{std::chrono::seconds{*R}} < + ts_now() - std::chrono::weeks{1}) + data["R"].erase(); + } +} + +std::optional UserProfile::get_refund_requested() const { + if (auto* R = data["R"].integer()) { + std::chrono::sys_seconds when{std::chrono::seconds{*R}}; + // Ignore stale values: a request more than a week old is treated as absent, so a flag some + // client forgot to clear cannot linger indefinitely across the account's devices. + if (when >= ts_now() - std::chrono::weeks{1}) + return when; + } + return std::nullopt; +} + +void UserProfile::set_refund_requested(std::optional when) { + if (when) + data["R"] = epoch_seconds(*when); + else + data["R"].erase(); + + // Stamp the profile-updated timestamp so the change is time-ordered across devices. + const auto target_timestamp = (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); +} + +std::optional UserProfile::get_pro_prepaid() const { + if (auto* I = data["I"].integer()) { + std::chrono::sys_seconds when{std::chrono::seconds{*I}}; + // Ignore a stale marker (a purchase that never propagated) so devices don't poll forever. + if (when >= ts_now() - std::chrono::weeks{1}) + return when; + } + return std::nullopt; +} + +void UserProfile::set_pro_prepaid(std::optional when) { + bool changed = false; + if (!when) { + if (data["I"].exists()) { + data["I"].erase(); + changed = true; + } + } else { + // Only mark a purchase pending if the account isn't already entitled to Pro (a live proof + // or a still-future access expiry); otherwise there's nothing to poll for. + bool already_pro = get_pro_config().has_value(); + if (!already_pro) + if (auto e = get_pro_access_expiry(); e && *e > ts_now()) + already_pro = true; + if (!already_pro) { + data["I"] = epoch_seconds(*when); + changed = true; + } + } + if (changed) { + const auto target_timestamp = + (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); + } +} + +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. + 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 +387,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 +460,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)); @@ -407,4 +535,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/network/network_config.cpp b/src/network/network_config.cpp index dde0b2b20..e97aca9ea 100644 --- a/src/network/network_config.cpp +++ b/src/network/network_config.cpp @@ -107,7 +107,7 @@ void Config::handle_config_opt(opt::file_server_use_stream_encryption fsuse) { // MARK: General options -void Config::handle_config_opt(opt::increase_no_file_limit dsd) { +void Config::handle_config_opt(opt::increase_no_file_limit) { increase_no_file_limit = true; log::debug(cat, "Network config will attempt to increase the NOFILE limit"); } @@ -117,7 +117,7 @@ void Config::handle_config_opt(opt::path_length pl) { log::debug(cat, "Network config path length set to {}", pl.length); } -void Config::handle_config_opt(opt::disable_subnet_diversity dsd) { +void Config::handle_config_opt(opt::disable_subnet_diversity) { enforce_subnet_diversity = false; log::debug(cat, "Network config disabled subnet diversity"); } @@ -235,7 +235,7 @@ void Config::handle_config_opt(opt::quic_keep_alive qka) { log::debug(cat, "Network config quic keep alive set to {}s", qka.duration.count()); } -void Config::handle_config_opt(opt::quic_disable_mtu_discovery qdmd) { +void Config::handle_config_opt(opt::quic_disable_mtu_discovery) { quic_disable_mtu_discovery = true; log::debug(cat, "Network config disabled MTU discovery for Quic"); } @@ -262,12 +262,12 @@ void Config::handle_config_opt(opt::onionreq_min_path_count mpc) { mpc.min_count); } -void Config::handle_config_opt(opt::onionreq_single_path_mode spm) { +void Config::handle_config_opt(opt::onionreq_single_path_mode) { onionreq_single_path_mode = true; log::debug(cat, "Network config onion requests set to single path mode"); } -void Config::handle_config_opt(opt::onionreq_disable_pre_build_paths dpbp) { +void Config::handle_config_opt(opt::onionreq_disable_pre_build_paths) { onionreq_disable_pre_build_paths = true; log::debug(cat, "Network config disabled pre-building onion request paths"); } diff --git a/src/network/routing/direct_router.cpp b/src/network/routing/direct_router.cpp index acba03026..6cc09f768 100644 --- a/src/network/routing/direct_router.cpp +++ b/src/network/routing/direct_router.cpp @@ -62,7 +62,7 @@ void DirectRouter::suspend() { }); } -void DirectRouter::resume(bool automatically_reconnect) { +void DirectRouter::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { if (!_suspended) diff --git a/src/network/routing/onion_request_router.cpp b/src/network/routing/onion_request_router.cpp index fe2147b90..99f6c4549 100644 --- a/src/network/routing/onion_request_router.cpp +++ b/src/network/routing/onion_request_router.cpp @@ -2177,7 +2177,11 @@ void OnionRequestRouter::_rotate_path(const std::string& path_id, PathCategory c pending_rotation.new_path, std::move(info_request), [weak_self = weak_from_this(), this, new_path_id, rotate_at = std::move(rotate_at)]( - bool success, bool timeout, int16_t status, auto headers, auto response) { + bool success, + bool timeout, + int16_t status, + auto /*headers*/, + auto /*response*/) { auto self = weak_self.lock(); if (!self) return; diff --git a/src/network/routing/session_router_router.cpp b/src/network/routing/session_router_router.cpp index 73c9e3106..cf1606d05 100644 --- a/src/network/routing/session_router_router.cpp +++ b/src/network/routing/session_router_router.cpp @@ -186,7 +186,7 @@ void SessionRouter::suspend() { }); } -void SessionRouter::resume(bool automatically_reconnect) { +void SessionRouter::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { if (!_suspended) diff --git a/src/network/session_network.cpp b/src/network/session_network.cpp index 823c26b22..d5c57ebf4 100644 --- a/src/network/session_network.cpp +++ b/src/network/session_network.cpp @@ -85,7 +85,7 @@ namespace { } config::DirectRouter build_direct_router_config( - const config::Config& main_config, const config::FileServer& file_server_config) { + const config::Config& /*main_config*/, const config::FileServer& file_server_config) { return {file_server_config}; } @@ -965,7 +965,7 @@ void Network::_launch_next_clock_out_of_sync_request( bool success, bool timeout, int16_t status_code, - std::vector> headers, + std::vector> /*headers*/, std::optional response) { auto end_steady = std::chrono::steady_clock::now(); auto end_system = sysclock_now_ms(); @@ -1031,7 +1031,7 @@ void Network::_launch_next_clock_out_of_sync_request( }); } -void Network::_on_clock_resync_complete(const uint8_t total_requests) { +void Network::_on_clock_resync_complete(const uint8_t /*total_requests*/) { auto raw_results = std::move(_clock_resync_results); auto refresh_id = std::move(*_current_clock_resync_id); @@ -1508,7 +1508,7 @@ LIBSESSION_C_API void session_network_set_network_info_changed_callback( } LIBSESSION_C_API void session_network_callbacks_respond( - network_object* network, + network_object* /*network*/, session_response_handle_t* response_handle, bool success, bool timeout, @@ -1566,7 +1566,7 @@ LIBSESSION_C_API void session_network_get_active_paths( size_t total_metadata_size = 0; for (const auto& p : cpp_paths) { std::visit( - [&](const T& md) { + [&](const T& /*md*/) { if constexpr (std::is_same_v) total_metadata_size += sizeof(session_onion_path_metadata); else { @@ -1872,7 +1872,7 @@ LIBSESSION_C_API session_download_handle_t* session_network_download( int64_t stall_timeout_ms, int64_t request_timeout_ms, int64_t overall_timeout_ms, - int64_t partial_min_interval_ms, + int64_t /*partial_min_interval_ms*/, int8_t desired_path_index) { if (!network || !download_url || !callbacks) diff --git a/src/network/snode_pool.cpp b/src/network/snode_pool.cpp index 41f2c6926..d96a7323f 100644 --- a/src/network/snode_pool.cpp +++ b/src/network/snode_pool.cpp @@ -602,7 +602,7 @@ void SnodePool::_launch_next_refresh_request( bool success, bool timeout, int16_t status_code, - std::vector> headers, + std::vector> /*headers*/, std::optional response) { // If the refresh was cancelled or completed while we were in-flight, do nothing if (!_current_snode_cache_refresh_id || diff --git a/src/network/transport/quic_transport.cpp b/src/network/transport/quic_transport.cpp index c9c161273..392cbb88d 100644 --- a/src/network/transport/quic_transport.cpp +++ b/src/network/transport/quic_transport.cpp @@ -60,7 +60,7 @@ void QuicTransport::suspend() { }); } -void QuicTransport::resume(bool automatically_reconnect) { +void QuicTransport::resume(bool /*automatically_reconnect*/) { // Use 'call_get' to force this to be synchronous _loop->call_get([this] { // Recreate the endpoint before updating the `_suspended` flag to avoid the chance that @@ -85,7 +85,7 @@ void QuicTransport::set_node_failure_reporter(node_failure_reporter_t reporter) void QuicTransport::verify_connectivity( service_node node, - std::chrono::milliseconds timeout, + std::chrono::milliseconds /*timeout*/, const std::string& request_id, const RequestCategory category, std::function error_code)> callback) { @@ -285,7 +285,7 @@ void QuicTransport::_send_request_internal(Request request, network_response_cal void QuicTransport::_establish_connection( const oxen::quic::RemoteAddress& address, const std::string& initiating_req_id, - const RequestCategory category) { + const RequestCategory /*category*/) { const auto address_pubkey_hex = oxenc::to_hex(address.view_remote_key()); try { @@ -360,18 +360,13 @@ void QuicTransport::_establish_connection( } }, [weak_self = weak_from_this(), address_pubkey_hex, initiating_req_id]( - oxen::quic::Connection& conn, uint64_t error_code) { + oxen::quic::Connection&, uint64_t error_code) { if (auto self = weak_self.lock()) self->_fail_connection( - address_pubkey_hex, - initiating_req_id, - conn.reference_id(), - error_code, - std::nullopt); + address_pubkey_hex, initiating_req_id, error_code, std::nullopt); }); } catch (const std::exception& e) { - _fail_connection( - address_pubkey_hex, initiating_req_id, std::nullopt, std::nullopt, e.what()); + _fail_connection(address_pubkey_hex, initiating_req_id, std::nullopt, e.what()); } } @@ -555,7 +550,6 @@ void QuicTransport::_send_on_connection( void QuicTransport::_fail_connection( const std::string& address_pubkey_hex, const std::string& initiating_req_id, - std::optional conn_id, std::optional error_code, std::optional custom_error) { if (error_code == NGTCP2_NO_ERROR) diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 37d54cb8f..18505e50f 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -129,12 +129,10 @@ 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 @@ -163,63 +161,11 @@ namespace { priv64.data() + crypto_sign_ed25519_SEEDBYTES, crypto_sign_ed25519_PUBLICKEYBYTES); } - // --- add-payment (endpoint add_pro_payment) --- - - std::vector add_payment_message( - std::span master_pubkey, - std::span rotating_pubkey, - std::string_view provider_code, - std::span payment_id) { - // Must match the add-payment signed-request message in pro-wire-protocol.md §3.2 - // (+ §3.5), built per §1.1. - return session::pro::signed_message( - session::ADD_PRO_PAYMENT_DOMAIN, - master_pubkey, - rotating_pubkey, - provider_code, - to_string_view(payment_id)); - } - - MasterRotatingSignatures add_payment_sign( - const cleared_uc64& master, - const cleared_uc64& rotating, - std::string_view provider_code, - std::span payment_id) { - auto msg = add_payment_message( - pubkey_of(master), pubkey_of(rotating), provider_code, payment_id); - MasterRotatingSignatures result = {}; - crypto_sign_ed25519_detached( - result.master_sig.data(), nullptr, msg.data(), msg.size(), master.data()); - crypto_sign_ed25519_detached( - result.rotating_sig.data(), nullptr, msg.data(), msg.size(), rotating.data()); - return result; - } - - // Serialise an add-payment request body from already-computed fields (shared by the C++ - // add_payment_request and the C ..._request_build wrapper). - std::string add_payment_body( - std::span master_pubkey, - std::span rotating_pubkey, - std::string_view provider_code, - std::string_view payment_id, - std::span master_sig, - std::span rotating_sig) { - return nlohmann::json{ - {"master_pkey", oxenc::to_hex(master_pubkey)}, - {"rotating_pkey", oxenc::to_hex(rotating_pubkey)}, - {"payment_tx", {{"provider", provider_code}, {"payment_id", payment_id}}}, - {"master_sig", oxenc::to_hex(master_sig)}, - {"rotating_sig", oxenc::to_hex(rotating_sig)}} - .dump(); - } - } // namespace // C endpoint symbols: each points at the single master endpoint string defined above, so the C API // and the C++ `ProRequest::endpoint` values are backed by one definition. extern "C" { -LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT = - add_payment_endpoint; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GENERATE_PRO_PROOF_ENDPOINT = generate_proof_endpoint; LIBSESSION_EXPORT extern const char* const SESSION_PRO_BACKEND_GET_PRO_STATUS_ENDPOINT = @@ -228,8 +174,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(); @@ -346,35 +290,6 @@ LIBSESSION_C_API const char* const* session_pro_backend_visible_platforms(size_t return codes.data(); } -MasterRotatingSignatures add_payment_sigs( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - return add_payment_sign(master, rotating, provider_code, payment_id); -} - -ProRequest add_payment_request( - std::span master_privkey, - std::span rotating_privkey, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - auto sigs = add_payment_sign(master, rotating, provider_code, payment_id); - return {add_payment_endpoint, - application_json, - add_payment_body( - pubkey_of(master), - pubkey_of(rotating), - provider_code, - to_string_view(payment_id), - sigs.master_sig, - sigs.rotating_sig)}; -} - namespace { // libsession-side slug when the backend's reply can't be parsed at all (malformed envelope, // missing/unrecognized status). Distinct from any backend error_code slug. @@ -417,7 +332,7 @@ namespace { // proof) from the already-extracted `result` object. void fill_proof( const nlohmann::json::object_t& result_obj, - ProProofResponse& result, + GenerateProProofResponse& result, std::vector& errs) { result.proof.version = json_require(result_obj, "version", errs); auto expiry_ts = json_require(result_obj, "expiry_ts", errs); @@ -427,23 +342,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 = std::chrono::sys_seconds(std::chrono::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 = {}; @@ -452,6 +358,16 @@ GenerateProProofResponse parse_pro_proof(std::string_view json) { if (!result || !errs.empty()) { if (!errs.empty()) set_protocol_error(result, errs.front()); + // On a subscription_expired failure the account's (now-past) true expiry rides top-level on + // the envelope (pro-wire-protocol.md §2.2 / §5.1) so the client can refresh its cached + // horizon / access expiry without a separate get_pro_status. Advisory -- read leniently. + else if (result.error_code == "subscription_expired") { + std::vector ignore; + auto j = json_parse(json, ignore); + if (auto it = j.find("account_expiry_ts"); it != j.end() && it->is_number_integer()) + result.account_expiry = + std::chrono::sys_seconds(std::chrono::seconds(it->get())); + } return result; } fill_proof(result_obj, result, errs); @@ -507,15 +423,6 @@ namespace { } // namespace -MasterRotatingSignatures pro_proof_sigs( - std::span master_privkey, - std::span rotating_privkey, - std::chrono::sys_seconds unix_ts) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto rotating = normalize_privkey(rotating_privkey, "rotating_privkey"); - return generate_proof_sign(master, rotating, unix_ts); -} - ProRequest pro_proof_request( std::span master_privkey, std::span rotating_privkey, @@ -596,7 +503,7 @@ namespace { std::vector pro_status_message( std::span master_pubkey, std::chrono::sys_seconds unix_ts) { - // Must match the get-pro-status signed-request message in pro-wire-protocol.md §3.4, built + // Must match the get-pro-status signed-request message in pro-wire-protocol.md §3.2, built // per §1.1. return session::pro::signed_message( session::GET_PRO_STATUS_DOMAIN, master_pubkey, epoch_seconds(unix_ts)); @@ -627,7 +534,7 @@ namespace { std::chrono::sys_seconds unix_ts, uint32_t limit, std::string_view before) { - // Must match the get-payment-details signed-request message in pro-wire-protocol.md §3.4, + // Must match the get-payment-details signed-request message in pro-wire-protocol.md §3.3, // built per §1.1. `before` is the opaque pagination cursor (§5.3), empty for the newest // page. return session::pro::signed_message( @@ -688,7 +595,6 @@ namespace { auto platform_refund_expiry_ts = json_require(obj, "platform_refund_expiry_ts", errs); auto revoked_ts = json_require_number(obj, "revoked_ts", errs); - auto refund_requested_ts = json_require(obj, "refund_requested_ts", errs); ProPaymentItem item = {}; item.status = std::move(status); @@ -704,19 +610,11 @@ namespace { item.platform_refund_expiry_at = std::chrono::sys_seconds(std::chrono::seconds(platform_refund_expiry_ts)); item.revoked_at = sys_ms_from_seconds(revoked_ts); - item.refund_requested_at = - std::chrono::sys_seconds(std::chrono::seconds(refund_requested_ts)); return item; } } // namespace -array_uc64 pro_status_sig( - std::span master_privkey, std::chrono::sys_seconds unix_ts) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return pro_status_sign(master, unix_ts); -} - ProRequest pro_status_request( std::span master_privkey, std::chrono::sys_seconds unix_ts) { auto master = normalize_privkey(master_privkey, "master_privkey"); @@ -726,15 +624,6 @@ ProRequest pro_status_request( pro_status_body(pubkey_of(master), sig, unix_ts)}; } -array_uc64 payment_details_sig( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - uint32_t limit, - std::string_view before) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return payment_details_sign(master, unix_ts, limit, before); -} - ProRequest payment_details_request( std::span master_privkey, std::chrono::sys_seconds unix_ts, @@ -777,8 +666,6 @@ ProStatusResponse parse_pro_status(std::string_view json) { std::chrono::seconds(json_require(result_obj, "expiry_ts", errs))); result.grace_period_duration = std::chrono::seconds(json_require(result_obj, "grace_period_duration", errs)); - result.refund_requested_at = std::chrono::sys_seconds( - std::chrono::seconds(json_require(result_obj, "refund_requested_ts", errs))); // `latest_payment` is a single payment item, or null when the account has no payments. if (auto it = result_obj.find("latest_payment"); it == result_obj.end()) { @@ -846,102 +733,6 @@ PaymentDetailsResponse parse_payment_details(std::string_view json) { return result; } -namespace { - - // --- refund / set-payment-refund-requested (endpoint set_payment_refund_requested) --- - - std::vector refund_message( - std::span master_pubkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - // Must match the set-payment-refund-requested signed-request message in - // pro-wire-protocol.md §3.3 (+ §3.5 for payment_id), built per §1.1. - return session::pro::signed_message( - session::SET_PAYMENT_REFUND_REQUESTED_DOMAIN, - master_pubkey, - epoch_seconds(unix_ts), - epoch_seconds(refund_requested_at), - provider_code, - to_string_view(payment_id)); - } - - array_uc64 refund_sign( - const cleared_uc64& master, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto msg = refund_message( - pubkey_of(master), unix_ts, refund_requested_at, provider_code, payment_id); - array_uc64 sig = {}; - crypto_sign_ed25519_detached(sig.data(), nullptr, msg.data(), msg.size(), master.data()); - return sig; - } - - std::string refund_body( - std::span master_pubkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::string_view payment_id, - std::span master_sig) { - return nlohmann::json{ - {"master_pkey", oxenc::to_hex(master_pubkey)}, - {"ts", epoch_seconds(unix_ts)}, - {"refund_requested_ts", epoch_seconds(refund_requested_at)}, - {"payment_tx", {{"provider", provider_code}, {"payment_id", payment_id}}}, - {"master_sig", oxenc::to_hex(master_sig)}} - .dump(); - } - -} // namespace - -array_uc64 refund_sig( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - return refund_sign(master, unix_ts, refund_requested_at, provider_code, payment_id); -} - -ProRequest refund_request( - std::span master_privkey, - std::chrono::sys_seconds unix_ts, - std::chrono::sys_seconds refund_requested_at, - std::string_view provider_code, - std::span payment_id) { - auto master = normalize_privkey(master_privkey, "master_privkey"); - auto sig = refund_sign(master, unix_ts, refund_requested_at, provider_code, payment_id); - return {set_refund_endpoint, - application_json, - refund_body( - pubkey_of(master), - unix_ts, - refund_requested_at, - provider_code, - to_string_view(payment_id), - sig)}; -} - -SetPaymentRefundRequestedResponse parse_refund(std::string_view json) { - SetPaymentRefundRequestedResponse result = {}; - std::vector errs; - auto result_obj = read_envelope(json, result, errs); - if (!result || !errs.empty()) { - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; - } - - result.updated = json_require(result_obj, "updated", errs); - if (!errs.empty()) - set_protocol_error(result, errs.front()); - return result; -} } // namespace session::pro_backend using namespace session::pro_backend; @@ -994,7 +785,6 @@ static session_pro_backend_pro_payment_item to_c(ProPaymentItem& src) { .grace_period_duration = src.grace_period_duration.count(), .platform_refund_expiry_ts = session::epoch_seconds(src.platform_refund_expiry_at), .revoked_ts = epoch_seconds_double(src.revoked_at), - .refund_requested_ts = session::epoch_seconds(src.refund_requested_at), .payment_id = src.payment_id.c_str(), }; } @@ -1010,7 +800,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) { @@ -1045,27 +835,6 @@ static void c_request_error(session_pro_backend_request& result, const std::exce error.data()); } -LIBSESSION_C_API session_pro_backend_request session_pro_backend_add_pro_payment_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - const uint8_t* rotating_privkey, - size_t rotating_privkey_len, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(add_payment_request( - {master_privkey, master_privkey_len}, - {rotating_privkey, rotating_privkey_len}, - provider_code, - {payment_id, payment_id_len})); - } catch (const std::exception& e) { - c_request_error(result, e); - } - return result; -} - LIBSESSION_C_API session_pro_backend_request session_pro_backend_generate_pro_proof_request_build( const uint8_t* master_privkey, size_t master_privkey_len, @@ -1138,8 +907,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); @@ -1154,8 +922,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; @@ -1218,7 +988,6 @@ session_pro_backend_get_pro_status_response_parse(const char* json, size_t json_ result.auto_renewing = owned->auto_renewing; result.expiry_ts = epoch_seconds(owned->expiry_at); result.grace_period_duration = owned->grace_period_duration.count(); - result.refund_requested_ts = epoch_seconds(owned->refund_requested_at); result.has_latest_payment = owned->latest_payment.has_value(); if (owned->latest_payment) result.latest_payment = to_c(*owned->latest_payment); @@ -1265,54 +1034,6 @@ session_pro_backend_get_payment_details_response_parse(const char* json, size_t return result; } -LIBSESSION_C_API session_pro_backend_request -session_pro_backend_set_payment_refund_requested_request_build( - const uint8_t* master_privkey, - size_t master_privkey_len, - int64_t ts, - int64_t refund_requested_ts, - const char* provider_code, - const uint8_t* payment_id, - size_t payment_id_len) { - session_pro_backend_request result = {}; - try { - result = c_own_request(refund_request( - {master_privkey, master_privkey_len}, - session::as_sys_seconds(ts), - session::as_sys_seconds(refund_requested_ts), - provider_code, - {payment_id, payment_id_len})); - } catch (const std::exception& e) { - c_request_error(result, e); - } - return result; -} - -LIBSESSION_C_API session_pro_backend_set_payment_refund_requested_response -session_pro_backend_set_payment_refund_requested_response_parse(const char* json, size_t json_len) { - session_pro_backend_set_payment_refund_requested_response result = {}; - if (!json) { - result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; - result.header.error_code = C_INVALID_RESPONSE_CODE; - result.header.error = C_PARSE_ERROR_INVALID_ARGS; - return result; - } - - try { - auto* owned = new SetPaymentRefundRequestedResponse(parse_refund({json, json_len})); - result.header.internal_ = owned; - fill_c_header(result.header, *owned); - result.updated = owned->updated; - } catch (const std::exception&) { - delete static_cast(result.header.internal_); - result = {}; - result.header.status = SESSION_PRO_BACKEND_RESPONSE_STATUS_ERROR; - result.header.error_code = C_INVALID_RESPONSE_CODE; - result.header.error = C_PARSE_ERROR_OUT_OF_MEMORY; - } - return result; -} - LIBSESSION_C_API void session_pro_backend_request_free(session_pro_backend_request* request) { if (request) { if (request->internal_) @@ -1323,7 +1044,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( @@ -1340,8 +1061,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 48b4403cb..09614ad2d 100644 --- a/src/pro_message.hpp +++ b/src/pro_message.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -17,8 +18,8 @@ namespace session::pro { /// and self-delimiting; /// - **integer**: canonical decimal ASCII (`std::to_chars`, base 10, locale-independent — never a /// locale-aware formatter); -/// - **string_view** (`provider_code`, and the opaque variable-length `payment_id` via -/// `to_string_view`): its bytes verbatim. +/// - **string_view** (e.g. the opaque `before` pagination cursor of get_payment_details): its bytes +/// verbatim. /// /// A single NUL byte is inserted between two *adjacent* variable-length fields (integer/string); /// raw fields need no separator, and the domain prefix (also raw) never precedes one. The message @@ -35,6 +36,7 @@ std::vector signed_message(std::string_view domain, const Fields& buf.push_back('\0'); char tmp[24]; // enough for -9223372036854775808 (20 chars) auto [ptr, ec] = std::to_chars(tmp, tmp + sizeof(tmp), field); + assert(ec == std::errc{}); // tmp is large enough for any integer, so this cannot fail buf.insert(buf.end(), tmp, ptr); prev_var = true; } else if constexpr (std::convertible_to) { diff --git a/src/session_encrypt.cpp b/src/session_encrypt.cpp index 56eba9de7..ebc0c68b2 100644 --- a/src/session_encrypt.cpp +++ b/src/session_encrypt.cpp @@ -1087,7 +1087,6 @@ LIBSESSION_C_API session_encrypt_group_message session_encrypt_for_group( const unsigned char* user_ed25519_privkey, size_t user_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* group_enc_key, size_t group_enc_key_len, const unsigned char* plaintext, @@ -1100,7 +1099,7 @@ LIBSESSION_C_API session_encrypt_group_message session_encrypt_for_group( try { std::vector result_cpp = encrypt_for_group( {user_ed25519_privkey, user_ed25519_privkey_len}, - {group_ed25519_pubkey, group_ed25519_pubkey_len}, + {group_ed25519_pubkey, crypto_sign_ed25519_PUBLICKEYBYTES}, {group_enc_key, group_enc_key_len}, {plaintext, plaintext_len}, compress, @@ -1200,7 +1199,6 @@ LIBSESSION_C_API session_decrypt_group_message_result session_decrypt_group_mess const span_u8* decrypt_ed25519_privkey_list, size_t decrypt_ed25519_privkey_len, const unsigned char* group_ed25519_pubkey, - size_t group_ed25519_pubkey_len, const unsigned char* ciphertext, size_t ciphertext_len, char* error, @@ -1214,7 +1212,7 @@ LIBSESSION_C_API session_decrypt_group_message_result session_decrypt_group_mess try { result_cpp = decrypt_group_message( {&key, 1}, - {group_ed25519_pubkey, group_ed25519_pubkey_len}, + {group_ed25519_pubkey, crypto_sign_ed25519_PUBLICKEYBYTES}, {ciphertext, ciphertext_len}); result = { .success = true, diff --git a/src/session_protocol.cpp b/src/session_protocol.cpp index c9d589a8a..4113b4e73 100644 --- a/src/session_protocol.cpp +++ b/src/session_protocol.cpp @@ -2,16 +2,21 @@ #include #include #include -#include +#include +#include +#include #include #include +#include #include #include #include #include #include #include +#include +#include #include "SessionProtos.pb.h" #include "WebSocketResources.pb.h" @@ -155,7 +160,8 @@ bool ProProof::is_active(sys_seconds unix_ts) const { ProStatus ProProof::status( std::span verify_pubkey, sys_seconds unix_ts, - const std::optional& signed_msg) { + std::optional> user_sig, + std::span signed_msg) 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) @@ -163,8 +169,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; } @@ -178,6 +184,43 @@ std::vector ProProof::signed_message() const { return proof_signed_message(revocation_tag, rotating_pubkey, epoch_seconds(expiry_at)); } +cleared_uc32 ProProof::rotating_seed( + std::span master_seed, std::chrono::sys_seconds 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 + + auto seed = master_seed.first(32); + std::vector msg; + msg.reserve(seed.size() + static_cast(ptr - dec)); + msg.insert(msg.end(), seed.begin(), seed.end()); + msg.insert(msg.end(), dec, ptr); + + static constexpr std::string_view personal = "ProRotatingSeed_"; + static_assert(personal.size() == crypto_generichash_blake2b_PERSONALBYTES); + + cleared_uc32 out = {}; + crypto_generichash_blake2b_salt_personal( + out.data(), + out.size(), + msg.data(), + msg.size(), + nullptr, // key + 0, + nullptr, // salt + reinterpret_cast(personal.data())); + return out; +} + void ProProfileBitset::set(SESSION_PROTOCOL_PRO_PROFILE_FEATURES features) { data |= (1ULL << static_cast(features)); } @@ -204,47 +247,19 @@ bool ProMessageBitset::is_set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES features) co return result; } -session::ProFeaturesForMsg pro_features_for_utf8_or_16( - const void* text, size_t text_size, bool is_utf8) { - session::ProFeaturesForMsg result = {}; - simdutf::result validate = is_utf8 - ? simdutf::validate_utf8_with_errors( - reinterpret_cast(text), text_size) - : simdutf::validate_utf16_with_errors( - reinterpret_cast(text), text_size); - if (validate.is_ok()) { - result.status = session::ProFeaturesForMsgStatus::Success; - result.codepoint_count = - is_utf8 ? simdutf::count_utf8(reinterpret_cast(text), text_size) - : simdutf::count_utf16(reinterpret_cast(text), text_size); - - if (result.codepoint_count > STANDARD_CHARACTER_LIMIT) { - if (result.codepoint_count <= PRO_HIGHER_CHARACTER_LIMIT) { - result.bitset.set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT); - } else { - result.error = "Message exceeds the maximum character limit allowed"; - result.status = session::ProFeaturesForMsgStatus::ExceedsCharacterLimit; - } +ProFeaturesForMsg pro_features_for_message(size_t codepoint_count) { + ProFeaturesForMsg result = {}; + result.status = ProFeaturesForMsgStatus::Success; + if (codepoint_count > STANDARD_CHARACTER_LIMIT) { + if (codepoint_count <= PRO_HIGHER_CHARACTER_LIMIT) { + result.bitset.set(SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT); + } else { + result.error = "Message exceeds the maximum character limit allowed"; + result.status = ProFeaturesForMsgStatus::ExceedsCharacterLimit; } - } else { - result.status = session::ProFeaturesForMsgStatus::UTFDecodingError; - result.error = simdutf::error_to_string(validate.error); } return result; } -}; // namespace session - -namespace session { - -ProFeaturesForMsg pro_features_for_utf8(const char* text, size_t text_size) { - ProFeaturesForMsg result = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ true); - return result; -} - -ProFeaturesForMsg pro_features_for_utf16(const char16_t* text, size_t text_size) { - ProFeaturesForMsg result = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ false); - return result; -} std::vector encode_for_1o1( std::span plaintext, @@ -265,7 +280,6 @@ std::vector encode_for_1o1( std::vector encode_for_community_inbox( std::span plaintext, std::span ed25519_privkey, - std::chrono::milliseconds sent_timestamp, const array_uc33& recipient_pubkey, const array_uc32& community_pubkey, std::optional> pro_rotating_ed25519_privkey) { @@ -273,7 +287,8 @@ std::vector encode_for_community_inbox( dest.type = DestinationType::CommunityInbox; dest.pro_rotating_ed25519_privkey = pro_rotating_ed25519_privkey ? *pro_rotating_ed25519_privkey : std::span{}; - dest.sent_timestamp_ms = sent_timestamp; + // Unused on the CommunityInbox path (only the envelope-based Group/1o1 path consumes it). + dest.sent_timestamp_ms = std::chrono::milliseconds{0}; dest.recipient_pubkey = recipient_pubkey; dest.community_inbox_server_pubkey = community_pubkey; std::vector result = encode_for_destination(plaintext, ed25519_privkey, dest); @@ -445,22 +460,23 @@ static EncryptedForDestinationInternal encode_for_destination_internal( envelope.set_content(content.data(), content.size()); // Generate the session pro signature. If there's no pro ed25519 key specified, we still - // fill out the pro signature with a valid but unverifiable signature by creating a - // throw-away key. This makes pro and non-pro messages indistinguishable on the wire. + // fill out the pro signature with a decoy (validly-encoded but unverifiable) signature. + // This makes pro and non-pro messages indistinguishable on the wire. { std::string* pro_sig = envelope.mutable_prosig(); pro_sig->resize(crypto_sign_ed25519_BYTES); if (dest_pro_rotating_ed25519_privkey.empty()) { - uc32 ignore_pk; - cleared_uc64 dummy_pro_ed_sk; - crypto_sign_ed25519_keypair(ignore_pk.data(), dummy_pro_ed_sk.data()); - crypto_sign_ed25519_detached( - reinterpret_cast(pro_sig->data()), - nullptr, - content.data(), - content.size(), - dummy_pro_ed_sk.data()); + // No pro key: attach a decoy signature -- a validly-encoded Ed25519 signature + // (R = r·B for a random scalar r, so a prime-order-subgroup point like a real + // R; s a second random scalar) that verifies against nothing. Keeps pro and + // non-pro envelopes indistinguishable on the wire without signing throwaway + // data. NOT a real signature; never verified. + auto* sig = reinterpret_cast(pro_sig->data()); + std::array r; + crypto_core_ed25519_scalar_random(r.data()); + crypto_scalarmult_ed25519_base_noclamp(sig, r.data()); + crypto_core_ed25519_scalar_random(sig + crypto_core_ed25519_BYTES); } else { crypto_sign_ed25519_detached( reinterpret_cast(pro_sig->data()), @@ -868,16 +884,14 @@ DecodedEnvelope decode_envelope( // Evaluate the pro status given the extracted components (was it signed, is it expired, // was the message signed validly?) - ProSignedMessage signed_msg = {}; - signed_msg.sig = to_span(pro_sig); - + // // Note that we sign the envelope content wholesale. For 1o1 which are padded to 160 // bytes, this means that we expected the user to have signed the padding as well. auto unix_ts = std::chrono::floor( std::chrono::sys_time( std::chrono::milliseconds(content.sigtimestamp()))); - signed_msg.msg = to_span(envelope.content()); - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, unix_ts, to_span(pro_sig), to_span(envelope.content())); } } return result; @@ -1028,9 +1042,7 @@ DecodedCommunityMessage decode_for_community( // Evaluate the pro status given the extracted components (was it signed, is it expired, // was the message signed validly?) - ProSignedMessage signed_msg = {}; - signed_msg.sig = to_span(*result.pro_sig); - + // // IMPORTANT: We have to bit-manipulate the content because we're including the signature // inside the payload itself that we had to sign. But we originally signed the payload // without a signature set in it. This is only the case if we're dealing with a `Content` @@ -1039,8 +1051,11 @@ DecodedCommunityMessage decode_for_community( // Entering the `pro_sig` and `result.envelope` branch means that the envelope must have // a pro signature. assert(result.envelope->flags & SESSION_PROTOCOL_ENVELOPE_FLAGS_PRO_SIG); - signed_msg.msg = result.content_plaintext; - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, + unix_ts, + to_span(*result.pro_sig), + result.content_plaintext); } else { SessionProtos::Content content_copy_without_sig = content; assert(content_copy_without_sig.has_prosigforcommunitymessageonly()); @@ -1053,8 +1068,11 @@ DecodedCommunityMessage decode_for_community( std::vector content_copy_without_sig_payload = pad_message(to_span(content_copy_without_sig.SerializeAsString())); - signed_msg.msg = to_span(content_copy_without_sig_payload); - pro.status = proof.status(pro_backend_pubkey, unix_ts, signed_msg); + pro.status = proof.status( + pro_backend_pubkey, + unix_ts, + to_span(*result.pro_sig), + to_span(content_copy_without_sig_payload)); } } @@ -1144,6 +1162,13 @@ LIBSESSION_C_API bool session_protocol_pro_proof_verify_signature( return result; } +LIBSESSION_C_API void session_protocol_pro_rotating_seed( + const unsigned char* master_seed, int64_t now_unix_ts, unsigned char* rotating_seed_out) { + auto seed = session::ProProof::rotating_seed( + {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, @@ -1190,27 +1215,13 @@ LIBSESSION_C_API SESSION_PROTOCOL_PRO_STATUS session_protocol_pro_proof_status( } LIBSESSION_C_API -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf8( - const char* text, size_t text_size) { - ProFeaturesForMsg result_cpp = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ true); - session_protocol_pro_features_for_msg result = { - .status = static_cast(result_cpp.status), - .error = result_cpp.error.data(), - .bitset = {result_cpp.bitset.data}, - .codepoint_count = result_cpp.codepoint_count, - }; - return result; -} - -LIBSESSION_C_API -session_protocol_pro_features_for_msg session_protocol_pro_features_for_utf16( - const uint16_t* text, size_t text_size) { - ProFeaturesForMsg result_cpp = pro_features_for_utf8_or_16(text, text_size, /*is_utf8*/ false); +session_protocol_pro_features_for_msg session_protocol_pro_features_for_message( + size_t codepoint_count) { + ProFeaturesForMsg result_cpp = pro_features_for_message(codepoint_count); session_protocol_pro_features_for_msg result = { .status = static_cast(result_cpp.status), .error = result_cpp.error.data(), .bitset = {result_cpp.bitset.data}, - .codepoint_count = result_cpp.codepoint_count, }; return result; } @@ -1252,7 +1263,6 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i size_t plaintext_len, const void* ed25519_privkey, size_t ed25519_privkey_len, - uint64_t sent_timestamp_ms, const bytes33* recipient_pubkey, const bytes32* community_pubkey, const void* pro_rotating_ed25519_privkey, @@ -1264,7 +1274,8 @@ session_protocol_encoded_for_destination session_protocol_encode_for_community_i dest.type = SESSION_PROTOCOL_DESTINATION_TYPE_COMMUNITY_INBOX; dest.pro_rotating_ed25519_privkey = pro_rotating_ed25519_privkey; dest.pro_rotating_ed25519_privkey_len = pro_rotating_ed25519_privkey_len; - dest.sent_timestamp_ms = sent_timestamp_ms; + // Unused on the CommunityInbox path (only the envelope-based Group/1o1 path consumes it). + dest.sent_timestamp_ms = 0; dest.recipient_pubkey = *recipient_pubkey; dest.community_inbox_server_pubkey = *community_pubkey; diff --git a/src/util.cpp b/src/util.cpp index afe3d4411..a5e548193 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -161,24 +161,15 @@ std::optional> zstd_decompress( return decompressed; } -inline bool is_utf16_low_surrogate(char16_t c) { - return c >= 0xDC00 && c <= 0xDFFF; -} - -inline bool is_utf16_high_surrogate(char16_t c) { - return c >= 0xD800 && c <= 0xDBFF; -} - -size_t utf16_count_truncated_to_codepoints( - std::span utf16_string, size_t codepoint_len) { +std::u16string_view utf16_truncate(std::u16string_view in, size_t max_codepoints) { // If the requested codepoint length is longer than the UTF-16 string length, // we can safely assume the entire string is needed. - if (utf16_string.size() <= codepoint_len) { - return utf16_string.size(); + if (in.size() <= max_codepoints) { + return in; } - if (codepoint_len == 0) { - return 0; + if (max_codepoints == 0) { + return {}; } // Call simdutf to count the codepoint for the entirety of the UTF-16 string. @@ -190,43 +181,31 @@ size_t utf16_count_truncated_to_codepoints( // Hence the overall cost to pay for the optimization: // * Truncation not needed: fast simdutf counting. // * Truncation needed: fast simdutf counting + slower iteration. - auto current_codepoint_len = simdutf::count_utf16(utf16_string.data(), utf16_string.size()); - if (current_codepoint_len <= codepoint_len) { - return utf16_string.size(); + auto current_codepoint_len = simdutf::count_utf16(in.data(), in.size()); + if (current_codepoint_len <= max_codepoints) { + return in; } - // Fallback: iterate through the UTF-16 string and count codepoints properly + // Fallback: iterate through the UTF-16 string and count codepoints properly, taking care not + // to split a surrogate pair. size_t counted_codepoints = 0; - bool expecting_low_surrogate = false; - for (size_t i = 0; i < utf16_string.size(); ++i) { - if (const char16_t c = utf16_string[i]; is_utf16_high_surrogate(c)) { - assert(!expecting_low_surrogate); - - // Start of a surrogate pair. Only count the codepoint when we see the low surrogate. - expecting_low_surrogate = true; - } else if (is_utf16_low_surrogate(c)) { - assert(expecting_low_surrogate); - - counted_codepoints++; - expecting_low_surrogate = false; - } else { - // Regular BMP character - assert(!expecting_low_surrogate); - counted_codepoints++; - } - - if (counted_codepoints == codepoint_len) { - return i + 1; - } + for (size_t i = 0; i < in.size(); ++i) { + // A high surrogate is the leading half of a surrogate pair: skip it so a pair is only + // counted (and only ever truncated) at its trailing low surrogate. + if (const char16_t c = in[i]; c >= 0xD800 && c <= 0xDBFF) + continue; + + if (++counted_codepoints == max_codepoints) + return in.substr(0, i + 1); } - // Should not be here, as the case of codepoint_len >= actual codepoint count should have + // Should not be here, as the case of max_codepoints >= actual codepoint count should have // been handled at the start of the function. As this indicates an invalid UTF-16 string, - // we will treat it as UB and return the whole string length. - return utf16_string.size(); + // we will treat it as UB and return the whole string. + return in; } -size_t utf16_count(std::span utf16_string) { +size_t utf16_count(std::u16string_view utf16_string) { return simdutf::count_utf16(utf16_string.data(), utf16_string.size()); } @@ -234,8 +213,10 @@ size_t utf16_count(std::span utf16_string) { LIBSESSION_C_API size_t utf16_count_truncated_to_codepoints( const uint16_t* utf16_string, size_t utf16_string_len, size_t codepoint_len) { - return session::utf16_count_truncated_to_codepoints( - {reinterpret_cast(utf16_string), utf16_string_len}, codepoint_len); + return session::utf16_truncate( + {reinterpret_cast(utf16_string), utf16_string_len}, + codepoint_len) + .size(); } LIBSESSION_C_API size_t utf16_count(const uint16_t* utf16_string, size_t utf16_string_len) { diff --git a/tests/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_config_pro.cpp b/tests/test_config_pro.cpp index 5388d6782..96513b784 100644 --- a/tests/test_config_pro.cpp +++ b/tests/test_config_pro.cpp @@ -23,7 +23,8 @@ TEST_CASE("Pro", "[config][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - pro_cpp.proof.version = 2; + // Config never persists the proof version (see ProConfig::load); a loaded proof is v0. + pro_cpp.proof.version = session::ProProofVersion_v0; pro_cpp.proof.rotating_pubkey = rotating_pk; pro_cpp.proof.expiry_at = std::chrono::sys_seconds(1s); constexpr auto revocation_tag = @@ -94,54 +95,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. { - std::array broken_sig = pro_cpp.proof.sig; - broken_sig[0] = ~broken_sig[0]; // Break the sig - const session::ProProof& proof = pro_cpp.proof; - - // clang-format off - session::config::dict bad_dict = { - {"r", std::string(reinterpret_cast(rotating_sk.data()), crypto_sign_ed25519_SEEDBYTES)}, - {"p", session::config::dict{ - /*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 eb458346f..856386b95 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -616,7 +616,9 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { { // CPP pro_cpp.rotating_privkey = rotating_sk; - pro_cpp.proof.version = 2; + // The config does not persist the proof version (dicts merge per-key, so an in-dict version + // can't reliably describe its sibling fields); a config-loaded proof is always v0. + pro_cpp.proof.version = session::ProProofVersion_v0; pro_cpp.proof.rotating_pubkey = rotating_pk; pro_cpp.proof.expiry_at = std::chrono::sys_seconds(1s); constexpr auto revocation_tag = @@ -651,4 +653,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{std::span{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{std::span{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_onion_request_router.cpp b/tests/test_onion_request_router.cpp index e48e31698..8913315d8 100644 --- a/tests/test_onion_request_router.cpp +++ b/tests/test_onion_request_router.cpp @@ -153,16 +153,16 @@ namespace { } void refresh_if_needed( - const std::vector& in_use_nodes, - std::function on_refresh_complete = nullptr) override { + const std::vector& /*in_use_nodes*/, + std::function /*on_refresh_complete*/ = nullptr) override { func_called("refresh_if_needed"); // Do nothing (don't want to trigger a cache refresh) } void get_swarm( - session::network::x25519_pubkey swarm_pubkey, - bool ignore_strike_count, - std::function)> callback) + session::network::x25519_pubkey /*swarm_pubkey*/, + bool /*ignore_strike_count*/, + std::function)> /*callback*/) override { func_called("get_swarm"); // Do nothing (don't want to trigger a cache refresh) @@ -183,28 +183,28 @@ namespace { class TestTransport : public ITransport, public CallTracker { public: void suspend() override { func_called("suspend"); }; - void resume(bool automatically_reconnect = true) override { func_called("resume"); }; + void resume(bool /*automatically_reconnect*/ = true) override { func_called("resume"); }; void close_connections() override { func_called("close_connections"); }; ConnectionStatus get_status() const override { return ConnectionStatus::unknown; }; void verify_connectivity( - service_node node, - std::chrono::milliseconds timeout, - const std::string& request_id, - const RequestCategory category, - std::function error_code)> callback) + service_node /*node*/, + std::chrono::milliseconds /*timeout*/, + const std::string& /*request_id*/, + const RequestCategory /*category*/, + std::function error_code)> /*callback*/) override { func_called("verify_connectivity"); } void add_failure_listener( - const ed25519_pubkey& pubkey, std::function listener) override { + const ed25519_pubkey& /*pubkey*/, std::function /*listener*/) override { func_called("add_failure_listener"); } - void remove_failure_listeners(const ed25519_pubkey& pubkey) override { + void remove_failure_listeners(const ed25519_pubkey& /*pubkey*/) override { func_called("remove_failure_listeners"); } - void send_request(Request request, network_response_callback_t callback) override { + void send_request(Request /*request*/, network_response_callback_t /*callback*/) override { func_called("send_request"); } }; diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index ca5275010..2ed051399 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -28,67 +28,11 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { crypto_sign_ed25519_keypair(rotating_pubkey.data, rotating_privkey.data); { - std::array fake_google_payment_token; - randombytes_buf(fake_google_payment_token.data(), fake_google_payment_token.size()); - std::array fake_google_order_id; - randombytes_buf(fake_google_order_id.data(), fake_google_order_id.size()); - // Google's composite payment_id is "|"; libsession treats the - // whole thing as one opaque value. - std::string fake_payment_id = "DEV." + oxenc::to_hex(fake_google_payment_token) + "|DEV." + - oxenc::to_hex(fake_google_order_id); - - const char* provider_code = SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_GOOGLE_PLAY; - auto payment_id = reinterpret_cast(fake_payment_id.data()); - size_t payment_id_len = fake_payment_id.size(); - int64_t unix_ts = 1698765432; // Arbitrary timestamp (unix epoch seconds) - SECTION("session_pro_backend_add_pro_payment_request_build") { - auto result = session_pro_backend_add_pro_payment_request_build( - master_privkey.data, - sizeof(master_privkey.data), - rotating_privkey.data, - sizeof(rotating_privkey.data), - provider_code, - payment_id, - payment_id_len); - { - scope_exit result_free{[&]() { session_pro_backend_request_free(&result); }}; - INFO(result.error); - REQUIRE(result.success); - REQUIRE(result.data.data != nullptr); - REQUIRE(result.data.size > 0); - - // Matches the C++ free-function implementation end to end. - auto cpp = add_payment_request( - master_privkey.data, - rotating_privkey.data, - provider_code, - std::span(payment_id, payment_id_len)); - REQUIRE(std::string_view(result.endpoint) == cpp.endpoint); - REQUIRE(cpp.endpoint == SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT); - REQUIRE(std::string_view(result.content_type) == cpp.content_type); - REQUIRE(cpp.content_type == "application/json"); - REQUIRE(span_u8_equals(result.data, cpp.data)); - } - - // After freeing - REQUIRE(result.data.data == nullptr); - REQUIRE(result.data.size == 0); - - // Invalid key size -> failure, no allocation - result = session_pro_backend_add_pro_payment_request_build( - master_privkey.data, - sizeof(master_privkey.data) - 1, - rotating_privkey.data, - sizeof(rotating_privkey.data), - provider_code, - payment_id, - payment_id_len); - REQUIRE(!result.success); - REQUIRE(result.error_count > 0); - REQUIRE(result.data.data == nullptr); - } + // Opaque, backend-owned payment identifier as it appears on a payment item (§5.2): the + // client stores and compares it for equality but never parses it. + std::string fake_payment_id = "DEV.opaque-payment-id-0123456789"; SECTION("session_pro_backend_generate_pro_proof_request_build") { auto result = session_pro_backend_generate_pro_proof_request_build( @@ -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, - std::span(payment_id, payment_id_len)); - REQUIRE(std::string_view(result.endpoint) == cpp.endpoint); - REQUIRE(cpp.endpoint == SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT); - REQUIRE(span_u8_equals(result.data, cpp.data)); - } - REQUIRE(result.data.data == nullptr); - - // Invalid key size - result = session_pro_backend_set_payment_refund_requested_request_build( - master_privkey.data, - sizeof(master_privkey.data) - 1, - unix_ts, - unix_ts + 1, - provider_code, - payment_id, - payment_id_len); - REQUIRE(!result.success); - REQUIRE(result.error_count > 0); - } - - SECTION("session_pro_backend_set_payment_refund_requested_response_parse") { - nlohmann::json j; - j["status"] = "ok"; - j["result"]["updated"] = true; - std::string json = j.dump(); - - // Valid JSON - auto result = session_pro_backend_set_payment_refund_requested_response_parse( - json.data(), json.size()); - { - scope_exit result_free{[&]() { - session_pro_backend_set_payment_refund_requested_response_free(&result); - }}; - if (result.header.error) - INFO(result.header.error); - REQUIRE(result.header.status == SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.status == SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error == nullptr); - REQUIRE(result.updated); - } - - // After freeing - REQUIRE(result.header.internal_ == nullptr); - - // Invalid JSON - json = "{invalid}"; - { - result = session_pro_backend_set_payment_refund_requested_response_parse( - json.data(), json.size()); - scope_exit result_free{[&]() { - session_pro_backend_set_payment_refund_requested_response_free(&result); - }}; - REQUIRE(result.header.status != SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error_code != nullptr); - REQUIRE(result.header.error_code != nullptr); - } - - // After freeing - REQUIRE(result.header.internal_ == nullptr); - - // Null JSON - result = session_pro_backend_set_payment_refund_requested_response_parse(nullptr, 0); - REQUIRE(result.header.status != SESSION_PRO_BACKEND_RESPONSE_STATUS_OK); - REQUIRE(result.header.error_code != nullptr); - REQUIRE(result.header.error_code != nullptr); - } } } @@ -740,8 +627,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{}; @@ -757,31 +643,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"; - auto pid_span = [&]() { - return std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}; - }; // Expected signed-message bytes (hex), each hand-verified against the spec field layout. - const std::string add_msg_hex = - "50726f4164645061796d656e745f5f5f8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" - "01" - "b40f6f5c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394676f6f676c655f" - "70" - "6c617900746573742d7061796d656e742d69642d313233"; const std::string gen_msg_hex = "50726f47656e657261746550726f6f668a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" "01" "b40f6f5c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39431373030303030" "303030"; - // get_pro_details is split (§3.4) into get_pro_status (master + ts) and paginated - // get_payment_details (master + ts + limit + before). Two details vectors pin the `before` - // framing: empty `before` (newest page) ends in the adjacency \0; a non-empty cursor is the - // opaque final field (no trailing separator). + // The two read requests: get_pro_status (§3.2; master + ts) and paginated get_payment_details + // (§3.3; master + ts + limit + before). Two details vectors pin the `before` framing: empty + // `before` (newest page) ends in the adjacency \0; a non-empty cursor is the opaque final field + // (no trailing separator). const std::string status_msg_hex = "50726f47657450726f5374617475735f8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf37488" "01b40f6f5c31373030303030303030"; @@ -791,12 +664,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" @@ -821,16 +688,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { pubkey.data()) == 0; }; - SECTION("add_pro_payment") { - auto req = add_payment_request(master_sk, rotating_sk, provider, pid_span()); - CHECK(req.endpoint == SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_ENDPOINT); - CHECK(sig_covers(req.data, "master_sig", add_msg_hex, master_pk)); - CHECK(sig_covers(req.data, "rotating_sig", add_msg_hex, rotating_pk)); - // Determinism pin: the exact master signature is itself a known answer. - CHECK(nlohmann::json::parse(req.data).at("master_sig").get() == - "746861ae1681d996e837d603dbd21c06c9544c5fc536eb4d3e3cad4f13692b79" - "0ef2b1282b729aa42f537d560b3b97b231b7f940be7021c0e16f894817a57609"); - } SECTION("generate_pro_proof") { auto req = pro_proof_request(master_sk, rotating_sk, ts); CHECK(req.endpoint == SESSION_PRO_BACKEND_GENERATE_PRO_PROOF_ENDPOINT); @@ -852,11 +709,6 @@ TEST_CASE("Pro backend known-answer vectors", "[pro_backend][pro_kat]") { CHECK(req.endpoint == SESSION_PRO_BACKEND_GET_PAYMENT_DETAILS_ENDPOINT); CHECK(sig_covers(req.data, "master_sig", details_cursor_msg_hex, master_pk)); } - SECTION("set_payment_refund_requested") { - auto req = refund_request(master_sk, ts, refund_ts, provider, pid_span()); - CHECK(req.endpoint == SESSION_PRO_BACKEND_SET_PAYMENT_REFUND_REQUESTED_ENDPOINT); - CHECK(sig_covers(req.data, "master_sig", refund_msg_hex, master_pk)); - } SECTION("pro proof") { session::ProProof proof; proof.version = 0; @@ -1167,7 +1019,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", @@ -1181,41 +1034,33 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { auto now = session::sysclock_now_s(); - // 1) add_pro_payment -> signed proof. - ProRequest add_req = add_payment_request( - master_sk, - rotating_sk, - provider, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - AddProPaymentResponse add = parse_add_payment(send(onion, add_req)); - INFO("add_pro_payment" << add.error.value_or("")); - REQUIRE(add.status == ResponseStatus::Ok); - CHECK(add.status == ResponseStatus::Ok); - CHECK(add.proof.verify_signature(backend_pubkey)); - CHECK(std::memcmp(add.proof.rotating_pubkey.data(), rotating_pk.data(), 32) == 0); - - // 1b) generate_pro_proof: pair a NEW rotating key to the same subscription -> a fresh proof. - std::array rotating2_pk{}; - std::array rotating2_sk{}; - crypto_sign_ed25519_keypair(rotating2_pk.data(), rotating2_sk.data()); - ProRequest gen_req = pro_proof_request(master_sk, rotating2_sk, now); + // 1) generate_pro_proof: redemption is implicit, so this master-signed request binds the seeded + // payment before answering and returns a signed proof for the paired rotating key. + ProRequest gen_req = pro_proof_request(master_sk, rotating_sk, now); GenerateProProofResponse gen = parse_pro_proof(send(onion, gen_req)); - INFO("generate_pro_proof" << gen.error.value_or("")); + INFO("generate_pro_proof " << gen.error.value_or("")); REQUIRE(gen.status == ResponseStatus::Ok); - CHECK(gen.status == ResponseStatus::Ok); CHECK(gen.proof.verify_signature(backend_pubkey)); - CHECK(std::memcmp(gen.proof.rotating_pubkey.data(), rotating2_pk.data(), 32) == 0); - // Same subscription epoch -> the fresh proof shares the add-payment proof's revocation_tag. - CHECK(gen.proof.revocation_tag == add.proof.revocation_tag); + CHECK(std::memcmp(gen.proof.rotating_pubkey.data(), rotating_pk.data(), 32) == 0); - // 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); @@ -1264,32 +1109,6 @@ TEST_CASE("Pro backend live full flow", "[pro_backend][pro_live]") { CHECK(page2.items.empty()); // no more payments CHECK_FALSE(page2.next_cursor.has_value()); // end of data } - - // 4) set_payment_refund_requested. Apple-only at the HTTP layer: app_store -> updated + - // reflected - // in get_pro_status; google_play -> rejected (Google refunds are handled out-of-band). - ProRequest refund_req = refund_request( - master_sk, - now, - now, - provider, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - SetPaymentRefundRequestedResponse refund = parse_refund(send(onion, refund_req)); - if (provider == SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE) { - INFO("refund" << refund.error.value_or("")); - REQUIRE(refund.status == ResponseStatus::Ok); - CHECK(refund.updated); - // The soft "refund requested" marker is now reflected back in get_pro_status. - ProStatusResponse status2 = - parse_pro_status(send(onion, pro_status_request(master_sk, now))); - REQUIRE(status2.status == ResponseStatus::Ok); - CHECK(status2.refund_requested_at.time_since_epoch().count() != 0); - } else { - // The endpoint rejects non-App-Store providers. - CHECK_FALSE(refund.updated); - CHECK(refund.status != ResponseStatus::Ok); - } } // Revocation-list round-trip: seed + redeem a payment, seed a revocation for that generation, then @@ -1325,15 +1144,13 @@ TEST_CASE("Pro backend live get_pro_revocations", "[pro_backend][pro_live]") { master_hex, "--plan", "1m"}); - ProRequest add_req = add_payment_request( - master_sk, - rotating_sk, - SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_APP_STORE, - std::span{ - reinterpret_cast(payment_id.data()), payment_id.size()}); - AddProPaymentResponse add = parse_add_payment(send(onion, add_req)); - REQUIRE(add.status == ResponseStatus::Ok); - CHECK(add.status == ResponseStatus::Ok); + // Bind + redeem it implicitly (any master-signed request binds unbound payments) and get a + // proof to revoke. + ProRequest gen_req = pro_proof_request(master_sk, rotating_sk, session::sysclock_now_s()); + GenerateProProofResponse gen = parse_pro_proof(send(onion, gen_req)); + INFO("generate_pro_proof " << gen.error.value_or("")); + REQUIRE(gen.status == ResponseStatus::Ok); + CHECK(gen.status == ResponseStatus::Ok); // Revoke that payment's generation (revocation is terminal now -- revoked_at set once, no // per-entry expiry), then poll: the list must carry our proof's revocation_tag. @@ -1347,7 +1164,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 4980afb8a..efb0d23f9 100644 --- a/tests/test_session_protocol.cpp +++ b/tests/test_session_protocol.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -101,59 +102,44 @@ static SerialisedProtobufContentWithProForTesting build_protobuf_content_with_se TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { // Do tests that require no setup - SECTION("Ensure get pro fetaures detects large message") { - // Try a message below the size threshold + SECTION("Ensure get pro features detects large message") { + // Below the size threshold { - auto msg = std::string(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(pro_msg.bitset.data == 0); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try an invalid message + // Exceeding the standard size threshold { - std::string_view msg = "\xFF"; session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); - REQUIRE(pro_msg.status == - SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR); - REQUIRE(pro_msg.error != nullptr); - REQUIRE(pro_msg.error[0] != '\0'); - } - - // Try a message exceeding the standard size threshold - { - auto msg = std::string(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1, 'a'); - session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try a message at the max size threshold + // At the max size threshold { - auto msg = std::string(SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); - REQUIRE(pro_msg.codepoint_count == msg.size()); } - // Try a message at the (max size + 1) threshold + // Over the max size threshold { - auto msg = std::string(SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT + 1, 'a'); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(msg.data(), msg.size()); + session_protocol_pro_features_for_message( + SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT + 1); REQUIRE(pro_msg.status == SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT); REQUIRE(pro_msg.bitset.data == 0); - REQUIRE(pro_msg.codepoint_count == msg.size()); } } @@ -421,7 +407,7 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { large_message.resize(SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT + 1); session_protocol_pro_features_for_msg pro_msg = - session_protocol_pro_features_for_utf8(large_message.data(), large_message.size()); + session_protocol_pro_features_for_message(large_message.size()); REQUIRE(session_protocol_pro_message_bitset_is_set( pro_msg.bitset, SESSION_PROTOCOL_PRO_MESSAGE_FEATURES_10K_CHARACTER_LIMIT)); @@ -882,7 +868,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, @@ -910,3 +895,25 @@ TEST_CASE("Session protocol helpers C API", "[session-protocol][helpers]") { REQUIRE(!decoded.has_pro); } } + +TEST_CASE("Pro rotating-seed derivation", "[session-protocol][pro][pro_kat]") { + // Deterministic BLAKE2b of the Pro master seed and the floored rotation period, so every device + // derives the same seed for the same period. Vectors computed independently (Python + // hashlib.blake2b, person="ProRotatingSeed_", input = seed || decimal-ASCII(period_start)). + auto master = "0101010101010101010101010101010101010101010101010101010101010101"_hexbytes; + auto seed_hex = [&](int64_t unix_ts) { + auto s = ProProof::rotating_seed( + master, std::chrono::sys_seconds{std::chrono::seconds{unix_ts}}); + return oxenc::to_hex(s.begin(), s.end()); + }; + + // KAT: 1700000000 floors to period start 1699488000; the next period starts at 1700092800. + CHECK(seed_hex(1700000000) == + "e617ee563883b95a736a4e375e581f578150346046b08fdb58d07f6a317c2ff7"); + CHECK(seed_hex(1700604800) == + "01887cd6b6827c3b335c5ab677ce831a6b253016e3d23646639188036d97bd91"); + + // Idempotent within a rotation period (any ts in the same 7-day window), distinct across them. + CHECK(seed_hex(1700000000 + 3600) == seed_hex(1700000000)); + CHECK(seed_hex(1700604800) != seed_hex(1700000000)); +} diff --git a/tests/test_snode_pool.cpp b/tests/test_snode_pool.cpp index 107233ecc..7fa4e3ebb 100644 --- a/tests/test_snode_pool.cpp +++ b/tests/test_snode_pool.cpp @@ -31,8 +31,8 @@ class TestSnodePool : public SnodePool { } void refresh_if_needed( - const std::vector& in_use_nodes, - std::function on_refresh_complete = nullptr) override { + const std::vector& /*in_use_nodes*/, + std::function /*on_refresh_complete*/ = nullptr) override { // Do nothing (don't want to trigger a cache refresh) } }; 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); +}