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}