зеркало из
1
0
Форкнуть 0

Merge pull request #7 from microsoft/ljoy-dev

Multiple updates (v0.2.0, fhir bundle, more QR formats)
This commit is contained in:
Christian Paquin 2021-03-09 12:00:17 -05:00 коммит произвёл GitHub
Родитель e8bfb0f1da 43237bf3fb
Коммит d2e390d6ba
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
29 изменённых файлов: 5670 добавлений и 1145 удалений

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

@ -19,15 +19,5 @@
"@typescript-eslint"
],
"rules": {
"indent": ["error", 4],
"require-jsdoc":"off",
"semi": [
"error",
"always"
],
"spaced-comment": [
"error",
"always"
]
}
}

4
.vscode/settings.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,4 @@
{
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true
}

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

@ -1,6 +1,6 @@
# SMART Health Cards Validation SDK
This project provides a tool to help implementers of the [SMART Health Card Framework](https://smarthealth.cards/) validate the artefacts they produce. The package's version number, currently `0.1.1`, matches the [specification version](https://smarthealth.cards/changelog/) the tool validates.
This project provides a tool to help implementers of the [SMART Health Card Framework](https://smarthealth.cards/) validate the artefacts they produce. The package's version number, currently `0.2.0`, matches the [specification version](https://smarthealth.cards/changelog/) the tool validates.
## Setup
@ -38,7 +38,7 @@ To validate health card artefacts, use the `shc-validator.ts` script, or simply
Options:
-v, --version display specification and tool version
-p, --path <path> path of the file to validate
-p, --path <path> path of the file to validate. Can be repeated for the qr and qrnumeric types, to provide multiple file chunks
-t, --type <type> type of file to validate (choices: "fhirbundle", "jwspayload", "jws", "healthcard", "qrnumeric", "qr", "jwkset")
-l, --loglevel <loglevel> set the minimum log level (choices: "debug", "info", "warning", "error", "fatal", default: "warning")
-o, --logout <path> output path for log (if not specified log will be printed on console)
@ -53,6 +53,10 @@ To validate a `QR.png` file, call:
node . --path QR.png --type qr
Multiple `path` options can be provided for QR artefacts (`qrnumeric` and `qr` types) split in multiple files , one for each chunk. For example, to validate a numeric QR code split in three chunks `QR1.txt`, `QR2.txt`, `QR3.txt`, call:
node . --path QR1.txt --path QR2.txt --path QR3.txt --type qrnumeric
The supported file types, as expressed with the `--type` option, are:
- *fhirbundle*: a JSON-encoded FHIR bundle
- *jwspayload*: a JSON Web Signature (JWS) payload, encoding a health card

1734
package-lock.json сгенерированный

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

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

@ -1,6 +1,6 @@
{
"name": "health-cards-validation-sdk",
"version": "0.1.1",
"version": "0.2.0",
"description": "",
"main": "js/src/shc-validator.js",
"scripts": {
@ -17,14 +17,18 @@
"license": "MIT",
"dependencies": {
"ajv": "^7.1.1",
"bmp-js": "^0.1.0",
"canonicalize": "^1.0.5",
"canvas": "^2.6.1",
"colors": "^1.4.0",
"commander": "^7.1.0",
"execa": "^5.0.0",
"file-type": "^16.2.0",
"gm": "^1.23.1",
"got": "^11.8.2",
"istextorbinary": "^5.12.0",
"jimp": "^0.16.1",
"jpeg-js": "^0.4.2",
"jsqr": "^1.3.1",
"node-jose": "^2.0.0",
"pako": "^2.0.3",
@ -32,6 +36,8 @@
"svg2img": "^0.9.1"
},
"devDependencies": {
"@types/bmp-js": "^0.1.0",
"@types/gm": "^1.18.9",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.31",
"@types/node-jose": "^1.1.5",

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

@ -1,151 +1,379 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$id": "https://smarthealth.cards/schema/fhir-bundle-schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "root",
"type": "object",
"required": [
"resourceType",
"type",
"entry"
],
"additionalProperties": false,
"properties": {
"resourceType": {
"$id": "#root/resourceType",
"title": "Resourcetype",
"type": "string",
"default": "",
"examples": [
"Bundle"
],
"pattern": "^.*$"
},
"type": {
"$id": "#root/type",
"title": "Type",
"type": "string",
"default": "",
"examples": [
"collection"
],
"pattern": "^.*$"
},
"entry": {
"$id": "#root/entry",
"title": "Entry",
"type": "array",
"default": [],
"$ref": "#/definitions/Bundle",
"description": "see https://www.hl7.org/fhir/bundle.schema.json.html for information about the FHIR Json Schemas",
"definitions": {
"Bundle": {
"description": "A container for a collection of resources.",
"properties": {
"resourceType": {
"description": "This is a Bundle resource",
"const": "Bundle"
},
"id": {
"description": "The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.",
"$ref": "fhir.schema.json#/definitions/id"
},
"meta": {
"description": "The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.",
"$ref": "fhir.schema.json#/definitions/Meta"
},
"implicitRules": {
"description": "A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.",
"$ref": "fhir.schema.json#/definitions/uri"
},
"_implicitRules": {
"description": "Extensions for implicitRules",
"$ref": "fhir.schema.json#/definitions/Element"
},
"language": {
"description": "The base language in which the resource is written.",
"$ref": "fhir.schema.json#/definitions/code"
},
"_language": {
"description": "Extensions for language",
"$ref": "fhir.schema.json#/definitions/Element"
},
"identifier": {
"description": "A persistent identifier for the bundle that won\u0027t change as a bundle is copied from server to server.",
"$ref": "fhir.schema.json#/definitions/Identifier"
},
"type": {
"description": "Indicates the purpose of this bundle - how it is intended to be used.",
"enum": [
"document",
"message",
"transaction",
"transaction-response",
"batch",
"batch-response",
"history",
"searchset",
"collection"
]
},
"_type": {
"description": "Extensions for type",
"$ref": "fhir.schema.json#/definitions/Element"
},
"timestamp": {
"description": "The date/time that the bundle was assembled - i.e. when the resources were placed in the bundle.",
"$ref": "fhir.schema.json#/definitions/instant"
},
"_timestamp": {
"description": "Extensions for timestamp",
"$ref": "fhir.schema.json#/definitions/Element"
},
"total": {
"description": "If a set of search matches, this is the total number of entries of type \u0027match\u0027 across all pages in the search. It does not include search.mode \u003d \u0027include\u0027 or \u0027outcome\u0027 entries and it does not provide a count of the number of entries in the Bundle.",
"$ref": "fhir.schema.json#/definitions/unsignedInt"
},
"_total": {
"description": "Extensions for total",
"$ref": "fhir.schema.json#/definitions/Element"
},
"link": {
"description": "A series of links that provide context to this bundle.",
"items": {
"$id": "#root/entry/items",
"title": "Items",
"type": "object",
"required": [
"fullUrl",
"resource"
],
"properties": {
"fullUrl": {
"$id": "#root/entry/items/fullUrl",
"title": "Fullurl",
"type": "string",
"default": "",
"examples": [
"resource:0"
],
"pattern": "^.*$"
},
"resource": {
"$id": "#root/entry/items/resource",
"title": "Resource",
"type": "object",
"required": [
"resourceType"
],
"properties": {
"resourceType": {
"$id": "#root/entry/items/resource/resourceType",
"title": "Resourcetype",
"type": "string",
"default": "",
"examples": [
"Patient"
],
"pattern": "^.*$"
},
"name": {
"$id": "#root/entry/items/resource/name",
"title": "Name",
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"default": [],
"items": {
"$id": "#root/entry/items/resource/name/items",
"title": "Items",
"type": "object",
"required": [
"family",
"given"
],
"properties": {
"family": {
"$id": "#root/entry/items/resource/name/items/family",
"title": "Family",
"type": "string",
"default": "",
"examples": [
"Anyperson"
],
"pattern": "^.*$"
},
"given": {
"$id": "#root/entry/items/resource/name/items/given",
"title": "Given",
"type": "array",
"default": [],
"items": {
"$id": "#root/entry/items/resource/name/items/given/items",
"title": "Items",
"type": "string",
"default": "",
"examples": [
"John"
],
"pattern": "^.*$"
}
}
}
}
}
]
},
"gender": {
"$id": "#root/entry/items/resource/gender",
"title": "Gender",
"type": "string",
"default": "",
"examples": [
"male"
],
"pattern": "^.*$"
},
"birthDate": {
"$id": "#root/entry/items/resource/birthDate",
"title": "Birthdate",
"type": "string",
"default": "",
"examples": [
"1951-01-20"
],
"pattern": "^.*$"
}
}
}
}
}
}
"$ref": "#/definitions/Bundle_Link"
},
"type": "array"
},
"entry": {
"description": "An entry in a bundle resource - will either contain a resource or information about a resource (transactions and history only).",
"items": {
"$ref": "#/definitions/Bundle_Entry"
},
"type": "array"
},
"signature": {
"description": "Digital Signature - base64 encoded. XML-DSig or a JWT.",
"$ref": "fhir.schema.json#/definitions/Signature"
}
},
"additionalProperties": false,
"required": [
"resourceType"
]
},
"Bundle_Link": {
"description": "A container for a collection of resources.",
"properties": {
"id": {
"description": "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.",
"$ref": "fhir.schema.json#/definitions/string"
},
"extension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"modifierExtension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element\u0027s descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.\n\nModifier extensions SHALL NOT change the meaning of any elements on Resource or DomainResource (including cannot change the meaning of modifierExtension itself).",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"relation": {
"description": "A name which details the functional use for this link - see [http://www.iana.org/assignments/link-relations/link-relations.xhtml#link-relations-1](http://www.iana.org/assignments/link-relations/link-relations.xhtml#link-relations-1).",
"$ref": "fhir.schema.json#/definitions/string"
},
"_relation": {
"description": "Extensions for relation",
"$ref": "fhir.schema.json#/definitions/Element"
},
"url": {
"description": "The reference details for the link.",
"$ref": "fhir.schema.json#/definitions/uri"
},
"_url": {
"description": "Extensions for url",
"$ref": "fhir.schema.json#/definitions/Element"
}
},
"additionalProperties": false
},
"Bundle_Entry": {
"description": "A container for a collection of resources.",
"properties": {
"id": {
"description": "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.",
"$ref": "fhir.schema.json#/definitions/string"
},
"extension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"modifierExtension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element\u0027s descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.\n\nModifier extensions SHALL NOT change the meaning of any elements on Resource or DomainResource (including cannot change the meaning of modifierExtension itself).",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"link": {
"description": "A series of links that provide context to this entry.",
"items": {
"$ref": "#/definitions/Bundle_Link"
},
"type": "array"
},
"fullUrl": {
"description": "The Absolute URL for the resource. The fullUrl SHALL NOT disagree with the id in the resource - i.e. if the fullUrl is not a urn:uuid, the URL shall be version-independent URL consistent with the Resource.id. The fullUrl is a version independent reference to the resource. The fullUrl element SHALL have a value except that: \n* fullUrl can be empty on a POST (although it does not need to when specifying a temporary id for reference in the bundle)\n* Results from operations might involve resources that are not identified.",
"$ref": "fhir.schema.json#/definitions/uri"
},
"_fullUrl": {
"description": "Extensions for fullUrl",
"$ref": "fhir.schema.json#/definitions/Element"
},
"resource": {
"description": "The Resource for the entry. The purpose/meaning of the resource is determined by the Bundle.type.",
"$ref": "fhir.schema.json#/definitions/ResourceList"
},
"search": {
"description": "Information about the search process that lead to the creation of this entry.",
"$ref": "#/definitions/Bundle_Search"
},
"request": {
"description": "Additional information about how this entry should be processed as part of a transaction or batch. For history, it shows how the entry was processed to create the version contained in the entry.",
"$ref": "#/definitions/Bundle_Request"
},
"response": {
"description": "Indicates the results of processing the corresponding \u0027request\u0027 entry in the batch or transaction being responded to or what the results of an operation where when returning history.",
"$ref": "#/definitions/Bundle_Response"
}
},
"additionalProperties": false
},
"Bundle_Search": {
"description": "A container for a collection of resources.",
"properties": {
"id": {
"description": "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.",
"$ref": "fhir.schema.json#/definitions/string"
},
"extension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"modifierExtension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element\u0027s descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.\n\nModifier extensions SHALL NOT change the meaning of any elements on Resource or DomainResource (including cannot change the meaning of modifierExtension itself).",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"mode": {
"description": "Why this entry is in the result set - whether it\u0027s included as a match or because of an _include requirement, or to convey information or warning information about the search process.",
"enum": [
"match",
"include",
"outcome"
]
},
"_mode": {
"description": "Extensions for mode",
"$ref": "fhir.schema.json#/definitions/Element"
},
"score": {
"description": "When searching, the server\u0027s search ranking score for the entry.",
"$ref": "fhir.schema.json#/definitions/decimal"
},
"_score": {
"description": "Extensions for score",
"$ref": "fhir.schema.json#/definitions/Element"
}
},
"additionalProperties": false
},
"Bundle_Request": {
"description": "A container for a collection of resources.",
"properties": {
"id": {
"description": "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.",
"$ref": "fhir.schema.json#/definitions/string"
},
"extension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"modifierExtension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element\u0027s descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.\n\nModifier extensions SHALL NOT change the meaning of any elements on Resource or DomainResource (including cannot change the meaning of modifierExtension itself).",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"method": {
"description": "In a transaction or batch, this is the HTTP action to be executed for this entry. In a history bundle, this indicates the HTTP action that occurred.",
"enum": [
"GET",
"HEAD",
"POST",
"PUT",
"DELETE",
"PATCH"
]
},
"_method": {
"description": "Extensions for method",
"$ref": "fhir.schema.json#/definitions/Element"
},
"url": {
"description": "The URL for this entry, relative to the root (the address to which the request is posted).",
"$ref": "fhir.schema.json#/definitions/uri"
},
"_url": {
"description": "Extensions for url",
"$ref": "fhir.schema.json#/definitions/Element"
},
"ifNoneMatch": {
"description": "If the ETag values match, return a 304 Not Modified status. See the API documentation for [\"Conditional Read\"](http.html#cread).",
"$ref": "fhir.schema.json#/definitions/string"
},
"_ifNoneMatch": {
"description": "Extensions for ifNoneMatch",
"$ref": "fhir.schema.json#/definitions/Element"
},
"ifModifiedSince": {
"description": "Only perform the operation if the last updated date matches. See the API documentation for [\"Conditional Read\"](http.html#cread).",
"$ref": "fhir.schema.json#/definitions/instant"
},
"_ifModifiedSince": {
"description": "Extensions for ifModifiedSince",
"$ref": "fhir.schema.json#/definitions/Element"
},
"ifMatch": {
"description": "Only perform the operation if the Etag value matches. For more information, see the API section [\"Managing Resource Contention\"](http.html#concurrency).",
"$ref": "fhir.schema.json#/definitions/string"
},
"_ifMatch": {
"description": "Extensions for ifMatch",
"$ref": "fhir.schema.json#/definitions/Element"
},
"ifNoneExist": {
"description": "Instruct the server not to perform the create if a specified resource already exists. For further information, see the API documentation for [\"Conditional Create\"](http.html#ccreate). This is just the query portion of the URL - what follows the \"?\" (not including the \"?\").",
"$ref": "fhir.schema.json#/definitions/string"
},
"_ifNoneExist": {
"description": "Extensions for ifNoneExist",
"$ref": "fhir.schema.json#/definitions/Element"
}
},
"additionalProperties": false
},
"Bundle_Response": {
"description": "A container for a collection of resources.",
"properties": {
"id": {
"description": "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.",
"$ref": "fhir.schema.json#/definitions/string"
},
"extension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"modifierExtension": {
"description": "May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element\u0027s descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and manageable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.\n\nModifier extensions SHALL NOT change the meaning of any elements on Resource or DomainResource (including cannot change the meaning of modifierExtension itself).",
"items": {
"$ref": "fhir.schema.json#/definitions/Extension"
},
"type": "array"
},
"status": {
"description": "The status code returned by processing this entry. The status SHALL start with a 3 digit HTTP code (e.g. 404) and may contain the standard HTTP description associated with the status code.",
"$ref": "fhir.schema.json#/definitions/string"
},
"_status": {
"description": "Extensions for status",
"$ref": "fhir.schema.json#/definitions/Element"
},
"location": {
"description": "The location header created by processing this operation, populated if the operation returns a location.",
"$ref": "fhir.schema.json#/definitions/uri"
},
"_location": {
"description": "Extensions for location",
"$ref": "fhir.schema.json#/definitions/Element"
},
"etag": {
"description": "The Etag for the resource, if the operation for the entry produced a versioned resource (see [Resource Metadata and Versioning](http.html#versioning) and [Managing Resource Contention](http.html#concurrency)).",
"$ref": "fhir.schema.json#/definitions/string"
},
"_etag": {
"description": "Extensions for etag",
"$ref": "fhir.schema.json#/definitions/Element"
},
"lastModified": {
"description": "The date/time that the resource was modified on the server.",
"$ref": "fhir.schema.json#/definitions/instant"
},
"_lastModified": {
"description": "Extensions for lastModified",
"$ref": "fhir.schema.json#/definitions/Element"
},
"outcome": {
"description": "An OperationOutcome containing hints and warnings produced as part of processing this entry in a batch or transaction.",
"$ref": "fhir.schema.json#/definitions/ResourceList"
}
},
"additionalProperties": false
}
}
}
}

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

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

@ -1,5 +1,5 @@
{
"$id": "https://smarthealth.cards/schema/fhir-bundle-schema.json",
"$id": "https://smarthealth.cards/schema/keyset-schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "root",
"type": "object",
@ -9,8 +9,7 @@
"properties": {
"keys": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"minItems": 1,
"items": {
"type": "object",
"properties": {
@ -46,5 +45,6 @@
]
}
}
}
},
"additionalProperties": false
}

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

@ -1,254 +1,34 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { LogLevels } from "./logger";
// eslint-disable-next-line no-var // TODO: ljoy, can we delete this?
// export class OutputTree {
// public child: OutputTree | undefined;
// public infos: InfoItem[] = [];
// public errors: ErrorItem[] = [];
// private _exitCode = 0;
// constructor(public title: string = '') { }
// public get exitCode(): number {
// if (this.child) {
// const childExitCode = this.child.exitCode;
// // the child should not return a different fatal exitCode
// if (this._exitCode && (childExitCode === this._exitCode)) {
// throw new Error("Exit code overwritten. Should only have one fatal error.");
// }
// // set this exit code to the child if it's currently 0
// this._exitCode = this._exitCode || childExitCode;
// }
// return this._exitCode;
// }
// error(errorItemArray: ErrorItem[]): OutputTree;
// error(message: string): OutputTree;
// error(message: string, code: ErrorCode): OutputTree;
// error(message: string, code: ErrorCode, fatal: boolean): OutputTree;
// error(message: string | ErrorItem[], code: ErrorCode = ErrorCode.ERROR, fatal = false): OutputTree {
// if (typeof message === 'string') {
// if (code == null || code === 0) {
// throw new Error("Non-zero error code required.");
// }
// if (fatal && this._exitCode !== 0) {
// throw new Error("Exit code overwritten. Should only have one fatal error.");
// }
// if (fatal) this._exitCode = code;
// this.errors.push(new ErrorItem(message, code, fatal));
// return this;
// }
// for (let i = 0; i < message.length; i++) {
// const err = message[i];
// if (err.fatal && this._exitCode !== 0) {
// throw new Error("Exit code overwritten. Should only have one fatal error.");
// }
// if (err.fatal) this._exitCode = err.code;
// this.errors.push(err);
// }
// return this;
// }
// info(message: string, code: InfoCode = InfoCode.INFO): OutputTree {
// this.infos.push(new InfoItem(message, code));
// return this;
// }
// warn(message: string, code: InfoCode = InfoCode.INFO): OutputTree {
// this.infos.push(new InfoItem(message, code));
// return this;
// }
// // collects errors from all children into a single collection
// flatten(): { title: string, message: string, code: ErrorCode, fatal: boolean }[] {
// let errors = this.errors.map(e => {
// return {
// title: this.title,
// message: e.message,
// code: e.code,
// fatal: e.fatal
// };
// });
// if (this.child) errors = errors.concat(this.child.flatten());
// return errors;
// }
// }
// eslint-disable-next-line no-var
export class OutputTree {
public child: OutputTree | undefined;
public log: LogItem[] = [];
public result: FhirBundle | JWS | JWSPayload | HealthCard | undefined = undefined;
private _exitCode = 0;
constructor(public title: string = '') { }
public get exitCode(): number {
if (this.child) {
const childExitCode = this.child.exitCode;
// the child should not return a different fatal exitCode
if (this._exitCode && (childExitCode === this._exitCode)) {
throw new Error("Exit code overwritten. Should only have one fatal error.");
}
// set this exit code to the child if it's currently 0
this._exitCode = this._exitCode || childExitCode;
}
return this._exitCode;
}
debug(message: string): OutputTree {
this.log.push(new LogItem(message, 0, LogLevels.DEBUG));
return this;
}
info(message: string): OutputTree {
this.log.push(new LogItem(message, 0, LogLevels.INFO));
return this;
}
warn(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
this.log.push(new LogItem(message, code, LogLevels.WARNING));
return this;
}
error(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
this.log.push(new LogItem(message, code, LogLevels.ERROR));
return this;
}
fatal(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
if (this._exitCode !== 0) {
throw new Error("Exit code overwritten. Should only have one fatal error.");
}
this._exitCode = code;
this.log.push(new LogItem(message, code, LogLevels.FATAL));
return this;
}
add(logs: LogItem[]): OutputTree {
for (let i = 0; i < logs.length; i++) {
const item = logs[i];
switch (item.logLevel) {
case LogLevels.DEBUG:
return this.debug(item.message);
case LogLevels.INFO:
return this.info(item.message);
case LogLevels.WARNING:
return this.warn(item.message, item.code);
case LogLevels.ERROR:
return this.error(item.message, item.code);
case LogLevels.FATAL:
return this.fatal(item.message, item.code);
}
}
return this;
}
get(level: LogLevels): LogItem[] {
return this.log.filter(item => {
return item.logLevel === level;
});
}
// collects errors from all children into a single collection
flatten(level: LogLevels = LogLevels.DEBUG): { title: string, message: string, code: ErrorCode, level: LogLevels }[] {
let items = this.log
.filter((item) => {
return item.logLevel >= level;
})
.map(e => {
return {
title: this.title,
message: e.message,
code: e.code,
level: e.logLevel
};
});
if (this.child) items = items.concat(this.child.flatten(level));
return items;
}
}
export class LogItem {
constructor(public message: string, public code: ErrorCode = 0, public logLevel: LogLevels = LogLevels.INFO) { }
}
export enum ErrorCode {
ERROR = 100,
DATA_FILE_NOT_FOUND,
SCHEMA_FILE_NOT_FOUND,
LOG_PATH_NOT_FOUND,
SCHEMA_ERROR,
INFLATION_ERROR,
JWS_VERIFICATION_ERROR,
QR_DECODE_ERROR,
ISSUER_KEY_DOWNLOAD_ERROR,
INVALID_SHC_STRING,
INVALID_NUMERIC_QR,
INVALID_NUMERIC_QR_HEADER,
INVALID_QR_CHUNK_INDEX,
NOT_IMPLEMENTED,
UNKNOWN_FILE_DATA, // 110
UNKNOWN_FILE_DATA,
JSON_PARSE_ERROR,
CRITICAL_DATA_MISSING,
JWS_TOO_LONG,
INVALID_FILE_EXTENSION
}
export enum InfoCode {
INFO = 0
}
export class ErrorWithCode extends Error {
constructor(message: string, public code: ErrorCode) {
super(message);
}
}
export class ResultWithErrors {
public result: string | undefined = undefined;
public errors: LogItem[] = [];
error(message: string, code: ErrorCode, level = LogLevels.ERROR): ResultWithErrors {
this.errors.push(new LogItem(message, code, level));
return this;
}
INVALID_FILE_EXTENSION,
INVALID_MISSING_KTY = 200,
INVALID_WRONG_KTY,
INVALID_MISSING_ALG,
INVALID_WRONG_ALG,
INVALID_MISSING_USE,
INVALID_WRONG_USE,
INVALID_MISSING_KID,
INVALID_WRONG_KID,
INVALID_SCHEMA,
INVALID_UNKNOWN
}

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

@ -13,7 +13,8 @@ import got from 'got';
const outPath = 'testdata';
const baseExampleUrl = 'https://smarthealth.cards/examples/';
const exampleCount = 2;
const exampleCount = 3;
const exampleQrChunkCount = [1,1,3]; // number of QR chunks per example
const examplePrefix = 'example-';
const exampleSuffixes = [
'-a-fhirBundle.json',
@ -21,8 +22,8 @@ const exampleSuffixes = [
'-c-jws-payload-minified.json',
'-d-jws.txt',
'-e-file.smart-health-card',
'-f-qr-code-numeric-value-0.txt',
'-g-qr-code-0.svg'
'-f-qr-code-numeric-value-X.txt',
'-g-qr-code-X.svg'
];
const pipeline = promisify(stream.pipeline);
@ -31,26 +32,39 @@ async function fetchExamples(outdir: string) : Promise<void> {
const getExamples = exampleSuffixes.map(async (exampleSuffix) => {
for(let i = 0; i < exampleCount; i++) {
for (let i = 0; i < exampleCount; i++) {
const exampleNumber = i.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
const exampleFile = examplePrefix + exampleNumber + exampleSuffix;
const filePath = path.join(outdir, exampleFile);
// files to download, either one file or multiple chunks
const exampleFiles = [];
const exampleFileBase = examplePrefix + exampleNumber + exampleSuffix;
if (/^-f.+|^-g.+/g.test(exampleSuffix)) {
// we might have multiple QR files
for (let j = 0; j < exampleQrChunkCount[i]; j++) {
exampleFiles.push(exampleFileBase.replace('X', j.toString()));
}
} else {
exampleFiles.push(exampleFileBase);
}
if (!fs.existsSync(filePath)) {
const exampleUrl = baseExampleUrl + exampleFile;
console.log('Retrieving ' + exampleUrl);
try {
await pipeline(
got.stream(exampleUrl),
fs.createWriteStream(filePath)
);
} catch (err) {
console.log('Error retrieving: ' + exampleUrl, (err as Error).message);
for (const exampleFile of exampleFiles) {
const filePath = path.join(outdir, exampleFile);
if (!fs.existsSync(filePath)) {
const exampleUrl = baseExampleUrl + exampleFile;
console.log('Retrieving ' + exampleUrl);
try {
await pipeline(
got.stream(exampleUrl),
fs.createWriteStream(filePath)
);
} catch (err) {
console.log('Error retrieving: ' + exampleUrl, (err as Error).message);
}
}
}
}

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

@ -2,28 +2,32 @@
// Licensed under the MIT license.
import * as utils from './utils';
import {validateSchema} from './schema';
import { OutputTree, ErrorCode } from './error';
import { validateSchema } from './schema';
import { ErrorCode } from './error';
import fhirBundleSchema from '../schema/fhir-bundle-schema.json';
import Log from './logger';
import { ValidationResult } from './validate';
export const schema = fhirBundleSchema;
export function validate(fhirBundleText: string): OutputTree {
const output = new OutputTree('FhirBundle');
export function validate(fhirBundleText: string): ValidationResult {
const output = new Log('FhirBundle');
const fhirBundle = utils.parseJson<FhirBundle>(fhirBundleText);
if (fhirBundle === undefined) {
return output
.fatal("Failed to parse FhirBundle data as JSON.", ErrorCode.JSON_PARSE_ERROR);
return {
result: fhirBundle,
log: output.fatal("Failed to parse FhirBundle data as JSON.", ErrorCode.JSON_PARSE_ERROR)
}
}
// returns [] if successful
const schemaResults = validateSchema(fhirBundleSchema, fhirBundle);
output.add(schemaResults);
// failures will be recorded in the log. we can continue processing.
validateSchema(fhirBundleSchema, fhirBundle, output);
// to continue validation, we must have a jws-compact string to validate
@ -32,11 +36,16 @@ export function validate(fhirBundleText: string): OutputTree {
fhirBundle.entry.length === 0
) {
// The schema check above will list the expected properties/type
return output.error("FhirBundle.entry[] required to contine.");
return {
result: fhirBundle,
log: output.fatal("FhirBundle.entry[] required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
}
}
output.info("Fhir Bundle Contents:");
output.info(JSON.stringify(fhirBundle, null, 2));
return output;
}
return { result: fhirBundle, log: output };
}

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

@ -4,9 +4,11 @@
import fs from 'fs';
import path from 'path';
import { isText, getEncoding } from 'istextorbinary';
import fileType from 'file-type';
import fileType from 'file-type';
import core from 'file-type/core';
import { PNG } from 'pngjs';
import jpeg from 'jpeg-js';
import {decode} from 'bmp-js';
type QRData = "png" | "jpg" | "bmp" | "svg" | "shc" | "unknown";
type FileData = { data: Buffer | string | undefined, type: QRData };
@ -19,7 +21,8 @@ export interface FileInfo {
encoding: string | null,
type: "text" | "binary",
buffer: Buffer,
fileType: core.FileTypeResult | string | undefined
fileType: core.FileTypeResult | string | undefined,
image?: { data: Buffer, height: number, width: number } | undefined
}
@ -57,8 +60,24 @@ export async function getFileData(filepath: string): Promise<FileInfo> {
if ((result.data as string).startsWith("shc:")) {
fileInfo.fileType = 'shc';
}
} else {
fileInfo.fileType = (fileInfo.fileType as core.FileTypeResult).ext;
switch (fileInfo.fileType) {
case 'png':
fileInfo.image = PNG.sync.read(fileInfo.buffer);
break;
case 'jpg':
fileInfo.image = jpeg.decode(buffer, { useTArray: true }) as { data: Buffer, height: number, width: number };
break;
case 'bmp':
fileInfo.image = decode(buffer) as { data: Buffer, height: number, width: number };
break;
default:
break;
}
}
return fileInfo;

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

@ -4,6 +4,8 @@
import fs from 'fs';
import path from 'path';
import jose, { JWK } from 'node-jose';
import svg2img from 'svg2img';
import Jimp from 'jimp';
interface KeyGenerationArgs {
kty: string;
@ -47,4 +49,45 @@ generateAndStoreKey('wrong_kty_key.json', { kty: 'RSA', size: 2048 });
generateAndStoreKey('missing_kid_key.json', { kty: 'EC', size: 'P-256', props: { alg: 'ES256', crv: 'P-256', use: 'sig' } });
// TODO: generate files with missing algs, once omit is implemented
function svgToImage(filePath: string): Promise<unknown> {
const baseFileName = filePath.slice(0, filePath.lastIndexOf('.'));
return new
Promise<Buffer>((resolve, reject) => {
svg2img(filePath, { width: 600, height: 600 },
(error: unknown, buffer: Buffer) => {
error ? reject("Could not create image from svg") : resolve(buffer);
});
})
.then((buffer) => {
fs.writeFileSync(baseFileName + '.png', buffer);
return Jimp.read(baseFileName + '.png');
})
.then(png => {
return Promise.all([
png.write(baseFileName + '.bmp'),
png.grayscale().quality(100).write(baseFileName + '.jpg')
]);
})
.catch(err => { console.error(err); });
}
async function generateImagesFromSvg(dir: string) {
const files = fs.readdirSync(dir);
for (let i = 0; i < files.length; i++) {
const file = path.join(dir, files[i]);
if (path.extname(file) === '.svg') {
await svgToImage(file);
}
}
}
// TODO: generate files with missing algs, once omit is implemented
void generateImagesFromSvg(outdir);

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

@ -3,25 +3,31 @@
import * as utils from './utils';
import { validateSchema } from './schema';
import { OutputTree, ErrorCode } from './error';
import { ErrorCode } from './error';
import healthCardSchema from '../schema/smart-health-card-schema.json';
import * as jws from './jws-compact';
import Log from './logger';
import { ValidationResult } from './validate';
export const schema = healthCardSchema;
export async function validate(healthCardText: string): Promise<OutputTree> {
const output = new OutputTree('SMART Health Card');
export async function validate(healthCardText: string): Promise<ValidationResult> {
const log = new Log('SMART Health Card');
const healthCard = utils.parseJson<HealthCard>(healthCardText);
if (healthCard == undefined) {
return output
.fatal("Failed to parse HealthCard data as JSON.", ErrorCode.JSON_PARSE_ERROR);
return {
result: healthCard,
log: log.fatal("Failed to parse HealthCard data as JSON.", ErrorCode.JSON_PARSE_ERROR)
}
}
// returns [] if successful
const schemaResults = validateSchema(healthCardSchema, healthCard);
output.add(schemaResults);
// failures will be recorded in the log. we can continue processing.
validateSchema(healthCardSchema, healthCard, log);
// to continue validation, we must have a jws-compact string to validate
const vc = healthCard.verifiableCredential;
@ -32,12 +38,16 @@ export async function validate(healthCardText: string): Promise<OutputTree> {
typeof vc[0] !== 'string'
) {
// The schema check above will list the expected properties/type
return output.fatal(
"HealthCard.verifiableCredential[jws-compact] required to contine.",
ErrorCode.CRITICAL_DATA_MISSING);
return {
result: healthCard,
log: log.fatal("HealthCard.verifiableCredential[jws-compact] required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
}
}
output.child = await jws.validate(vc[0]);
return output;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
log.child = (await jws.validate(vc[0])).log;
return { result: healthCard, log: log };
}

110
src/image.ts Normal file
Просмотреть файл

@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import svg2img from 'svg2img'; // svg files to image buffer
import jsQR from 'jsqr'; // qr image decoder
import { ErrorCode } from './error';
import Log from './logger';
import { FileInfo } from './file';
import * as qr from './qr';
import { PNG } from 'pngjs';
import fs from 'fs';
export async function validate(images: FileInfo[]): Promise<{ result: JWS | undefined, log: Log }> {
const log = new Log(
images.length > 1 ?
'QR images (' + images.length.toString() + ')' :
'QR image');
const shcStrings : SHC[] = [];
for (let i = 0; i < images.length; i++) {
const shc = await decode(images[i], log);
if(shc === undefined) return {result: undefined, log: log};
shcStrings.push(shc);
log.info(images[i].name + " decoded");
log.debug(images[i].name + ' = ' + shc);
}
log.child = (await qr.validate(shcStrings)).log;
return { result: JSON.stringify(shcStrings), log: log };
}
// takes file path to QR data and returns base64 data
async function decode(fileInfo: FileInfo, log: Log): Promise<string | undefined> {
let svgBuffer;
switch (fileInfo.fileType) {
case 'svg':
svgBuffer = await svgToImageBuffer(fileInfo.buffer.toString(), log);
fileInfo.image = PNG.sync.read(svgBuffer);
fs.writeFileSync(fileInfo.path + '.png', svgBuffer);
// eslint-disable-next-line no-fallthrough
case 'png':
case 'jpg':
case 'bmp':
return Promise.resolve(decodeQrBuffer(fileInfo, log));
default:
log.fatal("Unknown data in file", ErrorCode.UNKNOWN_FILE_DATA);
return Promise.resolve(undefined);
}
}
// the svg data is turned into an image buffer. these values ensure that the resulting image is readable
// by the QR image decoder. 300x300 fails while 400x400 suceedeeds
const svgImageWidth = 600;
// Converts a SVG file into a QR image buffer (as if read from a image file)
async function svgToImageBuffer(svgPath: string, log: Log): Promise<Buffer> {
// TODO: create a test that causes failure here
return new Promise<Buffer>((resolve, reject) => {
svg2img(svgPath, { width: svgImageWidth, height: svgImageWidth },
(error: unknown, buffer: Buffer) => {
if (error) {
log.fatal("Could not convert SVG to image. Error: " + (error as Error).message);
reject(undefined);
}
resolve(buffer);
});
});
}
// Decode QR image buffer to base64 string
function decodeQrBuffer(fileInfo: FileInfo, log: Log): string | undefined {
const result: JWS | undefined = undefined;
//const png = PNG.sync.read(image);
const data = fileInfo.image;
if(!data) {
log.fatal('Could not read image data from : ' + fileInfo.name);
return undefined;
}
// TODO : create a test that causes failure here
const code = jsQR(new Uint8ClampedArray(data.data.buffer), data.width, data.height);
if (code == null) {
log.fatal('Could not decode QR image from : ' + fileInfo.name, ErrorCode.QR_DECODE_ERROR);
return result;
}
return code.data;
}

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

@ -2,39 +2,47 @@
// Licensed under the MIT license.
import { validateSchema } from './schema';
import { OutputTree, ErrorCode } from './error';
import { ErrorCode } from './error';
import jwsCompactSchema from '../schema/jws-schema.json';
import * as jwsPayload from './jws-payload';
import * as keys from './keys';
import pako from 'pako';
import got from 'got/dist/source';
import got from 'got';
import jose, { JWK } from 'node-jose';
import path from 'path';
import Log from './logger';
import { ValidationResult } from './validate';
//const MAX_JWS_LENGTH = 1195;
export const schema = jwsCompactSchema;
const MAX_JWS_LENGTH = 1195;
export async function validate(jws: string): Promise<OutputTree> {
export async function validate(jws: JWS): Promise<ValidationResult> {
// the jws string is not JSON. It is base64url.base64url.base64url
const output = new OutputTree('JWS-compact');
const log = new Log('JWS-compact');
if (!/[0-9a-zA-Z_-]+\.[0-9a-zA-Z_-]+\.[0-9a-zA-Z_-]+/g.test(jws.trim())) {
return output
.fatal('Failed to parse JWS-compact data as \'base64url.base64url.base64url\' string.',
ErrorCode.JSON_PARSE_ERROR);
return new ValidationResult(
undefined,
log.fatal('Failed to parse JWS-compact data as \'base64url.base64url.base64url\' string.', ErrorCode.JSON_PARSE_ERROR)
);
}
/* FIXME: delete. Not a max length in spec v0.2
if (jws.length >= MAX_JWS_LENGTH) {
output.error('JWS, at ' + jws.length.toString() + ' characters, exceeds max character length of ' + MAX_JWS_LENGTH.toString(), ErrorCode.JWS_TOO_LONG);
}
*/
// returns [] if successful
const schemaResults = validateSchema(jwsCompactSchema, jws);
output.add(schemaResults);
// failures will be recorded in the log. we can continue processing.
validateSchema(jwsCompactSchema, jws, log);
// split into header[0], payload[1], key[2]
@ -42,12 +50,16 @@ export async function validate(jws: string): Promise<OutputTree> {
const rawPayload = parts[1];
log.debug('JWS.header = ' + Buffer.from(parts[0], 'base64').toString());
log.debug('JWS.key (hex) = ' + Buffer.from(parts[2], 'binary').toString('hex'));
let inflatedPayload;
try {
inflatedPayload = pako.inflateRaw(Buffer.from(rawPayload, 'base64'), { to: 'string' });
log.info('JWS payload inflated');
} catch (err) {
// TODO: we should try non-raw inflate, or try to parse JSON directly (if they forgot to deflate) and continue, to report the exact error
output.error(
log.error(
["Error inflating JWS payload. Did you use raw DEFLATE compression?",
(err as string)].join('\n'),
ErrorCode.INFLATION_ERROR);
@ -55,8 +67,9 @@ export async function validate(jws: string): Promise<OutputTree> {
// try to validate the payload (even if infation failed)
output.child = jwsPayload.validate(inflatedPayload || rawPayload);
const payload = output.child.result as JWSPayload;
const payloadResult = jwsPayload.validate(inflatedPayload || rawPayload);
const payload = payloadResult.result as JWSPayload;
log.child = payloadResult.log;
// if we did not get a payload back, it failed to be parsed and we cannot extract the key url
@ -64,39 +77,40 @@ export async function validate(jws: string): Promise<OutputTree> {
// the jws-payload child will contain the parse errors.
// The payload validation may have a Fatal error if
if (payload == null) {
return output;
return { result: payload, log: log };
}
// Extract the key url
if (!payload.iss) {
// continue, since we might have the key we need in the global keystore
output.error("Can't find 'iss' entry in JWS payload",
ErrorCode.SCHEMA_ERROR);
log.error("Can't find 'iss' entry in JWS payload", ErrorCode.SCHEMA_ERROR);
}
// download the keys into the keystore. if it fails, continue an try to use whatever is in the keystore.
await downloadKey(path.join(payload.iss, '/.well-known/jwks.json'), output);
await downloadKey(path.join(payload.iss, '/.well-known/jwks.json'), log);
if(await verifyJws(jws, output)) {
output.info("JWS signature verified");
if (await verifyJws(jws, log)) {
log.info("JWS signature verified");
}
return output;
// TODO: the result should probably be the expanded (non-compact) JWS object.
return { result: jws, log: log };
}
async function downloadKey(keyPath: string, log: OutputTree): Promise<JWK.Key[] | undefined> {
async function downloadKey(keyPath: string, log: Log): Promise<JWK.Key[] | undefined> {
log.info("Retrieving issuer key from " + keyPath);
return await got(keyPath).json<{ keys: unknown[] }>()
// TODO: split up download/parsing to provide finer-grainded error message
.then(async keysObj => {
log.debug("Downloaded issuer key : " + JSON.stringify(keysObj));
log.debug("Downloaded issuer key : " + JSON.stringify(keysObj, null, 2));
return [
await keys.store.add(JSON.stringify(keysObj.keys[0]), 'json'),
await keys.store.add(JSON.stringify(keysObj.keys[1]), 'json')
@ -110,7 +124,8 @@ async function downloadKey(keyPath: string, log: OutputTree): Promise<JWK.Key[]
}
async function verifyJws(jws: string, log: OutputTree): Promise<boolean> {
async function verifyJws(jws: string, log: Log): Promise<boolean> {
const verifier: jose.JWS.Verifier = jose.JWS.createVerify(keys.store);
@ -121,7 +136,7 @@ async function verifyJws(jws: string, log: OutputTree): Promise<boolean> {
} catch (error) {
log.error('JWS verification failed : (' + (error as Error).message + ')',
ErrorCode.JWS_VERIFICATION_ERROR);
return false;
}
return false;
}
}

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

@ -3,31 +3,32 @@
import * as utils from './utils';
import { validateSchema } from './schema';
import { OutputTree, ErrorCode } from './error';
import { ErrorCode } from './error';
import jwsPayloadSchema from '../schema/smart-health-card-vc-schema.json';
import * as fhirBundle from './fhirBundle';
import Log from './logger';
import { ValidationResult } from './validate';
export const schema = jwsPayloadSchema;
export function validate(jwsPayloadText: string): OutputTree {
const output = new OutputTree('JWS.payload');
export function validate(jwsPayloadText: string): ValidationResult {
const log = new Log('JWS.payload');
const jwsPayload = utils.parseJson<JWSPayload>(jwsPayloadText);
if (jwsPayload === undefined) {
return output
.fatal("Failed to parse JWS.payload data as JSON.", ErrorCode.JSON_PARSE_ERROR);
return {
result: jwsPayload,
log: log.fatal("Failed to parse JWS.payload data as JSON.", ErrorCode.JSON_PARSE_ERROR)
}
}
// this will get passed back to the jws-compact validation so it can
// pull out the url for the key
output.result = jwsPayload;
// returns [] if successful
const schemaResults = validateSchema(jwsPayloadSchema, jwsPayload);
output.add(schemaResults);
// failures will be recorded in the log. we can continue processing.
validateSchema(jwsPayloadSchema, jwsPayload, log);
// to continue validation, we must have a jws-compact string to validate
@ -37,13 +38,16 @@ export function validate(jwsPayloadText: string): OutputTree {
!jwsPayload.vc.credentialSubject.fhirBundle
) {
// The schema check above will list the expected properties/type
return output.fatal("JWS.payload.vc.credentialSubject.fhirBundle{} required to contine.",
ErrorCode.CRITICAL_DATA_MISSING);
return {
result: jwsPayload,
log: log.fatal("JWS.payload.vc.credentialSubject.fhirBundle{} required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
}
}
const fhirBundleText = JSON.stringify(jwsPayload.vc.credentialSubject.fhirBundle);
output.child = fhirBundle.validate(fhirBundleText);
log.child = (fhirBundle.validate(fhirBundleText)).log;
return output;
}
return { result: jwsPayload, log: log };
}

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

@ -1,14 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import path from 'path';
import fs from 'fs';
import { ErrorCode } from './error';
import color from 'colors';
import { CliOptions } from './shc-validator';
export enum ValidationErrors {
UNKNOWN,
// TODO: all errors
export class LogItem {
constructor(public message: string, public code: ErrorCode = 0, public logLevel: LogLevels = LogLevels.INFO) { }
}
export enum LogLevels {
// Print out everything
DEBUG = 0,
@ -22,132 +25,187 @@ export enum LogLevels {
FATAL
}
interface LogEntry {
level: LogLevels,
validationError: ValidationErrors,
message: string,
details?: unknown
}
/**
* Logs application messages. Each message has a level; the message will be
* printed if its level is higher than the logger's verbosity.
*/
class Logger {
// eslint-disable-next-line no-var
export default class Log {
public child: Log | undefined;
public log: LogItem[] = [];
private _exitCode = 0;
/**
* Constructs a logger.
* @param level mininum verbosity level to log
* @param outFilePath path to output the logs to if specified, console otherwise
*/
constructor(level: LogLevels, outFilePath?: string) {
this._verbosity = level;
constructor(public title: string = '') { }
if (outFilePath) {
// create new console that writes to file
outFilePath = path.normalize(outFilePath);
const ws = fs.createWriteStream(outFilePath);
this._console = new console.Console(ws, ws);
this._usesStdOut = false;
public get exitCode(): number {
if (this.child) {
const childExitCode = this.child.exitCode;
// the child should not return a different fatal exitCode
if (this._exitCode && (childExitCode === this._exitCode)) {
throw new Error("Exit code overwritten. Should only have one fatal error.");
}
// set this exit code to the child if it's currently 0
this._exitCode = this._exitCode || childExitCode;
}
return this._exitCode;
}
_console: Console = console;
_usesStdOut = true;
_verbosity: LogLevels;
_logEntries: LogEntry[] = [];
_prefix = false;
get verbosity(): LogLevels {
return this._verbosity;
debug(message: string): Log {
this.log.push(new LogItem(message, 0, LogLevels.DEBUG));
return this;
}
set verbosity(level: LogLevels) {
if (!Number.isInteger(level) || level < LogLevels.DEBUG || level > LogLevels.FATAL) {
throw new Error("Invalid verbosity level");
}
this._verbosity = level;
info(message: string): Log {
this.log.push(new LogItem(message, 0, LogLevels.INFO));
return this;
}
set prefix(on: boolean) {
this._prefix = on;
warn(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
this.log.push(new LogItem(message, code, LogLevels.WARNING));
return this;
}
color(s: string, color: string): string {
if (this._usesStdOut) {
// color the output, only on STDOUT
// if (color == 'red') {
// s = '\x1b[31m' + s + '\x1b[0m'; // red - message - reset
// } else if (color == 'yellow') {
// s = '\x1b[33m' + s + '\x1b[0m'; // yellow - message - reset
// } else {
// // don't know what that is, leave as is
// }
error(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
return s;
this.log.push(new LogItem(message, code, LogLevels.ERROR));
return this;
}
fatal(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
if (code == null || code === 0) {
throw new Error("Non-zero error code required.");
}
if (this._exitCode !== 0) {
throw new Error("Exit code overwritten. Should only have one fatal error.");
}
this._exitCode = code;
this.log.push(new LogItem(message, code, LogLevels.FATAL));
return this;
}
log(message: string, level: LogLevels = LogLevels.INFO, validationError?: ValidationErrors, details?: unknown): void {
if (!validationError) {
validationError = ValidationErrors.UNKNOWN;
}
if (this._prefix) {
message = level.toString() + ": " + message;
}
this._logEntries.push({
level: level,
validationError: validationError,
message: message,
details: details
get(level: LogLevels): LogItem[] {
return this.log.filter(item => {
return item.logLevel === level;
});
if (level >= this._verbosity) {
// print to console
if (level == LogLevels.DEBUG || level == LogLevels.INFO) {
this._console.log(message);
} else if (level == LogLevels.WARNING) {
this._console.log(this.color(message, 'yellow'));
// this._console.log('\x1b[33m%s\x1b[0m', message); // yellow - message - reset
} else if (level == LogLevels.ERROR || level == LogLevels.FATAL) {
this._console.log(this.color(message, 'red'));
// this._console.log('\x1b[31m%s\x1b[0m', message); // red - message - reset
}
if (details != null) {
// log details on a separate call to avoid stringification
this._console.log(details);
}
}
// collects errors from all children into a single collection; specify level to filter >= level
flatten(level: LogLevels = LogLevels.DEBUG): { title: string, message: string, code: ErrorCode, level: LogLevels }[] {
const items = this.log
.filter((item) => {
return item.logLevel >= level;
})
.map(e => {
return { title: this.title, message: e.message, code: e.code, level: e.logLevel };
});
return (this.child) ? items.concat(this.child.flatten(level)) : items;
}
toString(level: LogLevels = LogLevels.INFO): string {
return formatOutput(this, '', level).join('\n');
}
toFile(path: string, options: CliOptions, append = true): void {
return toFile(this, path, options, append);
}
}
function list(title: string, items: LogItem[], indent: string, color: (c: string) => string) {
const results: string[] = [];
if (items.length === 0) return results;
results.push(indent + "|");
results.push([indent, "├─ ", color(title), ' : '].join(''));
for (let i = 0; i < items.length; i++) {
const lines = items[i].message.split('\n');
for (let j = 0; j < lines.length; j++) {
results.push([indent, '| ', color(lines[j])].join(''));
}
}
error(message: string, level: LogLevels = LogLevels.ERROR, details?: unknown) {
log(message, level, undefined, details);
return results;
}
function formatOutput(outputTree: Log, indent: string, level: LogLevels): string[] {
let results: string[] = [];
results.push(indent + color.bold(outputTree.title));
indent = ' ' + indent;
switch (level) {
case LogLevels.DEBUG:
results = results.concat(list("Debug", outputTree.get(LogLevels.DEBUG), indent + ' ', color.gray));
// eslint-disable-next-line no-fallthrough
case LogLevels.INFO:
results = results.concat(list("Info", outputTree.get(LogLevels.INFO), indent + ' ', color.white.dim));
// eslint-disable-next-line no-fallthrough
case LogLevels.WARNING:
results = results.concat(list("Warning", outputTree.get(LogLevels.WARNING), indent + ' ', color.yellow));
// eslint-disable-next-line no-fallthrough
case LogLevels.ERROR:
results = results.concat(list("Error", outputTree.get(LogLevels.ERROR), indent + ' ', color.red));
// eslint-disable-next-line no-fallthrough
case LogLevels.FATAL:
results = results.concat(list("Fatal", outputTree.get(LogLevels.FATAL), indent + ' ', color.red.inverse));
}
if (outputTree.child) {
results.push(indent + ' |');
results = results.concat(formatOutput(outputTree.child, indent, level));
} else {
makeLeaf(results);
}
return results;
}
// removes the line leading to the next child
function makeLeaf(items: string[]) {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].trim()[0] === '├') {
items[i] = items[i].replace('├', '└');
break;
}
items[i] = items[i].replace('|', ' ');
}
}
export const logger = new Logger(LogLevels.WARNING /* 'out.log'*/);
export default function log(message: string, level: LogLevels = LogLevels.INFO, validationError?: ValidationErrors, details?: unknown) : void {
return logger.log(message, level, validationError, details);
}
function toFile(log: Log, logPath: string, options: CliOptions, append = true) {
const logWrapper = function (level: LogLevels) {
function f(message: string, validationError?: ValidationErrors, details?: unknown): void {
return logger.log(message, level, validationError, details);
let fileContents: Array<Record<string, unknown>> = [];
// if append, read the entire file and parse as JSON
// append the current log
// overwrite the existing file with everything
if (append && fs.existsSync(logPath)) {
fileContents = JSON.parse(fs.readFileSync(logPath).toString('utf-8')) as Array<Record<string, unknown>>;
}
return f;
};
log.debug = logWrapper(LogLevels.DEBUG);
log.error = logWrapper(LogLevels.ERROR);
log.fatal = logWrapper(LogLevels.FATAL);
log.info = logWrapper(LogLevels.INFO);
log.warn = logWrapper(LogLevels.WARNING);
// TypeScript really does not want to let you index enums by string
const level = (LogLevels as unknown as { [key: string]: number })[options.loglevel.toLocaleUpperCase()];
log.setLevel = function (level: LogLevels) {
logger.verbosity = level;
};
fileContents.push({
"time": new Date().toString(),
"options": options,
"log": log.flatten(level)
});
log.setPrefix = function (on: boolean) {
logger.prefix = on;
};
fs.writeFileSync(logPath, JSON.stringify(fileContents, null, 4) + '\n');
}

91
src/prune-fhir-schema.ts Normal file
Просмотреть файл

@ -0,0 +1,91 @@
import fhir_schema from '../schema/fhir-definitions-schema.json';
import fhir_bundle_schema from '../schema/fhir-bundle-schema.json';
import fs from 'fs';
// Takes the full fhir schema and prunes it down to just the required Bundle definitions
const bundle_refs = getUniqueBundleReferences(fhir_bundle_schema);
let fhir_refs = getFhirReferences(fhir_schema, bundle_refs);
fhir_refs = getFhirReferences(fhir_schema, fhir_refs);
fhir_refs = getFhirReferences(fhir_schema, fhir_refs);
const allRefs = bundle_refs.concat(fhir_refs);
const newSchema = pruneFhirSchema(fhir_schema, allRefs);
fs.writeFileSync('trimmed.fhir.schema.json', JSON.stringify(newSchema, null, 4));
function pruneFhirSchema(fhirSchema: Record<string, unknown>, bundle_refs: string[]) {
const defs = fhir_schema.definitions;
for (const key in defs) {
if (Object.prototype.hasOwnProperty.call(defs, key)) {
if (bundle_refs.indexOf(key) < 0) {
delete (fhir_schema.definitions as { [key: string]: unknown })[key];
}
}
}
return fhirSchema;
}
function findChildRefs(definition: Record<string, unknown | string>): string[] {
let result: string[] = [];
for (const key in definition) {
if (Object.prototype.hasOwnProperty.call(definition, key)) {
const prop = definition[key];
if (key === '$ref') result.push(prop as string);
if (key === 'oneOf') continue;
if (prop instanceof Object) {
result = result.concat(findChildRefs(definition[key] as Record<string, unknown>));
}
}
}
return result;
}
// from a list of bundle refs, collect all the internal references to fhir-schema definitions
// a definition, refered to by a bundle ref, may reference other internal references
function getFhirReferences(fhirSchema: Record<string, unknown>, bundle_refs: string[]): string[] {
const defs = fhir_schema.definitions;
let child_refs: string[] = bundle_refs.slice();
for (const key of bundle_refs) {
if (Object.prototype.hasOwnProperty.call(defs, key)) {
const prop = (defs as Record<string, unknown>)[key];
child_refs = child_refs.concat(
findChildRefs(prop as Record<string, unknown>))
.map(ref => ref.slice(ref.lastIndexOf('/') + 1));
}
}
// remove the duplicates and trim to just the definition name
return child_refs
.filter((ref, index) => child_refs.indexOf(ref) === index);
}
// returns unique external references to fhir-schema definitions
function getUniqueBundleReferences(bundleSchema: unknown): string[] {
const refs = JSON.stringify(bundleSchema)
.match(/"\$ref":"fhir\.schema\.json#\/definitions\/(.+?)"/g) || [];
// remove the duplicates and trim to just the definition name
return refs
.filter((ref, index) => refs.indexOf(ref) === index)
.map(ref => ref.slice(ref.lastIndexOf('/') + 1, ref.length - 1));
}

163
src/qr.ts
Просмотреть файл

@ -1,84 +1,101 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import svg2img from 'svg2img'; // svg files to image buffer
import { PNG } from 'pngjs'; // png image file reader
import jsQR from 'jsqr'; // qr image decoder
import core from 'file-type/core';
import {OutputTree, ResultWithErrors, ErrorCode, LogItem} from './error';
import { ErrorCode } from './error';
import * as jws from './jws-compact';
import Log from './logger';
interface FileInfo {
name: string,
path: string,
ext: string,
encoding: string | null,
type: "text" | "binary",
buffer: Buffer,
fileType: core.FileTypeResult | string | undefined
export async function validate(qr: string[]): Promise<{ result: JWS | undefined, log: Log }> {
const log = new Log(
qr.length > 1 ?
'QR numeric (' + qr.length.toString() + ')' :
'QR numeric');
const jwsString: JWS | undefined = shcChunksToJws(qr, log); //await decode(qr, log);
jwsString && (log.child = (await jws.validate(jwsString)).log);
return { result: jwsString, log: log };
}
export async function validate(qrSvg: FileInfo): Promise<OutputTree> {
function shcChunksToJws(shc: string[], log : Log): JWS | undefined {
const output = new OutputTree('QR code (' + (qrSvg.fileType as string) + ')');
const chunkCount = shc.length;
const jwsChunks = new Array(chunkCount);
const results = await decode(qrSvg);
for (const shcChunk of shc) {
output.add(results.errors);
const chunkResult = shcToJws(shcChunk, log, chunkCount);
if (results.result != null) {
output.child = await jws.validate(results.result);
}
// bad header is fatal (according to tests)
if(!chunkResult) return undefined; // move on to next chunk
return output;
}
// Converts a SVG file into a QR image buffer (as if read from a image file)
async function svgToImageBuffer(svgPath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
svg2img(svgPath, { width: 600, height: 600, quality: 100 }, function (error: unknown, buffer: Buffer) {
if (error) reject(error);
resolve(buffer);
});
});
}
// Decode QR image buffer to base64 string
function decodeQrBuffer(image: Buffer): ResultWithErrors {
const result = new ResultWithErrors();
const png = PNG.sync.read(image);
const code = jsQR(new Uint8ClampedArray(png.data.buffer), png.width, png.height);
if (code == null) {
result.errors.push(new LogItem("Could not decode QR image.", ErrorCode.QR_DECODE_ERROR));
return result;
const chunkIndex = chunkResult.chunkIndex;
if (jwsChunks[chunkIndex - 1]) {
// we have a chunk index collision
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.error('we have two chunks with index ' + chunkIndex, ErrorCode.INVALID_QR_CHUNK_INDEX);
} else {
jwsChunks[chunkIndex - 1] = chunkResult.result;
}
}
// make sure we have all chunks we expect
for (let i = 0; i < chunkCount; i++) {
if (!jwsChunks[i]) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.error('missing QR chunk ' + i, ErrorCode.INVALID_QR_CHUNK_INDEX);
}
}
return shcToJws(code.data);
if(shc.length > 1) log.info('All shc parts decoded');
log.debug('JWS = ' + jwsChunks.join(''));
return jwsChunks.join('');
}
function shcToJws(shc: string): ResultWithErrors {
function shcToJws(shc: string, log: Log, chunkCount = 1): {result: JWS, chunkIndex: number} | undefined {
const result = new ResultWithErrors();
const chunked = chunkCount > 1; // TODO: what about chunk 1 of 1 ('shc:/1/1/...' it's legal but shouldn't happen)
const qrHeader = 'shc:/';
let chunkIndex = 1;
const bodyIndex = chunked ? qrHeader.length + 4 : qrHeader.length;
if (!/^shc:\/\d+$/g.test(shc)) {
return result.error("Invalid 'shc:/' header string", ErrorCode.INVALID_SHC_STRING);
// check numeric QR header
if (!new RegExp(chunked ? `^${qrHeader}[0-9]/${chunkCount}/.+$` : `^${qrHeader}.+$`, 'g').test(shc)) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.fatal("Invalid numeric QR header: expected" + chunked ? `${qrHeader}[0-9]+` : `${qrHeader}[0-9]/[0-9]/[0-9]+`, ErrorCode.INVALID_NUMERIC_QR_HEADER);
return undefined;
}
// check numeric QR encoding
if (!new RegExp(chunked ? `^${qrHeader}[0-9]/${chunkCount}/[0-9]+$` : `^${qrHeader}[0-9]+$`, 'g').test(shc)) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.fatal("Invalid numeric QR: expected" + chunked ? `${qrHeader}[0-9]+` : `${qrHeader}[0-9]/[0-9]/[0-9]+`, ErrorCode.INVALID_NUMERIC_QR);
return undefined;
}
// get the chunk index
if (chunked) {
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
chunkIndex = parseInt((shc.match(new RegExp('^shc:/[0-9]')) as RegExpMatchArray)[0].substring(5, 6));
if (chunkIndex < 1 || chunkIndex > chunkCount) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.fatal("Invalid QR chunk index: " + chunkIndex, ErrorCode.INVALID_QR_CHUNK_INDEX);
return undefined;
}
}
const b64Offset = '-'.charCodeAt(0);
const digitPairs = shc.match(/(\d\d?)/g);
const digitPairs = shc.substring(bodyIndex).match(/(\d\d?)/g);
if (digitPairs == null) {
return result.error("Invalid 'shc:/' header string", ErrorCode.INVALID_SHC_STRING);
log.fatal("Invalid numeric QR code", ErrorCode.INVALID_NUMERIC_QR);
return undefined;
}
// breaks string array of digit pairs into array of numbers: 'shc:/123456...' = [12,34,56]
@ -88,38 +105,8 @@ function shcToJws(shc: string): ResultWithErrors {
// merge the array into a single base64 string
.join('');
result.result = jws;
return result;
}
// takes file path to QR data and returns base64 data
async function decode(fileInfo: FileInfo): Promise<ResultWithErrors> {
let svgBuffer;
const result = new ResultWithErrors();
switch (fileInfo.fileType) {
case 'svg':
svgBuffer = await svgToImageBuffer(fileInfo.buffer.toString());
return decodeQrBuffer(svgBuffer);
case 'shc':
return Promise.resolve(shcToJws(fileInfo.buffer.toString()));
case 'png':
return decodeQrBuffer(fileInfo.buffer);
case 'jpg':
return result.error("jpg : Not implemented", ErrorCode.NOT_IMPLEMENTED);
case 'bmp':
return result.error("bmp : Not implemented", ErrorCode.NOT_IMPLEMENTED);
default:
return result.error("Unknown data in file", ErrorCode.UNKNOWN_FILE_DATA);
}
log.info( shc.slice(0, shc.lastIndexOf('/')) + '/... decoded');
log.debug( shc.slice(0, shc.lastIndexOf('/')) + '/... = ' + jws);
return { result: jws, chunkIndex : chunkIndex};
}

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

@ -1,86 +1,49 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import fs from 'fs';
import path from 'path';
import log, { LogLevels } from './logger';
import { ErrorCode, LogItem } from './error';
import Log from './logger';
import { ErrorCode } from './error';
import metaSchema from 'ajv/lib/refs/json-schema-draft-06.json';
import fhirSchema from '../schema/fhir-definitions-schema.json';
import Ajv, { AnySchemaObject } from "ajv";
import { AnyValidateFunction } from 'ajv/dist/core';
// http://json-schema.org/
// https://github.com/ajv-validator/ajv
import JsonValidator, { AnySchemaObject, ErrorObject } from "ajv";
export async function validateFromFile(schemaPath: string, data: FhirBundle | JWS | JWSPayload | HealthCard): Promise<LogItem[]> {
if (!fs.existsSync(schemaPath)) {
log('Schema file not found : ' + schemaPath, LogLevels.FATAL);
return [new LogItem('Schema: file not found : ' + schemaPath, ErrorCode.SCHEMA_FILE_NOT_FOUND)];
}
const schemaDir = path.basename(path.dirname(schemaPath));
// for each $ref in our schema, load the schema file into an object and return it.
const jsonValidator = new JsonValidator({
allErrors: true,
loadSchema: (uri) => {
const schemaFile: string = uri.slice(uri.lastIndexOf('/'));
const schemaFullPath = path.join(schemaDir, schemaFile);
const schema = JSON.parse(fs.readFileSync(schemaFullPath, 'utf8')) as AnySchemaObject;
return Promise.resolve(schema);
}
});
const schemaObj = JSON.parse(fs.readFileSync(schemaPath, 'utf8')) as AnySchemaObject;
const schemaCache: Record<string, AnyValidateFunction> = {};
return jsonValidator.compileAsync(schemaObj)
.then(validate => {
export function validateSchema(schema: AnySchemaObject | AnySchemaObject[], data: FhirBundle | JWS | JWSPayload | HealthCard, log: Log): boolean {
if (validate(data)) {
return [];
} else {
const errors = (validate.errors as ErrorObject[]);
const outErrors = errors.map(c => {
return new LogItem(
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
'Schema: ' + c.schemaPath + ' \'' + c.message + '\'',
ErrorCode.SCHEMA_ERROR);
});
return outErrors;
}
})
.catch(error => {
return [new LogItem(
"Error: validating against schema : " + (error as Error).message,
ErrorCode.SCHEMA_ERROR)];
});
}
export function validateSchema(schema: AnySchemaObject, data: FhirBundle | JWS | JWSPayload | HealthCard): LogItem[] {
const jsonValidator = new JsonValidator({ allErrors: true });
// by default, the validator will stop at the first failure. 'allErrors' allows it to keep going.
const schemaId = (schema as { [key: string]: string })["$id"];
try {
const validate = jsonValidator.compile(schema);
if (validate(data)) { return []; }
if (!schemaCache[schemaId]) {
const ajv = new Ajv({ allErrors: true, strict: false });
ajv.addMetaSchema(metaSchema); // required for avj 7 to support json-schema draft-06 (draft-07 is current)
const validate = ajv.addSchema(fhirSchema).compile(schema);
schemaCache[schemaId] = validate;
}
const validationErrors = (validate.errors as ErrorObject[]);
const validate = schemaCache[schemaId];
const outErrors = validationErrors.map(ve => {
return new LogItem(
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
'Schema: ' + ve.schemaPath + ' \'' + ve.message + '\'',
ErrorCode.SCHEMA_ERROR, LogLevels.ERROR);
if (validate(data)) { return true; }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
validate.errors!.forEach(ve => {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
log.error('Schema: ' + ve.schemaPath + ' \'' + ve.message + '\'', ErrorCode.SCHEMA_ERROR);
});
return outErrors;
return false;
} catch (err) {
return [new LogItem('Schema: ' + (err as Error).message, ErrorCode.SCHEMA_ERROR)];
// TODO: get to this catch in test
log.error('Schema: ' + (err as Error).message, ErrorCode.SCHEMA_ERROR);
return false;
}
}
}

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

@ -6,13 +6,17 @@ import path from 'path';
import fs from 'fs';
import { Option, Command } from 'commander';
import * as validator from './validate';
import log, { LogLevels } from './logger';
import { LogLevels } from './logger';
import { getFileData } from './file';
import { ErrorCode } from './error';
import * as keys from './keys';
import npmpackage from '../package.json';
// function collect(value: string, previous: string[]) {
// return previous.concat([value]);
// }
/**
* Defines the program
* see https://www.npmjs.com/package/commander for documentation
@ -21,51 +25,59 @@ import npmpackage from '../package.json';
const loglevelChoices = ['debug', 'info', 'warning', 'error', 'fatal'];
const program = new Command();
program.version(npmpackage.version, '-v, --version', 'display specification and tool version');
program.requiredOption('-p, --path <path>', 'path of the file to validate');
program.requiredOption('-p, --path <path>', 'path of the file(s) to validate. Can be repeated for the qr and qrnumeric types, to provide multiple file chunks',
(p: string, paths: string[]) => paths.concat([p]), []);
program.addOption(new Option('-t, --type <type>', 'type of file to validate').choices(['fhirbundle', 'jwspayload', 'jws', 'healthcard', 'qrnumeric', 'qr', 'jwkset'])); // TODO: populate this from the validate enum.
program.addOption(new Option('-l, --loglevel <loglevel>', 'set the minimum log level').choices(loglevelChoices).default('warning'));
program.option('-o, --logout <path>', 'output path for log (if not specified log will be printed on console)');
program.option('-k, --jwkset <key>', 'path to trusted issuer key set');
program.parse(process.argv);
interface Options {
path: string;
export interface CliOptions {
path: string[];
type: validator.ValidationType;
jwkset: string;
loglevel: string;
logout: string;
}
//const log = new Log('main');
/**
* Processes the program options and launches validation
*/
async function processOptions() {
const options = program.opts() as Options;
const options = program.opts() as CliOptions;
let logFilePathIsValid = false;
// verify that the directory of the logfile exists
if (options.logout) {
const logDir = path.dirname(path.resolve(options.logout));
if(!fs.existsSync(logDir)){
log.fatal('Cannot create log file at: ' + logDir);
if (!fs.existsSync(logDir)) {
console.log('Cannot create log file at: ' + logDir);
process.exitCode = ErrorCode.LOG_PATH_NOT_FOUND;
return;
}
logFilePathIsValid = true;
}
if (options.loglevel) {
log.setLevel(loglevelChoices.indexOf(options.loglevel) as LogLevels);
}
if (options.path && options.type) {
if (options.path.length > 0 && options.type) {
if (options.path.length > 1 && !(options.type === 'qr' || options.type === 'qrnumeric')) {
console.log("Only the qr and qrnumeric types can have multiple path options")
return; // TODO: add unit test
}
// read the file to validate
let fileData;
try {
fileData = await getFileData(options.path);
} catch (error) {
log.error((error as Error).message);
process.exitCode = ErrorCode.DATA_FILE_NOT_FOUND;
return;
const fileData = [];
for (const path of options.path) {
try {
fileData.push(await getFileData(path));
} catch (error) {
console.log((error as Error).message);
process.exitCode = ErrorCode.DATA_FILE_NOT_FOUND;
return;
}
}
// if we have a key option, parse is and add it to the global key store
@ -73,34 +85,26 @@ async function processOptions() {
// creates a new keyStore from a JSON key set file
// const keyStore: JWK.KeyStore = await createKeyStoreFromFile(options.key);
await keys.initKeyStoreFromFile(options.jwkset);
log.debug('keyStore');
//log.debug('keyStore');
}
if (options.type === 'jwkset') {
// validate a key file
await validator.validateKey(fileData.buffer);
await validator.validateKey(fileData[0].buffer);
} else {
// validate a health card
const output = await validator.validateCard(fileData, options.type);
process.exitCode = output.exitCode;
process.exitCode = output.log.exitCode;
const level = loglevelChoices.indexOf(options.loglevel) as LogLevels;
// append to the specified logfile
if(logFilePathIsValid) {
const out = {
"time" : new Date().toString(),
"options" : options,
"log" : output.flatten(level)
};
fs.appendFileSync(options.logout, JSON.stringify(out, null, 4) + '\n');
fs.appendFileSync(options.logout, '\n--------------------------------------------------------------------------------');
if (logFilePathIsValid) {
output.log.toFile(options.logout, options, true);
} else {
if (output != null) {
log(validator.formatOutput(output, '').join('\n'), level);
}
console.log(output.log.toString(level));
}
}

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

@ -1,30 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
/* eslint-disable @typescript-eslint/no-explicit-any */
/* Validate SMART Health Card JSON Web Keys (JWK) */
import log, {LogLevels} from './logger';
import Log from './logger';
import jose, { JWK } from 'node-jose';
import { ErrorCode } from './error';
export enum KeyValidationErrors {
INVALID_MISSING_KTY,
INVALID_WRONG_KTY,
INVALID_MISSING_ALG,
INVALID_WRONG_ALG,
INVALID_MISSING_USE,
INVALID_WRONG_USE,
INVALID_MISSING_KID,
INVALID_WRONG_KID,
INVALID_SCHEMA,
INVALID_UNKNOWN
}
export class shcKeyValidator {
async verifyHealthCardIssuerKey(jwk: string | Buffer): Promise<KeyValidationErrors[]> {
const validationResult : KeyValidationErrors[] = [];
async verifyHealthCardIssuerKey(jwk: string | Buffer): Promise<Log> {
const log: Log = new Log('IssuerKey');
const keyStore = JWK.createKeyStore();
return keyStore.add(jwk)
@ -32,58 +18,48 @@ export class shcKeyValidator {
.then(async key => {
// check that key type is 'EC'
if (!key.kty) {
log("'kty' missing in issuer key", LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_MISSING_KTY);
log.error("'kty' missing in issuer key", ErrorCode.INVALID_MISSING_KTY);
} else if (key.kty !== 'EC') {
log("wrong key type in issuer key. expected: 'EC', actual: " + key.kty, LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_WRONG_KTY);
log.error("wrong key type in issuer key. expected: 'EC', actual: " + key.kty, ErrorCode.INVALID_WRONG_KTY);
}
// check that EC curve is 'ES256'
if (!key.alg) {
log("'alg' missing in issuer key", LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_MISSING_ALG);
log.error("'alg' missing in issuer key", ErrorCode.INVALID_MISSING_ALG);
} else if (key.alg !== 'ES256') {
log("wrong algorithm in issuer key. expected: 'ES256', actual: " + key.alg, LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_WRONG_ALG);
log.error("wrong algorithm in issuer key. expected: 'ES256', actual: " + key.alg, ErrorCode.INVALID_WRONG_ALG);
}
// check that usage is 'sig'
if (!key.use) {
log("'use' missing in issuer key", LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_MISSING_USE);
log.error("'use' missing in issuer key", ErrorCode.INVALID_MISSING_USE);
} else if (key.use !== 'sig') {
log("wrong usage in issuer key. expected: 'sig', actual: " + key.use, LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_WRONG_USE);
log.error("wrong usage in issuer key. expected: 'sig', actual: " + key.use, ErrorCode.INVALID_WRONG_USE);
}
// check that kid is properly generated
if (!key.kid) {
log("'kid' missing in issuer key", LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_MISSING_KID);
log.error("'kid' missing in issuer key", ErrorCode.INVALID_MISSING_KID);
} else {
await key.thumbprint('SHA-256')
.then(tpDigest => {
const thumbprint = jose.util.base64url.encode(tpDigest);
if (key.kid !== thumbprint) {
log("'kid' does not match thumbprint in issuer key. expected: "
+ thumbprint + ", actual: " + key.kid, LogLevels.ERROR);
validationResult.push(KeyValidationErrors.INVALID_WRONG_KID);
log.error("'kid' does not match thumbprint in issuer key. expected: "
+ thumbprint + ", actual: " + key.kid, ErrorCode.INVALID_WRONG_KID);
}
})
.catch(err => {
log("Failed to calculate issuer key thumbprint", LogLevels.ERROR, err);
validationResult.push(KeyValidationErrors.INVALID_UNKNOWN);
log.error("Failed to calculate issuer key thumbprint : " + (err as Error).message, ErrorCode.INVALID_UNKNOWN);
});
}
return validationResult;
return log;
})
.catch(err => {
log("Failed to parse issuer key", LogLevels.ERROR, err);
validationResult.push(KeyValidationErrors.INVALID_SCHEMA);
return validationResult;
log.error("Failed to parse issuer key : " + (err as Error).message, ErrorCode.INVALID_SCHEMA);
return log;
});
}
}
}

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

@ -4,6 +4,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
type JWS = string;
type SHC = string;
interface HealthCard {
"verifiableCredential": JWS[]
}
@ -22,9 +24,3 @@ interface FhirBundle {
"type": string,
"entry": unknown[]
}
interface TestFhirBundle {
"resourceType"?: string,
"type"?: string,
"entry"?: unknown[]
}

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

@ -1,138 +1,87 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import log, { LogLevels, logger } from './logger';
import color from 'colors';
import Log from './logger';
import { shcKeyValidator } from './shcKeyValidator';
import { FileInfo } from './file';
import { ErrorCode, LogItem, OutputTree } from './error';
import { ErrorCode } from './error';
import * as healthCard from './healthCard';
import * as jws from './jws-compact';
import * as jwsPayload from './jws-payload';
import * as fhirBundle from './fhirBundle';
import * as qr from './qr';
import * as image from './image';
function list(title: string, items: LogItem[], indent: string, color: (c: string) => string) {
const results: string[] = [];
if (items.length === 0) return results;
results.push(indent + "|");
results.push([indent, "├─ ", color(title), ' : '].join(''));
for (let i = 0; i < items.length; i++) {
const lines = items[i].message.split('\n');
for (let j = 0; j < lines.length; j++) {
results.push([indent, '| ', color(lines[j])].join(''));
}
}
return results;
}
export function formatOutput(outputTree: OutputTree, indent: string): string[] {
let results: string[] = [];
results.push(indent + color.bold(outputTree.title));
indent = ' ' + indent;
switch (logger.verbosity) {
case LogLevels.DEBUG:
results = results.concat(list("Debug", outputTree.get(LogLevels.DEBUG), indent + ' ', color.gray));
// eslint-disable-next-line no-fallthrough
case LogLevels.INFO:
results = results.concat(list("Info", outputTree.get(LogLevels.INFO), indent + ' ', color.white.dim ));
// eslint-disable-next-line no-fallthrough
case LogLevels.WARNING:
results = results.concat(list("Warning", outputTree.get(LogLevels.WARNING), indent + ' ', color.yellow));
// eslint-disable-next-line no-fallthrough
case LogLevels.ERROR:
results = results.concat(list("Error", outputTree.get(LogLevels.ERROR), indent + ' ', color.red));
// eslint-disable-next-line no-fallthrough
case LogLevels.FATAL:
results = results.concat(list("Fatal", outputTree.get(LogLevels.FATAL), indent + ' ', color.red.inverse));
}
if (outputTree.child) {
results.push(indent + ' |');
results = results.concat(formatOutput(outputTree.child, indent));
} else {
makeLeaf(results);
}
return results;
}
function makeLeaf(items: string[]) {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].trim()[0] === '├') {
items[i] = items[i].replace('├', '└');
break;
}
items[i] = items[i].replace('|', ' ');
}
}
/** Validate the issuer key */
export async function validateKey(key: Buffer): Promise<void> {
log.debug('Validating key', undefined, key);
const log = new Log('Validate Key');
log.debug('Validating key : ' + key.toString('utf-8'));
const keyValidator = new shcKeyValidator();
return keyValidator.verifyHealthCardIssuerKey(key)
return keyValidator
.verifyHealthCardIssuerKey(key)
.then(() => { return Promise.resolve(); })
.catch(err => {
log.error("Error validating issuer key", undefined, err);
log.error("Error validating issuer key : " + (err as Error).message);
return Promise.reject();
});
}
export type ValidationType = "qr" | "qrnumeric" | "healthcard" | "jws" | "jwspayload" | "fhirbundle" | "jwkset";
/** Validates SMART Health Card */
export async function validateCard(fileData: FileInfo, type: ValidationType): Promise<OutputTree> {
let output: OutputTree | undefined = undefined;
export class ValidationResult {
constructor(
public result: HealthCard | JWS | JWSPayload | FhirBundle | undefined,
public log: Log
) { }
}
/** Validates SMART Health Card */
export async function validateCard(fileData: FileInfo[], type: ValidationType): Promise<ValidationResult> {
let result: ValidationResult;
switch (type.toLocaleLowerCase()) {
case "qr":
output = await qr.validate(fileData);
break;
case "qr":
result = await image.validate(fileData);
break;
case "qrnumeric":
output = await qr.validate(fileData);
break;
case "qrnumeric":
result = await qr.validate(fileData.map((fi)=>fi.buffer.toString('utf-8')));
break;
case "healthcard":
output = await healthCard.validate(fileData.buffer.toString());
if (fileData.ext !== '.smart-health-card') {
output.warn("Invalid file extenion. Should be .smart-health-card.", ErrorCode.INVALID_FILE_EXTENSION);
}
break;
case "healthcard":
result = await healthCard.validate(fileData[0].buffer.toString());
if (fileData[0].ext !== '.smart-health-card') {
result.log.warn("Invalid file extenion. Should be .smart-health-card.", ErrorCode.INVALID_FILE_EXTENSION);
}
break;
case "jws":
output = await jws.validate(fileData.buffer.toString());
break;
case "jws":
result = await jws.validate(fileData[0].buffer.toString());
break;
case "jwspayload":
output = jwsPayload.validate(fileData.buffer.toString());
break;
case "jwspayload":
result = jwsPayload.validate(fileData[0].buffer.toString());
break;
case "fhirbundle":
output = fhirBundle.validate(fileData.buffer.toString());
break;
case "fhirbundle":
result = fhirBundle.validate(fileData[0].buffer.toString());
break;
default:
return Promise.reject("Invalid type : " + type);
default:
return Promise.reject("Invalid type : " + type);
}
return output;
return result;
}

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

@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// TODO: add QR chunk test cases: warning 'sch:/1/1/...', missing chunks, unbalanced chunks, too big chunks
import path from 'path';
import { validateCard, ValidationType } from '../src/validate';
import { getFileData } from '../src/file';
@ -9,51 +11,77 @@ import { LogLevels } from '../src/logger';
const testdataDir = './testdata/';
async function testCard(fileName: string, fileType: ValidationType = 'healthcard', levels : LogLevels[] = [LogLevels.ERROR, LogLevels.FATAL]): Promise<{ title: string, message: string, code: ErrorCode }[]> {
const filePath = path.join(testdataDir, fileName);
const outputTree = await validateCard(await getFileData(filePath), fileType);
return outputTree.flatten().filter(i=>{return levels.includes(i.level);});
async function testCard(fileName: string | string[], fileType: ValidationType = 'healthcard', levels: LogLevels[] = [LogLevels.ERROR, LogLevels.FATAL]): Promise<{ title: string, message: string, code: ErrorCode }[]> {
if (typeof fileName === 'string') fileName = [fileName];
const files = [];
for (const fn of fileName) { // TODO: I tried a map here, but TS didn't like the async callback
files.push(await getFileData(path.join(testdataDir, fn)));
}
const log = (await validateCard(files, fileType)).log;
return log.flatten().filter(i => { return levels.includes(i.level); });
}
// Test valid examples from spec
test("Cards: valid 00 FHIR bundle", async () => expect(await testCard('example-00-a-fhirBundle.json', "fhirbundle")).toHaveLength(0));
test("Cards: valid 01 FHIR bundle", async () => expect(await testCard('example-01-a-fhirBundle.json', "fhirbundle")).toHaveLength(0));
test("Cards: valid 00 FHIR bundle", async () => expect(await testCard(['example-00-a-fhirBundle.json'], "fhirbundle")).toHaveLength(0));
test("Cards: valid 01 FHIR bundle", async () => expect(await testCard(['example-01-a-fhirBundle.json'], "fhirbundle")).toHaveLength(0));
test("Cards: valid 02 FHIR bundle", async () => expect(await testCard(['example-02-a-fhirBundle.json'], "fhirbundle")).toHaveLength(0));
test("Cards: valid 00 JWS payload expanded", async () => expect(await testCard('example-00-b-jws-payload-expanded.json', "jwspayload")).toHaveLength(0));
test("Cards: valid 01 JWS payload expanded", async () => expect(await testCard('example-01-b-jws-payload-expanded.json', "jwspayload")).toHaveLength(0));
test("Cards: valid 00 JWS payload expanded", async () => expect(await testCard(['example-00-b-jws-payload-expanded.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 01 JWS payload expanded", async () => expect(await testCard(['example-01-b-jws-payload-expanded.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 02 JWS payload expanded", async () => expect(await testCard(['example-02-b-jws-payload-expanded.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 00 JWS payload minified", async () => expect(await testCard('example-00-c-jws-payload-minified.json', "jwspayload")).toHaveLength(0));
test("Cards: valid 01 JWS payload minified", async () => expect(await testCard('example-01-c-jws-payload-minified.json', "jwspayload")).toHaveLength(0));
test("Cards: valid 00 JWS payload minified", async () => expect(await testCard(['example-00-c-jws-payload-minified.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 01 JWS payload minified", async () => expect(await testCard(['example-01-c-jws-payload-minified.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 02 JWS payload expanded", async () => expect(await testCard(['example-02-b-jws-payload-expanded.json'], "jwspayload")).toHaveLength(0));
test("Cards: valid 00 JWS", async () => expect(await testCard('example-00-d-jws.txt', "jws")).toHaveLength(0));
test("Cards: valid 01 JWS", async () => expect(await testCard('example-01-d-jws.txt', "jws")).toHaveLength(0));
test("Cards: valid 00 JWS", async () => expect(await testCard(['example-00-d-jws.txt'], "jws")).toHaveLength(0));
test("Cards: valid 01 JWS", async () => expect(await testCard(['example-01-d-jws.txt'], "jws")).toHaveLength(0));
test("Cards: valid 02 JWS", async () => expect(await testCard(['example-02-d-jws.txt'], "jws")).toHaveLength(0));
test("Cards: valid 00 health card", async () => expect(await testCard('example-00-e-file.smart-health-card', "healthcard")).toHaveLength(0));
test("Cards: valid 01 health card", async () => expect(await testCard('example-01-e-file.smart-health-card', "healthcard")).toHaveLength(0));
test("Cards: valid 00 health card", async () => expect(await testCard(['example-00-e-file.smart-health-card'], "healthcard")).toHaveLength(0));
test("Cards: valid 01 health card", async () => expect(await testCard(['example-01-e-file.smart-health-card'], "healthcard")).toHaveLength(0));
test("Cards: valid 02 health card", async () => expect(await testCard(['example-02-e-file.smart-health-card'], "healthcard")).toHaveLength(0));
test("Cards: valid 00 QR numeric", async () => expect(await testCard('example-00-f-qr-code-numeric.txt', "qrnumeric")).toHaveLength(0));
test("Cards: valid 01 QR numeric", async () => expect(await testCard('example-01-f-qr-code-numeric.txt', "qrnumeric")).toHaveLength(0));
test("Cards: valid 00 QR numeric", async () => expect(await testCard(['example-00-f-qr-code-numeric-value-0.txt'], "qrnumeric")).toHaveLength(0));
test("Cards: valid 01 QR numeric", async () => expect(await testCard(['example-01-f-qr-code-numeric-value-0.txt'], "qrnumeric")).toHaveLength(0));
test("Cards: valid 02 QR numeric", async () => expect(
await testCard(['example-02-f-qr-code-numeric-value-0.txt',
'example-02-f-qr-code-numeric-value-1.txt',
'example-02-f-qr-code-numeric-value-2.txt'], "qrnumeric")).toHaveLength(0));
test("Cards: valid 00 QR code", async () => expect(await testCard('example-00-g-qr-code-0.svg', "qr")).toHaveLength(0));
test("Cards: valid 01 QR code", async () => expect(await testCard('example-01-g-qr-code-0.svg', "qr")).toHaveLength(0));
test("Cards: valid 00 QR code", async () => expect(await testCard(['example-00-g-qr-code-0.svg'], "qr")).toHaveLength(0));
test("Cards: valid 01 QR code", async () => expect(await testCard(['example-01-g-qr-code-0.svg'], "qr")).toHaveLength(0));
test("Cards: valid 02 QR code", async () => expect(
await testCard(['example-02-g-qr-code-0.svg', 'example-02-g-qr-code-1.svg', 'example-02-g-qr-code-2.svg'], "qr")).toHaveLength(0));
test("Cards: valid 02 QR code PNG", async () => expect(
await testCard(['example-02-g-qr-code-0.png', 'example-02-g-qr-code-1.png', 'example-02-g-qr-code-2.png'], "qr")).toHaveLength(0));
test("Cards: valid 02 QR code JPG", async () => expect(
await testCard(['example-02-g-qr-code-0.jpg', 'example-02-g-qr-code-1.jpg', 'example-02-g-qr-code-2.jpg'], "qr")).toHaveLength(0));
test("Cards: valid 02 QR code BMP", async () => expect(
await testCard(['example-02-g-qr-code-0.bmp', 'example-02-g-qr-code-1.bmp', 'example-02-g-qr-code-2.bmp'], "qr")).toHaveLength(0));
test("Cards: invalid deflate", async () => {
const results = await testCard('test-example-00-e-file-invalid_deflate.smart-health-card');
const results = await testCard(['test-example-00-e-file-invalid_deflate.smart-health-card']);
expect(results).toHaveLength(2);
expect(results[0].code).toBe(ErrorCode.INFLATION_ERROR);
expect(results[1].code).toBe(ErrorCode.JSON_PARSE_ERROR);
});
test("Cards: no deflate", async () => {
const results = await testCard('test-example-00-e-file-no_deflate.smart-health-card');
expect(results).toHaveLength(3);
expect(results[0].code).toBe(ErrorCode.JWS_TOO_LONG);
expect(results[1].code).toBe(ErrorCode.INFLATION_ERROR);
expect(results[2].code).toBe(ErrorCode.JSON_PARSE_ERROR);
const results = await testCard(['test-example-00-e-file-no_deflate.smart-health-card']);
expect(results).toHaveLength(2);
// expect(results[0].code).toBe(ErrorCode.JWS_TOO_LONG); // FIXME: fix for chunk
expect(results[0].code).toBe(ErrorCode.INFLATION_ERROR);
expect(results[1].code).toBe(ErrorCode.JSON_PARSE_ERROR);
});
test("Cards: invalid issuer url", async () => {
const results = await testCard('test-example-00-e-file-invalid_issuer_url.smart-health-card');
const results = await testCard(['test-example-00-e-file-invalid_issuer_url.smart-health-card']);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ErrorCode.ISSUER_KEY_DOWNLOAD_ERROR);
});
@ -67,25 +95,27 @@ test("Cards: invalid QR mode", async () => {
*/
test("Cards: invalid QR header", async () => {
const results = await testCard('test-example-00-f-qr-code-numeric-wrong_qr_header.txt', 'qr');
const results = await testCard(['test-example-00-f-qr-code-numeric-wrong_qr_header.txt'], 'qrnumeric');
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ErrorCode.INVALID_SHC_STRING);
expect(results[0].code).toBe(ErrorCode.INVALID_NUMERIC_QR_HEADER);
});
/* TODO: FIX this test
test("Cards:JWS too long", async () => {
const results = await testCard('test-example-00-d-jws-jws_too_long.txt', 'jws');
const results = await testCard(['test-example-00-d-jws-jws_too_long.txt'], 'jws');
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ErrorCode.JWS_TOO_LONG);
});
*/
test("Cards: wrong file extension", async () => {
const results = await testCard('test-example-00-e-file.wrong-extension', 'healthcard', [LogLevels.WARNING, LogLevels.ERROR, LogLevels.FATAL]);
const results = await testCard(['test-example-00-e-file.wrong-extension'], 'healthcard', [LogLevels.WARNING, LogLevels.ERROR, LogLevels.FATAL]);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ErrorCode.INVALID_FILE_EXTENSION);
});
test("Cards: invalid signature", async () => {
const results = await testCard('test-example-00-d-jws-invalid-signature.txt', 'jws');
const results = await testCard(['test-example-00-d-jws-invalid-signature.txt'], 'jws');
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ErrorCode.JWS_VERIFICATION_ERROR);
});

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

@ -2,7 +2,18 @@
// Licensed under the MIT license.
import execa from 'execa';
import fs from 'fs';
import { ErrorCode } from '../src/error';
import { LogItem } from '../src/logger';
import { CliOptions } from '../src/shc-validator';
interface LogEntry {
time: string,
options: CliOptions,
log: LogItem[]
}
function runCommand(command: string) {
try {
@ -16,6 +27,7 @@ function runCommand(command: string) {
}
}
// Puts the standard output into an array of line,
// grouping multi-line json into single lines and prefixing with JSON:
function parseStdout(stdout: string): string[] {
@ -38,6 +50,35 @@ function parseStdout(stdout: string): string[] {
return out;
}
function testLogFile(logPath: string, deleteLog = true): LogEntry[] {
expect(fs.existsSync(logPath)).toBe(true);
const fileText = fs.readFileSync(logPath).toString('utf8');
expect(typeof fileText).toBe('string');
const logs = JSON.parse(fileText) as LogEntry[];
expect(Array.isArray(logs)).toBe(true);
if (deleteLog) fs.rmSync(logPath);
let d0 = new Date(0);
logs.forEach(entry => {
const de = new Date(entry.time);
expect(Number.isNaN(de)).toBe(false);
expect(de >= d0).toBe(true);
expect(Array.isArray(entry.log)).toBe(true);
expect(entry.options).toBeDefined();
d0 = de;
});
return logs;
}
function testCliCommand(command: string): number {
const commandResult = runCommand(command);
const out = parseStdout(commandResult.stdout);
@ -50,15 +91,28 @@ test("Cards: valid 00 health card", () => expect(testCliCommand('node . --path t
test("Cards: valid 00 jws", () => expect(testCliCommand('node . --path testdata/example-00-d-jws.txt --type jws --loglevel info')).toBe(0));
test("Cards: valid 00 jws-payload", () => expect(testCliCommand('node . --path testdata/example-00-c-jws-payload-minified.json --type jwspayload --loglevel info')).toBe(0));
test("Cards: valid 00 fhirBundle", () => expect(testCliCommand('node . --path testdata/example-00-a-fhirBundle.json --type fhirbundle --loglevel info')).toBe(0));
test("Cards: valid 00 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-00-f-qr-code-numeric.txt --type qr --loglevel info')).toBe(0));
test("Cards: valid 00 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-00-f-qr-code-numeric-value-0.txt --type qrnumeric --loglevel info')).toBe(0));
test("Cards: valid 00 qr-code.svg", () => expect(testCliCommand('node . --path testdata/example-00-g-qr-code-0.svg --type qr --loglevel info')).toBe(0));
test("Cards: valid 01 health card", () => expect(testCliCommand('node . --path testdata/example-01-e-file.smart-health-card --type healthcard --loglevel warning')).toBe(0));
test("Cards: valid 01 jws", () => expect(testCliCommand('node . --path testdata/example-01-d-jws.txt --type jws --loglevel warning')).toBe(0));
test("Cards: valid 01 jws-payload", () => expect(testCliCommand('node . --path testdata/example-01-c-jws-payload-minified.json --type jwspayload --loglevel warning')).toBe(0));
test("Cards: valid 01 fhirBundle", () => expect(testCliCommand('node . --path testdata/example-01-a-fhirBundle.json --type fhirbundle --loglevel warning')).toBe(0));
test("Cards: valid 01 r-code-numeric", () => expect(testCliCommand('node . --path testdata/example-01-f-qr-code-numeric.txt --type qrnumeric --loglevel info')).toBe(0));
test("Cards: valid 01 qr-code.svg", () => expect(testCliCommand('node . --path testdata/example-00-g-qr-code-0.svg --type qr --loglevel info')).toBe(0));
test("Cards: valid qr.png", () => expect(testCliCommand('node . --path testdata/qr.png --type qr --loglevel info')).toBe(0));
test("Cards: valid qr-90.pngd", () => expect(testCliCommand('node . --path testdata/qr-90.png --type qr --loglevel info')).toBe(0));
test("Cards: valid 01 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-01-f-qr-code-numeric-value-0.txt --type qrnumeric --loglevel info')).toBe(0));
test("Cards: valid 01 qr-code.svg", () => expect(testCliCommand('node . --path testdata/example-01-g-qr-code-0.svg --type qr --loglevel info')).toBe(0));
test("Cards: valid 02 health card", () => expect(testCliCommand('node . --path testdata/example-02-e-file.smart-health-card --type healthcard --loglevel warning')).toBe(0));
test("Cards: valid 02 jws", () => expect(testCliCommand('node . --path testdata/example-02-d-jws.txt --type jws --loglevel warning')).toBe(0));
test("Cards: valid 02 jws-payload", () => expect(testCliCommand('node . --path testdata/example-02-c-jws-payload-minified.json --type jwspayload --loglevel warning')).toBe(0));
test("Cards: valid 02 fhirBundle", () => expect(testCliCommand('node . --path testdata/example-02-a-fhirBundle.json --type fhirbundle --loglevel warning')).toBe(0));
test("Cards: valid 02 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-02-f-qr-code-numeric-value-0.txt --path testdata/example-02-f-qr-code-numeric-value-1.txt --path testdata/example-02-f-qr-code-numeric-value-2.txt --type qrnumeric --loglevel info')).toBe(0));
test("Cards: valid 02 qr-code.svg", () => expect(testCliCommand('node . --path testdata/example-02-g-qr-code-0.svg --path testdata/example-02-g-qr-code-1.svg --path testdata/example-02-g-qr-code-2.svg --type qr --loglevel info')).toBe(0));
test("Cards: valid 02 qr-code.png", () => expect(testCliCommand('node . --path testdata/example-02-g-qr-code-0.png --path testdata/example-02-g-qr-code-1.png --path testdata/example-02-g-qr-code-2.png --type qr --loglevel info')).toBe(0));
test("Cards: valid 02 qr-code.jpg", () => expect(testCliCommand('node . --path testdata/example-02-g-qr-code-0.jpg --path testdata/example-02-g-qr-code-1.jpg --path testdata/example-02-g-qr-code-2.jpg --type qr --loglevel info')).toBe(0));
test("Cards: valid 02 qr-code.bmp", () => expect(testCliCommand('node . --path testdata/example-02-g-qr-code-0.bmp --path testdata/example-02-g-qr-code-1.bmp --path testdata/example-02-g-qr-code-2.bmp --type qr --loglevel info')).toBe(0));
// Bad paths to data files
test("Cards: missing healthcard", () => expect(testCliCommand('node . --path bogus-path/bogus-file.json --type healthcard --loglevel info')).toBe(ErrorCode.DATA_FILE_NOT_FOUND));
@ -68,3 +122,45 @@ test("Cards: missing fhirbundle", () => expect(testCliCommand('node . --path bog
test("Cards: missing qrnumeric", () => expect(testCliCommand('node . --path bogus-path/bogus-file.json --type qrnumeric --loglevel info')).toBe(ErrorCode.DATA_FILE_NOT_FOUND));
test("Cards: missing qr", () => expect(testCliCommand('node . --path bogus-path/bogus-file.json --type qr --loglevel info')).toBe(ErrorCode.DATA_FILE_NOT_FOUND));
// Log file
test("Logs: valid 00-e health card single log file", () => {
const logFile = 'log-00-e-single.txt';
const expectedEntries = 1;
const expectedLogItems = 5;
runCommand('node . --path testdata/example-00-e-file.smart-health-card --type healthcard --loglevel info --logout ' + logFile);
const logs: LogEntry[] = testLogFile(logFile);
expect(logs).toHaveLength(expectedEntries);
expect(logs[0].log).toHaveLength(expectedLogItems);
});
test("Logs: valid 00-e health card append log file", () => {
const logFile = 'log-00-e-append.txt';
const expectedEntries = 2;
const expectedLogItems = [5, 5];
runCommand('node . --path testdata/example-00-e-file.smart-health-card --type healthcard --loglevel info --logout ' + logFile);
runCommand('node . --path testdata/example-00-e-file.smart-health-card --type healthcard --loglevel info --logout ' + logFile);
const logs: LogEntry[] = testLogFile(logFile);
expect(logs).toHaveLength(expectedEntries);
expect(logs[0].log).toHaveLength(expectedLogItems[0]);
expect(logs[1].log).toHaveLength(expectedLogItems[1]);
});
test("Logs: valid 00-e health card bad log path", () => {
const logFile = '../foo/log.txt';
const commandResult = runCommand('node . --path testdata/example-00-e-file.smart-health-card --type healthcard --loglevel info --logout ' + logFile);
expect(commandResult.exitCode).toBe(ErrorCode.LOG_PATH_NOT_FOUND);
});

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

@ -3,29 +3,37 @@
import fs from 'fs';
import path from 'path';
import {shcKeyValidator, KeyValidationErrors} from '../src/shcKeyValidator';
import { ErrorCode } from '../src/error';
import { shcKeyValidator } from '../src/shcKeyValidator';
const testdataDir = './testdata/';
async function testKey(fileName: string): Promise<KeyValidationErrors[]> {
async function testKey(fileName: string): Promise<ErrorCode[]> {
const filePath = path.join(testdataDir, fileName);
const keyValidator = new shcKeyValidator();
return await keyValidator.verifyHealthCardIssuerKey(fs.readFileSync(filePath));
const log = (await keyValidator.verifyHealthCardIssuerKey(fs.readFileSync(filePath))).log;
return log.map(item => item.code);
}
test("Keys: valid", async () => {
expect(await testKey('valid_key.json')).toHaveLength(0);});
expect(await testKey('valid_key.json')).toHaveLength(0);
});
test("Keys: wrong key identifier (kid)", async () => {
expect(await testKey('wrong_kid_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_KID);});
expect(await testKey('wrong_kid_key.json')).toContain(ErrorCode.INVALID_WRONG_KID);
});
test("Keys: wrong elliptic curve", async () => {
expect(await testKey('wrong_curve_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_ALG);});
expect(await testKey('wrong_curve_key.json')).toContain(ErrorCode.INVALID_WRONG_ALG);
});
test("Keys: wrong key use (use)", async () => {
expect(await testKey('wrong_use_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_USE);});
test("Keys: wrong key use (use)", async () => {
expect(await testKey('wrong_use_key.json')).toContain(ErrorCode.INVALID_WRONG_USE);
});
test("Keys: wrong algorithm (alg)", async () => {
expect(await testKey('wrong_alg_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_ALG);});
expect(await testKey('wrong_alg_key.json')).toContain(ErrorCode.INVALID_WRONG_ALG);
});
test("Keys: wrong key type (kty)", async () => {
expect(await testKey('wrong_kty_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_KTY);});
expect(await testKey('wrong_kty_key.json')).toContain(ErrorCode.INVALID_WRONG_KTY);
});

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

@ -1,14 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { AnySchemaObject } from 'ajv';
import fs from 'fs';
import path from 'path';
import { validateFromFile } from '../src/schema';
import Log from '../src/logger';
import { validateSchema } from '../src/schema';
const schemaDir = './schema';
const exampleDir = './testdata';
// Map our schema files to the examples. The tests below can lookup
// the appropriate schema with the 'lookup' function
const schemaMappings: { [key: string]: string[] } = {
@ -42,43 +45,39 @@ const lookup = function (example: string): string {
throw new Error('Example not found : ' + example);
};
const examples: string[] = [
'issuer.jwks.public.json',
'example-00-a-fhirBundle.json',
'example-00-b-jws-payload-expanded.json',
'example-00-c-jws-payload-minified.json',
'example-00-d-jws.txt',
'example-00-e-file.smart-health-card',
'example-01-a-fhirBundle.json',
'example-01-b-jws-payload-expanded.json',
'example-01-c-jws-payload-minified.json',
'example-01-d-jws.txt',
'example-01-e-file.smart-health-card'
];
const log = new Log('Schema Tests');
examples.forEach(exampleName => {
testSchema(exampleName);
});
function testSchema(exampleFile: string): void {
// TODO: the next logical step would be to read each example file out of the examples directory
// and determine the appropriate schema and then build a test.
// this would require code that can determine schema by analysing the example.
function testSchema(exampleFile: string): boolean {
const schemaName = lookup(exampleFile);
const schemaPath = path.resolve(schemaDir, schemaName + ".json");
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')) as AnySchemaObject;
const examplePath = path.resolve(exampleDir, exampleFile);
const fileData = fs.readFileSync(examplePath, 'utf-8');
const ext = path.extname(examplePath);
const dataObj = ext !== '.txt' ? JSON.parse(fileData) as FhirBundle | JWS | JWSPayload | HealthCard : fileData;
test("Schema: " + schemaName + " " + exampleFile, async () => {
const result = await validateFromFile(schemaPath, dataObj);
expect(result.length).toBe(0);
});
const result = validateSchema(schema, dataObj, log);
return;
}
expect(result).toBe(true);
return result;
}
test("Schema: valid 00-a-fhirBundle", () => { testSchema('example-00-a-fhirBundle.json'); });
test("Schema: valid 00-b-jws-payload-expanded", () => { testSchema('example-00-b-jws-payload-expanded.json'); });
test("Schema: valid 00-c-jws-payload-minified", () => { testSchema('example-00-c-jws-payload-minified.json'); });
test("Schema: valid 00-d-jws", () => { testSchema('example-00-d-jws.txt'); });
test("Schema: valid 00-e-file.smart-health-card", () => { testSchema('example-00-e-file.smart-health-card'); });
test("Schema: valid issuer.jwks.public", () => { testSchema('issuer.jwks.public.json'); });
test("Schema: valid 01-a-fhirBundle", () => { testSchema('example-01-a-fhirBundle.json'); });
test("Schema: valid 01-b-jws-payload-expanded", () => { testSchema('example-01-b-jws-payload-expanded.json'); });
test("Schema: valid 01-c-jws-payload-minified", () => { testSchema('example-01-c-jws-payload-minified.json'); });
test("Schema: valid 01-d-jws", () => { testSchema('example-01-d-jws.txt'); });
test("Schema: valid 01-e-file.smart-health-card", () => { testSchema('example-01-e-file.smart-health-card'); });