Files
zeekay 21d564c34b consensus/auth: canonical KEM scheme numbering via config.KeyExchangeID (Bug 3)
Before: protocol/auth/scheme_ids.go declared its own KeyExchangeID type
with values 0x52/0x53 — disjoint from config/pq_mode.go (0x01/0x02) and
node/network/kem/scheme.go (0x62/0x63). Three packages, three numberings
of the same primitive.

After: auth.KeyExchangeID is a type alias of config.KeyExchangeID. The
canonical wire bytes (0x01 = ML-KEM-768, 0x02 = ML-KEM-1024, 0x90 =
X25519Unsafe) live in config and are re-exported by auth. Methods
(String, IsPostQuantum, IsForbiddenInPQMode) are inherited from config.

Dropped: KeyExchangeMLKEM512 (NIST Cat 1, below strict-PQ floor; never
deployed) and KeyExchangeX25519Legacy (renamed to X25519Unsafe to match
config). No production code referenced these.

New test (auth_test package): TestKEMSchemeIDs_AllUseCanonicalNumbering
proves auth's exported KeyExchange* constants are byte-identical to
config's, with wire bytes 0x01 / 0x02 / 0x90.
2026-05-11 09:29:31 -07:00

329 lines
12 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package auth — canonical PQ authorization surface for the user-facing
// layers (wallets, contracts, EVM precompiles).
//
// This package is intentionally orthogonal to the validator-facing
// consensus envelope: the same chain pins one ChainSecurityProfile and
// uses it to gate BOTH a validator-produced QBlock and a user-produced
// TxAuthEnvelope. The profile is the single safety surface; auth
// envelopes consult it the same way zchain proof envelopes do.
//
// Five PQ-class enums live here:
//
// WalletSchemeID — what a user's EOA signs with (raw ML-DSA family
// at production; ECDSA marker reserved for the
// legacy migration window only)
// TxSchemeID — transaction envelope semantics (legacy single-sig
// vs PQ-bound TxAuthEnvelope)
// ContractAuthID — per-contract action authorization scheme (admin
// / upgrade / pause / permit)
// KeyExchangeID — KEM used for ephemeral channel keys (ML-KEM
// family; X25519 marker reserved as legacy)
// RecoverySchemeID — social / threshold account recovery scheme
//
// These enums are scheduled to land in config/ alongside the canonical
// ChainSecurityProfile expansion. Until that lands, they live here as a
// TEMPORARY local type. The merge will reconcile by:
//
// 1. Moving the enum declarations to config/.
// 2. Replacing this file with a re-export `type WalletSchemeID =
// config.WalletSchemeID` (etc.) until callers update their imports.
// 3. Deleting this file once callers point at config.
//
// Numbering rule: each enum stays inside its own byte block so the wire
// format is unambiguous even if every five enums are flattened into a
// single transcript stream by a downstream digest function. New entries
// claim the next free integer in their block; never reuse a retired ID.
package auth
import (
"fmt"
"github.com/luxfi/consensus/config"
)
// WalletSchemeID is the wire byte that identifies the signature scheme a
// user's externally-owned account (EOA) signs transactions with.
//
// Distinct from config.SigSchemeID (validator threshold/identity) and
// config.IdentitySchemeID (validator identity) because user wallets are
// single-party and the wire format makes that semantic visible in one
// byte. A receiver knows immediately whether a TxAuthEnvelope.Signature
// is single-party ML-DSA or a future threshold-wallet variant without
// having to dispatch on a separate envelope type.
//
// Numbering:
//
// 0x00 — None / unspecified (rejected by every locked profile)
// 0x10..0x1F — Legacy classical (forbidden in strict-PQ; reserved for
// the secp256k1 legacy migration window only)
// 0x40..0x4F — Raw FIPS 204 ML-DSA single-party (production wallets):
// 0x41 = ML-DSA-44 (devnet only, NIST PQ Cat 2)
// 0x42 = ML-DSA-65 (production default, NIST PQ Cat 3)
// 0x43 = ML-DSA-87 (high-value wallets, NIST PQ Cat 5)
// 0x60..0x6F — Hash-based (SLH-DSA / SPHINCS+ family):
// 0x61 = SLH-DSA-128f
// 0x62 = SLH-DSA-192f
// 0x63 = SLH-DSA-256f
// 0xE0..0xEF — Hybrid (PQ-primary + classical witness; reserved for
// migration. NOT used at production strict-PQ.)
type WalletSchemeID uint8
const (
WalletSchemeNone WalletSchemeID = 0x00
// Legacy classical — explicit forbidden marker so audit tooling can
// name a misconfiguration precisely. Strict-PQ profiles refuse this
// at VerifyTxAuthEnvelope.
WalletSchemeECDSASecp256k1Legacy WalletSchemeID = 0x10
// Production PQ wallets.
WalletSchemeMLDSA44 WalletSchemeID = 0x41
WalletSchemeMLDSA65 WalletSchemeID = 0x42 // production default
WalletSchemeMLDSA87 WalletSchemeID = 0x43
// Hash-based PQ wallets — FIPS 205 stateless SLH-DSA family.
WalletSchemeSLHDSA128f WalletSchemeID = 0x61
WalletSchemeSLHDSA192f WalletSchemeID = 0x62
WalletSchemeSLHDSA256f WalletSchemeID = 0x63
)
// String returns the canonical wire name.
func (w WalletSchemeID) String() string {
switch w {
case WalletSchemeNone:
return "none"
case WalletSchemeECDSASecp256k1Legacy:
return "ecdsa-secp256k1-legacy"
case WalletSchemeMLDSA44:
return "ml-dsa-44"
case WalletSchemeMLDSA65:
return "ml-dsa-65"
case WalletSchemeMLDSA87:
return "ml-dsa-87"
case WalletSchemeSLHDSA128f:
return "slh-dsa-128f"
case WalletSchemeSLHDSA192f:
return "slh-dsa-192f"
case WalletSchemeSLHDSA256f:
return "slh-dsa-256f"
default:
return fmt.Sprintf("wallet-scheme(0x%02x)", uint8(w))
}
}
// IsRawMLDSA reports whether this wallet scheme uses raw FIPS 204 ML-DSA.
func (w WalletSchemeID) IsRawMLDSA() bool {
return w == WalletSchemeMLDSA44 ||
w == WalletSchemeMLDSA65 ||
w == WalletSchemeMLDSA87
}
// IsSLHDSA reports whether this wallet scheme uses FIPS 205 SLH-DSA.
func (w WalletSchemeID) IsSLHDSA() bool {
return w == WalletSchemeSLHDSA128f ||
w == WalletSchemeSLHDSA192f ||
w == WalletSchemeSLHDSA256f
}
// IsPostQuantum reports whether the scheme is acceptable in strict-PQ
// mode. The ECDSA legacy marker returns false; ML-DSA and SLH-DSA
// families return true; None returns false.
func (w WalletSchemeID) IsPostQuantum() bool {
return w.IsRawMLDSA() || w.IsSLHDSA()
}
// IsLegacyClassical reports whether this wallet scheme is the explicit
// classical legacy marker. Strict-PQ verifiers MUST refuse these.
func (w WalletSchemeID) IsLegacyClassical() bool {
return w == WalletSchemeECDSASecp256k1Legacy
}
// TxSchemeID is the wire byte that identifies the transaction envelope
// kind. Pinned at the chain level so a single byte in a locked profile
// tells a verifier whether to expect legacy single-sig or PQ-bound
// TxAuthEnvelope semantics.
//
// Numbering:
//
// 0x00 — None / unspecified (rejected by every locked profile)
// 0x10 — TxSchemeLegacyECDSA (forbidden in strict-PQ; migration only)
// 0x42 — TxSchemePQAuthV1 (production: TxAuthEnvelope under ML-DSA family)
type TxSchemeID uint8
const (
TxSchemeNone TxSchemeID = 0x00
TxSchemeLegacyECDSA TxSchemeID = 0x10
TxSchemePQAuthV1 TxSchemeID = 0x42
)
// String returns the canonical wire name.
func (t TxSchemeID) String() string {
switch t {
case TxSchemeNone:
return "none"
case TxSchemeLegacyECDSA:
return "tx-legacy-ecdsa"
case TxSchemePQAuthV1:
return "tx-pq-auth-v1"
default:
return fmt.Sprintf("tx-scheme(0x%02x)", uint8(t))
}
}
// IsPostQuantum reports whether this transaction scheme is admissible
// under a strict-PQ profile.
func (t TxSchemeID) IsPostQuantum() bool {
return t == TxSchemePQAuthV1
}
// ContractAuthID is the wire byte that identifies the signature scheme a
// contract action (admin call, upgrade, pause, permit) is authorised
// under. Distinct from WalletSchemeID because a contract MAY pin a
// different scheme than its caller's wallet — e.g. governance
// multisigs at ML-DSA-87 (Cat 5) on a chain whose default wallet is
// ML-DSA-65 (Cat 3).
//
// Numbering blocks mirror WalletSchemeID so the same byte pattern means
// the same primitive across user / contract context:
//
// 0x00 — None
// 0x10 — ECDSASecp256k1 legacy (forbidden in strict-PQ)
// 0x41 / 0x42 / 0x43 — ML-DSA-44 / 65 / 87
// 0x61..0x63 — SLH-DSA-128f / 192f / 256f
type ContractAuthID uint8
const (
ContractAuthNone ContractAuthID = 0x00
ContractAuthECDSASecp256k1Legacy ContractAuthID = 0x10
ContractAuthMLDSA44 ContractAuthID = 0x41
ContractAuthMLDSA65 ContractAuthID = 0x42 // production default
ContractAuthMLDSA87 ContractAuthID = 0x43
ContractAuthSLHDSA128f ContractAuthID = 0x61
ContractAuthSLHDSA192f ContractAuthID = 0x62
ContractAuthSLHDSA256f ContractAuthID = 0x63
)
// String returns the canonical wire name.
func (c ContractAuthID) String() string {
switch c {
case ContractAuthNone:
return "none"
case ContractAuthECDSASecp256k1Legacy:
return "ecdsa-secp256k1-legacy"
case ContractAuthMLDSA44:
return "ml-dsa-44"
case ContractAuthMLDSA65:
return "ml-dsa-65"
case ContractAuthMLDSA87:
return "ml-dsa-87"
case ContractAuthSLHDSA128f:
return "slh-dsa-128f"
case ContractAuthSLHDSA192f:
return "slh-dsa-192f"
case ContractAuthSLHDSA256f:
return "slh-dsa-256f"
default:
return fmt.Sprintf("contract-auth(0x%02x)", uint8(c))
}
}
// IsPostQuantum reports whether this contract-auth scheme is admissible
// under strict-PQ. ECDSA legacy returns false.
func (c ContractAuthID) IsPostQuantum() bool {
switch c {
case ContractAuthMLDSA44, ContractAuthMLDSA65, ContractAuthMLDSA87,
ContractAuthSLHDSA128f, ContractAuthSLHDSA192f, ContractAuthSLHDSA256f:
return true
}
return false
}
// IsLegacyClassical reports whether this scheme is the explicit
// classical legacy marker. Strict-PQ refuses these.
func (c ContractAuthID) IsLegacyClassical() bool {
return c == ContractAuthECDSASecp256k1Legacy
}
// KeyExchangeID is the wire byte that identifies the KEM used for
// ephemeral channel keys (encrypted RPC, account-level encryption,
// channel rekey).
//
// Canonical numbering lives in config.KeyExchangeID:
//
// 0x00 — Invalid / unspecified
// 0x01 — ML-KEM-768 (FIPS 203 Cat 3, production default)
// 0x02 — ML-KEM-1024 (FIPS 203 Cat 5, high-value)
// 0x90 — X25519Unsafe (classical, refused in strict-PQ)
//
// This package's KeyExchangeID is a type alias of config.KeyExchangeID
// so wire bytes, String(), IsPostQuantum() and IsForbiddenInPQMode() are
// shared across the auth and config packages. ML-KEM-512 (NIST Cat 1)
// is intentionally absent — it sits below the strict-PQ floor and was
// reserved-but-unused in earlier drafts.
//
// Closes the "three disjoint KEM ID registries" drift the spec/code
// audit named under Bug 3: auth, config, and node/network/kem now all
// resolve to the same byte values.
type KeyExchangeID = config.KeyExchangeID
const (
KeyExchangeInvalid = config.KeyExchangeInvalid
KeyExchangeMLKEM768 = config.KeyExchangeMLKEM768 // 0x01 — production default
KeyExchangeMLKEM1024 = config.KeyExchangeMLKEM1024 // 0x02 — high-value
KeyExchangeX25519Unsafe = config.KeyExchangeX25519Unsafe
)
// RecoverySchemeID is the wire byte that identifies the account recovery
// scheme (social recovery, threshold guardians, time-locked PQ recovery).
//
// Numbering:
//
// 0x00 — None
// 0x10 — NoneByDesign (account is non-recoverable; loss is final)
// 0x20 — SocialKofN (k-of-n guardian threshold)
// 0x30 — TimelockPQ (PQ-signed time-locked recovery key)
type RecoverySchemeID uint8
const (
RecoverySchemeNone RecoverySchemeID = 0x00
RecoverySchemeNoneByDesign RecoverySchemeID = 0x10
RecoverySchemeSocialKofN RecoverySchemeID = 0x20
RecoverySchemeTimelockPQ RecoverySchemeID = 0x30
)
// String returns the canonical wire name.
func (r RecoverySchemeID) String() string {
switch r {
case RecoverySchemeNone:
return "none"
case RecoverySchemeNoneByDesign:
return "recovery-none-by-design"
case RecoverySchemeSocialKofN:
return "recovery-social-k-of-n"
case RecoverySchemeTimelockPQ:
return "recovery-timelock-pq"
default:
return fmt.Sprintf("recovery-scheme(0x%02x)", uint8(r))
}
}
// IsKnown reports whether this RecoverySchemeID is a defined entry. A
// chain MAY set RecoverySchemeNone meaning "no recovery axis pinned by
// this chain"; RecoverySchemeNoneByDesign is the explicit
// non-recoverable choice. Unknown bytes return false so the codec can
// refuse a malformed envelope.
func (r RecoverySchemeID) IsKnown() bool {
switch r {
case RecoverySchemeNone,
RecoverySchemeNoneByDesign,
RecoverySchemeSocialKofN,
RecoverySchemeTimelockPQ:
return true
}
return false
}