mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
The verifier looks up an attestation by node before it holds any claim, so the subject encoding has to be reachable without one. Exporting it keeps a single definition instead of every caller re-deriving the same 20-into-32 copy. Co-authored-by: Hanzo Dev <[email protected]>
218 lines
9.0 KiB
Go
218 lines
9.0 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package ownership is the ONE canonical definition of the NFT-ownership
|
|
// entitlement seam: M-Chain (mpcvm) threshold-signs the fact that an address
|
|
// holds a specific token of a specific collection and has bound that token to a
|
|
// specific node; a node's chain manager verifies that signature before it
|
|
// activates an entitlement-gated chain.
|
|
//
|
|
// The collection lives on an external EVM chain (the Lux Genesis collection is an
|
|
// ERC-721 on Ethereum mainnet). Nothing here moves, wraps or mirrors the token —
|
|
// the claim is a statement ABOUT the token, signed by a quorum, so the token
|
|
// stays the single source of truth and the attestation stays revocable and
|
|
// re-issuable. The reader of an attestation never touches Ethereum.
|
|
//
|
|
// Why this package exists rather than the encoding living in mpcvm: a node must
|
|
// verify an attestation WITHOUT importing the M-Chain VM, because M is a
|
|
// plugin-only VM that luxd must never link in-process. So the digest is a value
|
|
// seam, not a place — a pure function of the claim's field values, in one small
|
|
// package that both the M-Chain VM and the node import. Never duplicated, never
|
|
// able to drift.
|
|
//
|
|
// The package is deliberately dependency-free (stdlib + luxfi/crypto/secp256k1),
|
|
// so importing it costs a node nothing.
|
|
//
|
|
// The domain tag, field order, digest layout and signature encoding are FROZEN.
|
|
// The KAT vectors in ownership_test.go pin the bytes, so drift on either surface
|
|
// trips a test.
|
|
package ownership
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/crypto/secp256k1"
|
|
)
|
|
|
|
// Domain is the attestation domain this package defines. mpcvm registers it in
|
|
// its domain-separator registry under exactly this name.
|
|
const Domain = "nft/ownership"
|
|
|
|
// DomainTag is the domain-separation byte string hashed into every payload, so
|
|
// an ownership attestation can never be replayed as any other message M signs
|
|
// (oracle reads/writes, session-complete, epoch beacons, bridge transfers). It
|
|
// follows the registry's own convention, LUX:QuantumAttest:<domain>:v1, and is
|
|
// written raw (no length prefix) as the first bytes of the preimage.
|
|
//
|
|
// mpcvm MUST register this exact byte string for Domain; the shared constant is
|
|
// what makes that impossible to get wrong.
|
|
const DomainTag = "LUX:QuantumAttest:nft/ownership:v1"
|
|
|
|
var (
|
|
// ErrNoQuorum means fewer parties signed than the key's policy requires, so
|
|
// the signature cannot represent the committee's decision.
|
|
ErrNoQuorum = errors.New("ownership: signer count below quorum")
|
|
// ErrNoKey means no group key was supplied, so nothing can be verified.
|
|
ErrNoKey = errors.New("ownership: no group key")
|
|
// ErrBadSignature means the threshold signature does not verify against the
|
|
// group key over this claim's domain-bound payload.
|
|
ErrBadSignature = errors.New("ownership: signature does not verify")
|
|
// ErrWrongNode means the attestation is authentic but authorizes a DIFFERENT
|
|
// node. This is the reason a node may not borrow a peer's entitlement.
|
|
ErrWrongNode = errors.New("ownership: attestation is bound to a different node")
|
|
)
|
|
|
|
// Claim is the fact a quorum signs. Field layout is FROZEN so the digest is
|
|
// canonical across the M-Chain VM, every node, and any future non-Go verifier.
|
|
//
|
|
// One attestation over one Claim authorizes exactly one node — on the strength
|
|
// of exactly one token, of one collection, on one chain, as read at one block —
|
|
// and nothing else.
|
|
type Claim struct {
|
|
// Chain is the EVM chain id the collection lives on (1 = Ethereum mainnet).
|
|
Chain uint64 `json:"chain"`
|
|
// Collection is the ERC-721 contract address.
|
|
Collection [20]byte `json:"collection"`
|
|
// Token is the tokenId. For the Lux Genesis collection the token IS the
|
|
// validator slot.
|
|
Token uint64 `json:"token"`
|
|
// Owner is ownerOf(Token) as read at Block.
|
|
Owner [20]byte `json:"owner"`
|
|
// Node is the luxd node this entitlement binds to — the raw 20 bytes of its
|
|
// NodeID. The node proves nothing at verification time: it simply IS this
|
|
// identity, because a NodeID is a hash of the staking certificate it must
|
|
// hold to be that node at all.
|
|
Node [20]byte `json:"node"`
|
|
// Block is the block height the ownership read was taken at, so a claim is
|
|
// pinned to a point in the collection's history rather than "now".
|
|
Block uint64 `json:"block"`
|
|
}
|
|
|
|
// Root is the canonical commitment to a claim — the CommitmentRoot an M-Chain
|
|
// QuantumAttestation carries.
|
|
//
|
|
// sha256(
|
|
// "LUX:QuantumAttest:nft/ownership:v1" // ASCII tag, raw
|
|
// || uint64_BE(Chain) // 8
|
|
// || Collection[20] // 20
|
|
// || uint64_BE(Token) // 8
|
|
// || Owner[20] // 20
|
|
// || Node[20] // 20
|
|
// || uint64_BE(Block) // 8
|
|
// )
|
|
func (c Claim) Root() [32]byte {
|
|
h := sha256.New()
|
|
h.Write([]byte(DomainTag))
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], c.Chain)
|
|
h.Write(b[:])
|
|
h.Write(c.Collection[:])
|
|
binary.BigEndian.PutUint64(b[:], c.Token)
|
|
h.Write(b[:])
|
|
h.Write(c.Owner[:])
|
|
h.Write(c.Node[:])
|
|
binary.BigEndian.PutUint64(b[:], c.Block)
|
|
h.Write(b[:])
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// Subject is the attestation's SubjectID: WHO is entitled, as opposed to Root's
|
|
// WHAT was verified. It is the node's 20 NodeID bytes, left-aligned in 32.
|
|
//
|
|
// Keying the subject on the node is what makes equivocation meaningful: two
|
|
// attestations in this domain with the same subject and epoch but different
|
|
// roots are two conflicting ownership facts about one node, which is exactly the
|
|
// condition mpcvm's DetectEquivocation slashes.
|
|
//
|
|
// It is a package function as well as a method because a lookup starts from a
|
|
// node, before any claim is in hand. One encoding, both directions.
|
|
func Subject(node [20]byte) [32]byte {
|
|
var out [32]byte
|
|
copy(out[:], node[:])
|
|
return out
|
|
}
|
|
|
|
// Subject is this claim's SubjectID. See the package-level Subject.
|
|
func (c Claim) Subject() [32]byte { return Subject(c.Node) }
|
|
|
|
// Payload is the preimage a quorum signs. It MUST stay byte-identical to
|
|
// mpcvm.ComputeAttestationPayload for this domain — that function hashes the
|
|
// domain separator, then the subject, then the commitment root, then the epoch
|
|
// big-endian, and so does this.
|
|
func Payload(subject, root [32]byte, epoch uint64) [32]byte {
|
|
h := sha256.New()
|
|
h.Write([]byte(DomainTag))
|
|
h.Write(subject[:])
|
|
h.Write(root[:])
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], epoch)
|
|
h.Write(b[:])
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// Attestation is a quorum's signature over a Claim plus everything a verifier
|
|
// needs, so verification is zero-interaction: no callback to M-Chain, no
|
|
// Ethereum read, no network at all.
|
|
type Attestation struct {
|
|
Claim Claim `json:"claim"`
|
|
// Epoch binds the attestation in time; it is part of the signed payload.
|
|
Epoch uint64 `json:"epoch"`
|
|
// Signers is how many parties participated in the ceremony.
|
|
Signers int `json:"signers"`
|
|
// Quorum is the signing policy's K — the number of parties required. It
|
|
// travels with the attestation because a bare signature cannot say what
|
|
// threshold produced it.
|
|
Quorum int `json:"quorum"`
|
|
// Signature is secp256k1 r(32)||s(32)||v(1), the encoding every M-Chain
|
|
// ceremony artifact uses. r||s (64) is also accepted.
|
|
Signature []byte `json:"signature"`
|
|
// GroupKey is the compressed secp256k1 group public key. A verifier must
|
|
// obtain this from consensus state, never from the party being authorized —
|
|
// see the caller-side note on Authorizes.
|
|
GroupKey []byte `json:"groupKey"`
|
|
}
|
|
|
|
// Authorizes reports whether this attestation authorizes [node]. It answers
|
|
// authenticity AND binding in ONE call, and there is deliberately no exported
|
|
// way to ask only one of them: a caller that could check the signature and
|
|
// forget the binding would let any node activate on a peer's entitlement.
|
|
//
|
|
// Authenticity here means "a quorum of the holders of GroupKey signed this
|
|
// claim". It does NOT mean GroupKey is the right key — the caller must read
|
|
// GroupKey from consensus state the operator cannot edit. An attestation
|
|
// verified against an operator-supplied key proves nothing, because the operator
|
|
// can sign anything.
|
|
func (a *Attestation) Authorizes(node [20]byte) error {
|
|
if a == nil {
|
|
return ErrBadSignature
|
|
}
|
|
if a.Claim.Node != node {
|
|
return fmt.Errorf("%w: attested %x, asked about %x", ErrWrongNode, a.Claim.Node, node)
|
|
}
|
|
if a.Quorum <= 0 || a.Signers < a.Quorum {
|
|
return fmt.Errorf("%w: %d signers for a %d-party quorum", ErrNoQuorum, a.Signers, a.Quorum)
|
|
}
|
|
if len(a.GroupKey) == 0 {
|
|
return ErrNoKey
|
|
}
|
|
sig := a.Signature
|
|
if len(sig) == 65 {
|
|
sig = sig[:64] // drop the recovery id; VerifySignature wants r||s
|
|
}
|
|
if len(sig) != 64 {
|
|
return fmt.Errorf("%w: signature is %d bytes", ErrBadSignature, len(a.Signature))
|
|
}
|
|
payload := Payload(a.Claim.Subject(), a.Claim.Root(), a.Epoch)
|
|
if !secp256k1.VerifySignature(a.GroupKey, payload[:], sig) {
|
|
return ErrBadSignature
|
|
}
|
|
return nil
|
|
}
|