bridgeattest: the attested message for a security crossing

A fungible transfer says move N units of an asset to an account. A
security crossing cannot be said that way: the destination has to run
the register before the position exists there — whether the recipient is
verified, what act this is (an arrival is not a mint), and whose clock
the position brings with it.

None of that fits BridgeTransfer, whose layout is frozen and whose
Amount is a uint64 — too small for an eighteen-decimal position above
about 18.4 tokens. So SecurityCrossing is a sibling message with its own
domain tag rather than a widening of that one. The frozen digest stays
frozen, and a fungible attestation can never be replayed as a security
one because the tag they commit to differs in the first bytes of the
preimage. That property is asserted, not assumed.

Two fields carry what the destination cannot ask the source for:

  Identity  the holder's ONCHAINID on the SOURCE register. The register's
            clocks belong to the person, not the key, and the
            destination has no way to look it up.
  Acquired  when the holder first received on the source. Without it an
            arriving position anchors to now, and a holding period a
            crossing resets is a holding period a crossing washes off.

Both are zero where the source had none — an honest absence the
destination weighs as it likes, rather than a default that reads as an
identity of zero.

Quantity is 32 bytes big-endian for the full uint256.

VerifyAgainst takes the expected group key as an argument, following the
fix already recorded on the fungible verifier: reading the key off the
struct being verified answers "was this signed by whoever signed it",
which an attacker's own key satisfies. Asserted here too.

Every field is bound — each of the seven, mutated one at a time, stops
the signature verifying.
This commit is contained in:
2026-09-06 12:30:49 -07:00
parent 77fa2901e2
commit e081ccef5f
2 changed files with 348 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgeattest
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"github.com/luxfi/crypto/secp256k1"
)
// security.go — the attested message for a security crossing.
//
// A fungible transfer says move N units of an asset to an account. A security
// crossing cannot be said that way. The destination has to run the register
// before the position exists there: whether the recipient is verified, which
// claims their identity carries, what act this is (an arrival is not a mint),
// and whose clock the position brings with it. None of that fits BridgeTransfer,
// whose field layout is frozen and whose Amount is a uint64 — too small to hold
// an eighteen-decimal position above about 18.4 tokens.
//
// So this is a sibling message with its own domain tag rather than a widening
// of that one. Two consequences, both wanted: the frozen digest stays frozen,
// and a fungible attestation can never be replayed as a security one, because
// the tag they commit to differs in the first bytes of the preimage.
// SecurityDomainTag separates a security crossing from every other message M
// signs. Written raw, as the first bytes of the preimage, exactly as DomainTag
// is.
const SecurityDomainTag = "LUX_SECURITY_CROSSING_v1"
// SecurityCrossing is the domain-bound message B commits to when a security
// leaves one register, and M threshold-signs as its attestation. Field layout is
// FROZEN, for the same reason BridgeTransfer's is: the digest has to be
// identical across validators and across the B/M/Solidity boundary.
//
// One attestation authorises exactly one arrival — of that quantity, of that
// security, to that holder, on that route, at that nonce — and nothing else.
type SecurityCrossing struct {
SrcChainID uint32 `json:"srcChainId"` // source EVM chain id
DstChainID uint32 `json:"dstChainId"` // destination EVM chain id
// Security is the canonical cross-chain id of the security itself, not the
// token contract on either side. The same paper has a different address on
// every register it appears on.
Security [32]byte `json:"security"`
// Quantity is 32 bytes big-endian, holding the full uint256 the token
// moves. A security's smallest unit is the register's business and an
// eighteen-decimal position overflows a uint64 at about 18.4 tokens, so a
// crossing that could not carry more than that would be a crossing for
// nothing.
Quantity [32]byte `json:"quantity"`
// Holder is the account the position lands on.
Holder [20]byte `json:"holder"`
// Identity is the holder's ONCHAINID on the SOURCE register.
//
// It travels because the register's clocks belong to the person and not to
// the key: a holding period that starts again on arrival is a holding
// period a crossing can wash off, and the destination cannot ask the source
// register anything. Zero where the source had no identity registered — an
// honest absence, which the destination weighs as it likes rather than
// reading as an identity of zero.
Identity [20]byte `json:"identity"`
// Acquired is when the holder first received on the SOURCE register, in
// seconds. The destination anchors the arriving position's clock to it
// rather than to now, so a §144 holding period or a Reg CF year is served
// once by the holder and not once per chain they visit. Zero means the
// source had no anchor to send.
Acquired uint64 `json:"acquired"`
// Nonce is the per-route monotonic replay guard.
Nonce uint64 `json:"nonce"`
}
// Digest is the canonical, domain-separated signing preimage for a crossing.
//
// sha256(
// "LUX_SECURITY_CROSSING_v1" // 24-byte ASCII tag, raw
// || uint32_BE(SrcChainID) // 4
// || uint32_BE(DstChainID) // 4
// || Security[32] // 32
// || Quantity[32] // 32, big-endian uint256
// || Holder[20] // 20
// || Identity[20] // 20
// || uint64_BE(Acquired) // 8
// || uint64_BE(Nonce) // 8
// )
//
// abi.encodePacked(bytes(SecurityDomainTag), uint32, uint32, bytes32, uint256,
// bytes20, bytes20, uint64, uint64) on the Solidity side produces the identical
// preimage.
func (sc SecurityCrossing) Digest() [32]byte {
h := sha256.New()
h.Write([]byte(SecurityDomainTag))
var b [8]byte
binary.BigEndian.PutUint32(b[:4], sc.SrcChainID)
h.Write(b[:4])
binary.BigEndian.PutUint32(b[:4], sc.DstChainID)
h.Write(b[:4])
h.Write(sc.Security[:])
h.Write(sc.Quantity[:])
h.Write(sc.Holder[:])
h.Write(sc.Identity[:])
binary.BigEndian.PutUint64(b[:], sc.Acquired)
h.Write(b[:])
binary.BigEndian.PutUint64(b[:], sc.Nonce)
h.Write(b[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// SecurityAttestation is M's threshold signature over a crossing, plus the
// context B needs to verify it without re-querying M.
type SecurityAttestation struct {
Crossing SecurityCrossing `json:"crossing"`
Digest [32]byte `json:"digest"`
Signature []byte `json:"signature"` // secp256k1 r||s||v
GroupPubKey []byte `json:"groupPubKey"` // 33-byte compressed group key
Signers []string `json:"signers"` // party ids of the T+1 quorum
KeyID string `json:"keyId"`
// CeremonyID is this attestation's entry in M-Chain's replicated ceremony
// log — the handle that turns "B was handed a signature" into "B can point
// at the consensus record that produced it".
CeremonyID string `json:"ceremonyId"`
CreatedAt int64 `json:"createdAt"`
}
// VerifyAgainst returns true iff the signature is a valid threshold signature,
// by the EXPECTED group key, over this crossing's domain-bound digest.
//
// The expected key is an argument because it is the whole check — reading the
// key off the struct being verified answers "was this signed by whoever signed
// it", which every attestation satisfies, including one an attacker minted with
// its own key. See VerifyAgainst on Attestation, where that was a real defect.
func (a *SecurityAttestation) VerifyAgainst(expectedGroupKey []byte) bool {
if a == nil || len(expectedGroupKey) == 0 {
return false
}
if a.Digest != a.Crossing.Digest() {
return false
}
if len(a.GroupPubKey) != 0 && !bytes.Equal(a.GroupPubKey, expectedGroupKey) {
return false
}
return VerifySecurityAttestation(expectedGroupKey, a.Crossing, a.Signature)
}
// VerifySecurityAttestation returns true iff sig is a valid ECDSA signature by
// groupPubKey over the crossing's domain-bound digest. Accepts r||s (64) or
// r||s||v (65). A threshold signature verifies exactly like a single-key one, so
// there is no interaction with M.
func VerifySecurityAttestation(groupPubKey []byte, sc SecurityCrossing, sig []byte) bool {
if len(sig) == 65 {
sig = sig[:64]
}
if len(sig) != 64 || len(groupPubKey) == 0 {
return false
}
d := sc.Digest()
return secp256k1.VerifySignature(groupPubKey, d[:], sig)
}
+180
View File
@@ -0,0 +1,180 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgeattest
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"math/big"
"testing"
"github.com/luxfi/crypto/secp256k1"
)
func katCrossing() SecurityCrossing {
var security [32]byte
copy(security[:], bytes.Repeat([]byte{0xAB}, 32))
var quantity [32]byte
// 1000 units at eighteen decimals — past what a uint64 Amount could hold,
// which is one of the reasons this message exists.
new(big.Int).Mul(big.NewInt(1000), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)).FillBytes(quantity[:])
var holder, identity [20]byte
copy(holder[:], bytes.Repeat([]byte{0x11}, 20))
copy(identity[:], bytes.Repeat([]byte{0x22}, 20))
return SecurityCrossing{
SrcChainID: 96369,
DstChainID: 36963,
Security: security,
Quantity: quantity,
Holder: holder,
Identity: identity,
Acquired: 1_700_000_000,
Nonce: 1,
}
}
// The preimage, built here by hand rather than by calling the method under
// test. A digest test that computes the digest the same way the code does
// proves the code agrees with itself.
func TestSecurityDigestIsTheStatedPreimage(t *testing.T) {
sc := katCrossing()
h := sha256.New()
h.Write([]byte("LUX_SECURITY_CROSSING_v1"))
var b [8]byte
binary.BigEndian.PutUint32(b[:4], 96369)
h.Write(b[:4])
binary.BigEndian.PutUint32(b[:4], 36963)
h.Write(b[:4])
h.Write(sc.Security[:])
h.Write(sc.Quantity[:])
h.Write(sc.Holder[:])
h.Write(sc.Identity[:])
binary.BigEndian.PutUint64(b[:], 1_700_000_000)
h.Write(b[:])
binary.BigEndian.PutUint64(b[:], 1)
h.Write(b[:])
if got, want := sc.Digest(), h.Sum(nil); !bytes.Equal(got[:], want) {
t.Fatalf("digest drift:\n got %x\nwant %x", got, want)
}
t.Logf("security crossing KAT digest: %s", hex.EncodeToString(func() []byte { d := sc.Digest(); return d[:] }()))
}
// The property the separate tag exists for. A fungible transfer and a security
// crossing that agree on every field they share must still produce different
// digests, or an attestation authorising one would authorise the other — and a
// security would arrive somewhere having run no register at all.
func TestAFungibleAttestationCannotBeReplayedAsASecurityOne(t *testing.T) {
sc := katCrossing()
var amount [32]byte
binary.BigEndian.PutUint64(amount[24:], 1_000_000)
bt := BridgeTransfer{
SrcChainID: sc.SrcChainID,
DstChainID: sc.DstChainID,
Asset: sc.Security,
Amount: 1_000_000,
Recipient: sc.Holder,
Nonce: sc.Nonce,
}
twin := sc
twin.Quantity = amount
twin.Identity = [20]byte{}
twin.Acquired = 0
if bt.Digest() == twin.Digest() {
t.Fatal("a transfer and a crossing over the same facts produced one digest")
}
// And the verifiers refuse each other's signatures outright.
key, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
d := bt.Digest()
sig, err := secp256k1.Sign(d[:], key.Bytes())
if err != nil {
t.Fatalf("sign: %v", err)
}
pub := key.PublicKey().CompressedBytes()
if !VerifyBridgeAttestation(pub, bt, sig) {
t.Fatal("control: the transfer signature does not verify as a transfer")
}
if VerifySecurityAttestation(pub, twin, sig) {
t.Fatal("a transfer signature authorised a security crossing")
}
}
func TestSecurityVerifyRoundTrip(t *testing.T) {
sc := katCrossing()
d := sc.Digest()
key, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
sig, err := secp256k1.Sign(d[:], key.Bytes())
if err != nil {
t.Fatalf("sign: %v", err)
}
pub := key.PublicKey().CompressedBytes()
a := &SecurityAttestation{Crossing: sc, Digest: d, Signature: sig, GroupPubKey: pub}
if !a.VerifyAgainst(pub) {
t.Fatal("a well-formed attestation did not verify")
}
// Every field is bound: change any one and the signature stops meaning it.
for name, mutate := range map[string]func(*SecurityCrossing){
"quantity": func(c *SecurityCrossing) { c.Quantity[31] ^= 1 },
"holder": func(c *SecurityCrossing) { c.Holder[19] ^= 1 },
"identity": func(c *SecurityCrossing) { c.Identity[19] ^= 1 },
"acquired": func(c *SecurityCrossing) { c.Acquired++ },
"nonce": func(c *SecurityCrossing) { c.Nonce++ },
"route": func(c *SecurityCrossing) { c.DstChainID++ },
"security": func(c *SecurityCrossing) { c.Security[31] ^= 1 },
} {
moved := sc
mutate(&moved)
bad := &SecurityAttestation{Crossing: moved, Digest: moved.Digest(), Signature: sig, GroupPubKey: pub}
if bad.VerifyAgainst(pub) {
t.Fatalf("%s is not bound by the signature", name)
}
}
}
// The defect the fungible verifier already carries a comment about: reading the
// key off the struct being verified answers "was this signed by whoever signed
// it", which an attacker's own key satisfies.
func TestAnAttackersOwnKeyIsNotEvidence(t *testing.T) {
sc := katCrossing()
d := sc.Digest()
theirs, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
sig, err := secp256k1.Sign(d[:], theirs.Bytes())
if err != nil {
t.Fatalf("sign: %v", err)
}
forged := &SecurityAttestation{
Crossing: sc, Digest: d, Signature: sig,
GroupPubKey: theirs.PublicKey().CompressedBytes(),
}
ours, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
if forged.VerifyAgainst(ours.PublicKey().CompressedBytes()) {
t.Fatal("an attestation signed by a stranger verified against the group key")
}
if forged.VerifyAgainst(nil) {
t.Fatal("a nil expected key was treated as permission")
}
}