---
title: "Verification procedures"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:auditor-verification-procedures@1.0"
source: "Auditor/VerificationProcedures.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Verification procedures {#verification-procedures}

Reproducible procedures that demonstrate a control **works**, rather than that
it is configured. Each one states its expected result, so a deviation is a
finding rather than a matter of interpretation.

> [!CAUTION]
> **Danger**
>
> **Procedures marked STAGING-ONLY deliberately misconfigure the vault or
> manipulate the audit table.** Never run them on production. Run them on a
> staging system with production-like configuration and throwaway data,
> restore the original state afterwards, and expect the manipulated system's
> audit chain to stay permanently broken — that is the point of the test, and
> it is why the system must be disposable.

Read-only evidence collection is in [Evidence collection](https://docs.typo3.org/permalink/netresearch/nr-vault:auditor-evidence-collection@1.0).

> [!NOTE]
> The `vault:audit` queries below assert `audit.view`, and so do
> `vault:audit --verify` and `vault:audit-verify` — verification is a
> read of the chain. The one procedure that publishes a baseline anchor
> (`vault:audit-anchor`) asserts `vault.configure` instead, because it
> mutates tamper evidence. Both permissions are excluded from the
> [cliAllowedOperations](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-cliallowedoperations@1.0) default, so grant them to the
> actor running the procedure (see [Evidence collection](https://docs.typo3.org/permalink/netresearch/nr-vault:auditor-evidence-collection@1.0)) or
> read the same rows through the audit module.

## Procedure 1 — A reveal writes an audit row {#procedure-1-a-reveal-writes-an-audit-row}

**Claim under test.** Every disclosure of a plaintext to a human produces one
audit entry. There is no client-side cache that could serve a second reveal
without a server round trip.

**Safe on production.** Yes.

**Preconditions.** A backend user holding `secret.reveal` and `secret.use`
and per-secret read access; one known identifier.

**Steps**

1.  Record the current row count for the identifier:

    ```bash
    vendor/bin/typo3 vault:audit --identifier=<identifier> --action=read \
        --format=json --limit=1000 > before.json
    ```
1.  In **Vault > Secrets**, reveal that secret. Wait for the value to
    appear.
1.  Close the modal, then reveal the **same** secret again.
1.  Re-run the query into `after.json`.

**Expected result**

-   `after.json` contains exactly **two** more `read` rows than
    `before.json` — one per reveal. A second reveal producing no row means a
    client-side cache exists, which would break the audit guarantee.
-   Each row carries `actor_uid`, `actor_username`, \``actor_type =
    'backend'``, ``ip_address`` and ``success = 1`\`.
-   The chain still verifies:

    ```bash
    vendor/bin/typo3 vault:audit-verify
    ```

**Also verify, in the browser's developer tools**

-   The `vault_reveal` response carries `Cache-Control: no-store`.
-   The response body contains `copyAllowed` — `false` under the hardened
    profile, in which case **no copy button is rendered**.
-   The value disappears on its own after 30 seconds.
-   Switching to another browser tab clears it **immediately** (the
    `visibilitychange` wipe), without waiting for the countdown.

> [!NOTE]
> The last two checks must be done in a real browser. A unit test cannot
> demonstrate `visibilitychange` or `pagehide` behaviour.

## Procedure 2 — `secret.use` does not grant `secret.reveal` {#procedure-2-secret-use-does-not-grant-secret-reveal}

**Claim under test.** Machine consumption and human disclosure are separate
permissions, and neither implies the other. An integration account cannot read
a plaintext with someone's eyes.

**Safe on production.** Yes — it only produces denials.

**Preconditions.** A backend group granted `secret.use` and **not**
`secret.reveal`, and a test user in only that group with read access to a
test secret.

**Steps**

1.  As that user, open a record whose form contains a vault-backed field. The
    field must resolve — that is `secret.use` working.
1.  As the same user, attempt a reveal from **Vault > Secrets**.
1.  Query the audit log for denials:

    ```bash
    vendor/bin/typo3 vault:audit --action=access_denied --actor=<uid> --format=json
    ```

**Expected result**

-   Step 1 succeeds. If the field does not resolve, `secret.use` is not
    granted — recheck the group.
-   Step 2 is refused with **HTTP 403** and no plaintext in the response body.
-   An `access_denied` row exists for that actor.

**The mirror test.** Grant `secret.reveal` and remove `secret.use`. The
reveal must **still** fail: the endpoint asserts `secret.reveal`, but the
shared read path asserts `secret.use`, so a non-admin needs both. A reveal
that succeeds with only `secret.reveal` is a serious finding.

**Frontend check (safe).** With a valid backend session, request a frontend
page that resolves a vault value. Frontend requests hold no operation
permission regardless of the session, so the session may not widen the
outcome. For a `%vault(id)%` placeholder the flag is necessary but
not sufficient: the identifier must also 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)). A `frontend_accessible`
secret that resolves from editor-authored content it was never published to is
a finding.

## Procedure 3 — The administrator override is effectively withdrawn {#procedure-3-the-administrator-override-is-effectively-withdrawn}

**Claim under test.** In the hardened profile with `disableAdminOverride`
set, an administrator holds only what their groups grant — on **both** gates,
not just one.

**Safe on production.** The read-only parts, yes. Removing grants from a live
administrator is not.

**Steps**

1.  Confirm the flag is effective, not merely set:

    ```bash
    vendor/bin/typo3 vault:break-glass --status
    ```

    `adminOverrideDisabledEffective` must read `yes`. A raw `1` with an
    effective `no` means the profile is `standard` and the flag is inert.
1.  As an administrator whose groups grant **no** vault permissions, attempt to
    reveal a secret they do **not** own and share no group with.
1.  Attempt an operation-level action they were not granted — for example
    opening the audit module without `audit.view`.

**Expected result**

-   Step 2 is refused; the secret is not readable. This is the per-secret gate.
-   Step 3 is refused. This is the operation gate — and it is the more
    important half of the test, because a grant lookup routed through TYPO3's
    `BackendUserAuthentication::check()` would return `true`
    unconditionally for an admin and silently defeat it.
-   Ownership still works: the same administrator retains full access to
    secrets they **own**. That is expected, not a finding.
-   Both refusals appear as `access_denied` audit rows.

**Finding if:** the operation gate refuses but the per-secret gate allows, or
vice versa. A half-disabled override is worse than none, because the
deployment believes it is protected.

## Procedure 4 — Break-glass leaves a complete evidence trail {#procedure-4-break-glass-leaves-a-complete-evidence-trail}

**Claim under test.** Emergency access cannot happen without evidence, a named
actor, a justification and an expiry.

**Safe on production.** Technically yes — but it grants full admin power for
the duration. Prefer staging; if run on production, treat it as a real
activation and review it ([Break-glass usage policy and review](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-incident-response-breakglass@1.0)).

**Steps**

1.  Attempt activation **without** a reason, and with a whitespace-only reason:

    ```bash
    vendor/bin/typo3 vault:break-glass --activate
    vendor/bin/typo3 vault:break-glass --activate --reason="   "
    ```
1.  Activate properly, with an out-of-range TTL:

    ```bash
    vendor/bin/typo3 vault:break-glass --activate \
        --reason="AUDIT-1 break-glass evidence test" --minutes=600
    ```
1.  Check the status, and the backend UI.
1.  Deactivate:

    ```bash
    vendor/bin/typo3 vault:break-glass --deactivate --reason="AUDIT-1 complete"
    ```
1.  Read the evidence and verify the chain:

    ```bash
    vendor/bin/typo3 vault:audit --identifier=__break_glass__ --format=json
    vendor/bin/typo3 vault:audit-verify
    ```

**Expected result**

-   Both attempts in step 1 are **rejected**. A reason is mandatory and an
    empty or whitespace-only value does not satisfy it.
-   Step 2 succeeds with the TTL **clamped to 60 minutes**, not rejected — the
    ceiling carries the security property either way, and an error under
    incident pressure would not help.
-   The vault Overview and Secrets modules show a danger callout naming the
    actor, the reason and the expiry. A window nobody notices is the admin
    override with extra steps.
-   A `break_glass_activated` row exists whose context carries
    `actorUid`, `actorUsername`, `expiresAt` and `ttlMinutes`, and
    whose `reason` is the text supplied.
-   A `break_glass_deactivated` row exists, whose context also carries the
    **original activation reason**.
-   The chain still verifies with both rows inside it.

**Expiry variant (STAGING-ONLY, and slow).** Activate with `--minutes=1`, do
not deactivate, wait past the expiry, then confirm the bypass is gone
(`--status`) and that **no** row was written for the expiry. Nothing runs at
the moment a window lapses — the closed interval must be reconstructed from the
activation row's `expiresAt`. Verify that is genuinely the case rather than
assuming it.

> [!NOTE]
> Also confirm that a `TechnicalActorContext::runAs()` scope cannot open a
> window, even for an actor whose snapshot carries the admin flag. This needs
> a code-level test rather than a CLI step; the invariant is asserted in the
> test suite and stated in [Break-glass mode](https://docs.typo3.org/permalink/netresearch/nr-vault:security-break-glass@1.0).

## Procedure 5 — Fail-closed provider behaviour (STAGING-ONLY) {#procedure-5-fail-closed-provider-behaviour-staging-only}

> [!CAUTION]
> **Danger**
>
> **STAGING-ONLY.** This procedure deliberately misconfigures the master-key
> provider. On production it makes every secret unreadable for the duration.

**Claim under test.** A misconfigured hardened vault stops. It never silently
continues on weaker key material.

**Test 5a — the forbidden provider is refused**

**Extension configuration, staging**

```none
securityProfile = hardened
masterKeyProvider = typo3
```

Attempt any vault operation that needs the master key.

*Expected:* a `ConfigurationException` with code **1753900002** and a
message naming the provider as not permitted in the hardened profile. **No
secret is decrypted**, and the operation does not fall back to another
provider.

*Finding if:* the operation succeeds. That would mean the hardened profile is
not enforced in code, only documented.

**Test 5b — no auto-detection fallback**

**Extension configuration, staging**

```none
securityProfile = hardened
masterKeyProvider = file
masterKeySource = /nonexistent/path/vault-master.key
```

*Expected:* the operation fails with a master-key error naming the missing
path. It does **not** silently fall through to the TYPO3 encryption key, an
environment variable, or an auto-generated development key.

*Contrast, to prove the difference is the profile and not the path:* set
`securityProfile = standard` with the same broken path. Now the provider
chain **does** fall back (configured → `typo3` → `env` → `file`) and the
operation may succeed. Two different outcomes from the same broken
configuration is exactly the evidence sought — the profile changes behaviour,
not just documentation.

**Test 5c — an unknown profile is refused**

**Extension configuration, staging**

```none
securityProfile = paranoid
```

*Expected:* a `ConfigurationException` with code **1753900001** and a
message stating it refuses to fall back to a weaker profile. **Not** a silent
default to `standard`.

**Test 5d — the hardened sink requirement**

Disable every audit sink, keep `securityProfile = hardened`, and run:

```bash
vendor/bin/typo3 vault:audit-verify --format=json
```

*Expected:* a `NO_EXTERNAL_SINK` finding. Repeat with a sink enabled but no
anchor ever published — also `NO_EXTERNAL_SINK`, this time about the missing
anchor. Then set `securityProfile = standard` and repeat: **no** such finding,
because the standard profile treats sinks as opt-in.

**Restore** the original configuration and confirm with a probe decrypt and
`vault:doctor`.

## Procedure 6 — Tamper detection (STAGING-ONLY) {#procedure-6-tamper-detection-staging-only}

> [!CAUTION]
> **Danger**
>
> **STAGING-ONLY.** These steps manipulate `tx_nrvault_audit_log` directly
> and permanently break the chain on that system. Never on production. Take a
> dump first if you want to repeat individual tests.

**Claim under test.** Row edits, deletions, full resets and algorithm
downgrades are all detected, and each produces its own reason code.

Establish a baseline first — a chain that verifies, and a published anchor:

```bash
vendor/bin/typo3 vault:audit-verify        # must be clean
vendor/bin/typo3 vault:audit-anchor        # publish the baseline
cp <auditSinkAnchorPath> baseline-anchor.ndjson
```

**Test 6a — row edit →** `HASH_MISMATCH`

```sql
UPDATE tx_nrvault_audit_log SET actor_username = 'someone.else' WHERE uid = <mid>;
```

*Expected:* `HASH_MISMATCH`. Note **which** field you changed: from epoch 3
the attribution fields are bound into the hash, so this proves blame cannot be
reassigned. Repeat with `success` (bound from epoch 2) and with
`reason`. On an epoch-1 chain the attribution edit will **not** be detected —
which is itself the finding, and the reason to migrate.

**Test 6b — row deletion →** `UID_GAP`

```sql
DELETE FROM tx_nrvault_audit_log WHERE uid = <mid>;
```

*Expected:* `UID_GAP`, with the missing-uid count in the finding context, and
`HASH_MISMATCH` on the following rows.

**Test 6c — full reset →** `TABLE_RESET`

```sql
TRUNCATE TABLE tx_nrvault_audit_log;
```

Then perform a few normal vault operations so a fresh chain is built, and
verify with both verifiers:

```bash
vendor/bin/typo3 vault:audit --verify
vendor/bin/typo3 vault:audit-verify --format=json
```

*Expected:* **both** anchors fire.

`vault:audit --verify` reports `Tip anchor: VIOLATED` and an **invalid**
chain: the in-database anchor in `sys_registry` still names a row the
truncation removed, and verification raises that as a hard error. This is what
an attacker limited to `tx_nrvault_audit_log` runs into.

`vault:audit-verify` additionally raises `TABLE_RESET` from the external
anchor comparison.

*Finding if:* no `TABLE_RESET` appears. Then either no anchor was published,
or the anchor file was truncated along with the table — check that
`baseline-anchor.ndjson` still contains the pre-truncate anchor, and restore
it if the sink wrote to a file the truncation also removed. **An anchor stored
only on the compromised host is not independent evidence**, and this test is
how that becomes visible.

**Test 6e — full reset with the in-database anchor removed first**

This is the case Test 6c's "the chain alone cannot see a reset" claim actually
describes, and it is the one worth demonstrating to a sceptical assessor.

```sql
DELETE FROM sys_registry WHERE entry_namespace = 'tx_nrvault_audit_anchor';
TRUNCATE TABLE tx_nrvault_audit_log;
```

Rebuild a fresh chain as above, then verify.

*Expected:* `vault:audit --verify` now reports a **valid**, perfectly
self-consistent chain with `Tip anchor: NOT ARMED` — the internal evidence
is gone, and only `vault:audit-verify`'s external `TABLE_RESET` still
detects the reset. This demonstrates both halves at once: what the in-database
anchor buys, and why it does not remove the need for an off-host one.

**Test 6f —** `auditAnchorRequired` **closes the downgrade**

Repeat Test 6e with `auditAnchorRequired = 1` in the extension
configuration.

*Expected:* the missing anchor is now reported as **critical** rather than as
the `NOT ARMED` warning, and `vault:doctor` fails on `audit.db_anchor`.
Because the setting lives in the extension configuration rather than in a
table, deleting the `sys_registry` row no longer silences the control.

*Finding if:* the run still reports only a warning. Then the setting is not in
force — confirm it is set, and remember a backend administrator can still
clear it from the Settings module, since this key is **not** among the three
that accept a `$TYPO3_CONF_VARS` pin.

**Test 6d — algorithm downgrade →** `EPOCH_DOWNGRADE`

```sql
-- Partial: a decrease between consecutive rows.
UPDATE tx_nrvault_audit_log SET hmac_key_epoch = 0 WHERE uid = <mid>;

-- Uniform: every row, which no per-row comparison would notice.
UPDATE tx_nrvault_audit_log SET hmac_key_epoch = 0;
```

*Expected:* the partial case raises `EPOCH_DOWNGRADE` from the per-row
comparison. The uniform case is caught by the **chain-level epoch floor** — the
chain's highest epoch must reach the configured `auditHmacEpoch` — and, on an
epoch-3 chain, also by `HASH_MISMATCH`, because the epoch column is itself
bound into the hash.

Run the uniform test explicitly. It is the case a naive implementation misses,
and the one an attacker with full table write access would actually attempt.

**Test 6e — sink independence**

With a sink enabled, perform an audited operation, confirm the record arrived
at the collector, then delete the corresponding row from the database and
verify. *Expected:* the database reports the gap, **and** the collector still
holds the record. Diffing the two is the comparison that turns a suspicion into
proof — see [Suspected audit tampering](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-incident-response-tampering@1.0).

**Restore** the staging system from the pre-test dump, or accept that its audit
chain is permanently broken and record why.

## Summary {#summary}

| Procedure | Environment | Demonstrates |
| --- | --- | --- |
| 1 — Reveal writes an audit row | Production safe | Non-repudiation of disclosure; no client-side cache; bounded exposure |
| 2 — `use` versus `reveal` | Production safe | Separation of machine consumption from human disclosure |
| 3 — Admin override withdrawn | Production safe (read-only parts) | Both gates honour the withdrawal |
| 4 — Break-glass evidence trail | Prefer staging | Mandatory justification, TTL clamp, complete audit evidence |
| 5 — Fail-closed provider | **STAGING-ONLY** | The hardened profile is enforced in code, not documented |
| 6 — Tamper detection | **STAGING-ONLY** | Edit, deletion, reset and downgrade each detected, with the anchor supplying what the chain cannot |
