Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions vectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` holds `blob.bin` (`envelope‖ciphertext`),
`plaintext.bin`, and `meta.json`.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Binary file added vectors/testdata/empty-file-go/blob.bin
Binary file not shown.
12 changes: 12 additions & 0 deletions vectors/testdata/empty-file-go/meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
Empty file.
Binary file added vectors/testdata/empty-file-ts/blob.bin
Binary file not shown.
11 changes: 11 additions & 0 deletions vectors/testdata/empty-file-ts/meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
Empty file.
63 changes: 48 additions & 15 deletions vectors/ts/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -30,23 +33,53 @@ 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<void> {
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<void> {
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
}
Comment thread
bajtos marked this conversation as resolved.
Outdated

const cek = sha256(fixture.cekLabel)
const plaintext = fixture.plaintext()

const blob = await encrypt(plaintext, cek, {
algorithm: CoseAlgorithm.CHUNKED_AES_256_GCM_STREAM,
chunkSize: CHUNK_SIZE,
})
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)
Expand All @@ -56,7 +89,7 @@ async function generateMultiChunkTS(): Promise<void> {
{
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,
Expand Down Expand Up @@ -102,7 +135,7 @@ async function verifyAll(): Promise<number> {
}

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) {
Expand Down
22 changes: 22 additions & 0 deletions vectors/vectors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
Loading