Skip to content

(PFS) Pro: atomic credential storage; key rotation & renewal logic; add_pro_payment renewal - #106

Merged
jagerman merged 34 commits into
session-foundation:pfsfrom
jagerman:refund-requested-config-pfs
Jul 31, 2026
Merged

(PFS) Pro: atomic credential storage; key rotation & renewal logic; add_pro_payment renewal#106
jagerman merged 34 commits into
session-foundation:pfsfrom
jagerman:refund-requested-config-pfs

Conversation

@jagerman

Copy link
Copy Markdown
Member

Same as #104 but for the PFS development branch.

jagerman and others added 30 commits July 31, 2026 02: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/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>
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
called from exactly one place each -- their own *_request() -- and never
from the C API or tests. They split every request build across two
public functions for no benefit. Inline the (1-3 line) signing into each
request builder and drop the helpers, along with MasterRotatingSignatures
which only existed as pro_proof_sigs's return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <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>
- The decode doc block was written for an old unified decode_envelope(keys,
  ...) and left attached to decode_dm_envelope: retarget it (its API name,
  the nonexistent 'keys' input, the groups-v2 dual-mode language that now
  belongs to decode_group_envelope) and drop the duplicated one-line
  description that had been appended after it.
- pro_features_for_utf8/16 documented a 'success' bool and 'features'/'bitset'
  outputs; the function returns a ProFeaturesForMsg with a 'status' enum and a
  'flags' field. Update the Outputs to match.

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

encode_for_community_inbox encodes the message as a bare (padded) Content
and blinded-encrypts it -- it never builds an Envelope, so the
sent_timestamp had nowhere to go and was silently ignored. The pro-proof
timestamp travels inside Content.sigTimestamp regardless. Drop the
vestigial parameter from the C++ and C signatures, the C wrapper, and the
two test call sites (and renumber the C NON_NULL_ARG list accordingly).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Most of these are just silenced, but at least a couple make changes to
the API to drop parameters that have no point.

Adds a WARN_UNUSED_PARAMETERS cmake option to turn these on.
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.
… now

The argument must be the current time -- the seed is for the rotation
period containing it -- so `now` states that constraint precisely, where
`timestamp` misleadingly suggested any point would do. The C API param is
renamed unix_ts -> now_unix_ts to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a renewal is already due and now sits right at a weekly rotation
boundary, return now+2min rather than "renew now", so a device cleanly on
one side of the boundary can renew and propagate its config first (and if
none does, we re-poll a couple minutes on, unambiguously past the
boundary). Guarded so the deferred time still leaves >=5min of validity;
otherwise renew now. This and the existing target nudge are best-effort
collision-avoidance only -- a genuine collision still resolves fine via
config last-writer-wins/refetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
is_final is only referenced by the following assert, so under
WARN_UNUSED_PARAMETERS a release (NDEBUG) build -- where the assert is
stripped -- warns it is unused. Mark it [[maybe_unused]] to silence that
while keeping it available to the assert in debug builds. (Parity with
the dev-branch fix prompted by the clients agent's debug-build report.)

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

- rotating_seed doc (C++ and C): "as of now" instead of "contains now";
  drop the epoch-computation narration and the redundant naming rationale.
- pro_renewal_target: promote the boundary defer / min-validity magic
  values to documented constants -- PRO_RENEWAL_BOUNDARY_DEFER (reduced
  2min -> 1min) and PRO_RENEWAL_BOUNDARY_MIN_VALIDITY (5min) -- and reword
  the collision comment to just say a collision is resolved by config
  resolution rather than (incorrectly) describing how config resolves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the C-style utf16_count_truncated_to_codepoints (span in, code-unit
index out) with utf16_truncate(u16string_view) -> u16string_view returning the
truncated prefix directly, and take utf16_count on a u16string_view. Remove the
assert()s on the surrogate state (invalid UTF-16 is documented-but-UB *input*,
not a programming invariant, so asserting on it is wrong) and the now-dead
surrogate helpers. Rename utf8_truncate's `n` to `max_bytes`. Tests use native
u"..." literals instead of a UTF-8->UTF-16 round-trip UDL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The utf8/16 variants forced libsession to validate and codepoint-count the
message text, but every client already counts codepoints natively. Take the
count directly via pro_features_for_message(size_t codepoint_count) and keep
only the policy libsession should own: the character-limit thresholds and the
count->flag mapping.

Drops the simdutf validation/count (and the simdutf include, its only user in
this TU), the redundant codepoint_count output field, and the now-impossible
UTFDecodingError status.

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

Same doc correction as dev: the 7-day rotating_seed quantization is not a
"weekly rotation period" -- rotation cadence is the backend-issued proof expiry
(min of subscription remaining, 30 days), and the 7-day bucket only makes every
device agree on the derived seed.

Also adds a TODO in pro_renewal_target to investigate device-count-aware renewal
jitter (skewed so the first-order statistic is uniform, using PFS's multi-device
account info) so the public renewal-time distribution can't leak the device
count -- the dev branch can't (count unknown there) and accepts the lesser
backend-only leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jagerman and others added 3 commits July 31, 2026 03:00
… signal

pro_renewal_target returned `now` (fetch immediately) for *any* missing or
expired proof, which told a non-Pro account -- and a lapsed one -- to hammer the
backend indefinitely. Split the two cases:
  - no proof at all: fetch only if a purchase is in flight (the prepaid marker),
    else nullopt. An entitled account carries its proof in synced config `s`, so
    genuinely having none means the account isn't Pro.
  - present-but-expired proof: always re-check. The subscription may have
    auto-renewed without this device's knowledge (a stale cached access expiry
    can't be trusted), and on an authoritative not-Pro the client clears the
    credential -- which terminates the loop.

Also corrects a stray "weekly rotation" mischaracterization in a test comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
generate_pro_proof responses now carry account_expiry_ts -- the account's true,
grace-inclusive entitlement end (the same value get_pro_status returns), distinct
from the proof's own clamped <=30d expiry_ts. It rides the response so a proof
fetch also refreshes the client's cached access expiry. Exposed on
GenerateProProofResponse (C++, optional) and session_pro_backend_pro_proof_response
(C, 0 when absent).

Advisory and unsigned: never fed into signature verification -- M is reconstructed
from version/revocation_tag/rotating_pkey/expiry_ts only (pro-wire-protocol.md
§2.2). Required on a successful proof; also read top-level off a
subscription_expired failure so an expired-sub client can refresh its horizon
without a separate get_pro_status; absent on not_subscribed / revoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Pro backend (>= 0.3.0) sizes the padding on its per-account proof-expiry
grid around exactly this 1h renewal lead, so `expiry_ts - PRO_RENEWAL_LEAD`
lands just after the subscription's grace-inclusive true end. Increasing the
lead (renewing earlier than 1h before expiry) would reach the upstream store
before its final chance to report a renewal and get a spurious
subscription_expired, so it now requires a coordinated backend change. Comment
only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jagerman
jagerman force-pushed the refund-requested-config-pfs branch from 0da1f32 to 304dea9 Compare July 31, 2026 06:01
@jagerman
jagerman force-pushed the refund-requested-config-pfs branch from 304dea9 to fdb5826 Compare July 31, 2026 16:12
@jagerman
jagerman merged commit 4b9a59f into session-foundation:pfs Jul 31, 2026
1 check failed
jagerman added a commit that referenced this pull request Aug 1, 2026
Records dev up to 4a47113 as merged, keeping pfs's tree unchanged (-s ours).
Everything in the range is already applied to pfs:

- #104 (refund-requested-config) via #106 refund-requested-config-pfs
- #107 (drop-redeemed-at)        via #108 drop-redeemed-at-pfs
- #110 (rename-rp-to-stf)        via #111 rename-rp-to-stf-pfs
- #109 (fix-blinded-id-validation) forward-ported in the preceding commit

The first three were rewritten for pfs and so have different patch-ids, which
is why `git cherry` still reports their commits as absent; the content was
verified present by comparing the identifiers each PR added or removed across
both branches.
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