Keys
Rotate a vault's master key through the ordered ceremony, and destroy its keys irreversibly, over HTTP.
Two operations live under /v1/keys, both reserved for operator principals:
rotating the vault's master key, and destroying its keys. Throughout both,
the server sees no master key, no unwrapped record key and no plaintext — it stores
the bytes an enrolled device hands it and refuses the ones it must not accept.
Rotating the master key
Rotation is a ceremony run as an ordered sequence. Every route below is exempt from the latch the rotation sets, so it can read and re-wrap while every other caller is held out.
Checking rotation status
GET /v1/keys/rotate/status reports whether a rotation is outstanding and how far it
has got. It answers both before a rotation — as a pre-flight, so you can see how many
values a rotation will re-wrap before you begin one — and during one.
GET /v1/keys/rotate/status
Authorization: Bearer <operator token>{
"rotating_since": null,
"client_encrypted_values": 340,
"searchable_blind_indexes": 128,
"records_with_wrapped_key": 128,
"records_rewrapped": null,
"remaining_under_old_wrapping": null,
"collections": [
{ "name": "policies", "records": 128 }
]
}rotating_sinceis the instant the outstanding rotation began, ornullwhen none is outstanding.client_encrypted_valuesandsearchable_blind_indexescount theclient_encryptedvalues on the vault's live records, and those of them that are searchable — the tokens a rotation re-wraps, and the blind indexes it recomputes.records_with_wrapped_keycounts the live records carrying a wrapped record key.records_rewrappedandremaining_under_old_wrappingare progress figures that mean something only once a rotation is running, so both arenullwhen none is. During a rotation,records_rewrappedcounts how many records already carry the new wrapping andremaining_under_old_wrappinghow many still carry the old one.collectionscarries the live-record count per collection.
1. Begin
POST /v1/keys/rotate/begin takes the latch and names the rotation.
POST /v1/keys/rotate/begin
Authorization: Bearer <operator token>{ "rotation": { "id": "2026-08-28T10:00:00Z", "started_at": "2026-08-28T10:00:00Z" } }A begin while a rotation is already outstanding — whether it is running or has been suspended (see Complete, suspend or discard) — is refused, naming when the first began and how many records it has already re-wrapped. Resume and complete that rotation, or discard it, before beginning another:
{
"code": "rotation_already_in_progress",
"message": "This vault has an outstanding master-key rotation, started at 2026-08-28T10:00:00Z, with 40 records already re-wrapped; resume and complete it, or discard it, before beginning another.",
"details": { "started_at": "2026-08-28T10:00:00Z", "records_rewrapped": 40 }
}A begin on a vault whose keys have been destroyed is vault_keys_destroyed.
The wrapped master-key copies this device holds
GET /v1/keys/rotate/self-keys returns the wrapped copies of the master key the
calling device holds, and no other device's. A resumed rotation may run on a
machine that holds no local copy of either master key; this route hands the device
its own copies back, so it can unwrap the current master key before it re-wraps
records.
The caller is identified by the device key it presents, passed as a query parameter (base64, URL-encoded):
GET /v1/keys/rotate/self-keys?device[public_key]=<base64>
Authorization: Bearer <operator token>{
"wrapped_keys": [
{
"ciphertext": "<base64>",
"algorithm": "ECIES-P256-HKDF-SHA256-XCHACHA20-POLY1305",
"wrapped_for_rotation": null,
"enrolment": true
},
{
"ciphertext": "<base64>",
"algorithm": "ECIES-P256-HKDF-SHA256-XCHACHA20-POLY1305",
"wrapped_for_rotation": "2026-08-28T10:00:00Z",
"enrolment": false
}
]
}Each copy carries its ciphertext and the algorithm it was wrapped under.
wrapped_for_rotation names the rotation the copy belongs to, and is null for the
copy made when the device was enrolled; enrolment is that same fact as a boolean,
so the enrolment copy — under the old master key — is told apart from the copy the
rotation added under the new one.
A key that names no live device — a revoked device, another vault's device, an
unknown key or a malformed submission — takes the shared not_found refusal, exactly
as POST /v1/devices/self does, so the route cannot be used
to learn which device keys are enrolled. Called with no rotation in progress, it is
refused with rotation_not_in_progress before the key is even read.
2. Read the records still under the old wrapping
GET /v1/keys/rotate/records returns the records still carrying the old wrapping,
cursor-paged by record identifier with ?after=<id>. Each record carries what a
re-wrap needs and nothing a rotation may change: its identifiers and schema version,
its wrapped record key, the rotation that key currently belongs to, and per
client_encrypted value its field_id, object_key, ciphertext, nonce, searchable
flag and index_bits.
GET /v1/keys/rotate/records?after=0
Authorization: Bearer <operator token>{
"records": [
{
"id": 8801,
"public_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
"external_ref": "policy-4471",
"schema_version": 4,
"wrapped_record_key_rotated_at": null,
"wrapped_record_key": "<base64>",
"field_values": [
{ "field_id": 71, "object_key": null, "ciphertext": "<base64>", "nonce": "<base64>", "searchable": true, "index_bits": 16 },
{ "field_id": 88, "object_key": "42/MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U", "ciphertext": null, "nonce": null, "searchable": false, "index_bits": null }
]
}
],
"next_cursor": 8801
}Every value carries the same keys, and object_key tells the two kinds of value
apart. An inline value carries its ciphertext and nonce, with object_key
null; the device decrypts them to recompute the token and blind index. A
file value carries its object_key, with
ciphertext and nonce null — its bytes live in object storage and are not moved,
and its token is derived from the object key, so the device recomputes the token from
the key alone and reads no bytes.
wrapped_record_key_rotated_at names which rotation the record's wrapped key belongs
to — null when the record has never been re-wrapped, otherwise the instant a re-wrap
set it. It is what pairs each record to the right master key when a device holds more
than one.
The last page is empty, so a resumed walk stops when nothing is left. The
index_bits is included so a resumed rotation recomputes each blind index at exactly
its declared width.
3. Submit the re-wrapped records
POST /v1/keys/rotate/records submits a bounded batch (at most 500 records) of the
new wrapped record keys, tokens and blind indexes.
POST /v1/keys/rotate/records
Authorization: Bearer <operator token>
Content-Type: application/json
{
"records": [
{
"id": 8801,
"wrapped_record_key": "<base64>",
"field_values": [
{ "field_id": 71, "token": "tok_...", "blind_index": "<base64>" }
]
}
]
}{ "rewrapped": 1, "remaining_under_old_wrapping": 127 }A batch that carries a value's ciphertext, nonce or object_key — none of which a
master-key rotation rewrites — is refused whole, before anything is written. Rewriting
a file value's object_key during a rotation would both orphan the stored object and
invalidate the token, so it is refused alongside ciphertext and nonce:
{
"code": "rotation_field_values_immutable",
"message": "A master-key rotation rewrites only the wrapped record key, token and blind index; it never changes a value's ciphertext or nonce."
}The batch is also refused whole — naming the offending record and field — when an
entry is not a complete, consistent re-wrap. Every one of a record's
client_encrypted values must be re-wrapped in the same entry, and no other field
named; each value must carry its parts together (a token always, and a blind index
exactly where the value is searchable); and a non-searchable value must carry no
blind index. Before this release these were a bare 422; now each is a coded
refusal.
An entry that omits one of the record's values, or names a field the record does not
carry, or supplies a value's parts singly, is incomplete_client_encrypted_patch
(422). A missing value:
{
"code": "incomplete_client_encrypted_patch",
"message": "Re-wrapping record 018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70 must carry every one of its client_encrypted values and no other (missing sa_id_number); a subset leaves an old token or blind index pointing at a new value.",
"details": { "record_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70", "missing": [71] }
}A value supplied without all its parts carries the same code, naming the field and what is missing — for example, "Re-wrapping client_encrypted field sa_id_number of record 018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70 must supply its parts together (blind_index missing); a subset leaves an old token or blind index pointing at a new value."
A blind index sent for a value that is not searchable is
rotation_index_on_non_searchable (422):
{
"code": "rotation_index_on_non_searchable",
"message": "Field policy_number of record 018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70 is not searchable, so it carries no blind index.",
"details": { "record_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70", "field_id": 71 }
}A batch above 500 records is a 413; a batch naming a record that is not a live
record of the vault takes the shared not_found.
4. Re-wrap the master key for the other devices
GET /v1/keys/rotate/devices returns the live devices that do not yet hold a copy
under the new key, each with its public key. The rotating device wraps the new
master key to each and sends it back.
GET /v1/keys/rotate/devices
Authorization: Bearer <operator token>{
"devices": [
{
"id": 4242,
"label": "founder-laptop",
"provider": "secure_enclave",
"enrolled_at": "2026-08-12T09:02:00Z",
"last_seen_at": "2026-08-27T14:11:07Z",
"public_key": "<base64>"
}
]
}PUT /v1/devices/:id/wrapped-key adds the copy under the new key alongside the one
the device already holds — a resumed rotation still needs the old master key to
unwrap what it has not yet re-wrapped. It is accepted only while a rotation is in
progress.
PUT /v1/devices/4242/wrapped-key
Authorization: Bearer <operator token>
Content-Type: application/json
{
"wrapped_master_key": {
"ciphertext": "<base64>",
"algorithm": "ECIES-P256-HKDF-SHA256-XCHACHA20-POLY1305"
}
}{ "wrapped_key": { "device_id": 4242, "wrapped_for_rotation": "2026-08-28T10:00:00Z" } }A revoked device is refused with device_revoked; a call with no rotation in
progress is rotation_not_in_progress.
5. Complete, suspend or discard
POST /v1/keys/rotate/complete releases the latch once no record carries the old
wrapping, and deletes the old wrapped copies of every device given a new one.
{ "rotation": { "completed": true, "records_rewrapped": 128 } }It is refused while records remain under the old wrapping, naming how many:
{
"code": "records_under_old_wrapping",
"message": "This rotation cannot be completed: 42 records still carry the old wrapping.",
"details": { "remaining": 42 }
}It is also refused while a live device still lacks a copy of the master key under the new wrapping, naming how many. Completing deletes the old wrapped copies, so a device missed here would be locked out of every record permanently — this is the last point it can be caught:
{
"code": "devices_under_old_wrapping",
"message": "This rotation cannot be completed: 2 live devices still lack a wrapped master key under the new wrapping and would be locked out.",
"details": { "outstanding": 2 }
}Give each of those devices a copy with PUT /v1/devices/:id/wrapped-key before completing.
Suspend
POST /v1/keys/rotate/abort suspends the rotation. It releases the latch, so
ordinary reads and writes resume, but it leaves the rotation outstanding: a later run
resumes into the same rotation under the same master key rather than starting
a fresh one. It rolls nothing back and leaves both wrapped copies on each device.
{
"rotation": {
"suspended": true,
"records_rewrapped": 40,
"finishable": true,
"next_step": "resume"
}
}records_rewrappedis how many records were re-wrapped before the suspend.finishableis whether the rotation can still be carried to completion.next_stepis what to run next:resume_or_discardwhen nothing was re-wrapped (so the rotation may still be thrown away),resumewhen some records were re-wrapped (so it can only be finished), orrecoverwhen it cannot be finished at all.
A rotation cannot be finished only in one case: records were already re-wrapped under
the new master key and no live device still holds a copy of it. Then finishable is
false, next_step is recover, and a detail names the state:
{
"rotation": {
"suspended": true,
"records_rewrapped": 40,
"finishable": false,
"next_step": "recover",
"detail": "The records already re-wrapped under the new master key are unreadable: no live device holds a copy of that key. This rotation cannot be resumed; recover the vault from its recovery envelope, or destroy its keys."
}
}In that terminal case you cannot resume. Recover the vault from its stored
recovery envelope — the CLI does this as
custodian recover — or destroy its keys.
Resume
A suspended rotation is still outstanding: the suspend released the latch but left
rotating_since set, so the vault still reports the rotation and a later run continues
the same one. There is no separate resume call. Every rotation route still operates on
it exactly as during an active rotation — status,
read the records,
submit the re-wrapped records,
re-wrap for the other devices and
complete. Only POST /v1/keys/rotate/begin
is refused, because a rotation is already outstanding.
To resume, re-drive the ceremony against that outstanding rotation: read
GET /v1/keys/rotate/status to see where it stands, then continue from
step 2 through
complete. The latch is not re-taken — resume runs
against the unlatched vault, so ordinary reads and writes continue alongside it, and
complete releases the already-released latch.
A resuming run can be cold — a fresh process, possibly on a different machine — holding
no local copy of either master key. It must run on a device that was live when the
rotation began, and it reads both keys from
the wrapped master-key copies this device holds
(GET /v1/keys/rotate/self-keys): the enrolment copy under the old master key and the
copy this rotation added under the new one, pairing each record to the key it belongs to
by its wrapped_record_key_rotated_at. The CLI drives all of this as
custodian key rotate --resume.
Discard
POST /v1/keys/rotate/discard throws an outstanding rotation away while nothing has
been re-wrapped under it, reclaiming the wrapped copies it added to devices as it
goes.
{ "rotation": { "discarded": true } }Once any record has been re-wrapped, a discard would strand it, so it is refused, naming the count. The only way out then is to resume and finish the rotation:
{
"code": "rotation_records_rewrapped",
"message": "This rotation cannot be discarded: 40 records have already been re-wrapped under the new master key, and discarding would strand them; resume and complete the rotation instead.",
"details": { "records_rewrapped": 40 }
}Any rotation route reached with no rotation in progress is rotation_not_in_progress.
Destroying the vault's keys
POST /v1/keys/destroy is irreversible by design: there is no undo. In one
transaction it deletes every wrapped copy of the master key and the recovery
envelope, schedules deletion of the key material, and stamps keys_destroyed_at.
It requires the request to name the vault's own slug as confirmation:
POST /v1/keys/destroy
Authorization: Bearer <operator token>
Content-Type: application/json
{ "vault_slug": "acme" }A missing or mismatched slug destroys nothing:
{
"code": "vault_slug_mismatch",
"message": "Destroying a vault's keys requires naming the vault's own slug as confirmation; \"acme-old\" does not match this vault. Nothing was destroyed.",
"details": { "expected": "acme" }
}A destroy while a master-key rotation is actively latched — a rotation that is running, not one that has been suspended — is refused, and points you at suspending that rotation first:
{
"code": "rotation_already_in_progress",
"message": "This vault is already rotating its master key; the rotation in progress started at 2026-08-28T10:00:00Z. Abort it with POST /v1/keys/rotate/abort before this call can proceed.",
"details": { "started_at": "2026-08-28T10:00:00Z", "abort_with": "POST /v1/keys/rotate/abort" }
}POST /v1/keys/rotate/abort suspends the rotation, which lifts the latch;
the destroy can then proceed.
On success the response spells out, in plain terms, what is now unrecoverable, what survives and until when, and two things it is careful not to let you confuse:
{
"destruction": {
"vault": "acme",
"destroyed_at": "2026-08-28T10:10:00Z",
"already_destroyed": false,
"collections": [ { "name": "policies", "records": 128 } ],
"records_unrecoverable": 128,
"objects_removed": 4,
"objects_removed_detail": "The vault's stored file objects were removed as offboarding hygiene, so its bucket holds nothing of the tenant's. Their bytes were ciphertext no key could open; the vault's data is unrecoverable because its master key material is destroyed, not because these objects were removed. Any object a removal missed is taken later by the orphan sweep, and the data is unrecoverable either way."
},
"unrecoverable_now": {
"live_master_key_copies": 0,
"device_reconstructs_master_key": false,
"recovery_code_reconstructs_master_key": false,
"detail": "No live copy of the master key remains: every wrapped copy and the recovery envelope are deleted, so no enrolled device and no recovery code reconstructs it, and no client-encrypted value can be decrypted again."
},
"surviving_copies": {
"aws_key_material_provisioned": true,
"aws_key_material_unrecoverable_at": "2026-09-27T10:10:00Z",
"backup_ages_out_at": "2026-10-02T10:10:00Z",
"detail": "Destruction is not instantaneous. The AWS key material behind the server-side layer stays recoverable, and its tokenised values with it, until aws_key_material_unrecoverable_at — that date is when destruction completes for that layer, not when it began. A database backup taken before now still holds the wrapped copies and the recovery envelope until backup_ages_out_at."
},
"two_mechanisms": {
"database_refuses": "The database now refuses every insert, update and delete on this vault. This protects against the application — a forgotten guard, a rake task, a console session — but not against an administrator with database access, who can clear the latch and make the vault accept writes again.",
"cryptographically_destroyed": "The master key is gone. This holds against that administrator too: a vault whose latch has been cleared accepts writes again and still cannot decrypt a single client-encrypted value, because no key to do so exists anywhere."
},
"message": "This vault's keys have been destroyed. No live copy of the master key remains; what the database refuses can be undone by an administrator, but what the cryptography destroyed cannot. Two copies still expire on their own schedule — see surviving_copies."
}Read the difference the response draws: what the store refuses is undoable by
someone with direct access, but what the cryptography destroyed is not — a vault
that accepts writes again still cannot decrypt a single client_encrypted value,
because no key to do so exists. Two copies still expire on their own schedule: the
key material until aws_key_material_unrecoverable_at, and a backup taken before now
until backup_ages_out_at.
aws_key_material_provisioned reports whether the vault ever had a server-side key.
When it is true, aws_key_material_unrecoverable_at is the date that key material's
scheduled deletion completes. When a vault never had one — no tokenised value was
ever stored — it is false, aws_key_material_unrecoverable_at is null, and the
detail says so rather than leaving you to read the null: "No server-side key was
ever provisioned for this vault, so no AWS key material was scheduled for deletion and
there is no such date to report — aws_key_material_provisioned is false for that
reason, not because a date is missing." The backup window is named in either case.
Destruction also clears the vault's stored file objects
as offboarding hygiene. objects_removed counts how many the destruction is
responsible for, and objects_removed_detail explains it: those bytes were "ciphertext
no key could open", so removing them is hygiene, not what makes the data unrecoverable
— the vault's data is unrecoverable because its master key material is destroyed. A
second call on an already-destroyed vault reports objects_removed as 0.
Destruction is idempotent: a second call reports the original destruction time and key-deletion date, and schedules nothing further.