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
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:
parent
492a37c020
commit
76eb785ee2
11 changed files with 834 additions and 387 deletions
20
README.md
20
README.md
|
|
@ -646,9 +646,12 @@ validated its own chain. There is no compatibility mode for that workflow.
|
|||
|
||||
The replacement is the small, transport-neutral
|
||||
[`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an
|
||||
operator-pinned chain ID, OpenSSH root public key, profile, and namespace;
|
||||
records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth
|
||||
already provides a verifier for independently produced JSONL transport files:
|
||||
operator-pinned chain ID, OpenSSH root public key, and namespace. Parent hashes
|
||||
define order without a redundant sequence counter. Every record carries a
|
||||
public authority transition for devices, keys, causal revocation, scoped
|
||||
permissions, and anchor policy, plus optional profile commitments whose payloads
|
||||
can be selectively disclosed. Geth already provides a verifier for independently
|
||||
produced JSONL transport files:
|
||||
|
||||
```sh
|
||||
geth keychain verify-sigchain \
|
||||
|
|
@ -657,10 +660,13 @@ geth keychain verify-sigchain \
|
|||
--root-key ~/.ssh/geth-root.pub
|
||||
```
|
||||
|
||||
SSHSIGCHAIN local record storage, signing, publication, import, and accepted-head
|
||||
persistence remain follow-up work. Until they exist, do not substitute an
|
||||
unpinned checkpoint or a local operation-log view for the SSHSIGCHAIN trust
|
||||
tuple.
|
||||
The verifier reports active authority devices/keys, disclosed and incomplete
|
||||
profiles, the head digest, and current attester/backend anchor thresholds.
|
||||
SSHSIGCHAIN local
|
||||
record storage, signing, publication, import, accepted-head persistence, and
|
||||
concrete anchor adapters remain follow-up work. Until they exist, do not
|
||||
substitute an unpinned checkpoint or a local operation-log view for the
|
||||
SSHSIGCHAIN trust tuple.
|
||||
|
||||
Signing is mediated by OpenSSH. `--signing-key` may point at a private key file,
|
||||
a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,270 +1,382 @@
|
|||
# SSHSIGCHAIN v1
|
||||
|
||||
Status: Draft 1 (pre-deployment)
|
||||
Status: Draft 2 (pre-deployment)
|
||||
|
||||
This document specifies a deliberately small, generic append-only signature
|
||||
chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the
|
||||
first and only defined version of SSHSIGCHAIN. It is transport
|
||||
independent: a chain may be carried by a file, object store, HTTP, database, or
|
||||
mesh protocol. It does not make SSH a transport.
|
||||
SSHSIGCHAIN is a transport-independent, OpenSSH-signed authority chain. It is
|
||||
intended to be implementable outside geth. A chain can be carried by JSONL,
|
||||
HTTP, an object store, Iroh, or another transport; SSH is a signature mechanism,
|
||||
not a transport.
|
||||
|
||||
The reference implementation lives in geth's `geth-keychain` crate. Geth uses
|
||||
the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and
|
||||
verification core do not depend on geth data types.
|
||||
Version 1 is the first and only format. There is no legacy grammar or downgrade
|
||||
mode.
|
||||
|
||||
## 1. Goals
|
||||
## 1. Security goals and non-goals
|
||||
|
||||
SSHSIGCHAIN v1 provides:
|
||||
SSHSIGCHAIN provides:
|
||||
|
||||
- a fixed, deterministic byte sequence for every signed record;
|
||||
- an explicit, out-of-band root public key rather than trust bootstrapped from
|
||||
downloaded chain content;
|
||||
- one causally ordered, hash-linked history, so a later record cannot be made
|
||||
earlier by changing a timestamp;
|
||||
- application-defined authorization and state transitions evaluated at each
|
||||
chain position;
|
||||
- bounded field and chain sizes suitable for processing untrusted transport
|
||||
input; and
|
||||
- direct use of OpenSSH's `ssh-keygen -Y sign` / `-Y verify` SSHSIG format.
|
||||
- an explicit out-of-band `(chain_id, namespace, root_public_key)` trust tuple;
|
||||
- deterministic signing bytes and causal parent hashes, with no sequence number
|
||||
or timestamp ordering;
|
||||
- a mandatory public authority state machine for devices, keys, causal
|
||||
revocation, permissions, delegation, and anchor policy;
|
||||
- proof-of-possession when a key is added;
|
||||
- salted commitments for selectively disclosed application-profile payloads;
|
||||
- signed head claims and a generic receipt policy for independent anchor
|
||||
backends; and
|
||||
- fail-closed rollback and fork detection relative to a cached head or multiple
|
||||
verified histories.
|
||||
|
||||
It does not provide quorum, transparency, equivocation detection, secure clock
|
||||
attestation, encrypted payloads, or a distributed consensus protocol. A valid
|
||||
signature from an authorized key is still authority to make whatever change the
|
||||
application profile permits.
|
||||
It does not provide consensus, guaranteed publication, trusted time, anonymity,
|
||||
zero-knowledge disclosure, payload encryption, or automatic compromise
|
||||
recovery. An authorized key can perform every transition its effective
|
||||
permissions allow. Outer links reveal the chain ID, signer, authority
|
||||
transition, profile IDs, and payload commitments even when payloads are hidden.
|
||||
|
||||
## 2. Terminology and required inputs
|
||||
## 2. Trust inputs and identifiers
|
||||
|
||||
An implementation has four separately configured trust inputs:
|
||||
A verifier obtains these values through an authenticated channel, never from a
|
||||
downloaded chain:
|
||||
|
||||
1. `chain_id`: exactly 32 random bytes, rendered as 64 hexadecimal characters
|
||||
when displayed.
|
||||
2. `profile`: an ASCII identifier naming the application payload rules.
|
||||
3. `namespace`: the OpenSSH SSHSIG namespace passed to `ssh-keygen -Y`.
|
||||
4. `root_public_key`: one canonical OpenSSH public-key line.
|
||||
1. `chain_id`: 32 independently generated random bytes;
|
||||
2. `namespace`: the 1–128 byte printable, non-whitespace SSHSIG namespace
|
||||
(`sshsigchain.v1` by default); and
|
||||
3. `root_public_key`: canonical OpenSSH public-key text.
|
||||
|
||||
These values are a trust anchor. They must come from local configuration,
|
||||
enrollment material, release metadata, or another authenticated out-of-band
|
||||
channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT
|
||||
be used to discover or replace them.
|
||||
|
||||
The v1 core accepts a single root key. A threshold or witness scheme is a
|
||||
separate protocol extension and must bind the same `chain_id`, profile, and
|
||||
head digest; it is not implied by multiple signatures attached to a record.
|
||||
|
||||
## 3. Canonical public-key text
|
||||
|
||||
The public-key field is exactly two ASCII whitespace-separated fields:
|
||||
Canonical key text is exactly:
|
||||
|
||||
```text
|
||||
<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 1–128 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 1–128 ASCII bytes containing only letters, digits,
|
||||
`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without
|
||||
whitespace. Canonical public-key text is at most 16 KiB, signatures are at most
|
||||
64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
performing unbounded work. Implementations may set smaller limits.
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue