fee: cross-implementation test vectors vs foc-encryption (FIL-473) - #32
Conversation
There was a problem hiding this comment.
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/vectorspackage with deterministic fixture generation, validation tests, and committedtestdata/<name>/{blob.bin,plaintext.bin,meta.json}vectors. - Add a TypeScript harness (
fee/vectors/ts) and a vendoring script to pull a pinnedfoc-encryptionreference and verify/regenerate TS-produced fixtures. - Extend
fee/cosewith COSE_Encrypt0 encode/decode andPeekTagfor 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.
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
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
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
|
Moved the foc-encryption-demo pin to the latest |
bajtos
left a comment
There was a problem hiding this comment.
I think this is good to land 👍🏻
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
decodeRecipientsrejects an empty tag-96 recipients array — so a decoded tag-96 always carries recipients, which is exactly whatEncStructure's context selection relies on. "An Encrypt0 with recipients" is unrepresentable. - Blast radius is contained: nothing outside
fee/imports these packages, so theEncrypt→EnvelopeAPI 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,
RawProtectedAAD stability, a negative unwrap check, and thecoreVectorscompleteness 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)
- Stale pin docs —
fee/vectors/README.mdandfee/vectors/doc.gostill say master is buggy and not used, contradicting the script'srefs/heads/masterre-pin. Should fix before merge. - CEK + base-nonce reuse across fixtures —
single-chunk-goandmulti-recipient-gocollide on chunk-0 key+nonce; harmless for public vectors but worth a one-line per-fixture CEK derivation ahead of FIL-567. - Go encode path isn't pinned in CI — suggestion: byte-equality re-compose for the deterministic tag-16 Go fixtures in
TestVectors. PeekTagvsDecodesentinel 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
| [`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. |
There was a problem hiding this comment.
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
| cek := testCEK() | ||
| baseNonce := testBaseNonce() |
There was a problem hiding this comment.
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
| // 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") |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This should be added to CI when the code is extracted as a library.
| 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) |
There was a problem hiding this comment.
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
alanshaw
left a comment
There was a problem hiding this comment.
I think this whole implementation and the test vectors should be extracted as a standalone library in a separate repo.
| // 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) { |
There was a problem hiding this comment.
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.
| // 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") |
There was a problem hiding this comment.
This should be added to CI when the code is extracted as a library.
…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>
200534e to
5d07354
Compare
…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
…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>
65a9831 to
324a361
Compare
tl;dr: Checks our implementation against the
foc-encryptionreference 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 referencefoc-encryption(Kubuxu/foc-encryption-demo,packages/foc-encryption), pinned tomasterthe head of PR #2 (158571ae…, the RFC 9052 §5.3 context fix) — notmaster, 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-go(tag 16);foc-encryptiondecrypts it.multi-chunk-ts;TestVectorsdecrypts it.multi-recipient-go(tag 96) with real ECDH-ES+A256KW (X25519) and A256KW recipients. The reference carrieswrappedKeyopaquely 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 inTestVectors.testdata/<name>/{blob.bin,plaintext.bin,meta.json}; tests only read fixed files and run deterministic decrypt/unwrap.(Plus
multi-chunk-gofor extra Go→TS multi-chunk coverage.)Go implementation changes (reconcile to the reference)
Confined to
fee/cose:Envelopetype for both COSE forms — the reference uses tag 16 (COSE_Encrypt0) for a no-recipient file and tag 96 (COSE_Encrypt) with recipients;cosepreviously modeled only tag 96. Rather than carry a parallelEncrypt0type,cosenow has oneEnvelope{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-dispatchingDecodereturning*Envelope;PeekTagstays for callers that want the raw on-wire tag without decoding.encrypt.go+encrypt0.gomerge intoenvelope.go."Encrypt"when recipients are present,"Encrypt0"when not.Envelope.EncStructureselects between them by recipient presence; the context strings stay internal tocose, and the vectors take the AAD from the envelope's ownEncStructure(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 labelschunkSize -65790/chunkCount -65791) live in the vectors package (the caller), matching the reference'ssrc/cose/headers.ts.Verifying
pull-foc-encryption.shvendors the pinned reference intots/vendor/(gitignored) viagit clone+git fetch refs/pull/2/head, checks out the pinned SHA, and falls back toraw.githubusercontent.comwheregitegress is blocked. The TS driver runs underbun. Seefee/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/coseandfee/vectors)../fee/vectors/pull-foc-encryption.sh—cross-implementation check OK(4/4 fixtures decrypt under the real foc-encryption).go-testchecks are the pre-existing flaky./testing/smoke suite (itsTestSmokeXFail_*cases fail the run on an unexpected pass), unrelated to thisfee/-only diff —testing/has no dependency onfee/*.Notes
meta.json— it exists only to pin these vectors.fee/*crypto) is gated on this reconciliation settling.🤖 Generated with Claude Code
https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
Generated by Claude Code