packages feed

solana-haskell-sdk-1.2.0.0: src/Network/Solana/Core/Crypto.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Network.Solana.Core.Crypto
-- Description : Ed25519 keys, signatures, and Base58\/Base64 helpers for Solana.
--
-- Wrappers around "Crypto.Sign.Ed25519" providing the core cryptographic
-- types: 'SolanaPublicKey', 'SolanaPrivateKey' and 'SolanaSignature'.
-- All three 'show' as the Base58 strings used across the Solana ecosystem.
module Network.Solana.Core.Crypto
  ( createSolanaKeyPair,
    createSolanaKeypairFromSeed,
    toSolanaPublicKey,
    sign,
    verify,
    dsign,
    dverify,
    SolanaPublicKey,
    SolanaPrivateKey,
    SolanaSignature,
    unsafeSolanaPublicKey,
    unsafeSolanaPublicKeyRaw,
    unsafeSolanaPrivateKey,
    unsafeSolanaPrivateKeyRaw,
    unsafeSigFromString,
    getSolanaPublicKeyRaw,
    getSolanaPrivateKeyRaw,
    getSolanaSignatureRaw,
    toBase58String,
    toBase64String,
    fromBase64String,
    fromBase58String,
    readSigningKeyFromFile,
    mkPublicKeyFromString,
    mkPrivateKeyFromString,
  )
where

import Crypto.Sign.Ed25519 qualified as Ed25519
import Data.Aeson
import Data.Aeson.Types
import Data.Bifunctor (Bifunctor (bimap))
import Data.Binary
import Data.Binary.Get (getByteString)
import Data.Binary.Put (putByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base58
import Data.ByteString.Base64 (decodeBase64Lenient, encodeBase64')
import Data.ByteString.Char8 qualified as BS8
import Data.Either.Extra (maybeToEither)
import Data.String (IsString, fromString)
import Data.Text qualified as Text
import GHC.Generics (Generic)
import Text.Read (readMaybe)

-- | Encode a byte string as Base58 text (Bitcoin alphabet), the encoding
-- Solana uses for addresses, signatures and hashes.
toBase58String :: BS.ByteString -> String
toBase58String = tail . init . show . encodeBase58 bitcoinAlphabet

-- | Encode a byte string as Base64 text.
toBase64String :: BS.ByteString -> String
toBase64String = tail . init . show . encodeBase64'

-- | Decode Base58 text (Bitcoin alphabet); 'Nothing' if the input contains
-- characters outside the alphabet.
fromBase58String :: String -> Maybe BS.ByteString
fromBase58String = decodeBase58 bitcoinAlphabet . fromString

-- | Decode Base64 text /leniently/: invalid characters are skipped rather
-- than reported, so this never fails but may silently accept malformed
-- input.
fromBase64String :: String -> BS.ByteString
fromBase64String = decodeBase64Lenient . fromString

--- >>> fromBase64String $ toBase64String "Sun"
--- >>> fromBase58String $ toBase58String "Sun"
-- "Sun"
-- Just "Sun"

------------------------------------------------------------------------------------------------

-- * SolanaSignature

------------------------------------------------------------------------------------------------

-- | A 64-byte detached Ed25519 signature. 'Show' and the JSON instances use
-- the Base58 rendering.
newtype SolanaSignature = SolanaSignature Ed25519.Signature
  deriving (Eq, Ord, Generic)

instance Show SolanaSignature where
  show :: SolanaSignature -> String
  show (SolanaSignature (Ed25519.Signature bs)) = toBase58String bs

instance Binary SolanaSignature where
  put :: SolanaSignature -> Put
  put (SolanaSignature (Ed25519.Signature bs)) = putByteString bs
  get :: Get SolanaSignature
  get = SolanaSignature . Ed25519.Signature <$> getByteString 64

instance ToJSON SolanaSignature where
  toJSON :: SolanaSignature -> Value
  toJSON pk = toJSON (show pk)

instance FromJSON SolanaSignature where
  parseJSON :: Value -> Parser SolanaSignature
  parseJSON = withText "SolanaSignature" $ either fail pure . mkSigFromString . Text.unpack

------------------------------------------------------------------------------------------------

-- *** SolanaPublicKey

------------------------------------------------------------------------------------------------

-- | A 32-byte Ed25519 public key: the address of an account, program or
-- signer. 'Show' and the JSON instances use the Base58 rendering; the
-- 'IsString' instance is partial (see 'unsafeSolanaPublicKey').
newtype SolanaPublicKey
  = SolanaPublicKey Ed25519.PublicKey
  deriving (Eq, Ord, Generic)

instance Show SolanaPublicKey where
  show :: SolanaPublicKey -> String
  show (SolanaPublicKey (Ed25519.PublicKey bs)) = toBase58String bs

instance IsString SolanaPublicKey where
  fromString = unsafeSolanaPublicKey

instance Binary SolanaPublicKey where
  put :: SolanaPublicKey -> Put
  put (SolanaPublicKey (Ed25519.PublicKey bs)) = putByteString bs
  get :: Get SolanaPublicKey
  get = SolanaPublicKey . Ed25519.PublicKey <$> getByteString 32

instance ToJSON SolanaPublicKey where
  toJSON :: SolanaPublicKey -> Value
  toJSON pk = toJSON (show pk)

instance FromJSON SolanaPublicKey where
  parseJSON :: Value -> Parser SolanaPublicKey
  parseJSON = withText "SolanaPublicKey" $ either fail pure . mkPublicKeyFromString . Text.unpack

instance FromJSONKey SolanaPublicKey where
  fromJSONKey :: FromJSONKeyFunction SolanaPublicKey
  fromJSONKey = FromJSONKeyTextParser (either fail pure . mkPublicKeyFromString . Text.unpack)

instance ToJSONKey SolanaPublicKey where
  toJSONKey :: ToJSONKeyFunction SolanaPublicKey
  toJSONKey = toJSONKeyText (Text.pack . show)

------------------------------------------------------------------------------------------------

-- *** SolanaPrivateKey

------------------------------------------------------------------------------------------------

-- | A 64-byte Ed25519 secret key (seed plus public key, NaCl layout).
-- 'Show' renders it in Base58 — avoid logging values of this type.
newtype SolanaPrivateKey
  = SolanaPrivateKey Ed25519.SecretKey
  deriving (Eq, Ord, Generic)

instance Show SolanaPrivateKey where
  show :: SolanaPrivateKey -> String
  show (SolanaPrivateKey (Ed25519.SecretKey bs)) = toBase58String bs

------------------------------------------------------------------------------------------------

-- *** Functions

------------------------------------------------------------------------------------------------

mkSigFromString :: String -> Either String SolanaSignature
mkSigFromString str = do
  bs <- maybeToEither "Not base58" $ fromBase58String str
  if BS.length bs == 64
    then Right $ (SolanaSignature . Ed25519.Signature) bs
    else Left "Invalid string length for sig"

-- | Build a 'SolanaSignature' from Base58 text. Calls 'error' unless the
-- input decodes to exactly 64 bytes.
unsafeSigFromString :: String -> SolanaSignature
unsafeSigFromString = either error id . mkSigFromString

mkKeyFromString :: forall f. Int -> (BS.ByteString -> f) -> String -> Either String f
mkKeyFromString n cstr str = do
  bs <- maybeToEither "Not base58" $ fromBase58String str
  if BS.length bs == n
    then Right $ cstr bs
    else Left "Invalid string length for key"

-- | Parse Base58 text into a 'SolanaPublicKey'. 'Left' if the input is not
-- Base58 or does not decode to exactly 32 bytes.
mkPublicKeyFromString :: String -> Either String SolanaPublicKey
mkPublicKeyFromString = mkKeyFromString 32 (SolanaPublicKey . Ed25519.PublicKey)

-- | Parse Base58 text into a 'SolanaPrivateKey'. 'Left' if the input is not
-- Base58 or has the wrong length.
mkPrivateKeyFromString :: String -> Either String SolanaPrivateKey
mkPrivateKeyFromString = mkKeyFromString 64 (SolanaPrivateKey . Ed25519.SecretKey)

unsafeKeyFromString :: forall f. Int -> (BS.ByteString -> f) -> String -> f
unsafeKeyFromString n cstr str = either error id $ mkKeyFromString n cstr str

unsafeKeyFromWords :: forall f. (BS.ByteString -> f) -> [Word8] -> f
unsafeKeyFromWords cstr ws = cstr (BS.pack ws)

-- | Partial version of 'mkPublicKeyFromString': calls 'error' on invalid
-- input. Prefer the total variant outside of literals and tests.
unsafeSolanaPublicKey :: String -> SolanaPublicKey
unsafeSolanaPublicKey = unsafeKeyFromString 32 (SolanaPublicKey . Ed25519.PublicKey)

-- | Build a 'SolanaPublicKey' directly from raw bytes. No length check is
-- performed — the caller must supply exactly 32 bytes.
unsafeSolanaPublicKeyRaw :: [Word8] -> SolanaPublicKey
unsafeSolanaPublicKeyRaw = unsafeKeyFromWords (SolanaPublicKey . Ed25519.PublicKey)

-- | Partial version of 'mkPrivateKeyFromString': calls 'error' on invalid
-- input.
unsafeSolanaPrivateKey :: String -> SolanaPrivateKey
unsafeSolanaPrivateKey = unsafeKeyFromString 64 (SolanaPrivateKey . Ed25519.SecretKey)

-- | Build a 'SolanaPrivateKey' directly from raw bytes (e.g. the 64 numbers
-- in a @solana-keygen@ keypair file). No length check is performed.
unsafeSolanaPrivateKeyRaw :: [Word8] -> SolanaPrivateKey
unsafeSolanaPrivateKeyRaw = unsafeKeyFromWords (SolanaPrivateKey . Ed25519.SecretKey)

-- | The raw 32 bytes of a public key.
getSolanaPublicKeyRaw :: SolanaPublicKey -> BS.ByteString
getSolanaPublicKeyRaw (SolanaPublicKey (Ed25519.PublicKey bs)) = bs

-- | The raw bytes of a secret key. Handle with care.
getSolanaPrivateKeyRaw :: SolanaPrivateKey -> BS.ByteString
getSolanaPrivateKeyRaw (SolanaPrivateKey (Ed25519.SecretKey bs)) = bs

-- | The raw 64 bytes of a signature.
getSolanaSignatureRaw :: SolanaSignature -> BS.ByteString
getSolanaSignatureRaw (SolanaSignature (Ed25519.Signature bs)) = bs

-- | Generate a fresh random Ed25519 keypair.
createSolanaKeyPair :: IO (SolanaPublicKey, SolanaPrivateKey)
createSolanaKeyPair = bimap SolanaPublicKey SolanaPrivateKey <$> Ed25519.createKeypair

-- | Derive a keypair deterministically from a seed. 'Nothing' unless the
-- seed is exactly 32 bytes.
createSolanaKeypairFromSeed :: BS.ByteString -> Maybe (SolanaPublicKey, SolanaPrivateKey)
createSolanaKeypairFromSeed bs = bimap SolanaPublicKey SolanaPrivateKey <$> Ed25519.createKeypairFromSeed_ bs

-- | The public key corresponding to a private key.
toSolanaPublicKey :: SolanaPrivateKey -> SolanaPublicKey
toSolanaPublicKey (SolanaPrivateKey pv) = SolanaPublicKey $ Ed25519.toPublicKey pv

-- | Sign a message, returning the /joined/ signed message (signature
-- prepended to the message). For transaction signatures use 'dsign'.
sign :: SolanaPrivateKey -> BS.ByteString -> BS.ByteString
sign (SolanaPrivateKey sk) = Ed25519.sign sk

-- | Verify a joined signed message produced by 'sign'.
verify :: SolanaPublicKey -> BS.ByteString -> Bool
verify (SolanaPublicKey pk) = Ed25519.verify pk

-- | Produce a detached 'SolanaSignature' over a byte string. This is the
-- primitive used to sign transaction messages.
dsign :: SolanaPrivateKey -> BS.ByteString -> SolanaSignature
dsign (SolanaPrivateKey sk) bs = SolanaSignature $ Ed25519.dsign sk bs

-- | Verify a detached 'SolanaSignature' over a byte string.
dverify :: SolanaPublicKey -> BS.ByteString -> SolanaSignature -> Bool
dverify (SolanaPublicKey pk) bs (SolanaSignature sig) = Ed25519.dverify pk bs sig

----
----
----
----

-- | Read a private key from a @solana-keygen@-style keypair file containing
-- a list of byte values (e.g. @[1,2,...]@). Throws an 'IOError' if the file
-- cannot be read, or if its contents cannot be parsed as a @[Word8]@ list.
readSigningKeyFromFile :: FilePath -> IO SolanaPrivateKey
readSigningKeyFromFile path = do
  contents <- BS8.readFile path
  case readMaybe (BS8.unpack contents) of
    Just word8List -> return $ unsafeSolanaPrivateKeyRaw (word8List :: [Word8])
    Nothing -> fail ("readSigningKeyFromFile: could not parse " <> path <> " as a list of bytes (e.g. \"[1,2,...]\")")