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:
| Actor | Trust | Threat |
|---|---|---|
| Pool operator | high | Has shell access, can modify config and secrets. Threat: secrets leak via backup, swap file, or a compromised SSH account |
| Admin user | medium | Logs in via browser, can manage profile, tokens, and auto-update. Threat: compromised browser cookie or leaked API token |
| Miner | low | Authenticates only via Bitcoin address as username. Threat: stale-share flood, reconnect loop, malformed Stratum, ASIC-Boost abuse |
| Internet | hostile | Unauthenticated. Threat: DoS flood, TLS downgrade, reverse-proxy IP spoofing, supply chain via auto-update |
Threat → Mitigation Matrix
| Threat | Mitigation | Phase |
|---|---|---|
| Stratum DoS flood | Rate 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 exhaustion | Per-IP concurrent-connection cap ([stratum].max_connections_per_ip, default 0 = unlimited) — one host can’t monopolise the global connection pool. Loopback exempt | 33 |
| Unwanted / hostile network ranges | CIDR 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 disabled | 33 |
| 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 disabled | 33 |
| Unauthorized payout address mining | Optional 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 bruteforce | Auth-RPM limit per IP — on exceedance, mining.authorize is acknowledged with result: false (no disconnect loop), Argon2id for admin password | 3 |
| TLS downgrade | Rustls 0.23 with default suite set (TLS 1.2 + 1.3, no TLS 1.0/1.1) | 3 |
| RPC credential leak | Cookie auth preferred (auto-rotation on bitcoind restart), otherwise secrets.toml chmod 600 | 1 |
| Supply chain | cargo deny in CI with license allowlist + advisories, signed releases (project cosign key; keyless OIDC only ≤ v1.15.0), reproducible builds | 6, 8b, 8g |
| Memory safety | Rust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates | — |
| Stratum replay | Dedup per (worker, job_id, extranonce2, ntime, nonce) tuple in share validator | 2 |
| Reorg attack | Block-found events only after submitblock OK, UI marks pending/accepted/rejected separately | 2 |
| Insecure UI origin | CORS permissive only in dev mode; production runs daemon-static-served UI = same-origin → no CORS issue | 5 |
| Admin session hijack | JWT 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 theft | Token hash (sha256) instead of plaintext in DB, soft-revoke via revoked_at, last_used_at tracking for audit | 3.2 |
| Account takeover | Optional 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 hatch | 3.3 |
| Audit bypass | All state-changing routes emit audit_log (actor, action, target, peer_ip, ok, details) | 3 |
| IP spoofing via X-Forwarded-For | Default trust_proxy_headers = false; only enable when behind a trusted proxy | 2.5 |
| Notifier secrets in backup | Token/webhook URL/SMTP password are all env-var references — config.toml contains no secrets | 15 |
| Auto-update tampering | Mandatory sha256 verify; optional cosign-verify-blob (env-gated) before atomic_swap; OnFailure rollback via systemd | 8c, 8f, 8g |
| Privilege escalation via setup | UPnP whitelist on 4 ports + consent gate, external probes opt-in per probe with consent | 9, 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 found | 32 |
| 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 switch | 32 |
| Sv2 coinbase manipulation | Miner extranonce length is checked against the negotiated channel size (invalid-extranonce-size, usize comparison, no truncation) | 32 |
| Web Push SSRF | is_safe_push_endpoint (https + block private/loopback/link-local/CGNAT/IPv6-ULA/localhost) on subscribe AND send + DNS resolve guard against rebinding | 32 |
| 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.toml | 32 |
Auth Stack
dvb-WarpPool has three orthogonal auth paths:
- JWT cookie — browser UI, normal login flow
- API tokens — bearer token for scripts (
wpat_<32hex>prefix) - 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
| Key | Where | Protection | What happens on leak |
|---|---|---|---|
Pool payout_address | config.toml | no protection needed — it is public | Nothing — incoming rewards always land at the address |
| Bitcoin RPC cookie | ~/.bitcoin/.cookie | chmod 0640 (bitcoind default) | Full RPC access to Bitcoin Core. Pool operator and Bitcoin user should be in the same group. |
rpc_user / rpc_pass | secrets.toml | chmod 0600 | Full RPC access. Bitcoin Core uses rpcauth= PHC hashes — leaking the hash source renders the pool password useless. |
admin_password_hash | secrets.toml | chmod 0600 | Argon2id 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_secret | secrets.toml | chmod 0600 | On 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_hex | secrets.toml | chmod 0600 | With 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 + key | operator-configurable | operator responsibility | If cert/key/CSR process is leaked, MITM on Stratum-TLS is possible |
| 2FA-TOTP secret | admin_2fa DB | DB chmod 0600 | Per user, base32. On DB leak + admin-pw hash + jwt-secret the attacker can generate 2FA codes — all three must be protected. |
| Notifier tokens | env vars, NEVER config.toml | systemd EnvironmentFile chmod 0600 | Telegram/Discord/Slack spam from the operator’s identity, email sending. Nothing pool-internal. |
| Web Push VAPID | secrets.toml (vapid_private_key) | chmod 0600 | With 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:
| Action | Trigger |
|---|---|
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→ fieldsv2_authority_pubkey_b58(Base58, unauthenticated; the public key is by design not a secret). - CLI:
dvb-warppool-cli sv2-pubkeyreadssecrets.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:
| Limit | Value | When it triggers |
|---|---|---|
connects_per_sec | 5.0 | TCP accept |
connect_burst | 20 | Burst window |
auths_per_sec | 1.0 | mining.authorize attempts |
auth_burst | 10 | Burst 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:
| Key | Default | false means |
|---|---|---|
wallet_directory_public | true | The public wallet directory (/api/wallets) becomes operator-only |
leaderboard_addresses_public | true | Payout 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
| Behaviour | Default | Why |
|---|---|---|
trust_proxy_headers | false | A direct-to-Internet setup must not be IP-spoofable |
| External probes (Phase 9) | opt-in per probe + consent | Avoid privacy leak — the IP goes to api.ipify.org / bitnodes.io |
| UPnP forward (Phase 11) | consent gate + port whitelist | Otherwise UPnP would become a generic port opener |
| Auto-update via /api/admin/update | not periodic | The operator decides when to update — the periodic check (Phase 8e) only pushes an SSE banner |
WARPPOOL_AUTOUPDATE_REPO | not set | Auto-update disabled by default, operator must enable explicitly |
| Cosign verify | false in update request | Operator 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
| Component | Recommended UID | Reason |
|---|---|---|
dvb-warppool-daemon | non-root (warppool UID 1000) | No wallet key, no privileged port |
dvb-warppool-setup | operator user | First-run wizard, writes config.toml into the operator’s home |
dvb-warppool-translator | non-root | Pure sidecar, no file state |
rollback.sh (systemd OnFailure) | root | Must 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 possible | Why not yet |
|---|---|
| WAF / anomaly detection | Out of scope for the pool daemon. Put Caddy/Cloudflare/NGINX-WAF in front if needed |
| Per-worker encryption-at-rest for VarDiff snapshots | DB chmod 600 is sufficient for the threat model; key derivation would add complexity without clear value |
| Hardware-wallet signing for coinbase | Solo pool — coinbase goes via payout_address, the pool never touches private keys |
| OAuth/OIDC for admin | Single-user self-hosted is the primary target; standalone auth suffices. PRs welcome for multi-user setups |
| TPM attestation | Reproducible builds + cosign cover the audit trail; TPM would be for enterprise compliance, not solo |
Accepted residual risks after the security audit (Phase 32)
| Residual risk | Why accepted / condition |
|---|---|
| Miner IP / topology disclosure | Largely 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 TOCTOU | The 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 Debug | Latent leak footgun (a future dbg!(secrets) would log everything) — Secrets is currently logged nowhere. Optional: a manual redacting Debug |