Skip to content

fee/cose: COSE_Encrypt envelope codec with detached payload (FIL-469)#5

Merged
Peeja merged 7 commits into
mainfrom
fil-469-fee-go-cose_encrypt-envelope-encodedecode-with-detached
Jun 26, 2026
Merged

fee/cose: COSE_Encrypt envelope codec with detached payload (FIL-469)#5
Peeja merged 7 commits into
mainfrom
fil-469-fee-go-cose_encrypt-envelope-encodedecode-with-detached

Conversation

@Peeja

@Peeja Peeja commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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 is nil and 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 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 (ECDH-ES+A256KW over X25519), and the content AEAD (AES-256-GCM / chunked STREAM) to the higher-level fee package.

What's here

  • Encode — deterministic (RFC 8949 core) tag-96 envelope with a null body ciphertext (detached); an empty protected header serializes to h'' per COSE's empty_or_serialized_map rule.
  • 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. 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 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/v2 for COSE-grade deterministic encoding and robust malformed-input handling.

Acceptance criteria

  • Encode a COSE_Encrypt (tag 96) with a nil ciphertext and one or more recipients → valid CBOR.
  • Decode recovers all protected fields, unprotected headers, and recipient entries intact.
  • Build the Enc_structure and confirm it matches the expected AAD bytes.
  • Decoding an envelope with a wrong or missing typ returns an error.
  • Malformed CBOR returns an error rather than a partial structure.
  • Multi-recipient round trip recovers each recipient's header fields and wrapped CEK bytes intact.

Tests

Round trips (single + multi-recipient), full-field preservation including nested maps, the detached-payload split, hand-verified golden AAD/envelope vectors, typ validation, a 21-case malformed-input table, and a runnable Example. go build ./..., go vet, gofmt, and go test -race all 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-specific typ value and private-use alg/label numbers will live in the parent fee package, keeping fee/cose a generic COSE codec.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GAH2CgLUJgzFsqTzfiNxKT


Generated by Claude Code

@Peeja Peeja force-pushed the fil-469-fee-go-cose_encrypt-envelope-encodedecode-with-detached branch from a16c5fa to 4a93a74 Compare June 23, 2026 19:08
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
@Peeja Peeja marked this pull request as draft June 23, 2026 19:58
@Peeja Peeja force-pushed the fil-469-fee-go-cose_encrypt-envelope-encodedecode-with-detached branch from 4a93a74 to 74334e1 Compare June 23, 2026 19:59
@Peeja Peeja requested a review from Copilot June 23, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new 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/cose with deterministic COSE_Encrypt encoding, strict decoding (incl. typ validation via WithExpectedType), 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.

Comment thread fee/cose/cose.go Outdated
Comment on lines +116 to +134
// 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()
Comment thread fee/cose/cose.go Outdated
Comment on lines +80 to +81
// Sentinel errors. Every error returned by this package wraps one of these,
// so callers can classify failures with errors.Is.
Comment thread fee/cose/encrypt.go Outdated
// 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
Comment thread fee/cose/vectors_test.go Outdated
Comment on lines +21 to +24
// 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.
claude added 6 commits June 23, 2026 21:26
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
@Peeja Peeja marked this pull request as ready for review June 24, 2026 20:51
@Peeja Peeja requested review from alanshaw and frrist June 24, 2026 20:51

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

@Peeja Peeja merged commit 275528f into main Jun 26, 2026
3 of 4 checks passed
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.

4 participants