packages feed

gachi-sync-0.1.0.0: src/Gachi/Sync/Hash.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Module: Gachi.Sync.Hash
-- Description: Pure, self-contained SHA-256 differential hashing engine
--
-- This module implements FIPS 180-2 compliant Secure Hash Algorithm (SHA-256)
-- in pure Haskell with zero external cryptographic dependencies.
--
-- = Architectural Context & Invariants
--
-- In accordance with Note [Harmonic Chattering Synchronization and Differential Propagation]
-- [ref:note-harmonic-sync], the synchronization engine must rapidly verify
-- content identity across dozens of workspace files without triggering unnecessary
-- disk writes or external process invocations.
--
-- == Invariant: Pure Referential Transparency
--
-- * __Totality:__ Hashing functions are pure and total across all inputs.
-- * __Zero-Dependency:__ Implemented using standard @base@ and @bytestring@ primitives.
-- * __FIPS 180-2 Compliance:__ Satisfies standard NIST test vectors, including the empty string
--   digest (@e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855@).
--
-- == Algorithmic Overview (FIPS 180-2 SHA-256)
--
-- SHA-256 operates on 512-bit message blocks through a sequence of mathematical
-- transformations structured across four stages:
--
-- 1. __Preprocessing and Padding:__
--    The arbitrary-length input bitstream \( M \) of length \( L \) bits is padded
--    by appending a single '1' bit, followed by \( k \) '0' bits, where \( k \)
--    is the smallest non-negative integer satisfying:
--    \[
--      L + 1 + k \equiv 448 \pmod{512}
--    \]
--    Finally, the 64-bit big-endian representation of \( L \) is appended, yielding
--    an exact multiple of 512 bits (16 32-bit words).
--
-- 2. __Message Schedule Expansion:__
--    Each 16-word block \( W_{0..15} \) is expanded into a 64-word schedule \( W_{0..63} \):
--    \[
--      W_t = \sigma_1(W_{t-2}) + W_{t-7} + \sigma_0(W_{t-15}) + W_{t-16}
--    \]
--    where the lower-case sigma functions apply rotational and right-shift mixing:
--    \[
--      \sigma_0(x) = \text{RotR}(x, 7) \oplus \text{RotR}(x, 18) \oplus \text{ShR}(x, 3)
--    \]
--    \[
--      \sigma_1(x) = \text{RotR}(x, 17) \oplus \text{RotR}(x, 19) \oplus \text{ShR}(x, 10)
--    \]
--
-- 3. __64-Round Compression Loop:__
--    Eight 32-bit working state variables (\( a, b, c, d, e, f, g, h \)) are initialized
--    from the intermediate hash values \( H^{(i-1)} \). In each step \( t \in [0, 63] \):
--    \[
--      T_1 = h + \Sigma_1(e) + \text{Ch}(e, f, g) + K_t + W_t
--    \]
--    \[
--      T_2 = \Sigma_0(a) + \text{Maj}(a, b, c)
--    \]
--    \[
--      h \leftarrow g, \quad g \leftarrow f, \quad f \leftarrow e, \quad e \leftarrow d + T_1
--    \]
--    \[
--      d \leftarrow c, \quad c \leftarrow b, \quad b \leftarrow a, \quad a \leftarrow T_1 + T_2
--    \]
--    where the non-linear bitwise choice and majority combinators are defined as:
--    \[
--      \text{Ch}(x, y, z) = (x \wedge y) \oplus (\neg x \wedge z)
--    \]
--    \[
--      \text{Maj}(x, y, z) = (x \wedge y) \oplus (x \wedge z) \oplus (y \wedge z)
--    \]
--
-- 4. __Intermediate State Accumulation:__
--    The updated working variables are summed modulo \( 2^{32} \) into the running state:
--    \[
--      H_j^{(i)} = H_j^{(i-1)} + \text{Var}_j \quad \forall j \in \{0..7\}
--    \]
--
-- = Worked Example
--
-- >>> import Gachi.Sync.Hash
-- >>> sha256Text ""
-- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
-- >>> sha256Text "gachi"
-- "e43b177d6118bfa6b6f7097eb586ef291f5a5a92cf6d62ea09c0fa168e312521"
module Gachi.Sync.Hash
  ( -- * Pure SHA-256 Digest Functions
    sha256Text
  , sha256ByteString
  , sha256File
  ) where

import Data.Bits (complement, rotateR, shiftL, shiftR, xor, (.&.), (.|.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word32, Word64)
import Numeric (showHex)

--------------------------------------------------------------------------------
-- 1. Initial State & Round Constants (FIPS 180-2)
--------------------------------------------------------------------------------

-- | Initial 256-bit hash values representing first 32 bits of fractional parts
-- of the square roots of the first 8 prime numbers (2..19).
initialHash :: (Word32, Word32, Word32, Word32, Word32, Word32, Word32, Word32)
initialHash =
  ( 0x6a09e667
  , 0xbb67ae85
  , 0x3c6ef372
  , 0xa54ff53a
  , 0x510e527f
  , 0x9b05688c
  , 0x1f83d9ab
  , 0x5be0cd19
  )

-- | Sixty-four 32-bit round constants representing the first 32 bits of the
-- fractional parts of the cube roots of the first 64 prime numbers (2..311).
roundConstants :: [Word32]
roundConstants =
  [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5
  , 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5
  , 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3
  , 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174
  , 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc
  , 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da
  , 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7
  , 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967
  , 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13
  , 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85
  , 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3
  , 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070
  , 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5
  , 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3
  , 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208
  , 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
  ]

--------------------------------------------------------------------------------
-- 2. Bitwise Logical Functions
--------------------------------------------------------------------------------

-- | Bitwise choice: (x AND y) XOR (NOT x AND z).
ch :: Word32 -> Word32 -> Word32 -> Word32
ch !x !y !z = (x .&. y) `xor` (complement x .&. z)
{-# INLINE ch #-}

-- | Bitwise majority: (x AND y) XOR (x AND z) XOR (y AND z).
maj :: Word32 -> Word32 -> Word32 -> Word32
maj !x !y !z = (x .&. y) `xor` (x .&. z) `xor` (y .&. z)
{-# INLINE maj #-}

-- | Upper-case Sigma 0: RotR(x, 2) XOR RotR(x, 13) XOR RotR(x, 22).
sigmaUpper0 :: Word32 -> Word32
sigmaUpper0 !x = rotateR x 2 `xor` rotateR x 13 `xor` rotateR x 22
{-# INLINE sigmaUpper0 #-}

-- | Upper-case Sigma 1: RotR(x, 6) XOR RotR(x, 11) XOR RotR(x, 25).
sigmaUpper1 :: Word32 -> Word32
sigmaUpper1 !x = rotateR x 6 `xor` rotateR x 11 `xor` rotateR x 25
{-# INLINE sigmaUpper1 #-}

-- | Lower-case Sigma 0: RotR(x, 7) XOR RotR(x, 18) XOR ShiftR(x, 3).
sigmaLower0 :: Word32 -> Word32
sigmaLower0 !x = rotateR x 7 `xor` rotateR x 18 `xor` shiftR x 3
{-# INLINE sigmaLower0 #-}

-- | Lower-case Sigma 1: RotR(x, 17) XOR RotR(x, 19) XOR ShiftR(x, 10).
sigmaLower1 :: Word32 -> Word32
sigmaLower1 !x = rotateR x 17 `xor` rotateR x 19 `xor` shiftR x 10
{-# INLINE sigmaLower1 #-}

--------------------------------------------------------------------------------
-- 3. Message Padding & Block Preparation
--------------------------------------------------------------------------------

-- | Pads message bytes according to FIPS 180-2:
-- 1. Appends bit 1 (single byte 0x80).
-- 2. Appends k zero bytes such that (length + 1 + k) == 56 mod 64.
-- 3. Appends 64-bit big-endian representation of original bit length.
padMessage :: BS.ByteString -> BL.ByteString
padMessage input =
  let lenBytes = fromIntegral (BS.length input) :: Word64
      lenBits = lenBytes * 8
      padLen = (55 - fromIntegral (lenBytes `mod` 64)) `mod` 64
      zeros = BS.replicate padLen 0
      lenEncoding = BS.pack
        [ fromIntegral (shiftR lenBits 56 .&. 0xFF)
        , fromIntegral (shiftR lenBits 48 .&. 0xFF)
        , fromIntegral (shiftR lenBits 40 .&. 0xFF)
        , fromIntegral (shiftR lenBits 32 .&. 0xFF)
        , fromIntegral (shiftR lenBits 24 .&. 0xFF)
        , fromIntegral (shiftR lenBits 16 .&. 0xFF)
        , fromIntegral (shiftR lenBits 8  .&. 0xFF)
        , fromIntegral (lenBits .&. 0xFF)
        ]
  in BL.fromChunks [input, BS.singleton 0x80, zeros, lenEncoding]

-- | Parses a 64-byte block into 16 32-bit big-endian words.
parseBlock :: BS.ByteString -> [Word32]
parseBlock bs = go (BS.unpack bs)
  where
    go [] = []
    go (b0:b1:b2:b3:rest) =
      let !w0 = fromIntegral b0 `shiftL` 24
          !w1 = fromIntegral b1 `shiftL` 16
          !w2 = fromIntegral b2 `shiftL` 8
          !w3 = fromIntegral b3
          !w  = w0 .|. w1 .|. w2 .|. w3
      in w : go rest
    go _ = []

-- | Expands the 16 words of a block into a full 64-word message schedule.
expandSchedule :: [Word32] -> [Word32]
expandSchedule w16 = go 16 (w16 ++ replicate 48 0)
  where
    go !t !ws
      | t == 64   = ws
      | otherwise =
          let s0 = sigmaLower0 (ws !! (t - 15))
              s1 = sigmaLower1 (ws !! (t - 2))
              newW = (ws !! (t - 16)) + s0 + (ws !! (t - 7)) + s1
              updated = take t ws ++ [newW] ++ drop (t + 1) ws
          in go (t + 1) updated

--------------------------------------------------------------------------------
-- 4. Compression Function & Main Loop
--------------------------------------------------------------------------------

-- | Processes a single 64-byte block, updating the internal 8-word hash state.
compressBlock
  :: (Word32, Word32, Word32, Word32, Word32, Word32, Word32, Word32)
  -> BS.ByteString
  -> (Word32, Word32, Word32, Word32, Word32, Word32, Word32, Word32)
compressBlock (!h0, !h1, !h2, !h3, !h4, !h5, !h6, !h7) blockBytes =
  let wSchedule = expandSchedule (parseBlock blockBytes)
      (!a, !b, !c, !d, !e, !f, !g, !h) =
        foldl step (h0, h1, h2, h3, h4, h5, h6, h7) (zip roundConstants wSchedule)
  in (h0 + a, h1 + b, h2 + c, h3 + d, h4 + e, h5 + f, h6 + g, h7 + h)
  where
    step (!a, !b, !c, !d, !e, !f, !g, !h) (!k, !w) =
      let !t1 = h + sigmaUpper1 e + ch e f g + k + w
          !t2 = sigmaUpper0 a + maj a b c
      in (t1 + t2, a, b, c, d + t1, e, f, g)

-- | Computes the raw 8-word SHA-256 state from lazy byte stream.
hashByteString :: BL.ByteString -> (Word32, Word32, Word32, Word32, Word32, Word32, Word32, Word32)
hashByteString lazyBytes =
  let chunks = splitBlocks (BL.toStrict lazyBytes)
  in foldl compressBlock initialHash chunks
  where
    splitBlocks bs
      | BS.null bs = []
      | otherwise  = BS.take 64 bs : splitBlocks (BS.drop 64 bs)

-- | Formats an 8-word hash state into a 64-character lowercase hexadecimal string.
formatDigest :: (Word32, Word32, Word32, Word32, Word32, Word32, Word32, Word32) -> Text
formatDigest (h0, h1, h2, h3, h4, h5, h6, h7) =
  T.pack $ concatMap padHex [h0, h1, h2, h3, h4, h5, h6, h7]
  where
    padHex w =
      let s = showHex w ""
      in replicate (8 - length s) '0' ++ s

--------------------------------------------------------------------------------
-- 5. Public API
--------------------------------------------------------------------------------

-- | Computes the SHA-256 hex digest of a UTF-8 'Text' string.
--
-- === Worked Example
--
-- >>> sha256Text ""
-- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
sha256Text :: Text -> Text
sha256Text = sha256ByteString . TE.encodeUtf8

-- | Computes the SHA-256 hex digest of a strict 'BS.ByteString'.
--
-- === Worked Example
--
-- >>> sha256ByteString "hello"
-- "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
sha256ByteString :: BS.ByteString -> Text
sha256ByteString bs = formatDigest (hashByteString (padMessage bs))

-- | Computes the SHA-256 hex digest of a file on disk.
--
-- Reads the file contents strictly as binary and computes the digest.
sha256File :: FilePath -> IO Text
sha256File path = do
  content <- BS.readFile path
  pure (sha256ByteString content)