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. For the two policy bundles that decide how strictly the controls below are enforced, read Security profiles.

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 

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).
Backend editor Whatever their groups were granted (see Operation permissions), 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.
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(). 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 

Five boundaries; crossing each one requires something different.
┌────────────────────────────────────────────────────────────────┐
│  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           │
└─────────────────────────────────────────────────────────────┘
Copied!

Trust boundaries describes what crossing each boundary requires, and what each boundary does not stop.

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 

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

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, and Backup and restore for the separation that avoids it.

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).

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) 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 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 

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 

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 

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 

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) 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 

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 

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 and ADR-016: Sidecar daemon option for the boundary that would change this answer.