Files
zeekay e5fbbc5201 fhevm: a block extends the tip, a signature names one chain, and a failed read is not an answer
Four defects, each confirmed by executing it before it was fixed and by
mutation after.

A block whose parent was an OLD accepted block satisfied height ==
parent+1, so Verify passed it and Accept applied it: the chain at height 3
rewound to 2, the last-accepted pointer and the height index moved with
it, and the index went on naming the abandoned block at 3. A peer
bootstrapping from it was served an incoherent chain. The parent must now
be the tip or a block verified above it; Accept decides again under its
own hold of the lock, because the tip moves between the two.

Nothing bound a transaction or a block to the chain it was meant for. An
address is the hash of a public key, so the same payer exists on every
F-Chain, and a captured transaction authenticated verbatim on all of them
and burned a balance there. Every chain sharing a genesis timestamp also
shared a genesis id, so one chain's block resolved its parent on the
others. The chain id now enters the signing preimage and the block id
without travelling on the wire: each side supplies its own, so a mismatch
is a failed signature and an unresolvable parent rather than a check
someone can forget.

A read that FAILED was reported as a read that found NOTHING. An
unreadable last-accepted pointer left a live chain looking like a fresh
one, with Initialize returning nil and the node ready to build height 1
over height 2. The same conflation on the epoch pointer seats a committee
nobody elected, and on a nonce it reopens the replay window a nonce exists
to close. One reader now separates absent from unreadable, and nonceOf
reports rather than returns zero. HealthCheck likewise stops reporting a
ledger it could not read as zero burned.

MaxBlockTxs was applied by Verify, and Verify runs after the parse, so an
8 MB message carrying 1,524 transactions parsed to completion before any
check ran. Bytes and count are both bounded at the first byte, and neither
implies the other; selection stops at whichever comes first, so a proposer
cannot build what its own parser refuses.

Deleted: the tx-blob accounting check, which the canonical-encoding rule
subsumes exactly (a blob with unread bytes re-serializes shorter), and the
empty-path branch in NewHTTPHandler, whose one producer returns a literal.

fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in
the report, not padded. The harness now holds constant what production
holds constant: one chain id and one genesis across every node of a chain,
rather than a fresh id per node.
2026-08-28 11:55:53 -07:00

618 lines
27 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/ids"
)
// TestSyntacticVerify_AcceptsWellFormed establishes the baseline every
// rejection test below is measured against.
func TestSyntacticVerify_AcceptsWellFormed(t *testing.T) {
k := newTestKey(t)
require.NoError(t, registerTx(t, k, testScheme, digestOf("ok"), 1).SyntacticVerify())
}
// TestSyntacticVerify_Rejects proves each structural rule actually refuses.
// Every case is a well-formed transaction with exactly one thing wrong, so a
// rule that stopped being enforced fails here instead of shipping.
func TestSyntacticVerify_Rejects(t *testing.T) {
k := newTestKey(t)
digest := digestOf("subject")
handle := deriveHandle(digest, testScheme)
for _, tc := range []struct {
name string
tx *Transaction
want error
}{
{
name: "unknown transaction type",
tx: &Transaction{Type: 99, Nonce: 1},
want: ErrInvalidTxType,
},
{
name: "unknown scheme",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: "paillier", Nonce: 1,
Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrUnknownScheme,
},
{
name: "nonce zero",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 0,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrBadNonce,
},
{
name: "undecodable payload",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: []byte("not json")},
want: ErrInvalidPayload,
},
{
name: "register with no digest",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Payload: mustJSON(t, RegisterPayload{Size: 1})},
want: ErrInvalidPayload,
},
{
name: "register with zero size",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest})},
want: ErrInvalidPayload,
},
{
name: "register with negative level",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1, Level: -1})},
want: ErrInvalidPayload,
},
{
name: "register whose subject is not the derived handle",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: [32]byte{0xde, 0xad}, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrHandleMismatch,
},
{
name: "register under a different scheme than the handle names",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: "bfv-n13", Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrHandleMismatch,
},
{
name: "grant conferring nothing",
tx: &Transaction{Type: TxGrantPermit, Nonce: 1, Subject: handle,
Payload: mustJSON(t, GrantPayload{Grantee: k.addr, Operations: 0})},
want: ErrInvalidPayload,
},
{
name: "grant with unknown operation bits",
tx: &Transaction{Type: TxGrantPermit, Nonce: 1, Subject: handle,
Payload: mustJSON(t, GrantPayload{Grantee: k.addr, Operations: 1 << 20})},
want: ErrInvalidPayload,
},
{
name: "request naming no permit",
tx: &Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: mustJSON(t, RequestPayload{})},
want: ErrInvalidPayload,
},
{
name: "fulfil carrying no result",
tx: &Transaction{Type: TxFulfillDecrypt, Nonce: 1, Subject: handle,
Payload: mustJSON(t, FulfillPayload{})},
want: ErrInvalidPayload,
},
} {
t.Run(tc.name, func(t *testing.T) {
require.ErrorIs(t, tc.tx.SyntacticVerify(), tc.want)
})
}
}
// TestSyntacticVerify_AdvanceRejectsUnusableCommittee proves an epoch proposal
// carries the whole committee check with it, so a committee that could never
// sign cannot be voted in.
func TestSyntacticVerify_AdvanceRejectsUnusableCommittee(t *testing.T) {
good, _ := newCommittee(t, 3)
pk := []byte("next-network-key")
advance := func(c []fhe.CommitteeMember, threshold int, key []byte) *Transaction {
return &Transaction{
Type: TxAdvanceEpoch, Nonce: 1,
Subject: committeeDigest(1, threshold, key, c),
Payload: mustJSON(t, AdvancePayload{Epoch: 1, Committee: c, Threshold: threshold, PublicKey: key}),
}
}
require.NoError(t, advance(good, 2, pk).SyntacticVerify())
require.ErrorIs(t, advance(nil, 1, pk).SyntacticVerify(), ErrInvalidCommittee)
require.ErrorIs(t, advance(good, 0, pk).SyntacticVerify(), ErrInvalidThreshold)
require.ErrorIs(t, advance(good, 4, pk).SyntacticVerify(), ErrInvalidThreshold)
require.ErrorIs(t, advance(good, 2, nil).SyntacticVerify(), ErrInvalidCommittee)
// Out of canonical order: two members proposing the same set would hash it
// differently and never agree, so the order is part of the value.
shuffled := append([]fhe.CommitteeMember(nil), good...)
shuffled[0], shuffled[2] = shuffled[2], shuffled[0]
require.ErrorIs(t, advance(shuffled, 2, pk).SyntacticVerify(), ErrInvalidCommittee)
// A member whose key cannot be parsed could never attest — installing it
// would wedge the chain, because a committee that cannot speak also cannot
// be replaced.
unusable := append([]fhe.CommitteeMember(nil), good...)
unusable[1].PublicKey = []byte("not-an-mldsa-65-key")
require.ErrorIs(t, advance(unusable, 2, pk).SyntacticVerify(), ErrInvalidCommittee)
// A proposal whose subject does not hash its own contents is refused, so
// the signature always covers the committee actually being voted for.
mismatched := advance(good, 2, pk)
mismatched.Subject = [32]byte{0xff}
require.ErrorIs(t, mismatched.SyntacticVerify(), ErrHandleMismatch)
}
// TestAuthenticate proves the payer authentication path: an unsigned, a
// tampered, and an impersonating transaction are each refused, and only a
// genuine signature by the claimed identity passes.
func TestAuthenticate(t *testing.T) {
k := newTestKey(t)
other := newTestKey(t)
require.NoError(t, registerTx(t, k, testScheme, digestOf("auth"), 1).authenticate(testChainID))
unsigned := registerTx(t, k, testScheme, digestOf("auth"), 1)
unsigned.Auth, unsigned.Sig = nil, nil
require.ErrorIs(t, unsigned.authenticate(testChainID), ErrUnsignedTx)
tampered := registerTx(t, k, testScheme, digestOf("auth"), 1)
tampered.Nonce = 7
require.ErrorIs(t, tampered.authenticate(testChainID), ErrBadSignature)
// Signed by `other` but claiming k's address: the address is derived from
// the attached public key, so the two cannot be made to agree.
impersonating := registerTx(t, k, testScheme, digestOf("auth"), 1)
other.sign(t, impersonating)
require.ErrorIs(t, impersonating.authenticate(testChainID), ErrPayerMismatch)
// Auth bytes that are not a public key at all.
garbage := registerTx(t, k, testScheme, digestOf("auth"), 1)
garbage.Auth = []byte("garbage")
garbage.Payer = addressOf(garbage.Auth)
require.Error(t, garbage.authenticate(testChainID))
}
// TestAddressDerivationIsStable proves a payer's address and a committee
// member's address come from the same one-way derivation, so membership and
// payer identity cannot disagree.
func TestAddressDerivationIsStable(t *testing.T) {
k := newTestKey(t)
require.Equal(t, k.addr, addressOf(k.pub))
members, keys := newCommittee(t, 3)
rec := &EpochRecord{EpochInfo: fhe.EpochInfo{Committee: members}}
for i, key := range keys {
require.Truef(t, rec.memberOf(key.addr), "member %d must be recognised by its payer address", i)
}
require.False(t, rec.memberOf(newTestKey(t).addr), "a stranger is not a member")
}
// TestIdentifiersAreDeterministic proves every id F derives comes from signed
// transaction fields alone, so two validators applying one block agree — and
// that changing any input changes the id.
func TestIdentifiersAreDeterministic(t *testing.T) {
d := digestOf("determinism")
a, b := newTestKey(t).addr, newTestKey(t).addr
require.Equal(t, deriveHandle(d, testScheme), deriveHandle(d, testScheme))
require.NotEqual(t, deriveHandle(d, testScheme), deriveHandle(d, "bfv-n13"))
require.NotEqual(t, deriveHandle(d, testScheme), deriveHandle(digestOf("other"), testScheme))
h := deriveHandle(d, testScheme)
require.Equal(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, a, b, 1, 0, 1))
require.NotEqual(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, a, b, 1, 0, 2),
"a grantor may re-grant the same capability without colliding")
require.NotEqual(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, b, a, 1, 0, 1))
require.Equal(t, deriveRequestID(h, a, 3), deriveRequestID(h, a, 3))
require.NotEqual(t, deriveRequestID(h, a, 3), deriveRequestID(h, a, 4))
require.NotEqual(t, deriveRequestID(h, a, 3), deriveRequestID(h, b, 3))
}
// TestCommitteeDigestIsSemantic proves the value members attest describes the
// PROPOSAL, not the bytes one client happened to encode: it changes with every
// meaningful field and with the members themselves.
func TestCommitteeDigestIsSemantic(t *testing.T) {
c, _ := newCommittee(t, 3)
pk := []byte("network-key")
base := committeeDigest(1, 2, pk, c)
require.Equal(t, base, committeeDigest(1, 2, pk, c))
require.NotEqual(t, base, committeeDigest(2, 2, pk, c), "epoch must bind")
require.NotEqual(t, base, committeeDigest(1, 3, pk, c), "threshold must bind")
require.NotEqual(t, base, committeeDigest(1, 2, []byte("other"), c), "network key must bind")
fewer := c[:2]
require.NotEqual(t, base, committeeDigest(1, 2, pk, fewer), "membership must bind")
// Length-prefixing means a member's fields cannot be re-split across the
// boundary to forge a colliding digest.
shifted := append([]fhe.CommitteeMember(nil), c...)
shifted[0].PublicKey = append(append([]byte(nil), c[0].PublicKey...), c[1].PublicKey...)
require.NotEqual(t, base, committeeDigest(1, 2, pk, shifted))
}
// TestTallyCountsDistinctMembers proves a threshold decision counts members,
// not messages: one member repeating itself never raises a count, and votes for
// different values are counted apart.
func TestTallyCountsDistinctMembers(t *testing.T) {
a, b := newTestKey(t).addr, newTestKey(t).addr
x, y := [32]byte{1}, [32]byte{2}
// vote() is the only writer, and it replaces rather than appends, so one
// member holds exactly one entry however often it votes.
var as []Attestation
as = vote(as, a, x)
as = vote(as, a, x)
require.Len(t, as, 1, "a repeated member holds one entry")
require.Equal(t, 1, tally(as, x), "and counts once")
as = vote(as, b, x)
require.Equal(t, 2, tally(as, x))
require.Zero(t, tally(as, y), "votes for another value do not count")
// Moving a vote moves it: the old value keeps nothing.
as = vote(as, a, y)
require.Len(t, as, 2, "still one entry per member")
require.Equal(t, 1, tally(as, x))
require.Equal(t, 1, tally(as, y))
// And the count is over DISTINCT members whatever the slice holds, so a
// record that somehow carried a duplicate could not inflate a threshold.
require.Equal(t, 1, tally([]Attestation{{Member: a, Value: x}, {Member: a, Value: x}}, x))
}
// TestEffectDistinguishesOperations proves the in-flight uniqueness key names
// the right thing: two votes by one member on one decision collide, votes by
// different members do not, and two grants by one grantor do not.
func TestEffectDistinguishesOperations(t *testing.T) {
k := newTestKey(t)
other := newTestKey(t)
req := [32]byte{7}
// One member cannot have two attestations to one request in flight, even
// naming different results — the effect is the vote, not the value.
v1 := fulfillTx(t, k, req, [32]byte{1}, 1)
v2 := fulfillTx(t, k, req, [32]byte{2}, 2)
require.Equal(t, v1.effect(), v2.effect())
// A different member voting on the same request is a different effect.
v3 := fulfillTx(t, other, req, [32]byte{1}, 1)
require.NotEqual(t, v1.effect(), v3.effect())
// Registering two different ciphertexts are different effects; registering
// the same one twice is the same effect.
r1 := registerTx(t, k, testScheme, digestOf("one"), 1)
r2 := registerTx(t, k, testScheme, digestOf("two"), 2)
r1again := registerTx(t, other, testScheme, digestOf("one"), 1)
require.NotEqual(t, r1.effect(), r2.effect())
require.Equal(t, r1.effect(), r1again.effect(), "the handle is the effect, whoever claims it")
// Two grants over the same handle by the same grantor are distinct — the
// nonce distinguishes the permits they create.
handle := deriveHandle(digestOf("g"), testScheme)
g1 := grantTx(t, k, handle, other.addr, fhe.PermitOpDecrypt, 0, 1)
g2 := grantTx(t, k, handle, other.addr, fhe.PermitOpDecrypt, 0, 2)
require.NotEqual(t, g1.effect(), g2.effect())
// An epoch advance is a vote on the DECISION, and exactly one epoch is ever
// open, so one member's two votes are one effect however they differ. The
// proposal must not enter the effect: if it did, a member could vote twice,
// both votes would pass Verify against committed state, and Accept would
// apply one and refuse the other — a block every validator certifies and no
// validator can apply.
cA, _ := newCommittee(t, 3)
cB, _ := newCommittee(t, 3)
a1 := advanceTx(t, k, 1, cA, 2, []byte("k"), 1)
a2 := advanceTx(t, k, 1, cB, 2, []byte("k"), 2)
require.NotEqual(t, a1.Subject, a2.Subject, "two genuinely different proposals")
require.Equal(t, a1.effect(), a2.effect(), "but one member, one vote")
// A different member voting on the same decision is a different effect.
a3 := advanceTx(t, other, 1, cA, 2, []byte("k"), 1)
require.NotEqual(t, a1.effect(), a3.effect())
// A vote and an attestation never collide across operation kinds.
require.NotEqual(t, a1.effect(), v1.effect())
}
// TestTransactionIDIsContentHash proves the id is the hash of the canonical
// wire, so it changes with any field and cannot be chosen by the sender.
func TestTransactionIDIsContentHash(t *testing.T) {
k := newTestKey(t)
tx := registerTx(t, k, testScheme, digestOf("id"), 1)
first := tx.ID()
require.NotEqual(t, ids.Empty, first)
require.Equal(t, first, tx.ID(), "ID is stable once computed")
other := registerTx(t, k, testScheme, digestOf("id"), 2)
require.NotEqual(t, first, other.ID())
}
// TestSyntacticVerifyRefusesEveryMalformedPayload walks each operation's payload
// rules. They are the checks that run on an UNAUTHENTICATED transaction, so
// each one is a refusal that costs nothing — and each is a field an attacker
// chooses, which is why none of them may be optional.
func TestSyntacticVerifyRefusesEveryMalformedPayload(t *testing.T) {
digest := digestOf("subject")
handle := deriveHandle(digest, testScheme)
sound := RegisterPayload{Digest: digest, Type: 4, Level: 3, Size: 4096}
for name, tc := range map[string]struct {
tx *Transaction
want error
}{
"register: undecodable": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: []byte("{not json")}, ErrInvalidPayload},
"register: no digest": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: deriveHandle([32]byte{}, testScheme),
Payload: mustJSON(nil, RegisterPayload{Type: 4, Level: 3, Size: 4096})}, ErrInvalidPayload},
"register: zero size": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: mustJSON(nil, RegisterPayload{Digest: digest, Type: 4, Level: 3})}, ErrInvalidPayload},
"register: size beyond anything servable": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: mustJSON(nil, RegisterPayload{Digest: digest, Type: 4, Level: 3, Size: MaxCiphertextSize + 1})}, ErrInvalidPayload},
"register: negative level": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: mustJSON(nil, RegisterPayload{Digest: digest, Type: 4, Level: -1, Size: 4096})}, ErrInvalidPayload},
"register: subject is not the handle": {
&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1, Subject: [32]byte{9},
Payload: mustJSON(nil, sound)}, ErrHandleMismatch},
"grant: undecodable": {
&Transaction{Type: TxGrantPermit, Nonce: 1, Payload: []byte("{")}, ErrInvalidPayload},
"grant: confers nothing": {
&Transaction{Type: TxGrantPermit, Nonce: 1,
Payload: mustJSON(nil, GrantPayload{Operations: 0})}, ErrInvalidPayload},
"grant: unknown capability bits": {
&Transaction{Type: TxGrantPermit, Nonce: 1,
Payload: mustJSON(nil, GrantPayload{Operations: 1 << 31})}, ErrInvalidPayload},
"grant: negative expiry": {
&Transaction{Type: TxGrantPermit, Nonce: 1,
Payload: mustJSON(nil, GrantPayload{Operations: fhe.PermitOpDecrypt, Expiry: -1})}, ErrInvalidPayload},
"revoke: undecodable": {
&Transaction{Type: TxRevokePermit, Nonce: 1, Payload: []byte("[]")}, ErrInvalidPayload},
"request: undecodable": {
&Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Nonce: 1, Payload: []byte("{")}, ErrInvalidPayload},
"request: names no permit": {
&Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Nonce: 1,
Payload: mustJSON(nil, RequestPayload{})}, ErrInvalidPayload},
"request: negative expiry": {
&Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Nonce: 1,
Payload: mustJSON(nil, RequestPayload{PermitID: [32]byte{1}, Expiry: -1})}, ErrInvalidPayload},
"fulfill: undecodable": {
&Transaction{Type: TxFulfillDecrypt, Nonce: 1, Payload: []byte("{")}, ErrInvalidPayload},
"fulfill: no result": {
&Transaction{Type: TxFulfillDecrypt, Nonce: 1,
Payload: mustJSON(nil, FulfillPayload{})}, ErrInvalidPayload},
"advance: undecodable": {
&Transaction{Type: TxAdvanceEpoch, Nonce: 1, Payload: []byte("{")}, ErrInvalidPayload},
"a nonce below the first one": {
&Transaction{Type: TxRevokePermit, Nonce: 0,
Payload: mustJSON(nil, RevokePayload{})}, ErrBadNonce},
} {
t.Run(name, func(t *testing.T) {
require.ErrorIs(t, tc.tx.SyntacticVerify(), tc.want)
})
}
// The control: the well-formed shapes all pass.
require.NoError(t, (&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: mustJSON(nil, sound)}).SyntacticVerify())
require.NoError(t, (&Transaction{Type: TxRevokePermit, Nonce: 1,
Payload: mustJSON(nil, RevokePayload{})}).SyntacticVerify())
}
// TestCommitteeOrderIsPartOfTheValue proves a committee's members must arrive
// in canonical order and without repeats. Members hash the SET to vote on it,
// so two orderings of the same members would be two different proposals and the
// committee could never agree with itself.
func TestCommitteeOrderIsPartOfTheValue(t *testing.T) {
c, _ := newCommittee(t, 3)
shuffled := []fhe.CommitteeMember{c[2], c[0], c[1]}
require.ErrorIs(t, ValidateCommittee(shuffled, 2, []byte("pk")), ErrInvalidCommittee)
repeated := []fhe.CommitteeMember{c[0], c[0], c[1]}
require.ErrorIs(t, ValidateCommittee(repeated, 2, []byte("pk")), ErrInvalidCommittee,
"one seat twice is not two seats")
require.NoError(t, ValidateCommittee(c, 2, []byte("pk")), "the control")
}
// TestApplyFailsClosedOnWhatItCannotWrite proves every effect reports a failed
// write instead of returning success. These are the errors that abort a block
// whole: a validator that cannot write cannot proceed, and a transaction that
// silently did not apply would leave two validators holding different state.
func TestApplyFailsClosedOnWhatItCannotWrite(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
next, _ := newCommittee(t, 3)
now := vm.clock.Time().Unix()
require.NoError(t, vm.versdb.Close())
for name, apply := range map[string]func() error{
"register": func() error { return registerTx(t, owner, testScheme, digestOf("w"), 9).applyRegister(vm, now) },
"grant": func() error {
return grantTx(t, owner, handle, grantee.addr, fhe.PermitOpDecrypt, 0, 9).applyGrant(vm, now)
},
"revoke": func() error { return revokeTx(t, owner, permitID, 9).applyRevoke(vm) },
"request": func() error { return requestTx(t, grantee, testScheme, handle, permitID, 0, 9).applyRequest(vm, now) },
"fulfill": func() error { return fulfillTx(t, members[0], requestID, digestOf("r"), 9).applyFulfill(vm, now) },
"advance": func() error { return advanceTx(t, members[0], 1, next, 2, []byte("pk"), 9).applyAdvance(vm, now) },
} {
t.Run(name, func(t *testing.T) {
require.Error(t, apply())
})
}
}
// TestApplyRefusesAPayloadItCannotDecode proves each effect re-derives its
// arguments from the payload and fails closed rather than applying a zero
// value. SyntacticVerify has already accepted the payload by the time a block
// carries it, so this is the check behind the check — but a check that only
// ever runs behind another is one nobody can show still works, so it is shown
// here directly.
func TestApplyRefusesAPayloadItCannotDecode(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
now := vm.clock.Time().Unix()
junk := func(typ uint8, subject [32]byte) *Transaction {
return &Transaction{Type: typ, Scheme: testScheme, Payer: owner.addr, Subject: subject,
Nonce: 9, Payload: []byte("{not json")}
}
require.Error(t, junk(TxRegisterCiphertext, handle).applyRegister(vm, now))
require.Error(t, junk(TxGrantPermit, handle).applyGrant(vm, now))
require.Error(t, junk(TxRequestDecrypt, handle).applyRequest(vm, now))
require.Error(t, junk(TxFulfillDecrypt, requestID).applyFulfill(vm, now))
require.Error(t, junk(TxAdvanceEpoch, [32]byte{}).applyAdvance(vm, now))
// And each effect that names a record refuses when the record is not there.
missing := [32]byte{0xaa}
require.ErrorIs(t, revokeTx(t, owner, missing, 9).applyRevoke(vm), ErrPermitNotFound)
require.ErrorIs(t, fulfillTx(t, members[0], missing, digestOf("r"), 9).applyFulfill(vm, now), ErrRequestNotFound)
// A fulfilment whose epoch has been forgotten is refused rather than tallied
// against a threshold nobody can name.
rec, ok := vm.Decrypt(requestID)
require.True(t, ok)
rec.Epoch = 99
vm.stateLock.Lock()
require.NoError(t, vm.putDecrypt(&DecryptRecord{DecryptRequest: rec.DecryptRequest, PermitID: rec.PermitID}))
vm.stateLock.Unlock()
require.ErrorIs(t, fulfillTx(t, members[0], requestID, digestOf("r"), 9).applyFulfill(vm, now), ErrEpochNotFound)
}
// TestAnUnknownOperationIsRefused proves authorization fails closed on an
// operation it does not know, rather than falling through to an effect.
//
// Apply has the same default and it is UNREACHABLE: checkAuth runs first and
// refuses every type the switch below does not name, so nothing reaches it. It
// stays because the switch assigns err per case — without the default an
// unknown type would leave err nil and Apply would report the transaction
// APPLIED, which is the one answer that must never be given by accident.
func TestAnUnknownOperationIsRefused(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(k), committee, 1)
vm.stateLock.Lock()
defer vm.stateLock.Unlock()
alien := &Transaction{Type: 99, Payer: k.addr, Nonce: 1}
require.ErrorIs(t, alien.checkAuth(vm, 0), ErrInvalidTxType)
applied, err := alien.Apply(vm, 0)
require.NoError(t, err, "an unknown operation reverts; it does not halt the block")
require.False(t, applied)
}
// TestCheckAuthRefusesWhatItCannotResolve covers the authorization decisions
// that turn on a record being there. Each is a refusal rather than a default:
// authorization that fell through when it could not find what it was asked
// about would grant on ignorance.
func TestCheckAuthRefusesWhatItCannotResolve(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
now := vm.clock.Time().Unix()
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
// A request whose payload does not decode cannot be authorized against a
// permit it does not name.
malformed := &Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Payer: grantee.addr,
Subject: handle, Nonce: 2, Payload: []byte("{not json")}
require.ErrorIs(t, malformed.checkAuth(vm, now), ErrInvalidPayload)
// So does an epoch proposal.
badAdvance := &Transaction{Type: TxAdvanceEpoch, Payer: members[0].addr, Nonce: 2,
Payload: []byte("{not json")}
require.ErrorIs(t, badAdvance.checkAuth(vm, now), ErrInvalidPayload)
// A fulfilment for an epoch the chain has forgotten names no committee, so
// there is nobody it could be authorized as.
rec, ok := vm.decrypts[requestID]
require.True(t, ok)
held := rec.Epoch
rec.Epoch = 99
require.ErrorIs(t,
fulfillTx(t, members[0], requestID, digestOf("r"), 1).checkAuth(vm, now), ErrEpochNotFound)
rec.Epoch = held
}
// TestAdvanceReportsAWriteItCannotMake proves the epoch rotation reports a
// failed write at either of the two records it must put down — the epoch it
// closes and the epoch it opens. A rotation that half-happened would leave the
// chain with no sitting committee.
func TestAdvanceReportsAWriteItCannotMake(t *testing.T) {
vm, _, members := newDecryptVM(t, 3, 1) // one vote decides
next, _ := newCommittee(t, 3)
now := vm.clock.Time().Unix()
require.NoError(t, vm.versdb.Close())
require.Error(t, advanceTx(t, members[0], 1, next, 2, []byte("pk"), 1).applyAdvance(vm, now),
"a rotation that cannot be written did not happen")
}
// TestFulfilmentNeedsThePermitThatAskedForIt proves a committee cannot answer a
// request whose authorizing permit is no longer there. The permit is what makes
// the ask legitimate, so a request that outlived it authorizes nothing —
// otherwise a deleted grant would still deliver a plaintext to a callback.
func TestFulfilmentNeedsThePermitThatAskedForIt(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
now := vm.clock.Time().Unix()
fulfil := fulfillTx(t, members[0], requestID, digestOf("r"), 1)
vm.stateLock.RLock()
require.NoError(t, fulfil.checkAuth(vm, now), "the control: with the permit there, it is authorized")
vm.stateLock.RUnlock()
// Point the request at a permit nothing granted.
vm.stateLock.Lock()
vm.decrypts[requestID].PermitID = [32]byte{0xab}
vm.stateLock.Unlock()
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
require.ErrorIs(t, fulfil.checkAuth(vm, now), ErrPermitNotFound)
}