Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Security

Operator-focused overview of what dvb-WarpPool protects, how it does so, and which responsibilities remain with the operator.

Threat Model

The pool sits between four actor classes with different levels of trust:

ActorTrustThreat
Pool operatorhighHas shell access, can modify config and secrets. Threat: secrets leak via backup, swap file, or a compromised SSH account
Admin usermediumLogs in via browser, can manage profile, tokens, and auto-update. Threat: compromised browser cookie or leaked API token
MinerlowAuthenticates only via Bitcoin address as username. Threat: stale-share flood, reconnect loop, malformed Stratum, ASIC-Boost abuse
InternethostileUnauthenticated. Threat: DoS flood, TLS downgrade, reverse-proxy IP spoofing, supply chain via auto-update

Threat → Mitigation Matrix

ThreatMitigationPhase
Stratum DoS floodRate limiting per peer IP ([ratelimit]), connection cap from profile, configurable burst; escalating auto-ban of abusive public IPs (loopback/LAN exempt) + operator manual ban list (applies to any IP incl. LAN; loopback stays exempt so the operator can’t lock out the local UI — a deliberate ban overrides only the auto-ban LAN exemption); optional CIDR IP-gate (ban_cidrs deny / allow_only + allow_cidrs whitelist) and per-IP concurrent-connection cap ([stratum].max_connections_per_ip)3, 32, 33
Single-IP connection exhaustionPer-IP concurrent-connection cap ([stratum].max_connections_per_ip, default 0 = unlimited) — one host can’t monopolise the global connection pool. Loopback exempt33
Unwanted / hostile network rangesCIDR IP-gate: [ratelimit].ban_cidrs denies ranges outright; allow_only = true + allow_cidrs flips the listener to a whitelist. Loopback always passes (no local lock-out). Enforced even when the token-bucket rate-limit is disabled33
Persistently rejecting worker (broken / hostile)Reject-ratio auto-ban ([ratelimit].reject_ban_*): a worker whose rejected/total share ratio exceeds the threshold after a minimum sample is banned for the escalating auto-ban duration. Public-IP-only (loopback/LAN exempt); off by default. Enforced even when the rate-limit is disabled33
Unauthorized payout address miningOptional payout-address allowlist ([mining].address_allowlist) — enforced on Stratum V1 mining.authorize and on Stratum V2 channel-open (Standard + Extended); empty = open (default)33
Auth bruteforceAuth-RPM limit per IP — on exceedance, mining.authorize is acknowledged with result: false (no disconnect loop), Argon2id for admin password3
TLS downgradeRustls 0.23 with default suite set (TLS 1.2 + 1.3, no TLS 1.0/1.1)3
RPC credential leakCookie auth preferred (auto-rotation on bitcoind restart), otherwise secrets.toml chmod 6001
Supply chaincargo deny in CI with license allowlist + advisories, signed releases (project cosign key; keyless OIDC only ≤ v1.15.0), reproducible builds6, 8b, 8g
Memory safetyRust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates
Stratum replayDedup per (worker, job_id, extranonce2, ntime, nonce) tuple in share validator2
Reorg attackBlock-found events only after submitblock OK, UI marks pending/accepted/rejected separately2
Insecure UI originCORS permissive only in dev mode; production runs daemon-static-served UI = same-origin → no CORS issue5
Admin session hijackJWT with short TTL (default 24h) signed with jwt_secret (32+ bytes), cookie HttpOnly + SameSite=Lax + optional Secure (server.cookie_secure when the panel is served behind TLS)3.1, 32
API token theftToken hash (sha256) instead of plaintext in DB, soft-revoke via revoked_at, last_used_at tracking for audit3.2
Account takeoverOptional 2FA-TOTP per user (/api/auth/2fa/{setup,enable,disable}) — with 2FA active, login without totp_code fails with 401+requires_2fa:true. Single-use recovery codes (hashed at rest, shown once) are the lockout escape hatch3.3
Audit bypassAll state-changing routes emit audit_log (actor, action, target, peer_ip, ok, details)3
IP spoofing via X-Forwarded-ForDefault trust_proxy_headers = false; only enable when behind a trusted proxy2.5
Notifier secrets in backupToken/webhook URL/SMTP password are all env-var references — config.toml contains no secrets15
Auto-update tamperingMandatory sha256 verify; optional cosign-verify-blob (env-gated) before atomic_swap; OnFailure rollback via systemd8c, 8f, 8g
Privilege escalation via setupUPnP whitelist on 4 ports + consent gate, external probes opt-in per probe with consent9, 11
Consensus correctness (silent reward loss)Coinbase merkle commits to txid (byte-reversed, internal order) — NOT wtxid; tested against real mainnet block 100000. pooltag_prefix validated ≤64 bytes (coinbase scriptSig 100-byte limit). Either would otherwise invalidate EVERY block found32
Memory DoS (Stratum)V1+Translator line cap 16 KiB (LinesCodec), V1 pre-auth handshake timeout 60s (no post-auth idle timeout → low-HR miners stay), Sv2 connection cap (semaphore) + read_buf cap (64 KiB), dedup-set reset on job switch32
Sv2 coinbase manipulationMiner extranonce length is checked against the negotiated channel size (invalid-extranonce-size, usize comparison, no truncation)32
Web Push SSRFis_safe_push_endpoint (https + block private/loopback/link-local/CGNAT/IPv6-ULA/localhost) on subscribe AND send + DNS resolve guard against rebinding32
Login bruteforce / CPU DoS (HTTP)Per-IP throttle (10 attempts / 5 min) BEFORE the Argon2 path; constant-time login (Argon2 always runs, no username-enumeration timing)32
Local file disclosure (DB / 2FA secrets)Data directory 0700 + SQLite DB+WAL+SHM 0600 (unix, enforced); setup wizard no longer overwrites an existing secrets.toml32

Auth Stack

dvb-WarpPool has three orthogonal auth paths:

  1. JWT cookie — browser UI, normal login flow
  2. API tokens — bearer token for scripts (wpat_<32hex> prefix)
  3. 2FA-TOTP — optional additional factor for (1)

Login Flow

POST /api/auth/login {username, password, totp_code?}
  -> verify Argon2id hash against admin_password_hash
  -> 2FA active? check totp_code (RFC 6238, ±1 step skew)
  -> mint JWT mit sub=username, exp=now+24h
  -> set HttpOnly Cookie warppool_session=<jwt>
  -> audit: login.ok | login.fail

API Token Flow

POST /api/admin/tokens {name, ttl_secs?, scope?}  (auth required)
  -> generate token: format "wpat_" + 32 hex chars (128 bits entropy)
  -> store sha256(token) + name + scope + expires_at
  -> response: {id, name, scope, token, expires_at}
  -> CLIENT MUST STORE THE TOKEN IMMEDIATELY — only the hash is kept afterwards
  -> audit: token.create

Authorization: Bearer wpat_abc123...   (subsequent requests)
  -> sha256(token) lookup in api_tokens WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)
  -> match found → AuthIdentity{sub: "token:<name>", exp: ...}
  -> fire-and-forget touch_api_token (last_used_at = now)

2FA Setup

POST /api/auth/2fa/setup
  -> mint_2fa_setup(issuer="dvb-WarpPool", account=username)
  -> generate base32 secret + otpauth:// URL
  -> persist with enabled=0
  -> audit: 2fa.setup

(User scans QR or enters secret in Authenticator app)

POST /api/auth/2fa/enable {code}
  -> verify_totp(secret, code) → ok
  -> UPDATE admin_2fa SET enabled=1, enabled_at=now
  -> issue single-use recovery codes (hashed at rest, shown once)
  -> audit: 2fa.enable

2FA Recovery Codes

Enabling 2FA also issues a set of single-use recovery codes. They are shown exactly once at enable time and only their hashes are stored — the same one-shot rule as API tokens. Write them down before leaving that screen.

At login, the totp_code field also accepts a recovery code: a plausible- looking code is checked against the unused set, and on match it is consumed (one use, never again) and the login succeeds. This is the way back in when the authenticator device is lost — without it, a lost phone means losing admin access to the pool.

GET /api/auth/2fa/status reports recovery_remaining, so the UI can warn as the pile runs low. POST /api/auth/2fa/recovery/regenerate rotates the set (invalidating all outstanding codes); this is also the way to refresh codes while 2FA stays active, since enable refuses to run twice.

Recovery-code logins are audited separately as login.recovery, including how many codes remain — an unexpected entry there is a strong signal of account compromise.

Key Material

KeyWhereProtectionWhat happens on leak
Pool payout_addressconfig.tomlno protection needed — it is publicNothing — incoming rewards always land at the address
Bitcoin RPC cookie~/.bitcoin/.cookiechmod 0640 (bitcoind default)Full RPC access to Bitcoin Core. Pool operator and Bitcoin user should be in the same group.
rpc_user / rpc_passsecrets.tomlchmod 0600Full RPC access. Bitcoin Core uses rpcauth= PHC hashes — leaking the hash source renders the pool password useless.
admin_password_hashsecrets.tomlchmod 0600Argon2id with the crate’s default params (m=19 MiB, t=2, p=1) — bruteforce-resistant. But: leaking the hash plus jwt_secret lets the attacker sign valid cookies without breaking the hash.
jwt_secretsecrets.tomlchmod 0600On leak: attacker can mint valid sessions without the password. Rotation: change jwt_secret → all sessions invalid (operator must log in again).
sv2_authority_priv_key_hexsecrets.tomlchmod 0600With the Sv2 authority key someone can impersonate the pool toward V2 miners. Auto-generated on first start (or via dvb-warppool-cli gen-sv2-key).
TLS cert + keyoperator-configurableoperator responsibilityIf cert/key/CSR process is leaked, MITM on Stratum-TLS is possible
2FA-TOTP secretadmin_2fa DBDB chmod 0600Per user, base32. On DB leak + admin-pw hash + jwt-secret the attacker can generate 2FA codes — all three must be protected.
Notifier tokensenv vars, NEVER config.tomlsystemd EnvironmentFile chmod 0600Telegram/Discord/Slack spam from the operator’s identity, email sending. Nothing pool-internal.
Web Push VAPIDsecrets.toml (vapid_private_key)chmod 0600With the VAPID private key, anyone who has the subscriptions can send fake push notifications

Audit Logging

All state-changing admin actions are persisted in audit_log:

ActionTrigger
login.ok / login.fail/api/auth/login
2fa.setup / 2fa.enable / 2fa.disable/api/auth/2fa/*
login.recovery/api/auth/login (a single-use 2FA recovery code was consumed; details carry the remaining count)
token.create / token.revoke/api/admin/tokens/*
profile.switch/api/admin/profile
miner.add / miner.delete/api/admin/miners/*
backup.export / backup.restore/api/admin/backup / /api/admin/restore
update.check / update.applied / update.failed/api/admin/update*
notifier.test / notifier.config/api/admin/notifier/*
ratelimit.ban / ratelimit.unban/api/admin/ratelimit/{ban,unban}
login.throttled/api/auth/login (per-IP throttle hit)

Fields: at (timestamp), actor (user, or token:<name>, or ? for failed login), action, target (optional, action-specific), peer_ip (with X-Forwarded-For resolution when trust_proxy_headers=true), ok (boolean), details (optional, e.g. error description).

Retrieve via GET /api/admin/audit?limit=100&actor=admin or CLI:

dvb-warppool-cli audit --limit 50 --actor admin

The table has indexes on at, actor, action for fast filtering.

Eviction is automatic. The daemon’s retention loop calls evict_audit_older_than([retention].audit_log_secs) — default 90 days — at most once per hour, so the DELETE does not run on every 60 s tick. Set [retention].audit_log_secs to change the window. (Earlier releases had no daemon-side eviction and required a cron job; that is no longer the case.)

TLS

dvb-WarpPool has two separate TLS surfaces:

Stratum-V1 TLS (port :3334 typical)

Optional. If stratum.tls_cert_path + stratum.tls_key_path are set, a second listener runs alongside the plain port. Rustls 0.23, default suite (TLS 1.2 + 1.3, no TLS 1.0/1.1).

The operator provides cert+key themselves — typically via Let’s Encrypt (certbot with DNS challenge when the pool is not publicly reachable) or mkcert for LAN-only setups.

Stratum-V2 NOISE (sv2_listen, :3336 in the reference deployment)

NOISE-NX handshake (Stratum V2 2024 spec: secp256k1 + EllSwift + Schnorr) via the noise_sv2 crate, pure Rust. Static authority key in secrets.sv2_authority_priv_key_hex. The public key the V2 miner needs as its server-pubkey is available three ways:

  • Pool UI: dashboard → “⛏ Miner verbinden” button → the connect modal shows the V2 endpoint and the server pubkey with a copy button.
  • API: GET /api/overview → field sv2_authority_pubkey_b58 (Base58, unauthenticated; the public key is by design not a secret).
  • CLI: dvb-warppool-cli sv2-pubkey reads secrets.toml, derives the pubkey and prints it on stdout — handy for headless / SSH setups where the UI is not reachable.

In contrast to classic TLS, NOISE has no PKI — the miner must learn the pool pubkey OOB (out of band). That is the Sv2 standard.

API TLS (HTTPS for /api + UI)

Optional via server.status_tls_listen. Operators frequently put a reverse proxy (Caddy with auto-LetsEncrypt) in front instead and let the daemon speak only HTTP on 127.0.0.1. In that case: set server.trust_proxy_headers = true so that the audit log sees real client IPs.

Reproducible Builds + Signing

Phase 8b: cargo [profile.release] with lto = "fat" (single-threaded LTO, deterministic) + RUSTFLAGS=--remap-path-prefix=... + cargo-home + cargo-git remap. CI builds twice in matrix jobs and compares sha256 — fails on drift.

Local end-user verification:

scripts/verify-reproducible.sh v1.24.0
# fetches the release tarball, extracts the daemon binary, rebuilds locally
# with the same flags + compares sha256

Phase 8g: POST /api/admin/update accepts cosign_verify: bool. If true, cosign verify-blob runs as an external subprocess before atomic_swap. The operator must set the WARPPOOL_COSIGN_BIN env var — otherwise hard-fail (500), not a silent skip.

Cosign verify uses the project cosign key (cosign.pub in the repo root): cosign verify-blob --key cosign.pub --bundle. (GitHub-era releases ≤ v1.15.0 were keyless via GitHub Actions OIDC + Fulcio; that path is GitHub-bound and gone since the move to the self-hosted forge.) For anyone who wants a second path: the GPG workflow has been available since Phase 6, but is not enabled by default.

Stratum Hardening

Rate Limiting (Phase 3)

Token bucket per peer IP. Defaults:

LimitValueWhen it triggers
connects_per_sec5.0TCP accept
connect_burst20Burst window
auths_per_sec1.0mining.authorize attempts
auth_burst10Burst window

To a rate-limited auth the server responds with result: false (no disconnect) so that the miner does not enter a reconnect loop — that would only keep loading the limit bucket and fill the connection cap.

idle_evict_secs (default 300) clears limiter state buckets for IPs that have had no traffic for 5 min — so that server memory stays linearly bounded and does not grow with the unique-IP count.

Auto-ban + manual ban list ([ratelimit])

A public IP that repeatedly trips the connect limit (default: 10 strikes / 60 s) is fully banned — rejected before the TLS/handshake — for an escalating duration (base 5 min, doubling, capped at 1 h). Loopback and private LAN (10/8, 172.16/12, 192.168/16, link-local) are never auto-banned, so local miners that reconnect in bursts can’t lock themselves out. Tunable via [ratelimit].autoban_* (off via autoban_enabled = false). On top, an operator-curated [ratelimit].manual_bans list (admin API /api/admin/ratelimit/{bans,ban,unban} + UI panel) blocks specific IPs permanently — these apply to any IP including LAN, but loopback stays exempt (so the operator can’t lock out the local UI); a deliberate ban overrides only the auto-ban’s LAN exemption.

Reject-ratio auto-ban ([ratelimit].reject_ban_*)

Off by default. When enabled, a worker whose rejected-share ratio (rejected / (accepted + rejected)) is at or above reject_ban_ratio once at least reject_ban_min_samples shares have been observed is banned for the same escalating auto-ban duration as the connect auto-ban. Public-IP-only — loopback and private LAN are never banned this way, so a noisy local miner can’t lock itself out. Because a ban is an access-control decision, an active ban is enforced on the next connect even if the token-bucket rate-limit (enabled = false) is turned off, as long as the ban mechanism that produced it (autoban_enabled or reject_ban_enabled) is set.

IP-gate — CIDR deny-list + allow-only whitelist ([ratelimit])

Off by default. ban_cidrs is a deny-list of IPv4/IPv6 CIDR ranges (a bare IP is treated as /32 / /128) — any connect from a listed range is refused before TLS/handshake. Setting allow_only = true flips the listener into a whitelist: only IPs matching allow_cidrs may connect. Loopback always passes regardless of the gate, so the operator can never lock out the local UI / local miners. Malformed CIDR entries are skipped (logged once at load). Like the bans, the gate is an access-control decision and applies even when the rate-limit is disabled.

Per-IP connection cap ([stratum].max_connections_per_ip)

Default 0 (unlimited). When set, a single peer IP may hold at most that many concurrent Stratum connections; further connects from the same IP are dropped at accept time. This bounds single-host connection exhaustion independently of the profile-wide connection cap. Loopback is exempt.

Payout-address allowlist ([mining].address_allowlist)

Empty by default (open — any Bitcoin address may mine). When populated, only the listed payout addresses may mine: the address is everything before the first . in the worker name. Enforced identically on Stratum V1 (mining.authorize is rejected) and Stratum V2 (the channel-open returns an error for Standard and Extended channels). Useful for a private “pool for friends” deployment.

Public-exposure privacy switches ([privacy])

Two booleans control what an anonymous visitor may read. Both default to true, i.e. the historical open behaviour — a solo or friends pool keeps working unchanged after an upgrade. A public deployment should review them explicitly, because the defaults publish more than a public pool usually wants:

KeyDefaultfalse means
wallet_directory_publictrueThe public wallet directory (/api/wallets) becomes operator-only
leaderboard_addresses_publictruePayout addresses in the best-shares leaderboard are masked for anonymous visitors
[privacy]
wallet_directory_public      = false
leaderboard_addresses_public = false

The authenticated operator always sees the unmasked data — these switches only affect the anonymous view. They complement the tab-visibility gating added in v1.19.1 (see the residual-risk table at the end of this page for what is still open on the unauthenticated read surface).

HTTP-API rate limit (api_rpm)

Separately from Stratum, the whole HTTP API is per-IP token-bucket limited (profile-driven api_rpm); over the limit returns HTTP 429 + Retry-After. Loopback and private LAN are exempt so the operator’s own UI is never throttled; the limit targets public remote clients only.

Connection Cap

Profile-dependent (klein: 16, mittel: 64, gross: 256, enterprise: 4096). Over-limit connects are dropped at accept time; semaphore-based, no per-connection allocation beforehand. The profile is hot-switchable, so this is a tuning knob rather than a fixed ceiling.

Conservative Mode (safe_mode)

When stratum.safe_mode = true (default): the share validator rejects unusual shares (unusual version bits, suspicious ntime drift). The operator can set this to false when testing experimental hardware.

Audit-Friendly Defaults

BehaviourDefaultWhy
trust_proxy_headersfalseA direct-to-Internet setup must not be IP-spoofable
External probes (Phase 9)opt-in per probe + consentAvoid privacy leak — the IP goes to api.ipify.org / bitnodes.io
UPnP forward (Phase 11)consent gate + port whitelistOtherwise UPnP would become a generic port opener
Auto-update via /api/admin/updatenot periodicThe operator decides when to update — the periodic check (Phase 8e) only pushes an SSE banner
WARPPOOL_AUTOUPDATE_REPOnot setAuto-update disabled by default, operator must enable explicitly
Cosign verifyfalse in update requestOperator must consciously set the trust anchor

Backup & Restore

GET /api/admin/backup exports the SQLite DB as plain, unencrypted SQLite bytes. Restore via POST /api/admin/restore. There is no built-in encryption — the export contains 2FA secrets and API-token hashes (see below), so encrypt it yourself (age, gpg, an encrypted volume) before it leaves the host.

Audit: backup.export + backup.restore. On backup.restore a fresh migration runs after the import so that schema drifts between the backup version and the current version are resolved.

What is in the backup: all DB tables incl. admin_2fa.secret_base32, api_tokens.token_hash, audit_log. What is NOT included: secrets.toml, config.toml, ~/.bitcoin/.cookie. Anyone backing up both must protect them separately.

Sandbox + Privileges

ComponentRecommended UIDReason
dvb-warppool-daemonnon-root (warppool UID 1000)No wallet key, no privileged port
dvb-warppool-setupoperator userFirst-run wizard, writes config.toml into the operator’s home
dvb-warppool-translatornon-rootPure sidecar, no file state
rollback.sh (systemd OnFailure)rootMust be able to replace the target binary in /usr/local/bin

Pool listen ports are all >1024 (3333/3334/…) — no CAP_NET_BIND_SERVICE required.

Disclosure

Please report security findings via coordinated disclosure: email first to dvbprojekt@gmx.de, an issue on git.warppool.org only after a fix. We are a small crew without a formal bug bounty — good-faith reports are acknowledged with credit.

SECURITY.md in the repo root holds the official policy

  • PGP key on request.

What is intentionally NOT implemented

Would be possibleWhy not yet
WAF / anomaly detectionOut of scope for the pool daemon. Put Caddy/Cloudflare/NGINX-WAF in front if needed
Per-worker encryption-at-rest for VarDiff snapshotsDB chmod 600 is sufficient for the threat model; key derivation would add complexity without clear value
Hardware-wallet signing for coinbaseSolo pool — coinbase goes via payout_address, the pool never touches private keys
OAuth/OIDC for adminSingle-user self-hosted is the primary target; standalone auth suffices. PRs welcome for multi-user setups
TPM attestationReproducible builds + cosign cover the audit trail; TPM would be for enterprise compliance, not solo

Accepted residual risks after the security audit (Phase 32)

Residual riskWhy accepted / condition
Miner IP / topology disclosureLargely closed in v1.19.1: /api/miners and /api/hardware now honour the operator’s tab-visibility settings — an anonymous visitor gets an empty list / 403 unless the operator publishes the tab, while the authenticated operator still sees everything; /api/wallets and the leaderboard follow [privacy].wallet_directory_public / leaderboard_addresses_public (both default to the historical open behaviour, so a public deployment must set them). Still open: /metrics and /api/energy are unauthenticated and expose aggregate device/power data — bind /metrics to localhost or gate it in the reverse proxy for a public pool
Setup-wizard unauth file-read / SSRF (localhost)/api/test-rpc / /api/bitcoin-health / /api/import-gopool/load read request-supplied paths and POST to arbitrary URLs without auth/CSRF/origin check. Default bind 127.0.0.1, short-lived. Before a non-loopback --bind, require a CSRF/origin check or a one-time token
Web Push DNS-rebinding TOCTOUThe resolve guard at send time closes most of it; a narrow window remains because reqwest resolves again independently. Fully closeable only via resolve-once-connect-by-IP
Secrets derives DebugLatent leak footgun (a future dbg!(secrets) would log everything) — Secrets is currently logged nowhere. Optional: a manual redacting Debug