specify selective disclosure and anchor policy
Some checks failed
CI / fmt, clippy, docs (push) Failing after 5s
CI / test (ubuntu-latest) (push) Failing after 5s
CI / iroh integration smoke tests (push) Failing after 5s
CodeQL / Analyze Rust (push) Failing after 4s
Security / RustSec cargo-audit (push) Failing after 5s
CI / test (macos-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled

This commit is contained in:
Eric Wendland 2026-07-18 22:05:57 +02:00
commit 76eb785ee2
11 changed files with 834 additions and 387 deletions

View file

@ -646,9 +646,12 @@ validated its own chain. There is no compatibility mode for that workflow.
The replacement is the small, transport-neutral The replacement is the small, transport-neutral
[`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an [`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an
operator-pinned chain ID, OpenSSH root public key, profile, and namespace; operator-pinned chain ID, OpenSSH root public key, and namespace. Parent hashes
records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth define order without a redundant sequence counter. Every record carries a
already provides a verifier for independently produced JSONL transport files: 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 ```sh
geth keychain verify-sigchain \ geth keychain verify-sigchain \
@ -657,10 +660,13 @@ geth keychain verify-sigchain \
--root-key ~/.ssh/geth-root.pub --root-key ~/.ssh/geth-root.pub
``` ```
SSHSIGCHAIN local record storage, signing, publication, import, and accepted-head The verifier reports active authority devices/keys, disclosed and incomplete
persistence remain follow-up work. Until they exist, do not substitute an profiles, the head digest, and current attester/backend anchor thresholds.
unpinned checkpoint or a local operation-log view for the SSHSIGCHAIN trust SSHSIGCHAIN local
tuple. 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, 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 a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is

View file

@ -4113,7 +4113,10 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
devices, devices,
disclosed_profiles, disclosed_profiles,
incomplete_profiles, incomplete_profiles,
anchor_threshold, anchor_attester_threshold,
anchor_backend_threshold,
required_anchor_backends,
required_anchor_classes,
note, note,
} => { } => {
println!("sigchain: {}", input.display()); println!("sigchain: {}", input.display());
@ -4124,7 +4127,10 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
println!("devices: {devices}"); println!("devices: {devices}");
println!("disclosed_profiles: {disclosed_profiles}"); println!("disclosed_profiles: {disclosed_profiles}");
println!("incomplete_profiles: {incomplete_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}"); eprintln!("note: {note}");
} }
ControlResponse::KeychainExplained { subject, lines } => { ControlResponse::KeychainExplained { subject, lines } => {

View file

@ -750,7 +750,10 @@ pub enum ControlResponse {
devices: usize, devices: usize,
disclosed_profiles: usize, disclosed_profiles: usize,
incomplete_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, note: String,
}, },
KeychainExplained { KeychainExplained {

View file

@ -13,16 +13,17 @@
mod sshsigchain; mod sshsigchain;
pub use sshsigchain::{ pub use sshsigchain::{
AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier, AnchoredHistory, AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier,
AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState, AuthorityState, AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState,
AuthorityTransition, ChainId as SshSigchainChainId, Digest as SshSigchainDigest, HeadClaim, AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId,
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES,
ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE,
SshSigchainError, SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord,
SshSigchainVerifier, authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory,
key_proof_signing_bytes, profile_payload_commitment, select_anchored_head, authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes,
verify_anchor_receipts, verify_head_claim, verify_sshsigchain, profile_payload_commitment, select_anchored_head, verify_anchor_receipts,
verify_anchored_history, verify_head_claim, verify_sshsigchain,
}; };
use geth_types::{ use geth_types::{

View file

@ -107,7 +107,7 @@ pub enum Permission {
KeyAddAny, KeyAddAny,
KeyRevokeSelf, KeyRevokeSelf,
KeyRevokeAny, KeyRevokeAny,
PermissionManage, ManagePermissions,
AnchorPolicy, AnchorPolicy,
AnchorAttest, AnchorAttest,
ProfileWrite(String), ProfileWrite(String),
@ -140,23 +140,28 @@ pub struct AnchorBackendPolicy {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[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 { pub struct AnchorPolicy {
/// Zero disables required external anchoring while retaining rollback checks against cached heads. /// Required weight from distinct authorized head-claim signers.
pub threshold: u16, pub attester_threshold: u16,
/// Explicit attesters. An empty list with a zero threshold permits any active `AnchorAttest` key.
pub attesters: Vec<AnchorAttesterPolicy>,
/// At least one valid backend receipt must come from every named class.
pub required_classes: Vec<String>,
/// Required weight from distinct backends.
pub backend_threshold: u16,
pub backends: Vec<AnchorBackendPolicy>, pub backends: Vec<AnchorBackendPolicy>,
} }
impl Default for AnchorPolicy {
fn default() -> Self {
Self {
threshold: 0,
backends: Vec::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[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 { pub enum AuthorityTransition {
Genesis { Genesis {
device_id: String, device_id: String,
@ -553,12 +558,12 @@ fn authorize_record<V: SshSigchainVerifier + ?Sized>(
delegable_permissions, delegable_permissions,
.. ..
} => { } => {
require_permission(signer, &Permission::PermissionManage)?; require_permission(signer, &Permission::ManagePermissions)?;
ensure_delegation(signer, permissions)?; ensure_delegation(signer, permissions)?;
ensure_delegation(signer, delegable_permissions)?; ensure_delegation(signer, delegable_permissions)?;
} }
AuthorityTransition::PermissionRevoke { .. } => { AuthorityTransition::PermissionRevoke { .. } => {
require_permission(signer, &Permission::PermissionManage)?; require_permission(signer, &Permission::ManagePermissions)?;
} }
AuthorityTransition::AnchorPolicySet { .. } => { AuthorityTransition::AnchorPolicySet { .. } => {
require_permission(signer, &Permission::AnchorPolicy)?; require_permission(signer, &Permission::AnchorPolicy)?;
@ -820,6 +825,7 @@ pub fn verify_head_claim<V: SshSigchainVerifier + ?Sized>(
return Err(SshSigchainError::UnauthorizedHeadClaim); return Err(SshSigchainError::UnauthorizedHeadClaim);
} }
if claim.signature.is_empty() if claim.signature.is_empty()
|| claim.signature.len() > MAX_SIGNATURE_BYTES
|| !verifier.verify( || !verifier.verify(
SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_ANCHOR_NAMESPACE,
&claim.signing_bytes()?, &claim.signing_bytes()?,
@ -832,20 +838,83 @@ pub fn verify_head_claim<V: SshSigchainVerifier + ?Sized>(
Ok(()) Ok(())
} }
fn verify_head_claims<V: SshSigchainVerifier + ?Sized>(
policy: &AnchorPolicy,
verification: &SshSigchainVerification,
claims: &[HeadClaim],
verifier: &V,
) -> Result<Vec<usize>, 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<R: AnchorReceiptVerifier + ?Sized>( pub fn verify_anchor_receipts<R: AnchorReceiptVerifier + ?Sized>(
policy: &AnchorPolicy, policy: &AnchorPolicy,
claim: &HeadClaim, claim: &HeadClaim,
receipts: &[AnchorReceipt], receipts: &[AnchorReceipt],
verifier: &R, verifier: &R,
) -> Result<(), SshSigchainError> {
verify_anchor_receipts_for_claims(policy, std::slice::from_ref(claim), receipts, verifier)
}
fn verify_anchor_receipts_for_claims<R: AnchorReceiptVerifier + ?Sized>(
policy: &AnchorPolicy,
claims: &[HeadClaim],
receipts: &[AnchorReceipt],
verifier: &R,
) -> Result<(), SshSigchainError> { ) -> Result<(), SshSigchainError> {
validate_anchor_policy(policy)?; validate_anchor_policy(policy)?;
let claim_hash = claim.claim_hash()?;
let mut weight = 0u32; let mut weight = 0u32;
let mut witnessed_classes = BTreeSet::new();
for backend in &policy.backends { for backend in &policy.backends {
let valid = receipts.iter().any(|receipt| { let valid = claims.iter().any(|claim| {
receipt.backend_id == backend.backend_id 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 && receipt.claim_hash == claim_hash
&& verifier.verify_receipt(backend, claim, receipt) && verifier.verify_receipt(backend, claim, receipt)
})
}); });
if backend.required && !valid { if backend.required && !valid {
return Err(SshSigchainError::RequiredAnchorMissing( return Err(SshSigchainError::RequiredAnchorMissing(
@ -854,11 +923,17 @@ pub fn verify_anchor_receipts<R: AnchorReceiptVerifier + ?Sized>(
} }
if valid { if valid {
weight += u32::from(backend.weight); 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 { return Err(SshSigchainError::AnchorThresholdNotMet {
required: policy.threshold, required: policy.backend_threshold,
actual: weight, actual: weight,
}); });
} }
@ -868,18 +943,60 @@ pub fn verify_anchor_receipts<R: AnchorReceiptVerifier + ?Sized>(
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AnchoredHistory<'a> { pub struct AnchoredHistory<'a> {
pub verification: &'a SshSigchainVerification, 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<VerifiedAnchoredHistory<'a>, 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>( pub fn select_anchored_head<'a>(
histories: &'a [AnchoredHistory<'a>], histories: &'a [VerifiedAnchoredHistory<'a>],
cached_head: Option<Digest>, cached_head: Option<Digest>,
) -> Result<&'a AnchoredHistory<'a>, SshSigchainError> { ) -> Result<&'a VerifiedAnchoredHistory<'a>, SshSigchainError> {
let mut candidates: Vec<_> = histories let mut candidates: Vec<_> = histories
.iter() .iter()
.filter(|history| { .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(); .collect();
if candidates.is_empty() { if candidates.is_empty() {
@ -1062,7 +1179,9 @@ pub enum SshSigchainError {
UnknownKey, UnknownKey,
#[error("key is inactive")] #[error("key is inactive")]
InactiveKey, InactiveKey,
#[error("anchor policy contains invalid, duplicate, or unsatisfiable backend rules")] #[error(
"anchor policy contains invalid, duplicate, unsorted, or unsatisfiable attester/backend rules"
)]
InvalidAnchorPolicy, InvalidAnchorPolicy,
#[error("head claim does not name the verified chain head")] #[error("head claim does not name the verified chain head")]
ClaimHeadMismatch, ClaimHeadMismatch,
@ -1070,8 +1189,16 @@ pub enum SshSigchainError {
UnauthorizedHeadClaim, UnauthorizedHeadClaim,
#[error("head claim signature is invalid")] #[error("head claim signature is invalid")]
InvalidHeadClaimSignature, 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")] #[error("required anchor backend {0} has no valid receipt")]
RequiredAnchorMissing(String), RequiredAnchorMissing(String),
#[error("required anchor backend class {0} has no valid receipt")]
RequiredAnchorClassMissing(String),
#[error("anchor threshold not met: required {required}, got {actual}")] #[error("anchor threshold not met: required {required}, got {actual}")]
AnchorThresholdNotMet { required: u16, actual: u32 }, AnchorThresholdNotMet { required: u16, actual: u32 },
#[error("no valid anchored head was supplied")] #[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> { 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 ids = BTreeSet::new();
let mut total = 0u32; let mut total = 0u32;
let mut previous_id: Option<&str> = None; 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); previous_id = Some(&backend.backend_id);
total += u32::from(backend.weight); 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); return Err(SshSigchainError::InvalidAnchorPolicy);
} }
Ok(()) Ok(())
@ -1343,7 +1506,7 @@ fn encode_permissions(
Permission::KeyAddAny => out.push(4), Permission::KeyAddAny => out.push(4),
Permission::KeyRevokeSelf => out.push(5), Permission::KeyRevokeSelf => out.push(5),
Permission::KeyRevokeAny => out.push(6), Permission::KeyRevokeAny => out.push(6),
Permission::PermissionManage => out.push(7), Permission::ManagePermissions => out.push(7),
Permission::AnchorPolicy => out.push(8), Permission::AnchorPolicy => out.push(8),
Permission::AnchorAttest => out.push(9), Permission::AnchorAttest => out.push(9),
Permission::ProfileWrite(profile) => { Permission::ProfileWrite(profile) => {
@ -1361,7 +1524,18 @@ fn encode_permissions(
fn encode_anchor_policy(out: &mut Vec<u8>, policy: &AnchorPolicy) -> Result<(), SshSigchainError> { fn encode_anchor_policy(out: &mut Vec<u8>, policy: &AnchorPolicy) -> Result<(), SshSigchainError> {
validate_anchor_policy(policy)?; 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())?; push_u16(out, policy.backends.len())?;
for backend in &policy.backends { for backend in &policy.backends {
push_u16_bytes(out, backend.backend_id.as_bytes())?; push_u16_bytes(out, backend.backend_id.as_bytes())?;
@ -1467,6 +1641,19 @@ mod tests {
struct TestVerifier; 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 { impl SshSigchainVerifier for TestVerifier {
fn verify( fn verify(
&self, &self,
@ -1537,6 +1724,17 @@ mod tests {
record.with_signature(signature).expect("signed") 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] #[test]
fn chain_order_uses_only_parent_hashes() { fn chain_order_uses_only_parent_hashes() {
let trust = trust(); let trust = trust();
@ -1654,7 +1852,33 @@ mod tests {
) )
.expect("valid key add"); .expect("valid key add");
assert_eq!(verified.state.active_key_count(), 2); 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_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( let bad = signed_record(
&trust, &trust,
Some(add_device.record_hash().expect("hash")), Some(add_device.record_hash().expect("hash")),
@ -1662,13 +1886,15 @@ mod tests {
AuthorityTransition::KeyAdd { AuthorityTransition::KeyAdd {
device_id: "device:phone".to_owned(), device_id: "device:phone".to_owned(),
key: bad_key, key: bad_key,
proof: vec![1], proof: bad_proof,
}, },
vec![], vec![],
); );
assert!(matches!( assert!(matches!(
verify_sshsigchain(&[first, add_device, bad], &trust, &TestVerifier), 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] #[test]
fn head_claims_require_current_authority_and_policy_receipts() { 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 trust = trust();
let verification = let verification =
verify_sshsigchain(&[genesis(&trust)], &trust, &TestVerifier).expect("chain"); verify_sshsigchain(&[genesis(&trust)], &trust, &TestVerifier).expect("chain");
let claim = let claim = signed_claim(&trust, &verification);
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");
verify_head_claim(&verification, &claim, &TestVerifier).expect("authorized claim"); verify_head_claim(&verification, &claim, &TestVerifier).expect("authorized claim");
let policy = AnchorPolicy { 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 { backends: vec![AnchorBackendPolicy {
backend_id: "transparency-main".to_owned(), backend_id: "transparency-main".to_owned(),
class: "transparency".to_owned(), class: "transparency".to_owned(),
@ -1784,9 +1998,20 @@ mod tests {
claim_hash: claim.claim_hash().expect("claim hash"), claim_hash: claim.claim_hash().expect("claim hash"),
evidence: b"valid".to_vec(), 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!( assert!(matches!(
verify_anchor_receipts(&policy, &claim, &[], &ReceiptVerifier), verify_anchor_receipts(&policy, &claim, &[], &TestReceiptVerifier),
Err(SshSigchainError::RequiredAnchorMissing(_)) Err(SshSigchainError::RequiredAnchorMissing(_))
)); ));
} }
@ -1796,7 +2021,7 @@ mod tests {
let trust = trust(); let trust = trust();
let first = genesis(&trust); let first = genesis(&trust);
let new_policy = AnchorPolicy { let new_policy = AnchorPolicy {
threshold: 1, backend_threshold: 1,
backends: vec![AnchorBackendPolicy { backends: vec![AnchorBackendPolicy {
backend_id: "nostr-main".to_owned(), backend_id: "nostr-main".to_owned(),
class: "nostr".to_owned(), class: "nostr".to_owned(),
@ -1804,6 +2029,7 @@ mod tests {
weight: 1, weight: 1,
required: true, required: true,
}], }],
..AnchorPolicy::default()
}; };
let change = signed_record( let change = signed_record(
&trust, &trust,
@ -1852,32 +2078,34 @@ mod tests {
); );
let va = verify_sshsigchain(&[first.clone(), a], &trust, &TestVerifier).expect("a"); let va = verify_sshsigchain(&[first.clone(), a], &trust, &TestVerifier).expect("a");
let vb = verify_sshsigchain(&[first, b], &trust, &TestVerifier).expect("b"); 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 claims_a = vec![signed_claim(&trust, &va)];
let cb = HeadClaim::unsigned(trust.chain_id, vb.head, ROOT_KEY).expect("claim b"); let claims_b = vec![signed_claim(&trust, &vb)];
assert!(matches!( let anchored_a = verify_anchored_history(
select_anchored_head(
&[
AnchoredHistory { AnchoredHistory {
verification: &va, verification: &va,
claim: &ca claims: &claims_a,
receipts: &[],
}, },
&TestVerifier,
&TestReceiptVerifier,
)
.expect("anchored a");
let anchored_b = verify_anchored_history(
AnchoredHistory { AnchoredHistory {
verification: &vb, verification: &vb,
claim: &cb claims: &claims_b,
} receipts: &[],
], },
None &TestVerifier,
), &TestReceiptVerifier,
)
.expect("anchored b");
assert!(matches!(
select_anchored_head(&[anchored_a.clone(), anchored_b], None),
Err(SshSigchainError::ForkDetected) Err(SshSigchainError::ForkDetected)
)); ));
assert!(matches!( assert!(matches!(
select_anchored_head( select_anchored_head(&[anchored_a], Some(Digest([3; 32]))),
&[AnchoredHistory {
verification: &va,
claim: &ca
}],
Some(Digest([3; 32]))
),
Err(SshSigchainError::RollbackDetected) Err(SshSigchainError::RollbackDetected)
)); ));
} }
@ -1900,4 +2128,28 @@ mod tests {
Err(SshSigchainError::JsonLine { .. }) 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"
)
);
}
} }

View file

@ -6518,7 +6518,16 @@ pub fn handle_request(
devices: verified.state.devices.values().filter(|device| device.active).count(), devices: verified.state.devices.values().filter(|device| device.active).count(),
disclosed_profiles: verified.state.disclosed_profiles.len(), disclosed_profiles: verified.state.disclosed_profiles.len(),
incomplete_profiles: verified.state.incomplete_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: note:
"verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key" "verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key"
.to_owned(), .to_owned(),
@ -11090,7 +11099,7 @@ mod tests {
.expect("unsigned init"), .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"); .expect("verify real OpenSSH SSHSIG chain");
assert_eq!(verified.records, 1); assert_eq!(verified.records, 1);
assert_eq!(verified.state.active_key_count(), 1); assert_eq!(verified.state.active_key_count(), 1);

View file

@ -1,42 +1,64 @@
# ADR 0018: Linked SSHSIGCHAIN v1 # ADR 0018: Sequence-free SSHSIGCHAIN authority protocol
## Status ## Status
Accepted for the generic core and geth keychain profile. The pre-standard Accepted for the generic core. The protocol is still pre-deployment and has no
test-only static workflow is removed; it is not an alternate format or a legacy compatibility requirement.
compatibility path.
## Context ## Context
The prior static JSONL keychain flow replayed records after sorting mutable The first linked draft improved on a timestamp-sorted static bundle, but still
timestamps and used a downloaded allowed-signers projection to verify its own made application profiles responsible for all key lifecycle semantics. It also
checkpoint. That permits self-bootstrapping trust and makes revocation, included both a sequence counter and a parent hash, fixed one application
rollback, and fork semantics inadequate for a trust foundation. 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 ## 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; - the trust tuple is `(chain ID, SSHSIG namespace, root public key)`;
- records are linearly ordered by sequence and linked by a digest over their - parent hashes alone define order; there is no sequence number;
signed content and SSHSIG signature; - link identity hashes the signed outer bytes, not the signature encoding;
- the initial root is only a genesis requirement, not a permanent bypass; - every link carries a mandatory public Authority v1 transition;
- profile authorization runs against the causally preceding state; - authority owns devices, keys, proof-of-possession, causal revocation,
- key lifecycle is represented by causal add/revoke records, not timestamp permission ceilings, delegation, and anchor policy;
validity fields; and - applications use profile-ID-scoped, salted payload commitments and cannot
- payloads use deterministic binary codecs with a strict round-trip check. 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 The generic core defines backend interfaces and deterministic policy behavior,
node-to-node communication; SSH remains an identity/signature integration and not Nostr, HTTP, blockchain, or transparency-log clients. Those adapters belong
not a geth transport. 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 ## Consequences
The core can be published and implemented by applications without importing Device and key behavior is interoperable rather than reinvented in each
geth's resource model. Geth's keychain profile is intentionally narrow and profile. A verifier can validate current authority while withholding application
tested against self-bootstrap, fork, and backdated-revocation attacks. 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 The wire format is intentionally incompatible with every pre-deployment test
SSHSIGCHAIN. There is no migration format because the repository has not been draft. No migration parser or version alias is retained.
deployed. Head persistence and later witness/transparency support remain
follow-up work; neither is implied by a single signed checkpoint. 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.

View file

@ -478,14 +478,19 @@ trust state; idempotent repeats leave the original bytes unchanged.
The portable signed-chain design is specified in The portable signed-chain design is specified in
[`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit [`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit
out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust out-of-band `(chain ID, SSHSIG namespace, root public key)` trust tuple. Exact
tuple, a strict sequence plus hash link, bounded fields, and a profile reducer parent hashes define causal order without a sequence counter. A mandatory,
that authorizes each record from only the causally preceding state. Geth's public authority reducer owns devices, keys, proof-of-possession, revocation,
`geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization permission ceilings, delegation, and anchor policy; application profiles can
policy and treats key revocation as a causal record. The prior test-only static only attach salted payload commitments and cannot mutate trust. Disclosures do
workflow was removed rather than migrated. Iroh keychain sync remains the not change link identity, and missing disclosures leave profile state
current replicated local operation-log path while explicit SSHSIGCHAIN explicitly incomplete. Signed head claims, backend-neutral receipts, cached-head
production and import workflows are completed. 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 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 state. `geth node enroll join` explicitly imports an owner admin public key as

View file

@ -561,9 +561,21 @@ resource-scoped capability decisions.
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record - `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record
format with explicit trust-anchor, chain-link, size-limit, and format with explicit trust-anchor, chain-link, size-limit, and
non-claim documentation. non-claim documentation.
- `[x]` Provide a reusable verifier core and a geth keychain profile that - `[x]` Remove the redundant sequence counter; exact parent hashes alone
rejects self-bootstrap, non-linked forks, non-canonical payloads, and define genesis and causal order.
post-revocation timestamp replay. - `[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 - `[~]` Add explicit CLI storage, signing, verification, publication, and
import workflows for a pinned SSHSIGCHAIN trust tuple. import workflows for a pinned SSHSIGCHAIN trust tuple.
- `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file - `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file
@ -572,15 +584,26 @@ resource-scoped capability decisions.
head-advance workflows are complete. head-advance workflows are complete.
- `[ ]` Persist accepted heads and require proof of extension before a source - `[ ]` Persist accepted heads and require proof of extension before a source
can advance. 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, - `[x]` Delete the previous test-only static export, publication,
verification, import, checkpoint, and fetch workflow. It is not a verification, import, checkpoint, and fetch workflow. It is not a
compatibility target because it was never deployed. compatibility target because it was never deployed.
- `[~]` Add OpenSSH integration tests and independently generated wire test - `[~]` Add OpenSSH integration tests and independently generated wire test
vectors for the published standard. vectors for the published standard.
- `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies a - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies an
linked root/init chain. Authority v1 genesis link.
- `[x]` The specification and reference implementation share a base - `[~]` The specification and reference implementation share base signing,
signing-byte test vector. key-proof, commitment, and head-claim vectors.
- `[ ]` Publish the complete vector set in the specification.
- `[ ]` Add independently generated cross-implementation vectors. - `[ ]` Add independently generated cross-implementation vectors.
- `[x]` SSH-admin-rooted keychain initialization. - `[x]` SSH-admin-rooted keychain initialization.

View file

@ -11,9 +11,9 @@ authorization, and OpenSSH `allowed_signers` projection.
[`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable [`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable
signed-chain format in this repository. It is deliberately separate from the signed-chain format in this repository. It is deliberately separate from the
current local keychain operation log: SSHSIGCHAIN starts with an explicit 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 out-of-band trust tuple, uses exact parent hashes without a sequence counter,
downloaded `allowed_signers` file as a trust root. The previous test-only and never uses a downloaded `allowed_signers` file as a trust root. The previous
static JSONL publication format was removed. test-only static JSONL publication format was removed.
## Local keychain model ## 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 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. 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 SSHSIGCHAIN uses the `sshsigchain.v1` SSHSIG namespace. Device/key management is
SSHSIG namespace is `sshsigchain.v1`. Its canonical binary payload is a not a geth-specific profile: the mandatory Authority v1 reducer defines genesis,
versioned mirror of a keychain operation; it is separate from the device add/revoke, key add/revoke, proof-of-possession, scoped and delegable
human-facing, internally tagged JSON API type so a decoder can prove one unique permissions, device permission ceilings, anchor-policy changes, and no-op links.
payload encoding. 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; Keys receive direct permissions such as `DeviceAdd`, `KeyAddSelf`,
- sequence one to be an `AdminKeyAdd` recording that root key; `ManagePermissions`, `AnchorAttest`, and `ProfileWrite(<profile>)`. An attached
- every following signer to be an active admin key in the causally prior key cannot exceed its device ceiling, and it cannot grant a permission outside
profile state; its delegable set. Newly added keys must sign the exact proposed key/device/
- every keychain operation ID to occur only once in the chain; permission binding under `sshsigchain.key-proof.v1`.
- 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.
The root key seeds authorization at genesis only. A causally valid revocation Head claims are separately signed by an active `AnchorAttest` key. An authority
removes it like any other admin key. 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 To inspect an SSHSIGCHAIN JSONL transport file, pin the chain ID and root public
key locally: key locally:
@ -89,11 +97,11 @@ geth keychain verify-sigchain \
--root-key ~/.ssh/geth-root.pub --root-key ~/.ssh/geth-root.pub
``` ```
This verifier is experimental while geth adds its own record creation, This verifier is experimental while geth adds record creation, publication,
publication, import, accepted-head persistence, and independently generated import, accepted-head persistence, concrete anchor adapters, and independently
wire vectors. Those later workflows must preserve the same explicit trust generated wire vectors. Those later workflows must preserve the same explicit
tuple; they must not introduce a compatibility route for the removed static trust tuple and Authority v1 semantics; they must not introduce a compatibility
format. route for the removed static format.
## Local commands ## Local commands

View file

@ -1,270 +1,382 @@
# SSHSIGCHAIN v1 # SSHSIGCHAIN v1
Status: Draft 1 (pre-deployment) Status: Draft 2 (pre-deployment)
This document specifies a deliberately small, generic append-only signature SSHSIGCHAIN is a transport-independent, OpenSSH-signed authority chain. It is
chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the intended to be implementable outside geth. A chain can be carried by JSONL,
first and only defined version of SSHSIGCHAIN. It is transport HTTP, an object store, Iroh, or another transport; SSH is a signature mechanism,
independent: a chain may be carried by a file, object store, HTTP, database, or not a transport.
mesh protocol. It does not make SSH a transport.
The reference implementation lives in geth's `geth-keychain` crate. Geth uses Version 1 is the first and only format. There is no legacy grammar or downgrade
the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and mode.
verification core do not depend on geth data types.
## 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 `(chain_id, namespace, root_public_key)` trust tuple;
- an explicit, out-of-band root public key rather than trust bootstrapped from - deterministic signing bytes and causal parent hashes, with no sequence number
downloaded chain content; or timestamp ordering;
- one causally ordered, hash-linked history, so a later record cannot be made - a mandatory public authority state machine for devices, keys, causal
earlier by changing a timestamp; revocation, permissions, delegation, and anchor policy;
- application-defined authorization and state transitions evaluated at each - proof-of-possession when a key is added;
chain position; - salted commitments for selectively disclosed application-profile payloads;
- bounded field and chain sizes suitable for processing untrusted transport - signed head claims and a generic receipt policy for independent anchor
input; and backends; and
- direct use of OpenSSH's `ssh-keygen -Y sign` / `-Y verify` SSHSIG format. - 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 It does not provide consensus, guaranteed publication, trusted time, anonymity,
attestation, encrypted payloads, or a distributed consensus protocol. A valid zero-knowledge disclosure, payload encryption, or automatic compromise
signature from an authorized key is still authority to make whatever change the recovery. An authorized key can perform every transition its effective
application profile permits. 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 1. `chain_id`: 32 independently generated random bytes;
when displayed. 2. `namespace`: the 1128 byte printable, non-whitespace SSHSIG namespace
2. `profile`: an ASCII identifier naming the application payload rules. (`sshsigchain.v1` by default); and
3. `namespace`: the OpenSSH SSHSIG namespace passed to `ssh-keygen -Y`. 3. `root_public_key`: canonical OpenSSH public-key text.
4. `root_public_key`: one canonical OpenSSH public-key line.
These values are a trust anchor. They must come from local configuration, Canonical key text is exactly:
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:
```text ```text
<key-type> SP <base64-encoded-OpenSSH-key-blob> <key-type> SP <canonical-base64-key-blob>
``` ```
Comments, leading/trailing whitespace, and extra fields are prohibited. The Comments, leading/trailing whitespace, and additional fields are prohibited.
base64 form is the standard canonical encoding of the decoded key blob. An Keys are identified by:
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.
## 4. Record model ```text
BLAKE3("sshsigchain.key-id.v1\0" || canonical_public_key)
```
A record has these logical fields: Protocol identifiers are 1128 ASCII bytes containing letters, digits, `.`,
`-`, `_`, `:`, or `/`.
| Field | Type | Rule | ## 3. Outer-link model
| --- | --- | --- |
| `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. |
The profile identifier is 1128 ASCII bytes containing only letters, digits, A record contains:
`.`, `-`, and `_`. The namespace is 1128 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.
Timestamps are intentionally not fields in the generic ordering mechanism. | Field | Meaning |
Applications may put timestamps in their payload, but MUST treat them as signed | --- | --- |
metadata rather than a way to order, revoke, or retroactively authorize | `chain_id` | Exact configured 32-byte chain ID. |
records. Sequence and `previous` define causal order. | `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. |
## 5. 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 signing byte string is the following binary grammar. `u16` and `u32` are The record hash named by its child is:
unsigned big-endian lengths. `u64` is an unsigned big-endian integer. `bytes[n]`
contains exactly `n` bytes; no implicit terminator or alignment is present. ```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 ```text
"SSCS" 4 bytes "SSCS" 4 bytes
0x01 1 byte (protocol version) 0x01 protocol version
chain_id 32 bytes chain_id 32 bytes
sequence u64 has_previous u8: 0 or 1
has_previous 1 byte: 0 or 1 previous 32 bytes when present
previous 32 bytes, only when has_previous is 1 signer_key_id 32 bytes
profile_length u16 signer_public_key string16
profile bytes[profile_length] authority_transition transition (section 5)
payload_length u32 extension_count u16
payload bytes[payload_length] repeated extension_count times:
signer_key_length u16 profile_id string16
signer_public_key bytes[signer_key_length] payload_commitment 32 bytes
``` ```
The `signature` field is not in the signing bytes because SSHSIG signs those Transport-only disclosure bytes and the SSHSIG signature are not part of these
bytes. The record digest, which binds the signature into the next link, is: 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 ```text
BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature) 5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d656432353531392041514944080000
``` ```
`previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or The derived signer key ID in that vector is
JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT `eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2`.
be signed or hashed as the record representation. This vector fixes the outer grammar; it is not a valid chain genesis because a
valid first record must carry `Genesis` authority.
### JSONL transport ## 5. Mandatory Authority v1 state machine
This specification defines JSONL as a convenient interchange transport. Each Authority is part of the protocol suite, not an optional application profile.
non-empty physical line contains exactly one JSON object with these fields: Every record contains exactly one transition. Transition tags and fields are:
`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.
Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete | Tag | Transition | Fields after tag |
input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those | --- | --- | --- |
limits before buffering an unbounded line or input. These limits are transport | `0` | `Genesis` | device ID, root authority key, anchor policy |
limits in addition to the record limits above. | `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 |
### Base test vector 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.
This unsigned record fixes the field grammar independently of OpenSSH key ### 5.1 Genesis
generation or signature randomness:
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.
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 ```text
chain_id 32 zero bytes "SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 ||
profile "example.test" public_key:string16 || permissions || delegable_permissions
sequence 0
previous absent
payload 01 02
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
``` ```
Its signing bytes, encoded as lowercase hexadecimal, MUST be: 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 ```text
53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332 profile_id
commitment = BLAKE3(
"sshsigchain.payload.v1\0" ||
profile_id:string16 || nonce:32 || payload:bytes32
)
optional transport disclosure { nonce, payload }
``` ```
## 6. Verification algorithm 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.
Given the configured trust input and an ordered candidate record list, a Authority transitions are always public. Profile payloads MUST NOT create,
conforming verifier MUST: 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.
1. Reject an empty or over-limit chain. ## 7. Anchor policies and head claims
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.
Records are never re-sorted by payload timestamp, identifier, signature time, Anchoring makes rollback or equivocation observable under a chosen policy; it
or transport arrival time. A verifier that cannot obtain its configured trust does not make a backend a consensus system.
input MUST fail closed.
For OpenSSH interoperability, implementations use commands equivalent to: An anchor policy contains separately weighted attesters and publication
backends. Both lists and the required-class list are strictly sorted and unique:
```text
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
```
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
"SSAH" || 0x01 || chain_id:32 || head:32 || signer_key_id:32 ||
signer_public_key:string16
```
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)`.
Adapters conceptually implement:
```text
publish(HeadClaim) -> AnchorReceipt
fetch(chain_id) -> HeadClaim[]
verify_receipt(policy_backend, HeadClaim, AnchorReceipt) -> bool
```
They do not choose a canonical head. After verifying claims, chains, authority,
and receipts, a client:
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 ```sh
ssh-keygen -Y sign -f <private-key> -n <namespace> <signing-bytes-file> ssh-keygen -Y sign -f <private-key> -n sshsigchain.v1 <signing-bytes-file>
ssh-keygen -Y verify -f <one-key-allowed-signers> -I <principal> \ ssh-keygen -Y verify -f <one-key-allowed-signers> -I sshsigchain \
-n <namespace> -s <signature-file> < <signing-bytes-file> -n sshsigchain.v1 -s <signature-file> < <signing-bytes-file>
``` ```
The allowed-signers file used for this individual cryptographic check contains The one-key `allowed_signers` file is only an input to cryptographic signature
only the record's already-canonical signer key. Authorization remains the verification. It never establishes authorization or root trust.
profile's job; a downloaded allowed-signers projection is never a root of
trust.
## 7. Application profiles ## 11. Security considerations
A profile defines its payload codec and reducer. It MUST document its profile - Use independent random chain IDs and protocol-specific SSHSIG namespaces.
identifier, payload versioning, signer authorization rules, transition rules, - A stolen active key retains its scoped authority until a causally prior
and any limits beyond this base specification. A profile MUST reject a payload revocation is in the accepted history. There is no trusted signature time.
that decodes successfully but does not round-trip to exactly the same canonical - Device ceilings and delegation checks prevent permission amplification but
bytes. cannot protect against a legitimately authorized `All` key.
- Selective disclosure leaks outer metadata and payload equality only when a
### geth keychain profile nonce is reused. Always use a fresh unpredictable nonce.
- Anchor strength is no greater than the configured independent backends and
Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical receipt verifiers. Cached-head checks remain mandatory.
binary mirror of a keychain operation. The mirror is deliberately separate from - Resource bearer secrets, discovery records, and profile payloads cannot
the human-facing, internally tagged JSON API type so that a decoder can prove a mutate SSHSIGCHAIN authority.
unique payload byte sequence. Version 1 requires: - Implementations should apply a local OpenSSH algorithm policy and reject
unsupported keys or SSHSIG algorithms.
- 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.
## References ## References
- [OpenSSH `PROTOCOL.sshsig`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig) - [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)