diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 11c0f9e7f6..a28dad2bed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2022,7 +2022,8 @@ data class LocalProfile( override val localAlias: String, val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + val localBadge: LocalBadge? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } @@ -2046,6 +2047,57 @@ enum class ChatPeerType { @SerialName("bot") Bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +@Serializable(with = BadgeTypeSerializer::class) +sealed class BadgeType { + @Serializable @SerialName("supporter") object Supporter: BadgeType() + @Serializable @SerialName("legend") object Legend: BadgeType() + @Serializable @SerialName("investor") object Investor: BadgeType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType() + + // the disclosed (signed) type name, shown to the user for verified badges + val text: String + get() = when (this) { + is Supporter -> "supporter" + is Legend -> "legend" + is Investor -> "investor" + is Unknown -> type + } +} + +object BadgeTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeType = + when (val v = decoder.decodeString()) { + "supporter" -> BadgeType.Supporter + "legend" -> BadgeType.Legend + "investor" -> BadgeType.Investor + else -> BadgeType.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text) +} + +@Serializable +enum class BadgeStatus { + @SerialName("active") Active, + @SerialName("expired") Expired, + @SerialName("failed") Failed +} + +@Serializable +data class BadgeInfo( + val badgeType: BadgeType, + val badgeExpiry: Instant? = null, + val badgeExtra: String = "" +) + +@Serializable +data class LocalBadge( + val badge: BadgeInfo, + val status: BadgeStatus +) + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 061ea71016..e7cccd4018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -708,7 +708,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Modifier.padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally ) { - ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight, tappableBadge = true) val displayName = contact.profile.displayName.trim() val text = buildAnnotatedString { if (contact.verified) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 8677609863..49dd43c1c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -733,7 +733,7 @@ fun GroupMemberInfoHeader(member: GroupMember) { Modifier.padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight, tappableBadge = true) val displayName = member.displayName.trim() // alias if set val text = buildAnnotatedString { if (member.verified) { @@ -900,16 +900,20 @@ fun MemberProfileImage( mem: GroupMember, color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, - async: Boolean = false + async: Boolean = false, + tappableBadge: Boolean = false ) { - ProfileImage( - size = size, - image = mem.image, - color = color, - backgroundColor = backgroundColor, - blurred = mem.blocked, - async = async - ) + val badge = mem.memberProfile.localBadge + BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) { + ProfileImage( + size = size, + image = mem.image, + color = color, + backgroundColor = backgroundColor, + blurred = mem.blocked, + async = async + ) + } } fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index a02e0dc768..47c35dbd92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -464,10 +464,10 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - ProfileImage( - image = u.image, - size = 54.dp * fontSizeSqrtMultiplier - ) + val avatarSize = 54.dp * fontSizeSqrtMultiplier + BadgedProfileImage(avatarSize, u.profile.localBadge) { + ProfileImage(image = u.image, size = avatarSize) + } Text( u.displayName, modifier = Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 5f3a73e7ea..79f4119f8a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.Icon @@ -14,10 +15,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @@ -25,7 +30,7 @@ import dev.icerock.moko.resources.ImageResource import kotlin.math.max @Composable -fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) { +fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false, tappableBadge: Boolean = false) { val icon = when (chatInfo) { is ChatInfo.Group -> chatInfo.groupInfo.chatIconName @@ -33,7 +38,10 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme is ChatInfo.Direct -> chatInfo.contact.chatIconName else -> MR.images.ic_account_circle_filled } - ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor) + val badge = if (chatInfo is ChatInfo.Direct) chatInfo.contact.profile.localBadge else null + BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) { + ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor) + } } @Composable @@ -103,6 +111,71 @@ fun ProfileImage( } } +// Overlays a supporter badge on an avatar with zero layout impact: a custom Layout measures the badge but +// reports only the avatar's size, so the badge overflows bottom-right (not clamped) and nothing around it shifts. +@Composable +fun BadgedProfileImage(size: Dp, badge: LocalBadge?, onBadgeClick: (() -> Unit)? = null, avatar: @Composable () -> Unit) { + if (badge == null) { + avatar() + return + } + Layout(content = { + avatar() + ProfileBadge(size, badge, onBadgeClick) + }) { measurables, constraints -> + val a = measurables[0].measure(constraints) + val b = measurables[1].measure(Constraints()) + layout(a.width, a.height) { + a.place(0, 0) + // phone center sits 0.33*S right and down of the avatar center, overflowing the avatar bounds + val off = (0.33f * a.width).toInt() + b.place(x = a.width / 2 + off - b.width / 2, y = a.height / 2 + off - b.height / 2) + } + } +} + +// the phone glyph (or warning triangle) scales inversely to the avatar so it stays readable when the avatar is small. +@Composable +private fun ProfileBadge(size: Dp, badge: LocalBadge, onBadgeClick: (() -> Unit)?) { + val s = size.value + val mult = 1f + 0.5f * ((192f - s) / 156f).coerceIn(0f, 1f) + val phoneH = 0.28f * size * mult + val phoneW = phoneH * 0.7617f + val mod = Modifier.size(width = phoneW, height = phoneH).let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } + if (badge.status == BadgeStatus.Failed) { + Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) + } else { + Image( + painterResource(badgeImage(badge.badge.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f, + modifier = mod + ) + } +} + +fun showBadgeInfoAlert(badge: LocalBadge) { + if (badge.status == BadgeStatus.Failed) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unverified_title), + text = generalGetString(MR.strings.badge_unverified_desc) + ) + } else { + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is shown + AlertManager.shared.showAlertMsg( + title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() }, + text = generalGetString(MR.strings.badge_verified_desc) + ) + } +} + +private fun badgeImage(t: BadgeType): ImageResource = when (t) { + is BadgeType.Legend -> MR.images.badge_legend + is BadgeType.Investor -> MR.images.badge_investor + else -> MR.images.badge_supporter // Supporter + Unknown +} + @Composable fun ProfileImage(size: Dp, image: ImageResource) { Image( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index cd0508f95a..fb0ece7d87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3105,4 +3105,7 @@ SimpleX — %d unread Minimize to tray when closing window Keep SimpleX running in the background to receive messages. + This is a verified badge - its type is signed and cannot be faked. + Unverified badge + This badge could not be verified and may not be genuine. \ No newline at end of file diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs index e8437574bb..a0b75d918c 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -46,13 +46,10 @@ module Simplex.Chat.Badges rowToBadge, ) where -import Control.Applicative ((<|>)) import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) -import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) -import qualified Data.Aeson as J +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson.TH as JQ -import Data.Aeson.Types (Parser) import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.Text (Text) @@ -138,28 +135,27 @@ deriving instance Eq (Badge 'BCCredential) deriving instance Eq (Badge 'BCProof) --- Local badge: a stored badge (own credential or peer proof) plus its display status. --- Existential - the inner Badge constructor is the discriminator. -data LocalBadge = forall b. LocalBadge (Badge b) BadgeStatus - -sameBadgeCrypto :: Badge x -> Badge y -> Bool -sameBadgeCrypto (BadgeCredential mk1 sg1 i1) (BadgeCredential mk2 sg2 i2) = mk1 == mk2 && sg1 == sg2 && i1 == i2 -sameBadgeCrypto (BadgeProof ph1 p1 i1) (BadgeProof ph2 p2 i2) = ph1 == ph2 && p1 == p2 && i1 == i2 -sameBadgeCrypto _ _ = False - -instance Show LocalBadge where - show (LocalBadge b st) = "LocalBadge (" <> show b <> ") " <> show st - -instance Eq LocalBadge where - LocalBadge b1 s1 == LocalBadge b2 s2 = s1 == s2 && sameBadgeCrypto b1 b2 +-- Local badge: a stored badge plus its display status. +-- OwnBadge - the user's own credential (loaded from the DB). +-- PeerBadge - a verified peer proof (from the DB, or received over the wire). +-- ShownBadge - decoded from a crypto-free profile JSON for display only: no crypto, so it cannot be sent. +data LocalBadge + = OwnBadge (Badge 'BCCredential) BadgeStatus + | PeerBadge (Badge 'BCProof) BadgeStatus + | ShownBadge BadgeInfo BadgeStatus + deriving (Eq, Show) localBadgeInfo :: LocalBadge -> BadgeInfo -localBadgeInfo (LocalBadge b _) = case b of - BadgeCredential _ _ i -> i - BadgeProof _ _ i -> i +localBadgeInfo = \case + OwnBadge (BadgeCredential _ _ i) _ -> i + PeerBadge (BadgeProof _ _ i) _ -> i + ShownBadge i _ -> i localBadgeStatus :: LocalBadge -> BadgeStatus -localBadgeStatus (LocalBadge _ st) = st +localBadgeStatus = \case + OwnBadge _ st -> st + PeerBadge _ st -> st + ShownBadge _ st -> st localBadgeVerified :: Maybe LocalBadge -> Maybe Bool localBadgeVerified = fmap $ \lb -> localBadgeStatus lb /= BSFailed @@ -295,16 +291,18 @@ type BadgeRow = (Maybe ByteString, Maybe ByteString, Maybe UTCTime, Maybe Text, -- receive/store sites have a wire proof + a computed verified flag badgeToRow :: Maybe (Badge 'BCProof) -> Bool -> BadgeRow -badgeToRow badge verified = localBadgeToRow $ (\b -> LocalBadge b (if verified then BSActive else BSFailed)) <$> badge +badgeToRow badge verified = localBadgeToRow $ (\b -> PeerBadge b (if verified then BSActive else BSFailed)) <$> badge localBadgeToRow :: Maybe LocalBadge -> BadgeRow -localBadgeToRow (Just (LocalBadge b st)) = case b of - BadgeCredential (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra} -> - (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), Just (BI verified), Just badgeExtra, Just mk, Just sg) - BadgeProof (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra} -> - (Just p, Just ph, badgeExpiry, Just (textEncode badgeType), Just (BI verified), Just badgeExtra, Nothing, Nothing) +localBadgeToRow (Just lb) = case lb of + OwnBadge (BadgeCredential (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), Just (BI (active st)), Just badgeExtra, Just mk, Just sg) + PeerBadge (BadgeProof (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Just p, Just ph, badgeExpiry, Just (textEncode badgeType), Just (BI (active st)), Just badgeExtra, Nothing, Nothing) + ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), Just (BI (active st)), Just badgeExtra, Nothing, Nothing) where - verified = st /= BSFailed + active st = st /= BSFailed localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing) rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge @@ -315,9 +313,9 @@ rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_) = do verified = maybe False unBI verified_ st = mkBadgeStatus now verified info case (mk_, sg_, p_, ph_) of - (Just mk, Just sg, _, _) -> Just $ LocalBadge (BadgeCredential (BadgeMasterKey mk) (BBSSignature sg) info) st - (_, _, Just p, Just ph) -> Just $ LocalBadge (BadgeProof (BBSPresHeader ph) (BBSProof p) info) st - _ -> Nothing + (Just mk, Just sg, _, _) -> Just $ OwnBadge (BadgeCredential (BadgeMasterKey mk) (BBSSignature sg) info) st + (_, _, Just p, Just ph) -> Just $ PeerBadge (BadgeProof (BBSPresHeader ph) (BBSProof p) info) st + _ -> Just $ ShownBadge info st -- JSON @@ -357,13 +355,18 @@ instance FromJSON (Badge 'BCCredential) where JBadgeCredential mk sg i -> pure (BadgeCredential mk sg i) _ -> fail "expected badge credential" --- LocalBadge round-trips (the inner Badge tags which crypto it is). +-- LocalBadge is sent to the UI/clients WITHOUT crypto - only disclosed info + status. The credential/proof +-- bytes stay core-side. FromJSON reconstructs a display-only badge (empty proof) for read-only consumers +-- (remote host, UI echoes); the authoritative badge is loaded from the DB (rowToBadge), never from this JSON. +data JSONBadge = JSONBadge {badge :: BadgeInfo, status :: BadgeStatus} + +$(JQ.deriveJSON defaultJSON ''JSONBadge) + instance ToJSON LocalBadge where - toJSON (LocalBadge b st) = J.object ["badge" .= b, "status" .= st] + toJSON lb = toJSON $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + toEncoding lb = toEncoding $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) instance FromJSON LocalBadge where - parseJSON = J.withObject "LocalBadge" $ \o -> do - st <- o .: "status" - bv <- o .: "badge" - (flip LocalBadge st <$> (parseJSON bv :: Parser (Badge 'BCProof))) - <|> (flip LocalBadge st <$> (parseJSON bv :: Parser (Badge 'BCCredential))) + parseJSON v = do + JSONBadge info st <- parseJSON v + pure $ ShownBadge info st diff --git a/src/Simplex/Chat/Badges/CLI.hs b/src/Simplex/Chat/Badges/CLI.hs index e46db34a2b..8fc6b8118a 100644 --- a/src/Simplex/Chat/Badges/CLI.hs +++ b/src/Simplex/Chat/Badges/CLI.hs @@ -3,8 +3,9 @@ {-# LANGUAGE OverloadedStrings #-} -- | Offline operator tooling for supporter badges, invoked as `simplex-chat badge ...`. --- `keygen` prints a base64url keypair (the public key is hardcoded into the app config); --- `sign` mints a credential as one-line JSON to paste into the app via `/badge add`. +-- keygen - the issuer keypair (the "secret" signs; the "public" is hardcoded into the app config). +-- master-key - the user's master secret (their unlinkability secret; generated client-side in the real flow). +-- sign - bind a user master secret to a badge with the issuer secret, printed as one-line JSON for `/badge add`. module Simplex.Chat.Badges.CLI (runBadgeCommand) where import qualified Data.Aeson as J @@ -16,19 +17,26 @@ import Data.Time.Format (defaultTimeLocale, parseTimeM) import Options.Applicative import Simplex.Chat.Badges import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen) import Simplex.Messaging.Encoding.String (strDecode, strEncode, textDecode) import System.Exit (die) +-- the issuer keypair is secret (32 bytes) <> public (96 bytes). +bbsSecretLen, bbsPublicLen :: Int +bbsSecretLen = 32 +bbsPublicLen = 96 + data BadgeCommand = Keygen - | Sign BBSSecretKey BBSPublicKey BadgeType (Maybe UTCTime) + | MasterKey + | Sign BBSSecretKey BBSPublicKey BadgeMasterKey BadgeType (Maybe UTCTime) runBadgeCommand :: [String] -> IO () runBadgeCommand args = handleParseResult (execParserPure defaultPrefs badgeInfo args) >>= \case Keygen -> keygen - Sign sk pk badgeType badgeExpiry -> sign sk pk badgeType badgeExpiry + MasterKey -> genMasterKey + Sign sk pk ms badgeType badgeExpiry -> sign sk pk ms badgeType badgeExpiry where badgeInfo = info (helper <*> hsubparser badgeCmd) fullDesc badgeCmd = command "badge" (info (helper <*> badgeCommandP) (progDesc "SimpleX supporter badge tooling")) @@ -36,16 +44,21 @@ runBadgeCommand args = badgeCommandP :: Parser BadgeCommand badgeCommandP = hsubparser $ - command "keygen" (info (pure Keygen) (progDesc "generate a BBS issuer keypair (base64url)")) - <> command "sign" (info signP (progDesc "sign a badge credential, printed as one-line JSON")) + command "keygen" (info (pure Keygen) (progDesc "generate an issuer keypair (issuer secret + public, base64url)")) + <> command "master-key" (info (pure MasterKey) (progDesc "generate a user master secret (base64url)")) + <> command "sign" (info signP (progDesc "sign a badge for a user master secret, printed as one-line JSON")) where signP = - Sign - <$> keyOpt "secret" "SK" "issuer secret key (base64url)" - <*> keyOpt "key" "PK" "issuer public key (base64url)" - <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, business, ...)") + (\(sk, pk) -> Sign sk pk) + <$> option (eitherReader issuerKeyR) (long "secret" <> metavar "ISSUER_KEY" <> help "issuer keypair from keygen (base64url)") + <*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)") + <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)") <*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'") - keyOpt l m h = option (eitherReader $ strDecode . B.pack) (long l <> metavar m <> help h) + issuerKeyR s = do + kp <- strDecode (B.pack s) + if B.length kp == bbsSecretLen + bbsPublicLen + then let (sk, pk) = B.splitAt bbsSecretLen kp in Right (BBSSecretKey sk, BBSPublicKey pk) + else Left "bad issuer key - use the 'secret' value from keygen" badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack expireR = \case "lifetime" -> Right Nothing @@ -55,16 +68,20 @@ keygen :: IO () keygen = bbsKeyGen >>= \case Left e -> die $ "keygen failed: " <> e - Right (sk, pk) -> do - B.putStrLn $ "secret " <> strEncode sk + Right (BBSSecretKey sk, BBSPublicKey pk) -> do + B.putStrLn $ "secret " <> strEncode (sk <> pk) B.putStrLn $ "public " <> strEncode pk -sign :: BBSSecretKey -> BBSPublicKey -> BadgeType -> Maybe UTCTime -> IO () -sign secretKey publicKey badgeType badgeExpiry = do +genMasterKey :: IO () +genMasterKey = do drg <- C.newRandom - masterKey <- generateMasterKey drg - let req = VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} + mk <- generateMasterKey drg + B.putStrLn $ strEncode mk + +sign :: BBSSecretKey -> BBSPublicKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO () +sign secretKey publicKey masterKey' badgeType badgeExpiry = do + let req = VerifiedBadgeRequest BadgeRequest {masterKey = masterKey', badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} issueBadge secretKey publicKey req >>= \case Left e -> die $ "sign failed: " <> e - -- single-line JSON, pasted into the app via `/badge add` + -- single-line JSON (master secret + signature + info), pasted into the app via `/badge add` Right cred -> LB.putStrLn $ J.encode cred diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e5341ae695..fd4e0592d8 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4650,7 +4650,7 @@ addUserBadge user cred = do key <- asks $ badgePublicKey . config verified <- liftIO $ verifyCredential key cred unless verified $ throwCmdError "badge credential does not verify against configured key" - user' <- withFastStore' $ \db -> setUserBadge db user (Just (LocalBadge cred BSActive)) + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred BSActive)) asks currentUser >>= atomically . (`writeTVar` Just user') cxt <- asks $ mkStoreCxt . config contacts <- withFastStore' $ \db -> getUserContacts db cxt user' diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f5ff24b5c2..ec9564765c 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1900,7 +1900,7 @@ sendDirectContactMessages' user ct events = do -- only own credentials present (peers carry proofs, which are not re-presented). callers must not present on incognito sends. presentUserBadge :: User -> Profile -> CM Profile presentUserBadge User {profile = LocalProfile {localBadge}} p = case localBadge of - Just (LocalBadge cred@BadgeCredential {} _) -> do + Just (OwnBadge cred _) -> do key <- asks $ badgePublicKey . config liftIO (badgeProof key cred PHTest) >>= \case Right proof -> pure p {badge = Just proof} diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index a3ed1d7782..2d5e9a18d6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -782,16 +782,18 @@ toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Bool -> Local toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} localAlias now verified = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias} where - localBadge = (\b@(BadgeProof _ _ info) -> LocalBadge b (mkBadgeStatus now verified info)) <$> badge + localBadge = (\b@(BadgeProof _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge} = Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge} where + -- only a verified peer proof rides the wire; the own credential is presented fresh, and a display-only badge never sends wireBadge :: LocalBadge -> Maybe (Badge 'BCProof) - wireBadge (LocalBadge b _) = case b of - BadgeProof {} -> Just b - BadgeCredential {} -> Nothing + wireBadge = \case + PeerBadge b _ -> Just b + OwnBadge _ _ -> Nothing + ShownBadge _ _ -> Nothing profileBadgeVerified :: BBSPublicKey -> LocalProfile -> Profile -> IO Bool profileBadgeVerified key LocalProfile {localBadge} Profile {badge = newBadge} =