From ef3c2d8be58fdeb6f798ccf0bf5cac2ec04dfd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 3 Aug 2026 13:30:15 +0200 Subject: [PATCH 1/2] test(vectors): pin empty-file framing across impls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty object is its own STREAM framing case: it encodes as one empty final chunk, so the body is a bare 16-byte tag with nothing to authenticate. Until now that case was only covered by Go-internal tests, so a divergence from the foc-encryption reference on the case most likely to diverge would have gone unnoticed (FIL-518 AC3). - Add empty-file-go (Go seals, the reference decrypts) and empty-file-ts (the reference seals, Go decrypts). - Assert the ciphertext length against aesstream.EncryptedSize for every fixture, so the one-tag-per-chunk framing is pinned rather than implied by a successful decrypt. - Make the TS driver table-driven and skip a fixture already on disk unless FEE_VECTORS_REGEN=1, so generating a new TS fixture no longer rewrites a committed blob (the reference draws a random base nonce per encrypt). The pinned reference decrypts both new fixtures: all six fixtures pass under ./vectors/pull-foc-encryption.sh. Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5 --- vectors/README.md | 25 ++++++-- vectors/testdata/empty-file-go/blob.bin | Bin 0 -> 88 bytes vectors/testdata/empty-file-go/meta.json | 12 ++++ vectors/testdata/empty-file-go/plaintext.bin | 0 vectors/testdata/empty-file-ts/blob.bin | Bin 0 -> 88 bytes vectors/testdata/empty-file-ts/meta.json | 11 ++++ vectors/testdata/empty-file-ts/plaintext.bin | 0 vectors/ts/driver.ts | 63 ++++++++++++++----- vectors/vectors_test.go | 22 +++++++ 9 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 vectors/testdata/empty-file-go/blob.bin create mode 100644 vectors/testdata/empty-file-go/meta.json create mode 100644 vectors/testdata/empty-file-go/plaintext.bin create mode 100644 vectors/testdata/empty-file-ts/blob.bin create mode 100644 vectors/testdata/empty-file-ts/meta.json create mode 100644 vectors/testdata/empty-file-ts/plaintext.bin diff --git a/vectors/README.md b/vectors/README.md index ad03229..0835c9e 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -24,6 +24,13 @@ to it and this repo matches it (see [Wire format](#wire-format)). | `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 | + +Three framing cases are covered in both directions: a single chunk, a final +partial chunk (the multi-chunk fixtures end mid-chunk), and the empty file. 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//` holds `blob.bin` (`envelope‖ciphertext`), `plaintext.bin`, and `meta.json`. @@ -53,7 +60,8 @@ 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), tag 16 bytes. Chunk count is `max(1, ceil(plaintextLen / chunkSize))`, + so an empty plaintext still seals one (empty) final chunk. - **Body AAD** — `Enc_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 @@ -68,22 +76,27 @@ Fixtures are checked in; tests are deterministic (they only read fixed files and run deterministic decrypt/unwrap). To recreate them: ```bash -# Go-produced fixtures (single-chunk-go, multi-chunk-go, multi-recipient-go): +# 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 -# TS-produced fixture (multi-chunk-ts) + verify every fixture decrypts under the -# real, pinned foc-encryption: +# 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`](https://bun.sh) to run the TypeScript. -The base nonces (Go's fixed, the reference's random) mean a regenerated blob may -differ byte-for-byte from the committed one while remaining a valid vector; the +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 diff --git a/vectors/testdata/empty-file-go/blob.bin b/vectors/testdata/empty-file-go/blob.bin new file mode 100644 index 0000000000000000000000000000000000000000..89095534c68749be405e3738f91c31c8095e6a64 GIT binary patch literal 88 zcmcc69HG64(TahQkwKtBF|nW^Co?&*Br`uxzbr3BFD*Y=H#M&;H7CCyRXaJqICU|r q`>wAiwBlF!0JSjul@wqA(f=5~EmxoLk#*CQMKABnx_ZWjpC15I7$H0W literal 0 HcmV?d00001 diff --git a/vectors/testdata/empty-file-go/meta.json b/vectors/testdata/empty-file-go/meta.json new file mode 100644 index 0000000..e24b032 --- /dev/null +++ b/vectors/testdata/empty-file-go/meta.json @@ -0,0 +1,12 @@ +{ + "name": "empty-file-go", + "producer": "go", + "description": "Empty plaintext encrypted in Go (one empty final chunk, tag-only body); decrypts in foc-encryption (TS).", + "tag": 16, + "algorithm": -65793, + "typ": "application/vnd.foc-envelope+cose", + "chunk_size": 4096, + "chunk_count": 1, + "cek_hex": "6f5534da03dcab453ffb8d3f4f52f466f1d41f4daeb8c5170ba7b1a489172ef9", + "base_nonce_hex": "baf5c82a5faa4c" +} diff --git a/vectors/testdata/empty-file-go/plaintext.bin b/vectors/testdata/empty-file-go/plaintext.bin new file mode 100644 index 0000000..e69de29 diff --git a/vectors/testdata/empty-file-ts/blob.bin b/vectors/testdata/empty-file-ts/blob.bin new file mode 100644 index 0000000000000000000000000000000000000000..c09d4ac8d50e28950941beb6a5f5caeb3ddb7e26 GIT binary patch literal 88 zcmcc69HG64(TahQkwKtBF|nW^Co?&*Br`uxzbr3BFD*Y=H#M&;H7CCyRXaJqICU|r pyFzXE$@2{vKrIY^B?TBj^gqUL>-t_c1O; Buffer.from(u).toString('hex') const fromHex = (h: string): Uint8Array => new Uint8Array(Buffer.from(h, 'hex')) -async function generateMultiChunkTS(): Promise { - const name = 'multi-chunk-ts' - const cek = sha256('fil-473-fee-cek-ts-v1') - - // Deterministic ~15 KiB plaintext so the STREAM spans several 4 KiB chunks. - const unit = new TextEncoder().encode('multi-chunk-ts/FIL-473 ') +// repeatTo returns label repeated until it reaches at least n bytes, the +// deterministic filler for a multi-chunk plaintext. +function repeatTo(label: string, n: number): Uint8Array { + const unit = new TextEncoder().encode(label) const bytes: number[] = [] - while (bytes.length < 15000) for (const b of unit) bytes.push(b) - const plaintext = new Uint8Array(bytes) + while (bytes.length < n) for (const b of unit) bytes.push(b) + return new Uint8Array(bytes) +} + +// The TS-produced fixtures. Each carries its own CEK label: no two fixtures may +// share a CEK, since several are single-chunk and a shared CEK would give them +// identical chunk-0 nonces (baseNonce ‖ 0 ‖ lastFlag) over different plaintexts. +// The Go side documents the same rule on testCEK (../helpers_test.go). +const TS_FIXTURES = [ + { + name: 'multi-chunk-ts', + cekLabel: 'fil-473-fee-cek-ts-v1', + description: 'AC2: multi-chunk file encrypted in foc-encryption (TS); decrypts in Go.', + // ~15 KiB, so the STREAM spans several 4 KiB chunks. + plaintext: () => repeatTo('multi-chunk-ts/FIL-473 ', 15000), + }, + { + name: 'empty-file-ts', + cekLabel: 'fee-cek-ts-empty-v1', + description: + 'Empty plaintext encrypted in foc-encryption (TS) (one empty final chunk, tag-only body); decrypts in Go.', + plaintext: () => new Uint8Array(0), + }, +] + +async function generateTS(fixture: (typeof TS_FIXTURES)[number]): Promise { + const { name, description } = fixture + const dir = join(TESTDATA, name) + if (existsSync(dir) && !process.env.FEE_VECTORS_REGEN) { + console.log(`skipped ${name}: already on disk (set FEE_VECTORS_REGEN=1 to rewrite it)`) + return + } + + const cek = sha256(fixture.cekLabel) + const plaintext = fixture.plaintext() const blob = await encrypt(plaintext, cek, { algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM, @@ -46,7 +80,6 @@ async function generateMultiChunkTS(): Promise { }) const meta = parseEnvelope(blob) - const dir = join(TESTDATA, name) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(join(dir, 'blob.bin'), blob) writeFileSync(join(dir, 'plaintext.bin'), plaintext) @@ -56,7 +89,7 @@ async function generateMultiChunkTS(): Promise { { name, producer: 'ts', - description: 'AC2: multi-chunk file encrypted in foc-encryption (TS); decrypts in Go.', + description, tag: 16, algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM, typ: FEE_TYP, @@ -102,7 +135,7 @@ async function verifyAll(): Promise { } const mode = process.argv[2] ?? 'all' -if (mode === 'generate' || mode === 'all') await generateMultiChunkTS() +if (mode === 'generate' || mode === 'all') for (const f of TS_FIXTURES) await generateTS(f) let failures = 0 if (mode === 'verify' || mode === 'all') failures = await verifyAll() if (failures > 0) { diff --git a/vectors/vectors_test.go b/vectors/vectors_test.go index 4412c3e..9cd93f2 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/filecoin-project/go-fee/aeskw" + "github.com/filecoin-project/go-fee/aesstream" "github.com/filecoin-project/go-fee/cose" "github.com/filecoin-project/go-fee/ecdhkw" "github.com/stretchr/testify/require" @@ -21,10 +22,17 @@ import ( // multi-chunk-ts AC2: the TS reference seals a multi-chunk file; Go decrypts it. // multi-recipient-go AC3: Go seals a multi-recipient envelope; the TS reference // parses each recipient and decrypts the body from the CEK. +// empty-file-go Go seals an empty file; the TS reference decrypts it. +// empty-file-ts the TS reference seals an empty file; Go decrypts it. +// +// The empty file is its own framing case in both directions: it encodes as one +// empty final chunk, so the whole body is a bare 16-byte tag. var coreVectors = map[string]string{ "single-chunk-go": "go", "multi-chunk-ts": "ts", "multi-recipient-go": "go", + "empty-file-go": "go", + "empty-file-ts": "ts", } // TestVectors verifies that this Go implementation decrypts every committed @@ -59,6 +67,14 @@ func TestVectors(t *testing.T) { require.NoError(t, err, "decrypt body") require.Equal(t, f.plaintext, got, "recovered plaintext") + // Framing: one tag per chunk, and an empty plaintext still costs + // one (empty) chunk — the case a naive STREAM crib gets wrong. + p, err := decodeFEE(f.blob) + require.NoError(t, err) + require.Equal(t, + aesstream.EncryptedSize(int64(len(f.plaintext)), p.chunkSize), + int64(len(p.ciphertext)), "ciphertext framing") + // Determinism: a second decrypt yields the same bytes. again, err := decryptFEE(f.blob, cek) require.NoError(t, err) @@ -189,6 +205,12 @@ func TestGenerate(t *testing.T) { "Multi-chunk file encrypted in Go (spans several STREAM chunks); decrypts in foc-encryption (TS).", bytes.Repeat([]byte("multi-chunk-go/FIL-473 "), 700)) // ~15 KiB > chunk size + // An empty file sealed in Go (tag 16): one empty final chunk, so the body is + // a bare 16-byte tag. The framing edge case the reference must agree on. + genGoBody(t, "empty-file-go", + "Empty plaintext encrypted in Go (one empty final chunk, tag-only body); decrypts in foc-encryption (TS).", + []byte{}) + // AC3 — multi-recipient envelope sealed in Go (tag 96) with a real // ECDH-ES+A256KW (X25519) recipient and a real A256KW recipient. genGoMultiRecipient(t, "multi-recipient-go", From bd868b81b9b6d5e62e0ca487ed264aa05cbbe384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 3 Aug 2026 13:58:41 +0200 Subject: [PATCH 2/2] test(vectors): treat partial fixtures as incomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generate skip check only looked for the fixture directory, so an aborted generation left a partial fixture that was never rewritten, and verify skipped it for lacking meta.json. Require all three files. Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5[1m] --- vectors/ts/driver.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/vectors/ts/driver.ts b/vectors/ts/driver.ts index 916cfdd..9df3a04 100644 --- a/vectors/ts/driver.ts +++ b/vectors/ts/driver.ts @@ -63,13 +63,21 @@ const TS_FIXTURES = [ }, ] +// The files every fixture directory must contain. A directory missing any of +// them is a partial write (an aborted generation) and gets rewritten. +const FIXTURE_FILES = ['blob.bin', 'plaintext.bin', 'meta.json'] + async function generateTS(fixture: (typeof TS_FIXTURES)[number]): Promise { const { name, description } = fixture const dir = join(TESTDATA, name) - if (existsSync(dir) && !process.env.FEE_VECTORS_REGEN) { + const complete = FIXTURE_FILES.every((f) => existsSync(join(dir, f))) + if (complete && !process.env.FEE_VECTORS_REGEN) { console.log(`skipped ${name}: already on disk (set FEE_VECTORS_REGEN=1 to rewrite it)`) return } + if (existsSync(dir) && !complete) { + console.log(`regenerating ${name}: fixture on disk is incomplete`) + } const cek = sha256(fixture.cekLabel) const plaintext = fixture.plaintext() @@ -110,10 +118,16 @@ async function verifyAll(): Promise { for (const entry of readdirSync(TESTDATA, { withFileTypes: true })) { if (!entry.isDirectory()) continue const dir = join(TESTDATA, entry.name) - const metaPath = join(dir, 'meta.json') - if (!existsSync(metaPath)) continue + const present = FIXTURE_FILES.filter((f) => existsSync(join(dir, f))) + if (present.length === 0) continue + if (present.length < FIXTURE_FILES.length) { + const missing = FIXTURE_FILES.filter((f) => !present.includes(f)) + console.error(`FAIL ${entry.name}: incomplete fixture, missing ${missing.join(', ')}`) + failures++ + continue + } - const meta = JSON.parse(readFileSync(metaPath, 'utf8')) + const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')) const blob = new Uint8Array(readFileSync(join(dir, 'blob.bin'))) const expected = new Uint8Array(readFileSync(join(dir, 'plaintext.bin'))) checked++