Skip to content

fee: top-level package composing encrypt/decrypt over existing primitives (FIL-569) - #14

Merged
alanshaw merged 7 commits into
claude/fil-473-s7pjf9from
claude/fil-569-nqlw8i
Jul 31, 2026
Merged

fee: top-level package composing encrypt/decrypt over existing primitives (FIL-569)#14
alanshaw merged 7 commits into
claude/fil-473-s7pjf9from
claude/fil-569-nqlw8i

Conversation

@Peeja

@Peeja Peeja commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

tl;dr: Provides the outer fee package interface that ties the primitives together.


Stacked on #32 (FIL-473). This PR targets claude/fil-473-s7pjf9, not main, so its diff is only the composer on top of #32. Review/merge #32 first; GitHub will retarget this to main automatically when #32 lands.

What

Adds github.com/fil-forge/ingot/fee — a standalone FEE (FilOne File Encryption Envelope) library that composes the four primitives (fee/cose, fee/aesstream, fee/ecdhkw, fee/aeskw) into a small public API for whole-object encrypt/decrypt, so callers no longer sequence COSE encode/decode, STREAM encrypt/decrypt and recipient wrap/unwrap by hand. No app vocabulary at this layer — it's publishable as an independent FEE package.

func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error)
func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error)
func Decrypt(src io.Reader, unwrap RecipientUnwrapper) (io.Reader, error)
func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error)

func NewECDHESRecipient(kid []byte, pub *ecdh.PublicKey) Recipient   // ECDH-ES+A256KW (X25519)
func NewA256KWRecipient(kid, kek []byte) Recipient                   // A256KW (32-byte KEK)
func NewECDHESUnwrapper(kid []byte, priv *ecdh.PrivateKey) RecipientUnwrapper
func NewA256KWUnwrapper(kid, kek []byte) RecipientUnwrapper
func WithChunkSize(n int) EncryptOption
func WithContentLength(n int64) EncryptOption

Two orthogonal concerns

1. Wrap algorithm — how the CEK is wrapped into an in-envelope COSE_Recipient. Both may be mixed in one envelope; on decrypt the caller never selects the algorithm, the recipient's COSE header does:

  • ECDH-ES+A256KW to an X25519 public key (NewECDHESRecipient / NewECDHESUnwrapper).
  • A256KW under a symmetric KEK (NewA256KWRecipient / NewA256KWUnwrapper).

kid is an opaque, caller-supplied key id for both kinds (a DID verification method ID in the consuming app); the library never derives or interprets it.

2. CEK delivery — decoupled from the algorithm:

  • In-envelope: Encrypt wraps a fresh CEK to the recipients (a COSE_Encrypt, tag 96); Decrypt recovers it by matching a RecipientUnwrapper's kid and unwrapping.
  • Externally-provided: EncryptWithCEK seals under a caller-held CEK; with recipients it emits a tag-96 envelope, and with no recipients it emits a recipient-less COSE_Encrypt0 (tag 16). DecryptWithCEK decrypts with a CEK obtained out of band (e.g. unwrapped by a custody service), accepting either form and ignoring recipients. Decrypt on a recipient-less envelope returns ErrNoRecipientsInEnvelope.

Naming tracks the exact COSE algorithms because the arguments are pinned to them (X25519-only key agreement; 32-byte A256KW KEK; 32-byte AES-256 CEK), not general ECDH/AES-KW.

Streaming (both directions, O(chunk) memory)

Neither direction buffers the whole object, so a multi-GB body can be piped straight to/from storage.

  • Decrypt/DecryptWithCEK are pull-based: cose.DecodeReader reads only the header, then aesstream.NewReader streams the ciphertext — no goroutine.
  • Encrypt/EncryptWithCEK return an io.ReadCloser: the STREAM body cipher is push-based (finalizes on Close, like gzip/age), so a reader-returning API bridges push→pull with an internal io.Pipe + goroutine. Read to EOF or Close()Close aborts the goroutine; an encryption error surfaces as a non-EOF Read error. The pipe is created only after all fallible header work succeeds, so no error path can orphan it.

cose.DecodeReader (sub-package addition)

DecodeReader(io.Reader) (*cose.Envelope, io.Reader, error) decodes the COSE header from a stream — dispatching on the tag (96 COSE_Encrypt / 16 COSE_Encrypt0) — and returns the detached ciphertext as a reader (the decoder's Buffered() remainder + the source). It shares the strict decodeEnvelope validation core with the byte-based cose.Decode and produces a byte-identical EncStructure (AAD), so an envelope decrypts the same read either way.

Note: #32 unified cose.Encrypt/cose.Encrypt0 into a single cose.Envelope{Headers, Recipients} whose form — COSE_Encrypt (tag 96) vs COSE_Encrypt0 (tag 16), the array shape, and the Enc_structure context — is derived from recipient presence rather than stored. This PR's DecodeReader returns that type, and the composer builds one Envelope for both the AAD and the encoded header.

Wire format

Detached-payload convention: the COSE envelope followed by the STREAM ciphertext. Reconciled to the foc-encryption reference and the FIL-473 cross-implementation vectors:

  • Body protected header: FEE typ (application/vnd.foc-envelope+cose) and the body alg (-65793, private-use chunked AES-256-GCM-STREAM), both authenticated via the Enc_structure AAD.
  • Body unprotected header: base nonce in iv (label 5), chunk size (-65790), and — only when the plaintext length is known via WithContentLength — the advisory chunk count (-65791). The chunk count is metadata for range/seek planning and is not required to decrypt; when omitted, range decryption derives the geometry from the ciphertext length instead.
  • Recipient entries: wrap alg (-31 ECDH-ES+A256KW / -5 A256KW), the kid, and — for ECDH-ES — the ephemeral key as a self-describing COSE_Key (kty=OKP, crv=X25519, x=public-key bytes), decoded and validated on unwrap.
  • Body AAD is the envelope's own Enc_structure, whose context ("Encrypt"/"Encrypt0") tracks recipient presence.

Acceptance criteria

  • ✅ Mixed ECDH-ES + A256KW recipients in one Encrypt, opened by either — TestRoundTripMixedRecipients.
  • ✅ Recover plaintext from the appropriate key/KEK, or a directly-provided CEK (with recipients and recipient-less) — round-trip + TestExternalCEK + TestExternalCEKNoRecipients.
  • ✅ A kid matching no recipient → ErrNoMatchingRecipient (no panic/silent failure) — TestDecryptKidMismatch.
  • ✅ Sub-package behavior unchanged; their tests pass (only doc comments de-worded).

Design notes

  • Chunk size stored in the envelope (unprotected), default 256 KiB, overridable via WithChunkSize; present-but-non-integer is rejected as malformed.
  • A256KW KEK / external CEK are required to be 32 bytes; ECDH-ES requires an X25519 key. Invalid recipients (nil key, empty kid, wrong KEK length) are rejected before any plaintext is read.
  • WithContentLength declares the plaintext length so the chunk count can be recorded; the returned reader then fails with ErrContentLengthMismatch if the actual length differs (and the already-written count can't be trusted).
  • ECDH ephemeral is a COSE_Key, not raw bytes — a malformed ephemeral key (wrong kty/crv, missing/short x, or not a map) is rejected as ErrMalformedEnvelope before any ECDH is attempted.

Testing

GOWORK=off go test -race ./fee/... -count=1 — green (incl. the goroutine-backed streaming Encrypt under the race detector). go build ./..., go vet ./fee/..., gofmt clean. Coverage: round-trips across sizes for ECDH-ES / A256KW / mixed; external-CEK round trip (with recipients + recipient-less) + in-envelope cross-check + wrong-CEK + invalid length; content-length chunk-count present/omitted + mismatch; pure reader→reader streaming; fragmented (one-byte-at-a-time) decrypt; Encrypt reader Close; kid mismatch (both kinds); wrong ECDH key and wrong A256KW KEK (both fail before decryption); recipient alg mismatch; corrupted header (→ cose.ErrMalformed); tampered ciphertext (→ aesstream.ErrCorrupted); malformed COSE_Key ephemeral (→ ErrMalformedEnvelope); wrong envelope typ; no/nil/invalid recipients; nil src/unwrapper; out-of-range & malformed chunk size; self-describing chunk size; and cose.DecodeReader (streaming + fragmented + empty + both forms + cose.Decode-equivalence).

Closes FIL-569.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new top-level fee package that composes the existing FEE primitives (fee/cose, fee/aesstream, fee/ecdhkw, fee/aeskw) into a small public API for whole-object encrypt/decrypt via a detached COSE_Encrypt envelope (envelope || ciphertext).

Changes:

  • Introduces fee.Encrypt / fee.Decrypt, including envelope typing, body alg pinning, and self-describing chunk size handling.
  • Adds sealed Recipient / RecipientUnwrapper implementations for tenant (X25519 ECDH-ES+A256KW) and region (A256KW) recipients.
  • Adds comprehensive round-trip and tamper/mismatch tests for mixed recipients and error paths.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
fee/fee.go Implements the top-level Encrypt/Decrypt orchestration and envelope conventions.
fee/recipient.go Defines sealed recipient wrappers for tenant/region key wrapping into COSE_Recipient entries.
fee/unwrapper.go Defines sealed unwrappers for recovering the CEK for tenant/region recipients during decrypt.
fee/fee_test.go Adds API-level tests for round trips, wire conventions, and failure modes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread fee/fee.go Outdated
Comment thread fee/fee.go
Comment thread fee/fee.go Outdated
Comment thread fee/fee.go
Comment thread fee/unwrapper.go Outdated
Comment thread fee/unwrapper.go Outdated
Peeja pushed a commit that referenced this pull request Jun 30, 2026
Address PR #14 review feedback on the FEE composition layer:

- Encrypt validates recipient contents (tenant key present + X25519; region kid
  non-empty + 32-byte A256KW KEK) up front via a new sealed validate(), so a
  malformed recipient fails before the plaintext is consumed and sealed.
- Wrap the base-nonce generation error with fee-level context.
- Decrypt distinguishes a missing/non-integer body alg header from a wrong alg,
  and rejects a chunk-size header that is present but not an integer as a
  malformed envelope rather than silently falling back to the default.
- The tenant and region unwrappers split the missing/invalid alg-header case
  from the wrong-algorithm case, so the error no longer misreports "algorithm 0".

Adds tests for invalid-recipient fail-fast and the malformed chunk-size header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
Peeja pushed a commit that referenced this pull request Jul 1, 2026
Per the PR #14 review discussion, neither Encrypt nor Decrypt should buffer the
whole object.

cose:
- Add DecodeReader(io.Reader) (*Encrypt, io.Reader, error): decode the envelope
  header from a stream and hand back the detached ciphertext as a reader (the
  decoder's Buffered() remainder followed by the source). Only the small header
  is held in memory.
- Reimplement Decode([]byte) as a thin convenience wrapper over DecodeReader.

fee:
- Encrypt now returns an io.ReadCloser over envelope||ciphertext, produced as
  the plaintext is read. The STREAM body cipher is push-based (like gzip/age, it
  finalizes on Close), so a reader-returning API bridges push->pull with an
  io.Pipe + background goroutine; Close aborts the goroutine.
- Decrypt now takes the envelope as an io.Reader and streams the detached
  ciphertext from it via cose.DecodeReader.

Both directions now stream end-to-end with O(chunk) memory, so a multi-GB body
can be piped straight to or from storage (e.g. the FIL-481 upload path). Callers
holding a []byte wrap it in bytes.NewReader / drain with io.ReadAll.

Tests: streaming reader->reader round trip, decrypt from a one-byte-at-a-time
reader, Encrypt reader Close, and cose.DecodeReader (incl. fragmented input);
existing round-trip / tamper / mismatch coverage retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
Peeja pushed a commit that referenced this pull request Jul 3, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
@Peeja
Peeja force-pushed the claude/fil-569-nqlw8i branch from 3e12471 to fbb1ee8 Compare July 3, 2026 21:01
@Peeja
Peeja changed the base branch from main to claude/fil-473-s7pjf9 July 3, 2026 21:02
@Peeja
Peeja force-pushed the claude/fil-569-nqlw8i branch from fbb1ee8 to 6b0b9c8 Compare July 3, 2026 21:04
@Peeja
Peeja requested a review from Copilot July 3, 2026 21:37
Peeja pushed a commit that referenced this pull request Jul 3, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread fee/fee.go Outdated
Comment thread fee/cose/decode.go Outdated
@Peeja
Peeja force-pushed the claude/fil-569-nqlw8i branch from 19eb203 to 5435a62 Compare July 3, 2026 22:17
Peeja pushed a commit that referenced this pull request Jul 3, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
claude added 4 commits July 3, 2026 22:35
…t (FIL-569)

Add a top-level `fee` package that sequences the four FEE primitives
(cose, aesstream, ecdhkw, aeskw) into a small streaming API:

  - Encrypt(plaintext, recipients) -> io.ReadCloser over envelope||ciphertext
  - Decrypt(src, unwrap) -> io.Reader over the recovered plaintext
  - EncryptWithCEK / DecryptWithCEK for an externally-managed CEK

Both directions stream with O(chunk size) memory: Encrypt seals through an
io.Pipe fed by a background goroutine; Decrypt reads only the envelope header
up front (via the new cose.DecodeReader) and streams the detached ciphertext.

Recipients are domain-agnostic and selected on decrypt by an opaque,
caller-supplied kid (a DID verification method ID): ECDH-ES+A256KW to an X25519
public key, or A256KW under a symmetric KEK. Both kinds may be mixed in one
envelope; a kid that matches no recipient yields ErrNoMatchingRecipient. With
no recipients, EncryptWithCEK emits a recipient-less COSE_Encrypt0 (tag 16).

The wire format matches the foc-encryption reference and the FIL-473
cross-implementation vectors: typ application/vnd.foc-envelope+cose, the
chunked AES-256-GCM-STREAM body alg in the protected header, and the base
nonce / chunk size (and, when the content length is known via WithContentLength,
the advisory chunk count) in the unprotected header. The ECDH-ES ephemeral key
is a self-describing COSE_Key (kty=OKP, crv=X25519), decoded and validated on
unwrap. The body AAD is the envelope's own Enc_structure, so its context tracks
the tag.

Also add cose.DecodeReader: a streaming, tag-16/96-dispatching decoder that
returns the decoded header plus a reader over the trailing detached ciphertext,
producing the same AAD and trailing bytes as the byte-based Decode. De-word the
aeskw/ecdhkw package docs (drop the app-specific "tenant"/"region" vocabulary)
so the primitives read as a standalone FEE library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
Address review: encryptStream created the io.Pipe and the aesstream writer
before wrapping recipients and encoding the COSE header, so an error from
r.wrap or Encode returned without closing the pipe, orphaning the
PipeReader/PipeWriter pair until GC. Move all the fallible header work ahead
of the pipe, and close both ends if the sole remaining fallible step
(NewWriter) fails, so no pipe is left dangling on any error path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
The DecodeReader doc still referenced [Decode], which #32 renamed to
DecodeEncrypt. Align the doc link with the current name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
FIL-473 collapsed cose.Encrypt/Encrypt0 into a single cose.Envelope (tag and
Enc_structure context computed from recipient presence) and replaced the two
byte decoders with one tag-dispatching cose.Decode. Adapt this PR's additions
to match:

  - cose.DecodeReader now returns *cose.Envelope and shares both cose's
    decodeTagArray preamble and decodeEnvelope validation core with cose.Decode
    (they differ only in how they recover the trailing detached payload — a
    []byte subslice vs an io.Reader). The redundant DecodedEnvelope type is gone.
  - encryptStream builds one cose.Envelope and uses it for both the AAD
    (EncStructure) and the encoded header (Encode), instead of a DecodedEnvelope
    for the AAD plus a separate Encrypt/Encrypt0 for the header. The envelopeTag
    helper is dropped — recipient presence is the form.
  - Decrypt discriminates the recipient-less envelope with len(Recipients)==0
    rather than a Tag comparison; openStream takes *cose.Envelope.
  - Tests updated: DecodeReader equivalence now checks against cose.Decode, and
    fee tests build cose.Envelope / call cose.Decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
@Peeja
Peeja force-pushed the claude/fil-569-nqlw8i branch from 5435a62 to 1214a14 Compare July 3, 2026 22:36
Peeja pushed a commit that referenced this pull request Jul 3, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
Comment thread fee/fee.go
Comment thread fee/fee.go
Encrypt/Decrypt wipe their CEK with defer zero(cek) as soon as encryptStream /
openStream return — before the returned reader is read. Document that guarantee
where it's provided, not just where it's relied upon: encryptStream wraps the
CEK to recipients and calls aesstream.NewWriter (which internalizes the key into
a GCM AEAD) synchronously before returning; openStream calls aesstream.NewReader
likewise. So neither retains the cek slice past its own return — the background
encryption goroutine and the lazy decrypt reads both work from the internalized
key, never the slice — and a caller may wipe cek immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
@Peeja
Peeja marked this pull request as ready for review July 3, 2026 22:52
Peeja pushed a commit that referenced this pull request Jul 3, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
Comment thread fee/fee.go
Address review: replace zero's manual byte loop with the built-in clear (Go
1.21+; the module is on 1.25.7). Keep the zero wrapper so callers can defer it
(a bare `defer clear(b)` on a built-in is not permitted) and so its doc records
the best-effort-wipe intent. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
@bajtos
bajtos requested a review from alanshaw July 29, 2026 16:56

@bajtos bajtos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review comments written by Claude (requested by @miroslav): five suggestions from a review pass, all non-blocking. The most substantive is documenting a CEK-uniqueness requirement on EncryptWithCEK; the rest are ordering/naming/doc nits.


Generated by Claude Code

Comment thread fee/fee.go
Comment thread fee/fee.go
Comment thread fee/fee.go
Comment thread fee/fee.go
Comment thread fee/fee_test.go Outdated
…ts (FIL-569)

Five non-blocking review points (bajtos):

- EncryptWithCEK: document the CEK-uniqueness requirement. Under a reused
  caller-supplied CEK the only cross-envelope separation is the 7-byte random
  base nonce (~2^28-envelope birthday bound before an AES-GCM nonce collision),
  so the doc now requires a distinct CEK per envelope (or reuse far below the
  bound). The wire format is fixed, so this is a caller obligation.

- encryptStream: check the content-length mismatch before w.Close(), so a
  mismatch withholds the final STREAM chunk. A caller that ignores the error and
  stores the blob then gets a truncated object that fails to decrypt
  (aesstream.ErrTruncated) rather than a valid-but-mislabeled one. Covered by an
  extended TestContentLengthMismatch.

- Encrypt-side out-of-range WithChunkSize is an invalid argument, not a malformed
  envelope (none exists yet): return aesstream.ErrChunkSize — the sentinel
  aesstream.NewWriter itself uses — instead of ErrMalformedEnvelope. Decode-side
  out-of-range stays ErrMalformedEnvelope. Test updated.

- WithContentLength: document that a negative n is treated as "unknown" (same as
  unset), so a propagated HTTP -1 is explicit rather than a silent footgun.

- test: replace the hand-rolled itoa with strconv.Itoa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
@bajtos bajtos self-assigned this Jul 30, 2026
@alanshaw
alanshaw merged commit 200534e into claude/fil-473-s7pjf9 Jul 31, 2026
3 of 4 checks passed
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…ives (FIL-569) (#14)

* feat(fee): compose FEE encrypt/decrypt over the pinned foc wire format (FIL-569)

Add a top-level `fee` package that sequences the four FEE primitives
(cose, aesstream, ecdhkw, aeskw) into a small streaming API:

  - Encrypt(plaintext, recipients) -> io.ReadCloser over envelope||ciphertext
  - Decrypt(src, unwrap) -> io.Reader over the recovered plaintext
  - EncryptWithCEK / DecryptWithCEK for an externally-managed CEK

Both directions stream with O(chunk size) memory: Encrypt seals through an
io.Pipe fed by a background goroutine; Decrypt reads only the envelope header
up front (via the new cose.DecodeReader) and streams the detached ciphertext.

Recipients are domain-agnostic and selected on decrypt by an opaque,
caller-supplied kid (a DID verification method ID): ECDH-ES+A256KW to an X25519
public key, or A256KW under a symmetric KEK. Both kinds may be mixed in one
envelope; a kid that matches no recipient yields ErrNoMatchingRecipient. With
no recipients, EncryptWithCEK emits a recipient-less COSE_Encrypt0 (tag 16).

The wire format matches the foc-encryption reference and the FIL-473
cross-implementation vectors: typ application/vnd.foc-envelope+cose, the
chunked AES-256-GCM-STREAM body alg in the protected header, and the base
nonce / chunk size (and, when the content length is known via WithContentLength,
the advisory chunk count) in the unprotected header. The ECDH-ES ephemeral key
is a self-describing COSE_Key (kty=OKP, crv=X25519), decoded and validated on
unwrap. The body AAD is the envelope's own Enc_structure, so its context tracks
the tag.

Also add cose.DecodeReader: a streaming, tag-16/96-dispatching decoder that
returns the decoded header plus a reader over the trailing detached ciphertext,
producing the same AAD and trailing bytes as the byte-based Decode. De-word the
aeskw/ecdhkw package docs (drop the app-specific "tenant"/"region" vocabulary)
so the primitives read as a standalone FEE library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: build envelope header before opening the stream pipe (FIL-569)

Address review: encryptStream created the io.Pipe and the aesstream writer
before wrapping recipients and encoding the COSE header, so an error from
r.wrap or Encode returned without closing the pipe, orphaning the
PipeReader/PipeWriter pair until GC. Move all the fallible header work ahead
of the pipe, and close both ends if the sole remaining fallible step
(NewWriter) fails, so no pipe is left dangling on any error path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee/cose: fix stale [Decode] doc link in DecodeReader (FIL-569)

The DecodeReader doc still referenced [Decode], which #32 renamed to
DecodeEncrypt. Align the doc link with the current name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: adapt composer + DecodeReader to unified cose.Envelope (FIL-569)

FIL-473 collapsed cose.Encrypt/Encrypt0 into a single cose.Envelope (tag and
Enc_structure context computed from recipient presence) and replaced the two
byte decoders with one tag-dispatching cose.Decode. Adapt this PR's additions
to match:

  - cose.DecodeReader now returns *cose.Envelope and shares both cose's
    decodeTagArray preamble and decodeEnvelope validation core with cose.Decode
    (they differ only in how they recover the trailing detached payload — a
    []byte subslice vs an io.Reader). The redundant DecodedEnvelope type is gone.
  - encryptStream builds one cose.Envelope and uses it for both the AAD
    (EncStructure) and the encoded header (Encode), instead of a DecodedEnvelope
    for the AAD plus a separate Encrypt/Encrypt0 for the header. The envelopeTag
    helper is dropped — recipient presence is the form.
  - Decrypt discriminates the recipient-less envelope with len(Recipients)==0
    rather than a Tag comparison; openStream takes *cose.Envelope.
  - Tests updated: DecodeReader equivalence now checks against cose.Decode, and
    fee tests build cose.Envelope / call cose.Decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: document the synchronous CEK-absorption guarantee (FIL-569)

Encrypt/Decrypt wipe their CEK with defer zero(cek) as soon as encryptStream /
openStream return — before the returned reader is read. Document that guarantee
where it's provided, not just where it's relied upon: encryptStream wraps the
CEK to recipients and calls aesstream.NewWriter (which internalizes the key into
a GCM AEAD) synchronously before returning; openStream calls aesstream.NewReader
likewise. So neither retains the cek slice past its own return — the background
encryption goroutine and the lazy decrypt reads both work from the internalized
key, never the slice — and a caller may wipe cek immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: use the built-in clear to wipe the CEK (FIL-569)

Address review: replace zero's manual byte loop with the built-in clear (Go
1.21+; the module is on 1.25.7). Keep the zero wrapper so callers can defer it
(a bare `defer clear(b)` on a built-in is not permitted) and so its doc records
the best-effort-wipe intent. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: address review — CEK-uniqueness doc, safer mismatch, sentinel/nits (FIL-569)

Five non-blocking review points (bajtos):

- EncryptWithCEK: document the CEK-uniqueness requirement. Under a reused
  caller-supplied CEK the only cross-envelope separation is the 7-byte random
  base nonce (~2^28-envelope birthday bound before an AES-GCM nonce collision),
  so the doc now requires a distinct CEK per envelope (or reuse far below the
  bound). The wire format is fixed, so this is a caller obligation.

- encryptStream: check the content-length mismatch before w.Close(), so a
  mismatch withholds the final STREAM chunk. A caller that ignores the error and
  stores the blob then gets a truncated object that fails to decrypt
  (aesstream.ErrTruncated) rather than a valid-but-mislabeled one. Covered by an
  extended TestContentLengthMismatch.

- Encrypt-side out-of-range WithChunkSize is an invalid argument, not a malformed
  envelope (none exists yet): return aesstream.ErrChunkSize — the sentinel
  aesstream.NewWriter itself uses — instead of ErrMalformedEnvelope. Decode-side
  out-of-range stays ErrMalformedEnvelope. Test updated.

- WithContentLength: document that a negative n is treated as "unknown" (same as
  unset), so a propagated HTTP -1 is explicit rather than a silent footgun.

- test: replace the hand-rolled itoa with strconv.Itoa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

---------

Co-authored-by: Claude <noreply@anthropic.com>
Peeja pushed a commit that referenced this pull request Jul 31, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
Add fee/integration_test.go (package fee_test) proving the composed fee API
recovers plaintext from a FEE envelope using only an archived tenant X25519
private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient,
and fee.Decrypt recovers it with the tenant private key back to the exact
original.

Rides on the top-level fee package (FIL-569) rather than sequencing the
primitives by hand; the one drop to a sub-package is the explicit on-wire kid
assertion (cose.Decode), which the issue calls for before recovery.

Covers the three acceptance criteria: the round trip; a wrong private key
failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with
no plaintext reader produced; and a corrupted protected header making
fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage.
The tenant keypair is a fixed, non-secret test fixture checked in for
determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal
value, so the multi-chunk path runs without a large fixture.

Stacked on FIL-569 (PR #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…ives (FIL-569) (#14)

* feat(fee): compose FEE encrypt/decrypt over the pinned foc wire format (FIL-569)

Add a top-level `fee` package that sequences the four FEE primitives
(cose, aesstream, ecdhkw, aeskw) into a small streaming API:

  - Encrypt(plaintext, recipients) -> io.ReadCloser over envelope||ciphertext
  - Decrypt(src, unwrap) -> io.Reader over the recovered plaintext
  - EncryptWithCEK / DecryptWithCEK for an externally-managed CEK

Both directions stream with O(chunk size) memory: Encrypt seals through an
io.Pipe fed by a background goroutine; Decrypt reads only the envelope header
up front (via the new cose.DecodeReader) and streams the detached ciphertext.

Recipients are domain-agnostic and selected on decrypt by an opaque,
caller-supplied kid (a DID verification method ID): ECDH-ES+A256KW to an X25519
public key, or A256KW under a symmetric KEK. Both kinds may be mixed in one
envelope; a kid that matches no recipient yields ErrNoMatchingRecipient. With
no recipients, EncryptWithCEK emits a recipient-less COSE_Encrypt0 (tag 16).

The wire format matches the foc-encryption reference and the FIL-473
cross-implementation vectors: typ application/vnd.foc-envelope+cose, the
chunked AES-256-GCM-STREAM body alg in the protected header, and the base
nonce / chunk size (and, when the content length is known via WithContentLength,
the advisory chunk count) in the unprotected header. The ECDH-ES ephemeral key
is a self-describing COSE_Key (kty=OKP, crv=X25519), decoded and validated on
unwrap. The body AAD is the envelope's own Enc_structure, so its context tracks
the tag.

Also add cose.DecodeReader: a streaming, tag-16/96-dispatching decoder that
returns the decoded header plus a reader over the trailing detached ciphertext,
producing the same AAD and trailing bytes as the byte-based Decode. De-word the
aeskw/ecdhkw package docs (drop the app-specific "tenant"/"region" vocabulary)
so the primitives read as a standalone FEE library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: build envelope header before opening the stream pipe (FIL-569)

Address review: encryptStream created the io.Pipe and the aesstream writer
before wrapping recipients and encoding the COSE header, so an error from
r.wrap or Encode returned without closing the pipe, orphaning the
PipeReader/PipeWriter pair until GC. Move all the fallible header work ahead
of the pipe, and close both ends if the sole remaining fallible step
(NewWriter) fails, so no pipe is left dangling on any error path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee/cose: fix stale [Decode] doc link in DecodeReader (FIL-569)

The DecodeReader doc still referenced [Decode], which #32 renamed to
DecodeEncrypt. Align the doc link with the current name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: adapt composer + DecodeReader to unified cose.Envelope (FIL-569)

FIL-473 collapsed cose.Encrypt/Encrypt0 into a single cose.Envelope (tag and
Enc_structure context computed from recipient presence) and replaced the two
byte decoders with one tag-dispatching cose.Decode. Adapt this PR's additions
to match:

  - cose.DecodeReader now returns *cose.Envelope and shares both cose's
    decodeTagArray preamble and decodeEnvelope validation core with cose.Decode
    (they differ only in how they recover the trailing detached payload — a
    []byte subslice vs an io.Reader). The redundant DecodedEnvelope type is gone.
  - encryptStream builds one cose.Envelope and uses it for both the AAD
    (EncStructure) and the encoded header (Encode), instead of a DecodedEnvelope
    for the AAD plus a separate Encrypt/Encrypt0 for the header. The envelopeTag
    helper is dropped — recipient presence is the form.
  - Decrypt discriminates the recipient-less envelope with len(Recipients)==0
    rather than a Tag comparison; openStream takes *cose.Envelope.
  - Tests updated: DecodeReader equivalence now checks against cose.Decode, and
    fee tests build cose.Envelope / call cose.Decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: document the synchronous CEK-absorption guarantee (FIL-569)

Encrypt/Decrypt wipe their CEK with defer zero(cek) as soon as encryptStream /
openStream return — before the returned reader is read. Document that guarantee
where it's provided, not just where it's relied upon: encryptStream wraps the
CEK to recipients and calls aesstream.NewWriter (which internalizes the key into
a GCM AEAD) synchronously before returning; openStream calls aesstream.NewReader
likewise. So neither retains the cek slice past its own return — the background
encryption goroutine and the lazy decrypt reads both work from the internalized
key, never the slice — and a caller may wipe cek immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: use the built-in clear to wipe the CEK (FIL-569)

Address review: replace zero's manual byte loop with the built-in clear (Go
1.21+; the module is on 1.25.7). Keep the zero wrapper so callers can defer it
(a bare `defer clear(b)` on a built-in is not permitted) and so its doc records
the best-effort-wipe intent. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: address review — CEK-uniqueness doc, safer mismatch, sentinel/nits (FIL-569)

Five non-blocking review points (bajtos):

- EncryptWithCEK: document the CEK-uniqueness requirement. Under a reused
  caller-supplied CEK the only cross-envelope separation is the 7-byte random
  base nonce (~2^28-envelope birthday bound before an AES-GCM nonce collision),
  so the doc now requires a distinct CEK per envelope (or reuse far below the
  bound). The wire format is fixed, so this is a caller obligation.

- encryptStream: check the content-length mismatch before w.Close(), so a
  mismatch withholds the final STREAM chunk. A caller that ignores the error and
  stores the blob then gets a truncated object that fails to decrypt
  (aesstream.ErrTruncated) rather than a valid-but-mislabeled one. Covered by an
  extended TestContentLengthMismatch.

- Encrypt-side out-of-range WithChunkSize is an invalid argument, not a malformed
  envelope (none exists yet): return aesstream.ErrChunkSize — the sentinel
  aesstream.NewWriter itself uses — instead of ErrMalformedEnvelope. Decode-side
  out-of-range stays ErrMalformedEnvelope. Test updated.

- WithContentLength: document that a negative n is treated as "unknown" (same as
  unset), so a propagated HTTP -1 is explicit rather than a silent footgun.

- test: replace the hand-rolled itoa with strconv.Itoa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

---------

Co-authored-by: Claude <noreply@anthropic.com>
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…ives (FIL-569) (#14)

* feat(fee): compose FEE encrypt/decrypt over the pinned foc wire format (FIL-569)

Add a top-level `fee` package that sequences the four FEE primitives
(cose, aesstream, ecdhkw, aeskw) into a small streaming API:

  - Encrypt(plaintext, recipients) -> io.ReadCloser over envelope||ciphertext
  - Decrypt(src, unwrap) -> io.Reader over the recovered plaintext
  - EncryptWithCEK / DecryptWithCEK for an externally-managed CEK

Both directions stream with O(chunk size) memory: Encrypt seals through an
io.Pipe fed by a background goroutine; Decrypt reads only the envelope header
up front (via the new cose.DecodeReader) and streams the detached ciphertext.

Recipients are domain-agnostic and selected on decrypt by an opaque,
caller-supplied kid (a DID verification method ID): ECDH-ES+A256KW to an X25519
public key, or A256KW under a symmetric KEK. Both kinds may be mixed in one
envelope; a kid that matches no recipient yields ErrNoMatchingRecipient. With
no recipients, EncryptWithCEK emits a recipient-less COSE_Encrypt0 (tag 16).

The wire format matches the foc-encryption reference and the FIL-473
cross-implementation vectors: typ application/vnd.foc-envelope+cose, the
chunked AES-256-GCM-STREAM body alg in the protected header, and the base
nonce / chunk size (and, when the content length is known via WithContentLength,
the advisory chunk count) in the unprotected header. The ECDH-ES ephemeral key
is a self-describing COSE_Key (kty=OKP, crv=X25519), decoded and validated on
unwrap. The body AAD is the envelope's own Enc_structure, so its context tracks
the tag.

Also add cose.DecodeReader: a streaming, tag-16/96-dispatching decoder that
returns the decoded header plus a reader over the trailing detached ciphertext,
producing the same AAD and trailing bytes as the byte-based Decode. De-word the
aeskw/ecdhkw package docs (drop the app-specific "tenant"/"region" vocabulary)
so the primitives read as a standalone FEE library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: build envelope header before opening the stream pipe (FIL-569)

Address review: encryptStream created the io.Pipe and the aesstream writer
before wrapping recipients and encoding the COSE header, so an error from
r.wrap or Encode returned without closing the pipe, orphaning the
PipeReader/PipeWriter pair until GC. Move all the fallible header work ahead
of the pipe, and close both ends if the sole remaining fallible step
(NewWriter) fails, so no pipe is left dangling on any error path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee/cose: fix stale [Decode] doc link in DecodeReader (FIL-569)

The DecodeReader doc still referenced [Decode], which #32 renamed to
DecodeEncrypt. Align the doc link with the current name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: adapt composer + DecodeReader to unified cose.Envelope (FIL-569)

FIL-473 collapsed cose.Encrypt/Encrypt0 into a single cose.Envelope (tag and
Enc_structure context computed from recipient presence) and replaced the two
byte decoders with one tag-dispatching cose.Decode. Adapt this PR's additions
to match:

  - cose.DecodeReader now returns *cose.Envelope and shares both cose's
    decodeTagArray preamble and decodeEnvelope validation core with cose.Decode
    (they differ only in how they recover the trailing detached payload — a
    []byte subslice vs an io.Reader). The redundant DecodedEnvelope type is gone.
  - encryptStream builds one cose.Envelope and uses it for both the AAD
    (EncStructure) and the encoded header (Encode), instead of a DecodedEnvelope
    for the AAD plus a separate Encrypt/Encrypt0 for the header. The envelopeTag
    helper is dropped — recipient presence is the form.
  - Decrypt discriminates the recipient-less envelope with len(Recipients)==0
    rather than a Tag comparison; openStream takes *cose.Envelope.
  - Tests updated: DecodeReader equivalence now checks against cose.Decode, and
    fee tests build cose.Envelope / call cose.Decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: document the synchronous CEK-absorption guarantee (FIL-569)

Encrypt/Decrypt wipe their CEK with defer zero(cek) as soon as encryptStream /
openStream return — before the returned reader is read. Document that guarantee
where it's provided, not just where it's relied upon: encryptStream wraps the
CEK to recipients and calls aesstream.NewWriter (which internalizes the key into
a GCM AEAD) synchronously before returning; openStream calls aesstream.NewReader
likewise. So neither retains the cek slice past its own return — the background
encryption goroutine and the lazy decrypt reads both work from the internalized
key, never the slice — and a caller may wipe cek immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: use the built-in clear to wipe the CEK (FIL-569)

Address review: replace zero's manual byte loop with the built-in clear (Go
1.21+; the module is on 1.25.7). Keep the zero wrapper so callers can defer it
(a bare `defer clear(b)` on a built-in is not permitted) and so its doc records
the best-effort-wipe intent. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

* fee: address review — CEK-uniqueness doc, safer mismatch, sentinel/nits (FIL-569)

Five non-blocking review points (bajtos):

- EncryptWithCEK: document the CEK-uniqueness requirement. Under a reused
  caller-supplied CEK the only cross-envelope separation is the 7-byte random
  base nonce (~2^28-envelope birthday bound before an AES-GCM nonce collision),
  so the doc now requires a distinct CEK per envelope (or reuse far below the
  bound). The wire format is fixed, so this is a caller obligation.

- encryptStream: check the content-length mismatch before w.Close(), so a
  mismatch withholds the final STREAM chunk. A caller that ignores the error and
  stores the blob then gets a truncated object that fails to decrypt
  (aesstream.ErrTruncated) rather than a valid-but-mislabeled one. Covered by an
  extended TestContentLengthMismatch.

- Encrypt-side out-of-range WithChunkSize is an invalid argument, not a malformed
  envelope (none exists yet): return aesstream.ErrChunkSize — the sentinel
  aesstream.NewWriter itself uses — instead of ErrMalformedEnvelope. Decode-side
  out-of-range stays ErrMalformedEnvelope. Test updated.

- WithContentLength: document that a negative n is treated as "unknown" (same as
  unset), so a propagated HTTP -1 is explicit rather than a silent footgun.

- test: replace the hand-rolled itoa with strconv.Itoa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants