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/// An Ed25519 signature over the vault's canonical content.
138///
139/// The shared-key MAC binds ciphertexts together but authenticates no *author*:
140/// its key lives in the meta blob, which anyone can re-encrypt using the public
141/// recipient keys. A signature closes that — an attacker without a recipient's
142/// signing key cannot forge one. Stored in the meta alongside the MAC. See
143/// [`crate::signing`] and `THREAT_MODEL.md`.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct VaultSignature {
146 /// The recipient pubkey (`age1...`) whose signing key produced `sig`. Must be
147 /// a current recipient, with a verifying key in `Meta::signers`, at verify time.
148 pub signer: String,
149 /// Signed-view version — bumped if the canonical signing message changes, so
150 /// an old binary rejects a newer signed view rather than misverifying it.
151 pub v: u32,
152 /// Base64-encoded 64-byte Ed25519 signature.
153 pub sig: String,
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct Meta {
158 /// Maps pubkey → display name. The only place names are stored.
159 pub recipients: HashMap<String, String>,
160 /// Integrity MAC over secrets + schema.
161 pub mac: String,
162 /// Registered Ed25519 verifying keys: recipient pubkey → base64 verifying key.
163 /// A signer's key must be listed here for its signature to verify. Populated
164 /// when a signing-capable identity saves. Empty for vaults only ever written
165 /// by SSH/hardware identities. Integrity of this map is anchored by the local
166 /// TOFU pin and signed git history (see [`crate::signing`]).
167 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
168 pub signers: BTreeMap<String, String>,
169 /// Ed25519 signature over the vault's canonical content. Absent when the last
170 /// writer had no signing-capable identity; a *present* signature must verify
171 /// or `load` fails as tampering.
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub sig: Option<VaultSignature>,
174 /// BLAKE3 keyed MAC key (hex-encoded, 32 bytes). Generated at init, stored encrypted.
175 #[serde(default, skip_serializing_if = "Option::is_none", alias = "hmac_key")]
176 pub mac_key: Option<String>,
177 /// Pinned GitHub key fingerprints: username → [SHA256:...].
178 /// Used for TOFU (Trust On First Use) verification on `authorize github:user`.
179 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
180 pub github_pins: HashMap<String, Vec<String>>,
181 /// Named recipient groups: group name → member pubkeys. Stored here (not in
182 /// the plaintext header) so org structure — who is in which group — does not
183 /// leak. Members are a subset of `Vault::recipients`. Covered by the keyed
184 /// MAC (`blake3v4:`) so membership cannot be tampered with undetected.
185 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186 pub groups: BTreeMap<String, Vec<String>>,
187 /// Short-lived agent grants: grant name → metadata. Stored here (encrypted)
188 /// so an agent's existence and scope do not leak. Covered by the keyed MAC
189 /// (`blake3v5:`) so TTL/scope/issuer are tamper-evident.
190 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191 pub grants: BTreeMap<String, GrantEntry>,
192}
193
194/// Outcome of verifying the vault's Ed25519 signature at load time.
195///
196/// An *invalid* signature never reaches here — it fails the load as tampering.
197/// So the working state distinguishes "signed" from "unsigned" (integrity then
198/// rests on git). The binary warns on `Unsigned`, and on a `Signed` that is not
199/// yet `anchored`.
200#[derive(Debug, Clone, PartialEq, Eq, Default)]
201pub enum SignatureState {
202 /// A valid signature produced by `signer` (a current recipient).
203 ///
204 /// `anchored` is whether the signer's verifying key is trusted independently
205 /// of the (attacker-mutable) meta registry: always true for ssh-ed25519
206 /// signers (the key is in the recipient string), and true for age signers
207 /// whose key matched a prior local pin. When false — an age signer's key seen
208 /// for the first time on this machine — the signature is trust-on-first-use,
209 /// not yet authenticated authorship; git commit signing is the anchor.
210 Signed { signer: String, anchored: bool },
211 /// No signature present — the last writer had no signing-capable identity.
212 #[default]
213 Unsigned,
214}
215
216// -- Murk (decrypted in-memory state) --
217// The working representation after decryption. Commands read/modify this,
218// then save_vault compares against the original to minimize re-encryption.
219
220#[derive(Debug, Clone, Default)]
221pub struct Murk {
222 /// Decrypted shared values. Wrapped in `Zeroizing` so plaintext is cleared
223 /// from memory when the `Murk` is dropped.
224 pub values: HashMap<String, Zeroizing<String>>,
225 /// Pubkey → display name (from meta).
226 pub recipients: HashMap<String, String>,
227 /// Private per-recipient values (the `me` tier): key → { pubkey → decrypted
228 /// value }. Only contains entries decryptable by the current identity.
229 pub private: HashMap<String, HashMap<String, Zeroizing<String>>>,
230 /// Named-group values: key → { group name → decrypted value }.
231 /// Only contains groups the current identity is a member of (and can decrypt).
232 pub grouped: HashMap<String, HashMap<String, Zeroizing<String>>>,
233 /// Group membership: group name → member pubkeys (carried from meta).
234 pub groups: BTreeMap<String, Vec<String>>,
235 /// Agent grants (carried from meta): grant name → metadata.
236 pub grants: BTreeMap<String, GrantEntry>,
237 /// True if the vault uses a legacy unkeyed MAC (sha256/sha256v2).
238 pub legacy_mac: bool,
239 /// Pinned GitHub key fingerprints (carried from meta).
240 pub github_pins: HashMap<String, Vec<String>>,
241 /// Registered Ed25519 verifying keys (carried from meta): recipient pubkey →
242 /// base64 verifying key. `save_vault` carries these forward so every signer's
243 /// key persists, then adds/refreshes the current signer's entry.
244 pub signers: BTreeMap<String, String>,
245 /// Whether the loaded vault carried a valid signature. `Unsigned` means
246 /// integrity rests on git; the binary surfaces a warning.
247 pub signature: SignatureState,
248 /// True when this vault loaded signed on this machine before but is now
249 /// unsigned — a stripped signature, or a merge result not yet re-signed. Set
250 /// from the signer-pin continuity check; the CLI warns distinctly, `verify`
251 /// fails, and `MURK_STRICT` refuses the load.
252 pub signature_downgraded: bool,
253}