The x/pqc module exposes a post-quantum (PQ) account registry so that user accounts can publish Dilithium public
keys alongside their legacy Ed25519 keys. Every transaction signed by an externally owned account (EOA) must include
a Dilithium signature. The only exception is a narrow IBC relayer-core bypass driven by ibc_relayer_allowlist:
allowlisted relayer addresses may submit relayer/core IBC transactions without the extra PQC extension, but user-facing
messages such as MsgTransfer still require PQC. Module accounts never sign transactions, so they are unaffected. The
registry feeds the dual-sign ante decorator and the policy is always enforced as REQUIRED (governance parameters remain
for backwards compatibility but are clamped in-memory).
- Each account may register a single Dilithium key via
MsgLinkAccountPQC. - The stored record contains the scheme identifier (currently
dilithium3), the SHA-256 hash of the public key, and the timestamp (added_at). Full keys never live on-chain; they are provided alongside signatures and hashed/verified on demand. - Rotation is permanently disabled; relinking with the exact same key is accepted as a no-op, but any attempt to replace the hash is rejected with
ErrAccountRotationDisabled. - Linking is gated by two guards:
min_balance_for_link– required spendable ULUMEN balance before broadcastingMsgLinkAccountPQC.pow_difficulty_bits– the transaction must include a nonce such thatsha256(pubkey || nonce)has at least this many leading zero bits.
Events emitted on successful linkage include the account address, scheme, and SHA-256 hash of the public key so that operators can index registry updates without storing full keys on-chain.
# Link the Dilithium3 key for the --from account (min-balance + PoW enforced automatically)
lumen tx pqc link-account --scheme dilithium3 --pubkey <hex|base64>
# Query the registered key for an address
lumen q pqc account <bech32-address>
# Inspect module parameters
lumen q pqc paramsThe CLI validates that the provided scheme matches the active backend (crypto/pqc/dilithium.Default()), refuses to
link if the account balance is below min_balance_for_link, and mines an appropriate nonce to satisfy
pow_difficulty_bits before broadcasting.
Production builds now expose the PQC helper commands under lumend keys so operators never have to rebuild custom
binaries:
# Generate a Dilithium3 keypair, store it locally, and link it to the "validator" keyring entry:
lumend keys pqc-generate --name validator-pqc --link-from validator \
--pqc-passphrase-file ~/.config/lumen/pqc_passphrase
# List stored keys and local bindings:
lumend keys pqc-list
# Produce the JSON snippet for genesis (app_state.pqc.accounts):
lumend keys pqc-genesis-entry --from validator \
--output validator-pqc.json \
--write-genesis ~/.lumen/config/genesis.jsonpqc-generate prints the public/private key hex by default and supports --pub-out / --priv-out to write them to
files, as well as --force to overwrite existing outputs. The generated key can be stored (--name) and linked to a
Cosmos key in one shot (--link-from). En ajoutant --pqc-passphrase-file ou la variable LUMEN_PQC_PASSPHRASE_FILE,
le keystore local (~/.lumen/pqc_keys/*.json) est chiffré ; sans passphrase, les clés restent en clair.
pqc-import importe toujours un couple fourni par un autre outil, et pqc-link relie un compte Cosmos à une clé locale.
pqc-genesis-entry inspecte la clé liée (ou celle passée via --pqc) et produit l’entrée JSON prête à être insérée
dans app_state.pqc.accounts. Avec --write-genesis, l’entrée est injectée directement dans le genesis.json
(un backup .bak est créé automatiquement).
| Parameter | Default | Description |
|---|---|---|
policy |
REQUIRED |
Runtime policy is forced to REQUIRED; other enum values are ignored and rejected in SetParams. |
min_scheme |
dilithium3 |
Minimum scheme identifier accepted for incoming signatures. |
min_balance_for_link |
1000ulmn |
Accounts must hold at least this spendable balance before linking. |
pow_difficulty_bits |
21 |
Required number of leading zero bits for `sha256(pubkey |
ibc_relayer_allowlist |
[] |
Relayer addresses allowed to bypass PQC for relayer/core IBC transactions only. |
Parameter updates are validated against the list of supported schemes exposed by the active backend. Account rotation is permanently disabled; attempting to re-link with a different key returns ErrAccountRotationDisabled. The PoW guard uses
big-endian encodings of the nonce bytes; clients increment the nonce until the digest carries enough leading zeros.
Governance does not reopen the full MsgUpdateParams surface for x/pqc. The only governable PQC mutations are the
dedicated MsgAddIBCRelayer and MsgRemoveIBCRelayer messages used to maintain the IBC relayer allowlist.
The PQCDualSignDecorator runs immediately after Ed25519 signature verification. If the transaction is composed only of
relayer/core IBC messages and every signer is in ibc_relayer_allowlist, the decorator bypasses the PQC extension
requirement. Otherwise, every EOA signature must pass the PQC checks. For each signer in the transaction:
-
The account’s registered PQC key is fetched from the
x/pqckeeper. -
The decorator attempts to locate a matching PQC signature in the transaction’s
TxBody.extension_options.
The payload must be encoded as alumen.pqc.v1.PQCSignaturesmessage where each entry contains:message PQCSignatureEntry { string addr = 1; // bech32 signer address string scheme = 2; // e.g. "dilithium3" bytes signature = 3; // Dilithium signature over the PQC payload bytes pub_key = 4; // Raw Dilithium public key (hash checked against registry) }
-
The signature is verified against the Dilithium backend (
crypto/pqc/dilithium.Default()).
The PQC payload reuses the standard Cosmos SDK SignDoc, but with a domain separator:
PQCv1: || SignDoc{
body_bytes: sanitized_body_bytes, // TxBody with any PQC signature extensions stripped
auth_info_bytes: tx_raw.auth_info_bytes,
chain_id: ctx.ChainID(),
account_number: signer_account_number,
}
Transactions should first be assembled without the PQC signature, the payload computed as above, and only then
should the final signature be inserted into the PQCSignatures extension.
Runtime policy is always REQUIRED:
| Situation | Behaviour |
|---|---|
| Missing key | Reject (ErrPQCRequired) |
| Missing signature | Reject (ErrMissingExtension / ErrPQCRequired) |
| Invalid signature | Reject (ErrPQCVerifyFailed) |
Successful verifications emit a pqc.verified event containing the signer address and scheme.
A lightweight keystore stores Dilithium testing keys under ~/.lumen/pqc_keys. The keystore is intentionally
plaintext (for operator convenience); ensure the directory is protected at the OS level or migrate the keys to an HSM
when you harden your environment. The following helpers are always available:
# Generate and link a new Dilithium keypair
lumend keys pqc-generate --name validator-pqc --link-from validator \
--pqc-passphrase-file ~/.config/lumen/pqc_passphrase
# Import a Dilithium keypair (private key provided as hex/base64)
lumend keys pqc-import --name local-dilithium --scheme dilithium3 \
--pubkey <hex-or-base64> --privkey <hex-or-base64>
# Show the imported keys and local bindings
lumend keys pqc-list
# Display a single key (prints the fingerprint and raw bytes)
lumend keys pqc-show local-dilithium
# Link a cosmos keyring entry to the imported PQC key
lumend keys pqc-link --from validator --pqc local-dilithium
# Produce the JSON entry for app_state.pqc.accounts
lumend keys pqc-genesis-entry --from validator --output validator-pqc.json \
--write-genesis ~/.lumen/config/genesis.jsonDuring transaction assembly, the CLI signs Ed25519 first and then augments the tx with PQC signatures. PQC signing is
enabled by default and controlled globally via the tx command’s persistent flags:
| Flag | Default | Purpose |
|---|---|---|
--pqc-enable |
true |
Toggle PQC signing for the current transaction. |
--pqc-scheme |
dilithium3 |
Select the target scheme (must match on-chain params). |
--pqc-from + --pqc-key |
[] |
Optional override mapping of signer addresses to local PQC key names. |
Example bank transfer (the PQC signature is embedded automatically; no manual extension management is required):
lumend tx bank send alice bob 10ulmn \
--pqc-enable \
--pqc-key local-dilithiumThe signer-specific overrides are useful for multi-signer flows; otherwise the CLI falls back to the local binding
established via keys pqc-link. When --pqc-enable=false, the CLI omits the PQC extension entirely.
make build
HOME=$(mktemp -d) make e2e
# or run the single flow
make e2e-pqcThis script generates a Dilithium3 key via CIRCL, imports it into the local PQC keystore, links the key on-chain,
sends a PQC-signed bank transaction (expected to pass), then retries the same transfer with --pqc-enable=false
which should fail when PqcPolicy=REQUIRED. Disable the client-side injector explicitly only when you need to
exercise failure paths or when the chain policy is permissive.
Two production-grade backends are available under crypto/pqc/dilithium:
- Circl (default) – pure Go implementation provided by
github.com/cloudflare/circl/sign/dilithium. - PQClean fallback – builds on the AES-enhanced Dilithium3 variant (compiled when
-tags pqc_oqsis supplied).
Default() tries Circl first; if that backend is unavailable it falls back to the PQClean path when pqc_oqs
is enabled, and otherwise panics. CI workflows enforce that pqc_testonly/noop symbols never appear in release
binaries, and cmd/lumend panics during init() if an unexpected backend name is detected.
All CLI and E2E scripts run with PQC enabled by default. The only supported way to bypass the decorator is to pass
--pqc-enable=false on a single transaction, which should then fail on-chain when policy=REQUIRED. Test harnesses may
flip that flag to ensure the failure path is exercised, but there is no global “disable PQC” environment variable for
the application.