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.
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-timestampmust be within five minutes of server time.x-nonceis at least 32 hex characters and is recorded per device, so an intercepted upload cannot be replayed.x-signatureis 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
| Value | Storage |
|---|---|
| Callback secret | users.callback_secret_enc, AES-256-GCM. |
| SMS bodies | sms_inbox.body_enc, AES-256-GCM. |
| API keys | SHA-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.
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_urlis 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-signaturewith 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.