diff --git a/vectors/README.md b/vectors/README.md index 16e86b9..45b8f52 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 @@ -71,22 +79,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 0000000..8909553 Binary files /dev/null and b/vectors/testdata/empty-file-go/blob.bin differ 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 0000000..c09d4ac Binary files /dev/null and b/vectors/testdata/empty-file-ts/blob.bin differ diff --git a/vectors/testdata/empty-file-ts/meta.json b/vectors/testdata/empty-file-ts/meta.json new file mode 100644 index 0000000..d2a9d50 --- /dev/null +++ b/vectors/testdata/empty-file-ts/meta.json @@ -0,0 +1,11 @@ +{ + "name": "empty-file-ts", + "producer": "ts", + "description": "Empty plaintext encrypted in foc-encryption (TS) (one empty final chunk, tag-only body); decrypts in Go.", + "tag": 16, + "algorithm": -65793, + "typ": "application/vnd.foc-envelope+cose", + "chunk_size": 4096, + "chunk_count": 1, + "cek_hex": "e02a112e62e624f4ac5f32ece6574158aea0a66317d2c2fccbd1b4f79d765218" +} diff --git a/vectors/testdata/empty-file-ts/plaintext.bin b/vectors/testdata/empty-file-ts/plaintext.bin new file mode 100644 index 0000000..e69de29 diff --git a/vectors/ts/driver.ts b/vectors/ts/driver.ts index 5ce09d1..9df3a04 100644 --- a/vectors/ts/driver.ts +++ b/vectors/ts/driver.ts @@ -5,11 +5,14 @@ // // bun driver.ts [generate|verify|all] (default: all) // -// generate — encrypt a multi-chunk file with foc-encryption and write the -// `multi-chunk-ts` fixture (AC2: TS seals, Go decrypts). +// generate — encrypt with foc-encryption and write the TS-produced fixtures +// (`multi-chunk-ts`, `empty-file-ts`: TS seals, Go decrypts). A +// fixture that already exists on disk is left alone unless +// FEE_VECTORS_REGEN=1 is set: the reference draws a random base nonce +// per encrypt, so regenerating rewrites a committed blob. // verify — decrypt every committed fixture with foc-encryption and check the -// recovered plaintext (AC1/AC3: TS decrypts the Go-sealed blobs, and -// the reference parses their recipient descriptors). Exits non-zero on +// recovered plaintext (TS decrypts the Go-sealed blobs, and the +// reference parses their recipient descriptors). Exits non-zero on // any mismatch. import { CoseAlgorithm, decrypt, encrypt, parseEnvelope } from './vendor/foc-encryption/src/index.ts' import { createHash } from 'node:crypto' @@ -30,15 +33,54 @@ function sha256(label: string, n = 32): Uint8Array { const toHex = (u: Uint8Array): string => 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), + }, +] + +// 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) + 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() const blob = await encrypt(plaintext, cek, { algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM, @@ -46,7 +88,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 +97,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, @@ -77,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++ @@ -102,7 +149,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 4628158..49397fc 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) @@ -193,6 +209,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",