Skip to content

Commit 5502ac5

Browse files
committed
Adding JWE encoding
1 parent a5f67ed commit 5502ac5

7 files changed

Lines changed: 204 additions & 25 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"test": "ng test",
1212
"download-ucum": "node scripts/download-ucum-units.js",
1313
"download-ehdsi-results": "node scripts/download-ehdsi-results-coded-value.js",
14-
"download-ehdsi-lab-technique": "node scripts/download-ehdsi-lab-technique.js"
14+
"download-ehdsi-lab-technique": "node scripts/download-ehdsi-lab-technique.js",
15+
"generate-shl-demo": "node scripts/generate-shl-demo.js"
1516
},
1617
"private": true,
1718
"dependencies": {

scripts/generate-shl-demo.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const crypto = require('crypto');
4+
5+
const rootDir = path.resolve(__dirname, '..');
6+
const sourcePath = path.join(rootDir, 'src/assets/data/ips-example-active-penicillin.json');
7+
const outDir = path.join(rootDir, 'src/assets/shl/ips-example-active-penicillin');
8+
const manifestPath = path.join(outDir, 'manifest.json');
9+
const jwePath = path.join(outDir, 'ips-example-active-penicillin.jwe');
10+
const metadataPath = path.join(outDir, 'demo-link-metadata.json');
11+
12+
const toBase64Url = (input) => {
13+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
14+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
15+
};
16+
17+
function encryptCompactJwe(plainText, keyBytes) {
18+
const protectedHeader = { alg: 'dir', enc: 'A256GCM' };
19+
const protectedHeaderB64 = toBase64Url(JSON.stringify(protectedHeader));
20+
21+
const iv = crypto.randomBytes(12);
22+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, iv, { authTagLength: 16 });
23+
cipher.setAAD(Buffer.from(protectedHeaderB64, 'ascii'));
24+
25+
const ciphertext = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
26+
const tag = cipher.getAuthTag();
27+
28+
const jweCompact = [
29+
protectedHeaderB64,
30+
'',
31+
toBase64Url(iv),
32+
toBase64Url(ciphertext),
33+
toBase64Url(tag)
34+
].join('.');
35+
36+
return jweCompact;
37+
}
38+
39+
function main() {
40+
const plainText = fs.readFileSync(sourcePath, 'utf8');
41+
fs.mkdirSync(outDir, { recursive: true });
42+
43+
const envKey = process.env.SHL_DEMO_KEY_B64URL;
44+
const keyBytes = envKey
45+
? Buffer.from(envKey.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(envKey.length / 4) * 4, '='), 'base64')
46+
: crypto.randomBytes(32);
47+
48+
if (keyBytes.length !== 32) {
49+
throw new Error('SHL_DEMO_KEY_B64URL must decode to exactly 32 bytes for A256GCM');
50+
}
51+
52+
const keyB64Url = toBase64Url(keyBytes);
53+
const jweCompact = encryptCompactJwe(plainText, keyBytes);
54+
55+
const manifest = {
56+
presentationInfo: {
57+
name: 'IPS Example Active Penicillin'
58+
},
59+
files: [
60+
{
61+
contentType: 'application/fhir+json',
62+
location: './ips-example-active-penicillin.jwe'
63+
}
64+
]
65+
};
66+
67+
const metadata = {
68+
id: 'ips-example-active-penicillin',
69+
label: 'IPS Example Active Penicillin',
70+
sourceFile: 'assets/data/ips-example-active-penicillin.json',
71+
manifestFile: 'assets/shl/ips-example-active-penicillin/manifest.json',
72+
key: keyB64Url,
73+
alg: 'dir',
74+
enc: 'A256GCM'
75+
};
76+
77+
fs.writeFileSync(jwePath, jweCompact, 'utf8');
78+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
79+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf8');
80+
81+
console.log('Generated SHL demo files:');
82+
console.log(`- ${path.relative(rootDir, manifestPath)}`);
83+
console.log(`- ${path.relative(rootDir, jwePath)}`);
84+
console.log(`- ${path.relative(rootDir, metadataPath)}`);
85+
console.log('');
86+
console.log('Use this SHL key in SmartHealthLinksComponent:');
87+
console.log(keyB64Url);
88+
}
89+
90+
main();

src/app/benefits-demo/interoperability/interoperability.component.ts

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {

src/app/benefits-demo/smart-health-links/smart-health-links.component.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export class SmartHealthLinksComponent implements OnInit {
2727
'ips-example-active-penicillin',
2828
'IPS Example Active Penicillin',
2929
'assets/data/ips-example-active-penicillin.json',
30-
'assets/shl/ips-example-active-penicillin/manifest.json'
30+
'assets/shl/ips-example-active-penicillin/manifest.json',
31+
'RfauHCPL59FysuVS0nmEpawm_VhF4z1P8Z-w_gtZqII'
3132
)
3233
];
3334
}
@@ -78,11 +79,17 @@ export class SmartHealthLinksComponent implements OnInit {
7879
}
7980
}
8081

81-
private buildLinkItem(id: string, label: string, sourceFile: string, manifestPath: string): SmartHealthLinkItem {
82+
private buildLinkItem(
83+
id: string,
84+
label: string,
85+
sourceFile: string,
86+
manifestPath: string,
87+
key: string
88+
): SmartHealthLinkItem {
8289
const manifestUrl = this.getAbsoluteUrl(manifestPath);
8390
const payload = {
8491
url: manifestUrl,
85-
key: this.generateDemoKey(),
92+
key,
8693
label
8794
};
8895

@@ -111,19 +118,6 @@ export class SmartHealthLinksComponent implements OnInit {
111118
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
112119
}
113120

114-
private generateDemoKey(): string {
115-
const bytes = new Uint8Array(32);
116-
crypto.getRandomValues(bytes);
117-
118-
let binary = '';
119-
bytes.forEach(byte => {
120-
binary += String.fromCharCode(byte);
121-
});
122-
123-
const base64 = btoa(binary);
124-
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
125-
}
126-
127121
private downloadBlobUrl(blobUrl: string, fileName: string): void {
128122
const anchor = document.createElement('a');
129123
anchor.href = blobUrl;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "ips-example-active-penicillin",
3+
"label": "IPS Example Active Penicillin",
4+
"sourceFile": "assets/data/ips-example-active-penicillin.json",
5+
"manifestFile": "assets/shl/ips-example-active-penicillin/manifest.json",
6+
"key": "RfauHCPL59FysuVS0nmEpawm_VhF4z1P8Z-w_gtZqII",
7+
"alg": "dir",
8+
"enc": "A256GCM"
9+
}

src/assets/shl/ips-example-active-penicillin/ips-example-active-penicillin.jwe

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/assets/shl/ips-example-active-penicillin/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"files": [
66
{
77
"contentType": "application/fhir+json",
8-
"location": "../../data/ips-example-active-penicillin.json"
8+
"location": "./ips-example-active-penicillin.jwe"
99
}
1010
]
1111
}

0 commit comments

Comments
 (0)