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

@ -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
&& receipt.claim_hash == claim_hash
&& verifier.verify_receipt(backend, claim, receipt)
let valid = claims.iter().any(|claim| {
let Ok(claim_hash) = claim.claim_hash() else {
return false;
};
receipts.iter().any(|receipt| {
receipt.evidence.len() <= MAX_PAYLOAD_BYTES
&& receipt.backend_id == backend.backend_id
&& receipt.claim_hash == claim_hash
&& verifier.verify_receipt(backend, claim, receipt)
})
});
if backend.required && !valid {
return Err(SshSigchainError::RequiredAnchorMissing(
@ -854,11 +923,17 @@ pub fn verify_anchor_receipts<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");
let claims_a = vec![signed_claim(&trust, &va)];
let claims_b = vec![signed_claim(&trust, &vb)];
let anchored_a = verify_anchored_history(
AnchoredHistory {
verification: &va,
claims: &claims_a,
receipts: &[],
},
&TestVerifier,
&TestReceiptVerifier,
)
.expect("anchored a");
let anchored_b = verify_anchored_history(
AnchoredHistory {
verification: &vb,
claims: &claims_b,
receipts: &[],
},
&TestVerifier,
&TestReceiptVerifier,
)
.expect("anchored b");
assert!(matches!(
select_anchored_head(
&[
AnchoredHistory {
verification: &va,
claim: &ca
},
AnchoredHistory {
verification: &vb,
claim: &cb
}
],
None
),
select_anchored_head(&[anchored_a.clone(), anchored_b], None),
Err(SshSigchainError::ForkDetected)
));
assert!(matches!(
select_anchored_head(
&[AnchoredHistory {
verification: &va,
claim: &ca
}],
Some(Digest([3; 32]))
),
select_anchored_head(&[anchored_a], Some(Digest([3; 32]))),
Err(SshSigchainError::RollbackDetected)
));
}
@ -1900,4 +2128,28 @@ mod tests {
Err(SshSigchainError::JsonLine { .. })
));
}
#[test]
fn signing_bytes_match_the_published_base_vector() {
let record = SshSigchainRecord::unsigned(
ChainId([0; 32]),
None,
ROOT_KEY,
AuthorityTransition::Noop,
vec![],
)
.expect("unsigned vector");
assert_eq!(
hex::encode(record.signing_bytes().expect("bytes")),
concat!(
"5353435301",
"0000000000000000000000000000000000000000000000000000000000000000",
"00",
"eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2",
"00107373682d656432353531392041514944",
"08",
"0000"
)
);
}
}