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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/filecoin-encryption-envelope/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ parsing, tags 16/96, protected and unprotected headers, detached-ciphertext fram
structural recipient validation), scheme-1 AES-256-GCM encryption and decryption in `aes-gcm.ts` (direct
CEK, both tag 16 and tag 96), A256KW recipient wrapping on encryption, the built-in A256KW unwrapper
factory (`createA256KWUnwrapper`), and recipient-based decryption through an unwrapper
(`aesGcm.decryptWith`), and chunked streaming encryption in `aes-gcm-stream.ts` (direct CEK or A256KW
recipients, optional `plaintext_length`). Not yet implemented: chunked decryption, range reads, and
envelope inspection beyond decode. ECDH-ES+A256KW remains deferred; the code enforces its settled header
placement but does not derive or unwrap its KEK.
(`aesGcm.decryptWith`), and chunked streaming encryption and decryption in `aes-gcm-stream.ts` (direct CEK
or A256KW recipients, optional `plaintext_length`). Not yet implemented: range reads and envelope
inspection beyond decode. ECDH-ES+A256KW remains deferred; the code enforces its settled header placement
but does not derive or unwrap its KEK.

## Scope discipline

Expand All @@ -38,14 +38,14 @@ explicit exports when helpers must stay internal, as `cose/index.ts` does for `h
`src/index.ts` is the allowlist of this package's public interface: a module is public only if
`src/index.ts` exports it. Shared implementation code lives under `src/internal/` and is never exported.
Two exceptions to "namespaces only": shared type-only exports (currently `AppMetadata`, `CborValue`),
since a type carries no runtime shape, and the chunked streaming functions (`encrypt` and its options
type, later `decrypt`/`decryptWith`), which the tech spec makes the default path at the root while
whole-object AES-GCM stays opt-in under `aesGcm`. Public constants are
since a type carries no runtime shape, and the chunked streaming functions (`encrypt`, `decrypt`,
`decryptWith`, and `encrypt`'s options type), which the tech spec makes the default path at the root
while whole-object AES-GCM stays opt-in under `aesGcm`. Public constants are
re-exported through the curated `src/public-constants.ts`, never `src/constants.ts` directly:

```ts
export * as aesGcm from './aes-gcm.ts'
export { type ChunkedEncryptOptions, encrypt } from './aes-gcm-stream.ts'
export { type ChunkedEncryptOptions, decrypt, decryptWith, encrypt } from './aes-gcm-stream.ts'
export type { AppMetadata, CborValue } from './cose/headers.ts'
export * as cose from './cose/index.ts'
export * as errors from './errors.ts'
Expand Down
358 changes: 328 additions & 30 deletions packages/filecoin-encryption-envelope/src/aes-gcm-stream.ts

Large diffs are not rendered by default.

33 changes: 5 additions & 28 deletions packages/filecoin-encryption-envelope/src/aes-gcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
* supplied CEK (`decrypt`) or a CEK recovered from a recipient (`decryptWith`).
*/
import { ALG_AES_256_GCM, MAX_AES_GCM_PLAINTEXT_SIZE, NONCE_SIZE, TAG_SIZE } from './constants.ts'
import { TAG_ENCRYPT0 } from './cose/constants.ts'
import { type DecodedEnvelope, decodeEnvelope } from './cose/decode.ts'
import { encStructure } from './cose/enc-structure.ts'
import { assemblePreparedEnvelope } from './cose/encode.ts'
Expand All @@ -19,14 +18,12 @@ import {
InvalidPlaintextError,
InvalidPlaintextLengthError,
MalformedEnvelopeError,
NoUsableRecipientError,
RecipientUnwrapError,
UnsupportedSchemeError,
} from './errors.ts'
import { assertAes256Key, assertArrayBufferBacked } from './internal/keys.ts'
import { aesGcmDecrypt, aesGcmEncrypt, importAesGcmKey, randomBytes } from './internal/web-crypto.ts'
import { toRecipientInfo } from './recipients/info.ts'
import { createRecipientRecords, prepareRecipientInputs } from './recipients/prepare.ts'
import { recoverCek } from './recipients/recover.ts'
import type { Recipient, Unwrapper } from './recipients/types.ts'

const MAX_AES_GCM_CIPHERTEXT_SIZE = MAX_AES_GCM_PLAINTEXT_SIZE + TAG_SIZE
Expand Down Expand Up @@ -128,10 +125,9 @@ interface PreparedDecryption {
}

/**
* Shared steps for both decryption entry points: input backing check, decode,
* content-algorithm check, detached-ciphertext view, AAD, and IV. Neither
* caller's remaining checks (CEK shape, recipient presence) belong here, since
* `decrypt` and `decryptWith` order them differently against this common work.
* Validate and decode a scheme-1 object, returning the detached ciphertext
* and inputs needed for content authentication. The ciphertext remains a view
* into `encoded`; the caller must still supply or recover the CEK.
*/
function prepareDecryption(encoded: Uint8Array): PreparedDecryption {
if (encoded instanceof Uint8Array) {
Expand Down Expand Up @@ -185,26 +181,7 @@ export async function decryptWith(encoded: Uint8Array, unwrapper: Unwrapper): Pr
}

const { decoded, ciphertext, additionalData, iv } = prepareDecryption(encoded)
if (decoded.tag === TAG_ENCRYPT0) {
throw new NoUsableRecipientError(
'No usable recipient: this envelope is COSE_Encrypt0 and carries no recipients; supply the CEK directly with aesGcm.decrypt instead.'
)
}

const infos = decoded.recipients.map(toRecipientInfo)
let cek: Uint8Array | undefined
try {
cek = await unwrapper(infos)
} catch (cause) {
throw new RecipientUnwrapError('Recipient key recovery failed.', { cause })
}
if (cek === undefined) {
throw new NoUsableRecipientError(
`No usable recipient: none of the ${infos.length} recipients offered a usable key.`
)
}
assertAes256Key(cek, 'recovered CEK')

const cek = await recoverCek(decoded, unwrapper)
const key = await importAesGcmKey(cek, 'decrypt', false)
return await aesGcmDecrypt(key, iv, additionalData, ciphertext)
}
264 changes: 264 additions & 0 deletions packages/filecoin-encryption-envelope/src/cose/envelope-scanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/**
* Finds the COSE envelope boundary in an arbitrarily chunked encoded object.
*
* The scanner incrementally walks the structure of the first CBOR item
* without decoding its values, so it can determine where the envelope ends
* and detached ciphertext begins. It advances only over newly available
* bytes and never reparses data it has already consumed.
*
* Once the boundary is found, `decodeEnvelope` runs exactly once on the
* complete envelope and performs the package's normal validation. Truncated
* input and malformed CBOR are reported separately.
*
* `scanEnvelopeStep` implements the stateless scanning step;
* `createEnvelopeScanner` provides the stateful streaming wrapper.
*/
import { MalformedEnvelopeError } from '../errors.ts'
import { MAX_APP_METADATA_DEPTH, MAX_ENVELOPE_SIZE } from './constants.ts'
import { type DecodedEnvelope, decodeEnvelope } from './decode.ts'

/**
* `cursor` is the start of the next head to read; it only ever moves
* forward, and only once a head (and, for a string, its content) is fully
* confirmed available. `stack` holds the remaining item count for each open
* array/map/tag; the top level starts by expecting exactly one item.
*/
export interface EnvelopeScanState {
cursor: number
stack: number[]
}

/** A fresh scan state, ready to be passed to {@link scanEnvelopeStep} from the start of an envelope. */
export function createEnvelopeScanState(): EnvelopeScanState {
return { cursor: 0, stack: [1] }
}

function closeFinished(stack: number[]): void {
for (;;) {
const top = stack[stack.length - 1]
if (top === undefined || top > 0) return
stack.pop()
}
}

function decrementParent(stack: number[]): void {
const top = stack[stack.length - 1]
if (top !== undefined) {
stack[stack.length - 1] = top - 1
}
}

/**
* Advances the incremental CBOR scan as far as the available bytes allow.
*
* Returns the byte length of the first complete top-level item, or `undefined`
* if more input is needed. When input is incomplete, `state.cursor` remains at
* the start of the unfinished item so only that item is reconsidered when more
* bytes arrive.
*
* Rejects CBOR structures, nesting, and sizes that this envelope profile does
* not permit. Full envelope validation is performed later by `decodeEnvelope`.
*/
export function scanEnvelopeStep(data: ArrayLike<number>, state: EnvelopeScanState): number | undefined {
for (;;) {
if (state.stack.length === 0) {
return state.cursor
}

const headStart = state.cursor
if (headStart >= data.length) {
return undefined
}

const firstByte = data[headStart] as number
const major = firstByte >>> 5
const info = firstByte & 0x1f

let extraBytes: number
if (info < 24) {
extraBytes = 0
} else if (info === 24) {
extraBytes = 1
} else if (info === 25) {
extraBytes = 2
} else if (info === 26) {
extraBytes = 4
} else if (info === 27) {
extraBytes = 8
} else if (info === 31) {
throw new MalformedEnvelopeError(
'Malformed envelope: indefinite-length CBOR items and the break code are not permitted.'
)
} else {
throw new MalformedEnvelopeError(
`Malformed envelope: reserved CBOR additional information ${info} is not permitted.`
)
}

const payloadStart = headStart + 1
if (payloadStart + extraBytes > data.length) {
return undefined // head itself is split across the available bytes; retry from headStart
}

let value: number
if (extraBytes === 0) {
value = info
} else if (extraBytes <= 4) {
value = 0
for (let i = 0; i < extraBytes; i++) {
value = value * 256 + (data[payloadStart + i] as number)
}
} else {
// A nonzero high half is too large for a string length or container
// count. Integer and tag values are not envelope lengths; the decoder
// validates those after the scan.
let high = 0
for (let i = 0; i < 4; i++) {
high = high * 256 + (data[payloadStart + i] as number)
}
if (high !== 0 && major >= 2 && major <= 5) {
throw new MalformedEnvelopeError("Malformed envelope: an 8-byte CBOR length exceeds this profile's limits.")
}
value = 0
for (let i = 4; i < 8; i++) {
value = value * 256 + (data[payloadStart + i] as number)
}
}

const nextPos = payloadStart + extraBytes
const remainingBudget = MAX_ENVELOPE_SIZE - nextPos

if (major === 2 || major === 3) {
// Byte or text string: skip `value` content bytes without reading
// them -- their content has no bearing on structure.
if (value > remainingBudget) {
throw new MalformedEnvelopeError(
`Malformed envelope: a ${major === 2 ? 'byte' : 'text'} string of ${value} bytes exceeds the ` +
`${MAX_ENVELOPE_SIZE}-byte envelope budget.`
)
}
const stringEnd = nextPos + value
if (stringEnd > data.length) {
return undefined // content not fully available yet; retry from headStart
}
decrementParent(state.stack)
state.cursor = stringEnd
closeFinished(state.stack)
continue
}

if (major === 4 || major === 5 || major === 6) {
// Array, map (pairs count double), or tag (always exactly one nested item).
const items = major === 6 ? 1 : major === 5 ? value * 2 : value
if (items > remainingBudget) {
throw new MalformedEnvelopeError(
`Malformed envelope: a container declaring ${items} items exceeds the ${MAX_ENVELOPE_SIZE}-byte envelope budget.`
)
}
decrementParent(state.stack)
// `state.stack` carries one extra, synthetic frame for the top level
// (see createEnvelopeScanState) that `headers.ts`'s tokenizer doesn't
// have -- its `open` array starts empty, not `[1]`. `state.stack.length`
// here is already that tokenizer's `open.length + 1` for the same
// wire position, so comparing it to the limit directly (no further
// `+ 1`) is what keeps the two counts in step.
if (state.stack.length > MAX_APP_METADATA_DEPTH) {
throw new MalformedEnvelopeError(
`Malformed envelope: CBOR nesting deeper than ${MAX_APP_METADATA_DEPTH} levels is not permitted.`
)
}
state.cursor = nextPos
state.stack.push(items)
closeFinished(state.stack)
continue
}

// Major 0 (uint), 1 (negint), 7 (simple/float): nothing further to skip.
decrementParent(state.stack)
state.cursor = nextPos
closeFinished(state.stack)
}
}

export interface EnvelopeScanResult {
decoded: DecodedEnvelope
/** Bytes after the envelope: a view into whichever `push()` block supplied them, not a copy. */
rest: Uint8Array
}

export interface EnvelopeScanner {
/**
* Feed the next block. Returns the decoded envelope plus any trailing
* ciphertext from this same block once the envelope completes, or
* `undefined` while more input is still needed. Calling this again after
* it has already returned a result is a caller bug.
*/
push(block: Uint8Array): EnvelopeScanResult | undefined
/** The source ended. Throws if the envelope never completed. */
finish(): void
}

/**
* Decode one envelope across any number of input blocks, up to
* `MAX_ENVELOPE_SIZE` bytes. Until the envelope is complete, `push()` returns
* `undefined`. On completion it returns decoded fields independent of the
* caller's blocks, plus a `rest` view into the block that ended the envelope.
*/
export function createEnvelopeScanner(): EnvelopeScanner {
let buffer = new Uint8Array(1024)
let filled = 0
const state = createEnvelopeScanState()
let completed = false

function ensureCapacity(needed: number): void {
if (buffer.length >= needed) return
let capacity = buffer.length
while (capacity < needed) capacity *= 2
const grown = new Uint8Array(Math.min(capacity, MAX_ENVELOPE_SIZE))
grown.set(buffer.subarray(0, filled))
buffer = grown
}

function push(block: Uint8Array): EnvelopeScanResult | undefined {
if (completed) {
throw new Error('createEnvelopeScanner: push() called after the envelope already completed.')
}

const filledBefore = filled
// Never let the scan see more than the envelope's own size ceiling.
const extraLength = Math.min(block.length, MAX_ENVELOPE_SIZE - filledBefore)
ensureCapacity(filledBefore + extraLength)
buffer.set(block.subarray(0, extraLength), filledBefore)

const length = scanEnvelopeStep(buffer.subarray(0, filledBefore + extraLength), state)
if (length === undefined) {
// Not complete yet: everything offered so far might still be envelope.
filled = filledBefore + extraLength
if (filled >= MAX_ENVELOPE_SIZE) {
throw new MalformedEnvelopeError(`Malformed envelope: exceeds the ${MAX_ENVELOPE_SIZE}-byte envelope limit.`)
}
return undefined
}

// Complete: anything past `length` in the buffer is unused scratch.
const consumedFromBlock = length - filledBefore
filled = length
completed = true

const decoded = decodeEnvelope(buffer.subarray(0, length))
if (decoded.envelopeLength !== length) {
throw new MalformedEnvelopeError(
'Malformed envelope: internal inconsistency between the structural scan and decodeEnvelope.'
)
}

return { decoded, rest: block.subarray(consumedFromBlock) }
}

function finish(): void {
if (completed) return
throw new MalformedEnvelopeError('Malformed envelope: input ended inside the envelope.')
}

return { push, finish }
}
3 changes: 2 additions & 1 deletion packages/filecoin-encryption-envelope/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
* import * as fee from '@filoz/filecoin-encryption-envelope'
*
* source.pipeThrough(fee.encrypt({ cek })) // chunked stream, the default
* encrypted.pipeThrough(fee.decrypt(cek)) // and back
* fee.aesGcm.encrypt(plaintext, { cek }) // whole-object, opt-in
* fee.constants.ALG_A256KW
* ```
*
* @module filecoin-encryption-envelope
*/
export * as aesGcm from './aes-gcm.ts'
export { type ChunkedEncryptOptions, encrypt } from './aes-gcm-stream.ts'
export { type ChunkedEncryptOptions, decrypt, decryptWith, encrypt } from './aes-gcm-stream.ts'
export type { AppMetadata, CborValue } from './cose/headers.ts'
export * as cose from './cose/index.ts'
export * as errors from './errors.ts'
Expand Down
Loading
Loading