In scope — please report these:
- Smart contract vulnerabilities (reentrancy, fund loss, logic errors in vault/rebalance)
- 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 update_apy_attested and update_tvl_attested registry functions require every value to
be signed by one or more registered ed25519 attester keys before it is accepted on-chain.
This converts "trust the caller" into "verify the data" and makes every accepted value
independently auditable via the VAL_ATT event log.
The attester signing key MUST be distinct from the Stellar transaction-submitting (fee-paying) key.
This requirement is not advisory — it is the security boundary the feature is built on:
- The transaction key pays Stellar fees and authorises the
update_apy_attestedcall. It is exposed to the Stellar network on every submission. Compromise of this key lets an attacker submit transactions, but without a valid attester signature the on-chain contract will reject the value. - The attester key signs the canonical payload (contract address, source id, field, value, validity window, nonce). It never touches the Stellar network directly. Compromise of this key lets an attacker forge signatures, but without the transaction key no on-chain call can be submitted.
If the same key plays both roles, attestation adds nothing — a compromise gives the attacker both halves simultaneously. The registry can be updated to any value with a single key, exactly as before.
The Go APY push pipeline (apps/api/internal/service/apy_service.go,
apps/api/internal/service/performance/apy_refresh.go) is responsible for signing attestations
before submitting registry updates via apps/api/internal/stellar/invoker.go.
- The attester private key must be stored in the same secret store as the AES cipher keys
(see
apps/api/internal/crypto/account_cipher.go), never in a plain environment variable. - The attester key versioning must follow the same rotation scheme used for cipher keys:
versioned labels (e.g.
attester-v1,attester-v2), non-destructive rotation, with old public keys revoked from the registry only after no in-flight attestations using them remain. - The configuration entry for the attester key lives in
apps/api/internal/config/config.gounder the same section as the cipher key configuration. The field name isATTESTER_SIGNING_KEY(env:API_ATTESTER_SIGNING_KEY). It holds the base64-encoded raw ed25519 private key (32 bytes). - The fee-paying Stellar key is already managed separately in config. Do not derive the attester key from it or store them together.
Every attester signature covers:
- The registry contract address — so a signature for testnet cannot be replayed on mainnet.
- The source id and field tag (APY vs TVL) — so a signature for one source/field cannot be used for another.
- The exact value — so a captured attestation cannot be altered.
- A validity window (
valid_from/valid_until) — so a captured-but-old signature has a bounded lifetime. - A monotonic nonce per attester — so the same signature cannot be submitted twice.
If all attesters become unavailable, the protocol can still mark any source Paused or
Deprecated via update_status, which requires only the normal Admin/Operator role — no
attestation. This means an attester outage can never become a protocol outage: sources can
always be paused to stop capital allocation while the attester infrastructure is restored.
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 | Key Version |
|---|---|---|
webhooks |
webhook signing secret (sealed via AccountCipher) |
secret_key_version (TEXT) |
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.
Rotation is non-destructive: new encryptions use the active key version, while every registered key version can still decrypt the ciphertext it sealed. Each row records the version that sealed it, so rotating the active key never orphans existing data, and re-encryption can proceed row by row without downtime.
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 batched command. 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.
Nester implements defence-in-depth for software supply-chain security, covering all ecosystems in the monorepo (Go, npm, Rust Cargo, and CI/containers).
Every CI build generates a CycloneDX-format SBOM for each deployable artifact affected by the change set:
- Go API:
cyclonedx-gomodgenerates the Go module SBOM - Frontend (npm/pnpm):
syftgenerates the pnpm dependency tree SBOM (catalogspnpm-lock.yamldirectly) - Contracts (Rust):
syftgenerates the Rust crate SBOM
SBOM generation is mandatory for the artifacts touched by a build: a generator failure fails the job, and the artifact upload is configured with if-no-files-found: error so a build with missing SBOMs cannot go green. SBOMs are uploaded as build artifacts and retained per build, so any deployed version has an exact, queryable record of its dependency contents.
All package dependencies are pinned to exact versions with integrity verification:
- Go:
go.sumwith checksum database (GOSUMDB) verification - npm: Lockfile (
pnpm-lock.yaml) with integrity hashes; CI uses--frozen-lockfileto reject unrecorded changes - Rust:
Cargo.lockwith verified crate checksums
CI fails the build if integrity verification fails for any ecosystem. CI tooling is pinned as well: third-party GitHub Actions are pinned to immutable commit SHAs (see below) and the SBOM generators (cyclonedx-gomod, syft) are installed from exact release versions, with downloaded binaries verified against published checksums.
All third-party GitHub Actions are pinned to immutable commit SHAs rather than mutable version tags. A @v4 tag can be repointed by the action owner; a SHA cannot. Each SHA is annotated with the equivalent semver tag for readability.
The following actions are pinned across all workflows:
actions/checkout,actions/setup-node,actions/setup-go,actions/setup-pythonpnpm/action-setup,dorny/paths-filter,dtolnay/rust-toolchaingithub/codeql-action/*,semgrep/semgrep-action,dependabot/fetch-metadataactions/upload-artifact
Where available, dependencies and actions with build provenance/attestations are preferred and verified:
- GitHub Actions: Official
actions/*andgithub/*actions publish signed attestations viaattestations.writepermission - npm packages: Packages with provenance attestations (via npm registry) are preferred
- Go modules: Go checksum database (
sum.golang.org) provides transparency log verification
Coverage is documented and reviewed as part of new-dependency introduction.
Automated vulnerability scanning runs on every PR and push across all ecosystems:
| Scanner | Ecosystem | Policy |
|---|---|---|
gitleaks |
Secrets | No secrets in any commit |
govulncheck |
Go (api) | Known critical/high blocks; accepted vulns must be waived in .vulnignore with a future review date, owner, and status |
gosec |
Go (api) | Medium+ severity; warnings only |
cargo-audit |
Rust (contracts) | All advisories; warnings only |
pnpm audit |
npm (dapp/website) | Moderate+ fails; warnings only |
semgrep |
TypeScript/Next.js | OSS + Pro rules (TypeScript, React, Next.js, secrets) |
CodeQL |
Go, JS/TS | All queries, security-extended suite |
A known vulnerability in a production dependency blocks the merge/release until remediated or explicitly waived.
New npm dependencies are automatically checked for typosquatting patterns (Levenshtein distance ≤ 2 from known popular packages) during CI. Suspicious matches are surfaced as warnings for manual review before merge.
Adding a new dependency follows this gated process:
- Dependabot or developer proposes the change in a PR
- CI runs vulnerability scans across all ecosystems
- Typosquat detection checks the new package name
- For significant additions, a brief review of the package's provenance and health is required
- The PR must pass all CI gates before merge
.vulnignore is the single source of truth for accepted vulnerabilities and is enforced by CI:
- Every entry requires a documented justification, a time-bounded review date (
Review date: YYYY-MM-DD), an owner, and a status - CI parses
.vulnignorewhen filtering vulnerability-scan results and fails the build if any waiver is expired, malformed, or ownerless - Waivers are auditable and reviewed as part of the release process; permanent silent suppressions are not permitted
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 steps in .github/workflows/ci.yml and .github/workflows/security.yml) reads the waiver list from .vulnignore, only allows vulnerabilities explicitly waived there, fails on any new or unreviewed vulnerability, and fails the build if a waiver is expired, malformed, or ownerless.
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.