core: redeem badge codes (#7438)

This commit is contained in:
spaced4ndy
2026-09-01 12:06:51 +00:00
committed by GitHub
parent ed2cf1d98f
commit 3ddf17dce5
28 changed files with 1408 additions and 96 deletions
+1
View File
@@ -77,6 +77,7 @@ defaultChatConfig =
(7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"),
(8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd")
],
badgeServiceAddress = Nothing,
confirmMigrations = MCConsole,
-- this property should NOT use operator = Nothing
-- non-operator servers can be passed via options
+118
View File
@@ -0,0 +1,118 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Badge redemption codes, shared by the client, the badge service and the checkout site.
--
-- A code is @SXB-@ 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 = "SXB"
-- | 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
+79 -4
View File
@@ -3,13 +3,18 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.Badges.Service
( BadgeServiceRequest (..),
BadgeServiceCommand (..),
BadgeServiceVersion,
VersionBadgeService,
VersionRangeBadgeService,
pattern VersionBadgeService,
initialBadgeServiceVersion,
currentBadgeServiceVersion,
supportedBadgeServiceVRange,
BadgeUpgrade (..),
BadgeServiceResponse (..),
BadgeServiceErrorCode (..),
@@ -24,9 +29,12 @@ module Simplex.Chat.Badges.Service
StatementDebitType (..),
) where
import Data.Aeson (FromJSON (..), ToJSON (..))
import Control.Applicative ((<|>))
import Data.Aeson (FromJSON (..), ToJSON (..), (.:))
import qualified Data.Aeson as J
import Data.Int (Int64)
import qualified Data.Aeson.Encoding as JE
import qualified Data.Aeson.TH as JQ
import qualified Data.Aeson.Types as JT
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word8, Word16, Word32)
@@ -35,7 +43,8 @@ import Simplex.Chat.Badges.Types
import Simplex.Chat.PaymentService
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Version (VersionScope)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON)
import Simplex.Messaging.Version (VersionRange, VersionScope, mkVersionRange)
import Simplex.Messaging.Version.Internal (Version (..))
data BadgeServiceVersion
@@ -47,6 +56,18 @@ type VersionBadgeService = Version BadgeServiceVersion
pattern VersionBadgeService :: Word16 -> VersionBadgeService
pattern VersionBadgeService v = Version v
type VersionRangeBadgeService = VersionRange BadgeServiceVersion
initialBadgeServiceVersion :: VersionBadgeService
initialBadgeServiceVersion = VersionBadgeService 1
currentBadgeServiceVersion :: VersionBadgeService
currentBadgeServiceVersion = VersionBadgeService 1
-- the service is deployed ahead of app releases, so it answers within the client's version
supportedBadgeServiceVRange :: VersionRangeBadgeService
supportedBadgeServiceVRange = mkVersionRange initialBadgeServiceVersion currentBadgeServiceVersion
data BadgeServiceRequest = BadgeServiceRequest
{ version :: VersionBadgeService,
purchaseKey :: Maybe C.PublicKeyEd25519, -- optional for BSCGetBadgeCatalog, required for other commands
@@ -164,7 +185,7 @@ data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {de
data StatementCreditType
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments
| SCCharge {chargeId :: Int64}
| SCCharge {chargeId :: Text}
| SCSupport
| SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519}
| SCOpening
@@ -248,3 +269,57 @@ instance ToJSON BadgeServiceErrorCode where
instance FromJSON BadgeServiceErrorCode where
parseJSON = textParseJSON "BadgeServiceErrorCode"
$(pure [])
instance FromJSON StatementCreditType where
parseJSON v@(J.Object j) =
$(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v
<|> SCUnknown <$> j .: "type" <*> pure j
parseJSON invalid =
JT.prependFailure "bad StatementCreditType, " (JT.typeMismatch "Object" invalid)
instance ToJSON StatementCreditType where
toJSON = \case
SCUnknown _ j -> J.Object j
v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v
toEncoding = \case
SCUnknown _ j -> JE.value $ J.Object j
v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v
instance FromJSON StatementDebitType where
parseJSON v@(J.Object j) =
$(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v
<|> SDUnknown <$> j .: "type" <*> pure j
parseJSON invalid =
JT.prependFailure "bad StatementDebitType, " (JT.typeMismatch "Object" invalid)
instance ToJSON StatementDebitType where
toJSON = \case
SDUnknown _ j -> J.Object j
v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v
toEncoding = \case
SDUnknown _ j -> JE.value $ J.Object j
v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SE") ''StatementEntryType)
$(JQ.deriveJSON defaultJSON ''StatementEntry)
$(JQ.deriveJSON defaultJSON ''BadgeStatement)
$(JQ.deriveJSON defaultJSON ''BadgeBalance)
$(JQ.deriveJSON defaultJSON ''BadgeUpgrade)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSC") ''BadgeServiceCommand)
$(JQ.deriveJSON defaultJSON ''BadgeServiceRequest)
$(JQ.deriveJSON defaultJSON ''BadgePrice)
$(JQ.deriveJSON defaultJSON ''BadgeOffer)
$(JQ.deriveJSON defaultJSON ''BadgeCatalog)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSP") ''BadgeServiceResponse)
+54 -2
View File
@@ -1,6 +1,10 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.Badges.Types
( BadgePriceId (..),
@@ -22,7 +26,9 @@ module Simplex.Chat.Badges.Types
UserBadgeState (..),
) where
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
@@ -30,15 +36,25 @@ import Data.Word (Word8)
import Simplex.Chat.Badges hiding (BadgePurchase (..))
import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment)
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple.FromField (FromField (..))
import Database.PostgreSQL.Simple.ToField (ToField (..))
#else
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
#endif
-- confirmed
newtype BadgePriceId = BadgePriceId Text
deriving newtype (Eq, Show)
deriving newtype (Eq, Show, ToJSON, FromJSON)
-- confirmed
newtype BadgeOfferId = BadgeOfferId Text
deriving newtype (Eq, Show)
deriving newtype (Eq, Show, ToJSON, FromJSON)
-- unconfirmed draft
data BadgePlan = BPOneTime | BPMonthly | BPAnnual
@@ -172,3 +188,39 @@ data UserBadgeState = UserBadgeState
willRenew :: Bool,
alert :: Maybe BadgeAlert
}
instance TextEncoding BadgePurchaseStatus where
textEncode = \case
PSAcquiring -> "acquiring"
PSIssued -> "issued"
PSSuperseded -> "superseded"
PSFailed -> "failed"
textDecode = \case
"acquiring" -> Just PSAcquiring
"issued" -> Just PSIssued
"superseded" -> Just PSSuperseded
"failed" -> Just PSFailed
_ -> Nothing
instance FromField BadgePurchaseStatus where fromField = fromTextField_ textDecode
instance ToField BadgePurchaseStatus where toField = toField . textEncode
instance TextEncoding BadgeCodePaymentStatus where
textEncode = \case
CPSPaid -> "paid"
CPSUnpaid -> "unpaid"
CPSFree -> "free"
textDecode = \case
"paid" -> Just CPSPaid
"unpaid" -> Just CPSUnpaid
"free" -> Just CPSFree
_ -> Nothing
instance FromField BadgeCodePaymentStatus where fromField = fromTextField_ textDecode
instance ToField BadgeCodePaymentStatus where toField = toField . textEncode
$(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount)
+36
View File
@@ -0,0 +1,36 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Simplex.Chat.Bot.Store
( storeCxt,
withDB,
withDB',
)
where
import Control.Logger.Simple
import Control.Monad.Except
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Chat.Controller
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Util (catchAll)
storeCxt :: ChatController -> StoreCxt
storeCxt ChatController {config} = mkStoreCxt config
{-# INLINE storeCxt #-}
withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a)
withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a
withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a)
withDB cxt ChatController {chatStore} action = do
r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show)
case r_ of
Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e
Right _ -> pure ()
pure r_
+5 -1
View File
@@ -83,7 +83,7 @@ import Simplex.Messaging.Agent.Store.DB (SQLError)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Badges (BadgeCredential)
import Simplex.Chat.Badges (BadgeCredential, LocalBadge)
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -143,6 +143,8 @@ data ChatConfig = ChatConfig
chatVRange :: VersionRangeChat,
-- issuer public keys by index: credentials and proofs name the key that signed them, for rotation
badgePublicKeys :: Map Int BBSPublicKey,
-- Nothing until the badge service is deployed
badgeServiceAddress :: Maybe (ConnectTarget 'CMContact),
confirmMigrations :: MigrationConfirmation,
presetServers :: PresetServers,
shortLinkPresetServers :: NonEmpty SMPServer,
@@ -638,6 +640,7 @@ data ChatCommand
| UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI)
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
| ShowProfileImage
| SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI)
| SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed)
@@ -842,6 +845,7 @@ data ChatResponse
| CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact}
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
+116 -29
View File
@@ -56,7 +56,9 @@ import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -77,6 +79,7 @@ import Simplex.Chat.Library.Internal
import Simplex.Chat.Stats
import Simplex.Chat.Store
import Simplex.Chat.Store.AppSettings
import Simplex.Chat.Store.Badges
import Simplex.Chat.Store.ContactRequest
import Simplex.Chat.Store.Connections
import Simplex.Chat.Store.Delivery
@@ -1464,26 +1467,8 @@ processChatCommand cxt nm = \case
liftIO $ deleteContactRequest db user connReqId
pure ct_
pure $ CRContactRequestRejected user cReq ct_
APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> do
cReq <- resolveServiceTarget user sendTarget
respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout (C.unStored <$> signKey) (LB.toStrict $ J.encode request)
resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData
pure $ CRServiceResponse user resp
where
resolveServiceTarget user = \case
CTFullContact cReq -> pure cReq
CTShortContact (CTLink sLnk) -> resolveShortLink sLnk
CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of
NTContact -> resolveDomain nameDomain
_ -> throwCmdError "service request target must be a contact"
CTDomain d -> resolveDomain d
where
resolveDomain d = do
nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d
case firstNameLink CCTContact (nrSimplexContact nr) of
Just sLnk -> resolveShortLink sLnk
Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink
resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk
APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user ->
CRServiceResponse user <$> sendServiceRequestTo nm user sendTarget requestTimeout (C.unStored <$> signKey) request
APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do
let AgentInvId invId = requestId
connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData)
@@ -3552,6 +3537,7 @@ processChatCommand cxt nm = \case
pure $ CRFileTransferStatus user fileStatus
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
SetBotCommands commands -> withUser $ \user@User {profile} -> do
let LocalProfile {preferences} = profile
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
@@ -5133,20 +5119,31 @@ createContactsSndFeatureItems user cts =
CUPContact {preference} -> preference
CUPUser {preference} -> preference
-- | Verify an own credential against the configured issuer keys.
-- Nothing means its key index is not among them, so this version cannot verify it at all.
verifyOwnBadge :: BadgeCredential -> CM (Maybe Bool)
verifyOwnBadge cred@(BadgeCredential keyIdx _ _ _) = do
keys <- asks $ badgePublicKeys . config
forM (M.lookup keyIdx keys) $ \key -> liftIO $ verifyCredential key cred
-- attach an issued badge credential to the user's own profile and present it to all current contacts.
-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge).
addUserBadge :: User -> BadgeCredential -> CM ()
addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do
keys <- asks $ badgePublicKeys . config
key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys
verified <- liftIO $ verifyCredential key cred
unless verified $ throwCmdError "badge credential does not verify against configured key"
now <- liftIO getCurrentTime
user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
addUserBadge user cred@(BadgeCredential _ _ _ info) =
verifyOwnBadge cred >>= \case
Nothing -> throwCmdError "unknown badge key index"
Just False -> throwCmdError "badge credential does not verify against configured key"
Just True -> do
now <- liftIO getCurrentTime
user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
presentUserBadgeToContacts user'
presentUserBadgeToContacts :: User -> CM ()
presentUserBadgeToContacts user' = do
asks currentUser >>= atomically . (`writeTVar` Just user')
cxt <- asks $ mkStoreCxt . config
contacts <- withFastStore' $ \db -> getUserContacts db cxt user'
withChatLock "addUserBadge" $ forM_ contacts $ \ct ->
withChatLock "presentUserBadge" $ forM_ contacts $ \ct ->
case contactSendConn_ ct of
Right conn
| not (connIncognito conn) -> do
@@ -5155,6 +5152,95 @@ addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do
void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView
_ -> pure ()
-- | The check character is verified before anything leaves the device, and the signing keys are
-- stashed before the request is sent, so a retry reaches the service as the same signer.
-- A terminal answer drops the stash; a timeout keeps it.
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
redeemBadgeCode nm user codeText = do
code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure
g <- asks random
now <- liftIO getCurrentTime
let codeSent = badgeCodeText code
redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <-
withStore' $ \db ->
getBadgeCodeRedemption db user codeSent
>>= maybe (createBadgeCodeRedemption db g user codeSent now) pure
let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}}
respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData
J.Success BSPError {code = errCode} -> do
when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption)
throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode)
J.Success BSPBadgeCredential {credential = Just cred} -> storeRedeemedBadge user redemption cred
J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData
where
-- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on
respJSON = LB.unpack . J.encode
-- the code will never work, so the keys stashed for it are dead; a timeout keeps them
terminalCodeError = \case
BSECodeInvalid -> True
BSECodeUsed -> True
BSECodeExpired -> True
_ -> False
-- | An unknown code is reported, since the service is deployed ahead of clients, but its text is
-- the service's - so it is bounded and stripped before reaching a terminal that acts on controls.
badgeServiceErrorText :: BadgeServiceErrorCode -> Text
badgeServiceErrorText = \case
BSEUnknown t -> case T.filter errorCodeChar (T.take 32 t) of
"" -> "unknown"
t' -> t'
code -> textEncode code
where
errorCodeChar c = isAsciiLower c || isDigit c || c == '_'
-- | Verify the credential before writing anything; the purchase, its issuance and the profile's
-- badge go in one transaction, and contacts are told after it commits.
storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> CM ChatResponse
storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeExpiry}) =
verifyOwnBadge cred >>= \case
Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index"
Just False -> throwCmdError "redeemed badge credential does not verify against configured key"
-- verifyCredential checks the signature against the key inside the credential, not the one we
-- sent - so a credential over any other master key also verifies
Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key"
Just True -> do
-- badge_issuances requires a period; a credential without an expiry has none to record
expiry <- maybe (throwCmdError "redeemed badge credential has no expiry") pure badgeExpiry
g <- asks random
now <- liftIO getCurrentTime
let badge = OwnBadge cred (mkBadgeStatus now (Just True) info)
-- TODO [badges] copy the statement's ledger entries, and retire a previously held badge
(user', newBadge) <- withStore' $ \db -> do
newBadge <- createCodeBadgePurchase db g user redemption cred expiry now
-- a replay must not put a superseded badge back, or tell every contact again
user' <- if newBadge then setUserBadge db user (Just badge) else pure user
pure (user', newBadge)
when newBadge $ presentUserBadgeToContacts user'
pure $ CRBadgeRedeemed user' badge newBadge
sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object
sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do
cReq <- resolveServiceTarget sendTarget
respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout signKey (LB.toStrict $ J.encode request)
either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData
where
resolveServiceTarget = \case
CTFullContact cReq -> pure cReq
CTShortContact (CTLink sLnk) -> resolveShortLink sLnk
CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of
NTContact -> resolveDomain nameDomain
_ -> throwCmdError "service request target must be a contact"
CTDomain d -> resolveDomain d
resolveDomain d = do
nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d
case firstNameLink CCTContact (nrSimplexContact nr) of
Just sLnk -> resolveShortLink sLnk
Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink
resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk
assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM ()
assertDirectAllowed user dir ct event =
unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $
@@ -5539,6 +5625,7 @@ chatCommandP =
"/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal),
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
"/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP),
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
+7
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.PaymentService
( ServiceInvoice (..),
@@ -6,9 +7,11 @@ module Simplex.Chat.PaymentService
module Simplex.Chat.PaymentService.Types,
) where
import qualified Data.Aeson.TH as JQ
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.PaymentService.Types
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON)
data ServiceInvoice = ServiceInvoice
{ invoiceId :: InvoiceId,
@@ -28,3 +31,7 @@ data ServicePayment
| SPInvoice {invoiceId :: InvoiceId}
| SPReceipt {receipt :: Text} -- transfer of unissued months
deriving (Show)
$(JQ.deriveJSON defaultJSON ''ServiceInvoice)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SP") ''ServicePayment)
+14 -1
View File
@@ -1,6 +1,7 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.PaymentService.Types
( CurrencyAmount (..),
@@ -19,18 +20,22 @@ module Simplex.Chat.PaymentService.Types
PaymentStatus (..),
) where
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson.TH as JQ
import Data.ByteString.Char8 (ByteString)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word32)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON)
-- USD etc. are in minor units, following Stripe etc. convention
newtype CurrencyAmount = CurrencyAmount Word32
deriving (Eq, Show)
deriving newtype (ToJSON, FromJSON)
-- confirmed
newtype InvoiceId = InvoiceId Text
deriving newtype (Eq, Show)
deriving newtype (Eq, Show, ToJSON, FromJSON)
-- confirmed
newtype PaymentId = PaymentId Text
@@ -132,3 +137,11 @@ data PaymentTerm
-- to review
data PaymentStatus = PSPending | PSSettled | PSFailed {exception :: Text}
deriving (Show)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CP") ''CardProvider)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CC") ''CryptoCurrency)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPM") ''ServicePaymentMethod)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPD") ''ServicePaymentDestination)
+133
View File
@@ -0,0 +1,133 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Badges
( BadgeCodeRedemption (..),
getBadgeCodeRedemption,
createBadgeCodeRedemption,
deleteBadgeCodeRedemption,
createCodeBadgePurchase,
)
where
import Control.Concurrent.STM (TVar, atomically)
import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson as J
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Types (BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (maybeFirstRow, safeDecodeUtf8)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple.QQ (sql)
#endif
-- | The keys one redemption attempt is signed with, stashed before the request is sent so that a
-- retry reaches the service as the same signer and is answered with the credential already issued.
data BadgeCodeRedemption = BadgeCodeRedemption
{ redemptionId :: Int64,
purchaseKey :: C.PublicKeyEd25519,
purchasePrivKey :: C.PrivateKeyEd25519,
masterKey :: BadgeMasterKey
}
getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeCodeRedemption)
getBadgeCodeRedemption db User {userId} code =
maybeFirstRow toRedemption $
DB.query
db
[sql|
SELECT badge_code_redemption_id, purchase_key, purchase_priv_key, master_key
FROM badge_code_redemptions
WHERE user_id = ? AND code = ?
|]
(userId, code)
where
toRedemption (redemptionId, purchaseKey, purchasePrivKey, Binary mk) =
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk}
createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeCodeRedemption
createBadgeCodeRedemption db g User {userId} code now = do
(purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g
masterKey@(BadgeMasterKey mk) <- generateMasterKey g
DB.execute
db
[sql|
INSERT INTO badge_code_redemptions (user_id, code, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?)
|]
(userId, code, purchaseKey, purchasePrivKey, Binary mk, now)
redemptionId <- insertedRowId db
pure BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey}
-- | Drop a stashed attempt whose code the service refused for good, unless a purchase already
-- came from it - badge_purchases references this row.
deleteBadgeCodeRedemption :: DB.Connection -> Int64 -> IO ()
deleteBadgeCodeRedemption db redemptionId =
DB.execute
db
[sql|
DELETE FROM badge_code_redemptions
WHERE badge_code_redemption_id = ?
AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?)
|]
(redemptionId, redemptionId)
-- | The purchase a redeemed code created, its issuance, and the profile's pointer to it.
-- False when the code was already redeemed here: the service replays the credential it issued,
-- and that must add no purchase and leave the shown badge alone. The expiry is passed in because
-- badge_issuances requires one and the caller has already resolved it.
createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool
createCodeBadgePurchase db g User {userId} redemption credential expiry now =
getCodeBadgePurchase db redemption >>= \case
Just _ -> pure False
Nothing -> do
purchaseId <- insertPurchase
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId)
pure True
where
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption
BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential
insertPurchase = do
DB.execute
db
[sql|
INSERT INTO badge_purchases
(user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
|]
(userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now)
purchaseId <- insertedRowId db
issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g)
-- TODO [badges] the credential's expiry stands in for the period end, which is up to a
-- week later, until the statement carries the real period
DB.execute
db
[sql|
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(issuanceId, purchaseId, badgeType, now, expiry, expiry, Binary (LB.toStrict $ J.encode credential), now)
pure purchaseId
getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64)
getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} =
maybeFirstRow fromOnly $
DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId)
@@ -251,7 +251,7 @@ CREATE TABLE badge_code_redemptions(
purchase_priv_key BYTEA NOT NULL,
master_key BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(code)
UNIQUE(user_id, code)
);
CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id);
@@ -1763,12 +1763,12 @@ ALTER TABLE test_chat_schema.xftp_file_descriptions ALTER COLUMN file_descr_id A
ALTER TABLE ONLY test_chat_schema.badge_code_redemptions
ADD CONSTRAINT badge_code_redemptions_code_key UNIQUE (code);
ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id);
ALTER TABLE ONLY test_chat_schema.badge_code_redemptions
ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id);
ADD CONSTRAINT badge_code_redemptions_user_id_code_key UNIQUE (user_id, code);
@@ -252,7 +252,7 @@ CREATE TABLE badge_code_redemptions(
purchase_priv_key BLOB NOT NULL,
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(code)
UNIQUE(user_id, code)
) STRICT;
CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id);
@@ -1203,6 +1203,19 @@ Query:
Plan:
SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?)
Query:
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
Plan:
Query:
INSERT INTO badge_purchases
(user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
Plan:
Query:
INSERT INTO chat_item_reactions
(contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts)
@@ -3463,6 +3476,14 @@ Query:
Plan:
SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?)
Query:
SELECT badge_code_redemption_id, purchase_key, purchase_priv_key, master_key
FROM badge_code_redemptions
WHERE user_id = ? AND code = ?
Plan:
SEARCH badge_code_redemptions USING INDEX sqlite_autoindex_badge_code_redemptions_1 (user_id=? AND code=?)
Query:
SELECT c.agent_conn_id
FROM connections c
@@ -4297,6 +4318,17 @@ SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?)
SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?)
Query:
DELETE FROM badge_code_redemptions
WHERE badge_code_redemption_id = ?
AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?)
Plan:
SEARCH badge_code_redemptions USING INTEGER PRIMARY KEY (rowid=?)
SCALAR SUBQUERY 1
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?)
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?)
Query:
DELETE FROM chat_items
WHERE group_scope_group_member_id = ?
@@ -4782,6 +4814,12 @@ LIST SUBQUERY 1
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?)
Query:
INSERT INTO badge_code_redemptions (user_id, code, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?)
Plan:
Query:
INSERT INTO calls
(contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
@@ -6944,6 +6982,8 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us
Query: DELETE FROM users WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?)
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?)
SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?)
SEARCH chat_tags USING COVERING INDEX idx_chat_tags_user_id (user_id=?)
SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?)
@@ -7177,6 +7217,10 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?
Plan:
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?)
Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?
Plan:
SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name
@@ -7982,6 +8026,10 @@ Query: UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET shown_badge_id = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
@@ -996,7 +996,7 @@ CREATE TABLE badge_code_redemptions(
purchase_priv_key BLOB NOT NULL,
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(code)
UNIQUE(user_id, code)
) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
+2
View File
@@ -188,6 +188,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"]
CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp]
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results