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
[`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

View file

@ -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 } => {

View file

@ -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 {

View file

@ -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::{

View file

@ -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<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>,
}
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<V: SshSigchainVerifier + ?Sized>(
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<V: SshSigchainVerifier + ?Sized>(
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<V: SshSigchainVerifier + ?Sized>(
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>(
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<R: AnchorReceiptVerifier + ?Sized>(
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
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<R: AnchorReceiptVerifier + ?Sized>(
}
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<R: AnchorReceiptVerifier + ?Sized>(
#[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<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>(
histories: &'a [AnchoredHistory<'a>],
histories: &'a [VerifiedAnchoredHistory<'a>],
cached_head: Option<Digest>,
) -> 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<u8>, 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");
assert!(matches!(
select_anchored_head(
&[
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,
claim: &ca
claims: &claims_a,
receipts: &[],
},
&TestVerifier,
&TestReceiptVerifier,
)
.expect("anchored a");
let anchored_b = verify_anchored_history(
AnchoredHistory {
verification: &vb,
claim: &cb
}
],
None
),
claims: &claims_b,
receipts: &[],
},
&TestVerifier,
&TestReceiptVerifier,
)
.expect("anchored b");
assert!(matches!(
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"
)
);
}
}

View file

@ -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);

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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(<profile>)`. 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

View file

@ -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 1128 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
<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
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 1128 ASCII bytes containing letters, digits, `.`,
`-`, `_`, `:`, or `/`.
| Field | Type | Rule |
| --- | --- | --- |
| `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. |
## 3. Outer-link model
The profile identifier is 1128 ASCII bytes containing only letters, digits,
`.`, `-`, 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.
A record contains:
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.
| 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. |
## 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
unsigned big-endian lengths. `u64` is an unsigned big-endian integer. `bytes[n]`
contains exactly `n` bytes; no implicit terminator or alignment is present.
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 1 byte (protocol version)
0x01 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]
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
```
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:
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
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
JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT
be signed or hashed as the record representation.
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.
### JSONL transport
## 5. Mandatory Authority v1 state machine
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.
Authority is part of the protocol suite, not an optional application profile.
Every record contains exactly one transition. Transition tags and fields are:
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.
| Tag | Transition | Fields after tag |
| --- | --- | --- |
| `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 |
### 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
generation or signature randomness:
### 5.1 Genesis
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
chain_id 32 zero bytes
profile "example.test"
sequence 0
previous absent
payload 01 02
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
"SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 ||
public_key:string16 || permissions || delegable_permissions
```
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
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
conforming verifier MUST:
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.
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.
## 7. Anchor policies and head claims
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.
Anchoring makes rollback or equivocation observable under a chosen policy; it
does not make a backend a consensus system.
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
ssh-keygen -Y sign -f <private-key> -n <namespace> <signing-bytes-file>
ssh-keygen -Y verify -f <one-key-allowed-signers> -I <principal> \
-n <namespace> -s <signature-file> < <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 sshsigchain \
-n sshsigchain.v1 -s <signature-file> < <signing-bytes-file>
```
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)