Skip to content

fee: cross-implementation test vectors vs foc-encryption (FIL-473) - #32

Merged
alanshaw merged 15 commits into
mainfrom
claude/fil-473-s7pjf9
Jul 31, 2026
Merged

fee: cross-implementation test vectors vs foc-encryption (FIL-473)#32
alanshaw merged 15 commits into
mainfrom
claude/fil-473-s7pjf9

Conversation

@Peeja

@Peeja Peeja commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

tl;dr: Checks our implementation against the foc-encryption reference implementation, including Kubuxu/foc-encryption-demo#2.


What

Implements FIL-473. Adds fixed, deterministic FEE cross-implementation test vectors that pin the wire format across the Go implementation (fee/cose, fee/aesstream, fee/ecdhkw, fee/aeskw) and the TypeScript reference foc-encryption (Kubuxu/foc-encryption-demo, packages/foc-encryption), pinned to master the head of PR #2 (158571ae…, the RFC 9052 §5.3 context fix) — not master, which still seals tag-96 bodies under the buggy "Encrypt0" context. Re-pin to the merge commit once PR #2 lands.

Per the reopened issue, the reference is the source of truth — the Go side was reconciled to it, then the vectors were written.

Acceptance criteria

All three met and verified in both directions against the real pinned reference:

  • Single-chunk in Go → decrypt in TSsingle-chunk-go (tag 16); foc-encryption decrypts it.
  • Multi-chunk in TS → decrypt in Gomulti-chunk-ts; TestVectors decrypts it.
  • Multi-recipient in Go → unwrap each CEK in TSmulti-recipient-go (tag 96) with real ECDH-ES+A256KW (X25519) and A256KW recipients. The reference carries wrappedKey opaquely and has no unwrap code, so it parses every recipient descriptor and decrypts the body from the shared CEK; the actual per-recipient CEK unwrap is asserted Go-side in TestVectors.
  • Deterministic, checked-in fixturestestdata/<name>/{blob.bin,plaintext.bin,meta.json}; tests only read fixed files and run deterministic decrypt/unwrap.

(Plus multi-chunk-go for extra Go→TS multi-chunk coverage.)

Go implementation changes (reconcile to the reference)

Confined to fee/cose:

  • A single Envelope type for both COSE forms — the reference uses tag 16 (COSE_Encrypt0) for a no-recipient file and tag 96 (COSE_Encrypt) with recipients; cose previously modeled only tag 96. Rather than carry a parallel Encrypt0 type, cose now has one Envelope{Headers, Recipients} whose form — the CBOR tag (96/16), the array shape (4/3 elements), and the Enc_structure context — is computed from recipient presence. RFC 9052 requires a COSE_Encrypt to carry ≥1 recipient, so a recipient-less envelope is exactly a COSE_Encrypt0; deriving the tag from the recipients makes "an Encrypt0 with recipients" unrepresentable and removes the duplicated encode/AAD paths. The two byte decoders collapse into one tag-dispatching Decode returning *Envelope; PeekTag stays for callers that want the raw on-wire tag without decoding. encrypt.go + encrypt0.go merge into envelope.go.
  • Body AAD context per form — the reference (as fixed in PR docs: write architecture document #2) builds the body AEAD's AAD with the RFC 9052 §5.3 context that matches the envelope: "Encrypt" when recipients are present, "Encrypt0" when not. Envelope.EncStructure selects between them by recipient presence; the context strings stay internal to cose, and the vectors take the AAD from the envelope's own EncStructure (no exported context constants or standalone builder).

fee/aesstream's STREAM framing (base[7] ‖ idx[4 BE] ‖ lastFlag[1], tag 16, constant per-chunk AAD) already matched byte-for-byte and is unchanged. The FEE profile constants (typ = application/vnd.foc-envelope+cose, alg -65793, unprotected labels chunkSize -65790 / chunkCount -65791) live in the vectors package (the caller), matching the reference's src/cose/headers.ts.

For #14 (FIL-569): #14 (the top-level fee composer) is stacked on this branch and has been reconciled both to the format pinned here (typ = application/vnd.foc-envelope+cose, unprotected chunkSize -65790, form-tracked AAD context) and to the unified cose.Envelope — its cose.DecodeReader returns *Envelope and shares the decodeEnvelope validation core with Decode. This PR stays independent (it targets main); #14 retargets to main automatically once this lands.

Verifying

GOWORK=off go test ./fee/...                 # Go decrypts every fixture + unwraps recipients
./fee/vectors/pull-foc-encryption.sh         # real foc-encryption regenerates multi-chunk-ts + decrypts all (needs bun)

pull-foc-encryption.sh vendors the pinned reference into ts/vendor/ (gitignored) via git clone + git fetch refs/pull/2/head, checks out the pinned SHA, and falls back to raw.githubusercontent.com where git egress is blocked. The TS driver runs under bun. See fee/vectors/README.md.

Testing

  • GOWORK=off go build ./..., go vet ./fee/..., gofmt -l fee/ — clean.
  • GOWORK=off go test ./fee/... -count=1 — green (incl. fee/cose and fee/vectors).
  • ./fee/vectors/pull-foc-encryption.shcross-implementation check OK (4/4 fixtures decrypt under the real foc-encryption).
  • The red go-test checks are the pre-existing flaky ./testing/ smoke suite (its TestSmokeXFail_* cases fail the run on an unexpected pass), unrelated to this fee/-only diff — testing/ has no dependency on fee/*.

Notes

  • All key material (CEKs, the X25519 tenant key, the A256KW KEK, base nonces) is non-secret, deterministically derived from fixed labels, and recorded in each meta.json — it exists only to pin these vectors.
  • Downstream: FIL-567 (security review of the hand-rolled fee/* crypto) is gated on this reconciliation settling.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS


Generated by Claude Code

Comment thread fee/cose/cose.go Outdated
Comment thread fee/cose/cose.go Outdated
Comment thread fee/cose/encrypt.go Outdated
Comment thread fee/vectors/README.md Outdated
Comment thread fee/cose/encrypt.go Outdated

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 deterministic, checked-in FEE cross-implementation fixtures and harnesses to pin the COSE+STREAM wire format against the pinned TypeScript foc-encryption reference, and extends the Go fee/cose package with COSE_Encrypt0 (tag 16) support plus tag dispatch.

Changes:

  • Add fee/vectors package with deterministic fixture generation, validation tests, and committed testdata/<name>/{blob.bin,plaintext.bin,meta.json} vectors.
  • Add a TypeScript harness (fee/vectors/ts) and a vendoring script to pull a pinned foc-encryption reference and verify/regenerate TS-produced fixtures.
  • Extend fee/cose with COSE_Encrypt0 encode/decode and PeekTag for tag-based dispatch.

Reviewed changes

Copilot reviewed 21 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
fee/vectors/vectors_test.go Loads all fixtures and verifies Go decryption + recipient unwrap assertions for tag-96 vectors.
fee/vectors/helpers_test.go Implements deterministic key derivation, fixture IO, envelope composition, decode, and body decryption helpers.
fee/vectors/doc.go Package-level documentation of the vector purpose, wire shape, and regeneration flow.
fee/vectors/README.md Human-readable documentation of fixtures, wire format, and regeneration steps.
fee/vectors/pull-foc-encryption.sh Pulls a pinned TS reference into ts/vendor/, installs deps, and runs the TS driver to generate/verify fixtures.
fee/vectors/ts/package.json Declares the minimal TS harness dependency set (cborg).
fee/vectors/ts/driver.ts Drives the pinned TS reference to generate multi-chunk-ts and verify decryption of all committed fixtures.
fee/vectors/ts/.gitignore Ensures vendored TS reference and node_modules are not committed.
fee/vectors/testdata/single-chunk-go/blob.bin Committed Go-produced tag-16 fixture blob (envelope
fee/vectors/testdata/single-chunk-go/plaintext.bin Plaintext for single-chunk-go fixture.
fee/vectors/testdata/single-chunk-go/meta.json Metadata sidecar for single-chunk-go fixture (tag/typ/alg/CEK/etc).
fee/vectors/testdata/multi-chunk-go/blob.bin Committed Go-produced multi-chunk tag-16 fixture blob.
fee/vectors/testdata/multi-chunk-go/plaintext.bin Plaintext for multi-chunk-go fixture.
fee/vectors/testdata/multi-chunk-go/meta.json Metadata sidecar for multi-chunk-go fixture.
fee/vectors/testdata/multi-chunk-ts/blob.bin Committed TS-produced multi-chunk tag-16 fixture blob.
fee/vectors/testdata/multi-chunk-ts/plaintext.bin Plaintext for multi-chunk-ts fixture.
fee/vectors/testdata/multi-chunk-ts/meta.json Metadata sidecar for multi-chunk-ts fixture.
fee/vectors/testdata/multi-recipient-go/blob.bin Committed Go-produced tag-96 multi-recipient fixture blob.
fee/vectors/testdata/multi-recipient-go/plaintext.bin Plaintext for multi-recipient-go fixture.
fee/vectors/testdata/multi-recipient-go/meta.json Metadata sidecar for multi-recipient-go fixture incl. recipient descriptors and unwrap inputs.
fee/cose/encrypt0.go Introduces COSE_Encrypt0 (tag 16) detached-payload envelope with encode + Enc_structure AAD construction.
fee/cose/encrypt0_test.go Adds tests for tag-16 encode/decode shape, Enc_structure correctness, and error cases.
fee/cose/encrypt.go Refactors Enc_structure building through a shared helper used by Encrypt and Encrypt0.
fee/cose/decode.go Adds DecodeEncrypt0 for tag-16 envelopes and PeekTag for lightweight tag dispatch.
fee/cose/cose.go Updates package docs/scope and defines tag-16 constant + Enc_structure context strings.

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

Comment thread fee/vectors/pull-foc-encryption.sh Outdated
Comment thread fee/vectors/README.md Outdated
Comment thread fee/cose/cose.go
Comment thread fee/cose/decode.go Outdated
@Peeja
Peeja marked this pull request as ready for review July 3, 2026 20:41
Peeja pushed a commit that referenced this pull request Jul 3, 2026
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
Peeja pushed a commit that referenced this pull request Jul 3, 2026
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
Peeja pushed a commit that referenced this pull request Jul 3, 2026
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
@bajtos

bajtos commented Jul 29, 2026

Copy link
Copy Markdown
Member

Moved the foc-encryption-demo pin to the latest master, since our fix was landed.

@bajtos
bajtos requested review from alanshaw and bajtos July 29, 2026 16:34

@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.

I think this is good to land 👍🏻

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 26 out of 30 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

fee/vectors/vectors_test.go:105

  • verifyRecipients indexes recipients by algorithm only (byAlg[alg] = r). If an envelope ever contains multiple recipients that share the same alg (e.g., two A256KW recipients with different kids), later entries overwrite earlier ones and the test can unwrap/validate the wrong recipient or miss one entirely. Index by (alg,kid) instead, and optionally fail on duplicate (alg,kid) pairs to avoid silent overwrites.
	byAlg := map[int64]*cose.Recipient{}
	for _, r := range p.recipients {
		alg, ok := r.Headers.Protected.Int(cose.HeaderLabelAlg)
		require.True(t, ok, "recipient algorithm present")
		byAlg[alg] = r
	}

	for _, rm := range f.meta.Recipients {
		r := byAlg[rm.Algorithm]
		require.NotNilf(t, r, "envelope missing recipient alg %d", rm.Algorithm)

		// The kid binds the descriptor to its key.
		kid, ok := r.Headers.Unprotected.Bytes(cose.HeaderLabelKID)
		require.True(t, ok, "recipient kid present")
		require.Equal(t, rm.KidHex, hex.EncodeToString(kid), "recipient kid")

@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.

Code review (written by Claude)

This review and its line comments were written by Claude, posted from a Claude Code session at a maintainer's request.

Overview

The PR does two related things: (1) refactors fee/cose to a single Envelope type covering COSE_Encrypt (tag 96) and COSE_Encrypt0 (tag 16), with the form — tag, array shape, and RFC 9052 §5.3 Enc_structure context — computed from recipient presence; and (2) adds fee/vectors: four checked-in deterministic fixtures, a Go suite that decrypts every fixture and actually unwraps each recipient CEK (ECDH-ES+A256KW/X25519 and A256KW), and pull-foc-encryption.sh + a bun/TS driver verifying everything against the pinned foc-encryption reference.

What's solid

  • The "form follows recipients" design is correct and its invariant is enforced, not assumed. RFC 9052 requires ≥1 recipient for COSE_Encrypt, and the unchanged decodeRecipients rejects an empty tag-96 recipients array — so a decoded tag-96 always carries recipients, which is exactly what EncStructure's context selection relies on. "An Encrypt0 with recipients" is unrepresentable.
  • Blast radius is contained: nothing outside fee/ imports these packages, so the EncryptEnvelope API break is confined; #14 is stated to be reconciled.
  • Test quality is high: round-trip, wire-shape (tag byte, arity, null detached body), AAD divergence between forms for an identical header, RawProtected AAD stability, a negative unwrap check, and the coreVectors completeness map so a dropped fixture fails the suite.
  • Security/supply-chain posture is right: all committed key material is deterministically derived, explicitly non-secret, and exists only to pin the vectors; the reference is pinned to a fixed SHA, vendored source is gitignored, and the raw fallback fetches by SHA.
  • CI is green at head (unit on 3 OSes, itest, go-check, both Docker build checks) — the flaky-smoke-suite caveat in the PR body no longer applies.

Findings (details in line comments)

  1. Stale pin docsfee/vectors/README.md and fee/vectors/doc.go still say master is buggy and not used, contradicting the script's refs/heads/master re-pin. Should fix before merge.
  2. CEK + base-nonce reuse across fixturessingle-chunk-go and multi-recipient-go collide on chunk-0 key+nonce; harmless for public vectors but worth a one-line per-fixture CEK derivation ahead of FIL-567.
  3. Go encode path isn't pinned in CI — suggestion: byte-equality re-compose for the deterministic tag-16 Go fixtures in TestVectors.
  4. PeekTag vs Decode sentinel inconsistency — nit.

Informational

A recipient-less Envelope.Encode() previously failed with ErrNoRecipients and now silently emits a valid tag-16 envelope. That's the intended design and no in-repo callers are affected, but it's worth remembering when #14 lands: a caller that forgets to attach recipients now gets a valid envelope instead of an error.

Verdict

Well-engineered PR — the unification is a genuine simplification with its central invariant enforced on both encode and decode paths, and the vector suite is rigorous in both directions. Item 1 actively misleads and should be fixed before merge; item 2 is best done while fixture regeneration is still cheap; items 3–4 are non-blocking.


Generated by Claude Code

Comment thread fee/vectors/README.md Outdated
Comment on lines +10 to +14
[`pull-foc-encryption.sh`](./pull-foc-encryption.sh) to the head of
[PR #2](https://github.com/Kubuxu/foc-encryption-demo/pull/2)
(`158571ae…`) — the RFC 9052 §5.3 fix. Master is deliberately *not* used: it
still seals tag-96 bodies under the buggy `"Encrypt0"` context. Re-pin to the
merge commit once the PR lands.

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.

Stale pin documentation (should fix before merge). The pin has since been moved to master (pull-foc-encryption.sh now sets REF_FETCH="refs/heads/master", per the earlier PR comment), but three doc spots still describe the pre-merge state and now contradict the script:

  • this paragraph: "Master is deliberately not used: it still seals tag-96 bodies under the buggy "Encrypt0" context. Re-pin to the merge commit once the PR lands."
  • line 80 below: "git clone + git fetch refs/pull/2/head" — the script no longer fetches the PR ref
  • fee/vectors/doc.go (the paragraph starting "The reference is the source of truth"): "…rather than master, which still seals tag-96 bodies under "Encrypt0""

Since the upstream fix landed on master, anyone reading these docs will wrongly conclude master is unusable. Suggest updating all three in the same spirit as the PR description's strikethrough edit.


Generated by Claude Code

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.

Addressed in 56de988

Comment thread fee/vectors/vectors_test.go Outdated
Comment on lines +181 to +182
cek := testCEK()
baseNonce := testBaseNonce()

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.

Minor (crypto hygiene): shared CEK + base nonce across fixtures. All three Go-produced fixtures reuse the same CEK and base nonce. single-chunk-go and multi-recipient-go are both single-chunk, so their chunk-0 nonces are identical (baseNonce ‖ 0 ‖ lastFlag=0x01) under the same key with different plaintexts — textbook AES-GCM key+nonce reuse. (The differing AAD context doesn't help: the GCM keystream depends only on key+nonce, so the two ciphertexts XOR to the plaintexts' XOR.)

This is harmless here since all the material is public by design, but these are reference vectors feeding the FIL-567 security review, and it would be better not to enshrine a nonce-reuse pattern in them. Deriving a per-fixture CEK — include the fixture name in the derivation label, as the TS side already does with fil-473-fee-cek-ts-v1 — is a one-line change while regeneration is still cheap.


Generated by Claude Code

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.

Addressed in 6cfa69e

Comment on lines +56 to +60
// AC core: Go decrypts the blob from the shared CEK, exactly as the
// reference's decrypt(blob, cek) does.
got, err := decryptFEE(f.blob, cek)
require.NoError(t, err, "decrypt body")
require.Equal(t, f.plaintext, got, "recovered plaintext")

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.

Suggestion (test coverage): the Go encode path isn't pinned in CI. This test decrypts the committed blobs, which covers TS→Go and Go's decode path. The Go→TS direction and Go's encode-path bytes are only checked when someone manually runs pull-foc-encryption.sh — an encode regression (e.g. a header-serialization change) would keep CI green because the committed blobs still decrypt.

For the tag-16 Go fixtures every input is deterministic (fixed CEK, base nonce, chunk size, committed plaintext), so this test could re-run composeFEE for producer == "go" tag-16 fixtures and assert byte equality with blob.bin, pinning the encode path in CI for free.

(The tag-96 fixture can't be byte-pinned — ecdhkw.Wrap mints a random ephemeral key — which is also the actual reason a regenerated Go blob can differ from the committed one; the README's regeneration note attributes that drift only to base nonces.)


Generated by Claude Code

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.

I don't know what to think about this. Seems like the solution is non-trivial; can we defer this for later? I don't expect the FEE implementation to change much, so maybe it's okay not to run full test coverage on every Ingot PR.

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.

This should be added to CI when the code is extracted as a library.

Comment thread fee/cose/decode.go Outdated
dec := decMode.NewDecoder(bytes.NewReader(data))
var tag cbor.RawTag
if err := dec.Decode(&tag); err != nil {
return 0, fmt.Errorf("%w: %v", ErrMalformed, err)

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.

Nit: sentinel inconsistency with Decode. For the same non-tag input, PeekTag returns ErrMalformed while Decode classifies it as ErrNotEncrypt (see the "bare integer, not a tag" case in decode_test.go). Both behaviors are documented, but callers classifying with errors.Is get different sentinels for the same bytes depending on entry point; aligning PeekTag on ErrNotEncrypt would be more predictable.


Generated by Claude Code

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.

Addressed in ed98ec6

@bajtos bajtos self-assigned this Jul 30, 2026

@alanshaw alanshaw 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.

I think this whole implementation and the test vectors should be extracted as a standalone library in a separate repo.

Comment thread fee/cose/envelope.go
// The body ciphertext is always null (detached); the caller appends the real
// ciphertext after the returned bytes. Encoding is RFC 8949 core deterministic,
// so the same envelope always produces identical bytes.
func (e *Envelope) Encode() ([]byte, error) {

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.

nit: I know this was not added here but typically we define a package level function for Encode and pass in the value to encode. This is also more idiomatic Go.

Comment on lines +56 to +60
// AC core: Go decrypts the blob from the shared CEK, exactly as the
// reference's decrypt(blob, cek) does.
got, err := decryptFEE(f.blob, cek)
require.NoError(t, err, "decrypt body")
require.Equal(t, f.plaintext, got, "recovered plaintext")

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.

This should be added to CI when the code is extracted as a library.

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>
@alanshaw
alanshaw force-pushed the claude/fil-473-s7pjf9 branch from 200534e to 5d07354 Compare July 31, 2026 11:27
@bajtos bajtos assigned alanshaw and unassigned bajtos Jul 31, 2026
claude added 3 commits July 31, 2026 16:45
…L-473)

The foc-encryption reference (the FEE wire-format source of truth) encodes a
no-recipient file as a COSE_Encrypt0 (tag 16) and always builds the body AEAD's
AAD with the "Encrypt0" Enc_structure context — even for a tag-96 multi-recipient
envelope. fee/cose previously implemented only COSE_Encrypt (tag 96) with the
"Encrypt" context, so it could neither produce nor consume the reference's
default envelope.

- Add the Encrypt0 type (Encode + EncStructure + ProtectedBytes) and tag-16
  decode (DecodeEncrypt0), plus PeekTag to dispatch tag 16 vs 96.
- Factor the shared Enc_structure builder out as EncStructureBytes and export
  the ContextEncrypt/ContextEncrypt0 constants, so a caller can build the
  "Encrypt0"-context body AAD for either envelope tag.

Unit tests cover encode shape, decode round-trip, RawProtected AAD stability,
the Encrypt0-vs-Encrypt context difference, error paths, and PeekTag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
…L-473)

Add fixed, deterministic FEE test vectors that pin the wire format across the Go
implementation (fee/cose, fee/aesstream, fee/ecdhkw, fee/aeskw) and the
TypeScript reference foc-encryption (Kubuxu/foc-encryption-demo, pinned at
f0eac6e).

Fixtures in testdata/ cover all three acceptance criteria:
- single-chunk-go: Go seals a single-chunk file (tag 16); foc-encryption decrypts it.
- multi-chunk-ts:  foc-encryption seals a multi-chunk file; Go decrypts it.
- multi-recipient-go: Go seals a tag-96 envelope with real ECDH-ES+A256KW (X25519)
  and A256KW recipients; foc-encryption parses every recipient descriptor and
  decrypts the body from the shared CEK.
(plus multi-chunk-go for extra Go->TS multi-chunk coverage.)

- TestVectors: Go decrypts every fixture and, for tag-96, unwraps each
  recipient's CEK (the reference has no unwrap code, so real unwrap is asserted
  Go-side). TestGenerate (FEE_VECTORS_REGEN=1) regenerates the Go fixtures.
- pull-foc-encryption.sh + ts/driver.ts: vendor the pinned reference (git clone,
  raw.githubusercontent fallback), regenerate multi-chunk-ts, and verify every
  fixture decrypts under the real foc-encryption. Requires bun.
- README.md documents the wire format, the fee/cose divergences reconciled here,
  and regeneration. All key material is non-secret, label-derived test vectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
…ix) (FIL-473)

The reference's body AAD hardcoded the "Encrypt0" Enc_structure context for
every envelope, even tag-96 — a deviation from RFC 9052 §5.3 surfaced while
building these vectors and fixed upstream in foc-encryption-demo PR #2. Re-pin
the vectors to that fix and match it on the Go side:

- pull-foc-encryption.sh: pin REF_SHA to PR #2 head (158571a) via
  refs/pull/2/head instead of master (still buggy); raw fallback uses the same SHA.
- helpers_test.go: composeFEE/decryptFEE now select the body AAD context by
  envelope structure (Encrypt for tag-96, Encrypt0 for tag-16), via the
  ContextEncrypt/ContextEncrypt0 constants already in fee/cose.
- Regenerated multi-recipient-go (tag-96, now Encrypt context) and multi-chunk-ts
  (re-sealed by the fixed reference). Deterministic tag-16 Go fixtures unchanged.
- doc.go / README.md: document the pin to PR #2 and the structure-driven context.

Cross-implementation check passes against the fixed reference (4/4 fixtures
decrypt under real foc-encryption; Go decrypts all + unwraps both recipients).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
claude and others added 12 commits July 31, 2026 16:45
…on (FIL-473)

- Collapse the context strings to a single exported pair (ContextEncrypt /
  ContextEncrypt0); internal callers use the exported names. No behavior change.
- Trim the Scope doc: "reported as an error" (drop "rather than silently
  dropped").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
…FIL-473)

- fee/vectors: compose/decrypt now take the body AAD from the envelope's own
  EncStructure (Encrypt for tag-96, Encrypt0 for tag-16) instead of selecting a
  context by hand. This removes the reason the cose helper was exported.
- fee/cose: unexport the shared Enc_structure builder (EncStructureBytes ->
  encStructureBytes) and drop the exported ContextEncrypt/ContextEncrypt0
  constants (now internal); fixes the stale "always Encrypt0 for tag-96" doc.
- fee/vectors/README: drop the "Divergences reconciled" section (PR narrative,
  not code docs).

No wire change: fixtures are byte-identical and still decrypt on both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
Review nit: the sentence after the Enc_structure code block now leads with the
function name rather than a pronoun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
…FIL-473)

Parallel naming for the two envelope decoders (DecodeEncrypt / DecodeEncrypt0,
mirroring the Encrypt / Encrypt0 types), and factor out the duplicated bodies:

- decodeTagged: shared front half — read one CBOR item, require the expected tag
  wrapping an array, return the elements + trailing detached ciphertext.
- newDecodeConfig + decodeConfig.checkTyp: shared option parsing and the
  WithExpectedType check.

DecodeEncrypt/DecodeEncrypt0 now just assert their element count, decode headers
+ null body (+ recipients for tag 96), run the typ check, and build their type.
Callers and doc references updated. No behavior change; fee tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
The package carried three near-identical envelope shapes — Encrypt, Encrypt0,
and (on the streaming path) a DecodedEnvelope — plus two byte decoders
(DecodeEncrypt / DecodeEncrypt0) that differed only in whether recipients were
present. But recipient presence *is* the distinction: RFC 9052 requires a
COSE_Encrypt to carry at least one recipient, so a recipient-less envelope is
exactly a COSE_Encrypt0. The tag (96/16), the array shape (4/3 elements), and
the Enc_structure context ("Encrypt"/"Encrypt0") are all derivable from it.

Replace the two encode types with a single Envelope{Headers, Recipients} whose
Encode and EncStructure compute the form from len(Recipients), and the two byte
decoders with a single tag-dispatching Decode returning *Envelope. A valid
tag-96 always decodes with recipients and a tag-16 never does, so the invariant
holds on both the encode and decode sides — and "an Encrypt0 with recipients"
becomes unrepresentable rather than a runtime check. decodeEnvelope is factored
out as the shared validation core for the streaming decoder (added in FIL-569)
to reuse. PeekTag stays for callers that want the on-wire tag without decoding.

Merge encrypt.go + encrypt0.go into envelope.go; adapt the cose tests and the
cross-implementation vectors to the unified API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
decodeEnvelope's comment referenced a [DecodeReader] symbol that does not
exist (a leftover from the earlier DecodeEncrypt/DecodeEncrypt0 split); it is
now Decode's only caller, so describe it as Decode's tag-dispatch core.

ErrNotEncrypt/ErrMalformed and the package doc still framed cose as
COSE_Encrypt (tag 96) only, but Decode now accepts COSE_Encrypt0 (tag 16)
too — ErrNotEncrypt is in fact returned only when the input is neither tag.
Widen the messages and docs to cover both forms. No behavior change; all
callers match on the sentinels via errors.Is, not the message text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
Decode's preamble — unmarshal one CBOR item's tag, check it wraps an array,
unmarshal the element array — is extracted into decodeTagArray, leaving Decode
with just its decoder setup, zero-copy rest subslice, decodeTagArray call, and
decodeEnvelope validation. This lets a streaming decoder reuse the same
tag/array extraction without duplicating it, while Decode keeps handing back
rest as a subslice of the input (no copy). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
The pull request fixing the envelope tag was merged.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
PeekTag reported ErrMalformed for input that does not begin with a CBOR
tag, while Decode classifies the very same bytes as ErrNotEncrypt. A
caller switching on errors.Is therefore got a different sentinel for one
input depending on which entry point it used. Align PeekTag on
ErrNotEncrypt: a non-tag item is not a COSE encrypt structure at all.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
All three Go-produced fixtures reused one CEK and one base nonce. Two of
them are single-chunk, so their chunk-0 nonces were identical
(baseNonce || 0 || lastFlag) under the same key over different
plaintexts — AES-GCM key+nonce reuse. Harmless here, since every byte of
this material is public by design, but these vectors feed the FIL-567
security review and should not enshrine that pattern.

testCEK now takes the fixture name and folds it into the derivation
label, mirroring the TS side's fil-473-fee-cek-ts-v1. The base nonce
stays shared: distinct keys make the repeated nonce harmless.

Fixtures regenerated; the pinned foc-encryption reference still decrypts
all four (pull-foc-encryption.sh verify).

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
The reference pin moved to a commit reachable from master once upstream
PR #2 merged, but three doc spots still described the pre-merge state and
contradicted the script: they claimed master seals tag-96 bodies under
the buggy "Encrypt0" context and told the reader to re-pin later, and the
regeneration section still named refs/pull/2/head.

Describe the pin as what it is — a fixed commit on master carrying the
merged RFC 9052 5.3 fix — and keep the caveat where it belongs: commits
before that fix are not comparable with these vectors.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
…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
alanshaw force-pushed the claude/fil-473-s7pjf9 branch from 65a9831 to 324a361 Compare July 31, 2026 14:45
@alanshaw
alanshaw merged commit 202bc14 into main Jul 31, 2026
8 checks passed
@alanshaw
alanshaw deleted the claude/fil-473-s7pjf9 branch July 31, 2026 14:59
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