mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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.
618 lines
27 KiB
Go
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)
|
|
}
|