fee: top-level package composing encrypt/decrypt over existing primitives (FIL-569) - #14
Conversation
There was a problem hiding this comment.
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/RecipientUnwrapperimplementations 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.
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
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
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
3e12471 to
fbb1ee8
Compare
fbb1ee8 to
6b0b9c8
Compare
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
19eb203 to
5435a62
Compare
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
…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
5435a62 to
1214a14
Compare
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
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
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
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
left a comment
There was a problem hiding this comment.
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
…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
…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>
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
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
…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>
…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>
tl;dr: Provides the outer
feepackage interface that ties the primitives together.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.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:NewECDHESRecipient/NewECDHESUnwrapper).NewA256KWRecipient/NewA256KWUnwrapper).kidis 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:
Encryptwraps a fresh CEK to the recipients (aCOSE_Encrypt, tag 96);Decryptrecovers it by matching aRecipientUnwrapper's kid and unwrapping.EncryptWithCEKseals under a caller-held CEK; with recipients it emits a tag-96 envelope, and with no recipients it emits a recipient-lessCOSE_Encrypt0(tag 16).DecryptWithCEKdecrypts with a CEK obtained out of band (e.g. unwrapped by a custody service), accepting either form and ignoring recipients.Decrypton a recipient-less envelope returnsErrNoRecipientsInEnvelope.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/DecryptWithCEKare pull-based:cose.DecodeReaderreads only the header, thenaesstream.NewReaderstreams the ciphertext — no goroutine.Encrypt/EncryptWithCEKreturn anio.ReadCloser: the STREAM body cipher is push-based (finalizes onClose, likegzip/age), so a reader-returning API bridges push→pull with an internalio.Pipe+ goroutine. Read to EOF orClose()—Closeaborts the goroutine; an encryption error surfaces as a non-EOFReaderror. 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 (96COSE_Encrypt/ 16COSE_Encrypt0) — and returns the detached ciphertext as a reader (the decoder'sBuffered()remainder + the source). It shares the strictdecodeEnvelopevalidation core with the byte-basedcose.Decodeand produces a byte-identicalEncStructure(AAD), so an envelope decrypts the same read either way.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:
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.iv(label 5), chunk size (-65790), and — only when the plaintext length is known viaWithContentLength— 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.-31ECDH-ES+A256KW /-5A256KW), the kid, and — for ECDH-ES — the ephemeral key as a self-describingCOSE_Key(kty=OKP,crv=X25519,x=public-key bytes), decoded and validated on unwrap.Enc_structure, whose context ("Encrypt"/"Encrypt0") tracks recipient presence.Acceptance criteria
Encrypt, opened by either —TestRoundTripMixedRecipients.TestExternalCEK+TestExternalCEKNoRecipients.ErrNoMatchingRecipient(no panic/silent failure) —TestDecryptKidMismatch.Design notes
WithChunkSize; present-but-non-integer is rejected as malformed.WithContentLengthdeclares the plaintext length so the chunk count can be recorded; the returned reader then fails withErrContentLengthMismatchif the actual length differs (and the already-written count can't be trusted).COSE_Key, not raw bytes — a malformed ephemeral key (wrongkty/crv, missing/shortx, or not a map) is rejected asErrMalformedEnvelopebefore any ECDH is attempted.Testing
GOWORK=off go test -race ./fee/... -count=1— green (incl. the goroutine-backed streamingEncryptunder the race detector).go build ./...,go vet ./fee/...,gofmtclean. 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;EncryptreaderClose; 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; andcose.DecodeReader(streaming + fragmented + empty + both forms +cose.Decode-equivalence).Closes FIL-569.
🤖 Generated with Claude Code