Skip to content

Vault format

A .murk file is a single JSON document. Every field is plaintext except the per-key encrypted values and one encrypted meta blob. This page walks through the structure conceptually. See SPEC.md on GitHub for the exact field-by-field schema.

  • version: semver. murk validates the major version on load (a vault with a different major version is rejected); minor bumps are accepted transparently.
  • created, vault_name, repo: informational metadata about the vault.
  • recipients: the list of authorized public keys (age, SSH, or hardware-plugin), and nothing else. No names or emails live here. See Recipients & signatures for where those go and why.
  • schema: one entry per key name, with a description and optional example and tags, plus optional lifecycle fields (created, updated, rotation_interval_days, expires_at, revoked_at) that murk doctor and murk rotate --list use to flag overdue or expiring credentials. This is public and readable without a key: it’s what murk info and murk ls show. Key names must be valid shell identifiers.
  • policy (optional): an agent allow-tag list. It lives in the plaintext header, at the same trust level as the recipient list, specifically so it’s readable without a key. See Grants.

Each key in secrets carries up to three ciphertext fields, one age blob per tier:

  • shared: encrypted to every recipient (the everyone group).
  • scoped: a map of recipient pubkey → ciphertext, one entry per recipient with a private override (the me tier; the field is still named scoped on disk for backward compatibility with vaults written before that tier was renamed).
  • grouped: a map of group name → ciphertext, for secrets whose base tier is a named group rather than everyone.

A secret’s base tier is exactly one of shared or a single grouped entry: assigning it to a named group drops any existing shared value. The private (me) override layers on top of whichever base tier is active. Because age determines readability from the ciphertext’s recipients, a non-member of a group can’t decrypt its grouped entry; there’s no separate access check.

meta is a single age blob, encrypted to every recipient, holding everything that shouldn’t be visible in the plaintext header:

  • recipients: the only place public keys are mapped to display names.
  • groups: group name → member pubkeys, kept out of the header so org structure (who’s in which group) doesn’t leak to a non-recipient.
  • grants: the agent grant registry (ephemeral pubkey, scope, TTL, issuer), kept out of the header so an agent’s existence and scope don’t leak either. See Grants.
  • mac / mac_key: the keyed integrity hash covering the vault’s contents, and the key used to compute it.
  • signers / sig: the Ed25519 verifying-key registry and the current signature, which make tampering by a repo-writer attributable.

The mechanics of the MAC and signatures (what they protect against and what they don’t) are covered in Recipients & signatures.

{
"version": "2.0",
"recipients": ["age1abc...", "age1xyz..."],
"schema": {
"DATABASE_URL": { "description": "postgres connection string" }
},
"policy": { "agent_allow_tags": ["agents"] },
"secrets": {
"DATABASE_URL": {
"shared": "<age ciphertext>",
"scoped": { "age1xyz...": "<age ciphertext>" }
}
},
"meta": "<age ciphertext: recipients, groups, grants, mac, signers>"
}

All ciphertext is base64-encoded age output. policy, scoped, and grouped are only present when the vault actually uses them, so a vault that never touches groups, grants, or policy stays byte-identical to one written by an older murk version.