Merged with upstream
This commit is contained in:
Christian Paquin 2021-04-01 07:16:58 -04:00
Родитель b5fbfb9f4d 7a109d786d
Коммит 89ef01eadd
12 изменённых файлов: 363 добавлений и 2690 удалений

Просмотреть файл

@ -1,5 +1,13 @@
# Changelog
## 0.4.3
Document CORS expectation for `.well-known/jwks.json`
## 0.4.2
Replace `iat` with `nbf` in JWT payload encoding
## 0.4.1
Added optional `x5c` in JWKS

Просмотреть файл

@ -51,7 +51,7 @@ Despite this broad scope, our *short-term definition of success* requires that w
* **Connect** the Health Wallet to an account with the Issuer (optional step)
* **Save** a Health Card from the Issuer into the Health Wallet
* **Present** a Health Card to a Verifier
* Presentation incluldes explicit user opt-in and approval
* Presentation includes explicit user opt-in and approval
* Presentation workflow depends on context (e.g., on-device presentation to a verifier's mobile app, or in-person presentation)
## Demo
@ -67,7 +67,7 @@ This section outlines higher-level design considerations. See [Protocol Details]
### Getting credentials into Health Wallet
* Required method: File download
* Required method: print QR on paper card, or scan QR into software
* Required method: Print QR on paper card, or scan QR into software
* Optional method: [FHIR API Access](#healthwalletissuevc-operation)
### Presenting credentials to Verifier
@ -96,13 +96,13 @@ It is an explicit design goal to let the holder **only disclose a minimum amount
The granularity of information disclosure will be at the level of an entire credential (i.e., a user can select "which cards" to share from a Health Wallet, and each card is shared wholesale). The credentials are designed to only include the minimum information necessary for a given use case.
### Granular Sharing
### Granular Sharing
Data holders should have full control over the data they choose to share for a particular use-case. Since Health Cards are signed by the issuer and cannot be altered later, it is important to ensure that Health Cards are created with granular sharing in mind. Therefore, issuers SHOULD only combine distinct data elements into a Health Card when a Health Card FHIR profile requires it.
Data holders should have full control over the data they choose to share for a particular use-case. Since Health Cards are signed by the issuer and cannot be altered later, it is important to ensure that Health Cards are created with granular sharing in mind. Therefore, issuers SHOULD only combine distinct data elements into a Health Card when a Health Card FHIR profile requires it.
Additionally, Health Card FHIR Profiles SHOULD only include data that need to be conveyed together. E.g., immunizations for different diseases should be kept separate. Immunizations and lab results should be kept separate.
Additionally, Health Card FHIR Profiles SHOULD only include data that need to be conveyed together. (e.g., immunizations for different diseases should be kept separate. Immunizations and lab results should be kept separate.)
### Future Considerations
### Future Considerations
If we identify *optional* data elements for a given use case, we might incorporate them into credentials by including a cryptographic hash of their values instead of embedding values directly. Longer term we can provide more granular options using techniques like zero-knowledge proofs, or by allowing a trusted intermediary to summarize results in a just-in-time fashion.
@ -140,13 +140,13 @@ certificate or certificate chain (see [RFC7517](https://tools.ietf.org/html/rfc7
The public key listed in the first certificate in the `"x5c"` array SHALL match the public key specified by the `"crv"`, `"x"`, and `"y"` parameters of the same JWK entry.
If the issuer has more than one certificate for the same public key (e.g. participation in more than one trust community), then a separate JWK entry is used for each certificate with all JWK parameter values identical except `"x5c"`.
Issuers SHALL publish their public keys as JSON Web Key Sets (see [RFC7517](https://tools.ietf.org/html/rfc7517#section-5)), available at `<<iss value from Signed JWT>>` + `/.well-known/jwks.json`.
Issuers SHALL publish their public keys as JSON Web Key Sets (see [RFC7517](https://tools.ietf.org/html/rfc7517#section-5)), available at `<<iss value from Signed JWT>>` + `/.well-known/jwks.json`, with [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) enabled.
The URL at `<<iss value from Signed JWT>>` SHALL use the `https` scheme and SHALL NOT include a trailing `/`. For example, `https://smarthealth.cards/examples/issuer` is a valid `iss` value (`https://smarthealth.cards/examples/issuer/` is **not**).
**Signing keys** in the `.keys[]` array can be identified by `kid` following the requirements above (i.e., by filtering on `kty`, `use`, and `alg`).
For example, the following is a fragment of a jwks.json file with one signing key:
For example, the following is a fragment of a `jwks.json` file with one signing key:
```
{
"keys":[
@ -169,10 +169,11 @@ X.509 certificates can be used by issuers to indicate the issuer's participation
If the Verifier supports PKI-based trust frameworks and the Health Card issuer includes the `"x5c"` parameter in matching JWK entries from the `.keys[]` array,
the Verifier establishes that the issuer is trusted as follows:
1. Verifier validates the leaf certificate's binding to the Health Card issuer by:
* matching the `<<iss value from Signed JWT>>` to the value
of a `uniformResourceIdentifier` entry in the certificate's Subject Alternative Name extension
(see [RFC5280](https://tools.ietf.org/html/rfc5280#section-4.2.1.6)), and
* matching the `<<iss value from Signed JWT>>` to the value of a `uniformResourceIdentifier`
entry in the certificate's Subject Alternative Name extension
(see [RFC5280](https://tools.ietf.org/html/rfc5280#section-4.21.6)), and
* verifying the signature in the Health Card using the public key in the certificate.
2. Verifier constructs a valid certificate path of unexpired and unrevoked certificates to one of its trusted anchors
(see [RFC5280](https://tools.ietf.org/html/rfc5280#section-6)).
@ -183,15 +184,15 @@ of a `uniformResourceIdentifier` entry in the certificate's Subject Alternative
Issuers SHOULD generate new signing keys at least annually.
When an issuer generates a new key to sign Health Cards, the public key SHALL be added to the
issuer's JWK set in its jwks.json file. Retired private keys that are no longer used to sign Health Cards SHALL be destroyed.
issuer's JWK set in its `jwks.json` file. Retired private keys that are no longer used to sign Health Cards SHALL be destroyed.
Older public key entries that are needed to validate previously
signed health cards SHALL remain in the JWK set for as long as the corresponding health cards
signed Health Cards SHALL remain in the JWK set for as long as the corresponding Health Cards
are clinically relevant. However, if a private signing key is compromised, then the issuer SHALL immediately remove the corresponding public key
from the JWK set in its jwks.json file and request revocation of all X.509 certificates bound to that public key.
from the JWK set in its `jwks.json` file and request revocation of all X.509 certificates bound to that public key.
## Issuer Generates Results
When the issuer is ready to generate a Health Card, the issuer creates a FHIR payload and packs it into a corresponding Health Card VC (or Health Card Set), ensuring the resulting payloads follow the [QR Embedding requirements](#every-health-card-can-be-embedded-in-a-qr-code).
When the issuer is ready to generate a Health Card, the issuer creates a FHIR payload and packs it into a corresponding Health Card VC (or Health Card Set).
```mermaid
sequenceDiagram
@ -200,7 +201,7 @@ participant Issuer
note over Holder, Issuer: Earlier...
Issuer ->> Issuer: Generate Issuer's keys
Issuer ->> Issuer: If health card data for holder already exist: re-generate VCs
Issuer ->> Issuer: If Health Card data for holder already exist: re-generate VCs
note over Issuer, Holder: Data Created
Issuer ->> Issuer: Generate FHIR Representation
@ -213,14 +214,14 @@ Issuer ->> Holder: Holder receives Health Card
### Health Cards are encoded as Compact Serialization JSON Web Signatures (JWS)
The VC structure (scaffold) is shown in the following example. The Health Cards framework serializes VCs using the compact JWS serialization, i.e. each Health Card is a signed JSON Web Token. Specific encoding choices ensure compatibility with standard JWT claims, as described at [https://www.w3.org/TR/vc-data-model/#jwt-encoding](https://www.w3.org/TR/vc-data-model/#jwt-encoding). Specifically: in the JWT payload, most properties have been "pushed down" into a `.vc` claim; there is no top-level `issuer`, `issuanceDate`, `@context`, `type`, or `credentialSubject` property, because these fields are either mapped into standard JWT claims (for `iss`, `iat`) or included within the `.vc` claim (for `@context`, `type`, `credentialSubject`).
The VC structure (scaffold) is shown in the following example. The Health Cards framework serializes VCs using the compact JWS serialization, i.e. each Health Card is a signed JSON Web Token (see [Appendix 3 of RFC7515](https://tools.ietf.org/html/rfc7515#appendix-A.3) for an example using ECDSA P-256 SHA-256, as required by this specification). Specific encoding choices ensure compatibility with standard JWT claims, as described at [https://www.w3.org/TR/vc-data-model/#jwt-encoding](https://www.w3.org/TR/vc-data-model/#jwt-encoding).
Hence, the overall JWS payload matches the following structure (before it is [minified and compressed](#health-cards-are-small)):
The `@context`, `type`, and `credentialSubject` properties are added to the `vc` claim of the JWT. The `issuer` property is represented by the registered JWT `iss` claim and the `issuanceDate` property is represented by the registered JWT `nbf` claim. Hence, the overall JWS payload matches the following structure (before it is [minified and compressed](#health-cards-are-small)):
```json
{
"iss": "<<Issuer URL>>",
"iat": 1591037940,
"nbf": 1591037940,
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": [
@ -264,7 +265,7 @@ For details about how to embed Health Cards in a QR code, [see below](#every-hea
## User Retrieves Health Cards
In this step, the user learns that a new Health Card is available (e.g., by receiving a text message or email notification, or by an in-wallet notification for FHIR-enabled issuers.
In this step, the user learns that a new Health Card is available (e.g., by receiving a text message or email notification, or by an in-wallet notification for FHIR-enabled issuers.)
### via File Download
@ -338,7 +339,7 @@ The following parameters are optional; clients MAY include them in a request, an
}
```
* **`_since`**. By default, the issuer will return health cards of any age. If the Health Wallet wants to request only cards pertaining to data since a specific point in time, it can provide a `_since` parameter with a `valueDateTime` (which is an ISO8601 string at the level of a year, month, day, or specific time of day using the extended time format; see [FHIR dateTime datatype](http://hl7.org/fhir/datatypes.html#dateTime) for details). For example, to request only COVID-19 data since March 2021:
* **`_since`**. By default, the issuer will return Health Cards of any age. If the Health Wallet wants to request only cards pertaining to data since a specific point in time, it can provide a `_since` parameter with a `valueDateTime` (which is an ISO8601 string at the level of a year, month, day, or specific time of day using the extended time format; see [FHIR dateTime datatype](http://hl7.org/fhir/datatypes.html#dateTime) for details). For example, to request only COVID-19 data since March 2021:
```json
@ -393,17 +394,17 @@ In the response, an optional repeating `resourceLink` parameter can capture the
## Presenting Health Cards to a Verifier
In this step, the verifier asks the user to share a COVID-19 result. The overall can be conveyed by presenting a QR code; by uploading a file; or by leveraging device-specific APIs. Over time, we will endeavor to standardize presentation workflows including device-specific patterns and web-based exchange.
In this step, the verifier asks the user to share a COVID-19 result. A Health Card containing the result can be conveyed by presenting a QR code; by uploading a file; or by leveraging device-specific APIs. Over time, we will endeavor to standardize presentation workflows including device-specific patterns and web-based exchange.
## Every Health Card can be embedded in a QR Code
Every Health Card can be embedded in one or more QR Codes. When embedding a Health Card in a QR Code, we aim to ensure that printed (or electronically displayed) codes are usable at physical dimensions of 40mmx40mm. This constraint allows us to use QR codes up to Version 22, at 105x105 modules. When embedding a Health Card in a QR Code, the same JWS strings that appear as `.verifiableCredential[]` entries in a `.smart-health.card` file SHALL be encoded as Numerical Mode QR codes consisting of the digits 0-9 (see ["Encoding Chunks as QR Codes"](#encoding-chunks-as-qr-codes)).
Every Health Card can be embedded in one or more QR Codes. When embedding a Health Card in a QR Code, we aim to ensure that printed (or electronically displayed) codes are usable at physical dimensions of 40mmx40mm. This constraint allows us to use QR codes up to Version 22, at 105x105 modules. When embedding a Health Card in a QR Code, the same JWS strings that appear as `.verifiableCredential[]` entries in a `.smart-health-card` file SHALL be encoded as Numerical Mode QR codes consisting of the digits 0-9 (see ["Encoding Chunks as QR Codes"](#encoding-chunks-as-qr-codes)).
Ensuring Health Cards can be presented as QR Codes:
* Allows basic storage and sharing of health cards for users without a smartphone
* Allows basic storage and sharing of Health Cards for users without a smartphone
* Allows smartphone-enabled users to print a usable backup
* Allows full health card contents to be shared with a verifier
* Allows full Health Card contents to be shared with a verifier
The following limitations apply when presenting Health Card as QR codes, rather than engaging in device-based workflows:
@ -440,6 +441,7 @@ When printing or displaying a Health Card using QR codes, let "N" be the total n
(The reason for representing Health Cards using Numeric Mode QRs instead of Binary Mode (Latin-1) QRs is information density: with Numeric Mode, 20% more data can fit in a given QR, vs Binary Mode. This is because the JWS character set conveys only log_2(65) bits per character (~6 bits); binary encoding requires log_2(256) bits per character (8 bits), which means ~2 wasted bits per character.)
For example:
* a single chunk might produce a QR code like `shc:/56762909524320603460292437404460<snipped for brevity>`
* in a longer JWS, the second chunk in a set of three might produce a QR code like `shc:/2/3/56762909524320603460292437404460<snipped for brevity>`
@ -449,6 +451,9 @@ When reading a QR code, scanning software can recognize a SMART Health Card from
# FAQ
## Can a SMART Health Card be used as a form of identification?
No. SMART Health Cards are designed for use *alongside* existing forms of identification (e.g., a driver's license in person, or an online ID verification service). A SMART Health Card is a non-forgeable digital artifact analogous to a paper record on official letterhead. Concretely, the problem SMART Health Cards solves is one of provenance: a digitally signed SMART Health Card is a credential that guarantees that a specific issuer generated the record. The duty of verifying that the person presenting a Health Card *is* the subject of the data within the Health Card (or is authorized to act on behalf of this data subject) falls to the person or system receiving and validating a Health Card.
## Which clinical data should be considered in decision-making?
* The data in Health Cards should focus on communicating "immutable clinical facts".
* Each use case will define specific data profiles.
@ -462,7 +467,12 @@ When reading a QR code, scanning software can recognize a SMART Health Card from
## How can we share conclusions like a "Safe-to-fly Pass", instead of sharing clinical results?
Decision-making often results in a narrowly-scoped "Pass" that embodies conclusions like "Person X qualifies for international flight between Country A and Country B, according to Rule Set C". While Health Cards are designed to be long-lived and general-purpose, Passes are highly contextual. We are not attempting to standardize "Passes" in this framework, but Health Cards can provide an important verifiable input for the generation of Passes.
## What testing tools are available to validate SMART Health Cards implementations?
The following tools are helpful to validate Health Card artefacts:
* The [HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) can be used to validate the Health Card's FHIR bundle
* The [Health Cards Validation SDK](https://github.com/microsoft/health-cards-validation-SDK) can be used to validate the various Health Card artifacts.
# Potential Extensions

1
generate-examples/.gitignore поставляемый
Просмотреть файл

@ -1 +1,2 @@
node_modules
certs

Просмотреть файл

@ -0,0 +1,19 @@
# SMART Health Cards Example Generator
Generates examples included in the SMART Health Cards Framework specification.
## Usage
Install dependencies:
```shell
npm install
```
Generate examples:
```shell
npm run generate-examples
```
Generated examples will be placed in `docs/examples`

Просмотреть файл

@ -0,0 +1,27 @@
#!/bin/bash
# This script generates a 3-cert ECDSA chain (root -> CA -> issuer).
# Leaf cert uses P-256 and is valid for 1 year (as per the SMART Health Card Framework),
# CA and root CA use the increasingly stronger P-384 and P-521, and are valid for
# 5 and 10 years, respectively.
# directory where intermediate files are kept
tmpdir=certs
mkdir -p $tmpdir
# generate self-signed root CA cert
openssl req -x509 -new -newkey ec:<(openssl ecparam -name secp521r1) -keyout $tmpdir/root_CA.key -out $tmpdir/root_CA.crt -nodes -subj "/CN=SMART Health Card Example Root CA" -days 3650 -config openssl_ca.cnf -extensions v3_ca -sha512
# generate intermediate CA cert request
openssl req -new -newkey ec:<(openssl ecparam -name secp384r1) -keyout $tmpdir/CA.key -out $tmpdir/CA.csr -nodes -subj "/CN=SMART Health Card Example CA" -config openssl_ca.cnf -extensions v3_ca -sha384
# root CA signs the CA cert request
openssl x509 -req -in $tmpdir/CA.csr -out $tmpdir/CA.crt -CA $tmpdir/root_CA.crt -CAkey $tmpdir/root_CA.key -CAcreateserial -days 1825 -extfile openssl_ca.cnf -extensions v3_ca -sha512
# generate issuer signing cert request
openssl req -new -newkey ec:<(openssl ecparam -name prime256v1) -keyout $tmpdir/issuer.key -out $tmpdir/issuer.csr -nodes -subj "/CN=SMART Health Card Example Issuer" -config openssl_ca.cnf -extensions v3_issuer -sha256
# intermediate CA signs the issuer cert request
openssl x509 -req -in $tmpdir/issuer.csr -out $tmpdir/issuer.crt -CA $tmpdir/CA.crt -CAkey $tmpdir/CA.key -CAcreateserial -days 365 -extfile openssl_ca.cnf -extensions v3_issuer -sha384
# add the issuer key to the JWK sets
node src/certs-to-x5c.js --key $tmpdir/issuer.key --cert $tmpdir/issuer.crt --cert $tmpdir/CA.crt --cert $tmpdir/root_CA.crt --private src/config/issuer.jwks.private.json --public issuer/.well-known/jwks.json

Просмотреть файл

@ -8,6 +8,20 @@
"crv": "P-256",
"x": "11XvRWy1I2S0EyJlyf_bWfw_TQ5CJJNLw78bHXNxcgw",
"y": "eZXwxvO1hvCY0KucrPfKo7yAyMT6Ajc3N7OkAB6VYy8"
},
{
"kty": "EC",
"kid": "bVKTnRwVq4YU9oLwwShYELnRtKop_MsCAjNklowYemg",
"use": "sig",
"alg": "ES256",
"x5c": [
"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"
],
"crv": "P-256",
"x": "f6GJiCnbnBaIm2jDaH_3UPC7Yl-x5yBAi5ddZ8v3Y_w",
"y": "jKcqirFw4G9v9gWTDCqAjvcCRQpbIK76bWqKBtseFzQ"
}
]
}
}

Просмотреть файл

@ -0,0 +1,18 @@
[ req ]
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_issuer ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature
subjectAltName = URI:https://smarthealth.cards/examples/issuer
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
[ v3_ca ]
basicConstraints = CA:TRUE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always

2728
generate-examples/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -6,6 +6,7 @@
"scripts": {
"generate-examples": "ts-node src/index.ts --outdir ../docs/examples",
"generate-testfiles": "ts-node src/index.ts --outdir testfiles --testcase trailing_chars",
"generate-keys": "node src/certs-to-x5c.js --key certs/issuer.key --cert certs/issuer.crt --cert certs/CA.crt --cert certs/root_CA.crt --private src/config/issuer.jwks.private.json --public issuer/.well-known/jwks.json",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",

Просмотреть файл

@ -0,0 +1,91 @@
// Reads EC P-256 PEM private key and certs and outputs a private and public JWK pair.
//
// PEM format for P-256 (prime256v1) private key
// -----BEGIN PRIVATE KEY-----
// <-- multi-line base64 encoding of ASN.1:
// [0..35]: header (36 bytes)
// [36..67]: private key (d value, 32 bytes)
// [68..72]: public key header
// [73]: 0x04 (uncompressed public key)
// [74..105]: x
// [106..137]: y
// -->
// -----END PRIVATE KEY-----
//
// PEM format of cert
//
const fs = require('fs');
const program = require('commander');
const jose = require('node-jose');
const { Buffer } = require('buffer');
const PRIVATE = 0;
const EC_P256_ASN1_PRIVATE_KEY_HEADER_HEX = "308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420";
const EC_P256_ASN1_PUBLIC_KEY_HEADER_HEX = "a144034200";
const EC_COMPRESSED_KEY_HEX = "04";
// PEM to DER encoding
// Drop the first and last lines (BEGIN/END markers), concatenate the others, base64-decode
const PEMtoDER = (pem) => Buffer.from(pem.split(/\r?\n/).slice(1,-2).join(), "base64");
// DER to JWK with x5c attribute, returns [private,public] keys
const DERtoJWK = async (key, certs) => {
// make expected header and values are good
if (key.slice(0,36).toString('hex') !== EC_P256_ASN1_PRIVATE_KEY_HEADER_HEX) throw "Invalid EC P-256 ASN.1 private key header";
if (key.slice(68,73).toString('hex') !== EC_P256_ASN1_PUBLIC_KEY_HEADER_HEX) throw "Invalid EC P-256 ASN.1 public key header";
if (key.slice(73,74).toString('hex') !== EC_COMPRESSED_KEY_HEX) throw "Invalid EC public key encoding";
const d = key.slice(36,68);
const x = key.slice(74,106);
const y = key.slice(106,138);
const jwk = await jose.JWK.asKey(
{
"kty":"EC",
"use":"sig",
"alg":"ES256",
"crv":"P-256",
"d":jose.util.base64url.encode(d),
"x":jose.util.base64url.encode(x),
"y":jose.util.base64url.encode(y),
"x5c":certs.map(cert => cert.toString('base64'))
// SHA-256 kid auto-generated by key import
}
);
return [JSON.stringify(jwk.toJSON(true)), JSON.stringify(jwk.toJSON(false))]
}
const main = async (options) => {
if (!options.key || !options.cert) {
console.log("Missing --key or --cert argument");
program.help();
}
try {
// open or create public/private key stores
const getStore = async path => fs.existsSync(path) ? await jose.JWK.asKeyStore(JSON.parse(fs.readFileSync(path))) : jose.JWK.createKeyStore();
const store = [await getStore(options.private), await getStore(options.public)];
// read and convert input key and certs to DER format
const keyFile = PEMtoDER(fs.readFileSync(options.key,'UTF-8'));
const certFiles = options.cert.map(certPath => PEMtoDER(fs.readFileSync(certPath,'UTF-8')));
// convert DER key to JWK, adding a x5c value with cert chain
keys = await DERtoJWK(keyFile, certFiles);
// output public/private key stores with new key
await Promise.all(keys.map( async (k,i) => {
await store[i].add(keys[i]);
const isPrivate = (i == PRIVATE);
fs.writeFileSync(isPrivate ? options.private : options.public, JSON.stringify(store[i].toJSON(isPrivate), null, 2));
}))
} catch (err) {
console.log(err);
}
}
program.option('-k, --key <key>', 'path to the P-256 EC PEM private key');
program.option('-c, --cert <cert>', 'path to a certificate to add to the x5c chain (repeatable, add in chain order: leaf first, root last)', (cert, certs) => certs.concat([cert]), []);
program.option('-p, --public <public>', 'path to the public JWK set to create or add the new key to', 'issuer.jwks.public.json');
program.option('-s, --private <private>', 'path to the private JWK set to create or add the new key to', 'issuer.jwks.private.json');
program.parse(process.argv);
main(program.opts());

Просмотреть файл

@ -1 +1,40 @@
{"kty":"EC","kid":"3Kfdg-XwP-7gXyywtUfUADwBumDOPKMQx-iELL11W9s","use":"sig","alg":"ES256","crv":"P-256","x":"11XvRWy1I2S0EyJlyf_bWfw_TQ5CJJNLw78bHXNxcgw","y":"eZXwxvO1hvCY0KucrPfKo7yAyMT6Ajc3N7OkAB6VYy8","d":"FvOOk6hMixJ2o9zt4PCfan_UW7i4aOEnzj76ZaCI9Og"}
{
"keys": [
{
"kty": "EC",
"kid": "3Kfdg-XwP-7gXyywtUfUADwBumDOPKMQx-iELL11W9s",
"use": "sig",
"alg": "ES256",
"crv": "P-256",
"x": "11XvRWy1I2S0EyJlyf_bWfw_TQ5CJJNLw78bHXNxcgw",
"y": "eZXwxvO1hvCY0KucrPfKo7yAyMT6Ajc3N7OkAB6VYy8",
"d": "FvOOk6hMixJ2o9zt4PCfan_UW7i4aOEnzj76ZaCI9Og"
},
{
"kty": "EC",
"kid": "7rIUtD5K5Izrptr3ywgESTxhgJtJSeb06V42hg7WUDs",
"use": "enc",
"alg": "ECDH-ES",
"crv": "P-256",
"x": "Lhaq5B4HhCDJSSQU_rJLShIIr6S5PxfXKTGKfKSP_CY",
"y": "EaxJ3zkqswLnw5GOf95A3atOTToVYmZPrp_t7WBcHcM",
"d": "K9zmeCMxMTdq_9nOa0s1Kfg-GXrNkrSLw209g3A5k70"
},
{
"kty": "EC",
"kid": "bVKTnRwVq4YU9oLwwShYELnRtKop_MsCAjNklowYemg",
"use": "sig",
"alg": "ES256",
"x5c": [
"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"
],
"crv": "P-256",
"x": "f6GJiCnbnBaIm2jDaH_3UPC7Yl-x5yBAi5ddZ8v3Y_w",
"y": "jKcqirFw4G9v9gWTDCqAjvcCRQpbIK76bWqKBtseFzQ",
"d": "xCu8ZMQENexZHGhP3tXBwIUec5guCFGinJYrX60Moc0"
}
]
}

Просмотреть файл

@ -9,10 +9,15 @@ import QrCode, { QRCodeSegment } from 'qrcode';
const ISSUER_URL = process.env.ISSUER_URL || 'smarthealth.cards/examples/issuer' ;
const exampleBundleUrls = [
'http://build.fhir.org/ig/dvci/vaccine-credential-ig/branches/main/Bundle-Scenario1Bundle.json',
'http://build.fhir.org/ig/dvci/vaccine-credential-ig/branches/main/Bundle-Scenario2Bundle.json',
'https://www.hl7.org/fhir/diagnosticreport-example-ghp.json'
interface BundleInfo {
url: string;
issuerIndex: number;
}
const exampleBundleInfo: BundleInfo[] = [
{url: 'http://build.fhir.org/ig/dvci/vaccine-credential-ig/branches/main/Bundle-Scenario1Bundle.json', issuerIndex: 0},
{url: 'http://build.fhir.org/ig/dvci/vaccine-credential-ig/branches/main/Bundle-Scenario2Bundle.json', issuerIndex: 2},
{url: 'https://www.hl7.org/fhir/diagnosticreport-example-ghp.json', issuerIndex: 0}
];
interface Bundle {
@ -34,7 +39,7 @@ interface StringMap {
export interface HealthCard {
iss: string;
iat: number;
nbf: number;
exp: number;
vc: {
type: string[];
@ -126,8 +131,8 @@ async function trimBundleForHealthCard(bundleIn: Bundle) {
function createHealthCardJwsPayload(fhirBundle: Bundle, types: string[]): Record<string, unknown> {
return {
iss: _issuerUrlPrefix + ISSUER_URL + _issuerUrlSuffix,
iat: new Date().getTime() / 1000, // TODO: add not yet valid
iss: _issuerUrlPrefix + ISSUER_URL + _issuerUrlSuffix + _issuerUrlSuffix2,
nbf: new Date().getTime() / 1000, // TODO: add not yet valid
vc: {
'@context': ['https://www.w3.org/2018/credentials/v1'],
type: [
@ -159,8 +164,8 @@ const splitJwsIntoChunks = (jws: string): string[] => {
return chunks || [];
}
async function createHealthCardFile(jwsPayload: Record<string, unknown>): Promise<Record<string, any>> {
const signer = new Signer({ signingKey: await JWK.asKey(_issuerSigningKey) });
async function createHealthCardFile(jwsPayload: Record<string, unknown>, keyIndex: number = 0): Promise<Record<string, any>> {
const signer = new Signer({ signingKey: await JWK.asKey(_issuerSigningKey.keys[keyIndex]) });
const signed = await signer.signJws(jwsPayload);
return {
verifiableCredential: [signed],
@ -180,16 +185,17 @@ const toNumericQr = (jws: string, chunkIndex: number, totalChunks: number): QRCo
},
];
async function processExampleBundle(exampleBundleUrl: string): Promise<{ fhirBundle: Bundle; payload: Record<string, unknown>; file: Record<string, any>; qrNumeric: string[]; qrSvgFiles: string[]; }> {
let types = exampleBundleUrl.match("vaccine") ? [
async function processExampleBundle(exampleBundleInfo: BundleInfo): Promise<{ fhirBundle: Bundle; payload: Record<string, unknown>; file: Record<string, any>; qrNumeric: string[]; qrSvgFiles: string[]; }> {
let types = exampleBundleInfo.url.match("vaccine") ? [
'https://smarthealth.cards#immunization',
'https://smarthealth.cards#covid19',
] : [];
const exampleBundleRetrieved = (await got(exampleBundleUrl).json()) as Bundle;
const exampleBundleRetrieved = (await got(exampleBundleInfo.url).json()) as Bundle;
const exampleBundleTrimmedForHealthCard = await trimBundleForHealthCard(exampleBundleRetrieved);
const exampleJwsPayload = createHealthCardJwsPayload(exampleBundleTrimmedForHealthCard, types);
const exampleBundleHealthCardFile = await createHealthCardFile(exampleJwsPayload);
const exampleBundleHealthCardFile = await createHealthCardFile(exampleJwsPayload, exampleBundleInfo.issuerIndex);
const jws = exampleBundleHealthCardFile.verifiableCredential[0] as string;
const jwsChunks = splitJwsIntoChunks(jws);
const qrSet = jwsChunks.map((c, i, chunks) => toNumericQr(c, i, chunks.length));
@ -214,14 +220,14 @@ async function processExampleBundle(exampleBundleUrl: string): Promise<{ fhirBun
async function generate(options: { outdir: string, testcase:string }) {
const exampleIndex: string[][] = [];
const writeExamples = exampleBundleUrls.map(async (url, i) => {
const writeExamples = exampleBundleInfo.map(async (info, i) => {
const exNum = i.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
const outputPrefix = `example-${exNum}-`;
const outputPrefix = _OUTPUT_PREFIX + `example-${exNum}-`;
const ouputSuffix = options.testcase ? `-${options.testcase}` : '';
const example = await processExampleBundle(url);
const example = await processExampleBundle(info);
const fileA = `${outputPrefix}a-fhirBundle${ouputSuffix}.json`;
const fileB = `${outputPrefix}b-jws-payload-expanded${ouputSuffix}.json`;
const fileC = `${outputPrefix}c-jws-payload-minified${ouputSuffix}.json`;
@ -295,6 +301,7 @@ const options = program.opts() as Options;
console.log('Opts', options);
// Test case options
const _OUTPUT_PREFIX = options.testcase ? 'test-' : '';
const _TRAILING_CHARS = options.testcase == 'trailing_chars' ? ' \t\n ' : '';
const _MAX_SINGLE_JWS_SIZE = options.testcase == 'qr_chunk_too_big' ? 2500 : MAX_SINGLE_JWS_SIZE;
const _MAX_CHUNK_SIZE = _MAX_SINGLE_JWS_SIZE - 4;
@ -302,8 +309,8 @@ const _doDeflate = options.testcase == 'no_deflate' ? false : true;
const _deflateFunction = options.testcase == 'invalid_deflate' ? pako.deflate : pako.deflateRaw;
const _jwsFormat = options.testcase == 'invalid_jws_format' ? 'flattened' : 'compact';
const _issuerUrlPrefix = options.testcase == 'invalid_issuer_url_http' ? 'http://' : 'https://';
let _issuerUrlSuffix = options.testcase == 'invalid_issuer_url' ? 'invalid_url' : '';
_issuerUrlSuffix = options.testcase == 'issuer_url_with_trailing_slash' ? '/' : '';
const _issuerUrlSuffix = options.testcase == 'invalid_issuer_url' ? 'invalid_url' : '';
const _issuerUrlSuffix2 = options.testcase == 'issuer_url_with_trailing_slash' ? '/' : '';
const _qrHeader = options.testcase == 'wrong_qr_header' ? 'shc:' : 'shc:/';
const _qrMode = options.testcase == 'wrong_qr_mode' ? 'byte' : 'numeric';
const _issuerKeyFile = './src/config/' +