# Authenticator encryption-key rotation

Phase 6 milestone 7 adds an offline, server-operator maintenance tool. It re-encrypts active and pending authenticator seeds, including expired pending setups, without changing their values, timestamps, expiry, replay counters or setup identifiers. Passwords, roles, access versions, sessions, backup codes, recovery tokens and invitations remain unchanged. Staff do not need to enroll again. No schema migration or client update is required.

The command is inspection-only unless `--apply` is supplied. It never generates, overwrites, renames or deletes key files and never changes service configuration. The preview’s original key has not been rotated.

## Private configuration

- `PB_MFA_KEY_FILE`: absolute path to the primary key. All new encryption uses this key.
- `PB_MFA_PREVIOUS_KEY_FILE`: optional absolute path to one previous key, temporarily available for decryption during maintenance. Do not leave old keys configured indefinitely.

Both configured files must be readable, valid and different. A configured but missing/invalid previous key fails closed even when a value can be read with the primary key. Staging startup validates both. Omit the previous-key variable entirely after retirement; do not set an empty or relative path. Keys must remain outside public web roots/repositories and readable only by trusted operators and the service account. For Compose-mounted secrets, preserve the private parent-directory boundary described in STAGING.md.

## Maintenance procedure

These commands are examples for an identified deployment, not commands to run against the current live PHP website. Use the correct private database environment. Never place database passwords, key values or authenticator seeds on a command line or in a ticket.

1. Schedule maintenance. Stop all API instances, workers and other administrative/database writers, and keep public traffic closed. `--services-stopped` records the operator’s attestation; it does not stop or detect processes. Do not treat this as an online rolling-rotation procedure.
2. Make and verify a database backup and protected copies of the original key and service configuration. Retain the old key with historical backups that need it. `--backup-confirmed` is an attestation, not an automated backup or restore drill.
3. Generate a replacement Fernet key into a **new** private file using the exclusive-create example in ACCOUNT-SECURITY.md. Use a new path, for example `/srv/phonebuzz-private/mfa-2026-10.key`. Restrict its permissions, back it up securely, and test that the maintenance/service account can read it. Never overwrite the original key file.
4. For the maintenance process only, set the replacement as primary and the original as previous. From `apps/api`:

```sh
export PB_MFA_KEY_FILE=/srv/phonebuzz-private/mfa-2026-10.key
export PB_MFA_PREVIOUS_KEY_FILE=/srv/phonebuzz-private/mfa-original.key
.venv/bin/python -m phonebuzz.rotate_mfa_key
```

The tool validates every encrypted seed and prints counts only: account rows, active/pending secrets, secrets readable by the primary/previous key, and rotated secrets. Inspection writes nothing. A successful inspection does not reserve a snapshot for a later apply; apply validates again in its own transaction.

5. After reviewing the counts and backup, apply:

```sh
.venv/bin/python -m phonebuzz.rotate_mfa_key \
  --apply --services-stopped --backup-confirmed \
  --reason 'Scheduled key rotation after verified private backup'
```

The reason must be 10–200 characters and must not contain secrets. Every token is validated before changes are made. Rotation and one private audit event per changed account commit in a single transaction. Any unreadable active or pending token aborts the operation; records are never silently skipped. Tokens already encrypted under the primary key are unchanged, so rerunning successfully is a no-op with zero new audit events. SQLite obtains a write lock; PostgreSQL locks users in administrator-first order and then their MFA rows. Maintenance is still mandatory because row locks cannot prevent new accounts being inserted afterward.

6. Keep services stopped. Remove the previous key from the maintenance environment and inspect again:

```sh
unset PB_MFA_PREVIOUS_KEY_FILE
.venv/bin/python -m phonebuzz.rotate_mfa_key
```

This must succeed using only the replacement, with `previous_secrets: 0`. Save the count-only result as operational evidence. Back up the rotated database with its replacement key.

7. Update the persistent configuration/mounts for **every** API and worker instance to use the replacement key alone. A shell export does not update services. For the prepared Compose package, the `mfa_key` secret source must point to the replacement private file; one-off commands must receive both keys at distinct readable mount paths. Use an installation-specific private Compose override and preserve it for future commands. Do not edit or regenerate the original secret file in place. Actual container mount/permission validation remains a deployment gate because Docker Engine is unavailable locally.
8. Restart the services from the updated configuration with access still restricted. Check readiness and perform a controlled MFA sign-in; readiness alone does not decrypt every seed. Only then restore staff access. Retire the original key from runtime mounts, keeping a protected copy with applicable historical backups according to retention policy.

## Failure and recovery

On any apply error, keep services stopped and retain both files. Errors intentionally omit database URLs, paths, account identities and encrypted/plaintext secrets. A database connection loss around commit can leave the commit outcome uncertain: inspect with the replacement as primary and the original as previous before deciding what to do. Database atomicity prevents a partial transaction, but a lost success response does not prove that nothing committed.

An unreadable token requires the correct historical key or a verified restore; this tool cannot reconstruct lost keys and does not remove MFA. Do not delete damaged rows to force success. After an interrupted operation, rerunning with the same key pair safely leaves already-rotated tokens alone. If a rollback is necessary, restore the matching pre-rotation database, key and configuration into an isolated environment and verify them together before replacing an installation. Simply restoring the old key against the rotated database will fail.

Rotation does not revoke sessions or recovery/invitation tokens and does not remedy an exposed plaintext authenticator seed or password. An incident requires a separate reviewed response. Operator audit attribution uses the existing CLI convention; it is not an independently authenticated human operator identity.

## Staff visibility and validation

Affected accounts see **Authenticator encryption key rotated** attributed to a server operator in their own security history. Private maintenance reasons remain excluded from personal and shared business feeds. No email/push notification is sent.

Local checks cover single-key compatibility, dual-key decryption and primary-only encryption; invalid/missing/duplicate/unrelated keys; non-mutating inspection; active/pending state and timestamp preservation; replay protection; post-rotation TOTP/backup sign-in and pending-setup confirmation; audit privacy; atomic rollback after flushed writes; repeat/concurrent rotation; CLI guards and redacted configuration failures. PostgreSQL checks also cover the shipped restricted runtime role and logical backup/restore followed by MFA sign-in with only the new key. These do not replace a hosted restore drill or independent security review.

Implementation uses the installed cryptography library’s [MultiFernet rotation](https://cryptography.io/en/latest/fernet/#cryptography.fernet.MultiFernet.rotate), which retains the original encrypted token timestamp. No new cryptographic primitive is implemented.
