diff --git a/examples/simple_json_serialization/specification.yml b/examples/json_serialization_flattened/specification.yml similarity index 100% rename from examples/simple_json_serialization/specification.yml rename to examples/json_serialization_flattened/specification.yml diff --git a/examples/json_serialization_general/specification.yml b/examples/json_serialization_general/specification.yml new file mode 100644 index 0000000..b1a99fa --- /dev/null +++ b/examples/json_serialization_general/specification.yml @@ -0,0 +1,40 @@ +user_claims: + sub: john_doe_42 + !sd given_name: John + !sd family_name: Doe + !sd birthdate: "1940-01-01" + +holder_disclosed_claims: + sub: null + given_name: true + family_name: true + birthdate: false + +key_binding: True + +serialization_format: json + +settings_override: + key_settings: + key_size: 256 + kty: EC + issuer_keys: + - kty: EC + d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g + crv: P-256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 + kid: issuer-key-1 + - kty: EC + crv: P-256 + d: WsGosxrp0XK7VEviPL9xBm3fBb7Xys2vLhPGhESNoXY + x: bN-hp3IN0GZB3OlaQnHDPhY4nZsZbQyo4wY-y1NWCvA + y: vaSsH5jt9zt3aQvTvrSaFYLyjPG9Ug-2vntoNXlCbVU + kid: issuer-key-2 + + holder_key: + kty: EC + d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I + crv: P-256 + x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc + y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ diff --git a/examples/settings.yml b/examples/settings.yml index 53890e3..2a2925a 100644 --- a/examples/settings.yml +++ b/examples/settings.yml @@ -7,12 +7,12 @@ key_settings: kty: EC - issuer_key: - kty: EC - d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g - crv: P-256 - x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ - y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 + issuer_keys: + - kty: EC + d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g + crv: P-256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 holder_key: kty: EC @@ -23,9 +23,9 @@ key_settings: key_binding_nonce: "1234567890" -expiry_seconds: 86400000 # 1000 days +expiry_seconds: 86400000 # 1000 days random_seed: 0 -iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 -exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000 \ No newline at end of file +iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 +exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000 diff --git a/src/sd_jwt/bin/demo.py b/src/sd_jwt/bin/demo.py index d4b152b..89fdf79 100644 --- a/src/sd_jwt/bin/demo.py +++ b/src/sd_jwt/bin/demo.py @@ -21,9 +21,13 @@ textwrap_json, textwrap_text, multiline_code, + markdown_disclosures, EXAMPLE_SHORT_WIDTH, ) -from sd_jwt.utils.yaml_specification import load_yaml_specification +from sd_jwt.utils.yaml_specification import ( + load_yaml_specification, + remove_sdobj_wrappers, +) logger = logging.getLogger("sd_jwt") @@ -35,6 +39,7 @@ def generate_nonce(): DEFAULT_EXP_MINS = 15 + def run(): parser = argparse.ArgumentParser( description=f"{__file__} demo.", @@ -119,6 +124,12 @@ def run(): ### Load settings settings = load_yaml_settings(_args.settings_path) + ### Load example file + example_identifer = _args.example.stem + example = load_yaml_specification(_args.example) + ### "settings_override" key in example can override settings + settings.update(example.get("settings_override", {})) + print(f"Settings: {settings}") # If "no randomness" is requested, we hash the file name of the example # file to use it as the random seed. This ensures that the same example @@ -134,18 +145,15 @@ def run(): seed = None demo_keys = get_jwk(settings["key_settings"], _args.no_randomness, seed) - - ### Load example file - - example_identifer = _args.example.stem - example = load_yaml_specification(_args.example) + print(f"Using keys: {demo_keys}") use_decoys = example.get("add_decoy_claims", False) + serialization_format = example.get("serialization_format", "compact") ### Add default claims if necessary iat = _args.iat or int(datetime.datetime.utcnow().timestamp()) exp = _args.exp or iat + (DEFAULT_EXP_MINS * 60) claims = { - "iss": settings.ISSUER, + "iss": settings["identifiers"]["issuer"], "iat": iat, "exp": exp, } @@ -156,82 +164,92 @@ def run(): SDJWTIssuer.unsafe_randomness = _args.no_randomness sdjwt_at_issuer = SDJWTIssuer( claims, - demo_keys["issuer_key"], + demo_keys["issuer_keys"], demo_keys["holder_key"] if example.get("key_binding", False) else None, add_decoy_claims=use_decoys, + serialization_format=serialization_format, ) ### Produce SD-JWT-R for selected example # Note: The only input from the issuer is the combined SD-JWT and SVC! - sdjwt_at_holder = SDJWTHolder(sdjwt_at_issuer.sd_jwt_issuance) + sdjwt_at_holder = SDJWTHolder( + sdjwt_at_issuer.sd_jwt_issuance, + serialization_format=serialization_format, + ) sdjwt_at_holder.create_presentation( example["holder_disclosed_claims"], _args.nonce if example.get("key_binding", False) else None, - settings.VERIFIER if example.get("key_binding", False) else None, + ( + settings["identifiers"]["issuer"] + if example.get("key_binding", False) + else None + ), demo_keys["holder_key"] if example.get("key_binding", False) else None, ) ### Verify the SD-JWT using the SD-JWT-R - # Define a function to check the issuer and retrieve the # matching public key - def cb_get_issuer_key(issuer): + def cb_get_issuer_key(issuer, header_parameters): # Do not use in production - this allows to use any issuer name for demo purposes if issuer == claims["iss"]: - return demo_keys["issuer_public_key"] + return demo_keys["issuer_public_keys"] else: raise Exception(f"Unknown issuer: {issuer}") - # Note: The only input from the holder is the combined presentation! sdjwt_at_verifier = SDJWTVerifier( sdjwt_at_holder.sd_jwt_presentation, cb_get_issuer_key, - settings.VERIFIER if example.get("key_binding", False) else None, + ( + settings["identifiers"]["issuer"] + if example.get("key_binding", False) + else None + ), _args.nonce if example.get("key_binding", False) else None, + serialization_format=serialization_format, ) verified = sdjwt_at_verifier.get_verified_payload() ### Done - now output everything to CLI (unless --replace-examples-in was used) - iid_payload = "" - for hash in sdjwt_at_holder._hash_to_decoded_disclosure: - salt, claim_name, claim_value = sdjwt_at_holder._hash_to_decoded_disclosure[hash] - b64 = sdjwt_at_holder._hash_to_disclosure[hash] - encoded_json = sdjwt_at_holder._base64url_decode(b64).decode("utf-8") - - iid_payload += ( - f"__Claim `{claim_name}`:__\n\n" - f" * SHA-256 Hash: `{hash}`\n" - f" * Disclosure:\\\n" - f"{multiline_code(textwrap_text(b64, EXAMPLE_SHORT_WIDTH))}\n" - f" * Contents:\n" - f"{multiline_code(textwrap_text(encoded_json, EXAMPLE_SHORT_WIDTH))}\n\n\n" - ) - - iid_payload = iid_payload.strip() - _artifacts = { - "user_claims": (example["user_claims"], "User Claims", "json"), - "sd_jwt_payload": (sdjwt_at_issuer.sd_jwt_payload, "Payload of the SD-JWT", "json"), + "user_claims": ( + remove_sdobj_wrappers(example["user_claims"]), + "User Claims", + "json", + ), + "sd_jwt_payload": ( + sdjwt_at_issuer.sd_jwt_payload, + "Payload of the SD-JWT", + "json", + ), "sd_jwt_jws_part": ( sdjwt_at_issuer.serialized_sd_jwt, "Serialized SD-JWT", "txt", ), - "disclosures": (iid_payload, "Payloads of the II-Disclosures", "md"), + "disclosures": ( + markdown_disclosures( + sdjwt_at_issuer.ii_disclosures, + ), + "Payloads of the II-Disclosures", + "md", + ), "sd_jwt_issuance": ( sdjwt_at_issuer.sd_jwt_issuance, "Combined SD-JWT and Disclosures", "txt", ), "kb_jwt_payload": ( - sdjwt_at_holder.key_binding_jwt_payload - if example.get("key_binding") - else None, + ( + sdjwt_at_holder.key_binding_jwt_payload + if example.get("key_binding") + else None + ), "Payload of the Holder Binding JWT", "json", ), @@ -245,7 +263,11 @@ def cb_get_issuer_key(issuer): "Combined representation of SD-JWT and HS-Disclosures", "txt", ), - "verified_contents": (verified, "Verified released contents of the SD-JWT", "json"), + "verified_contents": ( + verified, + "Verified released contents of the SD-JWT", + "json", + ), } # When decoys were used, list those as well @@ -262,7 +284,6 @@ def cb_get_issuer_key(issuer): "md", ) - if _args.output_dir: logger.info( f"Writing all the examples into separate files in '{_args.output_dir}'." @@ -306,5 +327,6 @@ def cb_get_issuer_key(issuer): sys.exit(0) + if __name__ == "__main__": - run() \ No newline at end of file + run() diff --git a/src/sd_jwt/bin/generate.py b/src/sd_jwt/bin/generate.py index ad00641..4e6644c 100755 --- a/src/sd_jwt/bin/generate.py +++ b/src/sd_jwt/bin/generate.py @@ -31,15 +31,22 @@ def generate_test_case_data(settings: Dict, testcase_path: Path, type: str): - seed = settings["random_seed"] - demo_keys = get_jwk(settings["key_settings"], True, seed) - ### Load test case data testcase = load_yaml_specification(testcase_path) + settings = { + **settings, + **testcase.get("settings_override", {}), + } # override settings + + seed = settings["random_seed"] + + demo_keys = get_jwk(settings["key_settings"], True, seed) use_decoys = testcase.get("add_decoy_claims", False) serialization_format = testcase.get("serialization_format", "compact") include_default_claims = testcase.get("include_default_claims", True) extra_header_parameters = testcase.get("extra_header_parameters", {}) + issuer_keys = demo_keys["issuer_keys"] + holder_key = demo_keys["holder_key"] if testcase.get("key_binding", False) else None claims = {} if include_default_claims: @@ -55,8 +62,8 @@ def generate_test_case_data(settings: Dict, testcase_path: Path, type: str): SDJWTIssuer.unsafe_randomness = True sdjwt_at_issuer = SDJWTIssuer( claims, - demo_keys["issuer_key"], - demo_keys["holder_key"] if testcase.get("key_binding", False) else None, + issuer_keys, + holder_key, add_decoy_claims=use_decoys, serialization_format=serialization_format, extra_header_parameters=extra_header_parameters, @@ -70,13 +77,13 @@ def generate_test_case_data(settings: Dict, testcase_path: Path, type: str): ) sdjwt_at_holder.create_presentation( testcase["holder_disclosed_claims"], - settings["key_binding_nonce"] - if testcase.get("key_binding", False) - else None, - settings["identifiers"]["verifier"] - if testcase.get("key_binding", False) - else None, - demo_keys["holder_key"] if testcase.get("key_binding", False) else None, + settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, + ( + settings["identifiers"]["verifier"] + if testcase.get("key_binding", False) + else None + ), + holder_key, ) ### Verify the SD-JWT using the SD-JWT-R @@ -86,19 +93,19 @@ def generate_test_case_data(settings: Dict, testcase_path: Path, type: str): def cb_get_issuer_key(issuer, header_parameters): # Do not use in production - this allows to use any issuer name for demo purposes if issuer == claims.get("iss", None): - return demo_keys["issuer_public_key"] + return demo_keys["issuer_public_keys"] else: raise Exception(f"Unknown issuer: {issuer}") sdjwt_at_verifier = SDJWTVerifier( sdjwt_at_holder.sd_jwt_presentation, cb_get_issuer_key, - settings["identifiers"]["verifier"] - if testcase.get("key_binding", False) - else None, - settings["key_binding_nonce"] - if testcase.get("key_binding", False) - else None, + ( + settings["identifiers"]["verifier"] + if testcase.get("key_binding", False) + else None + ), + settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, serialization_format=serialization_format, ) verified = sdjwt_at_verifier.get_verified_payload() @@ -142,16 +149,20 @@ def cb_get_issuer_key(issuer, header_parameters): _artifacts.update( { "kb_jwt_header": ( - sdjwt_at_holder.key_binding_jwt_header - if testcase.get("key_binding") - else None, + ( + sdjwt_at_holder.key_binding_jwt_header + if testcase.get("key_binding") + else None + ), "Header of the Holder Binding JWT", "json", ), "kb_jwt_payload": ( - sdjwt_at_holder.key_binding_jwt_payload - if testcase.get("key_binding") - else None, + ( + sdjwt_at_holder.key_binding_jwt_payload + if testcase.get("key_binding") + else None + ), "Payload of the Holder Binding JWT", "json", ), diff --git a/src/sd_jwt/common.py b/src/sd_jwt/common.py index 5a91a8a..abad28e 100644 --- a/src/sd_jwt/common.py +++ b/src/sd_jwt/common.py @@ -14,6 +14,8 @@ DIGEST_ALG_KEY = "_sd_alg" KB_DIGEST_KEY = "sd_hash" SD_LIST_PREFIX = "..." +JSON_SER_DISCLOSURE_KEY = "disclosures" +JSON_SER_KB_JWT_KEY = "kb_jwt" logger = logging.getLogger("sd_jwt") @@ -39,10 +41,10 @@ def __init__(self, error_location: any): class SDJWTCommon: - SD_JWT_HEADER = os.getenv("SD_JWT_HEADER", "example+sd-jwt") # overwriteable with extra_header_parameters = {"typ": "other-example+sd-jwt"} + SD_JWT_HEADER = os.getenv( + "SD_JWT_HEADER", "example+sd-jwt" + ) # overwriteable with extra_header_parameters = {"typ": "other-example+sd-jwt"} KB_JWT_TYP_HEADER = "kb+jwt" - JWS_KEY_DISCLOSURES = "disclosures" - JWS_KEY_KB_JWT = "kb_jwt" HASH_ALG = {"name": "sha-256", "fn": sha256} COMBINED_SERIALIZATION_FORMAT_SEPARATOR = "~" @@ -122,7 +124,6 @@ def _check_for_sd_claim(self, the_object): return def _parse_sd_jwt(self, sd_jwt): - if self._serialization_format == "compact": ( self._unverified_input_sd_jwt, @@ -135,17 +136,69 @@ def _parse_sd_jwt(self, sd_jwt): self._unverified_input_sd_jwt_payload = loads( self._base64url_decode(jwt_body) ) + self._unverified_compact_serialized_input_sd_jwt = ( + self._unverified_input_sd_jwt + ) else: # if the SD-JWT is in JSON format, parse the json and extract the disclosures. self._unverified_input_sd_jwt = sd_jwt self._unverified_input_sd_jwt_parsed = loads(sd_jwt) - self._input_disclosures = self._unverified_input_sd_jwt_parsed[ - self.JWS_KEY_DISCLOSURES - ] - self._unverified_input_key_binding_jwt = ( - self._unverified_input_sd_jwt_parsed.get(self.JWS_KEY_KB_JWT, "") - ) + self._unverified_input_sd_jwt_payload = loads( self._base64url_decode(self._unverified_input_sd_jwt_parsed["payload"]) ) + + # distinguish between flattened and general JSON serialization (RFC7515) + if "signature" in self._unverified_input_sd_jwt_parsed: + # flattened + self._input_disclosures = self._unverified_input_sd_jwt_parsed[ + "header" + ][JSON_SER_DISCLOSURE_KEY] + self._unverified_input_key_binding_jwt = ( + self._unverified_input_sd_jwt_parsed["header"].get( + JSON_SER_KB_JWT_KEY, "" + ) + ) + self._unverified_compact_serialized_input_sd_jwt = ".".join( + [ + self._unverified_input_sd_jwt_parsed["protected"], + self._unverified_input_sd_jwt_parsed["payload"], + self._unverified_input_sd_jwt_parsed["signature"] + ] + ) + + elif "signatures" in self._unverified_input_sd_jwt_parsed: + # general, look at the header in the first signature + self._input_disclosures = self._unverified_input_sd_jwt_parsed[ + "signatures" + ][0]["header"][JSON_SER_DISCLOSURE_KEY] + self._unverified_input_key_binding_jwt = ( + self._unverified_input_sd_jwt_parsed["signatures"][0]["header"].get( + JSON_SER_KB_JWT_KEY, "" + ) + ) + self._unverified_compact_serialized_input_sd_jwt = ".".join( + [ + self._unverified_input_sd_jwt_parsed["signatures"][0][ + "protected" + ], + self._unverified_input_sd_jwt_parsed["payload"], + self._unverified_input_sd_jwt_parsed["signatures"][0][ + "signature" + ], + ] + ) + + else: + raise ValueError("Invalid JSON serialization of SD-JWT") + + def _calculate_kb_hash(self, disclosures): + # Temporarily create the combined presentation in order to create the hash over it + # Note: For JSON Serialization, the compact representation of the SD-JWT is restored from the parsed JSON (see common.py) + string_to_hash = self._combine( + self._unverified_compact_serialized_input_sd_jwt, + *disclosures, + "" + ) + return self._b64hash(string_to_hash.encode("ascii")) diff --git a/src/sd_jwt/holder.py b/src/sd_jwt/holder.py index 99d7143..df107f7 100644 --- a/src/sd_jwt/holder.py +++ b/src/sd_jwt/holder.py @@ -1,13 +1,15 @@ -import logging +import logging from .common import ( - SDJWTCommon, - DEFAULT_SIGNING_ALG, - SD_DIGESTS_KEY, - SD_LIST_PREFIX, - KB_DIGEST_KEY + SDJWTCommon, + DEFAULT_SIGNING_ALG, + SD_DIGESTS_KEY, + SD_LIST_PREFIX, + KB_DIGEST_KEY, + JSON_SER_DISCLOSURE_KEY, + JSON_SER_KB_JWT_KEY, ) -from json import dumps, loads +from json import dumps from time import time from typing import Dict, List, Optional from itertools import zip_longest @@ -52,15 +54,10 @@ def create_presentation( # Optional: Create a key binding JWT if nonce and aud and holder_key: - # Temporarily create the combined presentation in order to create the hash over it - string_to_hash = self._combine( - self.serialized_sd_jwt, - *self.hs_disclosures, - "" - ) - sd_jwt_presentation_hash = self._b64hash(string_to_hash.encode("ascii")) - self._create_key_binding_jwt(nonce, aud, sd_jwt_presentation_hash, holder_key, sign_alg) - + sd_jwt_presentation_hash = self._calculate_kb_hash(self.hs_disclosures) + self._create_key_binding_jwt( + nonce, aud, sd_jwt_presentation_hash, holder_key, sign_alg + ) # Create the combined presentation if self._serialization_format == "compact": @@ -73,14 +70,29 @@ def create_presentation( ) else: # In this case, take the parsed JSON serialized SD-JWT and - # only filter the disclosures in the header. Add the holder + # only filter the disclosures in the header. Add the key # binding JWT to the header if it was created. - self.sd_jwt_parsed[self.JWS_KEY_DISCLOSURES] = self.hs_disclosures - if self.serialized_key_binding_jwt: - self.sd_jwt_parsed[ - self.JWS_KEY_KB_JWT - ] = self.serialized_key_binding_jwt - self.sd_jwt_presentation = dumps(self.sd_jwt_parsed) + presentation = self._unverified_input_sd_jwt_parsed + if "signature" in presentation: + # flattened JSON serialization + presentation["header"][JSON_SER_DISCLOSURE_KEY] = self.hs_disclosures + + if self.serialized_key_binding_jwt: + presentation["header"][ + JSON_SER_KB_JWT_KEY + ] = self.serialized_key_binding_jwt + else: + # general, add everything to first signature's header + presentation["signatures"][0]["header"][ + JSON_SER_DISCLOSURE_KEY + ] = self.hs_disclosures + + if self.serialized_key_binding_jwt: + presentation["signatures"][0]["header"][ + JSON_SER_KB_JWT_KEY + ] = self.serialized_key_binding_jwt + + self.sd_jwt_presentation = dumps(presentation) def _select_disclosures(self, sd_jwt_claims, claims_to_disclose): # Recursively process the claims in sd_jwt_claims. In each diff --git a/src/sd_jwt/issuer.py b/src/sd_jwt/issuer.py index f10c52b..0b82ae3 100644 --- a/src/sd_jwt/issuer.py +++ b/src/sd_jwt/issuer.py @@ -1,6 +1,6 @@ import random -from json import loads, dumps -from typing import Dict, List +from json import dumps +from typing import Dict, List, Union from jwcrypto.jws import JWS @@ -9,6 +9,7 @@ DIGEST_ALG_KEY, SD_DIGESTS_KEY, SD_LIST_PREFIX, + JSON_SER_DISCLOSURE_KEY, SDJWTCommon, SDObj, ) @@ -31,7 +32,7 @@ class SDJWTIssuer(SDJWTCommon): def __init__( self, user_claims: Dict, - issuer_key, + issuer_keys: Union[Dict, List[Dict]], holder_key=None, sign_alg=None, add_decoy_claims: bool = False, @@ -41,7 +42,9 @@ def __init__( super().__init__(serialization_format=serialization_format) self._user_claims = user_claims - self._issuer_key = issuer_key + if not isinstance(issuer_keys, list): + issuer_keys = [issuer_keys] + self._issuer_keys = issuer_keys self._holder_key = holder_key self._sign_alg = sign_alg or DEFAULT_SIGNING_ALG self._add_decoy_claims = add_decoy_claims @@ -50,6 +53,12 @@ def __init__( self.ii_disclosures = [] self.decoy_digests = [] + if len(self._issuer_keys) > 1 and self._serialization_format != "json": + raise ValueError( + f"Multiple issuer keys (here {len(self._issuer_keys)}) are only supported with JSON serialization." + f"\nKeys found: {self._issuer_keys}" + ) + self._check_for_sd_claim(self._user_claims) self._assemble_sd_jwt_payload() self._create_signed_jws() @@ -166,33 +175,33 @@ def _create_signed_jws(self): """ self.sd_jwt = JWS(payload=dumps(self.sd_jwt_payload)) - # Assemble protected headers starting with default - _protected_headers = { - "alg": self._sign_alg, - "typ": self.SD_JWT_HEADER - } + _protected_headers = {"alg": self._sign_alg, "typ": self.SD_JWT_HEADER} + if len(self._issuer_keys) == 1 and "kid" in self._issuer_keys[0]: + _protected_headers["kid"] = self._issuer_keys[0]["kid"] + # override if any _protected_headers.update(self._extra_header_parameters) - self.sd_jwt.add_signature( - self._issuer_key, - alg=self._sign_alg, - protected=dumps(_protected_headers), - ) + for i, key in enumerate(self._issuer_keys): + header = {"kid": key["kid"]} if "kid" in key else None + + # for json-serialization, add the disclosures to the first header + if self._serialization_format == "json" and i == 0: + header = header or {} + header[JSON_SER_DISCLOSURE_KEY] = [d.b64 for d in self.ii_disclosures] + + self.sd_jwt.add_signature( + key, + alg=self._sign_alg, + protected=dumps(_protected_headers), + header=header, + ) self.serialized_sd_jwt = self.sd_jwt.serialize( compact=(self._serialization_format == "compact") ) - # If serialization_format is "json", then add the disclosures to the JSON. - # There does not seem to be a straightforward way to do that with the library - # other than JSON-decoding the JWS and JSON-encoding it again. - if self._serialization_format == "json": - jws_content = loads(self.serialized_sd_jwt) - jws_content[self.JWS_KEY_DISCLOSURES] = [d.b64 for d in self.ii_disclosures] - self.serialized_sd_jwt = dumps(jws_content) - def _create_combined(self): if self._serialization_format == "compact": self.sd_jwt_issuance = self._combine( diff --git a/src/sd_jwt/utils/demo_utils.py b/src/sd_jwt/utils/demo_utils.py index fee6632..6b31d27 100644 --- a/src/sd_jwt/utils/demo_utils.py +++ b/src/sd_jwt/utils/demo_utils.py @@ -5,7 +5,7 @@ import yaml import sys -from jwcrypto.jwk import JWK +from jwcrypto.jwk import JWK, JWKSet from typing import Union logger = logging.getLogger("sd_jwt") @@ -19,6 +19,13 @@ def load_yaml_settings(file): if property not in settings: sys.exit(f"Settings file must define '{property}'.") + # 'issuer_key' can be used instead of 'issuer_keys' in the key settings; will be converted to an array anyway + if "issuer_key" in settings["key_settings"]: + if "issuer_keys" in settings["key_settings"]: + sys.exit("Settings file cannot define both 'issuer_key' and 'issuer_keys'.") + + settings["key_settings"]["issuer_keys"] = [settings["key_settings"]["issuer_key"]] + return settings @@ -44,7 +51,7 @@ def print_decoded_repr(value: str, nlines=2): def get_jwk(jwk_kwargs: dict = {}, no_randomness: bool = False, random_seed: int = 0): """ jwk_kwargs = { - issuer_key:dict : {}, + issuer_keys:list : [{}], holder_key:dict : {}, key_size: int : 0, kty: str : "RSA" @@ -54,17 +61,23 @@ def get_jwk(jwk_kwargs: dict = {}, no_randomness: bool = False, random_seed: int """ if no_randomness: random.seed(random_seed) - issuer_key = JWK.from_json(json.dumps(jwk_kwargs["issuer_key"])) + issuer_keys = [JWK.from_json(json.dumps(k)) for k in jwk_kwargs["issuer_keys"]] holder_key = JWK.from_json(json.dumps(jwk_kwargs["holder_key"])) logger.warning("Using fixed randomness for demo purposes") else: _kwargs = {"key_size": jwk_kwargs["key_size"], "kty": jwk_kwargs["kty"]} - issuer_key = JWK.generate(**_kwargs) + issuer_keys = [JWK.generate(**_kwargs)] holder_key = JWK.generate(**_kwargs) - issuer_public_key = JWK.from_json(issuer_key.export_public()) + if len(issuer_keys) > 1: + issuer_public_keys = JWKSet() + for k in issuer_keys: + issuer_public_keys.add(JWK.from_json(k.export_public())) + else: + issuer_public_keys = JWK.from_json(issuer_keys[0].export_public()) + return dict( - issuer_key=issuer_key, + issuer_keys=issuer_keys, holder_key=holder_key, - issuer_public_key=issuer_public_key, + issuer_public_keys=issuer_public_keys, ) diff --git a/src/sd_jwt/verifier.py b/src/sd_jwt/verifier.py index 7f0400d..30e6ab4 100644 --- a/src/sd_jwt/verifier.py +++ b/src/sd_jwt/verifier.py @@ -59,7 +59,9 @@ def _verify_sd_jwt( unverified_issuer = self._unverified_input_sd_jwt_payload.get("iss", None) unverified_header_parameters = parsed_input_sd_jwt.jose_header - issuer_public_key = cb_get_issuer_key(unverified_issuer, unverified_header_parameters) + issuer_public_key = cb_get_issuer_key( + unverified_issuer, unverified_header_parameters + ) parsed_input_sd_jwt.verify(issuer_public_key, alg=sign_alg) self._sd_jwt_payload = loads(parsed_input_sd_jwt.payload.decode("utf-8")) @@ -111,14 +113,14 @@ def _verify_key_binding_jwt( # Reassemble the SD-JWT in compact format and check digest if self._serialization_format == "compact": - string_to_hash = self._combine( - self._unverified_input_sd_jwt, - *self._input_disclosures, - "" + expected_sd_jwt_presentation_hash = self._calculate_kb_hash( + self._input_disclosures ) - expected_sd_jwt_presentation_hash = self._b64hash(string_to_hash.encode("ascii")) - if key_binding_jwt_payload[KB_DIGEST_KEY] != expected_sd_jwt_presentation_hash: + if ( + key_binding_jwt_payload[KB_DIGEST_KEY] + != expected_sd_jwt_presentation_hash + ): raise ValueError("Invalid digest in KB-JWT") def _extract_sd_claims(self): diff --git a/tests/test_disclose_all_shortcut.py b/tests/test_disclose_all_shortcut.py index 51579c5..fb99d2b 100644 --- a/tests/test_disclose_all_shortcut.py +++ b/tests/test_disclose_all_shortcut.py @@ -6,11 +6,12 @@ def test_e2e(testcase, settings): + settings.update(testcase.get("settings_override", {})) seed = settings["random_seed"] demo_keys = get_jwk(settings["key_settings"], True, seed) use_decoys = testcase.get("add_decoy_claims", False) serialization_format = testcase.get("serialization_format", "compact") - + extra_header_parameters = {"typ": "testcase+sd-jwt"} extra_header_parameters.update(testcase.get("extra_header_parameters", {})) @@ -22,7 +23,7 @@ def test_e2e(testcase, settings): SDJWTIssuer.unsafe_randomness = True sdjwt_at_issuer = SDJWTIssuer( user_claims, - demo_keys["issuer_key"], + demo_keys["issuer_keys"], demo_keys["holder_key"] if testcase.get("key_binding", False) else None, add_decoy_claims=use_decoys, serialization_format=serialization_format, @@ -37,9 +38,11 @@ def test_e2e(testcase, settings): # Verifier sdjwt_header_parameters = {} + def cb_get_issuer_key(issuer, header_parameters): - sdjwt_header_parameters.update(header_parameters) - return demo_keys["issuer_public_key"] + if type(header_parameters) == dict: + sdjwt_header_parameters.update(header_parameters) + return demo_keys["issuer_public_keys"] sdjwt_at_verifier = SDJWTVerifier( output_holder, @@ -61,6 +64,10 @@ def cb_get_issuer_key(issuer, header_parameters): assert verified == expected_claims + # We don't compare header parameters for JSON Serialization for now + if serialization_format != "compact": + return + expected_header_parameters = { "alg": testcase.get("sign_alg", "ES256"), "typ": "testcase+sd-jwt" diff --git a/tests/test_e2e_testcases.py b/tests/test_e2e_testcases.py index 04d881a..e282948 100644 --- a/tests/test_e2e_testcases.py +++ b/tests/test_e2e_testcases.py @@ -6,11 +6,12 @@ def test_e2e(testcase, settings): + settings.update(testcase.get("settings_override", {})) seed = settings["random_seed"] demo_keys = get_jwk(settings["key_settings"], True, seed) use_decoys = testcase.get("add_decoy_claims", False) serialization_format = testcase.get("serialization_format", "compact") - + extra_header_parameters = {"typ": "testcase+sd-jwt"} extra_header_parameters.update(testcase.get("extra_header_parameters", {})) @@ -22,7 +23,7 @@ def test_e2e(testcase, settings): SDJWTIssuer.unsafe_randomness = True sdjwt_at_issuer = SDJWTIssuer( user_claims, - demo_keys["issuer_key"], + demo_keys["issuer_keys"], demo_keys["holder_key"] if testcase.get("key_binding", False) else None, add_decoy_claims=use_decoys, serialization_format=serialization_format, @@ -40,9 +41,11 @@ def test_e2e(testcase, settings): sdjwt_at_holder.create_presentation( testcase["holder_disclosed_claims"], settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, - settings["identifiers"]["verifier"] - if testcase.get("key_binding", False) - else None, + ( + settings["identifiers"]["verifier"] + if testcase.get("key_binding", False) + else None + ), demo_keys["holder_key"] if testcase.get("key_binding", False) else None, ) @@ -50,16 +53,20 @@ def test_e2e(testcase, settings): # Verifier sdjwt_header_parameters = {} + def cb_get_issuer_key(issuer, header_parameters): - sdjwt_header_parameters.update(header_parameters) - return demo_keys["issuer_public_key"] + if type(header_parameters) == dict: + sdjwt_header_parameters.update(header_parameters) + return demo_keys["issuer_public_keys"] sdjwt_at_verifier = SDJWTVerifier( output_holder, cb_get_issuer_key, - settings["identifiers"]["verifier"] - if testcase.get("key_binding", False) - else None, + ( + settings["identifiers"]["verifier"] + if testcase.get("key_binding", False) + else None + ), settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, serialization_format=serialization_format, ) @@ -74,10 +81,14 @@ def cb_get_issuer_key(issuer, header_parameters): } assert verified == expected_claims - + + # We don't compare header parameters for JSON Serialization for now + if serialization_format != "compact": + return + expected_header_parameters = { "alg": testcase.get("sign_alg", "ES256"), - "typ": "testcase+sd-jwt" + "typ": "testcase+sd-jwt", } expected_header_parameters.update(extra_header_parameters) diff --git a/tests/testcases/header_mod/specification.yml b/tests/testcases/header_mod/specification.yml index 5b0ad17..4399dd1 100644 --- a/tests/testcases/header_mod/specification.yml +++ b/tests/testcases/header_mod/specification.yml @@ -22,7 +22,7 @@ extra_header_parameters: expect_verified_user_claims: given_name: John family_name: Doe - address: + address: street_address: 123 Main St locality: Anytown region: Anystate @@ -30,4 +30,4 @@ expect_verified_user_claims: key_binding: True -serialization_format: compact \ No newline at end of file +serialization_format: compact diff --git a/tests/testcases/json_serialization/specification.yml b/tests/testcases/json_serialization_flattened/specification.yml similarity index 64% rename from tests/testcases/json_serialization/specification.yml rename to tests/testcases/json_serialization_flattened/specification.yml index 3d845da..2bd9953 100644 --- a/tests/testcases/json_serialization/specification.yml +++ b/tests/testcases/json_serialization_flattened/specification.yml @@ -27,4 +27,13 @@ expect_verified_user_claims: key_binding: True -serialization_format: json \ No newline at end of file +serialization_format: json + +settings_override: + issuer_keys: + - kty: EC + d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g + crv: P-256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 + kid: "issuer-key-1" \ No newline at end of file diff --git a/tests/testcases/json_serialization_general/specification.yml b/tests/testcases/json_serialization_general/specification.yml new file mode 100644 index 0000000..a336d9b --- /dev/null +++ b/tests/testcases/json_serialization_general/specification.yml @@ -0,0 +1,55 @@ +user_claims: + !sd sub: john_doe_42 + !sd given_name: John + !sd family_name: Doe + !sd email: johndoe@example.com + !sd phone_number: +1-202-555-0101 + !sd address: + street_address: 123 Main St + locality: Anytown + region: Anystate + country: US + !sd birthdate: "1940-01-01" + +holder_disclosed_claims: + given_name: true + family_name: true + address: true + +expect_verified_user_claims: + given_name: John + family_name: Doe + address: + street_address: 123 Main St + locality: Anytown + region: Anystate + country: US + +key_binding: True + +serialization_format: json + +settings_override: + key_settings: + key_size: 256 + kty: EC + issuer_keys: + - kty: EC + d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g + crv: P-256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 + kid: issuer-key-1 + - kty: EC + crv: P-256 + d: WsGosxrp0XK7VEviPL9xBm3fBb7Xys2vLhPGhESNoXY + x: bN-hp3IN0GZB3OlaQnHDPhY4nZsZbQyo4wY-y1NWCvA + y: vaSsH5jt9zt3aQvTvrSaFYLyjPG9Ug-2vntoNXlCbVU + kid: issuer-key-2 + + holder_key: + kty: EC + d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I + crv: P-256 + x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc + y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ diff --git a/tests/testcases/settings.yml b/tests/testcases/settings.yml index 53890e3..2a2925a 100644 --- a/tests/testcases/settings.yml +++ b/tests/testcases/settings.yml @@ -7,12 +7,12 @@ key_settings: kty: EC - issuer_key: - kty: EC - d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g - crv: P-256 - x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ - y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 + issuer_keys: + - kty: EC + d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g + crv: P-256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 holder_key: kty: EC @@ -23,9 +23,9 @@ key_settings: key_binding_nonce: "1234567890" -expiry_seconds: 86400000 # 1000 days +expiry_seconds: 86400000 # 1000 days random_seed: 0 -iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 -exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000 \ No newline at end of file +iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 +exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000