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.
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.
Note
The vault: queries below assert audit., and so do
vault: and vault: — verification is a
read of the chain. The one procedure that publishes a baseline anchor
(vault:) asserts vault. instead, because it
mutates tamper evidence. Both permissions are excluded from the
cliAllowedOperations default, so grant them to the
actor running the procedure (see Evidence collection) or
read the same rows through the audit module.
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. and secret.
and per-secret read access; one known identifier.
Steps
-
Record the current row count for the identifier:
vendor/bin/typo3 vault:audit --identifier=<identifier> --action=read \ --format=json --limit=1000 > before.jsonCopied! - In Vault > Secrets, reveal that secret. Wait for the value to appear.
- Close the modal, then reveal the same secret again.
- Re-run the query into
after..json
Expected result
after.contains exactly two morejson readrows thanbefore.— one per reveal. A second reveal producing no row means a client-side cache exists, which would break the audit guarantee.json - Each row carries
actor_,uid actor_, `username actor_`.type = 'backend'``, ``ip_ address`` and ``success = 1 -
The chain still verifies:
vendor/bin/typo3 vault:audit-verifyCopied!
Also verify, in the browser's developer tools
- The
vault_response carriesreveal Cache-.Control: no- store - The response body contains
copy—Allowed falseunder 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
visibilitychangewipe), 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
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. and not
secret., and a test user in only that group with read access to a
test secret.
Steps
- As that user, open a record whose form contains a vault-backed field. The
field must resolve — that is
secret.working.use - As the same user, attempt a reveal from Vault > Secrets.
-
Query the audit log for denials:
vendor/bin/typo3 vault:audit --action=access_denied --actor=<uid> --format=jsonCopied!
Expected result
- Step 1 succeeds. If the field does not resolve,
secret.is not granted — recheck the group.use - Step 2 is refused with HTTP 403 and no plaintext in the response body.
- An
access_row exists for that actor.denied
The mirror test. Grant secret. and remove secret.. The
reveal must still fail: the endpoint asserts secret., but the
shared read path asserts secret., so a non-admin needs both. A reveal
that succeeds with only secret. 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 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). A frontend_
secret that resolves from editor-authored content it was never published to is
a finding.
Procedure 3 — The administrator override is effectively withdrawn
Claim under test. In the hardened profile with disable
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
-
Confirm the flag is effective, not merely set:
vendor/bin/typo3 vault:break-glass --statusCopied!adminmust readOverride Disabled Effective yes. A raw1with an effectivenomeans the profile isstandardand the flag is inert. - As an administrator whose groups grant no vault permissions, attempt to reveal a secret they do not own and share no group with.
- 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
Backendwould returnUser Authentication:: check () trueunconditionally 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_audit rows.denied
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
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).
Steps
-
Attempt activation without a reason, and with a whitespace-only reason:
vendor/bin/typo3 vault:break-glass --activate vendor/bin/typo3 vault:break-glass --activate --reason=" "Copied! -
Activate properly, with an out-of-range TTL:
vendor/bin/typo3 vault:break-glass --activate \ --reason="AUDIT-1 break-glass evidence test" --minutes=600Copied! - Check the status, and the backend UI.
-
Deactivate:
vendor/bin/typo3 vault:break-glass --deactivate --reason="AUDIT-1 complete"Copied! -
Read the evidence and verify the chain:
vendor/bin/typo3 vault:audit --identifier=__break_glass__ --format=json vendor/bin/typo3 vault:audit-verifyCopied!
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_row exists whose context carriesglass_ activated actor,Uid actor,Username expiresandAt ttl, and whoseMinutes reasonis the text supplied. - A
break_row exists, whose context also carries the original activation reason.glass_ deactivated - The chain still verifies with both rows inside it.
Expiry variant (STAGING-ONLY, and slow). Activate with --, do
not deactivate, wait past the expiry, then confirm the bypass is gone
(--) 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 expires. Verify that is genuinely the case rather than
assuming it.
Note
Also confirm that a Technical 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.
Procedure 5 — Fail-closed provider behaviour (STAGING-ONLY)
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
securityProfile = hardened
masterKeyProvider = typo3
Attempt any vault operation that needs the master key.
Expected: a
Configuration 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
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
security 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
securityProfile = paranoid
Expected: a
Configuration 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 security, and run:
vendor/bin/typo3 vault:audit-verify --format=json
Expected: a NO_ finding. Repeat with a sink enabled but no
anchor ever published — also NO_, this time about the missing
anchor. Then set security 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:.
Procedure 6 — Tamper detection (STAGING-ONLY)
Danger
STAGING-ONLY. These steps manipulate tx_ 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:
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_
UPDATE tx_nrvault_audit_log SET actor_username = 'someone.else' WHERE uid = <mid>;
Expected: HASH_. 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_
DELETE FROM tx_nrvault_audit_log WHERE uid = <mid>;
Expected: UID_, with the missing-uid count in the finding context, and
HASH_ on the following rows.
Test 6c — full reset → TABLE_
TRUNCATE TABLE tx_nrvault_audit_log;
Then perform a few normal vault operations so a fresh chain is built, and verify with both verifiers:
vendor/bin/typo3 vault:audit --verify
vendor/bin/typo3 vault:audit-verify --format=json
Expected: both anchors fire.
vault: reports Tip anchor: VIOLATED and an invalid
chain: the in-database anchor in sys_ still names a row the
truncation removed, and verification raises that as a hard error. This is what
an attacker limited to tx_ runs into.
vault: additionally raises TABLE_ from the external
anchor comparison.
Finding if: no TABLE_ appears. Then either no anchor was published,
or the anchor file was truncated along with the table — check that
baseline- 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.
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: now reports a valid, perfectly
self-consistent chain with Tip anchor: NOT ARMED — the internal evidence
is gone, and only vault:'s external TABLE_ 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 — audit closes the downgrade
Repeat Test 6e with audit in the extension
configuration.
Expected: the missing anchor is now reported as critical rather than as
the NOT ARMED warning, and vault: fails on audit..
Because the setting lives in the extension configuration rather than in a
table, deleting the sys_ 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_ pin.
Test 6d — algorithm downgrade → EPOCH_
-- 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_ 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 audit — and, on an
epoch-3 chain, also by HASH_, 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.
Restore the staging system from the pre-test dump, or accept that its audit chain is permanently broken and record why.
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 |