In scope — please report these:
- Smart contract vulnerabilities (reentrancy, fund loss, logic errors in vault/rebalance/offramp)
- Authentication or authorization bypass
- Data exposure (user PII, private keys, JWT secrets)
- Injection attacks (SQL, command, XSS with financial impact)
- Privilege escalation in the API or admin endpoints
- Cryptographic weaknesses
Out of scope — do not report these as security issues:
- Feature requests or general usability bugs
- UI/styling issues with no security impact
- Rate-limiting on non-sensitive endpoints
- Issues requiring physical access to a user's device
- Self-XSS or social engineering attacks
Do NOT open a public GitHub issue for security vulnerabilities. Doing so exposes users before a fix is available.
Instead, use one of the following:
- GitHub Private Vulnerability Reporting (preferred) — click "Report a vulnerability" under the Security tab of this repository.
- Email — send a report to security@nester.dev with the subject line
[SECURITY] <brief description>.
- Affected component (smart contract name, API endpoint, service)
- Description of the vulnerability and its potential impact
- Step-by-step reproduction instructions
- Any proof-of-concept code or screenshots (if safe to share)
- Your severity assessment (Critical / High / Medium / Low)
| Stage | Target |
|---|---|
| Acknowledgment | Within 48 hours |
| Initial assessment | Within 1 week |
| Fix or mitigation | Depends on severity (Critical: ASAP, High: 2 weeks, Medium/Low: next release) |
| Public disclosure | Coordinated with reporter after fix is deployed |
| Severity | Examples |
|---|---|
| Critical | Smart contract funds at risk, private key exposure, complete auth bypass |
| High | Privilege escalation, significant PII data breach, DoS of critical services |
| Medium | Limited data exposure, partial auth weakness, DoS of non-critical services |
| Low | Information disclosure, best-practice violations with minimal impact |
Every Nester contract shares one role model (nester_access_control::Role) rather than a single admin key:
| Role | Can do | Can never do |
|---|---|---|
Admin |
Grant/revoke roles, configure non-fund-moving parameters | — (narrower roles below carve fund movement and safety actions out of Admin's day-to-day path) |
Guardian |
Pause, halt deposits, trip the circuit breaker | Unpause, upgrade, withdraw, or loosen any threshold |
Upgrader |
Propose/execute upgrades through the timelock; run staged breaker recovery | Trip the breaker itself with Guardian's speed |
Attester |
Sign APY/TVL/yield reports | Move funds or change fee/role config |
FeeManager |
Adjust fees within governed bounds | Grant roles or move treasury funds |
RebalanceKeeper |
Trigger rebalances | Change fee/role config |
Treasurer |
Authorise treasury outflows | Grant roles or change fee config |
VaultCreator |
Deploy new vaults via the factory | Change the governed vault WASM hash (timelocked, Admin-gated) |
The Guardian asymmetry is the load-bearing guarantee. A Guardian key can always make a vault safer — pause it, halt new deposits, trip the circuit breaker into FullHalt — and can never make it riskier: it cannot unpause, cannot upgrade, cannot withdraw, and cannot even loosen the breaker thresholds that constrain it. Reversing anything a Guardian does always requires a higher role (Admin/Upgrader) to run the staged, cooled-down recovery path (recover_next_stage). Practically, this means a Guardian key can be held more widely and reacted with faster than an Admin key: a compromised Guardian can at worst halt the protocol — annoying and fully recoverable — never drain it.
Every role transfer is two-step (transfer_role / accept_role, cancellable via cancel_role_transfer) so a mistyped or uncontrolled successor address can never take over in one move. Operational roles can be time-bounded with grant_role_until so a forgotten grant to a rotated-away key stops authorising on its own. The full role state is inspectable on-chain via has_role, get_role_members (bounded), and role_expires_at.
On-chain roles vs application roles: the RBAC above lives entirely in the Soroban contracts. The user_roles tables in the API's Postgres migrations are a separate, application-level authorization layer (who can see what in the dashboard) and do not confer any on-chain authority — do not conflate the two when reasoning about contract security.
The vault trips itself automatically rather than waiting for an operator to notice a problem. Four independently-configurable conditions escalate a graded severity (Normal → Throttled → DepositsHalted → FullHalt):
- Share-price move — an implausible change in share price within a rolling window halts new deposits.
- Yield sanity — a single
report_yieldcall larger than a configured fraction of total assets is rejected (not applied) rather than accepted, and halts new deposits. - Withdrawal velocity — cumulative withdrawal value (not transaction count) within a rolling window throttles the vault. A configurable margin above the raw threshold prevents a dust withdrawal from griefing the vault into a halt for free.
- Source failure — consecutive yield-adapter failures halt new deposits.
The emergency withdrawal queue is never gated by severity — it works identically at every level, including FullHalt, because a breaker that stops users from leaving is a trap, not a safety device. Recovery only ever moves one severity stage at a time, only after a configurable cooldown, and only for callers holding Admin/Upgrader — never Guardian.
Nester uses envelope encryption with key versioning and rotation for all sensitive user data stored at rest. The scheme is implemented in apps/api/internal/crypto/account_cipher.go and reused across all domains.
Master Key (never stored in the application)
└── Key-Encryption Keys (KEKs) — versioned, configured via env vars
└── AES-256-GCM Data Key (derived per cipher instance)
└── Ciphertext stored in `*_encrypted` columns, tagged with `key_version`
Key configuration (environment variables):
| Variable | Purpose |
|---|---|
BANK_ACCOUNT_ENCRYPTION_KEY |
Legacy single 32-byte base64 key (registers as v1) |
ACCOUNT_CIPHER_KEYS |
Comma-separated version:base64key pairs for multi-key rotation |
ACCOUNT_CIPHER_ACTIVE_KEY |
Which version is used for new encryptions |
ACCOUNT_CIPHER_FINGERPRINT_KEY |
Independent pepper for blind-index HMAC (required when no v1 key is in the set) |
| Table | Encrypted Columns | Blind Index | Key Version |
|---|---|---|---|
bank_accounts |
account_number_encrypted (BYTEA) |
account_number_fingerprint (TEXT, unique) |
key_version (VARCHAR(32)) |
kyc_documents |
id_number_encrypted (BYTEA), front_object_key_encrypted (BYTEA), back_object_key_encrypted (BYTEA) |
id_number_fingerprint (TEXT) |
key_version (VARCHAR(32)) |
Fields that need exact-match lookup (deduplication, find-by-identifier) use a blind index: a keyed HMAC-SHA256 of the normalized plaintext stored in a separate column. The HMAC key is independent of the encryption keys — either the v1 KEK or the explicit ACCOUNT_CIPHER_FINGERPRINT_KEY — so the index cannot be used to attack the ciphertext even if both the index and ciphertext are leaked in the same breach.
Limitations:
- Exact-match only: No prefix, range or fuzzy search on blind indexes.
- Collision resistant: HMAC-SHA256 produces a 256-bit digest; collisions are not a practical concern.
The cmd/rotate_keys command re-encrypts stored ciphertext under the active key version. It processes all encrypted domains (bank accounts, KYC documents) in a single run. The rotator is:
- Idempotent: Rows already on the active version are skipped.
- Resumable: Each row is committed independently; an interrupted run picks up where it left off.
- Safe: Only row IDs and key versions are logged — never plaintext, ciphertext, or keys.
A complete data-classification inventory is maintained at apps/api/internal/crypto/DATA_CLASSIFICATION.md. Every column holding user data is classified as HIGH (sensitive, encrypted), MEDIUM (indirect PII), or LOW (non-sensitive).
When encryption is extended to a new domain, existing plaintext rows are backfilled using a dedicated command (e.g. cmd/backfill_kyc_encryption). The migration pattern follows:
- Add encrypted columns alongside existing plaintext columns (rollback-safe).
- Run a batched, resumable backfill job to populate encrypted values.
- Verify the backfill is complete.
- Drop plaintext columns in a separate, later migration after confidence is established.
Plaintext is never dropped in the same step that introduces ciphertext.
We maintain a list of known vulnerabilities in our dependency chain that we have assessed and accepted based on their risk profile:
| ID | Module | Severity | Reason | Status |
|---|---|---|---|---|
| GO-2026-4316 | github.com/go-chi/chi |
Medium | Open redirect in unused RedirectSlashes middleware. Transitive dependency via github.com/stellar/go-stellar-sdk. Middleware not used in codebase. Waiting for upstream fix. |
Accepted |
Mitigation strategy: Our CI/CD pipeline (govulncheck step in .github/workflows/security.yml) explicitly allows known accepted vulnerabilities and only fails on new or unreviewed vulnerabilities.
To report a vulnerability not listed here, see Reporting a Vulnerability above.
Nester commits to not pursuing legal action against security researchers who:
- Report vulnerabilities through this policy in good faith
- Avoid accessing, modifying, or deleting user data beyond what is necessary to demonstrate the issue
- Do not disrupt production services or degrade user experience during testing
- Give us reasonable time to resolve the issue before public disclosure
We maintain a Hall of Fame for researchers who responsibly disclose valid security issues. Credit will be given in the relevant release notes and security advisory unless you prefer to remain anonymous.
We do not currently operate a paid bug bounty program, but we appreciate and publicly recognize all valid reports.
Nester maintains a cryptographically hash-chained audit log for all security-relevant actions (admin operations, role changes, fund movements, configuration changes, authentication events). The goal is to make silent modification or deletion of log entries detectable even by a compromised database or malicious insider.
Each audit log entry includes four chain fields in addition to the standard event payload:
| Field | Description |
|---|---|
sequence |
Strictly monotone integer assigned under a serializable transaction + mutex. No gaps are allowed. |
prev_hash |
SHA-256 of the canonical serialization of the previous entry (empty for entry #1). |
entry_hash |
SHA-256 of this entry's canonical serialization, including prev_hash. |
anchored / anchor_tx_hash |
Whether the entry's hash has been recorded outside the DB (file log today; Stellar chain in production). |
Hash input canonical form — deterministic JSON serialization (sorted keys):
SHA256( JSON({sequence, prev_hash, actor, action, target, detail, timestamp}) )
Entries are written inside a SERIALIZABLE isolation-level transaction and guarded by an in-process sync.Mutex. This ensures:
- No two goroutines can race on the sequence counter.
- The database rejects non-serial concurrent writes.
- Every new entry's
prev_hashexactly matches theentry_hashof the previous entry.
The AuditService.VerifyChain(ctx, fromSeq, toSeq) method:
- Fetches entries in sequence order from
fromSeqtotoSeq. - Detects sequence gaps (a gap means an entry was deleted).
- Recomputes each
entry_hashfrom the raw fields and compares it to the stored value. A mismatch means the entry was modified. - Checks
prev_hashlinkage across entries.
Returns (ok bool, brokenAtSeq int64, err error) — when ok=false, brokenAtSeq is the sequence number where the first break was detected.
scheduler.AuditChainVerifier runs on a configurable interval (default: 30 minutes). On each tick it calls VerifyChain over the entire table. If a break is found, it calls ChainBreakAlerter.AlertChainBreak — in production this should page on-call.
Environment variables:
AUDIT_VERIFIER_ENABLED=true
AUDIT_VERIFIER_INTERVAL_MINUTES=30
Administrators can trigger a one-shot integrity check via:
GET /api/v1/admin/audit/verify
Authorization: Bearer <admin JWT>
Response (intact chain):
{ "chain_ok": true, "broken_at_sequence": 0 }Response (break detected):
{ "chain_ok": false, "broken_at_sequence": 7, "error": "entry_hash mismatch at sequence 7" }Sensitive fields (e.g. plaintext tokens inadvertently logged) can be redacted via AuditService.RedactEntry. Redaction:
- Replaces the targeted field value with
"[REDACTED]"indetail. - Sets
redacted = trueon the entry. - Appends a new
REDACTIONaudit entry (which is itself chained), so the redaction event is itself tamper-evident. - The original
entry_hashis preserved so historical chains that reference this entry remain verifiable.
The latest entry's hash can be anchored outside the database via AuditService.AnchorLatestEntry:
- MVP: Written to an append-only local file (
AUDIT_ANCHOR_FILE_PATH). - Production target: Published to a Stellar ledger transaction via
apps/api/internal/stellar/invoker.go.
Once anchored, the anchored = true and anchor_tx_hash are set. Any later modification of the anchored entry can be proved fraudulent by comparing against the external anchor.
| Threat | Mitigation |
|---|---|
| DBA deletes a row | Sequence gap detected on next VerifyChain call |
| DBA modifies a field | Hash mismatch detected on next VerifyChain call |
| DBA recomputes hash after modification | prev_hash chain breaks on the following entry; or anchored hash in Stellar is contradicted |
| Attacker reconstructs entire chain | Requires control of all external anchors and all on-chain Stellar transactions |
| In-process race / dual-write | Prevented by sync.Mutex + SERIALIZABLE transaction |
Note: The hash-chained model provides tamper evidence, not full tamper prevention. A sufficiently privileged attacker who can also rewrite the anchor store could still forge entries. Full prevention requires HSM-backed signing, which is tracked as a future enhancement.