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
38 changes: 37 additions & 1 deletion .ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
format: 2
id: seam/rust/ledger-core-src-authority-signers-rs
boundary: contract surface of ledger-core/src/authority/signers.rs
verdict_knowledge: 'Consumers learn allowed_signers is derived per namespace from that namespace''s key bindings in OpenSSH form, written by the identity verbs at .decisions/ns/<ns>/allowed_signers, and held byte-identical by verify''s [SIGNERS] stage.'
verdict_knowledge: 'Consumers learn allowed_signers is derived per namespace from that namespace''s key bindings in OpenSSH form, written by the identity verbs at .decisions/ns/<ns>/allowed_signers, that write reports what it wrote and removed (Synced) and removes a stale file in a namespace with no derived entry, and that verify''s [SIGNERS] stage holds every namespace''s file byte-identical (LP-4.10, LP-4.33).'
contract_location: ledger-core/src/authority/signers.rs
obligations: []
metadata: {}
Expand Down Expand Up @@ -90,3 +90,39 @@ bindings:
after: sha256:3e0780d22c990a69f8205668616cd6971976ec3130ee73caf7745cf51997396a
base_revision: 70395dfe317cb9a00665433813766ca763f3c58a
hash: sha256:1100a9e4e2b13c50ad9c31ffd8123af332e6e432cc0eca45e0f38a77ca12ca85
- symbol: write
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:e2f9b122875266f74fe7422bd3d395e6d56b27a6553e5705714064a0e1f9634d
- symbol: impl Synced
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:2c7c975a8eb135d7d59edc7a27ae08414f00a8c5b15f05a0e5461379def1e0c9
- symbol: Synced
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:8082982faff2d4c185bb57ea5b759dc5f3d66de12199e6c680292cc3711d22d6
- symbol: removed
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:c844b258c6699385c8c9220fdb85e871a3664fbe085370d9b46d7067970d8136
- symbol: written
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:2e2545b091a37a0fff2559ad54b41fdcdc0a5887cc84fd6ea96b62ba8177eafc
- symbol: lines
file: ledger-core/src/authority/signers.rs
before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26
after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef
base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838
hash: sha256:af2fbba83148f97a5f0a40da4b08890d60b8876f80aafefc6f23584be66de424
9 changes: 7 additions & 2 deletions ledger-cli/src/commands/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ fn key_file(path: &Path, namespace: String) -> Result<KeyArgs, String> {
pub fn identity(root: Option<PathBuf>, cmd: IdentityCmd) -> Result<i32, String> {
if let IdentityCmd::Sync = cmd {
let store = ledger_core::store::load(&resolve_root(root)?);
ledger_core::authority::signers::write(&store)?;
println!("{} rewritten from the log", ledger_core::authority::signers::FILE);
let synced = ledger_core::authority::signers::write(&store)?;
for line in synced.lines() {
println!("{line}");
}
if synced.written.is_empty() && synced.removed.is_empty() {
println!("no namespace binds a key — no {} to derive", ledger_core::authority::signers::FILE);
}
return Ok(EXIT_OK);
}
let mut author = open_author(root)?;
Expand Down
112 changes: 112 additions & 0 deletions ledger-cli/tests/signers_per_namespace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! `allowed_signers` is one derived file per namespace (LP-4.10, ruling 47; PRD §3.4; issue 7).
//!
//! Each namespace's file holds that namespace's lines, with `valid-before`
//! from closes in that namespace only; `[SIGNERS]` compares each committed
//! file with that namespace's trusted bindings; a namespace that binds
//! nothing has no file, and a stale one is removed when the files are
//! regenerated.

mod common;

use common::{hand, Repo};

const OWNER: &str = "owner@customer.example";
const MANDATE: &str = "contract 2026/117";
const A: &str = "a.ledger";
const B: &str = "b.ledger";

fn verify(repo: &Repo) -> (i32, String) {
let out = repo.ledger(&["verify", "--no-blame"]);
(out.status.code().unwrap_or(-1), common::both(&out))
}

/// Two namespaces, the owner's key self-bound in each by `init`.
fn two_bound() -> (Repo, String) {
let repo = Repo::with_identity(OWNER);
let key = repo.keygen("owner");
repo.use_key(&key);
repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]);
repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]);
hand::commit(&repo, "two namespaces, one key bound in each");
// A later act in the same second as the policies would sit at the
// policies' second: `ssh-keygen` reads windows at second granularity.
std::thread::sleep(std::time::Duration::from_millis(1100));
(repo, key)
}

fn lines(text: &str) -> Vec<&str> {
text.lines().filter(|l| !l.starts_with('#')).collect()
}

#[test]
fn each_namespace_has_its_own_file_holding_its_own_lines() {
let (repo, _) = two_bound();
for ns in [A, B] {
assert!(repo.signers_path(ns).is_file(), "`{ns}` has its file");
let text = repo.signers(ns);
let own = lines(&text);
assert_eq!(own.len(), 1, "{ns}: {own:?}");
assert!(own[0].contains(&format!("namespaces=\"ledger-accept@{ns}\"")), "{ns}: {}", own[0]);
}
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
}

#[test]
fn a_close_in_another_namespace_does_not_reach_the_file() {
let (repo, _) = two_bound();
let in_a = {
let store = ledger_core::store::load(repo.path());
store.log.iter().flat_map(|l| l.file.key_bindings.iter()).find(|b| b.namespace == A).map(|b| b.id.to_string()).expect("binding in A")
};
let before = repo.signers(B);
repo.ok(&["identity", "revoke", &in_a]);
hand::commit(&repo, "closed in A");
assert!(lines(&repo.signers(A)).iter().all(|l| l.contains("valid-before=")), "{}", repo.signers(A));
assert_eq!(repo.signers(B), before, "B's file is byte-identical: the close in A is A's");
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
}

#[test]
fn a_file_in_a_namespace_that_binds_nothing_fails_signers_and_sync_removes_it() {
let repo = Repo::with_identity(OWNER);
let key = repo.keygen("owner");
repo.use_key(&key);
repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]);
repo.declare_in(B);
// A file nobody derived, in a namespace with no binding.
let stale = repo.signers_path(B);
std::fs::create_dir_all(stale.parent().expect("dir")).expect("mkdir");
std::fs::write(&stale, format!("{}{OWNER} namespaces=\"ledger-accept@{B}\",valid-after=\"20261001000000Z\" ssh-ed25519 AAAA\n", ledger_core::authority::signers::HEADER)).expect("write");
hand::commit(&repo, "a stale allowed_signers in B");
let (code, text) = verify(&repo);
assert_eq!(code, 1, "{text}");
assert!(text.contains("[SIGNERS]") && text.contains(&format!("committed in `{B}` but the log binds no key there")), "{text}");
let out = repo.ok(&["identity", "sync"]);
assert!(out.contains(&format!("removed ns/{B}/allowed_signers")) && out.contains(&format!("regenerated ns/{A}/allowed_signers")), "{out}");
assert!(!stale.exists(), "the stale file is gone");
assert!(repo.signers_path(A).is_file(), "A's file stands");
hand::commit(&repo, "synced");
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
}

#[test]
fn a_namespace_whose_only_binding_is_untrusted_has_no_file_after_sync() {
let repo = Repo::with_identity(OWNER);
let key = repo.keygen("owner");
repo.use_key(&key);
repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]);
repo.declare_in(B);
// A forged binding in B, hand-filed with no policy to govern it: a
// schema fault, never trusted — so B derives no file, and one written
// beside it is stale.
let forged = repo.keygen("forged");
let b = hand::binding(ledger_core::authority::BindingAct::Add, OWNER, OWNER, B, Some(&format!("{forged}.pub")), None, None);
hand::file(&repo, vec![b], Vec::new(), &[]);
std::fs::write(repo.signers_path(B), ledger_core::authority::signers::HEADER).expect("write");
let out = repo.ok(&["identity", "sync"]);
assert!(out.contains(&format!("removed ns/{B}/allowed_signers")), "{out}");
assert!(!repo.signers_path(B).exists());
}
3 changes: 1 addition & 2 deletions ledger-core/src/author/authority_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ impl Author {
}
let path = self.append_signed(candidate)?;
if !candidate.key_bindings.is_empty() {
crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?;
lines.push(format!("regenerated {}", crate::authority::signers::FILE));
lines.extend(crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?.lines());
}
Ok(Applied { path, lines })
}
Expand Down
6 changes: 4 additions & 2 deletions ledger-core/src/author/identity_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ impl Author {
candidate.key_bindings.push(binding);
self.refusal_check(&store, &candidate, |_| false)?;
let path = self.append_signed(&candidate)?;
crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?;
Ok(Applied { path, lines: vec![line, format!("regenerated {}", crate::authority::signers::FILE)] })
let synced = crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?;
let mut lines = vec![line];
lines.extend(synced.lines());
Ok(Applied { path, lines })
}
}
48 changes: 39 additions & 9 deletions ledger-core/src/authority/signers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
//! — a close ends the key, not the binding (ruled 2026-10-06), in its own
//! namespace (ruling 47, LP-6.32). The principal
//! is the bare address (ruling 6: files keep the bare address). The file is
//! never edited by hand: every `ledger identity` verb rewrites it, and
//! `verify` re-derives it and holds the committed bytes identical — the
//! export's discipline, applied to the trust root.
//! never edited by hand: every `ledger identity` verb rewrites each
//! namespace's file and removes one no binding of that namespace derives
//! (LP-4.10), and `verify` re-derives each and holds the committed bytes
//! identical — the export's discipline, applied to the trust root.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -117,16 +118,45 @@ fn lines(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Vec<(String, String
.collect()
}

/// Rewrite each namespace's derived file from the log.
pub fn write(store: &Store) -> Result<(), String> {
for (namespace, text) in derive(store) {
let target = path(&store.root, &namespace);
/// What a rewrite did: the namespaces whose file was written, and those
/// whose stale file was removed.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Synced {
pub written: Vec<String>,
pub removed: Vec<String>,
}

impl Synced {
/// One line per namespace touched, for a verb's output.
pub fn lines(&self) -> Vec<String> {
let mut out: Vec<String> = self.written.iter().map(|ns| format!("regenerated ns/{ns}/{FILE}")).collect();
out.extend(self.removed.iter().map(|ns| format!("removed ns/{ns}/{FILE} — no trusted binding of `{ns}` derives it")));
out
}
}

/// Rewrite each namespace's derived file from the log, and remove a file
/// committed in a namespace none of whose bindings is trusted: a namespace
/// that binds nothing has no file (LP-4.10, ruling 47).
pub fn write(store: &Store) -> Result<Synced, String> {
let derived = derive(store);
let mut synced = Synced::default();
for (namespace, text) in &derived {
let target = path(&store.root, namespace);
if let Some(dir) = target.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
product_core::fileops::write_file_atomic(&target, &text).map_err(|e| e.to_string())?;
product_core::fileops::write_file_atomic(&target, text).map_err(|e| e.to_string())?;
synced.written.push(namespace.clone());
}
for namespace in store.namespaces().into_iter().filter(|ns| !derived.contains_key(ns)) {
let stale = path(&store.root, &namespace);
if stale.is_file() {
std::fs::remove_file(&stale).map_err(|e| e.to_string())?;
synced.removed.push(namespace);
}
}
Ok(())
Ok(synced)
}

/// The verify stage: each namespace's committed file equals the log's
Expand Down
Loading
Loading