Extract reusable keychain sigchain model
This commit is contained in:
parent
5b2c30f817
commit
4013c868aa
11 changed files with 938 additions and 20 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1653,6 +1653,7 @@ dependencies = [
|
||||||
name = "geth-keychain"
|
name = "geth-keychain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
"geth-codec",
|
"geth-codec",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -139,6 +139,10 @@ The bootstrap implementation provides:
|
||||||
- `geth resource create <kind> <name>`
|
- `geth resource create <kind> <name>`
|
||||||
- `geth keychain init [--admin-key <path>] [--signing-key <path>]`
|
- `geth keychain init [--admin-key <path>] [--signing-key <path>]`
|
||||||
- `geth keychain status`
|
- `geth keychain status`
|
||||||
|
- `geth keychain admin-add --admin-key <pub> --signing-key <private> [--principal <name>]`
|
||||||
|
- `geth keychain admin-revoke <key-fingerprint> --signing-key <private>`
|
||||||
|
- `geth keychain allowed-signers`
|
||||||
|
- `geth keychain verify`
|
||||||
- `geth keychain sync <node-id-or-name>`
|
- `geth keychain sync <node-id-or-name>`
|
||||||
- `geth auth sync <node-id-or-name>`
|
- `geth auth sync <node-id-or-name>`
|
||||||
- `geth sync status`
|
- `geth sync status`
|
||||||
|
|
@ -436,6 +440,26 @@ geth node revoke-grant resource:ssh-proxy:local <grant-id> \
|
||||||
geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
|
geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Admin SSH keys are managed through the same signed keychain log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth keychain admin-add \
|
||||||
|
--admin-key ~/.ssh/new_admin.pub \
|
||||||
|
--signing-key ~/.ssh/id_ed25519_sk \
|
||||||
|
--principal admin
|
||||||
|
geth keychain allowed-signers > /tmp/geth.allowed_signers
|
||||||
|
geth keychain verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The keychain follows a sigchain model documented in
|
||||||
|
`docs/sigchain-keychain.md`: each keychain operation is accepted only if it is
|
||||||
|
signed by an admin key from the previously accepted reduced view. This is the
|
||||||
|
geth analogue of verifying `git-skm` allowed-signers changes from a prior
|
||||||
|
trusted state. The reusable mechanics live in the `geth-keychain` crate,
|
||||||
|
including allowed-signers projection, replay verification, and an appendable
|
||||||
|
JSONL sigchain file format suitable for static hosting with HTTP caching/range
|
||||||
|
requests.
|
||||||
|
|
||||||
The enrollment flow for a new node is:
|
The enrollment flow for a new node is:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -635,6 +635,27 @@ pub enum KeychainCommand {
|
||||||
signing_key: Option<PathBuf>,
|
signing_key: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
Status,
|
Status,
|
||||||
|
AdminAdd {
|
||||||
|
#[arg(long)]
|
||||||
|
admin_key: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
signing_key: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
principal: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
valid_after_ms: Option<i64>,
|
||||||
|
#[arg(long)]
|
||||||
|
valid_before_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
AdminRevoke {
|
||||||
|
key: String,
|
||||||
|
#[arg(long)]
|
||||||
|
signing_key: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
admin_key: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
AllowedSigners,
|
||||||
|
Verify,
|
||||||
Sync {
|
Sync {
|
||||||
node: String,
|
node: String,
|
||||||
},
|
},
|
||||||
|
|
@ -1432,6 +1453,40 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Keychain {
|
Command::Keychain {
|
||||||
command: KeychainCommand::Status,
|
command: KeychainCommand::Status,
|
||||||
} => ControlRequest::KeychainStatus,
|
} => ControlRequest::KeychainStatus,
|
||||||
|
Command::Keychain {
|
||||||
|
command:
|
||||||
|
KeychainCommand::AdminAdd {
|
||||||
|
admin_key,
|
||||||
|
signing_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
},
|
||||||
|
} => ControlRequest::KeychainAdminAdd {
|
||||||
|
admin_key_path: admin_key,
|
||||||
|
signing_key_path: signing_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
},
|
||||||
|
Command::Keychain {
|
||||||
|
command:
|
||||||
|
KeychainCommand::AdminRevoke {
|
||||||
|
key,
|
||||||
|
signing_key,
|
||||||
|
admin_key,
|
||||||
|
},
|
||||||
|
} => ControlRequest::KeychainAdminRevoke {
|
||||||
|
key,
|
||||||
|
signing_key_path: signing_key,
|
||||||
|
admin_key_path: admin_key,
|
||||||
|
},
|
||||||
|
Command::Keychain {
|
||||||
|
command: KeychainCommand::AllowedSigners,
|
||||||
|
} => ControlRequest::KeychainAllowedSigners,
|
||||||
|
Command::Keychain {
|
||||||
|
command: KeychainCommand::Verify,
|
||||||
|
} => ControlRequest::KeychainVerify,
|
||||||
Command::Keychain {
|
Command::Keychain {
|
||||||
command: KeychainCommand::Sync { node },
|
command: KeychainCommand::Sync { node },
|
||||||
} => ControlRequest::KeychainSync { node },
|
} => ControlRequest::KeychainSync { node },
|
||||||
|
|
@ -2458,6 +2513,47 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ControlResponse::KeychainAdminUpdated {
|
||||||
|
op,
|
||||||
|
signatures,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
println!("recorded keychain op: {}", op.id);
|
||||||
|
for signature in signatures {
|
||||||
|
println!(
|
||||||
|
"signed keychain op: {} by {} ({})",
|
||||||
|
signature.op_id, signature.signer, signature.namespace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::KeychainAllowedSigners {
|
||||||
|
allowed_signers,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
print!("{allowed_signers}");
|
||||||
|
if allowed_signers.is_empty() {
|
||||||
|
println!("no active admin public keys available");
|
||||||
|
}
|
||||||
|
eprintln!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::KeychainVerified { report } => {
|
||||||
|
println!("ops: {}", report.ops);
|
||||||
|
println!("signatures: {}", report.signatures);
|
||||||
|
println!("accepted_ops: {}", report.accepted_ops);
|
||||||
|
println!("rejected_ops: {}", report.rejected_ops);
|
||||||
|
println!("active_admin_keys: {}", report.active_admin_keys);
|
||||||
|
println!(
|
||||||
|
"accepted_head: {}",
|
||||||
|
report
|
||||||
|
.accepted_head
|
||||||
|
.as_ref()
|
||||||
|
.map(|head| head.as_str())
|
||||||
|
.unwrap_or("none")
|
||||||
|
);
|
||||||
|
println!("note: {}", report.note);
|
||||||
|
}
|
||||||
ControlResponse::KeychainSynced {
|
ControlResponse::KeychainSynced {
|
||||||
peer_node_id,
|
peer_node_id,
|
||||||
peer_agent_id,
|
peer_agent_id,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ 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, KeychainOpSignature, NodeEnrollmentRequest, NodeRecord};
|
use geth_keychain::{
|
||||||
|
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
|
||||||
|
NodeEnrollmentRequest, NodeRecord,
|
||||||
|
};
|
||||||
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
|
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
|
||||||
use geth_overlay::{
|
use geth_overlay::{
|
||||||
OverlayInterfacePlan, OverlayJoinPlan, OverlayNetworkStatus, OverlayPacket, OverlayPlan,
|
OverlayInterfacePlan, OverlayJoinPlan, OverlayNetworkStatus, OverlayPacket, OverlayPlan,
|
||||||
|
|
@ -226,6 +229,20 @@ pub enum ControlRequest {
|
||||||
signing_key_path: Option<PathBuf>,
|
signing_key_path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
KeychainStatus,
|
KeychainStatus,
|
||||||
|
KeychainAdminAdd {
|
||||||
|
admin_key_path: PathBuf,
|
||||||
|
signing_key_path: PathBuf,
|
||||||
|
principal: Option<String>,
|
||||||
|
valid_after_ms: Option<i64>,
|
||||||
|
valid_before_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
KeychainAdminRevoke {
|
||||||
|
key: String,
|
||||||
|
signing_key_path: PathBuf,
|
||||||
|
admin_key_path: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
KeychainAllowedSigners,
|
||||||
|
KeychainVerify,
|
||||||
KeychainSync {
|
KeychainSync {
|
||||||
node: String,
|
node: String,
|
||||||
},
|
},
|
||||||
|
|
@ -662,6 +679,19 @@ pub enum ControlResponse {
|
||||||
ops: Vec<KeychainOp>,
|
ops: Vec<KeychainOp>,
|
||||||
signatures: Vec<KeychainOpSignature>,
|
signatures: Vec<KeychainOpSignature>,
|
||||||
},
|
},
|
||||||
|
KeychainAdminUpdated {
|
||||||
|
op: KeychainOp,
|
||||||
|
signatures: Vec<KeychainOpSignature>,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
KeychainAllowedSigners {
|
||||||
|
entries: Vec<KeychainAllowedSigner>,
|
||||||
|
allowed_signers: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
KeychainVerified {
|
||||||
|
report: KeychainSigchainReport,
|
||||||
|
},
|
||||||
KeychainSynced {
|
KeychainSynced {
|
||||||
peer_node_id: String,
|
peer_node_id: String,
|
||||||
peer_agent_id: String,
|
peer_agent_id: String,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
blake3.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
geth-codec = { path = "../geth-codec" }
|
geth-codec = { path = "../geth-codec" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,34 @@ pub struct KeychainOpSignature {
|
||||||
pub created_at: UnixMillis,
|
pub created_at: UnixMillis,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KeychainAllowedSigner {
|
||||||
|
pub key: KeyId,
|
||||||
|
pub principal: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub valid_after_ms: Option<i64>,
|
||||||
|
pub valid_before_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KeychainSigchainReport {
|
||||||
|
pub ops: usize,
|
||||||
|
pub signatures: usize,
|
||||||
|
pub accepted_ops: usize,
|
||||||
|
pub rejected_ops: usize,
|
||||||
|
pub active_admin_keys: usize,
|
||||||
|
pub accepted_head: Option<AuthOpId>,
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KeychainSigchainEntry {
|
||||||
|
pub op: KeychainOp,
|
||||||
|
pub signatures: Vec<KeychainOpSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NodeEnrollmentRequest {
|
pub struct NodeEnrollmentRequest {
|
||||||
pub id: AuthOpId,
|
pub id: AuthOpId,
|
||||||
|
|
@ -127,6 +155,11 @@ pub struct NodeEnrollmentRequestSigningPayload {
|
||||||
pub enum KeychainError {
|
pub enum KeychainError {
|
||||||
#[error("invalid node enrollment status: {0}")]
|
#[error("invalid node enrollment status: {0}")]
|
||||||
InvalidEnrollmentStatus(String),
|
InvalidEnrollmentStatus(String),
|
||||||
|
#[error("sigchain JSONL line {line}: {source}")]
|
||||||
|
SigchainJsonl {
|
||||||
|
line: usize,
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -152,6 +185,10 @@ pub enum KeychainOpKind {
|
||||||
KeychainInit,
|
KeychainInit,
|
||||||
AdminKeyAdd {
|
AdminKeyAdd {
|
||||||
key: KeyId,
|
key: KeyId,
|
||||||
|
public_key: Option<String>,
|
||||||
|
principal: Option<String>,
|
||||||
|
valid_after_ms: Option<i64>,
|
||||||
|
valid_before_ms: Option<i64>,
|
||||||
},
|
},
|
||||||
AdminKeyRevoke {
|
AdminKeyRevoke {
|
||||||
key: KeyId,
|
key: KeyId,
|
||||||
|
|
@ -254,7 +291,7 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
||||||
for op in ops {
|
for op in ops {
|
||||||
match &op.kind {
|
match &op.kind {
|
||||||
KeychainOpKind::KeychainInit => initialized = true,
|
KeychainOpKind::KeychainInit => initialized = true,
|
||||||
KeychainOpKind::AdminKeyAdd { key } => {
|
KeychainOpKind::AdminKeyAdd { key, .. } => {
|
||||||
admin_keys.insert(key.clone());
|
admin_keys.insert(key.clone());
|
||||||
}
|
}
|
||||||
KeychainOpKind::AdminKeyRevoke { key } => {
|
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||||
|
|
@ -364,6 +401,204 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sorted_keychain_ops(mut ops: Vec<KeychainOp>) -> Vec<KeychainOp> {
|
||||||
|
ops.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.created_at.0,
|
||||||
|
keychain_op_order(&left.kind),
|
||||||
|
left.id.to_string(),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.created_at.0,
|
||||||
|
keychain_op_order(&right.kind),
|
||||||
|
right.id.to_string(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 {
|
||||||
|
match kind {
|
||||||
|
KeychainOpKind::KeychainInit => 0,
|
||||||
|
KeychainOpKind::AdminKeyAdd { .. } => 1,
|
||||||
|
KeychainOpKind::AdminKeyRevoke { .. } => 2,
|
||||||
|
_ => 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn allowed_signers(
|
||||||
|
ops: &[KeychainOp],
|
||||||
|
signatures: &[KeychainOpSignature],
|
||||||
|
) -> Vec<KeychainAllowedSigner> {
|
||||||
|
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::new();
|
||||||
|
for op in sorted_keychain_ops(ops.to_vec()) {
|
||||||
|
match op.kind {
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key,
|
||||||
|
public_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
} => {
|
||||||
|
let public_key = public_key
|
||||||
|
.or_else(|| {
|
||||||
|
signatures
|
||||||
|
.iter()
|
||||||
|
.find(|signature| signature.signer == key)
|
||||||
|
.map(|signature| signature.signer_public_key.trim().to_owned())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !public_key.is_empty() {
|
||||||
|
entries.insert(
|
||||||
|
key.clone(),
|
||||||
|
KeychainAllowedSigner {
|
||||||
|
key,
|
||||||
|
principal: principal.unwrap_or_else(|| "admin".to_owned()),
|
||||||
|
public_key,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||||
|
entries.remove(&key);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
for entry in entries {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"{} {}\n",
|
||||||
|
entry.principal,
|
||||||
|
entry.public_key.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_sigchain(
|
||||||
|
ops: &[KeychainOp],
|
||||||
|
signatures: &[KeychainOpSignature],
|
||||||
|
verifier: &KeychainSignatureVerifier<'_>,
|
||||||
|
) -> KeychainSigchainReport {
|
||||||
|
let ops = sorted_keychain_ops(ops.to_vec());
|
||||||
|
let mut accepted = Vec::<KeychainOp>::new();
|
||||||
|
let mut trusted_admins = BTreeSet::<KeyId>::new();
|
||||||
|
let mut rejected_ops = 0;
|
||||||
|
for op in &ops {
|
||||||
|
let op_signatures = signatures
|
||||||
|
.iter()
|
||||||
|
.filter(|signature| signature.op_id == op.id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let bootstrap_init = accepted.is_empty() && matches!(op.kind, KeychainOpKind::KeychainInit);
|
||||||
|
let bootstrap_admin = accepted.len() == 1
|
||||||
|
&& matches!(accepted[0].kind, KeychainOpKind::KeychainInit)
|
||||||
|
&& matches!(&op.kind, KeychainOpKind::AdminKeyAdd { .. });
|
||||||
|
let valid = bootstrap_init
|
||||||
|
|| bootstrap_admin
|
||||||
|
|| op_signatures.iter().any(|signature| {
|
||||||
|
let signer_is_authorized = trusted_admins.contains(&signature.signer);
|
||||||
|
signer_is_authorized
|
||||||
|
&& signature_uses_claimed_key(signature)
|
||||||
|
&& verifier(op, signature)
|
||||||
|
});
|
||||||
|
if valid {
|
||||||
|
accepted.push(op.clone());
|
||||||
|
trusted_admins = reduce_keychain_ops(&accepted)
|
||||||
|
.admin_keys
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
rejected_ops += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let view = reduce_keychain_ops(&accepted);
|
||||||
|
KeychainSigchainReport {
|
||||||
|
ops: ops.len(),
|
||||||
|
signatures: signatures.len(),
|
||||||
|
accepted_ops: accepted.len(),
|
||||||
|
rejected_ops,
|
||||||
|
active_admin_keys: view.admin_keys.len(),
|
||||||
|
accepted_head: accepted.last().map(|op| op.id.clone()),
|
||||||
|
note: "verified by replaying keychain operations against the previously accepted admin-key view, similar to git-skm's parent allowed_signers verification".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
|
||||||
|
KeyId::new(admin_key_fingerprint(&signature.signer_public_key)) == signature.signer
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -390,6 +625,10 @@ mod tests {
|
||||||
created_at: UnixMillis(1),
|
created_at: UnixMillis(1),
|
||||||
kind: KeychainOpKind::AdminKeyAdd {
|
kind: KeychainOpKind::AdminKeyAdd {
|
||||||
key: "key:admin".into(),
|
key: "key:admin".into(),
|
||||||
|
public_key: Some("ssh-ed25519 AAAA test@example".to_owned()),
|
||||||
|
principal: Some("admin".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -422,12 +661,20 @@ mod tests {
|
||||||
2,
|
2,
|
||||||
KeychainOpKind::AdminKeyAdd {
|
KeychainOpKind::AdminKeyAdd {
|
||||||
key: "key:admin-a".into(),
|
key: "key: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,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
op(
|
op(
|
||||||
3,
|
3,
|
||||||
KeychainOpKind::AdminKeyAdd {
|
KeychainOpKind::AdminKeyAdd {
|
||||||
key: "key:admin-b".into(),
|
key: "key:admin-b".into(),
|
||||||
|
public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()),
|
||||||
|
principal: Some("admin-b".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
op(
|
op(
|
||||||
|
|
@ -562,6 +809,110 @@ mod tests {
|
||||||
assert!(!view.endpoints.contains_key("endpoint:old"));
|
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 sigchain_verification_replays_against_prior_admin_view() {
|
||||||
|
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();
|
||||||
|
let admin_b: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-b").into();
|
||||||
|
let ops = vec![
|
||||||
|
op(1, KeychainOpKind::KeychainInit),
|
||||||
|
op(
|
||||||
|
2,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: admin_a.clone(),
|
||||||
|
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
|
||||||
|
principal: Some("admin-a".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
3,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: admin_b.clone(),
|
||||||
|
public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()),
|
||||||
|
principal: Some("admin-b".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
4,
|
||||||
|
KeychainOpKind::AdminKeyRevoke {
|
||||||
|
key: admin_a.clone(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let signatures = vec![
|
||||||
|
KeychainOpSignature {
|
||||||
|
op_id: ops[1].id.clone(),
|
||||||
|
signer: admin_a.clone(),
|
||||||
|
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||||
|
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||||
|
signature: vec![1],
|
||||||
|
created_at: UnixMillis(2),
|
||||||
|
},
|
||||||
|
KeychainOpSignature {
|
||||||
|
op_id: ops[2].id.clone(),
|
||||||
|
signer: admin_a.clone(),
|
||||||
|
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||||
|
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||||
|
signature: vec![1],
|
||||||
|
created_at: UnixMillis(3),
|
||||||
|
},
|
||||||
|
KeychainOpSignature {
|
||||||
|
op_id: ops[3].id.clone(),
|
||||||
|
signer: admin_b,
|
||||||
|
signer_public_key: "ssh-ed25519 AAAA admin-b".to_owned(),
|
||||||
|
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||||
|
signature: vec![1],
|
||||||
|
created_at: UnixMillis(4),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let report = verify_sigchain(&ops, &signatures, &|_, signature| {
|
||||||
|
signature.signature == vec![1]
|
||||||
|
});
|
||||||
|
assert_eq!(report.accepted_ops, 4);
|
||||||
|
assert_eq!(report.rejected_ops, 0);
|
||||||
|
assert_eq!(report.active_admin_keys, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reducer_excludes_revoked_identity_subtrees() {
|
fn reducer_excludes_revoked_identity_subtrees() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,13 @@ fn initialize_owner_keychain(
|
||||||
ops.push(KeychainOp {
|
ops.push(KeychainOp {
|
||||||
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
|
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
|
kind: KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: admin_key,
|
||||||
|
public_key: Some(public_key.trim().to_owned()),
|
||||||
|
principal: Some("admin".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3389,7 +3395,7 @@ async fn keychain_sync_from_peer_since(
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|signature| {
|
.filter(|signature| {
|
||||||
if !trusted_admins.contains(&signature.signer)
|
if !trusted_admins.contains(&signature.signer)
|
||||||
|| !keychain_signature_uses_claimed_key(signature)
|
|| !geth_keychain::signature_uses_claimed_key(signature)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -7578,7 +7584,13 @@ pub fn handle_request(
|
||||||
let op = KeychainOp {
|
let op = KeychainOp {
|
||||||
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
|
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
|
||||||
created_at,
|
created_at,
|
||||||
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
|
kind: KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: admin_key,
|
||||||
|
public_key: Some(public_key.trim().to_owned()),
|
||||||
|
principal: Some("admin".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
store_keychain_op(&store, &op)?;
|
store_keychain_op(&store, &op)?;
|
||||||
ops.push(op);
|
ops.push(op);
|
||||||
|
|
@ -7608,6 +7620,81 @@ pub fn handle_request(
|
||||||
nodes: view.nodes.len(),
|
nodes: view.nodes.len(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
ControlRequest::KeychainAdminAdd {
|
||||||
|
admin_key_path,
|
||||||
|
signing_key_path,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
} => {
|
||||||
|
let public_key = std::fs::read_to_string(&admin_key_path)?;
|
||||||
|
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
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,
|
||||||
|
public_key: Some(public_key.trim().to_owned()),
|
||||||
|
principal: principal.or_else(|| Some("admin".to_owned())),
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let signatures = store_and_sign_keychain_ops(
|
||||||
|
&store,
|
||||||
|
node,
|
||||||
|
std::slice::from_ref(&op),
|
||||||
|
Some(&signing_key_path),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::KeychainAdminUpdated {
|
||||||
|
op,
|
||||||
|
signatures,
|
||||||
|
note: "recorded signed admin key addition in the keychain sigchain".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::KeychainAdminRevoke {
|
||||||
|
key,
|
||||||
|
signing_key_path,
|
||||||
|
admin_key_path,
|
||||||
|
} => {
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let op = KeychainOp {
|
||||||
|
id: generated_keychain_op_id("admin-key-revoke", &key, created_at),
|
||||||
|
created_at,
|
||||||
|
kind: KeychainOpKind::AdminKeyRevoke {
|
||||||
|
key: KeyId::new(key),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let signatures = store_and_sign_keychain_ops(
|
||||||
|
&store,
|
||||||
|
node,
|
||||||
|
std::slice::from_ref(&op),
|
||||||
|
Some(&signing_key_path),
|
||||||
|
admin_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::KeychainAdminUpdated {
|
||||||
|
op,
|
||||||
|
signatures,
|
||||||
|
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::KeychainAllowedSigners => {
|
||||||
|
let entries = geth_keychain::allowed_signers(
|
||||||
|
&load_keychain_ops(&store)?,
|
||||||
|
&load_keychain_signatures(&store)?,
|
||||||
|
);
|
||||||
|
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
|
||||||
|
Ok(ControlResponse::KeychainAllowedSigners {
|
||||||
|
entries,
|
||||||
|
allowed_signers,
|
||||||
|
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
|
||||||
|
report: verify_keychain_sigchain_with_ssh(&store, node)?,
|
||||||
|
}),
|
||||||
ControlRequest::NodeList => {
|
ControlRequest::NodeList => {
|
||||||
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
||||||
Ok(ControlResponse::NodeList {
|
Ok(ControlResponse::NodeList {
|
||||||
|
|
@ -10414,6 +10501,26 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn verify_keychain_sigchain_with_ssh(
|
||||||
|
store: &Store,
|
||||||
|
node: &LocalNode,
|
||||||
|
) -> Result<geth_keychain::KeychainSigchainReport, NodeError> {
|
||||||
|
let ops = load_keychain_ops(store)?;
|
||||||
|
let signatures = load_keychain_signatures(store)?;
|
||||||
|
Ok(geth_keychain::verify_sigchain(
|
||||||
|
&ops,
|
||||||
|
&signatures,
|
||||||
|
&|op, signature| {
|
||||||
|
verify_keychain_signature_with_ssh(
|
||||||
|
node,
|
||||||
|
op,
|
||||||
|
&stored_keychain_signature_from_signature(signature),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn store_and_sign_keychain_ops(
|
fn store_and_sign_keychain_ops(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
|
|
@ -10519,10 +10626,6 @@ fn stored_keychain_signature_from_signature(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
|
|
||||||
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
struct KeychainSignatureStatus {
|
struct KeychainSignatureStatus {
|
||||||
total: usize,
|
total: usize,
|
||||||
|
|
@ -10614,11 +10717,12 @@ fn verify_keychain_signature_with_ssh(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
|
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
|
||||||
store
|
let ops = store
|
||||||
.list_keychain_ops()?
|
.list_keychain_ops()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
|
.map(|stored| serde_json::from_str::<KeychainOp>(&stored.op_json).map_err(NodeError::from))
|
||||||
.collect()
|
.collect::<Result<Vec<_>, NodeError>>()?;
|
||||||
|
Ok(geth_keychain::sorted_keychain_ops(ops))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
|
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
|
||||||
|
|
|
||||||
|
|
@ -2575,6 +2575,85 @@ fn keychain_init_can_record_openssh_signatures() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
||||||
|
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");
|
||||||
|
generate_ssh_key(&admin_key_path);
|
||||||
|
|
||||||
|
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.clone()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("init signed keychain");
|
||||||
|
|
||||||
|
let second_admin_key_path = home.path().join("second_admin_ed25519");
|
||||||
|
generate_ssh_key(&second_admin_key_path);
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::KeychainAdminAdd {
|
||||||
|
admin_key_path: second_admin_key_path.with_extension("pub"),
|
||||||
|
signing_key_path: admin_key_path.clone(),
|
||||||
|
principal: Some("second-admin".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("admin add");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => {
|
||||||
|
assert_eq!(signatures.len(), 1);
|
||||||
|
match op.kind {
|
||||||
|
geth_keychain::KeychainOpKind::AdminKeyAdd {
|
||||||
|
public_key,
|
||||||
|
principal,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(public_key.expect("public key").starts_with("ssh-ed25519 "));
|
||||||
|
assert_eq!(principal.as_deref(), Some("second-admin"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected op kind: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let response =
|
||||||
|
geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners)
|
||||||
|
.expect("allowed signers");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::KeychainAllowedSigners {
|
||||||
|
entries,
|
||||||
|
allowed_signers,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert!(allowed_signers.contains("second-admin ssh-ed25519 "));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
|
||||||
|
.expect("verify sigchain");
|
||||||
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn init_owned_node_records_signed_owner_device_and_node() {
|
fn init_owned_node_records_signed_owner_device_and_node() {
|
||||||
if Command::new("ssh-keygen").arg("-?").output().is_err() {
|
if Command::new("ssh-keygen").arg("-?").output().is_err() {
|
||||||
|
|
|
||||||
|
|
@ -347,11 +347,18 @@ owner statements by accident. `geth node list` shows the active reduced node
|
||||||
view. `geth node rename` and `geth node revoke` record signed keychain
|
view. `geth node rename` and `geth node revoke` record signed keychain
|
||||||
operations and require `--signing-key`. Endpoint rotation is explicit:
|
operations and require `--signing-key`. Endpoint rotation is explicit:
|
||||||
`geth node endpoint-add` and `geth node endpoint-revoke` record signed
|
`geth node endpoint-add` and `geth node endpoint-revoke` record signed
|
||||||
`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. `geth keychain
|
`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. Admin SSH keys
|
||||||
sync <node>` pulls keychain operations and signatures from an imported peer over
|
are updated with signed `geth keychain admin-add` and `geth keychain
|
||||||
Iroh and imports only operations with a valid OpenSSH signature from a currently
|
admin-revoke` operations; `AdminKeyAdd` carries the public key material needed
|
||||||
trusted admin key over the canonical payload. This is currently a pull-based
|
to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
|
||||||
signed operation log, not a CRDT or Keyhive-style convergent authority.
|
the log against the previously accepted admin-key view, mirroring the `git-skm`
|
||||||
|
pattern of verifying key-registry changes from a prior trusted state. `geth
|
||||||
|
keychain sync <node>` pulls keychain operations and signatures from an imported
|
||||||
|
peer over Iroh and imports only operations with a valid OpenSSH signature from a
|
||||||
|
currently trusted admin key over the canonical payload. See
|
||||||
|
`docs/sigchain-keychain.md` for the detailed sigchain design. This is currently
|
||||||
|
a pull-based signed operation log, not a CRDT or Keyhive-style convergent
|
||||||
|
authority.
|
||||||
|
|
||||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||||
state. `geth node enroll request` creates a canonical, agent-key-signed request
|
state. `geth node enroll request` creates a canonical, agent-key-signed request
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,22 @@ resource-scoped capability decisions.
|
||||||
- `[x]` `geth keychain status` reports the stored keychain signature count.
|
- `[x]` `geth keychain status` reports the stored keychain signature count.
|
||||||
- `[x]` `geth keychain status` verifies stored keychain signatures against
|
- `[x]` `geth keychain status` verifies stored keychain signatures against
|
||||||
canonical payloads with OpenSSH when public key material is available.
|
canonical payloads with OpenSSH when public key material is available.
|
||||||
|
- `[x]` `AdminKeyAdd` carries OpenSSH public key material, principal, and
|
||||||
|
optional validity metadata so the admin key registry is reconstructable
|
||||||
|
from the signed log itself.
|
||||||
|
- `[x]` `geth keychain admin-add` and `geth keychain admin-revoke` append
|
||||||
|
signed admin-key registry operations.
|
||||||
|
- `[x]` `geth keychain allowed-signers` exports the active admin key view in
|
||||||
|
OpenSSH `allowed_signers` format.
|
||||||
|
- `[x]` `geth keychain verify` replays the keychain sigchain against the
|
||||||
|
previously accepted admin-key view.
|
||||||
|
- `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon
|
||||||
|
orchestration code.
|
||||||
|
- `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection,
|
||||||
|
replay verification with an injected verifier, and appendable JSONL
|
||||||
|
sigchain encode/decode helpers for static hosting or alternate transports.
|
||||||
|
- `[x]` `docs/sigchain-keychain.md` documents the sigchain data model,
|
||||||
|
verification algorithm, commands, and current security limits.
|
||||||
- `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear
|
- `[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
|
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key
|
||||||
|
|
|
||||||
211
docs/sigchain-keychain.md
Normal file
211
docs/sigchain-keychain.md
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
# Geth Keychain Sigchain Design
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The geth keychain is a signed operation log for mesh identity state. It is the
|
||||||
|
source of truth for admin SSH keys, users, devices, nodes, agents, and endpoint
|
||||||
|
bindings. Peers do not trust a mutable keychain snapshot. They replay signed
|
||||||
|
operations and reduce the accepted log into the current keychain view.
|
||||||
|
|
||||||
|
This is intentionally similar to the `git-skm` pattern:
|
||||||
|
|
||||||
|
- `git-skm` treats a committed `allowed_signers` file as trusted only if every
|
||||||
|
commit that changed it can be verified from a prior trusted state.
|
||||||
|
- geth treats a keychain operation as trusted only if it is signed by an admin
|
||||||
|
key that was trusted in the previously accepted keychain view.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
The reusable model lives in the `geth-keychain` crate. The daemon stores it in
|
||||||
|
SQLite and syncs it over Iroh, but the crate does not depend on SQLite, Iroh, or
|
||||||
|
the geth daemon. Other projects can publish the same keychain as a static
|
||||||
|
sigchain file, append it to object storage, embed it in a document, or transport
|
||||||
|
it by any other mechanism.
|
||||||
|
|
||||||
|
The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation
|
||||||
|
has a deterministic canonical signing payload under the
|
||||||
|
`geth.keychain.v1@geth.local` namespace.
|
||||||
|
|
||||||
|
Important operation kinds:
|
||||||
|
|
||||||
|
- `KeychainInit`
|
||||||
|
- `AdminKeyAdd`
|
||||||
|
- `AdminKeyRevoke`
|
||||||
|
- `UserAdd`, `UserRename`, `UserRevoke`
|
||||||
|
- `DeviceAdd`, `DeviceRevoke`
|
||||||
|
- `DeviceKeyAdd`, `DeviceKeyRevoke`
|
||||||
|
- `NodeAdd`, `NodeRename`, `NodeRevoke`
|
||||||
|
- `NodeEndpointAdd`, `NodeEndpointRevoke`
|
||||||
|
- `AgentBind`
|
||||||
|
|
||||||
|
`AdminKeyAdd` records the admin key fingerprint and, for new operations, the
|
||||||
|
OpenSSH public key material, optional principal, and optional validity metadata.
|
||||||
|
The public key is part of the signed operation so the key registry can be
|
||||||
|
reconstructed from the sigchain itself. Older local data may only have the
|
||||||
|
fingerprint; geth can use stored signature public-key material as a fallback
|
||||||
|
when exporting the current allowed signers view.
|
||||||
|
|
||||||
|
## Signature Rules
|
||||||
|
|
||||||
|
Each signed operation has a `KeychainOpSignature`:
|
||||||
|
|
||||||
|
- `op_id`
|
||||||
|
- signer key fingerprint
|
||||||
|
- signer OpenSSH public key
|
||||||
|
- namespace
|
||||||
|
- OpenSSH signature bytes
|
||||||
|
- creation time
|
||||||
|
|
||||||
|
Signatures are produced with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification uses:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -Y verify \
|
||||||
|
-f <allowed-signers> \
|
||||||
|
-I <signer-principal> \
|
||||||
|
-n geth.keychain.v1@geth.local \
|
||||||
|
-s <signature>
|
||||||
|
```
|
||||||
|
|
||||||
|
The signed payload is canonical binary encoding, not JSON.
|
||||||
|
|
||||||
|
## Verification Algorithm
|
||||||
|
|
||||||
|
For a candidate log ordered by `(created_at_ms, op_id)`:
|
||||||
|
|
||||||
|
1. Start from a local trust anchor. In the bootstrap implementation this is a
|
||||||
|
locally initialized `KeychainInit` plus an admin key added through
|
||||||
|
`geth init --admin-key ... --signing-key ...` or `geth keychain init
|
||||||
|
--admin-key ...`.
|
||||||
|
2. Maintain an accepted operation prefix and reduce it into the current
|
||||||
|
keychain view.
|
||||||
|
3. For each next operation, collect its signatures.
|
||||||
|
4. Accept the operation only if at least one signature:
|
||||||
|
- is from a key fingerprint present in the previous accepted view,
|
||||||
|
- carries public key material that hashes to that fingerprint,
|
||||||
|
- verifies over the canonical operation payload with OpenSSH, and
|
||||||
|
- uses the keychain namespace.
|
||||||
|
5. After accepting the operation, append it to the accepted prefix and reduce
|
||||||
|
again. This lets a valid `AdminKeyAdd` authorize later operations, and a
|
||||||
|
valid `AdminKeyRevoke` stop later authorization by that key.
|
||||||
|
6. Reject unsigned, invalidly signed, conflicting, or out-of-authority
|
||||||
|
operations.
|
||||||
|
|
||||||
|
This mirrors `git-skm`'s "verify from the previously trusted
|
||||||
|
allowed_signers" model, but the transport and storage are geth/Iroh/SQLite
|
||||||
|
instead of Git commits.
|
||||||
|
|
||||||
|
The `geth-keychain` crate exposes this as a transport-neutral verifier. Callers
|
||||||
|
provide:
|
||||||
|
|
||||||
|
- ordered or unordered `KeychainOp[]`
|
||||||
|
- `KeychainOpSignature[]`
|
||||||
|
- a signature verification callback
|
||||||
|
|
||||||
|
The callback is responsible for the cryptographic backend, such as
|
||||||
|
`ssh-keygen -Y verify`, WebCrypto, an HSM, or a test verifier. The crate owns
|
||||||
|
the replay order, bootstrap rule, previous-view authorization rule,
|
||||||
|
`allowed_signers` projection, and reduced keychain view.
|
||||||
|
|
||||||
|
## Static Sigchain File
|
||||||
|
|
||||||
|
For static hosting and range-friendly distribution, `geth-keychain` defines a
|
||||||
|
JSONL sigchain representation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"op":{...},"signatures":[...]}
|
||||||
|
{"op":{...},"signatures":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each line is a complete `KeychainSigchainEntry`. This keeps the file appendable
|
||||||
|
and cacheable:
|
||||||
|
|
||||||
|
- A publisher can append new entries to the end of the file.
|
||||||
|
- HTTP clients can use `ETag`, `Last-Modified`, and byte range requests to fetch
|
||||||
|
only new bytes.
|
||||||
|
- A client can decode complete trailing lines, ignore a partial final line until
|
||||||
|
the next fetch, and replay verification from its last accepted checkpoint.
|
||||||
|
- The canonical signed payload remains the `KeychainOp`; JSONL is only the
|
||||||
|
publication container.
|
||||||
|
|
||||||
|
The crate provides helpers to encode/decode JSONL and flatten entries back into
|
||||||
|
`KeychainOp[]` plus `KeychainOpSignature[]`.
|
||||||
|
|
||||||
|
Future work should add explicit checkpoint records containing the accepted head
|
||||||
|
ID, byte offset, reduced view hash, and log hash. That would let static clients
|
||||||
|
resume verification without replaying the entire file while still detecting
|
||||||
|
rollback or truncation.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Owner bootstrap:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth init \
|
||||||
|
--admin-key ~/.ssh/id_ed25519_sk.pub \
|
||||||
|
--signing-key ~/.ssh/id_ed25519_sk \
|
||||||
|
--node-name laptop
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new admin key:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth keychain admin-add \
|
||||||
|
--admin-key ~/.ssh/new_admin.pub \
|
||||||
|
--signing-key ~/.ssh/current_admin_sk \
|
||||||
|
--principal admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke an admin key:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth keychain admin-revoke <key-fingerprint> \
|
||||||
|
--signing-key ~/.ssh/current_admin_sk
|
||||||
|
```
|
||||||
|
|
||||||
|
Export the reduced active admin key registry in OpenSSH `allowed_signers`
|
||||||
|
format:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth keychain allowed-signers > allowed_signers
|
||||||
|
```
|
||||||
|
|
||||||
|
Replay and verify the local sigchain:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth keychain verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Differences From Git-SKM
|
||||||
|
|
||||||
|
`git-skm` uses Git commit history as the append-only log and a trusted commit
|
||||||
|
hash as the checkpoint. geth uses `KeychainOp[]` as the append-only log and the
|
||||||
|
locally initialized owner/admin key as the bootstrap trust anchor.
|
||||||
|
|
||||||
|
The current geth prototype does not yet provide:
|
||||||
|
|
||||||
|
- signed checkpoint objects equivalent to `skm.last-verified-commit`
|
||||||
|
- transparency-log style append proofs
|
||||||
|
- anti-rollback protection beyond local state, HTTP cache validators, and sync
|
||||||
|
conflict checks
|
||||||
|
- delegated admin scopes or threshold admin signatures
|
||||||
|
- strong compromise-recovery semantics
|
||||||
|
|
||||||
|
Those are future hardening items. The current prototype is intended to make the
|
||||||
|
key registry self-describing, replayable, and testable.
|
||||||
|
|
||||||
|
## Security Invariants
|
||||||
|
|
||||||
|
- The admin SSH private key is never copied into geth state.
|
||||||
|
- Admin key changes are keychain operations, not mutable ACL edits.
|
||||||
|
- Public key material needed to reconstruct active admin signers is stored in
|
||||||
|
the signed operation log.
|
||||||
|
- Import from peers accepts only operations signed by currently trusted admin
|
||||||
|
keys.
|
||||||
|
- A discovered peer card or Iroh EndpointID never grants keychain authority.
|
||||||
|
- Bearer secrets do not grant keychain mutation rights.
|
||||||
Loading…
Reference in a new issue