mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 13:20:36 +00:00
119 lines
4.4 KiB
Haskell
119 lines
4.4 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
-- | Badge redemption codes, shared by the client, the badge service and the checkout site.
|
|
--
|
|
-- A code is "SB-" and 20 Crockford base32 characters in four groups of five:
|
|
-- 19 payload characters and a final check character.
|
|
--
|
|
-- 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'.
|
|
--
|
|
-- 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.
|
|
--
|
|
-- 'BadgeCode' is only constructed by 'parseBadgeCode' and 'randomBadgeCode', so a code
|
|
-- whose check character fails cannot be hashed, looked up or sent.
|
|
module Simplex.Chat.Badges.Code
|
|
( BadgeCode,
|
|
parseBadgeCode,
|
|
randomBadgeCode,
|
|
badgeCodeText,
|
|
badgeCodeHash,
|
|
formatBadgeCode,
|
|
)
|
|
where
|
|
|
|
import Control.Concurrent.STM
|
|
import Crypto.Random (ChaChaDRG)
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import qualified Data.ByteString.Char8 as B
|
|
import Data.Char (isAlphaNum, toUpper)
|
|
import Data.List (elemIndex)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
|
|
-- | A code that has passed its check character, in canonical form: 'codePrefix' followed by
|
|
-- 20 upper-case alphabet characters, without separators.
|
|
newtype BadgeCode = BadgeCode Text
|
|
deriving (Eq, Show)
|
|
|
|
-- Crockford base32: the digits and the upper-case letters except I, L, O and U.
|
|
alphabet :: String
|
|
alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
|
|
base :: Int
|
|
base = 32
|
|
|
|
codeLength :: Int
|
|
codeLength = 20
|
|
|
|
groupLength :: Int
|
|
groupLength = 5
|
|
|
|
codePrefix :: Text
|
|
codePrefix = "SB"
|
|
|
|
-- | The Crockford value of a character, folding the omitted characters onto the digits they
|
|
-- are mistaken for.
|
|
charValue :: Char -> Maybe Int
|
|
charValue c = case toUpper c of
|
|
'I' -> Just 1
|
|
'L' -> Just 1
|
|
'O' -> Just 0
|
|
u -> elemIndex u alphabet
|
|
|
|
valueChar :: Int -> Char
|
|
valueChar v = alphabet !! v
|
|
|
|
-- | Luhn mod N (N = 32): the value that makes the whole code sum to zero modulo the base.
|
|
checkValue :: [Int] -> Int
|
|
checkValue payload = (base - total `mod` base) `mod` base
|
|
where
|
|
-- doubling every second value from the right, as the check character sits to the right of the payload
|
|
total = fst $ foldr step (0, 2) payload
|
|
step v (sum', factor) =
|
|
let addend = factor * v
|
|
in (sum' + addend `div` base + addend `mod` base, if factor == 2 then 1 else 2)
|
|
|
|
-- | Read a code as typed: any case, separators optional, ambiguous characters folded.
|
|
-- 'Nothing' for anything not well-formed, a failed check character included.
|
|
parseBadgeCode :: Text -> Maybe BadgeCode
|
|
parseBadgeCode t = do
|
|
body <- T.stripPrefix codePrefix $ T.toUpper $ T.filter isAlphaNum t
|
|
vs <- mapM charValue $ T.unpack body
|
|
let (payload, checkChar) = splitAt (codeLength - 1) vs
|
|
if T.length body == codeLength && checkChar == [checkValue payload]
|
|
-- rebuilt from the values, not from body: that is what folds I/L/O into the canonical form
|
|
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]
|
|
pure $ BadgeCode $ codePrefix <> T.pack (map valueChar vs)
|
|
|
|
-- | The canonical form: the only representation of a code that is hashed or sent.
|
|
badgeCodeText :: BadgeCode -> Text
|
|
badgeCodeText (BadgeCode t) = t
|
|
|
|
-- | The only thing about a code stored service-side: SHA-256 over the ASCII bytes of the
|
|
-- canonical form, prefix included.
|
|
badgeCodeHash :: BadgeCode -> ByteString
|
|
badgeCodeHash = C.sha256Hash . encodeUtf8 . badgeCodeText
|
|
|
|
-- | The code as it is shown and printed, in four groups of five.
|
|
formatBadgeCode :: BadgeCode -> Text
|
|
formatBadgeCode (BadgeCode t) = T.intercalate "-" $ codePrefix : groups (T.drop (T.length codePrefix) t)
|
|
where
|
|
groups s
|
|
| T.null s = []
|
|
| otherwise = let (g, rest) = T.splitAt groupLength s in g : groups rest
|