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 burst3
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.3 only, no TLS 1.0/1.1, no RSA key exchange)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 (cosign-keyless OIDC), reproducible builds6, 8b, 8g
Memory safetyRust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates
Stratum replayDedup per (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:true3.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
  -> audit: 2fa.enable

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 m=64MiB, t=3 — 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_static_priv_key_hexsecrets.tomlchmod 0600With the Sv2-NOISE static key someone can impersonate the pool toward V2 miners. Generated with 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 VAPIDPhase B (not yet active)would be secrets.tomlWith 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/*
token.create / token.revoke/api/admin/tokens/*
profile.switch/api/admin/profile
miner.add / miner.remove/api/admin/miners/*
backup.export / backup.restore/api/admin/backup / /api/admin/restore
update.check / update.applied / update.failed/api/admin/update*
notifier.test/api/admin/notifier/test

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: evict_audit_older_than can be called per cron (no auto-eviction in the daemon — the operator decides the retention policy).

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 — no RSA key exchange, no PSK-only auth.

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 (port :3334 or other)

NOISE-NX handshake with the snow crate, pure Rust. Static Curve25519 key in secrets.sv2_static_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_pubkey_hex (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.2.3 linux-x86_64
# fetches the release asset + 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 sigstore-keyless via the workflow OIDC token from the release workflow. The trust anchors are therefore GitHub Actions OIDC + Fulcio. For anyone who does not trust that: 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.

Connection Cap

Profile-dependent (Small: 64, Medium: 256, Large: 1024, Enterprise: 4096). Over-limit connects are dropped at accept time; semaphore-based, no per-connection allocation beforehand.

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 an encryptable binary blob (PR pending — currently plain SQLite bytes). Restore via POST /api/admin/restore.

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, GitHub issue 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 disclosure/api/miners, /metrics, /api/energy are readable without auth and leak internal miner IPs/hostnames/hashrates. Deliberate MVP read-only design. Before untrusted-multi-tenant or public exposure, gate behind auth (or bind /metrics to localhost)
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