mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-09 22:36:18 +00:00
296 lines
13 KiB
Haskell
296 lines
13 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DisambiguateRecordFields #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
|
|
|
module BadgeTests (badgeTests) where
|
|
|
|
import Control.Concurrent.STM (atomically)
|
|
import Data.Map.Strict (Map)
|
|
import qualified Data.Map.Strict as M
|
|
import qualified Data.Text as T
|
|
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay)
|
|
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
|
|
import qualified Data.Aeson as J
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Chat.Badges
|
|
import Simplex.Chat.Badges.Code
|
|
import Simplex.Chat.Badges.Service
|
|
import Simplex.Messaging.Crypto.BBS
|
|
import Simplex.Messaging.Version.Internal (Version (..))
|
|
import Test.Hspec
|
|
|
|
badgeTests :: Spec
|
|
badgeTests = do
|
|
it "full workflow: request, issue, verify credential, generate and verify proof" testFullWorkflow
|
|
it "should reject badge with tampered type" testTamperedType
|
|
it "should reject badge with tampered expiry" testTamperedExpiry
|
|
it "should reject badge with wrong server key" testWrongKey
|
|
it "should report a key index missing from configured keys" testUnknownKeyIdx
|
|
it "should compute badge status correctly" testExpiryCheck
|
|
it "should treat lifetime badges as always active" testLifetimeBadge
|
|
it "should accept unknown badge types" testUnknownBadgeType
|
|
it "credential serializes to a paste-able token and back" testCredentialSerialization
|
|
describe "redemption codes" $ do
|
|
it "a generated code reads back" testCodeRoundTrip
|
|
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 "service protocol JSON" $ do
|
|
it "redeemBadgeCode request matches the schema" testRedeemRequestJSON
|
|
it "badgeCredential response matches the schema" testCredentialResponseJSON
|
|
it "error response matches the schema" testErrorResponseJSON
|
|
it "statement entries round-trip, unknown entry types verbatim" testStatementJSON
|
|
|
|
proofOf :: BadgeProof -> BBSProof
|
|
proofOf (BadgeProof _ _ p _) = p
|
|
|
|
proofInfo :: BadgeProof -> BadgeInfo
|
|
proofInfo (BadgeProof _ _ _ i) = i
|
|
|
|
testKeyIdx :: Int
|
|
testKeyIdx = 1
|
|
|
|
keysFor :: BBSPublicKey -> Map Int BBSPublicKey
|
|
keysFor = M.singleton testKeyIdx
|
|
|
|
testFullWorkflow :: IO ()
|
|
testFullWorkflow = do
|
|
Right (pk, sk) <- bbsKeyGen
|
|
drg <- C.newRandom
|
|
mk <- generateMasterKey drg
|
|
let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}}
|
|
Just vreq <- verifyPayment (BPRedeemCode "TEST") req
|
|
Right cred <- issueBadge testKeyIdx sk vreq
|
|
let BadgeCredential idx mk' _ _ = cred
|
|
idx `shouldBe` testKeyIdx
|
|
mk' `shouldBe` mk
|
|
verifyCredential pk cred >>= (`shouldBe` True)
|
|
Right badge <- generateBadgeProof pk cred (BBSPresHeader "nonce-1")
|
|
-- the proof inherits the credential's key index, so receivers find the right key
|
|
let BadgeProof {badgeKeyIdx} = badge
|
|
badgeKeyIdx `shouldBe` testKeyIdx
|
|
verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True)
|
|
Right badge2 <- generateBadgeProof pk cred (BBSPresHeader "nonce-2")
|
|
verifyBadge (keysFor pk) badge2 >>= (`shouldBe` Just True)
|
|
proofOf badge `shouldNotBe` proofOf badge2
|
|
|
|
testTamperedType :: IO ()
|
|
testTamperedType = do
|
|
(pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime)
|
|
verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeType = BTLegend}) >>= (`shouldBe` Just False)
|
|
|
|
testTamperedExpiry :: IO ()
|
|
testTamperedExpiry = do
|
|
(pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime)
|
|
verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeExpiry = Just pastTime}) >>= (`shouldBe` Just False)
|
|
|
|
testWrongKey :: IO ()
|
|
testWrongKey = do
|
|
(_, badge) <- issueBadgeProof BTSupporter (Just futureTime)
|
|
Right (pk2, _) <- bbsKeyGen
|
|
verifyBadge (keysFor pk2) badge >>= (`shouldBe` Just False)
|
|
|
|
testUnknownKeyIdx :: IO ()
|
|
testUnknownKeyIdx = do
|
|
(pk, badge) <- issueBadgeProof BTSupporter (Just futureTime)
|
|
-- a key index not in the configured keys cannot be verified at all (Nothing)
|
|
verifyBadge (M.singleton (testKeyIdx + 1) pk) badge >>= (`shouldBe` Nothing)
|
|
|
|
testExpiryCheck :: IO ()
|
|
testExpiryCheck = do
|
|
now <- getCurrentTime
|
|
let info expiry = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}
|
|
futureInfo = info (Just futureTime)
|
|
mkBadgeStatus now (Just True) futureInfo `shouldBe` BSActive
|
|
mkBadgeStatus now (Just True) (info (Just (addUTCTime (-nominalDay) now))) `shouldBe` BSExpired
|
|
mkBadgeStatus now (Just True) (info (Just pastTime)) `shouldBe` BSExpiredOld
|
|
mkBadgeStatus now (Just False) futureInfo `shouldBe` BSFailed
|
|
mkBadgeStatus now Nothing futureInfo `shouldBe` BSUnknownKey
|
|
|
|
testLifetimeBadge :: IO ()
|
|
testLifetimeBadge = do
|
|
now <- getCurrentTime
|
|
(pk, badge) <- issueBadgeProof BTInvestor Nothing
|
|
verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True)
|
|
mkBadgeStatus now (Just True) (proofInfo badge) `shouldBe` BSActive
|
|
|
|
testUnknownBadgeType :: IO ()
|
|
testUnknownBadgeType = do
|
|
(pk, badge) <- issueBadgeProof (BTUnknown "future_type") (Just futureTime)
|
|
verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True)
|
|
|
|
testCredentialSerialization :: IO ()
|
|
testCredentialSerialization = do
|
|
Right (pk, sk) <- bbsKeyGen
|
|
drg <- C.newRandom
|
|
mk <- generateMasterKey drg
|
|
let mkCred expiry = do
|
|
Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}})
|
|
pure cred
|
|
dated <- mkCred (Just futureTime)
|
|
lifetime <- mkCred Nothing
|
|
J.eitherDecode (J.encode dated) `shouldBe` Right dated
|
|
J.eitherDecode (J.encode lifetime) `shouldBe` Right lifetime
|
|
-- a decoded credential still verifies against the issuing key
|
|
case J.eitherDecode (J.encode dated) of
|
|
Right cred -> verifyCredential pk cred >>= (`shouldBe` True)
|
|
Left e -> expectationFailure e
|
|
|
|
-- Helpers
|
|
|
|
futureTime :: UTCTime
|
|
futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31
|
|
|
|
pastTime :: UTCTime
|
|
pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01
|
|
|
|
issueBadgeProof :: BadgeType -> Maybe UTCTime -> IO (BBSPublicKey, BadgeProof)
|
|
issueBadgeProof bt expiry = do
|
|
Right (pk, sk) <- bbsKeyGen
|
|
drg <- C.newRandom
|
|
mk <- generateMasterKey drg
|
|
let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}}
|
|
Right cred <- issueBadge testKeyIdx sk vreq
|
|
Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce")
|
|
pure (pk, badge)
|
|
|
|
-- Redemption codes
|
|
|
|
testCodeRoundTrip :: IO ()
|
|
testCodeRoundTrip = do
|
|
drg <- C.newRandom
|
|
code <- randomBadgeCode drg
|
|
let formatted = formatBadgeCode code
|
|
T.length formatted `shouldBe` 27 -- SXB-XXXXX-XXXXX-XXXXX-XXXXX
|
|
T.take 4 formatted `shouldBe` "SXB-"
|
|
T.length (badgeCodeText code) `shouldBe` 23 -- the canonical form drops the separators
|
|
parseBadgeCode formatted `shouldBe` Just code
|
|
parseBadgeCode (badgeCodeText code) `shouldBe` Just code
|
|
|
|
testCodeNormalisation :: IO ()
|
|
testCodeNormalisation = do
|
|
drg <- C.newRandom
|
|
code <- randomBadgeCode drg
|
|
parseBadgeCode (T.toLower $ badgeCodeText code) `shouldBe` Just code
|
|
parseBadgeCode (T.replace "-" " " $ formatBadgeCode code) `shouldBe` Just code
|
|
-- a fixed code, because a random one contains no 0 or 1 about a quarter of the time and the
|
|
-- folding would then be asserted against nothing
|
|
let folded = T.map ambiguous fixedCode
|
|
folded `shouldNotBe` fixedCode
|
|
parseBadgeCode folded `shouldBe` parseBadgeCode fixedCode
|
|
parseBadgeCode fixedCode `shouldNotBe` Nothing
|
|
where
|
|
fixedCode = "SXB-0C0QS-XAQW1-N1VSA-R00Y3"
|
|
ambiguous = \case
|
|
'1' -> 'I'
|
|
'0' -> 'O'
|
|
c -> c
|
|
|
|
testCodeCheckCharacter :: IO ()
|
|
testCodeCheckCharacter = do
|
|
drg <- C.newRandom
|
|
code <- randomBadgeCode drg
|
|
let canonical = badgeCodeText code
|
|
-- every other value for the last character fails the check
|
|
wrong = T.init canonical <> T.singleton (if T.last canonical == 'Z' then 'Y' else 'Z')
|
|
parseBadgeCode wrong `shouldBe` Nothing
|
|
parseBadgeCode "" `shouldBe` Nothing
|
|
parseBadgeCode "SXB-00000-00000-00000-0000" `shouldBe` Nothing
|
|
parseBadgeCode (T.drop 3 canonical) `shouldBe` Nothing
|
|
|
|
testCodeHash :: IO ()
|
|
testCodeHash = do
|
|
drg <- C.newRandom
|
|
code <- randomBadgeCode drg
|
|
Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code
|
|
badgeCodeHash typed `shouldBe` badgeCodeHash code
|
|
|
|
-- Service protocol JSON, against docs/protocol/badges-rpc.schema.json
|
|
|
|
testRedeemRequestJSON :: IO ()
|
|
testRedeemRequestJSON = do
|
|
drg <- C.newRandom
|
|
mk <- generateMasterKey drg
|
|
(k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519)
|
|
code <- randomBadgeCode drg
|
|
let req = BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request = BSCRedeemBadgeCode {masterKey = mk, code = badgeCodeText code}}
|
|
J.toJSON req
|
|
`shouldBe` J.object
|
|
[ "version" J..= (1 :: Int),
|
|
"purchaseKey" J..= k,
|
|
"request" J..= J.object ["type" J..= ("redeemBadgeCode" :: T.Text), "masterKey" J..= mk, "code" J..= badgeCodeText code]
|
|
]
|
|
-- purchaseKey is optional in the schema, and a nullary command is a bare tagged object
|
|
J.toJSON BadgeServiceRequest {version = Version 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog}
|
|
`shouldBe` J.object ["version" J..= (1 :: Int), "request" J..= J.object ["type" J..= ("getBadgeCatalog" :: T.Text)]]
|
|
roundTrips req
|
|
|
|
testCredentialResponseJSON :: IO ()
|
|
testCredentialResponseJSON = do
|
|
Right (_, sk) <- bbsKeyGen
|
|
drg <- C.newRandom
|
|
mk <- generateMasterKey drg
|
|
let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}
|
|
Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = info})
|
|
let resp = BSPBadgeCredential {credential = Just cred, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}}
|
|
J.toJSON resp
|
|
`shouldBe` J.object
|
|
[ "type" J..= ("badgeCredential" :: T.Text),
|
|
"credential" J..= cred,
|
|
"statement" J..= J.object ["entries" J..= ([] :: [J.Value])]
|
|
]
|
|
roundTrips resp
|
|
|
|
testErrorResponseJSON :: IO ()
|
|
testErrorResponseJSON = do
|
|
let resp = BSPError {code = BSECodeInvalid, message = Nothing, retryAfter = Nothing}
|
|
J.toJSON resp `shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("code_invalid" :: T.Text)]
|
|
J.toJSON BSPError {code = BSERateLimited, message = Just "slow down", retryAfter = Just 30}
|
|
`shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("rate_limited" :: T.Text), "message" J..= ("slow down" :: T.Text), "retryAfter" J..= (30 :: Int)]
|
|
|
|
testStatementJSON :: IO ()
|
|
testStatementJSON = do
|
|
let entry =
|
|
StatementEntry
|
|
{ entryId = "e1",
|
|
changeMonths = 3,
|
|
balanceMonths = 3,
|
|
balanceStartTs = futureTime,
|
|
balanceBadgeType = BTSupporter,
|
|
wasPausedSince = Nothing,
|
|
createdAt = futureTime,
|
|
entryType = SECredit {credit = SCPayment {invoiceId = Nothing}}
|
|
}
|
|
-- the whole entry: the required fields, and wasPausedSince omitted rather than sent as null
|
|
J.toJSON entry
|
|
`shouldBe` J.object
|
|
[ "entryId" J..= ("e1" :: T.Text),
|
|
"changeMonths" J..= (3 :: Int),
|
|
"balanceMonths" J..= (3 :: Int),
|
|
"balanceStartTs" J..= futureTime,
|
|
"balanceBadgeType" J..= ("supporter" :: T.Text),
|
|
"createdAt" J..= futureTime,
|
|
"entryType" J..= entryType entry
|
|
]
|
|
J.toJSON entry {wasPausedSince = Just pastTime} `shouldNotBe` J.toJSON entry
|
|
J.toJSON (entryType entry) `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("payment" :: T.Text)]]
|
|
J.toJSON SEDebit {debit = SDBadge} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("badge" :: T.Text)]]
|
|
-- an entry type from a newer service is stored and re-emitted unchanged
|
|
let futureCredit = J.object ["type" J..= ("grant" :: T.Text), "grantedBy" J..= ("operator" :: T.Text)]
|
|
case J.fromJSON futureCredit of
|
|
J.Success c@SCUnknown {tag} -> do
|
|
tag `shouldBe` "grant"
|
|
J.toJSON c `shouldBe` futureCredit
|
|
r -> expectationFailure $ "expected SCUnknown, got " <> show (fmap (const ()) r)
|
|
|
|
-- decoding and re-encoding reproduces the encoding, without Eq on the protocol types
|
|
roundTrips :: (HasCallStack, J.ToJSON a, J.FromJSON a) => a -> IO ()
|
|
roundTrips x = case J.eitherDecode (J.encode x) of
|
|
Right x' -> J.toJSON (x' `asTypeOf` x) `shouldBe` J.toJSON x
|
|
Left e -> expectationFailure e
|