Security model
What you get out of the box
- Bearer token at rest is
<datadir>/tokenmode0600; datadir itself is0700. Constant-time comparison on every request. - Public binds fail-close unless TLS is on (
--tls) or you opt in with--allow-public-bind. See Tokens and scopes → Bind safety. - Wallet
.keyfiles mode0600in<datadir>/wallets/. Filenames are validated 64-hex addresses — no path traversal. - Signing happens in-process. Only the signed transaction bytes go to the upstream node; private keys never leave the daemon.
- In-flight UTXO tracker prevents back-to-back transfers from the same wallet racing onto the same outpoint (see internals below).
- Every spend-scope request emits a structured audit log line
(
spend audit) withmethod,client_ip,request_id,outcome— atINFOon success,WARNon error (the warn line also carries the error message). HonorsX-Forwarded-Forforclient_ipwhen present.
What's not protected (by design)
These are deliberate trade-offs, not bugs. Know what model you're running.
- Wallet keys are plaintext on disk. A daemon has no human to type a passphrase — keys live unencrypted, protected by FS permissions plus volume-level encryption (LUKS, dm-crypt, cloud volume encryption). Anyone who can read the wallet directory can spend every wallet.
- One spend token = total spend authority. No per-key
authorization, no quorum, no MPC. If you need finer-grained
authority, implement the
WalletStoretrait against an HSM or KMS and slot it in. - TLS is opt-in. Walletd defaults to plaintext HTTP on loopback.
For cross-host traffic, either pass
--tls(in-process, fingerprint-pinned by the SDK) or terminate TLS externally and pair with--allow-public-bind. - No rate limit, no IP allowlist. A 32-byte token is infeasible to brute-force online, but for public exposure you still want a WAF / firewall / Cloudflare in front for DoS protection.
- Upstream node RPC is unauthenticated. Walletd → node assumes a trusted hop (loopback, VPC, or HTTPS to a public provider).
In-flight UTXO tracker
When transfer picks an outpoint as an input, walletd records it in
an in-memory set with a 10-minute TTL. Subsequent transfers from the
same wallet skip outpoints in this set, so two back-to-back transfers
can't race onto the same UTXO and trigger the upstream's mempool
double-spend rejection.
- Pre-broadcast errors release the claim (RAII guard). Build / sign / authentication failures leave the outpoints re-selectable.
- Successful broadcast holds the claim until TTL. Even if the consuming tx confirms in 30s, the slot stays held for 10 minutes — small wasted availability, always safe.
- Transport-error broadcast also releases. Walletd can't be sure the broadcast landed; releasing is the safe call (a retry will get the upstream's own double-spend rejection if it did).
- In-memory only — no persistence across restarts. A pending tx from a pre-restart process is invisible to a fresh one. Run a single daemon instance per datadir.
Common misconfigurations to avoid
- Don't pass
--auth-token=…on the command line. It shows up inps aux. Use the auto-generated<datadir>/tokenor an env var. - Don't put the wallet directory on shared storage (NFS, S3-FUSE). Mode bits don't translate; concurrent writers will corrupt keys.
- Don't deploy two walletd processes against the same datadir. They don't coordinate on the in-flight tracker.
- Don't expose port 80 plaintext to the public internet. Some cloud proxies' "force HTTPS" toggle only redirects GET/HEAD; a stray POST will leak the token before redirect happens.