From d8a306f1d33924e57d56ddd4eaa2e877b67d9a93 Mon Sep 17 00:00:00 2001 From: spaced4ndy Date: Fri, 16 Oct 2026 18:11:16 +0000 Subject: [PATCH] deploy: a932c8298adc0376f5bd1223834f7465de41e823 --- docs/protocol/badges-rpc.html | 43 ++++++++++++++++++++++++---- docs/protocol/badges-rpc.md | 34 ++++++++++++++++++---- docs/protocol/badges-rpc.schema.json | 9 ++++-- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/docs/protocol/badges-rpc.html b/docs/protocol/badges-rpc.html index 7f0348addf..4709b6a8e1 100644 --- a/docs/protocol/badges-rpc.html +++ b/docs/protocol/badges-rpc.html @@ -899,23 +899,54 @@ window.addEventListener('click',(e)=>{

Commands

-

purchaseBadge and upgradeBadgeSubscription carry badgeRequest, the signer's input (BadgeRequest, Simplex.Chat.Badges): the service signs exactly this content or rejects the command. The proposed badgeExpiry is capped by the funded coverage (sundayAfter, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; badgeExtra is reserved and must be empty. issueBadge carries no badgeRequest: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state.

+

The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the sundayAfter of the period issued (endOfMondayAfter in code), shared by every credential of that week; badgeExtra is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries badgeRequest. upgradeBadgeSubscription still has the field in the schema; that is wrong and it must be dropped when the command is implemented.

@@ -935,7 +966,7 @@ window.addEventListener('click',(e)=>{

balance — lastEntry, the client's last entry, asserting the position and the months it believes it holds.

An assertion that names an entry the service holds is a prefix: the service proceeds and returns what follows it. Otherwise the service heals its own ledger first — provider evidence for charges, time for lapses — proceeds, and returns either the complete history or one opening credit. An opening entry is an absolute restatement: the ledger is reset to the amount it states, without relation to the preceding entry, which also serves a new device and, later, the discarding of old history into a brought-forward balance.

Errors

-

retryAfter marks the transient codes: payment_pending, provider_unavailable, rate_limited. offer_disabled calls for a catalog refresh. code_invalid covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; code_used — all its uses taken by other keys; code_expired — past its redemption deadline. receipt_invalid covers unknown receipts. All other codes are terminal for the attempted command.

+

retryAfter marks the transient codes: payment_pending, provider_unavailable, rate_limited. offer_disabled calls for a catalog refresh. code_invalid covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; code_used — all its uses taken by other keys; code_expired — past its redemption deadline. receipt_invalid covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; test_purchase is named apart from them because the device that bought it already knows; receipt_used — credited to another key; provider_not_configured — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.

diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 8c43ca6b28..249baa5431 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -20,21 +20,43 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. - `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. -- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. +- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. +The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. - `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store. + - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good. + - **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes. + - **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason. + - **Pending**, `payment_pending` — a real purchase the store has not settled. + - **Unreachable**, `provider_unavailable` — the store was not asked or did not answer. + - **Internal**, `internal` — this service's own failure, never the store's answer. + - **App Store.** Apple is verified offline, so it has no "not yet". + - `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked. + - `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction. + - `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. + - `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way. + - `provider_not_configured`: the deployment has no `[apple]` section. + - **Google Play.** Only a purchase record is a verdict. + - `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one. + - `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase. + - `payment_pending`: `purchaseState` pending. + - `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away. + - `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` this service does not know; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase. + - `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. 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: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above). - `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). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). @@ -63,4 +85,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 01581a5bac..f9ca9d4a14 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -95,7 +95,7 @@ "properties": {"jws": {"type": "string"}} }, "google": { - "properties": {"token": {"type": "string"}} + "properties": {"productId": {"type": "string"}, "token": {"type": "string"}} }, "invoice": { "properties": {"invoiceId": {"type": "string"}} @@ -309,7 +309,7 @@ }, "purchaseBadge": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "masterKey": {"ref": "base64url"}, "payment": {"ref": "payment"} }, "optionalProperties": { @@ -321,7 +321,10 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "badgeRequest": { + "ref": "badgeRequest", + "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} + }, "payment": { "ref": "payment", "metadata": {"comment": "store payments only"}