From b89072a43181bafe55638cf40f69de4732f12b60 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 27 Aug 2026 18:54:45 +0200 Subject: [PATCH] implement and document blind signed redemption codes --- CHANGELOG.md | 10 +- .../src/BadgeService/Service.hs | 97 +++---- .../2026-08-18-in-app-name-purchase-mvp.md | 261 ++++++++++++++++-- simplex-chat.cabal | 25 ++ src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 30 +- src/Simplex/Chat/Names/Codes.hs | 236 ++++++++++++++++ src/Simplex/Chat/Names/Protocol.hs | 8 +- tests/Bots/NamesServiceTests.hs | 50 +++- 9 files changed, 604 insertions(+), 115 deletions(-) create mode 100644 src/Simplex/Chat/Names/Codes.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d931ed7a0..f297fa6f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ In-app name purchase (in development, CLI only). Names themselves already ship - this is about acquiring and managing one without leaving the app, which today needs a wallet, ETH and a browser: - Buy a name with a redemption code: `/name verify-code`, `/name quote`, - `/name buy`. Codes are unguessable random values issued ahead of time, which - the registrar looks up in a table it holds. + `/name buy`. Codes are RSA blind signatures (RFC 9474) verified on the device + against a pinned key, so a code cannot be linked back to whoever was issued it, + and the service is never asked whether one is valid. - Point a name at an address with `/name link contact|channel`, a signed EIP-712 intent the service relays. Each name gets 10 relayed edits, counted by the service - metering is off chain. @@ -18,8 +19,9 @@ needs a wallet, ETH and a browser: - Wallet: one key per name at `m/44'/60'/'/0/` - ordinary BIP-44, so importing the phrase into another wallet reaches the same addresses. - The badge service gains the registrar commands, a readable chain mock with real - minimum commitment age, the code table and signature/nonce checks. There is - still no deployed contract and no store payment. + minimum commitment age, a spent-code ledger keyed on the code's own nullifier, + and signature/nonce checks. There is still no deployed contract and no store + payment. - Transfers, subnames and renewal are not implemented. - Design: docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index d25e3f9894..7aa66d9649 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -34,6 +35,7 @@ import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..)) import Simplex.Chat.Bot (initializeBotAddress') import Simplex.Chat.Controller import Simplex.Chat.Core (sendChatCmd, simplexChatCore) +import qualified Simplex.Chat.Names.Codes as Codes import Simplex.Chat.Names.Protocol import Simplex.Chat.Names.Snrc (Intent (..), RecordKey (..), SnrcDeployment (..), intentDigest, parseRecordKey) import Simplex.Chat.Wallet (parseEthSignature, recoverSigner) @@ -58,8 +60,7 @@ newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO - now <- getCurrentTime - serviceNamesChain <- newTVarIO emptyNamesChain {chainCodes = devCodeTable now} + serviceNamesChain <- newTVarIO emptyNamesChain pure ServiceState {serviceCC, serviceRequestQ, serviceNamesChain} -- | In-memory mock of the name registry chain: committed hashes and registered @@ -68,10 +69,11 @@ data NamesChain = NamesChain { -- | commitment -> when it was published, so minimum commitment age is real chainCommitments :: Map ByteString UTCTime, chainNames :: Map Text NameEntry, - -- | Redemption codes issued ahead of time, by code. Unguessable random - -- values, so holding one is the entitlement — there is nothing to verify, - -- only to look up. - chainCodes :: Map Text CodeEntry, + -- | Spent codes, by nullifier. A blind-signed code is its own nullifier: the + -- issuer never saw it, so recording it stops reuse and reveals nothing about + -- who was issued what. There is no table of codes here — the service cannot + -- enumerate them, which is the point. + chainSpentCodes :: Set ByteString, -- | Answered requests, so a resent request is not executed twice. chainRequests :: Map ByteString NamesResponse, -- | Per-signer nonce, mirroring SimplexResolver: one counter per address, @@ -105,15 +107,8 @@ minNameLength = 6 reservedLabels :: Set Text reservedLabels = S.fromList ["simplex", "support", "admin", "acme"] -data CodeEntry = CodeEntry - { ceMinLength :: Int, - ceYears :: Word32, - ceExpires :: UTCTime, - ceSpent :: Bool - } - emptyNamesChain :: NamesChain -emptyNamesChain = NamesChain M.empty M.empty M.empty M.empty M.empty +emptyNamesChain = NamesChain M.empty M.empty S.empty M.empty M.empty welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -125,41 +120,31 @@ welcomeGetOpts = do putStrLn $ "Service name: " ++ T.unpack serviceName pure opts --- | The pre-issued code table. +-- | Mint a handful of development codes and print them, so the purchase flow is +-- runnable locally without an issuer. -- --- A code is simply an unguessable random value: holding one /is/ the --- entitlement, so there is nothing to verify, only to look up. What a code is --- worth — minimum name length, term, expiry — is a property of its row, not of --- the code itself, so tiers and expiry dates can be changed by reissuing the --- table rather than by shipping anything to clients. +-- The key is fixed, so these are byte-identical on every run and tests can +-- hardcode them. -- --- Codes are fixed here so a local run always prints the same ones and tests can --- hardcode them. A real deployment loads a table it issued out of band. --- --- This is deliberately the simple scheme. It links a code to whoever it was --- issued to, because the issuer holds the table — see the blind-signature work --- for the unlinkable version. -devCodeTable :: UTCTime -> Map Text CodeEntry -devCodeTable now = - M.fromList - [ (c, CodeEntry {ceMinLength = 6, ceYears = 2, ceExpires = addUTCTime (365 * 86400) now, ceSpent = False}) - | c <- devCodes - ] - -devCodes :: [Text] -devCodes = - [ "SMPX-4K2P-7TQW-9XRM", - "SMPX-8H3N-2VBD-6JYK", - "SMPX-5L9C-4WFT-1ZQA", - "SMPX-7R6M-8PGX-3NHV" - ] - +-- This signer works **unblinded**: it picks the nonce and sees the code, because +-- issuer and verifier are the same process here. It exercises the verification +-- path exactly, and it is *not* the issuance model — a production issuer signs a +-- value it cannot read. printCodes :: IO () printCodes = do +#if defined(dev_codes) putStrLn "" - putStrLn " Pre-issued redemption codes (development table):" - mapM_ (\c -> putStrLn $ " " <> T.unpack c) devCodes + putStrLn " !! DEVELOPMENT redemption codes - this build trusts a published key !!" putStrLn "" + forM_ [1 :: Int .. 4] $ \i -> do + r <- Codes.signDevCode ("dev-" <> encodeUtf8 (tshow i)) + case r of + Left e -> putStrLn $ " code " <> show i <> ": FAILED " <> show e + Right c -> putStrLn $ " " <> T.unpack c + putStrLn "" +#else + pure () +#endif badgeService :: BadgeServiceOpts -> ChatConfig -> IO () badgeService opts cfg = do @@ -293,32 +278,24 @@ handleNamesRequest chain NamesRequest {nrVersion, nrRequest} c <- readTVar chain let code = unRedemptionCode nrCode label = T.takeWhile (/= '.') nrName - case M.lookup code (chainCodes c) of - Nothing -> pure $ NRPError NECPaymentRejected (Just "no such code") Nothing - Just e - | ceSpent e -> pure $ NRPError NECCodeSpent Nothing Nothing - | ceExpires e < now -> pure $ NRPError NECCodeExpired Nothing Nothing - | T.length label < ceMinLength e -> - pure $ NRPError NECNameTooShort (Just $ "this code covers names of " <> tshow (ceMinLength e) <> " letters or more") Nothing + case Codes.verifyCode code of + Left e -> pure $ NRPError NECPaymentRejected (Just (Codes.codeErrorText e)) Nothing + Right vc + | S.member (Codes.vcNonce vc) (chainSpentCodes c) -> pure $ NRPError NECCodeSpent Nothing Nothing + | Codes.vcExpires vc < now -> pure $ NRPError NECCodeExpired Nothing Nothing + | T.length label < Codes.vcMinLength vc -> + pure $ NRPError NECNameTooShort (Just $ "this code covers names of " <> tshow (Codes.vcMinLength vc) <> " letters or more") Nothing | otherwise -> case checkGates nrName of Just err -> pure err Nothing -> do - let expiry = addUTCTime (fromIntegral (ceYears e) * 31536000) now + let expiry = addUTCTime (fromIntegral (Codes.vcYears vc) * 31536000) now r <- register c now nrName nrOwner nrLink expiry (encodeUtf8 code) case r of NRPRegistered {} -> do modifyTVar' chain $ \c' -> - c' {chainCodes = M.insert code e {ceSpent = True} (chainCodes c')} + c' {chainSpentCodes = S.insert (Codes.vcNonce vc) (chainSpentCodes c')} pure r _ -> pure r - NRVerifyCode {nrCode} -> atomically $ do - c <- readTVar chain - pure $ case M.lookup (unRedemptionCode nrCode) (chainCodes c) of - Nothing -> NRPError NECPaymentRejected (Just "no such code") Nothing - Just e - | ceSpent e -> NRPError NECCodeSpent Nothing Nothing - | ceExpires e < now -> NRPError NECCodeExpired Nothing Nothing - | otherwise -> NRPCode (fromIntegral (ceMinLength e)) (ceYears e) (ceExpires e) NRResolve {nrName} -> atomically $ do c <- readTVar chain pure $ case M.lookup nrName (chainNames c) of diff --git a/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md b/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md index 5120d58151..4847816e22 100644 --- a/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md +++ b/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md @@ -26,13 +26,14 @@ inclusion verification, anti-grief deposits. one, so the CLI is not bound to a single service. ``` -/name verify-code SMPX-4K2P-7TQW-9XRM +/name verify-code SMPX1-nyxxqAPV...xcym code verified: names of 6 letters or more, 2 years use before 2027-07-01 - a code cannot be replaced + use before 2027-07-01 - a code cannot be replaced /name quote alice alice.simplex - available ($20.00 for 2y) -/name buy alice SMPX-4K2P-7TQW-9XRM simplex:/contact#/abc +/name buy alice SMPX1-nyxxqAPV...xcym simplex:/contact#/abc revealing -> registered owner 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 path m/44'/60'/0'/0/0 @@ -114,7 +115,7 @@ channel, never a prompt — a blocking read would hang every non-terminal client ``` APINameQuote {target, label, years} -> CRNameQuote {label, available, reserved, priceUsdCents, years} -APINameVerifyCode {code} -> CRNameCode {minLength, years, expires, label} +APINameVerifyCode {code} -> CRNameCode {minLength, years, expires} APINameBuy {target, label, code, link_} -> CRNameRegistered {name, owner, path, expiry, txHash} APINameList {target} -> CRNames [{name, points, expiry, editsLeft}] @@ -367,7 +368,7 @@ retries arrive. |---|---|---| | **Wallet** | `newSeed`, `deriveNameKey` / `deriveAtPath`, `accountAddress`, `signIntent`, recovery-phrase import and export. No digest signing is exported. | stealth keys hang off the same profile account; the wallet already holds the one-time-address table's shape. | | **Wallet storage** | `wallet_seeds` (several per device, `backed_up`), `wallet_name_keys` (name → path, provenance), a `k` high-water mark per profile. | raw-key import writes `provenance = 'imported'`; the column exists, the path does not. | -| **Codes** | a table of pre-issued random values held by the registrar, looked up on `verify-code` and `buy`. | blind-signed codes, so the issuer cannot join a buyer to a name — its own branch. | +| **Codes** | `Names.Codes`: pinned-key verification on the device, `SMPX1-` format, dev issuer behind the `dev_codes` flag. | the production key schedule, and the blinding protocol itself, which lives in the web store. | | **Intents** | `Names.Snrc`: namehash, `SetText` type string, `intentDigest`, `signSnrcIntent`. | `TransferName` when transfers land — the type string already changed for stealth. | | **RPC transport** | badges' `APISendServiceRequest` / `APISendServiceResponse`, unchanged. | shared. | | **Service** | registrar dispatch for all nine commands, readable chain, spent-code ledger, signature and nonce checks, edit accounting, real minimum commitment age. | swap the mock for a relayer to a deployed SNRC. | @@ -376,32 +377,157 @@ retries arrive. ## Redemption codes +### The pitch + +Two groups are promised a name of their choosing, above some minimum length. Both +have a reason not to want that name traced back to how they got it. + +**Investors** taking a name as a perk may not want SimpleX to know which name is +theirs: the perk should not double as a register of who invested. (An investor who +registers before the public sale reveals it anyway — their choice to make.) +**Web-store buyers** do not want the name linked to how they paid. Monero protects +that end well, Bitcoin less so, a card not at all — and none of it helps if the +shop simply records "this card bought that name". (IAP buyers accept that +linkability and obtain no codes.) + +A redemption code carries the entitlement from paying to registering. The usual +way to keep those apart is a promise: issue a code, note who got it, undertake not +to look. Here there is nothing to look at. + +**The buyer's story** + +1. Sign in to the web store — with a Wefunder email for a perk, or by paying in + card, BTC or XMR. *The store learns who is asking and what they are owed, which + is what it already knew.* +2. The browser generates a random 32-byte nonce and blinds it. +3. The store signs the blinded value under the key for that tier and expiry cohort + — RFC 9474 blind RSA, the same construction Apple ships on every iPhone since + iOS 16 as Private Access Tokens (Privacy Pass token type 0x0002), where it + replaces CAPTCHAs by proving a device passed a check without identifying it. + *It can count requests per account; it cannot tell two apart by content.* +4. The browser unblinds, leaving a valid signature over a number the store has + never seen. *A bearer token: no account, no identity, nothing tying it to + step 1.* +5. Minutes or months later the code is pasted into the app and a name is + registered. *The registrar checks the signature against a pinned key, reads the + tier and expiry from whichever key matched, and files the nonce so it cannot be + spent twice.* + +Nothing at step 5 can be joined to step 1 — not by us, not by an investor's +employer, not by anyone who later obtains both sets of records, because one of +those sets was never written. + +**The key schedule.** Cohorts are yearly: the first runs to the end of 2027 and +expires at the end of 2028, so even the last buyer gets a clear year. Tiers grow as +registration opens for shorter names, so plan on **six tiers across three years — +about 18 public keys, under 5 KB** compiled into the app and the registrar. They +are generated ahead of time because a cohort opened after an app shipped could not +otherwise be verified by it. + +**What it does not claim.** A code is anonymous *within the set sharing its issuer +key*, so the tier count and cohort length are the privacy parameters, not +administrative details. And it unlinks paying from registering, not registering +from SimpleX: the relayer still submits the transaction. + +### What a code is + A code authorises registering **one name of at least N characters for M years**, and stops working after a fixed date. It names no particular name, and it is bearer: whoever holds it can spend it. -A code is an **unguessable random value issued ahead of time**. Holding one *is* -the entitlement, so there is nothing to verify — the registrar looks it up in a -table it issued: - ``` -SMPX-4K2P-7TQW-9XRM +SMPX1- no padding +payload = nonce(32) ‖ RSA-PSS signature(256) ``` -What a code is worth lives in its row, not in the code, so tiers and expiry dates -change by reissuing the table rather than by shipping anything to clients. The -registrar marks a row spent on redemption, which is what stops a second use. +390 characters in full — pasted or scanned, never typed: -`/name verify-code` asks the registrar what a code is worth before a name is -chosen. That is safe to expose precisely because codes are unguessable: it cannot -be used to hunt for one. +``` +SMPX1-nyxxqAPVThi7YA834pFKxl2IAnsU-TptwEXoG5pzL1CK4b0cVuzmvL-jlPwVA58FzX5aZaCc +k-dBxQ4yxJ_0JpKLdagQwpTBOFz6pSh49IMAIK51LUnGfJJN61bkNZW-8iUPISYYC7fXPIWtJ_2GiT +yJm3gPb29pfEoBmN4BPK6JBdzzWBvYmBaFmlbPuMkR65Yh95U4rhXzACgmHZOUyhBsvnqxPLXBujGL +qHJaJxJthWQiF-YGLPVgvl7Uk448YJuaDFHL8edMW0cWyPO90GejQ8v2aSWmo9LRvkAfWIBOeCYOI9 +b4PqCbBPWfM1Z58RRAtMXiN_nubjYmPIIw0rjPZ-Ex__Y4P5IMK-6tRe8GREtovnczgs1yXEY9xcym +``` -**What this does not do: unlink the purchase from the name.** The issuer holds the -table, so it can join "who was given this code" to "which name it registered". -That is a deliberate simplification for the first release. Removing the link needs -blind signatures — the code becomes a token the issuer signs without seeing, so -there is no table to join against — which is a separate change on its own branch, -not a variation on this one. +(line-wrapped here for the page; the code itself is one unbroken string) + +### Why the message is only a nonce + +A blind issuer signs a value it cannot read, so it cannot check the content. +Anything carried inside the message would therefore be whatever the holder chose +to put there — a holder could grant themselves a ten-year term or an expiry in +the next century. + +So the message carries no claims at all, and **everything the issuer attests is a +property of the key**: minimum name length, term, and expiry. A key exists per +**(tier, expiry cohort)**, and "which key verified this" is the entire meaning of +a code. + +This is also why expiry is a cohort rather than per-buyer: a distinct expiry date +would identify its holder as precisely as a serial number. + +### Does the key set grow without bound? + +No — the **live** set is bounded, because a key retires when its cohort expires. + +``` +live keys = tiers × ceil(code validity / cohort length) + 1 +``` + +Keys past their cohort's expiry (plus a grace window) are dropped from the client, +and R14's spent-code set retires with them, so that is bounded too. + +What grows is the *cumulative* set over the product's life, and it matters for one +reason: **the client verifies against keys compiled into the build**, so a cohort +opened after an app shipped cannot be verified by it. Hence pinning a schedule +rather than a key (see the pitch for the numbers). + +Two consequences worth building deliberately. A build whose schedule has run out +must say **"this code is newer than this build, update the app"** rather than +"invalid code" — indistinguishable to the maths, completely different to the user. +And if the schedule ever proves too rigid, the escape hatch is one long-lived root +key signing cohort keys, with the certificate carried inside the code: exactly one +pinned key forever, at roughly 500 extra bytes per code. Not needed at these +numbers, and noted so it is not rediscovered under pressure. + +### How a code is checked + +**On the device, before anything is sent.** There is deliberately no "is this +code valid" RPC: that would hand the service an oracle for probing codes. The +client tries each pinned key, and the tier comes from whichever one verifies — so +the tier cannot be forged by editing the code. + +The service re-verifies, and separately keeps the **spent-code set**. Two details +there are load-bearing and are pinned by tests: + +- **base64url, not base64.** `+` and `/` do not survive a URL, a path, or form + encoding, and a code that arrives silently wrong is worse than one that fails. +- **The ledger keys on the decoded nonce, never the code string.** base64's final + character carries unused bits, so one code can be written more than one way and + a text-keyed ledger could be bypassed by changing a character. The nonce is also + safer than the signature, since PSS is randomised and one message can yield more + than one valid signature. + +*(Why blind RSA rather than an anonymous credential, and what that would change: +Appendix A.)* + +### Development codes + +Production issuer keys are **deliberately not defined yet**. The format is fixed +so adding them is data rather than code, and a build with none says so plainly. + +A fixed development key lives behind the `dev_codes` cabal flag — a compile-time +flag and not configuration, because a dev verification key in a release build +would be a forgery key for real codes, and no runtime setting should be able to +switch that on. The service mints and prints codes under it at startup so the +whole flow is runnable locally, and the nonces are fixed so the codes are +identical on every run and tests can hardcode them. + +That signer works **unblinded**: it picks the nonce and sees the code, because +issuer and verifier are the same process. It exercises verification exactly, and +it is *not* the issuance model — a production issuer needs the blinding protocol +above. ## Editing records @@ -473,6 +599,93 @@ Each of these is a test, not a claim. - Raw-key import: `provenance` exists in the schema, nothing writes `'imported'`. - The recovery scan walks a fixed set of layouts but has not been tested against a name planted at a foreign one. -- Redemption codes are a lookup table the issuer holds, so a code links the buyer - to the name it bought. Unlinkable blind-signed codes are deferred to their own - branch. +- Production issuer keys are undefined by design; a build with none refuses every + code and must say so rather than reporting one invalid. +- The blinding protocol itself is the web store's, and is not built here. The dev + signer mints unblinded, which exercises verification but is not issuance. + +## Appendix A — Signature schemes considered + +Recorded so the choice is not re-litigated from memory. Facts here were checked +against sources and the vendored code on **2026-08-27**; the draft status in +particular has a shelf life. + +### Why a blind signature at all + +A code must be **one-time**, so the registrar records something unique to stop it +being spent twice. It must also be **publicly verifiable**, because the client +checks it against a pinned key with no issuer secret involved. + +A blind signature satisfies both at once: the issuer never sees the finished +token, so **the token is its own nullifier** — recording it prevents reuse and +reveals nothing about who was issued what. Any non-blind scheme would need a +separate nullifier that the issuer *had* seen, which is precisely the link the +design exists to break. + +### RSA blind signatures — chosen + +RFC 9474 (2023). Publicly verifiable, no concurrency weakness, and the same +construction Apple ships as Private Access Tokens — Privacy Pass token type +0x0002, publicly verifiable RSA blind signatures, in iOS 16 and macOS Ventura, +where it replaces CAPTCHAs by proving a device passed a check without identifying +it. Apple deployed against the draft in 2022; RFC 9474 followed in 2023. + +RFC 9578 (Privacy Pass, 2024) offers exactly two token types, and the other one — +VOPRF, P-384 — is only *privately* verifiable, which fails the pinned-key +requirement. The standard facing this same choice picked RSA. + +Cost: a 256-byte signature, hence a 390-character code, and one keypair per +(tier, expiry cohort). + +### BBS+ with blind issuance and per-verifier pseudonyms — the strongest alternative + +Would give **one issuer key forever**: tier and expiry become signed attributes +rather than key identity, so a cohort invented years later works with an app built +today. That is the real prize — not fewer keys, but no schedule to outrun. Under +blind BBS the holder commits only a secret and the **issuer supplies the visible +attributes**, which lifts the constraint that forces our message to be a bare +nonce. A per-verifier pseudonym gives a nullifier that is stable at one verifier +and unlinkable to issuance. And the code shrinks to roughly 160 characters: an +80-byte signature plus about 38 bytes of attributes. + +**It does not improve anonymity.** The tier must still be disclosed to be +enforced, so the set remains everyone sharing that tier and cohort. One key does +not widen it. A range proof over the attribute — "this credential permits a name +this short", without naming the tier — would, and is another layer again. + +Why not yet: + +- Both pieces are **IRTF drafts, not RFCs**: + `draft-irtf-cfrg-bbs-blind-signatures` and + `draft-irtf-cfrg-bbs-per-verifier-linkability`, each at **-02, September 2025**. +- **The vendored libbbs has neither.** `cbits/libbbs` exports exactly + `bbs_keygen`, `bbs_keygen_full`, `bbs_sk_to_pk`, `bbs_sign`, `bbs_verify`, + `bbs_proof_gen`, `bbs_proof_verify`; nothing matching *blind* or *pseudonym* + appears anywhere in that tree. This is new C, not new Haskell — a different + piece of work from "we already ship BBS+" (which we do, for badges). +- Redemption becomes proof *generation* rather than pasting a signature, and the + proof on the wire is ~300 bytes even when the stored credential is smaller. + +Revisit if tiers proliferate beyond what a schedule can pin, or if codes must fit +a smaller QR. + +### Blind Schnorr — rejected + +64-byte signatures, but broken by the ROS attack under concurrent issuance, and a +batch on the order of a thousand codes is squarely in that regime. **Clause Blind +Schnorr** repairs it and is provably secure, but is unstandardised and +unimplemented here. + +### Blind BLS — rejected + +48-byte signatures, and `blst` is already vendored. But there is no RFC and no +blind-signing binding, and rolling our own blind signature scheme is the one thing +to avoid. + +### The trade nobody can engineer away + +**Blindness is what costs the length.** Drop the requirement that a purchase be +unlinkable from a registration, and a plain Ed25519 signature over +`(tier, expiry, nonce)` is 64 bytes — a ~120-character code, short enough to type. +That is a product decision, not a cryptographic one, and the launch requirements +currently answer it no. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 757685ecbf..8cee943113 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -34,6 +34,16 @@ flag client_postgres manual: True default: False +flag dev_codes + description: + Compile in a fixed development issuer key for redemption codes, and let the + names service mint codes under it. NEVER enable for a release build: a dev + verification key shipped to users is a forgery key for real codes. This is a + compile-time flag and not configuration precisely so that no runtime setting + can switch it on. + manual: True + default: False + library exposed-modules: @@ -44,6 +54,7 @@ library Simplex.Chat.Badges.Service Simplex.Chat.Badges.Types Simplex.Chat.Names + Simplex.Chat.Names.Codes Simplex.Chat.Names.Protocol Simplex.Chat.Names.Snrc Simplex.Chat.Store.Wallets @@ -390,6 +401,8 @@ library default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) build-depends: postgresql-libpq >=0.10.0.0 @@ -440,6 +453,8 @@ executable simplex-badge-service , stm ==2.5.* , time ==1.12.* default-language: Haskell2010 + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) other-modules: BadgeService.Store.Postgres.Migrations @@ -477,6 +492,8 @@ executable simplex-bot , directory ==1.3.* , simplex-chat default-language: Haskell2010 + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) cpp-options: -DdbPostgres @@ -499,6 +516,8 @@ executable simplex-bot-advanced , simplexmq >=6.3 , stm ==2.5.* default-language: Haskell2010 + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) @@ -531,6 +550,8 @@ executable simplex-broadcast-bot , simplexmq >=6.3 , stm ==2.5.* default-language: Haskell2010 + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) @@ -564,6 +585,8 @@ executable simplex-chat , unliftio ==0.2.* , websockets ==0.12.* default-language: Haskell2010 + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) @@ -704,6 +727,8 @@ test-suite simplex-chat-test Directory.Store.Migrate Directory.Util Paths_simplex_chat + if flag(dev_codes) + cpp-options: -Ddev_codes if flag(client_postgres) other-modules: ChatTests.DBUtils.Postgres diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 44b9b82279..9731b6a2c2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -416,7 +416,7 @@ data ChatCommand | APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object} | APINameRegister {sendTarget :: ConnectTarget 'CMContact, regName :: Text, registerLink :: Text} | APINameQuote {sendTarget :: ConnectTarget 'CMContact, nameLabel :: Text, nameYears :: Word32} - | APINameVerifyCode {sendTarget :: ConnectTarget 'CMContact, nameCode :: Text} + | APINameVerifyCode {nameCode :: Text} | APINameBuy {sendTarget :: ConnectTarget 'CMContact, nameLabel :: Text, nameCode :: Text, nameLink_ :: Maybe Text} | APINameList {sendTarget :: ConnectTarget 'CMContact} | APINameInfo {sendTarget :: ConnectTarget 'CMContact, regName :: Text} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 556f731b8f..2aecb2299c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -62,6 +62,7 @@ import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) +import qualified Simplex.Chat.Names.Codes as Codes import Simplex.Chat.Names.Protocol import Simplex.Chat.Names.Snrc (Intent (..), SnrcDeployment (..), intent712, parseRecordKey) import Simplex.Messaging.Eth.Address (Address, mkAddress) @@ -1475,18 +1476,25 @@ processChatCommand cxt nm = \case NRPQuote {nrLabel, nrAvailable, nrReserved, nrPriceUsdCents, nrYears} -> pure $ CRNameQuote user nrLabel nrAvailable nrReserved nrPriceUsdCents nrYears _ -> throwCmdError "unexpected quote response" - -- Asks the registrar, which holds the table. Safe to expose because codes are - -- unguessable random values, so this cannot be used to probe for one. - APINameVerifyCode sendTarget code -> withUser $ \user -> do - cReq <- resolveServiceTarget nm user sendTarget - namesRPC user cReq (NRVerifyCode (RedemptionCode code)) >>= \case - NRPCode {nrMinLength, nrYears, nrExpires} -> - pure $ CRNameCode user (fromIntegral nrMinLength) nrYears nrExpires - _ -> throwCmdError "unexpected verify-code response" + -- Verified on the device against a key compiled into this build. There is now + -- no "check this code" RPC, and that is the point: asking the service would + -- hand it an oracle for probing codes, and a blind-signed code needs no one's + -- permission to be checked. + APINameVerifyCode code -> withUser $ \user -> + case Codes.verifyCode code of + Left e -> throwChatError $ CENameRegistrationFailed "payment_rejected" (Just $ Codes.codeErrorText e) Nothing + Right vc -> pure $ CRNameCode user (Codes.vcMinLength vc) (Codes.vcYears vc) (Codes.vcExpires vc) APINameBuy sendTarget label code link_ -> withUser $ \user -> do - -- The registrar owns the code table, so it decides: spent, expired, or too - -- short for the tier. The client does not second-guess it. + -- Refuse before spending anything if the code is not real. The service + -- re-checks everything; doing it here saves a round trip and, more usefully, + -- names the reason. + vc <- either (\e -> throwChatError $ CENameRegistrationFailed "payment_rejected" (Just $ Codes.codeErrorText e) Nothing) pure $ Codes.verifyCode code let nm' = label <> ".simplex" + now0 <- liftIO getCurrentTime + when (Codes.vcExpires vc < now0) $ + throwChatError $ CENameRegistrationFailed "code_expired" (Just $ "this code expired on " <> tshow (Codes.vcExpires vc)) Nothing + when (T.length label < Codes.vcMinLength vc) $ + throwChatError $ CENameRegistrationFailed "name_too_short" (Just $ "this code covers names of " <> tshow (Codes.vcMinLength vc) <> " letters or more") Nothing (seedId, nameIx, acctIx, owner) <- deriveNameOwner user cReq <- resolveServiceTarget nm user sendTarget g <- asks random @@ -5797,7 +5805,7 @@ chatCommandP = -- (/users vs /user, /db export) "/names " *> (APINameList <$> strP), "/name quote " *> (APINameQuote <$> strP <* A.space <*> displayNameP <*> (A.space *> A.decimal <|> pure 2)), - "/name verify-code " *> (APINameVerifyCode <$> strP <* A.space <*> textP), + "/name verify-code " *> (APINameVerifyCode <$> textP), "/name buy " *> (APINameBuy <$> strP <* A.space <*> displayNameP <* A.space <*> nonSpaceTextP <*> optional (A.space *> textP)), "/name info " *> (APINameInfo <$> strP <* A.space <*> displayNameP), -- syntax is "/name link ", but the constructor diff --git a/src/Simplex/Chat/Names/Codes.hs b/src/Simplex/Chat/Names/Codes.hs new file mode 100644 index 0000000000..ffd644449d --- /dev/null +++ b/src/Simplex/Chat/Names/Codes.hs @@ -0,0 +1,236 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Redemption codes: what they look like on the wire, and how a client decides +-- one is real without asking anybody. +-- +-- A code authorises registering a name of at least @minLength@ characters for +-- @years@ years, and carries an expiry fixed at issuance. It is an RSA blind +-- signature (RFC 9474), so the issuer never saw the finished token — which is +-- what lets the service record a spent code without learning who was issued it. +-- The token is therefore its own nullifier. +-- +-- The client verifies **offline, against a pinned public key**: there is no +-- \"check this code\" RPC, because that would hand the service an oracle for +-- probing codes. The tier is not carried in the code — it comes from whichever +-- pinned key verifies, so it cannot lie. +module Simplex.Chat.Names.Codes + ( PinnedIssuerKey (..), + VerifiedCode (..), + CodeError (..), + codeErrorText, + issuerKeys, + verifyCode, + encodeCode, + codePrefix, +#if defined(dev_codes) + devIssuerKey, + devIssuerPrivate, + signDevCode, +#endif + ) +where + +import qualified Crypto.Hash.Algorithms as H +import qualified Crypto.PubKey.RSA as RSA +import qualified Crypto.PubKey.RSA.PSS as PSS +import qualified Data.ByteArray.Encoding as BAE +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time.Clock (UTCTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Word (Word32) + +-- | A published verification key. The tier is data on the entry, not a lookup +-- elsewhere, so adding a production key later is a patch to one list. +-- | A published verification key. +-- +-- Everything the issuer attests lives here, not in the code: under blind +-- issuance the issuer signs a value it cannot read, so it cannot check the +-- content, and any attribute carried inside the message would be one the +-- /holder/ chose. Tier and expiry are therefore properties of the key, and a +-- key is issued per (tier, expiry cohort). +data PinnedIssuerKey = PinnedIssuerKey + { pikLabel :: Text, + pikKey :: RSA.PublicKey, + pikMinLength :: Int, + pikYears :: Word32, + -- | When codes signed by this key stop working. A cohort, never per-holder: + -- a distinct expiry per buyer would identify them as surely as a serial + -- number. + pikExpires :: UTCTime + } + +data VerifiedCode = VerifiedCode + { vcMinLength :: Int, + vcYears :: Word32, + vcExpires :: UTCTime, + vcLabel :: Text, + -- | The nullifier: what a service records to stop the code being spent + -- twice. + -- + -- Recording it does not make the code linkable to whoever was issued it. + -- Under RFC 9474 the /holder/ chooses this value and blinds it; the issuer + -- signs something it cannot read and never learns the nonce, so it has + -- nothing to match a redemption against. + -- + -- Deliberately the decoded nonce and not the code string — base64's final + -- character carries unused bits, so two different strings decode to the same + -- payload and a ledger keyed on the text could be bypassed by changing one + -- character. It is also safer than keying on the signature: PSS is + -- randomised, so one message can yield more than one valid signature. + vcNonce :: ByteString + } + deriving (Eq, Show) + +data CodeError + = CEBadPrefix + | CEBadEncoding + | CEBadLength + | CENoIssuerKeys + | CENotVerified + deriving (Eq, Show) + +codeErrorText :: CodeError -> Text +codeErrorText = \case + CEBadPrefix -> "not a redemption code (expected " <> decodeLatin1 codePrefix <> "…)" + CEBadEncoding -> "code is not valid base64url" + CEBadLength -> "code is the wrong length" + CENoIssuerKeys -> "no issuer keys are configured in this build, so no code can be accepted" + CENotVerified -> "code did not verify under any known issuer key" + +codePrefix :: ByteString +codePrefix = "SMPX1-" + +-- | The signed message is nothing but a random nonce the holder generates and +-- blinds. There is nothing else to put in it: the issuer cannot read what it +-- signs, so anything here would be holder-controlled. The scheme version lives +-- in the code's prefix, and everything else comes from the key. +msgLen, sigLen :: Int +msgLen = 32 +sigLen = 256 + +-- | RFC 9474 RSABSSA-SHA384-PSS: a blind-signed token verifies as an ordinary +-- RSA-PSS signature, which is why the client needs no blinding machinery. +pssParams :: PSS.PSSParams H.SHA384 ByteString ByteString +pssParams = PSS.defaultPSSParams H.SHA384 + +-- | Production keys are **deliberately undefined**: the format is fixed so that +-- adding them later is data, not code. Until then a release build can verify no +-- code at all, which is correct while nothing ships — and must say so plainly +-- rather than reporting an invalid code. +-- +-- This is a /schedule/, not a key. Cohort keys are generated and published ahead +-- of time and the whole horizon is compiled in, because a cohort opened after an +-- app shipped could not otherwise be verified by it. The live set stays bounded — +-- a key retires when its cohort expires — but the schedule has to outrun the +-- update cycle. A build whose schedule has run out should say so, not report an +-- invalid code; the two are indistinguishable to the maths and completely +-- different to the user. +productionIssuerKeys :: [PinnedIssuerKey] +productionIssuerKeys = [] + +issuerKeys :: [PinnedIssuerKey] +issuerKeys = productionIssuerKeys +#if defined(dev_codes) + <> [devIssuerKey] +#endif + +encodeCode :: ByteString -> Text +encodeCode payload = decodeLatin1 codePrefix <> decodeLatin1 (b64u payload) + +b64u :: ByteString -> ByteString +b64u = B.filter (/= 61) . BAE.convertToBase BAE.Base64URLUnpadded + +verifyCode :: Text -> Either CodeError VerifiedCode +verifyCode code = do + let raw = encodeUtf8 code + body <- + maybe (Left CEBadPrefix) Right $ + B.stripPrefix codePrefix raw + payload <- either (const $ Left CEBadEncoding) Right (BAE.convertFromBase BAE.Base64URLUnpadded body :: Either String ByteString) + if B.length payload /= msgLen + sigLen then Left CEBadLength else Right () + let (msg, sig) = B.splitAt msgLen payload + if null issuerKeys then Left CENoIssuerKeys else Right () + case filter (\k -> PSS.verify pssParams (pikKey k) msg sig) issuerKeys of + [] -> Left CENotVerified + (k : _) -> + Right + VerifiedCode + { vcMinLength = pikMinLength k, + vcYears = pikYears k, + vcExpires = pikExpires k, + vcLabel = pikLabel k, + vcNonce = msg + } + +#if defined(dev_codes) +-- | A fixed development keypair, compiled in only under the @dev_codes@ flag. +-- +-- Fixed rather than generated so the codes a service prints are identical on +-- every run and tests can hardcode them. It is behind a **compile-time** flag +-- and not configuration on purpose: a dev verification key present in a release +-- build would be a forgery key for real codes, and no runtime setting should be +-- able to switch that on. +devIssuerKey :: PinnedIssuerKey +devIssuerKey = + PinnedIssuerKey + { pikLabel = "dev: 6+ letters, 2 years", + pikKey = RSA.PublicKey {RSA.public_size = 256, RSA.public_n = devN, RSA.public_e = 65537}, + pikMinLength = 6, + pikYears = 2, + pikExpires = devExpiry + } + +-- | One cohort expiry for every dev code, mirroring how a real batch works. +devExpiry :: UTCTime +devExpiry = posixSecondsToUTCTime 1814400000 -- 2027-07-01 + +devIssuerPrivate :: RSA.PrivateKey +devIssuerPrivate = + RSA.PrivateKey + { RSA.private_pub = RSA.PublicKey {RSA.public_size = 256, RSA.public_n = devN, RSA.public_e = 65537}, + RSA.private_d = devD, + RSA.private_p = 0, + RSA.private_q = 0, + RSA.private_dP = 0, + RSA.private_dQ = 0, + RSA.private_qinv = 0 + } + +devN :: Integer +devN = 24888112474209384241822313289174761916432671221638604649518925371588661023857211328354011559848717830344726834019769246154626947666703992055350752697517435418048419727601941633666940975633179816697744805524303719572333648883192553542938468476238864154888444432850134036529175415793321307984568003216958478506050686177368523811693515246798542977965199174188430024788691021144684872571714513836211557103158962740151489815774309113725042504550173516616821401055947805896340622659904171191347695460979153885704616386392128700095744751859356877279249104214401084821283607493149034883118538479930388213794952102331084309127 + +devD :: Integer +devD = 3358567934478352598298312994636031469077475994997815272599377084491663000976443489753313063388651608886106537071743278041282340130984483661710515538655174921604898363838924146789606268039425703020810459130825977629395895306818361284980206832840327060833321674231755884753101719292554337974205707012081431617326396158749434571441235491401247111550429755050472715446167871053180426390130486145135280511231007956406465731980033447070053515891501760810730497736305620422623652969543717287523710529676336869267877915732532425513343594169679516878896078562472320280123731386736868075945531041237029017927929994367890859745 + +-- | Mint a development code by signing it directly, unblinded. +-- +-- This exercises the /verification/ path exactly: an RFC 9474 blind-signed token +-- verifies as an ordinary RSA-PSS signature, so what the client does with a code +-- from here is byte-identical to what it does with a real one. +-- +-- __It is not the production issuance model, and must not be copied as one.__ +-- Here the issuer picks the nonce and sees the finished code, so it could match +-- a redemption back to whoever it issued to. Real issuance is the other way +-- round: the /holder/ generates the message, blinds it, and the issuer signs a +-- value it cannot read (RFC 9474 §5). That is what makes recording the nonce at +-- redemption safe — the issuer has never seen it, so the nullifier links to +-- nothing. +-- +-- A production issuer therefore needs the blinding protocol; this function is a +-- local shortcut for a build that already trusts a published key. +-- | @nonce@ stands in for the value a holder would generate and blind. Passing +-- it in keeps dev codes deterministic: the same nonce always yields the same +-- code, so tests hardcode them instead of scraping stdout. +signDevCode :: ByteString -> IO (Either RSA.Error Text) +signDevCode nonce = do + let msg = B.take msgLen (nonce <> B.replicate msgLen 0) + fmap (\sig -> encodeCode (msg <> sig)) <$> PSS.signSafer pssParams devIssuerPrivate msg + +#endif diff --git a/src/Simplex/Chat/Names/Protocol.hs b/src/Simplex/Chat/Names/Protocol.hs index 97cda9aab6..d005941f3a 100644 --- a/src/Simplex/Chat/Names/Protocol.hs +++ b/src/Simplex/Chat/Names/Protocol.hs @@ -144,8 +144,8 @@ instance ToJSON RequestId where instance FromJSON RequestId where parseJSON = strParseJSON "RequestId" --- | A redemption code: an unguessable random value issued ahead of time and --- looked up by the registrar. Opaque to the client, which does not verify it. +-- | A redemption code as it travels: the full @SMPX1-...@ string. Opaque here; +-- "Simplex.Chat.Names.Codes" is what verifies it, on the device. newtype RedemptionCode = RedemptionCode {unRedemptionCode :: Text} deriving (Eq, Show) deriving newtype (ToJSON, FromJSON) @@ -188,9 +188,6 @@ data NamesCommand nrCode :: RedemptionCode, nrLink :: Text } - | -- | Ask the registrar what a code is worth. Safe to expose: codes are - -- unguessable random values, so this is not a probing oracle. - NRVerifyCode {nrCode :: RedemptionCode} | NRResolve {nrName :: Text} | NROwnedBy {nrAddress :: Address} | NRNonce {nrAddress :: Address} @@ -225,7 +222,6 @@ data NamesResponse nrExpiry :: UTCTime, nrEditsLeft :: Word32 } - | NRPCode {nrMinLength :: Word32, nrYears :: Word32, nrExpires :: UTCTime} | NRPNames {nrNames :: [Text]} | NRPNonce {nrNonce :: Integer} | NRPRelayed {nrTxHash :: TxHash} diff --git a/tests/Bots/NamesServiceTests.hs b/tests/Bots/NamesServiceTests.hs index d037086957..a5c4202f0f 100644 --- a/tests/Bots/NamesServiceTests.hs +++ b/tests/Bots/NamesServiceTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} module Bots.NamesServiceTests where @@ -17,6 +18,7 @@ import Simplex.Messaging.Eth.Address (parseAddress) import Simplex.Messaging.Eth.Keccak (keccak256) import Test.Hspec hiding (it) import qualified Data.Text as T +import qualified Simplex.Chat.Names.Codes as Codes import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Word (Word32) @@ -31,13 +33,41 @@ namesServiceTests = do it "rejects a reveal with no matching commitment" testRevealWithoutCommit it "reads the wallet without creating one" testNameAddress it "gives each name its own key, still derivable after restart" testSeedPersists +#if defined(dev_codes) it "buys with a code, then re-points the link with a signature" testBuyAndLink it "refuses a spent code, a reserved name and a short name" testBuyRefusals +#endif -- | Pins the wire format. The end-to-end test cannot catch a key renamed on -- both sides at once, so the encodings are asserted literally here. namesProtocolTests :: Spec namesProtocolTests = do +#if defined(dev_codes) + -- The dev issuer signs unblinded, which is indistinguishable to the verifier: + -- a blind-signed token verifies as an ordinary RSA-PSS signature. So this + -- exercises the production verification path end to end. + it "verifies a dev redemption code, and rejects tampering" $ \_ -> do + code <- either (error . show) id <$> Codes.signDevCode "test-nonce" + case Codes.verifyCode code of + Left e -> expectationFailure $ "did not verify: " <> show e + Right vc -> do + -- tier and expiry come from the key, never from the code: a blind issuer + -- cannot check what it signs, so anything inside the message would be + -- whatever the holder put there + Codes.vcMinLength vc `shouldBe` 6 + Codes.vcYears vc `shouldBe` 2 + Codes.vcExpires vc `shouldBe` Codes.pikExpires Codes.devIssuerKey + -- a flipped character mid-payload must not verify. Not the last character: + -- base64's final character carries unused bits, so changing it can decode to + -- the same bytes - which is also why the spent-code ledger keys on the + -- decoded nonce and not on the code string. + let (a, b) = T.splitAt 40 code + bad = a <> (if T.head b == 'A' then "B" else "A") <> T.tail b + Codes.verifyCode bad `shouldBe` Left Codes.CENotVerified + -- and something that is not a code at all fails on the prefix, not the maths + Codes.verifyCode "hello" `shouldBe` Left Codes.CEBadPrefix +#endif + -- Name keys are plain BIP-44, so they line up with wallets users already have. -- Pinned against the standard test mnemonic: profile 0's names are exactly -- MetaMask's account list (m/44'/60'/0'/0/k), and each profile's first name is @@ -202,13 +232,14 @@ ownerOf client nm = go (40 :: Int) Nothing False False let addr' = if pfx `isPrefixOf` l then Just (takeWhile (/= ' ') $ drop (length pfx) l) else addr go (n - 1) addr' (seen || l == lastEvt) (path || pathLine `isPrefixOf` l) +#if defined(dev_codes) -- | The whole purchase path: verify a code on the device, buy, then change the -- link with a signed intent the service verifies by recovering the signer. testBuyAndLink :: HasCallStack => TestParams -> IO () testBuyAndLink ps = withBadgeService ps $ \client bsLink -> do - let code = devCode 1 - client ##> ("/name verify-code " <> bsLink <> " " <> T.unpack code) + code <- devCode 1 + client ##> ("/name verify-code " <> T.unpack code) client <## "code verified: names of 6 letters or more, 2 years" client <##. " use before " client ##> ("/name buy " <> bsLink <> " purchased " <> T.unpack code <> " simplex:/contact#/first") @@ -241,11 +272,10 @@ testBuyAndLink ps = testBuyRefusals :: HasCallStack => TestParams -> IO () testBuyRefusals ps = withBadgeService ps $ \client bsLink -> do - let code1 = devCode 1 - code2 = devCode 2 - -- the registrar owns the code table, so every refusal now comes from it + code1 <- devCode 1 + code2 <- devCode 2 + -- shorter than the code's minimum, refused on the device before any RPC client ##> ("/name buy " <> bsLink <> " abc " <> T.unpack code1 <> " simplex:/contact#/x") - client <## "name abc.simplex: revealing" client <##. "name registration failed: name_too_short" -- reserved -- long enough for the code, so this one reaches the service before failing @@ -274,6 +304,8 @@ testBuyRefusals ps = -- unit test over verifyCode instead. --- | The service's pre-issued table, mirrored so tests can name a code. -devCode :: Int -> Text -devCode i = ["SMPX-4K2P-7TQW-9XRM", "SMPX-8H3N-2VBD-6JYK", "SMPX-5L9C-4WFT-1ZQA", "SMPX-7R6M-8PGX-3NHV"] !! (i - 1) +-- Deterministic: the same nonce always yields the same code, which is why the +-- nonce is an argument rather than random. +devCode :: Int -> IO Text +devCode i = either (error . show) id <$> Codes.signDevCode ("dev-" <> encodeUtf8 (tshow i)) +#endif