Grants, consumers, and the vault.

A machine consumer never holds a recipient key. Access is one exact structured capability — an action, one item, and an optional exact field — and the acquisition model returns no standing bearer at all.

The vault#

The vault is one local JSON document, at SKARBIEC_VAULT_FILE or ~/.local/share/skarbiec/skarbiec.vault.json by default. Each item is validated against the skarbiec.item.v2 schema — typed kinds such as login, host-account, note, and token — and every field value is sealed with public-key encryption to the item's recipient group: owner, recovery, and any shared users.

Vault, acquisition-state, and journal writes go through a mode-0600 temporary file, fsync, and rename under a directory lock, so a reader sees the old document or the new one and never a partial write.

Item names and other vault metadata — ids, types, tags, recipients, revision counts — are cleartext; only field values are sealed, and the cryptography itself is delegated to local gpg, openssl, and shasum. Protection assumes host integrity and operator custody: a host compromised while a matching owner key is usable can read what that key can read, and OS permissions, GPG key custody, TLS, firewalling, backups, monitoring, and recovery drills remain the operator's responsibility.

The canonical item schema#

Every payload is one JSON object validated against skarbiec.item.v2. The allowed top-level properties are exactly schema, which must name that version, kind, fields as a non-empty object, context as a required object, and an optional extensions object. Any other property is refused by name.

A kind states what shape a secret has, and shape is a contract. A typed kind rejects every field outside its list. Values are strings, except that ports, recovery_codes, and chain may be an array or a string, and credential_json may be an object or a string.

  • A free-form kind accepts any field whose name is 1 to 128 ASCII alphanumerics plus dot, underscore, and hyphen, with any JSON value. The same grammar bounds every field and resource name a capability may state.
  • host-account is the one kind that also constrains its context, because an account credential that does not name the host it opens cannot be matched back to that host.
  • A host account is deliberately not a login. Login trajectories enumerate login items to drive browsers, so the two sets stay disjoint and a machine account is never typed into a web form.
  • context carries provenance rather than secrets, inside the ciphertext: source_kind, provider, account_ref, tenant_ref, request_id, operation, session_label, login_method, name, login_url, and domains are the recognized keys.
  • Two context members are owned end to end by the credential lifecycle and are never written through an item API: the sealed directory identity, and the receipt proving the last provider-verified change.
  • The credential lifecycle writes two families of record and now declares each with its own kind. An operation record is one request and its outcome; a sealed directory contract is the standing statement of which principal an item speaks for. Both are written under the same managed controller, so the authority says the lifecycle owns a record and the kind says which of the two families it is. Sealing and resealing are the only paths that write a seal, and once one exists no item API can rewrite it, because that managed authority refuses a direct owner write.
  • That distinction is a kind and not a tag because a kind states what a record permanently is, while every registered tag namespace labels state or provenance that can change — an item enters quarantine and later leaves it. A seal never becomes an operation record. Putting a permanent type distinction on the mutable, operator-writable tag surface would have rebuilt the same defect one field along.
  • A seal written before that kind existed still carries the operation record's kind, and it still reads, still lists, still resolves, and still keeps its write protection, because the lifecycle-owned test accepts either kind. Nothing is backfilled: a seal takes the new kind the next time it is sealed or resealed. An absent declaration narrows what a reader can prove and never breaks a read.
  • Neither kind is a claim an item can make for itself. The canonical envelope validation refuses a stored record naming either of them without the lifecycle's managed authority, so a record that merely calls itself a seal is rejected rather than trusted.
Canonical kinds and their fields
KindFields
loginusername required, plus at least one of password, totp_secret, or recovery_codes
host-accountusername and password both required, and context.account_ref naming user@host
notevalue required
api-keyapi_key required; api_user, username, and client_ip optional
access-keyaccess_key_id and secret_access_key required; session_token optional
tokentoken required
oauth-clientclient_id and client_secret both required
proxyusername and password required; host, ports, and zone optional
key-pairprivate_key required; public_key, passphrase, key_id, issuer_id, and team_id optional
certificatecertificate and private_key required; chain and passphrase optional
service-accountcredential_json required
credential-operationvalue required and the only field allowed; its value may be any JSON type
credential-directory-sealvalue required and the only field allowed; its value may be any JSON type. The sealed directory contract's own kind, distinct from the operation record it once shared.
bundlefree-form fields
stado-secretfree-form fields
internal-authorityfree-form fields

Every item carries an identifier a rename cannot touch#

An item id is a mutable, human-chosen name. Every item also carries an immutable identifier in its cleartext envelope: 128 random bits rendered as 32 lowercase hexadecimal characters, minted the first time the item is written and never rewritten afterwards. An existing item's own identifier always wins, so no rotation, retag, share, revoke, managed lifecycle write, version restore, or forced cross-vault overwrite can replace an identity that already exists.

It is never derived from the name, and it is random and nothing else — no counter, no hostname, no hardware address, and no timestamp — because the value travels in cleartext beside the ciphertext and into a metadata listing, a route table row, and a journal line. An identifier encoding when or where an item was made would disclose exactly what an opaque one must not. Hexadecimal rather than a hyphenated grouping, so it needs no escaping and carries no case ambiguity in a resource string, an error, or a log line.

  • Absent is a permanent, legitimate state. Every item written before the field existed has none, and no read, listing, route, or diagnosis fails or narrows because of it. The listing reports null for those items, which is how an operator sees which items backfill-item-uids still has to stamp.
  • It appears in the item envelope, in the listing projection, and on a capability route row — which is what lets route verification report a renamed item as renamed and name where it went, instead of reporting it as missing.
  • It grants no reach. It is not a capability resource, it is not a bearer, and no route accepts it as a selector, because items remain keyed by id.
  • rename moves the envelope whole under a new id, so the identifier, history, revision, tags, recipients, management, and the ciphertext are the same bytes at the new name. backfill-item-uids stamps every item that still lacks one and is idempotent.

Tag namespaces: role, not shape#

A kind states what shape a secret has. What an item is for belongs in its tags, and a consumer that needs to find its own items filters on them. Two properties make tags the only candidate: they are plaintext in the envelope, so list and a sync pull document expose them without a recipient key while fields and context stay sealed, and they are a set, so one item can carry several independent roles where an id can encode only one hierarchy.

An item id is not opaque today. Skarbiec and its consumers still read meaning out of the id string, so renaming an item can change behaviour with no error raised anywhere. Tags exist so that a new consumer does not add to that surface: filter on a registered namespace and the id stays free to change. What an id still decides today:

  • A provider: or agent: prefix decides which items capability-route reconciliation enumerates and maps to a field.
  • An -agent-auth or -model-agent-auth suffix elects the signing credential that stands behind an agent identity.
  • A -primary suffix elects the default credential for a provider family.
  • An operation:credential/ or directory:credential/ prefix marks a record the credential lifecycle owns and gates writes to it.
  • A stado-ssh- prefix scopes the fleet host-key listing.
  1. Shape a new namespace product:role, optionally product:role:value, lowercase, and register it in the binary's own registry in the same commit that starts writing it. Nothing else registers anything, and an unregistered namespace is refused on write. The rule runs both ways: a namespace the binary reads and decides on has to be registered too, because a gate that refuses a tag the product itself requires is a product refusing its own workflow.
  2. Never put a secret, a token, or a personal identifier in a tag — anything holding a list or pull grant reads them.
  3. Never make a tag the only record of a credential's meaning that a human needs; context remains the provenance of record inside the ciphertext.
  4. A consumer reading a namespace it does not own is doing discovery on someone else's contract; give it its own tag instead.
  5. The registry governs product namespaces — the ones a consumer discovers on. An item may also carry an operator's own unnamespaced labels, which the registry does not govern and which no consumer may filter on, because a label nothing owns is not a contract. A colon is the claim, and the claim is what the binary checks.
Registered tag namespaces, in the order a refusal lists them
NamespaceMatchOwnerMeaning
managed:welesExactWelesExternally managed credential; owner mutation is refused in favour of the credential lifecycle. Reserved — set, set-json, retag and import refuse it.
brama:subscriptionExactBramaThe item is a provider subscription.
brama:agent:<agent>ValuedBramaWhich agent owns that subscription; repeated per agent.
brama:provider:<provider>ValuedBramaProvider family the credential belongs to.
brama:id:<id>ValuedBramaSubscription id the control plane and its clients exchange.
brama:login:<login>ValuedBramaWhich login item a Codex subscription belongs to. credential status reauth treats an item as that subscription only when it carries this tag alongside brama:subscription and brama:provider:codex.
fleet:host-accountExactStadoThe item is one fleet host's operating-system account.
fleet:target:<name>ValuedStadoThe registry target that account belongs to; a reader with a host name filters on this.
fleet:tailnet-tlsExactStadoThe item is a private certificate authority the fleet's tailnet endpoints are anchored on; its private_key is the only copy.
lifecycle:quarantinedExactCredential lifecycleThe item is frozen. The lifecycle writes the marker when it freezes an item and reads it back to decide whether an item is frozen.

The registry is enforced by the binary#

The table above is no longer a convention the product documents and hopes its callers follow. The binary holds that same list as the authority and refuses a vault write that introduces a namespaced tag the list does not carry. There is one registry, and the published table describes what the code already enforces.

The gate sits where every item write in the product converges, so the rule is enforced once rather than once per caller with the next caller forgetting: set, set-json, import, the cross-vault copy, share, revoke, the emergency grant, the bond pull, donation-accept, the credential lifecycle, and the HTTP acquire route all arrive there. retag is gated on its own path, because its entire subject is the tag list and it must not slip past the registry on the way around the main write.

  • A refusal names the tag, says what is wrong with it, and lists every registered namespace, because a refusal that withholds the allowed set only moves the guessing one step along. It closes with the standing instruction: register a namespace before anything writes it; a tag with no colon claims no namespace and stays the operator's own label.
  • A tag whose namespace the registry does not carry is refused because it claims a namespace that is not registered.
  • A valued namespace given no usable value is refused because it claims the brama:agent:<agent> namespace without a usable agent: the value must be 1 to 128 bytes and carry no NUL, newline or carriage return. That bound is the same exact-name bound every other name in the product clears, and the refusal names whichever namespace and placeholder the tag claimed. A bare brama:agent: is a declaration with its subject missing, and is refused for exactly that reason.
  • An exact namespace is not a prefix. brama:subscription matches only itself — an item either is a subscription or is not — so brama:subscription:extra is a different, unowned tag and is refused. Flattening the two shapes into one prefix test is what would leave the surface open.
  • A tag containing no colon is never judged. It claims no namespace, it is the operator's own label, and the registry has no standing over it.

Only what a write introduces is judged#

The rule is introduced-only. A write is judged on the tags it introduces, compared against the tags the item already carries in its stored envelope, and a tag already there is left alone. Writes deliberately preserve tags they do not mention, so re-reading a preserved list through the gate would turn every unrelated rotation of an already-tagged item into a refusal — which is the tag-loss failure the preserving write was added to end, arriving by the other door.

So a rotation that restates an existing unregistered tag still succeeds, and only a newly written or changed tag is validated. Removals are never judged. An unregistered tag already in the vault is a migration to run, not a rotation to break, and the envelope migration is outside the gate for the same reason: it re-envelopes tags it did not author, and judging them would make a vault holding one unregistered tag impossible to migrate at all.

  • Restoring one registered tag onto an item that also carries an older unregistered one therefore works, because the older tag is preserved rather than re-judged.
  • Every write that sets tags is checked against the registry, including the credential lifecycle's own quarantine marker. That marker is written by direct envelope mutation, because it can be set while a staged candidate exists and the payload must not be re-encrypted then, but writing it around the item API does not carry it around the gate — which is what keeps a later change to the marker from introducing an unregistered namespace unnoticed.
  • Re-adding that marker by hand with retag after a retag dropped it is accepted, because the namespace it claims is registered. Clearing it introduces nothing and is never refused.

Consumers and grants#

A consumer is a named machine identity registered with grant issue. Capabilities are exact and structured: acquire, stage, rotate, and verify require a field; share, trash, purge, admin, lifecycle, and reseal are item-scoped. Wildcards and legacy scopes are rejected by the v2 validator.

acquire requires an Ed25519 workload public key and returns no standing bearer; wildcards and direct capabilities cannot be mixed into that identity. Direct capabilities return a bearer once and retain only its hash — a compatibility surface for existing consumers, not the model for new machine integrations.

Shell
skarbiec grant issue demo-workload \
  --capabilities acquire:demo-note#value \
  --workload-public-key-file workload.pub.pem
# -> workload_bound: true, token: null, one exact acquire capability

The capability grammar#

One capability is an action, one colon, one resource, and an optional hash followed by one field. Resource names are exact — alphanumerics plus dot, underscore, hyphen, colon, and slash — and there are no wildcards, no legacy scopes, and no duplicates within one grant.

  • A call field is a route rather than one field name: components joined by slashes, each exact, with no empty component and no parent-directory step, so a path pattern cannot reach outside what was granted.
  • context may be named only by a read capability, because it is metadata rather than a secret to be staged or acquired. Naming it in any other action is refused: context is metadata and may only be named by read capabilities, so acquire on a context field is still rejected at mint time. One context member nevertheless reaches a caller that cannot name it — a one-use acquisition read returns the item's declared provider beside the bound field, as the next section states. That is one deliberate member, not a route to the object: everything else context carries still requires the read capability.
  • A capability naming a missing item or a missing field is refused at mint time. stage and acquire may name a field the kind merely allows, since staging is how that field comes to exist, and call names a service rather than a vault item, so nothing has to exist yet.
  • Re-minting a consumer with a different capability set is refused unless the call states that it replaces them. Capabilities the mint merely preserves are not re-validated: a stale row whose item was later trashed is already inert, and re-checking it would let one stale row block every unrelated addition.
  • acquire cannot share a grant with direct capabilities, an acquire capability requires a workload public key, and a workload public key is valid only for acquire capabilities.
  • An expired grant and a grant that never existed are indistinguishable to a caller, so probing the broker discloses nothing about which consumers are registered.
Allowed capability actions
ActionGrants
readRead one exact field of one item.
acquireRequest a one-use bearer for one exact field. Requires a field.
stageWrite one exact field. Requires a field; provider-verified activation stays inside the credential lifecycle.
rotate, verifyField-scoped credential-change actions. Both require a field.
share, revoke, trash, purgeItem-scoped mutations: recipient changes, soft delete, permanent removal. trash is what gates the HTTP delete route.
adminAdministrative settlement on one exact item, such as resolving a quarantine.
lifecycleDrive credential operations on one exact item. It never authorizes reading that item's value and cannot share a grant with a read capability. Item-scoped — it must not name a field.
resealReplace that item's sealed directory contract. Item-scoped.
syncServe-channel replication, as sync:pull.
enrollRegister a replica recipient, as enroll followed by that exact uid.
donateEnqueue one exact item into another vault's donation inbox.
introspectAsk what an inbound bearer is. The answer is an identity and its capabilities, never a value.
callReach a service, and with a field the exact route within it, so a client's reach is a grant here rather than a list compiled into the service it calls.

One-use acquisition#

The workload signs the domain-separated consumer, item, field, workload id, epoch timestamp, and random nonce with its Ed25519 private key. Skarbiec verifies the signature, rejects proofs outside the short clock window, and records accepted nonce hashes until replay is impossible.

The issued bearer is bound to that workload, consumer, item, and field, with a TTL of 1–300 seconds (SKARBIEC_ACQUISITION_TTL_SECONDS, default 30). The first successful matching read removes the bearer hash under an exclusive state lock before the value is returned; replay, expiry, or a binding mismatch is unauthorized.

The answer carries the bound field, the request identifiers, and one more thing: the item's declared provider, when the item declares one. A flow that has to reset a password needs to know whether the account is directory-backed or a consumer account before it can drive anything, and the item's own declaration is the authoritative statement of that. Withholding it forced callers to keep a second copy of the mapping as a hardcoded list of item names elsewhere, so the response's shape was manufacturing a second source of truth.

  • The key is omitted when the item declares no provider, rather than sent as null or as an empty string, so a caller cannot read absence as a declaration. Absence is absence.
  • Only provider is disclosed. account_ref is an account address and on a login item restates the sealed username the caller holds no capability for; tenant_ref identifies a customer; request_id and operation are bookkeeping the caller minted itself; login_url, domains, session_label, login_method, name, and source_kind are trajectory input nothing here needs; and the sealed directory identity and the provider receipt are owned end to end by the credential lifecycle. A caller that genuinely needs the whole object asks for it with a read capability on context, which is the capability that exists for it.
  • Nothing else moves. The same route, the same one-use bearer spent under the same state lock, no new capability, and no widening of which items a consumer can reach — the caller has already presented a signed, one-use, workload-bound proof for that exact item and that exact field, and the provider an account belongs to is not a secret.
  • The disclosure costs no decryption the redemption was not already paying, and it can never turn a redemption that has already proved its capability into a failure.
  • The command and the loopback route answer with the same members but one: the command carries ok, and the route does not, because there the status line already says whether the read succeeded.
JSON
{ "consumer": "demo-workload", "field": "value", "item": "demo-note", "ok": true, "value": "not-a-secret" }
{ "error": "unauthorized", "ok": false }

The workload proof#

A workload signs one domain-separated string with its Ed25519 private key. The parts are the domain label SKARBIEC-WORKLOAD-ACQUISITION, the version v1, then the consumer, item, field, workload id, epoch timestamp, and nonce, each separated by a single NUL byte. Nothing else is signed, and the separator is not a hyphen or a space, so an implementation that guesses will simply be refused.

Every refusal at issue time is the same word — unauthorized — and it never names the check that failed. The bounds a caller must satisfy are therefore published here rather than discovered by probing.

  • The workload id is 1 to 128 characters with no control characters and no leading or trailing whitespace.
  • The nonce is exactly 43 base64url characters, which is one 32-byte random value unpadded.
  • The signature is exactly 128 lowercase hexadecimal characters, which is one 64-byte Ed25519 signature.
  • The timestamp is epoch seconds and must be within 30 seconds of the broker's clock in either direction.
  • The hash of the workload id and nonce together is retained for twice that window, so a replayed proof outlives the clock window it guards and is refused rather than re-issued.
  • Consumer, item, and field must be exact names; a wildcard or separator in any of them is an error rather than an unauthorized answer, as is a capability naming a field the item does not carry.

Failure semantics and audit#

Authorization failures remain authorization failures. An item that exists but cannot be decrypted returns infra_down rather than masquerading as missing. Error codes come from wisent-errors, pinned by commit, so a caller deciding whether to retry reads the fleet's definition.

The journal records accepted operations as non-sensitive identifiers on an append-only hash chain, and secret values, one-use bearers, signatures, and public keys never enter it, so it can be shipped to a log collector the vault itself must not reach. What it does and does not cover is narrower than every operation that ever touched the vault, and that scope, the chain's shape, and how verification reads it are stated once under Security rather than summarized differently here.

Recovery and rotation#

  • Every item carries a recovery recipient, so losing the daily identity never loses data.
  • rotate-owner rewraps every current and historical ciphertext onto the new recipient set and keeps the recovery recipient — the only correct way to change owner.
  • recovery-drill decrypts and discards a deterministic live canary in an isolated recovery keyring and appends pass/fail evidence to the audit chain.
  • Time-delayed emergency grants share the vault with a trusted user only after an operator-chosen moment.