Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ Entries also carry optional lifecycle metadata:
- `created` / `updated` — ISO-8601 UTC timestamps. `updated` is bumped on every value change (`add`, `edit`, `rotate`) and so doubles as the "last rotated" anchor.
- `rotation_interval_days` — soft rotation policy. `doctor` flags the key as overdue when `updated + rotation_interval_days` is in the past.
- `expires_at` — ISO-8601 UTC hard expiry for credentials with a known end-of-life (e.g. a token). `doctor` flags it as expired or expiring soon.
- `revoked_at` — ISO-8601 UTC marker set when a recipient who could read this key is revoked and rotation is deferred. Its *presence* is the obligation: the revoked recipient can still decrypt the live value from git history until it changes. `doctor` flags it until then; any value write clears it. Omitted when there is no pending rotation.

Set the last two with `murk describe KEY "desc" --rotate-every 90d --expires 2026-09-01` (`never` clears either). All four fields are covered by the integrity MAC (see Integrity), so rotation policy cannot be silently weakened without a key.
Set the rotation/expiry fields with `murk describe KEY "desc" --rotate-every 90d --expires 2026-09-01` (`never` clears either); `revoked_at` is managed automatically by `circle revoke`. All lifecycle fields are covered by the integrity MAC (see Integrity), so rotation policy and pending-rotation flags cannot be silently weakened without a key.

Key names must be valid shell identifiers: `[A-Za-z_][A-Za-z0-9_]*`.

Expand Down Expand Up @@ -210,11 +211,12 @@ The MAC is a BLAKE3 keyed hash covering, in order:
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 and v7) — for each group (sorted by name): `\x04`, the group name followed by `\x00`, then each member pubkey (sorted) prefixed by `\x05`
6. **Grant definitions** (v7 and v8) — for each grant (sorted by name): `\x06`, then the grant name, agent pubkey, `issued_at`, `expires_at`, and issuer each followed by `\x00`, then each scope key (sorted) prefixed by `\x07`
7. **Policy** (v8 only) — `\x08` opens the policy block, then each agent allow-tag (sorted) is length-prefixed (4-byte big-endian length + bytes) so tag contents can't forge a boundary
7. **Policy** (v8 and v9) — `\x08` opens the policy block, then each agent allow-tag (sorted) is length-prefixed (4-byte big-endian length + bytes) so tag contents can't forge a boundary
8. **Revoked-at markers** (v9 only) — for each schema entry with `revoked_at` set (sorted by key name): `\x09`, the key name followed by `\x00`, the marker timestamp followed by `\x00`. Absent markers emit nothing, so setting then clearing one hashes identically to never setting it.

The resulting digest is prefixed with `blake3v6:` (v8) when the vault has a policy, `blake3v5:` (v7) when it has a grant but no policy, `blake3v4:` (v6) when it has a group but neither, 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.
The resulting digest is prefixed with `blake3v7:` (v9) when any key has a `revoked_at` marker, `blake3v6:` (v8) when the vault has a policy, `blake3v5:` (v7) when it has a grant, `blake3v4:` (v6) when it has a 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), `blake3v2:` (v4, no lifecycle-metadata coverage), `blake3v3:` (v5, no group coverage), `blake3v4:` (v6, no grant coverage), and `blake3v5:` (v7, no policy coverage) are accepted for backward compatibility. On save, murk writes the lowest version that covers the vault's contents (`blake3v6:` if a policy exists, else `blake3v5:` for grants, `blake3v4:` for groups, otherwise `blake3v3:`), always with a fresh key. Gating each version bump on the first group/grant/policy keeps simpler vaults byte-identical to older murk. A vault carrying groups, grants, or a policy is rejected under an older prefix that doesn't cover them, so an attacker can't strip coverage by downgrading the MAC. (A vault written by a newer murk cannot be MAC-verified by an older binary that predates the prefix it uses.)
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), `blake3v3:` (v5, no group coverage), `blake3v4:` (v6, no grant coverage), `blake3v5:` (v7, no policy coverage), and `blake3v6:` (v8, no revoked-at coverage) are accepted for backward compatibility. On save, murk writes the lowest version that covers the vault's contents (`blake3v7:` if any `revoked_at` exists, else `blake3v6:` for a policy, `blake3v5:` for grants, `blake3v4:` for groups, otherwise `blake3v3:`), always with a fresh key. Gating each version bump on the first group/grant/policy/marker keeps simpler vaults byte-identical to older murk. A vault carrying groups, grants, a policy, or a revoked-at marker is rejected under an older prefix that doesn't cover them, so an attacker can't strip coverage (e.g. clear a pending-rotation flag) by downgrading the MAC. (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.

Expand Down
2 changes: 2 additions & 0 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ murk is pre-1.0 and has not been independently audited. See [SECURITY.md](SECURI

**Agent access policy.** `murk policy set --allow-tag TAG` records a tag allow-list in the plaintext header (MAC-covered, so it can't be stripped or weakened without a key). In agent mode (`agent exec`, `agent grant`) a secret may be injected or granted only if it carries an allowed tag; anything else is refused, with no override flag. This is explicitly NOT access control — it is a machine-enforceable guardrail. Its scope and limits: it constrains the *murk binary*, so it does nothing against a human insider using age directly or an older murk that predates the policy MAC version; it gates what reaches agents, not what recipients can decrypt; and it travels with the repo so the same constraint applies in CI. The value is keeping production secrets out of agent reach by accident or by a misbehaving agent that asks for them. Merging two branches that changed the policy differently raises a conflict rather than silently picking one side.

**Post-revoke rotation tracking.** Revoking a recipient re-encrypts going forward, but old `.murk` versions in git stay readable with the revoked key — so the exposed values must be rotated to actually cut access. When rotation is deferred at revoke time, murk stamps each exposed key with a `revoked_at` marker in the plaintext header; `doctor` flags it until a value write clears it, so the obligation survives the prompt being declined and is visible without a key. The marker is covered by the keyed MAC (`blake3v7:`), so an attacker editing `.murk` can't silently clear the flag without a key. This is a hygiene reminder, not enforcement: it does not and cannot rotate the underlying credential at its provider, and the revoked key keeps working against git history until you do.

**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.

**Admin-change accountability (agents and otherwise).** Every administrative change is a commit to the `.murk` file, so **git history is the admin audit trail**: creating or revoking an agent grant, setting or clearing a policy, authorizing or revoking a recipient, and rotating a value after an agent session all show up in `git log -p .murk` / `murk diff`, attributed to the commit author (and cryptographically signed if you use git commit signing). murk deliberately does **not** keep a second event log inside the vault: it would duplicate git, can drift from it, and — because the integrity MAC uses a key every recipient shares — any keyholder could forge entries, so it would be weaker than git on attribution, not stronger. What cannot be audited at all: secret *reads* on a developer's machine (murk can't see them), and any action taken with age directly or an old murk binary. Provable per-actor attribution beyond git's commit identity would require signed events, which needs a signing key murk's age identities don't have — deferred until a concrete requirement exists.
Expand Down
165 changes: 143 additions & 22 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pub use recipients::{
};
pub use secrets::{
EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
import_secrets, list_keys, remove_secret, rotation_health,
import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
};

use std::collections::{BTreeMap, BTreeSet, HashMap};
Expand Down Expand Up @@ -682,6 +682,9 @@ pub(crate) fn compute_mac(
mac_key: Option<&[u8; 32]>,
) -> String {
match mac_key {
Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
compute_mac_v9(vault, groups, grants, key)
}
Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
Expand Down Expand Up @@ -1097,18 +1100,16 @@ fn compute_mac_v7(
format!("blake3v5:{hash}")
}

/// v8 MAC (`blake3v6:`). Extends v7 with the plaintext header policy object, so a
/// vault's agent access policy cannot be weakened or stripped undetected. Only
/// emitted once a vault has a policy; policy-free vaults keep writing v5/v6/v7
/// and stay byte-identical.
fn compute_mac_v8(
/// Append the v8 byte stream (v7 bytes plus the header policy block) to `data`.
/// Factored out so v9 can extend the exact same bytes without risking a change
/// to v8's encoding.
fn v8_mac_bytes(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v7_mac_bytes(vault, groups, grants, &mut data);
data: &mut Vec<u8>,
) {
v7_mac_bytes(vault, groups, grants, data);

// Policy (header). `0x08` opens the policy block (present only when a policy
// exists, so Some-but-empty is distinct from None). Each agent allow-tag is
Expand All @@ -1128,13 +1129,58 @@ fn compute_mac_v8(
data.extend_from_slice(bytes);
}
}
}

/// v8 MAC (`blake3v6:`). Extends v7 with the plaintext header policy object, so a
/// vault's agent access policy cannot be weakened or stripped undetected. Only
/// emitted once a vault has a policy; policy-free vaults keep writing v5/v6/v7
/// and stay byte-identical.
fn compute_mac_v8(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v8_mac_bytes(vault, groups, grants, &mut data);
let hash = blake3::keyed_hash(key, &data);
format!("blake3v6:{hash}")
}

/// v9 MAC (`blake3v7:`). Extends v8 with each schema entry's `revoked_at` marker,
/// so the "still owed a rotation since a revoke" flag is tamper-evident — an
/// attacker editing `.murk` can't silently clear it. Only emitted once a vault
/// has at least one `revoked_at` set; vaults without one keep writing v5–v8 and
/// stay byte-identical.
fn compute_mac_v9(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v8_mac_bytes(vault, groups, grants, &mut data);

// Revoked-at markers, in schema order (BTreeMap → sorted by key name). `0x09`
// opens each marker so the stream can't be confused with the schema (`0x02`)
// or policy (`0x08`) blocks; absent markers emit nothing, so a vault that
// sets one then clears it hashes identically to one that never set it.
for (key_name, entry) in &vault.schema {
if let Some(revoked_at) = &entry.revoked_at {
data.push(0x09);
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
data.extend_from_slice(revoked_at.as_bytes());
data.push(0x00);
}
}

let hash = blake3::keyed_hash(key, &data);
format!("blake3v7:{hash}")
}

/// Verify a stored MAC against the vault, accepting v1, v2, blake3, blake3v2,
/// blake3v3, blake3v4, blake3v5, and blake3v6 schemes.
/// blake3v3, blake3v4, blake3v5, blake3v6, and blake3v7 schemes.
pub(crate) fn verify_mac(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
Expand All @@ -1144,38 +1190,56 @@ pub(crate) fn verify_mac(
) -> bool {
use constant_time_eq::constant_time_eq;

// Policy is only covered by v8 (`blake3v6:`). A vault carrying a policy but
// `revoked_at` is only covered by v9 (`blake3v7:`). A vault carrying one but
// stamped with an older MAC is tampered or inconsistent — reject it so an
// attacker can't strip or weaken the policy by downgrading the MAC.
if vault.policy.is_some() && !stored_mac.starts_with("blake3v6:") {
// attacker can't clear a pending-rotation flag by downgrading the MAC.
if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
{
return false;
}

// Grant metadata is covered by v7 (`blake3v5:`) and v8 (`blake3v6:`). A vault
// carrying grants but stamped with an older MAC is tampered or inconsistent.
// Policy is covered by v8 (`blake3v6:`) and v9 (`blake3v7:`). A vault carrying
// a policy but stamped with an older MAC is tampered or inconsistent — reject
// it so an attacker can't strip or weaken the policy by downgrading the MAC.
if vault.policy.is_some()
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}

// Grant metadata is covered by v7 (`blake3v5:`) and up. A vault carrying
// grants but stamped with an older MAC is tampered or inconsistent.
if !grants.is_empty()
&& !stored_mac.starts_with("blake3v5:")
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}

// Group data is covered by v6, v7, and v8. 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.
// Group data is covered by v6 and up. 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:")
&& !stored_mac.starts_with("blake3v5:")
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}

let expected = if stored_mac.starts_with("blake3v6:") {
let expected = if stored_mac.starts_with("blake3v7:") {
match mac_key {
Some(key) => compute_mac_v9(vault, groups, grants, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v6:") {
match mac_key {
Some(key) => compute_mac_v8(vault, groups, grants, key),
None => return false,
Expand Down Expand Up @@ -2689,6 +2753,63 @@ mod tests {
assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
}

#[test]
fn compute_mac_v9_covers_revoked_at() {
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(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.schema.insert(
"API_KEY".into(),
types::SchemaEntry {
description: "Main API key".into(),
updated: Some("2026-02-28T00:00:00Z".into()),
..Default::default()
},
);
let key = [0u8; 32];
let groups = BTreeMap::new();
let grants = BTreeMap::new();

// No marker → v8 falls through to v5 (no policy/grants/groups here).
let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
assert!(baseline.starts_with("blake3v3:"));

// Setting `revoked_at` switches the written scheme to v9 and changes the MAC.
vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
assert!(with_marker.starts_with("blake3v7:"));
assert_ne!(baseline, with_marker);

// The v9 MAC round-trips, and a downgraded (v8) MAC is rejected while the
// marker is present — an attacker can't clear it by stamping an older scheme.
assert!(verify_mac(
&vault,
&groups,
&grants,
&with_marker,
Some(&key)
));
let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));

// v8 (which ignores the marker) is blind to it — confirms `revoked_at` is
// what moved the v9 digest, mirroring the v5 rotation-metadata test.
let mut cleared = vault.clone();
cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
assert_eq!(
compute_mac_v8(&vault, &groups, &grants, &key),
compute_mac_v8(&cleared, &groups, &grants, &key)
);
}

#[test]
fn compute_mac_changes_with_schema() {
let mut vault = types::Vault {
Expand Down
Loading
Loading