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/// 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}