Add static keychain publication workflow
This commit is contained in:
parent
b6ffcde54c
commit
cfd41522d1
11 changed files with 1852 additions and 46 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1525,6 +1525,7 @@ dependencies = [
|
|||
"geth-cas",
|
||||
"geth-config",
|
||||
"geth-control",
|
||||
"geth-keychain",
|
||||
"geth-node",
|
||||
"geth-pipe",
|
||||
"serde_json",
|
||||
|
|
|
|||
41
README.md
41
README.md
|
|
@ -448,6 +448,29 @@ geth keychain admin-add \
|
|||
--signing-key ~/.ssh/id_ed25519_sk \
|
||||
--principal admin
|
||||
geth keychain allowed-signers > /tmp/geth.allowed_signers
|
||||
geth keychain allowed-signers --out /tmp/geth.allowed_signers
|
||||
geth keychain sign-file \
|
||||
--in /tmp/authorized_keys \
|
||||
--out /tmp/authorized_keys.sig \
|
||||
--signing-key ~/.ssh/id_ed25519_sk
|
||||
geth keychain verify-file \
|
||||
--in /tmp/authorized_keys \
|
||||
--signature /tmp/authorized_keys.sig
|
||||
geth keychain sigchain --out /tmp/geth.sigchain.jsonl
|
||||
geth keychain publish-bundle \
|
||||
--out ./public/.well-known/sshsigchain \
|
||||
--signing-key ~/.ssh/id_ed25519_sk \
|
||||
--snapshot authorized_keys=/tmp/authorized_keys
|
||||
geth keychain verify-checkpoint \
|
||||
--checkpoint /tmp/geth.sigchain.checkpoint.json \
|
||||
--signature /tmp/geth.sigchain.checkpoint.json.sig \
|
||||
--sigchain /tmp/geth.sigchain.jsonl \
|
||||
--allowed-signers /tmp/geth.allowed_signers
|
||||
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
|
||||
geth keychain verify-sigchain --in /tmp/geth.sigchain.jsonl
|
||||
geth keychain import-sigchain --in /tmp/geth.sigchain.jsonl
|
||||
geth keychain explain <op-id>
|
||||
geth keychain explain-signer <key-id>
|
||||
geth keychain verify
|
||||
```
|
||||
|
||||
|
|
@ -459,7 +482,23 @@ trusted state. The reusable mechanics live in the `geth-keychain` crate,
|
|||
including application-specific signature profiles, allowed-signers projection,
|
||||
replay verification through a caller-provided verifier trait, and an appendable
|
||||
JSONL sigchain file format suitable for static hosting with HTTP caching/range
|
||||
requests.
|
||||
requests. The default discovery/publication base is
|
||||
`https://example.com/.well-known/sshsigchain/`; publish bundles contain
|
||||
`allowed_signers`, `geth.sigchain.jsonl`, `geth.sigchain.checkpoint.json`, and
|
||||
`geth.sigchain.checkpoint.json.sig`. Clients can verify checkpoints, fetch
|
||||
bundles, import verified sigchains, and remember the last accepted checkpoint to
|
||||
reject older static bundles from the same source. `keychain fetch --url` is the
|
||||
retrieval location, so local `file://` mirrors work for testing; the signed
|
||||
checkpoint still records the advertised publication base URL, and
|
||||
`verify-checkpoint --base-url` can pin that value when needed.
|
||||
|
||||
Signing is mediated by OpenSSH. `--signing-key` may point at a private key file,
|
||||
a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is
|
||||
available in `ssh-agent`. For encrypted private keys, the recommended workflow
|
||||
is to unlock the key with `ssh-add` and pass the public key path. For PKCS#11
|
||||
tokens, load the key into `ssh-agent` with `ssh-add -s <provider>` and use the
|
||||
exported public key path; direct PKCS#11 signing is not exposed by
|
||||
`ssh-keygen -Y sign` in a portable way.
|
||||
|
||||
The enrollment flow for a new node is:
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ serde_json.workspace = true
|
|||
tokio.workspace = true
|
||||
geth-config = { path = "../geth-config" }
|
||||
geth-control = { path = "../geth-control" }
|
||||
geth-keychain = { path = "../geth-keychain" }
|
||||
geth-cas = { path = "../geth-cas" }
|
||||
geth-node = { path = "../geth-node" }
|
||||
geth-pipe = { path = "../geth-pipe" }
|
||||
|
|
|
|||
|
|
@ -153,6 +153,24 @@ const GUIDE_KEYS: &str = r#"Key terminology:
|
|||
shells out to ssh-keygen -Y sign with explicit namespaces to sign canonical
|
||||
geth keychain/auth operations. The private key is not copied into geth state.
|
||||
|
||||
Signing sources:
|
||||
Local key file:
|
||||
--signing-key ~/.ssh/id_ed25519 --admin-key ~/.ssh/id_ed25519.pub
|
||||
|
||||
Encrypted key file:
|
||||
Load it into ssh-agent with `ssh-add ~/.ssh/id_ed25519`, then sign through
|
||||
the agent by passing the public key path:
|
||||
--signing-key ~/.ssh/id_ed25519.pub
|
||||
|
||||
FIDO/YubiKey OpenSSH key:
|
||||
Use the security-key stub or load it into ssh-agent:
|
||||
--signing-key ~/.ssh/id_ed25519_sk --admin-key ~/.ssh/id_ed25519_sk.pub
|
||||
|
||||
PKCS#11:
|
||||
Direct ssh-keygen -Y signing does not expose a portable -D provider option.
|
||||
Load the token key into ssh-agent with `ssh-add -s <provider>`, then pass
|
||||
the public key path with --signing-key.
|
||||
|
||||
Examples:
|
||||
Software key:
|
||||
--admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519
|
||||
|
|
@ -160,6 +178,15 @@ Examples:
|
|||
YubiKey/FIDO OpenSSH key:
|
||||
--admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk
|
||||
|
||||
Generate the active OpenSSH allowed_signers projection:
|
||||
geth keychain allowed-signers --out ~/.config/geth/allowed_signers
|
||||
|
||||
Sign an arbitrary authorized_keys snapshot with an active admin key:
|
||||
geth keychain sign-file --in ~/.ssh/authorized_keys --out ~/.ssh/authorized_keys.sig --signing-key ~/.ssh/id_ed25519_sk
|
||||
|
||||
Verify the snapshot signature against the current keychain trust root:
|
||||
geth keychain verify-file --in ~/.ssh/authorized_keys --signature ~/.ssh/authorized_keys.sig
|
||||
|
||||
If you use --owner, --node-name, or --capability during init, geth requires both
|
||||
key options because those fields create signed owner/device/node statements.
|
||||
"#;
|
||||
|
|
@ -654,7 +681,86 @@ pub enum KeychainCommand {
|
|||
#[arg(long)]
|
||||
admin_key: Option<PathBuf>,
|
||||
},
|
||||
AllowedSigners,
|
||||
AllowedSigners {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
SignFile {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
namespace: Option<String>,
|
||||
#[arg(long)]
|
||||
signing_key: PathBuf,
|
||||
#[arg(long)]
|
||||
admin_key: Option<PathBuf>,
|
||||
},
|
||||
VerifyFile {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
#[arg(long)]
|
||||
signature: PathBuf,
|
||||
#[arg(long)]
|
||||
namespace: Option<String>,
|
||||
#[arg(long)]
|
||||
allowed_signers: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
principal: Option<String>,
|
||||
},
|
||||
Sigchain {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
PublishBundle {
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
|
||||
base_url: String,
|
||||
#[arg(long)]
|
||||
signing_key: PathBuf,
|
||||
#[arg(long)]
|
||||
admin_key: Option<PathBuf>,
|
||||
#[arg(long = "snapshot")]
|
||||
snapshots: Vec<String>,
|
||||
},
|
||||
VerifySigchain {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
},
|
||||
ImportSigchain {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
},
|
||||
VerifyCheckpoint {
|
||||
#[arg(long)]
|
||||
checkpoint: PathBuf,
|
||||
#[arg(long)]
|
||||
signature: PathBuf,
|
||||
#[arg(long)]
|
||||
sigchain: PathBuf,
|
||||
#[arg(long)]
|
||||
allowed_signers: PathBuf,
|
||||
#[arg(long)]
|
||||
base_url: Option<String>,
|
||||
#[arg(long)]
|
||||
principal: Option<String>,
|
||||
},
|
||||
Fetch {
|
||||
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
|
||||
url: String,
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
import: bool,
|
||||
},
|
||||
Explain {
|
||||
op_id: String,
|
||||
},
|
||||
ExplainSigner {
|
||||
key: String,
|
||||
},
|
||||
Verify,
|
||||
Sync {
|
||||
node: String,
|
||||
|
|
@ -1482,8 +1588,92 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
|||
admin_key_path: admin_key,
|
||||
},
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::AllowedSigners,
|
||||
} => ControlRequest::KeychainAllowedSigners,
|
||||
command: KeychainCommand::AllowedSigners { out },
|
||||
} => ControlRequest::KeychainAllowedSigners { out },
|
||||
Command::Keychain {
|
||||
command:
|
||||
KeychainCommand::SignFile {
|
||||
input,
|
||||
out,
|
||||
namespace,
|
||||
signing_key,
|
||||
admin_key,
|
||||
},
|
||||
} => ControlRequest::KeychainSignFile {
|
||||
input,
|
||||
out,
|
||||
namespace,
|
||||
signing_key_path: Some(signing_key),
|
||||
admin_key_path: admin_key,
|
||||
},
|
||||
Command::Keychain {
|
||||
command:
|
||||
KeychainCommand::VerifyFile {
|
||||
input,
|
||||
signature,
|
||||
namespace,
|
||||
allowed_signers,
|
||||
principal,
|
||||
},
|
||||
} => ControlRequest::KeychainVerifyFile {
|
||||
input,
|
||||
signature,
|
||||
namespace,
|
||||
allowed_signers_path: allowed_signers,
|
||||
principal,
|
||||
},
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::Sigchain { out },
|
||||
} => ControlRequest::KeychainSigchainExport { out },
|
||||
Command::Keychain {
|
||||
command:
|
||||
KeychainCommand::PublishBundle {
|
||||
out,
|
||||
base_url,
|
||||
signing_key,
|
||||
admin_key,
|
||||
snapshots,
|
||||
},
|
||||
} => ControlRequest::KeychainPublishBundle {
|
||||
out,
|
||||
base_url: Some(base_url),
|
||||
signing_key_path: signing_key,
|
||||
admin_key_path: admin_key,
|
||||
snapshots,
|
||||
},
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::VerifySigchain { input },
|
||||
} => ControlRequest::KeychainVerifySigchain { input },
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::ImportSigchain { input },
|
||||
} => ControlRequest::KeychainImportSigchain { input },
|
||||
Command::Keychain {
|
||||
command:
|
||||
KeychainCommand::VerifyCheckpoint {
|
||||
checkpoint,
|
||||
signature,
|
||||
sigchain,
|
||||
allowed_signers,
|
||||
base_url,
|
||||
principal,
|
||||
},
|
||||
} => ControlRequest::KeychainVerifyCheckpoint {
|
||||
checkpoint,
|
||||
signature,
|
||||
sigchain,
|
||||
allowed_signers,
|
||||
base_url,
|
||||
principal,
|
||||
},
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::Fetch { url, out, import },
|
||||
} => ControlRequest::KeychainFetch { url, out, import },
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::Explain { op_id },
|
||||
} => ControlRequest::KeychainExplain { op_id },
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::ExplainSigner { key },
|
||||
} => ControlRequest::KeychainExplainSigner { key },
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::Verify,
|
||||
} => ControlRequest::KeychainVerify,
|
||||
|
|
@ -1990,6 +2180,23 @@ fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
|
|||
.context("resolve current geth executable")
|
||||
}
|
||||
|
||||
fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) {
|
||||
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);
|
||||
}
|
||||
|
||||
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
|
|
@ -2529,30 +2736,168 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
|||
}
|
||||
ControlResponse::KeychainAllowedSigners {
|
||||
allowed_signers,
|
||||
out,
|
||||
note,
|
||||
..
|
||||
} => {
|
||||
if let Some(out) = out {
|
||||
println!("wrote allowed_signers: {}", out.display());
|
||||
if allowed_signers.is_empty() {
|
||||
println!("warning: generated file has no active admin public keys");
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
ControlResponse::KeychainFileSigned {
|
||||
input,
|
||||
out,
|
||||
namespace,
|
||||
signer,
|
||||
note,
|
||||
} => {
|
||||
println!("signed file: {}", input.display());
|
||||
println!(
|
||||
"accepted_head: {}",
|
||||
report
|
||||
.accepted_head
|
||||
"signature: {}",
|
||||
out.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "none".to_owned())
|
||||
);
|
||||
println!("namespace: {namespace}");
|
||||
println!("signer: {signer}");
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainFileVerified {
|
||||
input,
|
||||
signature,
|
||||
namespace,
|
||||
verified,
|
||||
principal,
|
||||
note,
|
||||
} => {
|
||||
println!("file: {}", input.display());
|
||||
println!("signature: {}", signature.display());
|
||||
println!("namespace: {namespace}");
|
||||
println!("principal: {}", principal.as_deref().unwrap_or("none"));
|
||||
println!("verified: {verified}");
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainSigchainExported {
|
||||
jsonl, out, note, ..
|
||||
} => {
|
||||
if let Some(out) = out {
|
||||
println!("wrote keychain sigchain: {}", out.display());
|
||||
} else {
|
||||
print!("{jsonl}");
|
||||
}
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainBundlePublished {
|
||||
out,
|
||||
base_url,
|
||||
allowed_signers_path,
|
||||
sigchain_path,
|
||||
checkpoint_path,
|
||||
checkpoint_signature_path,
|
||||
snapshots,
|
||||
note,
|
||||
..
|
||||
} => {
|
||||
println!("bundle: {}", out.display());
|
||||
println!("base_url: {base_url}");
|
||||
println!("allowed_signers: {}", allowed_signers_path.display());
|
||||
println!("sigchain: {}", sigchain_path.display());
|
||||
println!("checkpoint: {}", checkpoint_path.display());
|
||||
println!(
|
||||
"checkpoint_signature: {}",
|
||||
checkpoint_signature_path.display()
|
||||
);
|
||||
for snapshot in snapshots {
|
||||
println!(
|
||||
"snapshot: {} {} {}",
|
||||
snapshot.name,
|
||||
snapshot.path.display(),
|
||||
snapshot.signature_path.display()
|
||||
);
|
||||
}
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainSigchainFileVerified {
|
||||
input,
|
||||
report,
|
||||
note,
|
||||
} => {
|
||||
println!("sigchain: {}", input.display());
|
||||
print_keychain_sigchain_report(&report);
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainSigchainImported {
|
||||
input,
|
||||
ops_imported,
|
||||
signatures_imported,
|
||||
invalid_ops_rejected,
|
||||
note,
|
||||
} => {
|
||||
println!("sigchain: {}", input.display());
|
||||
println!("ops_imported: {ops_imported}");
|
||||
println!("signatures_imported: {signatures_imported}");
|
||||
println!("invalid_ops_rejected: {invalid_ops_rejected}");
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainCheckpointVerified {
|
||||
checkpoint,
|
||||
verified,
|
||||
principal,
|
||||
note,
|
||||
} => {
|
||||
println!(
|
||||
"checkpoint_head: {}",
|
||||
checkpoint
|
||||
.head
|
||||
.as_ref()
|
||||
.map(|head| head.as_str())
|
||||
.map(|h| h.as_str())
|
||||
.unwrap_or("none")
|
||||
);
|
||||
println!("note: {}", report.note);
|
||||
println!("base_url: {}", checkpoint.base_url);
|
||||
println!("verified: {verified}");
|
||||
println!("principal: {}", principal.as_deref().unwrap_or("none"));
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainFetched {
|
||||
url,
|
||||
out,
|
||||
checkpoint,
|
||||
imported,
|
||||
note,
|
||||
} => {
|
||||
println!("url: {url}");
|
||||
println!("out: {}", out.display());
|
||||
println!(
|
||||
"checkpoint_head: {}",
|
||||
checkpoint
|
||||
.head
|
||||
.as_ref()
|
||||
.map(|h| h.as_str())
|
||||
.unwrap_or("none")
|
||||
);
|
||||
if let Some(imported) = imported {
|
||||
println!("ops_imported: {}", imported.ops_imported);
|
||||
println!("signatures_imported: {}", imported.signatures_imported);
|
||||
println!("invalid_ops_rejected: {}", imported.invalid_ops_rejected);
|
||||
}
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainExplained { subject, lines } => {
|
||||
println!("subject: {subject}");
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
ControlResponse::KeychainVerified { report } => {
|
||||
print_keychain_sigchain_report(&report);
|
||||
}
|
||||
ControlResponse::KeychainSynced {
|
||||
peer_node_id,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
|
|||
use geth_discovery::{DiscoveredPeer, PeerCard};
|
||||
use geth_document::{DocumentResource, DocumentState};
|
||||
use geth_keychain::{
|
||||
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
|
||||
NodeEnrollmentRequest, NodeRecord,
|
||||
KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature,
|
||||
KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord,
|
||||
};
|
||||
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
|
||||
use geth_overlay::{
|
||||
|
|
@ -241,7 +241,58 @@ pub enum ControlRequest {
|
|||
signing_key_path: PathBuf,
|
||||
admin_key_path: Option<PathBuf>,
|
||||
},
|
||||
KeychainAllowedSigners,
|
||||
KeychainAllowedSigners {
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
KeychainSignFile {
|
||||
input: PathBuf,
|
||||
out: Option<PathBuf>,
|
||||
namespace: Option<String>,
|
||||
signing_key_path: Option<PathBuf>,
|
||||
admin_key_path: Option<PathBuf>,
|
||||
},
|
||||
KeychainVerifyFile {
|
||||
input: PathBuf,
|
||||
signature: PathBuf,
|
||||
namespace: Option<String>,
|
||||
allowed_signers_path: Option<PathBuf>,
|
||||
principal: Option<String>,
|
||||
},
|
||||
KeychainSigchainExport {
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
KeychainPublishBundle {
|
||||
out: PathBuf,
|
||||
base_url: Option<String>,
|
||||
signing_key_path: PathBuf,
|
||||
admin_key_path: Option<PathBuf>,
|
||||
snapshots: Vec<String>,
|
||||
},
|
||||
KeychainVerifySigchain {
|
||||
input: PathBuf,
|
||||
},
|
||||
KeychainImportSigchain {
|
||||
input: PathBuf,
|
||||
},
|
||||
KeychainVerifyCheckpoint {
|
||||
checkpoint: PathBuf,
|
||||
signature: PathBuf,
|
||||
sigchain: PathBuf,
|
||||
allowed_signers: PathBuf,
|
||||
base_url: Option<String>,
|
||||
principal: Option<String>,
|
||||
},
|
||||
KeychainFetch {
|
||||
url: String,
|
||||
out: Option<PathBuf>,
|
||||
import: bool,
|
||||
},
|
||||
KeychainExplain {
|
||||
op_id: String,
|
||||
},
|
||||
KeychainExplainSigner {
|
||||
key: String,
|
||||
},
|
||||
KeychainVerify,
|
||||
KeychainSync {
|
||||
node: String,
|
||||
|
|
@ -687,8 +738,70 @@ pub enum ControlResponse {
|
|||
KeychainAllowedSigners {
|
||||
entries: Vec<KeychainAllowedSigner>,
|
||||
allowed_signers: String,
|
||||
out: Option<PathBuf>,
|
||||
note: String,
|
||||
},
|
||||
KeychainFileSigned {
|
||||
input: PathBuf,
|
||||
out: Option<PathBuf>,
|
||||
namespace: String,
|
||||
signer: String,
|
||||
note: String,
|
||||
},
|
||||
KeychainFileVerified {
|
||||
input: PathBuf,
|
||||
signature: PathBuf,
|
||||
namespace: String,
|
||||
verified: bool,
|
||||
principal: Option<String>,
|
||||
note: String,
|
||||
},
|
||||
KeychainSigchainExported {
|
||||
entries: Vec<KeychainSigchainEntry>,
|
||||
jsonl: String,
|
||||
out: Option<PathBuf>,
|
||||
note: String,
|
||||
},
|
||||
KeychainBundlePublished {
|
||||
out: PathBuf,
|
||||
base_url: String,
|
||||
allowed_signers_path: PathBuf,
|
||||
sigchain_path: PathBuf,
|
||||
checkpoint_path: PathBuf,
|
||||
checkpoint_signature_path: PathBuf,
|
||||
checkpoint: KeychainCheckpoint,
|
||||
snapshots: Vec<KeychainPublishedSnapshot>,
|
||||
note: String,
|
||||
},
|
||||
KeychainSigchainFileVerified {
|
||||
input: PathBuf,
|
||||
report: KeychainSigchainReport,
|
||||
note: String,
|
||||
},
|
||||
KeychainSigchainImported {
|
||||
input: PathBuf,
|
||||
ops_imported: usize,
|
||||
signatures_imported: usize,
|
||||
invalid_ops_rejected: usize,
|
||||
note: String,
|
||||
},
|
||||
KeychainCheckpointVerified {
|
||||
checkpoint: KeychainCheckpoint,
|
||||
verified: bool,
|
||||
principal: Option<String>,
|
||||
note: String,
|
||||
},
|
||||
KeychainFetched {
|
||||
url: String,
|
||||
out: PathBuf,
|
||||
checkpoint: KeychainCheckpoint,
|
||||
imported: Option<KeychainFetchImportReport>,
|
||||
note: String,
|
||||
},
|
||||
KeychainExplained {
|
||||
subject: String,
|
||||
lines: Vec<String>,
|
||||
},
|
||||
KeychainVerified {
|
||||
report: KeychainSigchainReport,
|
||||
},
|
||||
|
|
@ -1021,6 +1134,22 @@ pub enum ControlResponse {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainFetchImportReport {
|
||||
pub ops_imported: usize,
|
||||
pub signatures_imported: usize,
|
||||
pub invalid_ops_rejected: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainPublishedSnapshot {
|
||||
pub name: String,
|
||||
pub source: PathBuf,
|
||||
pub path: PathBuf,
|
||||
pub signature_path: PathBuf,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatusResponse {
|
||||
pub home: PathBuf,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
|
||||
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
|
||||
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
|
||||
pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev";
|
||||
pub const KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev";
|
||||
pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/";
|
||||
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
|
||||
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
|
||||
|
||||
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
|
||||
|
||||
|
|
@ -194,6 +198,21 @@ pub struct KeychainSigchainEntry {
|
|||
pub signatures: Vec<KeychainOpSignature>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainCheckpoint {
|
||||
pub version: u16,
|
||||
pub profile: KeychainProfile,
|
||||
pub base_url: String,
|
||||
pub head: Option<AuthOpId>,
|
||||
pub ops: usize,
|
||||
pub signatures: usize,
|
||||
pub sigchain_bytes: u64,
|
||||
pub sigchain_hash: String,
|
||||
pub allowed_signers_hash: String,
|
||||
pub reduced_view_hash: String,
|
||||
pub generated_at: UnixMillis,
|
||||
}
|
||||
|
||||
pub trait KeychainSignatureVerifier {
|
||||
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
|
||||
}
|
||||
|
|
@ -299,6 +318,8 @@ pub enum KeychainError {
|
|||
InvalidNamespaceComponent { field: String, value: String },
|
||||
#[error("invalid keychain principal: {0}")]
|
||||
InvalidPrincipal(String),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
|
|
@ -763,6 +784,44 @@ pub fn flatten_sigchain_entries(
|
|||
(ops, signatures)
|
||||
}
|
||||
|
||||
pub fn keychain_checkpoint(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
sigchain_jsonl: &str,
|
||||
allowed_signers: &str,
|
||||
base_url: impl Into<String>,
|
||||
generated_at: UnixMillis,
|
||||
) -> Result<KeychainCheckpoint, KeychainError> {
|
||||
let sorted_ops = sorted_keychain_ops(ops.to_vec());
|
||||
let view = reduce_keychain_ops(&sorted_ops);
|
||||
Ok(KeychainCheckpoint {
|
||||
version: KEYCHAIN_CHECKPOINT_VERSION,
|
||||
profile: KeychainProfile::geth(),
|
||||
base_url: normalize_base_url(base_url.into()),
|
||||
head: sorted_ops.last().map(|op| op.id.clone()),
|
||||
ops: sorted_ops.len(),
|
||||
signatures: signatures.len(),
|
||||
sigchain_bytes: sigchain_jsonl.len() as u64,
|
||||
sigchain_hash: blake3_tagged_hash(sigchain_jsonl.as_bytes()),
|
||||
allowed_signers_hash: blake3_tagged_hash(allowed_signers.as_bytes()),
|
||||
reduced_view_hash: geth_codec::hash_canonical(&view)?.to_string(),
|
||||
generated_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn blake3_tagged_hash(bytes: &[u8]) -> String {
|
||||
format!("blake3:{}", blake3::hash(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn normalize_base_url(mut value: String) -> String {
|
||||
if !value.ends_with('/') {
|
||||
value.push('/');
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn validate_namespace(value: &str) -> Result<(), KeychainError> {
|
||||
let has_single_domain_separator = value.matches('@').count() == 1;
|
||||
let valid = has_single_domain_separator
|
||||
|
|
@ -1085,6 +1144,53 @@ mod tests {
|
|||
assert_eq!(decoded_signatures, signatures);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_records_static_publication_hashes() {
|
||||
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".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
let signatures = Vec::new();
|
||||
let entries = sigchain_entries(&ops, &signatures);
|
||||
let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl");
|
||||
let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures));
|
||||
let checkpoint = keychain_checkpoint(
|
||||
&ops,
|
||||
&signatures,
|
||||
&jsonl,
|
||||
&allowed,
|
||||
"https://example.com/.well-known/sshsigchain",
|
||||
UnixMillis(10),
|
||||
)
|
||||
.expect("checkpoint");
|
||||
|
||||
assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION);
|
||||
assert_eq!(
|
||||
checkpoint.base_url,
|
||||
"https://example.com/.well-known/sshsigchain/"
|
||||
);
|
||||
assert_eq!(checkpoint.ops, 2);
|
||||
assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64);
|
||||
assert_eq!(
|
||||
checkpoint.sigchain_hash,
|
||||
blake3_tagged_hash(jsonl.as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint.allowed_signers_hash,
|
||||
blake3_tagged_hash(allowed.as_bytes())
|
||||
);
|
||||
assert!(!checkpoint.reduced_view_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchain_verification_replays_against_prior_admin_view() {
|
||||
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ pub enum NodeError {
|
|||
Control(#[from] geth_control::ControlError),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("keychain error: {0}")]
|
||||
Keychain(#[from] geth_keychain::KeychainError),
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("io error: {0}")]
|
||||
|
|
@ -99,6 +101,8 @@ pub enum NodeError {
|
|||
InvalidInitGrant(String),
|
||||
#[error("invalid enrollment capability, expected <resource>=<capability>: {0}")]
|
||||
InvalidEnrollmentCapability(String),
|
||||
#[error("invalid keychain snapshot, expected <name>=<path>: {0}")]
|
||||
InvalidKeychainSnapshot(String),
|
||||
#[error("node enrollment request not found: {0}")]
|
||||
NodeEnrollmentRequestNotFound(String),
|
||||
#[error("node enrollment request has invalid provenance: {0}")]
|
||||
|
|
@ -7680,18 +7684,181 @@ pub fn handle_request(
|
|||
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainAllowedSigners => {
|
||||
ControlRequest::KeychainAllowedSigners { out } => {
|
||||
let entries = geth_keychain::allowed_signers(
|
||||
&load_keychain_ops(&store)?,
|
||||
&load_keychain_signatures(&store)?,
|
||||
);
|
||||
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
|
||||
if let Some(path) = &out {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, &allowed_signers)?;
|
||||
}
|
||||
Ok(ControlResponse::KeychainAllowedSigners {
|
||||
entries,
|
||||
allowed_signers,
|
||||
out,
|
||||
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainSignFile {
|
||||
input,
|
||||
out,
|
||||
namespace,
|
||||
signing_key_path,
|
||||
admin_key_path,
|
||||
} => {
|
||||
let signing_key_path = signing_key_path
|
||||
.as_ref()
|
||||
.ok_or_else(|| NodeError::SigningKeyRequired("keychain sign-file".to_owned()))?;
|
||||
let namespace =
|
||||
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
|
||||
let entries = geth_keychain::allowed_signers(
|
||||
&load_keychain_ops(&store)?,
|
||||
&load_keychain_signatures(&store)?,
|
||||
);
|
||||
let (signer, _) =
|
||||
keychain_signer_from_paths(signing_key_path, admin_key_path.as_deref())?;
|
||||
if !entries.iter().any(|entry| entry.key == signer) {
|
||||
return Err(NodeError::Unauthorized(format!(
|
||||
"signing key {signer} is not an active keychain admin signer"
|
||||
)));
|
||||
}
|
||||
let signature_out = sign_file_with_ssh(signing_key_path, &namespace, &input, out)?;
|
||||
Ok(ControlResponse::KeychainFileSigned {
|
||||
input,
|
||||
out: Some(signature_out),
|
||||
namespace,
|
||||
signer: signer.to_string(),
|
||||
note: "signed file with an active keychain admin signer; publish the file, signature, and allowed_signers projection together".to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainVerifyFile {
|
||||
input,
|
||||
signature,
|
||||
namespace,
|
||||
allowed_signers_path,
|
||||
principal,
|
||||
} => {
|
||||
let namespace =
|
||||
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
|
||||
let (verified, matched_principal) = verify_file_with_keychain_signers(
|
||||
&store,
|
||||
node,
|
||||
&input,
|
||||
&signature,
|
||||
&namespace,
|
||||
allowed_signers_path.as_deref(),
|
||||
principal.as_deref(),
|
||||
)?;
|
||||
Ok(ControlResponse::KeychainFileVerified {
|
||||
input,
|
||||
signature,
|
||||
namespace,
|
||||
verified,
|
||||
principal: matched_principal,
|
||||
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainSigchainExport { out } => {
|
||||
let ops = load_keychain_ops(&store)?;
|
||||
let signatures = load_keychain_signatures(&store)?;
|
||||
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
|
||||
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
|
||||
if let Some(path) = &out {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, &jsonl)?;
|
||||
}
|
||||
Ok(ControlResponse::KeychainSigchainExported {
|
||||
entries,
|
||||
jsonl,
|
||||
out,
|
||||
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainPublishBundle {
|
||||
out,
|
||||
base_url,
|
||||
signing_key_path,
|
||||
admin_key_path,
|
||||
snapshots,
|
||||
} => publish_keychain_bundle(
|
||||
&store,
|
||||
node,
|
||||
out,
|
||||
base_url,
|
||||
signing_key_path,
|
||||
admin_key_path,
|
||||
snapshots,
|
||||
),
|
||||
ControlRequest::KeychainVerifySigchain { input } => {
|
||||
let text = std::fs::read_to_string(&input)?;
|
||||
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
|
||||
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
|
||||
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
|
||||
Ok(ControlResponse::KeychainSigchainFileVerified {
|
||||
input,
|
||||
report,
|
||||
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainImportSigchain { input } => {
|
||||
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
|
||||
let imported =
|
||||
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
|
||||
Ok(ControlResponse::KeychainSigchainImported {
|
||||
input,
|
||||
ops_imported: imported.ops_imported,
|
||||
signatures_imported: imported.signatures_imported,
|
||||
invalid_ops_rejected: imported.invalid_ops_rejected,
|
||||
note: if imported.invalid_ops_rejected == 0 {
|
||||
"imported verified JSONL sigchain entries".to_owned()
|
||||
} else {
|
||||
"sigchain contained rejected operations; nothing imported".to_owned()
|
||||
},
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainVerifyCheckpoint {
|
||||
checkpoint,
|
||||
signature,
|
||||
sigchain,
|
||||
allowed_signers,
|
||||
base_url,
|
||||
principal,
|
||||
} => {
|
||||
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
|
||||
node,
|
||||
&checkpoint,
|
||||
&signature,
|
||||
&sigchain,
|
||||
&allowed_signers,
|
||||
base_url.as_deref(),
|
||||
principal.as_deref(),
|
||||
)?;
|
||||
Ok(ControlResponse::KeychainCheckpointVerified {
|
||||
checkpoint,
|
||||
verified,
|
||||
principal,
|
||||
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
ControlRequest::KeychainFetch { url, out, import } => {
|
||||
fetch_keychain_bundle(&store, node, url, out, import)
|
||||
}
|
||||
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
|
||||
subject: op_id.clone(),
|
||||
lines: explain_keychain_op(&store, node, &op_id)?,
|
||||
}),
|
||||
ControlRequest::KeychainExplainSigner { key } => Ok(ControlResponse::KeychainExplained {
|
||||
subject: key.clone(),
|
||||
lines: explain_keychain_signer(&store, &key)?,
|
||||
}),
|
||||
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
|
||||
report: verify_keychain_sigchain_with_ssh(&store, node)?,
|
||||
}),
|
||||
|
|
@ -10501,6 +10668,510 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct StaticKeychainSourceState {
|
||||
head: Option<AuthOpId>,
|
||||
ops: usize,
|
||||
sigchain_hash: String,
|
||||
generated_at_ms: i64,
|
||||
}
|
||||
|
||||
fn read_and_verify_sigchain_file(
|
||||
node: &LocalNode,
|
||||
input: &Path,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<KeychainOp>,
|
||||
Vec<KeychainOpSignature>,
|
||||
geth_keychain::KeychainSigchainReport,
|
||||
),
|
||||
NodeError,
|
||||
> {
|
||||
let text = std::fs::read_to_string(input)?;
|
||||
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
|
||||
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
|
||||
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
|
||||
Ok((ops, signatures, report))
|
||||
}
|
||||
|
||||
fn import_verified_sigchain(
|
||||
store: &Store,
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
rejected_ops: usize,
|
||||
) -> Result<geth_control::KeychainFetchImportReport, NodeError> {
|
||||
if rejected_ops != 0 {
|
||||
return Ok(geth_control::KeychainFetchImportReport {
|
||||
ops_imported: 0,
|
||||
signatures_imported: 0,
|
||||
invalid_ops_rejected: rejected_ops,
|
||||
});
|
||||
}
|
||||
let existing_ops = load_keychain_ops(store)?
|
||||
.into_iter()
|
||||
.map(|op| op.id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let existing_signatures = load_keychain_signatures(store)?
|
||||
.into_iter()
|
||||
.map(|signature| {
|
||||
(
|
||||
signature.op_id,
|
||||
signature.signer,
|
||||
signature.namespace,
|
||||
signature.signature,
|
||||
)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut ops_imported = 0;
|
||||
for op in ops {
|
||||
if !existing_ops.contains(&op.id) {
|
||||
store_keychain_op(store, op)?;
|
||||
ops_imported += 1;
|
||||
}
|
||||
}
|
||||
let mut signatures_imported = 0;
|
||||
for signature in signatures {
|
||||
let key = (
|
||||
signature.op_id.clone(),
|
||||
signature.signer.clone(),
|
||||
signature.namespace.clone(),
|
||||
signature.signature.clone(),
|
||||
);
|
||||
if !existing_signatures.contains(&key) {
|
||||
store.insert_keychain_signature(&StoredKeychainSignature {
|
||||
op_id: signature.op_id.to_string(),
|
||||
signer: signature.signer.to_string(),
|
||||
signer_public_key: signature.signer_public_key.clone(),
|
||||
namespace: signature.namespace.clone(),
|
||||
signature: signature.signature.clone(),
|
||||
created_at_ms: signature.created_at.0,
|
||||
})?;
|
||||
signatures_imported += 1;
|
||||
}
|
||||
}
|
||||
Ok(geth_control::KeychainFetchImportReport {
|
||||
ops_imported,
|
||||
signatures_imported,
|
||||
invalid_ops_rejected: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_keychain_checkpoint_files(
|
||||
node: &LocalNode,
|
||||
checkpoint_path: &Path,
|
||||
signature_path: &Path,
|
||||
sigchain_path: &Path,
|
||||
allowed_signers_path: &Path,
|
||||
expected_base_url: Option<&str>,
|
||||
principal: Option<&str>,
|
||||
) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option<String>), NodeError> {
|
||||
let checkpoint_text = std::fs::read_to_string(checkpoint_path)?;
|
||||
let checkpoint: geth_keychain::KeychainCheckpoint = serde_json::from_str(&checkpoint_text)?;
|
||||
if let Some(expected) = expected_base_url {
|
||||
let expected = geth_keychain::normalize_base_url(expected.to_owned());
|
||||
if checkpoint.base_url != expected {
|
||||
return Ok((checkpoint, false, None));
|
||||
}
|
||||
}
|
||||
let sigchain_text = std::fs::read_to_string(sigchain_path)?;
|
||||
let allowed_signers = std::fs::read_to_string(allowed_signers_path)?;
|
||||
if checkpoint.sigchain_bytes != sigchain_text.len() as u64
|
||||
|| checkpoint.sigchain_hash != geth_keychain::blake3_tagged_hash(sigchain_text.as_bytes())
|
||||
|| checkpoint.allowed_signers_hash
|
||||
!= geth_keychain::blake3_tagged_hash(allowed_signers.as_bytes())
|
||||
{
|
||||
return Ok((checkpoint, false, None));
|
||||
}
|
||||
let entries = geth_keychain::decode_sigchain_jsonl(&sigchain_text)?;
|
||||
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
|
||||
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
|
||||
if report.rejected_ops != 0 || report.accepted_head != checkpoint.head {
|
||||
return Ok((checkpoint, false, None));
|
||||
}
|
||||
let (verified, matched_principal) = verify_file_with_keychain_signers(
|
||||
&Store::open(&node.paths.metadata_db())?,
|
||||
node,
|
||||
checkpoint_path,
|
||||
signature_path,
|
||||
geth_keychain::KEYCHAIN_CHECKPOINT_NAMESPACE,
|
||||
Some(allowed_signers_path),
|
||||
principal,
|
||||
)?;
|
||||
Ok((checkpoint, verified, matched_principal))
|
||||
}
|
||||
|
||||
fn fetch_keychain_bundle(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
url: String,
|
||||
out: Option<PathBuf>,
|
||||
import: bool,
|
||||
) -> Result<ControlResponse, NodeError> {
|
||||
let base_url = geth_keychain::normalize_base_url(url.clone());
|
||||
let out = out.unwrap_or_else(|| {
|
||||
node.paths
|
||||
.home()
|
||||
.join("keychain-fetch")
|
||||
.join(geth_crypto::blake3_hex(base_url.as_bytes()))
|
||||
});
|
||||
std::fs::create_dir_all(&out)?;
|
||||
for name in [
|
||||
"allowed_signers",
|
||||
"geth.sigchain.jsonl",
|
||||
"geth.sigchain.checkpoint.json",
|
||||
"geth.sigchain.checkpoint.json.sig",
|
||||
] {
|
||||
fetch_bundle_file(&base_url, name, &out.join(name))?;
|
||||
}
|
||||
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
|
||||
let signature_path = out.join("geth.sigchain.checkpoint.json.sig");
|
||||
let sigchain_path = out.join("geth.sigchain.jsonl");
|
||||
let allowed_signers_path = out.join("allowed_signers");
|
||||
let (checkpoint, verified, _) = verify_keychain_checkpoint_files(
|
||||
node,
|
||||
&checkpoint_path,
|
||||
&signature_path,
|
||||
&sigchain_path,
|
||||
&allowed_signers_path,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
if !verified {
|
||||
return Err(NodeError::Unauthorized(
|
||||
"fetched keychain checkpoint did not verify".to_owned(),
|
||||
));
|
||||
}
|
||||
check_static_source_rollback(store, &base_url, &checkpoint)?;
|
||||
let imported = if import {
|
||||
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &sigchain_path)?;
|
||||
let imported = import_verified_sigchain(store, &ops, &signatures, report.rejected_ops)?;
|
||||
if imported.invalid_ops_rejected == 0 {
|
||||
remember_static_source_checkpoint(store, &base_url, &checkpoint)?;
|
||||
}
|
||||
Some(imported)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let note = format!(
|
||||
"fetched and verified static SSH sigchain bundle; checkpoint base URL is {}",
|
||||
checkpoint.base_url
|
||||
);
|
||||
Ok(ControlResponse::KeychainFetched {
|
||||
url: base_url,
|
||||
out,
|
||||
checkpoint,
|
||||
imported,
|
||||
note,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_bundle_file(base_url: &str, name: &str, out: &Path) -> Result<(), NodeError> {
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(root) = base_url.strip_prefix("file://") {
|
||||
std::fs::copy(Path::new(root).join(name), out)?;
|
||||
return Ok(());
|
||||
}
|
||||
if base_url.starts_with("http://") || base_url.starts_with("https://") {
|
||||
let url = format!("{base_url}{name}");
|
||||
let output = std::process::Command::new("curl")
|
||||
.arg("-fsSL")
|
||||
.arg(&url)
|
||||
.arg("-o")
|
||||
.arg(out)
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(NodeError::IrohPeer(format!(
|
||||
"curl failed fetching {url}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
std::fs::copy(Path::new(base_url).join(name), out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn static_source_state_key(base_url: &str) -> String {
|
||||
format!(
|
||||
"keychain-static-source:{}",
|
||||
geth_crypto::blake3_hex(base_url.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn check_static_source_rollback(
|
||||
store: &Store,
|
||||
base_url: &str,
|
||||
checkpoint: &geth_keychain::KeychainCheckpoint,
|
||||
) -> Result<(), NodeError> {
|
||||
let Some(state) = store.get_module_state(&static_source_state_key(base_url))? else {
|
||||
return Ok(());
|
||||
};
|
||||
let previous: StaticKeychainSourceState = serde_json::from_str(&state.state_json)?;
|
||||
if previous.generated_at_ms > checkpoint.generated_at.0 || previous.ops > checkpoint.ops {
|
||||
return Err(NodeError::Unauthorized(
|
||||
"fetched keychain checkpoint is older than the last accepted checkpoint".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remember_static_source_checkpoint(
|
||||
store: &Store,
|
||||
base_url: &str,
|
||||
checkpoint: &geth_keychain::KeychainCheckpoint,
|
||||
) -> Result<(), NodeError> {
|
||||
let state = StaticKeychainSourceState {
|
||||
head: checkpoint.head.clone(),
|
||||
ops: checkpoint.ops,
|
||||
sigchain_hash: checkpoint.sigchain_hash.clone(),
|
||||
generated_at_ms: checkpoint.generated_at.0,
|
||||
};
|
||||
store.put_module_state(&StoredModuleState {
|
||||
module: static_source_state_key(base_url),
|
||||
state_json: serde_json::to_string(&state)?,
|
||||
updated_at_ms: geth_store::now_ms(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn explain_keychain_op(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
op_id: &str,
|
||||
) -> Result<Vec<String>, NodeError> {
|
||||
let ops = load_keychain_ops(store)?;
|
||||
let signatures = load_keychain_signatures(store)?;
|
||||
let Some(op) = ops.iter().find(|op| op.id.as_str() == op_id) else {
|
||||
return Ok(vec![format!("operation not found: {op_id}")]);
|
||||
};
|
||||
let sorted = geth_keychain::sorted_keychain_ops(ops.clone());
|
||||
let position = sorted.iter().position(|candidate| candidate.id == op.id);
|
||||
let op_signatures = signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.op_id == op.id)
|
||||
.collect::<Vec<_>>();
|
||||
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("op_id: {}", op.id));
|
||||
lines.push(format!("created_at_ms: {}", op.created_at.0));
|
||||
lines.push(format!("kind: {:?}", op.kind));
|
||||
if let Some(position) = position {
|
||||
lines.push(format!("position: {position}"));
|
||||
}
|
||||
lines.push(format!("signatures: {}", op_signatures.len()));
|
||||
for signature in op_signatures {
|
||||
let claimed = geth_keychain::signature_uses_claimed_key(signature);
|
||||
let verified = verify_keychain_signature_with_ssh(
|
||||
node,
|
||||
op,
|
||||
&stored_keychain_signature_from_signature(signature),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
lines.push(format!(
|
||||
"signature signer={} namespace={} claimed_key={} cryptographic_verify={}",
|
||||
signature.signer, signature.namespace, claimed, verified
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
"replay_accepted_head: {}",
|
||||
report
|
||||
.accepted_head
|
||||
.as_ref()
|
||||
.map(|head| head.as_str())
|
||||
.unwrap_or("none")
|
||||
));
|
||||
lines.push(format!("replay_rejected_ops: {}", report.rejected_ops));
|
||||
let accepted = report.accepted_head.as_ref().is_some_and(|_| {
|
||||
let accepted_ops = report.accepted_ops;
|
||||
sorted
|
||||
.iter()
|
||||
.take(accepted_ops)
|
||||
.any(|accepted_op| accepted_op.id == op.id)
|
||||
});
|
||||
lines.push(format!("accepted_by_replay: {accepted}"));
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, NodeError> {
|
||||
let ops = load_keychain_ops(store)?;
|
||||
let signatures = load_keychain_signatures(store)?;
|
||||
let key_id = KeyId::new(key.to_owned());
|
||||
let allowed = geth_keychain::allowed_signers(&ops, &signatures);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("key: {key}"));
|
||||
if let Some(entry) = allowed.iter().find(|entry| entry.key == key_id) {
|
||||
lines.push("active_admin_signer: true".to_owned());
|
||||
lines.push(format!("principal: {}", entry.principal));
|
||||
lines.push(format!("public_key: {}", entry.public_key.trim()));
|
||||
} else {
|
||||
lines.push("active_admin_signer: false".to_owned());
|
||||
}
|
||||
for op in geth_keychain::sorted_keychain_ops(ops) {
|
||||
match &op.kind {
|
||||
KeychainOpKind::AdminKeyAdd { key: op_key, .. } if op_key == &key_id => {
|
||||
lines.push(format!("added_by_op: {} at {}", op.id, op.created_at.0));
|
||||
}
|
||||
KeychainOpKind::AdminKeyRevoke { key: op_key } if op_key == &key_id => {
|
||||
lines.push(format!("revoked_by_op: {} at {}", op.id, op.created_at.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let signed = signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.signer == key_id)
|
||||
.count();
|
||||
lines.push(format!("signed_operations: {signed}"));
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn publish_keychain_bundle(
|
||||
store: &Store,
|
||||
_node: &LocalNode,
|
||||
out: PathBuf,
|
||||
base_url: Option<String>,
|
||||
signing_key_path: PathBuf,
|
||||
admin_key_path: Option<PathBuf>,
|
||||
snapshots: Vec<String>,
|
||||
) -> Result<ControlResponse, NodeError> {
|
||||
let base_url = base_url
|
||||
.map(geth_keychain::normalize_base_url)
|
||||
.unwrap_or_else(|| geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned());
|
||||
let entries = geth_keychain::allowed_signers(
|
||||
&load_keychain_ops(store)?,
|
||||
&load_keychain_signatures(store)?,
|
||||
);
|
||||
let (signer, _) = keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
|
||||
if !entries.iter().any(|entry| entry.key == signer) {
|
||||
return Err(NodeError::Unauthorized(format!(
|
||||
"signing key {signer} is not an active keychain admin signer"
|
||||
)));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&out)?;
|
||||
let ops = load_keychain_ops(store)?;
|
||||
let signatures = load_keychain_signatures(store)?;
|
||||
let sigchain_entries = geth_keychain::sigchain_entries(&ops, &signatures);
|
||||
let sigchain_jsonl = geth_keychain::encode_sigchain_jsonl(&sigchain_entries)?;
|
||||
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
|
||||
|
||||
let allowed_signers_path = out.join("allowed_signers");
|
||||
let sigchain_path = out.join("geth.sigchain.jsonl");
|
||||
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
|
||||
std::fs::write(&allowed_signers_path, &allowed_signers)?;
|
||||
std::fs::write(&sigchain_path, &sigchain_jsonl)?;
|
||||
let checkpoint = geth_keychain::keychain_checkpoint(
|
||||
&ops,
|
||||
&signatures,
|
||||
&sigchain_jsonl,
|
||||
&allowed_signers,
|
||||
base_url.clone(),
|
||||
UnixMillis(geth_store::now_ms()),
|
||||
)?;
|
||||
std::fs::write(&checkpoint_path, serde_json::to_vec_pretty(&checkpoint)?)?;
|
||||
let checkpoint_signature_path = sign_file_with_ssh(
|
||||
&signing_key_path,
|
||||
"geth.sigchain-checkpoint.v1@eric.wendland.dev",
|
||||
&checkpoint_path,
|
||||
Some(out.join("geth.sigchain.checkpoint.json.sig")),
|
||||
)?;
|
||||
|
||||
let mut published_snapshots = Vec::new();
|
||||
for snapshot in snapshots {
|
||||
let (name, source) = parse_snapshot_arg(&snapshot)?;
|
||||
let path = out.join(&name);
|
||||
std::fs::copy(&source, &path)?;
|
||||
let namespace = snapshot_namespace(&name);
|
||||
let signature_path = sign_file_with_ssh(
|
||||
&signing_key_path,
|
||||
&namespace,
|
||||
&path,
|
||||
Some(out.join(format!("{name}.sig"))),
|
||||
)?;
|
||||
published_snapshots.push(geth_control::KeychainPublishedSnapshot {
|
||||
name,
|
||||
source,
|
||||
path,
|
||||
signature_path,
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ControlResponse::KeychainBundlePublished {
|
||||
out,
|
||||
base_url,
|
||||
allowed_signers_path,
|
||||
sigchain_path,
|
||||
checkpoint_path,
|
||||
checkpoint_signature_path,
|
||||
checkpoint,
|
||||
snapshots: published_snapshots,
|
||||
note:
|
||||
"published static SSH sigchain bundle; serve this directory at the discovery base URL"
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_keychain_sigchain_entries_with_ssh(
|
||||
node: &LocalNode,
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
) -> geth_keychain::KeychainSigchainReport {
|
||||
struct SshKeychainVerifier<'a> {
|
||||
node: &'a LocalNode,
|
||||
}
|
||||
|
||||
impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
|
||||
fn verify_keychain_signature(
|
||||
&self,
|
||||
op: &KeychainOp,
|
||||
signature: &KeychainOpSignature,
|
||||
) -> bool {
|
||||
verify_keychain_signature_with_ssh(
|
||||
self.node,
|
||||
op,
|
||||
&stored_keychain_signature_from_signature(signature),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
let verifier = SshKeychainVerifier { node };
|
||||
geth_keychain::verify_sigchain(ops, signatures, &verifier)
|
||||
}
|
||||
|
||||
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> {
|
||||
let Some((name, path)) = value.split_once('=') else {
|
||||
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
|
||||
};
|
||||
validate_snapshot_name(name)?;
|
||||
let path = PathBuf::from(path);
|
||||
if !path.is_file() {
|
||||
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
|
||||
}
|
||||
Ok((name.to_owned(), path))
|
||||
}
|
||||
|
||||
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> {
|
||||
let valid = !name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_namespace(name: &str) -> String {
|
||||
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
|
||||
}
|
||||
|
||||
fn verify_keychain_sigchain_with_ssh(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
|
|
@ -10562,9 +11233,16 @@ fn keychain_signer_from_paths(
|
|||
signing_key_path: &Path,
|
||||
admin_key_path: Option<&Path>,
|
||||
) -> Result<(KeyId, String), 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_path = admin_key_path.map(Path::to_path_buf).unwrap_or_else(|| {
|
||||
if signing_key_path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "pub")
|
||||
{
|
||||
signing_key_path.to_path_buf()
|
||||
} 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)),
|
||||
|
|
@ -10572,6 +11250,131 @@ fn keychain_signer_from_paths(
|
|||
))
|
||||
}
|
||||
|
||||
fn sign_file_with_ssh(
|
||||
signing_key_path: &Path,
|
||||
namespace: &str,
|
||||
input_path: &Path,
|
||||
out: Option<PathBuf>,
|
||||
) -> Result<PathBuf, NodeError> {
|
||||
geth_ssh_identity::ensure_ssh_keygen_available()?;
|
||||
let signature_path = Path::new(&format!("{}.sig", input_path.display())).to_path_buf();
|
||||
if signature_path.exists() {
|
||||
std::fs::remove_file(&signature_path)?;
|
||||
}
|
||||
let output =
|
||||
geth_ssh_identity::sign_command(signing_key_path, namespace, input_path).output()?;
|
||||
if !output.status.success() {
|
||||
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let Some(out) = out else {
|
||||
return Ok(signature_path);
|
||||
};
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if out != signature_path {
|
||||
if out.exists() {
|
||||
std::fs::remove_file(&out)?;
|
||||
}
|
||||
match std::fs::rename(&signature_path, &out) {
|
||||
Ok(()) => {}
|
||||
Err(_) => {
|
||||
std::fs::copy(&signature_path, &out)?;
|
||||
std::fs::remove_file(&signature_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn verify_file_with_keychain_signers(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
input_path: &Path,
|
||||
signature_path: &Path,
|
||||
namespace: &str,
|
||||
allowed_signers_path: Option<&Path>,
|
||||
principal: Option<&str>,
|
||||
) -> Result<(bool, Option<String>), NodeError> {
|
||||
geth_ssh_identity::ensure_ssh_keygen_available()?;
|
||||
let verify_dir = node
|
||||
.paths
|
||||
.home()
|
||||
.join("keychain-signatures")
|
||||
.join("file-verify");
|
||||
std::fs::create_dir_all(&verify_dir)?;
|
||||
let stable_id = geth_crypto::blake3_hex(
|
||||
format!(
|
||||
"{}\0{}\0{}\0{}",
|
||||
input_path.display(),
|
||||
signature_path.display(),
|
||||
namespace,
|
||||
principal.unwrap_or("")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let owned_allowed_signers_path;
|
||||
let allowed_signers_path = if let Some(path) = allowed_signers_path {
|
||||
path
|
||||
} else {
|
||||
let entries = geth_keychain::allowed_signers(
|
||||
&load_keychain_ops(store)?,
|
||||
&load_keychain_signatures(store)?,
|
||||
);
|
||||
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
|
||||
owned_allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers"));
|
||||
std::fs::write(&owned_allowed_signers_path, allowed_signers)?;
|
||||
&owned_allowed_signers_path
|
||||
};
|
||||
let principals = if let Some(principal) = principal {
|
||||
vec![principal.to_owned()]
|
||||
} else {
|
||||
read_allowed_signer_principals(allowed_signers_path)?
|
||||
};
|
||||
for principal in principals {
|
||||
let payload = std::fs::File::open(input_path)?;
|
||||
let output = std::process::Command::new("ssh-keygen")
|
||||
.arg("-Y")
|
||||
.arg("verify")
|
||||
.arg("-f")
|
||||
.arg(allowed_signers_path)
|
||||
.arg("-I")
|
||||
.arg(&principal)
|
||||
.arg("-n")
|
||||
.arg(namespace)
|
||||
.arg("-s")
|
||||
.arg(signature_path)
|
||||
.stdin(std::process::Stdio::from(payload))
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
return Ok((true, Some(principal)));
|
||||
}
|
||||
}
|
||||
Ok((false, None))
|
||||
}
|
||||
|
||||
fn read_allowed_signer_principals(path: &Path) -> Result<Vec<String>, NodeError> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut principals = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(field) = line.split_whitespace().next() {
|
||||
for principal in field.split(',') {
|
||||
if !principal.is_empty() && !principals.iter().any(|item| item == principal) {
|
||||
principals.push(principal.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(principals)
|
||||
}
|
||||
|
||||
fn sign_keychain_op_with_ssh(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
|
|
@ -11167,15 +11970,18 @@ async fn start_daemon_iroh_endpoint(
|
|||
let store = Store::open(&node.paths.metadata_db())?;
|
||||
store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
|
||||
}
|
||||
let blob_store =
|
||||
iroh_blobs::store::fs::FsStore::load(node.paths.cas_dir().join("iroh-blobs"))
|
||||
let iroh_blobs_path = node.paths.cas_dir().join("iroh-blobs");
|
||||
std::fs::create_dir_all(&iroh_blobs_path)?;
|
||||
let iroh_docs_path = node.paths.home().join("iroh-docs");
|
||||
std::fs::create_dir_all(&iroh_docs_path)?;
|
||||
let blob_store = iroh_blobs::store::fs::FsStore::load(iroh_blobs_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}"))
|
||||
})?;
|
||||
let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint());
|
||||
let blob_api: iroh_blobs::api::Store = blob_store.clone().into();
|
||||
let docs = iroh_docs::protocol::Docs::persistent(node.paths.home().join("iroh-docs"))
|
||||
let docs = iroh_docs::protocol::Docs::persistent(iroh_docs_path)
|
||||
.spawn(endpoint.endpoint(), blob_api, gossip.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
|
|
|
|||
|
|
@ -2627,8 +2627,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response =
|
||||
geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners)
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainAllowedSigners { out: None },
|
||||
)
|
||||
.expect("allowed signers");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainAllowedSigners {
|
||||
|
|
@ -2642,6 +2644,278 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let allowed_signers_path = home.path().join("allowed_signers");
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainAllowedSigners {
|
||||
out: Some(allowed_signers_path.clone()),
|
||||
},
|
||||
)
|
||||
.expect("allowed signers file");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainAllowedSigners { out, .. } => {
|
||||
assert_eq!(out.as_deref(), Some(allowed_signers_path.as_path()));
|
||||
let allowed_signers_file =
|
||||
std::fs::read_to_string(&allowed_signers_path).expect("read allowed_signers");
|
||||
assert!(allowed_signers_file.contains("second-admin ssh-ed25519 "));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let authorized_keys_path = home.path().join("authorized_keys");
|
||||
std::fs::write(
|
||||
&authorized_keys_path,
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGexample external-login-key\n",
|
||||
)
|
||||
.expect("write authorized_keys snapshot");
|
||||
let authorized_keys_signature_path = home.path().join("authorized_keys.sig");
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainSignFile {
|
||||
input: authorized_keys_path.clone(),
|
||||
out: Some(authorized_keys_signature_path.clone()),
|
||||
namespace: None,
|
||||
signing_key_path: Some(admin_key_path.clone()),
|
||||
admin_key_path: Some(admin_key_path.with_extension("pub")),
|
||||
},
|
||||
)
|
||||
.expect("sign authorized_keys snapshot");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainFileSigned {
|
||||
input,
|
||||
out,
|
||||
namespace,
|
||||
signer,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(input, authorized_keys_path);
|
||||
assert_eq!(
|
||||
out.as_deref(),
|
||||
Some(authorized_keys_signature_path.as_path())
|
||||
);
|
||||
assert_eq!(namespace, geth_keychain::AUTHORIZED_KEYS_NAMESPACE);
|
||||
assert!(!signer.is_empty());
|
||||
assert!(authorized_keys_signature_path.exists());
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainVerifyFile {
|
||||
input: authorized_keys_path.clone(),
|
||||
signature: authorized_keys_signature_path.clone(),
|
||||
namespace: None,
|
||||
allowed_signers_path: Some(allowed_signers_path.clone()),
|
||||
principal: None,
|
||||
},
|
||||
)
|
||||
.expect("verify authorized_keys snapshot");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainFileVerified {
|
||||
verified,
|
||||
principal,
|
||||
..
|
||||
} => {
|
||||
assert!(verified);
|
||||
assert_eq!(principal.as_deref(), Some("admin"));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let sigchain_path = home.path().join("keychain.sigchain.jsonl");
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainSigchainExport {
|
||||
out: Some(sigchain_path.clone()),
|
||||
},
|
||||
)
|
||||
.expect("sigchain export");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainSigchainExported { entries, out, .. } => {
|
||||
assert_eq!(out.as_deref(), Some(sigchain_path.as_path()));
|
||||
assert!(entries.len() >= 3);
|
||||
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain");
|
||||
let decoded = geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain");
|
||||
assert_eq!(decoded, entries);
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainVerifySigchain {
|
||||
input: sigchain_path.clone(),
|
||||
},
|
||||
)
|
||||
.expect("verify sigchain file");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainSigchainFileVerified { report, .. } => {
|
||||
assert_eq!(report.rejected_ops, 0);
|
||||
assert_eq!(report.active_admin_keys, 2);
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let bundle_dir = home.path().join("public-bundle");
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainPublishBundle {
|
||||
out: bundle_dir.clone(),
|
||||
base_url: None,
|
||||
signing_key_path: admin_key_path.clone(),
|
||||
admin_key_path: Some(admin_key_path.with_extension("pub")),
|
||||
snapshots: vec![format!(
|
||||
"authorized_keys={}",
|
||||
authorized_keys_path.display()
|
||||
)],
|
||||
},
|
||||
)
|
||||
.expect("publish bundle");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainBundlePublished {
|
||||
base_url,
|
||||
allowed_signers_path,
|
||||
sigchain_path: bundle_sigchain_path,
|
||||
checkpoint_path,
|
||||
checkpoint_signature_path,
|
||||
checkpoint,
|
||||
snapshots,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(base_url, geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL);
|
||||
assert!(allowed_signers_path.exists());
|
||||
assert!(bundle_sigchain_path.exists());
|
||||
assert!(checkpoint_path.exists());
|
||||
assert!(checkpoint_signature_path.exists());
|
||||
assert_eq!(
|
||||
checkpoint.base_url,
|
||||
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
|
||||
);
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].name, "authorized_keys");
|
||||
assert!(snapshots[0].path.exists());
|
||||
assert!(snapshots[0].signature_path.exists());
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainVerifyCheckpoint {
|
||||
checkpoint: bundle_dir.join("geth.sigchain.checkpoint.json"),
|
||||
signature: bundle_dir.join("geth.sigchain.checkpoint.json.sig"),
|
||||
sigchain: bundle_dir.join("geth.sigchain.jsonl"),
|
||||
allowed_signers: bundle_dir.join("allowed_signers"),
|
||||
base_url: Some(geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned()),
|
||||
principal: None,
|
||||
},
|
||||
)
|
||||
.expect("verify checkpoint");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainCheckpointVerified {
|
||||
verified,
|
||||
principal,
|
||||
..
|
||||
} => {
|
||||
assert!(verified);
|
||||
assert_eq!(principal.as_deref(), Some("admin"));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let fetched_home = tempfile::tempdir().expect("fetch tempdir");
|
||||
let fetched_paths = geth_config::GethPaths::from_home(fetched_home.path());
|
||||
let fetched_node = geth_node::init_node(&fetched_paths).expect("init fetched node");
|
||||
let response = geth_node::handle_request(
|
||||
&fetched_node,
|
||||
geth_control::ControlRequest::KeychainFetch {
|
||||
url: format!("file://{}", bundle_dir.display()),
|
||||
out: Some(fetched_home.path().join("bundle")),
|
||||
import: true,
|
||||
},
|
||||
)
|
||||
.expect("fetch bundle");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainFetched {
|
||||
imported: Some(imported),
|
||||
checkpoint,
|
||||
..
|
||||
} => {
|
||||
assert!(imported.ops_imported >= 3);
|
||||
assert!(imported.signatures_imported >= 3);
|
||||
assert_eq!(imported.invalid_ops_rejected, 0);
|
||||
assert_eq!(
|
||||
checkpoint.base_url,
|
||||
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain for explain");
|
||||
let decoded =
|
||||
geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain for explain");
|
||||
let explain_op_id = decoded.last().expect("last sigchain op").op.id.to_string();
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainExplain {
|
||||
op_id: explain_op_id.clone(),
|
||||
},
|
||||
)
|
||||
.expect("explain op");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainExplained { subject, lines } => {
|
||||
assert_eq!(subject, explain_op_id);
|
||||
assert!(lines.iter().any(|line| line.contains("accepted_by_replay")));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let admin_public_key =
|
||||
std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin pub");
|
||||
let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key);
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::KeychainExplainSigner { key: admin_key },
|
||||
)
|
||||
.expect("explain signer");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainExplained { lines, .. } => {
|
||||
assert!(lines.iter().any(|line| line == "active_admin_signer: true"));
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|line| line.starts_with("signed_operations:"))
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let import_home = tempfile::tempdir().expect("import tempdir");
|
||||
let import_paths = geth_config::GethPaths::from_home(import_home.path());
|
||||
let import_node = geth_node::init_node(&import_paths).expect("init import node");
|
||||
let response = geth_node::handle_request(
|
||||
&import_node,
|
||||
geth_control::ControlRequest::KeychainImportSigchain {
|
||||
input: sigchain_path.clone(),
|
||||
},
|
||||
)
|
||||
.expect("import sigchain file");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainSigchainImported {
|
||||
ops_imported,
|
||||
signatures_imported,
|
||||
invalid_ops_rejected,
|
||||
..
|
||||
} => {
|
||||
assert!(ops_imported >= 3);
|
||||
assert!(signatures_imported >= 3);
|
||||
assert_eq!(invalid_ops_rejected, 0);
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
|
||||
.expect("verify sigchain");
|
||||
match response {
|
||||
|
|
|
|||
|
|
@ -356,12 +356,25 @@ pattern of verifying key-registry changes from a prior trusted state. The
|
|||
transport-neutral replay rules, application-specific signature namespaces,
|
||||
allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain`
|
||||
so other applications can reuse the same identity-log model without depending
|
||||
on the daemon, SQLite, Iroh, or local control. `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.
|
||||
on the daemon, SQLite, Iroh, or local control. The CLI can export the same
|
||||
reduced key registry as OpenSSH `allowed_signers` or as appendable JSONL
|
||||
sigchain data for website publication. It can also sign and verify arbitrary
|
||||
snapshots, such as externally managed `authorized_keys`, with an active
|
||||
keychain signer under an explicit OpenSSH namespace. `geth keychain
|
||||
publish-bundle` writes a website-ready bundle for
|
||||
`https://example.com/.well-known/sshsigchain/`, including `allowed_signers`,
|
||||
`geth.sigchain.jsonl`, a signed checkpoint, and optional signed snapshots.
|
||||
`geth keychain fetch --import` verifies the checkpoint and records the last
|
||||
accepted checkpoint per retrieval source URL to reject older bundles. The
|
||||
retrieval source may be a local mirror; the checkpoint still carries the signed
|
||||
advertised publication base URL, and explicit checkpoint verification can pin it. `geth keychain
|
||||
explain` and `explain-signer` provide basic auditability for why a keychain
|
||||
operation or signer is trusted. `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
|
||||
state. `geth node enroll request` creates a canonical, agent-key-signed request
|
||||
|
|
|
|||
|
|
@ -325,6 +325,37 @@ resource-scoped capability decisions.
|
|||
signed admin-key registry operations.
|
||||
- `[x]` `geth keychain allowed-signers` exports the active admin key view in
|
||||
OpenSSH `allowed_signers` format.
|
||||
- `[x]` `geth keychain allowed-signers --out <path>` writes the active
|
||||
OpenSSH `allowed_signers` projection directly to a file.
|
||||
- `[x]` `geth keychain sign-file --in <path> --out <sig>` signs arbitrary
|
||||
snapshots, such as externally managed `authorized_keys`, with an active
|
||||
admin key under an explicit namespace.
|
||||
- `[x]` `geth keychain verify-file --in <path> --signature <sig>` verifies a
|
||||
snapshot signature against the current keychain-derived `allowed_signers`
|
||||
projection or a supplied `--allowed-signers` file.
|
||||
- `[x]` `geth keychain sigchain --out <path>` writes the appendable JSONL
|
||||
sigchain suitable for static website publication.
|
||||
- `[x]` `geth keychain publish-bundle --out <dir>` writes a static website
|
||||
bundle rooted at `https://example.com/.well-known/sshsigchain/` by default.
|
||||
- `[x]` Publication bundles include `allowed_signers`, `geth.sigchain.jsonl`,
|
||||
`geth.sigchain.checkpoint.json`, and a detached checkpoint signature.
|
||||
- `[x]` Publication bundles can copy and sign external snapshots with
|
||||
`--snapshot <name>=<path>` without making the keychain own their contents.
|
||||
- `[x]` `geth keychain verify-sigchain --in <path>` verifies a JSONL sigchain
|
||||
file by replaying operations and signatures.
|
||||
- `[x]` `geth keychain import-sigchain --in <path>` imports a JSONL sigchain
|
||||
only if replay verification rejects no operations.
|
||||
- `[x]` `geth keychain verify-checkpoint` verifies checkpoint signatures,
|
||||
checkpoint hashes, base URL, and sigchain head consistency.
|
||||
- `[x]` `geth keychain fetch --url <base> --import` fetches static bundles
|
||||
with `curl` for HTTP(S) or filesystem reads for local/file URLs.
|
||||
- `[x]` Static fetch/import records the last accepted checkpoint per source
|
||||
URL and rejects older checkpoints for rollback resistance.
|
||||
- `[x]` `geth keychain explain <op-id>` and `explain-signer <key-id>` provide
|
||||
basic audit output for operations and admin signers.
|
||||
- `[x]` Agent/FIDO signing is supported through OpenSSH by passing a public
|
||||
key or security-key stub to `--signing-key`; PKCS#11 is documented as an
|
||||
ssh-agent-backed flow when loaded with `ssh-add -s <provider>`.
|
||||
- `[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
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ Signatures are produced with:
|
|||
ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload>
|
||||
```
|
||||
|
||||
`<signing-key>` can be:
|
||||
|
||||
- a local private OpenSSH key file,
|
||||
- a FIDO/YubiKey OpenSSH security-key stub,
|
||||
- a public key whose private half is already loaded in `ssh-agent`, or
|
||||
- a public key backed by a PKCS#11 token loaded into `ssh-agent` with
|
||||
`ssh-add -s <provider>`.
|
||||
|
||||
Encrypted private key files should normally be unlocked into `ssh-agent` before
|
||||
running geth control commands. The daemon never stores private keys or
|
||||
passphrases. Direct PKCS#11 signing is intentionally not a first-class geth
|
||||
backend because portable `ssh-keygen -Y sign` flows do not expose the same
|
||||
provider flag as OpenSSH certificate signing.
|
||||
|
||||
Verification uses:
|
||||
|
||||
```sh
|
||||
|
|
@ -168,10 +182,17 @@ and cacheable:
|
|||
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.
|
||||
geth writes checkpoint records with `geth keychain publish-bundle`. The
|
||||
checkpoint contains the accepted head, operation/signature counts, byte length,
|
||||
BLAKE3 hashes for the sigchain and allowed signers projection, a reduced-view
|
||||
hash, generation time, and the discovery base URL. The checkpoint is signed as
|
||||
`geth.sigchain.checkpoint.json.sig` so static clients can detect rollback or
|
||||
truncation before importing updates. `geth keychain fetch --import` stores the
|
||||
last accepted checkpoint for a source URL and rejects older checkpoints from the
|
||||
same source. The fetch URL is a retrieval location and may be a `file://` mirror
|
||||
for testing; the checkpoint's signed `base_url` remains the advertised static
|
||||
publication location. Use `geth keychain verify-checkpoint --base-url <url>` when
|
||||
a consumer needs to pin that advertised value explicitly.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -205,6 +226,47 @@ format:
|
|||
|
||||
```sh
|
||||
geth keychain allowed-signers > allowed_signers
|
||||
geth keychain allowed-signers --out allowed_signers
|
||||
geth keychain sign-file \
|
||||
--in authorized_keys \
|
||||
--out authorized_keys.sig \
|
||||
--signing-key ~/.ssh/id_ed25519_sk
|
||||
geth keychain verify-file \
|
||||
--in authorized_keys \
|
||||
--signature authorized_keys.sig
|
||||
geth keychain sigchain --out geth.sigchain.jsonl
|
||||
geth keychain publish-bundle \
|
||||
--out public/.well-known/sshsigchain \
|
||||
--signing-key ~/.ssh/id_ed25519_sk \
|
||||
--snapshot authorized_keys=authorized_keys
|
||||
geth keychain verify-checkpoint \
|
||||
--checkpoint geth.sigchain.checkpoint.json \
|
||||
--signature geth.sigchain.checkpoint.json.sig \
|
||||
--sigchain geth.sigchain.jsonl \
|
||||
--allowed-signers allowed_signers
|
||||
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
|
||||
geth keychain verify-sigchain --in geth.sigchain.jsonl
|
||||
geth keychain import-sigchain --in geth.sigchain.jsonl
|
||||
geth keychain explain <op-id>
|
||||
geth keychain explain-signer <key-id>
|
||||
```
|
||||
|
||||
The default static discovery base URL used by `publish-bundle` is
|
||||
`https://example.com/.well-known/sshsigchain/`. The keychain does not manage
|
||||
`authorized_keys` policy. It signs an arbitrary
|
||||
snapshot you provide, which lets other projects keep their own SSH login policy
|
||||
while rooting snapshot approval in the keychain. By default `sign-file` uses the
|
||||
personal identity namespace `geth.authorized-keys.v1@eric.wendland.dev`; pass
|
||||
`--namespace` for project-specific snapshots. The signer must be an active key
|
||||
in the current `allowed_signers` projection, so website consumers can fetch the
|
||||
snapshot, signature, and allowed signers and verify:
|
||||
|
||||
```sh
|
||||
ssh-keygen -Y verify \
|
||||
-f allowed_signers \
|
||||
-I admin \
|
||||
-n geth.authorized-keys.v1@eric.wendland.dev \
|
||||
-s authorized_keys.sig < authorized_keys
|
||||
```
|
||||
|
||||
Replay and verify the local sigchain:
|
||||
|
|
@ -221,7 +283,6 @@ 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue