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

@ -1048,57 +1048,16 @@ pub enum KeychainCommand {
#[arg(long)]
principal: Option<String>,
},
/// Export the canonical SSH signature chain
Sigchain {
#[arg(long)]
out: Option<PathBuf>,
},
/// Build a signed static-publication bundle
PublishBundle {
#[arg(long)]
out: PathBuf,
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
base_url: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
#[arg(long = "snapshot")]
snapshots: Vec<String>,
},
/// Verify a signature chain without importing it
/// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key
VerifySigchain {
#[arg(long = "in")]
input: PathBuf,
},
/// Verify and import a signature chain
ImportSigchain {
#[arg(long = "in")]
input: PathBuf,
},
/// Verify a published checkpoint and its discovery metadata
VerifyCheckpoint {
#[arg(long)]
checkpoint: PathBuf,
chain_id: String,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
sigchain: PathBuf,
#[arg(long)]
allowed_signers: PathBuf,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
principal: Option<String>,
},
/// Fetch a published signature chain over HTTPS
Fetch {
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
url: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
import: bool,
root_key: PathBuf,
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
namespace: String,
},
/// Explain why one keychain operation was accepted or rejected
Explain { op_id: String },
@ -1785,6 +1744,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
"signature" => Some("OpenSSH signature file path"),
"allowed_signers" => Some("OpenSSH allowed_signers file used for verification"),
"base_url" => Some("Publication base URL recorded in signed discovery metadata"),
"chain_id" => Some("Pinned 32-byte SSHSIGCHAIN chain ID as 64 hexadecimal characters"),
"root_key" => Some("Pinned OpenSSH root public-key file for SSHSIGCHAIN verification"),
"snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"),
"checkpoint" => Some("Signed publication checkpoint file"),
"sigchain" => Some("Canonical keychain signature-chain JSONL file"),
@ -2671,52 +2632,20 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
allowed_signers_path: allowed_signers,
principal,
},
Command::Keychain {
command: KeychainCommand::Sigchain { out },
} => ControlRequest::KeychainSigchainExport { out },
Command::Keychain {
command:
KeychainCommand::PublishBundle {
out,
base_url,
signing_key,
admin_key,
snapshots,
KeychainCommand::VerifySigchain {
input,
chain_id,
root_key,
namespace,
},
} => ControlRequest::KeychainPublishBundle {
out,
base_url: Some(base_url),
signing_key_path: signing_key,
admin_key_path: admin_key,
snapshots,
} => ControlRequest::KeychainVerifySigchain {
input,
chain_id,
root_key_path: root_key,
namespace: Some(namespace),
},
Command::Keychain {
command: KeychainCommand::VerifySigchain { input },
} => ControlRequest::KeychainVerifySigchain { input },
Command::Keychain {
command: KeychainCommand::ImportSigchain { input },
} => ControlRequest::KeychainImportSigchain { input },
Command::Keychain {
command:
KeychainCommand::VerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
} => ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
Command::Keychain {
command: KeychainCommand::Fetch { url, out, import },
} => ControlRequest::KeychainFetch { url, out, import },
Command::Keychain {
command: KeychainCommand::Explain { op_id },
} => ControlRequest::KeychainExplain { op_id },
@ -4175,109 +4104,25 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
println!("verified: {verified}");
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainExported {
jsonl, out, note, ..
} => {
if let Some(out) = out {
println!("wrote keychain sigchain: {}", out.display());
} else {
print!("{jsonl}");
}
eprintln!("note: {note}");
}
ControlResponse::KeychainBundlePublished {
out,
base_url,
allowed_signers_path,
sigchain_path,
checkpoint_path,
checkpoint_signature_path,
snapshots,
note,
..
} => {
println!("bundle: {}", out.display());
println!("base_url: {base_url}");
println!("allowed_signers: {}", allowed_signers_path.display());
println!("sigchain: {}", sigchain_path.display());
println!("checkpoint: {}", checkpoint_path.display());
println!(
"checkpoint_signature: {}",
checkpoint_signature_path.display()
);
for snapshot in snapshots {
println!(
"snapshot: {} {} {}",
snapshot.name,
snapshot.path.display(),
snapshot.signature_path.display()
);
}
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainFileVerified {
ControlResponse::KeychainSigchainVerified {
input,
report,
chain_id,
records,
head,
active_admin_keys,
users,
devices,
nodes,
note,
} => {
println!("sigchain: {}", input.display());
print_keychain_sigchain_report(&report);
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainImported {
input,
ops_imported,
signatures_imported,
invalid_ops_rejected,
note,
} => {
println!("sigchain: {}", input.display());
println!("ops_imported: {ops_imported}");
println!("signatures_imported: {signatures_imported}");
println!("invalid_ops_rejected: {invalid_ops_rejected}");
eprintln!("note: {note}");
}
ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note,
} => {
println!(
"checkpoint_head: {}",
checkpoint
.head
.as_ref()
.map(|h| h.as_str())
.unwrap_or("none")
);
println!("base_url: {}", checkpoint.base_url);
println!("verified: {verified}");
println!("principal: {}", principal.as_deref().unwrap_or("none"));
eprintln!("note: {note}");
}
ControlResponse::KeychainFetched {
url,
out,
checkpoint,
imported,
note,
} => {
println!("url: {url}");
println!("out: {}", out.display());
println!(
"checkpoint_head: {}",
checkpoint
.head
.as_ref()
.map(|h| h.as_str())
.unwrap_or("none")
);
if let Some(imported) = imported {
println!("ops_imported: {}", imported.ops_imported);
println!("signatures_imported: {}", imported.signatures_imported);
println!("invalid_ops_rejected: {}", imported.invalid_ops_rejected);
}
println!("chain_id: {chain_id}");
println!("records: {records}");
println!("head: {head}");
println!("active_admin_keys: {active_admin_keys}");
println!("users: {users}");
println!("devices: {devices}");
println!("nodes: {nodes}");
eprintln!("note: {note}");
}
ControlResponse::KeychainExplained { subject, lines } => {
@ -5473,6 +5318,41 @@ mod tests {
assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err());
}
#[test]
fn sshsigchain_exposes_one_pinned_verifier_not_static_compatibility_commands() {
let parsed = Cli::try_parse_from([
"geth",
"keychain",
"verify-sigchain",
"--in",
"chain.jsonl",
"--chain-id",
"00",
"--root-key",
"root.pub",
])
.expect("parse SSHSIGCHAIN verifier");
assert!(matches!(
parsed.command,
Command::Keychain {
command: KeychainCommand::VerifySigchain { .. }
}
));
for removed in [
"sigchain",
"publish-bundle",
"import-sigchain",
"verify-checkpoint",
"fetch",
"verify-sigchain-v1",
] {
assert!(
Cli::try_parse_from(["geth", "keychain", removed]).is_err(),
"removed static command {removed} must not parse"
);
}
}
#[test]
fn service_install_copies_transient_binaries_unless_explicitly_overridden() {
assert!(

View file

@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::{
KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature,
KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord,
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
NodeEnrollmentRequest, NodeRecord,
};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_overlay::{
@ -266,34 +266,11 @@ pub enum ControlRequest {
allowed_signers_path: Option<PathBuf>,
principal: Option<String>,
},
KeychainSigchainExport {
out: Option<PathBuf>,
},
KeychainPublishBundle {
out: PathBuf,
base_url: Option<String>,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
snapshots: Vec<String>,
},
KeychainVerifySigchain {
input: PathBuf,
},
KeychainImportSigchain {
input: PathBuf,
},
KeychainVerifyCheckpoint {
checkpoint: PathBuf,
signature: PathBuf,
sigchain: PathBuf,
allowed_signers: PathBuf,
base_url: Option<String>,
principal: Option<String>,
},
KeychainFetch {
url: String,
out: Option<PathBuf>,
import: bool,
chain_id: String,
root_key_path: PathBuf,
namespace: Option<String>,
},
KeychainExplain {
op_id: String,
@ -764,46 +741,15 @@ pub enum ControlResponse {
principal: Option<String>,
note: String,
},
KeychainSigchainExported {
entries: Vec<KeychainSigchainEntry>,
jsonl: String,
out: Option<PathBuf>,
note: String,
},
KeychainBundlePublished {
out: PathBuf,
base_url: String,
allowed_signers_path: PathBuf,
sigchain_path: PathBuf,
checkpoint_path: PathBuf,
checkpoint_signature_path: PathBuf,
checkpoint: KeychainCheckpoint,
snapshots: Vec<KeychainPublishedSnapshot>,
note: String,
},
KeychainSigchainFileVerified {
KeychainSigchainVerified {
input: PathBuf,
report: KeychainSigchainReport,
note: String,
},
KeychainSigchainImported {
input: PathBuf,
ops_imported: usize,
signatures_imported: usize,
invalid_ops_rejected: usize,
note: String,
},
KeychainCheckpointVerified {
checkpoint: KeychainCheckpoint,
verified: bool,
principal: Option<String>,
note: String,
},
KeychainFetched {
url: String,
out: PathBuf,
checkpoint: KeychainCheckpoint,
imported: Option<KeychainFetchImportReport>,
chain_id: String,
records: usize,
head: String,
active_admin_keys: usize,
users: usize,
devices: usize,
nodes: usize,
note: String,
},
KeychainExplained {
@ -1151,22 +1097,6 @@ pub enum ControlResponse {
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainFetchImportReport {
pub ops_imported: usize,
pub signatures_imported: usize,
pub invalid_ops_rejected: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainPublishedSnapshot {
pub name: String,
pub source: PathBuf,
pub path: PathBuf,
pub signature_path: PathBuf,
pub namespace: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse {
pub home: PathBuf,
@ -1834,8 +1764,8 @@ mod tests {
use super::*;
use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 123;
const CONTROL_RESPONSE_VARIANTS: usize = 115;
const CONTROL_REQUEST_VARIANTS: usize = 118;
const CONTROL_RESPONSE_VARIANTS: usize = 110;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
@ -2126,11 +2056,6 @@ mod tests {
"devices": 1,
"nodes": 1
}),
"KeychainFetchImportReport" => json!({
"ops_imported": 1,
"signatures_imported": 1,
"invalid_ops_rejected": 0
}),
"ResourceDescriptor" => json!({
"id": "resource:sample",
"kind": "kv",
@ -2269,27 +2194,6 @@ mod tests {
"accepted_head": null,
"note": "sample"
}),
"KeychainSigchainEntry" => json!({
"op": sample_for_type("KeychainOp"),
"signatures": []
}),
"KeychainCheckpoint" => json!({
"version": 1,
"profile": {
"keychain_signature_namespace": "geth.keychain.v1@geth.local",
"node_enrollment_request_namespace": "geth.node-enrollment-request.v1@geth.local",
"default_admin_principal": "geth-admin"
},
"base_url": "https://example.invalid",
"head": null,
"ops": 1,
"signatures": 1,
"sigchain_bytes": 1,
"sigchain_hash": "hash",
"allowed_signers_hash": "hash",
"reduced_view_hash": "hash",
"generated_at": 1
}),
"AuthExplanation" => json!({
"subject": "node:peer",
"resource": "resource:sample",

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

View file

@ -1,30 +1,34 @@
//! Reference implementation of the small, generic SSHSIGCHAIN v2 core.
//! Reference implementation of the small, generic SSHSIGCHAIN v1 core.
//!
//! The core deliberately knows nothing about geth's resource or identity
//! model. It verifies one linear, linked sequence against an explicitly
//! configured root key, then delegates authorization and state transitions to
//! an application profile. `KeychainV2Policy` below is geth's first profile.
//! See `docs/sshsigchain-v2.md` for the interoperable format.
//! an application profile. `KeychainSshSigchainPolicy` below is geth's first profile.
//! See `docs/sshsigchain.md` for the interoperable format.
use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops};
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
pub const SSH_SIGCHAIN_VERSION: u8 = 2;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2";
pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2";
pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1;
pub const SSH_SIGCHAIN_VERSION: u8 = 1;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain";
pub const KEYCHAIN_SSH_SIGCHAIN_PROFILE: &str = "geth.keychain.sshsigchain.v1";
pub const KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION: u16 = 1;
pub const MAX_PROFILE_BYTES: usize = 128;
pub const MAX_NAMESPACE_BYTES: usize = 128;
pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024;
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
pub const MAX_RECORDS: usize = 100_000;
pub const MAX_JSONL_LINE_BYTES: usize = 5 * 1024 * 1024;
pub const MAX_JSONL_BYTES: usize = 64 * 1024 * 1024;
const SIGNING_MAGIC: &[u8] = b"SSCS";
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0";
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v1\0";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ChainId(pub [u8; 32]);
@ -84,6 +88,7 @@ impl SshSigchainTrust {
/// A JSON-serializable transport envelope. Its JSON representation is not
/// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SshSigchainRecord {
pub chain_id: ChainId,
pub profile: String,
@ -94,6 +99,46 @@ pub struct SshSigchainRecord {
pub signature: Vec<u8>,
}
pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result<String, SshSigchainError> {
let mut output = String::new();
for record in records {
record.validate(true)?;
output.push_str(
&serde_json::to_string(record)
.map_err(|error| SshSigchainError::JsonEncoding(error.to_string()))?,
);
output.push('\n');
}
Ok(output)
}
pub fn decode_sshsigchain_jsonl(input: &str) -> Result<Vec<SshSigchainRecord>, SshSigchainError> {
if input.len() > MAX_JSONL_BYTES {
return Err(SshSigchainError::JsonlTooLarge(input.len()));
}
let mut records = Vec::new();
for (index, line) in input.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
if line.len() > MAX_JSONL_LINE_BYTES {
return Err(SshSigchainError::JsonLineTooLarge {
line: index + 1,
bytes: line.len(),
});
}
let record = serde_json::from_str(line).map_err(|error| SshSigchainError::JsonLine {
line: index + 1,
detail: error.to_string(),
})?;
records.push(record);
if records.len() > MAX_RECORDS {
return Err(SshSigchainError::TooManyRecords(records.len()));
}
}
Ok(records)
}
impl SshSigchainRecord {
pub fn unsigned(
chain_id: ChainId,
@ -276,96 +321,98 @@ where
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KeychainV2Policy;
pub struct KeychainSshSigchainPolicy;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2State {
pub struct KeychainSshSigchainState {
initialized: bool,
admin_public_keys: BTreeMap<KeyId, String>,
seen_op_ids: BTreeSet<AuthOpId>,
ops: Vec<KeychainOp>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2Verification {
pub struct KeychainSshSigchainVerification {
pub view: KeychainView,
pub records: usize,
pub head: Digest,
}
pub fn keychain_v2_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
geth_codec::encode_canonical(&KeychainV2Payload {
version: KEYCHAIN_V2_PAYLOAD_VERSION,
pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
geth_codec::encode_canonical(&KeychainSshSigchainPayload {
version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION,
op: CanonicalKeychainOp::from(op),
})
.map_err(|error| SshSigchainError::PayloadEncoding(error.to_string()))
}
pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
let decoded = geth_codec::decode_canonical::<KeychainV2Payload>(bytes)
pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
let decoded = geth_codec::decode_canonical::<KeychainSshSigchainPayload>(bytes)
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?;
if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION {
if decoded.version != KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION {
return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
}
let op = KeychainOp::from(decoded.op);
let canonical = keychain_v2_payload(&op)?;
let canonical = keychain_sshsigchain_payload(&op)?;
if canonical != bytes {
return Err(SshSigchainError::NonCanonicalPayload);
}
Ok(op)
}
pub fn keychain_v2_unsigned_record(
pub fn keychain_sshsigchain_unsigned_record(
trust: &SshSigchainTrust,
sequence: u64,
previous: Option<Digest>,
op: &KeychainOp,
signer_public_key: impl AsRef<str>,
) -> Result<SshSigchainRecord, SshSigchainError> {
if trust.profile != KEYCHAIN_V2_PROFILE {
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(),
));
}
SshSigchainRecord::unsigned(
trust.chain_id,
KEYCHAIN_V2_PROFILE,
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
sequence,
previous,
keychain_v2_payload(op)?,
keychain_sshsigchain_payload(op)?,
signer_public_key,
)
}
pub fn verify_keychain_v2_sigchain<V>(
pub fn verify_keychain_sshsigchain<V>(
records: &[SshSigchainRecord],
trust: &SshSigchainTrust,
verifier: &V,
) -> Result<KeychainV2Verification, SshSigchainError>
) -> Result<KeychainSshSigchainVerification, SshSigchainError>
where
V: SshSigchainVerifier + ?Sized,
{
if trust.profile != KEYCHAIN_V2_PROFILE {
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(),
));
}
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?;
Ok(KeychainV2Verification {
let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?;
Ok(KeychainSshSigchainVerification {
view: reduce_keychain_ops(&verified.state.ops),
records: verified.records,
head: verified.head,
})
}
impl SshSigchainPolicy for KeychainV2Policy {
type State = KeychainV2State;
impl SshSigchainPolicy for KeychainSshSigchainPolicy {
type State = KeychainSshSigchainState;
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> {
let root =
canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?;
Ok(KeychainV2State {
Ok(KeychainSshSigchainState {
initialized: false,
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
seen_op_ids: BTreeSet::new(),
ops: Vec::new(),
})
}
@ -383,7 +430,11 @@ impl SshSigchainPolicy for KeychainV2Policy {
}
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> {
let op = decode_keychain_v2_payload(&record.payload).map_err(|error| error.to_string())?;
let op = decode_keychain_sshsigchain_payload(&record.payload)
.map_err(|error| error.to_string())?;
if !state.seen_op_ids.insert(op.id.clone()) {
return Err(format!("duplicate keychain operation ID: {}", op.id));
}
match &op.kind {
KeychainOpKind::KeychainInit => {
if state.initialized || record.sequence != 0 {
@ -403,30 +454,48 @@ impl SshSigchainPolicy for KeychainV2Policy {
}
if valid_after_ms.is_some() || valid_before_ms.is_some() {
return Err(
"v2 does not accept key validity windows as security policy; use a causally ordered revocation record"
"SSHSIGCHAIN does not accept key validity windows as security policy; use a causally ordered revocation record"
.to_owned(),
);
}
let public_key = public_key.as_deref().ok_or_else(|| {
"AdminKeyAdd requires its canonical public key in v2".to_owned()
"AdminKeyAdd requires its canonical public key in SSHSIGCHAIN".to_owned()
})?;
let public_key =
canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?;
if KeyId::new(admin_key_fingerprint(&public_key)) != *key {
return Err("AdminKeyAdd key ID does not match its public key".to_owned());
}
if record.sequence == 1 && public_key != record.signer_public_key {
return Err(
"sequence 1 must record the configured root signer as an admin key"
.to_owned(),
);
}
state.admin_public_keys.insert(key.clone(), public_key);
}
KeychainOpKind::AdminKeyRevoke { key } => {
if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned());
}
if record.sequence == 1 {
return Err(
"sequence 1 must record the configured root signer as an admin key"
.to_owned(),
);
}
state.admin_public_keys.remove(key);
}
_ => {
if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned());
}
if record.sequence == 1 {
return Err(
"sequence 1 must record the configured root signer as an admin key"
.to_owned(),
);
}
}
}
state.ops.push(op);
@ -435,13 +504,13 @@ impl SshSigchainPolicy for KeychainV2Policy {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct KeychainV2Payload {
struct KeychainSshSigchainPayload {
version: u16,
op: CanonicalKeychainOp,
}
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard
/// deliberately cannot deserialize that representation, so v2 uses this
/// deliberately cannot deserialize that representation, so SSHSIGCHAIN uses this
/// profile-local externally tagged mirror for its signed binary payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct CanonicalKeychainOp {
@ -654,7 +723,9 @@ pub enum SshSigchainError {
InvalidChainId,
#[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")]
InvalidProfile,
#[error("SSH sigchain namespace must be non-empty printable ASCII without whitespace")]
#[error(
"SSH sigchain namespace must be non-empty printable ASCII without whitespace and at most {MAX_NAMESPACE_BYTES} bytes"
)]
InvalidNamespace,
#[error("SSH public key must be a canonical two-field OpenSSH public key")]
InvalidPublicKey,
@ -668,6 +739,16 @@ pub enum SshSigchainError {
MissingSignature,
#[error("SSH sigchain cannot be empty")]
EmptyChain,
#[error("SSH sigchain JSONL line {line}: {detail}")]
JsonLine { line: usize, detail: String },
#[error("failed to encode SSH sigchain JSONL: {0}")]
JsonEncoding(String),
#[error("SSH sigchain JSONL input is {0} bytes, over the {MAX_JSONL_BYTES}-byte limit")]
JsonlTooLarge(usize),
#[error(
"SSH sigchain JSONL line {line} is {bytes} bytes, over the {MAX_JSONL_LINE_BYTES}-byte limit"
)]
JsonLineTooLarge { line: usize, bytes: usize },
#[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
TooManyRecords(usize),
#[error("SSH sigchain sequence counter overflow")]
@ -690,15 +771,15 @@ pub enum SshSigchainError {
InvalidSignature { index: usize },
#[error("SSH sigchain policy rejected record: {0}")]
Policy(String),
#[error("failed to encode keychain v2 payload: {0}")]
#[error("failed to encode keychain SSHSIGCHAIN payload: {0}")]
PayloadEncoding(String),
#[error("failed to decode keychain v2 payload: {0}")]
#[error("failed to decode keychain SSHSIGCHAIN payload: {0}")]
PayloadDecoding(String),
#[error("unsupported keychain v2 payload version {0}")]
#[error("unsupported keychain SSHSIGCHAIN payload version {0}")]
UnsupportedPayloadVersion(u16),
#[error("keychain v2 payload is not its unique canonical encoding")]
#[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")]
NonCanonicalPayload,
#[error("expected geth keychain v2 profile, got {0}")]
#[error("expected geth keychain SSHSIGCHAIN profile, got {0}")]
WrongKeychainProfile(String),
#[error("length does not fit the SSH sigchain wire format")]
LengthOverflow,
@ -732,7 +813,7 @@ fn validate_profile(value: String) -> Result<String, SshSigchainError> {
fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
if value.is_empty()
|| value.len() > MAX_PROFILE_BYTES
|| value.len() > MAX_NAMESPACE_BYTES
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
{
return Err(SshSigchainError::InvalidNamespace);
@ -804,7 +885,7 @@ mod tests {
fn trust() -> SshSigchainTrust {
SshSigchainTrust::new(
ChainId([7; 32]),
KEYCHAIN_V2_PROFILE,
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
SSH_SIGCHAIN_NAMESPACE,
ROOT_KEY,
)
@ -826,7 +907,7 @@ mod tests {
op: &KeychainOp,
signer: &str,
) -> SshSigchainRecord {
let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer)
let record = keychain_sshsigchain_unsigned_record(trust, sequence, previous, op, signer)
.expect("unsigned record");
let signature = test_signature(
&trust.namespace,
@ -851,7 +932,7 @@ mod tests {
}
#[test]
fn v2_accepts_a_linked_rooted_keychain() {
fn accepts_a_linked_rooted_keychain() {
let trust = trust();
let init = signed_record(
&trust,
@ -882,7 +963,7 @@ mod tests {
ROOT_KEY,
);
let verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier)
let verified = verify_keychain_sshsigchain(&[init, add, user], &trust, &TestVerifier)
.expect("valid chain");
assert_eq!(verified.records, 3);
assert_eq!(
@ -893,7 +974,7 @@ mod tests {
}
#[test]
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() {
fn requires_an_explicit_root_instead_of_self_bootstrap() {
let trust = trust();
let init = signed_record(
&trust,
@ -903,13 +984,13 @@ mod tests {
SECOND_KEY,
);
assert!(matches!(
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier),
verify_keychain_sshsigchain(&[init], &trust, &TestVerifier),
Err(SshSigchainError::RootSignerMismatch)
));
}
#[test]
fn v2_rejects_a_non_linked_fork() {
fn rejects_a_non_linked_fork() {
let trust = trust();
let init = signed_record(
&trust,
@ -920,13 +1001,94 @@ mod tests {
);
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
assert!(matches!(
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier),
verify_keychain_sshsigchain(&[init, add], &trust, &TestVerifier),
Err(SshSigchainError::UnexpectedPrevious { index: 1 })
));
}
#[test]
fn v2_revocation_is_causal_not_timestamp_ordered() {
fn requires_the_root_to_be_recorded_before_other_identity_changes() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let user = signed_record(
&trust,
1,
Some(init.record_hash().expect("hash")),
&op(
"op:user",
2,
KeychainOpKind::UserAdd {
user: "user:alice".into(),
name: "Alice".to_owned(),
},
),
ROOT_KEY,
);
assert!(matches!(
verify_keychain_sshsigchain(&[init, user], &trust, &TestVerifier),
Err(SshSigchainError::Policy(_))
));
}
#[test]
fn rejects_duplicate_keychain_operation_ids() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let add = signed_record(
&trust,
1,
Some(init.record_hash().expect("hash")),
&root_add_op(),
ROOT_KEY,
);
let user = signed_record(
&trust,
2,
Some(add.record_hash().expect("hash")),
&op(
"op:user",
3,
KeychainOpKind::UserAdd {
user: "user:alice".into(),
name: "Alice".to_owned(),
},
),
ROOT_KEY,
);
let duplicate = signed_record(
&trust,
3,
Some(user.record_hash().expect("hash")),
&op(
"op:user",
4,
KeychainOpKind::UserRename {
user: "user:alice".into(),
name: "Mallory".to_owned(),
},
),
ROOT_KEY,
);
assert!(matches!(
verify_keychain_sshsigchain(&[init, add, user, duplicate], &trust, &TestVerifier),
Err(SshSigchainError::Policy(_))
));
}
#[test]
fn revocation_is_causal_not_timestamp_ordered() {
let trust = trust();
let init = signed_record(
&trust,
@ -972,20 +1134,80 @@ mod tests {
ROOT_KEY,
);
assert!(matches!(
verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
Err(SshSigchainError::Policy(_))
));
}
#[test]
fn v2_payload_rejects_trailing_or_noncanonical_bytes() {
let payload =
keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload");
fn payload_rejects_trailing_or_noncanonical_bytes() {
let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit))
.expect("payload");
let mut noncanonical = payload;
noncanonical.push(0);
assert!(matches!(
decode_keychain_v2_payload(&noncanonical),
decode_keychain_sshsigchain_payload(&noncanonical),
Err(SshSigchainError::NonCanonicalPayload)
));
}
#[test]
fn jsonl_transport_roundtrips_without_becoming_signed_data() {
let trust = trust();
let record = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode JSONL");
assert_eq!(
decode_sshsigchain_jsonl(&jsonl).expect("decode JSONL"),
vec![record]
);
}
#[test]
fn jsonl_transport_rejects_unknown_record_fields() {
let trust = trust();
let record = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let mut value = serde_json::to_value(record).expect("record JSON");
value
.as_object_mut()
.expect("record object")
.insert("unrecognized".to_owned(), serde_json::Value::Bool(true));
assert!(matches!(
decode_sshsigchain_jsonl(&format!("{value}\n")),
Err(SshSigchainError::JsonLine { line: 1, .. })
));
}
#[test]
fn signing_bytes_match_the_published_base_vector() {
let record = SshSigchainRecord::unsigned(
ChainId([0; 32]),
"example.test",
0,
None,
vec![1, 2],
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2",
)
.expect("unsigned record");
assert_eq!(
hex::encode(record.signing_bytes().expect("signing bytes")),
concat!(
"5353435301",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000",
"00000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332"
)
);
}
}

View file

@ -12,6 +12,7 @@ bytes.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tempfile.workspace = true
tokio.workspace = true
tracing.workspace = true
geth-auth = { path = "../geth-auth" }

View file

@ -80,6 +80,7 @@ use runtime::{
PubsubRuntime,
};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -117,6 +118,8 @@ pub enum NodeError {
Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError),
#[error("SSH sigchain error: {0}")]
SshSigchain(#[from] geth_keychain::SshSigchainError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
@ -137,8 +140,6 @@ pub enum NodeError {
InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <resource>=<capability>: {0}")]
InvalidEnrollmentCapability(String),
#[error("invalid keychain snapshot, expected <name>=<path>: {0}")]
InvalidKeychainSnapshot(String),
#[error("node enrollment request not found: {0}")]
NodeEnrollmentRequestNotFound(String),
#[error("node enrollment request has invalid provenance: {0}")]
@ -6495,95 +6496,34 @@ pub fn handle_request(
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
})
}
ControlRequest::KeychainSigchainExport { out } => {
let ops = load_keychain_ops(&store)?;
let signatures = load_keychain_signatures(&store)?;
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
ControlRequest::KeychainVerifySigchain {
input,
chain_id,
root_key_path,
namespace,
} => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
node,
&checkpoint,
&signature,
&sigchain,
&allowed_signers,
base_url.as_deref(),
principal.as_deref(),
let records = read_sshsigchain_jsonl_file(&input)?;
let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
std::fs::read_to_string(root_key_path)?,
)?;
Ok(ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
.to_owned(),
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
Ok(ControlResponse::KeychainSigchainVerified {
input,
chain_id: trust.chain_id.to_hex(),
records: verified.records,
head: verified.head.to_hex(),
active_admin_keys: verified.view.admin_keys.len(),
users: verified.view.users.len(),
devices: verified.view.devices.len(),
nodes: verified.view.nodes.len(),
note:
"verified linked SSHSIGCHAIN v1 records against the explicitly pinned root key"
.to_owned(),
})
}
ControlRequest::KeychainFetch { url, out, import } => {
fetch_keychain_bundle(&store, node, url, out, import)
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
@ -9483,301 +9423,6 @@ fn stored_keychain_op_from_op(op: &KeychainOp) -> Result<StoredKeychainOp, NodeE
})
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StaticKeychainSourceState {
head: Option<AuthOpId>,
ops: usize,
sigchain_hash: String,
generated_at_ms: i64,
}
fn read_and_verify_sigchain_file(
node: &LocalNode,
input: &Path,
) -> Result<
(
Vec<KeychainOp>,
Vec<KeychainOpSignature>,
geth_keychain::KeychainSigchainReport,
),
NodeError,
> {
let text = std::fs::read_to_string(input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok((ops, signatures, report))
}
fn import_verified_sigchain(
store: &Store,
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
rejected_ops: usize,
) -> Result<geth_control::KeychainFetchImportReport, NodeError> {
if rejected_ops != 0 {
return Ok(geth_control::KeychainFetchImportReport {
ops_imported: 0,
signatures_imported: 0,
invalid_ops_rejected: rejected_ops,
});
}
let existing_ops = load_keychain_ops(store)?
.into_iter()
.map(|op| (op.id.clone(), op))
.collect::<BTreeMap<_, _>>();
let existing_signatures = load_keychain_signatures(store)?
.into_iter()
.map(|signature| {
(
(
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
),
signature,
)
})
.collect::<BTreeMap<_, _>>();
for op in ops {
if existing_ops
.get(&op.id)
.is_some_and(|existing| existing != op)
{
return Err(NodeError::Unauthorized(format!(
"refusing keychain operation {} because that ID already names different immutable content",
op.id
)));
}
}
for signature in signatures {
let identity = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
);
if existing_signatures
.get(&identity)
.is_some_and(|existing| existing != signature)
{
return Err(NodeError::Unauthorized(format!(
"refusing keychain signature for {} because its signer and namespace already name different immutable content",
signature.op_id
)));
}
}
let mut signatures_imported = 0;
let mut ops_imported = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.map(stored_keychain_signature_from_signature)
.collect::<Vec<_>>();
store
.insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?;
if !existing_ops.contains_key(&op.id) {
ops_imported += 1;
}
for signature in signatures
.iter()
.filter(|signature| signature.op_id == op.id)
{
let key = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
);
signatures_imported += usize::from(!existing_signatures.contains_key(&key));
}
}
Ok(geth_control::KeychainFetchImportReport {
ops_imported,
signatures_imported,
invalid_ops_rejected: 0,
})
}
fn verify_keychain_checkpoint_files(
node: &LocalNode,
checkpoint_path: &Path,
signature_path: &Path,
sigchain_path: &Path,
allowed_signers_path: &Path,
expected_base_url: Option<&str>,
principal: Option<&str>,
) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option<String>), NodeError> {
let checkpoint_text = std::fs::read_to_string(checkpoint_path)?;
let checkpoint: geth_keychain::KeychainCheckpoint = serde_json::from_str(&checkpoint_text)?;
if let Some(expected) = expected_base_url {
let expected = geth_keychain::normalize_base_url(expected.to_owned());
if checkpoint.base_url != expected {
return Ok((checkpoint, false, None));
}
}
let sigchain_text = std::fs::read_to_string(sigchain_path)?;
let allowed_signers = std::fs::read_to_string(allowed_signers_path)?;
if checkpoint.sigchain_bytes != sigchain_text.len() as u64
|| checkpoint.sigchain_hash != geth_keychain::blake3_tagged_hash(sigchain_text.as_bytes())
|| checkpoint.allowed_signers_hash
!= geth_keychain::blake3_tagged_hash(allowed_signers.as_bytes())
{
return Ok((checkpoint, false, None));
}
let entries = geth_keychain::decode_sigchain_jsonl(&sigchain_text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
if report.rejected_ops != 0 || report.accepted_head != checkpoint.head {
return Ok((checkpoint, false, None));
}
let (verified, matched_principal) = verify_file_with_keychain_signers(
&Store::open(&node.paths.metadata_db())?,
node,
checkpoint_path,
signature_path,
geth_keychain::KEYCHAIN_CHECKPOINT_NAMESPACE,
Some(allowed_signers_path),
principal,
)?;
Ok((checkpoint, verified, matched_principal))
}
fn fetch_keychain_bundle(
store: &Store,
node: &LocalNode,
url: String,
out: Option<PathBuf>,
import: bool,
) -> Result<ControlResponse, NodeError> {
let base_url = geth_keychain::normalize_base_url(url.clone());
let out = out.unwrap_or_else(|| {
node.paths
.home()
.join("keychain-fetch")
.join(geth_crypto::blake3_hex(base_url.as_bytes()))
});
std::fs::create_dir_all(&out)?;
for name in [
"allowed_signers",
"geth.sigchain.jsonl",
"geth.sigchain.checkpoint.json",
"geth.sigchain.checkpoint.json.sig",
] {
fetch_bundle_file(&base_url, name, &out.join(name))?;
}
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
let signature_path = out.join("geth.sigchain.checkpoint.json.sig");
let sigchain_path = out.join("geth.sigchain.jsonl");
let allowed_signers_path = out.join("allowed_signers");
let (checkpoint, verified, _) = verify_keychain_checkpoint_files(
node,
&checkpoint_path,
&signature_path,
&sigchain_path,
&allowed_signers_path,
None,
None,
)?;
if !verified {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint did not verify".to_owned(),
));
}
check_static_source_rollback(store, &base_url, &checkpoint)?;
let imported = if import {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &sigchain_path)?;
let imported = import_verified_sigchain(store, &ops, &signatures, report.rejected_ops)?;
if imported.invalid_ops_rejected == 0 {
remember_static_source_checkpoint(store, &base_url, &checkpoint)?;
}
Some(imported)
} else {
None
};
let note = format!(
"fetched and verified static SSH sigchain bundle; checkpoint base URL is {}",
checkpoint.base_url
);
Ok(ControlResponse::KeychainFetched {
url: base_url,
out,
checkpoint,
imported,
note,
})
}
fn fetch_bundle_file(base_url: &str, name: &str, out: &Path) -> Result<(), NodeError> {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(root) = base_url.strip_prefix("file://") {
std::fs::copy(Path::new(root).join(name), out)?;
return Ok(());
}
if base_url.starts_with("http://") || base_url.starts_with("https://") {
let url = format!("{base_url}{name}");
let output = std::process::Command::new("curl")
.arg("-fsSL")
.arg(&url)
.arg("-o")
.arg(out)
.output()?;
if output.status.success() {
return Ok(());
}
return Err(NodeError::IrohPeer(format!(
"curl failed fetching {url}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
std::fs::copy(Path::new(base_url).join(name), out)?;
Ok(())
}
fn static_source_state_key(base_url: &str) -> String {
format!(
"keychain-static-source:{}",
geth_crypto::blake3_hex(base_url.as_bytes())
)
}
fn check_static_source_rollback(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let Some(state) = store.get_module_state(&static_source_state_key(base_url))? else {
return Ok(());
};
let previous: StaticKeychainSourceState = serde_json::from_str(&state.state_json)?;
if previous.generated_at_ms > checkpoint.generated_at.0 || previous.ops > checkpoint.ops {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint is older than the last accepted checkpoint".to_owned(),
));
}
Ok(())
}
fn remember_static_source_checkpoint(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let state = StaticKeychainSourceState {
head: checkpoint.head.clone(),
ops: checkpoint.ops,
sigchain_hash: checkpoint.sigchain_hash.clone(),
generated_at_ms: checkpoint.generated_at.0,
};
store.put_module_state(&StoredModuleState {
module: static_source_state_key(base_url),
state_json: serde_json::to_string(&state)?,
updated_at_ms: geth_store::now_ms(),
})?;
Ok(())
}
fn explain_keychain_op(
store: &Store,
node: &LocalNode,
@ -9869,93 +9514,6 @@ fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, Node
Ok(lines)
}
fn publish_keychain_bundle(
store: &Store,
_node: &LocalNode,
out: PathBuf,
base_url: Option<String>,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
snapshots: Vec<String>,
) -> Result<ControlResponse, NodeError> {
let base_url = base_url
.map(geth_keychain::normalize_base_url)
.unwrap_or_else(|| geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned());
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(store)?,
&load_keychain_signatures(store)?,
);
let (signer, _) = keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
if !entries.iter().any(|entry| entry.key == signer) {
return Err(NodeError::Unauthorized(format!(
"signing key {signer} is not an active keychain admin signer"
)));
}
std::fs::create_dir_all(&out)?;
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let sigchain_entries = geth_keychain::sigchain_entries(&ops, &signatures);
let sigchain_jsonl = geth_keychain::encode_sigchain_jsonl(&sigchain_entries)?;
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
let allowed_signers_path = out.join("allowed_signers");
let sigchain_path = out.join("geth.sigchain.jsonl");
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
std::fs::write(&allowed_signers_path, &allowed_signers)?;
std::fs::write(&sigchain_path, &sigchain_jsonl)?;
let checkpoint = geth_keychain::keychain_checkpoint(
&ops,
&signatures,
&sigchain_jsonl,
&allowed_signers,
base_url.clone(),
UnixMillis(geth_store::now_ms()),
)?;
std::fs::write(&checkpoint_path, serde_json::to_vec_pretty(&checkpoint)?)?;
let checkpoint_signature_path = sign_file_with_ssh(
&signing_key_path,
"geth.sigchain-checkpoint.v1@eric.wendland.dev",
&checkpoint_path,
Some(out.join("geth.sigchain.checkpoint.json.sig")),
)?;
let mut published_snapshots = Vec::new();
for snapshot in snapshots {
let (name, source) = parse_snapshot_arg(&snapshot)?;
let path = out.join(&name);
std::fs::copy(&source, &path)?;
let namespace = snapshot_namespace(&name);
let signature_path = sign_file_with_ssh(
&signing_key_path,
&namespace,
&path,
Some(out.join(format!("{name}.sig"))),
)?;
published_snapshots.push(geth_control::KeychainPublishedSnapshot {
name,
source,
path,
signature_path,
namespace,
});
}
Ok(ControlResponse::KeychainBundlePublished {
out,
base_url,
allowed_signers_path,
sigchain_path,
checkpoint_path,
checkpoint_signature_path,
checkpoint,
snapshots: published_snapshots,
note:
"published static SSH sigchain bundle; serve this directory at the discovery base URL"
.to_owned(),
})
}
fn verify_keychain_sigchain_entries_with_ssh(
node: &LocalNode,
ops: &[KeychainOp],
@ -9984,34 +9542,80 @@ fn verify_keychain_sigchain_entries_with_ssh(
geth_keychain::verify_sigchain(ops, signatures, &verifier)
}
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> {
let Some((name, path)) = value.split_once('=') else {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
fn verify_keychain_sshsigchain_with_ssh(
records: &[geth_keychain::SshSigchainRecord],
trust: &geth_keychain::SshSigchainTrust,
) -> Result<geth_keychain::KeychainSshSigchainVerification, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let verify_dir = tempfile::tempdir()?;
struct SshSigchainOpenSshVerifier {
verify_dir: PathBuf,
}
impl geth_keychain::SshSigchainVerifier for SshSigchainOpenSshVerifier {
fn verify(
&self,
namespace: &str,
message: &[u8],
public_key: &str,
signature: &[u8],
) -> bool {
let payload_path = self.verify_dir.join("payload");
let signature_path = self.verify_dir.join("signature");
let allowed_signers_path = self.verify_dir.join("allowed-signers");
if std::fs::write(&payload_path, message).is_err()
|| std::fs::write(&signature_path, signature).is_err()
|| std::fs::write(
&allowed_signers_path,
format!(
"{} {}\n",
geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL,
public_key
),
)
.is_err()
{
return false;
}
let Ok(payload) = std::fs::File::open(payload_path) else {
return false;
};
std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(allowed_signers_path)
.arg("-I")
.arg(geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL)
.arg("-n")
.arg(namespace)
.arg("-s")
.arg(signature_path)
.stdin(std::process::Stdio::from(payload))
.output()
.is_ok_and(|output| output.status.success())
}
}
let verifier = SshSigchainOpenSshVerifier {
verify_dir: verify_dir.path().to_path_buf(),
};
validate_snapshot_name(name)?;
let path = PathBuf::from(path);
if !path.is_file() {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
}
Ok((name.to_owned(), path))
Ok(geth_keychain::verify_keychain_sshsigchain(
records, trust, &verifier,
)?)
}
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> {
let valid = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
if valid {
Ok(())
} else {
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
fn read_sshsigchain_jsonl_file(
input: &Path,
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
let mut text = String::new();
reader.read_to_string(&mut text)?;
if text.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
}
}
fn snapshot_namespace(name: &str) -> String {
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
}
fn verify_keychain_sigchain_with_ssh(
@ -11390,6 +10994,12 @@ mod tests {
))),
"daemon_already_running"
);
assert_eq!(
local_control::node_error_code(&NodeError::SshSigchain(
geth_keychain::SshSigchainError::EmptyChain
)),
"sshsigchain_error"
);
}
#[test]
@ -11407,6 +11017,108 @@ mod tests {
);
}
#[test]
fn sshsigchain_verifies_real_openssh_sshsig_records() {
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
return;
}
let dir = tempfile::tempdir().expect("temporary SSHSIGCHAIN directory");
let private_key = dir.path().join("root");
let generated = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&private_key)
.output()
.expect("run ssh-keygen");
assert!(
generated.status.success(),
"ssh-keygen key generation failed: {}",
String::from_utf8_lossy(&generated.stderr)
);
let generated_public_key =
std::fs::read_to_string(format!("{}.pub", private_key.display()))
.expect("read public key");
let mut fields = generated_public_key.split_ascii_whitespace();
let root_public_key = format!(
"{} {}",
fields.next().expect("key type"),
fields.next().expect("key blob")
);
let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId([0x42; 32]),
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
&root_public_key,
)
.expect("trust tuple");
let sign = |record: geth_keychain::SshSigchainRecord| {
let payload = dir.path().join(format!("record-{}", record.sequence));
std::fs::write(&payload, record.signing_bytes().expect("signing bytes"))
.expect("write signing payload");
let output = geth_ssh_identity::sign_command(
&private_key,
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
&payload,
)
.output()
.expect("sign SSHSIGCHAIN record");
assert!(
output.status.success(),
"ssh-keygen signing failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let signature =
std::fs::read(format!("{}.sig", payload.display())).expect("read SSHSIG signature");
record.with_signature(signature).expect("signed record")
};
let init_op = KeychainOp {
id: AuthOpId::new("auth-op:sshsigchain-init"),
created_at: UnixMillis(1),
kind: KeychainOpKind::KeychainInit,
};
let init = sign(
geth_keychain::keychain_sshsigchain_unsigned_record(
&trust,
0,
None,
&init_op,
&root_public_key,
)
.expect("unsigned init"),
);
let root_add_op = KeychainOp {
id: AuthOpId::new("auth-op:sshsigchain-root"),
created_at: UnixMillis(2),
kind: KeychainOpKind::AdminKeyAdd {
key: KeyId::new(geth_keychain::admin_key_fingerprint(&root_public_key)),
public_key: Some(root_public_key.clone()),
principal: Some("root".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
};
let root_add = sign(
geth_keychain::keychain_sshsigchain_unsigned_record(
&trust,
1,
Some(init.record_hash().expect("init hash")),
&root_add_op,
&root_public_key,
)
.expect("unsigned root add"),
);
let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone(), root_add], &trust)
.expect("verify real OpenSSH SSHSIG chain");
assert_eq!(verified.records, 2);
assert_eq!(verified.view.admin_keys.len(), 1);
let mut tampered = init;
tampered.payload.push(0);
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());
}
#[derive(Debug, PartialEq, Eq)]
enum RemoteGuardKind {
Capability,

View file

@ -547,6 +547,7 @@ pub(crate) fn node_error_code(error: &NodeError) -> &'static str {
NodeError::Store(_) => "store_error",
NodeError::Config(_) => "config_error",
NodeError::Codec(_) => "codec_error",
NodeError::SshSigchain(_) => "sshsigchain_error",
_ => "node_error",
}
}

View file

@ -3079,9 +3079,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
},
)
.expect("admin add");
match response {
let added_op_id = match response {
geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => {
assert_eq!(signatures.len(), 1);
let op_id = op.id.to_string();
match op.kind {
geth_keychain::KeychainOpKind::AdminKeyAdd {
public_key,
@ -3093,9 +3094,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
}
other => panic!("unexpected op kind: {other:?}"),
}
op_id
}
other => panic!("unexpected response: {other:?}"),
}
};
let response = geth_node::handle_request(
&node,
@ -3193,157 +3195,23 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
other => panic!("unexpected response: {other:?}"),
}
let sigchain_path = home.path().join("keychain.sigchain.jsonl");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainSigchainExport {
out: Some(sigchain_path.clone()),
},
)
.expect("sigchain export");
match response {
geth_control::ControlResponse::KeychainSigchainExported { entries, out, .. } => {
assert_eq!(out.as_deref(), Some(sigchain_path.as_path()));
assert!(entries.len() >= 3);
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain");
let decoded = geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain");
assert_eq!(decoded, entries);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainVerifySigchain {
input: sigchain_path.clone(),
},
)
.expect("verify sigchain file");
match response {
geth_control::ControlResponse::KeychainSigchainFileVerified { report, .. } => {
assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 2);
}
other => panic!("unexpected response: {other:?}"),
}
let bundle_dir = home.path().join("public-bundle");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainPublishBundle {
out: bundle_dir.clone(),
base_url: None,
signing_key_path: admin_key_path.clone(),
admin_key_path: Some(admin_key_path.with_extension("pub")),
snapshots: vec![format!(
"authorized_keys={}",
authorized_keys_path.display()
)],
},
)
.expect("publish bundle");
match response {
geth_control::ControlResponse::KeychainBundlePublished {
base_url,
allowed_signers_path,
sigchain_path: bundle_sigchain_path,
checkpoint_path,
checkpoint_signature_path,
checkpoint,
snapshots,
..
} => {
assert_eq!(base_url, geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL);
assert!(allowed_signers_path.exists());
assert!(bundle_sigchain_path.exists());
assert!(checkpoint_path.exists());
assert!(checkpoint_signature_path.exists());
assert_eq!(
checkpoint.base_url,
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].name, "authorized_keys");
assert!(snapshots[0].path.exists());
assert!(snapshots[0].signature_path.exists());
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainVerifyCheckpoint {
checkpoint: bundle_dir.join("geth.sigchain.checkpoint.json"),
signature: bundle_dir.join("geth.sigchain.checkpoint.json.sig"),
sigchain: bundle_dir.join("geth.sigchain.jsonl"),
allowed_signers: bundle_dir.join("allowed_signers"),
base_url: Some(geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned()),
principal: None,
},
)
.expect("verify checkpoint");
match response {
geth_control::ControlResponse::KeychainCheckpointVerified {
verified,
principal,
..
} => {
assert!(verified);
assert_eq!(principal.as_deref(), Some("admin"));
}
other => panic!("unexpected response: {other:?}"),
}
let fetched_home = tempfile::tempdir().expect("fetch tempdir");
let fetched_paths = geth_config::GethPaths::from_home(fetched_home.path());
let fetched_node = geth_node::init_node(&fetched_paths).expect("init fetched node");
let response = geth_node::handle_request(
&fetched_node,
geth_control::ControlRequest::KeychainFetch {
url: format!("file://{}", bundle_dir.display()),
out: Some(fetched_home.path().join("bundle")),
import: true,
},
)
.expect("fetch bundle");
match response {
geth_control::ControlResponse::KeychainFetched {
imported: Some(imported),
checkpoint,
..
} => {
assert!(imported.ops_imported >= 3);
assert!(imported.signatures_imported >= 3);
assert_eq!(imported.invalid_ops_rejected, 0);
assert_eq!(
checkpoint.base_url,
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
);
}
other => panic!("unexpected response: {other:?}"),
}
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain for explain");
let decoded =
geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain for explain");
let explain_op_id = decoded.last().expect("last sigchain op").op.id.to_string();
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainExplain {
op_id: explain_op_id.clone(),
op_id: added_op_id.clone(),
},
)
.expect("explain op");
.expect("explain operation");
match response {
geth_control::ControlResponse::KeychainExplained { subject, lines } => {
assert_eq!(subject, explain_op_id);
assert_eq!(subject, added_op_id);
assert!(lines.iter().any(|line| line.contains("accepted_by_replay")));
}
other => panic!("unexpected response: {other:?}"),
}
let admin_public_key =
std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin pub");
std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin public key");
let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key);
let response = geth_node::handle_request(
&node,
@ -3353,46 +3221,16 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
match response {
geth_control::ControlResponse::KeychainExplained { lines, .. } => {
assert!(lines.iter().any(|line| line == "active_admin_signer: true"));
assert!(
lines
.iter()
.any(|line| line.starts_with("signed_operations:"))
);
}
other => panic!("unexpected response: {other:?}"),
}
let import_home = tempfile::tempdir().expect("import tempdir");
let import_paths = geth_config::GethPaths::from_home(import_home.path());
let import_node = geth_node::init_node(&import_paths).expect("init import node");
let response = geth_node::handle_request(
&import_node,
geth_control::ControlRequest::KeychainImportSigchain {
input: sigchain_path.clone(),
},
)
.expect("import sigchain file");
match response {
geth_control::ControlResponse::KeychainSigchainImported {
ops_imported,
signatures_imported,
invalid_ops_rejected,
..
} => {
assert!(ops_imported >= 3);
assert!(signatures_imported >= 3);
assert_eq!(invalid_ops_rejected, 0);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
.expect("verify sigchain");
.expect("verify local keychain");
match response {
geth_control::ControlResponse::KeychainVerified { report } => {
assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 2);
assert!(report.note.contains("git-skm"));
}
other => panic!("unexpected response: {other:?}"),
}