Skip to content

Security: network-lumen/blockchain

Security

docs/security.md

Security Checklist

  • Rate limiting is two halves. The global sliding window runs early, before any work is done, and needs no identity. The per-account caps run after signature verification, because firstSigner reads the signer out of the message body: before verification that is a forgeable claim, and charging it there let a handful of malformed transactions lock any address out of any reachable node, for free and with no key. Neither half holds the limiter's mutex while the rest of the ante chain runs.

  • Gasless ante: the custom rate-limit decorator relies on:

    • LUMEN_RL_PER_BLOCK (default 5)
    • LUMEN_RL_PER_WINDOW (default 20)
    • LUMEN_RL_WINDOW_SEC (default 10)

    The binary refuses to start unless --minimum-gas-prices is unset or exactly 0ulmn, guaranteeing that gasless transactions remain valid while ante decorators enforce quotas. Ante-time fee checks are selective: gasless native txs must keep fee 0, IBC fee-bearing txs must carry a positive ulmn fee, and staking.MsgEditValidator must pay exactly 1000000ulmn.

  • Operator guard: Startup aborts when --minimum-gas-prices is non-zero; this prevents accidental deployment of fee-bearing configurations.

  • DNS payload bounds: MsgRegister/MsgUpdate enforce ≤64 records and ≤16 KiB combined payload. Oversized requests are rejected early.

  • Where fees go: every module fee — DNS registration, renewal, transfer, update, bids, and the gateways action fee — is routed to the community pool through the distribution keeper. The fee collector is a fallback used only when that keeper is unavailable. This is deliberate: anti-spam revenue must not reach block producers, or they gain a stake in the spam. Monitor the community pool, not the fee collector.

  • Bids are escrowed: MsgBid moves the bid to the module account when it is placed and refunds the previous bidder in full when they are outbid. Before that, a bid was a promise — anyone could name an arbitrary figure from an empty wallet, leaving nobody able to outbid and settlement failing forever.

  • Release spam: only addresses listed in Params.allowed_publishers may publish releases. Tune publish_fee_ulmn and max_pending_ttl to discourage abusive submissions.

  • Proposal submission is a bond, not a cost. min_initial_deposit_ratio is 0, so the floor comes from min_deposit_ratio (0.01) at 0.1 LMN, refunded when an under-funded proposal expires after 48 h. Neither the veto burn nor the full 10 LMN deposit applies on that path, because it never reaches voting. Raising the ratio to 1.0 removes the path — see "Votes to run after the upgrade" in docs/governance.md. min_deposit_ratio itself is not reachable by vote.

  • Governance vote churn: revotes on the same proposal are throttled by a per-(proposal_id, voter) cooldown of 10 blocks; the first vote needs no cooldown record. A vote from an account holding less than min_voting_stake_ulmn (1 LMN, governed by MsgUpdateMinVotingStake) is refused outright: it adds almost nothing to the tally, while the tally still iterates that voter's delegations and deletes the vote inside gov's EndBlocker — one block, every validator, and an error there is fatal. The guard used to ask only whether the voter held any delegation, which one ulmn satisfied, so it named a cost and set it to dust. It prices the flood rather than bounding it: an attacker with capital C can still cast C / threshold votes, and the figure is nominal, so its protection erodes as bonded stake grows. Bounding the walk means replacing gov's tally through CustomCalculateVoteResultsAndVotingPowerFn, which this release deliberately does not do.

  • Distribution churn: MsgSetWithdrawAddress rejects no-op updates and applies a 20-block cooldown per delegator. MsgWithdrawDelegatorReward rejects zero-reward withdrawals.

  • Disabled messages: MsgFundCommunityPool, MsgDepositValidatorRewardsPool and MsgCancelUnbondingDelegation are refused by BlockedMsgDecorator in CheckTx and DeliverTx alike, so a block proposer cannot include one either. The last of these gave back the max_entries slot an undelegation had just consumed, which made that cap a decoration.

  • Multisig accounts cannot transact. This is a property of the chain, not a gap. PQC is mandatory and an account carries exactly one registered Dilithium key, so there is nothing to verify a T-of-K aggregate against: the ante refuses MultiSignatureData before anything else, whatever the account's link state. A multisig address can receive funds and never move them. Linking a PQC key to it changes nothing. Supporting multisig means a set of keys and a threshold in the registry, one Dilithium signature per sub-key — a 5-of-8 is ~26 KB of signatures alone — and is its own piece of work. Note also that x/authz, x/group and x/feegrant are not wired, so there is at present no shared-custody primitive on this chain: plan treasury custody accordingly. The client refuses to build one: keys add --multisig and tx sign --multisig are rejected with the reason, and tx multi-sign / tx multisign-batch are not registered at all. That guard lives in the CLI rather than on the chain because the chain cannot have it — an address is sha256(pubkey)[:20] whatever the key behind it, the only on-chain signal is the public key stored on the account, and that is written by SetPubKeyDecorator inside the ante. An ante that fails writes nothing and a multisig ante always fails, so the key is never stored: when funds arrive at a fresh multisig address the chain cannot tell it from any other new account. The intervention has to happen before the address exists.

  • An unlinked account has no post-quantum protection, and linking is one-way. Under policy=REQUIRED an account with no Dilithium key can send exactly one message — its own MsgLinkAccountPQC — and everything else is refused. Until that lands, the account is protected by secp256k1 and nothing else, which is the assumption this whole chain exists to stop making. Whoever produces a valid classical signature first decides which Dilithium key the address carries, and SetAccountPQC refuses every replacement afterwards, so there is no second attempt and no rotation.

    This is not a gap that can be closed. A first key has to arrive somehow, and the only authority that exists before it arrives is the classical one. What is bounded is the window: the exemption reads the account's link state, so it shuts the moment a key is stored, and no bundled link message re-opens it — TestPQC_UnlinkedAccountIsProtectedByTheClassicalKeyAlone in app/ measures both halves end to end on a real node.

    So the rule is operational, and it has two directions:

    • Link before funding. A cold address, a validator's withdraw address, an exchange deposit address — anything that only ever receives and never signs — is not covered by this chain's post-quantum guarantee until it has linked, however much it holds. Treasury procedure should link an address as its first act, not its first spend.
    • Back the Dilithium key up like the account depends on it, because it does. There is no rotate verb, by design. A lost key leaves the funds visible, attributable and permanently unspendable — the account can neither transact nor re-link. Store it as described under "PQC key material" below, and test the restore.

    Note the shape of the risk, since it is the opposite of the usual one: what is dangerous is not linking early, and what is unrecoverable is losing the key after.

  • DNS update PoW: the digest is sha256(fqdn|creator|updated_at|nonce). It commits to updated_at so the work is a cost per update rather than a one-time cost per (name, owner) pair — a nonce stops being valid the moment the update it authorised lands. Two updates inside the same second read the same updated_at and so share a nonce; update_rate_limit_seconds (60 on mainnet) is what closes that, and at 0 the proof is not the bound anyway.

  • Send tax scope. The tax applies to the base denomination only, matching min_send_ulmn's scope. It used to apply to every denomination, and the ante refuses a coin whose tax truncates to zero — so at a 1% rate no amount under 100 base units of anything could move, which on a bridged asset was an undocumented floor with nothing behind it.

  • Signature count: tx_sig_limit (7) is enforced by SigCountDecorator, ahead of the rate limiter. The SDK's own ValidateSigCountDecorator is deliberately not installed: it counts CountSubKeys over GetPubKeys(), which is nil for any signer that omits its public key, so a transaction whose signers all leave that field empty would count as zero. Keys are resolved from the account before being counted, and multisig sub-keys count individually.

    The decorator also decodes TxRaw directly and requires equal lengths for TxRaw.Signatures, SignerInfos and derived signers. Both a missing signature entry and a surplus raw signature are rejected before signature verification, preventing ignored bytes and the SDK wrapper's out-of-range access.

  • Message count: every transaction is limited to 64 messages by MaxMessagesDecorator, before the message guards, signature verification and state reads. This applies in both CheckTx and DeliverTx; the node-local rate limiter remains an additional admission control, not the consensus message bound.

  • The IBC exemption follows the relayer, not the message type — and it buys fees, not admission. Core IBC messages are exempt from the per-message fee when every signer is on ibc_relayer_allowlist, because relayers submit roughly a hundred client updates for every user transfer and pricing them would end the relaying IBC depends on. That exemption used to be granted on the message type, so anyone could claim it — and the most expensive computation this application performs, light client verification, sat behind it at one ulmn. It now follows the same list that governs the PQC bypass. An allowlisted relayer keeps today's fee behaviour exactly; everyone else pays the ordinary price. Nobody is refused. Core messages are counted and charged individually, so a transaction mixing a MsgTransfer with two client updates pays for the transfer and both updates: only an all-core transaction from allowlisted signers is subsidised, and a mixed one never inherits it.

    The per-account rate limit exempts nobody, on the allowlist or off it. Admission bounds the work this node performs before anything has been paid for, which is not what an allowlist entry buys, so a relayer takes its turn like every other account. Those limits are node-local CheckTx policy — LUMEN_RL_PER_BLOCK, LUMEN_RL_PER_WINDOW, LUMEN_RL_WINDOW_SEC — so a relayer needing a wider window runs its own node.

    The global rate-limit window is the one place IBC traffic is still skipped, and the asymmetry is intentional: it runs before signature verification, where the signer is an unauthenticated claim, so gating it there would look like a control and be none.

  • Client updates are bounded independently of who sends them. IBCClientGuardDecorator refuses a trust_level of 1/1 — the commit check is strictly greater-than, so such a client can accept no header at all and exists only to make every update verify the whole set — any trust_level above 2/3, and any header carrying more than 512 validators or commit signatures, against CometBFT's MaxVotesCount of 10 000. A price bounds how often; this bounds how much. It is not conditional on the allowlist, so a stolen relayer key does not unlock it.

    The 2/3 ceiling is the second half of the same rule, and it was missing. Refusing only 1/1 removed the value where the early exit is unreachable and left every value where it is merely expensive: a commit check stops once the tally passes the trust level, so at 999/1000 it stops after 99.9 % of the set — one thousandth cheaper than the value that was refused. Two thirds is the highest value relayer tooling exposes, so nothing anyone runs is affected: 1/3, what every real chain uses, and 2/3 both pass. It applies to MsgCreateClient only, so no client that already exists is touched.

  • Message prices: delegate_fee_ulmn, redelegate_fee_ulmn, transfer_fee_ulmn and set_withdraw_addr_fee_ulmn are charged in the ante, before the message runs, so a transaction that fails still pays for the block space it took. All four ship in force at 1000ulmn, written by the v2.0.0 upgrade handler rather than left to a vote: the gap between the upgrade height and the first successful proposal is four days at minimum, and it would otherwise be the window in which every one of them is free. Governance changes them afterwards, to zero included. See docs/messages.md for the full cost surface.

  • Identity has a price: the link proof-of-work commits to the account address — sha256(creator || "|" || pub_key || nonce). It did not, once, which meant one solved nonce could link unlimited accounts. Several bounds assume an account costs something: validator creation, max_entries per account, and the per-signer rate limit. Keep link_fee_ulmn above zero, or raise pow_difficulty_bits. v2.0.0 satisfies this by writing link_fee_ulmn = 1000 in its upgrade handler, which matters because the same handler sets pow_difficulty_bits to 0 — the new digest would otherwise reject every nonce mined by a client still on the old formula. Until the difficulty is voted back up, the fee is the only one of the two costs in force, so a proposal taking it to zero removes the last one.

  • No gas means bytes are the only ceiling: there is no cap on a transaction's declared gas_limit and max_gas is -1, so per-transaction computation is bounded only by max_bytes. Any handler whose cost grows faster than its input size is a vector; review new handlers against that, not against gas.

  • REST exposure: place a TLS-enabled reverse proxy with per-IP rate limiting in front of :2327 if the API is exposed to the public internet.

  • Keys: avoid the test keyring backend on production nodes. Prefer OS keyrings, KMS, or HSM integration for validator and publisher keys.

  • Governance: parameter changes go through MsgUpdateParams (per-module authority). Document decisions and keep defaults version-controlled to aid audits.


PQC policy (REQUIRED)

  • PQC is mandatory for EOA-signed transactions: the keeper forces policy=REQUIRED and a missing or invalid signature is rejected. There is no exemption by message type.
  • There are exactly three exemptions, all by signer rather than by message:
    • An account that has not linked yet. Nobody can PQC-sign the transaction that registers their first PQC key with the key it registers, so pendingLinkForSigner lets a signer through when the transaction carries their own MsgLinkAccountPQC and the account holds no key. It is keyed to the account being unlinked, not to the link message, so it covers every other message in the same transaction — a classical signature alone moves money. See "An unlinked account has no post-quantum protection" above for the operational consequence.
    • IBC relayers on the allowlist. shouldBypassPQCSigForIBCRelayer skips the check entirely when every message in the transaction is an IBC core message and every signer appears in ibc_relayer_allowlist (an x/pqc parameter, set by governance). It exists so relayers, which are third-party infrastructure, are not required to carry a Dilithium key. From any account not on that list the same messages are refused.
    • Genesis height. The decorator returns early at BlockHeight() <= 1, which is what lets a gentx be collected into a genesis file before any account can have a linked key.
  • Approved backend: CIRCL Dilithium3. The pqc_oqs tag is a compatibility build selecting the same implementation, not a separate PQClean backend; release builds must remain byte-for-byte compatible.
  • Account rotate is disabled by design (no public CLI verb). Use link-account to attach initial PQC material.
  • See docs/modules/pqc.md for signing, parameters, and CLI usage.

PQC key material

  • Import keys with --pubkey-file / --privkey-file. The inline --privkey still works and should not be used: a command-line argument is readable in /proc/<pid>/cmdline by any process of the same user, captured by ps, by audit logs and by whatever collects container output, and kept in shell history.
  • pqc-show prints name, scheme, fingerprint, date and public key. The private key requires --unsafe-show-private.
  • pqc-generate echoes the private key only when neither --name nor --priv-out keeps it.
  • Encrypt the keystore: --pqc-passphrase-file, or LUMEN_PQC_PASSPHRASE_FILE. Without one the keys are written in plaintext and every command that writes says so.

Rate-limit clamps

  • Two halves: a global sliding window early in the ante chain, and per-account per-block and per-window caps after signature verification. See "Rate limiting is two halves" above for why the second waits.
  • Environment knobs: LUMEN_RL_PER_BLOCK, LUMEN_RL_PER_WINDOW, LUMEN_RL_WINDOW_SEC, LUMEN_RL_GLOBAL_MAX.
  • Values are clamped to a range, not to a direction. The clamps are [1, 1000] per block (default 5), [1, 1000] per window (default 20), [1, 600] seconds of window (default 10) and [10, 100000] globally (default 300) — so an operator can loosen every one of them by two to three orders of magnitude, which is effectively "off". This page used to claim you could only tighten them; that was never true, and it could not be: these are node-local and non-consensus, and an operator who wants them gone can rebuild the binary. Treat them as a default worth keeping rather than a guarantee the chain enforces.

Tx input caps

  • Transactions: ≤64 messages. Proposal handlers also cap each transaction at 1 MiB and each signer's cumulative transaction bytes at 1 MiB per proposal.
  • DNS: labels ≤63 chars, FQDN ≤255, ≤64 records per tx, max 16 KiB combined record payload, registration ≤365 days, and a bid amount ≤80 characters — two above the 78 decimal digits a 256-bit Int can hold. That last one is a liveness bound rather than an input cap: big.Int decimal parsing is superlinear, so an unbounded amount let one free transaction stall a block.
  • Gateways: operator metadata ≤1 KiB, contract metadata ≤1 KiB, endpoint strings ≤64 chars, and months_total ≤ MaxFinalizeDelayMonths (120). That last one is a fund-safety bound, not a size bound: the escrow pays out (total - tax) / months_total per month in integer arithmetic, and a large enough term truncates that to zero — at which point ClaimPayment answers "no payout due" forever, FinalizeContract can never satisfy claimed_months >= months_total, and the only remaining exit is CancelContract, which refunds price_ulmn * refund_months (also zero) and hands the whole escrow to the gateway as the penalty. Coins are conserved at every step, which is why the escrow-conservation tests were satisfied. The handler also refuses a per-month payout of zero outright, because the cap alone stops closing the hole if governance votes min_price_ulmn_per_month down near 1 against a 20 % service_tax_bps.
  • Release: version ≤64 chars, channel ≤32 chars, notes ≤8 KiB (UTF‑8, only \n/\t control chars). Artifact URLs ≤1024 chars (http/https, ipfs/lumen schemes), signature blobs ≤2048 bytes.
  • PQC: scheme identifiers ≤32 chars, Dilithium public keys ≤4 KiB.

Release safety guard

  • CI workflow “Release Safety” and make pre-release verify:
    • No test-only/noop PQC symbols in the binary.
    • ./devtools/scripts/go_with_pkgs.sh vet, unit tests, static analysis, vuln scan, and preflight tests.

Node hardening (prod)

  • Proposer validation: PrepareProposal and ProcessProposal share the 1 MiB transaction and per-signer limits; proposals above the 5 MiB application budget or containing malformed/over-budget transactions are rejected. Optimistic execution is disabled by default until these budgets have production load measurements.
  • mempool.max_txs_bytes. v2.0.0 sets the default to 128 MiB; CometBFT ships 1 GiB, which is self-limiting on a chain with fees and is not one here — at the 21 MiB a transaction may reach, about fifty of them fill a gigabyte of a node's memory for nothing. The default is written into config.toml at init, so it reaches new nodes only: operators of existing ones have to set it in their own config.toml.
  • query_gas_limit. v2.0.0 sets the default to 3000000; the SDK ships 0, which means unlimited. The custom modules clamp their own page limits, but the SDK's query servers do not: /cosmos/auth/v1beta1/accounts with pagination.limit at the top of the uint64 range, or any count_total=true over a large store, walks and serialises it. This meter is separate from transaction gas, which this chain does not charge, and it turns an unbounded query into a failed one. Like the mempool setting above it is written into app.toml at init, so it reaches new nodes only — operators of existing ones have to set it themselves. Raise it on a node that serves an indexer.
  • Do not expose :2327 directly; put TLS reverse proxy + IP rate-limits in front. Good advice, but after v2.0.0 it is no longer the only bound on a query.
  • Avoid the test keyring in production; prefer OS keyrings/KMS/HSM for validators & publishers.
  • Keep defaults and param changes versioned; review governance updates before rollout.

Reporting vulnerabilities

  • Please open a private security advisory on the repository (GitHub Security → “Report a vulnerability”), or contact maintainers via the email listed in the repository profile if available.

There aren't any published security advisories