Record SSH-signed keychain init ops
This commit is contained in:
parent
91c65e367d
commit
48a83c5a26
12 changed files with 283 additions and 18 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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()?
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue