implement and document blind signed redemption codes

This commit is contained in:
Alain Brenzikofer
2026-08-27 18:54:45 +02:00
parent 977ffbc3e1
commit b89072a431
9 changed files with 604 additions and 115 deletions
+6 -4
View File
@@ -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'/<profile>'/0/<name>` - 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
@@ -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
+237 -24
View File
@@ -26,13 +26,14 @@ inclusion verification, anti-grief deposits.
one, so the CLI is not bound to a single service.
```
/name verify-code <svc> 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 <svc> alice alice.simplex - available ($20.00 for 2y)
/name buy <svc> alice SMPX-4K2P-7TQW-9XRM simplex:/contact#/abc
/name buy <svc> 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-<base64url(payload)> 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.
+25
View File
@@ -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
+1 -1
View File
@@ -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}
+19 -11
View File
@@ -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 <record> <name> <link>", but the constructor
+236
View File
@@ -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
+2 -6
View File
@@ -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}
+41 -9
View File
@@ -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