packages feed

gbnet-hs-0.2.0.0: src/GBNet/Crypto.hs

{-# LANGUAGE BangPatterns #-}

-- |
-- Module      : GBNet.Crypto
-- Description : ChaCha20-Poly1305 AEAD encryption for packet payloads
--
-- Provides authenticated encryption for post-handshake packets using
-- ChaCha20-Poly1305 (RFC 8439). Pre-shared key model: both sides must
-- configure the same 'EncryptionKey' before connecting.
--
-- Wire format for encrypted packets:
--
-- @
-- [header:9 plaintext][nonce:8 BE][ciphertext:N][auth_tag:16][CRC32C:4]
-- @
--
-- Nonce construction: @[counter:8 BE][protocol_id:4 BE]@ = 12 bytes
-- (ChaCha20 requirement).
module GBNet.Crypto
  ( -- * Types
    EncryptionKey (..),
    NonceCounter (..),
    CryptoError (..),

    -- * Constants
    encryptionKeySize,
    nonceSize,
    authTagSize,
    encryptionOverhead,

    -- * Operations
    encrypt,
    decrypt,
  )
where

import Control.DeepSeq (NFData (..), rwhnf)
import qualified Crypto.Cipher.ChaChaPoly1305 as CCP
import Crypto.Error (CryptoFailable (..))
import Data.Bits (shiftL, shiftR, (.|.))
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import Data.ByteString.Internal (unsafeCreate)
import qualified Data.ByteString.Unsafe as BSU
import Data.Word (Word32, Word64, Word8)
import Foreign.Storable (pokeByteOff)

-- | 32-byte symmetric encryption key for ChaCha20-Poly1305.
newtype EncryptionKey = EncryptionKey {unEncryptionKey :: BS.ByteString}
  deriving (Eq, Show)

instance NFData EncryptionKey where
  rnf (EncryptionKey k) = rnf k

-- | Monotonically increasing nonce counter (one per connection direction).
newtype NonceCounter = NonceCounter {unNonceCounter :: Word64}
  deriving (Eq, Show, Ord)

instance NFData NonceCounter where
  rnf (NonceCounter n) = rnf n

-- | Encryption/decryption errors.
data CryptoError
  = CryptoInitError !String
  | CryptoAuthError
  | CryptoNonceReplay
  deriving (Eq, Show)

instance NFData CryptoError where rnf = rwhnf

-- | Encryption key size in bytes (256 bits).
encryptionKeySize :: Int
encryptionKeySize = 32

-- | Nonce size on wire in bytes (8-byte counter, big-endian).
nonceSize :: Int
nonceSize = 8

-- | Authentication tag size in bytes (Poly1305).
authTagSize :: Int
authTagSize = 16

-- | Total overhead added by encryption: nonce (8) + auth tag (16) = 24 bytes.
-- (CRC32C is added separately by the send path.)
encryptionOverhead :: Int
encryptionOverhead = nonceSize + authTagSize

-- | Encrypt a plaintext payload.
--
-- Returns nonce counter prepended to ciphertext + auth tag:
-- @[nonce:8 BE][ciphertext:N][auth_tag:16]@
--
-- The caller is responsible for incrementing the nonce counter after calling.
encrypt ::
  EncryptionKey ->
  NonceCounter ->
  Word32 ->
  BS.ByteString ->
  Either CryptoError BS.ByteString
encrypt (EncryptionKey key) (NonceCounter counter) protocolId plaintext =
  let !nonceBytes = buildNonce counter protocolId
   in case CCP.nonce12 nonceBytes of
        CryptoFailed err -> Left (CryptoInitError (show err))
        CryptoPassed nonce -> case CCP.initialize key nonce of
          CryptoFailed err -> Left (CryptoInitError (show err))
          CryptoPassed st0 ->
            let !st1 = CCP.finalizeAAD st0
                (!ciphertext, !st2) = CCP.encrypt plaintext st1
                !authTag = CCP.finalize st2
                !tagBytes = BA.convert authTag :: BS.ByteString
                !counterBytes = word64ToBE counter
             in Right (counterBytes <> ciphertext <> tagBytes)

-- | Decrypt a ciphertext payload.
--
-- Input format: @[nonce:8 BE][ciphertext:N][auth_tag:16]@
--
-- Returns the decrypted plaintext and the nonce counter from the packet,
-- or an error if authentication fails or the packet is too short.
--
-- Anti-replay: the caller should check that the returned 'NonceCounter'
-- is greater than the previously seen maximum.
decrypt ::
  EncryptionKey ->
  Word32 ->
  BS.ByteString ->
  Either CryptoError (BS.ByteString, NonceCounter)
decrypt (EncryptionKey key) protocolId dat
  | BS.length dat < nonceSize + authTagSize = Left CryptoAuthError
  | otherwise =
      let !counterBytes = BS.take nonceSize dat
          !counter = word64FromBE counterBytes
          !rest = BS.drop nonceSize dat
          !ciphertextLen = BS.length rest - authTagSize
          !ciphertext = BS.take ciphertextLen rest
          !tagBytes = BS.drop ciphertextLen rest
          !nonceBytes = buildNonce counter protocolId
       in case CCP.nonce12 nonceBytes of
            CryptoFailed err -> Left (CryptoInitError (show err))
            CryptoPassed nonce -> case CCP.initialize key nonce of
              CryptoFailed err -> Left (CryptoInitError (show err))
              CryptoPassed st0 ->
                let !st1 = CCP.finalizeAAD st0
                    (!plaintext, !st2) = CCP.decrypt ciphertext st1
                    !computedTag = CCP.finalize st2
                    !computedBytes = BA.convert computedTag :: BS.ByteString
                 in if BA.constEq computedBytes tagBytes
                      then Right (plaintext, NonceCounter counter)
                      else Left CryptoAuthError

-- | Build a 12-byte nonce from counter (8 bytes BE) and protocol ID (4 bytes BE).
buildNonce :: Word64 -> Word32 -> BS.ByteString
buildNonce counter protocolId =
  word64ToBE counter <> word32ToBE protocolId
{-# INLINE buildNonce #-}

-- | Encode a Word64 as 8 bytes big-endian.
word64ToBE :: Word64 -> BS.ByteString
word64ToBE !w = unsafeCreate nonceSize $ \ptr -> do
  pokeByteOff ptr 0 (fromIntegral (w `shiftR` 56) :: Word8)
  pokeByteOff ptr 1 (fromIntegral (w `shiftR` 48) :: Word8)
  pokeByteOff ptr 2 (fromIntegral (w `shiftR` 40) :: Word8)
  pokeByteOff ptr 3 (fromIntegral (w `shiftR` 32) :: Word8)
  pokeByteOff ptr 4 (fromIntegral (w `shiftR` 24) :: Word8)
  pokeByteOff ptr 5 (fromIntegral (w `shiftR` 16) :: Word8)
  pokeByteOff ptr 6 (fromIntegral (w `shiftR` 8) :: Word8)
  pokeByteOff ptr 7 (fromIntegral w :: Word8)
{-# INLINE word64ToBE #-}

-- | Decode 8 bytes big-endian to Word64.
word64FromBE :: BS.ByteString -> Word64
word64FromBE !bs =
  let !b0 = fromIntegral (BSU.unsafeIndex bs 0) :: Word64
      !b1 = fromIntegral (BSU.unsafeIndex bs 1) :: Word64
      !b2 = fromIntegral (BSU.unsafeIndex bs 2) :: Word64
      !b3 = fromIntegral (BSU.unsafeIndex bs 3) :: Word64
      !b4 = fromIntegral (BSU.unsafeIndex bs 4) :: Word64
      !b5 = fromIntegral (BSU.unsafeIndex bs 5) :: Word64
      !b6 = fromIntegral (BSU.unsafeIndex bs 6) :: Word64
      !b7 = fromIntegral (BSU.unsafeIndex bs 7) :: Word64
   in (b0 `shiftL` 56)
        .|. (b1 `shiftL` 48)
        .|. (b2 `shiftL` 40)
        .|. (b3 `shiftL` 32)
        .|. (b4 `shiftL` 24)
        .|. (b5 `shiftL` 16)
        .|. (b6 `shiftL` 8)
        .|. b7
{-# INLINE word64FromBE #-}

-- | Encode a Word32 as 4 bytes big-endian.
word32ToBE :: Word32 -> BS.ByteString
word32ToBE !w = unsafeCreate 4 $ \ptr -> do
  pokeByteOff ptr 0 (fromIntegral (w `shiftR` 24) :: Word8)
  pokeByteOff ptr 1 (fromIntegral (w `shiftR` 16) :: Word8)
  pokeByteOff ptr 2 (fromIntegral (w `shiftR` 8) :: Word8)
  pokeByteOff ptr 3 (fromIntegral w :: Word8)
{-# INLINE word32ToBE #-}