@@ -1326,7 +1326,7 @@ export class InteroperabilityComponent implements OnInit, OnDestroy {
13261326
13271327 try {
13281328 const shlinkPayload = this . parseShlinkPayload ( rawQrValue ) ;
1329- const bundle = await this . fetchBundleFromShl ( shlinkPayload . url ) ;
1329+ const bundle = await this . fetchBundleFromShl ( shlinkPayload . url , shlinkPayload . key ) ;
13301330 this . validateIPSBundleOrThrow ( bundle ) ;
13311331 this . processIPSBundleObject ( bundle , 'SHL' ) ;
13321332 } catch ( error ) {
@@ -1337,7 +1337,7 @@ export class InteroperabilityComponent implements OnInit, OnDestroy {
13371337 }
13381338 }
13391339
1340- private parseShlinkPayload ( rawQrValue : string ) : { url : string } {
1340+ private parseShlinkPayload ( rawQrValue : string ) : { url : string ; key ?: string } {
13411341 if ( ! rawQrValue . startsWith ( 'shlink:/' ) ) {
13421342 throw new Error ( 'Scanned QR is not a SMART Health Link (shlink:/...).' ) ;
13431343 }
@@ -1354,20 +1354,27 @@ export class InteroperabilityComponent implements OnInit, OnDestroy {
13541354 throw new Error ( 'SHL payload does not contain a valid manifest URL.' ) ;
13551355 }
13561356
1357- return { url : payload . url } ;
1357+ if ( payload . key !== undefined && typeof payload . key !== 'string' ) {
1358+ throw new Error ( 'SHL payload key must be a base64url string.' ) ;
1359+ }
1360+
1361+ return { url : payload . url , key : payload . key } ;
13581362 }
13591363
13601364 private decodeBase64UrlToText ( base64Url : string ) : string {
1365+ return new TextDecoder ( ) . decode ( this . decodeBase64UrlToBytes ( base64Url ) ) ;
1366+ }
1367+
1368+ private decodeBase64UrlToBytes ( base64Url : string ) : Uint8Array {
13611369 const base64 = base64Url
13621370 . replace ( / - / g, '+' )
13631371 . replace ( / _ / g, '/' )
13641372 . padEnd ( Math . ceil ( base64Url . length / 4 ) * 4 , '=' ) ;
13651373 const binary = atob ( base64 ) ;
1366- const bytes = Uint8Array . from ( binary , char => char . charCodeAt ( 0 ) ) ;
1367- return new TextDecoder ( ) . decode ( bytes ) ;
1374+ return Uint8Array . from ( binary , char => char . charCodeAt ( 0 ) ) ;
13681375 }
13691376
1370- private async fetchBundleFromShl ( manifestUrl : string ) : Promise < any > {
1377+ private async fetchBundleFromShl ( manifestUrl : string , shlKey ?: string ) : Promise < any > {
13711378 const manifestResponse = await fetch ( manifestUrl ) ;
13721379 if ( ! manifestResponse . ok ) {
13731380 throw new Error ( `Unable to download SHL manifest (${ manifestResponse . status } ).` ) ;
@@ -1390,7 +1397,84 @@ export class InteroperabilityComponent implements OnInit, OnDestroy {
13901397 throw new Error ( `Unable to download SHL file (${ resourceResponse . status } ).` ) ;
13911398 }
13921399
1393- return resourceResponse . json ( ) ;
1400+ const resourceText = await resourceResponse . text ( ) ;
1401+ const trimmedContent = resourceText . trim ( ) ;
1402+
1403+ if ( this . isCompactJwe ( trimmedContent ) ) {
1404+ if ( ! shlKey ) {
1405+ throw new Error ( 'SHL payload does not include a decryption key for JWE content.' ) ;
1406+ }
1407+ const decryptedPayload = await this . decryptCompactJwe ( trimmedContent , shlKey ) ;
1408+ return JSON . parse ( decryptedPayload ) ;
1409+ }
1410+
1411+ return JSON . parse ( resourceText ) ;
1412+ }
1413+
1414+ private isCompactJwe ( content : string ) : boolean {
1415+ const parts = content . split ( '.' ) ;
1416+ return parts . length === 5 && parts [ 0 ] . length > 0 ;
1417+ }
1418+
1419+ private async decryptCompactJwe ( compactJwe : string , keyBase64Url : string ) : Promise < string > {
1420+ const parts = compactJwe . split ( '.' ) ;
1421+ if ( parts . length !== 5 ) {
1422+ throw new Error ( 'Invalid Compact JWE format.' ) ;
1423+ }
1424+
1425+ const [ protectedHeaderB64 , encryptedKeyB64 , ivB64 , ciphertextB64 , tagB64 ] = parts ;
1426+ if ( encryptedKeyB64 !== '' ) {
1427+ throw new Error ( 'Only direct encryption (alg=dir) is supported in this demo.' ) ;
1428+ }
1429+
1430+ const protectedHeaderText = this . decodeBase64UrlToText ( protectedHeaderB64 ) ;
1431+ const protectedHeader = JSON . parse ( protectedHeaderText ) ;
1432+ if ( protectedHeader . alg !== 'dir' || protectedHeader . enc !== 'A256GCM' ) {
1433+ throw new Error ( 'Unsupported JWE algorithm. Expected alg=dir and enc=A256GCM.' ) ;
1434+ }
1435+
1436+ const keyBytes = this . decodeBase64UrlToBytes ( keyBase64Url ) ;
1437+ if ( keyBytes . byteLength !== 32 ) {
1438+ throw new Error ( 'Invalid SHL key length. Expected 32 bytes for A256GCM.' ) ;
1439+ }
1440+
1441+ const iv = this . decodeBase64UrlToBytes ( ivB64 ) ;
1442+ const ciphertext = this . decodeBase64UrlToBytes ( ciphertextB64 ) ;
1443+ const tag = this . decodeBase64UrlToBytes ( tagB64 ) ;
1444+ const encryptedPayload = this . concatUint8Arrays ( ciphertext , tag ) ;
1445+ const aad = new TextEncoder ( ) . encode ( protectedHeaderB64 ) ;
1446+
1447+ const cryptoKey = await crypto . subtle . importKey (
1448+ 'raw' ,
1449+ this . toArrayBuffer ( keyBytes ) ,
1450+ { name : 'AES-GCM' } ,
1451+ false ,
1452+ [ 'decrypt' ]
1453+ ) ;
1454+
1455+ const decryptedBuffer = await crypto . subtle . decrypt (
1456+ {
1457+ name : 'AES-GCM' ,
1458+ iv : this . toArrayBuffer ( iv ) ,
1459+ additionalData : this . toArrayBuffer ( aad ) ,
1460+ tagLength : 128
1461+ } ,
1462+ cryptoKey ,
1463+ this . toArrayBuffer ( encryptedPayload )
1464+ ) ;
1465+
1466+ return new TextDecoder ( ) . decode ( new Uint8Array ( decryptedBuffer ) ) ;
1467+ }
1468+
1469+ private concatUint8Arrays ( first : Uint8Array , second : Uint8Array ) : Uint8Array {
1470+ const combined = new Uint8Array ( first . length + second . length ) ;
1471+ combined . set ( first , 0 ) ;
1472+ combined . set ( second , first . length ) ;
1473+ return combined ;
1474+ }
1475+
1476+ private toArrayBuffer ( bytes : Uint8Array ) : ArrayBuffer {
1477+ return bytes . buffer . slice ( bytes . byteOffset , bytes . byteOffset + bytes . byteLength ) as ArrayBuffer ;
13941478 }
13951479
13961480 private validateIPSBundleOrThrow ( bundle : any ) : void {
0 commit comments