diff --git a/apps/simplex-store-service/src/StoreService/Names/Signatures.hs b/apps/simplex-store-service/src/StoreService/Names/Signatures.hs new file mode 100644 index 0000000000..8babe90eee --- /dev/null +++ b/apps/simplex-store-service/src/StoreService/Names/Signatures.hs @@ -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 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ffcf241f85..0570e2827f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -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 diff --git a/tests/Bots/StoreService/NamesSignatureTests.hs b/tests/Bots/StoreService/NamesSignatureTests.hs new file mode 100644 index 0000000000..3ad3a220a0 --- /dev/null +++ b/tests/Bots/StoreService/NamesSignatureTests.hs @@ -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 diff --git a/tests/Test.hs b/tests/Test.hs index 1a1fcab74a..5e1e987df2 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -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