You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Resurgamus Horizon - Threat Model & Security Assessment
Version: 1.2
Date: 2026-08-22 (assessment 2026-08-04, claims re-verified against the tree 2026-08-22)
Scope: Resurgamus Horizon self-hosted secrets vault (API + Frontend + Agent + PostgreSQL). Extended coverage for PKI, dynamic secrets, MCP/agent integration, multi-worker RPC, and LDAP/SSO proxy auth, all shipped after the 1.0 assessment.
Both summary tables below were recounted row by row and are exact: the ATT&CK
matrix totals 20 COVERED / 23 PARTIAL / 0 out-of-scope, and the ASVS summary
totals 42 MET / 1 PARTIAL / 0 NOT MET / 2 N/A. Numeric crypto claims
(Argon2id m=256MB t=3, 16-byte salt, the escalating rate-limit tiers) were
checked against api/app/crypto.py and api/app/rate_limit.py.
COVERED - Resurgamus Horizon has a concrete mitigation in place
PARTIAL - Mitigation exists but depends on deployment or has known gaps
OUT OF SCOPE - Resurgamus Horizon does not protect against this by design
TA0001 - Initial Access
ID
Technique
Status
Resurgamus Horizon Mitigation
T1190
Exploit Public-Facing Application
COVERED
Vault must NOT be exposed to the internet. Access via VPN (IPsec / OpenVPN / Tailscale) or private network. Auth is self-contained (master password + 2FA). No /docs or /redoc exposed. FastAPI input validation (Pydantic).
T1133
External Remote Services
COVERED
Single entry point: VPN or private network. No SSH on container. No remote admin interface. Auth requires master password + 2FA.
T1078
Valid Accounts
PARTIAL
Tokens are HMAC-SHA512 hashed (can't be extracted from DB dump). 2FA required for unseal. But: a stolen bearer token grants access until revoked. Optional LDAP/AD login follows the standard secure pattern: rhorizon never compares the user's password itself - a service account binds to find the user DN, then rhorizon attempts to bind AS the user with their supplied password (bind success = valid credential). The LDAP bind password is stored DEK-wrapped (AAD-bound to the config row).
T1078.001
Default Accounts
COVERED
No default credentials. First unseal creates the master key. Root token shown once.
T1195.001
Compromise Software Dependencies
PARTIAL
pip-audit in CI pipeline. Trivy CVE scan daily. Pinned versions in requirements.txt and Cargo.toml. But: no SBOM signature verification on Python packages yet. Trivy itself was compromised (0.69.4-6, March 2026) - pinned to 0.70.0 (clean post-incident release).
T1199
Trusted Relationship
PARTIAL
Tokens are namespace-scoped with minimal permissions. But: a compromised Ansible/CI host with a vault token can read its scoped secrets. The optional SSO-proxy auth path (Authelia/Authentik/Keycloak/oauth2-proxy) extends this same trust class: identity comes from Remote-User/Remote-Groups headers, trusted only from an operator-configured IP allowlist, not cryptographically verified per-request. A trusted-IP change itself requires an authenticated admin call. If the allowlist is too broad, or something upstream of the allow-listed IP fails to strip client-supplied identity headers before proxying, header spoofing bypasses auth entirely - this is a trust-boundary control, not a cryptographic one, and its strength is exactly as strong as the operator's proxy configuration.
TA0002 - Execution
ID
Technique
Status
Resurgamus Horizon Mitigation
T1059
Command and Scripting Interpreter
COVERED
No shell in production container (pip, curl, wget removed). Read-only filesystem. noexec on all tmpfs. cap_drop: ALL.
T1609
Container Administration Command
PARTIAL
Container runs as uid 1500 (non-root). no-new-privileges. But: depends on Docker socket not being exposed.
T1610
Deploy Container
PARTIAL
Depends on host Docker daemon security. Resurgamus Horizon containers use internal networks only.
T1677
Poisoned Pipeline Execution
PARTIAL
Woodpecker pipeline runs SAST (bandit), secrets scan (detect-secrets), dep-audit. But: pipeline has access to Docker socket on deploy host. Cosign signs SBOM.
T1072
Software Deployment Tools
PARTIAL
Ansible vault tokens should be scoped (secrets:r on specific namespaces only). Pipeline secrets stored as Woodpecker encrypted env vars.
TA0003 - Persistence
ID
Technique
Status
Resurgamus Horizon Mitigation
T1098
Account Manipulation
COVERED
Token creation requires admin:w scope. Token operations enter the Ed25519-signed mutation chain (HMAC-SHA512 legacy/fallback); tampering breaks verification.
T1556
Modify Authentication Process
COVERED
Authentication code is in read-only container filesystem. Docker image signed with cosign. Binary verification via SBOM (syft).
T1556.006
Multi-Factor Authentication Bypass
COVERED
2FA mode stored in DB (vault_config), not in code. WebAuthn challenges are single-use (DELETE+RETURNING in DB). TOTP codes validated server-side. YubiKey HMAC challenges have 60s TTL.
T1505.003
Web Shell
COVERED
Read-only filesystem. No shell binary. noexec tmpfs. cap_drop: ALL. No writable directory except tmpfs (16MB, noexec).
T1136
Create Account
PARTIAL
Dynamic secrets engines (PostgreSQL/MySQL/LDAP) create real accounts on the target system on every credential issuance (secrets:w, engine/role config gated at admin:w). Each mint is audit-logged (actor, role, lease). Auto-revoked at lease expiry by the reaper (revocation_sql / LDAP delete). But: revocation depends on the target executing the DROP/delete successfully - a failed drop leaves the account live until the next reaper cycle, and revocation_sql must itself be idempotent for that retry to be safe.
TA0005 - Defense Evasion
ID
Technique
Status
Resurgamus Horizon Mitigation
T1612
Build Image on Host
PARTIAL
Depends on host hardening. Container images are built in CI and signed with cosign.
T1610
Deploy Malicious Container
PARTIAL
SBOM generated with syft, signed with cosign. But: verification at deploy time is manual.
T1027
Obfuscated Files
COVERED
Secrets are encrypted with XChaCha20-Poly1305 (per-secret DEK) + AES-256-GCM (DEK wrapping). Offline bruteforce requires cracking Argon2id (256MB, 3 iterations).
T1070.003
Clear Command History
PARTIAL
Audit chain is tamper-evident (chained HMAC). But: an attacker with DB access could truncate the audit table - integrity is detectable but not preventable.
rhorizon IS the password store. Argon2id -> HKDF -> XChaCha20 + AES-GCM stack. Sealed by default at reboot. Master key never on disk. Symmetric primitives (AES-256, XChaCha20, HMAC-SHA512) are quantum-resistant.
T1555.005
Password Managers
COVERED
Double envelope encryption: secret -> per-secret DEK (XChaCha20) -> DEK wrapped with dek_key (AES-256-GCM). DB dump without master key is useless.
T1552
Unsecured Credentials
PARTIAL
.env file contains only POSTGRES_PASSWORD (not vault secrets). But: RH_TOKEN in docker-compose env is visible via docker inspect. Recommendation: use rh-fetch (file-based, tmpfs).
T1552.001
Credentials In Files
COVERED
Secrets are never written to disk in cleartext. rh-fetch writes to tmpfs (RAM) with mode 0400. Audit logs never contain secret values.
T1552.003
Shell History
PARTIAL
CLI tool (rhorizon) prompts through stdin, and operator guides avoid placing passwords or tokens in command arguments. Custom scripts remain the operator's responsibility.
T1552.007
Container API
PARTIAL
rh-inject removes RH_TOKEN from child env. But: resolved secrets remain as env vars in /proc/PID/environ. rh-fetch (file-based) is the secure alternative.
T1003
OS Credential Dumping
PARTIAL
Rust extension (rhorizon_crypto): keys use mlock when the host permits it and zeroize on drop. The wrap key lives in Rust heap, outside Python object introspection. Host root and kernel compromise remain out of scope.
T1003.007
Proc Filesystem
PARTIAL
mlock prevents swap. Rust wrap key is not in Python heap. But: an attacker with root on the host can still read /proc/PID/mem - mlock protects against swap, not root access. Multi-worker key custody uses the same boundary: only the master process holds sub-keys, followers delegate crypto ops over a 0700 filesystem-path Unix socket with peer-UID validated via SO_PEERCRED (fail-closed - a UID mismatch is rejected, not logged-and-allowed). A same-uid, same-host process could still connect; cross-uid and cross-host cannot.
T1040
Network Sniffing
COVERED
Client-to-frontend: TLS via Nginx (TLS_ENABLED). External access: VPN (IPsec / OpenVPN). API-to-PostgreSQL: TLS (self-signed cert). Internal Docker network is bridge-isolated.
T1528
Steal Application Access Token
PARTIAL
Tokens are HMAC-SHA512 hashed in DB (can't extract from dump). Tokens have optional TTL (ephemeral: 60s-24h). Revocation is immediate. But: a stolen token in transit is valid until revoked or expired. MCP tokens are the same token type, scoped read-only by policy.toml (fail-closed: an absent or empty policy denies every tool call). The optional mcp-hub daemon additionally caches bearer validation (30s positive / 5s negative TTL) and rate-limits repeated rejects (10/60s -> 429) to bound /tokens/whoami amplification from a probing agent.
T1110
Brute Force
COVERED
Rate limiting on unseal endpoint (configurable). Argon2id with 256MB memory cost makes bruteforce extremely expensive (~1 attempt/second on modern hardware).
WebAuthn is phishing-resistant (origin-bound). TOTP codes are time-limited (30s window). YubiKey HMAC challenges have 60s TTL, single-use. But: TOTP is vulnerable to real-time phishing proxies.
T1606
Forge Web Credentials
COVERED
HMAC-SHA512 key is derived from master key (HKDF-SHA512). Never stored on disk. 256-bit key space. Forging requires the master password + Argon2id derivation.
T1212
Exploitation for Credential Access
PARTIAL
SAST (bandit) in CI. No SQL injection (parameterized queries via SQLAlchemy). Input validation (Pydantic). But: zero-day in FastAPI/uvicorn/asyncpg is always possible.
T1649
Steal or Forge Authentication Certificates
PARTIAL
PKI engine mints leaf certificates + private keys server-side (secrets:w, namespace-scoped). The private key is returned once in the API response and never persisted. The CA private key is decrypted from DB only for the signing operation and explicitly zeroed after (secure_zero) - but it does transit through Python memory during that window, same exposure class as any other decrypt operation.
TA0008 - Lateral Movement
ID
Technique
Status
Resurgamus Horizon Mitigation
T1550.001
Application Access Token
PARTIAL
Tokens are namespace-scoped. Minimal permissions principle. But: an root token grants full access to all secrets.
T1021.004
SSH
COVERED
No SSH service in vault container. No SSH keys stored in vault by default (user choice).
T1210
Exploitation of Remote Services
PARTIAL
Internal Docker network. No exposed services except API (port 8200). PostgreSQL not exposed.
TA0009 - Collection
ID
Technique
Status
Resurgamus Horizon Mitigation
T1213
Data from Information Repositories
COVERED
All secret reads are audit-logged. Namespace isolation. Token scope enforcement. Bulk export requires admin:w + age encryption.
T1005
Data from Local System
COVERED
Secrets encrypted at rest in PostgreSQL (XChaCha20-Poly1305 + AES-256-GCM double envelope). DB files without master key are useless.
T1056.003
Web Portal Capture
PARTIAL
CSP: no unsafe-inline. X-Frame-Options: DENY. But: keylogger on admin's machine is out of scope.
T1557
Adversary-in-the-Middle
COVERED
TLS via Nginx (client-facing). TLS API-to-PostgreSQL (self-signed). VPN (IPsec / OpenVPN) for all external traffic. WebAuthn is origin-bound (MITM-resistant).
Summary Matrix
Category
Covered
Partial
Out of Scope
Initial Access
3
3
0
Execution
1
4
0
Persistence
4
1
0
Defense Evasion
1
3
0
Credential Access
7
9
0
Lateral Movement
1
2
0
Collection
3
1
0
Total
20
23
0
2. OWASP ASVS Level 2 Checklist
V2 - Authentication
Req
Requirement
Status
Implementation
2.1.1
Password min 12 characters
N/A
Master password has no minimum length enforced (user responsibility - Argon2id makes short passwords expensive to crack but not impossible). Consider adding.
2.1.7
Check against compromised password lists
N/A
Resurgamus Horizon has a single master password (no user accounts, no email). HIBP integration has no value here. Argon2id (256MB) makes brute-force of even weak passwords expensive.
2.2.1
Anti-automation: max failed attempts
MET
Escalating fail2ban-style lockout on /unseal. RATE_LIMITS = [(20, 30), (50, 300), (200, 3600)] is (failure_count, lockout_seconds): 20 failures locks the IP out for 30s, 50 for 300s, 200 for 3600s. The counting window is separate (rate_limit_findtime, 3600s default). Whitelist via RH_RATE_LIMIT_WHITELIST. Deliberately permissive: a 256-bit token is infeasible to brute-force and Argon2id already costs ~500ms per master-password attempt, so this is the second line, not the first.
2.4.1
Approved KDF for password storage
MET
Argon2id (m=256MB, t=3, p=1 - libsodium's crypto_pwhash fixes parallelism at 1, not configurable).
2.4.2
Random salt min 32 bits
MET
16-byte (128-bit) random salt per vault instance, stored in vault_config.
2.6.1
Single-use lookup secrets
MET
Challenges are single-use (DELETE+RETURNING). TOTP codes validated once per period.
2.8.2
Symmetric key protection via HSM or secure storage
MET
Rust extension: mlock when permitted + zeroize on drop. Wrap key in Rust heap, outside Python object introspection. This is software protection, not an HSM.
2.8.4
TOTP single-use per period
MET
Server-side validation, code accepted once per 30s window.
2.9.2
WebAuthn challenge nonce min 64 bits
MET
WebAuthn challenges generated with CSPRNG, stored in DB, single-use, 60s TTL.
2.9.3
Approved crypto for WebAuthn
MET
Server offers ES256 (ECDSA P-256) and RS256 in pubKeyCredParams; the authenticator picks. Both are standard COSE algorithms.
V3 - Session Management
Req
Requirement
Status
Implementation
3.2.1
New session token on auth
MET
Tokens are generated at creation with CSPRNG, HMAC-SHA512 hashed.
AEAD modes only (GCM, Poly1305). No CBC, no padding.
6.2.2
Approved algorithms only
MET
Argon2id, HKDF-SHA512, XChaCha20-Poly1305, AES-256-GCM, HMAC-SHA512. No custom crypto.
6.2.5
No weak algorithms
MET
No ECB, no Triple-DES, no MD5, no SHA1 (except YubiKey HMAC-SHA1 - hardware constraint).
6.2.6
Nonce never reused with same key
MET
24-byte random nonce (XChaCha20), 12-byte random nonce (AES-GCM). CSPRNG (os.urandom / OsRng). Probability of collision: negligible (2^-96 for GCM, 2^-192 for XChaCha20).
Rust extension: wrap key in Rust heap (mlock'd), Python GC never sees raw key material. AESGCM instance holds key in libcrypto (C extension), not Python heap.
V7 - Error Handling & Logging
Req
Requirement
Status
Implementation
7.1.1
No credentials in logs
MET
Audit logs record action, actor, target - never secret values or token plaintext.
7.1.3
Security events logged
MET
All auth, seal/unseal, secret access, token operations, 2FA changes are audit-logged.
7.3.3
Logs protected against tampering
MET
Mutation entries form an Ed25519-signed chain (HMAC-SHA512 legacy/fallback) mirrored to JSONL. Read entries are complete-row Merkle-hashed into signed checkpoints; archived prefixes carry verified digests, roots, and signed seal lineage.
7.3.4
Synchronized time sources
MET
UTC timestamps. Container uses host clock (Docker default).
V8 - Data Protection
Req
Requirement
Status
Implementation
8.1.2
Temporary copies purged
MET
Rust: zeroize on drop. Sealing clears the Rust-held key buffers.
8.2.1
Anti-cache headers
MET
Nginx: Cache-Control: no-store on API responses. CSP headers.
8.3.1
Sensitive data in body, not query string
MET
All secrets in POST/PUT body (JSON). No query string secrets. Tokens in Authorization header.
8.3.5
Audit of sensitive data access
MET
Every secret read/write records actor, action, target, detail, timestamp, and IP. Writes are signed per row; reads are covered by signed Merkle checkpoints and verifiable archives.
8.3.6
Memory zeroing of sensitive data
MET
Rust extension: mlock (no swap) + zeroize-on-drop. Wrap key in Rust heap. Seal zeroes all encrypted buffers. Python AESGCM key held in libcrypto (C), not Python GC.
Frontend Nginx supports TLS natively (TLS_ENABLED=true, cert/key mounted). API listens on HTTP behind Nginx (same compose stack). External traffic is encrypted by the operator's VPN (IPsec / OpenVPN).
9.1.3
TLS 1.2+ only
MET
Nginx TLS configuration enforces TLS 1.2+ (ssl_protocols TLSv1.2 TLSv1.3). VPN tunnels use cryptography equivalent to or stronger than TLS 1.2+.
9.2.2
TLS on all connections including internal
MET
API-to-PostgreSQL is always encrypted. RH_DATABASE_SSL defaults to require (no certificate verification, fine on a same-host Docker bridge, not against an active MITM); verify-full pins RH_DATABASE_CA_CERT and is the correct setting once the database is off-host.
V13 - API & Web Services
Req
Requirement
Status
Implementation
13.1.3
No sensitive data in URLs
MET
Secrets accessed by name in path (not value). Tokens in Authorization header.
13.1.4
Authorization at URI and resource level
MET
Scope checked per endpoint (auth.py dependency) AND per resource (namespace filtering).
A kernel vulnerability can bypass all container isolation (namespaces, cgroups, seccomp).
3.2 - Cryptographic Limitations
Threat
Status
Weak master password
Argon2id makes brute-force expensive but not impossible. A 6-character password is crackable regardless of KDF. No minimum length enforced.
Quantum computing (asymmetric)
Storage uses 256-bit symmetric primitives, with an estimated 128-bit brute-force margin under Grover. TLS prefers hybrid X25519MLKEM768 on nginx, PostgreSQL, and Rust-agent paths, but resistance depends on verifying live negotiation because classical fallbacks remain. WebAuthn signatures remain classical. See docs/POST-QUANTUM.md.
YubiKey HMAC-SHA1
SHA1 is weak for collision resistance but HMAC-SHA1 is still secure (keyed construction). Hardware limitation, not a choice.
No forward secrecy
If the master password is compromised, all past secrets encrypted with that key are recoverable from a DB backup. DEK rotation limits exposure window but doesn't provide forward secrecy.
Timing side channels
The constant-time crypto and its per-build asm verification are documented in docs/SIDE-CHANNELS.md. The one residual not closeable by test or asm-grep is the microarchitectural channel (SMT/port contention) - handled by the single-tenant deployment model (no untrusted co-resident), not by proof.
3.3 - Operational Gaps
Threat
Status
Single operator
No separation of duties enforced. Admin token has full access. Shamir mitigates for unseal but not for daily operations.
No HSM integration
Wrap key is in software (Rust mlock'd heap), not in a hardware security module. FIPS 140-2 Level 3+ requires HSM.
mlock degradation
Without IPC_LOCK or sufficient RLIMIT_MEMLOCK, best-effort mode separately reports buffer protection and whether whole-process pages remain swappable; zeroize still runs on drop. This is a persistent-data concern only with unencrypted or unverified swap, where the API warns. Encrypted swap, zram, and no swap are reported without a warning. Raise the limit/grant the capability, or select RH_MEMORY_LOCK_MODE=required to fail closed while swap is exposed.
No certificate pinning
No mutual TLS between API clients and vault. TLS via Nginx + operator's VPN, but no client cert pinning.
No anomaly detection
Audit logs detect tampering but don't alert on suspicious patterns (e.g., bulk secret reads at 3 AM). Requires external SIEM integration.
Backup decryption
age-encrypted backups require the age key. If the age key is lost, the backup is unrecoverable. No key escrow.
PostgreSQL TLS unverified by default
database_ssl defaults to require: encrypted, but the server certificate is not verified, so this stops passive sniffing and not an active MITM on the DB path. verify-fullis supported and pins database_ca_cert, so it is opt-in, not absent. The default suits single-host; set verify-full for any deployment where the database is reached across a network you do not own.
3.4 - Agent (rh-inject) Limitations
Threat
Status
Env var exposure
rh-inject resolves secrets into environment variables. These remain visible in /proc/PID/environ and to any process running as the same user.
docker inspect
Container definition shows RH_TOKEN and rh:// references.
Recommendation
Use rh-fetch (file-based, tmpfs, mode 0400) instead of rh-inject for production workloads. rh-inject is suitable for development and low-sensitivity environments.
3.5 - MCP / AI Agent Integration Limitations
Threat
Status
Prompt injection via a rogue upstream
rhorizon-mcp-hub forwards tool results from federated upstreams verbatim to the agent. A malicious or compromised upstream MCP server can inject instructions into a tool result the agent then treats as trusted context. This is not a MITRE ATT&CK Enterprise technique (LLM-specific; the adjacent framework is MITRE ATLASAML.T0051 - LLM Prompt Injection) and rhorizon has no code-level mitigation for it beyond the operational recommendation: every federated upstream must be under the operator's own control, never a public or third-party MCP server.
Read-only by design, not by proof
The MCP tool catalog exposes 7 read-only tools (status, whoami, list-namespaces, list-secrets, get-secret, audit-tail, cluster-health); there is no write, seal/unseal, or token-management tool. This bounds what a compromised or manipulated agent can do to reading whatever the token's policy.toml allows - but a broad policy.toml whitelist still means a manipulated agent can read (and potentially exfiltrate via its own output) every secret the operator granted it.
Operator-token discovery
Tool discovery (the catalog presented to the agent at startup) runs under the hub's own operator token, not the calling agent's. A mcp-hub misconfiguration that widens discovery scope affects every agent behind that hub.
Resurgamus Horizon's auth model is self-contained: master password + 2FA (WebAuthn/TOTP/YubiKey). No SSO proxy or reverse proxy is required for security. These are optional layers the operator may add.
Network isolation is the operator's responsibility. Resurgamus Horizon should not be internet-exposed.
3.7 - What Would Improve the Security Posture
Improvement
Effort
Impact
Minimum password length enforcement
Low
Prevent trivially weak master passwords
Make database_ssl=verify-full the default
Low
The verified mode already exists and is documented; what remains is flipping the default and shipping a CA path, which is a migration question rather than a missing control
Closes the last quantum gap - transport (X25519MLKEM768) and data-at-rest are already PQ; only the FIDO2 ECDSA signature remains
Shared-secret header alongside SSO-proxy IP allowlist
Low
A second factor the proxy must forward (known only to rhorizon and the reverse proxy) would make header spoofing require more than reaching an allow-listed IP
MCP upstream allowlist / signature check in mcp-hub
Medium
Would move the "operator must only federate trusted upstreams" recommendation from operational guidance to an enforced control