diff --git a/job-results/reader-writer.test.ts b/job-results/reader-writer.test.ts index 5ffac9d..3ffc147 100644 --- a/job-results/reader-writer.test.ts +++ b/job-results/reader-writer.test.ts @@ -44,6 +44,54 @@ describe('Encryption Library Tests', async () => { expect(new TextDecoder().decode(entries[0].contents)).toEqual('hello world!') }) + + it('throws if CryptoKey was imported without unwrapKey usage', async () => { + const publicKey = pemToArrayBuffer(readPublicKey()) + const fingerprint = await fingerprintKeyData(publicKey) + const writer = new ResultsWriter([{ publicKey, fingerprint }]) + await writer.addFile('test.data', toArrayBuffer('hello world!')) + const zip = await writer.generate() + + const wrongKey = await crypto.subtle.importKey( + 'pkcs8', + pemToArrayBuffer(readPrivateKey()), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'], + ) + + expect(() => new ResultsReader(zip, wrongKey, fingerprint)).toThrow(/unwrapKey/) + }) + + it('can read using a non-extractable CryptoKey', async () => { + const publicKey = pemToArrayBuffer(readPublicKey()) + const fingerprint = await fingerprintKeyData(publicKey) + const writer = new ResultsWriter([{ publicKey, fingerprint }]) + + const content = Buffer.from('hello world!', 'utf-8') + await writer.addFile( + 'test.data', + content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength), + ) + + const zip = await writer.generate() + + const privateKeyBuf = pemToArrayBuffer(readPrivateKey()) + const privateCryptoKey = await crypto.subtle.importKey( + 'pkcs8', + privateKeyBuf, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['unwrapKey'], + ) + expect(privateCryptoKey.extractable).toBe(false) + + const reader = new ResultsReader(zip, privateCryptoKey, fingerprint) + const entries = await reader.extractFiles() + + expect(entries).toHaveLength(1) + expect(new TextDecoder().decode(entries[0].contents)).toEqual('hello world!') + }) }) describe('listFiles and extractFile', async () => { diff --git a/job-results/reader.ts b/job-results/reader.ts index 7e721f4..6f27669 100644 --- a/job-results/reader.ts +++ b/job-results/reader.ts @@ -1,7 +1,7 @@ import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js' import type { FileEntry as ZipFileEntry } from '@zip.js/zip.js' import type { ResultsFile, ResultsManifest, FileEntry, FileInfo } from './types' -import { privateKeyFromBuffer } from '../util' +import { privateKeyFromBufferForUnwrap } from '../util' import logger from '../lib/logger' export class ResultsReader { @@ -11,15 +11,46 @@ export class ResultsReader { private zipReader: ZipReader private fingerprint: string - private privateKey: ArrayBuffer + private privateKey: ArrayBuffer | CryptoKey + private importedKey?: CryptoKey private decoded = false - constructor(zipBlob: Blob, privateKey: ArrayBuffer, fingerprint: string) { + /** + * Construct a reader from a pre-imported `CryptoKey`. + * + * The `CryptoKey` must have been imported with `keyUsages: ['unwrapKey']` + * (RSA-OAEP, SHA-256). It may be (and is recommended to be) non-extractable, + * so raw key bytes never reside in JS memory. + * + * @param fingerprint SHA-256 hex of the SPKI public key. Required — it + * cannot be derived from a non-extractable key. + */ + constructor(zipBlob: Blob, privateKey: CryptoKey, fingerprint: string) + /** + * @deprecated Pass a `CryptoKey` instead so raw key bytes do not have to + * live in JS memory. Import as: + * `crypto.subtle.importKey('pkcs8', bytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['unwrapKey'])`. + */ + constructor(zipBlob: Blob, privateKey: ArrayBuffer, fingerprint: string) + constructor(zipBlob: Blob, privateKey: ArrayBuffer | CryptoKey, fingerprint: string) { + if (privateKey instanceof CryptoKey && !privateKey.usages.includes('unwrapKey')) { + throw new Error( + `ResultsReader: CryptoKey must be imported with usages: ['unwrapKey'] (got: ${JSON.stringify(privateKey.usages)})`, + ) + } this.zipReader = new ZipReader(new BlobReader(zipBlob)) this.fingerprint = fingerprint this.privateKey = privateKey } + private async getPrivateKey(): Promise { + if (this.privateKey instanceof CryptoKey) return this.privateKey + if (!this.importedKey) { + this.importedKey = await privateKeyFromBufferForUnwrap(this.privateKey) + } + return this.importedKey + } + async extractFiles() { logger.info(`Extracting files`) @@ -113,16 +144,16 @@ export class ResultsReader { const encryptedKey = Buffer.from(encryptedKeyBase64, 'base64') - const rawKey = await crypto.subtle.decrypt( - { - name: 'RSA-OAEP', - }, - await privateKeyFromBuffer(this.privateKey), + const key = await crypto.subtle.unwrapKey( + 'raw', encryptedKey, + await this.getPrivateKey(), + { name: 'RSA-OAEP' }, + { name: 'AES-CBC', length: 256 }, + false, + ['decrypt'], ) - const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-CBC' }, false, ['decrypt']) - logger.info(`Finished decrypting key`) return key diff --git a/package-lock.json b/package-lock.json index 468aac8..09e419f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "si-encryption", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "si-encryption", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.23", diff --git a/package.json b/package.json index ba80d70..7d2981e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "si-encryption", - "version": "0.0.1", + "version": "0.1.0", "description": "Enryption in use at SafeInsights", "main": "index.js", "type": "module", diff --git a/util/keypair.ts b/util/keypair.ts index 6f238ae..8b576c3 100644 --- a/util/keypair.ts +++ b/util/keypair.ts @@ -129,6 +129,12 @@ export async function privateKeyFromBuffer(privateKeyBuffer: ArrayBuffer): Promi return key } +export async function privateKeyFromBufferForUnwrap(privateKeyBuffer: ArrayBuffer): Promise { + return crypto.subtle.importKey('pkcs8', privateKeyBuffer, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [ + 'unwrapKey', + ]) +} + export async function fingerprintPublicKeyFromPrivateKey(privateKey: CryptoKey) { logger.info(`Creating fingerprint from private key`) // Export the private key as a JWK (JSON Web Key)