From 0f5d6b17c87d3d69f6a69bf39e5b11bc2e7b4cd8 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 19:18:04 -0300 Subject: [PATCH 1/6] =?UTF-8?q?WIP:=20Pro=20redemption=20reflow=20?= =?UTF-8?q?=E2=80=94=20nodejs=20glue=20re-pin=20+=20compile=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-pin libsession -> 58bafc4a and fix the compile-breaking changes: - refund_requested_at removed from ProPaymentItem + ProStatusResponse (refund state is config-synced now) -> dropped from paymentItemToJs + parseProStatusResponse. - §4: dropped the removed sent_timestamp_ms arg from encode_for_community_inbox. - parseProProofResponse uses auto, so the ProProofResponse->GenerateProProofResponse rename is transparent (no change). Desktop is view-only, so there were no add_pro_payment/refund request builders to remove. Compiles against 58bafc4a (view-only glue; C++ LSP clean). REMAINING (functional): rotating-seed derivation (session_protocol_pro_rotating_seed) + UserProfile get/set_refund_requested, get/set_pro_prepaid, get_pro_renewal_target N-API — these pair with the desktop TS rework. WIP; not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/pro/pro.hpp | 2 -- libsession-util | 2 +- src/encrypt_decrypt/encrypt_decrypt.cpp | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/include/pro/pro.hpp b/include/pro/pro.hpp index 38bfd1a..1ba1db4 100644 --- a/include/pro/pro.hpp +++ b/include/pro/pro.hpp @@ -408,7 +408,6 @@ class ProWrapper : public Napi::ObjectWrap { item["expiryTsMs"] = toJsMs(env, src.expiry_at); item["gracePeriodDurationMs"] = toJsMs(env, src.grace_period_duration); item["platformRefundExpiryTsMs"] = toJsMs(env, src.platform_refund_expiry_at); - item["refundRequestedTsMs"] = toJsMs(env, src.refund_requested_at); item["paymentId"] = toJs(env, src.payment_id); return item; } @@ -431,7 +430,6 @@ class ProWrapper : public Napi::ObjectWrap { obj["autoRenewing"] = toJs(env, resp.auto_renewing); obj["expiryMs"] = toJsMs(env, resp.expiry_at); obj["gracePeriodDurationMs"] = toJsMs(env, resp.grace_period_duration); - obj["refundRequestedTsMs"] = toJsMs(env, resp.refund_requested_at); if (resp.latest_payment) { obj["latestPayment"] = paymentItemToJs(env, *resp.latest_payment); } else { diff --git a/libsession-util b/libsession-util index 7e4d764..58bafc4 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit 7e4d7641a8243baa9ca6ec3a749f251fdd18488b +Subproject commit 58bafc4a95b0f5bf876a6c81da85d5db571e6bb7 diff --git a/src/encrypt_decrypt/encrypt_decrypt.cpp b/src/encrypt_decrypt/encrypt_decrypt.cpp index feb7d76..098ded3 100644 --- a/src/encrypt_decrypt/encrypt_decrypt.cpp +++ b/src/encrypt_decrypt/encrypt_decrypt.cpp @@ -467,7 +467,7 @@ Napi::Value MultiEncryptWrapper::encryptForCommunityInbox(const Napi::CallbackIn extractPlaintext(obj, "encryptForCommunityInbox.obj.plaintext"), extractSenderEd25519SeedAsVector( obj, "encryptForCommunityInbox.obj.senderEd25519Seed"), - extractSentTimestampMs(obj, "encryptForCommunityInbox.obj.sentTimestampMs"), + // §4: sent_timestamp_ms removed — community-inbox messages carry no envelope ts. extractRecipientPubkeyAsArray( obj, "encryptForCommunityInbox.obj.recipientPubkey"), extractCommunityPubkeyAsArray( From 7e345996a3c21e33078fc364c5085419b7ce098e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:39:54 -0300 Subject: [PATCH 2/6] Native codepoint counting; proFeaturesForMessage takes a count Re-pin libsession 58bafc4a -> a4809f7c (pro_features_for_utf8/16 -> pro_features_for_message; UTFDecodingError status removed). - proFeaturesForMessage now takes { codepointCount } instead of { utf16 }: desktop counts codepoints natively ([...text].length) and libsession owns the count -> feature policy. Drops the echoed codepointCount output. - Remove the utf16Count / utf16CountTruncatedToCodepoints glue methods (and TS decls): desktop counts/truncates natively now. - Expose MESSAGE_CHARACTER_LIMIT_STANDARD / _PRO constants so desktop single-sources the limits instead of hard-coding them. --- include/pro/pro.hpp | 81 ++++----------------------------------------- libsession-util | 2 +- src/constants.cpp | 10 ++++++ src/pro/pro.cpp | 1 - types/pro/pro.d.ts | 12 ++----- types/shared.d.ts | 4 +++ 6 files changed, 23 insertions(+), 87 deletions(-) diff --git a/include/pro/pro.hpp b/include/pro/pro.hpp index 1ba1db4..b703104 100644 --- a/include/pro/pro.hpp +++ b/include/pro/pro.hpp @@ -34,14 +34,6 @@ class ProWrapper : public Napi::ObjectWrap { "ProWrapperNode", { // Pro features - StaticMethod<&ProWrapper::utf16CountTruncatedToCodepoints>( - "utf16CountTruncatedToCodepoints", - static_cast( - napi_writable | napi_configurable)), - StaticMethod<&ProWrapper::utf16Count>( - "utf16Count", - static_cast( - napi_writable | napi_configurable)), StaticMethod<&ProWrapper::proFeaturesForMessage>( "proFeaturesForMessage", static_cast( @@ -134,8 +126,10 @@ class ProWrapper : public Napi::ObjectWrap { return wrapResult(info, [&] { // we expect one argument that matches: // first: { - // "utf16": string, + // "codepointCount": number, // } + // The caller counts codepoints natively (JS: [...text].length) and passes the count; + // libsession owns the count -> feature policy. assertInfoLength(info, 1); assertIsObject(info[0]); @@ -146,84 +140,21 @@ class ProWrapper : public Napi::ObjectWrap { if (first.IsEmpty()) throw std::invalid_argument("proFeaturesForMessage first received empty"); - assertIsString(first.Get("utf16"), "proFeaturesForMessage.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - ProFeaturesForMsg pro_features_msg = - session::pro_features_for_utf16((utf16.data()), utf16.length()); + assertIsNumber(first.Get("codepointCount"), "proFeaturesForMessage.codepointCount"); + size_t codepoint_count = first.Get("codepointCount").As().Uint32Value(); + ProFeaturesForMsg pro_features_msg = session::pro_features_for_message(codepoint_count); auto obj = Napi::Object::New(env); obj["status"] = toJs(env, proBackendEnumToString(pro_features_msg.status)); obj["error"] = pro_features_msg.error.size() ? toJs(env, pro_features_msg.error) : env.Null(); - obj["codepointCount"] = toJs(env, pro_features_msg.codepoint_count); obj["proMessageBitset"] = proMessageBitsetToJS(env, pro_features_msg.bitset); return obj; }); }; - static Napi::Value utf16Count(const Napi::CallbackInfo& info) { - return wrapResult(info, [&] { - // we expect one argument that matches: - // first: { - // "utf16": string, - // } - // we return an object with a single property {`codepointCount: number`} - - assertInfoLength(info, 1); - assertIsObject(info[0]); - auto env = info.Env(); - - auto first = info[0].As(); - - if (first.IsEmpty()) - throw std::invalid_argument("utf16Count first received empty"); - - assertIsString(first.Get("utf16"), "utf16Count.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - size_t codepoint_count = session::utf16_count(utf16); - - auto obj = Napi::Object::New(env); - obj["codepointCount"] = toJs(env, codepoint_count); - - return obj; - }); - }; - - static Napi::Value utf16CountTruncatedToCodepoints(const Napi::CallbackInfo& info) { - return wrapResult(info, [&] { - // we expect one argument that matches: - // first: { - // "utf16": string, - // "codepointLen": number, - // } - // we return an object with a single property {`truncateAt: number`} - - assertInfoLength(info, 1); - assertIsObject(info[0]); - auto env = info.Env(); - - auto first = info[0].As(); - - if (first.IsEmpty()) - throw std::invalid_argument("utf16CountTruncatedToCodepoints first received empty"); - - assertIsString(first.Get("utf16"), "utf16CountTruncatedToCodepoints.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - assertIsNumber( - first.Get("codepointLen"), "utf16CountTruncatedToCodepoints.codepointLen"); - size_t codepointLen = first.Get("codepointLen").As().Uint32Value(); - - size_t truncate_at = session::utf16_count_truncated_to_codepoints(utf16, codepointLen); - - auto obj = Napi::Object::New(env); - obj["truncateAt"] = toJs(env, truncate_at); - - return obj; - }); - }; - static Napi::Value proProofRequest(const Napi::CallbackInfo& info) { return wrapResult(info, [&] { // first: { diff --git a/libsession-util b/libsession-util index 58bafc4..a4809f7 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit 58bafc4a95b0f5bf876a6c81da85d5db571e6bb7 +Subproject commit a4809f7c51cd365682e9af59cf5fd502e0396bdf diff --git a/src/constants.cpp b/src/constants.cpp index fd48fad..c4fb45d 100644 --- a/src/constants.cpp +++ b/src/constants.cpp @@ -55,6 +55,16 @@ Napi::Object ConstantsWrapper::Init(Napi::Env env, Napi::Object exports) { "COMMUNITY_FULL_URL_MAX_LENGTH", Napi::Number::New(env, session::config::community::FULL_URL_MAX_LENGTH), napi_enumerable), + // Pro message character (codepoint) limits — single-sourced from libsession so clients + // don't hard-code them (the count -> feature decision goes via proFeaturesForMessage). + ObjectWrap::StaticValue( + "MESSAGE_CHARACTER_LIMIT_STANDARD", + Napi::Number::New(env, SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT), + napi_enumerable), + ObjectWrap::StaticValue( + "MESSAGE_CHARACTER_LIMIT_PRO", + Napi::Number::New(env, SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT), + napi_enumerable), ObjectWrap::StaticValue("LIBSESSION_PRO_URLS", pro_urls, napi_enumerable), // Session Pro backend identity — the single source of truth clients read instead of // hand-carrying their own copies (URL is the overridable prod/default; pubkey is hex). diff --git a/src/pro/pro.cpp b/src/pro/pro.cpp index d439fe8..af38172 100644 --- a/src/pro/pro.cpp +++ b/src/pro/pro.cpp @@ -9,7 +9,6 @@ namespace session::nodeapi { std::string_view proBackendEnumToString(session::ProFeaturesForMsgStatus v) { switch (v) { case session::ProFeaturesForMsgStatus::Success: return "SUCCESS"; - case session::ProFeaturesForMsgStatus::UTFDecodingError: return "UTF_DECODING_ERROR"; case session::ProFeaturesForMsgStatus::ExceedsCharacterLimit: return "EXCEEDS_CHARACTER_LIMIT"; } diff --git a/types/pro/pro.d.ts b/types/pro/pro.d.ts index e0052fb..665e41b 100644 --- a/types/pro/pro.d.ts +++ b/types/pro/pro.d.ts @@ -210,12 +210,8 @@ declare module 'libsession_util_nodejs' { }; type ProWrapper = { - proFeaturesForMessage: (args: { utf16: string }) => WithProMessageBitset & { - status: 'SUCCESS' | 'UTF_DECODING_ERROR' | 'EXCEEDS_CHARACTER_LIMIT'; - }; - utf16Count: (args: { utf16: string }) => { codepointCount: number }; - utf16CountTruncatedToCodepoints: (args: { utf16: string; codepointLen: number }) => { - truncateAt: number; + proFeaturesForMessage: (args: { codepointCount: number }) => WithProMessageBitset & { + status: 'SUCCESS' | 'EXCEEDS_CHARACTER_LIMIT'; }; proProofRequest: ( @@ -260,8 +256,6 @@ declare module 'libsession_util_nodejs' { */ export class ProWrapperNode { public static proFeaturesForMessage: ProWrapper['proFeaturesForMessage']; - public static utf16Count: ProWrapper['utf16Count']; - public static utf16CountTruncatedToCodepoints: ProWrapper['utf16CountTruncatedToCodepoints']; public static proProofRequest: ProWrapper['proProofRequest']; public static proRevocationsRequest: ProWrapper['proRevocationsRequest']; public static proStatusRequest: ProWrapper['proStatusRequest']; @@ -279,8 +273,6 @@ declare module 'libsession_util_nodejs' { */ export type ProActionsType = | MakeActionCall - | MakeActionCall - | MakeActionCall | MakeActionCall | MakeActionCall | MakeActionCall diff --git a/types/shared.d.ts b/types/shared.d.ts index 780a017..c44043d 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -164,6 +164,10 @@ declare module 'libsession_util_nodejs' { * BASE_URL_MAX_LENGTH + '/r/' + ROOM_MAX_LENGTH + qs_pubkey.size() + hex pubkey + null terminator */ COMMUNITY_FULL_URL_MAX_LENGTH: number; + /** Max message codepoints for a standard (non-Pro) message */ + MESSAGE_CHARACTER_LIMIT_STANDARD: number; + /** Max message codepoints for a Pro message */ + MESSAGE_CHARACTER_LIMIT_PRO: number; /** * A string that looks like libsession-util v1.2.0-nogit */ From f4b943d8adf67516a0af20be99fc89a617b7e464 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 00:10:16 -0300 Subject: [PATCH 3/6] Pro reflow glue additions: rotating-seed derivation + config accessors Functional half of the reflow (pairs with the desktop TS rework): - deriveProRotatingKey({ proMasterKeyHex, nowMs }): deterministic weekly rotating seed + its ed25519 keypair via ProProof::rotating_seed, so every device converges on the same key instead of hand-rolling/never-rotating. - get/setRefundRequested (config key R), get/setProPrepaid (config key I), getProRenewalTarget(nowMs): the config-synced refund + purchase-in-flight markers and the renewal-target poll that replaces bespoke auto-renew logic. - Drop the now-stale refundRequestedTsMs from the pro.d.ts response/payment types (the C++ side stopped emitting it in the reflow re-pin). --- include/user_config.hpp | 13 +++++ src/user_config.cpp | 102 +++++++++++++++++++++++++++++++++++++ types/pro/pro.d.ts | 2 - types/user/userconfig.d.ts | 39 +++++++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/include/user_config.hpp b/include/user_config.hpp index 6af4579..b51bbbc 100644 --- a/include/user_config.hpp +++ b/include/user_config.hpp @@ -48,5 +48,18 @@ class UserConfigWrapper : public ConfigBaseImpl, public Napi::ObjectWrap(); + + auto master_key_js_hex = obj_in.Get("proMasterKeyHex"); + assertIsString(master_key_js_hex, "deriveProRotatingKey.proMasterKeyHex"); + auto master_key_hex = + toCppString(master_key_js_hex, "deriveProRotatingKey.proMasterKeyHex"); + auto master_key = from_hex_to_vector(master_key_hex); + + auto now = std::chrono::floor( + toCppSysMs(obj_in.Get("nowMs"), "deriveProRotatingKey.nowMs")); + + // Deterministic weekly seed shared across the account's devices, then its ed25519 keypair. + auto rotating_seed = session::ProProof::rotating_seed(master_key, now); + auto [rotating_pk, rotating_sk] = session::ed25519::ed25519_key_pair(rotating_seed); + + auto obj = Napi::Object::New(info.Env()); + obj["rotatingSeedHex"] = toJs(info.Env(), to_hex(rotating_seed)); + obj["rotatingPrivKeyHex"] = toJs(info.Env(), to_hex(rotating_sk)); + return obj; + }); +} + +Napi::Value UserConfigWrapper::getRefundRequested(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + // libsession stores whole seconds; the JS domain is milliseconds. Null when unset (or gated). + auto refund_s = config.get_refund_requested(); + std::optional> refund_ms; + if (refund_s) + refund_ms = std::chrono::sys_time( + std::chrono::duration_cast( + refund_s->time_since_epoch())); + return toJs(info.Env(), refund_ms); + }); +} + +void UserConfigWrapper::setRefundRequested(const Napi::CallbackInfo& info) { + wrapExceptions(info, [&] { + assertInfoLength(info, 1); + assertIsNumberOrNull(info[0], "setRefundRequested"); + auto refund_ms = maybeNonemptyTimeMs(info[0], "UserConfigWrapper::setRefundRequested"); + std::optional refund; + if (refund_ms) + refund = std::chrono::floor(*refund_ms); + config.set_refund_requested(refund); + }); +} + +Napi::Value UserConfigWrapper::getProPrepaid(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + auto prepaid_s = config.get_pro_prepaid(); + std::optional> prepaid_ms; + if (prepaid_s) + prepaid_ms = std::chrono::sys_time( + std::chrono::duration_cast( + prepaid_s->time_since_epoch())); + return toJs(info.Env(), prepaid_ms); + }); +} + +void UserConfigWrapper::setProPrepaid(const Napi::CallbackInfo& info) { + wrapExceptions(info, [&] { + assertInfoLength(info, 1); + assertIsNumberOrNull(info[0], "setProPrepaid"); + auto prepaid_ms = maybeNonemptyTimeMs(info[0], "UserConfigWrapper::setProPrepaid"); + std::optional prepaid; + if (prepaid_ms) + prepaid = std::chrono::floor(*prepaid_ms); + config.set_pro_prepaid(prepaid); + }); +} + +Napi::Value UserConfigWrapper::getProRenewalTarget(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + assertInfoLength(info, 1); + auto now = std::chrono::floor( + toCppSysMs(info[0], "UserConfigWrapper::getProRenewalTarget")); + auto target_s = config.pro_renewal_target(now); + std::optional> target_ms; + if (target_s) + target_ms = std::chrono::sys_time( + std::chrono::duration_cast( + target_s->time_since_epoch())); + return toJs(info.Env(), target_ms); + }); +} + } // namespace session::nodeapi diff --git a/types/pro/pro.d.ts b/types/pro/pro.d.ts index 665e41b..b2ad838 100644 --- a/types/pro/pro.d.ts +++ b/types/pro/pro.d.ts @@ -179,7 +179,6 @@ declare module 'libsession_util_nodejs' { expiryTsMs: number; gracePeriodDurationMs: number; platformRefundExpiryTsMs: number; - refundRequestedTsMs: number; /** * Opaque payment identifier (confidential). */ @@ -203,7 +202,6 @@ declare module 'libsession_util_nodejs' { autoRenewing: boolean; expiryMs: number; gracePeriodDurationMs: number; - refundRequestedTsMs: number; /** The single most-recent payment, or null when the account has no payments. (Full history is a * separate library-only query — not wired.) */ latestPayment: ProPaymentItem | null; diff --git a/types/user/userconfig.d.ts b/types/user/userconfig.d.ts index db7d498..d10c2e1 100644 --- a/types/user/userconfig.d.ts +++ b/types/user/userconfig.d.ts @@ -72,6 +72,31 @@ declare module 'libsession_util_nodejs' { * Note: this should only be done once per device, and saved to the DB or the extra_data of `UserProfile`. */ generateRotatingPrivKeyHex: () => WithRotatingPrivKeyHex; + + /** + * Deterministically derive the rotating seed (and its ed25519 keypair) for the weekly rotation + * period containing `nowMs`, from the Pro master key. Every device derives the same key for the + * same period, so concurrent proof (re)generations converge. Feed the priv key to a proof + * request and persist the seed via setProConfig once the backend returns a signed proof. + */ + deriveProRotatingKey: (args: { proMasterKeyHex: string; nowMs: number }) => { + /** 32 bytes, 64 chars */ + rotatingSeedHex: string; + /** 64 bytes, 128 chars */ + rotatingPrivKeyHex: string; + }; + + /** Refund-requested marker (config key R), in ms; null when unset or past the 1-week read gate. */ + getRefundRequested: () => number | null; + setRefundRequested: (refundTsMs: number | null) => void; + /** Pro-prepaid / purchase-in-flight marker (config key I), in ms; null when unset or gated. */ + getProPrepaid: () => number | null; + setProPrepaid: (prepaidTsMs: number | null) => void; + /** + * When to (re)request a proof given `nowMs`: nowMs (request now), a future ms (preemptive + * renewal ~1h before expiry), or null (don't renew). Supersedes bespoke auto-renew logic. + */ + getProRenewalTarget: (nowMs: number) => number | null; }; export type UserConfigWrapperActionsCalls = MakeWrapperActionCalls; @@ -105,6 +130,12 @@ declare module 'libsession_util_nodejs' { public generateProMasterKey: UserConfigWrapper['generateProMasterKey']; public generateRotatingPrivKeyHex: UserConfigWrapper['generateRotatingPrivKeyHex']; + public deriveProRotatingKey: UserConfigWrapper['deriveProRotatingKey']; + public getRefundRequested: UserConfigWrapper['getRefundRequested']; + public setRefundRequested: UserConfigWrapper['setRefundRequested']; + public getProPrepaid: UserConfigWrapper['getProPrepaid']; + public setProPrepaid: UserConfigWrapper['setProPrepaid']; + public getProRenewalTarget: UserConfigWrapper['getProRenewalTarget']; } /** @@ -137,5 +168,11 @@ declare module 'libsession_util_nodejs' { | MakeActionCall | MakeActionCall | MakeActionCall - | MakeActionCall; + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall; } From 8a668695ba6fc98a3f533a1a965900882d29c845 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 13:31:01 -0300 Subject: [PATCH 4/6] Drop client-side rotating-seed window characterization from comments Clients must not care about or assert libsession's rotating-seed rotation window (it's libsession-owned and may change). Reword the rotating-key comments to just 'the seed for now' instead of 'weekly'/'rotation period'; no code logic relied on the window. --- include/user_config.hpp | 6 +++--- src/user_config.cpp | 2 +- types/user/userconfig.d.ts | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/include/user_config.hpp b/include/user_config.hpp index b51bbbc..be2527e 100644 --- a/include/user_config.hpp +++ b/include/user_config.hpp @@ -49,9 +49,9 @@ class UserConfigWrapper : public ConfigBaseImpl, public Napi::ObjectWrap( toCppSysMs(obj_in.Get("nowMs"), "deriveProRotatingKey.nowMs")); - // Deterministic weekly seed shared across the account's devices, then its ed25519 keypair. + // Deterministic seed for `now` (shared across the account's devices), then its ed25519 keypair. auto rotating_seed = session::ProProof::rotating_seed(master_key, now); auto [rotating_pk, rotating_sk] = session::ed25519::ed25519_key_pair(rotating_seed); diff --git a/types/user/userconfig.d.ts b/types/user/userconfig.d.ts index d10c2e1..7b1c4eb 100644 --- a/types/user/userconfig.d.ts +++ b/types/user/userconfig.d.ts @@ -74,10 +74,11 @@ declare module 'libsession_util_nodejs' { generateRotatingPrivKeyHex: () => WithRotatingPrivKeyHex; /** - * Deterministically derive the rotating seed (and its ed25519 keypair) for the weekly rotation - * period containing `nowMs`, from the Pro master key. Every device derives the same key for the - * same period, so concurrent proof (re)generations converge. Feed the priv key to a proof - * request and persist the seed via setProConfig once the backend returns a signed proof. + * Deterministically derive the rotating seed (and its ed25519 keypair) for `nowMs` from the Pro + * master key (libsession owns the rotation schedule). Every device deriving from the same master + * key + time converges on the same key, so concurrent proof (re)generations don't race. Feed the + * priv key to a proof request and persist the seed via setProConfig once the backend returns a + * signed proof. */ deriveProRotatingKey: (args: { proMasterKeyHex: string; nowMs: number }) => { /** 32 bytes, 64 chars */ From 98162302ae5610cbd82b1d6ccca4ce048829461b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 19:37:27 -0300 Subject: [PATCH 5/6] Re-vendor libsession 078c611b; surface account_expiry_ts on proof response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession a4809f7c -> 078c611b (gated pro_renewal_target + advisory account_expiry_ts on generate_pro_proof). Surface the new field as accountExpiryMs on GenerateProProofResponse so the client can refresh its cached access expiry (E) in the same write as the new proof (and on a subscription_expired failure). Advisory/unsigned — display + E only. --- include/pro/pro.hpp | 9 +++++++++ libsession-util | 2 +- types/pro/pro.d.ts | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/include/pro/pro.hpp b/include/pro/pro.hpp index b703104..2dc6434 100644 --- a/include/pro/pro.hpp +++ b/include/pro/pro.hpp @@ -271,6 +271,15 @@ class ProWrapper : public Napi::ObjectWrap { auto obj = Napi::Object::New(env); emitResponseHeader(env, obj, resp); obj["proof"] = toJs(env, resp.proof); + // Advisory account (subscription) expiry — grace-inclusive true entitlement end. Present + // on success + subscription_expired (a past value there), null otherwise. Distinct from + // the proof's own clamped expiry; unsigned/not-in-M, for display + `E` refresh only. + std::optional> account_expiry_ms; + if (resp.account_expiry) + account_expiry_ms = std::chrono::sys_time( + std::chrono::duration_cast( + resp.account_expiry->time_since_epoch())); + obj["accountExpiryMs"] = toJs(env, account_expiry_ms); return obj; }); }; diff --git a/libsession-util b/libsession-util index a4809f7..078c611 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit a4809f7c51cd365682e9af59cf5fd502e0396bdf +Subproject commit 078c611bdb3f53f3182fc377a13e50e09b66a1df diff --git a/types/pro/pro.d.ts b/types/pro/pro.d.ts index b2ad838..17bc0b1 100644 --- a/types/pro/pro.d.ts +++ b/types/pro/pro.d.ts @@ -127,6 +127,13 @@ declare module 'libsession_util_nodejs' { type GenerateProProofResponse = WithProResponseHeader & { proof: ProProof; + /** + * Advisory account (subscription) expiry (ms) — grace-inclusive true entitlement end; present on + * a successful proof and on a `subscription_expired` failure (a past value), null otherwise. + * Distinct from the proof's own clamped expiry; unsigned / not-in-M — use for the "Pro until X" + * display and to refresh the cached access expiry (`E`), never for entitlement gating. + */ + accountExpiryMs: number | null; }; type ProRevocationItem = WithRevocationTag & { From 0eb3f300cfbdde352725c594722983c8949e9a6a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 21:18:42 -0300 Subject: [PATCH 6/6] formatting --- include/pro/pro.hpp | 7 ++++--- src/encrypt_decrypt/encrypt_decrypt.cpp | 3 ++- src/user_config.cpp | 15 +++++++-------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/include/pro/pro.hpp b/include/pro/pro.hpp index 2dc6434..90d338a 100644 --- a/include/pro/pro.hpp +++ b/include/pro/pro.hpp @@ -271,9 +271,10 @@ class ProWrapper : public Napi::ObjectWrap { auto obj = Napi::Object::New(env); emitResponseHeader(env, obj, resp); obj["proof"] = toJs(env, resp.proof); - // Advisory account (subscription) expiry — grace-inclusive true entitlement end. Present - // on success + subscription_expired (a past value there), null otherwise. Distinct from - // the proof's own clamped expiry; unsigned/not-in-M, for display + `E` refresh only. + // Advisory account (subscription) expiry — grace-inclusive true entitlement end. + // Present on success + subscription_expired (a past value there), null otherwise. + // Distinct from the proof's own clamped expiry; unsigned/not-in-M, for display + `E` + // refresh only. std::optional> account_expiry_ms; if (resp.account_expiry) account_expiry_ms = std::chrono::sys_time( diff --git a/src/encrypt_decrypt/encrypt_decrypt.cpp b/src/encrypt_decrypt/encrypt_decrypt.cpp index 098ded3..5041dc8 100644 --- a/src/encrypt_decrypt/encrypt_decrypt.cpp +++ b/src/encrypt_decrypt/encrypt_decrypt.cpp @@ -467,7 +467,8 @@ Napi::Value MultiEncryptWrapper::encryptForCommunityInbox(const Napi::CallbackIn extractPlaintext(obj, "encryptForCommunityInbox.obj.plaintext"), extractSenderEd25519SeedAsVector( obj, "encryptForCommunityInbox.obj.senderEd25519Seed"), - // §4: sent_timestamp_ms removed — community-inbox messages carry no envelope ts. + // §4: sent_timestamp_ms removed — community-inbox messages carry no envelope + // ts. extractRecipientPubkeyAsArray( obj, "encryptForCommunityInbox.obj.recipientPubkey"), extractCommunityPubkeyAsArray( diff --git a/src/user_config.cpp b/src/user_config.cpp index 41d10f1..55dcde3 100644 --- a/src/user_config.cpp +++ b/src/user_config.cpp @@ -105,14 +105,11 @@ void UserConfigWrapper::Init(Napi::Env env, Napi::Object exports) { &UserConfigWrapper::generateRotatingPrivKeyHex), InstanceMethod( "deriveProRotatingKey", &UserConfigWrapper::deriveProRotatingKey), - InstanceMethod( - "getRefundRequested", &UserConfigWrapper::getRefundRequested), - InstanceMethod( - "setRefundRequested", &UserConfigWrapper::setRefundRequested), + InstanceMethod("getRefundRequested", &UserConfigWrapper::getRefundRequested), + InstanceMethod("setRefundRequested", &UserConfigWrapper::setRefundRequested), InstanceMethod("getProPrepaid", &UserConfigWrapper::getProPrepaid), InstanceMethod("setProPrepaid", &UserConfigWrapper::setProPrepaid), - InstanceMethod( - "getProRenewalTarget", &UserConfigWrapper::getProRenewalTarget), + InstanceMethod("getProRenewalTarget", &UserConfigWrapper::getProRenewalTarget), }); } @@ -403,7 +400,8 @@ Napi::Value UserConfigWrapper::deriveProRotatingKey(const Napi::CallbackInfo& in auto now = std::chrono::floor( toCppSysMs(obj_in.Get("nowMs"), "deriveProRotatingKey.nowMs")); - // Deterministic seed for `now` (shared across the account's devices), then its ed25519 keypair. + // Deterministic seed for `now` (shared across the account's devices), then its ed25519 + // keypair. auto rotating_seed = session::ProProof::rotating_seed(master_key, now); auto [rotating_pk, rotating_sk] = session::ed25519::ed25519_key_pair(rotating_seed); @@ -416,7 +414,8 @@ Napi::Value UserConfigWrapper::deriveProRotatingKey(const Napi::CallbackInfo& in Napi::Value UserConfigWrapper::getRefundRequested(const Napi::CallbackInfo& info) { return wrapResult(info, [&] { - // libsession stores whole seconds; the JS domain is milliseconds. Null when unset (or gated). + // libsession stores whole seconds; the JS domain is milliseconds. Null when unset (or + // gated). auto refund_s = config.get_refund_requested(); std::optional> refund_ms; if (refund_s)