Skip to content

Pro: atomic credential storage; key rotation & renewal logic; add_pro_payment renewal - #104

Open
jagerman wants to merge 24 commits into
session-foundation:devfrom
jagerman:refund-requested-config
Open

Pro: atomic credential storage; key rotation & renewal logic; add_pro_payment renewal#104
jagerman wants to merge 24 commits into
session-foundation:devfrom
jagerman:refund-requested-config

Conversation

@jagerman

Copy link
Copy Markdown
Member

Changes to match session-foundation/session-pro-backend#4

The Pro backend is dropping two endpoints, and while wiring that up we found the
client-side Pro credential handling had a set of latent correctness bugs (non-atomic
config merges, an unused version field, millisecond/second confusion) and that the
rotating-key lifecycle was reimplemented — inconsistently and buggily — in each of the
three clients. This branch reworks how libsession stores and manages the Session Pro
credential so the library owns that logic.

Endpoint removals (backend-driven)

  • set_payment_refund_requested is gone. A device that initiates a refund now
    records it in its own config via a new synced refund_requested flag
    (UserProfile::get/set_refund_requested, config key R), so other devices learn
    about it through config sync instead of the backend. The value is client-cleared and
    ignored once it is more than a week old (a read-time gate — we don't dirty and
    re-upload config just to prune an expiring ~15-byte value).
  • /add_pro_payment is gone; redemption is now implicit by any master-signed
    request (e.g. get_pro_status, or get_pro_proof). The client-side payment_id composite,
    unknown_payment handling, endpoint, request/response types, C API, tests, and KAT are
    removed; [pro_live] now seeds → generates a proof directly.

Config correctness

  • Atomic credential storage. The whole Pro credential is now stored as one opaque
    bt-encoded string at data["s"] (ProConfig::serialize()/load()) instead of as
    separate dict fields. The config CRDT merges per-key and non-atomically, so the old
    layout could stitch a signature onto mismatched fields, or desync the rotating seed
    from the proof that authorizes its pubkey. One key ⇒ atomic.
  • Stop persisting the proof version. Dicts aren't versioned, and the field was
    only ever read into a struct member nobody consumed. Removed.
  • Legacy millisecond pro_access_expiry migration. Older clients stored E in
    milliseconds; it is now normalized to seconds on read.

Rotating-key lifecycle (now owned by libsession)

The three clients each rotated the Pro signing key differently (Android per-proof;
iOS/desktop never; iOS additionally never renewed a non-auto-renewing proof even after
it expired). This centralizes the policy:

  • ProProof::rotating_seed(master_seed, timestamp) — deterministic per-period seed
    derivation: BLAKE2b-256(personal="ProRotatingSeed_", master_seed[0:32] ‖ dec(floor(unix / PERIOD) · PERIOD)), with PRO_ROTATING_SEED_PERIOD = 7 * 24h.
    Because it is deterministic and keyed on shared config state, every device derives the
    same seed for a given period, so concurrent (re)generations converge instead of
    racing. C API: session_protocol_pro_rotating_seed.
  • pro_prepaid flag (config key I, get/set_pro_prepaid) — a "purchase in
    flight" marker; insert-only-if-not-already-Pro, 1-week read gate, auto-cleared by
    set_pro_config (a live proof arrived) and set_pro_access_expiry (live E). Also
    serves as the anchor for the first rotating seed.
  • pro_renewal_target(now) — the single "when do I renew?" decision: no proof or
    already-expired → now; valid proof with entitlement still at least PRO_RENEWAL_LEAD
    (60 min) ahead → ~1 h before proof expiry (nudged off the rotation-period boundary so
    every device floors to the same period); otherwise → nullopt. No auto_renewing
    input — remaining entitlement already gates it, and gating on auto_renewing would
    strand lifetime / paid-through users mid-subscription. C API:
    user_profile_get_pro_renewal_target.

Migrations

All new-format reads are self-healing: an old dict-shaped s, a millisecond E, or a
stray proof version are treated as absent and re-fetched. No flag day.

jagerman and others added 24 commits July 27, 2026 20:53
Add a top-level `R` key to UserProfile holding the unix timestamp at
which the user requested a refund of their Session Pro subscription.
This lets a device that initiated a refund propagate the state to the
user's other devices through config sync, rather than round-tripping it
through the Pro backend (which does nothing with it).

The setter stamps the profile-updated timestamp so the change is
time-ordered across devices; the client clears it (nullopt/0) when a new
subscription begins. Also documents the previously-undocumented `s`
(Session Pro data) key in the key ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The refund-requested timestamp did nothing on the Pro backend side; its
only purpose was cross-device sync, which now lives in the config layer
(UserProfile `R` key). Remove the whole set-refund path from
libsession:

- the `set_payment_refund_requested` endpoint constant and the
  `ProSetRefundReq_` signing domain;
- refund_sig / refund_request (+ internal message/sign/body helpers) and
  SetPaymentRefundRequestedResponse / parse_refund;
- the `refund_requested_at` field on ProStatusResponse and
  ProPaymentItem and its JSON (de)serialization;
- the C mirrors (endpoint const, struct fields, request_build /
  response_parse / response_free);
- the corresponding tests and the KAT generator's refund vector.

Kept: platform_refund_expiry_* (the provider-store refund deadline, a
distinct concept) and the refund_*_url support links.

Removing the read-side parse is safe regardless of backend timing: an
absent field can only break a json_require, and those calls are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An `if (auto x = f(); x)` where the init variable is itself the whole
condition is just `if (auto x = f())`. Unify the UserProfile and group
config sites on the shorter form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_refund_requested() now returns nullopt when the stored timestamp is
more than a week in the past.  This bounds the blast radius of the
client-cleared design: if a device forgets to clear the flag on
re-subscribe (or an old device never learns of it), the stale value
self-expires instead of showing 'refund requested' forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backend deleted the add_pro_payment signed request: the store now
notifies the backend of a purchase out-of-band and any master-signed
request binds the account's unbound payments before answering, so there
is no client-submitted redemption step. Client-side removals:

- ADD_PRO_PAYMENT_DOMAIN signing domain + the add_pro_payment endpoint;
- add_payment_sigs / add_payment_request (+ message/sign/body helpers),
  AddProPaymentResponse, parse_add_payment, and the C request_build;
- client-side payment_id composite construction -- payment_id is no
  longer a signed input, only an opaque output on payment items;
- the corresponding KAT vector, C-API test, and add-payment live steps
  (reworked to seed -> generate_pro_proof implicit binding).

The shared proof-response path (generate_pro_proof) is unchanged; its C
parse/free now allocate GenerateProProofResponse directly. Spec-section
citations remapped for the renumbered wire doc (get_pro_status 3.4->3.2,
get_payment_details 3.4->3.3; add-payment 3.2 removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pro_access_expiry (key E) is stored in epoch seconds, but older clients
(commit 09d2a37) stored it in milliseconds and left no migration, so
get_pro_access_expiry() read a ~1.7e12 ms value as seconds (a year-33000
date). Treat any stored value > 1e12 as milliseconds and divide by 1000
on read. Also corrects the stale 'in milliseconds' key-ledger comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ProProofResponse existed to unify the add-payment and generate-proof
responses; with add-payment gone it's a vestigial one-child base. Fold
its `proof` member into GenerateProProofResponse (now derives directly
from ResponseBase) and retype fill_proof accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proof `version` was stored at s.p.@ but nothing read it back
(verify_signature hardcodes v0) and it actively bloated the config. It
was also unsound: config dicts merge per-key, non-atomically, so an
in-dict version can't describe its sibling fields -- a concurrent edit
could stitch one update's version onto another update's fields. Config
formats must be versioned by key, not an in-dict marker.

ProConfig::load no longer reads @ and assigns v0 (the only config proof
format) directly; set_pro_config stops writing @ and erases any legacy
value on rewrite. The wire/protobuf proof version (a real, atomic
discriminator) is untouched. Also fixes the stale 'milliseconds' note on
the proof expiry (e) key, which is seconds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proof was scattered across sibling config keys (s.p.{g,e,s} + the
seed s.r), but config dicts merge per-key and non-atomically -- so a
concurrent update could stitch a signature onto a different update's
fields, or desync the seed from the pubkey the sig authorizes. Collapse
the whole credential into a single opaque bt-encoded string at data["s"]
({e,g,r,s}; rotating pubkey derived from the seed), so it can only merge
as an indivisible unit.

ProConfig gains serialize()/load(string_view); get/set_pro_config marshal
the one value. An older dict-shaped "s" no longer parses -> treated as
'no proof' -> re-fetched (self-healing; flag for clients).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive the rotating Session Pro seed deterministically from the Pro
master seed and the (weekly) rotation period containing a timestamp:

  seed = BLAKE2b-256(personal="ProRotatingSeed_",
                     master_seed[0:32] || dec(floor(unix/604800)*604800))

where dec() is decimal ASCII (the Pro wire's existing integer convention,
so no endianness). Because every device derives the same seed for the
same period with no coordination, concurrent proof (re)generations
converge on one credential instead of racing -- replacing the current
per-client rotating-key logic (Android mints per-proof, iOS/desktop never
rotate). Rooted at the Pro master (not the session-id seed) so all Pro key
material shares one hierarchy and the Pro subsystem never touches the
account identity seed. Static member of ProProof; C API
session_protocol_pro_rotating_seed (takes a sys_seconds/unix_ts and floors
internally -- no epoch exposed to callers). KAT vectors cross-checked
against an independent Python BLAKE2b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Like maybe_vector, but returns a std::span<const unsigned char> into the
dict's stored string value instead of allocating a copy. Not yet used;
provided for callers that only need to read the bytes and don't need
maybe_vector's allocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New top-level UserProfile key `I`: a timestamp marking that a Session
Pro purchase was initiated, synced so every device polls the backend to
pull the entitlement through (now possible since redemption is implicit
and needs no client-held payment_id). get/set_pro_prepaid:

- set inserts only if the account isn't already Pro (live proof or
  future access-expiry) -- otherwise there's nothing to poll for;
- get applies the same 1-week read gate as refund (a purchase that never
  propagates stops nagging devices to poll);
- it's the shared anchor for deriving the first rotating seed before any
  proof exists.

Cleared automatically once entitlement lands: set_pro_config clears it
when the stored proof is still valid, and set_pro_access_expiry clears it
(and any >1wk-stale refund R) when E is in the future. All clears are
no-ops when there's nothing to clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Centralise the "when should I renew my Pro proof?" decision that the
three clients each implemented inconsistently (Android per-proof, iOS
missing the expired case entirely). Given the stored proof + access
expiry, returns the timestamp to attempt a renewal (renew now if it's
<= the caller's clock, else schedule), or nullopt for "no renewal":

  - no proof, or proof already expired      -> now
  - valid proof, access expiry >1h ahead    -> ~1h before proof expiry
  - otherwise                                -> nullopt

The preemptive target is nudged off a rotation-period boundary so every
device floors the (shared) target into the same period and derives the
same rotating seed. No auto_renewing input -- 'entitlement still ahead'
(access expiry) already gates it, and gating on auto_renewing would
strand lifetime/paid-through users mid-subscription. Extracts the shared
PRO_ROTATING_SEED_PERIOD constant used by both the nudge and
ProProof::rotating_seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the verbose std::chrono::hours{...}/minutes{...} constructs in
the refund/pro_prepaid/pro_renewal_target tests with duration literals
(1h, 30min, 10 * 24h, ...). (weeks kept as std::chrono::weeks{2} -- no
literal suffix, and it reads clearly.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The signed-message builder discarded std::to_chars's error code. The
buffer is sized for any integer so it cannot actually fail, but assert
the invariant rather than silently trusting it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These load paths only read the dict bytes to memcpy them into a fixed
buffer, so the maybe_vector heap allocation was unnecessary; maybe_span
returns a view into the config dict value instead.
ProSignedMessage bundled exactly verify_message's two arguments (a
64-byte signature and the message it signs) and existed only to be
passed, wrapped in std::optional, as the last argument of
ProProof::status -- every call site had to populate the struct first.
Pass the two directly instead: an optional 64-byte signature span plus
the message span. Call sites hand the values over without the wrapper,
and the C API stops building an intermediate C++ struct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pro_proof_sigs, pro_status_sig and payment_details_sig were public but
unused: each *_request() already signs internally (via the private
*_sign helpers), and nothing in the C API or tests called the public
wrappers. They split the request-building API across two public
functions for no benefit. Drop the three wrappers; the request builders
and the private signing helpers they use are unchanged.

(MasterRotatingSignatures stays, unlike on the source branch, because
here it is also the private generate_proof_sign helper's return type.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
status only reads the proof (verify_signature, verify_message and
is_active are all const), so mark it const and let callers evaluate a
const ProProof&.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pro_features_for_utf8/16 documented a 'success' bool and a
'features'/'bitset' output; the function returns a ProFeaturesForMsg
with a 'status' enum and a 'bitset' field. Update the Outputs to match.

(The pfs source commit also retargeted a stale decode_envelope doc
block, but that drift is not present on this branch: decode_envelope is
still the unified keys-object entry point the block describes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pro-signature attachment always writes a 64-byte prosig so Pro and
non-Pro envelopes are indistinguishable on the wire. The no-Pro branch
was generating a throwaway keypair and signing the message content --
two scalarmults plus hashing the whole content. Replace it with a decoy
signature built inline, with the same structure and distribution as a
real Ed25519 signature but signing nothing: R = r*B for a random scalar
r (a prime-order-subgroup point, like a real R, via base_noclamp -- a
directly-sampled group element would be off the main subgroup ~7/8 of
the time and thus detectable), and s a second random scalar in ]0, L[.
It is never verified or relied on; it exists only as a wire decoy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These are all silenced by commenting out (or, for the tag-dispatch opt
overloads where the type is the tag, dropping) the parameter name; no
parameter that is actually used was removed.

Adds a WARN_UNUSED_PARAMETERS cmake option to turn these on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unity_inbox

encode_for_community_inbox threads sent_timestamp into the shared
Destination struct, but encode_for_destination_internal only consumes
dest.sent_timestamp_ms on the envelope-based Group/1o1 path -- the
CommunityInbox branch never reads it. So the parameter was dead here,
laundered through the struct (which is why -Wunused-parameter didn't
flag it). Drop it from the C++ and C signatures and the test caller; the
Destination field is left explicitly zeroed with a note since the struct
is shared with the paths that do use it. Matches the pfs branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… backport

The unused-parameters backport (699f823) only silenced dev's actual
-Wunused-parameter warnings, so it missed the deliberate parameter drops
from the pfs Fix-unused-parameters commit that don't surface as warnings
on dev:

  - session_encrypt_for_group / session_decrypt_group_message: drop the
    group_ed25519_pubkey_len C-API argument. The group Ed25519 pubkey is
    always 32 bytes, so the length was pointless; the C wrappers now pass
    a fixed-size span.
  - QuicTransport::_fail_connection: drop the unused conn_id parameter (and
    the conn argument threaded to it at the call sites).

(The pfs commit's blind_id_impl server_pk drop does not apply: dev has no
shared blind_id_impl -- that helper is a pfs-only refactor.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jagerman
jagerman force-pushed the refund-requested-config branch from 72b0f04 to 786551a Compare July 29, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant