From 76eb785ee29357ea3d0d9790330f22103d95f95a Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 22:05:57 +0200 Subject: [PATCH] specify selective disclosure and anchor policy --- README.md | 20 +- crates/geth-cli/src/lib.rs | 10 +- crates/geth-control/src/lib.rs | 5 +- crates/geth-keychain/src/lib.rs | 21 +- crates/geth-keychain/src/sshsigchain.rs | 410 ++++++++++++++---- crates/geth-node/src/lib.rs | 13 +- docs/adr/0018-sshsigchain.md | 76 ++-- docs/architecture.md | 21 +- docs/roadmap.md | 37 +- docs/sigchain-keychain.md | 60 +-- docs/sshsigchain.md | 536 ++++++++++++++---------- 11 files changed, 828 insertions(+), 381 deletions(-) diff --git a/README.md b/README.md index 7df3468..d559aca 100644 --- a/README.md +++ b/README.md @@ -646,9 +646,12 @@ validated its own chain. There is no compatibility mode for that workflow. The replacement is the small, transport-neutral [`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an -operator-pinned chain ID, OpenSSH root public key, profile, and namespace; -records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth -already provides a verifier for independently produced JSONL transport files: +operator-pinned chain ID, OpenSSH root public key, and namespace. Parent hashes +define order without a redundant sequence counter. Every record carries a +public authority transition for devices, keys, causal revocation, scoped +permissions, and anchor policy, plus optional profile commitments whose payloads +can be selectively disclosed. Geth already provides a verifier for independently +produced JSONL transport files: ```sh geth keychain verify-sigchain \ @@ -657,10 +660,13 @@ geth keychain verify-sigchain \ --root-key ~/.ssh/geth-root.pub ``` -SSHSIGCHAIN local record storage, signing, publication, import, and accepted-head -persistence remain follow-up work. Until they exist, do not substitute an -unpinned checkpoint or a local operation-log view for the SSHSIGCHAIN trust -tuple. +The verifier reports active authority devices/keys, disclosed and incomplete +profiles, the head digest, and current attester/backend anchor thresholds. +SSHSIGCHAIN local +record storage, signing, publication, import, accepted-head persistence, and +concrete anchor adapters remain follow-up work. Until they exist, do not +substitute an unpinned checkpoint or a local operation-log view for the +SSHSIGCHAIN trust tuple. Signing is mediated by OpenSSH. `--signing-key` may point at a private key file, a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 1ce33de..5bfc77d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -4113,7 +4113,10 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> { devices, disclosed_profiles, incomplete_profiles, - anchor_threshold, + anchor_attester_threshold, + anchor_backend_threshold, + required_anchor_backends, + required_anchor_classes, note, } => { println!("sigchain: {}", input.display()); @@ -4124,7 +4127,10 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> { println!("devices: {devices}"); println!("disclosed_profiles: {disclosed_profiles}"); println!("incomplete_profiles: {incomplete_profiles}"); - println!("anchor_threshold: {anchor_threshold}"); + println!("anchor_attester_threshold: {anchor_attester_threshold}"); + println!("anchor_backend_threshold: {anchor_backend_threshold}"); + println!("required_anchor_backends: {required_anchor_backends}"); + println!("required_anchor_classes: {required_anchor_classes}"); eprintln!("note: {note}"); } ControlResponse::KeychainExplained { subject, lines } => { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 0c42bb4..1164ed3 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -750,7 +750,10 @@ pub enum ControlResponse { devices: usize, disclosed_profiles: usize, incomplete_profiles: usize, - anchor_threshold: u16, + anchor_attester_threshold: u16, + anchor_backend_threshold: u16, + required_anchor_backends: usize, + required_anchor_classes: usize, note: String, }, KeychainExplained { diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 6da7262..263f6bf 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -13,16 +13,17 @@ mod sshsigchain; pub use sshsigchain::{ - AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier, AnchoredHistory, - AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState, AuthorityState, - AuthorityTransition, ChainId as SshSigchainChainId, Digest as SshSigchainDigest, HeadClaim, - MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, - ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, - SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, - SshSigchainError, SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, - SshSigchainVerifier, authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, - key_proof_signing_bytes, profile_payload_commitment, select_anchored_head, - verify_anchor_receipts, verify_head_claim, verify_sshsigchain, + AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier, + AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState, + AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId, + Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, + MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension, + SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE, + SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord, + SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory, + authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes, + profile_payload_commitment, select_anchored_head, verify_anchor_receipts, + verify_anchored_history, verify_head_claim, verify_sshsigchain, }; use geth_types::{ diff --git a/crates/geth-keychain/src/sshsigchain.rs b/crates/geth-keychain/src/sshsigchain.rs index f5a4156..919f6ee 100644 --- a/crates/geth-keychain/src/sshsigchain.rs +++ b/crates/geth-keychain/src/sshsigchain.rs @@ -107,7 +107,7 @@ pub enum Permission { KeyAddAny, KeyRevokeSelf, KeyRevokeAny, - PermissionManage, + ManagePermissions, AnchorPolicy, AnchorAttest, ProfileWrite(String), @@ -140,23 +140,28 @@ pub struct AnchorBackendPolicy { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +pub struct AnchorAttesterPolicy { + pub key_id: AuthorityKeyId, + pub weight: u16, + pub required: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AnchorPolicy { - /// Zero disables required external anchoring while retaining rollback checks against cached heads. - pub threshold: u16, + /// Required weight from distinct authorized head-claim signers. + pub attester_threshold: u16, + /// Explicit attesters. An empty list with a zero threshold permits any active `AnchorAttest` key. + pub attesters: Vec, + /// At least one valid backend receipt must come from every named class. + pub required_classes: Vec, + /// Required weight from distinct backends. + pub backend_threshold: u16, pub backends: Vec, } -impl Default for AnchorPolicy { - fn default() -> Self { - Self { - threshold: 0, - backends: Vec::new(), - } - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum AuthorityTransition { Genesis { device_id: String, @@ -553,12 +558,12 @@ fn authorize_record( delegable_permissions, .. } => { - require_permission(signer, &Permission::PermissionManage)?; + require_permission(signer, &Permission::ManagePermissions)?; ensure_delegation(signer, permissions)?; ensure_delegation(signer, delegable_permissions)?; } AuthorityTransition::PermissionRevoke { .. } => { - require_permission(signer, &Permission::PermissionManage)?; + require_permission(signer, &Permission::ManagePermissions)?; } AuthorityTransition::AnchorPolicySet { .. } => { require_permission(signer, &Permission::AnchorPolicy)?; @@ -820,6 +825,7 @@ pub fn verify_head_claim( return Err(SshSigchainError::UnauthorizedHeadClaim); } if claim.signature.is_empty() + || claim.signature.len() > MAX_SIGNATURE_BYTES || !verifier.verify( SSH_SIGCHAIN_ANCHOR_NAMESPACE, &claim.signing_bytes()?, @@ -832,20 +838,83 @@ pub fn verify_head_claim( Ok(()) } +fn verify_head_claims( + policy: &AnchorPolicy, + verification: &SshSigchainVerification, + claims: &[HeadClaim], + verifier: &V, +) -> Result, SshSigchainError> { + let mut valid_indices = Vec::new(); + let mut seen = BTreeSet::new(); + let mut weight = 0u32; + for (index, claim) in claims.iter().enumerate() { + if seen.contains(&claim.signer_key_id) + || verify_head_claim(verification, claim, verifier).is_err() + { + continue; + } + seen.insert(claim.signer_key_id); + let attester = policy + .attesters + .iter() + .find(|attester| attester.key_id == claim.signer_key_id); + if policy.attesters.is_empty() { + valid_indices.push(index); + } else if let Some(attester) = attester { + weight += u32::from(attester.weight); + valid_indices.push(index); + } + } + for attester in &policy.attesters { + if attester.required + && !valid_indices + .iter() + .any(|index| claims[*index].signer_key_id == attester.key_id) + { + return Err(SshSigchainError::RequiredAttesterMissing(attester.key_id)); + } + } + if valid_indices.is_empty() { + return Err(SshSigchainError::NoAuthorizedHeadClaim); + } + if weight < u32::from(policy.attester_threshold) { + return Err(SshSigchainError::AttesterThresholdNotMet { + required: policy.attester_threshold, + actual: weight, + }); + } + Ok(valid_indices) +} + pub fn verify_anchor_receipts( policy: &AnchorPolicy, claim: &HeadClaim, receipts: &[AnchorReceipt], verifier: &R, +) -> Result<(), SshSigchainError> { + verify_anchor_receipts_for_claims(policy, std::slice::from_ref(claim), receipts, verifier) +} + +fn verify_anchor_receipts_for_claims( + policy: &AnchorPolicy, + claims: &[HeadClaim], + receipts: &[AnchorReceipt], + verifier: &R, ) -> Result<(), SshSigchainError> { validate_anchor_policy(policy)?; - let claim_hash = claim.claim_hash()?; let mut weight = 0u32; + let mut witnessed_classes = BTreeSet::new(); for backend in &policy.backends { - let valid = receipts.iter().any(|receipt| { - receipt.backend_id == backend.backend_id - && receipt.claim_hash == claim_hash - && verifier.verify_receipt(backend, claim, receipt) + let valid = claims.iter().any(|claim| { + let Ok(claim_hash) = claim.claim_hash() else { + return false; + }; + receipts.iter().any(|receipt| { + receipt.evidence.len() <= MAX_PAYLOAD_BYTES + && receipt.backend_id == backend.backend_id + && receipt.claim_hash == claim_hash + && verifier.verify_receipt(backend, claim, receipt) + }) }); if backend.required && !valid { return Err(SshSigchainError::RequiredAnchorMissing( @@ -854,11 +923,17 @@ pub fn verify_anchor_receipts( } if valid { weight += u32::from(backend.weight); + witnessed_classes.insert(backend.class.clone()); } } - if weight < u32::from(policy.threshold) { + for class in &policy.required_classes { + if !witnessed_classes.contains(class) { + return Err(SshSigchainError::RequiredAnchorClassMissing(class.clone())); + } + } + if weight < u32::from(policy.backend_threshold) { return Err(SshSigchainError::AnchorThresholdNotMet { - required: policy.threshold, + required: policy.backend_threshold, actual: weight, }); } @@ -868,18 +943,60 @@ pub fn verify_anchor_receipts( #[derive(Clone, Debug)] pub struct AnchoredHistory<'a> { pub verification: &'a SshSigchainVerification, - pub claim: &'a HeadClaim, + pub claims: &'a [HeadClaim], + pub receipts: &'a [AnchorReceipt], +} + +#[derive(Clone, Debug)] +pub struct VerifiedAnchoredHistory<'a> { + verification: &'a SshSigchainVerification, +} + +impl<'a> VerifiedAnchoredHistory<'a> { + #[must_use] + pub fn verification(&self) -> &'a SshSigchainVerification { + self.verification + } +} + +pub fn verify_anchored_history<'a, V, R>( + history: AnchoredHistory<'a>, + signature_verifier: &V, + receipt_verifier: &R, +) -> Result, SshSigchainError> +where + V: SshSigchainVerifier + ?Sized, + R: AnchorReceiptVerifier + ?Sized, +{ + let valid_claims = verify_head_claims( + &history.verification.state.head_anchor_policy, + history.verification, + history.claims, + signature_verifier, + )?; + let claims: Vec<_> = valid_claims + .into_iter() + .map(|index| history.claims[index].clone()) + .collect(); + verify_anchor_receipts_for_claims( + &history.verification.state.head_anchor_policy, + &claims, + history.receipts, + receipt_verifier, + )?; + Ok(VerifiedAnchoredHistory { + verification: history.verification, + }) } pub fn select_anchored_head<'a>( - histories: &'a [AnchoredHistory<'a>], + histories: &'a [VerifiedAnchoredHistory<'a>], cached_head: Option, -) -> Result<&'a AnchoredHistory<'a>, SshSigchainError> { +) -> Result<&'a VerifiedAnchoredHistory<'a>, SshSigchainError> { let mut candidates: Vec<_> = histories .iter() .filter(|history| { - history.claim.head == history.verification.head - && cached_head.is_none_or(|head| history.verification.history.contains(&head)) + cached_head.is_none_or(|head| history.verification.history.contains(&head)) }) .collect(); if candidates.is_empty() { @@ -1062,7 +1179,9 @@ pub enum SshSigchainError { UnknownKey, #[error("key is inactive")] InactiveKey, - #[error("anchor policy contains invalid, duplicate, or unsatisfiable backend rules")] + #[error( + "anchor policy contains invalid, duplicate, unsorted, or unsatisfiable attester/backend rules" + )] InvalidAnchorPolicy, #[error("head claim does not name the verified chain head")] ClaimHeadMismatch, @@ -1070,8 +1189,16 @@ pub enum SshSigchainError { UnauthorizedHeadClaim, #[error("head claim signature is invalid")] InvalidHeadClaimSignature, + #[error("no supplied head claim has a distinct authorized signer at this head")] + NoAuthorizedHeadClaim, + #[error("required head attester {0:?} has no valid claim")] + RequiredAttesterMissing(AuthorityKeyId), + #[error("head attester threshold not met: required {required}, got {actual}")] + AttesterThresholdNotMet { required: u16, actual: u32 }, #[error("required anchor backend {0} has no valid receipt")] RequiredAnchorMissing(String), + #[error("required anchor backend class {0} has no valid receipt")] + RequiredAnchorClassMissing(String), #[error("anchor threshold not met: required {required}, got {actual}")] AnchorThresholdNotMet { required: u16, actual: u32 }, #[error("no valid anchored head was supplied")] @@ -1156,6 +1283,34 @@ fn validate_authority_key(key: &AuthorityKey) -> Result<(), SshSigchainError> { } fn validate_anchor_policy(policy: &AnchorPolicy) -> Result<(), SshSigchainError> { + let mut attester_ids = BTreeSet::new(); + let mut total_attester_weight = 0u32; + let mut previous_attester = None; + for attester in &policy.attesters { + if attester.weight == 0 + || !attester_ids.insert(attester.key_id) + || previous_attester.is_some_and(|previous| previous >= attester.key_id) + { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + previous_attester = Some(attester.key_id); + total_attester_weight += u32::from(attester.weight); + } + if u32::from(policy.attester_threshold) > total_attester_weight { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + let required_classes: BTreeSet<_> = policy.required_classes.iter().cloned().collect(); + if required_classes.len() != policy.required_classes.len() + || !policy + .required_classes + .windows(2) + .all(|pair| pair[0] < pair[1]) + { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + for class in &policy.required_classes { + validate_identifier(class.clone())?; + } let mut ids = BTreeSet::new(); let mut total = 0u32; let mut previous_id: Option<&str> = None; @@ -1173,7 +1328,15 @@ fn validate_anchor_policy(policy: &AnchorPolicy) -> Result<(), SshSigchainError> previous_id = Some(&backend.backend_id); total += u32::from(backend.weight); } - if u32::from(policy.threshold) > total { + if u32::from(policy.backend_threshold) > total { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + if !required_classes.iter().all(|class| { + policy + .backends + .iter() + .any(|backend| &backend.class == class) + }) { return Err(SshSigchainError::InvalidAnchorPolicy); } Ok(()) @@ -1343,7 +1506,7 @@ fn encode_permissions( Permission::KeyAddAny => out.push(4), Permission::KeyRevokeSelf => out.push(5), Permission::KeyRevokeAny => out.push(6), - Permission::PermissionManage => out.push(7), + Permission::ManagePermissions => out.push(7), Permission::AnchorPolicy => out.push(8), Permission::AnchorAttest => out.push(9), Permission::ProfileWrite(profile) => { @@ -1361,7 +1524,18 @@ fn encode_permissions( fn encode_anchor_policy(out: &mut Vec, policy: &AnchorPolicy) -> Result<(), SshSigchainError> { validate_anchor_policy(policy)?; - out.extend_from_slice(&policy.threshold.to_be_bytes()); + out.extend_from_slice(&policy.attester_threshold.to_be_bytes()); + push_u16(out, policy.attesters.len())?; + for attester in &policy.attesters { + out.extend_from_slice(&(attester.key_id.0).0); + out.extend_from_slice(&attester.weight.to_be_bytes()); + out.push(u8::from(attester.required)); + } + push_u16(out, policy.required_classes.len())?; + for class in &policy.required_classes { + push_u16_bytes(out, class.as_bytes())?; + } + out.extend_from_slice(&policy.backend_threshold.to_be_bytes()); push_u16(out, policy.backends.len())?; for backend in &policy.backends { push_u16_bytes(out, backend.backend_id.as_bytes())?; @@ -1467,6 +1641,19 @@ mod tests { struct TestVerifier; + struct TestReceiptVerifier; + + impl AnchorReceiptVerifier for TestReceiptVerifier { + fn verify_receipt( + &self, + _: &AnchorBackendPolicy, + _: &HeadClaim, + receipt: &AnchorReceipt, + ) -> bool { + receipt.evidence == b"valid" + } + } + impl SshSigchainVerifier for TestVerifier { fn verify( &self, @@ -1537,6 +1724,17 @@ mod tests { record.with_signature(signature).expect("signed") } + fn signed_claim(trust: &SshSigchainTrust, verification: &SshSigchainVerification) -> HeadClaim { + let claim = + HeadClaim::unsigned(trust.chain_id, verification.head, ROOT_KEY).expect("claim"); + let signature = test_signature( + SSH_SIGCHAIN_ANCHOR_NAMESPACE, + &claim.signing_bytes().expect("claim bytes"), + ROOT_KEY, + ); + claim.with_signature(signature).expect("signed claim") + } + #[test] fn chain_order_uses_only_parent_hashes() { let trust = trust(); @@ -1654,7 +1852,33 @@ mod tests { ) .expect("valid key add"); assert_eq!(verified.state.active_key_count(), 2); + let invalid_proof = signed_record( + &trust, + Some(add_device.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::KeyAdd { + device_id: "device:phone".to_owned(), + key: key.clone(), + proof: vec![1], + }, + vec![], + ); + assert_eq!( + verify_sshsigchain( + &[first.clone(), add_device.clone(), invalid_proof], + &trust, + &TestVerifier + ), + Err(SshSigchainError::InvalidKeyProof) + ); let bad_key = authority_key(SECOND_KEY, vec![Permission::AnchorPolicy]); + let bad_proof_message = + key_proof_signing_bytes(trust.chain_id, "device:phone", &bad_key).expect("proof bytes"); + let bad_proof = test_signature( + SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, + &bad_proof_message, + SECOND_KEY, + ); let bad = signed_record( &trust, Some(add_device.record_hash().expect("hash")), @@ -1662,13 +1886,15 @@ mod tests { AuthorityTransition::KeyAdd { device_id: "device:phone".to_owned(), key: bad_key, - proof: vec![1], + proof: bad_proof, }, vec![], ); assert!(matches!( verify_sshsigchain(&[first, add_device, bad], &trust, &TestVerifier), - Err(SshSigchainError::InvalidKeyProof | SshSigchainError::DeviceCeilingExceeded(_)) + Err(SshSigchainError::DeviceCeilingExceeded( + Permission::AnchorPolicy + )) )); } @@ -1745,32 +1971,20 @@ mod tests { #[test] fn head_claims_require_current_authority_and_policy_receipts() { - struct ReceiptVerifier; - impl AnchorReceiptVerifier for ReceiptVerifier { - fn verify_receipt( - &self, - _: &AnchorBackendPolicy, - _: &HeadClaim, - receipt: &AnchorReceipt, - ) -> bool { - receipt.evidence == b"valid" - } - } - let trust = trust(); let verification = verify_sshsigchain(&[genesis(&trust)], &trust, &TestVerifier).expect("chain"); - let claim = - HeadClaim::unsigned(trust.chain_id, verification.head, ROOT_KEY).expect("claim"); - let signature = test_signature( - SSH_SIGCHAIN_ANCHOR_NAMESPACE, - &claim.signing_bytes().expect("bytes"), - ROOT_KEY, - ); - let claim = claim.with_signature(signature).expect("signed claim"); + let claim = signed_claim(&trust, &verification); verify_head_claim(&verification, &claim, &TestVerifier).expect("authorized claim"); let policy = AnchorPolicy { - threshold: 1, + attester_threshold: 1, + attesters: vec![AnchorAttesterPolicy { + key_id: trust.root_key_id().expect("root ID"), + weight: 1, + required: true, + }], + required_classes: vec!["transparency".to_owned()], + backend_threshold: 1, backends: vec![AnchorBackendPolicy { backend_id: "transparency-main".to_owned(), class: "transparency".to_owned(), @@ -1784,9 +1998,20 @@ mod tests { claim_hash: claim.claim_hash().expect("claim hash"), evidence: b"valid".to_vec(), }; - verify_anchor_receipts(&policy, &claim, &[receipt], &ReceiptVerifier).expect("receipts"); + assert_eq!( + verify_head_claims( + &policy, + &verification, + std::slice::from_ref(&claim), + &TestVerifier + ) + .expect("attesters"), + vec![0] + ); + verify_anchor_receipts(&policy, &claim, &[receipt], &TestReceiptVerifier) + .expect("receipts"); assert!(matches!( - verify_anchor_receipts(&policy, &claim, &[], &ReceiptVerifier), + verify_anchor_receipts(&policy, &claim, &[], &TestReceiptVerifier), Err(SshSigchainError::RequiredAnchorMissing(_)) )); } @@ -1796,7 +2021,7 @@ mod tests { let trust = trust(); let first = genesis(&trust); let new_policy = AnchorPolicy { - threshold: 1, + backend_threshold: 1, backends: vec![AnchorBackendPolicy { backend_id: "nostr-main".to_owned(), class: "nostr".to_owned(), @@ -1804,6 +2029,7 @@ mod tests { weight: 1, required: true, }], + ..AnchorPolicy::default() }; let change = signed_record( &trust, @@ -1852,32 +2078,34 @@ mod tests { ); let va = verify_sshsigchain(&[first.clone(), a], &trust, &TestVerifier).expect("a"); let vb = verify_sshsigchain(&[first, b], &trust, &TestVerifier).expect("b"); - let ca = HeadClaim::unsigned(trust.chain_id, va.head, ROOT_KEY).expect("claim a"); - let cb = HeadClaim::unsigned(trust.chain_id, vb.head, ROOT_KEY).expect("claim b"); + let claims_a = vec![signed_claim(&trust, &va)]; + let claims_b = vec![signed_claim(&trust, &vb)]; + let anchored_a = verify_anchored_history( + AnchoredHistory { + verification: &va, + claims: &claims_a, + receipts: &[], + }, + &TestVerifier, + &TestReceiptVerifier, + ) + .expect("anchored a"); + let anchored_b = verify_anchored_history( + AnchoredHistory { + verification: &vb, + claims: &claims_b, + receipts: &[], + }, + &TestVerifier, + &TestReceiptVerifier, + ) + .expect("anchored b"); assert!(matches!( - select_anchored_head( - &[ - AnchoredHistory { - verification: &va, - claim: &ca - }, - AnchoredHistory { - verification: &vb, - claim: &cb - } - ], - None - ), + select_anchored_head(&[anchored_a.clone(), anchored_b], None), Err(SshSigchainError::ForkDetected) )); assert!(matches!( - select_anchored_head( - &[AnchoredHistory { - verification: &va, - claim: &ca - }], - Some(Digest([3; 32])) - ), + select_anchored_head(&[anchored_a], Some(Digest([3; 32]))), Err(SshSigchainError::RollbackDetected) )); } @@ -1900,4 +2128,28 @@ mod tests { Err(SshSigchainError::JsonLine { .. }) )); } + + #[test] + fn signing_bytes_match_the_published_base_vector() { + let record = SshSigchainRecord::unsigned( + ChainId([0; 32]), + None, + ROOT_KEY, + AuthorityTransition::Noop, + vec![], + ) + .expect("unsigned vector"); + assert_eq!( + hex::encode(record.signing_bytes().expect("bytes")), + concat!( + "5353435301", + "0000000000000000000000000000000000000000000000000000000000000000", + "00", + "eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2", + "00107373682d656432353531392041514944", + "08", + "0000" + ) + ); + } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 57008ea..7ad1644 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -6518,7 +6518,16 @@ pub fn handle_request( devices: verified.state.devices.values().filter(|device| device.active).count(), disclosed_profiles: verified.state.disclosed_profiles.len(), incomplete_profiles: verified.state.incomplete_profiles.len(), - anchor_threshold: verified.state.anchor_policy.threshold, + anchor_attester_threshold: verified.state.anchor_policy.attester_threshold, + anchor_backend_threshold: verified.state.anchor_policy.backend_threshold, + required_anchor_backends: verified + .state + .anchor_policy + .backends + .iter() + .filter(|backend| backend.required) + .count(), + required_anchor_classes: verified.state.anchor_policy.required_classes.len(), note: "verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key" .to_owned(), @@ -11090,7 +11099,7 @@ mod tests { .expect("unsigned init"), ); - let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone()], &trust) + let verified = verify_keychain_sshsigchain_with_ssh(std::slice::from_ref(&init), &trust) .expect("verify real OpenSSH SSHSIG chain"); assert_eq!(verified.records, 1); assert_eq!(verified.state.active_key_count(), 1); diff --git a/docs/adr/0018-sshsigchain.md b/docs/adr/0018-sshsigchain.md index 3b5e167..bad5a07 100644 --- a/docs/adr/0018-sshsigchain.md +++ b/docs/adr/0018-sshsigchain.md @@ -1,42 +1,64 @@ -# ADR 0018: Linked SSHSIGCHAIN v1 +# ADR 0018: Sequence-free SSHSIGCHAIN authority protocol ## Status -Accepted for the generic core and geth keychain profile. The pre-standard -test-only static workflow is removed; it is not an alternate format or a -compatibility path. +Accepted for the generic core. The protocol is still pre-deployment and has no +legacy compatibility requirement. ## Context -The prior static JSONL keychain flow replayed records after sorting mutable -timestamps and used a downloaded allowed-signers projection to verify its own -checkpoint. That permits self-bootstrapping trust and makes revocation, -rollback, and fork semantics inadequate for a trust foundation. +The first linked draft improved on a timestamp-sorted static bundle, but still +made application profiles responsible for all key lifecycle semantics. It also +included both a sequence counter and a parent hash, fixed one application +profile for the whole chain, exposed every payload, and left rollback anchors +outside the protocol model. + +That design would let applications fragment device/key behavior, made +multi-profile least privilege awkward, and offered no standard way to disclose +only selected profile data or compare independently anchored heads. ## Decision -Geth adopts SSHSIGCHAIN version 1 as its one portable signed-chain design: +SSHSIGCHAIN v1 is redesigned before deployment: -- root trust is an explicit `(chain ID, profile, namespace, root key)` tuple; -- records are linearly ordered by sequence and linked by a digest over their - signed content and SSHSIG signature; -- the initial root is only a genesis requirement, not a permanent bypass; -- profile authorization runs against the causally preceding state; -- key lifecycle is represented by causal add/revoke records, not timestamp - validity fields; and -- payloads use deterministic binary codecs with a strict round-trip check. +- the trust tuple is `(chain ID, SSHSIG namespace, root public key)`; +- parent hashes alone define order; there is no sequence number; +- link identity hashes the signed outer bytes, not the signature encoding; +- every link carries a mandatory public Authority v1 transition; +- authority owns devices, keys, proof-of-possession, causal revocation, + permission ceilings, delegation, and anchor policy; +- applications use profile-ID-scoped, salted payload commitments and cannot + mutate authority; +- disclosures are transport additions which do not change a link or its hash; +- authorized keys can sign head claims; +- distinct head attesters and backend-neutral receipts are evaluated by + separate weighted/required thresholds and required backend classes; +- cached ancestors are never replaced by older heads and incomparable verified + histories fail as forks; and +- a policy-change head is witnessed under the preceding policy before the new + policy governs descendants. -The generic core is transport-independent. Geth continues using Iroh for all -node-to-node communication; SSH remains an identity/signature integration and -not a geth transport. +The generic core defines backend interfaces and deterministic policy behavior, +not Nostr, HTTP, blockchain, or transparency-log clients. Those adapters belong +outside `geth-keychain`. Backends provide evidence and discovery, not consensus. + +The generic protocol remains independent of geth transports. All geth +node-to-node communication remains Iroh-only; SSH remains a trust and signature +integration. ## Consequences -The core can be published and implemented by applications without importing -geth's resource model. Geth's keychain profile is intentionally narrow and -tested against self-bootstrap, fork, and backdated-revocation attacks. +Device and key behavior is interoperable rather than reinvented in each +profile. A verifier can validate current authority while withholding application +data, but must report affected profile state as incomplete. Proofs of possession +prevent an administrator from silently enrolling a key it does not control. +Device ceilings and delegable permission sets constrain later amplification. -The old static bundle code and commands are deleted rather than supported beside -SSHSIGCHAIN. There is no migration format because the repository has not been -deployed. Head persistence and later witness/transparency support remain -follow-up work; neither is implied by a single signed checkpoint. +The wire format is intentionally incompatible with every pre-deployment test +draft. No migration parser or version alias is retained. + +Rollback protection still depends on persistent local accepted heads and the +operator's anchor policy. A Nostr relay, mutable HTTP URL, blockchain, or +transparency service has only the guarantees its receipt verifier and deployment +actually establish. Concrete publication, durable accepted-head storage, and +cross-implementation vectors remain follow-up work. diff --git a/docs/architecture.md b/docs/architecture.md index 38dc855..180a178 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -478,14 +478,19 @@ trust state; idempotent repeats leave the original bytes unchanged. The portable signed-chain design is specified in [`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit -out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust -tuple, a strict sequence plus hash link, bounded fields, and a profile reducer -that authorizes each record from only the causally preceding state. Geth's -`geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization -policy and treats key revocation as a causal record. The prior test-only static -workflow was removed rather than migrated. Iroh keychain sync remains the -current replicated local operation-log path while explicit SSHSIGCHAIN -production and import workflows are completed. +out-of-band `(chain ID, SSHSIG namespace, root public key)` trust tuple. Exact +parent hashes define causal order without a sequence counter. A mandatory, +public authority reducer owns devices, keys, proof-of-possession, revocation, +permission ceilings, delegation, and anchor policy; application profiles can +only attach salted payload commitments and cannot mutate trust. Disclosures do +not change link identity, and missing disclosures leave profile state +explicitly incomplete. Signed head claims, backend-neutral receipts, cached-head +rollback checks, and incomparable-history detection define the anchoring +boundary. Concrete Nostr, HTTP, blockchain, and transparency adapters stay +outside `geth-keychain` and do not select canonical history. The prior +test-only static workflow was removed rather than migrated. Iroh keychain sync +remains the current replicated local operation-log path while explicit +SSHSIGCHAIN production and import workflows are completed. New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll join` explicitly imports an owner admin public key as diff --git a/docs/roadmap.md b/docs/roadmap.md index ee2ced9..bee9a31 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -561,9 +561,21 @@ resource-scoped capability decisions. - `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record format with explicit trust-anchor, chain-link, size-limit, and non-claim documentation. - - `[x]` Provide a reusable verifier core and a geth keychain profile that - rejects self-bootstrap, non-linked forks, non-canonical payloads, and - post-revocation timestamp replay. + - `[x]` Remove the redundant sequence counter; exact parent hashes alone + define genesis and causal order. + - `[x]` Provide a mandatory Authority v1 reducer for devices, keys, + proof-of-possession, causal revocation, device permission ceilings, scoped + permissions, delegation, and anchor-policy changes. + - `[x]` Attach application data through profile-scoped salted commitments; + disclosures do not change link identity and missing payloads make profile + state explicitly incomplete. + - `[x]` Reject self-bootstrap, non-linked histories, noncanonical keys and + permission lists, permission amplification, invalid key proofs, and use of + a revoked device/key on later links. + - `[x]` Define signed head claims, separate weighted/required attester and + backend receipt policies, required backend classes, old-policy witnessing + of policy-change heads, cached-head rollback checks, and + incomparable-history fork failure. - `[~]` Add explicit CLI storage, signing, verification, publication, and import workflows for a pinned SSHSIGCHAIN trust tuple. - `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file @@ -572,15 +584,26 @@ resource-scoped capability decisions. head-advance workflows are complete. - `[ ]` Persist accepted heads and require proof of extension before a source can advance. + - `[ ]` Implement and separately test concrete anchor adapters. + - Acceptance criteria: immutable Nostr claim publication, signed HTTP or + transparency evidence, and any blockchain adapter all bind the canonical + `HeadClaim`; none treats backend ordering as SSHSIGCHAIN consensus. + - Acceptance criteria: adapter threat models document deletion, stale + reads, timestamp manipulation, backend equivocation, inclusion proof, and + operator-independence assumptions. + - `[ ]` Add record construction and disclosure-management commands which + generate unpredictable 32-byte nonces and never expose hidden payloads in + signing-byte diagnostics. - `[x]` Delete the previous test-only static export, publication, verification, import, checkpoint, and fetch workflow. It is not a compatibility target because it was never deployed. - `[~]` Add OpenSSH integration tests and independently generated wire test vectors for the published standard. - - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies a - linked root/init chain. - - `[x]` The specification and reference implementation share a base - signing-byte test vector. + - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies an + Authority v1 genesis link. + - `[~]` The specification and reference implementation share base signing, + key-proof, commitment, and head-claim vectors. + - `[ ]` Publish the complete vector set in the specification. - `[ ]` Add independently generated cross-implementation vectors. - `[x]` SSH-admin-rooted keychain initialization. diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md index 44a5b4a..2ed2584 100644 --- a/docs/sigchain-keychain.md +++ b/docs/sigchain-keychain.md @@ -11,9 +11,9 @@ authorization, and OpenSSH `allowed_signers` projection. [`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable signed-chain format in this repository. It is deliberately separate from the current local keychain operation log: SSHSIGCHAIN starts with an explicit -out-of-band trust tuple, has a strict sequence and hash link, and never uses a -downloaded `allowed_signers` file as a trust root. The previous test-only -static JSONL publication format was removed. +out-of-band trust tuple, uses exact parent hashes without a sequence counter, +and never uses a downloaded `allowed_signers` file as a trust root. The previous +test-only static JSONL publication format was removed. ## Local keychain model @@ -56,28 +56,36 @@ key in the previously accepted local view verifies over the canonical payload. This is useful for the local Iroh-synchronized operation log, but it is not a portable SSHSIGCHAIN history and MUST NOT be presented as one. -## SSHSIGCHAIN keychain profile +## SSHSIGCHAIN authority and profiles -Geth's SSHSIGCHAIN profile identifier is `geth.keychain.sshsigchain.v1` and its -SSHSIG namespace is `sshsigchain.v1`. Its canonical binary payload is a -versioned mirror of a keychain operation; it is separate from the -human-facing, internally tagged JSON API type so a decoder can prove one unique -payload encoding. +SSHSIGCHAIN uses the `sshsigchain.v1` SSHSIG namespace. Device/key management is +not a geth-specific profile: the mandatory Authority v1 reducer defines genesis, +device add/revoke, key add/revoke, proof-of-possession, scoped and delegable +permissions, device permission ceilings, anchor-policy changes, and no-op links. +Authority is always public and evaluated from the exact parent state. The root +key seeds authority only at genesis and can later be causally revoked. -The profile requires: +Applications attach profile-ID-scoped commitments. A fresh 32-byte nonce salts +each payload commitment so a withheld small value is not directly vulnerable to +dictionary guessing. A disclosure can be added to or removed from transport +without changing the SSH signature or record hash. Hidden application data can +never alter devices, keys, permissions, or anchor policy. Verification without +all disclosures remains useful for authority, but affected application profile +state is reported as incomplete. -- sequence zero to be `KeychainInit` signed by the operator-pinned root key; -- sequence one to be an `AdminKeyAdd` recording that root key; -- every following signer to be an active admin key in the causally prior - profile state; -- every keychain operation ID to occur only once in the chain; -- each added admin key to have canonical key material matching its declared key - fingerprint; and -- causal add/revoke records rather than validity windows or payload timestamps - as authorization policy. +Keys receive direct permissions such as `DeviceAdd`, `KeyAddSelf`, +`ManagePermissions`, `AnchorAttest`, and `ProfileWrite()`. An attached +key cannot exceed its device ceiling, and it cannot grant a permission outside +its delegable set. Newly added keys must sign the exact proposed key/device/ +permission binding under `sshsigchain.key-proof.v1`. -The root key seeds authorization at genesis only. A causally valid revocation -removes it like any other admin key. +Head claims are separately signed by an active `AnchorAttest` key. An authority +anchor policy names weighted/required attesting keys plus backend IDs, classes, +locators, weights, required backends/classes, and a separate backend threshold. +Adapters verify backend-specific receipts, while the core +rejects rollback behind a cached head and incomparable histories. Backends do +not decide which history is canonical. The head that changes anchor policy is +witnessed under the previous policy. To inspect an SSHSIGCHAIN JSONL transport file, pin the chain ID and root public key locally: @@ -89,11 +97,11 @@ geth keychain verify-sigchain \ --root-key ~/.ssh/geth-root.pub ``` -This verifier is experimental while geth adds its own record creation, -publication, import, accepted-head persistence, and independently generated -wire vectors. Those later workflows must preserve the same explicit trust -tuple; they must not introduce a compatibility route for the removed static -format. +This verifier is experimental while geth adds record creation, publication, +import, accepted-head persistence, concrete anchor adapters, and independently +generated wire vectors. Those later workflows must preserve the same explicit +trust tuple and Authority v1 semantics; they must not introduce a compatibility +route for the removed static format. ## Local commands diff --git a/docs/sshsigchain.md b/docs/sshsigchain.md index d030ea0..b9f3ede 100644 --- a/docs/sshsigchain.md +++ b/docs/sshsigchain.md @@ -1,270 +1,382 @@ # SSHSIGCHAIN v1 -Status: Draft 1 (pre-deployment) +Status: Draft 2 (pre-deployment) -This document specifies a deliberately small, generic append-only signature -chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the -first and only defined version of SSHSIGCHAIN. It is transport -independent: a chain may be carried by a file, object store, HTTP, database, or -mesh protocol. It does not make SSH a transport. +SSHSIGCHAIN is a transport-independent, OpenSSH-signed authority chain. It is +intended to be implementable outside geth. A chain can be carried by JSONL, +HTTP, an object store, Iroh, or another transport; SSH is a signature mechanism, +not a transport. -The reference implementation lives in geth's `geth-keychain` crate. Geth uses -the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and -verification core do not depend on geth data types. +Version 1 is the first and only format. There is no legacy grammar or downgrade +mode. -## 1. Goals +## 1. Security goals and non-goals -SSHSIGCHAIN v1 provides: +SSHSIGCHAIN provides: -- a fixed, deterministic byte sequence for every signed record; -- an explicit, out-of-band root public key rather than trust bootstrapped from - downloaded chain content; -- one causally ordered, hash-linked history, so a later record cannot be made - earlier by changing a timestamp; -- application-defined authorization and state transitions evaluated at each - chain position; -- bounded field and chain sizes suitable for processing untrusted transport - input; and -- direct use of OpenSSH's `ssh-keygen -Y sign` / `-Y verify` SSHSIG format. +- an explicit out-of-band `(chain_id, namespace, root_public_key)` trust tuple; +- deterministic signing bytes and causal parent hashes, with no sequence number + or timestamp ordering; +- a mandatory public authority state machine for devices, keys, causal + revocation, permissions, delegation, and anchor policy; +- proof-of-possession when a key is added; +- salted commitments for selectively disclosed application-profile payloads; +- signed head claims and a generic receipt policy for independent anchor + backends; and +- fail-closed rollback and fork detection relative to a cached head or multiple + verified histories. -It does not provide quorum, transparency, equivocation detection, secure clock -attestation, encrypted payloads, or a distributed consensus protocol. A valid -signature from an authorized key is still authority to make whatever change the -application profile permits. +It does not provide consensus, guaranteed publication, trusted time, anonymity, +zero-knowledge disclosure, payload encryption, or automatic compromise +recovery. An authorized key can perform every transition its effective +permissions allow. Outer links reveal the chain ID, signer, authority +transition, profile IDs, and payload commitments even when payloads are hidden. -## 2. Terminology and required inputs +## 2. Trust inputs and identifiers -An implementation has four separately configured trust inputs: +A verifier obtains these values through an authenticated channel, never from a +downloaded chain: -1. `chain_id`: exactly 32 random bytes, rendered as 64 hexadecimal characters - when displayed. -2. `profile`: an ASCII identifier naming the application payload rules. -3. `namespace`: the OpenSSH SSHSIG namespace passed to `ssh-keygen -Y`. -4. `root_public_key`: one canonical OpenSSH public-key line. +1. `chain_id`: 32 independently generated random bytes; +2. `namespace`: the 1–128 byte printable, non-whitespace SSHSIG namespace + (`sshsigchain.v1` by default); and +3. `root_public_key`: canonical OpenSSH public-key text. -These values are a trust anchor. They must come from local configuration, -enrollment material, release metadata, or another authenticated out-of-band -channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT -be used to discover or replace them. - -The v1 core accepts a single root key. A threshold or witness scheme is a -separate protocol extension and must bind the same `chain_id`, profile, and -head digest; it is not implied by multiple signatures attached to a record. - -## 3. Canonical public-key text - -The public-key field is exactly two ASCII whitespace-separated fields: +Canonical key text is exactly: ```text - SP + SP ``` -Comments, leading/trailing whitespace, and extra fields are prohibited. The -base64 form is the standard canonical encoding of the decoded key blob. An -implementation MUST compare canonical text, not an operator-supplied comment, -when binding a record signer to policy state. The SSHSIG verifier MUST still -verify the actual OpenSSH key and signature; textual validation alone is not a -cryptographic verification. +Comments, leading/trailing whitespace, and additional fields are prohibited. +Keys are identified by: -## 4. Record model +```text +BLAKE3("sshsigchain.key-id.v1\0" || canonical_public_key) +``` -A record has these logical fields: +Protocol identifiers are 1–128 ASCII bytes containing letters, digits, `.`, +`-`, `_`, `:`, or `/`. -| Field | Type | Rule | +## 3. Outer-link model + +A record contains: + +| Field | Meaning | +| --- | --- | +| `chain_id` | Exact configured 32-byte chain ID. | +| `previous` | Absent only for genesis; otherwise the preceding record hash. | +| `signer_key_id` | Hash-derived ID of `signer_public_key`. | +| `signer_public_key` | Canonical OpenSSH public key used for SSHSIG verification. | +| `authority` | Exactly one public authority transition. | +| `extensions` | Strictly profile-ID-sorted payload commitments and optional disclosures. | +| `signature` | OpenSSH SSHSIG signature over the canonical signing bytes. | + +There is deliberately no sequence field. A record's position and causality are +fully determined by genesis plus the exact `previous` link. Record counts are +local indexing metadata and are not signed protocol state. + +The record hash named by its child is: + +```text +BLAKE3("sshsigchain.record-hash.v1\0" || signing_bytes) +``` + +The signature is verified but excluded from record identity. This prevents two +valid encodings of a signature over identical outer content from creating +different chain identities. + +## 4. Binary signing grammar + +Integers are unsigned big-endian. `string16` and `bytes16` are a `u16` byte +length followed by that many bytes; `bytes32` uses a `u32` length. Lists begin +with a `u16` element count. No alignment or terminator bytes are present. + +```text +"SSCS" 4 bytes +0x01 protocol version +chain_id 32 bytes +has_previous u8: 0 or 1 +previous 32 bytes when present +signer_key_id 32 bytes +signer_public_key string16 +authority_transition transition (section 5) +extension_count u16 +repeated extension_count times: + profile_id string16 + payload_commitment 32 bytes +``` + +Transport-only disclosure bytes and the SSHSIG signature are not part of these +bytes. The `signer_key_id` MUST match the canonical public key. Extensions MUST +be strictly sorted by profile ID with no duplicates. + +### 4.1 Base signing vector + +For an unsigned structural link with a zero `chain_id`, absent `previous`, +signer `ssh-ed25519 AQID`, `Noop` authority, and no extensions, the signing +bytes are: + +```text +5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d656432353531392041514944080000 +``` + +The derived signer key ID in that vector is +`eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2`. +This vector fixes the outer grammar; it is not a valid chain genesis because a +valid first record must carry `Genesis` authority. + +## 5. Mandatory Authority v1 state machine + +Authority is part of the protocol suite, not an optional application profile. +Every record contains exactly one transition. Transition tags and fields are: + +| Tag | Transition | Fields after tag | | --- | --- | --- | -| `chain_id` | 32 bytes | Must equal the configured chain ID. | -| `profile` | UTF-8 ASCII identifier | Must equal the configured profile. | -| `sequence` | unsigned 64-bit integer | Starts at zero and increases by exactly one. | -| `previous` | absent or 32 bytes | Absent only at sequence zero; otherwise the preceding record digest. | -| `payload` | opaque byte string | Profile-defined canonical payload, at most 1 MiB. | -| `signer_public_key` | canonical key text | The key presented to SSHSIG verification. | -| `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. | +| `0` | `Genesis` | device ID, root authority key, anchor policy | +| `1` | `DeviceAdd` | device ID, permission ceiling | +| `2` | `DeviceRevoke` | device ID | +| `3` | `KeyAdd` | device ID, authority key, proof `bytes32` | +| `4` | `KeyRevoke` | key ID | +| `5` | `PermissionGrant` | key ID, permissions, delegable permissions | +| `6` | `PermissionRevoke` | key ID, permissions, delegable permissions | +| `7` | `AnchorPolicySet` | anchor policy | +| `8` | `Noop` | no fields | -The profile identifier is 1–128 ASCII bytes containing only letters, digits, -`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without -whitespace. Canonical public-key text is at most 16 KiB, signatures are at most -64 KiB, and a verifier MUST reject a chain over 100,000 records before -performing unbounded work. Implementations may set smaller limits. +An authority key encodes canonical public-key `string16`, permissions, then +delegable permissions. Permission lists MUST be strictly sorted according to +their encoded `(tag, profile ID)` ordering and contain no duplicates. -Timestamps are intentionally not fields in the generic ordering mechanism. -Applications may put timestamps in their payload, but MUST treat them as signed -metadata rather than a way to order, revoke, or retroactively authorize -records. Sequence and `previous` define causal order. +### 5.1 Genesis -## 5. Signing bytes +Genesis is the only record without `previous`. Its record signer and embedded +root key MUST equal the pinned root. The root key starts on the named root +device and MUST have both `All` authority and delegable `All` authority. Its +outer signature proves root-key possession. Genesis selects the initial anchor +policy. -The signing byte string is the following binary grammar. `u16` and `u32` are -unsigned big-endian lengths. `u64` is an unsigned big-endian integer. `bytes[n]` -contains exactly `n` bytes; no implicit terminator or alignment is present. +Another genesis is invalid. The pinned root has no permanent bypass: it can be +revoked like another key after genesis. + +### 5.2 Devices and keys + +Device IDs are stable opaque identifiers. `DeviceAdd` creates a device once and +sets its immutable permission ceiling. `DeviceRevoke` is causal and permanent; +it also disables every key attached to that device. IDs and keys are never +reused or replaced in place. + +`KeyAdd` declares the new key's device, active permissions, delegable +permissions, and a proof-of-possession SSHSIG made by the new key. The proof +uses namespace `sshsigchain.key-proof.v1` and these bytes: ```text -"SSCS" 4 bytes -0x01 1 byte (protocol version) -chain_id 32 bytes -sequence u64 -has_previous 1 byte: 0 or 1 -previous 32 bytes, only when has_previous is 1 -profile_length u16 -profile bytes[profile_length] -payload_length u32 -payload bytes[payload_length] -signer_key_length u16 -signer_public_key bytes[signer_key_length] +"SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 || +public_key:string16 || permissions || delegable_permissions ``` -The `signature` field is not in the signing bytes because SSHSIG signs those -bytes. The record digest, which binds the signature into the next link, is: +The adding signer must be allowed to add a key to its own device or any device, +as applicable. Every granted and delegable permission must be within both the +signer's delegation scope and the target device ceiling. `KeyRevoke` is causal +and permanent. + +### 5.3 Permissions + +Permission tags are: + +| Tag | Permission | +| --- | --- | +| `0` | `All` | +| `1` | `DeviceAdd` | +| `2` | `DeviceRevoke` | +| `3` | `KeyAddSelf` | +| `4` | `KeyAddAny` | +| `5` | `KeyRevokeSelf` | +| `6` | `KeyRevokeAny` | +| `7` | `ManagePermissions` | +| `8` | `AnchorPolicy` | +| `9` | `AnchorAttest` | +| `10` | `ProfileWrite(profile_id)` | +| `11` | `ProfileDelegate(profile_id)` | + +`All` satisfies every permission and is intended for bootstrap/recovery keys. +An effective key is active only when both it and its device are active. + +`PermissionGrant` requires `ManagePermissions`; every new permission must also +be within the signer's delegable set and the device ceiling. A delegable +`ProfileDelegate(x)` authorizes delegation of `ProfileWrite(x)` without +granting write access by itself. Revocation requires `ManagePermissions` and +takes effect before the next record is authorized. + +An implementation MUST authorize each record from the state after its parent, +never from a final-state key set or a payload timestamp. + +## 6. Selective disclosure + +Each extension contains: ```text -BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature) +profile_id +commitment = BLAKE3( + "sshsigchain.payload.v1\0" || + profile_id:string16 || nonce:32 || payload:bytes32 +) +optional transport disclosure { nonce, payload } ``` -`previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or -JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT -be signed or hashed as the record representation. +The 32-byte nonce MUST be generated unpredictably for each payload. It prevents +offline dictionary attacks against small hidden values; it is not encryption. +A disclosure verifies only when recomputation equals the signed commitment. +Removing or adding a valid disclosure does not change signing bytes or the +record hash. -### JSONL transport +Authority transitions are always public. Profile payloads MUST NOT create, +remove, revoke, authorize, or grant permissions to devices or keys. A verifier +can therefore verify chain authority without disclosures. If any needed +disclosure is absent, the affected profile state is `incomplete`; absence MUST +NOT be interpreted as an empty payload, deletion, or successful full replay. -This specification defines JSONL as a convenient interchange transport. Each -non-empty physical line contains exactly one JSON object with these fields: -`chain_id`, `profile`, `sequence`, `previous`, `payload`, -`signer_public_key`, and `signature`. `chain_id`, `previous` when present, -`payload`, and `signature` are JSON arrays of unsigned octets; `previous` is -`null` when absent. `profile` and `signer_public_key` are JSON strings, and -`sequence` is an unsigned integer. Unknown or duplicate fields MUST be -rejected. Object-member order and JSON whitespace carry no meaning. +## 7. Anchor policies and head claims -Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete -input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those -limits before buffering an unbounded line or input. These limits are transport -limits in addition to the record limits above. +Anchoring makes rollback or equivocation observable under a chosen policy; it +does not make a backend a consensus system. -### Base test vector - -This unsigned record fixes the field grammar independently of OpenSSH key -generation or signature randomness: +An anchor policy contains separately weighted attesters and publication +backends. Both lists and the required-class list are strictly sorted and unique: ```text -chain_id 32 zero bytes -profile "example.test" -sequence 0 -previous absent -payload 01 02 -signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2" +attester_threshold u16 +attester_count u16 +per attester: + key_id 32 bytes + weight u16 (non-zero) + required u8: 0 or 1 +required_class_count u16 +required classes repeated string16 +backend_threshold u16 +backend_count u16 +per backend: + backend_id string16 + class string16 + locator string16 + weight u16 (non-zero) + required u8: 0 or 1 ``` -Its signing bytes, encoded as lowercase hexadecimal, MUST be: +Attester key IDs and backend IDs are unique, and neither threshold can exceed +its corresponding total weight. Required attesters must publish valid claims; +required backends must have valid receipts independently of thresholds. At +least one valid receipt must come from every required backend class. Classes +such as `transparency`, `nostr`, `blockchain`, and `http` are policy labels; +adapter-specific receipt verification defines their evidence. An empty +attester list with threshold zero permits any active `AnchorAttest` key, but at +least one valid claim is still required for an anchored history. + +A key with `AnchorAttest` signs a `HeadClaim` under namespace +`sshsigchain.anchor.v1`: ```text -53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332 +"SSAH" || 0x01 || chain_id:32 || head:32 || signer_key_id:32 || +signer_public_key:string16 ``` -## 6. Verification algorithm +Every claim signer must be active and authorized at the claimed head. Distinct +signers are counted once against the attester policy. A receipt binds a backend +ID, one claim hash, and backend-defined evidence. A backend is counted once if +it has a valid receipt for any accepted claim. Claim identity is +`BLAKE3("sshsigchain.head-claim.v1\0" || claim_signing_bytes)`. -Given the configured trust input and an ordered candidate record list, a -conforming verifier MUST: +Adapters conceptually implement: -1. Reject an empty or over-limit chain. -2. For record `i`, require `sequence == i`, the configured chain ID and - profile, and the exact predecessor digest (or absent predecessor for `i=0`). -3. Require the sequence-zero record's canonical signer key to equal the - configured root public key. This is the only bootstrap rule. -4. Invoke OpenSSH SSHSIG verification using the configured namespace, the - canonical signing bytes, the record's public key, and its signature. Reject - on failure. -5. Ask the profile whether that signer is authorized by the state resulting - from records `0..i-1`. Reject on failure. -6. Apply the profile's deterministic transition. Reject on failure. -7. Hash the complete signed record and use that hash as the required - predecessor for the next record. +```text +publish(HeadClaim) -> AnchorReceipt +fetch(chain_id) -> HeadClaim[] +verify_receipt(policy_backend, HeadClaim, AnchorReceipt) -> bool +``` -Records are never re-sorted by payload timestamp, identifier, signature time, -or transport arrival time. A verifier that cannot obtain its configured trust -input MUST fail closed. +They do not choose a canonical head. After verifying claims, chains, authority, +and receipts, a client: -For OpenSSH interoperability, implementations use commands equivalent to: +1. satisfies required/weighted attesters and backend evidence under the policy + which governs that head; +2. rejects histories that do not contain its cached accepted head; +3. selects the furthest valid descendant; +4. rejects incomparable valid histories as a fork; and +5. persists the newly accepted head before trusting later descendants. + +The exact head containing `AnchorPolicySet` MUST satisfy the previous policy. +The new policy applies to its descendants. This prevents an authority from +weakening anchoring and treating the weakening record as already witnessed only +under the new policy. + +Nostr replaceable/addressable events and ordinary mutable HTTP resources are +discovery conveniences, not strong rollback evidence by themselves. Prefer +immutable events plus the SSH-signed claim, local head caching, independent +witnesses, or a transparency log with verifiable inclusion/consistency proofs. +A blockchain receipt proves inclusion under the configured backend rules; it +does not decide between forked SSHSIGCHAIN histories. + +## 8. Verification algorithm + +A conforming verifier MUST: + +1. reject an empty chain, over-limit inputs, noncanonical keys/lists, and + malformed commitments; +2. require the exact configured chain ID, absent genesis parent, and exact + parent hash thereafter; +3. require genesis to install the pinned root and initial authority state; +4. verify every outer SSHSIG using the configured namespace; +5. authorize the signer and transition from only the parent authority state; +6. verify new-key possession proofs and apply the authority transition; +7. require `ProfileWrite(profile_id)` for every extension and verify every + supplied disclosure; and +8. compute the record hash for the next link. + +Records are never reordered by timestamps, transport arrival, identifiers, or +record counts. A verifier without all trust inputs fails closed. + +## 9. JSONL transport and limits + +The reference transport uses one JSON object per nonblank line with exactly the +record fields in section 3. Byte arrays use JSON arrays of octets; enum objects +use the names in this document. Object order and whitespace are irrelevant. +Unknown or duplicate fields are rejected. + +Reference limits are: 16 KiB canonical key, 64 KiB signature/proof, 1 MiB one +profile payload, 1,024 extensions per record, 100,000 records, 5 MiB one JSONL +line, and 64 MiB one JSONL input. Implementations may impose smaller limits. + +## 10. OpenSSH interoperability + +Outer records use commands equivalent to: ```sh -ssh-keygen -Y sign -f -n -ssh-keygen -Y verify -f -I \ - -n -s < +ssh-keygen -Y sign -f -n sshsigchain.v1 +ssh-keygen -Y verify -f -I sshsigchain \ + -n sshsigchain.v1 -s < ``` -The allowed-signers file used for this individual cryptographic check contains -only the record's already-canonical signer key. Authorization remains the -profile's job; a downloaded allowed-signers projection is never a root of -trust. +The one-key `allowed_signers` file is only an input to cryptographic signature +verification. It never establishes authorization or root trust. -## 7. Application profiles +## 11. Security considerations -A profile defines its payload codec and reducer. It MUST document its profile -identifier, payload versioning, signer authorization rules, transition rules, -and any limits beyond this base specification. A profile MUST reject a payload -that decodes successfully but does not round-trip to exactly the same canonical -bytes. - -### geth keychain profile - -Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical -binary mirror of a keychain operation. The mirror is deliberately separate from -the human-facing, internally tagged JSON API type so that a decoder can prove a -unique payload byte sequence. Version 1 requires: - -- sequence zero to be `KeychainInit`; -- sequence one to be an `AdminKeyAdd` that records the configured root signer; -- every signer to be an active admin public key in the preceding profile state; -- every keychain operation ID to occur only once in the chain; -- each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID - matches the declared key ID; -- `AdminKeyRevoke` to remove that key before any later record is authorized; - and -- no `valid_after_ms` or `valid_before_ms` policy fields. Key lifecycle is - causally represented by add and revoke records, not untrusted timestamps. - -The configured root key seeds the profile's initial admin authorization state. -It can be revoked by a valid record; it is not a permanent bypass after genesis. - -## 8. Publication and rollback - -This base format proves only one supplied history. A publisher that controls a -root key can present different valid descendants to different readers. Clients -that need rollback or fork detection SHOULD persist the accepted `(chain_id, -head digest, sequence)` and only accept a later bundle after proving that it is -an extension of the stored head. A signed checkpoint alone is insufficient -unless its signer is an already pinned trust anchor and its claimed ancestry is -verified. - -For stronger equivocation evidence, publish heads to independently operated -witnesses or a transparency log. This is intentionally outside SSHSIGCHAIN's -small core. - -## 9. Security considerations - -- SSHSIG namespaces are mandatory domain separation. Reusing a namespace for a - different protocol is unsafe. -- The root key, chain ID, profile, and namespace form one trust tuple. Changing - any member starts a different trust domain and needs an explicit operator - action. -- A compromised active admin key can still append records until a causally - later revocation is accepted. This is inherent to single-key authority, not a - claim of compromise recovery. -- SSHSIG signatures do not give a trusted signing time. Do not make expiry or - ordering decisions from payload timestamps without a separate, authenticated - time design. -- This protocol does not encrypt payloads and does not make bearer secrets or - untrusted discovery data into node identity. -- Verifiers should use OpenSSH versions that support `ssh-keygen -Y` and should - reject unsupported algorithms according to their local OpenSSH policy. - -## 10. Compatibility - -This specification defines one format: SSHSIGCHAIN version 1. It has no -compatibility mode, downgrade path, or alternate legacy record grammar. -Implementations MUST reject another protocol version. Geth removed its -pre-standard test-only static JSONL workflow rather than treating it as an -SSHSIGCHAIN variant. +- Use independent random chain IDs and protocol-specific SSHSIG namespaces. +- A stolen active key retains its scoped authority until a causally prior + revocation is in the accepted history. There is no trusted signature time. +- Device ceilings and delegation checks prevent permission amplification but + cannot protect against a legitimately authorized `All` key. +- Selective disclosure leaks outer metadata and payload equality only when a + nonce is reused. Always use a fresh unpredictable nonce. +- Anchor strength is no greater than the configured independent backends and + receipt verifiers. Cached-head checks remain mandatory. +- Resource bearer secrets, discovery records, and profile payloads cannot + mutate SSHSIGCHAIN authority. +- Implementations should apply a local OpenSSH algorithm policy and reject + unsupported keys or SSHSIG algorithms. ## References - [OpenSSH `PROTOCOL.sshsig`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig) -- [OpenBSD `ssh-keygen(1)` manual](https://man.openbsd.org/ssh-keygen.1) +- [OpenBSD `ssh-keygen(1)`](https://man.openbsd.org/ssh-keygen.1) +- [Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [Sigstore Rekor](https://github.com/sigstore/rekor)