SecuritySecurity model
Security

Security model

What a server compromise can and cannot do on each chain, and how Turmite limits the damage.

Markdown

Turmite keeps the keys that receive money away from the server where the chain allows it. This page tells you what an attacker can do with full control of the server, per chain, and which controls limit the damage.

What a server compromise can do

The table assumes the worst case: an attacker controls the server, the database and every secret on it.

On every chain, the attacker can change payment records, mark an unpaid order as paid, and change a project's wallet so that new payments go to the attacker.

Network Deposit address The attacker can also The attacker cannot
Bitcoin Receive address of your BIP84 account key, in a watch-only wallet turmite-p<project_id> on your bitcoind Nothing more Spend any bitcoin. The server holds no Bitcoin private key
Monero Subaddress of a view-only wallet made from your address and private view key See incoming payments Spend any monero. The view key cannot sign
Ethereum, Base, Polygon, BNB Smart Chain CREATE2 deposit contract per payment Spend the hot wallet balance Send a deposit to another address. Each deposit contract can only flush to the cold wallet fixed in its factory
Tron, Solana, TON Key per payment, derived from the install's deposit seed Take deposits that are not yet swept. Spend the hot wallet balance Take funds that are already in your cold wallet

On Tron, Solana and TON, Turmite holds the deposit keys until the sweep. During that time the funds are custodial. The worker reads the sweep destination, the project's cold wallet, from the database at sweep time. Thus a person who can write to the database, for example through code execution in the api process, can change the cold wallet and send the deposits that are not swept yet to another address. The audit log does not record a direct database write. On the EVM networks this is not possible: each deposit contract flushes only to the cold wallet of its factory. To keep the exposure small:

  • Keep the hot wallet funded, so that sweeps do not stop.
  • Keep the hot wallet balance small. It only needs fees for sweeps and the payouts you plan.
  • Prefer the EVM networks, Bitcoin or Monero for large amounts.

The Monero wallet files on the wallet RPC host have no password. A person who can read the wallet directory of that host gets the private view key of each project and sees its incoming payments. The person cannot spend. Keep the wallet RPC host private, and give it to Turmite only.

A compromised server can lie about payment state. For a large order, check the deposit on a block explorer before you ship.

Process split

Turmite is one binary with two roles. Each role holds different secrets.

Role Faces the internet Holds Does not hold
api Yes, behind Caddy TURMITE_SECRET, the database password, TURMITE_HOT_RECIPIENT (an age public key) Any private key, the deposit seed, the hot identity, the hot passphrase
worker No. It opens no port TURMITE_SECRET, the database password, the hot identity file and its passphrase Nothing more is needed

The api role derives a deposit address only where it needs no private key. On Tron, Solana and TON it takes an address from a pool that the worker fills. The worker role is the only process that can decrypt hot keys and the deposit seed. The two roles talk only through PostgreSQL.

Code execution in the api process gives the attacker no private key. It gives the attacker write access to the database. See Threat model T1 and R1.

The default Docker Compose stack in /opt/turmite runs the roles in separate containers:

  • Only the worker container mounts secrets/, read-only.
  • The api and worker containers have a read-only file system, drop all Linux capabilities and set no-new-privileges.
  • The image is distroless. It has no shell and no package manager, and it runs as user 65532.
  • PostgreSQL is on an internal network with no published port.

Secrets

Secret Where Protects
TURMITE_SECRET /opt/turmite/.env Seals TOTP secrets, IPN secrets, webhook endpoint secrets, RPC node URLs, Monero view keys and Trocador API keys in the database. Signs payout API tokens. Keys the hashes of payout approval codes
Hot identity (age) /opt/turmite/secrets/hot-identity.age, encrypted with the passphrase Decrypts hot wallet keys and the deposit seed
Hot passphrase /opt/turmite/secrets/hot_passphrase (TURMITE_HOT_PASSPHRASE_FILE), or TURMITE_HOT_PASSPHRASE at start Unlocks the hot identity
Database password /opt/turmite/.env Read and write access to every row
Cold wallet keys Your hardware wallet or multisig Everything that is swept or paid to you. Turmite never sees them

turmite init stores the passphrase in secrets/hot_passphrase, so the worker can restart unattended. To keep it off the disk, run turmite init --no-store-passphrase and set TURMITE_HOT_PASSPHRASE each time the worker starts.

The api role encrypts every generated or imported hot key to TURMITE_HOT_RECIPIENT. It cannot decrypt what it encrypts.

A copy of the database alone does not reveal TOTP secrets, IPN and webhook secrets, node URLs, view keys, Trocador keys, hot keys or the deposit seed. Passwords are Argon2id hashes. API keys and session tokens are SHA-256 hashes. Store .env and secrets/ apart from the database backups. See Backups and restore.

Accounts and sessions

  • Passwords have at least 12 characters. They are hashed with Argon2id (64 MiB, 3 passes, 1 lane). At most four hashes run at one time, so a flood of logins cannot use all the memory.
  • Login is limited to 10 attempts a minute per IP address and per email. POST /v1/auth shares this limit.
  • Two-factor authentication (TOTP) is enrolled on the account page. When it is on, login needs a code.
  • Each code works one time. The last used time step is stored in the database. After 5 wrong codes, two-factor checks for that user are locked for up to 15 minutes. The lock is in the memory of the api process and ends when the process restarts.
  • Sensitive actions need a fresh code, for example API keys, the IPN secret, wallets, hot wallets, nodes, payouts and new users. See Dashboard and roles. On mainnet, these actions are refused until two-factor is on.
  • The session cookie is turmite_session. It is HttpOnly and SameSite=Strict. It is Secure when the site URL uses https. Its path is the path of the site URL. A session ends after 12 hours without activity, and at the latest 7 days after sign-in.
  • A disabled user cannot sign in. Turmite ends the user's sessions.
  • A password change ends the other sessions of the user.
  • Each process counts the login and API rate limits and the two-factor lockout in its own memory. With more than one api process, an attacker gets the limits one time for each process. A restart clears the counts.
  • Every dashboard POST request must carry an Origin or Referer header with the origin of the site URL. Turmite refuses other requests.
  • The audit log records each sign-in and each sensitive action with the time, the user or system, the project, the action, the target and the IP address.

The root user can reset two-factor for another user in the dashboard, with a fresh code. On the server, turmite user reset2fa <email> does the same. Both end the user's sessions and write an audit entry. See CLI.

Roles limit what each user can do. See Dashboard and roles.

Web security

The checkout pages send this content security policy:

default-src 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'

The checkout runs no JavaScript. Other sites cannot frame it. Only the live status frame (/pay/{token}/status) can be framed, and only by Turmite's own pages. The dashboard has its own policy with script-src 'self' and frame-ancestors 'none'. It works with JavaScript off. Its only script is optional: it copies text to the clipboard and asks for confirmation before some actions.

Turmite adds X-Content-Type-Options: nosniff, Referrer-Policy: same-origin and X-Frame-Options to its responses. Dashboard and checkout pages have Cache-Control: no-store. On an https:// site URL, responses have Strict-Transport-Security: max-age=31536000.

Turmite pages load nothing from third parties. The fonts are served by Turmite. The QR code is an inline SVG that the server makes.

A project logo is an upload or an https URL that the server downloads one time. Turmite refuses SVG and files over 2 MB, decodes the image, scales it to fit 512 by 512 pixels and stores it as PNG. The checkout loads the stored copy from Turmite.

Webhooks

  • Every delivery is signed. The ipn_callback_url of a payment uses the project's IPN secret. Each project endpoint has its own signing secret. See Webhooks.
  • The x-turmite-signature header covers the x-turmite-timestamp value and the body, so your receiver can refuse replays.
  • On mainnet, endpoints and logo URLs must be public https URLs. Turmite refuses private and reserved IP addresses when it connects. A DNS name that resolves only to such addresses is refused too.
  • Turmite does not follow redirects.

Payouts

Payouts are the only way Turmite sends money to an address that is not your cold wallet. Bitcoin and Monero have no payouts. These controls apply:

  • Payouts come only from hot wallets. Keep their balance small.
  • Limits per project, in USD: a minimum, an auto-approve amount, an hourly limit and a daily limit. By default the auto-approve amount is 0, so every payout waits for approval.
  • A payout above the auto-approve amount, over the hourly or daily limit, or without a USD rate waits for approval.
  • Approval needs a project admin's two-factor code. Turmite then emails that admin a 6-digit code. The code works for 10 minutes, 5 attempts and only for that admin. On mainnet, approvals need SMTP.
  • Turmite does not stop an admin from approving a payout that the same admin created.
  • A payout made in the dashboard needs a project admin and a fresh code. It can go to any valid address.
  • An API payout needs the project API key and a bearer token from POST /v1/auth for a project admin. The batch waits as pending-otp-verification until that admin sends a two-factor code to POST /v1/payout/{batch_id}/verify. API payouts can only go to addresses in the address book.
  • Changes to the address book and the limits need a fresh code.
  • Each withdrawal can carry a unique_external_id. A retry with the same fields returns the existing payout. The same ID with other fields is refused.
  • The worker signs and stores each transaction before it broadcasts it. When the outcome is unknown, the payout becomes needs-review and waits for an admin.
  • EVM payouts and EVM sweeps from the same hot wallet share one nonce lock.

See Payouts.

Nodes

Turmite trusts its RPC nodes to report deposits correctly. A malicious node cannot move funds, but it can report a deposit that did not occur. Turmite reads each network through one node at a time and does not compare nodes. An EVM node on another chain ID, a Solana node with another genesis hash, and a Tron or TON node on a network of the wrong mode are refused. Use your own nodes or paid providers, and add more than one node per network. See Networks and nodes.

Updates and advisories

A minute after start and then once a day, the worker reads the latest release and the security advisories of loruorg/turmite on GitHub. The requests send the version in the User-Agent header and nothing else. Admins see a banner for a new release, and a red banner for an advisory that affects the running version. When SMTP is set, admins get one email for each critical or high advisory. The check is on by default. To turn it off, open Server → Updates. This needs a fresh code. See Upgrades.

GitHub Actions builds each release image from a version tag, with an SBOM and a build provenance attestation. To check an image:

gh attestation verify oci://ghcr.io/loruorg/turmite:<version> --owner loruorg

Rotate secrets

Rotate a secret when a person who knew it leaves, or when you think that it leaked.

Secret How to rotate Effect
API key Create a key on API keys, put it in your shop, then revoke the old key A revoked key stops at once
IPN secret Settings → General → IPN secret → Rotate, with a fresh two-factor code Each delivery from then on, retries included, is signed with the new secret. Change the secret in your shop at the same time
Webhook endpoint secret Add a new endpoint, switch your receiver to it, then delete the old endpoint The old endpoint gets no more events
Password Account, with the current password Ends the user's other sessions
Two-factor turmite user reset2fa <email>, then enrol again Ends all sessions of the user. See CLI
Node URL or node API key Add the node with the new URL, then remove the old node Removing a node needs a fresh two-factor code
Trocador API key Enter the new key on Settings → Exchange The old key is no longer used
Hot wallet key Make or import a new hot wallet. Send the balance of the old one to it with a payout, then assign the new one to the projects The old key stays sealed in the database. It holds no balance
Database password Run ALTER ROLE turmite PASSWORD '<new>'; in the postgres container. Set the same value in POSTGRES_PASSWORD and TURMITE_DATABASE_URL in .env, then run docker compose up -d The roles connect with the new password
TURMITE_SECRET No command. See Environment variables Do not change it on a running install
Deposit seed Never Tron, Solana and TON deposit addresses derive from it. Unswept funds at those addresses need it

Deposit contracts

The EVM deposit contracts are in contracts/ in the repository. The tests check these properties:

  • A deposit contract sends native coin and tokens only to the cold wallet of its factory. Anyone can call flush.
  • The cold wallet and the implementation never change. The factory has no owner, no admin and no upgrade path.
  • A token that fails, blocks the cold wallet, returns false, takes a fee on transfer or calls back into the contract does not stop the other tokens and does not move funds elsewhere.
  • Random calls to a deposit contract cannot move funds.
Test kind What it does
Unit tests Each property above, with standard tokens, tokens without a return value, block lists, fee-on-transfer and reentrant tokens
Fuzz tests 2000 runs each: random salts, amounts, callers and call data
Invariant tests 256 runs of 100 random calls: funding, flushes, block-list changes, reentrant tokens and raw calls. After each call, all funds are at a deposit address or at the cold wallet, and the configuration is the same
Fork tests Real USDC and USDT on Ethereum, Base, Polygon and BNB Smart Chain, including a deposit address on the USDC and USDT block lists

To check a deployed factory, build the contracts with the settings in contracts/foundry.toml (solc 0.8.38, EVM version cancun, no metadata hash). The bytecode then matches contracts/out-min/DepositFactory.json, which the server embeds. That file also gives the formulas for the factory address, the implementation address and each deposit address. Compare the deposit address in the dashboard with the formula before the first payment.

Operator duties

The software cannot do everything. Before you go live:

  1. Turn on two-factor authentication for every user.
  2. Keep the dashboard private. See Private dashboard.
  3. Use a hardware wallet or a multisig as the cold wallet.
  4. Check the first deposit address against your own wallet. See Wallets per chain.
  5. Keep hot wallet balances small and set low payout limits.
  6. Back up the database, .env and secrets/, and store the copies in different places.
  7. Use your own or paid RPC nodes, at least two per network.

The full list is in the Go-live checklist. The threats and the remaining risks are in the Threat model.