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

@ -123,7 +123,10 @@ Roadmap items should be actionable and checkable:
local KV write grants for non-local test callers. Signature validation and local KV write grants for non-local test callers. Signature validation and
broader daemon-side module enforcement are still roadmap work. broader daemon-side module enforcement are still roadmap work.
- The daemon persists local keychain init/admin-key ops and reduces them for - The daemon persists local keychain init/admin-key ops and reduces them for
`keychain status`. SSH signature capture/verification is still roadmap work. `keychain status`. `keychain init --signing-key <path>` signs recorded
keychain ops with `ssh-keygen -Y sign` under the
`geth.keychain.v1@geth.local` namespace and stores signatures locally.
Verification before accepting replicated keychain ops is still roadmap work.
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
`cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon `cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon
can fetch CAS blobs from an imported signed peer card over Iroh when the peer can fetch CAS blobs from an imported signed peer card over Iroh when the peer

1
Cargo.lock generated
View file

@ -1233,6 +1233,7 @@ dependencies = [
"base64", "base64",
"geth-auth", "geth-auth",
"geth-cas", "geth-cas",
"geth-codec",
"geth-config", "geth-config",
"geth-control", "geth-control",
"geth-crypto", "geth-crypto",

View file

@ -27,6 +27,13 @@ registry, module router, local metadata store, and synchronized data structures.
Most non-daemon commands talk to the daemon through a local Unix socket at Most non-daemon commands talk to the daemon through a local Unix socket at
`$GETH_HOME/run/geth.sock`. `$GETH_HOME/run/geth.sock`.
`geth keychain init --admin-key <public-key> --signing-key <private-key>` records
the keychain initialization/admin-key operations and signs their canonical
payloads through `ssh-keygen -Y sign` using the
`geth.keychain.v1@geth.local` namespace. This is the bootstrap path for
admin/YubiKey-rooted trust; signature verification for replicated keychain ops is
still future work.
The daemon can also install itself as a user service: The daemon can also install itself as a user service:
```sh ```sh
@ -82,7 +89,7 @@ The bootstrap implementation provides:
- `geth peer auth-check <node-id> <resource> <capability>` - `geth peer auth-check <node-id> <resource> <capability>`
- `geth resource list` - `geth resource list`
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>]` - `geth keychain init [--admin-key <path>] [--signing-key <path>]`
- `geth keychain status` - `geth keychain status`
- `geth secret status` - `geth secret status`
- `geth secret create <resource>` - `geth secret create <resource>`

View file

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

View file

@ -3,7 +3,7 @@ use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp; use geth_keychain::{KeychainOp, KeychainOpSignature};
use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener}; use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
@ -99,6 +99,7 @@ pub enum ControlRequest {
}, },
KeychainInit { KeychainInit {
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
signing_key_path: Option<PathBuf>,
}, },
KeychainStatus, KeychainStatus,
SecretStatus, SecretStatus,
@ -369,6 +370,7 @@ pub enum ControlResponse {
KeychainStatus(KeychainStatusResponse), KeychainStatus(KeychainStatusResponse),
KeychainInitialized { KeychainInitialized {
ops: Vec<KeychainOp>, ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
}, },
SecretStatus { SecretStatus {
secrets: Vec<ResourceMasterSecret>, secrets: Vec<ResourceMasterSecret>,
@ -911,6 +913,15 @@ mod tests {
#[test] #[test]
fn control_request_response_serialization_roundtrip() { 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 { let request = ControlRequest::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), 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 serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
@ -28,6 +28,15 @@ pub struct KeychainOp {
pub kind: KeychainOpKind, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")] #[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind { pub enum KeychainOpKind {

View file

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

View file

@ -19,7 +19,7 @@ use geth_discovery::{
}; };
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; 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_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener}; use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
@ -34,8 +34,8 @@ use geth_ssh_identity::{
use geth_ssh_proxy::SshProxyConnection; use geth_ssh_proxy::SshProxyConnection;
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState, StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, StoredKvStore,
StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, StoredModuleState, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
@ -63,6 +63,8 @@ pub enum NodeError {
Db(#[from] geth_db::DbError), Db(#[from] geth_db::DbError),
#[error("control error: {0}")] #[error("control error: {0}")]
Control(#[from] geth_control::ControlError), Control(#[from] geth_control::ControlError),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("io error: {0}")] #[error("io error: {0}")]
@ -2860,7 +2862,10 @@ pub fn handle_request(
conflict: file_conflict_from_stored(conflict)?, 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 mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let init = KeychainOp { let init = KeychainOp {
@ -2871,7 +2876,7 @@ pub fn handle_request(
store_keychain_op(&store, &init)?; store_keychain_op(&store, &init)?;
ops.push(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 public_key = std::fs::read_to_string(admin_key_path)?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key)); let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
@ -2884,7 +2889,25 @@ pub fn handle_request(
ops.push(op); 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 => { ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); 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(()) 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> { fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
store store
.list_keychain_ops()? .list_keychain_ops()?

View file

@ -70,6 +70,14 @@ impl Store {
op_json TEXT NOT NULL, op_json TEXT NOT NULL,
created_at_ms INTEGER 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 ( CREATE TABLE IF NOT EXISTS auth_ops (
op_id TEXT PRIMARY KEY, op_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL, resource_id TEXT NOT NULL,
@ -921,6 +929,44 @@ impl Store {
.map_err(StoreError::from) .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( pub fn insert_ssh_cert_request(
&self, &self,
request: &StoredSshCertRequest, request: &StoredSshCertRequest,
@ -1292,6 +1338,15 @@ pub struct StoredKeychainOp {
pub created_at_ms: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest { pub struct StoredSshCertRequest {
pub request_id: String, 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] #[test]
fn cas_pins_roundtrip() { fn cas_pins_roundtrip() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -691,12 +691,14 @@ fn keychain_init_and_status_use_local_keychain_log() {
&node, &node,
geth_control::ControlRequest::KeychainInit { geth_control::ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path), admin_key_path: Some(admin_key_path),
signing_key_path: None,
}, },
) )
.expect("init keychain"); .expect("init keychain");
match response { match response {
geth_control::ControlResponse::KeychainInitialized { ops } => { geth_control::ControlResponse::KeychainInitialized { ops, signatures } => {
assert_eq!(ops.len(), 2); assert_eq!(ops.len(), 2);
assert!(signatures.is_empty());
} }
other => panic!("unexpected response: {other:?}"), 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] #[test]
fn db_add_and_status_register_local_db_metadata() { fn db_add_and_status_register_local_db_metadata() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -235,8 +235,11 @@ identity. Keychain operations reduce into an active view containing current
admin keys, users, devices, node records, agent bindings, and endpoint-to-node admin keys, users, devices, node records, agent bindings, and endpoint-to-node
bindings. Revoked identity subtrees are excluded from that active view. The bindings. Revoked identity subtrees are excluded from that active view. The
daemon persists local keychain init/admin-key operations and `keychain status` daemon persists local keychain init/admin-key operations and `keychain status`
reports the reduced local view. OpenSSH signature capture and verification for reports the reduced local view. `keychain init --signing-key <path>` writes the
those operations is still future work. canonical keychain signing payloads, runs `ssh-keygen -Y sign` with the explicit
`geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH
signatures in local SQLite. Verification and rejection of unsigned replicated
keychain operations are still future work.
The authorization plane is `geth-auth`: resource-local signed operation logs, The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`. Auth operations reduce into a grants, revocations, groups, and `auth explain`. Auth operations reduce into a

View file

@ -169,10 +169,17 @@ resource-scoped capability decisions.
- `[x]` `geth keychain init --admin-key <path>` records an admin SSH public - `[x]` `geth keychain init --admin-key <path>` records an admin SSH public
key fingerprint. key fingerprint.
- `[x]` `geth keychain status` reports the reduced local keychain view. - `[x]` `geth keychain status` reports the reduced local keychain view.
- `[ ]` Future completion records signed `KeychainInit` operations. - `[x]` `geth keychain init --signing-key <path>` signs recorded keychain ops
- `[ ]` OpenSSH signature namespaces are explicit in the signing flow. with `ssh-keygen -Y sign`.
- `[ ]` Missing `ssh-keygen` or unavailable hardware keys produce clear - `[x]` OpenSSH keychain signatures use the explicit
`geth.keychain.v1@geth.local` namespace.
- `[x]` Keychain OpenSSH signatures are stored in local SQLite.
- `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear
errors during signing. errors during signing.
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key
when `ssh-keygen` is available.
- `[ ]` Future completion verifies signatures before accepting replicated
keychain ops.
- `[x]` Keychain operation reducer. - `[x]` Keychain operation reducer.
Acceptance criteria: Acceptance criteria: