sd-jwt-0.1.0.0: src/SDJWT/Internal/Issuance/Nested.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Nested structure processing for SD-JWT issuance.
--
-- This module handles nested structures according to RFC 9901 Sections 6.2 and 6.3:
--
-- * Section 6.2 (Structured SD-JWT): Parent object stays in payload with @_sd@ array
-- containing digests for sub-claims.
--
-- * Section 6.3 (Recursive Disclosures): Parent is selectively disclosable, and its
-- disclosure contains an @_sd@ array with digests for sub-claims.
--
-- This module is used internally by 'SDJWT.Internal.Issuance' and is not part of the
-- public API.
module SDJWT.Internal.Issuance.Nested
( processNestedStructures
, processRecursiveDisclosures
) where
import SDJWT.Internal.Types (HashAlgorithm(..), Digest(..), EncodedDisclosure(..), SDJWTError(..), Salt(..), unDigest)
import SDJWT.Internal.Utils (groupPathsByFirstSegment, generateSalt)
import SDJWT.Internal.Digest (computeDigest)
import SDJWT.Internal.Disclosure (createObjectDisclosure, createArrayDisclosure)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Vector as V
import Data.List (partition, sortBy)
import Data.Maybe (mapMaybe)
import Data.Either (partitionEithers)
import Text.Read (readMaybe)
import Data.Ord (comparing)
-- | Process nested structures (Section 6.2: structured SD-JWT).
-- Creates _sd arrays within parent objects for sub-claims, or ellipsis objects in arrays.
-- Supports arbitrary depth paths like ["user", "profile", "email"] or ["user", "emails", "0"].
-- Handles both objects and arrays at each level.
-- Returns: (processed payload object, all disclosures, remaining unprocessed claims)
processNestedStructures
:: HashAlgorithm
-> [[T.Text]] -- ^ List of path segments (e.g., [["user", "profile", "email"]])
-> Aeson.Object -- ^ Original claims object
-> IO (Either SDJWTError (KeyMap.KeyMap Aeson.Value, [EncodedDisclosure], Aeson.Object))
processNestedStructures hashAlg nestedPaths claims = do
-- Group nested paths by first segment (top-level claim)
let getFirstSegment [] = ""
getFirstSegment (seg:_) = seg
-- Convert to format expected by groupPathsByFirstSegment (list of segments)
let groupedByTopLevel = Map.fromListWith (++) $ map (\path -> (getFirstSegment path, [path])) nestedPaths
-- Process each top-level claim recursively (can be object or array)
results <- mapM (\(topLevelName, paths) -> do
case KeyMap.lookup (Key.fromText topLevelName) claims of
Nothing -> return $ Left $ InvalidDisclosureFormat $ "Parent claim not found: " <> topLevelName
Just topLevelValue -> do
-- Strip the first segment (topLevelName) from each path before processing
let strippedPaths = map (\path -> case path of
[] -> []
(_:rest) -> rest) paths
-- Process all paths under this top-level claim (handles both objects and arrays)
processResult <- processPathsRecursively hashAlg strippedPaths topLevelValue
case processResult of
Left err -> return $ Left err
Right (modifiedValue, disclosures) -> return $ Right (topLevelName, modifiedValue, disclosures)
) (Map.toList groupedByTopLevel)
-- Check for errors
let (errors, successes) = partitionEithers results
case errors of
(err:_) -> return (Left err)
[] -> do
-- Separate objects and arrays
let (objects, arrays) = partition (\(_, val, _) -> case val of
Aeson.Object _ -> True
_ -> False) successes
let processedObjects = Map.fromList $ mapMaybe (\(name, val, _) -> case val of
Aeson.Object obj -> Just (name, obj)
_ -> Nothing) objects
let processedArrays = Map.fromList $ mapMaybe (\(name, val, _) -> case val of
Aeson.Array arr -> Just (name, arr)
_ -> Nothing) arrays
let allDisclosures = concatMap (\(_, _, disclosures) -> disclosures) successes
-- Remove processed parents from remaining claims
let processedParents = Set.fromList $ map (\(name, _, _) -> name) successes
let remainingClaims = KeyMap.filterWithKey (\k _ -> Key.toText k `Set.notMember` processedParents) claims
-- Convert processed objects and arrays to KeyMap
let processedPayload = foldl (\acc (name, obj) ->
KeyMap.insert (Key.fromText name) (Aeson.Object obj) acc) KeyMap.empty (Map.toList processedObjects)
-- Add processed arrays to payload
let processedPayloadWithArrays = Map.foldlWithKey (\acc name arr ->
KeyMap.insert (Key.fromText name) (Aeson.Array arr) acc) processedPayload processedArrays
return (Right (processedPayloadWithArrays, allDisclosures, remainingClaims))
where
-- Helper function to recursively process paths, handling both objects and arrays at each level
-- This unified function checks the type at each level and handles accordingly
processPathsRecursively :: HashAlgorithm -> [[T.Text]] -> Aeson.Value -> IO (Either SDJWTError (Aeson.Value, [EncodedDisclosure]))
processPathsRecursively hashAlg' paths value = case value of
Aeson.Object obj -> processObjectPaths hashAlg' paths obj
Aeson.Array arr -> processArrayPaths hashAlg' paths arr
_ -> return $ Left $ InvalidDisclosureFormat "Cannot process paths in primitive value (not an object or array)"
-- Validate that nested value is an object or array if there are remaining paths
validateNestedValueType :: T.Text -> [[T.Text]] -> Aeson.Value -> Either SDJWTError ()
validateNestedValueType firstSeg nonEmptyPaths nestedValue =
if null nonEmptyPaths
then Right () -- No remaining paths, value type doesn't matter
else case nestedValue of
Aeson.Object _ -> Right ()
Aeson.Array _ -> Right ()
_ -> Left $ InvalidDisclosureFormat $ "Path segment is not an object: " <> firstSeg
-- Mark a segment as selectively disclosable and create _sd object
markSegmentAsSelectivelyDisclosable
:: HashAlgorithm
-> T.Text -- ^ Segment name
-> Key.Key -- ^ Segment key
-> Aeson.Value -- ^ Value to mark as SD
-> KeyMap.KeyMap Aeson.Value -- ^ Original object
-> IO (Either SDJWTError (KeyMap.KeyMap Aeson.Value, [EncodedDisclosure]))
markSegmentAsSelectivelyDisclosable hashAlg' firstSeg firstKey nestedValue obj = do
result <- markSelectivelyDisclosable hashAlg' firstSeg nestedValue
case result of
Left err -> return (Left err)
Right (digest, disclosure) -> do
-- Replace this key with _sd object
-- When marking a claim as selectively disclosable, we replace it with {"_sd": ["digest"]}
-- at the same level, not nest it under the original key
let updatedObj = KeyMap.delete firstKey obj
let sdArray = Aeson.Array (V.fromList [Aeson.String (unDigest digest)])
let sdObj = KeyMap.insert "_sd" sdArray KeyMap.empty
-- Return the _sd object merged with the updated object (without the original key)
return (Right (KeyMap.union sdObj updatedObj, [disclosure]))
-- Process a single path segment (handles both target and nested cases)
processPathSegment
:: HashAlgorithm
-> T.Text -- ^ First segment name
-> [[T.Text]] -- ^ Remaining paths
-> KeyMap.KeyMap Aeson.Value -- ^ Original object
-> Aeson.Value -- ^ Nested value
-> IO (Either SDJWTError (KeyMap.KeyMap Aeson.Value, [EncodedDisclosure]))
processPathSegment hashAlg' firstSeg remainingPaths obj nestedValue = do
-- Filter out empty paths (this segment is the target)
let (emptyPaths, nonEmptyPaths) = partition null remainingPaths
-- Validate nested value type if there are remaining paths
case validateNestedValueType firstSeg nonEmptyPaths nestedValue of
Left err -> return (Left err)
Right () -> do
if null nonEmptyPaths
then do
-- This segment is the target - mark it as selectively disclosable
let firstKey = Key.fromText firstSeg
markSegmentAsSelectivelyDisclosable hashAlg' firstSeg firstKey nestedValue obj
else do
-- Recurse into nested value (could be object or array)
nestedResult <- processPathsRecursively hashAlg' nonEmptyPaths nestedValue
case nestedResult of
Left err -> return (Left err)
Right (modifiedNestedValue, nestedDisclosures) -> do
let firstKey = Key.fromText firstSeg
if null emptyPaths
then return (Right (KeyMap.insert firstKey modifiedNestedValue obj, nestedDisclosures))
else do
-- Mark this level as selectively disclosable too
result <- markSegmentAsSelectivelyDisclosable hashAlg' firstSeg firstKey modifiedNestedValue obj
case result of
Left err -> return (Left err)
Right (sdObj, parentDisclosure) ->
return (Right (sdObj, parentDisclosure ++ nestedDisclosures))
-- Combine results from processing all path segments
combineObjectPathResults
:: KeyMap.KeyMap Aeson.Value -- ^ Original object
-> Map.Map T.Text [[T.Text]] -- ^ Grouped paths
-> [(KeyMap.KeyMap Aeson.Value, [EncodedDisclosure])] -- ^ Success results
-> (KeyMap.KeyMap Aeson.Value, [EncodedDisclosure])
combineObjectPathResults obj groupedByFirst successes = do
-- Merge all modified objects and combine disclosures
-- Track which keys were deleted (marked as selectively disclosable)
let (modifiedObjs, disclosuresList) = unzip successes
let deletedKeys = Set.fromList $ map (\(firstSeg, _) -> Key.fromText firstSeg) (Map.toList groupedByFirst)
-- Start with original object and apply all modifications
-- When merging, combine _sd arrays instead of overwriting them
-- Also remove keys that were marked as selectively disclosable
let finalObj = foldl mergeModifiedObject obj modifiedObjs
-- Remove keys that were marked as selectively disclosable
let finalObjWithoutDeleted = Set.foldr KeyMap.delete finalObj deletedKeys
(finalObjWithoutDeleted, concat disclosuresList)
-- Process paths within an object
processObjectPaths :: HashAlgorithm -> [[T.Text]] -> KeyMap.KeyMap Aeson.Value -> IO (Either SDJWTError (Aeson.Value, [EncodedDisclosure]))
processObjectPaths hashAlg' paths obj = do
-- Group paths by their first segment
let groupedByFirst = groupPathsByFirstSegment paths
-- Process each group
results <- mapM (\(firstSeg, remainingPaths) -> do
let firstKey = Key.fromText firstSeg
case KeyMap.lookup firstKey obj of
Nothing -> return $ Left $ InvalidDisclosureFormat $ "Path segment not found: " <> firstSeg
Just nestedValue -> processPathSegment hashAlg' firstSeg remainingPaths obj nestedValue
) (Map.toList groupedByFirst)
-- Combine results
let (errors, successes) = partitionEithers results
case errors of
(err:_) -> return (Left err)
[] -> do
let (finalObj, allDisclosures) = combineObjectPathResults obj groupedByFirst successes
return (Right (Aeson.Object finalObj, allDisclosures))
-- Helper function to merge a modified object into an accumulator, combining _sd arrays
mergeModifiedObject :: KeyMap.KeyMap Aeson.Value -> KeyMap.KeyMap Aeson.Value -> KeyMap.KeyMap Aeson.Value
mergeModifiedObject = KeyMap.foldrWithKey insertOrMergeSD
-- Helper function to insert a key-value pair, merging _sd arrays if present
insertOrMergeSD :: Key.Key -> Aeson.Value -> KeyMap.KeyMap Aeson.Value -> KeyMap.KeyMap Aeson.Value
insertOrMergeSD k v acc2
| k == Key.fromText "_sd" = case (KeyMap.lookup k acc2, v) of
(Just (Aeson.Array existingArr), Aeson.Array newArr) ->
-- Combine arrays, removing duplicates and sorting
let allDigestsList = V.toList existingArr ++ V.toList newArr
allDigests = mapMaybe extractDigestString allDigestsList
uniqueDigests = Set.toList $ Set.fromList allDigests
sortedDigests = map Aeson.String $ sortBy compare uniqueDigests
in KeyMap.insert k (Aeson.Array (V.fromList sortedDigests)) acc2
_ -> KeyMap.insert k v acc2
| otherwise = KeyMap.insert k v acc2
-- Helper function to extract digest strings from Aeson values
extractDigestString :: Aeson.Value -> Maybe T.Text
extractDigestString (Aeson.String s) = Just s
extractDigestString _ = Nothing
-- Process paths within an array
-- Paths should have numeric segments representing array indices
processArrayPaths :: HashAlgorithm -> [[T.Text]] -> V.Vector Aeson.Value -> IO (Either SDJWTError (Aeson.Value, [EncodedDisclosure]))
processArrayPaths hashAlg' paths arr = do
-- Parse first segment of each path to extract array index
-- Group paths by first index
let groupedByFirstIndex = Map.fromListWith (++) $ mapMaybe (\path -> case path of
[] -> Nothing
(firstSeg:rest) -> case readMaybe (T.unpack firstSeg) :: Maybe Int of
Just idx -> Just (idx, [rest])
Nothing -> Nothing -- Not a numeric segment, skip (shouldn't happen for array paths)
) paths
-- Process each group
results <- mapM (\(firstIdx, remainingPaths) -> do
if firstIdx < 0 || firstIdx >= V.length arr
then return $ Left $ InvalidDisclosureFormat $ "Array index " <> T.pack (show firstIdx) <> " out of bounds"
else do
let element = arr V.! firstIdx
-- Filter out empty paths (this element is the target)
let (_emptyPaths, nonEmptyPaths) = partition null remainingPaths
if null nonEmptyPaths
then do
-- This element is the target - mark it as selectively disclosable
result <- markArrayElementDisclosable hashAlg' element
case result of
Left err -> return $ Left err
Right (digest, disclosure) ->
let ellipsisObj = Aeson.Object $ KeyMap.fromList [(Key.fromText "...", Aeson.String (unDigest digest))]
in return $ Right (firstIdx, ellipsisObj, [disclosure])
else do
-- Recurse into nested value (could be object or array)
nestedResult <- processPathsRecursively hashAlg' nonEmptyPaths element
case nestedResult of
Left err -> return $ Left err
Right (modifiedNestedValue, nestedDisclosures) -> do
-- If the modified nested value is still an array, preserve the structure
-- (don't mark the entire array as SD, just return it with SD elements inside)
case modifiedNestedValue of
Aeson.Array _ ->
-- Array structure preserved, return it directly without marking as SD
return $ Right (firstIdx, modifiedNestedValue, nestedDisclosures)
_ -> do
-- For objects or other types, mark as selectively disclosable
outerResult <- markArrayElementDisclosable hashAlg' modifiedNestedValue
case outerResult of
Left err -> return $ Left err
Right (digest, outerDisclosure) ->
let ellipsisObj = Aeson.Object $ KeyMap.fromList [(Key.fromText "...", Aeson.String (unDigest digest))]
in return $ Right (firstIdx, ellipsisObj, outerDisclosure:nestedDisclosures)
) (Map.toList groupedByFirstIndex)
let (errors, successes) = partitionEithers results
case errors of
(err:_) -> return $ Left err
[] -> do
-- Build modified array with ellipsis objects or modified arrays at specified indices
let arrWithDigests = foldl (\acc (idx, value, _) ->
-- value can be either an ellipsis object (from markArrayElementDisclosable) or a modified array
V.unsafeUpd acc [(idx, value)]
) arr successes
let allDisclosures = concat (map (\(_, _, disclosures) -> disclosures) successes)
return $ Right (Aeson.Array arrWithDigests, allDisclosures)
-- Helper function to mark a claim as selectively disclosable
markSelectivelyDisclosable :: HashAlgorithm -> T.Text -> Aeson.Value -> IO (Either SDJWTError (Digest, EncodedDisclosure))
markSelectivelyDisclosable hashAlg' claimName claimValue =
fmap (\saltBytes ->
let salt = Salt saltBytes
in case createObjectDisclosure salt claimName claimValue of
Left err -> Left err
Right encodedDisclosure ->
let digest = computeDigest hashAlg' encodedDisclosure
in Right (digest, encodedDisclosure)
) generateSalt
-- Helper function to mark an array element as selectively disclosable
markArrayElementDisclosable :: HashAlgorithm -> Aeson.Value -> IO (Either SDJWTError (Digest, EncodedDisclosure))
markArrayElementDisclosable hashAlg' elementValue =
fmap (\saltBytes ->
let salt = Salt saltBytes
in case createArrayDisclosure salt elementValue of
Left err -> Left err
Right encodedDisclosure ->
let digest = computeDigest hashAlg' encodedDisclosure
in Right (digest, encodedDisclosure)
) generateSalt
-- | Process recursive disclosures (Section 6.3: recursive disclosures).
-- Creates disclosures for parent claims where the disclosure value contains
-- an _sd array with digests for sub-claims.
-- Supports arbitrary depth paths like ["user", "profile", "email"].
-- Returns: (parent digests and disclosures with recursive structure, all disclosures including children, remaining unprocessed claims)
processRecursiveDisclosures
:: HashAlgorithm
-> [[T.Text]] -- ^ List of path segments for recursive disclosures (e.g., [["user", "profile", "email"]])
-> Aeson.Object -- ^ Original claims object
-> IO (Either SDJWTError ([(T.Text, Digest, EncodedDisclosure)], [EncodedDisclosure], Aeson.Object))
processRecursiveDisclosures hashAlg recursivePaths claims = do
-- Group recursive paths by first segment (top-level claim)
let getFirstSegment [] = ""
getFirstSegment (seg:_) = seg
let groupedByTopLevel = Map.fromListWith (++) $ map (\path -> (getFirstSegment path, [path])) recursivePaths
-- Process each top-level claim recursively
results <- mapM (\(topLevelName, paths) -> do
case KeyMap.lookup (Key.fromText topLevelName) claims of
Nothing -> return $ Left $ InvalidDisclosureFormat $ "Parent claim not found: " <> topLevelName
Just (Aeson.Object topLevelObj) -> do
-- Strip the first segment (topLevelName) from each path before processing
let strippedPaths = map (\path -> case path of
[] -> []
(_:rest) -> rest) paths
-- Process paths recursively - for recursive disclosures, the parent becomes selectively disclosable
processResult <- processRecursivePaths hashAlg strippedPaths topLevelObj topLevelName
case processResult of
Left err -> return $ Left err
Right (parentDigest, parentDisclosure, childDisclosures) ->
return $ Right (topLevelName, parentDigest, parentDisclosure, childDisclosures)
Just _ -> return $ Left $ InvalidDisclosureFormat $ "Top-level claim is not an object: " <> topLevelName
) (Map.toList groupedByTopLevel)
-- Check for errors
let (errors, successes) = partitionEithers results
case errors of
(err:_) -> return (Left err)
[] -> do
-- Extract parent info and all child disclosures
let parentInfo = map (\(name, digest, disc, _) -> (name, digest, disc)) successes
let allChildDisclosures = concatMap (\(_, _, _, childDiscs) -> childDiscs) successes
-- Remove recursive parents from remaining claims (they're now in disclosures)
let recursiveParentNames = Set.fromList $ map (\(name, _, _) -> name) parentInfo
let remainingClaims = KeyMap.filterWithKey (\k _ -> Key.toText k `Set.notMember` recursiveParentNames) claims
-- Combine parent and child disclosures (parents first, then children)
let parentDisclosures = map (\(_, _, disc) -> disc) parentInfo
let allDisclosures = parentDisclosures ++ allChildDisclosures
return (Right (parentInfo, allDisclosures, remainingClaims))
where
-- Helper function to recursively process paths for recursive disclosures
processRecursivePaths :: HashAlgorithm -> [[T.Text]] -> KeyMap.KeyMap Aeson.Value -> T.Text -> IO (Either SDJWTError (Digest, EncodedDisclosure, [EncodedDisclosure]))
processRecursivePaths hashAlg' paths obj parentName = do
-- Group paths by their first segment
let groupedByFirst = groupPathsByFirstSegment paths
-- Process each group
results <- mapM (\(firstSeg, remainingPaths) -> do
let firstKey = Key.fromText firstSeg
case KeyMap.lookup firstKey obj of
Nothing -> return $ Left $ InvalidDisclosureFormat $ "Path segment not found: " <> firstSeg
Just (Aeson.Object nestedObj) -> do
-- Filter out empty paths (this segment is the target)
let (_emptyPaths, nonEmptyPaths) = partition null remainingPaths
if null nonEmptyPaths
then do
-- This segment is the target - mark it as selectively disclosable
-- Return the digest and disclosure (will be combined into parent _sd array)
result <- markSelectivelyDisclosable hashAlg' firstSeg (Aeson.Object nestedObj)
case result of
Left err -> return $ Left err
Right (digest, disclosure) -> return $ Right (digest, disclosure, [])
else do
-- Recurse into nested object
nestedResult <- processRecursivePaths hashAlg' nonEmptyPaths nestedObj firstSeg
case nestedResult of
Left err -> return $ Left err
Right (childDigest, childDisclosure, grandchildDisclosures) -> do
-- Return child digest and disclosure (will be combined into parent _sd array)
return $ Right (childDigest, childDisclosure, grandchildDisclosures)
Just leafValue -> do
-- Leaf value (string, number, bool, etc.) - this is the target
-- Check if there are remaining paths (shouldn't happen for leaf values)
let (_emptyPaths, nonEmptyPaths) = partition null remainingPaths
if not (null nonEmptyPaths)
then return $ Left $ InvalidDisclosureFormat $ "Cannot traverse into leaf value: " <> firstSeg
else do
-- Mark this leaf value as selectively disclosable
result <- markSelectivelyDisclosable hashAlg' firstSeg leafValue
case result of
Left err -> return $ Left err
Right (digest, disclosure) -> return $ Right (digest, disclosure, [])
) (Map.toList groupedByFirst)
-- Combine results - for recursive disclosures, we need to combine all child digests
-- into one parent _sd array
let (errors, successes) = partitionEithers results
case errors of
(err:_) -> return $ Left err
[] -> do
case successes of
[] -> return $ Left $ InvalidDisclosureFormat "No paths to process"
_ -> do
-- Collect all child digests and disclosures
-- Each success is (digest, disclosure, grandchildDisclosures)
-- For leaf children, disclosure is the child disclosure itself
-- For nested children, disclosure is an intermediate parent, and grandchildDisclosures contains the actual children
let allChildDigests = map (\(digest, _, _) -> digest) successes
let allChildDisclosures = concatMap (\(_, disclosure, grandchildDiscs) -> disclosure:grandchildDiscs) successes
-- Create parent disclosure with _sd array containing all child digests
let sdArray = Aeson.Array (V.fromList $ map (Aeson.String . unDigest) (sortDigests allChildDigests))
let parentDisclosureValue = Aeson.Object $ KeyMap.fromList [("_sd", sdArray)]
parentResult <- markSelectivelyDisclosable hashAlg' parentName parentDisclosureValue
case parentResult of
Left err -> return $ Left err
Right (parentDigest, parentDisclosure) ->
return $ Right (parentDigest, parentDisclosure, allChildDisclosures)
-- Helper function to mark a claim as selectively disclosable
markSelectivelyDisclosable :: HashAlgorithm -> T.Text -> Aeson.Value -> IO (Either SDJWTError (Digest, EncodedDisclosure))
markSelectivelyDisclosable hashAlg' claimName claimValue =
fmap (\saltBytes ->
let salt = Salt saltBytes
in case createObjectDisclosure salt claimName claimValue of
Left err -> Left err
Right encodedDisclosure ->
let digest = computeDigest hashAlg' encodedDisclosure
in Right (digest, encodedDisclosure)
) generateSalt
-- Helper function to sort digests
sortDigests :: [Digest] -> [Digest]
sortDigests = sortBy (comparing unDigest)