Custodian
ReferenceHTTP API

Recovery

Store the recovery envelope that lets a vault survive the loss of every device, and read it back to recover, over HTTP.

The recovery envelope is what lets a vault survive the loss of every enrolled device: the master key sealed under a key derived from a recovery passphrase, so the passphrase can reconstruct it. PUT /v1/recovery stores it. This route is reserved for operator principals.

The envelope is built and opened on a device; the server stores bytes it never interprets and holds no key that could open them. It is produced on an enrolled device by the custodian command line — see Using the HTTP API. PUT /v1/recovery writes it; GET /v1/recovery returns it to an operator when the vault has no live device left, which is the route custodian recover relies on.

PUT /v1/recovery

The request carries exactly three things: the blob, its salt, and the params (the Argon2id cost parameters and an algorithm version). Nothing else.

PUT /v1/recovery
Authorization: Bearer <operator token>
Content-Type: application/json

{
  "recovery": {
    "blob": "<base64>",
    "salt": "<base64>",
    "params": { "memory": 65536, "iterations": 3, "parallelism": 1, "version": 1 }
  }
}

On success the response is 200 OK:

{
  "recovery": { "configured": true, "replaced": false },
  "message": "Stored the recovery envelope for this vault. The recovery code that opens it never reached the server and is not stored here; keep the printed code safe, because it is the only thing that reconstructs the master key."
}

Replacing an existing envelope

One envelope per vault, replaced wholesale. A write to a vault that already holds one is refused unless the request asks to replace it, because replacing it voids every recovery code printed before now:

{
  "code": "recovery_envelope_exists",
  "message": "This vault already holds a recovery envelope. Storing another destroys it and voids every recovery code printed before now; resend with \"replace\": true to rotate it deliberately."
}

Send "replace": true — a sibling of recovery, not one of its parameters — to replace it deliberately. The response then says so:

{
  "recovery": { "configured": true, "replaced": true },
  "message": "Stored a new recovery envelope, replacing the one this vault held. Every recovery code printed for this vault before now opens nothing; only the code printed for this envelope can reconstruct the master key."
}

The refusals

  • a parameter beyond the three — under any name, and especially one named for a code or a passphrase — is unexpected_recovery_parameter:

    {
      "code": "unexpected_recovery_parameter",
      "message": "The recovery envelope carries only blob, salt and params; it does not accept recovery_code. The recovery code never reaches the server, under any name.",
      "details": { "parameter": "recovery_code" }
    }
  • params missing a cost parameter or the algorithm version, or carrying a cost that is not a positive integer, is recovery_params_invalid:

    {
      "code": "recovery_params_invalid",
      "message": "The recovery parameters must carry the Argon2id cost parameters and the algorithm version; missing iterations, version.",
      "details": { "missing": ["iterations", "version"] }
    }
  • a blob that is empty or far larger than a wrapped master key is recovery_blob_out_of_bounds:

    {
      "code": "recovery_blob_out_of_bounds",
      "message": "The recovery blob is 4096 bytes; a wrapped master key is far smaller, so this is refused as a client error rather than stored.",
      "details": { "bytes": 4096, "max_bytes": 256 }
    }
  • a vault whose keys have been destroyed takes no envelope: vault_keys_destroyed.

GET /v1/recovery

GET /v1/recovery returns the stored envelope — the blob, the salt and the params — so a data owner who has lost every device can redeem their recovery code. It is the route custodian recover fetches from. It is reserved for operator principals and rate-limited. Every call against a live vault is recorded, whatever its outcome; the one exception is a vault whose keys have already been destroyed, which is refused before any audit row is written.

GET /v1/recovery
Authorization: Bearer <operator token>

When the vault has no live device, the response is 200 OK and carries the envelope, nested under recovery exactly as the PUT mirrors:

{
  "recovery": {
    "blob": "<base64>",
    "salt": "<base64>",
    "params": { "memory": 65536, "iterations": 3, "parallelism": 1, "version": 1 }
  }
}

When it refuses

The route names each condition:

  • the vault still has a live device — recovery is for the all-devices-lost case: recovery_refused_live_device (409)

    {
      "code": "recovery_refused_live_device",
      "message": "This vault still has a live device, so the recovery envelope will not be returned. Recovery is for a vault with no device left: revoke the remaining devices with `custodian device revoke` first, then recover."
    }
  • the caller is not an operator: recovery_refused_not_operator (403)

    {
      "code": "recovery_refused_not_operator",
      "message": "The recovery envelope is returned only to an operator principal; this credential is a service. Recover with an operator credential."
    }
  • the vault's keys have been destroyed: recovery_refused_keys_destroyed (410)

    {
      "code": "recovery_refused_keys_destroyed",
      "message": "This vault's keys have been destroyed, so no recovery envelope will be returned and nothing can reconstruct its master key. The destruction is final."
    }
  • too many attempts in the window: recovery_rate_limited (429)

    {
      "code": "recovery_rate_limited",
      "message": "Too many recovery-envelope requests. Wait before trying again; every attempt is recorded."
    }

An eligible vault that never had an envelope stored returns the same indistinguishable not_found the read path uses, so the route never reveals whether an envelope exists.

On this page