Record SSH-signed keychain init ops

This commit is contained in:
Eric Wendland 2026-05-19 16:04:20 +02:00
commit 48a83c5a26
12 changed files with 283 additions and 18 deletions

View file

@ -158,6 +158,8 @@ pub enum KeychainCommand {
Init {
#[arg(long)]
admin_key: Option<PathBuf>,
#[arg(long)]
signing_key: Option<PathBuf>,
},
Status,
}
@ -548,9 +550,14 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Keychain {
command: KeychainCommand::Init { admin_key },
command:
KeychainCommand::Init {
admin_key,
signing_key,
},
} => ControlRequest::KeychainInit {
admin_key_path: admin_key,
signing_key_path: signing_key,
},
Command::Keychain {
command: KeychainCommand::Status,
@ -1099,11 +1106,17 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes);
}
ControlResponse::KeychainInitialized { ops } => {
ControlResponse::KeychainInitialized { ops, signatures } => {
println!("initialized keychain");
for op in ops {
println!("recorded keychain op: {}", op.id);
}
for signature in signatures {
println!(
"signed keychain op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
}
ControlResponse::SecretStatus { secrets } => {
if secrets.is_empty() {

View file

@ -3,7 +3,7 @@ use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp;
use geth_keychain::{KeychainOp, KeychainOpSignature};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage;
@ -99,6 +99,7 @@ pub enum ControlRequest {
},
KeychainInit {
admin_key_path: Option<PathBuf>,
signing_key_path: Option<PathBuf>,
},
KeychainStatus,
SecretStatus,
@ -369,6 +370,7 @@ pub enum ControlResponse {
KeychainStatus(KeychainStatusResponse),
KeychainInitialized {
ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
},
SecretStatus {
secrets: Vec<ResourceMasterSecret>,
@ -911,6 +913,15 @@ mod tests {
#[test]
fn control_request_response_serialization_roundtrip() {
let request = ControlRequest::KeychainInit {
admin_key_path: Some(PathBuf::from("admin.pub")),
signing_key_path: Some(PathBuf::from("admin")),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
};

View file

@ -1,4 +1,4 @@
use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@ -28,6 +28,15 @@ pub struct KeychainOp {
pub kind: KeychainOpKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainOpSignature {
pub op_id: AuthOpId,
pub signer: KeyId,
pub namespace: String,
pub signature: Vec<u8>,
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind {

View file

@ -14,6 +14,7 @@ tokio.workspace = true
tracing.workspace = true
geth-auth = { path = "../geth-auth" }
geth-cas = { path = "../geth-cas" }
geth-codec = { path = "../geth-codec" }
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }

View file

@ -19,7 +19,7 @@ use geth_discovery::{
};
use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_keychain::{KeychainOp, KeychainOpKind, KeychainOpSignature};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage;
@ -34,8 +34,8 @@ use geth_ssh_identity::{
use geth_ssh_proxy::SshProxyConnection;
use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState,
StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, StoredKvStore,
StoredModuleState, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
@ -63,6 +63,8 @@ pub enum NodeError {
Db(#[from] geth_db::DbError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
@ -2860,7 +2862,10 @@ pub fn handle_request(
conflict: file_conflict_from_stored(conflict)?,
})
}
ControlRequest::KeychainInit { admin_key_path } => {
ControlRequest::KeychainInit {
admin_key_path,
signing_key_path,
} => {
let mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms());
let init = KeychainOp {
@ -2871,7 +2876,7 @@ pub fn handle_request(
store_keychain_op(&store, &init)?;
ops.push(init);
if let Some(admin_key_path) = admin_key_path {
if let Some(admin_key_path) = admin_key_path.as_ref() {
let public_key = std::fs::read_to_string(admin_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
@ -2884,7 +2889,25 @@ pub fn handle_request(
ops.push(op);
}
Ok(ControlResponse::KeychainInitialized { ops })
let signatures = if let Some(signing_key_path) = signing_key_path {
let signer =
keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
let mut signatures = Vec::new();
for op in &ops {
signatures.push(sign_keychain_op_with_ssh(
&store,
node,
op,
&signing_key_path,
&signer,
)?);
}
signatures
} else {
Vec::new()
};
Ok(ControlResponse::KeychainInitialized { ops, signatures })
}
ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
@ -3994,6 +4017,64 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(())
}
fn keychain_signer_from_paths(
signing_key_path: &Path,
admin_key_path: Option<&Path>,
) -> Result<KeyId, NodeError> {
let public_key_path = admin_key_path
.map(Path::to_path_buf)
.unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf());
let public_key = std::fs::read_to_string(public_key_path)?;
Ok(KeyId::new(ssh_public_key_fingerprint(&public_key)))
}
fn sign_keychain_op_with_ssh(
store: &Store,
node: &LocalNode,
op: &KeychainOp,
signing_key_path: &Path,
signer: &KeyId,
) -> Result<KeychainOpSignature, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_dir = node.paths.home().join("keychain-signatures");
std::fs::create_dir_all(&signature_dir)?;
let payload_path = signature_dir.join(format!(
"{}.payload",
geth_crypto::blake3_hex(op.id.as_str().as_bytes())
));
std::fs::write(&payload_path, geth_keychain::keychain_signing_payload(op)?)?;
let output = geth_ssh_identity::sign_command(
signing_key_path,
geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE,
&payload_path,
)
.output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let signature_path = Path::new(&format!("{}.sig", payload_path.display())).to_path_buf();
let signature_bytes = std::fs::read(signature_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let signature = KeychainOpSignature {
op_id: op.id.clone(),
signer: signer.clone(),
namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: signature_bytes,
created_at,
};
store.insert_keychain_signature(&StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
})?;
Ok(signature)
}
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
store
.list_keychain_ops()?

View file

@ -70,6 +70,14 @@ impl Store {
op_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS keychain_signatures (
op_id TEXT NOT NULL,
signer TEXT NOT NULL,
namespace TEXT NOT NULL,
signature BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
PRIMARY KEY (op_id, signer, namespace)
);
CREATE TABLE IF NOT EXISTS auth_ops (
op_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
@ -921,6 +929,44 @@ impl Store {
.map_err(StoreError::from)
}
pub fn insert_keychain_signature(
&self,
signature: &StoredKeychainSignature,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO keychain_signatures(
op_id, signer, namespace, signature, created_at_ms
)
VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
signature.op_id,
signature.signer,
signature.namespace,
signature.signature,
signature.created_at_ms
],
)?;
Ok(())
}
pub fn list_keychain_signatures(&self) -> Result<Vec<StoredKeychainSignature>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT op_id, signer, namespace, signature, created_at_ms
FROM keychain_signatures ORDER BY created_at_ms, op_id, signer, namespace"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredKeychainSignature {
op_id: row.get(0)?,
signer: row.get(1)?,
namespace: row.get(2)?,
signature: row.get(3)?,
created_at_ms: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request(
&self,
request: &StoredSshCertRequest,
@ -1292,6 +1338,15 @@ pub struct StoredKeychainOp {
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredKeychainSignature {
pub op_id: String,
pub signer: String,
pub namespace: String,
pub signature: Vec<u8>,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest {
pub request_id: String,
@ -1486,6 +1541,29 @@ mod tests {
);
}
#[test]
fn keychain_signatures_roundtrip() {
let store = Store::open_memory().expect("open");
let signature = StoredKeychainSignature {
op_id: "op:keychain:1".to_owned(),
signer: "ssh:blake3:admin".to_owned(),
namespace: "geth.keychain.v1@geth.local".to_owned(),
signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(),
created_at_ms: 3,
};
store
.insert_keychain_signature(&signature)
.expect("insert signature");
assert_eq!(
store
.list_keychain_signatures()
.expect("list keychain signatures"),
vec![signature]
);
}
#[test]
fn cas_pins_roundtrip() {
let store = Store::open_memory().expect("open");

View file

@ -691,12 +691,14 @@ fn keychain_init_and_status_use_local_keychain_log() {
&node,
geth_control::ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path),
signing_key_path: None,
},
)
.expect("init keychain");
match response {
geth_control::ControlResponse::KeychainInitialized { ops } => {
geth_control::ControlResponse::KeychainInitialized { ops, signatures } => {
assert_eq!(ops.len(), 2);
assert!(signatures.is_empty());
}
other => panic!("unexpected response: {other:?}"),
}
@ -713,6 +715,55 @@ fn keychain_init_and_status_use_local_keychain_log() {
}
}
#[test]
fn keychain_init_can_record_openssh_signatures() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {
return;
}
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let admin_key_path = home.path().join("admin_ed25519");
let status = Command::new("ssh-keygen")
.arg("-q")
.arg("-t")
.arg("ed25519")
.arg("-N")
.arg("")
.arg("-f")
.arg(&admin_key_path)
.status()
.expect("generate admin ssh key");
assert!(status.success());
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path.with_extension("pub")),
signing_key_path: Some(admin_key_path),
},
)
.expect("init signed keychain");
match response {
geth_control::ControlResponse::KeychainInitialized { ops, signatures } => {
assert_eq!(ops.len(), 2);
assert_eq!(signatures.len(), 2);
assert!(signatures.iter().all(|signature| {
signature.namespace == "geth.keychain.v1@geth.local"
&& !signature.signature.is_empty()
}));
}
other => panic!("unexpected response: {other:?}"),
}
let signatures = geth_store::Store::open(&paths.metadata_db())
.expect("open store")
.list_keychain_signatures()
.expect("list signatures");
assert_eq!(signatures.len(), 2);
}
#[test]
fn db_add_and_status_register_local_db_metadata() {
let home = tempfile::tempdir().expect("tempdir");