diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 081214cf89..9d82529921 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -15,7 +15,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) 8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) 9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search) -10. [Badges: Restoring a Subscription on a New Device or Profile](#gap-10-badges-restoring-a-subscription-on-a-new-device-or-profile) +10. [Badges: Limits on Moving a Badge to a New Device or Profile](#gap-10-badges-limits-on-moving-a-badge-to-a-new-device-or-profile) --- @@ -353,9 +353,9 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag --- -## GAP-10: Badges: Restoring a Subscription on a New Device or Profile +## GAP-10: Badges: Limits on Moving a Badge to a New Device or Profile -**Severity:** Medium +**Severity:** Low **Category:** Feature / Payments **Platforms:** Android diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md index 1ae7dc4fa6..12557507e3 100644 --- a/docs/protocol/badges-overview.md +++ b/docs/protocol/badges-overview.md @@ -1,4 +1,4 @@ -Revision 1, 2026-09-18 +Revision 2, 2026-10-22 # SimpleX Supporter Badges @@ -53,6 +53,8 @@ Two other uses are planned and described under [Future work](#future-work): The badge service holds the purchase key, the number of months bought, and the payment record of the purchase. A card payment is linked to the purchase, as in any other service; a user who wants the payment itself to be private may pay with cryptocurrency. In either case the record is held by the service alone, and the service does not learn where the badge is later shown or used, because a zero-knowledge proof contains nothing that refers back to the credential or the purchase. +An app store subscription's record is its store transaction, which renewals keep, and the charges the store reports for it. When Restore moves a subscription to another device or profile, the new purchase key presents the same store transaction as the old one, so the service links the two purchase keys, though not the profiles that hold them. + A party that verifies a proof learns the badge type and the expiry date. Since all credentials expiring in the same week have the same expiry date, these two values place the holder among the supporters whose badges expire that week. ### Comparison with Signal badges @@ -92,7 +94,7 @@ In SimpleX: - A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them. - A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it. -- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. +- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. The exception is an app store subscription, which Restore moves to a new purchase key of whichever profile presents its store receipt, and moves again only once the last credential of the key it last moved from has expired. - A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it. - A badge cannot be revoked; credentials are issued for one month at a time instead. @@ -147,7 +149,7 @@ In both cases the app generates the master key and the purchase key pair before ### Monthly issuance -A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. Unused months, e.g. if the app was offline, lapse at the next badge issuance. +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store whether it charged again once the paid months have run out, or at once when the app reports a higher tier. Unused months, e.g. if the app was offline, lapse at the next badge issuance. Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. @@ -202,8 +204,8 @@ Service requests are protected by the double ratchet of the [SimpleX agent](http 1. A proof discloses no value that links it to another proof or to the purchase. 2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. -3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. -4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code, the codes issued in the service's managed group or redeemed by its members, and a store subscription moved to a new key by Restore, which the service links to the key it moved from as the store account already links both, and a request about a purchase can be made only by the holder of the purchase key. +3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. A new badge or tier — after a purchase, a redemption, a Restore, a store subscription resumed after it lapsed, or a tier raised in the store — is shown as soon as its credential is issued, so for these the request and the profile update are made together. +4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code, the codes issued in the service's managed group or redeemed by its members, and a store subscription moved to a new key by Restore, which the service links to the key it moved from as the same store transaction funds both, and a request about a purchase can be made only by the holder of the purchase key. 5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. 6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. @@ -219,6 +221,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Issue any credential, or refuse to issue one, as it holds the issuer key. - Link a code issued in its managed group to the member who asked for it and to that group, as the group's messages, codes included, are kept in the service's chat database. - Link a redemption to a member of its managed group whose profile shows a new badge soon after it. +- Link the two purchase keys between which Restore moved a store subscription. *cannot:* @@ -289,6 +292,10 @@ An attacker who obtains the credential and the purchase key can use the badge un A code is a bearer secret until its last use is taken; after that, it is refused to any other purchase key. +**Interception of a store receipt** + +Restore moves a store subscription to any new purchase key that presents its store receipt, within the limit on moves, so whoever obtains the receipt can take the badge. The credential already issued on the device it left stays valid until it expires, and then Restore there can move it back. An App Store receipt stops moving the subscription once the store charges again, as only the subscription's latest transaction moves it. + **A passive network observer** *can:* @@ -304,4 +311,4 @@ A code is a bearer secret until its last use is taken; after that, it is refused - Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under [What a badge grants](#what-a-badge-grants). - SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas. -- Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned. +- The transfer of a badge bought by card or cryptocurrency to a new device, and the pausing of a prepaid badge, are planned. diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 90a19474db..0d4db36119 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -10,9 +10,9 @@ A request is an envelope: `version` — the client's protocol version; `purchase ## Identity -Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`. +Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below and a store subscription moved by `transferBadge`, whose old purchase's ledger names the new key. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`. -A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `receipt`. Those commands accept a key the service holds no record of — on a first purchase it always will. Every other command answers `unknown_purchase_key` for such a key. +A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, by `purchaseBadge` funded with `apple`, `google`, or `receipt`, or by `transferBadge`. Those commands accept a key the service holds no record of — on a first purchase it always will, and `transferBadge` requires one. Every other command answers `unknown_purchase_key` for such a key. ## Idempotency @@ -59,20 +59,20 @@ The client never states what is signed. The tier is that of whatever funded the - `product_unavailable` and `provider_not_configured` as for the App Store, the latter without a `[google]` section. - Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed; a subscription is acknowledged with `purchases.subscriptions.acknowledge` before its first charge is credited. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. -- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: it carries `payment`, an App Store transaction of the purchase's own subscription after the change, and `balance`, and makes the service ask the store now rather than at the next renewal. It is refused before the store is asked when the transaction does not verify (`receipt_invalid`), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (`bad_request`), and for an unknown key (`unknown_purchase_key`). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is `payment_pending` with a `retryAfter` to the next allowed check, never the old tier's credential. The tier granted is the store's; the transaction only names the subscription and the tier to wait for. -- `transferBadge` → `badgeCredential` — Restore on another device or profile: it carries `masterKey` and `payment`, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (`bad_request`). The store is asked first, at most once an hour per subscription as for `purchaseBadge`, and an App Store transaction must be the subscription's latest, so an older receipt stops moving it once the store charges again (`payment_pending` until then). The old purchase is lapsed and debited `transferOut {toPurchaseKey}` of its remaining months, even none, and is answered `subscription = ended` with no `credential` from then on; the new purchase is credited the store's current charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (`transfer_limited`): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by `purchaseBadge`. A one-time purchase is not moved: its months were issued at purchase. +- `upgradeBadgeSubscription` → `badgeCredential` — an App Store subscription raised to a higher tier, on the same key. The client sends it when the device sees a transaction of the purchase's own subscription for a higher tier, bought within a day of the device's clock (see Upgrades); it carries `payment`, that transaction, and `balance`, and makes the service ask the store now rather than at the next renewal check. It is refused before the store is asked when the transaction does not verify (`receipt_invalid`), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (`bad_request`), and for an unknown key (`unknown_purchase_key`). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is `payment_pending` with a `retryAfter` to the next allowed check, never the old tier's credential. The tier granted is the store's; the transaction only names the subscription and the tier to wait for, so a lower tier sent here is granted only once the store charges it, at its renewal. +- `transferBadge` → `badgeCredential` — Restore on another device or profile: it carries `masterKey` and `payment`, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (`bad_request`). The store is asked first, at most once an hour per subscription as for `purchaseBadge`, and an App Store transaction other than the subscription's latest is answered `payment_pending`, so an older receipt stops moving it once the store charges again. The store's current charge must be one a first purchase could be credited, and is answered as for a first purchase when it is not. The old purchase is lapsed and debited `transferOut {toPurchaseKey}` of its remaining months, even none, and is answered `subscription = ended` with no `credential` from then on; the new purchase is credited that charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (`transfer_limited`): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by `purchaseBadge`. A one-time purchase is not moved (`receipt_used`): its months were issued at purchase. - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - **Subscriptions.** While a store subscription's paid coverage runs, `issueBadge` is answered as above, without asking the store. Once it has ended, the service asks the store, at most once an hour per subscription whether or not the store answers, and answers requests in between from what it last read. A charge it can grant, by the rule `purchaseBadge` applies to a first charge but never twice, is granted at the store's period start, or at the ledger's last row when that is later, so rows are never dated before the row they follow, after a lapse of what ran out before it, and the month due is issued. A charge of another badge type is granted as that type: what remains of the old run is debited as `upgrade` naming the purchase's own key, the new run starts at that date, and the purchase's badge type follows it. Otherwise no ledger row is written: the charge, if any, and the state are recorded, and the answer carries no new `credential` and the `subscription` the store reported. A store that does not answer is answered `provider_unavailable` with a `retryAfter` of the hour. Every answer for a subscription purchase carries `subscription`. A development deployment may ask its store at every request, during coverage too, and bank the months of a charge it can grant, issued once coverage reaches them; a real store is asked during coverage only by a development deployment that also credits test purchases. - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). ## Upgrades -Always a new purchase under a new key, except store subscriptions, where the store owns the change. +Always a new purchase under a new key, except App Store subscriptions, where the store owns the change and the purchase keeps its key. -- Non-store: `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. -- Store one-time: an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. -- Store subscription, app-led: the native subscription-group flow (Apple — immediate, with the store's prorated refund; Google — per replacement mode), then `upgradeBadgeSubscription` with the new evidence. -- Store subscription, sheet-led, and every downgrade: the client sends nothing — the service discovers the change from provider state and notifications, and grants the badge type the store charged at its next check after coverage ends. +- Non-store (not built): `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. +- Store one-time (not built: `purchaseBadge` with `upgrade` is answered `bad_request`): an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. +- App Store subscription: the client never starts a change; the user makes it in Apple's manage-subscriptions sheet, and it keeps the original transaction id, as all badge subscriptions share one subscription group. A higher tier, which Apple charges at once with a prorated refund, is sent as `upgradeBadgeSubscription` when the device sees its transaction within a day of its purchase date, and is granted as soon as the store reports the charge. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier the device sees later — the client sends nothing: the service reads the change from the store and grants the badge type the store charged at its next check after coverage ends. +- Play subscription: a plan change gets a new purchase token from Play, so it arrives as a new purchase under a fresh key, credited as any first purchase is, and the old token is granted nothing more. ## Catalog