Custodian
ReferenceHTTP API

Record files

Write and read a file field's sealed ciphertext over HTTP — one file per request, on its own routes.

A file field holds a file's bytes. Those bytes do not travel in a record body — they move over their own two routes, one file per request, so a record write that touches other fields never carries a whole document and a batch token resolution never drags one per entry.

A file field is always client_encrypted. Your device seals the bytes; the server holds ciphertext it cannot read, and neither route returns a decrypted byte.

Before you start, the collection needs a file field — declare one in the schema first. Both routes need bytes your own machine produces on an enrolled device: the sealed ciphertext and its nonce, the object_key under which the bytes are stored, and the token the device derives from that object key. This page shows where each belongs; it does not restate how to produce them.

PUT /v1/collections/:name/records/:id/files/:field — write a file

Writes one file field's sealed ciphertext to one record. You need the write grant on the field. The record is named in the path by its record_id; the field by its name.

The body carries the sealed parts, the object key your device minted, the token, and the size of the ciphertext:

PUT /v1/collections/policyholders/records/018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70/files/passport_scan
Authorization: Bearer <token>
Content-Type: application/json

{
  "ciphertext": "<base64>",
  "nonce": "<base64>",
  "object_key": "MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U",
  "token": "tok_AOWVT36VNVCIESCL",
  "byte_count": 20480
}
  • ciphertext and nonce are the sealed file and its nonce, base64-encoded.
  • object_key is the object-key suffix your device mints from a random source: exactly 32 characters of RFC 4648 base32 (uppercase A–Z and 2–7, no padding). The server prepends the vault's own prefix to store it; the stored key is the object_key the response returns.
  • token is the token your device derived from the object key. The server stores it verbatim — it holds a token it cannot recompute. See Tokens for why a file's token comes from its object key and not its bytes.
  • byte_count is the size, in bytes, of the ciphertext this write carries, and is judged against the deployment-wide maximum (below). It must be a non-negative integer.

An object key names where the ciphertext is stored, and nothing else: it is the vault's identifier and a device-minted random value, so it "discloses nothing about the file, the record or the person it concerns".

On success the response is 200 OK and names what was stored — the field, the stored object key (the vault prefix and your suffix), the recorded byte count and the token stored verbatim:

{
  "field": "passport_scan",
  "object_key": "42/MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U",
  "byte_count": 20480,
  "token": "tok_AOWVT36VNVCIESCL"
}

Replacing a file writes a new object under a fresh key rather than overwriting the old one, so a replacement write carries a new object_key.

The refusals

  • No write grant on the field (403) — "you hold no write grant on field "passport_scan", so you may not write its file."

  • Not a file field (422) — "field "policy_number" is not a file field (it is string/plain); only a file field's ciphertext is carried on this route."

  • Over the size limit (413) — a write that declares, or sends, more than CUSTODIAN_FILE_MAX_BYTES (see Deployment) is refused and nothing is stored. A write that declares too much is refused on the declaration, before the ciphertext is decoded; one that declares less and sends more is refused as the bytes are read. Both carry maximum_bytes and offered_bytes:

    {
      "error": "this file declares 12000000 bytes, above the maximum of 10485760 bytes a single write may store; nothing is stored.",
      "details": { "maximum_bytes": 10485760, "offered_bytes": 12000000 }
    }
  • Malformed object key (422) — a suffix of the wrong width or alphabet is refused, naming which; nothing is stored:

    { "error": "object key suffix must be 32 characters, but \"MFRGG\" is 5." }
  • Object key already in use (409) — a suffix already stored in the vault (a collision or a replayed write) is refused, and the file already at that key is left untouched:

    { "error": "object key \"42/MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U\" is already stored in this vault; a collision or a replayed write is refused rather than overwriting the file already there." }

A missing ciphertext, nonce, token or byte_count is a plain 422 naming the missing part.

GET /v1/collections/:name/records/:id/files/:field — read a file

Returns one file field's sealed parts, for a device to unwrap locally. You need the read_plaintext grant on the field. On a client_encrypted field, read_plaintext is what lets the caller resolve the sealed value on an enrolled device.

GET /v1/collections/policyholders/records/018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70/files/passport_scan
Authorization: Bearer <token>
{
  "collection": "policyholders",
  "field": "passport_scan",
  "record_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
  "sensitivity": "client_encrypted",
  "ciphertext": "<base64>",
  "nonce": "<base64>",
  "wrapped_record_key": "<base64>"
}

The ciphertext is fetched from storage and returned untouched: the server holds no key for it and adds none, so the response never contains a decrypted byte. The wrapped_record_key is "one record's random key sealed under a key derived from the master key, inert to anyone without the master key the server has never held" — your device unwraps it and, with it, the file.

Reading a file discloses a plaintext, so this route appends a read_plaintext audit row before it responds.

The refusals

  • No read_plaintext grant on the field, or no such field — both return the uniform not_found, so the route discloses neither which fields exist nor which a principal is denied, and writes no audit row for either. A record that carries no file at this field takes the same not_found.

  • An agent principal is refused with agent_forbidden_on_v1 before any work, like every other /v1 route (see Using the HTTP API). A read_plaintext grant on a client_encrypted field is not grantable to an agent in the first place.

  • The record's wrapped key was discarded (409) — a deleted record discards its wrapped key, so its sealed value can no longer be unwrapped, and the read is refused before the bytes are fetched:

    { "error": "the record this token names holds no wrapped key, so its sealed value can no longer be unwrapped by any device." }
  • The vault's keys are destroyed — refused with vault_keys_destroyed, like every other read that would disclose a plaintext (see Keys).

A file field is refused in a record body

A file field is client_encrypted, but its ciphertext still may not be supplied in a record create or patch — naming one there is refused, pointing at the write route above. See Records.

On this page