account

package
v1.17.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 6, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

Documentation

Overview

Package account implements PQ-native wallet account types.

A wallet account is a triple (scheme, role, key material) together with a 48-byte AccountID. AccountIDs are derived deterministically from the public key and the chain-id under cSHAKE-256 with NIST SP 800-185 customization strings, so the same key under different (chain, scheme) pairs yields different AccountIDs. This is the safety property that lets a strict-PQ chain refuse a key that was issued for a permissive chain without re-running classification.

The scheme enum mirrors consensus/config.SigSchemeID byte assignments so the wire-level identifier of a wallet's signing scheme matches what the consensus profile pins. The wallet package declares its own enum (rather than importing config.WalletSchemeID) because the consensus security_profile.go has not yet committed the WalletSchemeID type; when that lands, this package's IDs will map 1:1 by byte value.

Recovery accounts live in a separate enum (RecoverySchemeID) because the signature scheme used for cold-key recovery is stateless hash-based (SLH-DSA / FIPS 205) and is intentionally NOT in the wallet's hot signing path. RecoverySchemeID values are disjoint from WalletSchemeID values so a recovery key cannot be mistaken for a hot key at the type boundary.

Index

Constants

View Source
const (
	CSHAKECustomizationIdentity  = "LUX/WALLET/IDENTITY/V1"
	CSHAKECustomizationTx        = "LUX/WALLET/TX/V1"
	CSHAKECustomizationSession   = "LUX/WALLET/SESSION/V1"
	CSHAKECustomizationRecovery  = "LUX/WALLET/RECOVERY/V1"
	CSHAKECustomizationAccountID = "LUX/WALLET/ACCOUNT_ID/V1"
)

CSHAKE customization strings, per NIST SP 800-185 §3.3 (cSHAKE). The customization string ("S") is what makes two cSHAKE invocations with the same input message produce independent outputs. We use it three ways:

  1. Per-role child-seed expansion. The BIP-32 child seed at m/44'/9000'/<chain>'/0'/<role>'/<account>' is expanded through cSHAKE-256 with the role's customization string into the 32-byte ξ that FIPS 204 §5.1 KeyGen consumes. Without the customization, a single leaked child seed would be re-usable across roles.

  2. AccountID derivation. AccountIDs hash the (chain, scheme, pubkey) triple under cSHAKE-256 with the "ACCOUNT_ID" customization so the same pubkey under different chains/schemes produces independent AccountIDs.

  3. Recovery-seed expansion. SLH-DSA recovery accounts use the same role-customized cSHAKE-256 to expand the BIP-32 child seed into the three n-byte seeds (skSeed, skPrf, pkSeed) that FIPS 205 KeyGen consumes.

The strings are versioned ("/V1") so a future scheme rotation (e.g., migration to ML-DSA-87 by default or to a new derivation tree shape) can land without invalidating already-issued V1 accounts.

View Source
const AccountIDSize = 48

AccountIDSize is the fixed 48-byte length of an AccountID. 48 bytes == 384 bits == matches the SHAKE256-384 output the consensus layer expects for identity hashes (ChainSecurityProfile.MinHashOutputBits >= 384 on strict-PQ). Bigger AccountIDs would not give more collision resistance (we're already past the SHA-384 birthday bound for any realistic chain size) and smaller ones would force a hash-suite mismatch with strict-PQ.

Variables

This section is empty.

Functions

func SealPQAccount

func SealPQAccount(account *PQAccount, passphrase []byte) ([]byte, error)

SealPQAccount AEAD-seals an account's private key under a passphrase using Argon2id (RFC 9106) → AES-256-GCM. Returns a JSON-encoded keystore blob suitable for persistence. The passphrase is consumed via a copy; callers SHOULD zeroize their own copy after the call returns.

Future migration: a V2 SealPQAccountKEM will land that wraps the DEK under ML-KEM-768 derived from passphrase+salt, so the Argon2id step becomes the KEM-shared-secret step. The blob Magic switches from LUXKSV1 to LUXKSV2 then; readers are dispatched by Magic.

func SignTx

func SignTx(account *PQAccount, env TxAuthEnvelope) error

SignTx is the high-level entry point: ask the envelope for its canonical digest, sign the digest with the account, attach the signature back to the envelope. Returns an error if any of the three steps fails.

Wallet callers should prefer SignTx over calling account.Sign directly. Direct callers must also remember to AttachSignature, and errors at that step are silent failures (no signed envelope) which SignTx surfaces explicitly.

Types

type AccountID

type AccountID [AccountIDSize]byte

AccountID is the 48-byte deterministic identifier for a wallet account. Computed as cSHAKE-256(N="LUX_ACCOUNT_V1", S="LUX/WALLET/ACCOUNT_ID/V1", msg = u32be(chain_id) || u8(scheme) || pubkey, outlen = 48 bytes).

func DeriveAccountID

func DeriveAccountID(chainID uint32, scheme WalletSchemeID, pubkey []byte) (AccountID, error)

DeriveAccountID returns the canonical 48-byte AccountID for a (chain, scheme, pubkey) triple. Pure function; same inputs → same output.

Errors only on pubkey == nil. Empty pubkey is permitted (yields a well-defined "uninitialised account" AccountID); callers that want to reject empty pubkeys should do so before calling.

type AccountRole

type AccountRole string

AccountRole identifies what an account is for. Roles map to distinct HD derivation indices so the same master seed cannot accidentally produce the same key under two roles, and so a chain can refuse a key whose declared role doesn't match the operation it signs.

const (
	AccountRoleIdentity AccountRole = "identity"
	AccountRoleTx       AccountRole = "tx"
	AccountRoleRecovery AccountRole = "recovery"
	AccountRoleSession  AccountRole = "session"
)

func (AccountRole) HDIndex

func (r AccountRole) HDIndex() (uint32, error)

HDIndex returns the conventional HD-derivation role index for this role. Callers of NewPQAccount may pass this verbatim as the roleIdx argument, or pin a different index for application-specific reasons (multiple tx keys per identity, etc.). Indices are stable across implementations; changing them is a wallet-incompatible rewrite.

AccountRoleRecovery returns an error because SLH-DSA recovery keys are NOT derived on the wallet ML-DSA branch; they have their own derivation path (see recovery.go).

type KeystoreBlob

type KeystoreBlob struct {
	Magic     [8]byte `json:"magic"`      // keystoreMagic
	Version   uint8   `json:"version"`    // 1
	SchemeID  uint8   `json:"scheme_id"`  // WalletSchemeID byte
	Role      string  `json:"role"`       // AccountRole string
	ChainID   uint32  `json:"chain_id"`   // for AccountID re-derivation
	Path      string  `json:"path"`       // derivation path, audit only
	AccountID string  `json:"account_id"` // hex(48), for human lookup

	Salt       []byte `json:"salt"`       // 16 bytes
	Nonce      []byte `json:"nonce"`      // 12 bytes (AES-GCM)
	Ciphertext []byte `json:"ciphertext"` // GCM(pubkey || privkey)
	PubkeyLen  uint32 `json:"pubkey_len"` // splits the plaintext

	// Argon2id parameters frozen at seal-time so a passphrase that
	// took 64 MiB to derive in 2026 still unlocks in 2032 even if
	// defaults shift.
	ArgonTime    uint32 `json:"argon_time"`
	ArgonMemory  uint32 `json:"argon_memory_kib"`
	ArgonThreads uint8  `json:"argon_threads"`
}

KeystoreBlob is the persisted on-disk shape. JSON-encoded so the blob is human-inspectable for audit purposes; the actual private-key material is inside Ciphertext and is AES-256-GCM sealed under a key derived from the user's passphrase via Argon2id.

Forward-compat: a future V2 blob would have a different Magic and a distinct field set (e.g., a KEM-wrapped DEK). Reading code dispatches on Magic before unmarshalling.

type PQAccount

type PQAccount struct {
	AccountID      AccountID
	SchemeID       WalletSchemeID
	PublicKey      []byte
	PrivateKey     []byte
	Role           AccountRole
	DerivationPath string
}

PQAccount is an ML-DSA-65 native wallet account.

Invariants:

  • SchemeID is one of {WalletSchemeMLDSA44, WalletSchemeMLDSA65, WalletSchemeMLDSA87}. The constructor refuses any other scheme.
  • PublicKey is the packed FIPS-204 public key bytes; never nil.
  • PrivateKey is the packed FIPS-204 private key bytes; never nil for accounts produced by NewPQAccount. Callers MUST wrap it through the keystore before persisting to disk.
  • AccountID == DeriveAccountID(chainID, SchemeID, PublicKey).

The struct is a deliberately flat data carrier — no methods that mutate state. Sign/Verify operate on a value receiver so the type is safe to pass by value or by pointer.

func NewPQAccount

func NewPQAccount(
	masterSeed []byte,
	chainID uint32,
	accountIdx uint32,
	roleIdx uint32,
	role AccountRole,
) (*PQAccount, error)

NewPQAccount derives a fresh ML-DSA-65 keypair from a domain-separated child seed under the F35 HD path.

masterSeed   — the BIP-39 seed (caller decides mnemonic provenance).
chainID      — uint32 network id, < 2^31 (BIP-32 hardening).
accountIdx   — leaf account index, < 2^31.
roleIdx      — hardened role index on branch 0' (< 2^31). Orthogonal
               from `role`: the HD path uses this verbatim, the cSHAKE
               customization is driven by `role`. Conventional indices
               (0=identity, 1=tx, 2=session) are returned by
               AccountRole.HDIndex but the caller may pass any value.
role         — identity | tx | session. Recovery role is refused; use
               NewSLHDSARecoveryAccount instead. Drives the cSHAKE
               customization string and is persisted on the struct.

The keypair is generated as:

child_seed = m/44'/9000'/<chainID>'/0'/<roleIdx>'/<accountIdx>'
ξ          = cSHAKE-256(N="LUX_PQ_KEYGEN_V1",
                        S="LUX/WALLET/<ROLE>/V1",
                        child_seed,
                        outlen = 32)
(pk, sk)   = ML-DSA-65.KeyGen(ξ)

The cSHAKE customization binds the keypair to its role. A leaked child seed cannot be re-used to derive a key of a different role because the customization string is mixed into the cSHAKE state before the seed.

func OpenPQAccount

func OpenPQAccount(blobBytes []byte, passphrase []byte) (*PQAccount, error)

OpenPQAccount inverts SealPQAccount. The caller supplies the passphrase and the blob bytes; on success the account is fully reconstructed including its AccountID (re-derived from the unsealed pubkey rather than trusted from the blob, so tampering with the AccountID hex field is detected at unseal time).

func (*PQAccount) ChainID

func (a *PQAccount) ChainID() uint32

ChainID parses the chain id back out of the derivation path. Returns 0 for an account with no path (an account that was constructed by hand rather than through NewPQAccount). The path always starts with "m/44'/9000'/<chainID>'/" so a single Scanf suffices.

func (*PQAccount) Sign

func (a *PQAccount) Sign(digest [AccountIDSize]byte) ([]byte, error)

Sign signs a 48-byte digest (typically from TxAuthEnvelope.SigningDigest()).

The 48-byte length is enforced — callers MUST hash to 384 bits before calling Sign, both to keep the on-wire transcript at the MinHashOutputBits floor that strict-PQ profiles enforce and to keep signature inputs deterministic-sized.

Signing uses FIPS-204's "pure" Sign with a Lux-specific context string ("LUX/WALLET/TX/V1") so signatures issued by this wallet cannot be replayed against a different protocol/version. Signing is randomized (the FIPS-204 default).

func (*PQAccount) Verify

func (a *PQAccount) Verify(digest [AccountIDSize]byte, sig []byte) bool

Verify is a thin wrapper around the ML-DSA-65 verify routine. Returns true iff sig is a valid signature of digest under a.PublicKey with the "LUX/WALLET/TX/V1" context.

type RecoveryAccount

type RecoveryAccount struct {
	AccountID      AccountID
	SchemeID       RecoverySchemeID
	PublicKey      []byte
	PrivateKey     []byte
	DerivationPath string
}

RecoveryAccount is an SLH-DSA-SHAKE-192s (FIPS 205, NIST Cat 3) account used as a stateless cold-key recovery anchor. Recovery accounts NEVER sign hot-path transactions; they sign account rotation / re-keying envelopes that the consensus layer pins under a higher policy (typically a multisig of N recovery accounts).

Invariants:

  • SchemeID == RecoverySchemeSLHDSA192s.
  • PublicKey is the packed FIPS-205 public key (2 * n = 48 bytes for 192s).
  • PrivateKey is the packed FIPS-205 private key (4 * n = 96 bytes for 192s).
  • AccountID == DeriveAccountID(chainID, WalletSchemeNone(...). Recovery accounts are tagged with a synthetic scheme byte that is NOT a hot WalletSchemeID, so AccountIDs of recovery vs hot keys are domain-separated even if they share a public key value space.

func NewSLHDSARecoveryAccount

func NewSLHDSARecoveryAccount(masterSeed []byte, chainID uint32, accountIdx uint32) (*RecoveryAccount, error)

NewSLHDSARecoveryAccount derives a fresh SLH-DSA-SHAKE-192s keypair from a domain-separated child seed under a dedicated HD path (m/44'/9000'/<chainID>'/2'/<accountIdx>'). The child seed is expanded through cSHAKE-256 with the "LUX/WALLET/RECOVERY/V1" customization into the 3 * n = 72-byte deterministic stream that FIPS-205 §10.1 KeyGen consumes as (skSeed, skPrf, pkSeed). The result is fully deterministic for a fixed (masterSeed, chainID, accountIdx).

Private keys produced here MUST be moved to an air-gapped store immediately; the in-memory copy returned on the struct is for the constructor's caller to wrap through the keystore before persisting.

func (*RecoveryAccount) Sign

func (r *RecoveryAccount) Sign(digest [AccountIDSize]byte) ([]byte, error)

Sign signs a 48-byte digest under the recovery key. Uses FIPS-205 SignDeterministic (no per-signature randomness) so recovery signatures are reproducible from the private key alone — a deliberate choice for air-gapped recovery flows where the signing host has no entropy source.

Context binds to "LUX/WALLET/RECOVERY/V1" so a recovery signature cannot be replayed as a hot wallet signature even on the same digest.

func (*RecoveryAccount) Verify

func (r *RecoveryAccount) Verify(digest [AccountIDSize]byte, sig []byte) bool

Verify returns true iff sig is a valid SLH-DSA-SHAKE-192s signature of digest under r.PublicKey with the "LUX/WALLET/RECOVERY/V1" context.

type RecoverySchemeID

type RecoverySchemeID uint8

RecoverySchemeID is the wire byte for stateless hash-based recovery signatures. Disjoint from WalletSchemeID so a recovery key cannot be confused with a hot signing key at the type boundary.

0x60..0x6F — SLH-DSA (FIPS 205), SHAKE-based parameter sets only.

0x61  — SLH-DSA-SHAKE-128s (NIST Cat 1)
0x62  — SLH-DSA-SHAKE-192s (NIST Cat 3; production default for cold recovery)
0x63  — SLH-DSA-SHAKE-256s (NIST Cat 5)
const (
	RecoverySchemeNone       RecoverySchemeID = 0x00
	RecoverySchemeSLHDSA128s RecoverySchemeID = 0x61
	RecoverySchemeSLHDSA192s RecoverySchemeID = 0x62 // production default
	RecoverySchemeSLHDSA256s RecoverySchemeID = 0x63
)

func (RecoverySchemeID) String

func (s RecoverySchemeID) String() string

String returns the canonical lowercase scheme name.

type TxAuthEnvelope

type TxAuthEnvelope interface {
	// SigningDigest returns the canonical 48-byte digest the wallet
	// signs. Must be deterministic for a fixed envelope state.
	SigningDigest() ([AccountIDSize]byte, error)

	// AttachSignature wires (schemeID, pubkey, sig) back into the
	// envelope. Implementations should store the triple verbatim; the
	// consensus verifier reconstructs the AccountID and scheme from
	// these bytes.
	AttachSignature(scheme WalletSchemeID, pubkey, sig []byte) error
}

TxAuthEnvelope is the wallet-side view of a transaction-authorization envelope. Callers (typically protocol/tx assemblers) implement this interface to expose the canonical 48-byte signing digest and to attach the wallet's signature back to the envelope after Sign returns.

Defined as an interface here rather than imported from a protocol package because the wallet account package is the canonical lowest layer — protocol/tx packages depend on account, not the other way around. Any envelope type that satisfies these two methods can be signed by SignTx.

The 48-byte (SHAKE256-384) digest size is the strict-PQ MinHashOutputBits floor; envelopes that produce a different digest size must rehash before exposing it through SigningDigest.

type WalletSchemeID

type WalletSchemeID uint8

WalletSchemeID is the wire byte that identifies which signature scheme a wallet account signs under. Byte values intentionally match the raw ML-DSA block (0x40..) defined in github.com/luxfi/consensus/config.SigSchemeID so a wallet's declared scheme is wire-compatible with the consensus identity-scheme byte.

0x00..0x3F — reserved (classical / non-PQ space, kept disjoint from PQ).

0x10  — secp256k1 (classical, allowed only under the classical-compat
        unsafe flag; strict-PQ chains refuse this).

0x40..0x4F — raw FIPS 204 ML-DSA, single-party identity signatures.

0x41  — ML-DSA-44 (NIST Cat 2; dev/testnet only).
0x42  — ML-DSA-65 (NIST Cat 3; production default).
0x43  — ML-DSA-87 (NIST Cat 5; high-value identities).
const (
	WalletSchemeNone      WalletSchemeID = 0x00
	WalletSchemeSecp256k1 WalletSchemeID = 0x10 // classical-compat (unsafe on strict-PQ chains)

	WalletSchemeMLDSA44 WalletSchemeID = 0x41
	WalletSchemeMLDSA65 WalletSchemeID = 0x42 // production default
	WalletSchemeMLDSA87 WalletSchemeID = 0x43
)

func (WalletSchemeID) IsClassicalCompat

func (s WalletSchemeID) IsClassicalCompat() bool

IsClassicalCompat reports whether this scheme is the legacy classical signature path. Allowed only on chains that opt into the classical-compat unsafe flag at genesis time; strict-PQ chains refuse it.

func (WalletSchemeID) IsPostQuantum

func (s WalletSchemeID) IsPostQuantum() bool

IsPostQuantum reports whether this scheme is a NIST-aligned PQ signature. Strict-PQ chains MUST refuse accounts whose scheme is not post-quantum.

func (WalletSchemeID) String

func (s WalletSchemeID) String() string

String returns the canonical lowercase scheme name.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL