Skip to content

Security

ToruPay handles money without holding it, which makes two things matter above everything else: that a payment cannot be claimed twice, and that a callback cannot be forged. This page describes how both are enforced.

Device signing

Each paired Android phone generates a non-exportable EC P-256 keypair in AndroidKeyStore. Only the public key is registered. Every SMS upload carries four headers.

SMS ingest headers
x-device-id: 4b1f9c2e-3a7d-4e58-9b02-6c8d1e2f3a4b
x-timestamp: 1772272860
x-nonce: 9f3c7a1e5d2b4806af17c93e5b0d2a64
x-signature: <DER ECDSA P-256 / SHA-256, base64>

signed over: "${timestamp}\n${nonce}\n${rawBody}"
  • x-timestamp must be within five minutes of server time.
  • x-nonce is at least 32 hex characters and is recorded per device, so an intercepted upload cannot be replayed.
  • x-signature is checked against the device’s registered public key. There is no shared secret in the app.
  • A revoked device stops being accepted immediately.

Tenant isolation

Row-level security is enabled on every table that holds merchant data: sessions, transactions, devices, the SMS inbox, pairing tokens, nonces, the audit log and admin sessions. One account reading another account’s rows is prevented by Postgres itself rather than by an application check that could be forgotten on a new query.

Encryption at rest

ValueStorage
Callback secretusers.callback_secret_enc, AES-256-GCM.
SMS bodiessms_inbox.body_enc, AES-256-GCM.
API keysSHA-256 hash plus a short display prefix. The key itself is shown once, when it is minted.

The audit log

audit_log is append-only. UPDATE, DELETE and TRUNCATE are revoked from the service role itself, so the history behind a disputed payment cannot be rewritten by the application, by an operator, or by anything holding the application’s own credentials. Ledger tables use ON DELETE RESTRICT, so a parent row cannot take its transactions with it.

Claiming is atomic

A claim is one Postgres function call. It checks the amount, the expiry, the account status and the attempt count, then marks the transaction applied and the session completed together. A TrxID is unique per provider, so the same transaction cannot be spent against two orders, and two concurrent requests for the same session cannot both win.

Failed attempts are capped per session.Repeated wrong TrxIDs lock the session and return 429 TOO_MANY_ATTEMPTS, so the hosted page cannot be used to guess transaction ids.

Balance verification

Every incoming wallet SMS states the balance right after the credit. If balance - amount equals the last confirmed balance on that phone’s SIM, the message continues a chain a forged SMS cannot fake without already knowing the real running balance. Sender-ID spoofing tells an attacker nothing about what your balance actually was a moment ago. The chain is only ever started or corrected by you, from /settings, entering the balance shown on the phone itself; an SMS can move the chain forward but can never create or reset it. Turning on Require balance verification makes a claim wait for that confirmation instead of accepting the TrxID and amount match alone. A matched-but-unconfirmed payment returns 202 PENDING_VERIFICATION, not a failure.

Outbound URL policy

  • Every merchant URL must be https:, at most 2048 characters, and free of embedded credentials.
  • A webhook_url is checked against your allowed-host list when the session is created, and the hostname is resolved again immediately before each delivery.
  • Addresses in loopback, RFC 1918, link-local including the cloud metadata address, carrier-grade NAT, multicast and other reserved ranges are refused, for both IPv4 and IPv6 including IPv4-mapped forms.
  • Hostnames written as raw integers or with hex or octal octets are refused outright, because they are used to slip past name-based blocklists.
  • Redirects are not followed on a callback.

Response headers

Every route sends HSTS, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, a Content-Security-Policy with frame-ancestors none and a Permissions-Policy that denies camera, microphone and geolocation.

What is on you

  • Keep the API key and the callback secret on the server, in environment variables.
  • Verify x-kronx-signature with a constant-time comparison over the raw body, as shown on the webhooks page.
  • Deduplicate on x-kronx-event-id.
  • Rotate a key the moment you suspect it leaked. Revocation takes effect on the next request.