Skip to content

Latest commit

 

History

History
119 lines (99 loc) · 7.43 KB

CUSTODIAL_HISTORY.md

File metadata and controls

119 lines (99 loc) · 7.43 KB

Custodial history recovery and key retirement

A successful identity switch is not proof that all history is readable or that all server-held copies of a key are gone. The migration inventory reports these facts independently. status=completed keeps its historical API meaning of identity switch, with completion_scope=identity_switch_only explicitly included. It is not an assertion of complete key retirement.

Owner workflow

The client saves a protected local journal and proves possession of both the new identity key and the new age key. The server then changes the primary encryption target. Writes during migration must identify that new key; old-key or unlabelled age writes fail closed. Another migration or encryption rotation cannot replace the bound target. The current token stays active until the explicit final decision.

msg identity upgrade-custodial new-handle --external-history unknown
msg identity migration status
msg identity migration migrate --method rewrap --limit 100
msg identity migration status
# Only after checking history, policy and external copies:
msg identity migration finalize --resolution verified --external-history migrated

migrate works in bounded batches and never finalizes automatically. It decrypts returned ciphertext locally, then signs an ACK over the exact source and target Refs/Revisions, key IDs, ciphertext and plaintext digests, method, inventory digest, challenge, subject and request ID. Plaintext is not sent back. A legacy unscoped ACK remains readable but is insufficient to finalize a scoped inventory.

status lists every retained owned age keystore revision, including non-current history. Outputs are separate private resources; neither source revision bytes nor its current pointer/signature are overwritten. A new source, missing source or changed result invalidates a finalization preview. refresh signs the observed delta, preserves missing items as unresolved, and increments the inventory version. Every retained item must be ACKed again for that new version. A caller-declared key ID on a new ciphertext is not a decryption proof.

Two recovery routes

A — rewrap. Each mapping points from one immutable source revision to one new private ciphertext encrypted to the proven new recipient. The official client actually decrypts the target before signing its scoped ACK.

B — compatibility recovery. msg identity migration envelope returns an owner-private age-encrypted envelope containing only that owner's retired EncryptionSubkey. It never exports an IdentityKey, vault master secret or another owner's key. migrate --method compatibility_recovery decrypts the envelope locally, validates its owner, challenge, key ID and recipient, then decrypts every selected old ciphertext before ACKing it. The old key exists only in a protected temporary client file during this verification. This route is labelled compatibility_recovery, never re-encryption. Protect the new age identity and recovery envelope: together they intentionally recover historical ciphertext.

Unresolved history and explicit decisions

Unidentified, missing or undecryptable revisions stay unresolved. A final decision is signed by the proven new identity key and binds the current inventory, actual observed inventory, result digest, policy version and complete loss list.

--resolution accept_loss --loss-revision REV --reason TEXT requires exactly the unresolved revision list and a nonempty reason. It does not label that history recoverable. An external-history declaration is an owner statement, not verified coverage of files outside the enumerated server history.

--resolution retain_decrypt --loss-revision REV --reason TEXT --external-history unknown can switch identity while keeping only the retired encryption key for later recovery. All old tokens/signing credentials are revoked and the encrypted signing key material is deleted in the same transaction. PostgreSQL prevents a decrypt_only vault row from retaining signing material. Only the new identity key can continue that migration; after verification it can explicitly finalize again to remove the remaining online age key. An unknown old database constraint is not silently dropped by the schema migration.

Crash boundaries and evidence

The client writes/fsyncs an exact signed decision intention before sending. A lost final response is recovered using the new identity key and the original finalization request ID; the old token is not resurrected. Accepted client identity state is saved before the pending journal is removed. The protected custodial-history.json remains as the stable entry point for later inspection or completion of decrypt-only retention.

Audit events distinguish token revocation, online signing/encryption-key deletion and the scoped migration decision. Doctor's custodial_history check is read-only; its output lists phases, unresolved counts, drift and online retirement facts. The custodial_history_recovery selftest creates real age history in the disposable Test Root, rewraps, locally decrypts, ACKs and finalizes it. It refuses a production application without the isolated selftest namespace.

history_recoverable covers enumerated and client-verified revisions only. online_key_retired describes online vault material only. backup_retired stays false unless a root-signed backup retirement record was imported at the physical console (msgd root backup-retirement sign SOURCE DESTINATION followed by msgd root backup-retirement import RECORD, both behind require_local_console). The record binds the subject, both old key IDs, a non-empty list of named backup sets and a validity window of at most 400 days; it is signed with purpose custodial-backup-retirement-v1. Every read re-verifies it against the active, unrevoked root credential, so an altered, expired, misbound or pre-rotation record reports backup_retired=false (backup_status=local_attestation_invalid|expired). server_key_retired is true only when that record and observable online retirement both hold.

The record is an operator attestation for the listed backup sets only. It does not prove that no other copy exists and it does not claim that production backups were destroyed. No network operation writes, accepts or forwards it; a backup_retirement value inside the network-writable migration body is ignored. Neither a boolean supplied by a network caller nor a green test run can establish that old backups lost a key. Input JSON must be a single-link regular file owned by the local operator, mode 0600, at most 1 MiB; symlinks and special files are rejected. Signing publishes a complete 0600 record without replacing an existing destination. Both commands require a digest-bound interactive confirmation; signing additionally unlocks Root with a non-echoed PIN prompt. Restore/ revocation replay and real operator acceptance are tracked separately in issues #69 and #70.

Published contract compatibility

The original v1 short-code rows and input schemas remain immutable. Scoped decisions use identity.custodial_upgrade_finish@2, scoped acknowledgements use identity.custodial_rewrap_ack@2, and explicit new-key writes use keystore.put@2. Existing deployments must explicitly authorize these new operations through local CA governance and reissue constrained credentials before using them; registry vocabulary never silently expands an existing signed grant. The official migration client selects v2. Legacy ACKs remain readable but cannot authorize retirement.