---
title: "ADR-033: Master-key rotation reaches consumer-owned envelopes"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0"
source: "Developer/Adr/ADR-033-ForeignEnvelopeRotation.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# ADR-033: Master-key rotation reaches consumer-owned envelopes {#adr-033-master-key-rotation-reaches-consumer-owned-envelopes}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.0)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.0)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.0)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.0)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.0)
-   [See also](https://docs.typo3.org/permalink/netresearch/nr-vault:see-also@1.0)

## Status {#status}

Accepted

## Date {#date}

2026-07-29

## Context {#context}

`vault:rotate-master-key` re-wrapped every DEK it could find by iterating
`SecretRepositoryInterface::findIdentifiers()` — that is, the rows of
`tx_nrvault_secret`, and nothing else.

That was complete for as long as this extension was the only thing storing
envelopes. It stopped being complete once consumers began encrypting their own
payloads with the same managed key: `EncryptionService::encrypt()` wraps each
DEK with the current master key, but a consumer's wrapped DEK sits in the
consumer's own table. Rotating therefore left those envelopes wrapped under a key
the operator was told to archive or destroy, and they became permanently
undecryptable — with no error at rotation time, because the rotation genuinely
succeeded at everything it knew about.

nr-llm hit this concretely. Its ADR-114 claimed "key rotation is the vault's …
rotating the master key re-wraps the DEKs without touching the row ciphertext",
and cited `MasterKeyRotatedEvent` as the mechanism. Neither half held:
rotation never looked outside `tx_nrvault_secret`, and the event — declared in
`Classes/Event/`, documented in `Api.rst`, and listed as step 3 of the
rotation procedure in [ADR-003](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management@1.0) — was never
dispatched from anywhere in `Classes/`. A consumer could not even have
subscribed to learn that a rotation had happened.

## Decision {#decision}

Make consumer-owned envelopes a first-class part of the rotation, and dispatch
the event that was already promised.

### The seam {#the-seam}

A consumer implements `ForeignEnvelopeRotatorInterface` and tags it:

```yaml
Vendor\Extension\Crypto\MyEnvelopeRotator:
  tags: ['nrvault.foreign_envelope_rotator']
```

The command collects the tagged services and calls each one's
`rewrapAll()` with an `EnvelopeRotationContext`.

### Keys stay inside the context {#keys-stay-inside-the-context}

Re-wrapping needs both the old and the new master key, but a consumer has no
business holding either. `EnvelopeRotationContext` closes over both and
exposes only `rewrap(string $sealed, string $identifier): string`, so a
consumer moves an envelope between keys without ever seeing key material. Both
constructor parameters carry `#[\SensitiveParameter]`, so a stack trace
unwinding through it shows them redacted, and a unit test asserts the class
exposes no property or method through which a key could be read back.

### Inside the transaction, before the audit re-key {#inside-the-transaction-before-the-audit-re-key}

This is the crux of the design. A listener notified *after* the commit can no
longer obtain the old master key, so it could not re-wrap anything — which is
why an "announce it afterwards" event was never going to be sufficient, and why
the seam is a collected interface call rather than an event listener.

`rewrapAll()` therefore runs inside the command's existing transaction:

1.  the vault's own secrets are re-encrypted;
1.  **every registered consumer re-wraps its envelopes**;
1.  the audit chain is re-keyed, which takes the audit advisory lock and holds it
    for the remainder of the transaction — so anything that still needs to write
    must have written by then;
1.  commit;
1.  `MasterKeyRotatedEvent` is dispatched, carrying both counts.

### Fail closed, everywhere {#fail-closed-everywhere}

-   A consumer that throws rolls back the **entire** rotation — this extension's
    secrets, the audit-chain re-key, and every other consumer. A partially
    rotated installation is worse than an unrotated one: some data is wrapped
    under a key the operator has been told to destroy.
-   A consumer that cannot be **inventoried** (`countEnvelopes()` throws) aborts
    before anything is touched. Not knowing what a rotation is about to change is
    a reason to refuse it.
-   A consumer whose tables are mapped to a **different database connection** is
    refused, mirroring the existing precondition on `tx_nrvault_audit_log`:
    across two connections "one transaction" is a fiction and half a rotation
    could commit.
-   A vault holding **no secrets of its own** no longer short-circuits to
    "nothing to do" — it may be the key authority for thousands of consumer
    envelopes. In that case the old key cannot be smoke-tested against a real
    secret up front, and the operator is told so explicitly rather than left to
    assume it was checked; a wrong key then surfaces as a consumer failure, which
    rolls back.

## Consequences {#consequences}

-   Sealing a payload as a consumer now has a stated obligation: register a
    rotator, or the data dies at the next rotation. Both this ADR and
    `EnvelopeCodecInterface` say so, because the failure is silent and
    surfaces long after the mistake.
-   `vault:rotate-master-key` reports each participant and its envelope count,
    in both dry-run and live mode, so an operator can see the blast radius before
    confirming.
-   `MasterKeyRotatedEvent` is dispatched for the first time, after the
    commit, so a listener never observes a rotation that was rolled back. It
    gained a `foreignEnvelopesReEncrypted` field with a default of 0, which
    keeps existing constructor calls working.
-   Rotation wall-clock now includes every consumer's pass, in one transaction.
    Consumers are required to work in batches for this reason.
-   The command's constructor gained the codec, the access-control service (for
    the event's actor), the event dispatcher, and the tagged-rotator iterable.

## See also {#see-also}

-   [ADR-003](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management@1.0) — the rotation procedure this
    completes.
-   [ADR-032](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-032-portable-envelope-codec@1.0) — the codec whose
    `seal()` creates the obligation.
