mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
First-principles cleanup: family names (Quasar, Annulus) and the Lux brand prefix belong in papers/docs, not Go identifiers. Canonical names: ProfileLuxStrictPQ -> ProfileStrictPQ ProfileLuxPermissive -> ProfilePermissive ProfileLuxFIPS -> ProfileFIPS QuasarStrictPQ() -> removed (was 0x06 byte-identical to 0x01) AnnulusStrictPQ() -> removed (was alias for the above) ProfileNameLuxStrictPQ etc. -> removed ProfileZooStrictPQ (0x04) and ProfileHanzoStrictPQ (0x05) kept — chain- specific siblings, not crypto family branding. Wire-format cSHAKE256 customization tags (LUX_PQ_PERMIT_V1 etc.) intentionally stay brand- prefixed as domain separators. IsPQ(), ProfileByID(), validatePolicy() switches updated. ProfileName strings drop LUX_ prefix: "LUX_STRICT_PQ" -> "STRICT_PQ". Golden ProfileHash in security_profile_e2e_pq_test.go re-pinned.
672 lines
25 KiB
Go
672 lines
25 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
)
|
|
|
|
// tx_envelope.go — the canonical TxAuthEnvelope plus its signing-digest,
|
|
// wire codec, and profile-gated verifier.
|
|
//
|
|
// One envelope, one verifier. Every transaction on a strict-PQ chain
|
|
// rides this struct. The signature covers TupleHash256 over every
|
|
// security-relevant field (customization "LUX_TX_AUTH_V1"), so any
|
|
// post-sign mutation of any bound field breaks signature verification
|
|
// at the ML-DSA layer — not just at the envelope-equality layer.
|
|
//
|
|
// Public surface:
|
|
//
|
|
// TxAuthEnvelope.SigningDigest() canonical 48-byte digest the
|
|
// wallet signs over.
|
|
// Marshal / UnmarshalTxAuthEnvelope canonical big-endian wire codec.
|
|
// Unmarshal refuses zero enums on
|
|
// security-relevant fields.
|
|
// VerifyTxAuthEnvelope profile-gated entry point. Refuses
|
|
// scheme drift, hash-suite drift,
|
|
// expired envelopes, and any
|
|
// non-PQ wallet scheme under a
|
|
// strict-PQ profile. Dispatches the
|
|
// signature check through an
|
|
// injected verifier so the auth
|
|
// package stays below pulsar / coreth
|
|
// in the dependency graph.
|
|
//
|
|
// No panic() anywhere in this file. Every refusal returns a typed error
|
|
// from the var block at the bottom of the file.
|
|
|
|
// txAuthSigningCustomization is the SP 800-185 cSHAKE256 customization
|
|
// tag for the TxAuthEnvelope signing transcript. The tag is the schema
|
|
// identity; bumping it invalidates every prior signature.
|
|
const txAuthSigningCustomization = "LUX_TX_AUTH_V1"
|
|
|
|
// txAuthProtocolTag is the in-band redundant protocol tag bound as the
|
|
// first TupleHash part. Defence-in-depth so a cross-customization-
|
|
// collision attacker also has to forge the leading TupleHash part.
|
|
const txAuthProtocolTag = "Lux/TxAuth/v1"
|
|
|
|
// TxAuthEnvelope is the canonical PQ transaction authorization envelope.
|
|
// Every byte that determines what the transaction asks the chain to do
|
|
// is bound into SigningDigest() via TupleHash256 — a flipped byte in
|
|
// any of these fields invalidates the wallet's signature.
|
|
//
|
|
// All 48-byte fields are PQ AccountID width (SHAKE256-384). All 32-byte
|
|
// fields are commitment / root width (SHA3-256). The 16-byte width is
|
|
// not used at this layer.
|
|
//
|
|
// Field roles:
|
|
//
|
|
// Version envelope-format version; bumped on incompatible
|
|
// layout changes. Current value: 1.
|
|
// ProfileID ChainSecurityProfile this envelope was produced
|
|
// under (config.ProfileStrictPQ / Permissive / FIPS).
|
|
// ChainID L1/L2 chain identifier (cross-chain replay seal).
|
|
// NetworkID mainnet/testnet/devnet (cross-network replay seal).
|
|
// AccountID 48-byte PQ AccountID of the originator. Equals
|
|
// DeriveAccountID(ProfileID, ChainID, WalletSchemeID, pubkey).
|
|
// Nonce per-account monotonic counter (intra-chain replay seal).
|
|
// ExpiryHeight block height after which the envelope is rejected.
|
|
// Set to math.MaxUint64 for "never expire" (discouraged).
|
|
// WalletSchemeID PQ signature scheme used by the wallet. Pinned by
|
|
// ChainSecurityProfile under strict-PQ; the verifier
|
|
// refuses any mismatch.
|
|
// HashSuiteID hash family the transcript binds (orthogonal to
|
|
// the cSHAKE256 kernel that produces SigningDigest).
|
|
// Bound as DATA into the digest so a flipped byte
|
|
// breaks signature verification.
|
|
// FeePayer AccountID paying fees. Equals AccountID for the
|
|
// common case; differs for sponsored / meta-tx flows.
|
|
// GasLimit per-envelope gas cap (caller-side; chain enforces).
|
|
// MaxFee per-gas maximum fee in chain's native unit (32-byte
|
|
// big-endian unsigned integer; allows up to 2^256-1).
|
|
// CallRoot Merkle root over the (target, calldata, value)
|
|
// tuple sequence. The verifier checks SignatureDigest
|
|
// over CallRoot, not the calls themselves — call
|
|
// contents are committed in CallRoot's preimage.
|
|
// AccessListRoot Merkle root over the access-list set (EIP-2930-style
|
|
// warm-storage hints).
|
|
// ZIdentityRoot Merkle root over the originator's Z-Chain identity
|
|
// commitment (binds identity proofs to this tx).
|
|
// AccountStateRoot 32-byte commitment to the originator's account
|
|
// state at the time of signing. The verifier rejects
|
|
// any envelope whose stored AccountStateRoot does not
|
|
// match the chain's current AccountStateRoot for this
|
|
// account — closes the "stale state replay" class.
|
|
// PublicKeyRef 32-byte hash reference to the wallet public key
|
|
// (e.g. SHA3-256 of the ML-DSA-65 pubkey). The
|
|
// verifier resolves it through AccountStateLookupFn
|
|
// to obtain the full pubkey bytes for signature
|
|
// verification.
|
|
// Signature wallet signature over SigningDigest under
|
|
// WalletSchemeID. Variable-length.
|
|
type TxAuthEnvelope struct {
|
|
Version uint16
|
|
|
|
ProfileID config.ProfileID
|
|
ChainID uint32
|
|
NetworkID uint32
|
|
|
|
AccountID [48]byte
|
|
Nonce uint64
|
|
|
|
ExpiryHeight uint64
|
|
|
|
WalletSchemeID WalletSchemeID
|
|
HashSuiteID config.HashSuiteID
|
|
|
|
FeePayer [48]byte
|
|
GasLimit uint64
|
|
MaxFee [32]byte
|
|
|
|
CallRoot [32]byte
|
|
AccessListRoot [32]byte
|
|
ZIdentityRoot [32]byte
|
|
AccountStateRoot [32]byte
|
|
|
|
PublicKeyRef [32]byte
|
|
|
|
Signature []byte
|
|
}
|
|
|
|
// SigningDigest returns the 48-byte digest the wallet signs over for
|
|
// this envelope. Bound via TupleHash256 (customization "LUX_TX_AUTH_V1")
|
|
// so any byte flip on any security-relevant field breaks signature
|
|
// verification — not just envelope equality.
|
|
//
|
|
// The hash family of the digest is fixed at cSHAKE256 (SP 800-185),
|
|
// independent of HashSuiteID. HashSuiteID is bound as DATA into the
|
|
// transcript, not as the hash family of the transcript itself.
|
|
//
|
|
// Signature is intentionally NOT bound (the digest is what the signature
|
|
// is computed over). All other fields are bound exactly once.
|
|
func (e *TxAuthEnvelope) SigningDigest() [48]byte {
|
|
parts := [][]byte{
|
|
[]byte(txAuthProtocolTag),
|
|
u16BE(e.Version),
|
|
{byte(e.ProfileID)},
|
|
u32BE(e.ChainID),
|
|
u32BE(e.NetworkID),
|
|
e.AccountID[:],
|
|
u64BE(e.Nonce),
|
|
u64BE(e.ExpiryHeight),
|
|
{byte(e.WalletSchemeID)},
|
|
{byte(e.HashSuiteID)},
|
|
e.FeePayer[:],
|
|
u64BE(e.GasLimit),
|
|
e.MaxFee[:],
|
|
e.CallRoot[:],
|
|
e.AccessListRoot[:],
|
|
e.ZIdentityRoot[:],
|
|
e.AccountStateRoot[:],
|
|
e.PublicKeyRef[:],
|
|
}
|
|
return tupleHash48(parts, txAuthSigningCustomization)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Wire codec
|
|
// =============================================================================
|
|
//
|
|
// Layout (deterministic, big-endian):
|
|
//
|
|
// version uint16 BE
|
|
// profile_id uint8
|
|
// chain_id uint32 BE
|
|
// network_id uint32 BE
|
|
// account_id [48]byte
|
|
// nonce uint64 BE
|
|
// expiry_height uint64 BE
|
|
// wallet_scheme_id uint8
|
|
// hash_suite_id uint8
|
|
// fee_payer [48]byte
|
|
// gas_limit uint64 BE
|
|
// max_fee [32]byte
|
|
// call_root [32]byte
|
|
// access_list_root [32]byte
|
|
// z_identity_root [32]byte
|
|
// account_state_root [32]byte
|
|
// public_key_ref [32]byte
|
|
// signature_len uint32 BE
|
|
// signature []byte
|
|
|
|
// Marshal returns the deterministic byte encoding of e. Returns an
|
|
// error on a nil receiver (programmer error, but no panic — callers
|
|
// route the failure).
|
|
func (e *TxAuthEnvelope) Marshal() ([]byte, error) {
|
|
if e == nil {
|
|
return nil, ErrTxAuthNilEnvelope
|
|
}
|
|
// Fixed size: 2 (ver) + 1 (profile) + 4 (chain) + 4 (network) +
|
|
// 48 (account) + 8 (nonce) + 8 (expiry) + 1 (wallet) + 1 (suite)
|
|
// + 48 (feepayer) + 8 (gas) + 32 (maxfee) + 5*32 (roots) + 32 (pk ref)
|
|
// + 4 (sig len) = 263 bytes + len(Signature).
|
|
size := 2 + 1 + 4 + 4 + 48 + 8 + 8 + 1 + 1 + 48 + 8 + 32 + 4*32 + 32 + 4 + len(e.Signature)
|
|
buf := make([]byte, 0, size)
|
|
|
|
buf = appendU16(buf, e.Version)
|
|
buf = append(buf, byte(e.ProfileID))
|
|
buf = appendU32(buf, e.ChainID)
|
|
buf = appendU32(buf, e.NetworkID)
|
|
buf = append(buf, e.AccountID[:]...)
|
|
buf = appendU64(buf, e.Nonce)
|
|
buf = appendU64(buf, e.ExpiryHeight)
|
|
buf = append(buf, byte(e.WalletSchemeID))
|
|
buf = append(buf, byte(e.HashSuiteID))
|
|
buf = append(buf, e.FeePayer[:]...)
|
|
buf = appendU64(buf, e.GasLimit)
|
|
buf = append(buf, e.MaxFee[:]...)
|
|
buf = append(buf, e.CallRoot[:]...)
|
|
buf = append(buf, e.AccessListRoot[:]...)
|
|
buf = append(buf, e.ZIdentityRoot[:]...)
|
|
buf = append(buf, e.AccountStateRoot[:]...)
|
|
buf = append(buf, e.PublicKeyRef[:]...)
|
|
buf = appendU32(buf, uint32(len(e.Signature)))
|
|
buf = append(buf, e.Signature...)
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
// UnmarshalTxAuthEnvelope is the round-trip inverse of Marshal. Returns
|
|
// a typed error from the ErrTxAuth* set on any framing failure. After
|
|
// framing succeeds, refuses zero values on security-relevant enum
|
|
// fields (ProfileID, WalletSchemeID, HashSuiteID): a zero-init envelope
|
|
// is never a legitimate wire payload, and making the codec refuse it
|
|
// removes one degree of freedom an attacker has when fuzzing a
|
|
// downstream verifier whose policy check might miss a path.
|
|
func UnmarshalTxAuthEnvelope(data []byte) (*TxAuthEnvelope, error) {
|
|
r := &txAuthReader{buf: data}
|
|
|
|
e := &TxAuthEnvelope{}
|
|
var err error
|
|
if e.Version, err = r.u16(); err != nil {
|
|
return nil, err
|
|
}
|
|
pid, err := r.u8()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.ProfileID = config.ProfileID(pid)
|
|
if e.ChainID, err = r.u32(); err != nil {
|
|
return nil, err
|
|
}
|
|
if e.NetworkID, err = r.u32(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read48(&e.AccountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if e.Nonce, err = r.u64(); err != nil {
|
|
return nil, err
|
|
}
|
|
if e.ExpiryHeight, err = r.u64(); err != nil {
|
|
return nil, err
|
|
}
|
|
ws, err := r.u8()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.WalletSchemeID = WalletSchemeID(ws)
|
|
hs, err := r.u8()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.HashSuiteID = config.HashSuiteID(hs)
|
|
if err = r.read48(&e.FeePayer); err != nil {
|
|
return nil, err
|
|
}
|
|
if e.GasLimit, err = r.u64(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.MaxFee); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.CallRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.AccessListRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.ZIdentityRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.AccountStateRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = r.read32(&e.PublicKeyRef); err != nil {
|
|
return nil, err
|
|
}
|
|
if e.Signature, err = r.lenPrefixed(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(r.buf) != 0 {
|
|
return nil, fmt.Errorf("%w: %d trailing bytes", ErrTxAuthTrailingBytes, len(r.buf))
|
|
}
|
|
|
|
// Refuse zero values on security-relevant enums. Order matches the
|
|
// field declaration so error messages point at the first violation
|
|
// a reader sees on the wire.
|
|
switch {
|
|
case e.ProfileID == config.ProfileNone:
|
|
return nil, fmt.Errorf("%w: ProfileID", ErrTxAuthZeroEnum)
|
|
case e.WalletSchemeID == WalletSchemeNone:
|
|
return nil, fmt.Errorf("%w: WalletSchemeID", ErrTxAuthZeroEnum)
|
|
case e.HashSuiteID == config.HashSuiteNone:
|
|
return nil, fmt.Errorf("%w: HashSuiteID", ErrTxAuthZeroEnum)
|
|
}
|
|
|
|
return e, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verifier
|
|
// =============================================================================
|
|
|
|
// AccountStateLookupFn is the chain-side hook that the auth package
|
|
// calls to resolve a wallet's stored public key and current account
|
|
// state root. The auth package stays orthogonal to any specific
|
|
// account-state implementation by going through this function pointer.
|
|
//
|
|
// Returns:
|
|
//
|
|
// pubkey the wallet's full public key bytes, in the
|
|
// format the WalletSchemeID's verifier
|
|
// expects (e.g. ML-DSA-65 encoded pubkey).
|
|
// currentAccountStateRoot the chain's current 32-byte AccountStateRoot
|
|
// for this account. The verifier compares it
|
|
// against env.AccountStateRoot — mismatch is
|
|
// a stale-state-replay refusal.
|
|
//
|
|
// On lookup failure (unknown account, db error) the function returns an
|
|
// error and the verifier surfaces it as ErrTxAuthAccountStateLookup.
|
|
type AccountStateLookupFn func(accountID [48]byte, publicKeyRef [32]byte) (pubkey []byte, currentAccountStateRoot [32]byte, err error)
|
|
|
|
// SignatureVerifierFn is the chain-side hook that the auth package calls
|
|
// to verify a wallet signature under a specific WalletSchemeID. The
|
|
// auth package does NOT bind to a concrete signature library — it
|
|
// would force a cycle (auth → pulsar → consensus → auth). Instead, the
|
|
// caller (typically coreth) injects a function that dispatches across
|
|
// the scheme block:
|
|
//
|
|
// ML-DSA-44 / 65 / 87 -> github.com/luxfi/pulsar mldsa.Verify
|
|
// SLH-DSA-128f / ... -> golang.org/x/crypto/sphincs.Verify
|
|
// ECDSA legacy -> rejected before this function fires when
|
|
// profile is strict-PQ; otherwise dispatches
|
|
// to a legacy verifier.
|
|
//
|
|
// Returns (true, nil) on a valid signature; (false, nil) on a malformed
|
|
// or non-verifying signature; (false, err) on a transient error (e.g.
|
|
// a verifier-side allocator failure).
|
|
type SignatureVerifierFn func(scheme WalletSchemeID, pubkey, msgDigest, signature []byte) (bool, error)
|
|
|
|
// CurrentHeightFn returns the chain's current finalized block height.
|
|
// Used by VerifyTxAuthEnvelope to enforce ExpiryHeight. Returning
|
|
// zero means "do not enforce expiry" (testing / replay tooling); a
|
|
// production caller MUST inject a real height source.
|
|
type CurrentHeightFn func() uint64
|
|
|
|
// VerifyTxAuthEnvelope is the profile-gated entry point for a transaction
|
|
// authorization envelope. Runs the following checks in order:
|
|
//
|
|
// 1. Structural: env non-nil, profile non-nil, Version supported.
|
|
// 2. Profile/scheme: env.WalletSchemeID is admissible under profile.
|
|
// Strict-PQ profiles refuse any IsLegacyClassical / non-PostQuantum
|
|
// wallet scheme.
|
|
// 3. Profile/suite: env.HashSuiteID matches profile.HashSuiteID.
|
|
// 4. Profile/id: env.ProfileID matches profile.ProfileID.
|
|
// 5. Expiry: env.ExpiryHeight > currentHeight (when currentHeight > 0).
|
|
// 6. AccountID derivation: DeriveAccountID(env.ProfileID, env.ChainID,
|
|
// env.WalletSchemeID, pubkey) MUST equal env.AccountID. Closes the
|
|
// "wrong key for the account" attack class deterministically.
|
|
// 7. AccountStateRoot: env.AccountStateRoot equals the chain's current
|
|
// AccountStateRoot for this account.
|
|
// 8. Signature: SignatureVerifierFn returns true on
|
|
// (env.WalletSchemeID, pubkey, env.SigningDigest(), env.Signature).
|
|
//
|
|
// On any failure returns a typed error from the ErrTxAuth* set. No
|
|
// panic() on any path.
|
|
func VerifyTxAuthEnvelope(
|
|
profile *config.ChainSecurityProfile,
|
|
env *TxAuthEnvelope,
|
|
accountStateLookup AccountStateLookupFn,
|
|
sigVerifier SignatureVerifierFn,
|
|
currentHeight CurrentHeightFn,
|
|
) error {
|
|
// 1. Structural.
|
|
if env == nil {
|
|
return ErrTxAuthNilEnvelope
|
|
}
|
|
if profile == nil {
|
|
return ErrTxAuthInvalidProfile
|
|
}
|
|
if accountStateLookup == nil {
|
|
return ErrTxAuthMissingAccountLookup
|
|
}
|
|
if sigVerifier == nil {
|
|
return ErrTxAuthMissingSigVerifier
|
|
}
|
|
if env.Version == 0 {
|
|
return fmt.Errorf("%w: Version=0", ErrTxAuthVersionUnsupported)
|
|
}
|
|
if env.Version > TxAuthCurrentVersion {
|
|
return fmt.Errorf("%w: Version=%d > current=%d",
|
|
ErrTxAuthVersionUnsupported, env.Version, TxAuthCurrentVersion)
|
|
}
|
|
|
|
// 2. Profile/scheme. Strict-PQ profile must enforce IsPostQuantum.
|
|
if !env.WalletSchemeID.IsPostQuantum() && profile.ForbidClassicalSNARKs {
|
|
// ForbidClassicalSNARKs subsumes "no classical primitives" on
|
|
// strict-PQ profiles; the wallet-scheme check piggy-backs on
|
|
// it so we don't add a new profile knob just for wallets.
|
|
return fmt.Errorf("%w: %s under profile %s",
|
|
ErrTxAuthWalletSchemeNotAllowed,
|
|
env.WalletSchemeID.String(), profile.ProfileName)
|
|
}
|
|
if env.WalletSchemeID == WalletSchemeNone {
|
|
return fmt.Errorf("%w: WalletSchemeNone", ErrTxAuthWalletSchemeNotAllowed)
|
|
}
|
|
|
|
// 3. Profile/suite. The envelope's HashSuiteID MUST match the
|
|
// profile's HashSuiteID byte-for-byte. Closes the "BLAKE3 envelope
|
|
// against a SHA3 profile" class deterministically.
|
|
if env.HashSuiteID != profile.HashSuiteID {
|
|
return fmt.Errorf("%w: env=%s profile=%s",
|
|
ErrTxAuthHashSuiteMismatch,
|
|
env.HashSuiteID.String(), profile.HashSuiteID.String())
|
|
}
|
|
|
|
// 4. Profile/id. ProfileID byte must match. Refuses profile drift
|
|
// between signing time and verification time.
|
|
if uint32(env.ProfileID) != profile.ProfileID {
|
|
return fmt.Errorf("%w: env=0x%02x profile=0x%08x",
|
|
ErrTxAuthInvalidProfile, uint8(env.ProfileID), profile.ProfileID)
|
|
}
|
|
|
|
// 5. Expiry. currentHeight==nil or returning 0 means "do not
|
|
// enforce expiry" — testing / replay tooling. Production callers
|
|
// MUST inject a real height source.
|
|
if currentHeight != nil {
|
|
h := currentHeight()
|
|
if h > 0 && env.ExpiryHeight <= h {
|
|
return fmt.Errorf("%w: expiry=%d current=%d",
|
|
ErrTxAuthExpired, env.ExpiryHeight, h)
|
|
}
|
|
}
|
|
|
|
// 6. Resolve pubkey + current account-state root via injected lookup.
|
|
pubkey, currentAccountStateRoot, err := accountStateLookup(env.AccountID, env.PublicKeyRef)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrTxAuthAccountStateLookup, err)
|
|
}
|
|
if len(pubkey) == 0 {
|
|
return fmt.Errorf("%w: empty pubkey", ErrTxAuthAccountStateLookup)
|
|
}
|
|
|
|
// 7. AccountID derivation. Closes the "wrong key for the account"
|
|
// class: even if a verifier somehow got handed a foreign pubkey, the
|
|
// derived AccountID would not match env.AccountID and the envelope
|
|
// would be rejected. The derivation binds the profile byte first so
|
|
// the same wallet cannot pose as the same account across two
|
|
// profiles (cross-profile replay class).
|
|
derivedAccountID := DeriveAccountID(uint32(env.ProfileID), env.ChainID, env.WalletSchemeID, pubkey)
|
|
if derivedAccountID != env.AccountID {
|
|
return fmt.Errorf("%w: derived=%x env=%x",
|
|
ErrTxAuthAccountIDMismatch,
|
|
derivedAccountID[:8], env.AccountID[:8])
|
|
}
|
|
|
|
// 8. AccountStateRoot match. The chain's current state root for
|
|
// this account MUST equal the root the wallet signed over. Closes
|
|
// the "stale state replay" attack class deterministically: a sender
|
|
// who signs an envelope against an old state and rebroadcasts it
|
|
// against a new state is rejected at this check.
|
|
if env.AccountStateRoot != currentAccountStateRoot {
|
|
return fmt.Errorf("%w: env=%x chain=%x",
|
|
ErrTxAuthAccountStateRoot,
|
|
env.AccountStateRoot[:8], currentAccountStateRoot[:8])
|
|
}
|
|
|
|
// 9. Signature verify. The envelope's SigningDigest is what the
|
|
// wallet signed over. Dispatch through the injected verifier so the
|
|
// auth package has no upward dependency on pulsar / coreth.
|
|
if len(env.Signature) == 0 {
|
|
return ErrTxAuthSignatureInvalid
|
|
}
|
|
digest := env.SigningDigest()
|
|
ok, vErr := sigVerifier(env.WalletSchemeID, pubkey, digest[:], env.Signature)
|
|
if vErr != nil {
|
|
return fmt.Errorf("%w: %v", ErrTxAuthSignatureInvalid, vErr)
|
|
}
|
|
if !ok {
|
|
return ErrTxAuthSignatureInvalid
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TxAuthCurrentVersion is the current TxAuthEnvelope wire-format version.
|
|
// Bumped only on incompatible layout changes; an envelope whose Version
|
|
// exceeds this value is refused by VerifyTxAuthEnvelope.
|
|
const TxAuthCurrentVersion uint16 = 1
|
|
|
|
// =============================================================================
|
|
// Typed errors
|
|
// =============================================================================
|
|
|
|
var (
|
|
// ErrTxAuthNilEnvelope — receiver is nil. Codec + verifier surface.
|
|
ErrTxAuthNilEnvelope = errors.New("txauth: nil envelope")
|
|
|
|
// ErrTxAuthInvalidProfile — profile is nil or env.ProfileID does
|
|
// not match profile.ProfileID.
|
|
ErrTxAuthInvalidProfile = errors.New("txauth: invalid or mismatched profile")
|
|
|
|
// ErrTxAuthVersionUnsupported — env.Version is zero or exceeds
|
|
// TxAuthCurrentVersion.
|
|
ErrTxAuthVersionUnsupported = errors.New("txauth: envelope Version unsupported")
|
|
|
|
// ErrTxAuthWalletSchemeNotAllowed — env.WalletSchemeID is either
|
|
// WalletSchemeNone or a non-PostQuantum scheme on a strict-PQ
|
|
// profile.
|
|
ErrTxAuthWalletSchemeNotAllowed = errors.New("txauth: WalletSchemeID not allowed under profile")
|
|
|
|
// ErrTxAuthHashSuiteMismatch — env.HashSuiteID does not match the
|
|
// profile's pinned HashSuiteID.
|
|
ErrTxAuthHashSuiteMismatch = errors.New("txauth: HashSuiteID does not match profile")
|
|
|
|
// ErrTxAuthZeroEnum — the codec saw a zero value on a
|
|
// security-relevant enum (ProfileID, WalletSchemeID, HashSuiteID)
|
|
// where a real value is required.
|
|
ErrTxAuthZeroEnum = errors.New("txauth: zero-value security-relevant enum")
|
|
|
|
// ErrTxAuthExpired — env.ExpiryHeight is at or below the chain's
|
|
// current height.
|
|
ErrTxAuthExpired = errors.New("txauth: envelope expired")
|
|
|
|
// ErrTxAuthAccountStateLookup — the injected AccountStateLookupFn
|
|
// returned an error or empty pubkey.
|
|
ErrTxAuthAccountStateLookup = errors.New("txauth: account-state lookup failed")
|
|
|
|
// ErrTxAuthAccountIDMismatch — DeriveAccountID(...) did not match
|
|
// env.AccountID. Wrong key for the account.
|
|
ErrTxAuthAccountIDMismatch = errors.New("txauth: derived AccountID does not match envelope")
|
|
|
|
// ErrTxAuthAccountStateRoot — env.AccountStateRoot does not match
|
|
// the chain's current AccountStateRoot for this account.
|
|
ErrTxAuthAccountStateRoot = errors.New("txauth: AccountStateRoot stale")
|
|
|
|
// ErrTxAuthSignatureInvalid — the wallet signature did not verify,
|
|
// or no signature bytes were provided.
|
|
ErrTxAuthSignatureInvalid = errors.New("txauth: signature does not verify")
|
|
|
|
// ErrTxAuthMissingAccountLookup — caller did not inject an
|
|
// AccountStateLookupFn.
|
|
ErrTxAuthMissingAccountLookup = errors.New("txauth: AccountStateLookupFn is required")
|
|
|
|
// ErrTxAuthMissingSigVerifier — caller did not inject a
|
|
// SignatureVerifierFn.
|
|
ErrTxAuthMissingSigVerifier = errors.New("txauth: SignatureVerifierFn is required")
|
|
|
|
// ErrTxAuthTruncated — codec ran out of input before reading every
|
|
// required field.
|
|
ErrTxAuthTruncated = errors.New("txauth: input truncated")
|
|
|
|
// ErrTxAuthTrailingBytes — codec finished reading every required
|
|
// field but data remained in the buffer.
|
|
ErrTxAuthTrailingBytes = errors.New("txauth: trailing bytes after envelope decode")
|
|
|
|
// ErrTxAuthSignatureTooLong — declared signature_len exceeds the
|
|
// remaining buffer.
|
|
ErrTxAuthSignatureTooLong = errors.New("txauth: declared signature_len exceeds input")
|
|
)
|
|
|
|
// =============================================================================
|
|
// Reader helpers (private to this file)
|
|
// =============================================================================
|
|
|
|
type txAuthReader struct {
|
|
buf []byte
|
|
}
|
|
|
|
func (r *txAuthReader) need(n int) error {
|
|
if len(r.buf) < n {
|
|
return fmt.Errorf("%w: need %d have %d",
|
|
io.ErrUnexpectedEOF, n, len(r.buf))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *txAuthReader) u8() (uint8, error) {
|
|
if err := r.need(1); err != nil {
|
|
return 0, ErrTxAuthTruncated
|
|
}
|
|
v := r.buf[0]
|
|
r.buf = r.buf[1:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *txAuthReader) u16() (uint16, error) {
|
|
if err := r.need(2); err != nil {
|
|
return 0, ErrTxAuthTruncated
|
|
}
|
|
v := binary.BigEndian.Uint16(r.buf[:2])
|
|
r.buf = r.buf[2:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *txAuthReader) u32() (uint32, error) {
|
|
if err := r.need(4); err != nil {
|
|
return 0, ErrTxAuthTruncated
|
|
}
|
|
v := binary.BigEndian.Uint32(r.buf[:4])
|
|
r.buf = r.buf[4:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *txAuthReader) u64() (uint64, error) {
|
|
if err := r.need(8); err != nil {
|
|
return 0, ErrTxAuthTruncated
|
|
}
|
|
v := binary.BigEndian.Uint64(r.buf[:8])
|
|
r.buf = r.buf[8:]
|
|
return v, nil
|
|
}
|
|
|
|
func (r *txAuthReader) read32(dst *[32]byte) error {
|
|
if err := r.need(32); err != nil {
|
|
return ErrTxAuthTruncated
|
|
}
|
|
copy(dst[:], r.buf[:32])
|
|
r.buf = r.buf[32:]
|
|
return nil
|
|
}
|
|
|
|
func (r *txAuthReader) read48(dst *[48]byte) error {
|
|
if err := r.need(48); err != nil {
|
|
return ErrTxAuthTruncated
|
|
}
|
|
copy(dst[:], r.buf[:48])
|
|
r.buf = r.buf[48:]
|
|
return nil
|
|
}
|
|
|
|
func (r *txAuthReader) lenPrefixed() ([]byte, error) {
|
|
n, err := r.u32()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if uint64(n) > uint64(len(r.buf)) {
|
|
return nil, fmt.Errorf("%w: declared=%d remaining=%d",
|
|
ErrTxAuthSignatureTooLong, n, len(r.buf))
|
|
}
|
|
out := make([]byte, n)
|
|
copy(out, r.buf[:n])
|
|
r.buf = r.buf[n:]
|
|
return out, nil
|
|
}
|