Bind every signer's commitment in the round-2 transcript

Hash walked the commitment map D by dense index 0..len(D)-1, but D is
keyed by party id. For any signer set that is not {0,1,...,k-1} that walk
read absent low indices and skipped the present high ones, so the parties
at the high indices could replace their round-1 commitment without moving
the digest that seeds u and the per-pair masks — a signer could equivocate
its commitment after the fact. Walk D by the agreed set T instead, which
is already written into the transcript, so every commitment is bound in a
fixed order.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-21 11:01:03 -07:00
co-authored by Hanzo Dev
parent 725c3ee3b9
commit 027508102b
2 changed files with 203 additions and 3 deletions
+8 -3
View File
@@ -205,9 +205,14 @@ func Hash(suite hash.HashSuite, A structs.Matrix[ring.Poly], b structs.Vector[ri
must("binary.Write(T-elem)", binary.Write(buf, binary.BigEndian, int32(t))) must("binary.Write(T-elem)", binary.Write(buf, binary.BigEndian, int32(t)))
} }
for i := 0; i < len(D); i++ { // Walk D by the agreed set T, not by dense index 0..len(D)-1. D is keyed by
_, err = D[i].WriteTo(buf) // party id, so a signer set that is not {0,1,...,k-1} would otherwise read
must(fmt.Sprintf("D[%d].WriteTo", i), err) // absent low indices and skip the present high ones, leaving those parties
// free to replace their round-1 commitment without moving the digest. T is
// already written above, so its order fixes the layout.
for _, id := range T {
_, err = D[id].WriteTo(buf)
must(fmt.Sprintf("D[%d].WriteTo", id), err)
} }
out := s.TranscriptHash(buf.Bytes()) out := s.TranscriptHash(buf.Bytes())
+195
View File
@@ -0,0 +1,195 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package primitives
import (
"bytes"
"testing"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
func bindingRing(t *testing.T) *ring.Ring {
t.Helper()
r, err := ring.NewRing(256, []uint64{8380417})
if err != nil {
t.Fatal(err)
}
return r
}
// markedCommitment builds a commitment matrix carrying one distinguishing
// coefficient, so a change to it is a change to the bytes the transcript
// should cover.
func markedCommitment(r *ring.Ring, mark uint64) structs.Matrix[ring.Poly] {
m := make(structs.Matrix[ring.Poly], 2)
for i := range m {
m[i] = make(structs.Vector[ring.Poly], 2)
for j := range m[i] {
m[i][j] = r.NewPoly()
}
}
m[0][0].Coeffs[0][0] = mark
return m
}
// TestHashBindsEveryCommitment is the binding property of the round-2
// transcript: the digest that seeds u and the per-pair masks must cover the
// round-1 commitment of every party in the agreed set.
//
// Hash walks D by dense index 0..len(D)-1 instead of by the agreed set T. For
// a signer set that is not {0, 1, ..., k-1} that walk reads absent entries and
// skips present ones, so the parties at the high indices can replace their
// commitments freely without moving the digest.
func TestHashBindsEveryCommitment(t *testing.T) {
r := bindingRing(t)
A := make(structs.Matrix[ring.Poly], 2)
for i := range A {
A[i] = structs.Vector[ring.Poly]{r.NewPoly(), r.NewPoly()}
}
b := structs.Vector[ring.Poly]{r.NewPoly(), r.NewPoly()}
for _, T := range [][]int{
{0, 1, 2},
{2, 3, 4},
{0, 2, 4},
{5, 6, 7},
} {
t.Run("", func(t *testing.T) {
base := map[int]structs.Matrix[ring.Poly]{}
for i, id := range T {
base[id] = markedCommitment(r, uint64(i+1))
}
baseline := Hash(nil, A, b, base, 1, T)
for _, id := range T {
altered := map[int]structs.Matrix[ring.Poly]{}
for k, v := range base {
altered[k] = v
}
altered[id] = markedCommitment(r, 0xDEADBEEF)
if bytes.Equal(baseline, Hash(nil, A, b, altered, 1, T)) {
t.Errorf("signer set %v: replacing the commitment of party %d leaves the transcript unchanged", T, id)
}
}
})
}
}
// TestHashBindsTheSessionAndTheSignerSet covers the other two transcript
// inputs. Two sessions, or two signer sets, must never share a digest — the
// digest is what a replayed partial response would have to match.
func TestHashBindsTheSessionAndTheSignerSet(t *testing.T) {
r := bindingRing(t)
A := structs.Matrix[ring.Poly]{{*r.NewPoly().CopyNew()}}
b := structs.Vector[ring.Poly]{r.NewPoly()}
D := map[int]structs.Matrix[ring.Poly]{0: markedCommitment(r, 1), 1: markedCommitment(r, 2)}
baseline := Hash(nil, A, b, D, 1, []int{0, 1})
if bytes.Equal(baseline, Hash(nil, A, b, D, 2, []int{0, 1})) {
t.Error("the transcript does not bind the session id")
}
if bytes.Equal(baseline, Hash(nil, A, b, D, 1, []int{1, 0})) {
t.Error("the transcript does not bind the order of the signer set")
}
if bytes.Equal(baseline, Hash(nil, A, b, D, 1, []int{0, 1, 2})) {
t.Error("the transcript does not bind the size of the signer set")
}
}
// TestHashBindsThePublicKey — the transcript names the key the round is
// signing under. Two eras that share a digest would let a partial response
// from one aggregate under the other.
func TestHashBindsThePublicKey(t *testing.T) {
r := bindingRing(t)
D := map[int]structs.Matrix[ring.Poly]{0: markedCommitment(r, 1)}
T := []int{0}
mkA := func(mark uint64) structs.Matrix[ring.Poly] {
p := r.NewPoly()
p.Coeffs[0][0] = mark
return structs.Matrix[ring.Poly]{{p}}
}
mkB := func(mark uint64) structs.Vector[ring.Poly] {
p := r.NewPoly()
p.Coeffs[0][0] = mark
return structs.Vector[ring.Poly]{p}
}
baseline := Hash(nil, mkA(1), mkB(1), D, 1, T)
if bytes.Equal(baseline, Hash(nil, mkA(2), mkB(1), D, 1, T)) {
t.Error("the transcript does not bind the public matrix A")
}
if bytes.Equal(baseline, Hash(nil, mkA(1), mkB(2), D, 1, T)) {
t.Error("the transcript does not bind the rounded public key b")
}
}
// TestDeriveSessionIDIsInjectiveOverItsInputs — the session identifier is the
// deterministic half of the nonce key. Two distinct (sid, T) that mapped to
// one identifier would put two signatures on one nonce stream whenever the
// hedge salt is pinned, which is exactly what the KAT harnesses do.
func TestDeriveSessionIDIsInjectiveOverItsInputs(t *testing.T) {
seen := map[[SessionIDSize]byte]string{}
record := func(label string, id [SessionIDSize]byte) {
if prev, dup := seen[id]; dup {
t.Errorf("%s and %s derive the same session identifier", prev, label)
}
seen[id] = label
}
for _, c := range []struct {
label string
sid int
T []int
}{
{"sid=0 T={}", 0, nil},
{"sid=0 T={0}", 0, []int{0}},
{"sid=1 T={0}", 1, []int{0}},
{"sid=0 T={1}", 0, []int{1}},
{"sid=0 T={0,1}", 0, []int{0, 1}},
{"sid=0 T={1,0}", 0, []int{1, 0}},
{"sid=1 T={0,1}", 1, []int{0, 1}},
{"sid=-1 T={0}", -1, []int{0}},
} {
record(c.label, DeriveSessionID(nil, c.sid, c.T))
}
}
// TestGenerateMACBindsTheDirection — the tag party i sends to party j must not
// be the tag j sends to i, or a peer can reflect a tag back and pass the gate
// under the shared pairwise key.
func TestGenerateMACBindsTheDirection(t *testing.T) {
r := bindingRing(t)
D := markedCommitment(r, 7)
key := bytes.Repeat([]byte{0xA5}, 32)
T := []int{0, 1}
iToJ := GenerateMAC(nil, D, key, 0, 1, T, 1, false)
jVerifies := GenerateMAC(nil, D, key, 1, 1, T, 0, true)
if !bytes.Equal(iToJ, jVerifies) {
t.Fatal("positive control failed: the sender and the verifier disagree on the tag")
}
reflected := GenerateMAC(nil, D, key, 1, 1, T, 0, false)
if bytes.Equal(iToJ, reflected) {
t.Error("the tag i sends to j equals the tag j sends to i")
}
if bytes.Equal(iToJ, GenerateMAC(nil, D, key, 0, 2, T, 1, false)) {
t.Error("the tag does not bind the session id")
}
if bytes.Equal(iToJ, GenerateMAC(nil, D, key, 0, 1, []int{0, 1, 2}, 1, false)) {
t.Error("the tag does not bind the signer set")
}
if bytes.Equal(iToJ, GenerateMAC(nil, markedCommitment(r, 8), key, 0, 1, T, 1, false)) {
t.Error("the tag does not bind the commitment")
}
if bytes.Equal(iToJ, GenerateMAC(nil, D, bytes.Repeat([]byte{0x5A}, 32), 0, 1, T, 1, false)) {
t.Error("the tag does not bind the pairwise key")
}
}