Skip to content

Commit f43b6d3

Browse files
authored
test(vectors): pin empty-file framing across impls (FIL-518) (#2)
* test(vectors): pin empty-file framing across impls 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š <oss@bajtos.net> Assisted-by: Claude:claude-opus-5 * test(vectors): treat partial fixtures as incomplete 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š <oss@bajtos.net> Assisted-by: Claude:claude-opus-5[1m] --------- Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
1 parent 8a4f3dc commit f43b6d3

9 files changed

Lines changed: 129 additions & 24 deletions

File tree

vectors/README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ to it and this repo matches it (see [Wire format](#wire-format)).
2424
| `multi-chunk-ts` | TS seals → Go decrypts | tag 16 (COSE_Encrypt0) |
2525
| `multi-recipient-go` | Go seals → TS parses recipients + decrypts body | tag 96 (COSE_Encrypt) |
2626
| `multi-chunk-go` | Go seals → TS decrypts (extra multi-chunk coverage) | tag 16 |
27+
| `empty-file-go` | Go seals → TS decrypts | tag 16 |
28+
| `empty-file-ts` | TS seals → Go decrypts | tag 16 |
29+
30+
Three framing cases are covered in both directions: a single chunk, a final
31+
partial chunk (the multi-chunk fixtures end mid-chunk), and the empty file. An
32+
empty plaintext encodes as one empty final chunk, so its whole body is a bare
33+
16-byte tag; `TestVectors` asserts the ciphertext length for every fixture.
2734

2835
Each `testdata/<name>/` holds `blob.bin` (`envelope‖ciphertext`),
2936
`plaintext.bin`, and `meta.json`.
@@ -53,7 +60,8 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5
5360

5461
- **Body cipher** — chunked AES-256-GCM-STREAM, alg `-65793`. Per-chunk nonce is
5562
`baseNonce[7] ‖ chunkIndex[4, big-endian] ‖ lastFlag[1]` (`0x01` on the final
56-
chunk), tag 16 bytes.
63+
chunk), tag 16 bytes. Chunk count is `max(1, ceil(plaintextLen / chunkSize))`,
64+
so an empty plaintext still seals one (empty) final chunk.
5765
- **Body AAD**`Enc_structure = [ context, protected, "" ]`, the **same** for
5866
every chunk. `context` follows the envelope structure per RFC 9052 §5.3:
5967
`"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
7179
run deterministic decrypt/unwrap). To recreate them:
7280

7381
```bash
74-
# Go-produced fixtures (single-chunk-go, multi-chunk-go, multi-recipient-go):
82+
# Go-produced fixtures (single-chunk-go, multi-chunk-go, empty-file-go,
83+
# multi-recipient-go):
7584
FEE_VECTORS_REGEN=1 go test ./vectors -run TestGenerate -v
7685

77-
# TS-produced fixture (multi-chunk-ts) + verify every fixture decrypts under the
78-
# real, pinned foc-encryption:
86+
# Missing TS-produced fixtures (multi-chunk-ts, empty-file-ts) + verify every
87+
# fixture decrypts under the real, pinned foc-encryption:
7988
./vectors/pull-foc-encryption.sh
8089
```
8190

91+
The driver writes a TS-produced fixture only when its directory is absent; set
92+
`FEE_VECTORS_REGEN=1` to rewrite one that is already committed.
93+
8294
`pull-foc-encryption.sh` vendors the pinned reference into `ts/vendor/`
8395
(gitignored — never committed): `git clone` + `git fetch refs/heads/master`,
8496
checking out the pinned SHA, and falling back to fetching the pinned source files
8597
from `raw.githubusercontent.com` where `git` is unavailable. It requires
8698
[`bun`](https://bun.sh) to run the TypeScript.
8799

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

92105
## Test key material
88 Bytes
Binary file not shown.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "empty-file-go",
3+
"producer": "go",
4+
"description": "Empty plaintext encrypted in Go (one empty final chunk, tag-only body); decrypts in foc-encryption (TS).",
5+
"tag": 16,
6+
"algorithm": -65793,
7+
"typ": "application/vnd.foc-envelope+cose",
8+
"chunk_size": 4096,
9+
"chunk_count": 1,
10+
"cek_hex": "6f5534da03dcab453ffb8d3f4f52f466f1d41f4daeb8c5170ba7b1a489172ef9",
11+
"base_nonce_hex": "baf5c82a5faa4c"
12+
}

vectors/testdata/empty-file-go/plaintext.bin

Whitespace-only changes.
88 Bytes
Binary file not shown.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "empty-file-ts",
3+
"producer": "ts",
4+
"description": "Empty plaintext encrypted in foc-encryption (TS) (one empty final chunk, tag-only body); decrypts in Go.",
5+
"tag": 16,
6+
"algorithm": -65793,
7+
"typ": "application/vnd.foc-envelope+cose",
8+
"chunk_size": 4096,
9+
"chunk_count": 1,
10+
"cek_hex": "e02a112e62e624f4ac5f32ece6574158aea0a66317d2c2fccbd1b4f79d765218"
11+
}

vectors/testdata/empty-file-ts/plaintext.bin

Whitespace-only changes.

vectors/ts/driver.ts

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
//
66
// bun driver.ts [generate|verify|all] (default: all)
77
//
8-
// generate — encrypt a multi-chunk file with foc-encryption and write the
9-
// `multi-chunk-ts` fixture (AC2: TS seals, Go decrypts).
8+
// generate — encrypt with foc-encryption and write the TS-produced fixtures
9+
// (`multi-chunk-ts`, `empty-file-ts`: TS seals, Go decrypts). A
10+
// fixture that already exists on disk is left alone unless
11+
// FEE_VECTORS_REGEN=1 is set: the reference draws a random base nonce
12+
// per encrypt, so regenerating rewrites a committed blob.
1013
// verify — decrypt every committed fixture with foc-encryption and check the
11-
// recovered plaintext (AC1/AC3: TS decrypts the Go-sealed blobs, and
12-
// the reference parses their recipient descriptors). Exits non-zero on
14+
// recovered plaintext (TS decrypts the Go-sealed blobs, and the
15+
// reference parses their recipient descriptors). Exits non-zero on
1316
// any mismatch.
1417
import { CoseAlgorithm, decrypt, encrypt, parseEnvelope } from './vendor/foc-encryption/src/index.ts'
1518
import { createHash } from 'node:crypto'
@@ -30,23 +33,61 @@ function sha256(label: string, n = 32): Uint8Array {
3033
const toHex = (u: Uint8Array): string => Buffer.from(u).toString('hex')
3134
const fromHex = (h: string): Uint8Array => new Uint8Array(Buffer.from(h, 'hex'))
3235

33-
async function generateMultiChunkTS(): Promise<void> {
34-
const name = 'multi-chunk-ts'
35-
const cek = sha256('fil-473-fee-cek-ts-v1')
36-
37-
// Deterministic ~15 KiB plaintext so the STREAM spans several 4 KiB chunks.
38-
const unit = new TextEncoder().encode('multi-chunk-ts/FIL-473 ')
36+
// repeatTo returns label repeated until it reaches at least n bytes, the
37+
// deterministic filler for a multi-chunk plaintext.
38+
function repeatTo(label: string, n: number): Uint8Array {
39+
const unit = new TextEncoder().encode(label)
3940
const bytes: number[] = []
40-
while (bytes.length < 15000) for (const b of unit) bytes.push(b)
41-
const plaintext = new Uint8Array(bytes)
41+
while (bytes.length < n) for (const b of unit) bytes.push(b)
42+
return new Uint8Array(bytes)
43+
}
44+
45+
// The TS-produced fixtures. Each carries its own CEK label: no two fixtures may
46+
// share a CEK, since several are single-chunk and a shared CEK would give them
47+
// identical chunk-0 nonces (baseNonce ‖ 0 ‖ lastFlag) over different plaintexts.
48+
// The Go side documents the same rule on testCEK (../helpers_test.go).
49+
const TS_FIXTURES = [
50+
{
51+
name: 'multi-chunk-ts',
52+
cekLabel: 'fil-473-fee-cek-ts-v1',
53+
description: 'AC2: multi-chunk file encrypted in foc-encryption (TS); decrypts in Go.',
54+
// ~15 KiB, so the STREAM spans several 4 KiB chunks.
55+
plaintext: () => repeatTo('multi-chunk-ts/FIL-473 ', 15000),
56+
},
57+
{
58+
name: 'empty-file-ts',
59+
cekLabel: 'fee-cek-ts-empty-v1',
60+
description:
61+
'Empty plaintext encrypted in foc-encryption (TS) (one empty final chunk, tag-only body); decrypts in Go.',
62+
plaintext: () => new Uint8Array(0),
63+
},
64+
]
65+
66+
// The files every fixture directory must contain. A directory missing any of
67+
// them is a partial write (an aborted generation) and gets rewritten.
68+
const FIXTURE_FILES = ['blob.bin', 'plaintext.bin', 'meta.json']
69+
70+
async function generateTS(fixture: (typeof TS_FIXTURES)[number]): Promise<void> {
71+
const { name, description } = fixture
72+
const dir = join(TESTDATA, name)
73+
const complete = FIXTURE_FILES.every((f) => existsSync(join(dir, f)))
74+
if (complete && !process.env.FEE_VECTORS_REGEN) {
75+
console.log(`skipped ${name}: already on disk (set FEE_VECTORS_REGEN=1 to rewrite it)`)
76+
return
77+
}
78+
if (existsSync(dir) && !complete) {
79+
console.log(`regenerating ${name}: fixture on disk is incomplete`)
80+
}
81+
82+
const cek = sha256(fixture.cekLabel)
83+
const plaintext = fixture.plaintext()
4284

4385
const blob = await encrypt(plaintext, cek, {
4486
algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM,
4587
chunkSize: CHUNK_SIZE,
4688
})
4789
const meta = parseEnvelope(blob)
4890

49-
const dir = join(TESTDATA, name)
5091
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
5192
writeFileSync(join(dir, 'blob.bin'), blob)
5293
writeFileSync(join(dir, 'plaintext.bin'), plaintext)
@@ -56,7 +97,7 @@ async function generateMultiChunkTS(): Promise<void> {
5697
{
5798
name,
5899
producer: 'ts',
59-
description: 'AC2: multi-chunk file encrypted in foc-encryption (TS); decrypts in Go.',
100+
description,
60101
tag: 16,
61102
algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM,
62103
typ: FEE_TYP,
@@ -77,10 +118,16 @@ async function verifyAll(): Promise<number> {
77118
for (const entry of readdirSync(TESTDATA, { withFileTypes: true })) {
78119
if (!entry.isDirectory()) continue
79120
const dir = join(TESTDATA, entry.name)
80-
const metaPath = join(dir, 'meta.json')
81-
if (!existsSync(metaPath)) continue
121+
const present = FIXTURE_FILES.filter((f) => existsSync(join(dir, f)))
122+
if (present.length === 0) continue
123+
if (present.length < FIXTURE_FILES.length) {
124+
const missing = FIXTURE_FILES.filter((f) => !present.includes(f))
125+
console.error(`FAIL ${entry.name}: incomplete fixture, missing ${missing.join(', ')}`)
126+
failures++
127+
continue
128+
}
82129

83-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
130+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'))
84131
const blob = new Uint8Array(readFileSync(join(dir, 'blob.bin')))
85132
const expected = new Uint8Array(readFileSync(join(dir, 'plaintext.bin')))
86133
checked++
@@ -102,7 +149,7 @@ async function verifyAll(): Promise<number> {
102149
}
103150

104151
const mode = process.argv[2] ?? 'all'
105-
if (mode === 'generate' || mode === 'all') await generateMultiChunkTS()
152+
if (mode === 'generate' || mode === 'all') for (const f of TS_FIXTURES) await generateTS(f)
106153
let failures = 0
107154
if (mode === 'verify' || mode === 'all') failures = await verifyAll()
108155
if (failures > 0) {

vectors/vectors_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/filecoin-project/go-fee/aeskw"
11+
"github.com/filecoin-project/go-fee/aesstream"
1112
"github.com/filecoin-project/go-fee/cose"
1213
"github.com/filecoin-project/go-fee/ecdhkw"
1314
"github.com/stretchr/testify/require"
@@ -21,10 +22,17 @@ import (
2122
// multi-chunk-ts AC2: the TS reference seals a multi-chunk file; Go decrypts it.
2223
// multi-recipient-go AC3: Go seals a multi-recipient envelope; the TS reference
2324
// parses each recipient and decrypts the body from the CEK.
25+
// empty-file-go Go seals an empty file; the TS reference decrypts it.
26+
// empty-file-ts the TS reference seals an empty file; Go decrypts it.
27+
//
28+
// The empty file is its own framing case in both directions: it encodes as one
29+
// empty final chunk, so the whole body is a bare 16-byte tag.
2430
var coreVectors = map[string]string{
2531
"single-chunk-go": "go",
2632
"multi-chunk-ts": "ts",
2733
"multi-recipient-go": "go",
34+
"empty-file-go": "go",
35+
"empty-file-ts": "ts",
2836
}
2937

3038
// TestVectors verifies that this Go implementation decrypts every committed
@@ -59,6 +67,14 @@ func TestVectors(t *testing.T) {
5967
require.NoError(t, err, "decrypt body")
6068
require.Equal(t, f.plaintext, got, "recovered plaintext")
6169

70+
// Framing: one tag per chunk, and an empty plaintext still costs
71+
// one (empty) chunk — the case a naive STREAM crib gets wrong.
72+
p, err := decodeFEE(f.blob)
73+
require.NoError(t, err)
74+
require.Equal(t,
75+
aesstream.EncryptedSize(int64(len(f.plaintext)), p.chunkSize),
76+
int64(len(p.ciphertext)), "ciphertext framing")
77+
6278
// Determinism: a second decrypt yields the same bytes.
6379
again, err := decryptFEE(f.blob, cek)
6480
require.NoError(t, err)
@@ -193,6 +209,12 @@ func TestGenerate(t *testing.T) {
193209
"Multi-chunk file encrypted in Go (spans several STREAM chunks); decrypts in foc-encryption (TS).",
194210
bytes.Repeat([]byte("multi-chunk-go/FIL-473 "), 700)) // ~15 KiB > chunk size
195211

212+
// An empty file sealed in Go (tag 16): one empty final chunk, so the body is
213+
// a bare 16-byte tag. The framing edge case the reference must agree on.
214+
genGoBody(t, "empty-file-go",
215+
"Empty plaintext encrypted in Go (one empty final chunk, tag-only body); decrypts in foc-encryption (TS).",
216+
[]byte{})
217+
196218
// AC3 — multi-recipient envelope sealed in Go (tag 96) with a real
197219
// ECDH-ES+A256KW (X25519) recipient and a real A256KW recipient.
198220
genGoMultiRecipient(t, "multi-recipient-go",

0 commit comments

Comments
 (0)