Custodian
ReferenceHTTP API

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_since is the instant the outstanding rotation began, or null when none is outstanding.
  • client_encrypted_values and searchable_blind_indexes count the client_encrypted values 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_key counts the live records carrying a wrapped record key.
  • records_rewrapped and remaining_under_old_wrapping are progress figures that mean something only once a rotation is running, so both are null when none is. During a rotation, records_rewrapped counts how many records already carry the new wrapping and remaining_under_old_wrapping how many still carry the old one.
  • collections carries 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_rewrapped is how many records were re-wrapped before the suspend.
  • finishable is whether the rotation can still be carried to completion.
  • next_step is what to run next: resume_or_discard when nothing was re-wrapped (so the rotation may still be thrown away), resume when some records were re-wrapped (so it can only be finished), or recover when 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.

On this page