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" } } -
paramsmissing a cost parameter or the algorithm version, or carrying a cost that is not a positive integer, isrecovery_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.