diff --git a/packages/filecoin-encryption-envelope/CLAUDE.md b/packages/filecoin-encryption-envelope/CLAUDE.md index b9a68275..c675fbda 100644 --- a/packages/filecoin-encryption-envelope/CLAUDE.md +++ b/packages/filecoin-encryption-envelope/CLAUDE.md @@ -16,10 +16,10 @@ parsing, tags 16/96, protected and unprotected headers, detached-ciphertext fram structural recipient validation), scheme-1 AES-256-GCM encryption and decryption in `aes-gcm.ts` (direct CEK, both tag 16 and tag 96), A256KW recipient wrapping on encryption, the built-in A256KW unwrapper factory (`createA256KWUnwrapper`), and recipient-based decryption through an unwrapper -(`aesGcm.decryptWith`), and chunked streaming encryption in `aes-gcm-stream.ts` (direct CEK or A256KW -recipients, optional `plaintext_length`). Not yet implemented: chunked decryption, range reads, and -envelope inspection beyond decode. ECDH-ES+A256KW remains deferred; the code enforces its settled header -placement but does not derive or unwrap its KEK. +(`aesGcm.decryptWith`), and chunked streaming encryption and decryption in `aes-gcm-stream.ts` (direct CEK +or A256KW recipients, optional `plaintext_length`). Not yet implemented: range reads and envelope +inspection beyond decode. ECDH-ES+A256KW remains deferred; the code enforces its settled header placement +but does not derive or unwrap its KEK. ## Scope discipline @@ -38,14 +38,14 @@ explicit exports when helpers must stay internal, as `cose/index.ts` does for `h `src/index.ts` is the allowlist of this package's public interface: a module is public only if `src/index.ts` exports it. Shared implementation code lives under `src/internal/` and is never exported. Two exceptions to "namespaces only": shared type-only exports (currently `AppMetadata`, `CborValue`), -since a type carries no runtime shape, and the chunked streaming functions (`encrypt` and its options -type, later `decrypt`/`decryptWith`), which the tech spec makes the default path at the root while -whole-object AES-GCM stays opt-in under `aesGcm`. Public constants are +since a type carries no runtime shape, and the chunked streaming functions (`encrypt`, `decrypt`, +`decryptWith`, and `encrypt`'s options type), which the tech spec makes the default path at the root +while whole-object AES-GCM stays opt-in under `aesGcm`. Public constants are re-exported through the curated `src/public-constants.ts`, never `src/constants.ts` directly: ```ts export * as aesGcm from './aes-gcm.ts' -export { type ChunkedEncryptOptions, encrypt } from './aes-gcm-stream.ts' +export { type ChunkedEncryptOptions, decrypt, decryptWith, encrypt } from './aes-gcm-stream.ts' export type { AppMetadata, CborValue } from './cose/headers.ts' export * as cose from './cose/index.ts' export * as errors from './errors.ts' diff --git a/packages/filecoin-encryption-envelope/src/aes-gcm-stream.ts b/packages/filecoin-encryption-envelope/src/aes-gcm-stream.ts index ea7d88b0..87c404d1 100644 --- a/packages/filecoin-encryption-envelope/src/aes-gcm-stream.ts +++ b/packages/filecoin-encryption-envelope/src/aes-gcm-stream.ts @@ -1,25 +1,35 @@ /** - * Chunked AES-256-GCM STREAM encryption (FEE scheme 2) using a caller-supplied - * CEK. Plaintext is written to `writable`; `readable` outputs the encoded FEE - * object: the envelope followed by detached, per-chunk-tagged ciphertext. - * - * `writable` uses the framer from `internal/chunk-framer.ts`, so its block - * ownership rules apply to whatever is piped in. Each `readable` pull requests and - * encrypts one chunk, keeping memory bounded regardless of source size. + * Chunked AES-256-GCM STREAM encryption and decryption (FEE scheme 2). + * Encryption emits an envelope followed by ciphertext chunks. Decryption + * consumes that format and releases plaintext after each chunk authenticates. */ -import { assertValidChunkSize, assertWithinObjectLimit, ciphertextLengthForPlaintext } from './chunk-layout.ts' -import { ALG_CHUNKED_AES_256_GCM_STREAM, BASE_NONCE_SIZE, DEFAULT_CHUNK_SIZE, TAG_SIZE } from './constants.ts' +import { + assertValidChunkSize, + assertWithinObjectLimit, + chunkLayout, + ciphertextLengthForPlaintext, +} from './chunk-layout.ts' +import { + ALG_CHUNKED_AES_256_GCM_STREAM, + BASE_NONCE_SIZE, + DEFAULT_CHUNK_SIZE, + MAX_ENCODED_OBJECT_SIZE, + TAG_SIZE, +} from './constants.ts' +import type { DecodedEnvelope } from './cose/decode.ts' import { encStructure } from './cose/enc-structure.ts' import { assemblePreparedEnvelope, type PreparedEnvelope, type RecipientInput } from './cose/encode.ts' +import { createEnvelopeScanner } from './cose/envelope-scanner.ts' import type { AppMetadata } from './cose/headers.ts' import { describeCborType, encodeProtectedHeader } from './cose/headers.ts' -import { MalformedEnvelopeError } from './errors.ts' -import { createChunkFramer } from './internal/chunk-framer.ts' -import { assertAes256Key } from './internal/keys.ts' -import { aesGcmEncrypt, importAesGcmKey, randomBytes } from './internal/web-crypto.ts' +import { InvalidCiphertextLengthError, MalformedEnvelopeError, UnsupportedSchemeError } from './errors.ts' +import { type ChunkFramer, createChunkFramer } from './internal/chunk-framer.ts' +import { assertAes256Key, assertArrayBufferBacked } from './internal/keys.ts' +import { aesGcmDecrypt, aesGcmEncrypt, importAesGcmKey, randomBytes } from './internal/web-crypto.ts' import { deriveChunkNonce } from './nonce.ts' import { createRecipientRecords, prepareRecipientInputs } from './recipients/prepare.ts' -import type { Recipient } from './recipients/types.ts' +import { recoverCek } from './recipients/recover.ts' +import type { Recipient, Unwrapper } from './recipients/types.ts' /** Options for one chunked AES-256-GCM STREAM encryption using a direct CEK. */ export interface ChunkedEncryptOptions { @@ -50,24 +60,25 @@ export interface ChunkedEncryptOptions { } /** - * Encrypt a plaintext stream using scheme 2 (chunked AES-256-GCM STREAM) and - * a direct CEK. + * Creates a streaming encryptor using scheme 2 + * (chunked AES-256-GCM STREAM) with a direct CEK. + * + * The writable side accepts plaintext blocks. The readable side emits the + * FEE envelope first, followed by one encrypted chunk at a time. * - * Returns a `{ writable, readable }` pair for - * `source.pipeThrough(encrypt(options))`. Plaintext goes in; the FEE envelope - * followed by one encrypted chunk at a time comes out. + * Validation that does not require cryptographic work is performed + * synchronously. CEK import, recipient key wrapping, and any size checks that + * depend on the final envelope are deferred until the readable side is first + * pulled, but complete before any output is emitted. * - * - Invalid options throw synchronously. Key work happens on the first read: - * importing the CEK and, with `recipients`, wrapping it. With recipients the - * `contentLength` total-size check also waits for that read (the envelope - * size isn't known before), but still fails before any output. - * - Every buffer in `options` (the CEK, recipient KEKs and kids, metadata) is - * borrowed and read as late as the first read: don't change or clear it - * until `readable` closes or errors. Input blocks may be reused once their - * `write()` resolves. - * - The base nonce is always generated internally. - * - The final chunk is emitted only after `writable` closes. - * - If the stream errors, any output already read is incomplete and must be discarded. + * Input buffers provided through `options` are borrowed and may be read until + * the readable side closes or errors, so they must not be modified or cleared + * during that time. Input blocks may be reused once their corresponding + * `write()` resolves. + * + * The base nonce is generated internally. The final encrypted chunk is emitted + * only after the writable side closes. If the stream fails, any output already + * consumed is incomplete and must be discarded. */ export function encrypt(options: ChunkedEncryptOptions): ReadableWritablePair { if (options === null || typeof options !== 'object') { @@ -180,3 +191,290 @@ export function encrypt(options: ChunkedEncryptOptions): ReadableWritablePair { + if (!(value instanceof Uint8Array)) { + throw new MalformedEnvelopeError( + `Invalid encoded object block: expected a Uint8Array, got ${describeCborType(value)}.` + ) + } + assertArrayBufferBacked(value, 'encoded object block', (message) => new MalformedEnvelopeError(message)) +} + +/** + * Create a streaming decryption pipeline for an encoded FEE object. + * + * The writable side decodes the envelope and frames the remaining ciphertext + * into chunks. The readable side resolves the CEK through `getCekKey`, then + * authenticates each chunk before releasing plaintext. Errors on either side + * propagate across the pair so pending reads or writes do not remain blocked. + */ +function createDecryptStream( + getCekKey: (decoded: DecodedEnvelope) => Promise +): ReadableWritablePair { + const scanner = createEnvelopeScanner() + + let receivedTotal = 0 + // Only known once the envelope completes and declares plaintext_length. + let expectedTotal: number | undefined + // Set once the envelope completes: the framer for the ciphertext after it. + let input: { framer: ChunkFramer; writer: WritableStreamDefaultWriter } | undefined + let outerController: WritableStreamDefaultController | undefined + + let resolveHandoff: ((opened: OpenedEnvelope) => void) | undefined + let rejectHandoff: ((reason: unknown) => void) | undefined + const handoff = new Promise((resolve, reject) => { + resolveHandoff = resolve + rejectHandoff = reject + }) + handoff.catch(() => { + // A stream nobody reads from must not surface an unhandled rejection. + }) + let handoffSettled = false + + // Rejects on the first failure, so a read waiting on caller code (an + // unwrapper that never settles) still ends when the stream fails. + let rejectFailure: ((reason: unknown) => void) | undefined + const failure = new Promise((_resolve, reject) => { + rejectFailure = reject + }) + failure.catch(() => { + // Only raced against key recovery; an unread stream must not surface this. + }) + + /** + * Used when the writable side is already failing on its own (the abort + * signal, or a write()/close() throw, which auto-errors the stream it + * throws from) -- must NOT also call `outerController.error()` here, or + * it reenters that same stream's own abort machinery mid-abort. + */ + function failInput(reason: unknown): void { + if (!handoffSettled) { + handoffSettled = true + rejectHandoff?.(reason) + } + rejectFailure?.(reason) + input?.framer.cancel(reason) + } + + function assertWithinDeclaredLength(): void { + if (expectedTotal !== undefined && receivedTotal > expectedTotal) { + throw new InvalidCiphertextLengthError( + `Invalid encoded object: received ${receivedTotal} bytes, but the declared plaintext_length implies a ` + + `total of at most ${expectedTotal} bytes.` + ) + } + } + + /** Used when the failure originates on the read side and needs to reach an otherwise-healthy writable. */ + function fail(reason: unknown): void { + failInput(reason) + // A no-op on an already errored or closed stream, per the Streams spec. + outerController?.error(reason) + } + + const writable = new WritableStream({ + start(controller) { + outerController = controller + // Same reasoning as the framer's own listener: abort() itself waits + // for an in-flight write, which only settles once the reader pulls. + controller.signal.addEventListener('abort', () => failInput(controller.signal.reason)) + }, + async write(block) { + // Wrapped so any failure here -- not just a framer/output failure -- + // also rejects a read still waiting on the envelope handoff, instead + // of leaving it pending forever. + try { + assertValidEncodedBlock(block) + receivedTotal += block.length + if (receivedTotal > MAX_ENCODED_OBJECT_SIZE) { + throw new InvalidCiphertextLengthError( + `Invalid encoded object: received ${receivedTotal} bytes, exceeding the ${MAX_ENCODED_OBJECT_SIZE}-byte limit.` + ) + } + assertWithinDeclaredLength() + + if (input === undefined) { + // Still scanning for the envelope. `scanner.push` copies whatever + // it needs synchronously, so a block is fully released the moment + // this returns -- nothing to await unless the envelope just + // completed with ciphertext already attached (`rest`, below). + const result = scanner.push(block) + if (result === undefined) return + + const { decoded, rest } = result + if (decoded.protectedHeader.alg !== ALG_CHUNKED_AES_256_GCM_STREAM) { + throw new UnsupportedSchemeError( + `Unsupported content algorithm ${decoded.protectedHeader.alg}: chunked decryption requires alg ${ALG_CHUNKED_AES_256_GCM_STREAM}.` + ) + } + // The decoder requires chunk_size for the chunked alg. + const chunkSize = decoded.protectedHeader.chunkSize + if (chunkSize === undefined) { + throw new Error('unreachable: the chunked alg always carries chunk_size') + } + const { plaintextLength } = decoded.protectedHeader + if (plaintextLength !== undefined) { + expectedTotal = decoded.envelopeLength + ciphertextLengthForPlaintext(plaintextLength, chunkSize) + if (expectedTotal > MAX_ENCODED_OBJECT_SIZE) { + throw new InvalidCiphertextLengthError( + `Invalid encoded object: the declared plaintext_length implies ${expectedTotal} bytes, exceeding ` + + `the ${MAX_ENCODED_OBJECT_SIZE}-byte limit.` + ) + } + assertWithinDeclaredLength() + } + + const framer = createChunkFramer(chunkSize + TAG_SIZE) + input = { framer, writer: framer.writable.getWriter() } + resolveHandoff?.({ decoded, framer, chunkSize }) + + if (rest.length > 0) { + await input.writer.write(rest) + } + return + } + + await input.writer.write(block) + } catch (cause) { + // The throw below auto-errors this writable; failInput just needs + // to reject a still-pending envelope handoff and stop the framer. + failInput(cause) + throw cause + } + }, + async close() { + if (input === undefined) { + // Always throws: input would already be set otherwise. Routed + // through `failInput` too, so a read still waiting on the envelope + // handoff rejects instead of hanging forever. + try { + scanner.finish() + } catch (cause) { + failInput(cause) + throw cause + } + return + } + await input.writer.close() + }, + }) + + let keyed: + | { + framer: ChunkFramer + cekKey: CryptoKey + additionalData: Uint8Array + baseNonce: Uint8Array + chunkSize: number + plaintextLength: number | undefined + } + | undefined + let chunkIndex = 0 + let receivedCiphertext = 0 + + const readable = new ReadableStream( + { + async pull(controller) { + try { + if (keyed === undefined) { + const { decoded, framer, chunkSize } = await handoff + // Do not start key work for input that has already failed. + framer.throwIfFailed() + // Caller code can take forever; a stream failure still ends this read. + const cekKey = await Promise.race([getCekKey(decoded), failure]) + keyed = { + framer, + cekKey, + additionalData: encStructure(decoded.tag, decoded.protectedHeader.bytes), + // Copied: retained for every chunk over the life of the stream. + baseNonce: new Uint8Array(decoded.protectedHeader.iv), + chunkSize, + plaintextLength: decoded.protectedHeader.plaintextLength, + } + // Fall through into the chunk step below, in the same pull. + } + + const { framer, cekKey, additionalData, baseNonce, chunkSize, plaintextLength } = keyed + const { bytes, isLast } = await framer.next() + receivedCiphertext += bytes.length + if (isLast) { + // Validates the total shape (a bare tag, a short remainder, and + // so on) before anything about this last chunk is trusted. + const layout = chunkLayout(receivedCiphertext, chunkSize) + if (plaintextLength !== undefined && layout.plaintextLength !== plaintextLength) { + throw new InvalidCiphertextLengthError( + `Invalid encoded object: the ciphertext implies a plaintext of ${layout.plaintextLength} bytes, ` + + `but the declared plaintext_length is ${plaintextLength}.` + ) + } + } + + // The header never picks the final chunk; only running out of + // input (the framer's own `isLast`) does. + const nonce = deriveChunkNonce(baseNonce, chunkIndex, isLast) + const plaintext = await aesGcmDecrypt(cekKey, nonce, additionalData, bytes) + if (plaintext.length > 0) { + controller.enqueue(plaintext) + } + chunkIndex++ + if (isLast) { + controller.close() + } + } catch (cause) { + // Propagate to the writable side too: rejects a pending write, and + // errors it so an upstream `pipeThrough` source stops. + fail(cause) + throw cause + } + }, + cancel(reason) { + fail(reason) + }, + }, + { highWaterMark: 0 } + ) + + return { writable, readable } +} + +/** + * Decrypt a scheme-2 (chunked AES-256-GCM STREAM) object using a direct CEK. + * + * Write the encoded object to `writable`; `readable` yields each plaintext + * chunk only after its authentication tag verifies. The pair can also be used + * with `source.pipeThrough(decrypt(cek))`. + * + * Keep `cek` unchanged until `readable` closes or errors. Input blocks may be + * reused once their `write()` resolves. If decryption fails, discard earlier + * plaintext: authentic chunks alone do not establish a complete object. + */ +export function decrypt(cek: Uint8Array): ReadableWritablePair { + assertAes256Key(cek, 'CEK') + return createDecryptStream(() => importAesGcmKey(cek, 'decrypt', false)) +} + +/** + * Decrypt a scheme-2 object using a CEK recovered by `unwrapper`. + * + * For tag 96, the unwrapper is called once with isolated copies of all + * recipients in wire order. A tag-16 object has no recipients and fails + * without calling it. The returned CEK is validated before any chunk is + * decrypted. As with `decrypt`, discard plaintext already read if the stream fails. + */ +export function decryptWith(unwrapper: Unwrapper): ReadableWritablePair { + if (typeof unwrapper !== 'function') { + throw new MalformedEnvelopeError(`Invalid unwrapper: expected a function, got ${describeCborType(unwrapper)}.`) + } + return createDecryptStream(async (decoded) => { + const cek = await recoverCek(decoded, unwrapper) + return importAesGcmKey(cek, 'decrypt', false) + }) +} diff --git a/packages/filecoin-encryption-envelope/src/aes-gcm.ts b/packages/filecoin-encryption-envelope/src/aes-gcm.ts index 6766d680..c2d46288 100644 --- a/packages/filecoin-encryption-envelope/src/aes-gcm.ts +++ b/packages/filecoin-encryption-envelope/src/aes-gcm.ts @@ -8,7 +8,6 @@ * supplied CEK (`decrypt`) or a CEK recovered from a recipient (`decryptWith`). */ import { ALG_AES_256_GCM, MAX_AES_GCM_PLAINTEXT_SIZE, NONCE_SIZE, TAG_SIZE } from './constants.ts' -import { TAG_ENCRYPT0 } from './cose/constants.ts' import { type DecodedEnvelope, decodeEnvelope } from './cose/decode.ts' import { encStructure } from './cose/enc-structure.ts' import { assemblePreparedEnvelope } from './cose/encode.ts' @@ -19,14 +18,12 @@ import { InvalidPlaintextError, InvalidPlaintextLengthError, MalformedEnvelopeError, - NoUsableRecipientError, - RecipientUnwrapError, UnsupportedSchemeError, } from './errors.ts' import { assertAes256Key, assertArrayBufferBacked } from './internal/keys.ts' import { aesGcmDecrypt, aesGcmEncrypt, importAesGcmKey, randomBytes } from './internal/web-crypto.ts' -import { toRecipientInfo } from './recipients/info.ts' import { createRecipientRecords, prepareRecipientInputs } from './recipients/prepare.ts' +import { recoverCek } from './recipients/recover.ts' import type { Recipient, Unwrapper } from './recipients/types.ts' const MAX_AES_GCM_CIPHERTEXT_SIZE = MAX_AES_GCM_PLAINTEXT_SIZE + TAG_SIZE @@ -128,10 +125,9 @@ interface PreparedDecryption { } /** - * Shared steps for both decryption entry points: input backing check, decode, - * content-algorithm check, detached-ciphertext view, AAD, and IV. Neither - * caller's remaining checks (CEK shape, recipient presence) belong here, since - * `decrypt` and `decryptWith` order them differently against this common work. + * Validate and decode a scheme-1 object, returning the detached ciphertext + * and inputs needed for content authentication. The ciphertext remains a view + * into `encoded`; the caller must still supply or recover the CEK. */ function prepareDecryption(encoded: Uint8Array): PreparedDecryption { if (encoded instanceof Uint8Array) { @@ -185,26 +181,7 @@ export async function decryptWith(encoded: Uint8Array, unwrapper: Unwrapper): Pr } const { decoded, ciphertext, additionalData, iv } = prepareDecryption(encoded) - if (decoded.tag === TAG_ENCRYPT0) { - throw new NoUsableRecipientError( - 'No usable recipient: this envelope is COSE_Encrypt0 and carries no recipients; supply the CEK directly with aesGcm.decrypt instead.' - ) - } - - const infos = decoded.recipients.map(toRecipientInfo) - let cek: Uint8Array | undefined - try { - cek = await unwrapper(infos) - } catch (cause) { - throw new RecipientUnwrapError('Recipient key recovery failed.', { cause }) - } - if (cek === undefined) { - throw new NoUsableRecipientError( - `No usable recipient: none of the ${infos.length} recipients offered a usable key.` - ) - } - assertAes256Key(cek, 'recovered CEK') - + const cek = await recoverCek(decoded, unwrapper) const key = await importAesGcmKey(cek, 'decrypt', false) return await aesGcmDecrypt(key, iv, additionalData, ciphertext) } diff --git a/packages/filecoin-encryption-envelope/src/cose/envelope-scanner.ts b/packages/filecoin-encryption-envelope/src/cose/envelope-scanner.ts new file mode 100644 index 00000000..960b5749 --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/cose/envelope-scanner.ts @@ -0,0 +1,264 @@ +/** + * Finds the COSE envelope boundary in an arbitrarily chunked encoded object. + * + * The scanner incrementally walks the structure of the first CBOR item + * without decoding its values, so it can determine where the envelope ends + * and detached ciphertext begins. It advances only over newly available + * bytes and never reparses data it has already consumed. + * + * Once the boundary is found, `decodeEnvelope` runs exactly once on the + * complete envelope and performs the package's normal validation. Truncated + * input and malformed CBOR are reported separately. + * + * `scanEnvelopeStep` implements the stateless scanning step; + * `createEnvelopeScanner` provides the stateful streaming wrapper. + */ +import { MalformedEnvelopeError } from '../errors.ts' +import { MAX_APP_METADATA_DEPTH, MAX_ENVELOPE_SIZE } from './constants.ts' +import { type DecodedEnvelope, decodeEnvelope } from './decode.ts' + +/** + * `cursor` is the start of the next head to read; it only ever moves + * forward, and only once a head (and, for a string, its content) is fully + * confirmed available. `stack` holds the remaining item count for each open + * array/map/tag; the top level starts by expecting exactly one item. + */ +export interface EnvelopeScanState { + cursor: number + stack: number[] +} + +/** A fresh scan state, ready to be passed to {@link scanEnvelopeStep} from the start of an envelope. */ +export function createEnvelopeScanState(): EnvelopeScanState { + return { cursor: 0, stack: [1] } +} + +function closeFinished(stack: number[]): void { + for (;;) { + const top = stack[stack.length - 1] + if (top === undefined || top > 0) return + stack.pop() + } +} + +function decrementParent(stack: number[]): void { + const top = stack[stack.length - 1] + if (top !== undefined) { + stack[stack.length - 1] = top - 1 + } +} + +/** + * Advances the incremental CBOR scan as far as the available bytes allow. + * + * Returns the byte length of the first complete top-level item, or `undefined` + * if more input is needed. When input is incomplete, `state.cursor` remains at + * the start of the unfinished item so only that item is reconsidered when more + * bytes arrive. + * + * Rejects CBOR structures, nesting, and sizes that this envelope profile does + * not permit. Full envelope validation is performed later by `decodeEnvelope`. + */ +export function scanEnvelopeStep(data: ArrayLike, state: EnvelopeScanState): number | undefined { + for (;;) { + if (state.stack.length === 0) { + return state.cursor + } + + const headStart = state.cursor + if (headStart >= data.length) { + return undefined + } + + const firstByte = data[headStart] as number + const major = firstByte >>> 5 + const info = firstByte & 0x1f + + let extraBytes: number + if (info < 24) { + extraBytes = 0 + } else if (info === 24) { + extraBytes = 1 + } else if (info === 25) { + extraBytes = 2 + } else if (info === 26) { + extraBytes = 4 + } else if (info === 27) { + extraBytes = 8 + } else if (info === 31) { + throw new MalformedEnvelopeError( + 'Malformed envelope: indefinite-length CBOR items and the break code are not permitted.' + ) + } else { + throw new MalformedEnvelopeError( + `Malformed envelope: reserved CBOR additional information ${info} is not permitted.` + ) + } + + const payloadStart = headStart + 1 + if (payloadStart + extraBytes > data.length) { + return undefined // head itself is split across the available bytes; retry from headStart + } + + let value: number + if (extraBytes === 0) { + value = info + } else if (extraBytes <= 4) { + value = 0 + for (let i = 0; i < extraBytes; i++) { + value = value * 256 + (data[payloadStart + i] as number) + } + } else { + // A nonzero high half is too large for a string length or container + // count. Integer and tag values are not envelope lengths; the decoder + // validates those after the scan. + let high = 0 + for (let i = 0; i < 4; i++) { + high = high * 256 + (data[payloadStart + i] as number) + } + if (high !== 0 && major >= 2 && major <= 5) { + throw new MalformedEnvelopeError("Malformed envelope: an 8-byte CBOR length exceeds this profile's limits.") + } + value = 0 + for (let i = 4; i < 8; i++) { + value = value * 256 + (data[payloadStart + i] as number) + } + } + + const nextPos = payloadStart + extraBytes + const remainingBudget = MAX_ENVELOPE_SIZE - nextPos + + if (major === 2 || major === 3) { + // Byte or text string: skip `value` content bytes without reading + // them -- their content has no bearing on structure. + if (value > remainingBudget) { + throw new MalformedEnvelopeError( + `Malformed envelope: a ${major === 2 ? 'byte' : 'text'} string of ${value} bytes exceeds the ` + + `${MAX_ENVELOPE_SIZE}-byte envelope budget.` + ) + } + const stringEnd = nextPos + value + if (stringEnd > data.length) { + return undefined // content not fully available yet; retry from headStart + } + decrementParent(state.stack) + state.cursor = stringEnd + closeFinished(state.stack) + continue + } + + if (major === 4 || major === 5 || major === 6) { + // Array, map (pairs count double), or tag (always exactly one nested item). + const items = major === 6 ? 1 : major === 5 ? value * 2 : value + if (items > remainingBudget) { + throw new MalformedEnvelopeError( + `Malformed envelope: a container declaring ${items} items exceeds the ${MAX_ENVELOPE_SIZE}-byte envelope budget.` + ) + } + decrementParent(state.stack) + // `state.stack` carries one extra, synthetic frame for the top level + // (see createEnvelopeScanState) that `headers.ts`'s tokenizer doesn't + // have -- its `open` array starts empty, not `[1]`. `state.stack.length` + // here is already that tokenizer's `open.length + 1` for the same + // wire position, so comparing it to the limit directly (no further + // `+ 1`) is what keeps the two counts in step. + if (state.stack.length > MAX_APP_METADATA_DEPTH) { + throw new MalformedEnvelopeError( + `Malformed envelope: CBOR nesting deeper than ${MAX_APP_METADATA_DEPTH} levels is not permitted.` + ) + } + state.cursor = nextPos + state.stack.push(items) + closeFinished(state.stack) + continue + } + + // Major 0 (uint), 1 (negint), 7 (simple/float): nothing further to skip. + decrementParent(state.stack) + state.cursor = nextPos + closeFinished(state.stack) + } +} + +export interface EnvelopeScanResult { + decoded: DecodedEnvelope + /** Bytes after the envelope: a view into whichever `push()` block supplied them, not a copy. */ + rest: Uint8Array +} + +export interface EnvelopeScanner { + /** + * Feed the next block. Returns the decoded envelope plus any trailing + * ciphertext from this same block once the envelope completes, or + * `undefined` while more input is still needed. Calling this again after + * it has already returned a result is a caller bug. + */ + push(block: Uint8Array): EnvelopeScanResult | undefined + /** The source ended. Throws if the envelope never completed. */ + finish(): void +} + +/** + * Decode one envelope across any number of input blocks, up to + * `MAX_ENVELOPE_SIZE` bytes. Until the envelope is complete, `push()` returns + * `undefined`. On completion it returns decoded fields independent of the + * caller's blocks, plus a `rest` view into the block that ended the envelope. + */ +export function createEnvelopeScanner(): EnvelopeScanner { + let buffer = new Uint8Array(1024) + let filled = 0 + const state = createEnvelopeScanState() + let completed = false + + function ensureCapacity(needed: number): void { + if (buffer.length >= needed) return + let capacity = buffer.length + while (capacity < needed) capacity *= 2 + const grown = new Uint8Array(Math.min(capacity, MAX_ENVELOPE_SIZE)) + grown.set(buffer.subarray(0, filled)) + buffer = grown + } + + function push(block: Uint8Array): EnvelopeScanResult | undefined { + if (completed) { + throw new Error('createEnvelopeScanner: push() called after the envelope already completed.') + } + + const filledBefore = filled + // Never let the scan see more than the envelope's own size ceiling. + const extraLength = Math.min(block.length, MAX_ENVELOPE_SIZE - filledBefore) + ensureCapacity(filledBefore + extraLength) + buffer.set(block.subarray(0, extraLength), filledBefore) + + const length = scanEnvelopeStep(buffer.subarray(0, filledBefore + extraLength), state) + if (length === undefined) { + // Not complete yet: everything offered so far might still be envelope. + filled = filledBefore + extraLength + if (filled >= MAX_ENVELOPE_SIZE) { + throw new MalformedEnvelopeError(`Malformed envelope: exceeds the ${MAX_ENVELOPE_SIZE}-byte envelope limit.`) + } + return undefined + } + + // Complete: anything past `length` in the buffer is unused scratch. + const consumedFromBlock = length - filledBefore + filled = length + completed = true + + const decoded = decodeEnvelope(buffer.subarray(0, length)) + if (decoded.envelopeLength !== length) { + throw new MalformedEnvelopeError( + 'Malformed envelope: internal inconsistency between the structural scan and decodeEnvelope.' + ) + } + + return { decoded, rest: block.subarray(consumedFromBlock) } + } + + function finish(): void { + if (completed) return + throw new MalformedEnvelopeError('Malformed envelope: input ended inside the envelope.') + } + + return { push, finish } +} diff --git a/packages/filecoin-encryption-envelope/src/index.ts b/packages/filecoin-encryption-envelope/src/index.ts index 0158f762..721a9bfc 100644 --- a/packages/filecoin-encryption-envelope/src/index.ts +++ b/packages/filecoin-encryption-envelope/src/index.ts @@ -6,6 +6,7 @@ * import * as fee from '@filoz/filecoin-encryption-envelope' * * source.pipeThrough(fee.encrypt({ cek })) // chunked stream, the default + * encrypted.pipeThrough(fee.decrypt(cek)) // and back * fee.aesGcm.encrypt(plaintext, { cek }) // whole-object, opt-in * fee.constants.ALG_A256KW * ``` @@ -13,7 +14,7 @@ * @module filecoin-encryption-envelope */ export * as aesGcm from './aes-gcm.ts' -export { type ChunkedEncryptOptions, encrypt } from './aes-gcm-stream.ts' +export { type ChunkedEncryptOptions, decrypt, decryptWith, encrypt } from './aes-gcm-stream.ts' export type { AppMetadata, CborValue } from './cose/headers.ts' export * as cose from './cose/index.ts' export * as errors from './errors.ts' diff --git a/packages/filecoin-encryption-envelope/src/recipients/recover.ts b/packages/filecoin-encryption-envelope/src/recipients/recover.ts new file mode 100644 index 00000000..3a377afe --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/recipients/recover.ts @@ -0,0 +1,37 @@ +import { TAG_ENCRYPT0 } from '../cose/constants.ts' +import type { DecodedEnvelope } from '../cose/decode.ts' +import { NoUsableRecipientError, RecipientUnwrapError } from '../errors.ts' +import { assertAes256Key } from '../internal/keys.ts' +import { toRecipientInfo } from './info.ts' +import type { Unwrapper } from './types.ts' + +/** + * Recover and validate a CEK from a decoded envelope's recipients. + * + * For tag 96, calls `unwrapper` once with isolated copies of every recipient + * in wire order. A tag-16 envelope or an unmatched recipient list fails with + * `NoUsableRecipientError`; an unwrapper failure becomes + * `RecipientUnwrapError`. The returned key must be a nonzero 32-byte CEK. + */ +export async function recoverCek(decoded: DecodedEnvelope, unwrapper: Unwrapper): Promise> { + if (decoded.tag === TAG_ENCRYPT0) { + throw new NoUsableRecipientError( + 'No usable recipient: this envelope is COSE_Encrypt0 and carries no recipients; supply the CEK directly instead.' + ) + } + + const infos = decoded.recipients.map(toRecipientInfo) + let cek: Uint8Array | undefined + try { + cek = await unwrapper(infos) + } catch (cause) { + throw new RecipientUnwrapError('Recipient key recovery failed.', { cause }) + } + if (cek === undefined) { + throw new NoUsableRecipientError( + `No usable recipient: none of the ${infos.length} recipients offered a usable key.` + ) + } + assertAes256Key(cek, 'recovered CEK') + return cek +} diff --git a/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt-with.test.ts b/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt-with.test.ts new file mode 100644 index 00000000..a3b27110 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt-with.test.ts @@ -0,0 +1,394 @@ +import assert from 'node:assert' +import { decryptWith, encrypt } from '../src/aes-gcm-stream.ts' +import { ALG_CHUNKED_AES_256_GCM_STREAM, KEY_SIZE } from '../src/constants.ts' +import { ALG_A256KW, HEADER_ALG } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import { encStructure } from '../src/cose/enc-structure.ts' +import { assemblePreparedEnvelope, type RecipientInput } from '../src/cose/encode.ts' +import { encodeProtectedHeader } from '../src/cose/headers.ts' +import { + AuthenticationError, + InvalidKeyError, + MalformedEnvelopeError, + NoUsableRecipientError, + RecipientUnwrapError, +} from '../src/errors.ts' +import { deriveChunkNonce } from '../src/nonce.ts' +import { createA256KWUnwrapper } from '../src/recipients/index.ts' +import { toRecipientInfo } from '../src/recipients/info.ts' +import type { A256KWRecipient, RecipientInfo, Unwrapper } from '../src/recipients/types.ts' +import { FIXED_CEK, fixedBaseNonceRandomValues, withRandomValues } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks } from './aes-gcm-stream-fixtures.ts' +import { concatBytes, FIXTURE_BASE_NONCE_7 } from './cose-fixtures.ts' + +const CHUNK_SIZE = 4096 + +const KEK_A = Uint8Array.from({ length: KEY_SIZE }, (_, index) => 0x40 + index) +const KEK_B = Uint8Array.from({ length: KEY_SIZE }, (_, index) => 0x80 + index) +const KID_A = Uint8Array.from([0xa1, 0xa2]) +const KID_B = Uint8Array.from([0xb1]) + +function recipient(kek: Uint8Array, kid?: Uint8Array): A256KWRecipient { + return kid === undefined + ? { alg: ALG_A256KW, kek: new Uint8Array(kek) } + : { alg: ALG_A256KW, kek: new Uint8Array(kek), kid: new Uint8Array(kid) } +} + +/** Encrypt via the production writer, optionally with recipients, and drive it to completion. */ +async function encryptFull(plaintext: Uint8Array, recipients?: readonly A256KWRecipient[]): Promise { + const { writable, readable } = await withRandomValues(fixedBaseNonceRandomValues, async () => + encrypt({ cek: new Uint8Array(FIXED_CEK), chunkSize: CHUNK_SIZE, recipients }) + ) + const writer = writable.getWriter() + const writeDone = writer.write(plaintext) + const closeDone = writer.close() + const chunks = await readAllChunks(readable) + await writeDone + await closeDone + return concatBytes(...chunks) +} + +/** Drive decryptWith() to completion (or rejection) with `encoded` delivered in one write. */ +async function decryptWithChunks(encoded: Uint8Array, unwrapper: Unwrapper): Promise[]> { + const { writable, readable } = decryptWith(unwrapper) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) + const closeDone = writer.close() + writeDone.catch(() => { + // Surfaced via readAllChunks below either way; avoid an unhandled rejection. + }) + closeDone.catch(() => { + // Same: an incomplete/invalid object rejects close(), read already reports it. + }) + const chunks = await readAllChunks(readable) + await writeDone + await closeDone + return chunks +} + +async function decryptWithBytes(encoded: Uint8Array, unwrapper: Unwrapper): Promise { + return concatBytes(...(await decryptWithChunks(encoded, unwrapper))) +} + +/** Fails the test if the wrapped unwrapper is ever invoked. */ +function neverCalledUnwrapper(): { unwrapper: Unwrapper; assertNeverCalled: () => void } { + let calls = 0 + return { + unwrapper: async () => { + calls++ + return undefined + }, + assertNeverCalled: () => assert.strictEqual(calls, 0), + } +} + +/** + * Build a chunked (tag-96) object with a single small chunk and an arbitrary + * recipient list, bypassing `encrypt()`'s recipient-shape validation -- + * mirrors `encryptTag96WithWebCrypto` in test/aes-gcm-decrypt-with.test.ts, + * adapted to the chunked scheme's per-chunk AEAD framing. + */ +async function buildSingleChunkTag96Object( + plaintext: Uint8Array, + cek: Uint8Array, + recipients: RecipientInput[] +): Promise { + const protectedBytes = encodeProtectedHeader({ + alg: ALG_CHUNKED_AES_256_GCM_STREAM, + iv: FIXTURE_BASE_NONCE_7, + chunkSize: CHUNK_SIZE, + }) + const prepared = assemblePreparedEnvelope(protectedBytes, recipients) + const key = await globalThis.crypto.subtle.importKey('raw', new Uint8Array(cek), 'AES-GCM', false, ['encrypt']) + const aad = encStructure(prepared.tag, prepared.protectedBytes) + const nonce = deriveChunkNonce(FIXTURE_BASE_NONCE_7, 0, true) + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: 128 }, + key, + new Uint8Array(plaintext) + ) + return concatBytes(prepared.bytes, new Uint8Array(ciphertext)) +} + +describe('aesGcmStream.decryptWith', () => { + describe('round trip', () => { + it('recovers the plaintext for one recipient with a kid', async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A)]) + const unwrapper = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + assert.deepStrictEqual(await decryptWithBytes(encoded, unwrapper), plaintext) + }) + + it('recovers the plaintext for two recipients, either KEK working', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE + 50) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A), recipient(KEK_B, KID_B)]) + + const unwrapperA = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + assert.deepStrictEqual(await decryptWithBytes(encoded, unwrapperA), plaintext) + + const unwrapperB = await createA256KWUnwrapper([{ kek: KEK_B, kid: KID_B }]) + assert.deepStrictEqual(await decryptWithBytes(encoded, unwrapperB), plaintext) + }) + + it('round-trips a multi-chunk object', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3 + 77) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A)]) + const unwrapper = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + assert.deepStrictEqual(await decryptWithBytes(encoded, unwrapper), plaintext) + }) + + it('round-trips when fed one byte at a time', async () => { + const plaintext = deterministicPlaintext(500) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A)]) + const unwrapper = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + const { writable, readable } = decryptWith(unwrapper) + const writer = writable.getWriter() + const writes: Promise[] = [] + for (let i = 0; i < encoded.length; i++) { + writes.push(writer.write(encoded.subarray(i, i + 1))) + } + const closeDone = writer.close() + const chunks = await readAllChunks(readable) + await Promise.all(writes) + await closeDone + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + + it('decrypts via a custom unwrapper handling a non-A256KW recipient', async () => { + // decryptWith must not filter recipients by algorithm itself. + const plaintext = deterministicPlaintext(20) + const unknownRecipient: RecipientInput = { + protectedBytes: new Uint8Array(0), + unprotected: new Map([[HEADER_ALG, -999]]), + ciphertext: new Uint8Array(40), + } + const encoded = await buildSingleChunkTag96Object(plaintext, FIXED_CEK, [unknownRecipient]) + const unwrapper: Unwrapper = async (recipients) => { + assert.strictEqual(recipients.length, 1) + assert.strictEqual(recipients[0].alg, -999) + assert.strictEqual(recipients[0].index, 0) + return new Uint8Array(FIXED_CEK) + } + assert.deepStrictEqual(await decryptWithBytes(encoded, unwrapper), plaintext) + }) + }) + + it('gives the unwrapper every recipient once, in wire order, matching toRecipientInfo exactly', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A), recipient(KEK_B, KID_B)]) + const expected = decodeEnvelope(encoded).recipients.map(toRecipientInfo) + let calls = 0 + const unwrapper: Unwrapper = async (recipients) => { + calls++ + assert.deepStrictEqual(recipients, expected) + return undefined + } + + await assert.rejects(decryptWithBytes(encoded, unwrapper), NoUsableRecipientError) + assert.strictEqual(calls, 1) + }) + + it('gives the unwrapper isolated recipient copies; mutating them affects neither encoded nor a later read of it', async () => { + const plaintext = deterministicPlaintext(20) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A), recipient(KEK_B, KID_B)]) + const encodedCopy = new Uint8Array(encoded) + let seen: readonly RecipientInfo[] = [] + const mutatingUnwrapper: Unwrapper = async (recipients) => { + seen = recipients + for (const info of recipients) { + info.wrappedKey.fill(0) + } + return undefined + } + + await assert.rejects(decryptWithBytes(encoded, mutatingUnwrapper), NoUsableRecipientError) + assert.strictEqual(seen.length, 2) + assert.strictEqual(seen[0].index, 0) + assert.strictEqual(seen[1].index, 1) + assert.deepStrictEqual(encoded, encodedCopy) + + // A later, independent read of the same bytes still works. + const freshUnwrapper = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + assert.deepStrictEqual(await decryptWithBytes(encoded, freshUnwrapper), plaintext) + }) + + describe('no usable recipient', () => { + it('rejects a tag-16 envelope with NoUsableRecipientError, without calling the unwrapper', async () => { + const encoded = await encryptFull(deterministicPlaintext(20)) + const { unwrapper, assertNeverCalled } = neverCalledUnwrapper() + + await assert.rejects(decryptWithBytes(encoded, unwrapper), NoUsableRecipientError) + assertNeverCalled() + }) + + it('reports a custom unwrapper returning undefined as NoUsableRecipientError', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const unwrapper: Unwrapper = async () => undefined + + await assert.rejects(decryptWithBytes(encoded, unwrapper), NoUsableRecipientError) + }) + }) + + describe('unwrapper failure', () => { + it('wraps a synchronously thrown value as RecipientUnwrapError with the exact cause', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const boom = { reason: 'boom' } + const unwrapper: Unwrapper = () => { + throw boom + } + + await assert.rejects( + decryptWithBytes(encoded, unwrapper), + (error: unknown) => error instanceof RecipientUnwrapError && error.cause === boom + ) + }) + + it('wraps a rejected unwrapper promise as RecipientUnwrapError with the exact cause', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const boom = new Error('boom') + const unwrapper: Unwrapper = async () => { + throw boom + } + + await assert.rejects( + decryptWithBytes(encoded, unwrapper), + (error: unknown) => error instanceof RecipientUnwrapError && error.cause === boom + ) + }) + }) + + describe('recovered CEK validation', () => { + const invalidCeks: Array<[string, unknown]> = [ + ['not a Uint8Array', 'nope'], + ['31 bytes', new Uint8Array(KEY_SIZE - 1)], + ['all-zero', new Uint8Array(KEY_SIZE)], + ['SharedArrayBuffer-backed', new Uint8Array(new SharedArrayBuffer(KEY_SIZE))], + ] + + for (const [label, badCek] of invalidCeks) { + it(`rejects a recovered CEK that is ${label}`, async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const unwrapper: Unwrapper = async () => badCek as Uint8Array + + await assert.rejects(decryptWithBytes(encoded, unwrapper), InvalidKeyError) + }) + } + + it('reports a wrong recovered CEK as AuthenticationError, calling the unwrapper exactly once', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const wrongCek = Uint8Array.from(FIXED_CEK, (byte) => byte ^ 0xff) + let calls = 0 + const unwrapper: Unwrapper = async () => { + calls++ + return wrongCek + } + + await assert.rejects(decryptWithBytes(encoded, unwrapper), AuthenticationError) + assert.strictEqual(calls, 1) + }) + }) + + describe('malformed input', () => { + it('rejects a non-function unwrapper synchronously', () => { + assert.throws(() => decryptWith('nope' as unknown as Unwrapper), MalformedEnvelopeError) + }) + }) + + describe('cancel and abort', () => { + it('does not call the unwrapper when input fails before the first read', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const decodedEnvelope = decodeEnvelope(encoded) + const { unwrapper, assertNeverCalled } = neverCalledUnwrapper() + const { writable, readable } = decryptWith(unwrapper) + const writer = writable.getWriter() + await writer.write(encoded.subarray(0, decodedEnvelope.envelopeLength)) // envelope only, framer created + + const reason = new Error('abort after envelope, before any read') + await writer.abort(reason) + await assert.rejects(readable.getReader().read(), (err) => err === reason) + assertNeverCalled() + }) + + it('rejects a pending read with the abort reason, and enqueues nothing, when aborted while the unwrapper is pending', async () => { + const encoded = await encryptFull(deterministicPlaintext(20), [recipient(KEK_A, KID_A)]) + const decodedEnvelope = decodeEnvelope(encoded) + let releaseUnwrapper: (() => void) | undefined + const gate = new Promise((resolve) => { + releaseUnwrapper = resolve + }) + let resolveCalled: (() => void) | undefined + const called = new Promise((resolve) => { + resolveCalled = resolve + }) + let calls = 0 + const unwrapper: Unwrapper = async (recipients) => { + calls++ + resolveCalled?.() + await gate + const real = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + return real(recipients) + } + const { writable, readable } = decryptWith(unwrapper) + const writer = writable.getWriter() + await writer.write(encoded.subarray(0, decodedEnvelope.envelopeLength)) // envelope only + + const reader = readable.getReader() + const pending = reader.read() // triggers the handoff, then the gated unwrapper call + await called // wait until the unwrapper has actually started and is blocked on `gate` + + const reason = new Error('abort while unwrapper pending') + const abortDone = writer.abort(reason) // signal fires synchronously, before this settles + abortDone.catch(() => { + // Asserted below via `pending`; avoid an unhandled rejection. + }) + releaseUnwrapper?.() // let the (now-pointless) unwrapper call finish after the abort + + await assert.rejects(pending, (err) => err === reason) + assert.strictEqual(calls, 1) + }) + + it('rejects a pending write and errors the writable when the unwrapper fails mid-stream', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptFull(plaintext, [recipient(KEK_A, KID_A)]) + const boom = new Error('unwrapper boom') + const unwrapper: Unwrapper = async () => { + throw boom + } + const { writable, readable } = decryptWith(unwrapper) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) // envelope + big ciphertext: stays pending + writeDone.catch(() => { + // Asserted below; avoid an unhandled rejection. + }) + + const reader = readable.getReader() + await assert.rejects( + reader.read(), + (error: unknown) => error instanceof RecipientUnwrapError && error.cause === boom + ) + + await assert.rejects(writeDone) + await assert.rejects(writer.write(Uint8Array.from([1, 2, 3, 4]))) + }) + }) +}) + +describe('aesGcmStream.decryptWith with an unwrapper that never settles', () => { + it('still ends a pending read when the writable is aborted', async () => { + const encoded = await encryptFull(deterministicPlaintext(100), [recipient(KEK_A)]) + let called = false + const { writable, readable } = decryptWith(() => { + called = true + return new Promise(() => { + // A KMS call that never answers. + }) + }) + const writer = writable.getWriter() + const read = readable.getReader().read() + await writer.write(encoded.subarray(0, decodeEnvelope(encoded).envelopeLength)) + while (!called) await new Promise((resolve) => setTimeout(resolve, 0)) + + const reason = new Error('abort while the unwrapper hangs') + await writer.abort(reason) + await assert.rejects(read, (err) => err === reason) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt.test.ts b/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt.test.ts new file mode 100644 index 00000000..7f2a8fa2 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/aes-gcm-stream-decrypt.test.ts @@ -0,0 +1,714 @@ +import assert from 'node:assert' +import crypto from 'node:crypto' +import { encrypt as encryptWholeObject } from '../src/aes-gcm.ts' +import { type ChunkedEncryptOptions, decrypt, encrypt } from '../src/aes-gcm-stream.ts' +import { KEY_SIZE, MIN_CHUNK_SIZE, TAG_SIZE } from '../src/constants.ts' +import { ALG_A256KW } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import { encStructure } from '../src/cose/enc-structure.ts' +import { + AuthenticationError, + InvalidCiphertextLengthError, + InvalidKeyError, + MalformedEnvelopeError, + UnsupportedSchemeError, +} from '../src/errors.ts' +import { FIXED_CEK, fixedBaseNonceRandomValues, withRandomValues } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks, sourceOf } from './aes-gcm-stream-fixtures.ts' +import { concatBytes, FIXTURE_BASE_NONCE_7, hexToBytes } from './cose-fixtures.ts' + +const CHUNK_SIZE = 4096 + +// Same envelope as aes-gcm-stream-encrypt.test.ts's independent vectors: +// { cek: FIXED_CEK, chunkSize: CHUNK_SIZE }, no contentType/appMetadata/ +// plaintext_length, base nonce fixed to FIXTURE_BASE_NONCE_7. +const ENVELOPE_HEX = + 'd083583fa4013a000101000547000102030405061078286170706c69636174696f6e2f766e642e66696c65636f696e2d656e6372797074696f6e2b636f736520191000a0f6' +// Same configuration but with contentLength: 100 (plaintext_length present). +const PLAINTEXT_LENGTH_ENVELOPE_HEX = + 'd0835846a5013a000101000547000102030405061078286170706c69636174696f6e2f766e642e66696c65636f696e2d656e6372797074696f6e2b636f7365201910003a000100fc1864a0f6' + +/** + * Build a decryptable object directly with node:crypto (never this + * package's aesGcmEncrypt/aesGcmDecrypt/deriveChunkNonce): a fixed envelope + * plus one aes-256-gcm chunk per stride, each with a literal nonce + * (`[...base, i3,i2,i1,i0, flag]`) and the AAD from encStructure (itself + * covered elsewhere). + */ +function buildObject( + totalLength: number, + envelopeHex: string = ENVELOPE_HEX +): { encoded: Uint8Array; plaintext: Uint8Array } { + const envelope = hexToBytes(envelopeHex) + const decoded = decodeEnvelope(envelope) + const aad = Buffer.from(encStructure(decoded.tag, decoded.protectedHeader.bytes)) + const plaintext = deterministicPlaintext(totalLength) + const chunkCount = totalLength === 0 ? 1 : Math.ceil(totalLength / CHUNK_SIZE) + + const parts: Uint8Array[] = [envelope] + for (let i = 0; i < chunkCount; i++) { + const isLast = i === chunkCount - 1 + const start = i * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, totalLength) + const slice = Buffer.from(plaintext.subarray(start, end)) + + const nonce = new Uint8Array(12) + nonce.set(FIXTURE_BASE_NONCE_7, 0) + new DataView(nonce.buffer).setUint32(7, i, false) + nonce[11] = isLast ? 1 : 0 + + const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(FIXED_CEK), Buffer.from(nonce), { + authTagLength: 16, + }) + cipher.setAAD(aad) + const ciphertext = Buffer.concat([cipher.update(slice), cipher.final(), cipher.getAuthTag()]) + parts.push(new Uint8Array(ciphertext)) + } + return { encoded: concatBytes(...parts), plaintext } +} + +/** Encrypt via the production writer and drive it to completion. */ +async function encryptFull(plaintext: Uint8Array, extra: Partial = {}): Promise { + const { writable, readable } = await withRandomValues(fixedBaseNonceRandomValues, async () => + encrypt({ cek: new Uint8Array(FIXED_CEK), chunkSize: CHUNK_SIZE, ...extra }) + ) + const writer = writable.getWriter() + const writeDone = writer.write(plaintext) + const closeDone = writer.close() + const chunks = await readAllChunks(readable) + await writeDone + await closeDone + return concatBytes(...chunks) +} + +/** Drive decrypt() to completion (or rejection) with `encoded` delivered in one write. */ +async function decryptChunks(encoded: Uint8Array, cek: Uint8Array = FIXED_CEK): Promise[]> { + const { writable, readable } = decrypt(new Uint8Array(cek)) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) + const closeDone = writer.close() + writeDone.catch(() => { + // Surfaced via readAllChunks below either way; avoid an unhandled rejection. + }) + closeDone.catch(() => { + // Same: an incomplete/invalid object rejects close(), read already reports it. + }) + const chunks = await readAllChunks(readable) + await writeDone + await closeDone + return chunks +} + +async function decryptBytes(encoded: Uint8Array, cek: Uint8Array = FIXED_CEK): Promise { + return concatBytes(...(await decryptChunks(encoded, cek))) +} + +/** Read until the stream rejects (or ends), capturing every chunk that arrived first. */ +async function decryptUntilFailure( + encoded: Uint8Array, + cek: Uint8Array = FIXED_CEK +): Promise<{ chunks: Uint8Array[]; error: unknown }> { + const { writable, readable } = decrypt(new Uint8Array(cek)) + const writer = writable.getWriter() + writer.write(encoded).catch(() => { + // Reported via the read side. + }) + writer.close().catch(() => { + // Reported via the read side. + }) + const reader = readable.getReader() + const chunks: Uint8Array[] = [] + let error: unknown + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + chunks.push(value as Uint8Array) + } + } catch (cause) { + error = cause + } + return { chunks, error } +} + +function findSubsequence(haystack: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer + } + return i + } + throw new Error('test helper: subsequence not found') +} + +describe('aesGcmStream.decrypt', () => { + describe('independent vectors', () => { + const cases = [ + { name: 'empty', length: 0 }, + { name: 'single partial chunk', length: 100 }, + { name: 'partial-final spanning >= 2 chunks', length: CHUNK_SIZE + 100 }, + { name: 'exact multiple spanning >= 2 chunks', length: CHUNK_SIZE * 2 }, + ] + for (const { name, length } of cases) { + it(`matches the independent oracle: ${name}`, async () => { + const { encoded, plaintext } = buildObject(length) + const chunks = await decryptChunks(encoded) + // One piece per non-empty chunk: an empty object yields no pieces at all. + assert.strictEqual(chunks.length, Math.ceil(length / CHUNK_SIZE)) + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + } + + it('matches the independent oracle with plaintext_length present', async () => { + const { encoded, plaintext } = buildObject(100, PLAINTEXT_LENGTH_ENVELOPE_HEX) + assert.deepStrictEqual(await decryptBytes(encoded), plaintext) + }) + }) + + describe('round-trips with the writer', () => { + it('round-trips at several sizes', async () => { + for (const length of [0, 1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, CHUNK_SIZE * 3 + 50]) { + const plaintext = deterministicPlaintext(length) + const encoded = await encryptFull(plaintext) + assert.deepStrictEqual(await decryptBytes(encoded), plaintext) + } + }) + + it('round-trips when fed one byte at a time', async () => { + const plaintext = deterministicPlaintext(500) + const encoded = await encryptFull(plaintext) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const writes: Promise[] = [] + for (let i = 0; i < encoded.length; i++) { + writes.push(writer.write(encoded.subarray(i, i + 1))) + } + const closeDone = writer.close() + const chunks = await readAllChunks(readable) + await Promise.all(writes) + await closeDone + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + + it('round-trips when one block holds the envelope and all ciphertext', async () => { + const plaintext = deterministicPlaintext(500) + const encoded = await encryptFull(plaintext) + assert.deepStrictEqual(await decryptBytes(encoded), plaintext) + }) + + it('round-trips via pipeThrough with a multi-block, non-aligned source', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE + 37) + const encoded = await encryptFull(plaintext) + const blocks = [ + encoded.subarray(0, 50), + encoded.subarray(50, 50), // empty block, interleaved + encoded.subarray(50, 3000), + encoded.subarray(3000), + ] + const output = sourceOf(blocks).pipeThrough(decrypt(new Uint8Array(FIXED_CEK))) + const chunks = await readAllChunks(output) + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + + it('decrypts a tag-96 object (recipients) with the direct CEK', async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext, { + recipients: [{ alg: ALG_A256KW, kek: new Uint8Array(KEY_SIZE).fill(0x55) }], + }) + assert.strictEqual(decodeEnvelope(encoded).recipients.length, 1) + assert.deepStrictEqual(await decryptBytes(encoded), plaintext) + }) + }) + + describe('no release before verify', () => { + it('yields exactly chunk 0 then rejects when chunk 1 of 3 is tampered', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const tampered = Uint8Array.from(encoded) + tampered[decodedEnvelope.envelopeLength + stride + 5] ^= 0xff // inside chunk 1's ciphertext + + const { chunks, error } = await decryptUntilFailure(tampered) + assert.strictEqual(chunks.length, 1) + assert.deepStrictEqual(chunks[0], plaintext.subarray(0, CHUNK_SIZE)) + assert.ok(error instanceof AuthenticationError) + }) + + it('enqueues nothing when the final chunk fails the plaintext_length check', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE + 50) + const encoded = await encryptFull(plaintext, { contentLength: CHUNK_SIZE + 50 }) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const droppedFinalChunk = encoded.subarray(0, decodedEnvelope.envelopeLength + stride) + + const { chunks, error } = await decryptUntilFailure(droppedFinalChunk) + assert.strictEqual(chunks.length, 0) + assert.ok(error instanceof InvalidCiphertextLengthError) + }) + }) + + describe('rejections', () => { + it('rejects the wrong key with AuthenticationError', async () => { + const encoded = await encryptFull(deterministicPlaintext(50)) + const wrongKey = new Uint8Array(FIXED_CEK) + wrongKey[0] ^= 0xff + await assert.rejects(decryptBytes(encoded, wrongKey), AuthenticationError) + }) + + it('rejects a changed protected-header byte with AuthenticationError', async () => { + const encoded = await encryptFull(deterministicPlaintext(10)) + const decodedEnvelope = decodeEnvelope(encoded) + const ivOffset = findSubsequence(encoded, decodedEnvelope.protectedHeader.iv) + const tampered = Uint8Array.from(encoded) + tampered[ivOffset] ^= 0xff + await assert.rejects(decryptBytes(tampered), AuthenticationError) + }) + + it('rejects a changed ciphertext byte with AuthenticationError', async () => { + const encoded = await encryptFull(deterministicPlaintext(50)) + const decodedEnvelope = decodeEnvelope(encoded) + const tampered = Uint8Array.from(encoded) + tampered[decodedEnvelope.envelopeLength] ^= 0xff + await assert.rejects(decryptBytes(tampered), AuthenticationError) + }) + + it('rejects a changed tag byte with AuthenticationError', async () => { + const encoded = await encryptFull(deterministicPlaintext(50)) + const tampered = Uint8Array.from(encoded) + tampered[tampered.length - 1] ^= 0xff + await assert.rejects(decryptBytes(tampered), AuthenticationError) + }) + + it('rejects chunks 0 and 1 swapped with AuthenticationError', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 2) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const envelopeBytes = encoded.subarray(0, decodedEnvelope.envelopeLength) + const chunk0 = encoded.subarray(decodedEnvelope.envelopeLength, decodedEnvelope.envelopeLength + stride) + const chunk1 = encoded.subarray(decodedEnvelope.envelopeLength + stride) + const swapped = concatBytes(envelopeBytes, chunk1, chunk0) + await assert.rejects(decryptBytes(swapped), AuthenticationError) + }) + + it('rejects truncation right after a full non-final chunk with AuthenticationError', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const truncated = encoded.subarray(0, decodedEnvelope.envelopeLength + stride) + await assert.rejects(decryptBytes(truncated), AuthenticationError) + }) + + it('rejects a dropped final chunk with AuthenticationError', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 2) // exact multiple, 2 chunks + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const dropped = encoded.subarray(0, decodedEnvelope.envelopeLength + stride) + await assert.rejects(decryptBytes(dropped), AuthenticationError) + }) + + it('rejects truncation leaving fewer than 16 trailing bytes with InvalidCiphertextLengthError', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 2) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const truncated = encoded.subarray(0, decodedEnvelope.envelopeLength + stride + 10) + await assert.rejects(decryptBytes(truncated), InvalidCiphertextLengthError) + }) + + it('rejects appended trailing bytes with AuthenticationError', async () => { + // Not InvalidCiphertextLengthError: appending a few bytes after a + // short final chunk still looks like *some* structurally valid short + // chunk to chunkLayout (a bigger one), so it isn't rejected on shape -- + // decrypting those extra bytes as part of "the tag" is what actually + // fails. + const encoded = await encryptFull(deterministicPlaintext(50)) + const withExtra = concatBytes(encoded, Uint8Array.from([1, 2, 3])) + await assert.rejects(decryptBytes(withExtra), AuthenticationError) + }) + + it('rejects bytes appended to an exact-multiple object at the old final chunk', async () => { + // The extra bytes make the old final chunk non-final. It was sealed with + // last_flag 1, so opening it with 0 fails before the short trailing + // piece ever reaches the layout check. + const encoded = await encryptFull(deterministicPlaintext(CHUNK_SIZE * 2)) + for (const extra of [1, 16]) { + const withExtra = concatBytes(encoded, new Uint8Array(extra)) + await assert.rejects(decryptBytes(withExtra), AuthenticationError) + } + }) + + it('rejects zero ciphertext bytes after the envelope with InvalidCiphertextLengthError', async () => { + const encoded = await encryptFull(deterministicPlaintext(0)) + const decodedEnvelope = decodeEnvelope(encoded) + const envelopeOnly = encoded.subarray(0, decodedEnvelope.envelopeLength) + await assert.rejects(decryptBytes(envelopeOnly), InvalidCiphertextLengthError) + }) + + it('rejects received bytes exceeding what the declared plaintext_length allows, immediately on write', async () => { + const encoded = await encryptFull(deterministicPlaintext(100), { contentLength: 100 }) + const withExtra = concatBytes( + encoded, + Uint8Array.from({ length: 50 }, () => 0xaa) + ) + const { writable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await assert.rejects(writer.write(withExtra), InvalidCiphertextLengthError) + }) + + it('rejects an overrun on a later write, after the envelope was already parsed', async () => { + // Distinct from the "immediately on write" case above: there, the + // whole envelope, ciphertext, and overrun arrive in one write() call, + // so the overrun is only ever caught by the check made right after + // computing expectedTotal for the first time. Splitting the envelope + // into its own write() call exercises the *separate* check that runs + // on every write() using the already-computed expectedTotal. + const encoded = await encryptFull(deterministicPlaintext(100), { contentLength: 100 }) + const decodedEnvelope = decodeEnvelope(encoded) + const envelopeBytes = encoded.subarray(0, decodedEnvelope.envelopeLength) + const rest = encoded.subarray(decodedEnvelope.envelopeLength) + const withExtra = concatBytes( + rest, + Uint8Array.from({ length: 50 }, () => 0xaa) + ) + const { writable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(envelopeBytes) + await assert.rejects(writer.write(withExtra), InvalidCiphertextLengthError) + }) + + it('rejects when the declared plaintext_length is larger than what arrives, at EOF', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE + 50) + const encoded = await encryptFull(plaintext, { contentLength: CHUNK_SIZE + 50 }) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const truncated = encoded.subarray(0, decodedEnvelope.envelopeLength + stride) + await assert.rejects(decryptBytes(truncated), InvalidCiphertextLengthError) + }) + + it('rejects a scheme-1 (whole-object AES-GCM) object with UnsupportedSchemeError', async () => { + const scheme1Encoded = await encryptWholeObject(deterministicPlaintext(20), { cek: new Uint8Array(FIXED_CEK) }) + await assert.rejects(decryptBytes(scheme1Encoded), UnsupportedSchemeError) + }) + + it('rejects input ending mid-envelope with MalformedEnvelopeError', async () => { + const encoded = await encryptFull(deterministicPlaintext(10)) + const decodedEnvelope = decodeEnvelope(encoded) + const partial = encoded.subarray(0, decodedEnvelope.envelopeLength - 5) + await assert.rejects(decryptBytes(partial), MalformedEnvelopeError) + }) + + it('rejects a non-Uint8Array block with MalformedEnvelopeError', async () => { + const { writable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + // @ts-expect-error deliberately passing a non-Uint8Array from untyped JS + await assert.rejects(writer.write('not bytes'), MalformedEnvelopeError) + }) + + it('rejects a SharedArrayBuffer-backed block with MalformedEnvelopeError', async () => { + const { writable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const view = new Uint8Array(new SharedArrayBuffer(16)) + await assert.rejects(writer.write(view), MalformedEnvelopeError) + }) + + it('throws synchronously for an invalid CEK', () => { + assert.throws(() => decrypt(new Uint8Array(KEY_SIZE - 1)), InvalidKeyError) + assert.throws(() => decrypt('not bytes' as unknown as Uint8Array), InvalidKeyError) + }) + }) + + describe('laziness', () => { + it('imports the key and decrypts lazily, exactly one decrypt per read', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 8) + const encoded = await encryptFull(plaintext) + + let importCalls = 0 + let decryptCalls = 0 + const originalImportKey = globalThis.crypto.subtle.importKey + const originalDecrypt = globalThis.crypto.subtle.decrypt + globalThis.crypto.subtle.importKey = ((...args: Parameters) => { + importCalls++ + return originalImportKey.apply(globalThis.crypto.subtle, args) + }) as SubtleCrypto['importKey'] + globalThis.crypto.subtle.decrypt = ((...args: Parameters) => { + decryptCalls++ + return originalDecrypt.apply(globalThis.crypto.subtle, args) + }) as SubtleCrypto['decrypt'] + + try { + // Everything before each counted call is microtasks; one macrotask + // is enough for any prefetching pull to reach it. + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + await settle() + assert.strictEqual(importCalls, 0, 'no key import before the first read') + assert.strictEqual(decryptCalls, 0) + + const writer = writable.getWriter() + const writeDone = writer.write(encoded) + const closeDone = writer.close() + + const reader = readable.getReader() + for (let k = 1; k <= 8; k++) { + const { done } = await reader.read() + assert.strictEqual(done, false) + await settle() + assert.strictEqual(decryptCalls, k) + assert.strictEqual(importCalls, 1, 'the key is imported once, not once per chunk') + } + const last = await reader.read() + assert.strictEqual(last.done, true) + await writeDone + await closeDone + } finally { + globalThis.crypto.subtle.importKey = originalImportKey + globalThis.crypto.subtle.decrypt = originalDecrypt + } + }) + }) + + describe('ownership', () => { + it('is unaffected by mutating a block (envelope + ciphertext) after its write() resolves', async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext) + const block = Uint8Array.from(encoded) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(block) + block.fill(0xff) + await writer.close() + assert.deepStrictEqual(concatBytes(...(await readAllChunks(readable))), plaintext) + }) + + it("is unaffected by transferring a block's buffer (envelope + ciphertext) after its write() resolves", async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext) + const block = Uint8Array.from(encoded) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(block) + structuredClone(block.buffer, { transfer: [block.buffer] }) + await writer.close() + assert.deepStrictEqual(concatBytes(...(await readAllChunks(readable))), plaintext) + }) + + it('is unaffected by mutating a ciphertext-only block after its write() resolves', async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const envelopeBytes = Uint8Array.from(encoded.subarray(0, decodedEnvelope.envelopeLength)) + const ciphertextBytes = Uint8Array.from(encoded.subarray(decodedEnvelope.envelopeLength)) + + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(envelopeBytes) + await writer.write(ciphertextBytes) + ciphertextBytes.fill(0xff) + await writer.close() + assert.deepStrictEqual(concatBytes(...(await readAllChunks(readable))), plaintext) + }) + + it("is unaffected by transferring a ciphertext-only block's buffer after its write() resolves", async () => { + const plaintext = deterministicPlaintext(200) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const envelopeBytes = Uint8Array.from(encoded.subarray(0, decodedEnvelope.envelopeLength)) + const ciphertextBytes = Uint8Array.from(encoded.subarray(decodedEnvelope.envelopeLength)) + + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(envelopeBytes) + await writer.write(ciphertextBytes) + structuredClone(ciphertextBytes.buffer, { transfer: [ciphertextBytes.buffer] }) + await writer.close() + assert.deepStrictEqual(concatBytes(...(await readAllChunks(readable))), plaintext) + }) + + it('completes a manual write loop of exactly-stride blocks against a concurrent consumer, without deadlock', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptFull(plaintext) + const decodedEnvelope = decodeEnvelope(encoded) + const stride = CHUNK_SIZE + TAG_SIZE + const envelopeBytes = encoded.subarray(0, decodedEnvelope.envelopeLength) + const ciphertext = encoded.subarray(decodedEnvelope.envelopeLength) + + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + + const produce = (async () => { + await writer.write(Uint8Array.from(envelopeBytes)) + for (let offset = 0; offset < ciphertext.length; offset += stride) { + await writer.write(Uint8Array.from(ciphertext.subarray(offset, offset + stride))) + } + await writer.close() + })() + + const chunks = await readAllChunks(readable) + await produce + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + }) + + describe('cancel and abort', () => { + it('rejects a pending write when the readable is cancelled', async () => { + const encoded = await encryptFull(deterministicPlaintext(CHUNK_SIZE * 3)) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) // envelope + big ciphertext: stays pending, nobody reading + + await readable.cancel(new Error('consumer gave up')) + await assert.rejects(writeDone) + }) + + it('rejects a pending read when the writable is aborted before the envelope completes', async () => { + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const reader = readable.getReader() + const pending = reader.read() // waiting on the envelope handoff + const reason = new Error('abort before envelope') + await writable.getWriter().abort(reason) + await assert.rejects(pending, (err) => err === reason) + }) + + it('rejects a pending read when the writable is aborted after the envelope completes', async () => { + const encoded = await encryptFull(deterministicPlaintext(10)) + const decodedEnvelope = decodeEnvelope(encoded) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(encoded.subarray(0, decodedEnvelope.envelopeLength)) // envelope only + + const reader = readable.getReader() + const pending = reader.read() // waiting on the first ciphertext chunk + const reason = new Error('abort after envelope') + await writer.abort(reason) + await assert.rejects(pending, (err) => err === reason) + }) + + it('starts no key work when input failed after the envelope but before the first read', async () => { + const { encoded } = buildObject(CHUNK_SIZE + 100) + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const original = globalThis.crypto.subtle.importKey + let importCalls = 0 + globalThis.crypto.subtle.importKey = ((...args: Parameters) => { + importCalls++ + return original.apply(globalThis.crypto.subtle, args) + }) as SubtleCrypto['importKey'] + try { + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + await writer.write(encoded.subarray(0, envelopeLength)) // envelope complete, framer created + const reason = new Error('abort after envelope, before any read') + await writer.abort(reason) + await assert.rejects(readable.getReader().read(), (err) => err === reason) + assert.strictEqual(importCalls, 0) + } finally { + globalThis.crypto.subtle.importKey = original + } + }) + + it('settles when the writable is aborted with an in-flight write and an idle reader', async () => { + const encoded = await encryptFull(deterministicPlaintext(CHUNK_SIZE * 3)) + const { writable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) + writeDone.catch(() => { + // Asserted below; avoid an unhandled rejection if abort() settles first. + }) + + await writer.abort(new Error('idle reader abort')) + await assert.rejects(writeDone) + }) + + it('rejects a pending write and errors the writable when decryption fails mid-stream', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptFull(plaintext) + let calls = 0 + const original = globalThis.crypto.subtle.decrypt + globalThis.crypto.subtle.decrypt = ((...args: Parameters) => { + calls++ + if (calls === 2) { + return Promise.reject(new DOMException('boom', 'OperationError')) + } + return original.apply(globalThis.crypto.subtle, args) + }) as SubtleCrypto['decrypt'] + + try { + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const writeDone = writer.write(encoded) // envelope + 3 chunks: stays pending + + const reader = readable.getReader() + await reader.read() // chunk 0 (decrypt call #1, succeeds) + await assert.rejects(reader.read()) // chunk 1 (decrypt call #2, fails) + + await assert.rejects(writeDone) + await assert.rejects(writer.write(Uint8Array.from([1, 2, 3, 4]))) + } finally { + globalThis.crypto.subtle.decrypt = original + } + }) + + it('settles cleanly when the readable is cancelled or the writable is aborted while idle', async () => { + const a = decrypt(new Uint8Array(FIXED_CEK)) + await assert.doesNotReject(a.readable.cancel(new Error('idle cancel'))) + + const b = decrypt(new Uint8Array(FIXED_CEK)) + await assert.doesNotReject(b.writable.abort(new Error('idle abort'))) + }) + + it('rejects a pending read when the writable closes mid-envelope', async () => { + const encoded = await encryptFull(deterministicPlaintext(10)) + const decodedEnvelope = decodeEnvelope(encoded) + const { writable, readable } = decrypt(new Uint8Array(FIXED_CEK)) + const writer = writable.getWriter() + const writeDone = writer.write(encoded.subarray(0, decodedEnvelope.envelopeLength - 5)) // incomplete envelope + + const reader = readable.getReader() + const pending = reader.read() // waiting on the envelope handoff + + await assert.rejects(writer.close(), MalformedEnvelopeError) + await assert.rejects(pending, MalformedEnvelopeError) + await writeDone + }) + }) +}) + +describe('aesGcmStream.decrypt declared total', () => { + /** Rewrite the 8-byte plaintext_length value inside an envelope; the envelope length is unchanged. */ + function rewritePlaintextLength(envelope: Uint8Array, from: number, to: number): Uint8Array { + const head = (n: number) => { + const bytes = new Uint8Array(9) + bytes[0] = 0x1b + new DataView(bytes.buffer).setBigUint64(1, BigInt(n)) + return bytes + } + const [needle, replacement] = [head(from), head(to)] + const out = new Uint8Array(envelope) + for (let i = 0; i + needle.length <= out.length; i++) { + if (needle.every((byte, j) => out[i + j] === byte)) { + out.set(replacement, i) + return out + } + } + throw new Error('test setup: plaintext_length not found') + } + + it('rejects an envelope whose plaintext_length implies more than 64 GiB in total', async () => { + // The writer's hand-calculated boundary: at chunk size 4096 this P puts + // envelope (83 bytes) + ciphertext exactly on 2^36; P + 1 is one over. + const atLimit = 68_452_085_693 + const writer = encrypt({ cek: new Uint8Array(FIXED_CEK), chunkSize: MIN_CHUNK_SIZE, contentLength: atLimit }) + const envelope = (await writer.readable.getReader().read()).value as Uint8Array + assert.strictEqual(envelope.length, 83, 'fixture assumes an 83-byte envelope') + + const accepted = decrypt(new Uint8Array(FIXED_CEK)) + await assert.doesNotReject(accepted.writable.getWriter().write(envelope)) + + const over = decrypt(new Uint8Array(FIXED_CEK)) + const overWrite = over.writable.getWriter().write(rewritePlaintextLength(envelope, atLimit, atLimit + 1)) + await assert.rejects(overWrite, InvalidCiphertextLengthError) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/envelope-scanner.test.ts b/packages/filecoin-encryption-envelope/test/envelope-scanner.test.ts new file mode 100644 index 00000000..6d7fb3c3 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/envelope-scanner.test.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert' +import { encode as cborEncode, rfc8949EncodeOptions } from 'cborg' +import { ALG_AES_256_GCM } from '../src/constants.ts' +import { ALG_A256KW, MAX_APP_METADATA_DEPTH, MAX_ENVELOPE_SIZE } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import type { EncodeEnvelopeInput, RecipientInput } from '../src/cose/encode.ts' +import { encodeEnvelope } from '../src/cose/encode.ts' +import { createEnvelopeScanner, createEnvelopeScanState, scanEnvelopeStep } from '../src/cose/envelope-scanner.ts' +import type { CborValue } from '../src/cose/headers.ts' +import { decodeExact, encodeProtectedHeader } from '../src/cose/headers.ts' +import { MalformedEnvelopeError } from '../src/errors.ts' +import { concatBytes, FIXTURE_IV_12, hexToBytes, MINIMAL_ENVELOPE_TAG16_HEX } from './cose-fixtures.ts' + +// Two recipients, one with a kid, mirroring the aes-gcm-recipients.test.ts fixtures. +const RECIPIENT_WITH_KID: RecipientInput = { + protectedBytes: new Uint8Array(0), + unprotected: new Map([ + [1, ALG_A256KW], + [4, Uint8Array.from([0xaa, 0xbb])], + ]), + ciphertext: new Uint8Array(40).fill(9), +} +const RECIPIENT_NO_KID: RecipientInput = { + protectedBytes: new Uint8Array(0), + unprotected: new Map([[1, ALG_A256KW]]), + ciphertext: new Uint8Array(40).fill(7), +} + +const ENVELOPE_INPUT: EncodeEnvelopeInput = { + protectedHeader: { alg: ALG_AES_256_GCM, iv: FIXTURE_IV_12 }, + recipients: [RECIPIENT_WITH_KID, RECIPIENT_NO_KID], +} +const ENVELOPE_BYTES = encodeEnvelope(ENVELOPE_INPUT) +const CIPHERTEXT = Uint8Array.from({ length: 37 }, (_, i) => i) +const FULL = concatBytes(ENVELOPE_BYTES, CIPHERTEXT) +const EXPECTED_DECODED = decodeEnvelope(FULL) + +function uint32be(n: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, n, false) + return bytes +} + +/** `d0` (tag 16) `83` (array/3) `` `a0` (empty map) `f6` (null) -- hand-wrapped so it can exceed MAX_ENVELOPE_SIZE, which `encodeEnvelope` itself refuses to produce. */ +function rawTag16Envelope(protectedBytes: Uint8Array): Uint8Array { + return concatBytes( + Uint8Array.from([0xd0, 0x83]), + cborEncode(protectedBytes, rfc8949EncodeOptions), + Uint8Array.from([0xa0, 0xf6]) + ) +} + +/** Build a tag-16 envelope of exactly `targetSize` bytes by tuning an app_metadata padding byte string. */ +function envelopeOfExactSize(targetSize: number): Uint8Array { + let n = targetSize + for (let attempt = 0; attempt < 10; attempt++) { + const protectedBytes = encodeProtectedHeader({ + alg: ALG_AES_256_GCM, + iv: FIXTURE_IV_12, + appMetadata: { pad: new Uint8Array(Math.max(n, 0)) }, + }) + const bytes = rawTag16Envelope(protectedBytes) + if (bytes.length === targetSize) return bytes + n += targetSize - bytes.length + } + throw new Error('test helper: envelopeOfExactSize did not converge') +} + +describe('createEnvelopeScanner', () => { + it('gives the same decoded result and correct rest at every split point within the envelope', () => { + // Splitting strictly before the envelope ends: the first push always + // needs more, so the fallback second push (envelope tail + all of the + // ciphertext) is the one that completes and reports the full rest. + // Splits at or after the envelope boundary are covered separately below. + for (let k = 0; k < ENVELOPE_BYTES.length; k++) { + const scanner = createEnvelopeScanner() + const first = scanner.push(FULL.subarray(0, k)) + assert.strictEqual(first, undefined, `expected split k=${k} to need more input`) + const result = scanner.push(FULL.subarray(k)) + if (result === undefined) { + throw new Error(`test bug: envelope did not complete for split k=${k}`) + } + assert.deepStrictEqual(result.decoded, EXPECTED_DECODED) + assert.deepStrictEqual(result.rest, CIPHERTEXT) + } + }) + + it('feeds one byte at a time and still completes correctly', () => { + const scanner = createEnvelopeScanner() + let result: ReturnType + for (let i = 0; i < ENVELOPE_BYTES.length && result === undefined; i++) { + result = scanner.push(FULL.subarray(i, i + 1)) + } + if (result === undefined) { + throw new Error('test bug: did not complete') + } + assert.deepStrictEqual(result.decoded, EXPECTED_DECODED) + // Only envelope bytes were ever fed in, one at a time; the ciphertext + // was never pushed, so this push's own rest is empty. + assert.strictEqual(result.rest.length, 0) + }) + + it('handles the minimal tag-16 envelope too', () => { + const minimal = hexToBytes(MINIMAL_ENVELOPE_TAG16_HEX) + const scanner = createEnvelopeScanner() + const result = scanner.push(minimal) + if (result === undefined) { + throw new Error('test bug: did not complete') + } + assert.deepStrictEqual(result.decoded, decodeEnvelope(minimal)) + assert.strictEqual(result.rest.length, 0) + }) + + it('accepts a safe integer recipient label encoded in eight bytes', () => { + const label = 4294967296 + const envelope = encodeEnvelope({ + protectedHeader: ENVELOPE_INPUT.protectedHeader, + recipients: [ + { + ...RECIPIENT_NO_KID, + unprotected: new Map([ + [1, ALG_A256KW], + [label, true], + ]), + }, + ], + }) + const result = createEnvelopeScanner().push(envelope) + + assert.strictEqual(result?.decoded.recipients[0].unprotected.get(label), true) + }) + + it('gives a rest view into the same block when envelope and ciphertext arrive together', () => { + const scanner = createEnvelopeScanner() + const result = scanner.push(FULL) + if (result === undefined) { + throw new Error('test bug: did not complete') + } + assert.strictEqual(result.rest.buffer, FULL.buffer) + assert.deepStrictEqual(result.rest, CIPHERTEXT) + }) + + it('gives an empty rest when the envelope ends exactly at a block boundary', () => { + const scanner = createEnvelopeScanner() + const result = scanner.push(ENVELOPE_BYTES) + if (result === undefined) { + throw new Error('test bug: did not complete') + } + assert.strictEqual(result.rest.length, 0) + }) + + it('is unaffected by mutating a pushed block after completion', () => { + const block = Uint8Array.from(FULL) + const scanner = createEnvelopeScanner() + const result = scanner.push(block) + if (result === undefined) { + throw new Error('test bug: did not complete') + } + const protectedBytesBefore = new Uint8Array(result.decoded.protectedHeader.bytes) + const ivBefore = new Uint8Array(result.decoded.protectedHeader.iv) + const recipientCountBefore = result.decoded.recipients.length + const firstCiphertextBefore = new Uint8Array(result.decoded.recipients[0].ciphertext) + + block.fill(0xff) // mutate the whole original block, envelope region included + + assert.deepStrictEqual(result.decoded.protectedHeader.bytes, protectedBytesBefore) + assert.deepStrictEqual(result.decoded.protectedHeader.iv, ivBefore) + assert.strictEqual(result.decoded.recipients.length, recipientCountBefore) + assert.deepStrictEqual(result.decoded.recipients[0].ciphertext, firstCiphertextBefore) + }) + + it('finish() throws when the source ends mid-envelope', () => { + const scanner = createEnvelopeScanner() + const result = scanner.push(ENVELOPE_BYTES.subarray(0, ENVELOPE_BYTES.length - 5)) + assert.strictEqual(result, undefined) + assert.throws(() => scanner.finish(), MalformedEnvelopeError) + }) + + it('finish() is a no-op once the envelope already completed', () => { + const scanner = createEnvelopeScanner() + scanner.push(ENVELOPE_BYTES) + assert.doesNotThrow(() => scanner.finish()) + }) + + it("surfaces decodeEnvelope's error for a structurally complete but profile-invalid envelope (wrong tag)", () => { + const protectedBytes = encodeProtectedHeader({ alg: ALG_AES_256_GCM, iv: FIXTURE_IV_12 }) + // d1 = tag 17: structurally identical to a real envelope, but not tag 16 or 96. + const badTag = concatBytes( + Uint8Array.from([0xd1, 0x83]), + cborEncode(protectedBytes, rfc8949EncodeOptions), + Uint8Array.from([0xa0, 0xf6]) + ) + const scanner = createEnvelopeScanner() + assert.throws(() => scanner.push(badTag), MalformedEnvelopeError) + }) + + describe('budget rejections happen before content bytes arrive', () => { + it('rejects a byte string head declaring more than the remaining budget', () => { + // major 2 (byte string), additional info 26 (4-byte length) + const head = concatBytes(Uint8Array.from([0x5a]), uint32be(MAX_ENVELOPE_SIZE)) + const scanner = createEnvelopeScanner() + assert.throws(() => scanner.push(head), MalformedEnvelopeError) + }) + + it('rejects an array count declaring more than the remaining budget', () => { + // major 4 (array), additional info 26 (4-byte length) + const head = concatBytes(Uint8Array.from([0x9a]), uint32be(MAX_ENVELOPE_SIZE)) + const scanner = createEnvelopeScanner() + assert.throws(() => scanner.push(head), MalformedEnvelopeError) + }) + + it('rejects a map count declaring more than the remaining budget', () => { + // major 5 (map), additional info 26 (4-byte length); each pair is 2 items. + const head = concatBytes(Uint8Array.from([0xba]), uint32be(Math.ceil(MAX_ENVELOPE_SIZE / 2))) + const scanner = createEnvelopeScanner() + assert.throws(() => scanner.push(head), MalformedEnvelopeError) + }) + }) + + it('completes an envelope of exactly MAX_ENVELOPE_SIZE bytes, and rejects one byte over as soon as certain', () => { + const atLimit = envelopeOfExactSize(MAX_ENVELOPE_SIZE) + assert.strictEqual(atLimit.length, MAX_ENVELOPE_SIZE) + const scanner = createEnvelopeScanner() + const result = scanner.push(atLimit) + if (result === undefined) { + throw new Error('test bug: at-limit envelope did not complete') + } + assert.strictEqual(result.rest.length, 0) + + // The overage is entirely inside the protected header's own byte string, + // so the scanner rejects it as soon as that string's declared length is + // known -- from the outer tag/array/bstr-head bytes alone, well before + // anywhere near a megabyte of content would need to arrive. + const oneOver = envelopeOfExactSize(MAX_ENVELOPE_SIZE + 1) + const overScanner = createEnvelopeScanner() + assert.throws(() => overScanner.push(oneOver), MalformedEnvelopeError) + }) +}) + +describe('scanEnvelopeStep', () => { + function stepOnce(bytes: Uint8Array): number | undefined { + return scanEnvelopeStep(bytes, createEnvelopeScanState()) + } + + it('rejects an indefinite-length byte string', () => { + assert.throws(() => stepOnce(Uint8Array.from([0x5f])), MalformedEnvelopeError) + }) + + it('rejects an indefinite-length array', () => { + assert.throws(() => stepOnce(Uint8Array.from([0x9f])), MalformedEnvelopeError) + }) + + it('rejects an indefinite-length map', () => { + assert.throws(() => stepOnce(Uint8Array.from([0xbf])), MalformedEnvelopeError) + }) + + it('rejects reserved additional information', () => { + assert.throws(() => stepOnce(Uint8Array.from([0x1c])), MalformedEnvelopeError) // major 0, info 28 + }) + + it('rejects a top-level break code', () => { + assert.throws(() => stepOnce(Uint8Array.from([0xff])), MalformedEnvelopeError) // major 7, info 31 + }) + + it('rejects an 8-byte length with a nonzero high half', () => { + // major 2 (byte string), additional info 27 (8-byte length), high half = 1 + const bytes = Uint8Array.from([0x5b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]) + assert.throws(() => stepOnce(bytes), MalformedEnvelopeError) + }) + + it('needs more input for a head split across the available bytes, and leaves the cursor at its start', () => { + // major 2, additional info 26 (4-byte length): 5-byte head, only 3 bytes given. + const state = createEnvelopeScanState() + const partialHead = Uint8Array.from([0x5a, 0x00, 0x00]) + assert.strictEqual(scanEnvelopeStep(partialHead, state), undefined) + assert.strictEqual(state.cursor, 0) + }) + + describe('depth, cross-checked against decodeExact', () => { + function nestedArray(depth: number): unknown[] { + let value: unknown[] = [] + for (let i = 0; i < depth; i++) { + value = [value] + } + return value + } + function encodeBare(value: unknown): Uint8Array { + return cborEncode(value, rfc8949EncodeOptions) + } + + it('accepts nesting exactly at the limit and rejects one level deeper, matching decodeExact', () => { + const lastAccepted = encodeBare(nestedArray(MAX_APP_METADATA_DEPTH - 1)) + assert.strictEqual(scanEnvelopeStep(lastAccepted, createEnvelopeScanState()), lastAccepted.length) + assert.doesNotThrow(() => decodeExact(lastAccepted)) + + const firstRejected = encodeBare(nestedArray(MAX_APP_METADATA_DEPTH)) + assert.throws(() => scanEnvelopeStep(firstRejected, createEnvelopeScanState()), MalformedEnvelopeError) + // decodeExact's own tokenizer throws a raw Error at this layer (only + // higher callers like decodeEnvelope wrap it) -- the point here is the + // shared depth boundary, not the error class. + assert.throws(() => decodeExact(firstRejected), Error) + }) + }) + + it('runs in linear time: reads stay bounded as input trickles in one byte at a time', () => { + const manyRecipients = Array.from({ length: 20 }, () => RECIPIENT_NO_KID) + const bytes = encodeEnvelope({ ...ENVELOPE_INPUT, recipients: manyRecipients }) + + let reads = 0 + let exposedLength = 0 + const countingView = new Proxy(bytes, { + get(target, prop, receiver) { + if (prop === 'length') return exposedLength + if (typeof prop === 'string' && Number.isInteger(Number(prop))) { + const index = Number(prop) + assert.ok(index < exposedLength, `read at index ${index} beyond the ${exposedLength} bytes exposed so far`) + reads++ + } + return Reflect.get(target, prop, receiver) + }, + }) + + const state = createEnvelopeScanState() + let steps = 0 + let previousCursor = 0 + let result: number | undefined + while (result === undefined) { + if (exposedLength >= bytes.length) { + throw new Error('test bug: never completed') + } + exposedLength++ + steps++ + result = scanEnvelopeStep(countingView, state) + assert.ok(state.cursor >= previousCursor, 'cursor must never move backwards') + previousCursor = state.cursor + } + + assert.strictEqual(result, bytes.length) + assert.strictEqual(previousCursor, bytes.length) + assert.ok(reads <= bytes.length + 9 * steps, `reads=${reads} exceeded bound of ${bytes.length + 9 * steps}`) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/public-surface.test.ts b/packages/filecoin-encryption-envelope/test/public-surface.test.ts index 2c313350..910979f6 100644 --- a/packages/filecoin-encryption-envelope/test/public-surface.test.ts +++ b/packages/filecoin-encryption-envelope/test/public-surface.test.ts @@ -12,6 +12,7 @@ import { EnvelopeError } from '../src/errors.ts' // `../src/index.ts` (the root barrel) is the file under test here; every // other test file in this package imports source files directly instead. import * as fee from '../src/index.ts' +import { concatBytes } from './cose-fixtures.ts' const EXPECTED_PUBLIC_CONSTANTS: Record = { ALG_A256KW, @@ -26,7 +27,16 @@ const EXPECTED_PUBLIC_CONSTANTS: Record = { describe('public surface (src/index.ts)', () => { it('exposes exactly the allowlisted root runtime exports', () => { - assert.deepStrictEqual(Object.keys(fee).sort(), ['aesGcm', 'constants', 'cose', 'encrypt', 'errors', 'recipients']) + assert.deepStrictEqual(Object.keys(fee).sort(), [ + 'aesGcm', + 'constants', + 'cose', + 'decrypt', + 'decryptWith', + 'encrypt', + 'errors', + 'recipients', + ]) }) it('encrypt works through the package root with pipeThrough', async () => { @@ -47,6 +57,44 @@ describe('public surface (src/index.ts)', () => { assert.strictEqual(chunks[1].length, 5 + 16) }) + it('round-trips through the package root only: encrypt then decrypt with a direct CEK', async () => { + const cek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => index + 1) + const plaintext = new TextEncoder().encode('hello, root barrel') + const source = new ReadableStream({ + start(controller) { + controller.enqueue(plaintext) + controller.close() + }, + }) + const chunks: Uint8Array[] = [] + for await (const chunk of source + .pipeThrough(fee.encrypt({ cek, chunkSize: MIN_CHUNK_SIZE })) + .pipeThrough(fee.decrypt(cek))) { + chunks.push(chunk) + } + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + + it('round-trips through the package root only: encrypt then decryptWith an A256KW recipient', async () => { + const cek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => index + 1) + const kek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => 0x80 + index) + const plaintext = new TextEncoder().encode('hello, root barrel') + const source = new ReadableStream({ + start(controller) { + controller.enqueue(plaintext) + controller.close() + }, + }) + const unwrapper = await fee.recipients.createA256KWUnwrapper([{ kek }]) + const chunks: Uint8Array[] = [] + for await (const chunk of source + .pipeThrough(fee.encrypt({ cek, chunkSize: MIN_CHUNK_SIZE, recipients: [{ alg: ALG_A256KW, kek }] })) + .pipeThrough(fee.decryptWith(unwrapper))) { + chunks.push(chunk) + } + assert.deepStrictEqual(concatBytes(...chunks), plaintext) + }) + it('aesGcm exposes exactly decrypt, decryptWith, and encrypt', () => { assert.deepStrictEqual(Object.keys(fee.aesGcm).sort(), ['decrypt', 'decryptWith', 'encrypt']) })