mirror of
https://github.com/luxfi/zk.git
synced 2026-10-30 15:11:33 +00:00
414 lines
16 KiB
Go
414 lines
16 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package registry
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
)
|
|
|
|
// key_record.go — the canonical PQKeyRecord plus the supporting enums
|
|
// (PubkeyLocation, KeyStatus, RevokeReason) and the DeriveAccountID
|
|
// helper that produces the 48-byte AccountID a record is keyed under.
|
|
//
|
|
// One record per (account_id, scheme) pair. The same compact_pubkey
|
|
// under two distinct schemes yields two distinct AccountIDs (the scheme
|
|
// byte is bound into the derivation), so cross-scheme key reuse cannot
|
|
// collide at the AccountID layer.
|
|
|
|
// AccountIDCustomization is the SP 800-185 cSHAKE256 customization tag
|
|
// for AccountID derivation. The tag is the schema identity; bumping it
|
|
// produces a digest stream that does not collide with any prior
|
|
// AccountID. Exported (not unexported) so tooling that audits the
|
|
// derivation can name the tag without re-typing the string.
|
|
const AccountIDCustomization = "LUX_ACCOUNT_ID_V1"
|
|
|
|
// PQKeyRecord is the canonical Z-Chain identity object. One record per
|
|
// active account; rotations create a new record and mark the prior
|
|
// record as KeyStatusRotated.
|
|
//
|
|
// Field roles:
|
|
//
|
|
// AccountID 48-byte SHAKE256-384 derivation over (profile_id,
|
|
// chain_id, scheme, compact_pubkey). Bound first
|
|
// in every op transcript so a forger who replaces
|
|
// the record's pubkey bytes still has to forge
|
|
// the AccountID derivation chain.
|
|
// SchemeID FIPS 204 ML-DSA parameter set the wallet signs
|
|
// under. ML-DSA-65 is the production default.
|
|
// CompactPubkey canonical encoded public key (ML-DSA-65 is
|
|
// 1952 bytes per FIPS 204). Inline-storable.
|
|
// CompactPubkeyHash SHAKE256-384 hash of CompactPubkey. Indexed
|
|
// alongside AccountID for fast equality checks.
|
|
// ExpandedPubkeyHash EIP-8051 / EthDilithium expanded form hash —
|
|
// the 48-byte commitment over the matrix-A
|
|
// expansion. Stored when the chain's executor
|
|
// needs the expanded form (e.g. EVM precompile);
|
|
// otherwise zero.
|
|
// ExpandedPubkeyLoc where the expanded pubkey lives (inline /
|
|
// state-trie / off-chain CAS). Defaults to
|
|
// PubkeyLocInline; off-chain locations require
|
|
// the executor to resolve before signature verify.
|
|
// Status key lifecycle state. KeyStatusActive is the
|
|
// only state under which signatures verify.
|
|
// ValidFromEpoch first epoch the key is admissible.
|
|
// ValidUntilEpoch last epoch the key is admissible (inclusive);
|
|
// 0 means "no expiry."
|
|
// RotationPolicyHash 48-byte commitment to the off-chain rotation
|
|
// policy (cadence, recovery key list, k-of-n
|
|
// threshold). Zero means "no rotation policy
|
|
// pinned" (the chain's default applies).
|
|
type PQKeyRecord struct {
|
|
AccountID [48]byte
|
|
SchemeID config.WalletSchemeID
|
|
CompactPubkey []byte
|
|
CompactPubkeyHash [48]byte
|
|
ExpandedPubkeyHash [48]byte
|
|
ExpandedPubkeyLoc PubkeyLocation
|
|
Status KeyStatus
|
|
ValidFromEpoch uint64
|
|
ValidUntilEpoch uint64
|
|
RotationPolicyHash [48]byte
|
|
}
|
|
|
|
// PubkeyLocation names where the expanded public key lives. The wire
|
|
// byte is bound into every op transcript so a record cannot be
|
|
// downgraded from inline to off-chain (or vice-versa) without breaking
|
|
// the signature.
|
|
type PubkeyLocation uint8
|
|
|
|
const (
|
|
// PubkeyLocInvalid is the zero value. The registry refuses it.
|
|
PubkeyLocInvalid PubkeyLocation = 0x00
|
|
|
|
// PubkeyLocInline — expanded pubkey is stored verbatim inside the
|
|
// record (or recomputable from CompactPubkey on demand).
|
|
PubkeyLocInline PubkeyLocation = 0x01
|
|
|
|
// PubkeyLocStateTrie — expanded pubkey lives at a derived key
|
|
// inside the chain's state trie; ExpandedPubkeyHash is its commitment.
|
|
PubkeyLocStateTrie PubkeyLocation = 0x02
|
|
|
|
// PubkeyLocOffchain — expanded pubkey lives in a content-addressed
|
|
// store referenced by ExpandedPubkeyHash; executors fetch on demand.
|
|
PubkeyLocOffchain PubkeyLocation = 0x03
|
|
)
|
|
|
|
// String returns the canonical wire name.
|
|
func (l PubkeyLocation) String() string {
|
|
switch l {
|
|
case PubkeyLocInvalid:
|
|
return "invalid"
|
|
case PubkeyLocInline:
|
|
return "inline"
|
|
case PubkeyLocStateTrie:
|
|
return "state-trie"
|
|
case PubkeyLocOffchain:
|
|
return "offchain"
|
|
default:
|
|
return "pubkey-loc(unknown)"
|
|
}
|
|
}
|
|
|
|
// IsKnown reports whether l is a defined PubkeyLocation.
|
|
func (l PubkeyLocation) IsKnown() bool {
|
|
switch l {
|
|
case PubkeyLocInline, PubkeyLocStateTrie, PubkeyLocOffchain:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// KeyStatus names the lifecycle state of a record. Only KeyStatusActive
|
|
// admits signatures; the other states are explicit refusal markers so
|
|
// audit tooling can name "this account is revoked / rotated" precisely.
|
|
type KeyStatus uint8
|
|
|
|
const (
|
|
// KeyStatusInvalid is the zero value. The registry refuses it.
|
|
KeyStatusInvalid KeyStatus = 0x00
|
|
|
|
// KeyStatusActive — record is live; signatures verify under it.
|
|
KeyStatusActive KeyStatus = 0x01
|
|
|
|
// KeyStatusRotated — record has been superseded by a successor
|
|
// record. Signatures under the prior record are refused.
|
|
KeyStatusRotated KeyStatus = 0x02
|
|
|
|
// KeyStatusRevoked — record has been revoked. Signatures are
|
|
// refused unconditionally.
|
|
KeyStatusRevoked KeyStatus = 0x03
|
|
)
|
|
|
|
// String returns the canonical wire name.
|
|
func (s KeyStatus) String() string {
|
|
switch s {
|
|
case KeyStatusInvalid:
|
|
return "invalid"
|
|
case KeyStatusActive:
|
|
return "active"
|
|
case KeyStatusRotated:
|
|
return "rotated"
|
|
case KeyStatusRevoked:
|
|
return "revoked"
|
|
default:
|
|
return "key-status(unknown)"
|
|
}
|
|
}
|
|
|
|
// IsTerminal reports whether the status is one a record cannot leave.
|
|
// Both rotated and revoked are terminal; the registry refuses any op
|
|
// that would mutate a terminal record's status further.
|
|
func (s KeyStatus) IsTerminal() bool {
|
|
return s == KeyStatusRotated || s == KeyStatusRevoked
|
|
}
|
|
|
|
// RevokeReason names why an OpRevokeKey was issued. Bound into the
|
|
// revocation transcript so audit pipelines can distinguish a
|
|
// compromise-driven revocation from a routine retire.
|
|
type RevokeReason uint8
|
|
|
|
const (
|
|
// RevokeReasonInvalid is the zero value. Refused.
|
|
RevokeReasonInvalid RevokeReason = 0x00
|
|
|
|
// RevokeReasonCompromise — the operator believes the private key
|
|
// was disclosed. Recovery key signature is sufficient (master sig
|
|
// may be unavailable to a compromised account).
|
|
RevokeReasonCompromise RevokeReason = 0x01
|
|
|
|
// RevokeReasonRetire — operator-initiated retire of an active
|
|
// key. Master sig REQUIRED.
|
|
RevokeReasonRetire RevokeReason = 0x02
|
|
|
|
// RevokeReasonGovernance — chain governance forced removal under
|
|
// a slashing / policy ruling. Governance sig REQUIRED.
|
|
RevokeReasonGovernance RevokeReason = 0x03
|
|
)
|
|
|
|
// String returns the canonical wire name.
|
|
func (r RevokeReason) String() string {
|
|
switch r {
|
|
case RevokeReasonInvalid:
|
|
return "invalid"
|
|
case RevokeReasonCompromise:
|
|
return "compromise"
|
|
case RevokeReasonRetire:
|
|
return "retire"
|
|
case RevokeReasonGovernance:
|
|
return "governance"
|
|
default:
|
|
return "revoke-reason(unknown)"
|
|
}
|
|
}
|
|
|
|
// IsKnown reports whether r is a defined RevokeReason.
|
|
func (r RevokeReason) IsKnown() bool {
|
|
switch r {
|
|
case RevokeReasonCompromise, RevokeReasonRetire, RevokeReasonGovernance:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DeriveAccountID returns the canonical 48-byte AccountID for the given
|
|
// (profile_id, chain_id, scheme, compact_pubkey) tuple, computed as:
|
|
//
|
|
// AccountID = SHAKE256-384(
|
|
// profile_id_be4 || chain_id_be4 || u8(scheme) || compact_pubkey,
|
|
// customization="LUX_ACCOUNT_ID_V1",
|
|
// )
|
|
//
|
|
// Determinism: identical inputs yield identical outputs across every
|
|
// platform and every build. No random seed, no nonce, no timestamp.
|
|
//
|
|
// Profile separation: profile_id is bound first so the same wallet
|
|
// keypair yields distinct AccountIDs on the strict-PQ vs permissive
|
|
// profile. A chain operator who migrates a key across profiles MUST
|
|
// re-register; the AccountIDs do not match.
|
|
//
|
|
// Chain separation: chain_id closes the "cross-chain pubkey reuse"
|
|
// attack class — an attacker who finds a pubkey collision on one chain
|
|
// cannot replay the colliding account on another chain because the
|
|
// chain_id prefix forces a distinct digest.
|
|
//
|
|
// Scheme separation: scheme byte ensures the same compact_pubkey under
|
|
// two different schemes yields distinct AccountIDs. Even if two PQ
|
|
// schemes shared a public-key byte layout (they do not), the scheme
|
|
// byte still keeps the accounts distinct on-chain.
|
|
//
|
|
// Empty compact_pubkey returns a defined-but-meaningless result; the
|
|
// caller is expected to refuse the empty pubkey before calling. See
|
|
// PQKeyRecord.Validate for the policy-gated entry point that performs
|
|
// that check.
|
|
func DeriveAccountID(profileID uint32, chainID uint32, scheme config.WalletSchemeID, compactPubkey []byte) [48]byte {
|
|
// Length-prefix the variable-length compact_pubkey by binding the
|
|
// fixed-width prefix (4 + 4 + 1 = 9 bytes) first and the pubkey
|
|
// last. cSHAKE256 absorbs in a single pass; the customization tag
|
|
// is the domain separator.
|
|
var buf []byte
|
|
buf = append(buf, u32BE(profileID)...)
|
|
buf = append(buf, u32BE(chainID)...)
|
|
buf = append(buf, byte(scheme))
|
|
buf = append(buf, compactPubkey...)
|
|
return shake256_384(buf, AccountIDCustomization)
|
|
}
|
|
|
|
// Hash returns the canonical 48-byte commitment over every record field.
|
|
// Bound via TupleHash256 with customization "LUX_PQ_KEY_RECORD_V1" so
|
|
// any byte flip on any field changes the digest. Used by the registry
|
|
// to build the leaves of identity_root / account_key_root.
|
|
//
|
|
// Signature: there is no signature on a record itself — the record is
|
|
// the witness; the op transcripts (register / rotate / revoke) carry
|
|
// the signatures. Record.Hash is what the ops sign over indirectly via
|
|
// their bound AccountID + CompactPubkeyHash fields.
|
|
func (r *PQKeyRecord) Hash() [48]byte {
|
|
if r == nil {
|
|
// Defined empty digest for the nil case. A nil record is a
|
|
// programmer error at the caller; we surface it as the
|
|
// canonical zero-record digest rather than panic so this is
|
|
// safe to call from hot paths.
|
|
return shake256_384(nil, "LUX_PQ_KEY_RECORD_NIL_V1")
|
|
}
|
|
parts := [][]byte{
|
|
[]byte("Lux/PQKeyRecord/v1"),
|
|
r.AccountID[:],
|
|
{byte(r.SchemeID)},
|
|
r.CompactPubkey,
|
|
r.CompactPubkeyHash[:],
|
|
r.ExpandedPubkeyHash[:],
|
|
{byte(r.ExpandedPubkeyLoc)},
|
|
{byte(r.Status)},
|
|
u64BE(r.ValidFromEpoch),
|
|
u64BE(r.ValidUntilEpoch),
|
|
r.RotationPolicyHash[:],
|
|
}
|
|
return tupleHash48(parts, "LUX_PQ_KEY_RECORD_V1")
|
|
}
|
|
|
|
// Validate runs the structural / policy checks the registry's Apply
|
|
// path runs before mutating state. Returns nil iff the record is
|
|
// admissible under the supplied profile.
|
|
//
|
|
// Checks (in order, fail-closed):
|
|
//
|
|
// 1. SchemeID is non-zero and matches profile.WalletSchemeID byte-for-byte.
|
|
// 2. SchemeID.IsPostQuantum() is true (strict-PQ profiles refuse
|
|
// classical wallets via this check, defence-in-depth on top of
|
|
// profile.ForbidECDSAWallets).
|
|
// 3. CompactPubkey length matches the scheme's expected width
|
|
// (ML-DSA-65: 1952 bytes; ML-DSA-87: 2592 bytes).
|
|
// 4. CompactPubkeyHash equals SHAKE256-384(CompactPubkey).
|
|
// 5. AccountID equals DeriveAccountID(profile.ProfileID, chain_id,
|
|
// scheme, compact_pubkey) for the provided chain_id.
|
|
// 6. ExpandedPubkeyLoc.IsKnown() is true.
|
|
// 7. Status.IsTerminal() is false (a fresh record is never terminal).
|
|
// 8. ValidUntilEpoch is 0 OR >= ValidFromEpoch.
|
|
func (r *PQKeyRecord) Validate(profile *config.ChainSecurityProfile, chainID uint32) error {
|
|
if r == nil {
|
|
return ErrRegistryNilRecord
|
|
}
|
|
if profile == nil {
|
|
return ErrRegistryNilProfile
|
|
}
|
|
if r.SchemeID == config.WalletSchemeInvalid {
|
|
return ErrRegistryZeroScheme
|
|
}
|
|
if r.SchemeID != profile.WalletSchemeID {
|
|
return ErrRegistrySchemeMismatch
|
|
}
|
|
if !r.SchemeID.IsPostQuantum() {
|
|
return ErrRegistrySchemeForbidden
|
|
}
|
|
if !r.ExpandedPubkeyLoc.IsKnown() {
|
|
return ErrRegistryUnknownPubkeyLoc
|
|
}
|
|
if r.Status.IsTerminal() {
|
|
return ErrRegistryRecordTerminal
|
|
}
|
|
if r.ValidUntilEpoch != 0 && r.ValidUntilEpoch < r.ValidFromEpoch {
|
|
return ErrRegistryValidityInverted
|
|
}
|
|
expectedLen, ok := compactPubkeyLen(r.SchemeID)
|
|
if !ok {
|
|
return ErrRegistrySchemeForbidden
|
|
}
|
|
if len(r.CompactPubkey) != expectedLen {
|
|
return ErrRegistryPubkeyLen
|
|
}
|
|
computedHash := shake256_384(r.CompactPubkey, "LUX_PQ_PUBKEY_HASH_V1")
|
|
if computedHash != r.CompactPubkeyHash {
|
|
return ErrRegistryPubkeyHashMismatch
|
|
}
|
|
derivedID := DeriveAccountID(profile.ProfileID, chainID, r.SchemeID, r.CompactPubkey)
|
|
if derivedID != r.AccountID {
|
|
return ErrRegistryAccountIDMismatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compactPubkeyLen returns the canonical compact-pubkey byte length for
|
|
// the named scheme. Only schemes that are admissible on production
|
|
// strict-PQ profiles are listed; any other scheme returns (0, false)
|
|
// which Validate translates into ErrRegistrySchemeForbidden.
|
|
func compactPubkeyLen(s config.WalletSchemeID) (int, bool) {
|
|
switch s {
|
|
case config.WalletSchemeMLDSA65:
|
|
return 1952, true // FIPS 204 ML-DSA-65 public-key byte length.
|
|
case config.WalletSchemeMLDSA87:
|
|
return 2592, true // FIPS 204 ML-DSA-87 public-key byte length.
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// =============================================================================
|
|
// Typed errors
|
|
// =============================================================================
|
|
|
|
var (
|
|
// ErrRegistryNilRecord — a Validate / Hash receiver was nil where a
|
|
// real value is required.
|
|
ErrRegistryNilRecord = errors.New("registry: nil record")
|
|
|
|
// ErrRegistryNilProfile — caller passed nil profile to Validate.
|
|
ErrRegistryNilProfile = errors.New("registry: nil profile")
|
|
|
|
// ErrRegistryZeroScheme — SchemeID was config.WalletSchemeInvalid.
|
|
ErrRegistryZeroScheme = errors.New("registry: SchemeID is zero")
|
|
|
|
// ErrRegistrySchemeMismatch — SchemeID does not match the profile's
|
|
// pinned WalletSchemeID.
|
|
ErrRegistrySchemeMismatch = errors.New("registry: SchemeID does not match profile.WalletSchemeID")
|
|
|
|
// ErrRegistrySchemeForbidden — SchemeID is not in the admissible
|
|
// strict-PQ set (e.g. classical ECDSA marker).
|
|
ErrRegistrySchemeForbidden = errors.New("registry: SchemeID forbidden under strict-PQ")
|
|
|
|
// ErrRegistryUnknownPubkeyLoc — ExpandedPubkeyLoc was not one of
|
|
// {inline, state-trie, offchain}.
|
|
ErrRegistryUnknownPubkeyLoc = errors.New("registry: ExpandedPubkeyLoc unknown")
|
|
|
|
// ErrRegistryRecordTerminal — record is in a terminal status
|
|
// (rotated / revoked) and cannot be registered as a new record.
|
|
ErrRegistryRecordTerminal = errors.New("registry: record status is terminal")
|
|
|
|
// ErrRegistryValidityInverted — ValidUntilEpoch < ValidFromEpoch
|
|
// (with no-expiry as the only exception, encoded as ValidUntil=0).
|
|
ErrRegistryValidityInverted = errors.New("registry: ValidUntilEpoch precedes ValidFromEpoch")
|
|
|
|
// ErrRegistryPubkeyLen — CompactPubkey length did not match the
|
|
// scheme's declared public-key byte length.
|
|
ErrRegistryPubkeyLen = errors.New("registry: CompactPubkey length does not match scheme")
|
|
|
|
// ErrRegistryPubkeyHashMismatch — CompactPubkeyHash did not equal
|
|
// SHAKE256-384(CompactPubkey).
|
|
ErrRegistryPubkeyHashMismatch = errors.New("registry: CompactPubkeyHash mismatch")
|
|
|
|
// ErrRegistryAccountIDMismatch — DeriveAccountID(...) did not
|
|
// equal record.AccountID. Wrong derivation inputs.
|
|
ErrRegistryAccountIDMismatch = errors.New("registry: AccountID does not match DeriveAccountID")
|
|
)
|