Skip to content
Open
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
20 changes: 19 additions & 1 deletion .ddd/seams/seam-rust-ledger-core-src-author-identity-ops-rs.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
format: 2
id: seam/rust/ledger-core-src-author-identity-ops-rs
boundary: contract surface of ledger-core/src/author/identity_ops.rs
verdict_knowledge: Callers learn identity add|rotate|revoke append key bindings (the namespace's first is the genesis holder's, self-bound under the mandate), refuse a software key under an -sk policy, and rewrite allowed_signers.
verdict_knowledge: 'Callers learn identity add|rotate|revoke append key bindings in one namespace (the namespace''s first is the genesis holder''s, self-bound under the mandate), refuse a software key under an -sk policy, rewrite that namespace''s allowed_signers, and that rotate and revoke with everywhere file one change-set per namespace the principal has the key open in, to land in one commit (LP-6.34).'
contract_location: ledger-core/src/author/identity_ops.rs
obligations: []
metadata: {}
Expand Down Expand Up @@ -66,3 +66,21 @@ bindings:
after: sha256:bead00f9272725f5dd6f5a1aed8e104e0952dc2de57a3c505f490c599b2f8b28
base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e
hash: sha256:6a22798d9caf6c67109e21f5d717775fd87d1bf77439c166a0ca52a7265aecd9
- symbol: KeyArgs
file: ledger-core/src/author/identity_ops.rs
before: sha256:8bea51520bb0b7ffe6eed9cda30c74dbc7edecba82e555148063f65a776f07e7
after: sha256:65495d3f5ae13c3b9afe51dca8fb9aca2c43fefa1ced4f3740169de3d61087c2
base_revision: 111f4f611f1e31b07843a4211292f0c6d7d3e500
hash: sha256:1d17d847b3c0af6a505e4ae9b5444bf3e8ba2646a01d20201cd1da687bb88939
- symbol: identity_revoke
file: ledger-core/src/author/identity_ops.rs
before: sha256:8bea51520bb0b7ffe6eed9cda30c74dbc7edecba82e555148063f65a776f07e7
after: sha256:65495d3f5ae13c3b9afe51dca8fb9aca2c43fefa1ced4f3740169de3d61087c2
base_revision: 111f4f611f1e31b07843a4211292f0c6d7d3e500
hash: sha256:c3c6135fa7c45bd808835e06a1b272454571dc6f5eb8e5096153f46565a16b53
- symbol: identity_rotate
file: ledger-core/src/author/identity_ops.rs
before: sha256:8bea51520bb0b7ffe6eed9cda30c74dbc7edecba82e555148063f65a776f07e7
after: sha256:65495d3f5ae13c3b9afe51dca8fb9aca2c43fefa1ced4f3740169de3d61087c2
base_revision: 111f4f611f1e31b07843a4211292f0c6d7d3e500
hash: sha256:85346982663c44e63ae8712fd40804749d996059e1e76b45cbc61ccf5927b608
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,10 @@ store (`.decisions/`), separate ontology.
namespace (`A003`/`A005` count one trust root each). Keys are per
namespace too (ruling 47): `init --namespace` self-binds the holder's
configured key in every namespace it opens, a close ends a key in its
own namespace only, and a key closed in one namespace and open in
another is a `verify` notice (ruling 69), never a finding. `allowed_signers` is derived (never hand-edited) and held by
own namespace only (`identity revoke|rotate --everywhere` files one
change-set per namespace the holder has the key open in, to land in one
commit), and a key closed in one namespace and open in another is a
`verify` notice (ruling 69), never a finding. `allowed_signers` is derived (never hand-edited) and held by
`verify`'s `[SIGNERS]` stage. Graph classes `A003`/`A005`; `A006` deferred.
- **Signing (format 7 / spec v1.8, #70)** — `ledger-core/src/signing/`.
A namespace's policy lists the required schemes; a signature is a sidecar
Expand Down
6 changes: 3 additions & 3 deletions ledger-cli/src/commands/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ pub fn identity(root: Option<PathBuf>, cmd: IdentityCmd) -> Result<i32, String>
args.principal = principal.as_deref().map(str::parse).transpose()?;
finish(author.identity_add(args))
}
IdentityCmd::Revoke { binding } => finish(author.identity_revoke(&binding.parse()?)),
IdentityCmd::Rotate { binding, key_file: path } => {
IdentityCmd::Revoke { binding, everywhere } => finish(author.identity_revoke(&binding.parse()?, everywhere)),
IdentityCmd::Rotate { binding, key_file: path, everywhere } => {
let closes = binding.parse()?;
finish(author.identity_rotate(&closes, key_file(&path, String::new())?))
finish(author.identity_rotate(&closes, key_file(&path, String::new())?, everywhere))
}
IdentityCmd::Sync => Ok(EXIT_OK),
}
Expand Down
8 changes: 8 additions & 0 deletions ledger-cli/src/commands/authority_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,21 @@ pub enum IdentityCmd {
Revoke {
/// `key:<ulid>` — the binding to close
binding: String,
/// Close the key in every namespace its principal holds it open in:
/// one change-set per namespace, to land in one commit (ruling 69)
#[arg(long)]
everywhere: bool,
},
/// Close one of your bindings and bind a new key in the same act
Rotate {
/// `key:<ulid>` — the binding to close
binding: String,
#[arg(long, value_name = "PATH")]
key_file: std::path::PathBuf,
/// Rotate in every namespace you hold the closed key open in: one
/// change-set per namespace, to land in one commit (ruling 69)
#[arg(long)]
everywhere: bool,
},
/// Rewrite each namespace's allowed_signers from the log (after a merge)
Sync,
Expand Down
94 changes: 85 additions & 9 deletions ledger-cli/tests/key_across_namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
//! untouched (AC-47, second bullet), and `verify` reports the split as a
//! notice, never a finding. Within a namespace the rules of 2026-10-06
//! stand: a key is bound once there, a closed key is never bound again
//! there, and every check asks over all bindings of the key there. The
//! writer's close in every namespace it holds is issue 8's.
//! there, and every check asks over all bindings of the key there. A close
//! in every namespace the holder has the key open in is one invocation,
//! `--everywhere`: one change-set per namespace, each with the same `at`,
//! to land in one commit (LP-6.34; AC-47, third bullet).

mod common;

Expand Down Expand Up @@ -258,17 +260,91 @@ fn no_agent_identity_and_no_non_interactive_session_produces_a_key_close() {
let before = repo.log_files();
for ns in [A, B] {
let id = bindings(&repo, ns)[0].id.to_string();
let piped = repo.piped(&["identity", "revoke", &id]);
assert_ne!(piped.status.code(), Some(0), "{}", common::both(&piped));
for agent in ["claude@anthropic.com", "noreply@anthropic.com", "github-actions@github.com"] {
repo.act_as(agent);
let out = repo.tty(&["identity", "revoke", &id]);
assert_ne!(out.status.code(), Some(0), "{agent} in `{ns}`: {}", common::both(&out));
for args in [vec!["identity", "revoke", &id], vec!["identity", "revoke", &id, "--everywhere"]] {
let piped = repo.piped(&args);
assert_ne!(piped.status.code(), Some(0), "{}", common::both(&piped));
for agent in ["claude@anthropic.com", "noreply@anthropic.com", "github-actions@github.com"] {
repo.act_as(agent);
let out = repo.tty(&args);
assert_ne!(out.status.code(), Some(0), "{agent} in `{ns}`: {}", common::both(&out));
}
repo.act_as(OWNER);
}
repo.act_as(OWNER);
}
assert_eq!(repo.log_files(), before, "nothing filed");
let in_a = bindings(&repo, A)[0].id.to_string();
repo.ok(&["identity", "revoke", &in_a]);
assert_eq!(repo.log_files().len(), before.len() + 1, "the holder, at a terminal, closes");
}

/// The log files of `ns`, sorted.
fn log_files_in(repo: &Repo, ns: &str) -> Vec<String> {
let mut out: Vec<String> = std::fs::read_dir(repo.log_dir(ns)).map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()).unwrap_or_default();
out.sort();
out
}

/// AC-47, third bullet: one invocation closes the key in every namespace
/// the holder has it open in — one change-set per namespace, the same
/// `at`, landing in one commit — and no split is left to notice.
#[test]
fn a_revoke_everywhere_files_one_change_set_per_namespace_with_one_at_in_one_commit() {
let (repo, _) = a_then_b();
let (in_a, in_b) = (bindings(&repo, A)[0].clone(), bindings(&repo, B)[0].clone());
let (before_a, before_b) = (log_files_in(&repo, A), log_files_in(&repo, B));
let out = repo.ok(&["identity", "revoke", &in_a.id.to_string(), "--everywhere"]);
assert!(out.contains(&format!("closing {OWNER}'s key in 2 namespace(s): `{A}`, `{B}`")), "{out}");
assert_eq!(out.matches("filed ").count(), 2, "one file per namespace named: {out}");
assert_eq!(log_files_in(&repo, A).len(), before_a.len() + 1, "one change-set in A");
assert_eq!(log_files_in(&repo, B).len(), before_b.len() + 1, "one change-set in B");
let closes: Vec<ledger_core::authority::KeyBinding> = ledger_core::store::load(repo.path())
.log
.iter()
.flat_map(|l| l.file.key_bindings.iter())
.filter(|b| b.closes.is_some())
.cloned()
.collect();
assert_eq!(closes.len(), 2, "{closes:?}");
assert_eq!(closes[0].at, closes[1].at, "the same `at` in each namespace");
assert!(closes.iter().any(|c| c.namespace == A && c.closes.as_ref() == Some(&in_a.id)), "A's close names A's binding");
assert!(closes.iter().any(|c| c.namespace == B && c.closes.as_ref() == Some(&in_b.id)), "B's close names B's binding");
hand::commit(&repo, "closed everywhere, one commit");
let (code, text) = verify(&repo, &[]);
assert_eq!(code, 0, "{text}");
assert!(!text.contains("is closed in"), "no split to notice: {text}");
for ns in [A, B] {
assert!(repo.signers(ns).lines().filter(|l| !l.starts_with('#')).all(|l| l.contains("valid-before=")), "{ns}: {}", repo.signers(ns));
}
// Closed everywhere, there is nothing left to close.
let again = repo.ledger(&["identity", "revoke", &in_b.id.to_string(), "--everywhere"]);
assert_eq!(again.status.code(), Some(1), "{}", common::both(&again));
assert!(common::both(&again).contains("nothing to close"), "{}", common::both(&again));
}

#[test]
fn a_rotate_everywhere_closes_the_key_and_binds_the_new_one_in_each_namespace() {
let (repo, key) = a_then_b();
let in_a = bindings(&repo, A)[0].id.to_string();
let next = repo.keygen("owner-next");
repo.use_key(&key);
let out = repo.ok(&["identity", "rotate", &in_a, "--key-file", &format!("{next}.pub"), "--everywhere"]);
assert!(out.contains("2 namespace(s)"), "{out}");
let blob = std::fs::read_to_string(format!("{next}.pub")).expect("pub").split_whitespace().nth(1).map(str::to_string).expect("blob");
for ns in [A, B] {
let here = bindings(&repo, ns);
let rotate = here.iter().find(|b| b.act == BindingAct::Rotate).expect("a rotate in each namespace");
assert_eq!(rotate.key.as_deref(), Some(blob.as_str()), "{ns}: the new key");
assert_eq!(rotate.closes.as_ref(), Some(&here[0].id), "{ns}: closes that namespace's binding");
}
hand::commit(&repo, "rotated everywhere, one commit");
let (code, text) = verify(&repo, &[]);
assert_eq!(code, 0, "{text}");
assert!(!text.contains("is closed in"), "{text}");
// The new key signs in B straight away; the old one does not.
let id = decision(&repo, B, "Signed with the rotated key.");
hand::commit(&repo, "a decision in B");
let refused = refusal(&repo, &["accept", &id]);
assert!(refused.contains("it was closed in"), "{refused}");
repo.use_key(&next);
repo.ok_tty(&["accept", &id]);
}
64 changes: 60 additions & 4 deletions ledger-core/src/author/identity_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use super::sign_ops::ToSign;
use super::{Applied, Author, AuthorError};

/// A public key, as OpenSSH writes it: type then base64.
#[derive(Clone)]
pub struct KeyArgs {
pub namespace: String,
pub key_type: String,
Expand All @@ -39,14 +40,69 @@ impl Author {
}

/// Close one of the actor's open bindings and open a new key in one act.
pub fn identity_rotate(&mut self, closes: &KeyBindingId, args: KeyArgs) -> Result<Applied, AuthorError> {
self.bind(BindingAct::Rotate, Some(closes.clone()), Some(args))
/// With `everywhere`, in every namespace the principal holds the closed
/// key open in (LP-6.34).
pub fn identity_rotate(&mut self, closes: &KeyBindingId, args: KeyArgs, everywhere: bool) -> Result<Applied, AuthorError> {
match everywhere {
true => self.close_everywhere(BindingAct::Rotate, closes, Some(&args)),
false => self.bind(BindingAct::Rotate, Some(closes.clone()), Some(args)),
}
}

/// Close a binding's window — the principal's act, or the genesis
/// holder's (a compromised key's owner may not be the one to notice).
pub fn identity_revoke(&mut self, closes: &KeyBindingId) -> Result<Applied, AuthorError> {
self.bind(BindingAct::Revoke, Some(closes.clone()), None)
/// With `everywhere`, in every namespace the principal holds the key
/// open in (LP-6.34).
pub fn identity_revoke(&mut self, closes: &KeyBindingId, everywhere: bool) -> Result<Applied, AuthorError> {
match everywhere {
true => self.close_everywhere(BindingAct::Revoke, closes, None),
false => self.bind(BindingAct::Revoke, Some(closes.clone()), None),
}
}

/// A close in every namespace the key's principal holds it open in
/// (ruling 69, LP-6.34): one change-set per namespace, each the same
/// act with the same `at`, signed in its own namespace and gated as it
/// is filed, in the order of the namespaces' names. A close ends the key
/// in its own namespace only (LP-6.32), so this is how one invocation
/// ends a key everywhere; the files land in one commit.
fn close_everywhere(&mut self, act: BindingAct, closes: &KeyBindingId, key: Option<&KeyArgs>) -> Result<Applied, AuthorError> {
let store = self.load();
let all = Authority::build(&store);
let target = all
.bindings
.iter()
.copied()
.find(|b| b.id == *closes && b.act.opens())
.ok_or_else(|| AuthorError::Usage(format!("{closes} opens no key window")))?;
let mut open: Vec<&KeyBinding> = all
.bindings
.iter()
.copied()
.filter(|o| o.act.opens() && o.principal == target.principal && o.key == target.key)
.filter(|o| !crate::authority::key_close::is_closed(&all.bindings, o))
.collect();
open.sort_by(|a, b| a.namespace.cmp(&b.namespace));
if open.is_empty() {
return Err(AuthorError::Conflict(format!("{closes}'s key is closed in every namespace it was bound in — nothing to close")));
}
let mut lines = vec![format!(
"closing {}'s key in {} namespace(s): {}",
target.principal,
open.len(),
open.iter().map(|o| format!("`{}`", o.namespace)).collect::<Vec<_>>().join(", ")
)];
let mut last = None;
for o in open {
let key = key.map(|k| KeyArgs { namespace: o.namespace.clone(), ..k.clone() });
let applied = self.bind(act, Some(o.id.clone()), key)?;
lines.extend(applied.lines);
if let Some(previous) = last.replace(applied.path) {
lines.push(format!("filed {}", previous.display()));
}
}
let path = last.ok_or_else(|| AuthorError::Io("nothing filed".into()))?;
Ok(Applied { path, lines })
}

fn bind(
Expand Down
Loading
Loading