Skip to content

Latest commit

 

History

History
119 lines (97 loc) · 6.04 KB

File metadata and controls

119 lines (97 loc) · 6.04 KB

FEE cross-implementation test vectors

Fixed fixtures that pin the FEE (Filecoin Encryption Envelope) wire format across two implementations:

  • Go — this repo's cose, aesstream, ecdhkw, aeskw.
  • TypeScript — the reference foc-encryption (Kubuxu/foc-encryption-demo, packages/foc-encryption), pinned in pull-foc-encryption.sh to 158571ae… on master — the RFC 9052 §5.3 fix, which landed upstream in PR #2. Before that fix the reference sealed tag-96 bodies under the wrong "Encrypt0" context, so vectors are only comparable against this commit or later.

The reference is the source of truth for the wire format; these vectors pin to it and this repo matches it (see Wire format).

What's covered (acceptance criteria)

Fixture Direction Envelope
single-chunk-go Go seals → TS decrypts tag 16 (COSE_Encrypt0)
multi-chunk-ts TS seals → Go decrypts tag 16 (COSE_Encrypt0)
multi-recipient-go Go seals → TS parses recipients + decrypts body tag 96 (COSE_Encrypt)
multi-chunk-go Go seals → TS decrypts (extra multi-chunk coverage) tag 16
empty-file-go Go seals → TS decrypts tag 16
empty-file-ts TS seals → Go decrypts tag 16
exact-multiple-go Go seals → TS decrypts (plaintext is exactly 3 chunks) tag 16

Four framing cases are covered: a single chunk, a final partial chunk (the multi-chunk fixtures end mid-chunk), the empty file, and a full final chunk (exact-multiple-go, whose plaintext is exactly 3 chunks). The partial final chunk and the empty file run in both directions. An empty plaintext encodes as one empty final chunk, so its whole body is a bare 16-byte tag; TestVectors asserts the ciphertext length for every fixture.

Each testdata/<name>/ holds blob.bin (envelope‖ciphertext), plaintext.bin, and meta.json.

Both directions are exercised:

  • Go decrypts every fixturego test ./vectors (TestVectors). For the tag-96 fixture it also unwraps each recipient's CEK (ECDH-ES+A256KW over X25519, and A256KW) and checks it equals the shared CEK — the assertion the reference can't make, since it has no key-unwrap code.
  • The real foc-encryption decrypts every fixturepull-foc-encryption.sh drives the pinned reference to decrypt each blob.bin from the CEK in meta.json and to (re)generate multi-chunk-ts.

Wire format

blob = envelope ‖ ciphertext (detached payload). The COSE envelope is self-delimiting CBOR; the bytes after it are the STREAM ciphertext.

envelope    = 16([ protected, unprotected, null ])              # no recipients
            | 96([ protected, unprotected, null, recipients ])  # with recipients
protected   = { 1: -65793, 16: "application/vnd.foc-envelope+cose" }
unprotected = { 5: baseNonce(7B), -65790: chunkSize, -65791: chunkCount }
recipient   = [ {1: alg}, {4: kid, ...}, wrappedKey ]           # alg -31 or -5
  • Body cipher — chunked AES-256-GCM-STREAM, alg -65793. Per-chunk nonce is baseNonce[7] ‖ chunkIndex[4, big-endian] ‖ lastFlag[1] (0x01 on the final chunk), tag 16 bytes. Chunk count is max(1, ceil(plaintextLen / chunkSize)), so an empty plaintext still seals one (empty) final chunk.
  • Chunk count on decode — the formula above is the producer's rule, which both implementations follow, so exact-multiple-go declares 3 chunks rather than 3 full chunks plus an empty one. A decoder must not re-derive the count that way: a stream ending in an empty final chunk holds one chunk more than the formula gives for the same plaintext, and both forms decrypt identically. The declared count is authoritative, and only the ciphertext length tells the two forms apart.
  • Body AADEnc_structure = [ context, protected, "" ], the same for every chunk. context follows the envelope structure per RFC 9052 §5.3: "Encrypt" for a tag-96 envelope, "Encrypt0" for tag-16. AAD interop is order-independent: both sides key the AAD off the on-wire raw protected bytes.
  • RecipientswrappedKey is carried opaquely; the reference never unwraps it (decryption takes the CEK directly). The Go side does real ECDH-ES+A256KW / A256KW wrap and unwrap. Because no fixture here pins the ECDH key derivation, TestHKDFPublishedVector in ecdhkw/kdf_test.go pins it instead, against the COSE-WG example ecdh-wrap-examples/p256-wrap-128-01.

Regenerating

Fixtures are checked in; tests are deterministic (they only read fixed files and run deterministic decrypt/unwrap). To recreate them:

# Go-produced fixtures (single-chunk-go, multi-chunk-go, empty-file-go,
# multi-recipient-go):
FEE_VECTORS_REGEN=1 go test ./vectors -run TestGenerate -v

# Missing TS-produced fixtures (multi-chunk-ts, empty-file-ts) + verify every
# fixture decrypts under the real, pinned foc-encryption:
./vectors/pull-foc-encryption.sh

The driver writes a TS-produced fixture only when its directory is absent; set FEE_VECTORS_REGEN=1 to rewrite one that is already committed.

pull-foc-encryption.sh vendors the pinned reference into ts/vendor/ (gitignored — never committed): git clone + git fetch refs/heads/master, checking out the pinned SHA, and falling back to fetching the pinned source files from raw.githubusercontent.com where git is unavailable. It requires bun to run the TypeScript.

The base nonces (Go's fixed, the reference's random) and the fresh ECDH-ES ephemeral key in multi-recipient-go mean a regenerated blob may differ byte-for-byte from the committed one while remaining a valid vector; the committed files are the fixed reference.

Test key material

All keys (CEKs, the X25519 tenant key, the A256KW KEK, base nonces) are non-secret, derived deterministically from fixed labels, and recorded in each meta.json. They exist only to pin these vectors — never reuse them.