diff --git a/packages/filecoin-encryption-envelope/CLAUDE.md b/packages/filecoin-encryption-envelope/CLAUDE.md index c675fbda..65d7e52f 100644 --- a/packages/filecoin-encryption-envelope/CLAUDE.md +++ b/packages/filecoin-encryption-envelope/CLAUDE.md @@ -17,9 +17,11 @@ structural recipient validation), scheme-1 AES-256-GCM encryption and decryption 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 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. +or A256KW recipients, optional `plaintext_length`), unauthenticated envelope inspection (`parse`) and +authenticated range reads from a `RandomAccessSource` (`decryptRange`, `decryptRangeWith`), both under +`range/`. Not yet implemented: persisting and restoring cached +`ChunkedEnvelopeParams` (they are in-memory only). ECDH-ES+A256KW remains deferred; the code enforces its +settled header placement but does not derive or unwrap its KEK. ## Scope discipline @@ -37,10 +39,10 @@ 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`, `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 +Two exceptions to "namespaces only": shared type-only exports (such as `AppMetadata`, `CborValue`), +since a type carries no runtime shape, and the chunked-scheme functions (`encrypt`, `decrypt`, +`decryptWith`, `parse`, `decryptRange`, `decryptRangeWith`, and their public types), 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 @@ -50,6 +52,16 @@ export type { AppMetadata, CborValue } from './cose/headers.ts' export * as cose from './cose/index.ts' export * as errors from './errors.ts' export * as constants from './public-constants.ts' +export { + type ByteRange, + type ChunkedEnvelopeParams, + decryptRange, + decryptRangeWith, + type EnvelopeInfo, + parse, + type RandomAccessSource, + type RangeResult, +} from './range/index.ts' export * as recipients from './recipients/index.ts' ``` diff --git a/packages/filecoin-encryption-envelope/src/errors.ts b/packages/filecoin-encryption-envelope/src/errors.ts index cef9224e..1b02dad2 100644 --- a/packages/filecoin-encryption-envelope/src/errors.ts +++ b/packages/filecoin-encryption-envelope/src/errors.ts @@ -76,6 +76,20 @@ export class InvalidNonceError extends EnvelopeError { override name = 'InvalidNonceError' } +/** A requested byte range cannot be served: malformed, out of bounds, or empty. HTTP 416 territory. */ +export class InvalidRangeError extends EnvelopeError { + override name = 'InvalidRangeError' +} + +/** + * A random-access source's `size` is invalid, or an opened range produced + * fewer or more bytes than requested. A transport or source fault, not a bad + * ciphertext layout. + */ +export class InvalidSourceLengthError extends EnvelopeError { + override name = 'InvalidSourceLengthError' +} + /** * The envelope, or a value destined for one, does not match this package's * wire profile. Raised on both paths: by encode when a caller's input would diff --git a/packages/filecoin-encryption-envelope/src/index.ts b/packages/filecoin-encryption-envelope/src/index.ts index 721a9bfc..3c84a9d3 100644 --- a/packages/filecoin-encryption-envelope/src/index.ts +++ b/packages/filecoin-encryption-envelope/src/index.ts @@ -7,6 +7,7 @@ * * source.pipeThrough(fee.encrypt({ cek })) // chunked stream, the default * encrypted.pipeThrough(fee.decrypt(cek)) // and back + * await fee.decryptRange(object, cek, { offset: 1024, length: 4096 }) // one byte range * fee.aesGcm.encrypt(plaintext, { cek }) // whole-object, opt-in * fee.constants.ALG_A256KW * ``` @@ -19,4 +20,14 @@ export type { AppMetadata, CborValue } from './cose/headers.ts' export * as cose from './cose/index.ts' export * as errors from './errors.ts' export * as constants from './public-constants.ts' +export { + type ByteRange, + type ChunkedEnvelopeParams, + decryptRange, + decryptRangeWith, + type EnvelopeInfo, + parse, + type RandomAccessSource, + type RangeResult, +} from './range/index.ts' export * as recipients from './recipients/index.ts' diff --git a/packages/filecoin-encryption-envelope/src/range/decrypt.ts b/packages/filecoin-encryption-envelope/src/range/decrypt.ts new file mode 100644 index 00000000..699a7cb0 --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/range/decrypt.ts @@ -0,0 +1,301 @@ +/** + * Authenticated range decryption for the chunked scheme: fetch and decrypt + * only the chunks a byte range touches. See docs/tech-spec.md's + * `decryptRange`/`RangeResult` block and "Random-access source contract". + */ +import { ALG_CHUNKED_AES_256_GCM_STREAM, TAG_SIZE } from '../constants.ts' +import type { DecodedEnvelope } from '../cose/decode.ts' +import { encStructure } from '../cose/enc-structure.ts' +import { describeCborType } from '../cose/headers.ts' +import { InvalidSourceLengthError, MalformedEnvelopeError, UnsupportedSchemeError } from '../errors.ts' +import { assertAes256Key } from '../internal/keys.ts' +import { aesGcmDecrypt, importAesGcmKey } from '../internal/web-crypto.ts' +import { deriveChunkNonce } from '../nonce.ts' +import { recoverCek } from '../recipients/recover.ts' +import type { Unwrapper } from '../recipients/types.ts' +import { type ChunkedEnvelopeParams, paramsState } from './inspect.ts' +import { type ByteRange, planRange, type RangePlan } from './plan.ts' +import { + type ExactRangeReader, + openExactRange, + type RandomAccessSource, + readEnvelope, + toRandomAccessSource, +} from './source.ts' + +export interface RangeResult { + /** Plaintext for the requested range, one authenticated chunk at a time. */ + stream: ReadableStream + /** Bytes `stream` will emit: `Content-Length` for this response. */ + rangeLength: number + /** The whole object's plaintext length: `Content-Range` total. */ + totalPlaintextLength: number + /** What will actually be fetched from `source`: absolute bytes, envelope included. */ + ciphertextSpan: { offset: number; length: number } + /** + * Whether the requested span includes the object's presumed final chunk. + * Its `last_flag` is authenticated once that chunk is actually read from + * `stream` -- this field alone doesn't prove the object wasn't truncated. + */ + includesFinalChunk: boolean +} + +interface RangeDecryptOptions { + params?: ChunkedEnvelopeParams +} + +function readParams(options: RangeDecryptOptions | undefined): ChunkedEnvelopeParams | undefined { + if (options === undefined) return undefined + // An array passes typeof 'object' but isn't a valid options shape. + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new MalformedEnvelopeError( + `Invalid range decryption options: expected an object, got ${describeCborType(options)}.` + ) + } + const { params } = options + return params +} + +/** + * Assemble one ciphertext piece at a time from `reader`: a zero-copy + * `subarray` when a single block covers the whole piece, otherwise copied + * into a scratch buffer (sized to the largest possible piece, one stride) + * across blocks. Any unused tail of a block is held for the next piece. + */ +function createPieceReader(reader: ExactRangeReader, stride: number) { + let heldBlock: Uint8Array | undefined + let heldOffset = 0 + // Allocated only once a piece spans blocks; contiguous sources never need it. + let scratch: Uint8Array | undefined + + async function nextBlock(): Promise> { + if (heldBlock !== undefined && heldOffset < heldBlock.length) return heldBlock + const block = await reader.read() + if (block === undefined) { + throw new InvalidSourceLengthError( + 'Invalid range source: the opened range ended before its planned bytes were fully read.' + ) + } + heldBlock = block + heldOffset = 0 + return block + } + + async function readPiece(pieceLength: number): Promise> { + let filled = 0 + for (;;) { + const block = await nextBlock() + const available = block.length - heldOffset + if (filled === 0 && available >= pieceLength) { + // Zero-copy: nothing copied yet, and this block alone covers it. + const view = block.subarray(heldOffset, heldOffset + pieceLength) as Uint8Array + heldOffset += pieceLength + return view + } + const take = Math.min(available, pieceLength - filled) + scratch ??= new Uint8Array(stride) + scratch.set(block.subarray(heldOffset, heldOffset + take), filled) + filled += take + heldOffset += take + if (heldOffset === block.length) { + heldBlock = undefined + heldOffset = 0 + } + if (filled === pieceLength) { + return scratch.subarray(0, pieceLength) as Uint8Array + } + } + } + + return { readPiece } +} + +interface KeyedRangePlan { + cekKey: CryptoKey + /** The `Enc_structure` AAD, shared by every chunk in this range. */ + additionalData: Uint8Array + /** The envelope's IV; each chunk's nonce derives from this plus its index. */ + baseNonce: Uint8Array +} + +/** + * The range's output stream. Opens `plan.ciphertextSpan` on the first pull + * (not before), reassembles each planned chunk piece, and releases its + * plaintext only once that chunk's own tag verifies. + */ +function createRangeStream( + source: RandomAccessSource, + plan: RangePlan, + chunkSize: number, + keyed: KeyedRangePlan +): ReadableStream { + const stride = chunkSize + TAG_SIZE + let opened: { reader: ExactRangeReader; pieces: ReturnType } | undefined + let index = plan.firstChunk + let remainingToEmit = plan.rangeLength + + return new ReadableStream( + { + async pull(controller) { + let reader: ExactRangeReader | undefined + try { + if (opened === undefined) { + const newReader = openExactRange(source, plan.ciphertextSpan.offset, plan.ciphertextSpan.length) + opened = { reader: newReader, pieces: createPieceReader(newReader, stride) } + } + reader = opened.reader + + const isLastPiece = index === plan.lastChunk + const pieceLength = isLastPiece ? plan.lastChunkCipherLength : stride + const piece = await opened.pieces.readPiece(pieceLength) + + // The size-derived layout decides finality, never "last piece of this span". + const isFinalChunk = index === plan.chunkCount - 1 + const nonce = deriveChunkNonce(keyed.baseNonce, index, isFinalChunk) + // Decrypt before reading again: `piece` may be a view into a block + // the source could reuse or overwrite on the next read. + let plaintext = await aesGcmDecrypt(keyed.cekKey, nonce, keyed.additionalData, piece) + + if (isLastPiece) { + // Confirms the source has nothing left beyond the planned span, + // before this final piece's plaintext is ever released. + const extra = await reader.read() + if (extra !== undefined) { + throw new InvalidSourceLengthError('Invalid range source: produced extra bytes beyond the planned span.') + } + } + + if (index === plan.firstChunk && plan.skip > 0) { + plaintext = plaintext.subarray(plan.skip) + } + if (plaintext.length > remainingToEmit) { + plaintext = plaintext.subarray(0, remainingToEmit) + } + remainingToEmit -= plaintext.length + + if (plaintext.length > 0) { + controller.enqueue(plaintext) + } + index++ + if (isLastPiece) { + controller.close() + } + } catch (cause) { + await reader?.cancel(cause) + throw cause + } + }, + cancel(reason) { + return opened?.reader.cancel(reason) + }, + }, + { highWaterMark: 0 } + ) +} + +/** + * Shared steps for `decryptRange` and `decryptRangeWith`: resolve the source + * and envelope, plan the range, resolve the key, and hand back a + * `RangeResult` before any ciphertext is opened. + */ +async function createRangeResult( + source: Uint8Array | RandomAccessSource, + range: unknown, + options: RangeDecryptOptions | undefined, + getCekKey: (decoded: DecodedEnvelope) => Promise +): Promise { + const randomAccessSource = toRandomAccessSource(source) + const params = readParams(options) + + // With params, the envelope is never read: `paramsState` hands back the + // one this package already decoded when it created them. + const decoded = params === undefined ? await readEnvelope(randomAccessSource) : paramsState(params).decoded + if (decoded.protectedHeader.alg !== ALG_CHUNKED_AES_256_GCM_STREAM) { + throw new UnsupportedSchemeError( + `Unsupported content algorithm ${decoded.protectedHeader.alg}: range decryption requires alg ${ALG_CHUNKED_AES_256_GCM_STREAM}.` + ) + } + const { chunkSize, plaintextLength } = decoded.protectedHeader + if (chunkSize === undefined) { + throw new Error('unreachable: the chunked alg always carries chunk_size') + } + + const plan = planRange( + { sourceSize: randomAccessSource.size, headerLength: decoded.envelopeLength, chunkSize, plaintextLength }, + range + ) + + const cekKey = await getCekKey(decoded) + const additionalData = encStructure(decoded.tag, decoded.protectedHeader.bytes) + // Copied: retained for every chunk over the life of the stream. + const baseNonce = new Uint8Array(decoded.protectedHeader.iv) + + return { + stream: createRangeStream(randomAccessSource, plan, chunkSize, { cekKey, additionalData, baseNonce }), + rangeLength: plan.rangeLength, + totalPlaintextLength: plan.totalPlaintextLength, + // A copy: the stream opens `plan.ciphertextSpan` later, so a caller + // editing this field must not change what gets fetched. + ciphertextSpan: { ...plan.ciphertextSpan }, + includesFinalChunk: plan.includesFinalChunk, + } +} + +/** + * Decrypt one byte range of a chunked object using a direct CEK. + * + * The promise settles with every result field -- `rangeLength`, + * `totalPlaintextLength`, `ciphertextSpan`, `includesFinalChunk` -- known + * before any ciphertext is fetched, so a caller can send response headers + * first. `ciphertextSpan` is one contiguous request, opened lazily on the + * stream's first read; each chunk's plaintext is released only after its own + * tag verifies. + * + * `cek` is borrowed until this promise settles; `source` is borrowed until + * `stream` closes or errors. Pass `options.params` from `parse()` on this + * same object version to skip re-reading the envelope. A mismatch isn't + * guaranteed to be caught: most fail authentication, but versions that differ + * only outside the protected header and the chunks read can still decrypt. + * + * Truncation: a declared `plaintext_length` catches a size mismatch before + * any fetch. A range that includes the presumed final chunk catches + * truncation via that chunk's authenticated `last_flag`. A range stopping + * short of the end, on an object with neither, can't tell. + * + * If `stream` errors, discard everything already read from it. + */ +export async function decryptRange( + source: RandomAccessSource | Uint8Array, + cek: Uint8Array, + range: ByteRange, + options?: RangeDecryptOptions +): Promise { + assertAes256Key(cek, 'CEK') + return createRangeResult(source, range, options, () => importAesGcmKey(cek, 'decrypt', false)) +} + +/** + * Like `decryptRange`, but recovers the CEK from the envelope's recipients + * through `unwrapper`. + * + * - Tag 96 only: a tag-16 object fails with `NoUsableRecipientError` without + * calling `unwrapper`. + * - `unwrapper` is called once, with copies of every recipient in wire order, + * also when `options.params` skips the envelope read; its CEK is validated. + * - The range is planned first and the key recovered before the promise + * settles, so recipient failures reject it, never the stream. + */ +export async function decryptRangeWith( + source: RandomAccessSource | Uint8Array, + unwrapper: Unwrapper, + range: ByteRange, + options?: RangeDecryptOptions +): Promise { + if (typeof unwrapper !== 'function') { + throw new MalformedEnvelopeError(`Invalid unwrapper: expected a function, got ${describeCborType(unwrapper)}.`) + } + return createRangeResult(source, range, options, async (decoded) => { + const cek = await recoverCek(decoded, unwrapper) + return importAesGcmKey(cek, 'decrypt', false) + }) +} diff --git a/packages/filecoin-encryption-envelope/src/range/index.ts b/packages/filecoin-encryption-envelope/src/range/index.ts new file mode 100644 index 00000000..fa055bc1 --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/range/index.ts @@ -0,0 +1,15 @@ +/** + * Random-access support for chunked encryption (scheme 2). + * + * `parse` inspects an encoded object without a key and, for chunked objects, + * returns the parameters `decryptRange` can reuse. It can also inspect + * scheme-1 objects, returning their scheme and envelope metadata, but range + * decryption is supported only for the chunked scheme. + * + * The implementation details used by these APIs (`paramsState`, `planRange`, + * `readEnvelope`, and `openExactRange`) are kept in separate modules. + */ +export { decryptRange, decryptRangeWith, type RangeResult } from './decrypt.ts' +export { type ChunkedEnvelopeParams, type EnvelopeInfo, parse } from './inspect.ts' +export type { ByteRange } from './plan.ts' +export type { RandomAccessSource } from './source.ts' diff --git a/packages/filecoin-encryption-envelope/src/range/inspect.ts b/packages/filecoin-encryption-envelope/src/range/inspect.ts new file mode 100644 index 00000000..93e1a21e --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/range/inspect.ts @@ -0,0 +1,112 @@ +/** + * Unauthenticated envelope inspection. `parse()` reads just enough of a + * source to report scheme, headers, and recipients, and -- for the chunked + * scheme -- caches parameters range decryption can reuse instead of + * re-reading the envelope. See docs/tech-spec.md's `parse`/`EnvelopeInfo`/ + * `ChunkedEnvelopeParams` block and "Cached chunked-envelope parameters". + * + * Nothing here is authenticated. Content type, application metadata, + * recipients, and every size come from CBOR structure alone; none of it is + * trustworthy until an AEAD tag over the relevant chunk verifies it. + * + * `ChunkedEnvelopeParams` only saves a re-read of the envelope: it must be + * used against the same immutable object version it was read from. A + * mismatch isn't guaranteed to be caught: most fail authentication, but + * versions that differ only outside the protected header and the chunks read + * can still decrypt. Params exist only in memory; persisting or restoring + * them is out of scope here. + */ +import { ALG_AES_256_GCM } from '../constants.ts' +import type { DecodedEnvelope } from '../cose/decode.ts' +import type { AppMetadata } from '../cose/headers.ts' +import { MalformedEnvelopeError } from '../errors.ts' +import { toRecipientInfo } from '../recipients/info.ts' +import type { RecipientInfo } from '../recipients/types.ts' +import { type RandomAccessSource, readEnvelope, toRandomAccessSource } from './source.ts' + +/** Cached values from one chunked envelope's protected header, for range decryption to reuse. */ +export interface ChunkedEnvelopeParams { + readonly scheme: 'chunked' + /** Byte length of the envelope: where the detached ciphertext begins. */ + readonly headerLength: number + readonly chunkSize: number + readonly plaintextLength?: number +} + +interface EnvelopeInfoBase { + contentType?: string | number + appMetadata?: AppMetadata + /** Isolated copies; empty for a tag-16 (`COSE_Encrypt0`) object. */ + recipients: RecipientInfo[] +} + +/** Only the chunked scheme carries `params`: scheme 1 is decrypted as one complete object, never by range. */ +export type EnvelopeInfo = + | (EnvelopeInfoBase & { scheme: 'aes-gcm' }) + | (EnvelopeInfoBase & { scheme: 'chunked'; params: ChunkedEnvelopeParams }) + +/** The decoded envelope a `ChunkedEnvelopeParams` was created from. */ +export interface ParamsState { + decoded: DecodedEnvelope +} + +/** + * Registers every `ChunkedEnvelopeParams` this module has created, keyed by + * object identity. This is what makes params library-created rather than + * merely shaped like one: a look-alike object with the same public fields + * was never put here, so {@link paramsState} rejects it. + */ +const paramsRegistry = new WeakMap() + +/** + * Look up the decoded envelope behind a `ChunkedEnvelopeParams`, for range + * decryption to reuse instead of decoding again. + */ +export function paramsState(params: unknown): ParamsState { + const state = typeof params === 'object' && params !== null ? paramsRegistry.get(params) : undefined + if (state === undefined) { + throw new MalformedEnvelopeError( + 'Invalid chunked envelope params: must come from parse(), not a look-alike object.' + ) + } + return state +} + +function createChunkedParams(decoded: DecodedEnvelope): ChunkedEnvelopeParams { + const { chunkSize, plaintextLength } = decoded.protectedHeader + if (chunkSize === undefined) { + throw new Error('unreachable: the chunked alg always carries chunk_size') + } + const params: ChunkedEnvelopeParams = Object.freeze({ + scheme: 'chunked' as const, + headerLength: decoded.envelopeLength, + chunkSize, + ...(plaintextLength === undefined ? {} : { plaintextLength }), + }) + paramsRegistry.set(params, { decoded }) + return params +} + +/** + * Inspect an encoded FEE object without a key: scheme, content type, + * application metadata, and recipients. Unauthenticated -- see above. + * + * Reads only as much of `source` as it takes to find the envelope boundary + * (see `readEnvelope`), never the detached ciphertext. + */ +export async function parse(source: Uint8Array | RandomAccessSource): Promise { + const decoded = await readEnvelope(toRandomAccessSource(source)) + const { alg, contentType, appMetadata } = decoded.protectedHeader + + const base: EnvelopeInfoBase = { + ...(contentType === undefined ? {} : { contentType }), + ...(appMetadata === undefined ? {} : { appMetadata }), + recipients: decoded.recipients.map(toRecipientInfo), + } + + if (alg === ALG_AES_256_GCM) { + return { ...base, scheme: 'aes-gcm' } + } + // decodeEnvelope accepts only ALG_AES_256_GCM or the chunked alg. + return { ...base, scheme: 'chunked', params: createChunkedParams(decoded) } +} diff --git a/packages/filecoin-encryption-envelope/src/range/plan.ts b/packages/filecoin-encryption-envelope/src/range/plan.ts new file mode 100644 index 00000000..48cf3caf --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/range/plan.ts @@ -0,0 +1,157 @@ +/** + * Turn a caller's byte range into an exact ciphertext span and chunk plan for + * the chunked scheme. Pure arithmetic: no I/O, no crypto, no streams -- every + * input is a plain number already in hand (from a decoded envelope, or from + * library-created cached params). See docs/tech-spec.md, "Random-access + * source contract" and its `RandomAccessSource`/`decryptRange` API block. + * + * Semantics (HTTP-like): + * - The object's total plaintext length always comes from `sourceSize` and + * `headerLength` via `chunkLayout`, never from a cached/declared + * `plaintextLength` alone -- that value is only ever checked against the + * derived layout, the same direction every other reader in this package + * checks it. + * - An empty object (`totalPlaintextLength === 0`) has no valid range; + * `decrypt()` already handles an empty object directly. + * - `offset >= 0`: `offset` must be strictly less than the total. `end` is + * `offset + length`, clamped to the total; omitting `length` means "to the + * end". + * - `offset < 0`: a suffix range, e.g. `-1024` means "the last 1024 bytes". + * It carries no `length`. An overlong suffix clamps to the whole object + * rather than failing. + */ +import { chunkLayout } from '../chunk-layout.ts' +import { TAG_SIZE } from '../constants.ts' +import { describeCborType } from '../cose/headers.ts' +import { InvalidCiphertextLengthError, InvalidRangeError, InvalidSourceLengthError } from '../errors.ts' + +/** A byte range over an object's plaintext, HTTP `Range`-header style. */ +export interface ByteRange { + /** Non-negative: from the start. Negative: a suffix, e.g. `-1024` is the last 1024 bytes. */ + offset: number + /** Bytes to include. Omitted means "to the end". Forbidden together with a negative `offset`. */ + length?: number +} + +/** The chunked envelope values a plan is computed from -- trusted, already-validated numbers. */ +export interface ChunkedRangeLayoutInput { + /** Total size of the encoded object: envelope plus detached ciphertext. */ + sourceSize: number + /** Byte length of the envelope, i.e. where the detached ciphertext begins. */ + headerLength: number + chunkSize: number + /** Checked against the layout `chunkLayout` derives from `sourceSize`/`headerLength`, not trusted outright. */ + plaintextLength?: number +} + +export interface RangePlan { + /** The whole object's plaintext length, derived from the source size, not from `plaintextLength` alone. */ + totalPlaintextLength: number + /** Bytes this range actually covers: `end - start`. */ + rangeLength: number + /** Absolute bytes to fetch from the source, envelope included. */ + ciphertextSpan: { offset: number; length: number } + /** Index of the first chunk the range touches. */ + firstChunk: number + /** Index of the last chunk the range touches (inclusive). */ + lastChunk: number + /** Total chunks in the object. */ + chunkCount: number + /** Plaintext bytes to drop from the start of the first decrypted chunk. */ + skip: number + /** Whether `lastChunk` is the object's actual final chunk (`lastChunk === chunkCount - 1`). */ + includesFinalChunk: boolean + /** Wire length (ciphertext plus tag) of `lastChunk`: `chunkSize + TAG_SIZE`, or the object's real final length when `includesFinalChunk`. */ + lastChunkCipherLength: number +} + +/** Validate `range`, reading each field once, and return that snapshot. */ +function readByteRange(range: unknown): ByteRange { + if (range === null || typeof range !== 'object') { + throw new InvalidRangeError(`Invalid range: expected an object, got ${describeCborType(range)}.`) + } + const { offset, length } = range as { offset: unknown; length: unknown } + if (typeof offset !== 'number' || !Number.isSafeInteger(offset)) { + throw new InvalidRangeError( + `Invalid range offset: ${describeCborType(offset)} ${String(offset)}. Expected a safe integer.` + ) + } + if (length !== undefined && (typeof length !== 'number' || !Number.isSafeInteger(length) || length <= 0)) { + throw new InvalidRangeError( + `Invalid range length: ${describeCborType(length)} ${String(length)}. Expected a positive safe integer, or omitted.` + ) + } + if (offset < 0 && length !== undefined) { + throw new InvalidRangeError( + `Invalid range: a suffix offset (${offset}) takes no length; a suffix always runs to the end.` + ) + } + return { offset, length } +} + +/** + * Plan the exact ciphertext span and chunk indices `range` needs, for a + * chunked object already located by `layoutInput`. Reused identically by + * `decryptRange` and `decryptRangeWith`. + */ +export function planRange(layoutInput: ChunkedRangeLayoutInput, range: unknown): RangePlan { + const { sourceSize, headerLength, chunkSize, plaintextLength: declaredPlaintextLength } = layoutInput + if (sourceSize < headerLength) { + throw new InvalidSourceLengthError( + `Invalid range source: size ${sourceSize} is smaller than the envelope's header length ${headerLength}.` + ) + } + + const layout = chunkLayout(sourceSize - headerLength, chunkSize) + if (declaredPlaintextLength !== undefined && layout.plaintextLength !== declaredPlaintextLength) { + throw new InvalidCiphertextLengthError( + `Invalid encoded object: the ciphertext implies a plaintext of ${layout.plaintextLength} bytes, ` + + `but the declared plaintext_length is ${declaredPlaintextLength}.` + ) + } + const total = layout.plaintextLength + + // One snapshot: a getter must not answer differently after validation. + const { offset, length } = readByteRange(range) + + if (total === 0) { + throw new InvalidRangeError('Invalid range: the object is empty; decrypt() handles an empty object directly.') + } + + let start: number + let end: number + if (offset < 0) { + // Suffix: an overlong request just means "the whole object". + start = Math.max(0, total + offset) + end = total + } else { + if (offset >= total) { + throw new InvalidRangeError(`Invalid range offset ${offset}: at or past the total plaintext length ${total}.`) + } + // Avoids ever forming offset + length when length could be astronomically + // large (up to Number.MAX_SAFE_INTEGER) -- compare against the remainder instead. + end = length === undefined || length >= total - offset ? total : offset + length + start = offset + } + + const stride = chunkSize + TAG_SIZE + const firstChunk = Math.floor(start / chunkSize) + const lastChunk = Math.floor((end - 1) / chunkSize) + const skip = start - firstChunk * chunkSize + const includesFinalChunk = lastChunk === layout.chunkCount - 1 + + const spanOffset = headerLength + firstChunk * stride + const spanEnd = includesFinalChunk ? sourceSize : headerLength + (lastChunk + 1) * stride + + return { + totalPlaintextLength: total, + rangeLength: end - start, + ciphertextSpan: { offset: spanOffset, length: spanEnd - spanOffset }, + firstChunk, + lastChunk, + chunkCount: layout.chunkCount, + skip, + includesFinalChunk, + lastChunkCipherLength: includesFinalChunk ? layout.lastChunkCipherLength : stride, + } +} diff --git a/packages/filecoin-encryption-envelope/src/range/source.ts b/packages/filecoin-encryption-envelope/src/range/source.ts new file mode 100644 index 00000000..fab5c5b3 --- /dev/null +++ b/packages/filecoin-encryption-envelope/src/range/source.ts @@ -0,0 +1,257 @@ +/** + * Random-access byte sources for range decryption: the `RandomAccessSource` + * contract, adapting a plain `Uint8Array` or a caller object to it, reading + * exactly one requested range, and reading a chunked envelope from one + * without loading the whole object. See docs/tech-spec.md, "Random-access + * source contract". + */ + +import { MAX_ENCODED_OBJECT_SIZE } from '../constants.ts' +import { MAX_ENVELOPE_SIZE } from '../cose/constants.ts' +import type { DecodedEnvelope } from '../cose/decode.ts' +import { createEnvelopeScanner } from '../cose/envelope-scanner.ts' +import { describeCborType } from '../cose/headers.ts' +import { InvalidSourceLengthError, MalformedEnvelopeError } from '../errors.ts' +import { assertArrayBufferBacked } from '../internal/keys.ts' + +/** One immutable encoded FEE object, readable by byte range. */ +export interface RandomAccessSource { + /** Exact size of the encoded object: envelope plus detached ciphertext. */ + readonly size: number + /** + * Open the half-open byte range `[offset, offset + length)`. The returned + * stream must produce exactly `length` bytes and then close; a short or long + * response is rejected rather than reinterpreted, and a stream error + * propagates unchanged. + */ + openRange(offset: number, length: number): Promise> +} + +function assertValidSourceSize(size: unknown): asserts size is number { + if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0 || size > MAX_ENCODED_OBJECT_SIZE) { + throw new InvalidSourceLengthError( + `Invalid range source size: ${describeCborType(size)} ${String(size)}. Expected a non-negative safe ` + + `integer of at most ${MAX_ENCODED_OBJECT_SIZE} bytes.` + ) + } +} + +/** + * Adapt `input` to `RandomAccessSource`: a `Uint8Array` (the whole object + * already in memory) or an object already shaped like one. This is the one + * place that validates a caller-supplied source. + * + * `size` and `openRange` are each read from `input` exactly once, so a + * getter or a later mutation can't change what the rest of the library sees. + * A `Uint8Array` input is borrowed, not copied, until every range opened + * from it is fully read or cancelled. + */ +export function toRandomAccessSource(input: unknown): RandomAccessSource { + if (input instanceof Uint8Array) { + assertArrayBufferBacked(input, 'range source', (message) => new MalformedEnvelopeError(message)) + const bytes = input + return { + size: bytes.length, + openRange(offset, length) { + return Promise.resolve( + new ReadableStream({ + start(controller) { + // Borrowed, not copied: the caller must not modify `input` + // until every range read from it has completed. + controller.enqueue(bytes.subarray(offset, offset + length)) + controller.close() + }, + }) + ) + }, + } + } + + if (input === null || typeof input !== 'object') { + throw new MalformedEnvelopeError( + `Invalid range source: expected a Uint8Array or an object, got ${describeCborType(input)}.` + ) + } + + const { size, openRange } = input as { size: unknown; openRange: unknown } + assertValidSourceSize(size) + if (typeof openRange !== 'function') { + throw new MalformedEnvelopeError( + `Invalid range source: openRange must be a function, got ${describeCborType(openRange)}.` + ) + } + const boundOpenRange = openRange as RandomAccessSource['openRange'] + return { + size, + openRange: (offset, length) => boundOpenRange.call(input, offset, length), + } +} + +/** One block, or the underlying stream's end, from `readNextNonEmpty`. */ +type NextBlock = { done: true } | { done: false; value: Uint8Array } + +async function readNextNonEmpty(reader: ReadableStreamDefaultReader): Promise { + for (;;) { + const { value, done } = await reader.read() + if (done) return { done: true } + if (!(value instanceof Uint8Array)) { + throw new MalformedEnvelopeError( + `Invalid range source block: expected a Uint8Array, got ${describeCborType(value)}.` + ) + } + assertArrayBufferBacked(value, 'range source block', (message) => new MalformedEnvelopeError(message)) + // Content-free chunks carry no bytes to count; keep reading past them. + if (value.length === 0) continue + return { done: false, value } + } +} + +/** A reader over one opened range, confirmed to produce exactly `length` bytes. */ +export interface ExactRangeReader { + /** The next non-empty block, or `undefined` once exactly `length` bytes are confirmed complete. */ + read(): Promise | undefined> + /** Stop early. Safe to call at any point, including before the first `read()`. */ + cancel(reason?: unknown): Promise +} + +/** + * Open `[offset, offset + length)` from `source` and read it back as exactly + * `length` bytes, or fail with `InvalidSourceLengthError`. Reused wherever a + * caller needs one range's bytes without trusting the source to have + * measured them correctly: too few, too many, or extra bytes discovered only + * after the count matches are all rejected. + * + * `openRange` itself is called at most once. A throw or rejection from it, or + * from the stream it returns, propagates unchanged -- those are the source's + * own transport errors, not this reader's to interpret. + */ +export function openExactRange(source: RandomAccessSource, offset: number, length: number): ExactRangeReader { + let readerPromise: Promise> | undefined + let opened = false + let received = 0 + let finished = false + + function getReader(): Promise> { + if (readerPromise === undefined) { + readerPromise = (async () => { + const stream = await source.openRange(offset, length) + if (!(stream instanceof ReadableStream)) { + throw new MalformedEnvelopeError( + `Invalid range source: openRange(${offset}, ${length}) must resolve to a ReadableStream, got ` + + `${describeCborType(stream)}.` + ) + } + opened = true + return stream.getReader() + })() + } + return readerPromise + } + + async function read(): Promise | undefined> { + if (finished) return undefined + const reader = await getReader() + try { + return await readValidated(reader) + } catch (cause) { + // Release the source (e.g. an HTTP body) instead of leaving it open. + await reader.cancel(cause).catch(() => undefined) + throw cause + } + } + + async function readValidated( + reader: ReadableStreamDefaultReader + ): Promise | undefined> { + if (received === length) { + // Every requested byte was already returned by an earlier call. A + // normal `while ((block = await read()) !== undefined)` loop reaches + // this exact branch on its last iteration, so this is where "nothing + // more is coming" gets confirmed -- read() must not report done + // (return undefined) without having checked. + const trailing = await readNextNonEmpty(reader) + if (!trailing.done) { + throw new InvalidSourceLengthError( + `Invalid range source: openRange(${offset}, ${length}) produced extra bytes after the ${length} ` + + 'requested were already read.' + ) + } + finished = true + return undefined + } + + const next = await readNextNonEmpty(reader) + if (next.done) { + throw new InvalidSourceLengthError( + `Invalid range source: openRange(${offset}, ${length}) produced only ${received} of the ${length} ` + + 'requested bytes before ending.' + ) + } + + const { value } = next + if (received + value.length > length) { + throw new InvalidSourceLengthError( + `Invalid range source: openRange(${offset}, ${length}) produced more than the ${length} requested bytes.` + ) + } + received += value.length + return value + } + + function cancel(reason?: unknown): Promise { + if (readerPromise === undefined) return Promise.resolve() + // Nothing useful to report if the source fails to cancel. + const cancelled = readerPromise.then( + (reader) => reader.cancel(reason).catch(() => undefined), + () => undefined + ) + // A pending openRange has no abort signal: don't let a hung open hang + // cancellation. Its stream is still cancelled whenever it arrives. + return opened ? cancelled : Promise.resolve() + } + + return { read, cancel } +} + +/** + * Read just enough of `source` to decode its envelope, without loading the + * detached ciphertext. + * + * Probes contiguous, doubling spans starting at `[0, min(4096, size))`, each + * capped so it never crosses `size` or the 1 MiB envelope limit. Every block + * is fed to a fresh `createEnvelopeScanner()`; once it completes, the rest of + * that span is cancelled without being read. An envelope decode error, or a + * span of the wrong length (`InvalidSourceLengthError`), fails immediately -- + * no further span is opened after one. + */ +export async function readEnvelope(source: RandomAccessSource): Promise { + const scanner = createEnvelopeScanner() + let offset = 0 + let length = Math.min(4096, source.size, MAX_ENVELOPE_SIZE) + + while (offset < source.size) { + const range = openExactRange(source, offset, length) + for (;;) { + const block = await range.read() + if (block === undefined) break + let result: ReturnType + try { + result = scanner.push(block) + } catch (cause) { + await range.cancel(cause) + throw cause + } + if (result !== undefined) { + await range.cancel() + return result.decoded + } + } + offset += length + length = Math.min(length * 2, source.size - offset, MAX_ENVELOPE_SIZE - offset) + } + + // Ran out of source bytes (including size === 0) without a complete + // envelope: always throws MalformedEnvelopeError. + scanner.finish() + throw new Error('unreachable: scanner.finish() always throws once the envelope did not complete') +} diff --git a/packages/filecoin-encryption-envelope/test/inspect.test.ts b/packages/filecoin-encryption-envelope/test/inspect.test.ts new file mode 100644 index 00000000..1de5132a --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/inspect.test.ts @@ -0,0 +1,275 @@ +import assert from 'node:assert' +import { encrypt as encryptWholeObject } from '../src/aes-gcm.ts' +import { type ChunkedEncryptOptions, encrypt } from '../src/aes-gcm-stream.ts' +import { KEY_SIZE, MIN_CHUNK_SIZE } from '../src/constants.ts' +import { A256KW_WRAPPED_CEK_SIZE, ALG_A256KW } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import { InvalidSourceLengthError, MalformedEnvelopeError } from '../src/errors.ts' +import { paramsState, parse } from '../src/range/inspect.ts' +import type { RandomAccessSource } from '../src/range/source.ts' +import type { A256KWRecipient } from '../src/recipients/types.ts' +import { FIXED_CEK } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks } from './aes-gcm-stream-fixtures.ts' +import { concatBytes } from './cose-fixtures.ts' + +const CHUNK_SIZE = MIN_CHUNK_SIZE +const KEK_A = Uint8Array.from({ length: KEY_SIZE }, (_, i) => 0x40 + i) +const KID_A = Uint8Array.from([0xa1, 0xa2]) +const KEK_B = Uint8Array.from({ length: KEY_SIZE }, (_, i) => 0x80 + i) + +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 chunked writer and drive it to completion. */ +async function encryptChunkedFull( + plaintext: Uint8Array, + extra: Partial = {} +): Promise { + const { writable, readable } = 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) +} + +/** A `RandomAccessSource` that records every `openRange` call, like the one in test/range-source.test.ts. */ +function recordingSource(bytes: Uint8Array): { + source: RandomAccessSource + calls: Array<{ offset: number; length: number }> +} { + const calls: Array<{ offset: number; length: number }> = [] + const blockSize = 64 + const source: RandomAccessSource = { + size: bytes.length, + async openRange(offset, length) { + calls.push({ offset, length }) + const slice = bytes.subarray(offset, offset + length) + let pos = 0 + return new ReadableStream( + { + pull(controller) { + if (pos >= slice.length) { + controller.close() + return + } + const end = Math.min(pos + blockSize, slice.length) + controller.enqueue(slice.subarray(pos, end)) + pos = end + }, + }, + { highWaterMark: 0 } + ) + }, + } + return { source, calls } +} + +describe('parse', () => { + describe('scheme', () => { + it('reports a tag-16 chunked object as chunked, with no recipients', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + assert.deepStrictEqual(info.recipients, []) + }) + + it('reports a tag-96 chunked object (one recipient with a kid, one without) as chunked', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { + recipients: [recipient(KEK_A, KID_A), recipient(KEK_B)], + }) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + assert.strictEqual(info.recipients.length, 2) + }) + + it('reports a scheme-1 (whole-object AES-GCM) object as aes-gcm, with no params key', async () => { + const plaintext = deterministicPlaintext(10) + const encoded = await encryptWholeObject(plaintext, { cek: new Uint8Array(FIXED_CEK) }) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'aes-gcm') + assert.strictEqual('params' in info, false) + }) + }) + + describe('field values', () => { + it('reports a string contentType', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { contentType: 'text/plain' }) + const info = await parse(encoded) + assert.strictEqual(info.contentType, 'text/plain') + }) + + it('reports a numeric contentType', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { contentType: 42 }) + const info = await parse(encoded) + assert.strictEqual(info.contentType, 42) + }) + + it('reports appMetadata', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { appMetadata: { note: 'hello' } }) + const info = await parse(encoded) + // decodeAppMetadata returns a null-prototype object, so compare fields rather than deepStrictEqual. + assert.deepStrictEqual(Object.keys(info.appMetadata ?? {}), ['note']) + assert.strictEqual(info.appMetadata?.note, 'hello') + }) + + it('omits contentType and appMetadata when neither was set', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual('contentType' in info, false) + assert.strictEqual('appMetadata' in info, false) + }) + + it('reports recipient index, alg, kid, and wrappedKey, omitting kid when absent', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { + recipients: [recipient(KEK_A, KID_A), recipient(KEK_B)], + }) + const info = await parse(encoded) + assert.strictEqual(info.recipients[0].index, 0) + assert.strictEqual(info.recipients[0].alg, ALG_A256KW) + assert.deepStrictEqual(info.recipients[0].kid, KID_A) + assert.strictEqual(info.recipients[0].wrappedKey.length, A256KW_WRAPPED_CEK_SIZE) + assert.strictEqual(info.recipients[1].index, 1) + assert.strictEqual('kid' in info.recipients[1], false) + }) + + it('params.headerLength equals the real envelope length, and chunkSize matches', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.strictEqual(info.params.headerLength, decodeEnvelope(encoded).envelopeLength) + assert.strictEqual(info.params.chunkSize, CHUNK_SIZE) + }) + + it('params.plaintextLength is present when contentLength was declared', async () => { + const plaintext = deterministicPlaintext(10) + const encoded = await encryptChunkedFull(plaintext, { contentLength: 10 }) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.strictEqual(info.params.plaintextLength, 10) + }) + + it('params.plaintextLength is absent when contentLength was not declared', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.strictEqual('plaintextLength' in info.params, false) + }) + }) + + describe('params', () => { + it('is frozen', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.ok(Object.isFrozen(info.params)) + }) + + it('JSON.stringify shows only the public fields', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { contentLength: 10 }) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + const roundTripped = JSON.parse(JSON.stringify(info.params)) + assert.deepStrictEqual(Object.keys(roundTripped).sort(), [ + 'chunkSize', + 'headerLength', + 'plaintextLength', + 'scheme', + ]) + }) + + it('paramsState accepts real params from parse()', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.strictEqual(paramsState(info.params).decoded.envelopeLength, info.params.headerLength) + }) + + it('paramsState rejects a spread copy, a frozen spread copy, and null', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + assert.throws(() => paramsState({ ...info.params }), MalformedEnvelopeError) + assert.throws(() => paramsState(Object.freeze({ ...info.params })), MalformedEnvelopeError) + assert.throws(() => paramsState(null), MalformedEnvelopeError) + }) + + it('two parses of the same bytes give distinct params objects with equal fields', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const infoA = await parse(encoded) + const infoB = await parse(encoded) + assert.strictEqual(infoA.scheme, 'chunked') + assert.strictEqual(infoB.scheme, 'chunked') + if (infoA.scheme !== 'chunked' || infoB.scheme !== 'chunked') return + assert.notStrictEqual(infoA.params, infoB.params) + assert.deepStrictEqual(infoA.params, infoB.params) + }) + }) + + describe('isolation', () => { + it('mutating a returned recipient does not affect paramsState().decoded or a later parse', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10), { recipients: [recipient(KEK_A, KID_A)] }) + const infoA = await parse(encoded) + assert.strictEqual(infoA.scheme, 'chunked') + if (infoA.scheme !== 'chunked') return + const originalWrappedKey = new Uint8Array(infoA.recipients[0].wrappedKey) + + infoA.recipients[0].wrappedKey.fill(0) + + const state = paramsState(infoA.params) + assert.deepStrictEqual(state.decoded.recipients[0].ciphertext, originalWrappedKey) + + const infoB = await parse(encoded) + assert.deepStrictEqual(infoB.recipients[0].wrappedKey, originalWrappedKey) + }) + }) + + describe('source path', () => { + it('reads only the envelope span(s), not the detached ciphertext', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(plaintext) + const { source, calls } = recordingSource(encoded) + const info = await parse(source) + assert.strictEqual(info.scheme, 'chunked') + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const bytesRequested = calls.reduce((sum, call) => sum + call.length, 0) + // Spans double from 4096 and are cut off once the envelope completes; + // the true test is that we never approach the ciphertext's own size. + assert.ok(bytesRequested < envelopeLength + CHUNK_SIZE, 'must not read anywhere near the detached ciphertext') + }) + + it('parses directly from a Uint8Array', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + }) + + it('rejects a non-source input with the same error toRandomAccessSource would', async () => { + await assert.rejects(parse('nope' as unknown as Uint8Array), MalformedEnvelopeError) + }) + + it('rejects an invalid source size', async () => { + const badSource = { size: -1, openRange: async () => new ReadableStream() } as unknown as RandomAccessSource + await assert.rejects(parse(badSource), InvalidSourceLengthError) + }) + + it('rejects a truncated envelope', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10)) + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const truncated = encoded.subarray(0, envelopeLength - 5) + await assert.rejects(parse(truncated), MalformedEnvelopeError) + }) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/public-surface.test.ts b/packages/filecoin-encryption-envelope/test/public-surface.test.ts index 910979f6..b0e25319 100644 --- a/packages/filecoin-encryption-envelope/test/public-surface.test.ts +++ b/packages/filecoin-encryption-envelope/test/public-surface.test.ts @@ -32,13 +32,52 @@ describe('public surface (src/index.ts)', () => { 'constants', 'cose', 'decrypt', + 'decryptRange', + 'decryptRangeWith', 'decryptWith', 'encrypt', 'errors', + 'parse', 'recipients', ]) }) + it('parses and decrypts a range through the package root only, with a CEK and with a recipient', async () => { + const cek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => index + 1) + const kek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => 0x80 + index) + const plaintext = Uint8Array.from({ length: MIN_CHUNK_SIZE * 2 + 100 }, (_, index) => index & 0xff) + const source = new ReadableStream({ + start(controller) { + controller.enqueue(plaintext) + controller.close() + }, + }) + const encoded: Uint8Array[] = [] + for await (const chunk of source.pipeThrough( + fee.encrypt({ cek, chunkSize: MIN_CHUNK_SIZE, recipients: [{ alg: ALG_A256KW, kek }] }) + )) { + encoded.push(chunk) + } + const object = concatBytes(...encoded) + + const info = await fee.parse(object) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + const range = { offset: MIN_CHUNK_SIZE - 10, length: 20 } // crosses the first chunk boundary + const expected = plaintext.subarray(range.offset, range.offset + range.length) + + const direct = await fee.decryptRange(object, cek, range, { params: info.params }) + const directBytes: Uint8Array[] = [] + for await (const chunk of direct.stream) directBytes.push(chunk) + assert.deepStrictEqual(concatBytes(...directBytes), expected) + + const unwrapper = await fee.recipients.createA256KWUnwrapper([{ kek }]) + const viaRecipient = await fee.decryptRangeWith(object, unwrapper, range) + const recipientBytes: Uint8Array[] = [] + for await (const chunk of viaRecipient.stream) recipientBytes.push(chunk) + assert.deepStrictEqual(concatBytes(...recipientBytes), expected) + }) + it('encrypt works through the package root with pipeThrough', async () => { const cek = Uint8Array.from({ length: KEY_SIZE }, (_, index) => index + 1) const source = new ReadableStream({ @@ -128,6 +167,8 @@ describe('public surface (src/index.ts)', () => { 'InvalidNonceError', 'InvalidPlaintextError', 'InvalidPlaintextLengthError', + 'InvalidRangeError', + 'InvalidSourceLengthError', 'MalformedEnvelopeError', 'NoUsableRecipientError', 'RecipientAttemptLimitError', diff --git a/packages/filecoin-encryption-envelope/test/range-decrypt-with.test.ts b/packages/filecoin-encryption-envelope/test/range-decrypt-with.test.ts new file mode 100644 index 00000000..1817065b --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/range-decrypt-with.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert' +import { encrypt as encryptWholeObject } from '../src/aes-gcm.ts' +import { type ChunkedEncryptOptions, encrypt } from '../src/aes-gcm-stream.ts' +import { KEY_SIZE, MIN_CHUNK_SIZE } from '../src/constants.ts' +import { ALG_A256KW } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import { + AuthenticationError, + InvalidKeyError, + InvalidRangeError, + MalformedEnvelopeError, + NoUsableRecipientError, + RecipientUnwrapError, + UnsupportedSchemeError, +} from '../src/errors.ts' +import { decryptRangeWith } from '../src/range/decrypt.ts' +import { parse } from '../src/range/inspect.ts' +import type { ByteRange } from '../src/range/plan.ts' +import type { RandomAccessSource } from '../src/range/source.ts' +import { createA256KWUnwrapper } from '../src/recipients/index.ts' +import type { A256KWRecipient, RecipientInfo, Unwrapper } from '../src/recipients/types.ts' +import { FIXED_CEK } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks, sourceOf } from './aes-gcm-stream-fixtures.ts' +import { concatBytes } from './cose-fixtures.ts' + +const CHUNK_SIZE = MIN_CHUNK_SIZE +const KEK_A = Uint8Array.from({ length: KEY_SIZE }, (_, i) => 0x40 + i) +const KID_A = Uint8Array.from([0xa1, 0xa2]) +const KEK_B = Uint8Array.from({ length: KEY_SIZE }, (_, i) => 0x80 + i) + +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 chunked writer and drive it to completion. */ +async function encryptChunkedFull( + plaintext: Uint8Array, + extra: Partial = {} +): Promise { + const { writable, readable } = 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) +} + +/** The plaintext bytes `range` describes, computed from the documented semantics only. */ +function expectedSlice(fullPlaintext: Uint8Array, range: ByteRange): Uint8Array { + const total = fullPlaintext.length + if (range.offset < 0) { + return fullPlaintext.subarray(Math.max(0, total + range.offset)) + } + const end = range.length === undefined ? total : Math.min(total, range.offset + range.length) + return fullPlaintext.subarray(range.offset, end) +} + +async function decryptRangeWithBytes( + source: RandomAccessSource | Uint8Array, + unwrapper: Unwrapper, + range: ByteRange, + options?: Parameters[3] +) { + const result = await decryptRangeWith(source, unwrapper, range, options) + const bytes = concatBytes(...(await readAllChunks(result.stream))) + return { result, bytes } +} + +/** A `RandomAccessSource` that records every `openRange` call. */ +function recordingSource(bytes: Uint8Array): { + source: RandomAccessSource + calls: Array<{ offset: number; length: number }> +} { + const calls: Array<{ offset: number; length: number }> = [] + const blockSize = 64 + const source: RandomAccessSource = { + size: bytes.length, + async openRange(offset, length) { + calls.push({ offset, length }) + const slice = bytes.subarray(offset, offset + length) + let pos = 0 + return new ReadableStream( + { + pull(controller) { + if (pos >= slice.length) { + controller.close() + return + } + const end = Math.min(pos + blockSize, slice.length) + controller.enqueue(slice.subarray(pos, end)) + pos = end + }, + }, + { highWaterMark: 0 } + ) + }, + } + return { source, calls } +} + +/** 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), + } +} + +const RANGE_SCENARIOS: Array<{ name: string; range: ByteRange }> = [ + { name: 'first chunk', range: { offset: 0, length: 100 } }, + { name: 'cross-chunk', range: { offset: 4000, length: 200 } }, + { name: 'final chunk, open-ended', range: { offset: 8192 } }, + { name: 'suffix', range: { offset: -500 } }, +] + +describe('decryptRangeWith', () => { + describe('round trips', () => { + for (const { name, range } of RANGE_SCENARIOS) { + it(`${name} (one recipient)`, async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext, { recipients: [recipient(KEK_A, KID_A)] }) + const unwrapper = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + const { bytes } = await decryptRangeWithBytes(encoded, unwrapper, range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + + it(`${name} (two recipients, either KEK works)`, async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext, { + recipients: [recipient(KEK_A, KID_A), recipient(KEK_B)], + }) + + const unwrapperA = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + const { bytes: bytesA } = await decryptRangeWithBytes(encoded, unwrapperA, range) + assert.deepStrictEqual(bytesA, expectedSlice(plaintext, range)) + + const unwrapperB = await createA256KWUnwrapper([{ kek: KEK_B }]) + const { bytes: bytesB } = await decryptRangeWithBytes(encoded, unwrapperB, range) + assert.deepStrictEqual(bytesB, expectedSlice(plaintext, range)) + }) + } + }) + + describe('with params', () => { + it('opens no envelope span, and gives the unwrapper every recipient in wire order', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext, { + recipients: [recipient(KEK_A, KID_A), recipient(KEK_B)], + }) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + + const { source, calls } = recordingSource(encoded) + let seen: readonly RecipientInfo[] = [] + const unwrapper: Unwrapper = async (recipients) => { + seen = recipients + const real = await createA256KWUnwrapper([{ kek: KEK_A, kid: KID_A }]) + return real(recipients) + } + const range: ByteRange = { offset: 0, length: 100 } + const result = await decryptRangeWith(source, unwrapper, range, { params: info.params }) + assert.deepStrictEqual(calls, [], 'no envelope span, since params supplied the decoded envelope') + + const bytes = concatBytes(...(await readAllChunks(result.stream))) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + assert.deepStrictEqual(calls, [result.ciphertextSpan]) + + assert.strictEqual(seen.length, 2) + assert.strictEqual(seen[0].index, 0) + assert.strictEqual(seen[0].alg, ALG_A256KW) + assert.deepStrictEqual(seen[0].kid, KID_A) + assert.strictEqual(seen[1].index, 1) + assert.strictEqual(seen[1].alg, ALG_A256KW) + assert.strictEqual('kid' in seen[1], false) + }) + }) + + describe('rejections', () => { + it('rejects a tag-16 object with NoUsableRecipientError, opening no ciphertext span', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const { source, calls } = recordingSource(encoded) + const { unwrapper, assertNeverCalled } = neverCalledUnwrapper() + + await assert.rejects(decryptRangeWith(source, unwrapper, { offset: 0 }), NoUsableRecipientError) + assertNeverCalled() + + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const bytesRequested = calls.reduce((sum, call) => sum + call.length, 0) + assert.ok(bytesRequested < envelopeLength + CHUNK_SIZE, 'no ciphertext span should have opened') + }) + + it('wraps a thrown unwrapper error as RecipientUnwrapError with the exact cause', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10000), { + recipients: [recipient(KEK_A, KID_A)], + }) + const boom = new Error('boom') + const unwrapper: Unwrapper = async () => { + throw boom + } + await assert.rejects( + decryptRangeWith(encoded, unwrapper, { offset: 0 }), + (error: unknown) => error instanceof RecipientUnwrapError && error.cause === boom + ) + }) + + it('reports an unwrapper returning undefined as NoUsableRecipientError', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10000), { + recipients: [recipient(KEK_A, KID_A)], + }) + const unwrapper: Unwrapper = async () => undefined + await assert.rejects(decryptRangeWith(encoded, unwrapper, { offset: 0 }), NoUsableRecipientError) + }) + + it('rejects an invalid recovered CEK with InvalidKeyError', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10000), { + recipients: [recipient(KEK_A, KID_A)], + }) + const unwrapper: Unwrapper = async () => new Uint8Array(KEY_SIZE) // all-zero + await assert.rejects(decryptRangeWith(encoded, unwrapper, { offset: 0 }), InvalidKeyError) + }) + + it('reports a valid but wrong recovered CEK as AuthenticationError on the first read', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10000), { + recipients: [recipient(KEK_A, KID_A)], + }) + const wrongCek = Uint8Array.from(FIXED_CEK, (byte) => byte ^ 0xff) + const unwrapper: Unwrapper = async () => wrongCek + const result = await decryptRangeWith(encoded, unwrapper, { offset: 0, length: 100 }) + await assert.rejects(readAllChunks(result.stream), AuthenticationError) + }) + + it('rejects a non-function unwrapper synchronously, with no openRange call', async () => { + let calls = 0 + const source: RandomAccessSource = { + size: 100, + async openRange() { + calls++ + return sourceOf([]) + }, + } + await assert.rejects( + decryptRangeWith(source, 'nope' as unknown as Unwrapper, { offset: 0 }), + MalformedEnvelopeError + ) + assert.strictEqual(calls, 0) + }) + + it('rejects an invalid range before calling the unwrapper', async () => { + const encoded = await encryptChunkedFull(deterministicPlaintext(10000), { + recipients: [recipient(KEK_A, KID_A)], + }) + const { unwrapper, assertNeverCalled } = neverCalledUnwrapper() + await assert.rejects(decryptRangeWith(encoded, unwrapper, { offset: -1, length: 5 }), InvalidRangeError) + assertNeverCalled() + }) + + it('rejects a scheme-1 (whole-object AES-GCM) object with UnsupportedSchemeError, without calling the unwrapper', async () => { + const encoded = await encryptWholeObject(deterministicPlaintext(10), { cek: new Uint8Array(FIXED_CEK) }) + const { unwrapper, assertNeverCalled } = neverCalledUnwrapper() + await assert.rejects(decryptRangeWith(encoded, unwrapper, { offset: 0 }), UnsupportedSchemeError) + assertNeverCalled() + }) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/range-decrypt.test.ts b/packages/filecoin-encryption-envelope/test/range-decrypt.test.ts new file mode 100644 index 00000000..c9180147 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/range-decrypt.test.ts @@ -0,0 +1,557 @@ +import assert from 'node:assert' +import { encrypt as encryptWholeObject } from '../src/aes-gcm.ts' +import { type ChunkedEncryptOptions, 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 { + AuthenticationError, + InvalidCiphertextLengthError, + InvalidKeyError, + InvalidRangeError, + InvalidSourceLengthError, + MalformedEnvelopeError, + UnsupportedSchemeError, +} from '../src/errors.ts' +import { decryptRange } from '../src/range/decrypt.ts' +import { parse } from '../src/range/inspect.ts' +import { type ByteRange, planRange } from '../src/range/plan.ts' +import type { RandomAccessSource } from '../src/range/source.ts' +import type { A256KWRecipient } from '../src/recipients/types.ts' +import { FIXED_CEK } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks, sourceOf } from './aes-gcm-stream-fixtures.ts' +import { concatBytes } from './cose-fixtures.ts' + +const CHUNK_SIZE = MIN_CHUNK_SIZE +const STRIDE = CHUNK_SIZE + TAG_SIZE +const KEK_A = Uint8Array.from({ length: KEY_SIZE }, (_, i) => 0x40 + i) +const KID_A = Uint8Array.from([0xa1, 0xa2]) + +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 chunked writer and drive it to completion. */ +async function encryptChunkedFull( + plaintext: Uint8Array, + extra: Partial = {} +): Promise { + const { writable, readable } = 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) +} + +/** The plaintext bytes `range` describes, computed from the documented semantics only -- not from planRange. */ +function expectedSlice(fullPlaintext: Uint8Array, range: ByteRange): Uint8Array { + const total = fullPlaintext.length + if (range.offset < 0) { + return fullPlaintext.subarray(Math.max(0, total + range.offset)) + } + const end = range.length === undefined ? total : Math.min(total, range.offset + range.length) + return fullPlaintext.subarray(range.offset, end) +} + +async function decryptRangeBytes( + source: RandomAccessSource | Uint8Array, + cek: Uint8Array, + range: ByteRange, + options?: Parameters[3] +) { + const result = await decryptRange(source, cek, range, options) + const bytes = concatBytes(...(await readAllChunks(result.stream))) + return { result, bytes } +} + +/** A `RandomAccessSource` that records every `openRange` call. */ +function recordingSource(bytes: Uint8Array): { + source: RandomAccessSource + calls: Array<{ offset: number; length: number }> +} { + const calls: Array<{ offset: number; length: number }> = [] + const blockSize = 64 + const source: RandomAccessSource = { + size: bytes.length, + async openRange(offset, length) { + calls.push({ offset, length }) + const slice = bytes.subarray(offset, offset + length) + let pos = 0 + return new ReadableStream( + { + pull(controller) { + if (pos >= slice.length) { + controller.close() + return + } + const end = Math.min(pos + blockSize, slice.length) + controller.enqueue(slice.subarray(pos, end)) + pos = end + }, + }, + { highWaterMark: 0 } + ) + }, + } + return { source, calls } +} + +/** Delivers any requested range split into fixed-size blocks. */ +function blockSplitSource(bytes: Uint8Array, blockSize: number): RandomAccessSource { + return { + size: bytes.length, + async openRange(offset, length) { + const slice = bytes.subarray(offset, offset + length) + let pos = 0 + return new ReadableStream( + { + pull(controller) { + if (pos >= slice.length) { + controller.close() + return + } + const end = Math.min(pos + blockSize, slice.length) + controller.enqueue(slice.subarray(pos, end)) + pos = end + }, + }, + { highWaterMark: 0 } + ) + }, + } +} + +async function readUntilFailure(stream: ReadableStream): Promise<{ chunks: Uint8Array[]; error: unknown }> { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let error: unknown + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + chunks.push(value) + } + } catch (cause) { + error = cause + } + return { chunks, error } +} + +const RANGE_SCENARIOS: Array<{ name: string; range: ByteRange }> = [ + { name: 'first bytes', range: { offset: 0, length: 100 } }, + { name: 'middle of a chunk', range: { offset: 5000, length: 2000 } }, + { name: 'exactly one whole chunk', range: { offset: 0, length: 4096 } }, + { name: 'crossing one chunk boundary', range: { offset: 4000, length: 200 } }, + { name: 'crossing several chunk boundaries', range: { offset: 100, length: 9000 } }, + { name: 'ending exactly at a chunk boundary', range: { offset: 0, length: 8192 } }, + { name: 'the final partial chunk, open-ended', range: { offset: 8192 } }, + { name: 'the whole object', range: { offset: 0 } }, + { name: 'open-ended from the middle', range: { offset: 5000 } }, + { name: 'a suffix inside the final chunk', range: { offset: -500 } }, + { name: 'a suffix crossing chunk boundaries', range: { offset: -5000 } }, + { name: 'a suffix longer than the object', range: { offset: -999999 } }, + { name: 'the end clamps past EOF', range: { offset: 9000, length: 5000 } }, +] + +function pick(names: string[]): Array<{ name: string; range: ByteRange }> { + return RANGE_SCENARIOS.filter((s) => names.includes(s.name)) +} + +describe('decryptRange', () => { + describe('round trips (tag 16)', () => { + for (const { name, range } of RANGE_SCENARIOS) { + it(name, async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const { result, bytes } = await decryptRangeBytes(encoded, new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + + const plan = planRange( + { sourceSize: encoded.length, headerLength: decodeEnvelope(encoded).envelopeLength, chunkSize: CHUNK_SIZE }, + range + ) + assert.strictEqual(result.rangeLength, plan.rangeLength) + assert.strictEqual(result.totalPlaintextLength, plan.totalPlaintextLength) + assert.deepStrictEqual(result.ciphertextSpan, plan.ciphertextSpan) + assert.strictEqual(result.includesFinalChunk, plan.includesFinalChunk) + }) + } + + it('the final full chunk of an exact multiple, open-ended', async () => { + const plaintext = deterministicPlaintext(8192) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 4096 } + const { result, bytes } = await decryptRangeBytes(encoded, new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + assert.strictEqual(result.includesFinalChunk, true) + }) + + it('decrypts correctly when the range ends on a non-final chunk (nonce flag 0)', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 0, length: 8192 } + const { result, bytes } = await decryptRangeBytes(encoded, new Uint8Array(FIXED_CEK), range) + assert.strictEqual(result.includesFinalChunk, false) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + }) + + describe('round trips (tag 96, direct CEK)', () => { + for (const { name, range } of pick([ + 'first bytes', + 'crossing several chunk boundaries', + 'the whole object', + 'the final partial chunk, open-ended', + ])) { + it(name, async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext, { recipients: [recipient(KEK_A, KID_A)] }) + const { bytes } = await decryptRangeBytes(encoded, new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + } + }) + + describe('round trips (with plaintext_length)', () => { + for (const { name, range } of pick([ + 'first bytes', + 'crossing several chunk boundaries', + 'the whole object', + 'the final partial chunk, open-ended', + ])) { + it(name, async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext, { contentLength: plaintext.length }) + const { result, bytes } = await decryptRangeBytes(encoded, new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + assert.strictEqual(result.totalPlaintextLength, plaintext.length) + }) + } + }) + + describe('source path', () => { + it('decrypts correctly when the source reuses its block on the next pull', async () => { + // Legal source behaviour: once pulled again, a source may recycle the + // buffer it handed out. The last piece must be decrypted before the + // end-of-span read, or its ciphertext changes underneath it. + const plaintext = deterministicPlaintext(100) + const encoded = await encryptChunkedFull(plaintext) + const source: RandomAccessSource = { + size: encoded.length, + async openRange(offset, length) { + const shared = new Uint8Array(encoded.subarray(offset, offset + length)) + let sent = false + return new ReadableStream( + { + pull(controller) { + if (sent) { + shared.fill(0) + controller.close() + } else { + sent = true + controller.enqueue(shared) + } + }, + }, + { highWaterMark: 0 } + ) + }, + } + const { bytes } = await decryptRangeBytes(source, new Uint8Array(FIXED_CEK), { offset: 0 }) + assert.deepStrictEqual(bytes, plaintext) + }) + + it('fetches the planned span even if the caller edits result.ciphertextSpan', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 5000, length: 100 } + const result = await decryptRange(encoded, new Uint8Array(FIXED_CEK), range) + result.ciphertextSpan.offset = 0 + result.ciphertextSpan.length = 1 + const bytes = concatBytes(...(await readAllChunks(result.stream))) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + + it('opens only envelope spans before the promise resolves; the first read opens exactly the ciphertext span', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const { source, calls } = recordingSource(encoded) + const range: ByteRange = { offset: 100, length: 9000 } + const result = await decryptRange(source, new Uint8Array(FIXED_CEK), range) + const callsBeforeRead = calls.length + assert.ok( + calls.every( + (call) => call.offset + call.length !== result.ciphertextSpan.offset + result.ciphertextSpan.length + ), + 'no call before the first read should match the ciphertext span' + ) + + const reader = result.stream.getReader() + await reader.read() + assert.deepStrictEqual(calls.slice(callsBeforeRead), [result.ciphertextSpan]) + }) + + it('with params, no envelope span is ever opened', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + + const { source, calls } = recordingSource(encoded) + const result = await decryptRange( + source, + new Uint8Array(FIXED_CEK), + { offset: 0, length: 100 }, + { params: info.params } + ) + assert.deepStrictEqual(calls, []) + await readAllChunks(result.stream) + assert.deepStrictEqual(calls, [result.ciphertextSpan]) + }) + + it('works when the source delivers one byte at a time', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 100, length: 9000 } + const { bytes } = await decryptRangeBytes(blockSplitSource(encoded, 1), new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + + it('works when blocks straddle chunk boundaries', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 100, length: 9000 } + // 777 shares no common factor with the stride, so blocks land at different offsets within each chunk. + const { bytes } = await decryptRangeBytes(blockSplitSource(encoded, 777), new Uint8Array(FIXED_CEK), range) + assert.deepStrictEqual(bytes, expectedSlice(plaintext, range)) + }) + }) + + describe('rejections', () => { + it('rejects a short span response with InvalidSourceLengthError', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 100, length: 9000 } + const plan = planRange( + { sourceSize: encoded.length, headerLength: decodeEnvelope(encoded).envelopeLength, chunkSize: CHUNK_SIZE }, + range + ) + const source: RandomAccessSource = { + size: encoded.length, + async openRange(offset, length) { + if (offset === plan.ciphertextSpan.offset && length === plan.ciphertextSpan.length) { + return sourceOf([encoded.subarray(offset, offset + length - 1)]) + } + return sourceOf([encoded.subarray(offset, offset + length)]) + }, + } + const result = await decryptRange(source, new Uint8Array(FIXED_CEK), range) + await assert.rejects(readAllChunks(result.stream), InvalidSourceLengthError) + }) + + it('rejects extra trailing bytes, releasing nothing from the last piece', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const range: ByteRange = { offset: 100, length: 9000 } // includes the final chunk + const plan = planRange( + { sourceSize: encoded.length, headerLength: decodeEnvelope(encoded).envelopeLength, chunkSize: CHUNK_SIZE }, + range + ) + const source: RandomAccessSource = { + size: encoded.length, + async openRange(offset, length) { + if (offset === plan.ciphertextSpan.offset && length === plan.ciphertextSpan.length) { + // Delivered as a separate block after the correct span, so the + // exact-count is reached before the extra bytes are discovered. + return sourceOf([encoded.subarray(offset, offset + length), Uint8Array.from([1, 2, 3])]) + } + return sourceOf([encoded.subarray(offset, offset + length)]) + }, + } + const result = await decryptRange(source, new Uint8Array(FIXED_CEK), range) + const { chunks, error } = await readUntilFailure(result.stream) + assert.ok(error instanceof InvalidSourceLengthError) + const released = concatBytes(...chunks) + assert.ok(released.length < plan.rangeLength, 'the last piece must not have been released') + }) + + it('rejects a tampered chunk, releasing earlier chunks but nothing from the bad one', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(plaintext) + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const tampered = new Uint8Array(encoded) + tampered[envelopeLength + CHUNK_SIZE + 20] ^= 0xff // inside chunk 1's ciphertext + const result = await decryptRange(tampered, new Uint8Array(FIXED_CEK), { offset: 0 }) + const { chunks, error } = await readUntilFailure(result.stream) + assert.ok(error instanceof AuthenticationError) + assert.deepStrictEqual(concatBytes(...chunks), plaintext.subarray(0, CHUNK_SIZE)) + }) + + it('rejects the wrong CEK with AuthenticationError', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const wrongCek = Uint8Array.from(FIXED_CEK, (byte) => byte ^ 0xff) + const result = await decryptRange(encoded, wrongCek, { offset: 0, length: 100 }) + await assert.rejects(readAllChunks(result.stream), AuthenticationError) + }) + + it('rejects an invalid CEK synchronously, before any openRange', async () => { + let calls = 0 + const source: RandomAccessSource = { + size: 100, + async openRange() { + calls++ + return sourceOf([]) + }, + } + await assert.rejects(decryptRange(source, new Uint8Array(10), { offset: 0 }), InvalidKeyError) + assert.strictEqual(calls, 0) + }) + + it('rejects a scheme-1 (whole-object AES-GCM) object with UnsupportedSchemeError', async () => { + const encoded = await encryptWholeObject(deterministicPlaintext(10), { cek: new Uint8Array(FIXED_CEK) }) + await assert.rejects(decryptRange(encoded, new Uint8Array(FIXED_CEK), { offset: 0 }), UnsupportedSchemeError) + }) + + it('rejects an invalid range before any key import', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + let importCalls = 0 + const original = globalThis.crypto.subtle.importKey + globalThis.crypto.subtle.importKey = ((...args: Parameters) => { + importCalls++ + return original.apply(globalThis.crypto.subtle, args) + }) as SubtleCrypto['importKey'] + try { + await assert.rejects( + decryptRange(encoded, new Uint8Array(FIXED_CEK), { offset: -1, length: 5 }), + InvalidRangeError + ) + assert.strictEqual(importCalls, 0) + } finally { + globalThis.crypto.subtle.importKey = original + } + }) + + it('rejects look-alike params with MalformedEnvelopeError', async () => { + const plaintext = deterministicPlaintext(10000) + const encoded = await encryptChunkedFull(plaintext) + const info = await parse(encoded) + assert.strictEqual(info.scheme, 'chunked') + if (info.scheme !== 'chunked') return + await assert.rejects( + decryptRange(encoded, new Uint8Array(FIXED_CEK), { offset: 0 }, { params: { ...info.params } }), + MalformedEnvelopeError + ) + }) + + it('rejects params from a different object with AuthenticationError on read', async () => { + const encodedA = await encryptChunkedFull(deterministicPlaintext(10000)) + const encodedB = await encryptChunkedFull(deterministicPlaintext(10000)) + const infoA = await parse(encodedA) + assert.strictEqual(infoA.scheme, 'chunked') + if (infoA.scheme !== 'chunked') return + const result = await decryptRange( + encodedB, + new Uint8Array(FIXED_CEK), + { offset: 0, length: 100 }, + { params: infoA.params } + ) + await assert.rejects(readAllChunks(result.stream), AuthenticationError) + }) + + it('rejects a truncated source without plaintext_length as AuthenticationError when the range covers the presumed final chunk', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(plaintext) + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const truncated = encoded.subarray(0, envelopeLength + STRIDE * 2) // still a structurally valid 2-chunk object + const result = await decryptRange(truncated, new Uint8Array(FIXED_CEK), { offset: 0 }) + await assert.rejects(readAllChunks(result.stream), AuthenticationError) + }) + + it('rejects a truncated source with plaintext_length before opening any ciphertext span', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(plaintext, { contentLength: plaintext.length }) + const envelopeLength = decodeEnvelope(encoded).envelopeLength + const truncated = encoded.subarray(0, envelopeLength + STRIDE * 2) + const { source, calls } = recordingSource(truncated) + await assert.rejects(decryptRange(source, new Uint8Array(FIXED_CEK), { offset: 0 }), InvalidCiphertextLengthError) + const bytesRequested = calls.reduce((sum, call) => sum + call.length, 0) + assert.ok(bytesRequested < envelopeLength + CHUNK_SIZE, 'must not have opened a ciphertext span') + }) + }) + + describe('laziness', () => { + it('imports the key eagerly but decrypts lazily, one aesGcmDecrypt per read', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(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 { + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + const result = await decryptRange(encoded, new Uint8Array(FIXED_CEK), { offset: 0 }) + assert.strictEqual(importCalls, 1) + assert.strictEqual(decryptCalls, 0) + + const reader = result.stream.getReader() + for (let k = 1; k <= 3; k++) { + const { done } = await reader.read() + assert.strictEqual(done, false) + await settle() + assert.strictEqual(decryptCalls, k) + } + const last = await reader.read() + assert.strictEqual(last.done, true) + } finally { + globalThis.crypto.subtle.importKey = originalImportKey + globalThis.crypto.subtle.decrypt = originalDecrypt + } + }) + }) + + describe('cancel', () => { + it('cancelling the result stream cancels the source span stream', async () => { + const plaintext = deterministicPlaintext(CHUNK_SIZE * 3) + const encoded = await encryptChunkedFull(plaintext) + let cancelReason: unknown + const source: RandomAccessSource = { + size: encoded.length, + async openRange(offset, length) { + const slice = encoded.subarray(offset, offset + length) + return new ReadableStream({ + start(controller) { + controller.enqueue(slice) + // Left open: no pull()/close(), so nothing more happens on its own. + }, + cancel(reason) { + cancelReason = reason + }, + }) + }, + } + const result = await decryptRange(source, new Uint8Array(FIXED_CEK), { offset: 0 }) + const reader = result.stream.getReader() + await reader.read() // triggers the first pull, opening the ciphertext span + const reason = new Error('stop early') + await reader.cancel(reason) + assert.strictEqual(cancelReason, reason) + }) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/range-plan.test.ts b/packages/filecoin-encryption-envelope/test/range-plan.test.ts new file mode 100644 index 00000000..5a758277 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/range-plan.test.ts @@ -0,0 +1,511 @@ +import assert from 'node:assert' +import { TAG_SIZE } from '../src/constants.ts' +import { InvalidCiphertextLengthError, InvalidRangeError, InvalidSourceLengthError } from '../src/errors.ts' +import { type ChunkedRangeLayoutInput, planRange, type RangePlan } from '../src/range/plan.ts' + +const HEADER_LENGTH = 200 +const CHUNK_SIZE = 4096 +const STRIDE = CHUNK_SIZE + TAG_SIZE + +// Object A: partial final chunk. Chunks: [0,4096) [4096,8192) [8192,10000) (1808 B final). +const TOTAL_A = 10000 +const LAST_A_PLAINTEXT = TOTAL_A - 2 * CHUNK_SIZE +const CIPHERTEXT_A = STRIDE * 2 + (LAST_A_PLAINTEXT + TAG_SIZE) +const LAYOUT_A: ChunkedRangeLayoutInput = { + sourceSize: HEADER_LENGTH + CIPHERTEXT_A, + headerLength: HEADER_LENGTH, + chunkSize: CHUNK_SIZE, +} + +// Object B: exact multiple of the stride. Two full chunks: [0,4096) [4096,8192). +const TOTAL_B = 8192 +const CIPHERTEXT_B = STRIDE * 2 +const LAYOUT_B: ChunkedRangeLayoutInput = { + sourceSize: HEADER_LENGTH + CIPHERTEXT_B, + headerLength: HEADER_LENGTH, + chunkSize: CHUNK_SIZE, +} + +// Object C: a single, partial chunk. +const TOTAL_C = 100 +const CIPHERTEXT_C = TOTAL_C + TAG_SIZE +const LAYOUT_C: ChunkedRangeLayoutInput = { + sourceSize: HEADER_LENGTH + CIPHERTEXT_C, + headerLength: HEADER_LENGTH, + chunkSize: CHUNK_SIZE, +} + +// Object D: empty. The sole chunk carries a tag and no plaintext. +const CIPHERTEXT_D = TAG_SIZE +const LAYOUT_D: ChunkedRangeLayoutInput = { + sourceSize: HEADER_LENGTH + CIPHERTEXT_D, + headerLength: HEADER_LENGTH, + chunkSize: CHUNK_SIZE, +} + +interface Row { + name: string + layout: ChunkedRangeLayoutInput + range: { offset: number; length?: number } + expected: RangePlan +} + +const rows: Row[] = [ + { + name: 'first bytes', + layout: LAYOUT_A, + range: { offset: 0, length: 100 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 100, + ciphertextSpan: { offset: HEADER_LENGTH, length: STRIDE }, + firstChunk: 0, + lastChunk: 0, + chunkCount: 3, + skip: 0, + includesFinalChunk: false, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'middle of a chunk', + layout: LAYOUT_A, + range: { offset: 5000, length: 2000 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 2000, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE, length: STRIDE }, + firstChunk: 1, + lastChunk: 1, + chunkCount: 3, + skip: 904, + includesFinalChunk: false, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'exactly one whole chunk', + layout: LAYOUT_A, + range: { offset: 0, length: 4096 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 4096, + ciphertextSpan: { offset: HEADER_LENGTH, length: STRIDE }, + firstChunk: 0, + lastChunk: 0, + chunkCount: 3, + skip: 0, + includesFinalChunk: false, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'crossing one chunk boundary', + layout: LAYOUT_A, + range: { offset: 4000, length: 200 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 200, + ciphertextSpan: { offset: HEADER_LENGTH, length: STRIDE * 2 }, + firstChunk: 0, + lastChunk: 1, + chunkCount: 3, + skip: 4000, + includesFinalChunk: false, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'crossing several chunk boundaries', + layout: LAYOUT_A, + range: { offset: 100, length: 9000 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 9000, + ciphertextSpan: { offset: HEADER_LENGTH, length: CIPHERTEXT_A }, + firstChunk: 0, + lastChunk: 2, + chunkCount: 3, + skip: 100, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'ending exactly at a chunk boundary', + layout: LAYOUT_A, + range: { offset: 0, length: 8192 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 8192, + ciphertextSpan: { offset: HEADER_LENGTH, length: STRIDE * 2 }, + firstChunk: 0, + lastChunk: 1, + chunkCount: 3, + skip: 0, + includesFinalChunk: false, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'the final partial chunk, open-ended', + layout: LAYOUT_A, + range: { offset: 8192 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A - 8192, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE * 2, length: LAST_A_PLAINTEXT + TAG_SIZE }, + firstChunk: 2, + lastChunk: 2, + chunkCount: 3, + skip: 0, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'the final full chunk of an exact multiple, open-ended', + layout: LAYOUT_B, + range: { offset: 4096 }, + expected: { + totalPlaintextLength: TOTAL_B, + rangeLength: TOTAL_B - 4096, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE, length: STRIDE }, + firstChunk: 1, + lastChunk: 1, + chunkCount: 2, + skip: 0, + includesFinalChunk: true, + lastChunkCipherLength: STRIDE, + }, + }, + { + name: 'the whole object', + layout: LAYOUT_A, + range: { offset: 0 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A, + ciphertextSpan: { offset: HEADER_LENGTH, length: CIPHERTEXT_A }, + firstChunk: 0, + lastChunk: 2, + chunkCount: 3, + skip: 0, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'a declared plaintextLength matching the derived layout is accepted', + layout: { ...LAYOUT_A, plaintextLength: TOTAL_A }, + range: { offset: 0 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A, + ciphertextSpan: { offset: HEADER_LENGTH, length: CIPHERTEXT_A }, + firstChunk: 0, + lastChunk: 2, + chunkCount: 3, + skip: 0, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'open-ended from the middle', + layout: LAYOUT_A, + range: { offset: 5000 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A - 5000, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE, length: CIPHERTEXT_A - STRIDE }, + firstChunk: 1, + lastChunk: 2, + chunkCount: 3, + skip: 904, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'a suffix inside the final chunk', + layout: LAYOUT_A, + range: { offset: -500 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 500, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE * 2, length: LAST_A_PLAINTEXT + TAG_SIZE }, + firstChunk: 2, + lastChunk: 2, + chunkCount: 3, + skip: 1308, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'a suffix crossing chunk boundaries', + layout: LAYOUT_A, + range: { offset: -5000 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 5000, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE, length: CIPHERTEXT_A - STRIDE }, + firstChunk: 1, + lastChunk: 2, + chunkCount: 3, + skip: 904, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'a suffix longer than the object clamps to the whole object', + layout: LAYOUT_A, + range: { offset: -999999 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A, + ciphertextSpan: { offset: HEADER_LENGTH, length: CIPHERTEXT_A }, + firstChunk: 0, + lastChunk: 2, + chunkCount: 3, + skip: 0, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'the end clamps past EOF', + layout: LAYOUT_A, + range: { offset: 9000, length: 5000 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: TOTAL_A - 9000, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE * 2, length: LAST_A_PLAINTEXT + TAG_SIZE }, + firstChunk: 2, + lastChunk: 2, + chunkCount: 3, + skip: 808, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'offset = total - 1', + layout: LAYOUT_A, + range: { offset: TOTAL_A - 1 }, + expected: { + totalPlaintextLength: TOTAL_A, + rangeLength: 1, + ciphertextSpan: { offset: HEADER_LENGTH + STRIDE * 2, length: LAST_A_PLAINTEXT + TAG_SIZE }, + firstChunk: 2, + lastChunk: 2, + chunkCount: 3, + skip: LAST_A_PLAINTEXT - 1, + includesFinalChunk: true, + lastChunkCipherLength: LAST_A_PLAINTEXT + TAG_SIZE, + }, + }, + { + name: 'a range entirely inside a single-chunk object', + layout: LAYOUT_C, + range: { offset: 10, length: 50 }, + expected: { + totalPlaintextLength: TOTAL_C, + rangeLength: 50, + ciphertextSpan: { offset: HEADER_LENGTH, length: CIPHERTEXT_C }, + firstChunk: 0, + lastChunk: 0, + chunkCount: 1, + skip: 10, + includesFinalChunk: true, + lastChunkCipherLength: CIPHERTEXT_C, + }, + }, +] + +describe('planRange', () => { + it('reads offset and length once, so a getter cannot change them after validation', () => { + let offsetReads = 0 + let lengthReads = 0 + const range = { + get offset() { + return offsetReads++ === 0 ? 10 : Number.NaN + }, + get length() { + return lengthReads++ === 0 ? 20 : Number.NaN + }, + } + const plan = planRange({ sourceSize: 10000, headerLength: 0, chunkSize: 4096 }, range) + assert.strictEqual(offsetReads, 1) + assert.strictEqual(lengthReads, 1) + assert.strictEqual(plan.rangeLength, 20) + assert.strictEqual(plan.skip, 10) + }) + + describe('table', () => { + for (const { name, layout, range, expected } of rows) { + it(name, () => { + assert.deepStrictEqual(planRange(layout, range), expected) + }) + } + }) + + describe('rejections', () => { + it('rejects every range shape against an empty object', () => { + for (const range of [{ offset: 0 }, { offset: 0, length: 10 }, { offset: -10 }]) { + assert.throws(() => planRange(LAYOUT_D, range), InvalidRangeError) + } + }) + + it('rejects length: 0', () => { + assert.throws(() => planRange(LAYOUT_A, { offset: 0, length: 0 }), InvalidRangeError) + }) + + it('rejects a negative length', () => { + assert.throws(() => planRange(LAYOUT_A, { offset: 0, length: -5 }), InvalidRangeError) + }) + + it('rejects offset === total', () => { + assert.throws(() => planRange(LAYOUT_A, { offset: TOTAL_A }), InvalidRangeError) + }) + + it('rejects offset > total', () => { + assert.throws(() => planRange(LAYOUT_A, { offset: TOTAL_A + 1 }), InvalidRangeError) + }) + + const badNumbers: Array<[string, number]> = [ + ['1.5', 1.5], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['2**53', 2 ** 53], + ] + for (const [label, value] of badNumbers) { + it(`rejects an offset of ${label}`, () => { + assert.throws(() => planRange(LAYOUT_A, { offset: value }), InvalidRangeError) + }) + it(`rejects a length of ${label}`, () => { + assert.throws(() => planRange(LAYOUT_A, { offset: 0, length: value }), InvalidRangeError) + }) + } + + it('rejects a suffix range that also specifies a length', () => { + assert.throws(() => planRange(LAYOUT_A, { offset: -10, length: 5 }), InvalidRangeError) + }) + + const nonObjectRanges: Array<[string, unknown]> = [ + ['null', null], + ['a number', 5], + ['a string', 'nope'], + ['undefined', undefined], + ['an array', [0, 10]], + ] + for (const [label, range] of nonObjectRanges) { + it(`rejects ${label} as a range`, () => { + assert.throws(() => planRange(LAYOUT_A, range), InvalidRangeError) + }) + } + + it('rejects sourceSize < headerLength', () => { + assert.throws( + () => planRange({ sourceSize: 100, headerLength: 200, chunkSize: CHUNK_SIZE }, { offset: 0 }), + InvalidSourceLengthError + ) + }) + + it('rejects an impossible ciphertext length (remainder below the tag size)', () => { + const layout: ChunkedRangeLayoutInput = { + sourceSize: HEADER_LENGTH + STRIDE + 5, + headerLength: HEADER_LENGTH, + chunkSize: CHUNK_SIZE, + } + assert.throws(() => planRange(layout, { offset: 0 }), InvalidCiphertextLengthError) + }) + + it('rejects a plaintextLength that disagrees with the derived layout', () => { + const layout: ChunkedRangeLayoutInput = { ...LAYOUT_A, plaintextLength: TOTAL_A - 1 } + assert.throws(() => planRange(layout, { offset: 0 }), InvalidCiphertextLengthError) + }) + }) + + describe('independent oracle', () => { + // A small object at the minimum chunk size: 3 chunks (2 full, one partial final chunk). + const chunkSize = CHUNK_SIZE + const headerLength = 50 + const total = 9000 + const chunkCount = Math.ceil(total / chunkSize) + const lastChunkPlaintext = total - (chunkCount - 1) * chunkSize + const ciphertextLength = (chunkCount - 1) * (chunkSize + TAG_SIZE) + (lastChunkPlaintext + TAG_SIZE) + const layout: ChunkedRangeLayoutInput = { sourceSize: headerLength + ciphertextLength, headerLength, chunkSize } + + /** Never uses planRange's own formulas: walks every chunk and keeps the ones overlapping [start, end). */ + function naivePlan(offset: number, length?: number): RangePlan { + let start: number + let end: number + if (offset < 0) { + start = Math.max(0, total + offset) + end = total + } else { + start = offset + end = total + if (length !== undefined && offset + length < end) { + end = offset + length + } + } + + let firstChunk = -1 + let lastChunk = -1 + let cumulativeOffset = headerLength + let spanOffset = -1 + let spanEnd = -1 + let lastChunkCipherLength = 0 + for (let i = 0; i < chunkCount; i++) { + const chunkStart = i * chunkSize + const chunkEnd = Math.min(chunkStart + chunkSize, total) + const isFinal = i === chunkCount - 1 + const cipherLength = isFinal ? lastChunkPlaintext + TAG_SIZE : chunkSize + TAG_SIZE + if (chunkStart < end && chunkEnd > start) { + if (firstChunk === -1) { + firstChunk = i + spanOffset = cumulativeOffset + } + lastChunk = i + spanEnd = cumulativeOffset + cipherLength + lastChunkCipherLength = cipherLength + } + cumulativeOffset += cipherLength + } + + return { + totalPlaintextLength: total, + rangeLength: end - start, + ciphertextSpan: { offset: spanOffset, length: spanEnd - spanOffset }, + firstChunk, + lastChunk, + chunkCount, + skip: start - firstChunk * chunkSize, + includesFinalChunk: lastChunk === chunkCount - 1, + lastChunkCipherLength, + } + } + + it('matches a chunk-by-chunk walk across many offsets, lengths, and suffixes', () => { + for (let offset = 0; offset < total; offset++) { + for (const length of [undefined, 1, 3, total, total + 1000]) { + assert.deepStrictEqual( + planRange(layout, { offset, length }), + naivePlan(offset, length), + `offset=${offset} length=${length}` + ) + } + } + for (let suffix = 1; suffix <= total; suffix++) { + assert.deepStrictEqual(planRange(layout, { offset: -suffix }), naivePlan(-suffix), `suffix=-${suffix}`) + } + for (const suffix of [total + 1, total + 100, 10000]) { + assert.deepStrictEqual(planRange(layout, { offset: -suffix }), naivePlan(-suffix), `suffix=-${suffix}`) + } + }) + }) +}) diff --git a/packages/filecoin-encryption-envelope/test/range-source.test.ts b/packages/filecoin-encryption-envelope/test/range-source.test.ts new file mode 100644 index 00000000..6964c7d7 --- /dev/null +++ b/packages/filecoin-encryption-envelope/test/range-source.test.ts @@ -0,0 +1,485 @@ +import assert from 'node:assert' +import { type ChunkedEncryptOptions, encrypt } from '../src/aes-gcm-stream.ts' +import { MAX_ENCODED_OBJECT_SIZE } from '../src/constants.ts' +import { MAX_ENVELOPE_SIZE } from '../src/cose/constants.ts' +import { decodeEnvelope } from '../src/cose/decode.ts' +import { InvalidSourceLengthError, MalformedEnvelopeError } from '../src/errors.ts' +import { openExactRange, type RandomAccessSource, readEnvelope, toRandomAccessSource } from '../src/range/source.ts' +import { FIXED_CEK, fixedBaseNonceRandomValues, withRandomValues } from './aes-gcm-fixtures.ts' +import { deterministicPlaintext, readAllChunks, sourceOf } from './aes-gcm-stream-fixtures.ts' +import { concatBytes } from './cose-fixtures.ts' + +/** 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), ...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) +} + +/** An object whose envelope alone (large `app_metadata`) is bigger than 12288 bytes. */ +async function encryptBigEnvelope(): Promise { + return encryptFull(Uint8Array.from([1, 2, 3]), { appMetadata: { note: 'x'.repeat(14800) } }) +} + +describe('toRandomAccessSource', () => { + describe('Uint8Array adapter', () => { + it('reports size and returns the exact requested range', async () => { + const bytes = deterministicPlaintext(50) + const source = toRandomAccessSource(bytes) + assert.strictEqual(source.size, 50) + const stream = await source.openRange(10, 5) + const chunks = await readAllChunks(stream) + assert.deepStrictEqual(concatBytes(...chunks), bytes.subarray(10, 15)) + }) + + it('the returned view shares the input buffer (no copy)', async () => { + const bytes = deterministicPlaintext(20) + const source = toRandomAccessSource(bytes) + const stream = await source.openRange(0, 20) + const [chunk] = await readAllChunks(stream) + assert.strictEqual(chunk.buffer, bytes.buffer) + }) + + it('rejects a SharedArrayBuffer-backed input', () => { + const shared = new Uint8Array(new SharedArrayBuffer(10)) + assert.throws(() => toRandomAccessSource(shared), MalformedEnvelopeError) + }) + }) + + describe('object adapter', () => { + it('captures size and forwards openRange calls', async () => { + let calls = 0 + const raw = { + size: 42, + async openRange(offset: number, length: number) { + calls++ + return sourceOf([Uint8Array.from({ length }, (_, i) => offset + i)]) + }, + } + const source = toRandomAccessSource(raw) + assert.strictEqual(source.size, 42) + const stream = await source.openRange(1, 3) + assert.deepStrictEqual(concatBytes(...(await readAllChunks(stream))), Uint8Array.from([1, 2, 3])) + assert.strictEqual(calls, 1) + }) + + it('reads size and openRange exactly once from the input object, even across several openRange calls', async () => { + let sizeReads = 0 + let openRangeReads = 0 + const raw = { + get size() { + sizeReads++ + return 10 + }, + get openRange() { + openRangeReads++ + return async () => sourceOf([]) + }, + } + const source = toRandomAccessSource(raw) + assert.strictEqual(sizeReads, 1) + assert.strictEqual(openRangeReads, 1) + + // The getter must not be re-consulted on later calls through the + // adapter: only the function reference captured at construction runs. + await source.openRange(0, 1) + await source.openRange(1, 2) + assert.strictEqual(openRangeReads, 1) + }) + }) + + describe('size validation', () => { + const invalidSizes: Array<[string, unknown]> = [ + ['negative', -1], + ['non-integer', 1.5], + ['NaN', Number.NaN], + ['2**53 (not a safe integer)', 2 ** 53], + ['one over the max', MAX_ENCODED_OBJECT_SIZE + 1], + ] + for (const [label, size] of invalidSizes) { + it(`rejects a size that is ${label}`, () => { + assert.throws( + () => toRandomAccessSource({ size, openRange: async () => sourceOf([]) }), + InvalidSourceLengthError + ) + }) + } + + it('accepts exactly the max size', () => { + const source = toRandomAccessSource({ size: MAX_ENCODED_OBJECT_SIZE, openRange: async () => sourceOf([]) }) + assert.strictEqual(source.size, MAX_ENCODED_OBJECT_SIZE) + }) + }) + + describe('malformed input', () => { + it('rejects a non-function openRange', () => { + assert.throws(() => toRandomAccessSource({ size: 10, openRange: 'nope' }), MalformedEnvelopeError) + }) + + const nonSources: Array<[string, unknown, typeof MalformedEnvelopeError | typeof InvalidSourceLengthError]> = [ + ['null', null, MalformedEnvelopeError], + ['a number', 5, MalformedEnvelopeError], + ['a string', 'nope', MalformedEnvelopeError], + ['undefined', undefined, MalformedEnvelopeError], + // An array is `typeof 'object'` but has no `size`, so it fails size + // validation instead of the "not an object" check. + ['an array', [1, 2, 3], InvalidSourceLengthError], + ] + for (const [label, input, expected] of nonSources) { + it(`rejects ${label} as a source`, () => { + assert.throws(() => toRandomAccessSource(input), expected) + }) + } + }) +}) + +describe('openExactRange', () => { + it('cancel does not wait for a pending openRange, and cancels its stream once it arrives', async () => { + let resolveOpen: ((stream: ReadableStream) => void) | undefined + let signalOpenCalled: (() => void) | undefined + const openCalled = new Promise((resolve) => { + signalOpenCalled = resolve + }) + const source: RandomAccessSource = { + size: 100, + openRange: () => + new Promise((resolve) => { + resolveOpen = resolve + signalOpenCalled?.() + }), + } + const range = openExactRange(source, 0, 10) + const pendingRead = range.read() + await openCalled // openRange is now pending + + await range.cancel(new Error('stop')) // must settle while the open hangs + + let signalLateCancel: (() => void) | undefined + const lateCancelled = new Promise((resolve) => { + signalLateCancel = resolve + }) + resolveOpen?.( + new ReadableStream({ + cancel() { + signalLateCancel?.() + }, + }) + ) + await lateCancelled // the late stream is released, not leaked + // The read that was waiting on the open ends too, instead of hanging. + await assert.rejects(pendingRead, InvalidSourceLengthError) + }) + + function sourceReturning( + blocks: Uint8Array[], + openRangeOverride?: (offset: number, length: number) => Promise> + ): RandomAccessSource { + return { + size: 1000, + openRange: openRangeOverride ?? (async () => sourceOf(blocks)), + } + } + + it('reads a single-block exact range', async () => { + const data = deterministicPlaintext(10) + const range = openExactRange(sourceReturning([data]), 0, 10) + assert.deepStrictEqual(await range.read(), data) + assert.strictEqual(await range.read(), undefined) + }) + + it('reads a multi-block exact range', async () => { + const a = deterministicPlaintext(4) + const b = Uint8Array.from([9, 9, 9, 9, 9, 9]) + const range = openExactRange(sourceReturning([a, b]), 0, 10) + assert.deepStrictEqual(await range.read(), a) + assert.deepStrictEqual(await range.read(), b) + assert.strictEqual(await range.read(), undefined) + }) + + it('skips zero-length blocks', async () => { + const data = deterministicPlaintext(5) + const range = openExactRange(sourceReturning([new Uint8Array(0), data, new Uint8Array(0)]), 0, 5) + assert.deepStrictEqual(await range.read(), data) + assert.strictEqual(await range.read(), undefined) + }) + + it('rejects a short range once the underlying stream ends early', async () => { + const range = openExactRange(sourceReturning([deterministicPlaintext(3)]), 0, 10) + await range.read() + await assert.rejects(range.read(), InvalidSourceLengthError) + }) + + it('rejects a single block longer than the requested length', async () => { + const range = openExactRange(sourceReturning([deterministicPlaintext(20)]), 0, 10) + await assert.rejects(range.read(), InvalidSourceLengthError) + }) + + it('rejects a trailing block after the exact count', async () => { + const range = openExactRange(sourceReturning([deterministicPlaintext(10), Uint8Array.from([1])]), 0, 10) + await range.read() + await assert.rejects(range.read(), InvalidSourceLengthError) + }) + + it('rejects a zero-length block followed by more data after the exact count', async () => { + const range = openExactRange( + sourceReturning([deterministicPlaintext(10), new Uint8Array(0), Uint8Array.from([1])]), + 0, + 10 + ) + await range.read() + await assert.rejects(range.read(), InvalidSourceLengthError) + }) + + it('rejects a non-Uint8Array block', async () => { + const range = openExactRange( + sourceReturning( + [], + async () => + new ReadableStream({ + start(controller) { + controller.enqueue('nope' as unknown as Uint8Array) + controller.close() + }, + }) + ), + 0, + 3 + ) + await assert.rejects(range.read(), MalformedEnvelopeError) + }) + + it('rejects openRange resolving to a non-stream', async () => { + const range = openExactRange( + sourceReturning([], async () => 'nope' as unknown as ReadableStream), + 0, + 3 + ) + await assert.rejects(range.read(), MalformedEnvelopeError) + }) + + it('propagates an openRange rejection unchanged', async () => { + const boom = new Error('transport boom') + const range = openExactRange( + sourceReturning([], () => Promise.reject(boom)), + 0, + 3 + ) + await assert.rejects(range.read(), (error: unknown) => error === boom) + }) + + it('calls openRange at most once', async () => { + let calls = 0 + const range = openExactRange( + sourceReturning([deterministicPlaintext(4), deterministicPlaintext(4)], async () => { + calls++ + return sourceOf([deterministicPlaintext(8)]) + }), + 0, + 8 + ) + await range.read() + await range.read() + assert.strictEqual(calls, 1) + }) + + /** A pull-driven source stream that records cancellation; `blocks` are served one per pull. */ + function trackedStream(blocks: Uint8Array[]): { stream: ReadableStream; cancelled: () => boolean } { + let cancelled = false + let index = 0 + const stream = new ReadableStream( + { + pull(controller) { + const block = blocks[index++] + if (block === undefined) controller.close() + else controller.enqueue(block) + }, + cancel() { + cancelled = true + }, + }, + { highWaterMark: 0 } + ) + return { stream, cancelled: () => cancelled } + } + + it('cancels the source stream when a block overruns the requested length', async () => { + const tracked = trackedStream([new Uint8Array(4), new Uint8Array(8), new Uint8Array(4)]) + const range = openExactRange( + sourceReturning([], async () => tracked.stream), + 0, + 10 + ) + await range.read() + await assert.rejects(range.read(), InvalidSourceLengthError) + assert.strictEqual(tracked.cancelled(), true) + }) + + it('cancels the source stream when bytes follow the requested length', async () => { + const tracked = trackedStream([new Uint8Array(10), new Uint8Array(1), new Uint8Array(1)]) + const range = openExactRange( + sourceReturning([], async () => tracked.stream), + 0, + 10 + ) + await range.read() + await assert.rejects(range.read(), InvalidSourceLengthError) + assert.strictEqual(tracked.cancelled(), true) + }) + + it('cancel is a no-op before any read()', async () => { + const range = openExactRange(sourceReturning([deterministicPlaintext(5)]), 0, 5) + await assert.doesNotReject(range.cancel(new Error('unused'))) + }) + + it('cancel cancels the underlying reader', async () => { + let cancelReason: unknown + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(deterministicPlaintext(5)) + // Left open: the range is never fully drained naturally. + }, + cancel(reason) { + cancelReason = reason + }, + }) + const range = openExactRange( + sourceReturning([], async () => stream), + 0, + 10 + ) + await range.read() + const reason = new Error('stop early') + await range.cancel(reason) + assert.strictEqual(cancelReason, reason) + }) +}) + +describe('readEnvelope', () => { + function recordingSource(bytes: Uint8Array): { + source: RandomAccessSource + calls: Array<{ offset: number; length: number }> + cancelled: Array<{ offset: number; length: number }> + } { + const calls: Array<{ offset: number; length: number }> = [] + const cancelled: Array<{ offset: number; length: number }> = [] + // Delivered in small blocks, closing only on a *later* pull() once + // fully drained -- like a real transport, so a span that completes the + // envelope partway through still has bytes left to actually cancel. + const blockSize = 64 + const source: RandomAccessSource = { + size: bytes.length, + async openRange(offset, length) { + calls.push({ offset, length }) + const slice = bytes.subarray(offset, offset + length) + let pos = 0 + return new ReadableStream( + { + pull(controller) { + if (pos >= slice.length) { + controller.close() + return + } + const end = Math.min(pos + blockSize, slice.length) + controller.enqueue(slice.subarray(pos, end)) + pos = end + }, + cancel() { + cancelled.push({ offset, length }) + }, + }, + // Without this, the default queuing strategy (highWaterMark: 1) + // eagerly pulls one block ahead of every explicit read() -- which + // would call pull() again (and self-close) right after the block + // that completes the envelope, before this test ever cancels it. + { highWaterMark: 0 } + ) + }, + } + return { source, calls, cancelled } + } + + it('probes exactly one span [0, min(4096, size)) for a small envelope', async () => { + const full = await encryptFull(deterministicPlaintext(10)) + const { source, calls } = recordingSource(full) + const decoded = await readEnvelope(source) + assert.deepStrictEqual(calls, [{ offset: 0, length: Math.min(4096, full.length) }]) + assert.deepStrictEqual(decoded, decodeEnvelope(full)) + }) + + it('probes contiguous, doubling spans for an envelope larger than 4096 bytes, never re-reading offset 0', async () => { + const full = await encryptBigEnvelope() + const { source, calls, cancelled } = recordingSource(full) + const decoded = await readEnvelope(source) + + assert.deepStrictEqual( + calls.map((call) => call.offset), + [0, 4096, 12288] + ) + assert.deepStrictEqual(calls[0], { offset: 0, length: 4096 }) + assert.deepStrictEqual(calls[1], { offset: 4096, length: 8192 }) + assert.strictEqual(calls[2].offset, 12288) + for (const call of calls) { + assert.ok(call.offset + call.length <= full.length, 'never reads past the source size') + assert.ok(call.offset + call.length <= MAX_ENVELOPE_SIZE, 'never reads past the 1 MiB envelope limit') + } + assert.deepStrictEqual(decoded, decodeEnvelope(full)) + // The span that completed the envelope is cancelled, not drained. + assert.deepStrictEqual(cancelled, [calls[2]]) + }) + + it('probes exactly `size` when smaller than 4096', async () => { + const full = await encryptFull(deterministicPlaintext(2)) + assert.ok(full.length < 4096) + const { source, calls } = recordingSource(full) + await readEnvelope(source) + assert.deepStrictEqual(calls, [{ offset: 0, length: full.length }]) + }) + + it('rejects bytes ending inside the envelope with MalformedEnvelopeError', async () => { + const full = await encryptBigEnvelope() + const envelopeLength = decodeEnvelope(full).envelopeLength + const truncated = full.subarray(0, envelopeLength - 5) + const { source } = recordingSource(truncated) + await assert.rejects(readEnvelope(source), MalformedEnvelopeError) + }) + + it('behaves the same for size === 0', async () => { + const source: RandomAccessSource = { + size: 0, + async openRange() { + return sourceOf([]) + }, + } + await assert.rejects(readEnvelope(source), MalformedEnvelopeError) + }) + + it('fails fast on non-FEE bytes, with no further openRange calls', async () => { + // 0xff: major 7, additional info 31 -- rejected immediately as an + // indefinite-length/break marker, before any more input is requested. + const garbage = new Uint8Array(5000).fill(0xff) + const { source, calls, cancelled } = recordingSource(garbage) + await assert.rejects(readEnvelope(source), MalformedEnvelopeError) + assert.strictEqual(calls.length, 1) + // The failing span is released, not left open. + assert.deepStrictEqual(cancelled, [calls[0]]) + }) + + it('rejects a source returning a short first span with InvalidSourceLengthError', async () => { + const full = await encryptBigEnvelope() + const source: RandomAccessSource = { + size: full.length, + async openRange(offset, length) { + // Always one byte short of what was requested. + return sourceOf([full.subarray(offset, offset + length - 1)]) + }, + } + await assert.rejects(readEnvelope(source), InvalidSourceLengthError) + }) +})