A loopback broker with exact capabilities.

The HTTP surface binds to loopback, authorizes every operation against an exact structured capability, and returns stable failure classes without turning absence into permission disclosure.

Transport and identity#

serve binds the IPv4 loopback address on the port its unit names. There is no remote listener and no TLS termination in this binary; the boundary is the loopback interface. A deployment that exposes it through a tunnel or TLS edge owns that transport, firewall, certificate, and supervision boundary.

Consumer routes authenticate with two headers: X-Consumer naming the consumer, and an Authorization bearer header carrying its bearer. The grant must carry the exact capability the route checks. Direct consumers present an opaque bearer whose hash is registered in the vault. Acquisition consumers present a signed workload proof to issue a short-lived, one-use bearer; they never receive the owner's decryption authority.

Mutating routes serialize process-wide behind a write lock, and every request opens the vault fresh, so a grant minted while the broker is running takes effect on the next request. Every connection is served on a thread of its own; nothing waits behind a worker count or a queue.

Request-worker failures retain their full underlying cause chain. A typed bad-descriptor or disk-full failure ends the serving process so its supervisor can replace it; the diagnostic preserves the actual cause rather than labelling every fatal failure as a closed socket. Other request failures end only the affected connection.

A field read that cannot decrypt its stored item returns the complete underlying cause chain in the HTTP refusal detail, not only the outer item-read context. The diagnostic does not include the requested credential value.

When a worker cannot start, Skarbiec returns the operating-system refusal as HTTP 503. If the refusal itself cannot be delivered, stderr retains both the startup failure and the socket-clone or response-write cause. A closed supervision channel likewise retains the original worker failure.

A poisoned supervision lock or disconnected failure receiver ends the serving command with the failed supervision operation and its cause; Skarbiec does not continue using poisoned supervision state.

Health#

  • GET /livez reports process liveness only, touching no vault, keyring, or journal.
  • GET /readyz proves the journal can lock and write, opens the vault, and decrypts the deterministic and configured canary items. It reports current and maximum cryptographic and GPG executor occupancy, and answers 503 when the infrastructure beneath it cannot serve.
  • GET /readyz does not wait behind a held keyring lock: when a gpg run of the vault is in flight and a keyring lock file names a holder, it answers 503 with that holder's pid, host, command and the time the lock was taken, instead of accepting the connection and never answering.
  • GET /readyz does not wait behind a silent gpg-agent either: every gpg child of the vault is registered with the last machine status line it wrote, and when every child in flight stopped at ENC_TO (it asked the agent for its key and heard nothing), or has written no status while it cannot open the keyring, the check answers 503 naming each child's pid, arguments, start and status and the repair (`skarbiec maintain`), and a decrypt asked of the vault in that state is refused the same way instead of becoming one more child that never answers. A child cannot open the keyring when a keyring lock names a child waiting on the agent as its holder, or when the keyboxd database lock (public-keys.d/pubring.db.lock) is held by a live process that is not the one serving the keyboxd socket gpgconf names: keyboxd keeps that lock while its database is open, so a keyboxd that lost its socket keeps the database from every keyboxd started after it, and the refusal names the lock, its holder and who serves the socket. A keyring lock that cannot be read while children are in flight is refused with its cause. The daemon recovery proceeds past those children, which then end with their lost socket's error and are retried once, and ends the configured GnuPG program that kept the lock. The health document carries them under crypto.gpg_waiting_on_agent, empty when none waits.
  • The register is the account's, not one process's: every Skarbiec process (the service, a `skarbiec list` Brama spawns for a vault read, an operator's shell) publishes its gpg children to ~/.local/state/skarbiec/gpg-in-flight/<pid>.json on every change and removes the file when none runs, and reads the other processes' files before it runs gpg. A process that never ran a child of its own therefore refuses a decrypt while every child of the account waits on the agent, naming each with `of skarbiec process <pid>`, instead of joining the wedge; its caller (Brama's vault router reads the exit and its last line) gets that sentence. A file whose process no longer runs, as the process table says (ps exits non-zero for its pid), is removed and not counted. When ps itself cannot be run, every file is kept and the failure, naming the process and ps's cause, is said in the health document's crypto.gpg_waiting_on_agent, as is a file that cannot be read.
  • GET /health is a compatibility alias for readiness.

Items, fields, and identity#

  • POST /v1/items/list returns metadata for items covered by the caller's read grants; it never returns values.
  • POST /v1/items/read accepts one id and one field and requires read:item#field. It answers {id, item, item_uid, revision, field, value}: item_uid is the item's identity (a rename keeps it, a purge and recreate does not; null on an item skarbiec upgrade has not stamped) and revision the version of its current value. A trashed item is 410, a pre-v2 envelope is 409, ciphertext that will not open is 503, and an in-flight adopt candidate is refused rather than spent.
  • POST /v1/items/revision takes the same body and the same read:item#field grant and answers {id, item, item_uid, revision, field} without the value: it decrypts nothing and appends no audit entry, because it reveals no value. A consumer that holds a value it read compares its (item, item_uid, revision) with this answer and reads the value again only when they differ. The revision changes with every new value (a write, a rotate, an activated staged rotation, a restored version) and stays the same when only tags, recipients or the id change. Its refusals are those of /v1/items/read: 400 without id or field, 403 without the grant, 404 for a role no single item plays or an absent item, 409 for an in-flight adopt candidate or a pre-v2 envelope, 410 for a trashed item.
  • PUT /v1/items in acquire mode creates a provider-verified managed item and in stage mode updates a field controlled by that exact Weles writer; both require stage:item#field. rotate mode replaces one field of an existing owner-controlled item and requires rotate:item#field: the rest of the payload, the kind, recipients and tags stay, and the consumer is recorded as the revision's writer. rotate refuses a missing grant (403), a missing value or a context write (400), an absent item (404), a trashed item (410), and a lifecycle-, Weles- or replica-controlled item (409/403).
  • DELETE /v1/items requires trash:item and refuses lifecycle-owned or differently controlled records.
  • POST /v1/tokens/introspect requires introspect and answers what an inbound bearer is — an identity and its capabilities, never a value. It exists so a gateway can decide whether to serve a request without keeping a copy of every credential in the fleet.

Acquisitions and credential operations#

  • POST /v1/acquisitions takes X-Consumer and the workload proof fields and sends no standing authorization bearer at all. It verifies the proof and issues an opaque short-TTL bearer bound to that workload, consumer, item, and field.
  • POST /v1/acquisitions/read presents the issued bearer, returns only the bound field, and atomically consumes the bearer before the value is returned. Replay, expiry, or any binding mismatch is unauthorized. The answer also carries the item's declared provider when the item declares one, and omits that key entirely when it does not, so absence cannot be read as a declaration. Only provider is disclosed; the rest of the item's context stays sealed and still requires a read capability naming it.
  • POST /v1/credential/operations requires lifecycle:item and submits or resumes one operation. The body carries no directory identity and no provider: both come from the sealed item contract. adopt is refused here because its password exists only on operator stdin.
  • GET /v1/credential/operations for one item id requires the same lifecycle capability and returns the persisted state with its receipt and quarantine block. The poll can commit a staged revision, so it serializes with every other writer. lifecycle never authorizes reading a credential value.

Synchronization, donations, and enrollment#

  • GET /v1/vault requires sync:pull and serves the whole ciphertext vault document; items are served exactly as stored and are never decrypted here.
  • GET /v1/owner-pubkey needs no capability and returns the vault owner's armored public key, so a donor can seal a donation to this vault. The public half is not secret.
  • POST /v1/donations requires donate for that exact item id and enqueues one sealed item into the donation inbox for owner review. An existing id admits the donation only when its recorded writer matches the donor's claim.
  • POST /v1/enroll requires enroll for that exact uid. A replica sends its armored public key and item ids, and the source registers the key and re-seals exactly those items to it.

Unversioned aliases and operator routes#

Three unversioned routes remain reachable: a metadata listing, an item-count summary, and a grant-gated login resolution whose emitted values land in an owner-only file rather than in the response. All three are gated.

The metadata listing and the item-count summary are the pre-versioned spellings of the item index, and they now answer through the same consumer grant and the same per-item filter as the versioned item listing. A caller presenting no usable grant is refused with 403 and a body naming a required consumer grant. A caller presenting one sees only the items its read grants cover, and the summary counts that filtered set rather than the whole vault. They were previously left ungated on the strength of a documented claim that existing callers depended on them; a search across every product in this workspace found no caller of either, so the claim was retired and the routes were gated. They stay reachable rather than deleted so that a caller nobody found reads a refusal naming the missing grant instead of a not-found that could equally mean a wrong port or a dead daemon.

The versioned operator prefix serves the local console with the same authority as the CLI. Every route is a POST with a JSON body that may name its vault for that request; the local keyring decides what opens. Item reads and writes, TOTP and route resolution return their actual results. POST /v1/operator/grants/issue returns the complete grant issuance document, including the bearer for a direct grant or the acquisition instructions for a workload-bound grant. The stored grant still keeps only its hash.

POST /v1/operator/grants/capability issues the finite redemption through the same grant capability dispatcher. Its JSON fields are agent, purpose, resource, target, ttl, max-uses and optional authorization-id; ttl and max-uses are explicit positive whole numbers, with no defaults. It returns capability_id and status, not a credential value. Missing input, unrepresentable expiry and route or credential refusals keep the command's cause in the HTTP error response. The endpoint is serialized as a mutation and respects the same request-scoped vault or configured fleet owner as other operator routes.

POST /v1/operator/grants/keep takes consumer and runs grant keep: it answers status kept with previous_expires_at, or unchanged for a grant that already lives until revoked, and refuses an absent consumer, an acquire grant and a grant that records no bearer with the command's own sentence. It is serialized as a mutation like every other grant write.

People and rotation have the same parity. POST /v1/operator/recipients/remove with uid runs remove-user and returns its report. POST /v1/operator/rotation lists the rotation policies and runs_here; /v1/operator/rotation/set takes item, every-days, provider, consumer and an optional purpose; /v1/operator/rotation/remove takes item; /v1/operator/rotation/run takes an optional item. A refusal is a 400 whose error is the command's own sentence, and the mutating routes take the vault write lock like every other write.

Failure contract#

Statuses and the 400-character bound on a failure detail are Skarbiec's own. Every error code is drawn from the fleet's shared failure package, pinned by commit, so a caller deciding whether to retry reads one definition rather than parsing prose. Four codes are emitted: not_found, config, rate_limit, and infra_down.

An item-read decryption refusal retains the underlying GnuPG or filesystem cause in detail, subject to the documented bound. A valid grant with a missing secret key is an infrastructure failure, not an authorization refusal; the revision endpoint can still answer because it does not decrypt.

Run SKARBIEC=/path/to/candidate SKARBIEC_TEST_SOURCE_REVISION=<full-source-sha> tests/read/revision.sh to qualify the real CLI and HTTP revision contract. The journey creates its own vault and grant, then starts another real broker with an empty keyring: metadata must remain available, the value read must identify the missing secret key without disclosing a value, and the original broker must still read the unchanged value and revision. Commands, HTTP statuses and the decryption refusal are retained beneath target/real-tests/revision.

  • 401 means the presented bearer or workload proof does not authorize this exact call. A refused acquisition names no failed check.
  • 403 means the consumer is not authorized for the exact item and field; existence is deliberately not disclosed.
  • 409 with config identifies a conflicting state or an unsupported legacy shape, such as a pre-v2 envelope.
  • 410 with not_found identifies a trashed item, and the detail names the restore that would bring it back.
  • 503 with infra_down identifies infrastructure that cannot open or serve the encrypted value; 503 with rate_limit identifies a full request queue and is not a statement about the caller's authority.