Skip to main content

murk_cli/
types.rs

1use std::collections::{BTreeMap, HashMap};
2
3use serde::{Deserialize, Serialize};
4use zeroize::Zeroizing;
5
6/// Current vault format version.
7pub const VAULT_VERSION: &str = "2.0";
8
9/// Default vault filename.
10pub const DEFAULT_VAULT_NAME: &str = ".murk";
11
12// -- Vault (on-disk format, v2) --
13// The entire .murk file is a single JSON document with per-value encryption.
14// Key names and schema are plaintext. Values are individually age-encrypted.
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Vault {
18    pub version: String,
19    pub created: String,
20    pub vault_name: String,
21    /// Repository URL, auto-detected from git remote during init.
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub repo: String,
24    /// Public keys only — no names. Name mappings live in the encrypted meta blob.
25    pub recipients: Vec<String>,
26    /// Key metadata — public, readable without decryption.
27    pub schema: BTreeMap<String, SchemaEntry>,
28    /// Optional agent access policy. Lives in the plaintext header (like schema)
29    /// so it is readable on no-key paths and at the same trust level as the
30    /// recipient list. Covered by the keyed MAC (`blake3v6:`) so it is
31    /// tamper-evident. Absent when no policy is set, keeping policy-free vaults
32    /// byte-identical to pre-policy murk.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub policy: Option<Policy>,
35    /// Per-value encrypted secrets. Each value is a separate age ciphertext.
36    pub secrets: BTreeMap<String, SecretEntry>,
37    /// Encrypted metadata blob: recipient names and integrity MAC.
38    pub meta: String,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SchemaEntry {
43    pub description: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub example: Option<String>,
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub tags: Vec<String>,
48    /// When the key was first added.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub created: Option<String>,
51    /// When the value was last updated. Doubles as "last rotated": any value
52    /// change (`add`/`edit`/`rotate`) bumps it, so it anchors the rotation clock.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub updated: Option<String>,
55    /// Soft rotation policy: rotate at least every N days. `doctor` flags the
56    /// key as overdue when `updated + rotation_interval_days` is in the past.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub rotation_interval_days: Option<u32>,
59    /// Hard expiry (ISO-8601 UTC) for credentials with a known end-of-life,
60    /// e.g. a token. `doctor` flags it as expired or expiring soon.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub expires_at: Option<String>,
63    /// Set to the revoke time (ISO-8601 UTC) when a recipient who could read
64    /// this key is revoked and rotation is deferred. Its *presence* is the
65    /// obligation: the revoked recipient can still decrypt the live value from
66    /// git history until it changes. Any value write (`add`/`edit`/`rotate`/
67    /// `import`) clears it, so a set `revoked_at` always means "still owed a
68    /// rotation since this revoke". `doctor` flags it until then.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub revoked_at: Option<String>,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct SecretEntry {
75    /// Shared value encrypted to all recipients (the implicit `everyone` group).
76    /// Empty when the secret's base group is a named group instead.
77    pub shared: String,
78    /// Private per-recipient values: pubkey → encrypted value (encrypted to that
79    /// pubkey only). This is the `me` tier — a singleton group of one recipient.
80    /// Serialized as `scoped` for on-disk compatibility with vaults written
81    /// before the tier was renamed; the wire format is unchanged.
82    #[serde(rename = "scoped", default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub private: BTreeMap<String, String>,
84    /// Named-group values: group name → encrypted value (encrypted to that
85    /// group's current members). A secret has at most one base group, so this
86    /// map holds at most one entry, but it is keyed by name so the integrity MAC
87    /// and merge driver can treat it uniformly with `scoped`. Group *names* are
88    /// plaintext (like key names); group *membership* lives in the encrypted meta.
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub grouped: BTreeMap<String, String>,
91}
92
93/// A short-lived agent grant: an ephemeral identity with read access to a
94/// narrow set of keys. The grant's `pubkey` is also a `Vault::recipients`
95/// entry, and each granted key carries a `scoped` ciphertext under that pubkey —
96/// so the agent's *access* is governed (and MAC-covered) by the scoped entries.
97/// This record is the audit/TTL layer: it lives in the encrypted meta (so an
98/// agent's existence and scope don't leak) and is covered by the keyed MAC
99/// (`blake3v5:`) so TTL, scope, and issuer cannot be tampered with undetected.
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
101pub struct GrantEntry {
102    /// The agent's ephemeral age public key (also in `Vault::recipients`).
103    pub pubkey: String,
104    /// Keys this grant can read (the `--only` set). Display/audit only — actual
105    /// access is the set of `scoped` ciphertexts encrypted to `pubkey`.
106    pub scope: Vec<String>,
107    /// When the grant was issued (ISO-8601 UTC).
108    pub issued_at: String,
109    /// Advisory expiry (ISO-8601 UTC). `agent ls` flags grants past this; nothing
110    /// auto-revokes. age keys cannot self-destruct, so the real close is
111    /// `agent revoke` + rotate.
112    pub expires_at: String,
113    /// Pubkey of the recipient who issued the grant (minimal accountability).
114    pub issuer: String,
115}
116
117/// Agent access policy: machine-enforceable guardrails that travel with the
118/// vault. This is NOT access control — every recipient can read every shared
119/// secret by design, and an insider can use age directly or an old murk binary.
120/// Its value is constraining what the murk binary will expose to *agents* (CI,
121/// AI coding agents), enforced at the agent entry points (`agent exec`,
122/// `agent grant`). Lives in the plaintext header and is MAC-covered so it can't
123/// be silently weakened.
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Policy {
126    /// Agent allow-list: in agent mode, a secret may be injected or granted only
127    /// if it carries at least one of these tags. Default-deny once a policy is
128    /// set — an untagged or wrong-tagged key is refused with a clear error. An
129    /// empty list means no key is agent-injectable (agents fully locked out).
130    #[serde(default)]
131    pub agent_allow_tags: Vec<String>,
132}
133
134// -- Meta (encrypted, stored in vault.meta) --
135// Contains metadata only visible to recipients.
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct Meta {
139    /// Maps pubkey → display name. The only place names are stored.
140    pub recipients: HashMap<String, String>,
141    /// Integrity MAC over secrets + schema.
142    pub mac: String,
143    /// BLAKE3 keyed MAC key (hex-encoded, 32 bytes). Generated at init, stored encrypted.
144    #[serde(default, skip_serializing_if = "Option::is_none", alias = "hmac_key")]
145    pub mac_key: Option<String>,
146    /// Pinned GitHub key fingerprints: username → [SHA256:...].
147    /// Used for TOFU (Trust On First Use) verification on `authorize github:user`.
148    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149    pub github_pins: HashMap<String, Vec<String>>,
150    /// Named recipient groups: group name → member pubkeys. Stored here (not in
151    /// the plaintext header) so org structure — who is in which group — does not
152    /// leak. Members are a subset of `Vault::recipients`. Covered by the keyed
153    /// MAC (`blake3v4:`) so membership cannot be tampered with undetected.
154    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
155    pub groups: BTreeMap<String, Vec<String>>,
156    /// Short-lived agent grants: grant name → metadata. Stored here (encrypted)
157    /// so an agent's existence and scope do not leak. Covered by the keyed MAC
158    /// (`blake3v5:`) so TTL/scope/issuer are tamper-evident.
159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160    pub grants: BTreeMap<String, GrantEntry>,
161}
162
163// -- Murk (decrypted in-memory state) --
164// The working representation after decryption. Commands read/modify this,
165// then save_vault compares against the original to minimize re-encryption.
166
167#[derive(Debug, Clone, Default)]
168pub struct Murk {
169    /// Decrypted shared values. Wrapped in `Zeroizing` so plaintext is cleared
170    /// from memory when the `Murk` is dropped.
171    pub values: HashMap<String, Zeroizing<String>>,
172    /// Pubkey → display name (from meta).
173    pub recipients: HashMap<String, String>,
174    /// Private per-recipient values (the `me` tier): key → { pubkey → decrypted
175    /// value }. Only contains entries decryptable by the current identity.
176    pub private: HashMap<String, HashMap<String, Zeroizing<String>>>,
177    /// Named-group values: key → { group name → decrypted value }.
178    /// Only contains groups the current identity is a member of (and can decrypt).
179    pub grouped: HashMap<String, HashMap<String, Zeroizing<String>>>,
180    /// Group membership: group name → member pubkeys (carried from meta).
181    pub groups: BTreeMap<String, Vec<String>>,
182    /// Agent grants (carried from meta): grant name → metadata.
183    pub grants: BTreeMap<String, GrantEntry>,
184    /// True if the vault uses a legacy unkeyed MAC (sha256/sha256v2).
185    pub legacy_mac: bool,
186    /// Pinned GitHub key fingerprints (carried from meta).
187    pub github_pins: HashMap<String, Vec<String>>,
188}