diff --git a/SPEC.md b/SPEC.md index 62647a4..7c69090 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,8 +26,9 @@ Existing secrets tools are either too complex (SOPS, Vault), tied to a runtime ( ## Terminology -- **murk** — the shared layer. Secrets encrypted to all recipients. -- **mote** — a scoped secret. Encrypted to a single recipient's key. Overrides the shared value during export. +- **murk** — the shared layer. Secrets encrypted to all recipients (the implicit `everyone` group). +- **mote** — a scoped secret. Encrypted to a single recipient's key. Overrides the shared value during export. On the CLI this is the `me` tier (`--group me`). +- **group** — a named subset of recipients. A secret assigned to a group is encrypted only to that group's members, so a leaked member key can't read secrets outside that member's groups. `everyone` (all recipients) and `me` (just you) are the two reserved, implicit groups; named groups (e.g. `prod`) sit between them. --- @@ -102,6 +103,12 @@ A `.murk` file is a single JSON document. All fields except encrypted values and "age1xyz...": "" } }, + "STRIPE_KEY": { + "shared": "", + "grouped": { + "prod": "" + } + }, "OPENAI_KEY": { "shared": "" } @@ -134,9 +141,11 @@ Key names must be valid shell identifiers: `[A-Za-z_][A-Za-z0-9_]*`. ### Secrets -Each secret has a `shared` field containing age ciphertext encrypted to all recipients, and an optional `scoped` map of recipient pubkey to age ciphertext encrypted to only that recipient. +Each secret has a `shared` field containing age ciphertext encrypted to all recipients (the `everyone` group), an optional `scoped` map of recipient pubkey to age ciphertext encrypted to only that recipient (the `me` tier), and an optional `grouped` map of group name to age ciphertext encrypted to that group's current members. + +A secret's base tier is exactly one of: shared (`everyone`), or a single named group (in which case `shared` is empty and `grouped` holds one entry). The `me` tier is a per-identity override layered on top. Group *names* are plaintext, like key names; group *membership* lives in the encrypted meta. age determines readability — a non-member simply can't decrypt a `grouped` ciphertext. -During `murk export`, scoped values override shared values for the current identity. +During `murk export` / `get`, resolution is: a personal scoped (`me`) override first, then a named-group value the current identity can read, then the shared value. All age ciphertext is base64-encoded (standard alphabet, with padding). @@ -150,8 +159,11 @@ The `meta` field is a single age blob encrypted to all recipients. It contains: "age1abc...": "mickey@example.com", "age1xyz...": "alice@example.com" }, - "mac": "blake3v3:abc123...", - "hmac_key": "0a1b2c3d..." + "mac": "blake3v4:abc123...", + "hmac_key": "0a1b2c3d...", + "groups": { + "prod": ["age1abc...", "age1xyz..."] + } } ``` @@ -161,6 +173,8 @@ The `meta` field is a single age blob encrypted to all recipients. It contains: `hmac_key` is a hex-encoded 32-byte random key used for BLAKE3 keyed hashing. Generated fresh on each save. +`groups` maps a group name to its member pubkeys (a subset of `recipients`). Stored here, not in the plaintext header, so org structure — who is in which group — does not leak. Members are covered by the MAC. The field is omitted entirely when the vault has no named groups, keeping group-free vaults byte-identical to pre-groups murk. + ### Integrity The MAC is a BLAKE3 keyed hash covering, in order: @@ -169,12 +183,14 @@ The MAC is a BLAKE3 keyed hash covering, in order: 2. **Per-key encrypted values** — for each key (sorted): - The shared ciphertext, followed by `\x00` - For each scoped entry (sorted by pubkey): the pubkey followed by `\x01`, the scoped ciphertext followed by `\x00` + - (v6 only) For each grouped entry (sorted by group name): `\x03`, the group name followed by `\x00`, the grouped ciphertext followed by `\x00` 3. **Recipient pubkeys** — sorted, each followed by `\x00` 4. **Schema** — for each key (sorted): `\x02`, then the key name, description, and example (empty if unset) each followed by `\x00`, then each tag followed by `\x00`, then the lifecycle fields `created`, `updated`, `rotation_interval_days` (decimal text), and `expires_at` — each emitted as its bytes (empty if unset) followed by `\x00` +5. **Group definitions** (v6 only) — for each group (sorted by name): `\x04`, the group name followed by `\x00`, then each member pubkey (sorted) prefixed by `\x05` -The resulting digest is prefixed with `blake3v3:` and stored as the `mac` field in meta. The 32-byte BLAKE3 key is stored as `hmac_key` in the same encrypted meta blob. +The resulting digest is prefixed with `blake3v4:` (v6) when the vault has at least one group, or `blake3v3:` (v5) when it has none, and stored as the `mac` field in meta. The 32-byte BLAKE3 key is stored as `hmac_key` in the same encrypted meta blob. -On load, murk verifies the MAC. Legacy prefixes `sha256:` (v1, no scoped coverage), `sha256v2:` (v2, unkeyed), `blake3:` (v3, no schema coverage), and `blake3v2:` (v4, no lifecycle-metadata coverage) are accepted for backward compatibility. On save, murk always writes `blake3v3:` with a fresh key. (A vault written by a newer murk therefore cannot be MAC-verified by an older binary that predates `blake3v3:`.) +On load, murk verifies the MAC. Legacy prefixes `sha256:` (v1, no scoped coverage), `sha256v2:` (v2, unkeyed), `blake3:` (v3, no schema coverage), `blake3v2:` (v4, no lifecycle-metadata coverage), and `blake3v3:` (v5, no group coverage) are accepted for backward compatibility. On save, murk writes `blake3v4:` if any group exists, otherwise `blake3v3:`, always with a fresh key. Gating the version bump on the first group keeps group-free vaults byte-identical to pre-groups murk. (A vault written by a newer murk cannot be MAC-verified by an older binary that predates the prefix it uses.) Because both the MAC and its key live inside the encrypted meta blob, only authorized recipients can compute or verify the hash. This prevents an attacker from modifying secrets and recomputing a valid MAC. @@ -194,19 +210,19 @@ Interactive setup. Prompts for a display name. Then: --- -### `murk add KEY [--scoped] [--desc DESC] [--tag TAG] [--vault NAME]` +### `murk add KEY [--group NAME] [--desc DESC] [--tag TAG] [--vault NAME]` Adds or updates a secret. Prompts for the value interactively (hidden input via rpassword) or reads from stdin when piped. -Without `--scoped`, encrypts to all recipients (shared/murk layer). With `--scoped`, encrypts to only your key (scoped/mote layer). +`--group` selects who can read it: `everyone` (the default; the shared/murk layer), `me` (only your key; the scoped/mote layer), or a named group (encrypted to that group's members; you must be a member). Assigning a secret to a named group makes that group its sole base tier — any existing shared value is dropped. `--scoped` is a deprecated alias for `--group me`. Key names are validated as shell identifiers. Invalid names are rejected. --- -### `murk generate KEY [--length N] [--hex] [--desc DESC] [--tag TAG] [--vault NAME]` +### `murk generate KEY [--length N] [--hex] [--group NAME] [--desc DESC] [--tag TAG] [--vault NAME]` -Generates a cryptographically random value and stores it as a shared secret. Default length is 32 bytes, output as URL-safe base64 (no padding). Use `--hex` for hexadecimal output. Uses the same RNG as key generation. +Generates a cryptographically random value and stores it. Default length is 32 bytes, output as URL-safe base64 (no padding). Use `--hex` for hexadecimal output. `--group` works as for `murk add`. Uses the same RNG as key generation. --- @@ -244,9 +260,9 @@ Sets metadata for a key in the plaintext schema. Does not touch encrypted values --- -### `murk edit [KEY] [--scoped] [--vault NAME]` +### `murk edit [KEY] [--scoped] [--group NAME] [--vault NAME]` -Opens secrets in `$EDITOR`. With KEY, edits a single value; without, edits all secrets as `KEY=VALUE` lines. With `--scoped`, edits scoped overrides (motes) instead of shared values. +Opens secrets in `$EDITOR`. With KEY, edits a single value; without, edits all secrets as `KEY=VALUE` lines. With `--scoped`, edits scoped overrides (motes) instead of shared values; with `--group NAME`, edits the values for that named group (you must be a member). The plaintext buffer is written to a mode-0600 temp file (preferring `XDG_RUNTIME_DIR`), then overwritten with zeros and deleted after the editor exits. An empty value or non-zero editor exit aborts without saving. @@ -293,9 +309,9 @@ Emits schema-only context safe to paste into an AI agent prompt — key names, d --- -### `murk import [FILE] [--vault NAME]` +### `murk import [FILE] [--group NAME] [--vault NAME]` -Imports secrets from a `.env` file. Parses `KEY=VALUE` lines (supports `export` prefix, single/double quotes). Skips `MURK_*` keys with a warning. Invalid key names are skipped with a warning. +Imports secrets from a `.env` file. Parses `KEY=VALUE` lines (supports `export` prefix, single/double quotes). Skips `MURK_*` keys with a warning. Invalid key names are skipped with a warning. `--group` assigns all imported secrets to a tier (`everyone` default, `me`, or a named group), as for `murk add`. --- @@ -325,9 +341,33 @@ Lists all recipients. With `MURK_KEY`, shows display names from the encrypted me --- -### `murk circle authorize PUBKEY [--name NAME] [--vault NAME]` +### `murk circle authorize PUBKEY [--name NAME] [--group NAME] [--vault NAME]` + +Adds a new recipient. Re-encrypts all shared secrets to include the new public key. Accepts `age1...`, `ssh-ed25519 ...`, or `github:username` formats. With `--group`, also adds the new recipient(s) to that group in the same step (you must be a member of the group). + +--- + +### `murk group create NAME [--vault NAME]` + +Creates a new named recipient group, seeded with you as its first member so you can always read and re-encrypt it. Reserved names (`everyone`, `me`, `all`, `self`, `mine`, `shared`) are rejected. + +--- + +### `murk group ls [--json] [--vault NAME]` + +Lists groups and their members (resolved to display names; the current user is marked with `*`). Requires `MURK_KEY` to decrypt membership. + +--- + +### `murk group add NAME --member RECIPIENT [--vault NAME]` + +Adds a recipient (by pubkey or display name) to a group. You must already be a member. The group's secrets are re-encrypted to include the new member on save. + +--- + +### `murk group rm NAME [--member RECIPIENT] [--vault NAME]` -Adds a new recipient. Re-encrypts all shared secrets to include the new public key. Accepts `age1...`, `ssh-ed25519 ...`, or `github:username` formats. +With `--member`, removes a recipient from the group and re-encrypts its secrets so the removed member loses access to current values (git history stays readable — rotate to fully close). Without `--member`, deletes the group entirely; refused if any secret is still assigned to it. You must be a member to modify a group. --- diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 8868329..18b075c 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -24,7 +24,7 @@ murk is pre-1.0 and has not been independently audited. See [SECURITY.md](SECURI **Historical access after revocation.** Revoking a recipient re-encrypts the vault going forward, but old `.murk` versions remain in git history. The revoked recipient can still decrypt any version they previously had access to. Always rotate credentials after revocation. murk warns about this at revocation time. -**Fine-grained access control.** All authorized recipients can decrypt all shared secrets. Per-key access metadata is stored but not enforced cryptographically in v1. If a recipient's public key is in the recipient list, they can read everything in the shared layer. +**Fine-grained access control.** All authorized recipients can decrypt all *shared* secrets — anything in the `everyone` layer is readable by every recipient. Named recipient groups narrow this: a secret assigned to a group is age-encrypted only to that group's members, so a leaked member key can read only the groups it belongs to (plus the shared layer), not the whole vault. This is enforced cryptographically by age, and group membership is covered by the keyed MAC (`blake3v4:`) so it can't be altered undetected. Limits: group *names* and which key belongs to which group are plaintext in the header (only membership is hidden, in the encrypted meta); managing a group requires being a member of it (you can't re-encrypt what you can't read); and the historical-access caveat above applies per group — removing a member re-encrypts going forward, but old `.murk` versions in git remain readable, so rotate. **Audit logging.** murk has no built-in audit trail beyond git history. It does not log who decrypted what or when. For regulated environments requiring provable access controls, use a dedicated secrets server. @@ -80,6 +80,7 @@ murk includes a git merge driver (`murk merge-driver`) that performs three-way m - The merge driver operates without a key. It cannot verify the MAC of any version. Integrity is verified on the next `load_vault` after merge. - Recipient additions and removals on only one side produce a merge conflict. Both sides must agree on recipient changes for a clean merge. - Secret additions from one side are accepted if the other side did not touch secrets. If both sides modified secrets (e.g. from a re-encryption after recipient change), all overlapping secrets conflict. +- Group membership lives in the encrypted meta, which the merge driver cannot read without a key. When a key is available it merges memberships (union, ours-wins, dropping non-recipients); without one it keeps `ours` meta. Either way, group definitions are covered by the `blake3v4:` MAC, so any inconsistency between merged group ciphertexts and membership is caught on the next `load_vault`. **What the merge driver prevents:** - Silent recipient removal (one-sided removal → conflict) diff --git a/node/src/lib.rs b/node/src/lib.rs index df6bdae..5e0ad8f 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -23,8 +23,8 @@ pub struct Vault { #[napi] impl Vault { - /// Get a single decrypted secret value. - /// Returns the scoped override if one exists, otherwise the shared value. + /// Get a single decrypted secret value. Resolution order: a personal scoped + /// override, then a named-group value we can read, then the shared value. /// /// Internally, vault state stores values in `Zeroizing` so plaintext /// is wiped from memory when dropped. Crossing the napi boundary into a @@ -33,10 +33,7 @@ impl Vault { /// follow. This is a known leak in the JS bindings — see THREAT_MODEL.md. #[napi] pub fn get(&self, key: String) -> Option { - if let Some(value) = self.murk.scoped.get(&key).and_then(|m| m.get(&self.pubkey)) { - return Some(value.to_string()); - } - self.murk.values.get(&key).map(|v| v.to_string()) + murk_cli::get_secret(&self.murk, &key, &self.pubkey).map(str::to_string) } /// Export all secrets as an object. Scoped values override shared values. diff --git a/src/error.rs b/src/error.rs index e5918b6..ffed8a1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,8 @@ pub enum MurkError { Recipient(String), /// Secret management (add, remove, describe). Secret(String), + /// Recipient group management (create, add/remove member, assign). + Group(String), /// GitHub key fetch. GitHub(GitHubError), /// General I/O. @@ -35,6 +37,7 @@ impl std::fmt::Display for MurkError { MurkError::Key(msg) => write!(f, "{msg}"), MurkError::Recipient(msg) => write!(f, "{msg}"), MurkError::Secret(msg) => write!(f, "{msg}"), + MurkError::Group(msg) => write!(f, "{msg}"), MurkError::GitHub(e) => write!(f, "{e}"), MurkError::Io(e) => write!(f, "I/O error: {e}"), } diff --git a/src/export.rs b/src/export.rs index eeb41a0..e5c977c 100644 --- a/src/export.rs +++ b/src/export.rs @@ -25,6 +25,14 @@ pub fn resolve_secrets( .map(|(k, v)| (k.clone(), v.clone())) .collect::>>(); + // Apply named-group values we can read. A group secret has no shared value, + // so this is usually an addition; scoped (below) still wins over it. + for (key, group_map) in &murk.grouped { + if let Some(value) = group_map.values().next() { + values.insert(key.clone(), value.clone()); + } + } + // Apply scoped overrides. for (key, scoped_map) in &murk.scoped { if let Some(value) = scoped_map.get(pubkey) { @@ -613,6 +621,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); vault.secrets.insert( @@ -620,6 +629,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -643,6 +653,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -682,6 +693,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); vault.secrets.insert( @@ -689,6 +701,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -722,6 +735,7 @@ mod tests { types::SecretEntry { shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); diff --git a/src/groups.rs b/src/groups.rs new file mode 100644 index 0000000..c1784d0 --- /dev/null +++ b/src/groups.rs @@ -0,0 +1,267 @@ +//! Named recipient groups: the access-segmentation primitive. +//! +//! A group is a named subset of the vault's recipients. Secrets assigned to a +//! group are encrypted only to its members, so a leaked member key can't read +//! secrets outside that member's groups. Membership lives in the encrypted meta +//! (see [`crate::types::Meta::groups`]) so org structure doesn't leak, and is +//! covered by the keyed MAC so it can't be tampered with undetected. +//! +//! Two group names are reserved as routing aliases for the existing tiers and +//! are never stored: `everyone` (the shared value, encrypted to all recipients) +//! and `me` (a personal scoped value, encrypted to the caller only). + +use crate::error::MurkError; +use crate::types; + +/// Reserved group names. These route to the shared/scoped tiers instead of a +/// stored group, so a real group can't take these names. +pub const RESERVED_GROUP_NAMES: &[&str] = &["everyone", "me", "all", "self", "mine", "shared"]; + +/// True if `name` is a reserved routing alias. +pub fn is_reserved(name: &str) -> bool { + RESERVED_GROUP_NAMES.contains(&name) +} + +/// Validate a group name: 1–64 chars of `[A-Za-z0-9_-]`, not reserved. +pub fn validate_group_name(name: &str) -> Result<(), MurkError> { + if name.is_empty() { + return Err(MurkError::Group("group name cannot be empty".into())); + } + if name.len() > 64 { + return Err(MurkError::Group( + "group name too long (max 64 characters)".into(), + )); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(MurkError::Group(format!( + "invalid group name \"{name}\" — use letters, digits, dashes, underscores" + ))); + } + if is_reserved(name) { + return Err(MurkError::Group(format!( + "\"{name}\" is a reserved name (it routes to the {name} tier, not a group)" + ))); + } + Ok(()) +} + +/// Create a new empty group, seeded with the creator as its first member so +/// they can always read and re-encrypt it. Errors if the name is invalid or the +/// group already exists. +pub fn create_group( + murk: &mut types::Murk, + name: &str, + creator_pubkey: &str, +) -> Result<(), MurkError> { + validate_group_name(name)?; + if murk.groups.contains_key(name) { + return Err(MurkError::Group(format!("group already exists: {name}"))); + } + murk.groups + .insert(name.into(), vec![creator_pubkey.to_string()]); + Ok(()) +} + +/// Delete a group. Refuses if any secret is still assigned to it — the caller +/// should reassign or remove those secrets first, so no data is silently +/// orphaned (group ciphertext would become unreadable). +pub fn delete_group( + vault: &types::Vault, + murk: &mut types::Murk, + name: &str, +) -> Result<(), MurkError> { + if !murk.groups.contains_key(name) { + return Err(MurkError::Group(format!("group not found: {name}"))); + } + let assigned: Vec<&str> = vault + .secrets + .iter() + .filter(|(_, e)| e.grouped.contains_key(name)) + .map(|(k, _)| k.as_str()) + .collect(); + if !assigned.is_empty() { + return Err(MurkError::Group(format!( + "group \"{name}\" still has {} secret(s) assigned ({}) — reassign or remove them first", + assigned.len(), + assigned.join(", ") + ))); + } + murk.groups.remove(name); + Ok(()) +} + +/// Resolve a member spec (a pubkey or a display name) to a recipient pubkey. +/// The result must be an authorized recipient of the vault. +pub fn resolve_member( + vault: &types::Vault, + murk: &types::Murk, + spec: &str, +) -> Result { + if vault.recipients.iter().any(|pk| pk == spec) { + return Ok(spec.to_string()); + } + let matched: Vec<&String> = murk + .recipients + .iter() + .filter(|(_, name)| name.as_str() == spec) + .map(|(pk, _)| pk) + .collect(); + match matched.as_slice() { + [] => Err(MurkError::Group(format!( + "no recipient matches \"{spec}\" — authorize them first with `murk circle authorize`" + ))), + [pk] => Ok((*pk).clone()), + _ => Err(MurkError::Group(format!( + "ambiguous name \"{spec}\" matches {} recipients — use a pubkey", + matched.len() + ))), + } +} + +/// Add a member to a group. The group must exist, the operator must already be +/// a member (so they can re-encrypt the group's secrets), and the new member +/// must be an authorized recipient. Returns true if the member was added (false +/// if already present). +pub fn add_member( + murk: &mut types::Murk, + name: &str, + member_pubkey: &str, + operator_pubkey: &str, +) -> Result { + let members = murk + .groups + .get_mut(name) + .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?; + if !members.iter().any(|pk| pk == operator_pubkey) { + return Err(MurkError::Group(format!( + "you must be a member of group \"{name}\" to modify it" + ))); + } + if members.iter().any(|pk| pk == member_pubkey) { + return Ok(false); + } + members.push(member_pubkey.to_string()); + Ok(true) +} + +/// Remove a member from a group. The operator must be a member. Refuses to +/// remove the last member (the group's secrets would become unreadable). +/// Returns true if the member was removed (false if not present). +pub fn remove_member( + murk: &mut types::Murk, + name: &str, + member_pubkey: &str, + operator_pubkey: &str, +) -> Result { + let members = murk + .groups + .get_mut(name) + .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?; + if !members.iter().any(|pk| pk == operator_pubkey) { + return Err(MurkError::Group(format!( + "you must be a member of group \"{name}\" to modify it" + ))); + } + if !members.iter().any(|pk| pk == member_pubkey) { + return Ok(false); + } + if members.len() == 1 { + return Err(MurkError::Group(format!( + "cannot remove the last member of group \"{name}\" — delete the group instead" + ))); + } + members.retain(|pk| pk != member_pubkey); + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::testutil::{empty_murk, empty_vault}; + + #[test] + fn validate_rejects_reserved_and_invalid() { + assert!(validate_group_name("prod").is_ok()); + assert!(validate_group_name("dev-team_1").is_ok()); + assert!(validate_group_name("me").is_err()); + assert!(validate_group_name("everyone").is_err()); + assert!(validate_group_name("").is_err()); + assert!(validate_group_name("has space").is_err()); + assert!(validate_group_name(&"x".repeat(65)).is_err()); + } + + #[test] + fn create_seeds_creator_as_member() { + let mut murk = empty_murk(); + create_group(&mut murk, "prod", "age1alice").unwrap(); + assert_eq!(murk.groups["prod"], vec!["age1alice".to_string()]); + // Duplicate create fails. + assert!(create_group(&mut murk, "prod", "age1alice").is_err()); + } + + #[test] + fn add_member_requires_operator_membership() { + let mut murk = empty_murk(); + create_group(&mut murk, "prod", "age1alice").unwrap(); + // A non-member operator can't modify the group. + assert!(add_member(&mut murk, "prod", "age1bob", "age1carol").is_err()); + // A member can. + assert!(add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap()); + // Adding an existing member is a no-op. + assert!(!add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap()); + assert_eq!(murk.groups["prod"].len(), 2); + } + + #[test] + fn remove_member_refuses_last() { + let mut murk = empty_murk(); + create_group(&mut murk, "prod", "age1alice").unwrap(); + add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap(); + assert!(remove_member(&mut murk, "prod", "age1bob", "age1alice").unwrap()); + // Only alice left — removing her is refused. + assert!(remove_member(&mut murk, "prod", "age1alice", "age1alice").is_err()); + } + + #[test] + fn delete_refuses_when_secrets_assigned() { + let mut vault = empty_vault(); + let mut murk = empty_murk(); + create_group(&mut murk, "prod", "age1alice").unwrap(); + + // No secrets yet — delete works. + delete_group(&vault, &mut murk, "prod").unwrap(); + + // Re-create and assign a secret; now delete is refused. + create_group(&mut murk, "prod", "age1alice").unwrap(); + vault.secrets.insert( + "K".into(), + types::SecretEntry { + grouped: BTreeMap::from([("prod".into(), "ct".into())]), + ..Default::default() + }, + ); + assert!(delete_group(&vault, &mut murk, "prod").is_err()); + } + + #[test] + fn resolve_member_by_name_and_pubkey() { + let vault = types::Vault { + recipients: vec!["age1alice".into()], + ..empty_vault() + }; + let mut murk = empty_murk(); + murk.recipients.insert("age1alice".into(), "alice".into()); + + assert_eq!( + resolve_member(&vault, &murk, "age1alice").unwrap(), + "age1alice" + ); + assert_eq!(resolve_member(&vault, &murk, "alice").unwrap(), "age1alice"); + assert!(resolve_member(&vault, &murk, "nobody").is_err()); + } +} diff --git a/src/init.rs b/src/init.rs index 65d41bb..6d6dafe 100644 --- a/src/init.rs +++ b/src/init.rs @@ -153,12 +153,13 @@ pub fn create_vault( let mac_key_hex = crate::generate_mac_key(); let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap(); - let mac = crate::compute_mac(&vault, Some(&mac_key)); + let mac = crate::compute_mac(&vault, &BTreeMap::new(), Some(&mac_key)); let meta = types::Meta { recipients: recipient_names, mac, mac_key: Some(mac_key_hex), github_pins: HashMap::new(), + groups: BTreeMap::new(), }; let meta_json = serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?; @@ -302,6 +303,7 @@ mod tests { mac: String::new(), mac_key: None, github_pins: HashMap::new(), + ..Default::default() }; let meta_json = serde_json::to_vec(&meta).unwrap(); let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 037a6fc..0a869bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod error; pub(crate) mod export; pub(crate) mod git; pub mod github; +pub(crate) mod groups; pub mod hardening; pub(crate) mod info; pub(crate) mod init; @@ -58,6 +59,9 @@ pub use export::{ }; pub use git::{MergeDriverSetupStep, setup_merge_driver}; pub use github::{GitHubError, fetch_keys}; +pub use groups::{ + add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name, +}; pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info}; pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key}; pub use merge::{MergeDriverOutput, run_merge_driver}; @@ -66,8 +70,8 @@ pub use recipients::{ list_recipients, revoke_recipient, truncate_pubkey, }; pub use secrets::{ - EXPIRY_WARN_DAYS, RotationIssue, add_secret, describe_key, get_secret, import_secrets, - list_keys, remove_secret, rotation_health, + EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret, + import_secrets, list_keys, remove_secret, rotation_health, }; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -219,27 +223,29 @@ pub fn decrypt_vault( // Verify integrity BEFORE decrypting secrets — a tampered vault should fail // with an integrity error, not a misleading "you are not a recipient" message. - let (recipients, legacy_mac, github_pins) = match decrypt_meta(vault, identity) { + let (recipients, groups, legacy_mac, github_pins) = match decrypt_meta(vault, identity) { Some(meta) if !meta.mac.is_empty() => { let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key); - if !verify_mac(vault, &meta.mac, mac_key.as_ref()) { - let expected = compute_mac(vault, mac_key.as_ref()); + if !verify_mac(vault, &meta.groups, &meta.mac, mac_key.as_ref()) { + let expected = compute_mac(vault, &meta.groups, mac_key.as_ref()); return Err(MurkError::Integrity(format!( "vault may have been tampered with (expected {expected}, got {})", meta.mac ))); } let legacy = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:"); - (meta.recipients, legacy, meta.github_pins) + (meta.recipients, meta.groups, legacy, meta.github_pins) + } + Some(meta) if vault.secrets.is_empty() => { + (meta.recipients, meta.groups, false, meta.github_pins) } - Some(meta) if vault.secrets.is_empty() => (meta.recipients, false, meta.github_pins), Some(_) => { return Err(MurkError::Integrity( "vault has secrets but MAC is empty — vault may have been tampered with".into(), )); } None if vault.secrets.is_empty() && vault.meta.is_empty() => { - (HashMap::new(), false, HashMap::new()) + (HashMap::new(), BTreeMap::new(), false, HashMap::new()) } None => { return Err(MurkError::Integrity( @@ -280,10 +286,30 @@ pub fn decrypt_vault( } } + // Decrypt named-group values we're a member of. age tells us whether our + // identity is a recipient, so we just try each group ciphertext and keep the + // ones that decrypt — non-members silently fall through. + let mut grouped: HashMap>> = HashMap::new(); + for (key, entry) in &vault.secrets { + for (group, encoded) in &entry.grouped { + if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| { + plaintext_bytes_to_zeroizing_string(&pt) + .map_err(|e| MurkError::Secret(e.to_string())) + }) { + grouped + .entry(key.clone()) + .or_default() + .insert(group.clone(), value); + } + } + } + Ok(types::Murk { values, recipients, scoped, + grouped, + groups, legacy_mac, github_pins, }) @@ -309,6 +335,113 @@ pub fn load_vault( Ok((vault, murk, identity)) } +/// Re-encrypt a key's shared (everyone) ciphertext, reusing the existing one +/// when the value and recipient set are unchanged (for minimal git diffs). +fn rebuild_shared( + key: &str, + vault: &types::Vault, + recipients: &[crypto::MurkRecipient], + recipients_changed: bool, + original: &types::Murk, + current: &types::Murk, +) -> Result { + let Some(value) = current.values.get(key) else { + // Scoped/group-only key — no shared ciphertext. + return Ok(String::new()); + }; + // Reuse the stored ciphertext when the value and recipient set are unchanged. + if !recipients_changed + && original.values.get(key) == Some(value) + && let Some(existing) = vault.secrets.get(key) + { + return Ok(existing.shared.clone()); + } + encrypt_value(value.as_bytes(), recipients) +} + +/// Re-encrypt a key's scoped (per-recipient) ciphertexts, keeping unchanged +/// entries and dropping ones removed since load. +fn rebuild_scoped( + key: &str, + vault: &types::Vault, + original: &types::Murk, + current: &types::Murk, +) -> Result, MurkError> { + let mut scoped = vault + .secrets + .get(key) + .map(|e| e.scoped.clone()) + .unwrap_or_default(); + + if let Some(key_scoped) = current.scoped.get(key) { + for (pk, val) in key_scoped { + let original_val = original.scoped.get(key).and_then(|m| m.get(pk)); + if original_val != Some(val) { + let recipient = crypto::parse_recipient(pk)?; + scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?); + } + } + } + + if let Some(orig_key_scoped) = original.scoped.get(key) { + for pk in orig_key_scoped.keys() { + let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk)); + if !still_present { + scoped.remove(pk); + } + } + } + + Ok(scoped) +} + +/// Re-encrypt a key's named-group ciphertexts to each group's current members. +/// Re-encrypts when the value changed or the group's membership changed; drops +/// groups removed since load. +fn rebuild_grouped( + key: &str, + vault: &types::Vault, + changed_groups: &BTreeSet<&str>, + original: &types::Murk, + current: &types::Murk, +) -> Result, MurkError> { + let mut grouped = vault + .secrets + .get(key) + .map(|e| e.grouped.clone()) + .unwrap_or_default(); + + if let Some(key_grouped) = current.grouped.get(key) { + for (group, val) in key_grouped { + let members = current.groups.get(group).ok_or_else(|| { + MurkError::Secret(format!("secret {key} references unknown group {group}")) + })?; + let original_val = original.grouped.get(key).and_then(|m| m.get(group)); + if original_val != Some(val) || changed_groups.contains(group.as_str()) { + let group_recipients = parse_recipients(members)?; + grouped.insert( + group.clone(), + encrypt_value(val.as_bytes(), &group_recipients)?, + ); + } + } + } + + if let Some(orig_key_grouped) = original.grouped.get(key) { + for group in orig_key_grouped.keys() { + let still_present = current + .grouped + .get(key) + .is_some_and(|m| m.contains_key(group)); + if !still_present { + grouped.remove(group); + } + } + } + + Ok(grouped) +} + /// Save the vault: compare against original state and only re-encrypt changed values. /// Unchanged values keep their original ciphertext for minimal git diffs. pub fn save_vault( @@ -328,56 +461,61 @@ pub fn save_vault( current_pks != original_pks }; + // Groups whose membership changed since load — their secrets must be + // re-encrypted even when the plaintext is unchanged, so a removed member + // loses access (and a new one gains it). + let changed_groups: BTreeSet<&str> = current + .groups + .keys() + .chain(original.groups.keys()) + .filter(|g| current.groups.get(*g) != original.groups.get(*g)) + .map(String::as_str) + .collect(); + let mut new_secrets = BTreeMap::new(); - // Collect all keys with a shared or scoped value. + // Collect all keys with a shared, scoped, or grouped value in the operator's + // working state. let mut all_keys: BTreeSet<&String> = current.values.keys().collect(); all_keys.extend(current.scoped.keys()); - - for key in all_keys { - let shared = if let Some(value) = current.values.get(key) { - if !recipients_changed && original.values.get(key) == Some(value) { - if let Some(existing) = vault.secrets.get(key) { - existing.shared.clone() - } else { - encrypt_value(value.as_bytes(), &recipients)? - } - } else { - encrypt_value(value.as_bytes(), &recipients)? - } - } else { - // Scoped-only key — no shared ciphertext. - String::new() - }; - - let mut scoped = vault - .secrets - .get(key) - .map(|e| e.scoped.clone()) - .unwrap_or_default(); - - if let Some(key_scoped) = current.scoped.get(key) { - for (pk, val) in key_scoped { - let original_val = original.scoped.get(key).and_then(|m| m.get(pk)); - if original_val == Some(val) { - // Unchanged — keep original ciphertext. - } else { - let recipient = crypto::parse_recipient(pk)?; - scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?); - } - } - } - - if let Some(orig_key_scoped) = original.scoped.get(key) { - for pk in orig_key_scoped.keys() { - let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk)); - if !still_present { - scoped.remove(pk); - } - } + all_keys.extend(current.grouped.keys()); + + // Preserve on-disk secrets the operator can't see (other groups' values, or + // other recipients' scoped entries). These never enter the decrypted `Murk`, + // so without this they'd be silently dropped when a non-member saves. A key + // the operator *deleted* was visible at load (in `original`) and is excluded, + // so deletions still take effect. + let original_visible: BTreeSet<&String> = original + .values + .keys() + .chain(original.scoped.keys()) + .chain(original.grouped.keys()) + .collect(); + for key in vault.secrets.keys() { + if !original_visible.contains(key) { + all_keys.insert(key); } + } - new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped }); + for key in all_keys { + let shared = rebuild_shared( + key, + vault, + &recipients, + recipients_changed, + original, + current, + )?; + let scoped = rebuild_scoped(key, vault, original, current)?; + let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?; + new_secrets.insert( + key.clone(), + types::SecretEntry { + shared, + scoped, + grouped, + }, + ); } vault.secrets = new_secrets; @@ -385,12 +523,13 @@ pub fn save_vault( // Update meta — always generate a fresh BLAKE3 key on save. let mac_key_hex = generate_mac_key(); let mac_key = decode_mac_key(&mac_key_hex).unwrap(); - let mac = compute_mac(vault, Some(&mac_key)); + let mac = compute_mac(vault, ¤t.groups, Some(&mac_key)); let meta = types::Meta { recipients: current.recipients.clone(), mac, mac_key: Some(mac_key_hex), github_pins: current.github_pins.clone(), + groups: current.groups.clone(), }; let meta_json = serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?; @@ -399,13 +538,21 @@ pub fn save_vault( Ok(vault::write(Path::new(vault_path), vault)?) } -/// Compute an integrity MAC over the vault's secrets, scoped entries, recipients, and schema. +/// Compute an integrity MAC over the vault's secrets, scoped entries, grouped +/// entries, recipients, schema, and group membership. /// -/// If an HMAC key is provided, uses BLAKE3 keyed hash v5 (written as `blake3v3:`), -/// which covers schema plus per-key lifecycle metadata. Otherwise falls back to -/// unkeyed SHA-256 v2 for legacy compatibility. -pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String { +/// With a key and at least one group, uses BLAKE3 keyed hash v6 (`blake3v4:`), +/// which additionally covers the grouped ciphertexts and group definitions. With +/// a key and no groups, uses v5 (`blake3v3:`) so group-free vaults stay +/// byte-identical to before groups existed. Without a key, falls back to unkeyed +/// SHA-256 v2 for legacy compatibility. +pub(crate) fn compute_mac( + vault: &types::Vault, + groups: &BTreeMap>, + mac_key: Option<&[u8; 32]>, +) -> String { match mac_key { + Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key), Some(key) => compute_mac_v5(vault, key), None => compute_mac_v2(vault), } @@ -655,16 +802,139 @@ fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String { format!("blake3v3:{hash}") } +/// Append the v5/v6 schema byte stream to `data`. Kept identical to the inline +/// loop in `compute_mac_v5` so v6 reuses the exact schema encoding without +/// risking a change to v5's bytes. +fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec) { + for (key_name, entry) in &vault.schema { + data.push(0x02); + data.extend_from_slice(key_name.as_bytes()); + data.push(0x00); + data.extend_from_slice(entry.description.as_bytes()); + data.push(0x00); + if let Some(example) = &entry.example { + data.extend_from_slice(example.as_bytes()); + } + data.push(0x00); + for tag in &entry.tags { + data.extend_from_slice(tag.as_bytes()); + data.push(0x00); + } + if let Some(created) = &entry.created { + data.extend_from_slice(created.as_bytes()); + } + data.push(0x00); + if let Some(updated) = &entry.updated { + data.extend_from_slice(updated.as_bytes()); + } + data.push(0x00); + if let Some(days) = entry.rotation_interval_days { + data.extend_from_slice(days.to_string().as_bytes()); + } + data.push(0x00); + if let Some(expires) = &entry.expires_at { + data.extend_from_slice(expires.as_bytes()); + } + data.push(0x00); + } +} + +/// v6 MAC (`blake3v4:`). Extends v5 with the per-secret grouped ciphertexts and +/// the group membership map, so a named group's members and the values encrypted +/// to them cannot be tampered with undetected. Only emitted once a vault has at +/// least one group; group-free vaults keep writing v5 and stay byte-identical. +fn compute_mac_v6( + vault: &types::Vault, + groups: &BTreeMap>, + key: &[u8; 32], +) -> String { + let mut data = Vec::new(); + + for key_name in vault.secrets.keys() { + data.extend_from_slice(key_name.as_bytes()); + data.push(0x00); + } + + for entry in vault.secrets.values() { + data.extend_from_slice(entry.shared.as_bytes()); + data.push(0x00); + + let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect(); + scoped_pks.sort(); + for pk in scoped_pks { + data.extend_from_slice(pk.as_bytes()); + data.push(0x01); + data.extend_from_slice(entry.scoped[pk].as_bytes()); + data.push(0x00); + } + + // Grouped ciphertexts, sorted by group name. `0x03` marks each entry so + // the group stream can't be confused with the scoped (`0x01`) stream. + let mut group_names: Vec<&String> = entry.grouped.keys().collect(); + group_names.sort(); + for g in group_names { + data.push(0x03); + data.extend_from_slice(g.as_bytes()); + data.push(0x00); + data.extend_from_slice(entry.grouped[g].as_bytes()); + data.push(0x00); + } + } + + let mut pks = vault.recipients.clone(); + pks.sort(); + for pk in &pks { + data.extend_from_slice(pk.as_bytes()); + data.push(0x00); + } + + schema_mac_bytes(vault, &mut data); + + // Group definitions (sorted by name; members sorted). `0x04` separates each + // group, `0x05` each member, so membership can't be tampered with undetected. + for (name, members) in groups { + data.push(0x04); + data.extend_from_slice(name.as_bytes()); + data.push(0x00); + let mut sorted = members.clone(); + sorted.sort(); + for member in &sorted { + data.push(0x05); + data.extend_from_slice(member.as_bytes()); + } + } + + let hash = blake3::keyed_hash(key, &data); + format!("blake3v4:{hash}") +} + /// Verify a stored MAC against the vault, accepting v1, v2, blake3, blake3v2, -/// and blake3v3 schemes. +/// blake3v3, and blake3v4 schemes. pub(crate) fn verify_mac( vault: &types::Vault, + groups: &BTreeMap>, stored_mac: &str, mac_key: Option<&[u8; 32]>, ) -> bool { use constant_time_eq::constant_time_eq; - let expected = if stored_mac.starts_with("blake3v3:") { + // Group data is only covered by v6 (`blake3v4:`). A vault carrying any + // grouped ciphertext or group membership but stamped with an older MAC is + // either tampered (an attacker injected a `grouped` entry that the old MAC + // ignores, then relies on group-before-shared resolution) or inconsistent. + // Reject it rather than verify against a scheme that doesn't cover groups. + let touches_groups = + !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty()); + if touches_groups && !stored_mac.starts_with("blake3v4:") { + return false; + } + + let expected = if stored_mac.starts_with("blake3v4:") { + match mac_key { + Some(key) => compute_mac_v6(vault, groups, key), + None => return false, + } + } else if stored_mac.starts_with("blake3v3:") { match mac_key { Some(key) => compute_mac_v5(vault, key), None => return false, @@ -860,13 +1130,13 @@ mod tests { }; let key = [0u8; 32]; - let mac1 = compute_mac(&vault, Some(&key)); - let mac2 = compute_mac(&vault, Some(&key)); + let mac1 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); + let mac2 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_eq!(mac1, mac2); assert!(mac1.starts_with("blake3v3:")); // Without key, falls back to sha256v2 - let mac_legacy = compute_mac(&vault, None); + let mac_legacy = compute_mac(&vault, &std::collections::BTreeMap::new(), None); assert!(mac_legacy.starts_with("sha256v2:")); } @@ -884,17 +1154,18 @@ mod tests { }; let key = [0u8; 32]; - let mac_empty = compute_mac(&vault, Some(&key)); + let mac_empty = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); vault.secrets.insert( "KEY".into(), types::SecretEntry { shared: "ciphertext".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); - let mac_with_secret = compute_mac(&vault, Some(&key)); + let mac_with_secret = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(mac_empty, mac_with_secret); } @@ -912,9 +1183,9 @@ mod tests { }; let key = [0u8; 32]; - let mac1 = compute_mac(&vault, Some(&key)); + let mac1 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); vault.recipients.push("age1xyz".into()); - let mac2 = compute_mac(&vault, Some(&key)); + let mac2 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(mac1, mac2); } @@ -944,6 +1215,7 @@ mod tests { types::SecretEntry { shared: shared.clone(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -955,6 +1227,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; let current = original.clone(); @@ -1001,6 +1274,7 @@ mod tests { types::SecretEntry { shared, scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1012,6 +1286,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; let mut current = original.clone(); @@ -1051,6 +1326,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); vault.secrets.insert( @@ -1058,6 +1334,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1072,6 +1349,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; let mut current = original.clone(); @@ -1111,6 +1389,7 @@ mod tests { types::SecretEntry { shared: shared.clone(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1122,6 +1401,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; let mut current_recipients = HashMap::new(); @@ -1133,6 +1413,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap(); @@ -1172,6 +1453,7 @@ mod tests { types::SecretEntry { shared, scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1183,6 +1465,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; // Add a scoped override. @@ -1246,6 +1529,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1257,6 +1541,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; // save_vault needs MURK_KEY set to encrypt meta. @@ -1313,6 +1598,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", &[recipient]).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1324,6 +1610,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; unsafe { std::env::set_var("MURK_KEY", &secret) }; @@ -1372,6 +1659,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", &[other_recipient]).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1384,6 +1672,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; unsafe { std::env::set_var("MURK_KEY", &other_secret) }; @@ -1443,6 +1732,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; unsafe { std::env::set_var("MURK_KEY", &secret) }; @@ -1490,6 +1780,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", &[recipient]).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1501,6 +1792,7 @@ mod tests { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() }; unsafe { std::env::set_var("MURK_KEY", &secret) }; @@ -1556,6 +1848,7 @@ mod tests { types::SecretEntry { shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1567,6 +1860,7 @@ mod tests { mac: String::new(), mac_key: None, github_pins: HashMap::new(), + ..Default::default() }; let meta_json = serde_json::to_vec(&meta).unwrap(); vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap(); @@ -1607,11 +1901,12 @@ mod tests { types::SecretEntry { shared: "ciphertext".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); let key = [0u8; 32]; - let mac_no_scoped = compute_mac(&vault, Some(&key)); + let mac_no_scoped = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); vault .secrets @@ -1620,7 +1915,7 @@ mod tests { .scoped .insert("age1bob".into(), "scoped-ct".into()); - let mac_with_scoped = compute_mac(&vault, Some(&key)); + let mac_with_scoped = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(mac_no_scoped, mac_with_scoped); } @@ -1641,26 +1936,123 @@ mod tests { let v1_mac = compute_mac_v1(&vault); let v2_mac = compute_mac_v2(&vault); let v3_mac = compute_mac_v3(&vault, &key); - assert!(verify_mac(&vault, &v1_mac, None)); - assert!(verify_mac(&vault, &v2_mac, None)); - assert!(verify_mac(&vault, &v3_mac, Some(&key))); - assert!(!verify_mac(&vault, "sha256:bogus", None)); - assert!(!verify_mac(&vault, "blake3:bogus", Some(&key))); - assert!(!verify_mac(&vault, "blake3v2:bogus", Some(&key))); - assert!(!verify_mac(&vault, "blake3v3:bogus", Some(&key))); - assert!(!verify_mac(&vault, "unknown:prefix", None)); + assert!(verify_mac( + &vault, + &std::collections::BTreeMap::new(), + &v1_mac, + None + )); + assert!(verify_mac( + &vault, + &std::collections::BTreeMap::new(), + &v2_mac, + None + )); + assert!(verify_mac( + &vault, + &std::collections::BTreeMap::new(), + &v3_mac, + Some(&key) + )); + assert!(!verify_mac( + &vault, + &std::collections::BTreeMap::new(), + "sha256:bogus", + None + )); + assert!(!verify_mac( + &vault, + &std::collections::BTreeMap::new(), + "blake3:bogus", + Some(&key) + )); + assert!(!verify_mac( + &vault, + &std::collections::BTreeMap::new(), + "blake3v2:bogus", + Some(&key) + )); + assert!(!verify_mac( + &vault, + &std::collections::BTreeMap::new(), + "blake3v3:bogus", + Some(&key) + )); + assert!(!verify_mac( + &vault, + &std::collections::BTreeMap::new(), + "unknown:prefix", + None + )); // v4 (blake3v2) — includes schema; still accepted as legacy let v4_mac = compute_mac_v4(&vault, &key); assert!(v4_mac.starts_with("blake3v2:")); - assert!(verify_mac(&vault, &v4_mac, Some(&key))); + assert!(verify_mac( + &vault, + &std::collections::BTreeMap::new(), + &v4_mac, + Some(&key) + )); // v5 (blake3v3) — current scheme, includes lifecycle metadata let v5_mac = compute_mac_v5(&vault, &key); assert!(v5_mac.starts_with("blake3v3:")); - assert!(verify_mac(&vault, &v5_mac, Some(&key))); - // compute_mac now emits v5 by default - assert!(compute_mac(&vault, Some(&key)).starts_with("blake3v3:")); + assert!(verify_mac( + &vault, + &std::collections::BTreeMap::new(), + &v5_mac, + Some(&key) + )); + // compute_mac emits v5 when there are no groups + assert!( + compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)) + .starts_with("blake3v3:") + ); + + // v6 (blake3v4) — emitted once a group exists; verifies and round-trips + let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]); + let v6_mac = compute_mac(&vault, &groups, Some(&key)); + assert!(v6_mac.starts_with("blake3v4:")); + assert!(verify_mac(&vault, &groups, &v6_mac, Some(&key))); + // Tampering with membership changes the MAC. + let tampered = BTreeMap::from([( + "prod".to_string(), + vec!["age1abc".to_string(), "age1evil".to_string()], + )]); + assert!(!verify_mac(&vault, &tampered, &v6_mac, Some(&key))); + } + + #[test] + fn verify_mac_rejects_grouped_under_legacy_prefix() { + // A v5 (blake3v3) MAC doesn't cover grouped ciphertext. Injecting a + // grouped entry must not verify against the old scheme — otherwise an + // attacker without a key could add a group value that wins on read. + let mut vault = types::Vault { + version: types::VAULT_VERSION.into(), + created: "2026-02-28T00:00:00Z".into(), + vault_name: ".murk".into(), + repo: String::new(), + recipients: vec!["age1abc".into()], + schema: BTreeMap::new(), + secrets: BTreeMap::new(), + meta: String::new(), + }; + let key = [7u8; 32]; + let no_groups = BTreeMap::new(); + let v5_mac = compute_mac(&vault, &no_groups, Some(&key)); + assert!(v5_mac.starts_with("blake3v3:")); + assert!(verify_mac(&vault, &no_groups, &v5_mac, Some(&key))); + + // Attacker injects a grouped entry; the v5 MAC is now invalid for it. + vault.secrets.insert( + "STOLEN".into(), + types::SecretEntry { + grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]), + ..Default::default() + }, + ); + assert!(!verify_mac(&vault, &no_groups, &v5_mac, Some(&key))); } #[test] @@ -1685,7 +2077,7 @@ mod tests { ); let key = [0u8; 32]; - let baseline = compute_mac(&vault, Some(&key)); + let baseline = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); // Setting a rotation interval changes the MAC — tamper-evident. vault @@ -1693,12 +2085,12 @@ mod tests { .get_mut("API_KEY") .unwrap() .rotation_interval_days = Some(90); - let with_interval = compute_mac(&vault, Some(&key)); + let with_interval = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(baseline, with_interval); // So does an expiry. vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into()); - let with_expiry = compute_mac(&vault, Some(&key)); + let with_expiry = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(with_interval, with_expiry); // v4 (which ignores these fields) is blind to the change — the reason @@ -1727,7 +2119,7 @@ mod tests { }; let key = [0u8; 32]; - let mac_no_schema = compute_mac(&vault, Some(&key)); + let mac_no_schema = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); vault.schema.insert( "API_KEY".into(), @@ -1738,13 +2130,13 @@ mod tests { }, ); - let mac_with_schema = compute_mac(&vault, Some(&key)); + let mac_with_schema = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(mac_no_schema, mac_with_schema); // Changing a tag changes the MAC let mac_before_retag = mac_with_schema; vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()]; - let mac_after_retag = compute_mac(&vault, Some(&key)); + let mac_after_retag = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key)); assert_ne!(mac_before_retag, mac_after_retag); } @@ -1788,8 +2180,8 @@ mod tests { let key1 = [0u8; 32]; let key2 = [1u8; 32]; - let mac1 = compute_mac(&vault, Some(&key1)); - let mac2 = compute_mac(&vault, Some(&key2)); + let mac1 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key1)); + let mac2 = compute_mac(&vault, &std::collections::BTreeMap::new(), Some(&key2)); assert_ne!(mac1, mac2); } diff --git a/src/main.rs b/src/main.rs index d20c570..fac9c55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,9 @@ enum Command { /// Overwrite existing secrets without prompting #[arg(long)] force: bool, + /// Assign imported secrets to this group (default: everyone) + #[arg(long)] + group: Option, /// Vault filename #[arg(long, env = "MURK_VAULT", default_value = ".murk")] vault: String, @@ -67,8 +70,11 @@ enum Command { /// Description for this key #[arg(long)] desc: Option, - /// Encrypt to only your key (scoped override) + /// Who can read it: a group name, `everyone` (default), or `me` #[arg(long)] + group: Option, + /// Deprecated alias for `--group me` + #[arg(long, hide = true)] scoped: bool, /// Tag for grouping (repeatable) #[arg(long)] @@ -91,6 +97,9 @@ enum Command { /// Description for this key #[arg(long)] desc: Option, + /// Who can read it: a group name, `everyone` (default), or `me` + #[arg(long)] + group: Option, /// Tag for grouping (repeatable) #[arg(long)] tag: Vec, @@ -207,6 +216,9 @@ enum Command { /// Edit scoped overrides instead of shared secrets #[arg(long)] scoped: bool, + /// Edit values for this group instead of shared secrets + #[arg(long)] + group: Option, /// Vault filename #[arg(long, env = "MURK_VAULT", default_value = ".murk")] vault: String, @@ -277,6 +289,12 @@ enum Command { vault: String, }, + /// Manage recipient groups + Group { + #[command(subcommand)] + sub: GroupCommand, + }, + /// Write a .envrc for direnv integration Env { /// Vault filename @@ -418,6 +436,9 @@ enum CircleCommand { /// Display name for this recipient #[arg(long)] name: Option, + /// Also add the new recipient to this group + #[arg(long)] + group: Option, /// Accept changed GitHub keys without confirmation #[arg(long)] force: bool, @@ -442,6 +463,52 @@ enum CircleCommand { }, } +#[derive(Subcommand)] +enum GroupCommand { + /// Create a new recipient group (you become its first member) + Create { + /// Group name + name: String, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, + + /// List groups and their members + Ls { + /// Output as JSON + #[arg(long)] + json: bool, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, + + /// Add a member to a group + Add { + /// Group name + name: String, + /// Recipient pubkey or display name to add + #[arg(long)] + member: String, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, + + /// Remove a member from a group, or delete the group entirely + Rm { + /// Group name + name: String, + /// Recipient pubkey or display name to remove (omit to delete the group) + #[arg(long)] + member: Option, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, +} + /// Prompt the user for a line of input, with an optional default value. fn prompt(label: &str, default: Option<&str>) -> String { let stdin = io::stdin(); @@ -711,10 +778,133 @@ fn random_secret(length: usize, hex: bool) -> zeroize::Zeroizing { zeroize::Zeroizing::new(value) } +/// Resolved destination tier for a secret command, from `--group`/`--scoped`. +enum SecretTier { + /// The shared value, encrypted to all recipients (the default). + Everyone, + /// A personal scoped value, encrypted to the caller only. + Me, + /// A named group, encrypted to that group's members. + Group(String), +} + +/// Map `--group`/`--scoped` onto a tier. The reserved names `everyone`/`me` +/// route to the shared/scoped tiers; `--scoped` is a deprecated alias for +/// `--group me`. Both flags at once is a usage error. +fn resolve_secret_tier(group: Option<&str>, scoped: bool) -> SecretTier { + if let Some(g) = group { + if scoped { + die( + &format_args!("pass either --group or --scoped, not both"), + 1, + ); + } + match g { + "everyone" | "all" | "shared" => SecretTier::Everyone, + "me" | "self" | "mine" => SecretTier::Me, + _ => SecretTier::Group(g.to_string()), + } + } else if scoped { + eprintln!( + "{} --scoped is deprecated; use --group me", + "warn".yellow().bold() + ); + SecretTier::Me + } else { + SecretTier::Everyone + } +} + +impl SecretTier { + /// Short suffix for status lines, e.g. ` (group prod)`. + fn label(&self) -> String { + match self { + SecretTier::Everyone => String::new(), + SecretTier::Me => " (me)".to_string(), + SecretTier::Group(name) => format!(" (group {name})"), + } + } +} + +/// Read a key's value for the given tier from the working state. +fn tier_get( + current: &murk_cli::types::Murk, + tier: &SecretTier, + pubkey: &str, + key: &str, +) -> Option> { + match tier { + SecretTier::Everyone => current.values.get(key).cloned(), + SecretTier::Me => current.scoped.get(key).and_then(|m| m.get(pubkey)).cloned(), + SecretTier::Group(name) => current.grouped.get(key).and_then(|m| m.get(name)).cloned(), + } +} + +/// Set a key's value for the given tier in the working state. +fn tier_set( + current: &mut murk_cli::types::Murk, + tier: &SecretTier, + pubkey: &str, + key: &str, + value: zeroize::Zeroizing, +) { + match tier { + SecretTier::Everyone => { + // everyone is the base tier — drop any group assignment so the + // shared value isn't shadowed by stale grouped ciphertext. + current.grouped.remove(key); + current.values.insert(key.to_string(), value); + } + SecretTier::Me => { + // me is an override; leave the base tier untouched. + current + .scoped + .entry(key.to_string()) + .or_default() + .insert(pubkey.to_string(), value); + } + SecretTier::Group(name) => { + // the named group becomes the sole base tier. + current.values.remove(key); + let entry = current.grouped.entry(key.to_string()).or_default(); + entry.clear(); + entry.insert(name.to_string(), value); + } + } +} + +/// List all (key, value) pairs visible at the given tier, sorted by key. +fn tier_list( + current: &murk_cli::types::Murk, + tier: &SecretTier, + pubkey: &str, +) -> Vec<(String, zeroize::Zeroizing)> { + let mut entries: Vec<(String, zeroize::Zeroizing)> = match tier { + SecretTier::Everyone => current + .values + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + SecretTier::Me => current + .scoped + .iter() + .filter_map(|(k, m)| m.get(pubkey).map(|v| (k.clone(), v.clone()))) + .collect(), + SecretTier::Group(name) => current + .grouped + .iter() + .filter_map(|(k, m)| m.get(name).map(|v| (k.clone(), v.clone()))) + .collect(), + }; + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries +} + fn cmd_add( key: &str, value: &str, desc: Option<&str>, + group: Option<&str>, scoped: bool, tags: &[String], vault_path: &str, @@ -729,26 +919,51 @@ fn cmd_add( ); } + let tier = resolve_secret_tier(group, scoped); + let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); let original = murk.clone(); let mut current = murk; - let needs_desc_hint = murk_cli::add_secret( - &mut vault, - &mut current, - key, - value, - desc, - scoped, - tags, - &identity, - ); + let (needs_desc_hint, label) = match &tier { + SecretTier::Group(name) => { + let pubkey = try_or_die(identity.pubkey_string()); + let needs = try_or_die(murk_cli::add_grouped_secret( + &mut vault, + &mut current, + key, + value, + desc, + name, + tags, + &pubkey, + )); + (needs, format!(" (group {name})")) + } + tier => { + let scoped = matches!(tier, SecretTier::Me); + let needs = murk_cli::add_secret( + &mut vault, + &mut current, + key, + value, + desc, + scoped, + tags, + &identity, + ); + ( + needs, + if scoped { + " (me)".to_string() + } else { + String::new() + }, + ) + } + }; - if scoped { - eprintln!("{} added {} (scoped)", "✦".yellow(), key.bold()); - } else { - eprintln!("{} added {}", "◆".magenta(), key.bold()); - } + eprintln!("{} added {}{label}", "◆".magenta(), key.bold()); if needs_desc_hint { eprintln!( @@ -760,7 +975,8 @@ fn cmd_add( save_vault(vault_path, &mut vault, &original, ¤t); } -fn cmd_import(file: &str, force: bool, vault_path: &str) { +fn cmd_import(file: &str, force: bool, group: Option<&str>, vault_path: &str) { + let tier = resolve_secret_tier(group, false); // Wrap the raw file contents in Zeroizing so the plaintext is wiped // from memory as soon as parsing completes, not when the function returns. let contents = zeroize::Zeroizing::new( @@ -805,15 +1021,19 @@ fn cmd_import(file: &str, force: bool, vault_path: &str) { return; } - let (mut vault, murk, _identity, _lock) = load_vault_locked(vault_path); + let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); let original = murk.clone(); let mut current = murk; - // Check for collisions with existing secrets. + // Check for collisions with existing secrets (any tier). if !force { let collisions: Vec<&str> = pairs .iter() - .filter(|(k, _)| current.values.contains_key(k)) + .filter(|(k, _)| { + current.values.contains_key(k) + || current.grouped.contains_key(k) + || current.scoped.contains_key(k) + }) .map(|(k, _)| k.as_str()) .collect(); if !collisions.is_empty() { @@ -831,7 +1051,40 @@ fn cmd_import(file: &str, force: bool, vault_path: &str) { } } - let imported = murk_cli::import_secrets(&mut vault, &mut current, &pairs); + let imported: Vec = match &tier { + SecretTier::Everyone => murk_cli::import_secrets(&mut vault, &mut current, &pairs), + SecretTier::Me => { + for (key, value) in &pairs { + murk_cli::add_secret( + &mut vault, + &mut current, + key, + value, + None, + true, + &[], + &identity, + ); + } + pairs.iter().map(|(k, _)| k.clone()).collect() + } + SecretTier::Group(name) => { + let pubkey = try_or_die(identity.pubkey_string()); + for (key, value) in &pairs { + try_or_die(murk_cli::add_grouped_secret( + &mut vault, + &mut current, + key, + value, + None, + name, + &[], + &pubkey, + )); + } + pairs.iter().map(|(k, _)| k.clone()).collect() + } + }; for key in &imported { eprintln!(" {} {}", "◆".magenta(), key.bold()); @@ -839,8 +1092,13 @@ fn cmd_import(file: &str, force: bool, vault_path: &str) { save_vault(vault_path, &mut vault, &original, ¤t); let count = imported.len(); + let label = match &tier { + SecretTier::Group(name) => format!(" into group {name}"), + SecretTier::Me => " (me)".to_string(), + SecretTier::Everyone => String::new(), + }; eprintln!( - "{} imported {count} secret{}", + "{} imported {count} secret{}{label}", "◆".magenta(), if count == 1 { "" } else { "s" } ); @@ -851,6 +1109,7 @@ fn cmd_generate( length: usize, hex: bool, desc: Option<&str>, + group: Option<&str>, tags: &[String], vault_path: &str, ) { @@ -864,24 +1123,49 @@ fn cmd_generate( ); } + let tier = resolve_secret_tier(group, false); let value = random_secret(length, hex); let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); let original = murk.clone(); let mut current = murk; - murk_cli::add_secret( - &mut vault, - &mut current, - key, - &value, - desc, - false, - tags, - &identity, - ); + let label = match &tier { + SecretTier::Group(name) => { + let pubkey = try_or_die(identity.pubkey_string()); + try_or_die(murk_cli::add_grouped_secret( + &mut vault, + &mut current, + key, + &value, + desc, + name, + tags, + &pubkey, + )); + format!(" (group {name})") + } + tier => { + let scoped = matches!(tier, SecretTier::Me); + murk_cli::add_secret( + &mut vault, + &mut current, + key, + &value, + desc, + scoped, + tags, + &identity, + ); + if scoped { + " (me)".to_string() + } else { + String::new() + } + } + }; - eprintln!("{} generated {}", "◆".magenta(), key.bold()); + eprintln!("{} generated {}{label}", "◆".magenta(), key.bold()); save_vault(vault_path, &mut vault, &original, ¤t); } @@ -1117,64 +1401,52 @@ fn cmd_export(tags: &[String], json: bool, vault_path: &str) { } } -fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { +fn cmd_edit(key: Option<&str>, scoped: bool, group: Option<&str>, vault_path: &str) { + let tier = resolve_secret_tier(group, scoped); + let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); let original = murk.clone(); let mut current = murk; let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + if let SecretTier::Group(name) = &tier { + match current.groups.get(name) { + None => die(&format_args!("group not found: {name}"), 1), + Some(members) if !members.contains(&pubkey) => die( + &format_args!("you must be a member of group \"{name}\" to edit it"), + 1, + ), + Some(_) => {} + } + } + + let tier_label = tier.label(); + // Build the edit buffer. let (header, entries) = if let Some(k) = key { // Single key: just the raw value. - let value = if scoped { - current.scoped.get(k).and_then(|m| m.get(&pubkey)).cloned() - } else { - current.values.get(k).cloned() - }; - let value = value.unwrap_or_else(|| { - die( - &format_args!( - "key {} not found{}", - k.bold(), - if scoped { " (scoped)" } else { "" } - ), - 1, - ); + let value = tier_get(¤t, &tier, &pubkey, k).unwrap_or_else(|| { + die(&format_args!("key {} not found{tier_label}", k.bold()), 1); }); ( format!( - "# Editing {}{}\n# Save and quit to apply. Empty value or exit non-zero to abort.\n", - k, - if scoped { " (scoped)" } else { "" } + "# Editing {k}{tier_label}\n# Save and quit to apply. Empty value or exit non-zero to abort.\n", ), vec![(k.to_string(), value)] as Vec<(String, zeroize::Zeroizing)>, ) } else { // All keys: KEY=VALUE format. - let mut entries: Vec<(String, zeroize::Zeroizing)> = if scoped { - current - .scoped - .iter() - .filter_map(|(k, m)| m.get(&pubkey).map(|v| (k.clone(), v.clone()))) - .collect() - } else { - current - .values - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect() + let entries = tier_list(¤t, &tier, &pubkey); + let scope_note = match &tier { + SecretTier::Everyone => String::new(), + SecretTier::Me => "# Editing your personal (me) values.\n".to_string(), + SecretTier::Group(name) => format!("# Editing group {name} values.\n"), }; - entries.sort_by(|a, b| a.0.cmp(&b.0)); let header = format!( "# Edit secrets below. Lines starting with # are ignored.\n\ # Format: KEY=VALUE (one per line).\n\ # Delete a line to remove that secret. Add KEY=VALUE to create.\n\ - # Save and quit to apply. Exit non-zero to abort.\n{}\n", - if scoped { - "# Editing scoped overrides.\n" - } else { - "" - } + # Save and quit to apply. Exit non-zero to abort.\n{scope_note}\n", ); (header, entries) }; @@ -1283,33 +1555,17 @@ fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { return; } - let old_value: Option> = if scoped { - current.scoped.get(k).and_then(|m| m.get(&pubkey)).cloned() - } else { - current.values.get(k).cloned() - }; + let old_value = tier_get(¤t, &tier, &pubkey, k); if old_value.as_ref().map(|v| v.as_str()) == Some(new_value.as_str()) { eprintln!("{} no changes", "◆".magenta()); return; } - if scoped { - current - .scoped - .entry(k.into()) - .or_default() - .insert(pubkey.clone(), new_value); - } else { - current.values.insert(k.into(), new_value); - } + tier_set(&mut current, &tier, &pubkey, k, new_value); save_vault(vault_path, &mut vault, &original, ¤t); - if scoped { - eprintln!("{} updated {} (scoped)", "✦".yellow(), k.bold()); - } else { - eprintln!("{} updated {}", "◆".magenta(), k.bold()); - } + eprintln!("{} updated {}{tier_label}", "◆".magenta(), k.bold()); } else { // Multi-key: parse KEY=VALUE lines, diff against original. let mut new_entries: std::collections::BTreeMap> = @@ -1349,27 +1605,11 @@ fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { match old_entries.get(k) { Some(old_v) if old_v.as_str() == v.as_str() => {} // Unchanged. Some(_) => { - if scoped { - current - .scoped - .entry(k.clone()) - .or_default() - .insert(pubkey.clone(), v.clone()); - } else { - current.values.insert(k.clone(), v.clone()); - } + tier_set(&mut current, &tier, &pubkey, k, v.clone()); updated += 1; } None => { - if scoped { - current - .scoped - .entry(k.clone()) - .or_default() - .insert(pubkey.clone(), v.clone()); - } else { - current.values.insert(k.clone(), v.clone()); - } + tier_set(&mut current, &tier, &pubkey, k, v.clone()); // Ensure schema entry exists for new keys. vault .schema @@ -1383,14 +1623,23 @@ fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { // Remove deleted keys. for k in old_entries.keys() { if !new_entries.contains_key(k) { - if scoped { - if let Some(m) = current.scoped.get_mut(k) { - m.remove(&pubkey); + match &tier { + SecretTier::Everyone => { + current.values.remove(k); + current.scoped.remove(k); + current.grouped.remove(k); + vault.schema.remove(k); + } + SecretTier::Me => { + if let Some(m) = current.scoped.get_mut(k) { + m.remove(&pubkey); + } + } + SecretTier::Group(name) => { + if let Some(m) = current.grouped.get_mut(k) { + m.remove(name); + } } - } else { - current.values.remove(k); - current.scoped.remove(k); - vault.schema.remove(k); } removed += 1; } @@ -1756,9 +2005,24 @@ fn reject_rsa_keys(keys: &[String], allow: bool) { ); } +/// Add freshly-authorized pubkeys to `group` (if set). The caller must be a +/// member of the group. Dies on error. +fn add_recipients_to_group( + current: &mut murk_cli::types::Murk, + group: Option<&str>, + pubkeys: &[String], + operator_pubkey: &str, +) { + let Some(g) = group else { return }; + for pk in pubkeys { + try_or_die(murk_cli::add_member(current, g, pk, operator_pubkey)); + } +} + fn cmd_authorize( pubkey: &str, name: Option<&str>, + group: Option<&str>, force: bool, allow_ssh_rsa: bool, vault_path: &str, @@ -1766,6 +2030,7 @@ fn cmd_authorize( let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); let original = murk.clone(); let mut current = murk; + let operator_pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); if let Some(username) = pubkey.strip_prefix("github:") { // Fetch all SSH keys from GitHub. @@ -1817,6 +2082,7 @@ fn cmd_authorize( let display_name = format!("{username}@github"); let mut added = 0; + let mut authorized: Vec = Vec::new(); let mut type_counts: std::collections::HashMap = std::collections::HashMap::new(); @@ -1835,9 +2101,12 @@ fn cmd_authorize( let key_type = murk_cli::github::key_type_label(key_string); *type_counts.entry(key_type.to_string()).or_default() += 1; + authorized.push((*key_string).clone()); added += 1; } + add_recipients_to_group(&mut current, group, &authorized, &operator_pubkey); + if added == 0 { eprintln!( "{} all {} SSH keys for {}@github are already authorized", @@ -1916,6 +2185,13 @@ fn cmd_authorize( name, )); + add_recipients_to_group( + &mut current, + group, + std::slice::from_ref(&key_string), + &operator_pubkey, + ); + save_vault(vault_path, &mut vault, &original, ¤t); let display = name @@ -1932,6 +2208,13 @@ fn cmd_authorize( name, )); + add_recipients_to_group( + &mut current, + group, + std::slice::from_ref(&pubkey.to_string()), + &operator_pubkey, + ); + save_vault(vault_path, &mut vault, &original, ¤t); let display = name.unwrap_or(pubkey); @@ -2001,6 +2284,151 @@ fn cmd_revoke(recipient: &str, rotate: bool, vault_path: &str) { ); } +fn cmd_group(sub: GroupCommand) { + match sub { + GroupCommand::Create { name, vault } => { + let vault_path = murk_cli::resolve_vault_path(&vault); + let (mut vault, murk, identity, _lock) = load_vault_locked(&vault_path); + let original = murk.clone(); + let mut current = murk; + let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + + try_or_die(murk_cli::create_group(&mut current, &name, &pubkey)); + save_vault(&vault_path, &mut vault, &original, ¤t); + eprintln!("{} created group {}", "◆".magenta(), name.bold()); + } + + GroupCommand::Ls { json, vault } => { + let vault_path = murk_cli::resolve_vault_path(&vault); + let (_vault, murk, identity) = load_vault(&vault_path); + let self_pubkey = identity.pubkey_string().ok(); + + if json { + let map: serde_json::Map = murk + .groups + .iter() + .map(|(name, members)| { + let arr: Vec = members + .iter() + .map(|pk| serde_json::Value::String(pk.clone())) + .collect(); + (name.clone(), serde_json::Value::Array(arr)) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&map).unwrap()); + return; + } + + if murk.groups.is_empty() { + eprintln!( + "{}", + "no groups — create one with `murk group create`".dimmed() + ); + return; + } + + for (name, members) in &murk.groups { + eprintln!("{} {}", "◆".magenta(), name.bold()); + for pk in members { + let label = murk + .recipients + .get(pk) + .filter(|n| !n.is_empty()) + .cloned() + .unwrap_or_else(|| murk_cli::truncate_pubkey(pk)); + let marker = if Some(pk) == self_pubkey.as_ref() { + "*" + } else { + " " + }; + eprintln!(" {marker} {}", label.green().bold()); + } + } + } + + GroupCommand::Add { + name, + member, + vault, + } => { + let vault_path = murk_cli::resolve_vault_path(&vault); + let (mut vault, murk, identity, _lock) = load_vault_locked(&vault_path); + let original = murk.clone(); + let mut current = murk; + let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + + let member_pk = try_or_die(murk_cli::resolve_member(&vault, ¤t, &member)); + let added = try_or_die(murk_cli::add_member( + &mut current, + &name, + &member_pk, + &pubkey, + )); + if !added { + eprintln!( + "{} {} is already in group {}", + "◆".magenta(), + member.bold(), + name.bold() + ); + return; + } + save_vault(&vault_path, &mut vault, &original, ¤t); + eprintln!( + "{} added {} to group {}", + "◆".magenta(), + member.bold(), + name.bold() + ); + } + + GroupCommand::Rm { + name, + member, + vault, + } => { + let vault_path = murk_cli::resolve_vault_path(&vault); + let (mut vault, murk, identity, _lock) = load_vault_locked(&vault_path); + let original = murk.clone(); + let mut current = murk; + let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + + match member { + Some(member) => { + let member_pk = try_or_die(murk_cli::resolve_member(&vault, ¤t, &member)); + let removed = try_or_die(murk_cli::remove_member( + &mut current, + &name, + &member_pk, + &pubkey, + )); + if !removed { + eprintln!( + "{} {} is not in group {}", + "◆".magenta(), + member.bold(), + name.bold() + ); + return; + } + save_vault(&vault_path, &mut vault, &original, ¤t); + eprintln!( + "{} removed {} from group {}", + "◆".magenta(), + member.bold(), + name.bold() + ); + } + None => { + try_or_die(murk_cli::delete_group(&vault, &mut current, &name)); + save_vault(&vault_path, &mut vault, &original, ¤t); + eprintln!("{} deleted group {}", "◆".magenta(), name.bold()); + } + } + } + } +} + /// Rotate the given keys in the still-locked session after a revoke, prompting /// for each new value. `baseline` is the post-revoke state already on disk; we /// diff against it so only the rotated ciphertexts are re-encrypted. @@ -2809,25 +3237,45 @@ fn main() { Command::Init { vault } => cmd_init(&vault), Command::Recover => cmd_recover(), Command::Restore => cmd_restore(), - Command::Import { file, force, vault } => { - cmd_import(&file, force, &murk_cli::resolve_vault_path(&vault)); + Command::Import { + file, + force, + group, + vault, + } => { + cmd_import( + &file, + force, + group.as_deref(), + &murk_cli::resolve_vault_path(&vault), + ); } Command::Add { key, desc, + group, scoped, tag, vault, } => { let vault = murk_cli::resolve_vault_path(&vault); let resolved = resolve_value(&key); - cmd_add(&key, &resolved, desc.as_deref(), scoped, &tag, &vault); + cmd_add( + &key, + &resolved, + desc.as_deref(), + group.as_deref(), + scoped, + &tag, + &vault, + ); } Command::Generate { key, length, hex, desc, + group, tag, vault, } => cmd_generate( @@ -2835,6 +3283,7 @@ fn main() { length, hex, desc.as_deref(), + group.as_deref(), &tag, &murk_cli::resolve_vault_path(&vault), ), @@ -2881,10 +3330,16 @@ fn main() { Command::Export { tag, json, vault } => { cmd_export(&tag, json, &murk_cli::resolve_vault_path(&vault)); } - Command::Edit { key, scoped, vault } => { + Command::Edit { + key, + scoped, + group, + vault, + } => { cmd_edit( key.as_deref(), scoped, + group.as_deref(), &murk_cli::resolve_vault_path(&vault), ); } @@ -2910,6 +3365,7 @@ fn main() { } => cmd_authorize( &pubkey, name.as_deref(), + None, force, allow_ssh_rsa, &murk_cli::resolve_vault_path(&vault), @@ -2931,6 +3387,7 @@ fn main() { Some(CircleCommand::Authorize { pubkey, name, + group, force, allow_ssh_rsa, vault, @@ -2939,6 +3396,7 @@ fn main() { } => cmd_authorize( &pubkey, name.as_deref(), + group.as_deref(), force, allow_ssh_rsa, &murk_cli::resolve_vault_path(&vault), @@ -2952,6 +3410,7 @@ fn main() { }), .. } => cmd_revoke(&recipient, rotate, &murk_cli::resolve_vault_path(&vault)), + Command::Group { sub } => cmd_group(sub), Command::Env { vault } => cmd_env(&vault), Command::Diff { git_ref, diff --git a/src/merge.rs b/src/merge.rs index bd5cc6e..58e247a 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -359,8 +359,19 @@ fn merge_secrets_normal( _ => o.shared.clone(), }; - let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts); - result.insert(key.to_string(), SecretEntry { shared, scoped }); + let scoped = + merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, "scoped", conflicts); + let grouped = merge_scoped( + &b.grouped, &o.grouped, &t.grouped, key, "grouped", conflicts, + ); + result.insert( + key.to_string(), + SecretEntry { + shared, + scoped, + grouped, + }, + ); } } } @@ -369,11 +380,15 @@ fn merge_secrets_normal( } /// Merge scoped (mote) entries within a single secret key. +/// Three-way merge of a per-name ciphertext map. Used for both `scoped` +/// (keyed by pubkey) and `grouped` (keyed by group name) — `kind` is the field +/// name used in conflict messages. fn merge_scoped( base: &BTreeMap, ours: &BTreeMap, theirs: &BTreeMap, secret_key: &str, + kind: &str, conflicts: &mut Vec, ) -> BTreeMap { let all_pks: BTreeSet<&str> = base @@ -402,8 +417,8 @@ fn merge_scoped( result.insert(pk.to_string(), o.clone()); } else { conflicts.push(MergeConflict { - field: format!("secrets.{secret_key}.scoped.{pk}"), - reason: "scoped override added on both sides".into(), + field: format!("secrets.{secret_key}.{kind}.{pk}"), + reason: "{kind} entry added on both sides".into(), }); result.insert(pk.to_string(), o.clone()); } @@ -412,8 +427,8 @@ fn merge_scoped( (Some(b), Some(o), None) => { if o != b { conflicts.push(MergeConflict { - field: format!("secrets.{secret_key}.scoped.{pk}"), - reason: "scoped override modified on our side but removed on theirs".into(), + field: format!("secrets.{secret_key}.{kind}.{pk}"), + reason: "{kind} entry modified on our side but removed on theirs".into(), }); result.insert(pk.to_string(), o.clone()); } @@ -421,8 +436,8 @@ fn merge_scoped( (Some(b), None, Some(t)) => { if t != b { conflicts.push(MergeConflict { - field: format!("secrets.{secret_key}.scoped.{pk}"), - reason: "scoped override removed on our side but modified on theirs".into(), + field: format!("secrets.{secret_key}.{kind}.{pk}"), + reason: "{kind} entry removed on our side but modified on theirs".into(), }); result.insert(pk.to_string(), t.clone()); } @@ -437,8 +452,8 @@ fn merge_scoped( } (true, true) if o != t => { conflicts.push(MergeConflict { - field: format!("secrets.{secret_key}.scoped.{pk}"), - reason: "scoped override modified on both sides".into(), + field: format!("secrets.{secret_key}.{kind}.{pk}"), + reason: "{kind} entry modified on both sides".into(), }); result.insert(pk.to_string(), o.clone()); } @@ -607,6 +622,7 @@ pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Opti mac: String::new(), mac_key: None, github_pins: HashMap::new(), + groups: BTreeMap::new(), }; let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta); @@ -621,9 +637,20 @@ pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Opti // Only keep names for recipients still in the merged vault. names.retain(|pk, _| merged.recipients.contains(pk)); + // Merge group membership: union, ours wins on conflict. Drop members no + // longer in the merged recipient set, and drop now-empty groups. + let mut groups = theirs_meta.groups; + for (name, members) in ours_meta.groups { + groups.insert(name, members); + } + for members in groups.values_mut() { + members.retain(|pk| merged.recipients.contains(pk)); + } + groups.retain(|_, members| !members.is_empty()); + let mac_key_hex = crate::generate_mac_key(); let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap(); - let mac = compute_mac(merged, Some(&mac_key)); + let mac = compute_mac(merged, &groups, Some(&mac_key)); // Merge github pins: union, ours wins on conflict. let mut github_pins = theirs_meta.github_pins; for (user, pins) in ours_meta.github_pins { @@ -635,6 +662,7 @@ pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Opti mac, mac_key: Some(mac_key_hex), github_pins, + groups, }; let recipients = parse_recipients(&merged.recipients).ok()?; @@ -673,6 +701,7 @@ mod tests { SecretEntry { shared: "base-cipher-db".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -710,6 +739,7 @@ mod tests { SecretEntry { shared: "ours-cipher-api".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); ours.schema.insert( @@ -740,6 +770,7 @@ mod tests { SecretEntry { shared: "theirs-cipher-stripe".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -759,6 +790,7 @@ mod tests { SecretEntry { shared: "ours-cipher-api".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -768,6 +800,7 @@ mod tests { SecretEntry { shared: "theirs-cipher-stripe".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -846,6 +879,7 @@ mod tests { SecretEntry { shared: "ours-cipher".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); let mut theirs = base.clone(); @@ -854,6 +888,7 @@ mod tests { SecretEntry { shared: "theirs-cipher".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -1103,6 +1138,7 @@ mod tests { SecretEntry { shared: "theirs-new".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); diff --git a/src/python.rs b/src/python.rs index edc1966..08eee10 100644 --- a/src/python.rs +++ b/src/python.rs @@ -26,21 +26,13 @@ struct Vault { #[pymethods] impl Vault { - /// Get a single decrypted secret value. - /// Returns the scoped override if one exists, otherwise the shared value. + /// Get a single decrypted secret value. Resolution order: a personal scoped + /// override, then a named-group value we can read, then the shared value. /// /// The returned `String` is a plain Python-owned copy — once it crosses /// the FFI boundary the plaintext is outside murk's zeroization. fn get(&self, key: &str) -> Option { - if let Some(value) = self - .decrypted - .scoped - .get(key) - .and_then(|m| m.get(&self.pubkey)) - { - return Some(value.to_string()); - } - self.decrypted.values.get(key).map(|v| v.to_string()) + crate::get_secret(&self.decrypted, key, &self.pubkey).map(str::to_string) } /// Export all secrets as a dict. Scoped values override shared values. diff --git a/src/recipients.rs b/src/recipients.rs index 548c6ec..5407dcb 100644 --- a/src/recipients.rs +++ b/src/recipients.rs @@ -131,6 +131,14 @@ pub fn revoke_recipient( )); } + // Groups any revoked pubkey belonged to (captured before removal below). + let revoked_groups: std::collections::BTreeSet = murk + .groups + .iter() + .filter(|(_, members)| members.iter().any(|pk| pubkeys.contains(pk))) + .map(|(name, _)| name.clone()) + .collect(); + let mut display_name = None; for pubkey in &pubkeys { vault.recipients.retain(|pk| pk != pubkey); @@ -146,15 +154,24 @@ pub fn revoke_recipient( for entry in vault.secrets.values_mut() { entry.scoped.remove(pubkey); } + + // Remove them from any groups they belonged to. save_vault re-encrypts + // affected groups (when the operator is a member and can read them). + for members in murk.groups.values_mut() { + members.retain(|pk| pk != pubkey); + } } - // Only report keys the revoked recipient could actually decrypt: - // shared secrets (all recipients can read) + their scoped entries. + // Only report keys the revoked recipient could actually decrypt: shared + // secrets (all recipients can read), their scoped entries, and any group + // they were a member of. let exposed_keys: Vec = vault .secrets .iter() .filter(|(_, entry)| { - !entry.shared.is_empty() || pubkeys.iter().any(|pk| entry.scoped.contains_key(pk)) + !entry.shared.is_empty() + || pubkeys.iter().any(|pk| entry.scoped.contains_key(pk)) + || entry.grouped.keys().any(|g| revoked_groups.contains(g)) }) .map(|(key, _)| key.clone()) .collect(); @@ -345,6 +362,7 @@ mod tests { types::SecretEntry { shared: "ciphertext".into(), scoped: std::collections::BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); let mut murk = empty_murk(); @@ -416,6 +434,7 @@ mod tests { types::SecretEntry { shared: "ct".into(), scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]), + grouped: std::collections::BTreeMap::default(), }, ); let mut murk = empty_murk(); @@ -459,6 +478,7 @@ mod tests { types::SecretEntry { shared: "ct".into(), scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]), + grouped: std::collections::BTreeMap::default(), }, ); vault.secrets.insert( @@ -466,6 +486,7 @@ mod tests { types::SecretEntry { shared: "ct2".into(), scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]), + grouped: std::collections::BTreeMap::default(), }, ); let mut murk = empty_murk(); @@ -500,6 +521,7 @@ mod tests { mac: String::new(), mac_key: None, github_pins: HashMap::new(), + ..Default::default() }; let meta_json = serde_json::to_vec(&meta).unwrap(); let r2 = make_recipient(&pk2); @@ -543,6 +565,7 @@ mod tests { mac: String::new(), mac_key: None, github_pins: HashMap::new(), + ..Default::default() }; let meta_json = serde_json::to_vec(&meta).unwrap(); let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap(); diff --git a/src/secrets.rs b/src/secrets.rs index 9648267..ba8d726 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -18,16 +18,66 @@ pub fn add_secret( identity: &crypto::MurkIdentity, ) -> bool { if scoped { + // `me` is a per-identity override layered on top of the base tier — it + // does not change which group owns the key, so shared/grouped are left + // untouched. let pubkey = identity.pubkey_string().expect("valid identity has pubkey"); murk.scoped .entry(key.into()) .or_default() .insert(pubkey, Zeroizing::new(value.to_owned())); } else { + // Setting the shared (everyone) value makes `everyone` the base tier, so + // any named-group assignment is dropped — otherwise the stale grouped + // ciphertext would still win over the new shared value for members. + murk.grouped.remove(key); murk.values .insert(key.into(), Zeroizing::new(value.to_owned())); } + upsert_schema(vault, key, desc, tags) +} + +/// Add or update a secret encrypted to a named group. The operator must be a +/// member of the group (so they can read it and re-encrypt it later). Assigning +/// a secret to a group makes the group its sole base tier: any existing shared +/// value and other group assignments are dropped so non-members can't read it. +/// Returns true if the key was new (no existing schema entry). +pub fn add_grouped_secret( + vault: &mut types::Vault, + murk: &mut types::Murk, + key: &str, + value: &str, + desc: Option<&str>, + group: &str, + tags: &[String], + operator_pubkey: &str, +) -> Result { + use crate::error::MurkError; + + let members = murk + .groups + .get(group) + .ok_or_else(|| MurkError::Group(format!("group not found: {group}")))?; + if !members.iter().any(|pk| pk == operator_pubkey) { + return Err(MurkError::Group(format!( + "you must be a member of group \"{group}\" to add secrets to it" + ))); + } + + // The group becomes the sole base tier for this key. + murk.values.remove(key); + let entry = murk.grouped.entry(key.into()).or_default(); + entry.clear(); + entry.insert(group.into(), Zeroizing::new(value.to_owned())); + + Ok(upsert_schema(vault, key, desc, tags)) +} + +/// Insert or update the schema entry for a key, bumping `updated`. Returns true +/// if the key was new and no description was supplied (the caller uses this to +/// decide whether to print a "describe this key" hint). +fn upsert_schema(vault: &mut types::Vault, key: &str, desc: Option<&str>, tags: &[String]) -> bool { let is_new = !vault.schema.contains_key(key); let now = now_utc(); @@ -64,14 +114,20 @@ pub fn add_secret( pub fn remove_secret(vault: &mut types::Vault, murk: &mut types::Murk, key: &str) { murk.values.remove(key); murk.scoped.remove(key); + murk.grouped.remove(key); vault.schema.remove(key); } -/// Look up a decrypted value. Scoped overrides take priority over shared values. +/// Look up a decrypted value. Resolution order, highest priority first: +/// a personal scoped override, then a named-group value we can read, then the +/// shared (everyone) value. pub fn get_secret<'a>(murk: &'a types::Murk, key: &str, pubkey: &str) -> Option<&'a str> { if let Some(value) = murk.scoped.get(key).and_then(|m| m.get(pubkey)) { return Some(value.as_str()); } + if let Some(value) = murk.grouped.get(key).and_then(|m| m.values().next()) { + return Some(value.as_str()); + } murk.values.get(key).map(|v| v.as_str()) } @@ -100,6 +156,8 @@ pub fn import_secrets( let now = now_utc(); let mut imported = Vec::new(); for (key, value) in pairs { + // Shared (everyone) base tier — drop any prior group assignment. + murk.grouped.remove(key); murk.values.insert(key.clone(), value.clone()); if let Some(entry) = vault.schema.get_mut(key.as_str()) { diff --git a/src/testutil.rs b/src/testutil.rs index da98944..fa67647 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -50,6 +50,7 @@ pub fn empty_murk() -> types::Murk { scoped: HashMap::new(), legacy_mac: false, github_pins: HashMap::new(), + ..Default::default() } } diff --git a/src/types.rs b/src/types.rs index a7ebbde..eec934f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -55,19 +55,28 @@ pub struct SchemaEntry { pub expires_at: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SecretEntry { - /// Shared value encrypted to all recipients. + /// Shared value encrypted to all recipients (the implicit `everyone` group). + /// Empty when the secret's base group is a named group instead. pub shared: String, /// Scoped overrides: pubkey → encrypted value (encrypted to that pubkey only). + /// This is the `me` tier — a singleton group of one recipient. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub scoped: BTreeMap, + /// Named-group values: group name → encrypted value (encrypted to that + /// group's current members). A secret has at most one base group, so this + /// map holds at most one entry, but it is keyed by name so the integrity MAC + /// and merge driver can treat it uniformly with `scoped`. Group *names* are + /// plaintext (like key names); group *membership* lives in the encrypted meta. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub grouped: BTreeMap, } // -- Meta (encrypted, stored in vault.meta) -- // Contains metadata only visible to recipients. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Meta { /// Maps pubkey → display name. The only place names are stored. pub recipients: HashMap, @@ -80,13 +89,19 @@ pub struct Meta { /// Used for TOFU (Trust On First Use) verification on `authorize github:user`. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub github_pins: HashMap>, + /// Named recipient groups: group name → member pubkeys. Stored here (not in + /// the plaintext header) so org structure — who is in which group — does not + /// leak. Members are a subset of `Vault::recipients`. Covered by the keyed + /// MAC (`blake3v4:`) so membership cannot be tampered with undetected. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub groups: BTreeMap>, } // -- Murk (decrypted in-memory state) -- // The working representation after decryption. Commands read/modify this, // then save_vault compares against the original to minimize re-encryption. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct Murk { /// Decrypted shared values. Wrapped in `Zeroizing` so plaintext is cleared /// from memory when the `Murk` is dropped. @@ -96,6 +111,11 @@ pub struct Murk { /// Scoped overrides: key → { pubkey → decrypted value }. /// Only contains entries decryptable by the current identity. pub scoped: HashMap>>, + /// Named-group values: key → { group name → decrypted value }. + /// Only contains groups the current identity is a member of (and can decrypt). + pub grouped: HashMap>>, + /// Group membership: group name → member pubkeys (carried from meta). + pub groups: BTreeMap>, /// True if the vault uses a legacy unkeyed MAC (sha256/sha256v2). pub legacy_mac: bool, /// Pinned GitHub key fingerprints (carried from meta). diff --git a/src/vault.rs b/src/vault.rs index 40a7faf..09d782b 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -223,6 +223,7 @@ mod tests { SecretEntry { shared: "encrypted-value".into(), scoped: BTreeMap::new(), + grouped: std::collections::BTreeMap::default(), }, ); @@ -374,6 +375,7 @@ mod tests { SecretEntry { shared: "encrypted-value".into(), scoped, + grouped: std::collections::BTreeMap::default(), }, ); diff --git a/tests/cli.rs b/tests/cli.rs index cce4e33..db1e845 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -3558,3 +3558,279 @@ fn scan_reports_multiple_leaks() { .stderr(predicate::str::contains("notes.txt")) .stderr(predicate::str::contains("2 leaked secrets found")); } + +// ── groups ── + +#[test] +fn group_secret_readable_only_by_members() { + let dir = TempDir::new().unwrap(); + let (alice_key, _alice_pk) = init_vault(&dir); + + // Second recipient (bob) becomes a group member; third (carol) does not. + let bob_dir = TempDir::new().unwrap(); + let (bob_key, bob_pk) = init_vault(&bob_dir); + let carol_dir = TempDir::new().unwrap(); + let (carol_key, carol_pk) = init_vault(&carol_dir); + + // Authorize carol as a plain recipient (not in any group). + murk(&dir, &alice_key) + .args([ + "circle", + "authorize", + &carol_pk, + "--name", + "carol", + "--vault", + "test.murk", + ]) + .assert() + .success(); + + // Create the prod group, then authorize bob straight into it. + murk(&dir, &alice_key) + .args(["group", "create", "prod", "--vault", "test.murk"]) + .assert() + .success(); + murk(&dir, &alice_key) + .args([ + "circle", + "authorize", + &bob_pk, + "--name", + "bob", + "--group", + "prod", + "--vault", + "test.murk", + ]) + .assert() + .success(); + + // Add a secret encrypted to the prod group only. + murk(&dir, &alice_key) + .args([ + "add", + "STRIPE_KEY", + "--group", + "prod", + "--vault", + "test.murk", + ]) + .write_stdin("sk_live_123\n") + .assert() + .success(); + + // group ls shows prod with both members. + murk(&dir, &alice_key) + .args(["group", "ls", "--vault", "test.murk"]) + .assert() + .success() + .stderr(predicate::str::contains("prod").and(predicate::str::contains("bob"))); + + // Bob (a member) can read it. + murk(&dir, &bob_key) + .args(["get", "STRIPE_KEY", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("sk_live_123")); + + // Carol (a recipient but not a prod member) cannot. + murk(&dir, &carol_key) + .args(["get", "STRIPE_KEY", "--vault", "test.murk"]) + .assert() + .failure(); +} + +#[test] +fn group_rm_member_revokes_access_after_reencrypt() { + let dir = TempDir::new().unwrap(); + let (alice_key, _alice_pk) = init_vault(&dir); + + let bob_dir = TempDir::new().unwrap(); + let (bob_key, bob_pk) = init_vault(&bob_dir); + + murk(&dir, &alice_key) + .args(["group", "create", "prod", "--vault", "test.murk"]) + .assert() + .success(); + murk(&dir, &alice_key) + .args([ + "circle", + "authorize", + &bob_pk, + "--name", + "bob", + "--group", + "prod", + "--vault", + "test.murk", + ]) + .assert() + .success(); + murk(&dir, &alice_key) + .args(["add", "PROD_DB", "--group", "prod", "--vault", "test.murk"]) + .write_stdin("prod_secret\n") + .assert() + .success(); + + // Bob can read before removal. + murk(&dir, &bob_key) + .args(["get", "PROD_DB", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("prod_secret")); + + // Remove bob from the group (alice is a member, so she re-encrypts). + murk(&dir, &alice_key) + .args([ + "group", + "rm", + "prod", + "--member", + "bob", + "--vault", + "test.murk", + ]) + .assert() + .success(); + + // Bob can no longer read the current ciphertext. + murk(&dir, &bob_key) + .args(["get", "PROD_DB", "--vault", "test.murk"]) + .assert() + .failure(); +} + +#[test] +fn group_create_rejects_reserved_name() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["group", "create", "me", "--vault", "test.murk"]) + .assert() + .failure() + .stderr(predicate::str::contains("reserved")); +} + +#[test] +fn non_member_save_preserves_group_secret() { + // Regression: a recipient who is NOT in a group must not drop that group's + // secrets when they save an unrelated change. The group ciphertext never + // enters their decrypted view, so save_vault must carry it through. + let dir = TempDir::new().unwrap(); + let (alice_key, _alice_pk) = init_vault(&dir); + + let bob_dir = TempDir::new().unwrap(); + let (bob_key, bob_pk) = init_vault(&bob_dir); + + // Alice creates prod and adds bob; both are members. + murk(&dir, &alice_key) + .args(["group", "create", "prod", "--vault", "test.murk"]) + .assert() + .success(); + murk(&dir, &alice_key) + .args([ + "circle", + "authorize", + &bob_pk, + "--name", + "bob", + "--group", + "prod", + "--vault", + "test.murk", + ]) + .assert() + .success(); + murk(&dir, &alice_key) + .args(["add", "PROD_DB", "--group", "prod", "--vault", "test.murk"]) + .write_stdin("prod_secret\n") + .assert() + .success(); + + // Remove alice from prod — now bob is the sole member; alice is a recipient + // but cannot read PROD_DB. + murk(&dir, &alice_key) + .args([ + "group", + "rm", + "prod", + "--member", + "testuser", + "--vault", + "test.murk", + ]) + .assert() + .success(); + murk(&dir, &alice_key) + .args(["get", "PROD_DB", "--vault", "test.murk"]) + .assert() + .failure(); + + // Alice (non-member) saves an unrelated change. + murk(&dir, &alice_key) + .args(["add", "UNRELATED", "--vault", "test.murk"]) + .write_stdin("x\n") + .assert() + .success(); + + // Bob can still read PROD_DB — it was not dropped. + murk(&dir, &bob_key) + .args(["get", "PROD_DB", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("prod_secret")); +} + +#[test] +fn add_shared_clears_group_assignment() { + // Regression: assigning a key to everyone must drop its prior group entry, + // so the new shared value isn't shadowed by stale grouped ciphertext. + let dir = TempDir::new().unwrap(); + let (alice_key, _alice_pk) = init_vault(&dir); + + let carol_dir = TempDir::new().unwrap(); + let (carol_key, carol_pk) = init_vault(&carol_dir); + murk(&dir, &alice_key) + .args([ + "circle", + "authorize", + &carol_pk, + "--name", + "carol", + "--vault", + "test.murk", + ]) + .assert() + .success(); + + murk(&dir, &alice_key) + .args(["group", "create", "prod", "--vault", "test.murk"]) + .assert() + .success(); + murk(&dir, &alice_key) + .args(["add", "TOKEN", "--group", "prod", "--vault", "test.murk"]) + .write_stdin("group_val\n") + .assert() + .success(); + // carol (not in prod) can't read it yet. + murk(&dir, &carol_key) + .args(["get", "TOKEN", "--vault", "test.murk"]) + .assert() + .failure(); + + // Reassign to everyone. + murk(&dir, &alice_key) + .args(["add", "TOKEN", "--vault", "test.murk"]) + .write_stdin("shared_val\n") + .assert() + .success(); + + // carol now reads the shared value (not the stale group ciphertext). + murk(&dir, &carol_key) + .args(["get", "TOKEN", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("shared_val")); +}