packages feed

sd-jwt-0.1.0.0: src/SDJWT/Internal/KeyBinding.hs

{-# LANGUAGE OverloadedStrings #-}
-- | Key Binding JWT support for SD-JWT+KB.
--
-- This module provides functions for creating and verifying Key Binding JWTs
-- (KB-JWT) as specified in RFC 9901 Section 7. Key Binding provides proof
-- of possession of a key by the holder.
module SDJWT.Internal.KeyBinding
  ( createKeyBindingJWT
  , computeSDHash
  , verifyKeyBindingJWT
  , addKeyBindingToPresentation
  ) where

import SDJWT.Internal.Types (HashAlgorithm(..), Digest(..), SDJWTPresentation(..), SDJWTError(..))
import SDJWT.Internal.Utils (hashToBytes, textToByteString, base64urlEncode, constantTimeEq, base64urlDecode)
import SDJWT.Internal.Serialization (serializePresentation)
import SDJWT.Internal.JWT (signJWTWithTyp, verifyJWT, JWKLike)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Text as T
import Data.Int (Int64)

-- | Create a Key Binding JWT.
--
-- Creates a KB-JWT that proves the holder possesses a specific key.
-- The KB-JWT contains:
--
-- - aud: Audience (verifier identifier)
-- - nonce: Nonce provided by verifier
-- - iat: Issued at timestamp
-- - sd_hash: Hash of the SD-JWT presentation
-- - Optional additional claims (e.g., exp for expiration time)
--
-- Note: RFC 9901 Section 4.3 states that additional claims in @optionalClaims@ SHOULD be avoided
-- unless there is a compelling reason, as they may harm interoperability.
--
-- Returns the signed KB-JWT as a compact JWT string.
createKeyBindingJWT
  :: JWKLike jwk => HashAlgorithm  -- ^ Hash algorithm for computing sd_hash
  -> jwk  -- ^ Holder private key (Text or jose JWK object)
  -> T.Text  -- ^ Audience claim (verifier identifier)
  -> T.Text  -- ^ Nonce from verifier
  -> Int64   -- ^ Issued at timestamp (Unix epoch seconds)
  -> SDJWTPresentation  -- ^ The SD-JWT presentation to bind
  -> Aeson.Object  -- ^ Optional additional claims (e.g., exp, nbf). These will be validated during verification if present. Pass @KeyMap.empty@ for no additional claims.
  -> IO (Either SDJWTError T.Text)
createKeyBindingJWT hashAlg holderPrivateKey audience nonce issuedAt presentation optionalClaims =
  -- Compute sd_hash of the presentation
  let sdHash = computeSDHash hashAlg presentation
      
      -- Build base KB-JWT payload with required claims
      basePayloadObj = KeyMap.fromList
        [ (Key.fromText "aud", Aeson.String audience)
        , (Key.fromText "nonce", Aeson.String nonce)
        , (Key.fromText "iat", Aeson.Number (fromIntegral issuedAt))
        , (Key.fromText "sd_hash", Aeson.String (unDigest sdHash))
        ]
      
      -- Merge optional claims into payload (optional claims override base claims if keys conflict)
      kbPayloadObj = KeyMap.union optionalClaims basePayloadObj  -- optionalClaims takes precedence
      kbPayload = Aeson.Object kbPayloadObj
  in
    -- Sign the KB-JWT with typ: "kb+jwt" header (RFC 9901 Section 4.3 requirement)
    -- Supports all key types: RSA (PS256 default, RS256 also supported), EC P-256 (ES256), and Ed25519 (EdDSA).
    signJWTWithTyp "kb+jwt" holderPrivateKey kbPayload

-- | Compute sd_hash for key binding.
--
-- The sd_hash is computed as the hash of the serialized SD-JWT presentation
-- (without the KB-JWT part). This hash is included in the KB-JWT to bind
-- it to the specific presentation.
--
-- The hash is computed over the US-ASCII bytes of the presentation string
-- (per RFC 9901). Since the serialized presentation contains only ASCII
-- characters (base64url-encoded strings and tilde separators), UTF-8 encoding
-- produces identical bytes to US-ASCII.
computeSDHash
  :: HashAlgorithm
  -> SDJWTPresentation
  -> Digest
computeSDHash hashAlg presentation =
  -- Serialize presentation (without KB-JWT)
  -- Create a presentation without KB-JWT for serialization
  let presentationWithoutKB = presentation { keyBindingJWT = Nothing }
      presentationText = serializePresentation presentationWithoutKB
      -- Convert to bytes (UTF-8 is equivalent to US-ASCII for ASCII-only strings)
      presentationBytes = textToByteString presentationText
      -- Compute hash
      hashBytes = hashToBytes hashAlg presentationBytes
      -- Base64url encode
      hashText = base64urlEncode hashBytes
  in
    Digest hashText

-- | Verify a Key Binding JWT.
--
-- Verifies that:
--
-- 1. The KB-JWT signature is valid (using holder's public key)
-- 2. The sd_hash in the KB-JWT matches the computed hash of the presentation
-- 3. The nonce, audience, and iat claims are present and valid
--
-- Returns 'Right ()' if verification succeeds, 'Left' with error otherwise.
verifyKeyBindingJWT
  :: JWKLike jwk => HashAlgorithm  -- ^ Hash algorithm for verifying sd_hash
  -> jwk  -- ^ Holder public key (Text or jose JWK object)
  -> T.Text  -- ^ KB-JWT to verify
  -> SDJWTPresentation  -- ^ The SD-JWT presentation
  -> IO (Either SDJWTError ())
verifyKeyBindingJWT hashAlg holderPublicKey kbJWT presentation = do
  -- RFC 9901 Section 4.3: Validate KB-JWT header first
  -- typ: REQUIRED. MUST be kb+jwt
  let kbParts = T.splitOn "." kbJWT
  case kbParts of
    (headerPart : _payloadPart : _signaturePart) -> do
      -- Decode and validate header
      headerBytes <- case base64urlDecode headerPart of
        Left err -> return $ Left $ InvalidKeyBinding $ "Failed to decode KB-JWT header: " <> err
        Right bs -> return $ Right bs
      
      case headerBytes of
        Left err -> return $ Left err
        Right hBytes -> do
          headerJson <- case Aeson.eitherDecodeStrict hBytes of
            Left err -> return $ Left $ InvalidKeyBinding $ "Failed to parse KB-JWT header: " <> T.pack err
            Right val -> return $ Right val
          
          case headerJson of
            Left err -> return $ Left err
            Right (Aeson.Object hObj) -> do
              -- RFC 9901 Section 4.3: typ MUST be "kb+jwt"
              case KeyMap.lookup "typ" hObj of
                Just (Aeson.String "kb+jwt") -> do
                  -- typ is correct, continue with signature verification
                  -- Note: For KB-JWT, typ is already validated above, so we pass Nothing (liberal mode)
                  -- (KB-JWT typ validation is handled separately, not through verifyJWT's typ check)
                  verifiedPayloadResult <- verifyJWT holderPublicKey kbJWT Nothing
                  case verifiedPayloadResult of
                    Left err -> return (Left err)
                    Right kbPayload -> do
                      -- Extract claims from verified payload
                      sdHashClaim <- return $ extractClaim "sd_hash" kbPayload
                      nonceClaim <- return $ extractClaim "nonce" kbPayload
                      audClaim <- return $ extractClaim "aud" kbPayload
                      iatClaim <- return $ extractClaim "iat" kbPayload
                      
                      case sdHashClaim of
                        Left err -> return (Left err)
                        Right (Aeson.String hashText) -> do
                          -- Verify sd_hash matches presentation using constant-time comparison
                          -- SECURITY: Constant-time comparison prevents timing attacks
                          let computedHash = computeSDHash hashAlg presentation
                              expectedBytes = textToByteString hashText
                              computedBytes = textToByteString (unDigest computedHash)
                          if constantTimeEq expectedBytes computedBytes
                            then do
                              -- Verify nonce, audience, iat are present (basic validation)
                              case (nonceClaim, audClaim, iatClaim) of
                                (Right (Aeson.String _), Right (Aeson.String _), Right (Aeson.Number _)) -> return (Right ())
                                _ -> return $ Left $ InvalidKeyBinding "Missing required claims (nonce, aud, iat)"
                            else return $ Left $ InvalidKeyBinding "sd_hash mismatch"
                        Right _ -> return $ Left $ InvalidKeyBinding "Invalid sd_hash claim format"
                Just (Aeson.String typValue) -> return $ Left $ InvalidKeyBinding $ "Invalid KB-JWT typ: expected 'kb+jwt', got '" <> typValue <> "' (RFC 9901 Section 4.3)"
                _ -> return $ Left $ InvalidKeyBinding "Missing 'typ' header in KB-JWT (RFC 9901 Section 4.3 requires typ: 'kb+jwt')"
            Right _ -> return $ Left $ InvalidKeyBinding "Invalid KB-JWT header format: expected object"
    _ -> return $ Left $ InvalidKeyBinding "Invalid KB-JWT format: expected header.payload.signature"

-- | Add key binding to a presentation.
--
-- Creates a KB-JWT and adds it to the presentation, converting it to SD-JWT+KB format.
-- The KB-JWT includes required claims (@aud@, @nonce@, @iat@, @sd_hash@) plus any optional
-- claims provided. Standard JWT claims like @exp@ (expiration time) and @nbf@ (not before)
-- will be automatically validated during verification if present.
--
-- Note: RFC 9901 Section 4.3 states that additional claims in @optionalClaims@ SHOULD be avoided
-- unless there is a compelling reason, as they may harm interoperability.
addKeyBindingToPresentation
  :: JWKLike jwk => HashAlgorithm  -- ^ Hash algorithm for computing sd_hash
  -> jwk  -- ^ Holder private key (Text or jose JWK object)
  -> T.Text  -- ^ Audience claim (verifier identifier)
  -> T.Text  -- ^ Nonce provided by verifier
  -> Int64   -- ^ Issued at timestamp (Unix epoch seconds)
  -> SDJWTPresentation  -- ^ The SD-JWT presentation to bind
  -> Aeson.Object  -- ^ Optional additional claims (e.g., exp, nbf). Standard JWT claims will be validated during verification if present. Pass @KeyMap.empty@ for no additional claims.
  -> IO (Either SDJWTError SDJWTPresentation)
addKeyBindingToPresentation hashAlg holderKey audience nonce issuedAt presentation optionalClaims =
  fmap (\kb -> presentation { keyBindingJWT = Just kb })
    <$> createKeyBindingJWT hashAlg holderKey audience nonce issuedAt presentation optionalClaims

-- Helper functions

-- | Extract a claim from a JSON object.
extractClaim :: T.Text -> Aeson.Value -> Either SDJWTError Aeson.Value
extractClaim claimName (Aeson.Object obj) =
  case KeyMap.lookup (Key.fromText claimName) obj of
    Just val -> Right val
    Nothing -> Left $ InvalidKeyBinding $ "Missing claim: " <> claimName
extractClaim _ _ = Left $ InvalidKeyBinding "KB-JWT payload is not an object"