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 | 3 |
| 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.3 only, no TLS 1.0/1.1, no RSA key exchange) | 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 (cosign-keyless OIDC), 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 (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 | 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
-> audit: 2fa.enable
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 m=64MiB, t=3 — 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_static_priv_key_hex | secrets.toml | chmod 0600 | With the Sv2-NOISE static key someone can impersonate the pool toward V2 miners. Generated with 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 | Phase B (not yet active) | would be secrets.toml | 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/* |
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→ fieldsv2_pubkey_hex(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.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:
| 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.
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
| 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 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
| 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, 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 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 | /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 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 |