Skip to content

Security model

The BLS pairing check is the only defense against signature forgery, and it’s sufficient. The verifier computes msg_hash = keccak256(round.to_be_bytes()), maps it to G1 via SVDW, and runs alt_bn128_pairing([(σ, G2_gen), (-H, pubkey_g2)]). Valid signatures return pairing-equals-identity; invalid ones revert with AleaError::InvalidSignature (6000). The pairing math is a Solana syscall, not a Rust library that could be subtly broken. Pipeline pinned in verify.rs.

Signatures are bound to the round number through the message hash, so a signature valid for round N cannot be replayed as round M — H is different and the pairing fails. No stored round state, no window, no chance of misbinding.

Cross-chain signature substitution (someone trying to pass a mainnet or fastnet drand signature as an evmnet one) is caught by the pairing check itself. The verify path never reads Config.chain_hash — a signature produced against a different chain’s group public key simply fails pairing against evmnet’s pubkey_g2 and reverts 6000. The WrongChainHash (6007) and WrongPubkey (6008) guards are belt-and-braces protections at init / update time, not runtime verify-path checks. They ensure Alea was deployed against evmnet in the first place; pairing does the per-call work.

Fake Config substitution is the integrator footgun. The seeds::program = alea_program.key() constraint on your Accounts struct is mandatory. Without it, an attacker can pass a PDA derived under Alea’s program ID but at different seeds — or a future-version Alea PDA that happens to fit the Config layout — and feed chosen public keys into the pairing. Anchor’s Account<'info, Config> type guarantees the account is owned by Alea’s program (so a totally-fake account from another program is rejected at deserialize), but without seeds::program, it does NOT guarantee the account was derived from [b"config"] against Alea’s program ID specifically. That second check is what closes the substitution hole. The Rust SDK’s AleaVerify reference struct includes both constraints; if you copy the two fields into your own Accounts struct rather than use the reference, do not omit seeds::program.

A hostile RPC can refuse to broadcast your transaction or silently drop the result. What it cannot do is make a signature verify that wouldn’t, or vice versa — verify executes deterministically on every validator’s local copy of the program. The attack surface is availability, not soundness.

Authority compromise — what an attacker gets

Section titled “Authority compromise — what an attacker gets”

Two authority keys matter, and they do different things:

  1. BPFLoader upgrade authority controls program-binary replacement. Compromise ships a malicious binary. The blast radius is forward-only: past verifies remain mathematically valid (the signatures were genuinely checked against the real pubkey at the time), but every subsequent verify becomes attacker-controlled. Consumers defend by pinning against the known-good binary SHA and re-auditing on upgrades.

  2. Config.authority is the persistent admin key gating update_config. An attacker with this key can call update_config, but the handler enforces byte-equality against EXPECTED_EVMNET_* constants — any non-evmnet value is rejected. Combined with the idempotency guard in update_config.rs (no-op when the stored values already match), a compromise today produces no observable effect: the same evmnet values go in, the same evmnet values stay. If a future program upgrade ever changes the EXPECTED_EVMNET_* constants, Config.authority becomes privileged against those new values. No in-band rotation path exists; rotating Config.authority requires a program upgrade that rewrites the relevant code.

Today both keys are the deployer keypair. Planned transfer to a Squads multisig, eventual immutability, no fixed timeline. When the multisig transfer happens, Config.authority rotates alongside the BPFLoader upgrade authority via an explicit program upgrade.

On-chain crate runtime deps are anchor-lang, solana-program, and the arkworks field / curve stack (ark-bn254, ark-ff, ark-ec), all version-pinned at =0.5.0 in the workspace. Pairing runs as a syscall, not through a Rust crate, so the arkworks stack handles only pre-pairing point arithmetic and SVDW. A compromised off-chain SDK could round-downgrade before submission (feed your code a stale beacon whose randomness the attacker already knows), but cannot make the on-chain program accept invalid signatures. Defend against round downgrade with is_round_recent.

What Alea explicitly does not defend against

Section titled “What Alea explicitly does not defend against”

If the threshold of League of Entropy members collude, they can produce signatures that verify against the published group public key. That breaks every drand consumer — Ethereum, Filecoin, your laptop, Alea. Alea sits downstream of drand’s trust assumption, not on top of it.

A sufficient-stake Solana attacker can re-order or censor verify transactions. They cannot make an invalid signature verify (the syscall runs on every validator), but they can withhold a valid one. For high-stakes settlements — lottery payouts above a few thousand dollars, validator-rotation seeds, prediction-market resolution — wait for finalized commitment. confirmed has non-zero reorg risk.

Bugs in alt_bn128_pairing itself are another floor Alea inherits. The syscall has been through Solana validator-client review, but it’s software. SIMD-0334 (activated on mainnet-beta March 2026) was a real input-length validation hardening. A future bug in the syscall would affect every BN254-using Solana program, not Alea specifically. Treat it as part of the trust base.

Stale beacons are your problem, not Alea’s

Section titled “Stale beacons are your problem, not Alea’s”

Alea verifies any mathematically valid signature, regardless of when drand produced the round. Replay protection sits in the consumer program. The SDK ships is_round_recent(round, config, clock, max_age_seconds) that compares the round’s drand-derived timestamp against the on-chain Clock sysvar:

require!(
alea_sdk::is_round_recent(round, &ctx.accounts.alea_config, &ctx.accounts.clock, 30),
YourError::StaleBeacon,
);

30 seconds is a sensible default. 3 seconds (one drand round) fits MEV-sensitive contexts where even one round of wiggle is exploitable. Skip the check entirely and your settle has no replay defense — an attacker submits a year-old valid drand round whose randomness they already know and biases the outcome.

Alea returns 32 bytes. What you do with them — modular reduction for an index, range mapping, multi-byte concatenation — is on you. The most common bug is bytes[0..8] mod N for small N, which gives a non-uniform distribution. Use rejection sampling, or the closest unbiased method to your range. The constraints look pedantic because they are: a lottery that biases 1-in-256 outcomes is trivially drainable by anyone who notices.

Alea verifies a beacon. It does not order your state transitions. If your settle() reads the verified randomness and then writes a winner, the gap between those steps is a window for a same-block adversary who sees the transaction in the mempool. Do verify and state-write in the same instruction, atomically. Split them across two transactions and the property is gone.

Full breakdown in Testing & guarantees. Worth citing inline because auditors look for it first:

Testing page covers the fuzz baseline, the reproducible-build SHA, and the testing gaps (external audit, formal verification, constant-time characterization) without repeating them here.

Vulnerability disclosures: security@alea.so or via GitHub Security Advisory. Standard responsible-disclosure window is 90 days from acknowledgment. No paid bug bounty currently. See Responsible disclosure for the full process.