Skip to content

Commit 9ff30b0

Browse files
committed
fee: insurance-recovery integration test (FIL-474)
Add fee/integration_test.go (package fee_test) proving the composed fee API recovers plaintext from a FEE envelope using only an archived tenant X25519 private key: fee.Encrypt seals a few-KB sample to an ECDH-ES tenant recipient, and fee.Decrypt recovers it with the tenant private key back to the exact original. Rides on the top-level fee package (FIL-569) rather than sequencing the primitives by hand; the one drop to a sub-package is the explicit on-wire kid assertion (cose.Decode), which the issue calls for before recovery. Covers the three acceptance criteria: the round trip; a wrong private key failing at unwrap (aeskw.ErrIntegrity) before any decryption is attempted, with no plaintext reader produced; and a corrupted protected header making fee.Decrypt return a wrapped cose.ErrMalformed rather than a reader over garbage. The tenant keypair is a fixed, non-secret test fixture checked in for determinism. Chunk size is aesstream.MinChunkSize (4 KiB), the smallest legal value, so the multi-chunk path runs without a large fixture. Stacked on FIL-569 (PR #14). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199oDgYVzczErg9qbbqmL4B
1 parent 9763f86 commit 9ff30b0

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

fee/integration_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Package fee_test's integration test is the FEE (FilOne File Encryption
2+
// Envelope) insurance-recovery artifact. It is the v1 confidence check for the
3+
// Hilt recovery path (see FIL-474): it proves the composed fee API recovers
4+
// plaintext from an envelope using only an archived tenant X25519 private key —
5+
// no region KEK and no Ingot database.
6+
//
7+
// Unlike the sub-package unit tests, this exercises the whole round trip through
8+
// the top-level fee package (fee.Encrypt / fee.Decrypt, FIL-569), which composes
9+
// the COSE envelope, the chunked AES-256-GCM-STREAM body cipher and the
10+
// ECDH-ES+A256KW tenant key wrap. The one place it drops to a sub-package is the
11+
// explicit kid assertion: the issue calls for reading the recipient entry's kid
12+
// off the wire and confirming it names the tenant key before recovery, so the
13+
// test decodes the envelope header with fee/cose to check that directly rather
14+
// than relying on the kid match fee.Decrypt performs internally.
15+
//
16+
// The envelope is not a fixture: it is built at run time by fee.Encrypt. Only
17+
// the tenant keypair is checked in, for determinism.
18+
package fee_test
19+
20+
import (
21+
"bytes"
22+
"crypto/ecdh"
23+
"encoding/hex"
24+
"io"
25+
"testing"
26+
27+
"github.com/fil-forge/ingot/fee"
28+
"github.com/fil-forge/ingot/fee/aeskw"
29+
"github.com/fil-forge/ingot/fee/aesstream"
30+
"github.com/fil-forge/ingot/fee/cose"
31+
"github.com/stretchr/testify/require"
32+
)
33+
34+
const (
35+
// streamChunkSize is the STREAM plaintext chunk size passed to
36+
// fee.WithChunkSize. The issue asks for a small chunk (vs the 256 KiB
37+
// default) so the multi-chunk path runs against a few-KB sample without a
38+
// large fixture; aesstream rejects anything below MinChunkSize (4 KiB), so
39+
// that minimum is the smallest legal "small" value. fee records it in the
40+
// envelope, so recovery recovers it without the test tracking it.
41+
streamChunkSize = aesstream.MinChunkSize
42+
43+
// samplePlaintextSize is a few KB: large enough to span several
44+
// streamChunkSize chunks with a partial final chunk, small enough to stay a
45+
// trivial in-test fixture.
46+
samplePlaintextSize = 10_000
47+
)
48+
49+
// tenantPrivateKeyHex is a fixed, non-secret X25519 private scalar, checked in
50+
// for determinism (the issue's "fixed test tenant keypair"). It is test-only
51+
// key material — never a real tenant key — so committing it is safe. Any 32
52+
// bytes form a valid X25519 private key; these are the ASCII bytes of
53+
// "fil-474-fee-tenant-recovery-test". The derived public key is
54+
// 69228bafb04870ffe3b19fcdd604a9aa3e12c53b7c0e86301dc38b2a317cf37b.
55+
const tenantPrivateKeyHex = "66696c2d3437342d6665652d74656e616e742d7265636f766572792d74657374"
56+
57+
// tenantKey loads the fixed tenant recipient keypair. The public key is derived
58+
// from the private scalar, so the archived private key is the single source of
59+
// truth — exactly what a real recovery would start from.
60+
func tenantKey(t *testing.T) *ecdh.PrivateKey {
61+
t.Helper()
62+
seed, err := hex.DecodeString(tenantPrivateKeyHex)
63+
require.NoError(t, err)
64+
priv, err := ecdh.X25519().NewPrivateKey(seed)
65+
require.NoError(t, err)
66+
return priv
67+
}
68+
69+
// samplePlaintext returns n deterministic bytes. The period (251, prime) does
70+
// not align to streamChunkSize, so chunk boundaries fall on varying byte values
71+
// rather than a repeating phase.
72+
func samplePlaintext(n int) []byte {
73+
b := make([]byte, n)
74+
for i := range b {
75+
b[i] = byte(i % 251)
76+
}
77+
return b
78+
}
79+
80+
// encryptToEnvelope seals plaintext to a single ECDH-ES tenant recipient via the
81+
// composed fee.Encrypt API and returns the complete wire blob
82+
// (envelope || ciphertext).
83+
func encryptToEnvelope(t *testing.T, recipientPub *ecdh.PublicKey, kid, plaintext []byte) []byte {
84+
t.Helper()
85+
r, err := fee.Encrypt(
86+
bytes.NewReader(plaintext),
87+
[]fee.Recipient{fee.NewECDHESRecipient(kid, recipientPub)},
88+
fee.WithChunkSize(streamChunkSize),
89+
)
90+
require.NoError(t, err)
91+
// Close immediately (mirroring fee_test.go's encrypt helper): the reader is
92+
// goroutine-backed, so it must be closed even if io.ReadAll or a later
93+
// assertion fails, or the background Encrypt goroutine/pipe leaks.
94+
defer r.Close()
95+
blob, err := io.ReadAll(r)
96+
require.NoError(t, err)
97+
return blob
98+
}
99+
100+
// TestInsuranceRecoveryRoundTrip is the primary acceptance criterion: encrypt a
101+
// plaintext sample to the test tenant key, confirm the envelope's recipient kid
102+
// names that key, then recover with only the archived tenant private key and
103+
// check the plaintext matches the original exactly — all through the composed
104+
// fee API.
105+
func TestInsuranceRecoveryRoundTrip(t *testing.T) {
106+
tenant := tenantKey(t)
107+
kid := tenant.PublicKey().Bytes()
108+
109+
plaintext := samplePlaintext(samplePlaintextSize)
110+
require.Greater(t, len(plaintext), streamChunkSize,
111+
"sample must span multiple chunks so the multi-chunk STREAM path runs")
112+
113+
blob := encryptToEnvelope(t, tenant.PublicKey(), kid, plaintext)
114+
115+
// Confirm the envelope tags the right key for the right recipient before
116+
// recovery. With a single recipient the unwrap would still succeed if the
117+
// kid were wrong or dropped, so this explicit on-wire check is the only
118+
// thing that binds the recipient entry to the tenant key. fee.Decrypt also
119+
// matches on kid internally, but the issue calls for asserting it directly.
120+
env, _, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
121+
require.NoError(t, err)
122+
require.Len(t, env.Recipients, 1)
123+
gotKid, ok := env.Recipients[0].Headers.Unprotected.Bytes(cose.HeaderLabelKID)
124+
require.True(t, ok, "recipient kid present")
125+
require.Equal(t, kid, gotKid, "recipient kid matches the tenant key")
126+
127+
// Recover using only the archived tenant private key.
128+
pr, err := fee.Decrypt(bytes.NewReader(blob), fee.NewECDHESUnwrapper(kid, tenant))
129+
require.NoError(t, err)
130+
recovered, err := io.ReadAll(pr)
131+
require.NoError(t, err)
132+
require.Equal(t, plaintext, recovered, "recovered plaintext matches the original exactly")
133+
}
134+
135+
// TestInsuranceRecoveryWrongPrivateKeyFailsBeforeDecrypt is the second
136+
// acceptance criterion: unwrapping with the wrong private key returns an error,
137+
// and decryption is never attempted on the still-encrypted data. The unwrapper
138+
// carries the real tenant kid (so the recipient still matches), isolating the
139+
// wrong-key behaviour to the unwrap step.
140+
func TestInsuranceRecoveryWrongPrivateKeyFailsBeforeDecrypt(t *testing.T) {
141+
tenant := tenantKey(t)
142+
kid := tenant.PublicKey().Bytes()
143+
blob := encryptToEnvelope(t, tenant.PublicKey(), kid, samplePlaintext(samplePlaintextSize))
144+
145+
// A different archived X25519 key. Deriving it from a distinct fixed scalar
146+
// keeps the test deterministic.
147+
wrongSeed := []byte("fil-474-fee-WRONG-tenant-key-xxx")
148+
require.Len(t, wrongSeed, 32)
149+
wrong, err := ecdh.X25519().NewPrivateKey(wrongSeed)
150+
require.NoError(t, err)
151+
152+
pr, err := fee.Decrypt(bytes.NewReader(blob), fee.NewECDHESUnwrapper(kid, wrong))
153+
require.Nil(t, pr, "no plaintext reader is produced")
154+
require.Error(t, err)
155+
require.ErrorIs(t, err, aeskw.ErrIntegrity, "recovery fails at the ECDH-ES+A256KW unwrap")
156+
// A STREAM (aesstream) error would mean decryption had been attempted; its
157+
// absence shows fee.Decrypt stopped at the unwrap, before opening the body.
158+
require.NotErrorIs(t, err, aesstream.ErrCorrupted, "STREAM decryption is never attempted")
159+
}
160+
161+
// TestInsuranceRecoveryCorruptedProtectedHeaderFailsDecode is the third
162+
// acceptance criterion: flipping a byte in the protected-header bytes makes
163+
// decode return an error, rather than yielding a structure that would decrypt to
164+
// garbage plaintext. fee.Decrypt surfaces the decode failure as a wrapped
165+
// cose.ErrMalformed and produces no plaintext reader.
166+
func TestInsuranceRecoveryCorruptedProtectedHeaderFailsDecode(t *testing.T) {
167+
tenant := tenantKey(t)
168+
kid := tenant.PublicKey().Bytes()
169+
blob := encryptToEnvelope(t, tenant.PublicKey(), kid, samplePlaintext(samplePlaintextSize))
170+
171+
// Decode once cleanly to locate the protected-header bytes within the blob.
172+
env, _, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
173+
require.NoError(t, err)
174+
require.NotEmpty(t, env.Headers.RawProtected)
175+
176+
off := bytes.Index(blob, env.Headers.RawProtected)
177+
require.GreaterOrEqual(t, off, 0, "protected-header bytes located in the blob")
178+
179+
// Flip the first protected-header byte (the CBOR map head), so the protected
180+
// bytes are no longer a CBOR map and a strict decode rejects the envelope
181+
// outright instead of returning a structure to decrypt.
182+
corrupted := bytes.Clone(blob)
183+
corrupted[off] ^= 0xFF
184+
185+
pr, err := fee.Decrypt(bytes.NewReader(corrupted), fee.NewECDHESUnwrapper(kid, tenant))
186+
require.Nil(t, pr, "no plaintext reader is produced")
187+
require.Error(t, err)
188+
require.ErrorIs(t, err, cose.ErrMalformed)
189+
}

0 commit comments

Comments
 (0)