UNPKG

@sd-jwt/core

Version:

sd-jwt draft 7 implementation in typescript

377 lines (338 loc) 11.7 kB
import { type Hasher, type HasherAndAlg, type HasherAndAlgSync, type HasherSync, IANA_HASH_ALGORITHMS, SD_DIGEST, SD_LIST_KEY, SD_SEPARATOR, } from '../types'; import { Disclosure, SDJWTException } from '../utils'; import { decodeBase64urlJsonStrict } from '../utils/strict-json'; export const decodeJwt = < H extends Record<string, unknown>, T extends Record<string, unknown>, >( jwt: string, ): { header: H; payload: T; signature: string } => { const { 0: header, 1: payload, 2: signature, length } = jwt.split('.'); if (length !== 3) { throw new SDJWTException('Invalid JWT as input'); } return { header: decodeBase64urlJsonStrict(header, 'Invalid JWT as input'), payload: decodeBase64urlJsonStrict(payload, 'Invalid JWT as input'), signature: signature, }; }; // Split the sdjwt into 3 parts: jwt, disclosures and keybinding jwt. each part is base64url encoded // It's separated by the ~ character // // If there is no keybinding jwt, the third part will be undefined // If there are no disclosures, the second part will be an empty array export const splitSdJwt = ( sdjwt: string, ): { jwt: string; disclosures: string[]; kbJwt?: string } => { const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR); if (encodedDisclosures.length === 0) { // if input is just jwt, then return here. // This is for compatibility with jwt return { jwt: encodedJwt, disclosures: [], }; } const encodedKeyBindingJwt = encodedDisclosures.pop(); return { jwt: encodedJwt, disclosures: encodedDisclosures, kbJwt: encodedKeyBindingJwt || undefined, }; }; // Decode the sdjwt into the jwt, disclosures and keybinding jwt // jwt, disclosures and keybinding jwt are also decoded export const decodeSdJwt = async ( sdjwt: string, hasher: Hasher, ): Promise<DecodedSDJwt> => { const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR); const jwt = decodeJwt(encodedJwt); if (encodedDisclosures.length === 0) { // if input is just jwt, then return here. // This is for compatibility with jwt return { jwt, disclosures: [], }; } const encodedKeyBindingJwt = encodedDisclosures.pop(); const kbJwt = encodedKeyBindingJwt ? decodeJwt(encodedKeyBindingJwt) : undefined; const { _sd_alg } = getSDAlgAndPayload(jwt.payload); const disclosures = await Promise.all( encodedDisclosures.map((ed) => Disclosure.fromEncode(ed, { alg: _sd_alg, hasher }), ), ); return { jwt, disclosures, kbJwt, }; }; export const decodeSdJwtSync = ( sdjwt: string, hasher: HasherSync, ): DecodedSDJwt => { const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR); const jwt = decodeJwt(encodedJwt); if (encodedDisclosures.length === 0) { // if input is just jwt, then return here. // This is for compatibility with jwt return { jwt, disclosures: [], }; } const encodedKeyBindingJwt = encodedDisclosures.pop(); const kbJwt = encodedKeyBindingJwt ? decodeJwt(encodedKeyBindingJwt) : undefined; const { _sd_alg } = getSDAlgAndPayload(jwt.payload); const disclosures = encodedDisclosures.map((ed) => Disclosure.fromEncodeSync(ed, { alg: _sd_alg, hasher }), ); return { jwt, disclosures, kbJwt, }; }; // Get the claims from jwt and disclosures // The digested values are matched with the disclosures and the claims are extracted export const getClaims = async <T = Record<string, unknown>>( rawPayload: Record<string, unknown>, disclosures: Array<Disclosure>, hasher: Hasher, ): Promise<T> => { const { unpackedObj } = await unpack(rawPayload, disclosures, hasher); // The caller supplies T to match their expected shape return unpackedObj as T; }; export const getClaimsSync = <T = Record<string, unknown>>( rawPayload: Record<string, unknown>, disclosures: Array<Disclosure>, hasher: HasherSync, ): T => { const { unpackedObj } = unpackSync(rawPayload, disclosures, hasher); // The caller supplies T to match their expected shape return unpackedObj as T; }; const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v); const unpackArray = ( arr: Array<unknown>, map: Record<string, Disclosure>, prefix = '', seenDigests?: Set<string>, ): { unpackedObj: unknown; disclosureKeymap: Record<string, string> } => { const keys: Record<string, string> = {}; const unpackedArray: unknown[] = []; arr.forEach((item, idx) => { if (isRecord(item)) { const hash = item[SD_LIST_KEY]; if (typeof hash === 'string') { // RFC 9901 Section 7.1 step 4: reject duplicate digests if (seenDigests) { if (seenDigests.has(hash)) { throw new SDJWTException( 'Duplicate digest found in SD-JWT payload', ); } seenDigests.add(hash); } const disclosed = map[hash]; if (disclosed) { const presentKey = prefix ? `${prefix}.${idx}` : `${idx}`; keys[presentKey] = hash; const { unpackedObj, disclosureKeymap: disclosureKeys } = unpackObjInternal(disclosed.value, map, presentKey, seenDigests); unpackedArray.push(unpackedObj); Object.assign(keys, disclosureKeys); } } else { const newKey = prefix ? `${prefix}.${idx}` : `${idx}`; const { unpackedObj, disclosureKeymap: disclosureKeys } = unpackObjInternal(item, map, newKey, seenDigests); unpackedArray.push(unpackedObj); Object.assign(keys, disclosureKeys); } } else if (Array.isArray(item)) { const newKey = prefix ? `${prefix}.${idx}` : `${idx}`; const { unpackedObj, disclosureKeymap: disclosureKeys } = unpackObjInternal(item, map, newKey, seenDigests); unpackedArray.push(unpackedObj); Object.assign(keys, disclosureKeys); } else { unpackedArray.push(item); } }); return { unpackedObj: unpackedArray, disclosureKeymap: keys }; }; export const unpackObj = (obj: unknown, map: Record<string, Disclosure>) => { const copiedObj = JSON.parse(JSON.stringify(obj)); const seenDigests = new Set<string>(); const result = unpackObjInternal(copiedObj, map, '', seenDigests); // RFC 9901 Section 7.1 step 5: reject unreferenced disclosures const mapDigests = Object.keys(map); const unusedDigests = mapDigests.filter((d) => !seenDigests.has(d)); if (unusedDigests.length > 0) { throw new SDJWTException('Unreferenced disclosure(s) detected in SD-JWT'); } return result; }; const unpackObjInternal = ( obj: unknown, map: Record<string, Disclosure>, prefix = '', seenDigests?: Set<string>, ): { unpackedObj: unknown; disclosureKeymap: Record<string, string> } => { const keys: Record<string, string> = {}; if (typeof obj === 'object' && obj !== null) { if (Array.isArray(obj)) { return unpackArray(obj, map, prefix, seenDigests); } const record = obj as Record<string, unknown>; for (const key in record) { if ( key !== SD_DIGEST && key !== SD_LIST_KEY && typeof record[key] === 'object' ) { const newKey = prefix ? `${prefix}.${key}` : key; const { unpackedObj, disclosureKeymap: disclosureKeys } = unpackObjInternal(record[key], map, newKey, seenDigests); record[key] = unpackedObj; Object.assign(keys, disclosureKeys); } } const { _sd, ...payload } = record as Record<string, unknown> & { _sd?: Array<string>; }; const claims: Record<string, unknown> = {}; if (_sd) { for (const hash of _sd) { // RFC 9901 Section 7.1 step 4: reject duplicate digests if (seenDigests) { if (seenDigests.has(hash)) { throw new SDJWTException( 'Duplicate digest found in SD-JWT payload', ); } seenDigests.add(hash); } const disclosed = map[hash]; if (disclosed?.key) { // RFC 9901 Section 7.1 step 3c.ii.3: reject if claim name already exists if (disclosed.key in payload) { throw new SDJWTException( `Disclosed claim name "${disclosed.key}" conflicts with existing payload key`, ); } const presentKey = prefix ? `${prefix}.${disclosed.key}` : disclosed.key; keys[presentKey] = hash; const { unpackedObj, disclosureKeymap: disclosureKeys } = unpackObjInternal(disclosed.value, map, presentKey, seenDigests); claims[disclosed.key] = unpackedObj; Object.assign(keys, disclosureKeys); } } } const unpackedObj = Object.assign(payload, claims); return { unpackedObj, disclosureKeymap: keys }; } return { unpackedObj: obj, disclosureKeymap: keys }; }; // Creates a mapping of the digests of the disclosures to the actual disclosures export const createHashMapping = async ( disclosures: Array<Disclosure>, hash: HasherAndAlg, ) => { const map: Record<string, Disclosure> = {}; for (let i = 0; i < disclosures.length; i++) { const disclosure = disclosures[i]; const digest = await disclosure.digest(hash); map[digest] = disclosure; } return map; }; export const createHashMappingSync = ( disclosures: Array<Disclosure>, hash: HasherAndAlgSync, ) => { const map: Record<string, Disclosure> = {}; for (let i = 0; i < disclosures.length; i++) { const disclosure = disclosures[i]; const digest = disclosure.digestSync(hash); map[digest] = disclosure; } return map; }; // Extract _sd_alg. If it is not present, it is assumed to be sha-256 export const getSDAlgAndPayload = (SdJwtPayload: Record<string, unknown>) => { const { _sd_alg, ...payload } = SdJwtPayload; if (typeof _sd_alg !== 'string') { // This is for compatibility return { _sd_alg: 'sha-256', payload }; } if ( !IANA_HASH_ALGORITHMS.includes( _sd_alg as (typeof IANA_HASH_ALGORITHMS)[number], ) ) { throw new SDJWTException(`Invalid _sd_alg: ${_sd_alg}`); } return { _sd_alg, payload }; }; // Match the digests of the disclosures with the claims and extract the claims // unpack function use unpackObjInternal and unpackArray to recursively unpack the claims // Since getSDAlgAndPayload create new object So we don't need to clone it again export const unpack = async ( SdJwtPayload: Record<string, unknown>, disclosures: Array<Disclosure>, hasher: Hasher, ) => { const { _sd_alg, payload } = getSDAlgAndPayload(SdJwtPayload); const hash = { hasher, alg: _sd_alg }; const map = await createHashMapping(disclosures, hash); return unpackObj(payload, map); }; export const unpackSync = ( SdJwtPayload: Record<string, unknown>, disclosures: Array<Disclosure>, hasher: HasherSync, ) => { const { _sd_alg, payload } = getSDAlgAndPayload(SdJwtPayload); const hash = { hasher, alg: _sd_alg }; const map = createHashMappingSync(disclosures, hash); return unpackObj(payload, map); }; // This is the type of the object that is returned by the decodeSdJwt function // It is a combination of the decoded jwt, the disclosures and the keybinding jwt export type DecodedSDJwt = { jwt: { header: Record<string, unknown>; payload: Record<string, unknown>; // raw payload of sd-jwt signature: string; }; disclosures: Array<Disclosure>; kbJwt?: { header: Record<string, unknown>; payload: Record<string, unknown>; signature: string; }; };