packages feed

age-0.0.1.0: src/Crypto/Age/Recipient/Stanza.hs

-- | age recipient stanzas.
module Crypto.Age.Recipient.Stanza
  ( -- * @scrypt@ recipient stanza
    ScryptRecipientStanza (..)
  , wrapFileKeyForScryptRecipient
  , ParseScryptStanzaError (..)
  , toScryptRecipientStanza
  , fromScryptRecipientStanza
  , scryptStanzaTag

    -- * X25519 recipient stanza
  , X25519RecipientStanza (..)
  , WrapX25519StanzaFileKeyError (..)
  , wrapFileKeyForX25519Recipient
  , ParseX25519StanzaError (..)
  , toX25519RecipientStanza
  , fromX25519RecipientStanza
  , x25519StanzaTag
  ) where

import Control.Monad ( unless, when )
import Crypto.Age.Header ( Stanza (..) )
import Crypto.Age.Identity ( X25519Identity (..) )
import Crypto.Age.Key ( FileKey, fileKeyToBytes )
import Crypto.Age.Recipient ( ScryptRecipient (..), X25519Recipient (..) )
import Crypto.Age.Scrypt
  ( Passphrase (..)
  , Salt
  , WorkFactor (..)
  , bytesToSalt
  , saltToBytes
  , workFactorBuilder
  , workFactorParser
  )
import qualified Crypto.Cipher.ChaChaPoly1305 as ChaCha20Poly1305
import qualified Crypto.Error as Crypto
import qualified Crypto.Hash as Crypto
import qualified Crypto.KDF.HKDF as HKDF
import qualified Crypto.KDF.Scrypt as Scrypt
import qualified Crypto.MAC.Poly1305 as Poly1305
import qualified Crypto.PubKey.Curve25519 as Curve25519
import Data.Attoparsec.ByteString ( parseOnly )
import Data.ByteArray ( ScrubbedBytes )
import qualified Data.ByteArray as BA
import Data.ByteString ( ByteString )
import qualified Data.ByteString as BS
import Data.ByteString.Base64.Extra
  ( decodeBase64StdUnpadded, encodeBase64StdUnpadded )
import qualified Data.ByteString.Builder as Builder
import Data.Text ( Text )
import Prelude

-- | [@scrypt@ recipient stanza](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#scrypt-recipient-stanza).
data ScryptRecipientStanza = ScryptRecipientStanza
  { -- | Salt.
    srsSalt :: !Salt
  , -- | @scrypt@ work factor.
    srsWorkFactor :: !WorkFactor
  , -- | Encrypted file key.
    srsEncryptedFileKey :: !ByteString
  } deriving stock (Show, Eq)

-- | Wrap a 'FileKey' for an 'ScryptRecipient'.
wrapFileKeyForScryptRecipient ::
  ScryptRecipient ->
  FileKey ->
  ScryptRecipientStanza
wrapFileKeyForScryptRecipient r fk = do
  let salt :: ByteString
      salt = "age-encryption.org/v1/scrypt" <> saltToBytes srSalt

      params :: Scrypt.Parameters
      params =
        Scrypt.Parameters
          { Scrypt.n = 2 ^ workFactorW8
          , Scrypt.r = 8
          , Scrypt.p = 1
          , Scrypt.outputLength = 32
          }

      wrapKey :: ScrubbedBytes
      wrapKey = Scrypt.generate params passphrase salt

      nonce :: ChaCha20Poly1305.Nonce
      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)

      st :: ChaCha20Poly1305.State
      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce

      ciphertext :: ByteString
      st2 :: ChaCha20Poly1305.State
      (ciphertext, st2) = ChaCha20Poly1305.encrypt (BA.convert $ fileKeyToBytes fk) st

      authTag :: Poly1305.Auth
      authTag = ChaCha20Poly1305.finalize st2

  ScryptRecipientStanza
    { srsSalt = srSalt
    , srsWorkFactor = srWorkFactor
    , srsEncryptedFileKey = ciphertext <> BA.convert authTag
    }
  where
    ScryptRecipient
      { srPassphrase = Passphrase passphrase
      , srSalt
      , srWorkFactor
      } = r

    WorkFactor workFactorW8 = srWorkFactor

-- | @scrypt@ recipient stanza tag (i.e. the first stanza argument).
--
-- According to the
-- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#scrypt-recipient-stanza),
-- this is expected to be the string, @scrypt@.
scryptStanzaTag :: ByteString
scryptStanzaTag = "scrypt"

-- | Error converting a 'Stanza' to an 'ScryptRecipientStanza'.
data ParseScryptStanzaError
  = -- | Invalid tag.
    ParseScryptStanzaInvalidTagError
      -- | Expected tag.
      !ByteString
      -- | Actual tag.
      !ByteString
  | -- | Invalid number of arguments.
    ParseScryptStanzaInvalidNumberOfArgumentsError
      -- | Expected number of arguments.
      !Int
      -- | Actual number of arguments.
      !Int
  | -- | Error decoding the @scrypt@ salt from base64.
    ParseScryptStanzaSaltBase64DecodingError
      -- | Base64 decoding error.
      !Text
  | -- | Invalid @scrypt@ salt size.
    ParseScryptStanzaInvalidSaltSizeError
      -- | Expected body size in bytes.
      !Int
      -- | Actual body size in bytes.
      !Int
  | -- | Error parsing the @scrypt@ work factor.
    ParseScryptStanzaWorkFactorParseError !String
  | -- | Invalid stanza body size.
    ParseScryptStanzaInvalidBodySizeError
      -- | Expected body size in bytes.
      !Int
      -- | Actual body size in bytes.
      !Int
  deriving stock (Show, Eq)

-- | Convert a 'Stanza' in an 'ScryptRecipientStanza'.
toScryptRecipientStanza :: Stanza -> Either ParseScryptStanzaError ScryptRecipientStanza
toScryptRecipientStanza s = do
  unless (sTag == scryptStanzaTag) $ Left (ParseScryptStanzaInvalidTagError scryptStanzaTag sTag)

  (saltB64, workFactorBs) <-
    case sArgs of
      [arg1, arg2] -> Right (arg1, arg2)
      _ ->
        -- We add 1 because, technically, the tag is a stanza argument too.
        Left $ ParseScryptStanzaInvalidNumberOfArgumentsError 3 (length sArgs + 1)

  salt <-
    case decodeBase64StdUnpadded saltB64 of
      Left err -> Left (ParseScryptStanzaSaltBase64DecodingError err)
      Right bs ->
        case bytesToSalt bs of
          Nothing -> Left (ParseScryptStanzaInvalidSaltSizeError 16 (BS.length bs))
          Just salt -> Right salt

  workFactor <-
    case parseOnly workFactorParser workFactorBs of
      Left err -> Left (ParseScryptStanzaWorkFactorParseError err)
      Right wf -> Right wf

  unless (actualBodyLength == expectedBodyLength) $ Left (ParseScryptStanzaInvalidBodySizeError expectedBodyLength actualBodyLength)

  Right $
    ScryptRecipientStanza
      { srsSalt = salt
      , srsWorkFactor = workFactor
      , srsEncryptedFileKey = sBody
      }
  where
    Stanza
      { sTag
      , sArgs
      , sBody
      } = s

    expectedBodyLength :: Int
    expectedBodyLength = 32

    actualBodyLength :: Int
    actualBodyLength = BS.length sBody

-- | Convert an 'ScryptRecipientStanza' to a 'Stanza'.
fromScryptRecipientStanza :: ScryptRecipientStanza -> Stanza
fromScryptRecipientStanza s =
  Stanza
    { sTag = scryptStanzaTag
    , sArgs =
        [ encodeBase64StdUnpadded (saltToBytes srsSalt)
        , BS.toStrict (Builder.toLazyByteString $ workFactorBuilder srsWorkFactor)
        ]
    , sBody = srsEncryptedFileKey
    }
  where
    ScryptRecipientStanza
      { srsSalt
      , srsWorkFactor
      , srsEncryptedFileKey
      } = s

-- | [X25519 recipient stanza](https://github.com/C2SP/C2SP/blob/34a9210873230d2acaa4a4c9c5d4d1119b2ee77d/age.md#x25519-recipient-stanza).
data X25519RecipientStanza = X25519RecipientStanza
  { -- | Sender's ephemeral Curve25519 public key.
    --
    -- Referred to as the \"ephemeral share\" in the age specification.
    xrsSenderPublicKey :: !Curve25519.PublicKey
  , -- | Encrypted file key.
    xrsEncryptedFileKey :: !ByteString
  } deriving stock (Show, Eq)

-- | Error wrapping a file key in an X25519 recipient stanza.
data WrapX25519StanzaFileKeyError
  = -- | DH shared secret is an all-zero value.
    WrapX25519StanzaFileKeyAllZeroSharedSecretError
  deriving stock (Show, Eq)

-- | Wrap a 'FileKey' in an 'X25519RecipientStanza'.
wrapFileKeyForX25519Recipient ::
  X25519Recipient ->
  X25519Identity ->
  FileKey ->
  Either WrapX25519StanzaFileKeyError X25519RecipientStanza
wrapFileKeyForX25519Recipient (X25519Recipient recipientPk) (X25519Identity senderSk) fk = do
  let senderPk :: Curve25519.PublicKey
      senderPk = Curve25519.toPublic senderSk

      salt :: ByteString
      salt = BA.convert senderPk <> BA.convert recipientPk

      info :: ByteString
      info = "age-encryption.org/v1/X25519"

      zeroSharedSecret :: Curve25519.DhSecret
      zeroSharedSecret = Crypto.throwCryptoError $ Curve25519.dhSecret (BA.replicate 32 0x00 :: ScrubbedBytes)

      sharedSecret :: Curve25519.DhSecret
      sharedSecret = Curve25519.dh recipientPk senderSk

  when (sharedSecret == zeroSharedSecret) (Left WrapX25519StanzaFileKeyAllZeroSharedSecretError)

  let prk :: HKDF.PRK Crypto.SHA256
      prk = HKDF.extract salt sharedSecret

      wrapKey :: ScrubbedBytes
      wrapKey = HKDF.expand prk info 32

      nonce :: ChaCha20Poly1305.Nonce
      nonce = Crypto.throwCryptoError $ ChaCha20Poly1305.nonce12 (BS.replicate 12 0x00)

      st :: ChaCha20Poly1305.State
      st = Crypto.throwCryptoError $ ChaCha20Poly1305.initialize wrapKey nonce

      ciphertext :: ByteString
      st2 :: ChaCha20Poly1305.State
      (ciphertext, st2) = ChaCha20Poly1305.encrypt (BA.convert $ fileKeyToBytes fk) st

      authTag :: Poly1305.Auth
      authTag = ChaCha20Poly1305.finalize st2

  Right $
    X25519RecipientStanza
      { xrsSenderPublicKey = senderPk
      , xrsEncryptedFileKey = ciphertext <> BA.convert authTag
      }

-- | X25519 recipient stanza tag (i.e. the first stanza argument).
--
-- According to the
-- [age specification](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#x25519-recipient-stanza),
-- this is expected to be the string, @X25519@.
x25519StanzaTag :: ByteString
x25519StanzaTag = "X25519"

-- | Error converting a 'Stanza' to an 'X25519RecipientStanza'.
data ParseX25519StanzaError
  = -- | Invalid tag.
    ParseX25519StanzaInvalidTagError
      -- | Expected tag.
      !ByteString
      -- | Actual tag.
      !ByteString
  | -- | Invalid number of arguments.
    ParseX25519StanzaInvalidNumberOfArgumentsError
      -- | Expected number of arguments.
      !Int
      -- | Actual number of arguments.
      !Int
  | -- | Error decoding the sender's ephemeral public key from base64.
    ParseX25519StanzaEphemeralShareBase64DecodingError
      -- | Base64 decoding error.
      !Text
  | -- | Invalid ephemeral share.
    ParseX25519StanzaInvalidEphemeralShareError
      -- | Error that occurred.
      !Crypto.CryptoError
      -- | Invalid ephemeral share bytes.
      !ByteString
  | -- | Invalid stanza body size.
    ParseX25519StanzaInvalidBodySizeError
      -- | Expected body size in bytes.
      !Int
      -- | Actual body size in bytes.
      !Int
  deriving stock (Show, Eq)

-- | Convert a 'Stanza' to an 'X25519RecipientStanza'.
toX25519RecipientStanza :: Stanza -> Either ParseX25519StanzaError X25519RecipientStanza
toX25519RecipientStanza s = do
  unless (sTag == x25519StanzaTag) $ Left (ParseX25519StanzaInvalidTagError x25519StanzaTag sTag)

  ephemeralShareB64 <-
    case sArgs of
      [arg] -> Right arg
      _ ->
        -- We add 1 because, technically, the tag is a stanza argument too.
        Left $ ParseX25519StanzaInvalidNumberOfArgumentsError 2 (length sArgs + 1)

  ephemeralShareBs <-
    case decodeBase64StdUnpadded ephemeralShareB64 of
      Left err -> Left (ParseX25519StanzaEphemeralShareBase64DecodingError err)
      Right bs -> Right bs

  ephemeralShare <-
    case Curve25519.publicKey ephemeralShareBs of
      Crypto.CryptoFailed err -> Left (ParseX25519StanzaInvalidEphemeralShareError err ephemeralShareBs)
      Crypto.CryptoPassed pk -> Right pk

  unless (actualBodyLength == expectedBodyLength) $ Left (ParseX25519StanzaInvalidBodySizeError expectedBodyLength actualBodyLength)

  Right $
    X25519RecipientStanza
      { xrsSenderPublicKey = ephemeralShare
      , xrsEncryptedFileKey = sBody
      }
  where
    Stanza
      { sTag
      , sArgs
      , sBody
      } = s

    expectedBodyLength :: Int
    expectedBodyLength = 32

    actualBodyLength :: Int
    actualBodyLength = BS.length sBody

-- | Convert an 'X25519RecipientStanza' to a 'Stanza'.
fromX25519RecipientStanza :: X25519RecipientStanza -> Stanza
fromX25519RecipientStanza s =
  Stanza
    { sTag = x25519StanzaTag
    , sArgs = [encodeBase64StdUnpadded (BA.convert ephemeralShare)]
    , sBody = xrsEncryptedFileKey
    }
  where
    X25519RecipientStanza
      { xrsSenderPublicKey = ephemeralShare
      , xrsEncryptedFileKey
      } = s