store: check owner signatures and messages

This commit is contained in:
shum committed 2026-10-21 22:07:25 +00:00
1 parent 27a99dc19a
commit 606818ba8b
4 files changed
+314

No files matched your search

@@ -0,0 +1,120 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module StoreService.Names.Signatures
( SignatureError (..),
AddressError (..),
OrderFields (..),
parseSignature,
personalDigest,
orderMessage,
findMessage,
retargetMessage,
takenCodeMessage,
parseChecksumAddress,
parseAnyCaseAddress,
)
where
import Control.Monad (unless, when)
import Crypto.Number.Serialize (os2ip)
import qualified Crypto.PubKey.ECC.Types as ECC
import Data.Bifunctor (first)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.Char (isAsciiLower, isDigit, isHexDigit)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word64)
import Simplex.Messaging.Crypto (keccak256, sha256Hash)
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature (..))
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
import Simplex.Messaging.Eth.Address (Address, unAddress)
import Simplex.Messaging.Util (tshow)
import StoreService.Names.Abi (hex)
data SignatureError
= SEFormat
| SERecoveryId
| SEHighS
deriving (Eq, Show)
data AddressError
= ADFormat
| ADChecksum
deriving (Eq, Show)
-- | The fields an owner signs in an order and in another name for it.
data OrderFields = OrderFields
{ ofLabelHash :: ByteString,
ofLength :: Int,
ofDuration :: Word64,
ofCommitment :: ByteString,
ofSealed :: Text
}
signatureHexLength :: Int
signatureHexLength = 130
scalarSize :: Int
scalarSize = 32
addressHexLength :: Int
addressHexLength = 40
-- the contracts' SignatureChecker refuses a higher s, though recovery accepts it
maxS :: Integer
maxS = ECC.ecc_n (ECC.common_curve $ ECC.getCurveByName ECC.SEC_p256k1) `div` 2
parseSignature :: Text -> Either SignatureError RecoverableSignature
parseSignature t = do
h <- maybe (Left SEFormat) Right $ T.stripPrefix "0x" t
unless (T.length h == signatureHexLength && T.all isLowerHex h) $ Left SEFormat
sig <- first (const SEFormat) $ convertFromBase Base16 (encodeUtf8 h)
let (rsR, (rsS, v)) = B.splitAt scalarSize <$> B.splitAt scalarSize sig
rsRecId <- case B.unpack v of
[27] -> Right 0
[28] -> Right 1
_ -> Left SERecoveryId
when (os2ip rsS > maxS) $ Left SEHighS
pure RecoverableSignature {rsR, rsS, rsRecId}
where
isLowerHex c = isDigit c || (isAsciiLower c && isHexDigit c)
-- | EIP-191 @personal_sign@ digest.
personalDigest :: ByteString -> ByteString
personalDigest msg = keccak256 $ "\x19\&Ethereum Signed Message:\n" <> BC.pack (show $ B.length msg) <> msg
orderMessage :: OrderFields -> Word64 -> ByteString
orderMessage order timestamp = message $ ["order"] <> orderWords order <> [tshow timestamp]
findMessage :: Address -> Word64 -> ByteString
findMessage owner timestamp = message ["find", hex $ unAddress owner, tshow timestamp]
retargetMessage :: Text -> OrderFields -> Word64 -> ByteString
retargetMessage registrationId order timestamp = message $ ["name", registrationId] <> orderWords order <> [tshow timestamp]
takenCodeMessage :: Text -> Text -> Int -> Word64 -> ByteString
takenCodeMessage registrationId codeHash codeLength timestamp = message ["code", registrationId, codeHash, tshow codeLength, tshow timestamp]
message :: [Text] -> ByteString
message = encodeUtf8 . T.unwords . ("simplex.domains" :)
orderWords :: OrderFields -> [Text]
orderWords OrderFields {ofLabelHash, ofLength, ofDuration, ofCommitment, ofSealed} =
[hex ofLabelHash, tshow ofLength, tshow ofDuration, hex ofCommitment, hex $ sha256Hash $ encodeUtf8 ofSealed]
-- the address parser checks EIP-55 only on mixed-case hex, so one-case text is refused here by comparing the encoding
parseChecksumAddress :: Text -> Either AddressError Address
parseChecksumAddress t = do
a <- parseAnyCaseAddress t
unless (strEncode a == encodeUtf8 t) $ Left ADChecksum
pure a
parseAnyCaseAddress :: Text -> Either AddressError Address
parseAnyCaseAddress t = case T.stripPrefix "0x" t of
Just h | T.length h == addressHexLength && T.all isHexDigit h -> first (const ADFormat) $ strDecode $ encodeUtf8 $ T.toLower h
_ -> Left ADFormat
+3
View File
@@ -443,6 +443,7 @@ executable simplex-store-service
StoreService.Group.Command
StoreService.Log
StoreService.Names.Abi
StoreService.Names.Signatures
StoreService.Options
StoreService.Orders
StoreService.Poller
@@ -739,6 +740,7 @@ test-suite simplex-chat-test
Bots.StoreService.GroupIntegrationTests
Bots.StoreService.GroupTests
Bots.StoreService.NameRegistrationTests
Bots.StoreService.NamesSignatureTests
Bots.StoreService.StripeTests
Bots.StoreService.WaitersTests
Bots.StoreService.WebTests
@@ -763,6 +765,7 @@ test-suite simplex-chat-test
StoreService.Names.Abi
StoreService.Names.Chain
StoreService.Names.Registration
StoreService.Names.Signatures
StoreService.Options
StoreService.Orders
StoreService.Poller
@@ -0,0 +1,189 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Bots.StoreService.NamesSignatureTests (namesSignatureTests) where
import Control.Monad (forM_)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word64)
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature (..))
import Simplex.Messaging.Encoding.String (strDecode)
import Simplex.Messaging.Eth.Address (Address, recoverAddress)
import Simplex.Messaging.SimplexName (SimplexTLD (..))
import StoreService.Names.Abi
import StoreService.Names.Registration
import StoreService.Names.Signatures
import Test.Hspec
namesSignatureTests :: Spec
namesSignatureTests = describe "StoreService names: signatures" $ do
-- expected values are viem's, for the webapp's fixture wallet and the SimplexController, SimplexResolver and SimplexPriceOracle ABIs
describe "EIP-712" $ do
it "commits to a registration with one contact link" $
makeCommitment dynamis `shouldBe` commitment
it "hashes RegisterName" $
registerIntentDigest (sponsorDomain chainId sponsor) dynamis 0 1800000000 `shouldBe` hx "0x4b0bcc282db581446f6c25e0e3fbe2839409acbc011bb3e082523dd9eab85a6a"
it "hashes SetText" $
setTextDigest (resolverDomain chainId resolver) node "simplex.contact" "https://x" 3 99 `shouldBe` hx "0xa62d4320889eed3713462d725dc298cd96885572b8e6b424e93a205a96b96fb5"
it "hashes ClearRecords" $
clearRecordsDigest (resolverDomain chainId resolver) node 4 99 `shouldBe` hx "0xca4bd243288f27f38e3b45384ba36ce685ff155352cf8125f031e6fb189a46dd"
describe "EIP-191 messages" $ do
it "signs an order" $
testMessage
(orderMessage dynamisOrder timestamp)
"simplex.domains order 0x78b602f03f164366531da963764c1f2fdfd588d0487daf2aa261ca799e5ffe33 7 69259500 0x9cf04ddef2eedfb02e6e7ba58f879c0636c584b6acc67ffe9f97a06bfb016ac1 0xb1971beb567c8dc1b2f01f0d823754c6168d8d6830db2221702a7678e1804674 1792574100"
"0x5deee1b3589df9e53665e34aa65a491a4eabfd76a39207b0e9303457f7c44b7a"
"0x73e32be461e99e64b093a4ad09b04b4d810e5623a1b0e9c9fdb4701ec2a6a2a84df9ffdccc39c5cb827cde4d846c7662f591665f1b868ab1c15778646d3f414e1b"
it "signs find with the owner in lowercase" $
testMessage
(findMessage owner timestamp)
"simplex.domains find 0x014f9c09cceedef1b713110d04d3a07d5b5d9b73 1792574100"
"0x08a989fbf0457984c062b44d7237be9c7948f2cf23d48410609777a09b515646"
"0xfa0908260c3f21c8e47bd37e2c3cf3bd6bc62966591ab1a2c4e31695646548f47d2e79b34413be27b4b07330682de642f48a8f5e641449d7d79728eeb4ab14da1c"
it "signs another name for an order, with its duration" $
testMessage
(retargetMessage registrationId dynamisOrder {ofLabelHash = hx "0x5bfbbf75311ec002834031d73fa30bfe8bdf675f4a3df72443841490c4b49d02"} timestamp)
"simplex.domains name t2Jk8RmQ4vXw9NcP3yLb6g 0x5bfbbf75311ec002834031d73fa30bfe8bdf675f4a3df72443841490c4b49d02 7 69259500 0x9cf04ddef2eedfb02e6e7ba58f879c0636c584b6acc67ffe9f97a06bfb016ac1 0xb1971beb567c8dc1b2f01f0d823754c6168d8d6830db2221702a7678e1804674 1792574100"
"0x8bc79aa98fbd53013e8961db27554ecd17139e14ec168b86afbaf2fc910bfc52"
"0xe3d680bcc62255179d5260beeb920319c86bd919f6b01927bee01509cf6c1fe3121151947fb9e3a310e6652c0c9a9bf7ceb48e8d903774a38a3162d916e8b4591b"
it "signs a taken order's code, with its length" $
testMessage
(takenCodeMessage registrationId "7hg6ED6EotRVm21uS1KOZ-CKEzkJK5hvG4W5atfcn_Q" 7 timestamp)
"simplex.domains code t2Jk8RmQ4vXw9NcP3yLb6g 7hg6ED6EotRVm21uS1KOZ-CKEzkJK5hvG4W5atfcn_Q 7 1792574100"
"0x1f61297cd585c7c94218f8f87f30ea20e9b0631d36890460f24ee9bca83a2a19"
"0x897173292eb63ac3587d3c900af08dee71f296af8718ec045dd49ce953bb0f666eefe546bea0d3623022c7cc7f03cc572a498bb0ceac26bb46ed37c9e99ecdb51b"
describe "signature format" $ do
it "refuses a high s, though recovery accepts it" testHighS
it "refuses v other than 27 or 28" $
forM_ ["00", "01", "1d"] $ \v -> parseSignature (T.dropEnd 2 orderSignature <> v) `shouldBe` Left SERecoveryId
it "refuses uppercase hex, no 0x and a wrong length" $ do
parseSignature ("0x" <> T.toUpper (T.drop 2 orderSignature)) `shouldBe` Left SEFormat
parseSignature (T.drop 2 orderSignature) `shouldBe` Left SEFormat
parseSignature (T.dropEnd 2 orderSignature) `shouldBe` Left SEFormat
parseSignature (orderSignature <> "00") `shouldBe` Left SEFormat
describe "addresses" $ do
it "accepts the EIP-55 form in orders" $
parseChecksumAddress ownerText `shouldBe` Right owner
it "refuses a letter in the wrong case and one-case hex in orders" $ do
parseChecksumAddress "0x014F9c09CcEeDEF1B713110d04D3a07d5B5D9b73" `shouldBe` Left ADChecksum
parseChecksumAddress ownerLower `shouldBe` Left ADChecksum
parseChecksumAddress ownerUpper `shouldBe` Left ADChecksum
it "accepts any case in find" $
forM_ [ownerText, ownerLower, ownerUpper, "0x014F9c09CcEeDEF1B713110d04D3a07d5B5D9b73"] $ \a ->
parseAnyCaseAddress a `shouldBe` Right owner
it "refuses what is not an address" $
forM_ ["014f9c09cceedef1b713110d04d3a07d5b5d9b73", "0X014f9c09cceedef1b713110d04d3a07d5b5d9b73", T.dropEnd 1 ownerLower, ownerLower <> "0", "0x014f9c09cceedef1b713110d04d3a07d5b5d9b7g"] $ \a -> do
parseAnyCaseAddress a `shouldBe` Left ADFormat
parseChecksumAddress a `shouldBe` Left ADFormat
describe "ABI return data" $ do
it "decodes each type as viem encodes it" $ do
decodeUint256 (hx "00000000000000000000000000000000000000018ee90ff6c373e0ee4e3f0ad2") `shouldBe` Right 123456789012345678901234567890
decodeAddress (hx "000000000000000000000000014f9c09cceedef1b713110d04d3a07d5b5d9b73") `shouldBe` Right owner
decodeBool (hx "0000000000000000000000000000000000000000000000000000000000000001") `shouldBe` Right True
decodeBool (B.replicate 32 0) `shouldBe` Right False
decodeBytes32 (hx "833cc73a7eae1ddee67ec8845ec3d39799406c7bc87fab7c504a7e0ab5cb89bb") `shouldBe` Right node
decodeString retString `shouldBe` Right "https://smp8.simplex.im/a#contact"
decodeString (hx "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000") `shouldBe` Right ""
decodeBytes (hx "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000010410f13a8c833cc73a7eae1ddee67ec8845ec3d39799406c7bc87fab7c504a7e0ab5cb89bb000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000f73696d706c65782e636f6e746163740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002168747470733a2f2f736d70382e73696d706c65782e696d2f6123636f6e746163740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") `shouldBe` Right (hx "10f13a8c833cc73a7eae1ddee67ec8845ec3d39799406c7bc87fab7c504a7e0ab5cb89bb000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000f73696d706c65782e636f6e746163740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002168747470733a2f2f736d70382e73696d706c65782e696d2f6123636f6e7461637400000000000000000000000000000000000000000000000000000000000000")
it "decodes the oracle's prices" $ do
decodePrices retPrices `shouldBe` Right (5000, [(7, 10000), (6, 20000)])
decodePrices (hx "000000000000000000000000000000000000000000000000000000000000138800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000") `shouldBe` Right (5000, [])
it "refuses data that does not hold the type" $ do
decodeUint256 (B.replicate 31 0) `shouldBe` Left AEShort
decodeAddress (B.singleton 1 <> B.drop 1 (hx "000000000000000000000000014f9c09cceedef1b713110d04d3a07d5b5d9b73")) `shouldBe` Left AENotAddress
decodeBool (uint256 (2 :: Int)) `shouldBe` Left AENotBool
decodeString (uint256 (4096 :: Int) <> B.drop 32 retString) `shouldBe` Left AEOffset
decodeString (B.take 80 retString) `shouldBe` Left AEShort
decodeString (abiTuple [Dynamic $ abiBytes "\xff"]) `shouldBe` Left AENotUtf8
decodePrices (B.dropEnd 32 retPrices) `shouldBe` Left AEShort
testMessage :: ByteString -> ByteString -> Text -> Text -> IO ()
testMessage msg expected digest signature = do
msg `shouldBe` expected
personalDigest msg `shouldBe` hx digest
sig <- either (fail . show) pure $ parseSignature signature
recoverAddress sig (personalDigest msg) `shouldReturn` Right owner
testHighS :: IO ()
testHighS = do
parseSignature highS `shouldBe` Left SEHighS
let (rsR, (rsS, v)) = B.splitAt 32 <$> B.splitAt 32 (hx highS)
recoverAddress RecoverableSignature {rsR, rsS, rsRecId = B.head v - 27} (personalDigest $ orderMessage dynamisOrder timestamp)
`shouldReturn` Right owner
where
highS = "0x73e32be461e99e64b093a4ad09b04b4d810e5623a1b0e9c9fdb4701ec2a6a2a8b206002333c63a347d8321b27b93899bc51d768793c21589fe7ae62862f6fff31c"
-- fixtures
dynamis :: Registration
dynamis =
Registration
{ rLabel = "dynamis",
rTLD = TLDSimplex,
rOwner = owner,
rDuration = duration,
rSecret = B.replicate 32 0x11,
rResolver = resolver,
rContactLink = Just "https://smp8.simplex.im/a#contact",
rChannelLink = Nothing
}
dynamisOrder :: OrderFields
dynamisOrder =
OrderFields
{ ofLabelHash = hx "0x78b602f03f164366531da963764c1f2fdfd588d0487daf2aa261ca799e5ffe33",
ofLength = 7,
ofDuration = duration,
ofCommitment = commitment,
ofSealed = "q3Zk0Yx7c2VhbGVkLW9yZGVyLWZpeHR1cmU"
}
commitment :: ByteString
commitment = hx "0x9cf04ddef2eedfb02e6e7ba58f879c0636c584b6acc67ffe9f97a06bfb016ac1"
node :: ByteString
node = hx "0x833cc73a7eae1ddee67ec8845ec3d39799406c7bc87fab7c504a7e0ab5cb89bb"
-- 21 October 2026 09:15 UTC
timestamp :: Word64
timestamp = 1792574100
-- 2 years of 365 days and the free days until 1 January 2027
duration :: Word64
duration = 69259500
registrationId :: Text
registrationId = "t2Jk8RmQ4vXw9NcP3yLb6g"
orderSignature :: Text
orderSignature = "0x73e32be461e99e64b093a4ad09b04b4d810e5623a1b0e9c9fdb4701ec2a6a2a84df9ffdccc39c5cb827cde4d846c7662f591665f1b868ab1c15778646d3f414e1b"
retString :: ByteString
retString = hx "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002168747470733a2f2f736d70382e73696d706c65782e696d2f6123636f6e7461637400000000000000000000000000000000000000000000000000000000000000"
retPrices :: ByteString
retPrices = hx "0000000000000000000000000000000000000000000000000000000000001388000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000004e20"
owner :: Address
owner = either error id $ strDecode $ encodeUtf8 ownerText
ownerText, ownerLower, ownerUpper :: Text
ownerText = "0x014f9c09CcEeDEF1B713110d04D3a07d5B5D9b73"
ownerLower = T.toLower ownerText
ownerUpper = "0x" <> T.toUpper (T.drop 2 ownerText)
sponsor, resolver :: Address
sponsor = either error id $ strDecode "0x9a676e781a523b5d0c0e43731313a708cb607508"
resolver = either error id $ strDecode "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6"
chainId :: Word64
chainId = 31337
hx :: Text -> ByteString
hx t = either error id $ convertFromBase Base16 $ encodeUtf8 $ fromMaybe t $ T.stripPrefix "0x" t
+2
View File
@@ -10,6 +10,7 @@ import Bots.StoreService.ConfigTests
import Bots.StoreService.GroupIntegrationTests
import Bots.StoreService.GroupTests
import Bots.StoreService.NameRegistrationTests
import Bots.StoreService.NamesSignatureTests
import Bots.StoreService.StripeTests
import Bots.StoreService.WaitersTests
import Bots.StoreService.WebTests
@@ -86,6 +87,7 @@ main = do
badgeBTCPayTests
badgeStripeTests
badgeNameRegistrationTests
namesSignatureTests
describe "SimpleX chat markdown" markdownTests
describe "JSON Tests" jsonTests
describe "Member relations" memberRelationsTests