make sshsigchain the only portable sigchain format

This commit is contained in:
Eric Wendland 2026-07-18 21:00:06 +02:00
commit 73500e1944
15 changed files with 812 additions and 1603 deletions

View file

@ -5,25 +5,23 @@
//! to update them. It intentionally has no dependency on the daemon, SQLite,
//! Iroh, local sockets, or any particular publication mechanism.
//!
//! Applications can publish `KeychainSigchainEntry` values in an append-only
//! JSONL file, object store, database row stream, document CRDT, or another
//! transport. Consumers decode entries, flatten them into operations and
//! signatures, then call `verify_sigchain_with_profile` with an application
//! profile and a `KeychainSignatureVerifier` implementation.
//!
//! The default `KeychainProfile` is geth-specific. Other applications should
//! create their own profile with `KeychainProfile::for_application` or
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
//! The current local operation-log verifier remains local to geth's daemon
//! state. Portable SSHSIGCHAIN records are implemented separately in
//! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and
//! a causal hash chain rather than a timestamp-sorted JSONL bundle.
mod sshsigchain;
pub use sshsigchain::{
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION,
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
KeychainSshSigchainPolicy, KeychainSshSigchainState, KeychainSshSigchainVerification,
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, SSH_SIGCHAIN_NAMESPACE,
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
verify_keychain_v2_sigchain, verify_sshsigchain,
decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl,
keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record,
verify_keychain_sshsigchain, verify_sshsigchain,
};
use geth_types::{
@ -35,10 +33,7 @@ use std::collections::{BTreeMap, BTreeSet};
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev";
pub const KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev";
pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/";
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
@ -203,27 +198,6 @@ pub struct KeychainSigchainReport {
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainEntry {
pub op: KeychainOp,
pub signatures: Vec<KeychainOpSignature>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainCheckpoint {
pub version: u16,
pub profile: KeychainProfile,
pub base_url: String,
pub head: Option<AuthOpId>,
pub ops: usize,
pub signatures: usize,
pub sigchain_bytes: u64,
pub sigchain_hash: String,
pub allowed_signers_hash: String,
pub reduced_view_hash: String,
pub generated_at: UnixMillis,
}
pub trait KeychainSignatureVerifier {
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
}
@ -331,11 +305,6 @@ pub enum KeychainError {
InvalidPrincipal(String),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl {
line: usize,
source: serde_json::Error,
},
}
#[must_use]
@ -739,100 +708,6 @@ pub fn admin_key_fingerprint(public_key: &str) -> String {
format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes()))
}
#[must_use]
pub fn sigchain_entries(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> Vec<KeychainSigchainEntry> {
sorted_keychain_ops(ops.to_vec())
.into_iter()
.map(|op| KeychainSigchainEntry {
signatures: signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect(),
op,
})
.collect()
}
pub fn encode_sigchain_jsonl(
entries: &[KeychainSigchainEntry],
) -> Result<String, serde_json::Error> {
let mut text = String::new();
for entry in entries {
text.push_str(&serde_json::to_string(entry)?);
text.push('\n');
}
Ok(text)
}
pub fn decode_sigchain_jsonl(text: &str) -> Result<Vec<KeychainSigchainEntry>, KeychainError> {
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
KeychainError::SigchainJsonl {
line: index + 1,
source,
}
})
})
.collect()
}
#[must_use]
pub fn flatten_sigchain_entries(
entries: &[KeychainSigchainEntry],
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
let ops = entries.iter().map(|entry| entry.op.clone()).collect();
let signatures = entries
.iter()
.flat_map(|entry| entry.signatures.clone())
.collect();
(ops, signatures)
}
pub fn keychain_checkpoint(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
sigchain_jsonl: &str,
allowed_signers: &str,
base_url: impl Into<String>,
generated_at: UnixMillis,
) -> Result<KeychainCheckpoint, KeychainError> {
let sorted_ops = sorted_keychain_ops(ops.to_vec());
let view = reduce_keychain_ops(&sorted_ops);
Ok(KeychainCheckpoint {
version: KEYCHAIN_CHECKPOINT_VERSION,
profile: KeychainProfile::geth(),
base_url: normalize_base_url(base_url.into()),
head: sorted_ops.last().map(|op| op.id.clone()),
ops: sorted_ops.len(),
signatures: signatures.len(),
sigchain_bytes: sigchain_jsonl.len() as u64,
sigchain_hash: blake3_tagged_hash(sigchain_jsonl.as_bytes()),
allowed_signers_hash: blake3_tagged_hash(allowed_signers.as_bytes()),
reduced_view_hash: geth_codec::hash_canonical(&view)?.to_string(),
generated_at,
})
}
#[must_use]
pub fn blake3_tagged_hash(bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(bytes))
}
#[must_use]
pub fn normalize_base_url(mut value: String) -> String {
if !value.ends_with('/') {
value.push('/');
}
value
}
fn validate_namespace(value: &str) -> Result<(), KeychainError> {
let has_single_domain_separator = value.matches('@').count() == 1;
let valid = has_single_domain_separator
@ -1118,90 +993,6 @@ mod tests {
assert!(!view.endpoints.contains_key("endpoint:old"));
}
#[test]
fn allowed_signers_and_sigchain_jsonl_are_portable() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
principal: Some("admin-a".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
),
];
let signatures = vec![KeychainOpSignature {
op_id: ops[1].id.clone(),
signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: vec![1],
created_at: UnixMillis(2),
}];
let allowed = allowed_signers(&ops, &signatures);
assert_eq!(allowed.len(), 1);
assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519"));
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl");
assert_eq!(jsonl.lines().count(), 2);
let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl");
assert_eq!(decoded, entries);
let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded);
assert_eq!(decoded_ops, sorted_keychain_ops(ops));
assert_eq!(decoded_signatures, signatures);
}
#[test]
fn checkpoint_records_static_publication_hashes() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
),
];
let signatures = Vec::new();
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl");
let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures));
let checkpoint = keychain_checkpoint(
&ops,
&signatures,
&jsonl,
&allowed,
"https://example.com/.well-known/sshsigchain",
UnixMillis(10),
)
.expect("checkpoint");
assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION);
assert_eq!(
checkpoint.base_url,
"https://example.com/.well-known/sshsigchain/"
);
assert_eq!(checkpoint.ops, 2);
assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64);
assert_eq!(
checkpoint.sigchain_hash,
blake3_tagged_hash(jsonl.as_bytes())
);
assert_eq!(
checkpoint.allowed_signers_hash,
blake3_tagged_hash(allowed.as_bytes())
);
assert!(!checkpoint.reduced_view_hash.is_empty());
}
#[test]
fn sigchain_verification_replays_against_prior_admin_view() {
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();