mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 17:17:39 +00:00
store: merge prepaid name codes
This commit is contained in:
commit
29ec635cdf
3 files changed
+266
-65
No files matched your search
+122
-40
@@ -6,19 +6,24 @@
|
||||
-- A code is "SB-" and 20 Crockford base32 characters in four groups of five:
|
||||
-- 19 payload characters and a final check character.
|
||||
--
|
||||
-- A name code is "SN-" and 30 characters in six groups of five: the 128-bit wallet
|
||||
-- entropy and the first 22 bits of its SHA-256.
|
||||
-- A name code is one of two kinds, told apart by the length of the text after "SN":
|
||||
-- - an owner code, "SN-" and 30 characters in six groups of five: the 128-bit wallet
|
||||
-- entropy and the first 22 bits of its SHA-256;
|
||||
-- - a prepaid code, "SN", the shortest name length it pays for in one or two ASCII digits
|
||||
-- (6 to 63, no leading zero), then 20 characters in four groups of five: 19 payload
|
||||
-- characters and a check character over the length digits and the payload.
|
||||
--
|
||||
-- Reading folds the characters the alphabet omits so that a code copied by hand still
|
||||
-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0'.
|
||||
-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0', except in a
|
||||
-- prepaid code's length.
|
||||
--
|
||||
-- The check character is Luhn mod N with N = 32 over the payload values, which keeps it
|
||||
-- inside the same 32-character alphabet. It detects every single-character substitution
|
||||
-- and every transposition of adjacent characters except '0' next to 'Z' - the values 0 and
|
||||
-- N-1, which is Luhn's one blind spot at any base.
|
||||
-- The check character of badge and prepaid codes is Luhn mod N with N = 32 over the values
|
||||
-- before it, which keeps it inside the same 32-character alphabet. It detects every
|
||||
-- single-character substitution and every transposition of adjacent characters except '0'
|
||||
-- next to 'Z' - the values 0 and N-1, which is Luhn's one blind spot at any base.
|
||||
--
|
||||
-- 'BadgeCode' is only constructed by 'parseBadgeCode' and 'randomBadgeCode', so a code
|
||||
-- whose check character fails cannot be hashed, looked up or sent.
|
||||
-- 'BadgeCode' and 'NamePrepaidCode' are only constructed by their parse and random
|
||||
-- functions, so a code whose check character fails cannot be hashed, looked up or sent.
|
||||
module Simplex.Chat.Badges.Code
|
||||
( BadgeCode,
|
||||
parseBadgeCode,
|
||||
@@ -26,11 +31,19 @@ module Simplex.Chat.Badges.Code
|
||||
badgeCodeText,
|
||||
badgeCodeHash,
|
||||
formatBadgeCode,
|
||||
NameCode,
|
||||
randomNameCode,
|
||||
NameCode (..),
|
||||
parseNameCode,
|
||||
formatNameCode,
|
||||
nameCodeEntropy,
|
||||
NameOwnerCode,
|
||||
randomNameOwnerCode,
|
||||
parseNameOwnerCode,
|
||||
formatNameOwnerCode,
|
||||
nameOwnerCodeEntropy,
|
||||
NamePrepaidCode,
|
||||
randomNamePrepaidCode,
|
||||
namePrepaidCodeText,
|
||||
namePrepaidCodeHash,
|
||||
namePrepaidCodeLength,
|
||||
)
|
||||
where
|
||||
|
||||
@@ -41,14 +54,15 @@ import Crypto.Random (ChaChaDRG)
|
||||
import Data.Bits (shiftR)
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (isAlphaNum, toUpper)
|
||||
import Data.Char (digitToInt, isAlphaNum, toUpper)
|
||||
import Data.List (elemIndex, foldl')
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.BIP39 (EntropyStrength (..), WalletEntropy, mkEntropy, randomEntropy, unEntropy)
|
||||
import Simplex.Messaging.Util (eitherToMaybe)
|
||||
import Simplex.Messaging.Util (eitherToMaybe, tshow)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
-- | A code that has passed its check character, in canonical form: 'codePrefix' followed by
|
||||
-- 20 upper-case alphabet characters, without separators.
|
||||
@@ -105,14 +119,17 @@ parseBadgeCode t = do
|
||||
then Just $ BadgeCode $ codePrefix <> T.pack (map valueChar vs)
|
||||
else Nothing
|
||||
|
||||
-- | A new code from the CSPRNG. 256 is a multiple of the base, so a byte reduces without bias.
|
||||
randomBadgeCode :: TVar ChaChaDRG -> IO BadgeCode
|
||||
randomBadgeCode drg = do
|
||||
bs <- atomically $ C.randomBytes (codeLength - 1) drg
|
||||
let payload = map ((`mod` base) . fromEnum) $ B.unpack bs
|
||||
vs = payload <> [checkValue payload]
|
||||
payload <- randomPayload drg
|
||||
let vs = payload <> [checkValue payload]
|
||||
pure $ BadgeCode $ codePrefix <> T.pack (map valueChar vs)
|
||||
|
||||
-- | The payload values of a new badge or prepaid code from the CSPRNG. 256 is a multiple of
|
||||
-- the base, so a byte reduces without bias.
|
||||
randomPayload :: TVar ChaChaDRG -> IO [Int]
|
||||
randomPayload drg = map ((`mod` base) . fromEnum) . B.unpack <$> atomically (C.randomBytes (codeLength - 1) drg)
|
||||
|
||||
-- | The canonical form: the only representation of a code that is hashed or sent.
|
||||
badgeCodeText :: BadgeCode -> Text
|
||||
badgeCodeText (BadgeCode t) = t
|
||||
@@ -130,38 +147,103 @@ formatBadgeCode (BadgeCode t) = T.intercalate "-" $ codePrefix : groups (T.drop
|
||||
| T.null s = []
|
||||
| otherwise = let (g, rest) = T.splitAt groupLength s in g : groups rest
|
||||
|
||||
newtype NameCode = NameCode WalletEntropy
|
||||
|
||||
nameCodeLength :: Int
|
||||
nameCodeLength = 30
|
||||
data NameCode = NCOwner NameOwnerCode | NCPrepaid NamePrepaidCode
|
||||
deriving (Eq, Show)
|
||||
|
||||
nameCodePrefix :: Text
|
||||
nameCodePrefix = "SN"
|
||||
|
||||
nameCodeChecksumBits :: Int
|
||||
nameCodeChecksumBits = 22
|
||||
|
||||
randomNameCode :: TVar ChaChaDRG -> IO NameCode
|
||||
randomNameCode drg = NameCode <$> atomically (randomEntropy ES128 drg)
|
||||
|
||||
-- | Read either kind of name code as typed, as 'parseBadgeCode' reads a badge code.
|
||||
parseNameCode :: Text -> Maybe NameCode
|
||||
parseNameCode t = do
|
||||
body <- T.stripPrefix nameCodePrefix $ T.toUpper $ T.filter isAlphaNum t
|
||||
vs <- mapM charValue $ T.unpack body
|
||||
guard $ length vs == nameCodeLength
|
||||
let (payload, checksum) = foldl' (\acc v -> acc * toInteger base + toInteger v) 0 vs `divMod` (2 ^ nameCodeChecksumBits)
|
||||
guard $ checksum == nameCodeChecksum payload
|
||||
NameCode <$> eitherToMaybe (mkEntropy $ i2ospOf_ 16 payload)
|
||||
if T.length body == nameOwnerCodeLength
|
||||
then NCOwner <$> parseOwnerBody body
|
||||
else NCPrepaid <$> parsePrepaidBody body
|
||||
|
||||
-- | The code as it is shown and printed.
|
||||
formatNameCode :: NameCode -> Text
|
||||
formatNameCode (NameCode e) = T.intercalate "-" $ nameCodePrefix : T.chunksOf groupLength (T.pack $ map valueChar values)
|
||||
formatNameCode = \case
|
||||
NCOwner c -> formatNameOwnerCode c
|
||||
NCPrepaid (NamePrepaidCode len body) -> T.intercalate "-" $ (nameCodePrefix <> tshow len) : T.chunksOf groupLength body
|
||||
|
||||
newtype NameOwnerCode = NameOwnerCode WalletEntropy
|
||||
deriving (Eq, Show)
|
||||
|
||||
nameOwnerCodeLength :: Int
|
||||
nameOwnerCodeLength = 30
|
||||
|
||||
nameOwnerCodeChecksumBits :: Int
|
||||
nameOwnerCodeChecksumBits = 22
|
||||
|
||||
randomNameOwnerCode :: TVar ChaChaDRG -> IO NameOwnerCode
|
||||
randomNameOwnerCode drg = NameOwnerCode <$> atomically (randomEntropy ES128 drg)
|
||||
|
||||
parseNameOwnerCode :: Text -> Maybe NameOwnerCode
|
||||
parseNameOwnerCode t =
|
||||
parseNameCode t >>= \case
|
||||
NCOwner c -> Just c
|
||||
NCPrepaid _ -> Nothing
|
||||
|
||||
parseOwnerBody :: Text -> Maybe NameOwnerCode
|
||||
parseOwnerBody body = do
|
||||
vs <- mapM charValue $ T.unpack body
|
||||
let (payload, checksum) = foldl' (\acc v -> acc * toInteger base + toInteger v) 0 vs `divMod` (2 ^ nameOwnerCodeChecksumBits)
|
||||
guard $ checksum == nameOwnerCodeChecksum payload
|
||||
NameOwnerCode <$> eitherToMaybe (mkEntropy $ i2ospOf_ 16 payload)
|
||||
|
||||
formatNameOwnerCode :: NameOwnerCode -> Text
|
||||
formatNameOwnerCode (NameOwnerCode e) = T.intercalate "-" $ nameCodePrefix : T.chunksOf groupLength (T.pack $ map valueChar values)
|
||||
where
|
||||
payload = os2ip $ unEntropy e
|
||||
n = payload * 2 ^ nameCodeChecksumBits + nameCodeChecksum payload
|
||||
values = reverse $ take nameCodeLength $ map (fromInteger . (`mod` toInteger base)) $ iterate (`div` toInteger base) n
|
||||
n = payload * 2 ^ nameOwnerCodeChecksumBits + nameOwnerCodeChecksum payload
|
||||
values = reverse $ take nameOwnerCodeLength $ map (fromInteger . (`mod` toInteger base)) $ iterate (`div` toInteger base) n
|
||||
|
||||
nameCodeEntropy :: NameCode -> WalletEntropy
|
||||
nameCodeEntropy (NameCode e) = e
|
||||
nameOwnerCodeEntropy :: NameOwnerCode -> WalletEntropy
|
||||
nameOwnerCodeEntropy (NameOwnerCode e) = e
|
||||
|
||||
nameCodeChecksum :: Integer -> Integer
|
||||
nameCodeChecksum = (`shiftR` (256 - nameCodeChecksumBits)) . os2ip . C.sha256Hash . i2ospOf_ 16
|
||||
nameOwnerCodeChecksum :: Integer -> Integer
|
||||
nameOwnerCodeChecksum = (`shiftR` (256 - nameOwnerCodeChecksumBits)) . os2ip . C.sha256Hash . i2ospOf_ 16
|
||||
|
||||
-- | A prepaid code that has passed its check: the shortest name length it pays for and the
|
||||
-- 20 canonical characters after it.
|
||||
data NamePrepaidCode = NamePrepaidCode Int Text
|
||||
deriving (Eq, Show)
|
||||
|
||||
minPrepaidNameLength :: Int
|
||||
minPrepaidNameLength = 6
|
||||
|
||||
maxPrepaidNameLength :: Int
|
||||
maxPrepaidNameLength = 63
|
||||
|
||||
-- | The length must be 6 to 63, or the code will not parse.
|
||||
randomNamePrepaidCode :: TVar ChaChaDRG -> Int -> IO NamePrepaidCode
|
||||
randomNamePrepaidCode drg len = do
|
||||
payload <- randomPayload drg
|
||||
let vs = payload <> [checkValue $ lengthValues len <> payload]
|
||||
pure $ NamePrepaidCode len $ T.pack $ map valueChar vs
|
||||
|
||||
parsePrepaidBody :: Text -> Maybe NamePrepaidCode
|
||||
parsePrepaidBody body = do
|
||||
let (lenText, codeText) = T.splitAt (T.length body - codeLength) body
|
||||
len <- readMaybe $ T.unpack lenText
|
||||
-- a leading zero adds nothing to the check, so it would give the same code a second text and hash
|
||||
guard $ tshow len == lenText && len >= minPrepaidNameLength && len <= maxPrepaidNameLength
|
||||
vs <- mapM charValue $ T.unpack codeText
|
||||
let (payload, checkChar) = splitAt (codeLength - 1) vs
|
||||
guard $ checkChar == [checkValue $ lengthValues len <> payload]
|
||||
Just $ NamePrepaidCode len $ T.pack $ map valueChar vs
|
||||
|
||||
lengthValues :: Int -> [Int]
|
||||
lengthValues = map digitToInt . show
|
||||
|
||||
-- | The canonical form, "SN", the length and the 20 characters: the only representation hashed.
|
||||
namePrepaidCodeText :: NamePrepaidCode -> Text
|
||||
namePrepaidCodeText (NamePrepaidCode len body) = nameCodePrefix <> tshow len <> body
|
||||
|
||||
-- | SHA-256 over the ASCII bytes of the canonical form, as 'badgeCodeHash'.
|
||||
namePrepaidCodeHash :: NamePrepaidCode -> ByteString
|
||||
namePrepaidCodeHash = C.sha256Hash . encodeUtf8 . namePrepaidCodeText
|
||||
|
||||
namePrepaidCodeLength :: NamePrepaidCode -> Int
|
||||
namePrepaidCodeLength (NamePrepaidCode len _) = len
|
||||
+136
-17
@@ -12,7 +12,7 @@ module BadgeTests (badgeTests) where
|
||||
import StoreService.Service (badgeErrorRetryAfter)
|
||||
import Control.Concurrent.STM (atomically)
|
||||
import Control.Monad (forM_)
|
||||
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
||||
import Data.ByteArray.Encoding (Base (Base16, Base64URLUnpadded), convertToBase)
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
@@ -63,9 +63,15 @@ badgeTests = do
|
||||
it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation
|
||||
it "rejects a code whose check character does not match" testCodeCheckCharacter
|
||||
it "hashes the canonical form, whatever was typed" testCodeHash
|
||||
describe "name codes" $ do
|
||||
it "encodes the entropy and its checksum" testNameCodeVectors
|
||||
it "rejects a name code with a wrong character" testNameCodeChecksum
|
||||
describe "StoreService names: codes" $ do
|
||||
it "encodes an owner code's entropy and its checksum" testNameOwnerCodeVectors
|
||||
it "rejects an owner code with a wrong character" testNameOwnerCodeChecksum
|
||||
it "reads the webapp's prepaid codes: length, canonical form, display and hash" testNamePrepaidCodeVectors
|
||||
it "reads a taken order's code derived by the webapp, and refuses its lengths outside 6 to 63" testTakenOrderCodeVectors
|
||||
it "never reads an owner code as prepaid, nor the reverse" testNameCodeKinds
|
||||
it "reads random codes of both kinds back, as typed" testNameCodeRoundTrip
|
||||
it "refuses a prepaid code with one character changed or two adjacent swapped, except 0 and Z" testNamePrepaidCodeCheck
|
||||
it "refuses a prepaid code whose length is not plain ASCII digits" testNamePrepaidCodeLengthDigits
|
||||
describe "ledger transitions" $ do
|
||||
it "issues a twelve month code one month at a time, and no thirteenth" testTwelveMonths
|
||||
it "lapses only the elapsed months after a gap, leaving paidThrough unchanged" testLapseAfterGap
|
||||
@@ -337,25 +343,138 @@ testCodeHash = do
|
||||
Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code
|
||||
badgeCodeHash typed `shouldBe` badgeCodeHash code
|
||||
|
||||
testNameCodeVectors :: IO ()
|
||||
testNameCodeVectors =
|
||||
forM_ nameCodeVectors $ \(entropy, formatted) -> do
|
||||
let code = parseNameCode formatted
|
||||
formatNameCode <$> code `shouldBe` Just formatted
|
||||
convertToBase Base16 . unEntropy . nameCodeEntropy <$> code `shouldBe` Just entropy
|
||||
testNameOwnerCodeVectors :: IO ()
|
||||
testNameOwnerCodeVectors =
|
||||
forM_ nameOwnerCodeVectors $ \(entropy, formatted) -> do
|
||||
let code = parseNameOwnerCode formatted
|
||||
formatNameOwnerCode <$> code `shouldBe` Just formatted
|
||||
convertToBase Base16 . unEntropy . nameOwnerCodeEntropy <$> code `shouldBe` Just entropy
|
||||
formatNameCode <$> parseNameCode (T.toLower formatted) `shouldBe` Just formatted
|
||||
|
||||
testNameCodeChecksum :: IO ()
|
||||
testNameCodeChecksum =
|
||||
formatNameCode <$> parseNameCode "SN-KT45V-59ATD-HCNTT-EZRTA-HT8VT-8A0BV" `shouldBe` Nothing
|
||||
testNameOwnerCodeChecksum :: IO ()
|
||||
testNameOwnerCodeChecksum = do
|
||||
formatNameOwnerCode <$> parseNameOwnerCode "SN-KT45V-59ATD-HCNTT-EZRTA-HT8VT-8A0BV" `shouldBe` Nothing
|
||||
parseNameCode "SN-DWPHT-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5" `shouldBe` Nothing
|
||||
|
||||
-- entropy and its name code, computed by a separate Python implementation of the format
|
||||
nameCodeVectors :: [(ByteString, T.Text)]
|
||||
nameCodeVectors =
|
||||
-- entropy and its name code, computed by a separate Python implementation of the format,
|
||||
-- and the webapp's fixture from simplex.domains webapp/test/crypto.test.ts
|
||||
nameOwnerCodeVectors :: [(ByteString, T.Text)]
|
||||
nameOwnerCodeVectors =
|
||||
[ ("00000000000000000000000000000000", "SN-00000-00000-00000-00000-00000-0VME2"),
|
||||
("ffffffffffffffffffffffffffffffff", "SN-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-XDCD9"),
|
||||
("9e885d952ad362caeb4efe34a8e91bd2", "SN-KT45V-59ATD-HCNTT-EZRTA-HT8VT-8A0BT")
|
||||
("9e885d952ad362caeb4efe34a8e91bd2", "SN-KT45V-59ATD-HCNTT-EZRTA-HT8VT-8A0BT"),
|
||||
("6f2d1c9a4b7e3f80d15a2c6e9b04f7a3", "SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5")
|
||||
]
|
||||
|
||||
testNamePrepaidCodeVectors :: IO ()
|
||||
testNamePrepaidCodeVectors = do
|
||||
forM_ namePrepaidCodeVectors $ \(typed, len, canonical, display, hash) -> do
|
||||
Just (NCPrepaid code) <- pure $ parseNameCode typed
|
||||
(namePrepaidCodeLength code, namePrepaidCodeText code, formatNameCode (NCPrepaid code)) `shouldBe` (len, canonical, display)
|
||||
convertToBase Base64URLUnpadded (namePrepaidCodeHash code) `shouldBe` (hash :: ByteString)
|
||||
-- the webapp's typo vector
|
||||
parseNameCode "SN7-4K2P7-TQ9M1-ZX3RB-8HJ5X" `shouldBe` Nothing
|
||||
|
||||
-- typed code, length, canonical form, display and the webapp's codeHash
|
||||
namePrepaidCodeVectors :: [(T.Text, Int, T.Text, T.Text, ByteString)]
|
||||
namePrepaidCodeVectors =
|
||||
[ ("SN7-BJTBP-29596-R3HJ8-25YM7", 7, "SN7BJTBP29596R3HJ825YM7", "SN7-BJTBP-29596-R3HJ8-25YM7", "7hg6ED6EotRVm21uS1KOZ-CKEzkJK5hvG4W5atfcn_Q"),
|
||||
("sn7 bjtbp 29596 r3hj8 25ym7", 7, "SN7BJTBP29596R3HJ825YM7", "SN7-BJTBP-29596-R3HJ8-25YM7", "7hg6ED6EotRVm21uS1KOZ-CKEzkJK5hvG4W5atfcn_Q"),
|
||||
("SN8-25X6F-6JDNM-8C59E-HDHJD", 8, "SN825X6F6JDNM8C59EHDHJD", "SN8-25X6F-6JDNM-8C59E-HDHJD", "cOnJDSzWBEZiY2z1I-HMGW2gylyx_13V8sOpMDeJOvU")
|
||||
]
|
||||
|
||||
-- takenOrderCode(seed of the webapp's fixture entropy, "t2Jk8RmQ4vXw9NcP3yLb6g", length), computed by the webapp's code.ts
|
||||
testTakenOrderCodeVectors :: IO ()
|
||||
testTakenOrderCodeVectors = do
|
||||
forM_ takenOrderCodeVectors $ \(display, len, hash) -> do
|
||||
Just (NCPrepaid code) <- pure $ parseNameCode display
|
||||
(namePrepaidCodeLength code, formatNameCode (NCPrepaid code)) `shouldBe` (len, display)
|
||||
convertToBase Base64URLUnpadded (namePrepaidCodeHash code) `shouldBe` (hash :: ByteString)
|
||||
-- the webapp reads these, but no name length is outside 6 to 63
|
||||
parseNameCode "SN5-N0N9J-5D292-HPSYA-R911Q" `shouldBe` Nothing
|
||||
parseNameCode "SN64-N0N9J-5D292-HPSYA-R911C" `shouldBe` Nothing
|
||||
|
||||
takenOrderCodeVectors :: [(T.Text, Int, ByteString)]
|
||||
takenOrderCodeVectors =
|
||||
[ ("SN6-N0N9J-5D292-HPSYA-R911P", 6, "SKKFjAl5K21B6HRIgQcYQ3AaZ67nVxl2xszsSkZ97cA"),
|
||||
("SN7-N0N9J-5D292-HPSYA-R911N", 7, "pXhWV9oSfjH0oaQ78HPSGKTg1lScZhNGi-YZnqt-GyQ"),
|
||||
("SN12-N0N9J-5D292-HPSYA-R911R", 12, "FozeCQ3A_sUsZF9_OwQDp42kdHRMsoJazuGvOkTS-FQ"),
|
||||
("SN63-N0N9J-5D292-HPSYA-R911D", 63, "U-Nh_Kw3-xDlQuFJffkuCAyolK5m9EfN3QuYiJ28A0A")
|
||||
]
|
||||
|
||||
testNameCodeKinds :: IO ()
|
||||
testNameCodeKinds = do
|
||||
drg <- C.newRandom
|
||||
forM_ [1 :: Int .. 100] $ \_ -> do
|
||||
owner <- randomNameOwnerCode drg
|
||||
parseNameCode (formatNameOwnerCode owner) `shouldBe` Just (NCOwner owner)
|
||||
forM_ [6 .. 63] $ \len -> do
|
||||
prepaid <- randomNamePrepaidCode drg len
|
||||
parseNameCode (namePrepaidCodeText prepaid) `shouldBe` Just (NCPrepaid prepaid)
|
||||
parseNameOwnerCode (namePrepaidCodeText prepaid) `shouldBe` Nothing
|
||||
|
||||
testNameCodeRoundTrip :: IO ()
|
||||
testNameCodeRoundTrip = do
|
||||
drg <- C.newRandom
|
||||
owner <- NCOwner <$> randomNameOwnerCode drg
|
||||
forM_ (typings $ formatNameCode owner) $ \typed -> parseNameCode typed `shouldBe` Just owner
|
||||
forM_ [6 .. 63] $ \len -> do
|
||||
prepaid <- randomNamePrepaidCode drg len
|
||||
let display = formatNameCode (NCPrepaid prepaid)
|
||||
T.length display `shouldBe` T.length (namePrepaidCodeText prepaid) + 4 -- four separators
|
||||
T.isPrefixOf ("SN" <> tshow len <> "-") display `shouldBe` True
|
||||
forM_ (namePrepaidCodeText prepaid : typings display) $ \typed -> parseNameCode typed `shouldBe` Just (NCPrepaid prepaid)
|
||||
where
|
||||
typings display = [display, T.toLower display, T.replace "-" " " display, misreadAfterLength display]
|
||||
-- folding never applies to a prepaid code's length, which is refused when misread
|
||||
misreadAfterLength display = let (prefix, rest) = T.breakOn "-" display in prefix <> T.map misread rest
|
||||
misread = \case
|
||||
'1' -> 'I'
|
||||
'0' -> 'O'
|
||||
c -> c
|
||||
|
||||
testNamePrepaidCodeCheck :: IO ()
|
||||
testNamePrepaidCodeCheck = do
|
||||
drg <- C.newRandom
|
||||
forM_ [6, 9, 10, 63] $ \len -> do
|
||||
code <- randomNamePrepaidCode drg len
|
||||
let canonical = namePrepaidCodeText code
|
||||
(prefix, rest) = T.splitAt 2 canonical
|
||||
changed =
|
||||
[ prefix <> T.take i rest <> T.singleton c <> T.drop (i + 1) rest
|
||||
| i <- [0 .. T.length rest - 1],
|
||||
c <- "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
|
||||
c /= T.index rest i
|
||||
]
|
||||
swapped =
|
||||
[ prefix <> T.take i rest <> T.pack [b, a] <> T.drop (i + 2) rest
|
||||
| i <- [0 .. T.length rest - 2],
|
||||
let a = T.index rest i
|
||||
b = T.index rest (i + 1),
|
||||
a /= b,
|
||||
-- Luhn mod 32's blind spot, the values 0 and 31
|
||||
[a, b] `notElem` ["0Z", "Z0"]
|
||||
]
|
||||
forM_ (changed <> swapped) $ \typed -> (typed, parseNameCode typed) `shouldBe` (typed, Nothing)
|
||||
-- the blind spot itself: a 0 and Z swapped in the payload still pass the check
|
||||
Just code <- pure $ parseNameCode "SN7-N0ZNJ-5D292-HPSYA-R911N"
|
||||
Just swapped <- pure $ parseNameCode "SN7-NZ0NJ-5D292-HPSYA-R911N"
|
||||
swapped `shouldNotBe` code
|
||||
|
||||
testNamePrepaidCodeLengthDigits :: IO ()
|
||||
testNamePrepaidCodeLengthDigits = do
|
||||
drg <- C.newRandom
|
||||
code <- randomNamePrepaidCode drg 10
|
||||
let display = formatNameCode (NCPrepaid code)
|
||||
parseNameCode display `shouldBe` Just (NCPrepaid code)
|
||||
-- O and I fold to 0 and 1 in the 20 characters, never in the length
|
||||
parseNameCode (T.replace "SN10-" "SN1O-" display) `shouldBe` Nothing
|
||||
parseNameCode (T.replace "SN10-" "SNI0-" display) `shouldBe` Nothing
|
||||
-- a leading zero adds nothing to the check, so it would be a second text with another hash
|
||||
parseNameCode "SN07-BJTBP-29596-R3HJ8-25YM7" `shouldBe` Nothing
|
||||
parseNameCode "SN-BJTBP-29596-R3HJ8-25YM7" `shouldBe` Nothing
|
||||
parseNameCode "SN100-BJTBP-29596-R3HJ8-25YM7" `shouldBe` Nothing
|
||||
|
||||
-- Ledger transitions, against plans/2026-07-30-supporter-badges-v3-ux.md §3
|
||||
|
||||
at :: Integer -> Int -> Int -> UTCTime
|
||||
|
||||
@@ -15,7 +15,7 @@ import Control.Concurrent.STM (TVar)
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Data.Text (Text)
|
||||
import Data.Word (Word64)
|
||||
import Simplex.Chat.Badges.Code (NameCode, formatNameCode, nameCodeEntropy, parseNameCode, randomNameCode)
|
||||
import Simplex.Chat.Badges.Code (NameOwnerCode, formatNameOwnerCode, nameOwnerCodeEntropy, parseNameOwnerCode, randomNameOwnerCode)
|
||||
import Simplex.Messaging.Crypto.BIP32 (masterKey, xkKey)
|
||||
import Simplex.Messaging.Crypto.BIP39 (entropySeed)
|
||||
import Simplex.Messaging.Crypto.BIP44 (CoinType (..), bip44Path, mkAccountIndex)
|
||||
@@ -24,19 +24,19 @@ import Simplex.Messaging.Eth.Address (Address, deriveAddress)
|
||||
import Simplex.Messaging.Eth.EIP712 (Eip712Domain)
|
||||
|
||||
data EphemeralWallet = EphemeralWallet
|
||||
{ ewCode :: NameCode,
|
||||
{ ewCode :: NameOwnerCode,
|
||||
ewKey :: Secp256k1PrivateKey,
|
||||
ewAddress :: Address
|
||||
}
|
||||
|
||||
newEphemeralWallet :: TVar ChaChaDRG -> IO EphemeralWallet
|
||||
newEphemeralWallet g = randomNameCode g >>= walletFromNameCode g
|
||||
newEphemeralWallet g = randomNameOwnerCode g >>= walletFromNameOwnerCode g
|
||||
|
||||
redemptionCode :: EphemeralWallet -> Text
|
||||
redemptionCode = formatNameCode . ewCode
|
||||
redemptionCode = formatNameOwnerCode . ewCode
|
||||
|
||||
walletFromCode :: TVar ChaChaDRG -> Text -> IO EphemeralWallet
|
||||
walletFromCode g = maybe (fail "invalid name code") (walletFromNameCode g) . parseNameCode
|
||||
walletFromCode g = maybe (fail "invalid name code") (walletFromNameOwnerCode g) . parseNameOwnerCode
|
||||
|
||||
signRegisterIntent :: TVar ChaChaDRG -> Eip712Domain -> EphemeralWallet -> Registration -> Word64 -> IO RegisterIntent
|
||||
signRegisterIntent g domain EphemeralWallet {ewKey} r deadline =
|
||||
@@ -44,9 +44,9 @@ signRegisterIntent g domain EphemeralWallet {ewKey} r deadline =
|
||||
where
|
||||
nonce = 0
|
||||
|
||||
walletFromNameCode :: TVar ChaChaDRG -> NameCode -> IO EphemeralWallet
|
||||
walletFromNameCode g ewCode = do
|
||||
master <- either fail pure $ masterKey $ entropySeed (nameCodeEntropy ewCode) ""
|
||||
walletFromNameOwnerCode :: TVar ChaChaDRG -> NameOwnerCode -> IO EphemeralWallet
|
||||
walletFromNameOwnerCode g ewCode = do
|
||||
master <- either fail pure $ masterKey $ entropySeed (nameOwnerCodeEntropy ewCode) ""
|
||||
account <- either fail pure $ mkAccountIndex 0
|
||||
(xk, ewAddress) <- deriveAddress g master (bip44Path Ethereum account) >>= either fail pure
|
||||
pure EphemeralWallet {ewCode, ewKey = xkKey xk, ewAddress}
|
||||
Reference in new issue
Block a user