---
title: "Threat model"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:security-threat-model@1.0"
source: "Security/ThreatModel.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Threat model {#threat-model}

This page states what nr-vault defends, who it defends against, and where a
defence stops. It is written to be falsifiable: every control names the class
or command that implements it, and every scenario ends in a residual risk
rather than in a reassurance.

For the limits that no configuration removes, read
[Known limitations](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations@1.0). For the two policy bundles that decide how
strictly the controls below are enforced, read [Security profiles](https://docs.typo3.org/permalink/netresearch/nr-vault:security-profiles@1.0).

## Assets {#assets}

| Asset | Where it lives | Why it matters |
| --- | --- | --- |
| Master key | Outside the database: derived from `$GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']` (`typo3` provider), a key file (`file`), an environment variable (`env`), or unwrapped on demand from a KMS (`transit`). Cached for the duration of one request in `AbstractMasterKeyProvider`. | Root of trust. It unwraps every DEK and derives the audit HMAC key. Its loss is data loss; its compromise is total compromise. |
| Data encryption keys (DEKs) | One per secret, stored wrapped in the envelope (`encrypted_dek` \+ `dek_nonce`). | A DEK opens exactly one secret. That bounded blast radius is the reason for the envelope scheme. |
| Plaintext secret values | Never at rest. Transient in PHP memory during `EncryptionService::decrypt()`, and on screen for at most 30 seconds during a reveal. | The thing being protected. |
| Audit chain | `tx_nrvault_audit_log`, HMAC-chained, mirrored to external sinks and anchored outside the database. | The only evidence of who touched what. Its integrity is what makes every other control auditable. |
| Audit HMAC key | Not stored. Derived per request from the master key with `hash_hkdf('sha256', $masterKey, 32, 'nr-vault-audit-hmac-v1')`. | Without it, a database-write attacker cannot forge chain hashes from epoch 1 upwards. |
| Backend sessions | TYPO3 core (`be_sessions`), outside this extension. | A stolen session with `secret.reveal` reveals secrets. nr-vault inherits TYPO3's session security wholesale. |
| KMS token | `transit` provider only: an environment variable, preferred over the stored setting. | Holding it is equivalent to holding the master key while Vault is reachable. |
| Chain-tip anchors | NDJSON lines written by the `file` sink, plus whatever the syslog and webhook sinks shipped off-host. | The external facts that make a full table reset detectable. |

## Actors {#actors}

| Actor | Reach over the vault |
| --- | --- |
| Anonymous visitor | None. Every vault surface requires a backend context; frontend requests hold no operation permission at all. |
| Frontend user | Reads only secrets flagged `frontend_accessible`, and only through resolution code. A valid backend session carried by a frontend visitor grants nothing extra: `AccessControlService::isGranted()` returns `false` for any frontend request, because frontend output is shared with anonymous visitors through the page cache. Placeholder resolution additionally requires the identifier to be in the request's allow-set ([ADR-035: Per-request allow-set of frontend-resolvable identifiers](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-035-frontend-placeholder-allow-set@1.0)). |
| Backend editor | Whatever their groups were granted (see [Operation permissions](https://docs.typo3.org/permalink/netresearch/nr-vault:security-operation-permissions@1.0)), intersected with per-secret ownership and group tiers. Typically `secret.use` so vault-backed form fields resolve. |
| Backend administrator | By default every operation permission and every secret, through the single bypass seam `AccessControlService::adminBypassActive()`. The hardened profile can withdraw that — see [Disabling the admin override](https://docs.typo3.org/permalink/netresearch/nr-vault:security-disable-admin-override@1.0). |
| System maintainer | As an administrator, plus the Install Tool. Reaches the extension configuration and therefore the provider choice, so a system maintainer is inside the trust boundary of every setting that is not pinned in `config/system/additional.php`. |
| DBA / hoster | Full read and write on the database, no PHP execution assumed. Reads ciphertext and wrapped DEKs (useless without the master key), and can rewrite audit rows — which the HMAC chain and the anchors make evident, not impossible. |
| Technical actor | A named backend user impersonated by headless code through [TechnicalActorContext::runAs()](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-technical-actor-context@1.0). Not an authentication boundary: any code with DI access can open a scope. Its value is validation, guaranteed scope restoration and honest audit attribution. |
| CLI operator | A shell on the host. Reaches `settings.php`, the key file and the environment, so CLI is treated as trusted where it is trusted at all. Secret reads over CLI stay gated on `allowCliAccess` (off by default); break-glass deliberately is not, because a shell already reaches the master key. |

## Trust boundaries {#trust-boundaries}

**Five boundaries; crossing each one requires something different.**

```text
┌────────────────────────────────────────────────────────────────┐
│  Browser (backend operator)                                    │
│  · revealed plaintext, max 30 s, no-store, no copy in hardened │
└───────────────────────────┬────────────────────────────────────┘
                            │  (1) HTTPS + BE session + CSRF
                            │      + secret.reveal AND secret.use
┌───────────────────────────┴────────────────────────────────────┐
│  PHP process  ── the trust anchor ──                           │
│  · master key cached for one request                           │
│  · plaintext exists here and only here                         │
│  · AccessControlService decides every access                   │
└──┬──────────────────┬───────────────────┬──────────────────────┘
   │ (2) SQL          │ (3) filesystem    │ (4) HTTPS + token
   │                  │                   │
┌──┴────────────┐  ┌──┴─────────────┐  ┌──┴────────────────┐
│  Database     │  │  Filesystem    │  │  KMS (Transit)    │
│  ciphertext,  │  │  key file,     │  │  unwraps the      │
│  wrapped DEKs,│  │  wrapped key,  │  │  master key;      │
│  audit chain  │  │  anchor NDJSON │  │  audits each call │
└───────────────┘  └────────────────┘  └───────────────────┘
   │
   │ (5) syslog / NDJSON / webhook — one-way, after commit
┌──┴──────────────────────────────────────────────────────────┐
│  SIEM / log pipeline                                        │
│  · holds evidence the database owner cannot reach           │
└─────────────────────────────────────────────────────────────┘
```

[Trust boundaries](https://docs.typo3.org/permalink/netresearch/nr-vault:security-trust-boundaries@1.0) describes what crossing each boundary
requires, and what each boundary does *not* stop.

## STRIDE-lite {#stride-lite}

| Category | Concern | Control |
| --- | --- | --- |
| Spoofing | Acting as another operator, or as a technical identity that was never authorised. | TYPO3 backend authentication; `runAs()` validates the target user (deleted, disabled and time-restricted users are refused) and never mutates `$GLOBALS['BE_USER']`; audit rows carry `actor_type`, `actor_uid` and `actor_username`, bound into the chain from epoch 3. |
| Tampering | Editing or deleting audit rows; downgrading the chain algorithm; substituting ciphertext. | AEAD tags reject modified ciphertext; the HMAC chain plus `hash_equals()` verification detects row edits; the epoch is bound into the hash from epoch 3 and floored at the configured epoch; anchors detect truncate-and-rebuild. |
| Repudiation | "I never read that credential." | Every read, write, rotation, deletion and denial writes a row before the plaintext is returned; rows are chained and mirrored to external sinks after commit. |
| Information disclosure | Plaintext reaching a log, a cache, a clipboard or a screen that outlives the operator. | `sodium_memzero()` after use; `[REDACTED]` in logs and exceptions; `Cache-Control: no-store` on every reveal response; 30-second auto-hide plus wipe on `visibilitychange`/`pagehide`; copy disabled in the hardened profile. |
| Denial of service | Losing access to secrets, or hanging vault operations. | Sink fan-out happens after commit and outside the audit advisory lock, so a hanging collector cannot serialise vault operations; sink failures are contained per sink and counted; break-glass keeps a hardened installation recoverable. |
| Elevation of privilege | Turning `secret.use` into `secret.reveal`, or admin into unlimited plaintext access. | Ten distinct operation permissions with no implication between `secret.use` and `secret.reveal`; grant lookup deliberately avoids `BackendUserAuthentication::check()`, which short-circuits to `true` for admins; the admin bypass is one seam and can be withdrawn. |

## Attack scenarios {#attack-scenarios}

Each scenario names the control that answers it and the risk that remains.

### Stolen database dump {#stolen-database-dump}

**Attack.** A backup, a replica or a SQL injection elsewhere on the host
yields the full contents of `tx_nrvault_secret`.

**Control.** Values are AEAD ciphertext; DEKs are wrapped by a master key that
is not in the database under any provider. The `typo3` provider derives it
from `encryptionKey` in `settings.php`, the others from a file, an
environment variable or a KMS.

**Residual risk.** If the dump was taken together with the key material, the
secrets are readable. With the `typo3` provider, "the key material" means a
file most backup jobs already include — see
[Known limitations](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations@1.0), and [Backup and restore](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore@1.0)
for the separation that avoids it.

### Audit-row deletion by a database writer {#audit-row-deletion-by-a-database-writer}

**Attack.** An actor with `DELETE` on the audit table removes the rows that
name them.

**Control.** `vault:audit-verify` reports `UID_GAP` from the uid sequence
and `HASH_MISMATCH` for every row whose recomputed hash no longer matches.
From epoch 1 the hash is an HMAC under a key derived from the master key, so
an attacker without the master key cannot re-sign the chain.

**Residual risk.** Detection, not prevention. The window between the deletion
and the next verification run is the exposure — which is why anchoring and
verification belong on a schedule ([Monitoring and alerting](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-and-alerting@1.0)).

### Full audit-table reset {#full-audit-table-reset}

**Attack.** `TRUNCATE TABLE tx_nrvault_audit_log`, then let the service
build a fresh, internally consistent chain from uid 1. The chain itself
cannot distinguish that from a young installation.

**Control.** Chain-tip anchoring at two distances.

Inside the database, `sys_registry` holds a MACed anchor
([ADR-034: Audit chain tip anchor](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-034-audit-chain-tip-anchor@1.0)) under a key derived from the master
key, so an attacker limited to the audit table cannot forge or explain it
away: the anchored row is simply gone, reported as a violation.

Outside the database, an anchor records that sequence *N* once carried entry
hash *H*, plus the HMAC epoch in force. Verification then checks that the
chain did not shrink, that the row at *N* still hashes to *H*, and that its
epoch did not regress — reported as `TABLE_RESET` and `EPOCH_DOWNGRADE`.

**Residual risk.** Each anchor is only as trustworthy as its storage. An
attacker who also holds `DELETE` on `sys_registry` can drop the in-database
anchor, which then reads as "not armed" rather than as a violation —
[auditAnchorRequired](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-auditanchorrequired@1.0) promotes that state to critical from
a configuration file the database cannot reach. An anchor file on the same host
that the attacker owns can be rewritten; `AnchorFileReader` takes the
*highest* anchored sequence rather than the last line, so appending a weaker
anchor is useless, but truncating the file is not. Ship anchors off-host
(syslog or webhook) for the property to hold. Under the hardened profile a
missing sink or missing anchor is itself reported, as `NO_EXTERNAL_SINK`.

### Algorithm downgrade of the audit chain {#algorithm-downgrade-of-the-audit-chain}

**Attack.** Relabel rows to `hmac_key_epoch = 0`, whose hash is a keyless
SHA-256, then recompute a self-consistent chain without ever holding the HMAC
key.

**Control.** Three layers. A decrease between consecutive rows is an
`EPOCH_DOWNGRADE` finding; a uniform downgrade of the whole chain is caught
by the chain-level epoch floor, which defaults to the configured epoch; and
from epoch 3 the epoch column itself is bound into the hash payload, so
re-signing after flipping it needs the key anyway.

**Residual risk.** A chain that is genuinely still at epoch 0 carries no
keyed evidence at all. Migrate with `vault:audit-migrate-hmac`.

### Compromised administrator account {#compromised-administrator-account}

**Attack.** An attacker reaches an account with the TYPO3 admin flag.

**Control.** By default: none worth claiming — an admin holds every vault
permission on purpose. In the hardened profile, `disableAdminOverride`
withdraws the bypass everywhere at once (operation permissions, per-secret
tiers, the privileged-column policy, and the technical-actor equivalents), and
pinning it in `config/system/additional.php` puts it out of the
backend's reach. Regaining full power then requires a break-glass window: a
named actor, a mandatory reason, a hash-chained audit row written *before* the
window opens, a PSR-14 event, a banner in the module, and an expiry between 1
and 60 minutes.

**Residual risk.** Break-glass restores full admin power while it is open —
it prevents nothing. Its value is evidence and time boxing. And an
administrator who also has filesystem access can unpin the flag.

### Reveal left on screen {#reveal-left-on-screen}

**Attack.** An operator reveals a credential and walks away, or a shoulder
surfer photographs the screen.

**Control.** `startRevealLifecycle()` wipes the value after 30 seconds and
immediately when the tab is hidden or the page goes away. Every reveal
re-hits the `vault_reveal` endpoint, so nothing is cached client-side and
each reveal writes its own audit row. The response carries
`Cache-Control: no-store`. In the hardened profile the reveal response
reports `copyAllowed = false` and no copy button is offered, because
clipboard contents outlive the dialog and cannot be cleared reliably.

**Residual risk.** JavaScript strings cannot be zeroized; the engine may
retain copies after the field is cleared. The guarantee is a short exposure
window, not cleared memory. A screenshot or a photograph defeats all of it.

### Vault used as an SSRF pivot {#vault-used-as-an-ssrf-pivot}

**Attack.** A compromised administrator repoints the audit webhook at a cloud
metadata endpoint and reads the response through the sink's error reporting.

**Control.** The webhook sink is built on
`SecureHttpClientFactory`, so it inherits the extension-wide SSRF and
DNS-rebinding defences ([ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-026-dns-rebinding-defence@1.0)) and refuses
private, loopback and RFC1918 targets unless the host is allow-listed in
`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']` — which is
filesystem-bound and out of the backend's reach. The scheme is restricted to
`http` and `https`, so a `file://` value cannot turn an audit fan-out
into a local write. Refusals are not silent: they surface as a
`SINK_FAILURE`.

**Residual risk.** An operator who allow-lists a host broadly re-opens the
pivot for that host. Keep `allowed_hosts` narrow.

### Headless code impersonating a privileged user {#headless-code-impersonating-a-privileged-user}

**Attack.** An unrelated extension opens a `runAs()` scope for an
administrator and reads every secret.

**Control.** None at the impersonation step, and the code says so: `runAs()`
is not an authentication boundary, because any code with DI access already
reaches `$GLOBALS['BE_USER']`. What is enforced: a non-admin technical
actor holds only what the bypass seam allows, a `runAs()` scope may never
open a break-glass window even when its snapshot carries the admin flag, and
every row written inside a scope carries `actor_type = 'technical'` with the
actor's uid and username, sealed into the chain.

**Residual risk.** Any installed extension is inside the PHP trust boundary.
Auditing installed extensions is the control; the vault only makes the
resulting access attributable.

### Fully compromised PHP process {#fully-compromised-php-process}

**Attack.** Arbitrary PHP execution in the TYPO3 process (an RCE, a malicious
extension, a hostile Composer dependency).

**Control.** None. This is the boundary the design accepts.

**Residual risk.** Total, for every secret the process can legitimately
request. A KMS moves custody but not runtime protection: the process holds a
token it may legitimately use. What remains is attribution — reads still
write audit rows, and with a KMS every unwrap is centrally logged and
revocable. See [Known limitations](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations@1.0) and
[ADR-016: Sidecar daemon option](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-016-sidecar-option@1.0) for the boundary that would change this answer.
