fee/cose: COSE_Encrypt envelope codec with detached payload (FIL-469)#5
Conversation
a16c5fa to
4a93a74
Compare
Add fee/cose, the low-level COSE (RFC 9052) envelope layer beneath the forthcoming FEE package. It encodes and decodes a COSE_Encrypt structure (CBOR tag 96) with a detached payload, and builds the Enc_structure that COSE feeds to an AEAD as additional authenticated data. The package is FEE-agnostic and performs no cryptography: it carries opaque header parameters and wrapped content-encryption keys, leaving key agreement, key wrap, and the content AEAD to the higher-level fee package. - Encode: deterministic (RFC 8949 core) tag-96 envelope with a null body ciphertext; empty protected headers serialize to h'' per COSE's empty_or_serialized_map rule. - Decode: strict parsing that returns the trailing detached ciphertext; rejects a wrong/missing tag, malformed CBOR, duplicate header labels, a non-null body ciphertext, and nested recipients, never yielding a partial structure. An optional WithExpectedType pins the protected "typ" header so a profile such as FEE can enforce its envelope type. - EncStructure: AAD built from the exact on-wire protected bytes (preserved as RawProtected on decode) so authentication is stable across a round trip. - Header: a label/value map with typed accessors and integer-label normalization that round-trips arbitrary parameters, including nested maps. Uses github.com/fxamacker/cbor/v2 for COSE-grade deterministic encoding and robust malformed-input handling. Tests cover encode/decode round trips (single and multi-recipient), full-field preservation, the detached-payload split, AAD construction with hand-verified golden vectors, typ validation, and a table of malformed inputs. go test -race -cover: ~90% of statements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
4a93a74 to
74334e1
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new low-level fee/cose package implementing a COSE_Encrypt (CBOR tag 96) envelope codec with a detached payload, plus AAD (Enc_structure) construction for downstream AEAD usage.
Changes:
- Introduces
fee/cosewith deterministic COSE_Encrypt encoding, strict decoding (incl.typvalidation viaWithExpectedType), and AAD construction. - Adds a header map abstraction (
Header) with label normalization and typed accessors. - Adds extensive tests: round-trips, golden vectors, malformed-input table, and a runnable
Example.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| go.mod | Adds github.com/fxamacker/cbor/v2 dependency (and indirect float16). |
| go.sum | Adds checksums for new CBOR dependency and transitive float16. |
| fee/cose/cose.go | Defines core types/constants, sentinel errors, and CBOR encode/decode modes. |
| fee/cose/encrypt.go | Implements deterministic encoding, EncStructure, and protected header byte handling. |
| fee/cose/decode.go | Implements strict decoding with detached payload split and optional typ enforcement. |
| fee/cose/header.go | Implements COSE header map normalization and typed accessors. |
| fee/cose/cose_test.go | End-to-end and property-style tests (determinism, round-trip, detached payload, AAD stability). |
| fee/cose/decode_test.go | Malformed input table tests + WithExpectedType behavior tests. |
| fee/cose/header_test.go | Unit tests for header normalization/accessors and encode-time invalid label handling. |
| fee/cose/vectors_test.go | Golden vectors for encoding/decoding and AAD construction cross-checking. |
| fee/cose/example_test.go | Example demonstrating envelope lifecycle and detached ciphertext handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // decMode rejects duplicate map keys (which COSE forbids) and decodes every | ||
| // CBOR integer to a Go int64 (erroring only if one overflows int64) so that | ||
| // header labels and values have a single, predictable representation. | ||
| var ( | ||
| encMode cbor.EncMode | ||
| decMode cbor.DecMode | ||
| ) | ||
|
|
||
| func init() { | ||
| em, err := cbor.CoreDetEncOptions().EncMode() | ||
| if err != nil { | ||
| panic(fmt.Sprintf("cose: building CBOR encode mode: %v", err)) | ||
| } | ||
| encMode = em | ||
|
|
||
| dm, err := cbor.DecOptions{ | ||
| DupMapKey: cbor.DupMapKeyEnforcedAPF, | ||
| IntDec: cbor.IntDecConvertSignedOrFail, | ||
| }.DecMode() |
| // Sentinel errors. Every error returned by this package wraps one of these, | ||
| // so callers can classify failures with errors.Is. |
| // protectedBytes returns the protected header byte-string content for this | ||
| // Headers value: the on-wire RawProtected when present (set by Decode), | ||
| // otherwise a fresh deterministic serialization of Protected. An empty | ||
| // protected header serializes to an empty byte string (h”), per COSE's |
| // cborBstr returns the canonical CBOR encoding of a byte string (header + | ||
| // payload), for byte strings up to 65535 bytes — enough for the test vectors. | ||
| // It is an independent re-implementation used to cross-check the encoder, so | ||
| // the tests don't validate cose against itself. |
Address Copilot review feedback on the FIL-469 envelope codec. All three are comment-only; no behavior change: - cose.go: narrow the sentinel-error doc. Only Decode and header validation guarantee a wrapped sentinel; Encode can also fail with a non-sentinel error (a nil recipient, or a header value CBOR cannot marshal), so callers must not rely on errors.Is for those. - encrypt.go: describe an empty protected header as a zero-length byte string in prose, replacing a stray non-ASCII quote in the COSE notation. - vectors_test.go: cborBstr/cborHead emit the 8/16/32-bit length forms, not a 65535-byte maximum; fix the helper's stale comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
A header value set as a uint64 greater than MaxInt64 encoded fine but then failed the package's own Decode: the decode mode used IntDecConvertSignedOrFail, which rejects any CBOR unsigned integer that does not fit int64. The package could therefore produce an envelope it could not read, contradicting Header's documented "round-trips without loss" contract. Decode now uses IntDecConvertNone (unsigned -> uint64, negative -> int64), and normalizeValue is made recursive: it folds every integer that fits back to int64 while keeping a larger unsigned value as uint64, at every depth including nested maps and their keys. This mirrors the encode-side normalization, so any header Encode produces round-trips through Decode. Small integers keep their single int64 representation and encoded bytes are unchanged (canonical CBOR is representation-agnostic for integers). Add TestRoundTripLargeUnsignedValue covering the top-level and nested cases. Addresses Copilot review feedback on the FIL-469 envelope codec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
Adopt github.com/stretchr/testify/require for this package's unit tests — house style in other repos, not previously used here. The four in-package test files now assert with require.NoError/Error/ErrorIs/Equal/True/False/ Nil/Len/Panics instead of hand-rolled if + t.Fatalf/t.Errorf blocks. No test behavior changes, and the panic-based Example (example_test.go) is left as-is. testify v1.11.1 was already an indirect dependency; promote it to a direct require (go.sum unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
Collapse the package's top-level tests into a few cohesive outer tests so the suite reads as a spec for each unit under test. No assertions change; every test body is preserved verbatim as a subtest. - cose_test.go: TestRoundTrip (preserves all fields / multi recipient / detached payload / large unsigned value), TestEncode (deterministic / requires recipient / empty protected header), TestEncStructure (binds protected not unprotected / stable across round trip). - header_test.go: TestHeader (accessors / label normalization / int-uint boundaries / integer width normalization) and TestHeaderSetPanics (overflow label / invalid label). TestEncodeRejectsInvalidLabel stays standalone (it exercises Encode, not Header.Set). - vectors_test.go: TestVectors (encode minimal / decode minimal / decode rich / enc_structure empty protected). decode_test.go already used table/subtests and is left unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
cose is FEE-agnostic, but the tests leaned on FEE/Filecoin-specific values
(a filecoin-encryption typ, hilt did:web key ids, tenant/chunk/app-metadata
labels), which muddied what the package actually guarantees. Swap them for
neutral examples so the tests read as generic COSE:
- exampleType ("application/example") replaces the filecoin typ.
- labelNegSmall/labelNegLarge replace labelChunkSize/labelAppMetadata as
arbitrary private-use integer labels (still exercising small/large,
signed integer labels).
- Recipient kids become key-1/2/3; the nested map uses name/count; the
body alg is A256GCM (3). Standard COSE values (alg -31, OKP/X25519
ephemeral key) are kept since they're generic, not FEE-specific.
- example_test.go and the vectors_test.go comment lose their FEE wording
(wrapped key, not "wrapped CEK"); the Example's checked output is updated.
No behavior or coverage change — same value kinds and label kinds, same
assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
Replace the magic-number-with-comment algorithm values (3 // A256GCM, -31 // ECDH-ES+A256KW, …) with named constants alongside the existing HeaderLabel* block: AlgA256GCM = 3 // AES-256-GCM content encryption AlgA256KW = -5 // AES Key Wrap (256-bit) AlgECDHESA256KW = -31 // ECDH-ES + AES-256 Key Wrap These are the generic, IANA-registered COSE algorithms (RFC 9053), so they live in the FEE-agnostic cose package as int64 conveniences — same pattern and type as HeaderLabel*. FEE's private-use algorithm (Chunked-AES-256-GCM-STREAM, -65793) is intentionally not here; it belongs to the future fee package. Tests and the example now reference the constants. Left as-is: header_test's accessor test, where 3 is a generic integer exercising Int/Uint (not an algorithm), and nested-map "count" values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
frrist
left a comment
There was a problem hiding this comment.
I'm going to sound like a broken record across all these crypto PRs; I lack the depth of knowledge to determine is this is "correct", though it looks good to me:
Approving so this isn't blocked. But flagging, as a follow-up rather than an objection: this is hand-rolled cryptography, and the blast radius if any of it is subtly wrong is large.
This piece is just an implementation of COSE (RFC 8152, RFC 9052). More specifically, it's just enough to implement FEE for our needs, which means just
COSE_Encrypt. (COSE_Encrypt0, the version with no recipients in the envelope, is part of FEE too, but we're not going to use it, so we leave it alone.) It also means only detached payloads, where the payload value in the FEE envelope isniland the ciphertext bytes are stored separately—in our case, they'll be immediately following the envelope.Implements FIL-469. Adds
fee/cose, the low-level COSE (RFC 9052) envelope layer beneath the forthcoming FEE package: it encodes/decodes aCOSE_Encryptstructure (CBOR tag 96) with a detached payload and builds theEnc_structurethat COSE feeds to an AEAD as additional authenticated data.The package is FEE-agnostic and performs no cryptography — it carries opaque header parameters and wrapped content-encryption keys, leaving key agreement, key wrap (ECDH-ES+A256KW over X25519), and the content AEAD (AES-256-GCM / chunked STREAM) to the higher-level
feepackage.What's here
Encode— deterministic (RFC 8949 core) tag-96 envelope with a null body ciphertext (detached); an empty protected header serializes toh''per COSE'sempty_or_serialized_maprule.Decode— strict parsing that returns the trailing detached ciphertext alongside the envelope; rejects a wrong/missing tag, malformed CBOR, duplicate header labels, a non-null body ciphertext, and nested recipients — never yielding a partial structure.WithExpectedTypepins the protectedtypheader so a profile such as FEE can enforce its envelope type.EncStructure— AAD built from the exact on-wire protected bytes (preserved asRawProtectedon decode), so authentication is stable across an encode/decode round trip.Header— a label/value map with typed accessors and integer-label normalization; arbitrary/unknown parameters, including nested maps, round-trip losslessly.Uses
github.com/fxamacker/cbor/v2for COSE-grade deterministic encoding and robust malformed-input handling.Acceptance criteria
COSE_Encrypt(tag 96) with a nil ciphertext and one or more recipients → valid CBOR.Enc_structureand confirm it matches the expected AAD bytes.typreturns an error.Tests
Round trips (single + multi-recipient), full-field preservation including nested maps, the detached-payload split, hand-verified golden AAD/envelope vectors,
typvalidation, a 21-case malformed-input table, and a runnableExample.go build ./...,go vet,gofmt, andgo test -raceall pass (~90% statement coverage).Scope notes
Per the ticket, this implements
COSE_Encrypt(tag 96) only.COSE_Encrypt0(tag 16) and nested recipients are intentionally out of scope and easy to add later if the single-recipient/PSK path needs tag 16. The FEE-specifictypvalue and private-use alg/label numbers will live in the parentfeepackage, keepingfee/cosea generic COSE codec.🤖 Generated with Claude Code
https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT
Generated by Claude Code