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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
format: 2
id: seam/rust/ledger-core-src-author-genesis-key-rs
boundary: contract surface of ledger-core/src/author/genesis_key.rs
verdict_knowledge: 'Callers learn the genesis holder''s key at init: bound self-bound in the same change-set as the first policy when a usable signing key is configured, signing that policy; in a later namespace the first policy is signed with a live key.'
verdict_knowledge: 'Callers learn the genesis holder''s key at init: in every namespace init opens, the configured key is self-bound in the same change-set as the first policy, under the genesis mandate, and signs that policy; a later namespace binds the holder''s live key there, signed by a key of theirs already trusted, and init is refused with no usable key unless --without-key proceeds unbound (LP-4.31, LP-4.38, ruling 47).'
contract_location: ledger-core/src/author/genesis_key.rs
obligations: []
metadata: {}
Expand All @@ -24,3 +24,9 @@ bindings:
after: sha256:b1f3d002e552fc53131758042caaa73f52c3d2a9da2f2550a0e6a4269478f7ea
base_revision: 8edaa49385d53722a01a95c588f109af94a59783
hash: sha256:da0eab5c2006e4377a59d6279ad1a219a4bd638f9d97593677a109704363c292
- symbol: impl Author
file: ledger-core/src/author/genesis_key.rs
before: sha256:f56e3ebc3bb733576bb09e4130a239088b8e87803c199ce2ef4504a4e5799092
after: sha256:5bd15ca42caf8a69e2ef6194d1439b8f2cc215f38499e1295443806f185a23af
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:441c0b7bd086c83d20224dfb089ea984ce4890be043dcd910844b3dd9c453a0c
62 changes: 62 additions & 0 deletions .ddd/seams/seam-rust-ledger-core-src-verify-key-notices-rs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
format: 2
id: seam/rust/ledger-core-src-verify-key-notices-rs
boundary: contract surface of ledger-core/src/verify/key_notices.rs
verdict_knowledge: 'Callers learn the two repository notices about keys across namespaces: a key closed in one namespace and open in another (KeySplit), and one key blob bound to different principals in different namespaces (KeyShared), each reported by verify and never a finding, because a key is per namespace (LP-4.37, LP-4.39, rulings 47 and 69).'
contract_location: ledger-core/src/verify/key_notices.rs
obligations: []
metadata: {}
bindings:
- symbol: shared
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:657965eba48123977a8979e45086c93e5a6557cdab576693197de4a144ac27d8
- symbol: split
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:f7a9e3dffce583822fdc779b7348ac013faf32d07002e7f97775b5c949e98dd5
- symbol: KeyShared
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:01e30b271f83e276a396083c5c7f05be9861e1482e90219b3f058b9a745c1af3
- symbol: KeySplit
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:2a1273dc7af2ba52f5fc52465971389f23ee648f8b6bbcbb12e33cce4fff47e7
- symbol: bound_to
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:e7c3108b3eb0f0e1053ff28f6976f9ae2d5acc08e1f38482da23636308484f47
- symbol: key
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:1345c32ee60edaefa2293714331bfc0a60691974f49a2a9b67259f582f5a79d1
- symbol: closed_in
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:033977638acd7c020565757cf7e11d24f479e7e5e4c0e78173030f5c8f06e9ed
- symbol: holder
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:3b6772a7f9407d7d911d567b12b90873cd8ea8272ff044d5a4816c12678abf71
- symbol: open_in
file: ledger-core/src/verify/key_notices.rs
before: absent
after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:bf512f9996b72b984629634e920592994d296bc86a2357d355dc4a22b1020a81
18 changes: 18 additions & 0 deletions .ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,21 @@ bindings:
after: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071
base_revision: ec43481f591b1022d8840755c8c0381b06a59387
hash: sha256:b6181d32eae2268519f21749b68bbc52b97f0d30aeb7ce7e162221d88ed05a96
- symbol: key_notices
file: ledger-core/src/verify/mod.rs
before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071
after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:c0f4d954f116eccf6adc9c07540ae27de7a319027a1bffabf6396d53f28516b9
- symbol: key_shared
file: ledger-core/src/verify/mod.rs
before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071
after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:56eee557b7edf1f3bb1b05b25029f2d6e624cecbf90efddb6619d3f35da524b6
- symbol: key_split
file: ledger-core/src/verify/mod.rs
before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071
after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a
base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882
hash: sha256:149bea573e9a5e142ab3142c2eaac8f223e9bdab5d1ac5223fada1b6e2695aea
25 changes: 14 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,11 @@ store (`.decisions/`), separate ontology.
function, `authority::authorize`, over `Authority::of(store, ns)`, which
`accept`, `revoke` and the grant verbs call; a namespace **without** a
policy is pre-v2 and not role-checked. The graph stage runs per
namespace (`A003`/`A005` count one trust root each). Keys are still
bound and trusted store-wide until issue 6 of the namespace PRD. `allowed_signers` is derived (never hand-edited) and held by
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
`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 All @@ -353,18 +356,18 @@ store (`.decisions/`), separate ontology.
D7 (`authority/filing.rs`, called by the verbs and by `verify`).
`init --namespace` binds the genesis holder's configured key in the same
act (refused with no usable key; `--without-key` proceeds unbound), and a
first policy is signed when its author holds a trusted key (#96); `verify`
names `[none]` namespaces and an unbound genesis holder in notices. In a
later namespace `init` binds the holder's live key there, signed by a key
of theirs already trusted. A key binding is never exempt as a pre-policy
first policy is signed when its author holds a key trusted in that
namespace (#96); `verify` names `[none]` namespaces and an unbound genesis
holder (per namespace) in notices. A key binding is never exempt as a pre-policy
act (D5 (c) covers acceptances and revocations only): one before the
first policy is judged by D7 and that policy's requirement, so every
filed binding is trusted or named by a finding. A close ends the key, not
the binding (ruled 2026-10-06, `authority/key_close.rs`): closing any
binding of a principal's key closes it in every namespace, every check
asks over all bindings of the key; a key is bound once per namespace, a
closed key never again, and a key belongs to one principal (each a schema
fault, refused at filing). Integration suites need `ssh-keygen` (OpenSSH) as well as `script(1)`.
the binding (ruled 2026-10-06, `authority/key_close.rs`), in its own
namespace (ruling 47): closing any binding of a principal's key there
closes it there, every check asks over all bindings of the key in the
namespace; a key is bound once per namespace, a closed key never again
there, and a key belongs to one principal there (each a schema fault,
refused at filing). Integration suites need `ssh-keygen` (OpenSSH) as well as `script(1)`.
- **Authority verbs and signing verbs refuse a non-interactive caller**
(#71, #85). `accept <dec>`, `accept … --confirm`, `revoke`, and every verb
that writes an authority record exit non-zero and write nothing unless
Expand Down
33 changes: 27 additions & 6 deletions ledger-cli/tests/binding_accounting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,30 @@ fn governed() -> (Repo, String) {
/// A hand-filed binding for `principal` by `by` in `ns`, dated `at`,
/// signed by `signer` when given.
fn by_hand(repo: &Repo, by: &str, principal: &str, ns: &str, at: chrono::DateTime<Utc>, signer: Option<&str>) -> String {
hand_binding(repo, by, principal, ns, at, signer, false)
}

/// The genesis holder's self-bound first binding in a namespace `hand::found`
/// opened (its mandate is that namespace's), dated `at`, signed by the key
/// it binds when `signed` — keys are per namespace (ruling 47), so a key
/// trusted elsewhere signs nothing here.
fn self_bound_by_hand(repo: &Repo, ns: &str, at: chrono::DateTime<Utc>, signed: bool) -> String {
hand_binding(repo, OWNER, OWNER, ns, at, None, signed)
}

fn hand_binding(repo: &Repo, by: &str, principal: &str, ns: &str, at: chrono::DateTime<Utc>, signer: Option<&str>, self_signed: bool) -> String {
let id: ledger_core::id::KeyBindingId = UlidMint::system().mint_id("key").expect("id");
let key = repo.keygen(&format!("k{}", id.ulid()));
let mut b = hand::binding(BindingAct::Add, principal, by, ns, Some(&format!("{key}.pub")), None, None);
b.at = at;
if self_signed || (signer.is_none() && by == OWNER && principal == OWNER && ns != NS && ns != "third.ledger") {
b.self_bound = true;
b.mandate = Some(format!("mandate for {ns}"));
}
b.hash = ledger_core::authority::payload::binding_hash(&b);
let ulid = b.id.ulid().to_string();
let signers: Vec<(&str, &str)> = signer.map(|s| vec![(ulid.as_str(), s)]).unwrap_or_default();
let own = self_signed.then_some(key.as_str());
let signers: Vec<(&str, &str)> = signer.or(own).map(|s| vec![(ulid.as_str(), s)]).unwrap_or_default();
hand::file(repo, vec![b], Vec::new(), &signers);
blob(&key)
}
Expand All @@ -102,13 +119,17 @@ fn every_filed_binding_is_trusted_or_named_by_a_finding() {
let refused = by_hand(&repo, OTHER, OTHER, NS, Utc::now(), None);
// Ungoverned: a binding in a namespace no policy governs.
let ungoverned = by_hand(&repo, OWNER, OWNER, "third.ledger", Utc::now(), Some(&owner));
// Before a signed first policy: one signed, one unsigned.
let at = first_policy(&repo, SECOND, vec![Scheme::Ssh], Some(&owner));
let before_signed = by_hand(&repo, OWNER, OWNER, SECOND, at - Duration::seconds(1), Some(&owner));
let before_unsigned = by_hand(&repo, OWNER, OWNER, SECOND, at - Duration::seconds(1), None);
// Before a signed first policy, the genesis holder's self-bound first
// binding in that namespace (ruling 47): one signed by the key it
// binds, one unsigned — each in a namespace of its own, since a
// namespace has one first binding.
let at = first_policy(&repo, SECOND, vec![Scheme::Ssh], None);
let before_signed = self_bound_by_hand(&repo, SECOND, at - Duration::seconds(1), true);
let at = first_policy(&repo, "fifth.ledger", vec![Scheme::Ssh], None);
let before_unsigned = self_bound_by_hand(&repo, "fifth.ledger", at - Duration::seconds(1), false);
// Before a `[none]` first policy, unsigned.
let at = first_policy(&repo, "fourth.ledger", vec![Scheme::None], None);
let before_none = by_hand(&repo, OWNER, OWNER, "fourth.ledger", at - Duration::seconds(1), None);
let before_none = self_bound_by_hand(&repo, "fourth.ledger", at - Duration::seconds(1), false);
hand::commit(&repo, "every way a binding is filed");

let cases = [
Expand Down
51 changes: 31 additions & 20 deletions ledger-cli/tests/genesis_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ fn bound_at_init_a_forged_self_bound_binding_is_never_trusted() {
let id = forge_self_bound(&repo, NS);
let (code, text) = verify(&repo);
assert_eq!(code, 1, "{text}");
assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the store"), "{text}");
assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the namespace"), "{text}");
}

#[test]
Expand Down Expand Up @@ -148,10 +148,10 @@ fn a_first_policy_filed_before_any_key_stays_valid_unsigned() {
fn a_later_namespaces_first_policy_is_signed_and_unsigned_it_is_l011() {
let (repo, _, _) = keyed();
let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]);
assert!(out.contains("signed"), "{out}");
assert!(out.contains("it signs the policy"), "{out}");
let policy = policy_of(&repo, SECOND);
let sig = sidecar(&repo, SECOND, policy.id.ulid());
assert!(sig.exists(), "signed with the key trusted in the first namespace");
assert!(sig.exists(), "signed with the key self-bound in the second namespace (ruling 47)");
hand::commit(&repo, "second namespace");
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
Expand All @@ -175,20 +175,23 @@ fn verify_names_a_none_namespace_in_a_notice_and_passes() {
assert_eq!(report["unsigned"][0], NS);
}

/// Every namespace is opened as the first is (ruling 47): the holder's key
/// is self-bound in the new namespace under its own mandate, whatever they
/// hold elsewhere, and signs there straight away.
#[test]
fn init_in_a_later_namespace_binds_the_holders_key_there_and_they_sign_with_no_identity_add() {
fn init_in_a_later_namespace_self_binds_the_holders_key_there_and_they_sign_with_no_identity_add() {
let (repo, key, _) = keyed();
let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]);
assert!(out.contains(&format!("in `{SECOND}`, signed by their key trusted elsewhere")), "{out}");
let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/118"]);
assert!(out.contains(&format!("first key in `{SECOND}`")) && out.contains("self-bound"), "{out}");
let policy = policy_of(&repo, SECOND);
let store = ledger_core::store::load(repo.path());
let here: Vec<_> = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).filter(|b| b.namespace == SECOND).collect();
assert_eq!(here.len(), 1, "one binding, in the init change-set");
let b = here[0];
assert!(!b.self_bound && b.mandate.is_none() && b.by.as_str() == OWNER, "their own add: {b:?}");
assert!(b.self_bound && b.mandate.as_deref() == Some("contract 2026/118") && b.by.as_str() == OWNER, "self-bound under the second namespace's mandate: {b:?}");
assert_eq!(b.at, policy.at, "dated with the policy");
assert!(std::fs::read_to_string(format!("{key}.pub")).expect("pub").contains(b.key.as_deref().unwrap_or("?")), "the existing key");
assert!(sidecar(&repo, SECOND, b.id.ulid()).exists(), "signed by their key trusted in the first namespace");
assert!(std::fs::read_to_string(format!("{key}.pub")).expect("pub").contains(b.key.as_deref().unwrap_or("?")), "the same key, bound again here");
assert!(sidecar(&repo, SECOND, b.id.ulid()).exists(), "signed by the key it binds");
// They sign in the new namespace straight away.
let grant = hand::word(&repo.ok(&["grant", "new", "acceptor", "--to", OWNER, "--scope", &format!("ns:{SECOND}")]), "grant:");
repo.ok(&["grant", "accept", &grant]);
Expand All @@ -214,26 +217,34 @@ fn every_key_closed() -> (Repo, String) {
(repo, id)
}

/// A close ends the key in its own namespace (ruling 47, LP-6.32): a key
/// closed in the first namespace is another key in the second, and `init`
/// self-binds it there. The split is a repository notice (ruling 69).
#[test]
fn with_every_key_closed_init_in_a_later_namespace_refuses_and_names_them() {
fn with_every_key_closed_elsewhere_init_in_a_later_namespace_self_binds_the_key_and_verify_notices_the_split() {
let (repo, closed) = every_key_closed();
let out = repo.ledger(&["init", "--namespace", SECOND, "--external-ref", MANDATE]);
let refused = common::both(&out);
assert_eq!(out.status.code(), Some(2), "a usage refusal: {refused}");
assert!(refused.contains("no live key") && refused.contains(&closed), "{refused}");
assert!(refused.contains("--without-key"), "the way to proceed is named: {refused}");
let store = ledger_core::store::load(repo.path());
assert!(store.log.iter().all(|l| l.file.policies.iter().all(|p| p.namespace != SECOND)), "nothing written");
let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]);
assert!(out.contains("self-bound") && !out.contains(&closed), "{out}");
hand::commit(&repo, "second namespace, the key bound again there");
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
assert!(text.contains(&format!("notice: key ssh-ed25519")) && text.contains(&format!("of {OWNER} is closed in `{NS}` and open in `{SECOND}`")), "{text}");
let json = repo.ledger(&["verify", "--no-blame", "--json"]);
let report: serde_json::Value = serde_json::from_slice(&json.stdout).expect("json");
assert_eq!(report["key_split"][0]["closed_in"][0], NS);
assert_eq!(report["key_split"][0]["open_in"][0], SECOND);
}

#[test]
fn with_every_key_closed_and_without_key_init_warns_and_initialises_unbound() {
let (repo, closed) = every_key_closed();
fn with_no_usable_key_and_without_key_init_in_a_later_namespace_warns_and_initialises_unbound() {
let (repo, _) = every_key_closed();
repo.use_key("/nonexistent/owner-key");
let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE, "--without-key"]);
assert!(out.contains("warning: no live key") && out.contains(&closed), "{out}");
assert!(out.contains("warning: no key bound") && out.contains("--without-key"), "{out}");
let store = ledger_core::store::load(repo.path());
assert!(store.log.iter().flat_map(|l| l.file.key_bindings.iter()).all(|b| b.namespace != SECOND), "no binding in {SECOND}");
hand::commit(&repo, "second namespace, unbound");
let (code, text) = verify(&repo);
assert_eq!(code, 0, "{text}");
assert!(text.contains(&format!("notice: the genesis holder {OWNER} has no trusted key — governed namespace `{SECOND}`")), "{text}");
}
Loading
Loading