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

Enabled multi-part images; added debug/info logging; new tests; split out image.ts from qr.ts

This commit is contained in:
Larry Joy 2021-03-07 21:27:03 -08:00
Родитель caba20fe3a
Коммит 1c8cf8f733
12 изменённых файлов: 1589 добавлений и 127 удалений

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

@ -67,7 +67,7 @@ Issuer signing keys can be validated before being uploaded to their well-known U
The tool currently verifies proper encoding of the:
- QR code image
- Numeric QR data (header, content)
- Smart Health Card file (schema)
- SMART Health Card file (schema)
- JWS (schema, deflate compression, format, size limits, signature, issuer key retrieval)
- JWS payload (schema)
- FHIR bundle (schema)

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

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

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

@ -24,8 +24,10 @@
"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",
@ -35,6 +37,7 @@
},
"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",

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

@ -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);

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;
}

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

@ -50,6 +50,9 @@ export async function validate(jws: JWS): Promise<ValidationResult> {
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' });
@ -107,7 +110,7 @@ async function downloadKey(keyPath: string, log: Log): Promise<JWK.Key[] | undef
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')

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

@ -1,69 +1,23 @@
// 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 { ErrorCode } from './error';
import * as jws from './jws-compact';
import Log from './logger';
import { FileInfo } from './file';
export async function validate(qr: FileInfo[]): Promise<{ result: JWS | undefined, log: Log }> {
export async function validate(qr: string[]): Promise<{ result: JWS | undefined, log: Log }> {
const log = new Log('QR code (' + (qr[0].fileType as string) + ')');
const log = new Log(
qr.length > 1 ?
'QR numeric (' + qr.length.toString() + ')' :
'QR numeric');
const results: JWS | undefined = await decode(qr, log);
const jwsString: JWS | undefined = shcChunksToJws(qr, log); //await decode(qr, log);
results && await jws.validate(results);
jwsString && (log.child = (await jws.validate(jwsString)).log);
return { result: results, log: log };
}
// the svg data is turned into an image buffer. these values ensure that the resulting image is readable
// by the QR image decoder.
const svgImageWidth = 600;
const svgImageHeight = 600;
const svgImageQuality = 100;
// TODO: find minimal values that cause the resulting image to fail decoding.
// 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: svgImageHeight, quality: svgImageQuality },
(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(image: Buffer, log: Log): string | undefined {
const result: JWS | undefined = undefined;
const png = PNG.sync.read(image);
// TODO : create a test that causes failure here
const code = jsQR(new Uint8ClampedArray(png.data.buffer), png.width, png.height);
if (code == null) {
log.fatal("Could not decode QR image.", ErrorCode.QR_DECODE_ERROR);
return result;
}
return code.data;
return { result: jwsString, log: log };
}
@ -76,16 +30,8 @@ function shcChunksToJws(shc: string[], log : Log): JWS | undefined {
const chunkResult = shcToJws(shcChunk, log, chunkCount);
if(!chunkResult) continue; // move on to next chunk
// if (chunkResult.errors.length > 0) {
// // propagate errors, if any
// for (let err of chunkResult.errors) {
// result.error(err.message, err.code, err.logLevel); // TODO: overload this method to take a LogInfo
// }
// continue; // move on to next chunk
// }
// bad header is fatal (according to tests)
if(!chunkResult) return undefined; // move on to next chunk
const chunkIndex = chunkResult.chunkIndex;
@ -105,6 +51,10 @@ function shcChunksToJws(shc: string[], log : Log): JWS | undefined {
}
}
if(shc.length > 1) log.info('All shc parts decoded');
log.debug('JWS = ' + jwsChunks.join(''));
return jwsChunks.join('');
}
@ -155,39 +105,8 @@ function shcToJws(shc: string, log: Log, chunkCount = 1): {result: JWS, chunkInd
// merge the array into a single base64 string
.join('');
log.info( shc.slice(0, shc.lastIndexOf('/')) + '/... decoded');
log.debug( shc.slice(0, shc.lastIndexOf('/')) + '/... = ' + jws);
return { result: jws, chunkIndex : chunkIndex};
}
// takes file path to QR data and returns base64 data
async function decode(fileInfo: FileInfo[], log: Log): Promise<string | undefined> {
let svgBuffer;
//const result = new ResultWithErrors();
switch (fileInfo[0].fileType) { // TODO: how to deal with different inconsistent files
case 'svg':
svgBuffer = await svgToImageBuffer(fileInfo[0].buffer.toString(), log); // TODO: handle multiple files
return decodeQrBuffer(svgBuffer, log);
case 'shc':
return Promise.resolve(shcChunksToJws(fileInfo.map(fi => fi.buffer.toString()), log));
case 'png':
return decodeQrBuffer(fileInfo[0].buffer, log); // TODO: handle multiple files
case 'jpg':
log.fatal("jpg : Not implemented", ErrorCode.NOT_IMPLEMENTED);
return undefined;
case 'bmp':
log.fatal("bmp : Not implemented", ErrorCode.NOT_IMPLEMENTED);
return undefined;
default:
log.fatal("Unknown data in file", ErrorCode.UNKNOWN_FILE_DATA);
return undefined;
}
}

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

@ -73,7 +73,7 @@ async function processOptions() {
try {
fileData.push(await getFileData(path));
} catch (error) {
log.error((error as Error).message);
console.log((error as Error).message);
process.exitCode = ErrorCode.DATA_FILE_NOT_FOUND;
return;
}
@ -103,7 +103,7 @@ async function processOptions() {
if (logFilePathIsValid) {
output.log.toFile(options.logout, options, true);
} else {
console.log(log.toString(level));
console.log(output.log.toString(level));
}
}

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

@ -4,6 +4,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
type JWS = string;
type SHC = string;
interface HealthCard {
"verifiableCredential": JWS[]
}

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

@ -10,7 +10,7 @@ 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';
@ -51,11 +51,11 @@ export async function validateCard(fileData: FileInfo[], type: ValidationType):
switch (type.toLocaleLowerCase()) {
case "qr":
result = await qr.validate(fileData);
result = await image.validate(fileData);
break;
case "qrnumeric":
result = await qr.validate(fileData);
result = await qr.validate(fileData.map((fi)=>fi.buffer.toString('utf-8')));
break;
case "healthcard":

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

@ -11,14 +11,9 @@ 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 log = (await validateCard([await getFileData(filePath)], fileType)).log;
// return log.flatten().filter(i => { return levels.includes(i.level); });
// }
async function testCard(fileName: string[], fileType: ValidationType = 'healthcard', levels : LogLevels[] = [LogLevels.ERROR, LogLevels.FATAL]): Promise<{ title: string, message: string, code: ErrorCode }[]> {
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)));
@ -52,17 +47,23 @@ test("Cards: valid 00 QR numeric", async () => expect(await testCard(['example-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));
'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));
/* TODO: enable once QR chunk image parsing works
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));
*/
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']);
@ -74,7 +75,7 @@ test("Cards: invalid deflate", async () => {
test("Cards: no deflate", async () => {
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.JWS_TOO_LONG); // FIXME: fix for chunk
expect(results[0].code).toBe(ErrorCode.INFLATION_ERROR);
expect(results[1].code).toBe(ErrorCode.JSON_PARSE_ERROR);
});
@ -94,10 +95,9 @@ 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');
expect(results).toHaveLength(2);
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_NUMERIC_QR_HEADER);
expect(results[1].code).toBe(ErrorCode.JSON_PARSE_ERROR); // FIXME: this shouldn't be returned, we should stop after QR failure
});
/* TODO: FIX this test

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

@ -106,10 +106,13 @@ test("Cards: valid 02 jws", () => expect(testCliCommand('node . --path testdata/
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-0.svg --path testdata/example-02-g-qr-code-0.svg --type qr --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));
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));
// 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));