mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 15:48:54 +00:00
implement and document blind signed redemption codes
This commit is contained in:
+6
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user