Skip to content
This repository has been archived by the owner on Jul 17, 2021. It is now read-only.

Commit

Permalink
feat: use WebCrypto API instead of node crypto
Browse files Browse the repository at this point in the history
Use WebCrypto API to ensure availability and operability in browser environments

Closes #4
  • Loading branch information
BerniWittmann committed Apr 8, 2021
1 parent 5b19af9 commit f6573be
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 55 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
},
"version": "1.1.5",
"dependencies": {
"axios": "^0.21.1"
"@peculiar/webcrypto": "^1.1.6",
"axios": "^0.21.1",
"md5": "^2.3.0"
},
"publishConfig": {
"access": "public"
Expand Down
91 changes: 54 additions & 37 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import axios from 'axios';
import * as crypto from 'crypto';
import { Crypto } from '@peculiar/webcrypto';
import { pack, ab2str, unpack, decode } from './lib/util';
import * as md5 from 'md5';

const crypto = new Crypto();

import Iris from '.';
import IrisDataRequestDTO from './types/dto/IrisDataRequestDTO';
import IrisContactsEvents from './types/IrisContactsEvents';

if (typeof TextEncoder === 'undefined') {
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
}

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('index', () => {
let privateKey: string;
let privateKey: CryptoKey;
let publicKey: string;
let dataRequest: IrisDataRequestDTO;
const submission: IrisContactsEvents = {
Expand All @@ -28,38 +37,34 @@ describe('index', () => {
},
},
};
beforeAll((done) => {
beforeAll(async (done) => {
mockedAxios.create.mockImplementation(() => {
return mockedAxios;
});
crypto.generateKeyPair(
'rsa',
const keys = await crypto.subtle.generateKey(
{
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: 'top secret',
},
},
(err, pub, priv) => {
publicKey = pub;
privateKey = priv;
dataRequest = {
healthDepartment: 'Test Health Department',
key: Buffer.from(publicKey, 'utf-8').toString('base64'),
keyReferenz: 'random-string-keyref',
start: '2011-10-05T14:48:00.000Z',
end: '2021-10-05T14:48:00.000Z',
};
done(err);
name: 'RSA-OAEP',
hash: 'SHA-256', // SHA-1, SHA-256, SHA-384, or SHA-512
publicExponent: new Uint8Array([1, 0, 1]), // 0x03 or 0x010001
modulusLength: 2048, // 1024, 2048, or 4096
},
true,
['encrypt', 'decrypt'],
);
const exported = await crypto.subtle.exportKey('spki', keys.publicKey);
const exportedAsString = ab2str(exported);
const exportedAsBase64 = window.btoa(exportedAsString);
publicKey = `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`;

privateKey = keys.privateKey;
dataRequest = {
healthDepartment: 'Test Health Department',
key: window.btoa(publicKey),
keyReferenz: 'random-string-keyref',
start: '2011-10-05T14:48:00.000Z',
end: '2021-10-05T14:48:00.000Z',
};
done();
});
it('provides the Iris class', () => {
expect(new Iris({})).toBeDefined();
Expand Down Expand Up @@ -92,16 +97,28 @@ describe('index', () => {
});
const submittedData = mockedAxios.post.mock.calls[0][1];

expect(submittedData.checkCode[0]).toEqual(crypto.createHash('md5').update('hansmller').digest('base64'));
expect(submittedData.checkCode[1]).toEqual(crypto.createHash('md5').update('19630105').digest('base64'));
expect(submittedData.checkCode[0]).toEqual(md5('hansmller'));
expect(submittedData.checkCode[1]).toEqual(md5('19630105'));

const symmetricKey = crypto.privateDecrypt(
{ key: privateKey, passphrase: 'top secret' },
Buffer.from(submittedData.secret, 'base64'),
const symmetricKeyData = await crypto.subtle.decrypt(
{
name: 'RSA-OAEP',
},
privateKey, // RSA private key
Buffer.from(submittedData.secret, 'base64'), // BufferSource
);
const symmetricKey = await crypto.subtle.importKey('raw', symmetricKeyData, 'AES-GCM', true, ['decrypt']);
const decryptedData = pack(
await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: Buffer.from(submittedData.nonce, 'base64'),
},
symmetricKey, // AES key
Buffer.from(submittedData.encryptedData, 'base64'), // BufferSource
),
);
const decipher = crypto.createDecipheriv('AES-256-CBC', symmetricKey, Buffer.from(submittedData.nonce, 'base64'));
let receivedPlaintext = decipher.update(submittedData.encryptedData, 'base64', 'utf8');
receivedPlaintext += decipher.final();
expect(JSON.parse(receivedPlaintext)).toEqual(submission);
const result = decode(new Uint8Array(unpack(decryptedData)));
expect(JSON.parse(result)).toEqual(submission);
});
});
2 changes: 1 addition & 1 deletion src/lib/Iris.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default class Iris {
throw new Error("Code could not be found in key map. Did you perform 'getDataRequest' before?");
}
const keys = this.codeKeyMap.get(code);
const { dataToTransport, keyToTransport, nonce } = encryptData(keys.key, data);
const { dataToTransport, keyToTransport, nonce } = await encryptData(keys.key, data);
const response = await this.axiosInstance.post(`/data-submissions/${code}/contacts_events`, {
checkCode: [getNameCheckHash(user.firstName, user.lastName), getBirthDateCheckHash(user.birthDate)].filter(
(c) => !!c,
Expand Down
70 changes: 59 additions & 11 deletions src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,66 @@
import * as crypto from 'crypto';
import { Crypto } from '@peculiar/webcrypto';
import { pack, encode, str2ab } from './util';

export function encryptData(
const crypto = new Crypto();

async function importRsaKey(pemBase64Encoded: string): Promise<CryptoKey> {
const pem = window.atob(pemBase64Encoded);
// fetch the part of the PEM string between header and footer
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length);
// base64 decode the string to get the binary data
const binaryDerString = window.atob(pemContents);
// convert from a binary string to an ArrayBuffer
const binaryDer = str2ab(binaryDerString);

return crypto.subtle.importKey(
'spki',
binaryDer,
{
name: 'RSA-OAEP',
hash: 'SHA-256',
},
true,
['encrypt'],
);
}

export async function encryptData(
keyOfHealthDepartment: string,
data,
): { dataToTransport: string; keyToTransport: string; nonce: string } {
const nonce = crypto.randomBytes(16);
const key = crypto.randomBytes(32);
const cipher = crypto.createCipheriv('AES-256-CBC', key, nonce);
): Promise<{ dataToTransport: string; keyToTransport: string; nonce: string }> {
const nonce = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt'],
);
const publicKey = await importRsaKey(keyOfHealthDepartment);

const dataString = JSON.stringify(data);
const encryptedData = Buffer.concat([cipher.update(dataString, 'utf8'), cipher.final()]);
const encryptedKey = crypto.publicEncrypt({ key: Buffer.from(keyOfHealthDepartment, 'base64') }, key);
const encryptedData = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: nonce,
},
key,
encode(dataString),
);

const encryptedKey = await crypto.subtle.encrypt(
{
name: 'RSA-OAEP',
},
publicKey,
await crypto.subtle.exportKey('raw', key),
);
return {
dataToTransport: encryptedData.toString('base64'),
keyToTransport: encryptedKey.toString('base64'),
nonce: nonce.toString('base64'),
dataToTransport: pack(encryptedData),
keyToTransport: pack(encryptedKey),
nonce: pack(nonce),
};
}
41 changes: 37 additions & 4 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
import * as crypto from 'crypto';
import * as md5 from 'md5';

export function getNameCheckHash(firstName: string, lastName: string): string {
const str = `${firstName.trim()}${lastName.trim()}`.toLowerCase().replace(/\W/g, '');
return crypto.createHash('md5').update(str).digest('base64');
return md5(str);
}
export function getBirthDateCheckHash(birthDate?: string): string | undefined {
if (!birthDate) {
return undefined;
}
const date = new Date(birthDate);
const str = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}`
return crypto.createHash('md5').update(str).digest('base64');
const str = `${date.getFullYear()}${(date.getMonth() + 1)
.toString()
.padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}`;
return md5(str);
}

export function encode(data: string): Uint8Array {
const encoder = new TextEncoder();
return encoder.encode(data);
}

export function decode(data: Uint8Array): string {
const decoder = new TextDecoder('utf8');
return decoder.decode(data);
}

export function str2ab(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}

export function ab2str(buf: ArrayBuffer): string {
return String.fromCharCode.apply(null, new Uint8Array(buf));
}

export function pack(buffer: ArrayBuffer): string {
return window.btoa(ab2str(buffer));
}

export function unpack(packed: string): ArrayBuffer {
return str2ab(window.atob(packed));
}
Loading

0 comments on commit f6573be

Please sign in to comment.