Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions job-results/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import { readPublicKey, readPrivateKey } from '../testing'
import { fingerprintKeyData, pemToArrayBuffer, generateKeyPair } from '../util'
import { ResultsWriter } from './writer'
import { ResultsReader } from './reader'
import { unwrapAesKey, wrapAesKey, decryptFile } from './crypto'

const toArrayBuffer = (str: string): ArrayBuffer => {
const buf = Buffer.from(str, 'utf-8')
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
}

describe('wrapAesKey / unwrapAesKey', () => {
it('round-trips a raw AES key through an RSA keypair', async () => {
const publicKey = pemToArrayBuffer(readPublicKey())
const privateKey = pemToArrayBuffer(readPrivateKey())

const aesKey = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt'])
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey)

const crypt = await wrapAesKey(rawAesKey, publicKey)
const { rawAesKey: unwrapped } = await unwrapAesKey(crypt, privateKey)

expect(new Uint8Array(unwrapped)).toEqual(new Uint8Array(rawAesKey))
})
})

describe('decryptFile', () => {
it('decrypts a standalone body + metadata and returns the raw AES key', async () => {
const publicKey = pemToArrayBuffer(readPublicKey())
const privateKey = pemToArrayBuffer(readPrivateKey())

// Encrypt a body with AES-CBC, then RSA-wrap the AES key.
const aesKey = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt'])
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey)
const iv = crypto.getRandomValues(new Uint8Array(16))
const plaintext = toArrayBuffer('secret,data')
const body = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aesKey, plaintext)
const crypt = await wrapAesKey(rawAesKey, publicKey)

const { contents, rawAesKey: recovered } = await decryptFile({
body,
iv: Buffer.from(iv).toString('base64'),
crypt,
privateKey,
})

expect(new TextDecoder().decode(contents)).toBe('secret,data')
expect(new Uint8Array(recovered)).toEqual(new Uint8Array(rawAesKey))
})
})

describe('ResultsReader override keys (researcher re-wrap)', () => {
it('lets a researcher decrypt with a re-wrapped key absent from the manifest', async () => {
// Data owner encrypts results for their own key only.
const doPublic = pemToArrayBuffer(readPublicKey())
const doFingerprint = await fingerprintKeyData(doPublic)
const doPrivate = pemToArrayBuffer(readPrivateKey())

const writer = new ResultsWriter([{ publicKey: doPublic, fingerprint: doFingerprint }])
await writer.addFile('result.csv', toArrayBuffer('secret,data'))
const zip = await writer.generate()

// Reviewer reads with their manifest key and recovers each file's raw AES key.
const reviewer = new ResultsReader(zip, doPrivate, doFingerprint)
const [entry] = await reviewer.extractFilesWithKeys()
expect(new TextDecoder().decode(entry.contents)).toBe('secret,data')
expect(entry.rawAesKey.byteLength).toBe(32)

// Researcher: brand-new keypair, NOT an original manifest recipient.
const researcher = await generateKeyPair()
const researcherFp = await fingerprintKeyData(researcher.exportedPublicKey)
const crypt = await wrapAesKey(entry.rawAesKey, researcher.exportedPublicKey)

// Same ciphertext + IV; only the wrapped key differs, supplied as an override.
const reader = new ResultsReader(zip, researcher.exportedPrivateKey, researcherFp, {
'result.csv': crypt,
})
const [out] = await reader.extractFiles()
expect(new TextDecoder().decode(out.contents)).toBe('secret,data')
})

it('throws cleanly when an override is wrapped for a different keypair', async () => {
const doPublic = pemToArrayBuffer(readPublicKey())
const doFingerprint = await fingerprintKeyData(doPublic)

const writer = new ResultsWriter([{ publicKey: doPublic, fingerprint: doFingerprint }])
await writer.addFile('result.csv', toArrayBuffer('secret,data'))
const zip = await writer.generate()

const reviewer = new ResultsReader(zip, pemToArrayBuffer(readPrivateKey()), doFingerprint)
const [entry] = await reviewer.extractFilesWithKeys()

// Wrap the raw key for researcher A, but hand the reader researcher B's private key.
const researcherA = await generateKeyPair()
const researcherB = await generateKeyPair()
const crypt = await wrapAesKey(entry.rawAesKey, researcherA.exportedPublicKey)

const reader = new ResultsReader(zip, researcherB.exportedPrivateKey, researcherB.fingerprint, {
'result.csv': crypt,
})
// RSA-OAEP unwrap fails on the mismatched key — deterministic throw, never garbage plaintext.
await expect(reader.extractFiles()).rejects.toThrow()
})

it('throws when a manifest file has neither a fingerprint match nor an override', async () => {
const doPublic = pemToArrayBuffer(readPublicKey())
const doFingerprint = await fingerprintKeyData(doPublic)

const writer = new ResultsWriter([{ publicKey: doPublic, fingerprint: doFingerprint }])
await writer.addFile('a.csv', toArrayBuffer('alpha'))
await writer.addFile('b.csv', toArrayBuffer('beta'))
const zip = await writer.generate()

const researcher = await generateKeyPair()
// Override supplied for a.csv only; b.csv has no key for this researcher.
const decrypted = await new ResultsReader(
zip,
pemToArrayBuffer(readPrivateKey()),
doFingerprint,
).extractFilesWithKeys()
const aEntry = decrypted.find((e) => e.path === 'a.csv')!
const crypt = await wrapAesKey(aEntry.rawAesKey, researcher.exportedPublicKey)

const reader = new ResultsReader(zip, researcher.exportedPrivateKey, researcher.fingerprint, {
'a.csv': crypt,
})
await expect(reader.extractFiles()).rejects.toThrow(/key signature/)
})

it('still reads with the manifest fingerprint when no override is supplied', async () => {
const publicKey = pemToArrayBuffer(readPublicKey())
const fingerprint = await fingerprintKeyData(publicKey)
const privateKey = pemToArrayBuffer(readPrivateKey())

const writer = new ResultsWriter([{ publicKey, fingerprint }])
await writer.addFile('a.txt', toArrayBuffer('alpha'))
const zip = await writer.generate()

const reader = new ResultsReader(zip, privateKey, fingerprint)
const [out] = await reader.extractFiles()
expect(new TextDecoder().decode(out.contents)).toBe('alpha')
})
})
65 changes: 65 additions & 0 deletions job-results/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { privateKeyFromBuffer } from '../util'
import logger from '../lib/logger'

/**
* Unwrap a file's RSA-encrypted AES key. Returns the AES `CryptoKey` plus its raw
* bytes — re-wrap needs the bytes to grant other recipients access (see {@link wrapAesKey}).
*/
export async function unwrapAesKey(
crypt: string,
privateKey: ArrayBuffer,
): Promise<{ aesKey: CryptoKey; rawAesKey: ArrayBuffer }> {
const encryptedKey = Buffer.from(crypt, 'base64')

const rawAesKey = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
await privateKeyFromBuffer(privateKey),
encryptedKey,
)

const aesKey = await crypto.subtle.importKey('raw', rawAesKey, { name: 'AES-CBC' }, false, ['decrypt'])

return { aesKey, rawAesKey }
}

/** Re-wrap a raw AES key for a recipient's RSA public key. Body and IV are untouched. */
export async function wrapAesKey(rawAesKey: ArrayBuffer, publicKey: ArrayBuffer): Promise<string> {
const key = await crypto.subtle.importKey('spki', publicKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [
'encrypt',
])

const encryptedKey = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, rawAesKey)

return Buffer.from(encryptedKey).toString('base64')
}

// AES-CBC kept for backward-compat with existing production results (see writer.addFile).
// CBC is unauthenticated: a wrong-but-valid key usually trips PKCS#7 padding and throws, but not
// guaranteed (~1/256 yields garbage, no error), and tampered ciphertext is not detected.
export async function decryptFileBody(body: ArrayBuffer, iv: BufferSource, aesKey: CryptoKey): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aesKey, body)
}

/**
* Decrypt a standalone file body + metadata (vs {@link ResultsReader}'s zip iteration).
* Returns the raw AES key too, so the caller can re-wrap without decrypting again.
*/
export async function decryptFile({
body,
iv,
crypt,
privateKey,
}: {
body: ArrayBuffer
iv: string
crypt: string
privateKey: ArrayBuffer
}): Promise<{ contents: ArrayBuffer; rawAesKey: ArrayBuffer }> {
logger.info(`Decrypting file`)

const { aesKey, rawAesKey } = await unwrapAesKey(crypt, privateKey)
const contents = await decryptFileBody(body, Buffer.from(iv, 'base64'), aesKey)

logger.info(`Finished decrypting file`)
return { contents, rawAesKey }
}
1 change: 1 addition & 0 deletions job-results/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './reader'
export * from './writer'
export * from './crypto'
Comment thread
chrisbendel marked this conversation as resolved.
export type { FileInfo } from './types'
108 changes: 53 additions & 55 deletions job-results/reader.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { decryptFileBody, unwrapAesKey } from './crypto'
import logger from '../lib/logger'

export type DecryptedEntry = FileEntry & { rawAesKey: ArrayBuffer }

export class ResultsReader {
manifest: ResultsManifest = {
files: {},
Expand All @@ -12,25 +14,44 @@ export class ResultsReader {
private zipReader: ZipReader<Blob>
private fingerprint: string
private privateKey: ArrayBuffer
private readonly additionalKeys: Record<string, string>
private decoded = false

constructor(zipBlob: Blob, privateKey: ArrayBuffer, fingerprint: string) {
/**
* @param additionalKeys inner file path -> AES key wrapped for *this* `fingerprint`. Lets a
* recipient absent from the original manifest (e.g. a researcher granted access later)
* decrypt the same ciphertext: on decode these are spliced into the manifest under
* `fingerprint`, so reads stay a single fingerprint lookup with no bypass.
*/
constructor(
zipBlob: Blob,
privateKey: ArrayBuffer,
fingerprint: string,
additionalKeys: Record<string, string> = {},
) {
this.zipReader = new ZipReader(new BlobReader(zipBlob))
this.fingerprint = fingerprint
this.privateKey = privateKey
this.additionalKeys = additionalKeys
}

async extractFiles(): Promise<FileEntry[]> {
const entries = await this.extractFilesWithKeys()
return entries.map(({ path, contents }) => ({ path, contents }))
}

async extractFiles() {
/** Like {@link extractFiles}, but also returns each file's raw AES key for re-wrapping. */
async extractFilesWithKeys(): Promise<DecryptedEntry[]> {
logger.info(`Extracting files`)

await this.decode()

const generator = this.entries()
const entries: FileEntry[] = []
for await (const entry of generator) {
const entries: DecryptedEntry[] = []
for await (const entry of this.entries()) {
entries.push({
path: entry.path,
contents: entry.contents,
rawAesKey: entry.rawAesKey,
})
}
logger.info(`Finished extracting files`)
Expand All @@ -43,17 +64,26 @@ export class ResultsReader {
logger.info(`Decoding entries`)

const entries = await this.zipReader.getEntries()
let manifestFound = false
for (const entry of entries) {
if (!entry.directory && entry.filename == 'manifest.json') {
if (!entry.directory && entry.filename === 'manifest.json') {
const manifestText = await entry.getData(new TextWriter())
this.manifest = JSON.parse(manifestText) as ResultsManifest
manifestFound = true
}
}

if (!this.manifest) {
if (!manifestFound) {
throw new Error('Manifest not found in zip archive.')
}

// Splice any caller-supplied keys into the manifest under our fingerprint, so a recipient
// not baked into the zip (e.g. a researcher) reads through the same path as everyone else.
for (const [path, crypt] of Object.entries(this.additionalKeys)) {
const file = this.manifest.files[path]
if (file) file.keys[this.fingerprint] = { crypt }
}

this.decoded = true
logger.info(`Finished decoding entries`)
}
Expand All @@ -77,72 +107,40 @@ export class ResultsReader {
throw new Error(`File not found in zip archive: ${filePath}`)
}

const contents = await this.readFile(file, entry)
const { contents } = await this.readFile(file, entry)
return { path: filePath, contents }
}

async *entries(): AsyncGenerator<ResultsFile & { contents: ArrayBuffer }, void, void> {
async *entries(): AsyncGenerator<ResultsFile & DecryptedEntry, void, void> {
const entries = await this.zipReader.getEntries()
for (const entry of entries) {
const file = this.manifest.files[entry.filename]
if (!entry.directory && file) {
const contents = await this.readFile(file, entry)
yield { ...file, contents }
const { contents, rawAesKey } = await this.readFile(file, entry)
yield { ...file, contents, rawAesKey }
}
}
}

private async readFile(fileEntry: ResultsFile, entry: ZipFileEntry): Promise<ArrayBuffer> {
private async readFile(
fileEntry: ResultsFile,
entry: ZipFileEntry,
): Promise<{ contents: ArrayBuffer; rawAesKey: ArrayBuffer }> {
logger.info(`Reading file ${entry.filename}`)

const encryptedData = await entry.getData(new BlobWriter())

const encryptionKey = fileEntry.keys[this.fingerprint]
if (!encryptionKey) throw new Error(`file was not encrypted with key signature ${this.fingerprint}`)
const crypt = fileEntry.keys[this.fingerprint]?.crypt
if (!crypt) throw new Error(`file was not encrypted with key signature ${this.fingerprint}`)

const aesKey = await this.decryptKeyWithPrivateKey(encryptionKey.crypt)

const iv = Buffer.from(fileEntry.iv, 'base64')
const { aesKey, rawAesKey } = await unwrapAesKey(crypt, this.privateKey)

logger.info(`Finished reading file ${entry.filename}`)
return this.decryptData(encryptedData, aesKey, iv)
}

private async decryptKeyWithPrivateKey(encryptedKeyBase64: string): Promise<CryptoKey> {
logger.info(`Decrypting key`)

const encryptedKey = Buffer.from(encryptedKeyBase64, 'base64')

const rawKey = await crypto.subtle.decrypt(
{
name: 'RSA-OAEP',
},
await privateKeyFromBuffer(this.privateKey),
encryptedKey,
)

const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-CBC' }, false, ['decrypt'])

logger.info(`Finished decrypting key`)

return key
}

private async decryptData(encryptedData: Blob, aesKey: CryptoKey, iv: BufferSource): Promise<ArrayBuffer> {
logger.info(`Decrypting data`)

const arrayBuffer = await encryptedData.arrayBuffer()
const results = crypto.subtle.decrypt(
{
name: 'AES-CBC',
iv,
},
const contents = await decryptFileBody(
await encryptedData.arrayBuffer(),
Buffer.from(fileEntry.iv, 'base64'),
aesKey,
arrayBuffer,
)

logger.info(`Finished decrypting data`)

return results
return { contents, rawAesKey }
}
}
Loading
Loading