mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user