FerrVault inherits the baseline controls of the FerrLabs platform — see ferrlabs.com/security for identity, infrastructure, vulnerability disclosure, and breach notification. The addenda below cover what is specific to a secrets manager: how a secret value flows from your CLI or pod, through TLS, into envelope-encrypted storage, and back out under audit.
<h2>Transport</h2>
<ul>
<li>
<strong>TLS 1.2+ only.</strong> Mozilla "Modern" profile pinned at the edge: AEAD ciphers
only (AES-GCM, ChaCha20-Poly1305), ECDH P-384/P-521, SNI strict.
<a href="https://www.ssllabs.com/ssltest/analyze.html?d=api.ferrvault.com&latest"
>SSL Labs grade A+</a
>
at the time of writing.
</li>
<li>
<strong>Post-quantum hybrid key exchange.</strong>
<code>X25519MLKEM768</code> negotiated with capable clients (Chrome 124+, Firefox 132+,
Edge 124+). Defends against "harvest now, decrypt later" attacks where an adversary records
ciphertext today and tries to decrypt it with a future quantum computer.
</li>
<li>
<strong>HSTS preload</strong> on every FerrVault subdomain:
<code>max-age=31536000; includeSubDomains; preload</code>, plus a 301 redirect on
<code>www.ferrvault.com</code>. Submitted to the Chrome / Firefox / Edge preload lists.
</li>
<li><strong>DNS CAA</strong> pins certificate issuance to Let's Encrypt only.</li>
<li>
<strong>No response compression</strong> on reveal endpoints. Removes the theoretical
BREACH side channel.
</li>
<li>
<strong>HTTP→HTTPS</strong> permanent redirect; the HTTP entrypoint serves no real content.
</li>
</ul>
<h2>Storage and encryption</h2>
<ul>
<li>
<strong>Envelope encryption.</strong> Each secret version is encrypted with a unique
256-bit Data Encryption Key (DEK) generated by a CSPRNG. The DEK is itself wrapped by a
per-vault Key Encryption Key (KEK) held outside the application database. We never write a
DEK to disk in plaintext, and we never write a KEK at all — only the KMS identifier.
</li>
<li>
<strong>AES-256-GCM</strong> with a 96-bit random nonce per write. No (key, nonce) pair is
ever reused: each rotation mints a new DEK.
</li>
<li>
<strong>KMS-backed KEK.</strong> Production runs against HashiCorp Vault Transit; LocalKMS
is explicitly refused at startup when <code>FERRVAULT_ENVIRONMENT=production</code>.
</li>
<li>
<strong>KEK rotation</strong> is auditable; rotation events emit a dedicated
<code>kek.rotated</code> entry.
</li>
<li>At-rest disk encryption on the underlying Postgres volume.</li>
</ul>
<h2>Authorization and isolation</h2>
<ul>
<li>
<strong>Three-level hierarchy:</strong> Vault → Environment → Secret. A staging operator
token literally cannot decrypt production ciphertext — the SAT is bound to one
<code>(vault, environment, role)</code> tuple and every SQL query scopes by
<code>environment_id</code>.
</li>
<li>
<strong>RBAC roles:</strong> Viewer (read), Writer (read + rotate), Admin (full + grants).
</li>
<li>
<strong>Per-SAT IP allowlist.</strong> Each service-account token can declare a list of
CIDRs / IP literals (v4 + v6) outside of which it is rejected with 403.
</li>
<li>
<strong>Per-SAT + per-IP rate limit.</strong> 60 requests / minute on each axis;
forged-token floods cannot bypass via random bearer rotation.
</li>
<li>
SAT tokens are hashed with <strong>Argon2id</strong> at rest; lookup uses an indexed
SHA-256 hash so verification stays O(1) under load.
</li>
<li>
JWT auth uses <strong>ed25519</strong> signatures issued by the central FerrLabs identity
provider.
</li>
</ul>
<h2>Audit</h2>
<ul>
<li>
<strong>Every read of a secret value is logged</strong> — that is the product, and a failed
audit insert blocks the read. The plaintext does not leave the process if the trail cannot
be persisted.
</li>
<li>
Every write, version restore, grant change, KEK rotation, and environment lifecycle event
is also recorded.
</li>
<li>
Audit rows carry the org, the vault, the secret, the actor (user JWT or SAT id), the
action, and structured metadata. Reachable via the API and the web UI under the
<em>Audit</em> tab of each vault.
</li>
<li>Tamper-evident audit (append-only hash chain + immutable export) is on the roadmap.</li>
</ul>
<h2>HTTP security headers</h2>
<p>
Applied to every <code>api.ferrvault.com</code>, <code>app.ferrvault.com</code>,
<code>auth.ferrvault.com</code>, and <code>ferrvault.com</code> response:
</p>
<ul>
<li><code>Strict-Transport-Security: max-age=31536000; includeSubDomains; preload</code></li>
<li>
<code>Content-Security-Policy: frame-ancestors 'none'</code> +
<code>X-Frame-Options: DENY</code>
</li>
<li><code>X-Content-Type-Options: nosniff</code></li>
<li><code>Referrer-Policy: strict-origin-when-cross-origin</code></li>
<li><code>Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()</code></li>
<li><code>Cross-Origin-Opener-Policy: same-origin</code></li>
<li><code>X-DNS-Prefetch-Control: off</code></li>
</ul>
<h2>Operational hardening</h2>
<ul>
<li>Hard cap on inbound request bodies (1 MiB) — kills OOM attempts via arbitrary-sized POSTs.</li>
<li>SQL fully parameterised; no user input ever concatenated into a query string.</li>
<li>
<code>ON DELETE CASCADE</code> on every vault-scoped foreign key — no dangling references
after vault or environment removal.
</li>
<li>
Database errors are mapped to a generic <code>"Database error occurred"</code> body; the
full error is logged server-side only.
</li>
<li>
Container images pulled from <code>ghcr.io/ferrlabs/*</code>; pods run non-root with
read-only root filesystem.
</li>
</ul>
<h2>What we do not claim</h2>
<ul>
<li>
FerrVault is <strong>not yet SOC 2 / ISO 27001 certified.</strong> Tracked on the FerrLabs
compliance roadmap.
</li>
<li>
No external penetration test has been performed yet. The controls above are the result of
internal review.
</li>
<li>
Mutual TLS for operator → API and append-only hash-chained audit are on the roadmap but not
shipped today.
</li>
</ul>
<h2>Contact</h2>
<p>
<a href="mailto:security@ferrlabs.com">security@ferrlabs.com</a> ·
<a href="https://ferrlabs.com/security">FerrLabs platform security →</a>
</p>