Wire keychain status to local keychain ops
This commit is contained in:
parent
f54920bb65
commit
99f6dee26f
12 changed files with 191 additions and 20 deletions
|
|
@ -114,6 +114,8 @@ Roadmap items should be actionable and checkable:
|
||||||
groups, and bearer access. The daemon persists local auth grant/revoke ops
|
groups, and bearer access. The daemon persists local auth grant/revoke ops
|
||||||
and uses them for `auth explain`. Signature validation and daemon-side module
|
and uses them for `auth explain`. Signature validation and daemon-side module
|
||||||
enforcement are still roadmap work.
|
enforcement are still roadmap work.
|
||||||
|
- The daemon persists local keychain init/admin-key ops and reduces them for
|
||||||
|
`keychain status`. SSH signature capture/verification is still roadmap work.
|
||||||
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
|
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
|
||||||
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
|
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
|
||||||
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
|
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1115,6 +1115,7 @@ name = "geth-control"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"geth-auth",
|
"geth-auth",
|
||||||
|
"geth-keychain",
|
||||||
"geth-resource",
|
"geth-resource",
|
||||||
"geth-ssh-identity",
|
"geth-ssh-identity",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
|
|
@ -1205,6 +1206,7 @@ dependencies = [
|
||||||
"geth-control",
|
"geth-control",
|
||||||
"geth-crypto",
|
"geth-crypto",
|
||||||
"geth-iroh",
|
"geth-iroh",
|
||||||
|
"geth-keychain",
|
||||||
"geth-resource",
|
"geth-resource",
|
||||||
"geth-ssh-identity",
|
"geth-ssh-identity",
|
||||||
"geth-store",
|
"geth-store",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ The bootstrap implementation provides:
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
- `geth resource list`
|
- `geth resource list`
|
||||||
- `geth resource create <kind> <name>`
|
- `geth resource create <kind> <name>`
|
||||||
|
- `geth keychain init [--admin-key <path>]`
|
||||||
- `geth keychain status`
|
- `geth keychain status`
|
||||||
- `geth auth explain <subject> <resource> <capability>`
|
- `geth auth explain <subject> <resource> <capability>`
|
||||||
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
|
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,10 @@ pub enum ResourceCommand {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum KeychainCommand {
|
pub enum KeychainCommand {
|
||||||
Init,
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
admin_key: Option<PathBuf>,
|
||||||
|
},
|
||||||
Status,
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,10 +344,9 @@ 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,
|
command: KeychainCommand::Init { admin_key },
|
||||||
} => ControlRequest::ModuleStub {
|
} => ControlRequest::KeychainInit {
|
||||||
module: "keychain".to_owned(),
|
admin_key_path: admin_key,
|
||||||
command: "init".to_owned(),
|
|
||||||
},
|
},
|
||||||
Command::Keychain {
|
Command::Keychain {
|
||||||
command: KeychainCommand::Status,
|
command: KeychainCommand::Status,
|
||||||
|
|
@ -595,6 +597,12 @@ 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 } => {
|
||||||
|
println!("initialized keychain");
|
||||||
|
for op in ops {
|
||||||
|
println!("recorded keychain op: {}", op.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
ControlResponse::AuthExplain(explain) => {
|
ControlResponse::AuthExplain(explain) => {
|
||||||
println!("allowed: {}", explain.allowed);
|
println!("allowed: {}", explain.allowed);
|
||||||
println!("subject: {}", explain.subject);
|
println!("subject: {}", explain.subject);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
geth-auth = { path = "../geth-auth" }
|
geth-auth = { path = "../geth-auth" }
|
||||||
|
geth-keychain = { path = "../geth-keychain" }
|
||||||
geth-resource = { path = "../geth-resource" }
|
geth-resource = { path = "../geth-resource" }
|
||||||
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use geth_auth::{AuthExplanation, AuthOp};
|
use geth_auth::{AuthExplanation, AuthOp};
|
||||||
|
use geth_keychain::KeychainOp;
|
||||||
use geth_resource::ResourceDescriptor;
|
use geth_resource::ResourceDescriptor;
|
||||||
use geth_ssh_identity::{
|
use geth_ssh_identity::{
|
||||||
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
||||||
|
|
@ -31,6 +32,9 @@ pub enum ControlRequest {
|
||||||
hash: BlobHash,
|
hash: BlobHash,
|
||||||
},
|
},
|
||||||
CasList,
|
CasList,
|
||||||
|
KeychainInit {
|
||||||
|
admin_key_path: Option<PathBuf>,
|
||||||
|
},
|
||||||
KeychainStatus,
|
KeychainStatus,
|
||||||
AuthExplain {
|
AuthExplain {
|
||||||
subject: String,
|
subject: String,
|
||||||
|
|
@ -114,6 +118,9 @@ pub enum ControlResponse {
|
||||||
blobs: Vec<CasBlob>,
|
blobs: Vec<CasBlob>,
|
||||||
},
|
},
|
||||||
KeychainStatus(KeychainStatusResponse),
|
KeychainStatus(KeychainStatusResponse),
|
||||||
|
KeychainInitialized {
|
||||||
|
ops: Vec<KeychainOp>,
|
||||||
|
},
|
||||||
AuthExplain(AuthExplanation),
|
AuthExplain(AuthExplanation),
|
||||||
AuthOpRecorded {
|
AuthOpRecorded {
|
||||||
op: AuthOp,
|
op: AuthOp,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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" }
|
||||||
geth-iroh = { path = "../geth-iroh" }
|
geth-iroh = { path = "../geth-iroh" }
|
||||||
|
geth-keychain = { path = "../geth-keychain" }
|
||||||
geth-resource = { path = "../geth-resource" }
|
geth-resource = { path = "../geth-resource" }
|
||||||
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
||||||
geth-store = { path = "../geth-store" }
|
geth-store = { path = "../geth-store" }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use geth_control::{
|
||||||
};
|
};
|
||||||
use geth_crypto::AgentKey;
|
use geth_crypto::AgentKey;
|
||||||
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
||||||
|
use geth_keychain::{KeychainOp, KeychainOpKind};
|
||||||
use geth_resource::ResourceDescriptor;
|
use geth_resource::ResourceDescriptor;
|
||||||
use geth_ssh_identity::{
|
use geth_ssh_identity::{
|
||||||
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
|
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
|
||||||
|
|
@ -16,12 +17,12 @@ use geth_ssh_identity::{
|
||||||
certificate_id, revocation_id, ssh_public_key_fingerprint,
|
certificate_id, revocation_id, ssh_public_key_fingerprint,
|
||||||
};
|
};
|
||||||
use geth_store::{
|
use geth_store::{
|
||||||
Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate,
|
Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest,
|
||||||
StoredSshRevocation,
|
StoredSshCertificate, StoredSshRevocation,
|
||||||
};
|
};
|
||||||
use geth_types::{
|
use geth_types::{
|
||||||
AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId,
|
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
|
||||||
SshCertRequestId, UnixMillis,
|
SshCertId, SshCertRequestId, UnixMillis,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
@ -237,13 +238,40 @@ pub fn handle_request(
|
||||||
.collect(),
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ControlRequest::KeychainInit { admin_key_path } => {
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let init = KeychainOp {
|
||||||
|
id: generated_keychain_op_id("keychain-init", "local", created_at),
|
||||||
|
created_at,
|
||||||
|
kind: KeychainOpKind::KeychainInit,
|
||||||
|
};
|
||||||
|
store_keychain_op(&store, &init)?;
|
||||||
|
ops.push(init);
|
||||||
|
|
||||||
|
if let Some(admin_key_path) = admin_key_path {
|
||||||
|
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));
|
||||||
|
let op = KeychainOp {
|
||||||
|
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
|
||||||
|
created_at,
|
||||||
|
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
|
||||||
|
};
|
||||||
|
store_keychain_op(&store, &op)?;
|
||||||
|
ops.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ControlResponse::KeychainInitialized { ops })
|
||||||
|
}
|
||||||
ControlRequest::KeychainStatus => {
|
ControlRequest::KeychainStatus => {
|
||||||
|
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
||||||
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
|
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
|
||||||
initialized: false,
|
initialized: view.initialized,
|
||||||
admin_keys: 0,
|
admin_keys: view.admin_keys.len(),
|
||||||
users: 0,
|
users: view.users.len(),
|
||||||
devices: 0,
|
devices: view.devices.len(),
|
||||||
nodes: 1,
|
nodes: view.nodes.len(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
ControlRequest::AuthExplain {
|
ControlRequest::AuthExplain {
|
||||||
|
|
@ -518,6 +546,23 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthO
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
|
||||||
|
store.insert_keychain_op(&StoredKeychainOp {
|
||||||
|
op_id: op.id.to_string(),
|
||||||
|
op_json: serde_json::to_string(op)?,
|
||||||
|
created_at_ms: op.created_at.0,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
|
||||||
|
store
|
||||||
|
.list_keychain_ops()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
|
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"grant:{}",
|
"grant:{}",
|
||||||
|
|
@ -539,6 +584,13 @@ fn generated_auth_op_id(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generated_keychain_op_id(kind: &str, stable_id: &str, created_at: UnixMillis) -> AuthOpId {
|
||||||
|
AuthOpId::new(format!(
|
||||||
|
"keychain-op:{}",
|
||||||
|
geth_crypto::blake3_hex(format!("{}\0{kind}\0{stable_id}", created_at.0).as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn stable_node_id(agent_id: &str) -> String {
|
fn stable_node_id(agent_id: &str) -> String {
|
||||||
format!("node:{agent_id}")
|
format!("node:{agent_id}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,31 @@ impl Store {
|
||||||
.map_err(StoreError::from)
|
.map_err(StoreError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> {
|
||||||
|
self.conn.execute(
|
||||||
|
r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms)
|
||||||
|
VALUES (?1, ?2, ?3)"#,
|
||||||
|
params![op.op_id, op.op_json, op.created_at_ms],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_keychain_ops(&self) -> Result<Vec<StoredKeychainOp>, StoreError> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
r#"SELECT op_id, op_json, created_at_ms
|
||||||
|
FROM keychain_ops ORDER BY created_at_ms, op_id"#,
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(StoredKeychainOp {
|
||||||
|
op_id: row.get(0)?,
|
||||||
|
op_json: row.get(1)?,
|
||||||
|
created_at_ms: row.get(2)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
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,
|
||||||
|
|
@ -513,6 +538,13 @@ pub struct StoredAuthOp {
|
||||||
pub created_at_ms: i64,
|
pub created_at_ms: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct StoredKeychainOp {
|
||||||
|
pub op_id: String,
|
||||||
|
pub op_json: String,
|
||||||
|
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,
|
||||||
|
|
@ -653,4 +685,27 @@ mod tests {
|
||||||
vec![first]
|
vec![first]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keychain_ops_roundtrip() {
|
||||||
|
let store = Store::open_memory().expect("open");
|
||||||
|
let first = StoredKeychainOp {
|
||||||
|
op_id: "op:keychain:1".to_owned(),
|
||||||
|
op_json: r#"{"id":"op:keychain:1"}"#.to_owned(),
|
||||||
|
created_at_ms: 1,
|
||||||
|
};
|
||||||
|
let second = StoredKeychainOp {
|
||||||
|
op_id: "op:keychain:2".to_owned(),
|
||||||
|
op_json: r#"{"id":"op:keychain:2"}"#.to_owned(),
|
||||||
|
created_at_ms: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.insert_keychain_op(&second).expect("insert second");
|
||||||
|
store.insert_keychain_op(&first).expect("insert first");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.list_keychain_ops().expect("list keychain ops"),
|
||||||
|
vec![first, second]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,40 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keychain_init_and_status_use_local_keychain_log() {
|
||||||
|
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.pub");
|
||||||
|
std::fs::write(&admin_key_path, "ssh-ed25519 AAAAADMIN eric@geth\n").expect("write admin key");
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::KeychainInit {
|
||||||
|
admin_key_path: Some(admin_key_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("init keychain");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::KeychainInitialized { ops } => {
|
||||||
|
assert_eq!(ops.len(), 2);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus)
|
||||||
|
.expect("keychain status");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::KeychainStatus(status) => {
|
||||||
|
assert!(status.initialized);
|
||||||
|
assert_eq!(status.admin_keys, 1);
|
||||||
|
assert_eq!(status.users, 0);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
|
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
|
||||||
let home = tempfile::tempdir().expect("tempdir");
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,10 @@ The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents
|
||||||
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
||||||
identity. Keychain operations reduce into an active view containing current
|
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.
|
bindings. Revoked identity subtrees are excluded from that active view. The
|
||||||
|
daemon persists local keychain init/admin-key operations and `keychain status`
|
||||||
|
reports the reduced local view. OpenSSH signature capture and verification for
|
||||||
|
those operations is 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
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,16 @@ resource-scoped capability decisions.
|
||||||
- JSON is not used as the signed representation.
|
- JSON is not used as the signed representation.
|
||||||
- Tests verify equivalent operations hash/sign identically across runs.
|
- Tests verify equivalent operations hash/sign identically across runs.
|
||||||
|
|
||||||
- `[ ]` SSH-admin-rooted keychain initialization.
|
- `[~]` SSH-admin-rooted keychain initialization.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `geth keychain init` records a signed `KeychainInit`.
|
- `[x]` `geth keychain init` records a local `KeychainInit`.
|
||||||
- OpenSSH signature namespaces are explicit.
|
- `[x]` `geth keychain init --admin-key <path>` records an admin SSH public
|
||||||
- Missing `ssh-keygen` or unavailable hardware keys produce clear errors.
|
key fingerprint.
|
||||||
|
- `[x]` `geth keychain status` reports the reduced local keychain view.
|
||||||
|
- `[ ]` Future completion records signed `KeychainInit` operations.
|
||||||
|
- `[ ]` OpenSSH signature namespaces are explicit in the signing flow.
|
||||||
|
- `[ ]` Missing `ssh-keygen` or unavailable hardware keys produce clear
|
||||||
|
errors during signing.
|
||||||
|
|
||||||
- `[x]` Keychain operation reducer.
|
- `[x]` Keychain operation reducer.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue