make sshsigchain the only portable sigchain format
This commit is contained in:
parent
9e5871d02a
commit
73500e1944
15 changed files with 812 additions and 1603 deletions
52
README.md
52
README.md
|
|
@ -634,41 +634,33 @@ geth keychain sign-file \
|
|||
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
|
||||
```
|
||||
|
||||
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 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. 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.
|
||||
The previous test-only static sigchain commands (`sigchain`, `publish-bundle`,
|
||||
`import-sigchain`, `verify-checkpoint`, and `fetch`) were removed. Their
|
||||
downloaded `allowed_signers` projection could establish the trust that
|
||||
validated its own chain. There is no compatibility mode for that workflow.
|
||||
|
||||
The replacement is the small, transport-neutral
|
||||
[`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an
|
||||
operator-pinned chain ID, OpenSSH root public key, profile, and namespace;
|
||||
records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth
|
||||
already provides a verifier for independently produced JSONL transport files:
|
||||
|
||||
```sh
|
||||
geth keychain verify-sigchain \
|
||||
--in ./geth.sshsigchain.v1.jsonl \
|
||||
--chain-id <64-hex-character-chain-id> \
|
||||
--root-key ~/.ssh/geth-root.pub
|
||||
```
|
||||
|
||||
SSHSIGCHAIN local record storage, signing, publication, import, and accepted-head
|
||||
persistence remain follow-up work. Until they exist, do not substitute an
|
||||
unpinned checkpoint or a local operation-log view for the SSHSIGCHAIN trust
|
||||
tuple.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1048,57 +1048,16 @@ pub enum KeychainCommand {
|
|||
#[arg(long)]
|
||||
principal: Option<String>,
|
||||
},
|
||||
/// Export the canonical SSH signature chain
|
||||
Sigchain {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Build a signed static-publication bundle
|
||||
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>,
|
||||
},
|
||||
/// Verify a signature chain without importing it
|
||||
/// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key
|
||||
VerifySigchain {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
},
|
||||
/// Verify and import a signature chain
|
||||
ImportSigchain {
|
||||
#[arg(long = "in")]
|
||||
input: PathBuf,
|
||||
},
|
||||
/// Verify a published checkpoint and its discovery metadata
|
||||
VerifyCheckpoint {
|
||||
#[arg(long)]
|
||||
checkpoint: PathBuf,
|
||||
chain_id: String,
|
||||
#[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 a published signature chain over HTTPS
|
||||
Fetch {
|
||||
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
|
||||
url: String,
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
import: bool,
|
||||
root_key: PathBuf,
|
||||
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
|
||||
namespace: String,
|
||||
},
|
||||
/// Explain why one keychain operation was accepted or rejected
|
||||
Explain { op_id: String },
|
||||
|
|
@ -1785,6 +1744,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
|
|||
"signature" => Some("OpenSSH signature file path"),
|
||||
"allowed_signers" => Some("OpenSSH allowed_signers file used for verification"),
|
||||
"base_url" => Some("Publication base URL recorded in signed discovery metadata"),
|
||||
"chain_id" => Some("Pinned 32-byte SSHSIGCHAIN chain ID as 64 hexadecimal characters"),
|
||||
"root_key" => Some("Pinned OpenSSH root public-key file for SSHSIGCHAIN verification"),
|
||||
"snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"),
|
||||
"checkpoint" => Some("Signed publication checkpoint file"),
|
||||
"sigchain" => Some("Canonical keychain signature-chain JSONL file"),
|
||||
|
|
@ -2671,52 +2632,20 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
|||
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,
|
||||
KeychainCommand::VerifySigchain {
|
||||
input,
|
||||
chain_id,
|
||||
root_key,
|
||||
namespace,
|
||||
},
|
||||
} => ControlRequest::KeychainPublishBundle {
|
||||
out,
|
||||
base_url: Some(base_url),
|
||||
signing_key_path: signing_key,
|
||||
admin_key_path: admin_key,
|
||||
snapshots,
|
||||
} => ControlRequest::KeychainVerifySigchain {
|
||||
input,
|
||||
chain_id,
|
||||
root_key_path: root_key,
|
||||
namespace: Some(namespace),
|
||||
},
|
||||
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 },
|
||||
|
|
@ -4175,109 +4104,25 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
|
|||
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 {
|
||||
ControlResponse::KeychainSigchainVerified {
|
||||
input,
|
||||
report,
|
||||
chain_id,
|
||||
records,
|
||||
head,
|
||||
active_admin_keys,
|
||||
users,
|
||||
devices,
|
||||
nodes,
|
||||
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(|h| h.as_str())
|
||||
.unwrap_or("none")
|
||||
);
|
||||
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);
|
||||
}
|
||||
println!("chain_id: {chain_id}");
|
||||
println!("records: {records}");
|
||||
println!("head: {head}");
|
||||
println!("active_admin_keys: {active_admin_keys}");
|
||||
println!("users: {users}");
|
||||
println!("devices: {devices}");
|
||||
println!("nodes: {nodes}");
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
ControlResponse::KeychainExplained { subject, lines } => {
|
||||
|
|
@ -5473,6 +5318,41 @@ mod tests {
|
|||
assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sshsigchain_exposes_one_pinned_verifier_not_static_compatibility_commands() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"geth",
|
||||
"keychain",
|
||||
"verify-sigchain",
|
||||
"--in",
|
||||
"chain.jsonl",
|
||||
"--chain-id",
|
||||
"00",
|
||||
"--root-key",
|
||||
"root.pub",
|
||||
])
|
||||
.expect("parse SSHSIGCHAIN verifier");
|
||||
assert!(matches!(
|
||||
parsed.command,
|
||||
Command::Keychain {
|
||||
command: KeychainCommand::VerifySigchain { .. }
|
||||
}
|
||||
));
|
||||
for removed in [
|
||||
"sigchain",
|
||||
"publish-bundle",
|
||||
"import-sigchain",
|
||||
"verify-checkpoint",
|
||||
"fetch",
|
||||
"verify-sigchain-v1",
|
||||
] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["geth", "keychain", removed]).is_err(),
|
||||
"removed static command {removed} must not parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_install_copies_transient_binaries_unless_explicitly_overridden() {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
|
|||
use geth_discovery::{DiscoveredPeer, PeerCard};
|
||||
use geth_document::{DocumentResource, DocumentState};
|
||||
use geth_keychain::{
|
||||
KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature,
|
||||
KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord,
|
||||
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
|
||||
NodeEnrollmentRequest, NodeRecord,
|
||||
};
|
||||
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
|
||||
use geth_overlay::{
|
||||
|
|
@ -266,34 +266,11 @@ pub enum ControlRequest {
|
|||
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,
|
||||
chain_id: String,
|
||||
root_key_path: PathBuf,
|
||||
namespace: Option<String>,
|
||||
},
|
||||
KeychainExplain {
|
||||
op_id: String,
|
||||
|
|
@ -764,46 +741,15 @@ pub enum ControlResponse {
|
|||
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 {
|
||||
KeychainSigchainVerified {
|
||||
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>,
|
||||
chain_id: String,
|
||||
records: usize,
|
||||
head: String,
|
||||
active_admin_keys: usize,
|
||||
users: usize,
|
||||
devices: usize,
|
||||
nodes: usize,
|
||||
note: String,
|
||||
},
|
||||
KeychainExplained {
|
||||
|
|
@ -1151,22 +1097,6 @@ 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,
|
||||
|
|
@ -1834,8 +1764,8 @@ mod tests {
|
|||
use super::*;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const CONTROL_REQUEST_VARIANTS: usize = 123;
|
||||
const CONTROL_RESPONSE_VARIANTS: usize = 115;
|
||||
const CONTROL_REQUEST_VARIANTS: usize = 118;
|
||||
const CONTROL_RESPONSE_VARIANTS: usize = 110;
|
||||
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
|
||||
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
|
||||
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
|
||||
|
|
@ -2126,11 +2056,6 @@ mod tests {
|
|||
"devices": 1,
|
||||
"nodes": 1
|
||||
}),
|
||||
"KeychainFetchImportReport" => json!({
|
||||
"ops_imported": 1,
|
||||
"signatures_imported": 1,
|
||||
"invalid_ops_rejected": 0
|
||||
}),
|
||||
"ResourceDescriptor" => json!({
|
||||
"id": "resource:sample",
|
||||
"kind": "kv",
|
||||
|
|
@ -2269,27 +2194,6 @@ mod tests {
|
|||
"accepted_head": null,
|
||||
"note": "sample"
|
||||
}),
|
||||
"KeychainSigchainEntry" => json!({
|
||||
"op": sample_for_type("KeychainOp"),
|
||||
"signatures": []
|
||||
}),
|
||||
"KeychainCheckpoint" => json!({
|
||||
"version": 1,
|
||||
"profile": {
|
||||
"keychain_signature_namespace": "geth.keychain.v1@geth.local",
|
||||
"node_enrollment_request_namespace": "geth.node-enrollment-request.v1@geth.local",
|
||||
"default_admin_principal": "geth-admin"
|
||||
},
|
||||
"base_url": "https://example.invalid",
|
||||
"head": null,
|
||||
"ops": 1,
|
||||
"signatures": 1,
|
||||
"sigchain_bytes": 1,
|
||||
"sigchain_hash": "hash",
|
||||
"allowed_signers_hash": "hash",
|
||||
"reduced_view_hash": "hash",
|
||||
"generated_at": 1
|
||||
}),
|
||||
"AuthExplanation" => json!({
|
||||
"subject": "node:peer",
|
||||
"resource": "resource:sample",
|
||||
|
|
|
|||
|
|
@ -5,25 +5,23 @@
|
|||
//! to update them. It intentionally has no dependency on the daemon, SQLite,
|
||||
//! Iroh, local sockets, or any particular publication mechanism.
|
||||
//!
|
||||
//! Applications can publish `KeychainSigchainEntry` values in an append-only
|
||||
//! JSONL file, object store, database row stream, document CRDT, or another
|
||||
//! transport. Consumers decode entries, flatten them into operations and
|
||||
//! signatures, then call `verify_sigchain_with_profile` with an application
|
||||
//! profile and a `KeychainSignatureVerifier` implementation.
|
||||
//!
|
||||
//! The default `KeychainProfile` is geth-specific. Other applications should
|
||||
//! create their own profile with `KeychainProfile::for_application` or
|
||||
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
|
||||
//! The current local operation-log verifier remains local to geth's daemon
|
||||
//! state. Portable SSHSIGCHAIN records are implemented separately in
|
||||
//! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and
|
||||
//! a causal hash chain rather than a timestamp-sorted JSONL bundle.
|
||||
|
||||
mod sshsigchain;
|
||||
|
||||
pub use sshsigchain::{
|
||||
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION,
|
||||
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification,
|
||||
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
|
||||
ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
KeychainSshSigchainPolicy, KeychainSshSigchainState, KeychainSshSigchainVerification,
|
||||
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, SSH_SIGCHAIN_NAMESPACE,
|
||||
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
|
||||
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
|
||||
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
|
||||
verify_keychain_v2_sigchain, verify_sshsigchain,
|
||||
decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl,
|
||||
keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record,
|
||||
verify_keychain_sshsigchain, verify_sshsigchain,
|
||||
};
|
||||
|
||||
use geth_types::{
|
||||
|
|
@ -35,10 +33,7 @@ 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>;
|
||||
|
||||
|
|
@ -203,27 +198,6 @@ pub struct KeychainSigchainReport {
|
|||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainSigchainEntry {
|
||||
pub op: KeychainOp,
|
||||
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;
|
||||
}
|
||||
|
|
@ -331,11 +305,6 @@ pub enum KeychainError {
|
|||
InvalidPrincipal(String),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -739,100 +708,6 @@ 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -1118,90 +993,6 @@ mod tests {
|
|||
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 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();
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
//! Reference implementation of the small, generic SSHSIGCHAIN v2 core.
|
||||
//! Reference implementation of the small, generic SSHSIGCHAIN v1 core.
|
||||
//!
|
||||
//! The core deliberately knows nothing about geth's resource or identity
|
||||
//! model. It verifies one linear, linked sequence against an explicitly
|
||||
//! configured root key, then delegates authorization and state transitions to
|
||||
//! an application profile. `KeychainV2Policy` below is geth's first profile.
|
||||
//! See `docs/sshsigchain-v2.md` for the interoperable format.
|
||||
//! an application profile. `KeychainSshSigchainPolicy` below is geth's first profile.
|
||||
//! See `docs/sshsigchain.md` for the interoperable format.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops};
|
||||
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
|
||||
|
||||
pub const SSH_SIGCHAIN_VERSION: u8 = 2;
|
||||
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2";
|
||||
pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2";
|
||||
pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1;
|
||||
pub const SSH_SIGCHAIN_VERSION: u8 = 1;
|
||||
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
|
||||
pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain";
|
||||
pub const KEYCHAIN_SSH_SIGCHAIN_PROFILE: &str = "geth.keychain.sshsigchain.v1";
|
||||
pub const KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION: u16 = 1;
|
||||
pub const MAX_PROFILE_BYTES: usize = 128;
|
||||
pub const MAX_NAMESPACE_BYTES: usize = 128;
|
||||
pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024;
|
||||
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_RECORDS: usize = 100_000;
|
||||
pub const MAX_JSONL_LINE_BYTES: usize = 5 * 1024 * 1024;
|
||||
pub const MAX_JSONL_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
const SIGNING_MAGIC: &[u8] = b"SSCS";
|
||||
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0";
|
||||
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v1\0";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct ChainId(pub [u8; 32]);
|
||||
|
|
@ -84,6 +88,7 @@ impl SshSigchainTrust {
|
|||
/// A JSON-serializable transport envelope. Its JSON representation is not
|
||||
/// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshSigchainRecord {
|
||||
pub chain_id: ChainId,
|
||||
pub profile: String,
|
||||
|
|
@ -94,6 +99,46 @@ pub struct SshSigchainRecord {
|
|||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result<String, SshSigchainError> {
|
||||
let mut output = String::new();
|
||||
for record in records {
|
||||
record.validate(true)?;
|
||||
output.push_str(
|
||||
&serde_json::to_string(record)
|
||||
.map_err(|error| SshSigchainError::JsonEncoding(error.to_string()))?,
|
||||
);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn decode_sshsigchain_jsonl(input: &str) -> Result<Vec<SshSigchainRecord>, SshSigchainError> {
|
||||
if input.len() > MAX_JSONL_BYTES {
|
||||
return Err(SshSigchainError::JsonlTooLarge(input.len()));
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_JSONL_LINE_BYTES {
|
||||
return Err(SshSigchainError::JsonLineTooLarge {
|
||||
line: index + 1,
|
||||
bytes: line.len(),
|
||||
});
|
||||
}
|
||||
let record = serde_json::from_str(line).map_err(|error| SshSigchainError::JsonLine {
|
||||
line: index + 1,
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
records.push(record);
|
||||
if records.len() > MAX_RECORDS {
|
||||
return Err(SshSigchainError::TooManyRecords(records.len()));
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
impl SshSigchainRecord {
|
||||
pub fn unsigned(
|
||||
chain_id: ChainId,
|
||||
|
|
@ -276,96 +321,98 @@ where
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct KeychainV2Policy;
|
||||
pub struct KeychainSshSigchainPolicy;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeychainV2State {
|
||||
pub struct KeychainSshSigchainState {
|
||||
initialized: bool,
|
||||
admin_public_keys: BTreeMap<KeyId, String>,
|
||||
seen_op_ids: BTreeSet<AuthOpId>,
|
||||
ops: Vec<KeychainOp>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeychainV2Verification {
|
||||
pub struct KeychainSshSigchainVerification {
|
||||
pub view: KeychainView,
|
||||
pub records: usize,
|
||||
pub head: Digest,
|
||||
}
|
||||
|
||||
pub fn keychain_v2_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
|
||||
geth_codec::encode_canonical(&KeychainV2Payload {
|
||||
version: KEYCHAIN_V2_PAYLOAD_VERSION,
|
||||
pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
|
||||
geth_codec::encode_canonical(&KeychainSshSigchainPayload {
|
||||
version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION,
|
||||
op: CanonicalKeychainOp::from(op),
|
||||
})
|
||||
.map_err(|error| SshSigchainError::PayloadEncoding(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
|
||||
let decoded = geth_codec::decode_canonical::<KeychainV2Payload>(bytes)
|
||||
pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
|
||||
let decoded = geth_codec::decode_canonical::<KeychainSshSigchainPayload>(bytes)
|
||||
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?;
|
||||
if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION {
|
||||
if decoded.version != KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION {
|
||||
return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
|
||||
}
|
||||
let op = KeychainOp::from(decoded.op);
|
||||
let canonical = keychain_v2_payload(&op)?;
|
||||
let canonical = keychain_sshsigchain_payload(&op)?;
|
||||
if canonical != bytes {
|
||||
return Err(SshSigchainError::NonCanonicalPayload);
|
||||
}
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
pub fn keychain_v2_unsigned_record(
|
||||
pub fn keychain_sshsigchain_unsigned_record(
|
||||
trust: &SshSigchainTrust,
|
||||
sequence: u64,
|
||||
previous: Option<Digest>,
|
||||
op: &KeychainOp,
|
||||
signer_public_key: impl AsRef<str>,
|
||||
) -> Result<SshSigchainRecord, SshSigchainError> {
|
||||
if trust.profile != KEYCHAIN_V2_PROFILE {
|
||||
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
|
||||
return Err(SshSigchainError::WrongKeychainProfile(
|
||||
trust.profile.clone(),
|
||||
));
|
||||
}
|
||||
SshSigchainRecord::unsigned(
|
||||
trust.chain_id,
|
||||
KEYCHAIN_V2_PROFILE,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
sequence,
|
||||
previous,
|
||||
keychain_v2_payload(op)?,
|
||||
keychain_sshsigchain_payload(op)?,
|
||||
signer_public_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_keychain_v2_sigchain<V>(
|
||||
pub fn verify_keychain_sshsigchain<V>(
|
||||
records: &[SshSigchainRecord],
|
||||
trust: &SshSigchainTrust,
|
||||
verifier: &V,
|
||||
) -> Result<KeychainV2Verification, SshSigchainError>
|
||||
) -> Result<KeychainSshSigchainVerification, SshSigchainError>
|
||||
where
|
||||
V: SshSigchainVerifier + ?Sized,
|
||||
{
|
||||
if trust.profile != KEYCHAIN_V2_PROFILE {
|
||||
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
|
||||
return Err(SshSigchainError::WrongKeychainProfile(
|
||||
trust.profile.clone(),
|
||||
));
|
||||
}
|
||||
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?;
|
||||
Ok(KeychainV2Verification {
|
||||
let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?;
|
||||
Ok(KeychainSshSigchainVerification {
|
||||
view: reduce_keychain_ops(&verified.state.ops),
|
||||
records: verified.records,
|
||||
head: verified.head,
|
||||
})
|
||||
}
|
||||
|
||||
impl SshSigchainPolicy for KeychainV2Policy {
|
||||
type State = KeychainV2State;
|
||||
impl SshSigchainPolicy for KeychainSshSigchainPolicy {
|
||||
type State = KeychainSshSigchainState;
|
||||
|
||||
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> {
|
||||
let root =
|
||||
canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?;
|
||||
Ok(KeychainV2State {
|
||||
Ok(KeychainSshSigchainState {
|
||||
initialized: false,
|
||||
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
|
||||
seen_op_ids: BTreeSet::new(),
|
||||
ops: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -383,7 +430,11 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
|
||||
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> {
|
||||
let op = decode_keychain_v2_payload(&record.payload).map_err(|error| error.to_string())?;
|
||||
let op = decode_keychain_sshsigchain_payload(&record.payload)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !state.seen_op_ids.insert(op.id.clone()) {
|
||||
return Err(format!("duplicate keychain operation ID: {}", op.id));
|
||||
}
|
||||
match &op.kind {
|
||||
KeychainOpKind::KeychainInit => {
|
||||
if state.initialized || record.sequence != 0 {
|
||||
|
|
@ -403,30 +454,48 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
if valid_after_ms.is_some() || valid_before_ms.is_some() {
|
||||
return Err(
|
||||
"v2 does not accept key validity windows as security policy; use a causally ordered revocation record"
|
||||
"SSHSIGCHAIN does not accept key validity windows as security policy; use a causally ordered revocation record"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
let public_key = public_key.as_deref().ok_or_else(|| {
|
||||
"AdminKeyAdd requires its canonical public key in v2".to_owned()
|
||||
"AdminKeyAdd requires its canonical public key in SSHSIGCHAIN".to_owned()
|
||||
})?;
|
||||
let public_key =
|
||||
canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?;
|
||||
if KeyId::new(admin_key_fingerprint(&public_key)) != *key {
|
||||
return Err("AdminKeyAdd key ID does not match its public key".to_owned());
|
||||
}
|
||||
if record.sequence == 1 && public_key != record.signer_public_key {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
state.admin_public_keys.insert(key.clone(), public_key);
|
||||
}
|
||||
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||
if !state.initialized {
|
||||
return Err("keychain must start with KeychainInit".to_owned());
|
||||
}
|
||||
if record.sequence == 1 {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
state.admin_public_keys.remove(key);
|
||||
}
|
||||
_ => {
|
||||
if !state.initialized {
|
||||
return Err("keychain must start with KeychainInit".to_owned());
|
||||
}
|
||||
if record.sequence == 1 {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.ops.push(op);
|
||||
|
|
@ -435,13 +504,13 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct KeychainV2Payload {
|
||||
struct KeychainSshSigchainPayload {
|
||||
version: u16,
|
||||
op: CanonicalKeychainOp,
|
||||
}
|
||||
|
||||
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard
|
||||
/// deliberately cannot deserialize that representation, so v2 uses this
|
||||
/// deliberately cannot deserialize that representation, so SSHSIGCHAIN uses this
|
||||
/// profile-local externally tagged mirror for its signed binary payload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct CanonicalKeychainOp {
|
||||
|
|
@ -654,7 +723,9 @@ pub enum SshSigchainError {
|
|||
InvalidChainId,
|
||||
#[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")]
|
||||
InvalidProfile,
|
||||
#[error("SSH sigchain namespace must be non-empty printable ASCII without whitespace")]
|
||||
#[error(
|
||||
"SSH sigchain namespace must be non-empty printable ASCII without whitespace and at most {MAX_NAMESPACE_BYTES} bytes"
|
||||
)]
|
||||
InvalidNamespace,
|
||||
#[error("SSH public key must be a canonical two-field OpenSSH public key")]
|
||||
InvalidPublicKey,
|
||||
|
|
@ -668,6 +739,16 @@ pub enum SshSigchainError {
|
|||
MissingSignature,
|
||||
#[error("SSH sigchain cannot be empty")]
|
||||
EmptyChain,
|
||||
#[error("SSH sigchain JSONL line {line}: {detail}")]
|
||||
JsonLine { line: usize, detail: String },
|
||||
#[error("failed to encode SSH sigchain JSONL: {0}")]
|
||||
JsonEncoding(String),
|
||||
#[error("SSH sigchain JSONL input is {0} bytes, over the {MAX_JSONL_BYTES}-byte limit")]
|
||||
JsonlTooLarge(usize),
|
||||
#[error(
|
||||
"SSH sigchain JSONL line {line} is {bytes} bytes, over the {MAX_JSONL_LINE_BYTES}-byte limit"
|
||||
)]
|
||||
JsonLineTooLarge { line: usize, bytes: usize },
|
||||
#[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
|
||||
TooManyRecords(usize),
|
||||
#[error("SSH sigchain sequence counter overflow")]
|
||||
|
|
@ -690,15 +771,15 @@ pub enum SshSigchainError {
|
|||
InvalidSignature { index: usize },
|
||||
#[error("SSH sigchain policy rejected record: {0}")]
|
||||
Policy(String),
|
||||
#[error("failed to encode keychain v2 payload: {0}")]
|
||||
#[error("failed to encode keychain SSHSIGCHAIN payload: {0}")]
|
||||
PayloadEncoding(String),
|
||||
#[error("failed to decode keychain v2 payload: {0}")]
|
||||
#[error("failed to decode keychain SSHSIGCHAIN payload: {0}")]
|
||||
PayloadDecoding(String),
|
||||
#[error("unsupported keychain v2 payload version {0}")]
|
||||
#[error("unsupported keychain SSHSIGCHAIN payload version {0}")]
|
||||
UnsupportedPayloadVersion(u16),
|
||||
#[error("keychain v2 payload is not its unique canonical encoding")]
|
||||
#[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")]
|
||||
NonCanonicalPayload,
|
||||
#[error("expected geth keychain v2 profile, got {0}")]
|
||||
#[error("expected geth keychain SSHSIGCHAIN profile, got {0}")]
|
||||
WrongKeychainProfile(String),
|
||||
#[error("length does not fit the SSH sigchain wire format")]
|
||||
LengthOverflow,
|
||||
|
|
@ -732,7 +813,7 @@ fn validate_profile(value: String) -> Result<String, SshSigchainError> {
|
|||
|
||||
fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_PROFILE_BYTES
|
||||
|| value.len() > MAX_NAMESPACE_BYTES
|
||||
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
{
|
||||
return Err(SshSigchainError::InvalidNamespace);
|
||||
|
|
@ -804,7 +885,7 @@ mod tests {
|
|||
fn trust() -> SshSigchainTrust {
|
||||
SshSigchainTrust::new(
|
||||
ChainId([7; 32]),
|
||||
KEYCHAIN_V2_PROFILE,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
SSH_SIGCHAIN_NAMESPACE,
|
||||
ROOT_KEY,
|
||||
)
|
||||
|
|
@ -826,7 +907,7 @@ mod tests {
|
|||
op: &KeychainOp,
|
||||
signer: &str,
|
||||
) -> SshSigchainRecord {
|
||||
let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer)
|
||||
let record = keychain_sshsigchain_unsigned_record(trust, sequence, previous, op, signer)
|
||||
.expect("unsigned record");
|
||||
let signature = test_signature(
|
||||
&trust.namespace,
|
||||
|
|
@ -851,7 +932,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn v2_accepts_a_linked_rooted_keychain() {
|
||||
fn accepts_a_linked_rooted_keychain() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -882,7 +963,7 @@ mod tests {
|
|||
ROOT_KEY,
|
||||
);
|
||||
|
||||
let verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier)
|
||||
let verified = verify_keychain_sshsigchain(&[init, add, user], &trust, &TestVerifier)
|
||||
.expect("valid chain");
|
||||
assert_eq!(verified.records, 3);
|
||||
assert_eq!(
|
||||
|
|
@ -893,7 +974,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() {
|
||||
fn requires_an_explicit_root_instead_of_self_bootstrap() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -903,13 +984,13 @@ mod tests {
|
|||
SECOND_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::RootSignerMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_rejects_a_non_linked_fork() {
|
||||
fn rejects_a_non_linked_fork() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -920,13 +1001,94 @@ mod tests {
|
|||
);
|
||||
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init, add], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::UnexpectedPrevious { index: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_revocation_is_causal_not_timestamp_ordered() {
|
||||
fn requires_the_root_to_be_recorded_before_other_identity_changes() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let user = signed_record(
|
||||
&trust,
|
||||
1,
|
||||
Some(init.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
2,
|
||||
KeychainOpKind::UserAdd {
|
||||
user: "user:alice".into(),
|
||||
name: "Alice".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_sshsigchain(&[init, user], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_keychain_operation_ids() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let add = signed_record(
|
||||
&trust,
|
||||
1,
|
||||
Some(init.record_hash().expect("hash")),
|
||||
&root_add_op(),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let user = signed_record(
|
||||
&trust,
|
||||
2,
|
||||
Some(add.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
3,
|
||||
KeychainOpKind::UserAdd {
|
||||
user: "user:alice".into(),
|
||||
name: "Alice".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let duplicate = signed_record(
|
||||
&trust,
|
||||
3,
|
||||
Some(user.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
4,
|
||||
KeychainOpKind::UserRename {
|
||||
user: "user:alice".into(),
|
||||
name: "Mallory".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_sshsigchain(&[init, add, user, duplicate], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_is_causal_not_timestamp_ordered() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -972,20 +1134,80 @@ mod tests {
|
|||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_payload_rejects_trailing_or_noncanonical_bytes() {
|
||||
let payload =
|
||||
keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload");
|
||||
fn payload_rejects_trailing_or_noncanonical_bytes() {
|
||||
let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit))
|
||||
.expect("payload");
|
||||
let mut noncanonical = payload;
|
||||
noncanonical.push(0);
|
||||
assert!(matches!(
|
||||
decode_keychain_v2_payload(&noncanonical),
|
||||
decode_keychain_sshsigchain_payload(&noncanonical),
|
||||
Err(SshSigchainError::NonCanonicalPayload)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_transport_roundtrips_without_becoming_signed_data() {
|
||||
let trust = trust();
|
||||
let record = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode JSONL");
|
||||
assert_eq!(
|
||||
decode_sshsigchain_jsonl(&jsonl).expect("decode JSONL"),
|
||||
vec![record]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_transport_rejects_unknown_record_fields() {
|
||||
let trust = trust();
|
||||
let record = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let mut value = serde_json::to_value(record).expect("record JSON");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("record object")
|
||||
.insert("unrecognized".to_owned(), serde_json::Value::Bool(true));
|
||||
assert!(matches!(
|
||||
decode_sshsigchain_jsonl(&format!("{value}\n")),
|
||||
Err(SshSigchainError::JsonLine { line: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_bytes_match_the_published_base_vector() {
|
||||
let record = SshSigchainRecord::unsigned(
|
||||
ChainId([0; 32]),
|
||||
"example.test",
|
||||
0,
|
||||
None,
|
||||
vec![1, 2],
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2",
|
||||
)
|
||||
.expect("unsigned record");
|
||||
assert_eq!(
|
||||
hex::encode(record.signing_bytes().expect("signing bytes")),
|
||||
concat!(
|
||||
"5353435301",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"0000000000000000",
|
||||
"00000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ bytes.workspace = true
|
|||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
geth-auth = { path = "../geth-auth" }
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ use runtime::{
|
|||
PubsubRuntime,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -117,6 +118,8 @@ pub enum NodeError {
|
|||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("keychain error: {0}")]
|
||||
Keychain(#[from] geth_keychain::KeychainError),
|
||||
#[error("SSH sigchain error: {0}")]
|
||||
SshSigchain(#[from] geth_keychain::SshSigchainError),
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("io error: {0}")]
|
||||
|
|
@ -137,8 +140,6 @@ 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}")]
|
||||
|
|
@ -6495,95 +6496,34 @@ pub fn handle_request(
|
|||
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,
|
||||
ControlRequest::KeychainVerifySigchain {
|
||||
input,
|
||||
chain_id,
|
||||
root_key_path,
|
||||
namespace,
|
||||
} => {
|
||||
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
|
||||
node,
|
||||
&checkpoint,
|
||||
&signature,
|
||||
&sigchain,
|
||||
&allowed_signers,
|
||||
base_url.as_deref(),
|
||||
principal.as_deref(),
|
||||
let records = read_sshsigchain_jsonl_file(&input)?;
|
||||
let trust = geth_keychain::SshSigchainTrust::new(
|
||||
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
|
||||
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
|
||||
std::fs::read_to_string(root_key_path)?,
|
||||
)?;
|
||||
Ok(ControlResponse::KeychainCheckpointVerified {
|
||||
checkpoint,
|
||||
verified,
|
||||
principal,
|
||||
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
|
||||
.to_owned(),
|
||||
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
|
||||
Ok(ControlResponse::KeychainSigchainVerified {
|
||||
input,
|
||||
chain_id: trust.chain_id.to_hex(),
|
||||
records: verified.records,
|
||||
head: verified.head.to_hex(),
|
||||
active_admin_keys: verified.view.admin_keys.len(),
|
||||
users: verified.view.users.len(),
|
||||
devices: verified.view.devices.len(),
|
||||
nodes: verified.view.nodes.len(),
|
||||
note:
|
||||
"verified linked SSHSIGCHAIN v1 records against the explicitly pinned root key"
|
||||
.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)?,
|
||||
|
|
@ -9483,301 +9423,6 @@ fn stored_keychain_op_from_op(op: &KeychainOp) -> Result<StoredKeychainOp, NodeE
|
|||
})
|
||||
}
|
||||
|
||||
#[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.clone(), op))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let existing_signatures = load_keychain_signatures(store)?
|
||||
.into_iter()
|
||||
.map(|signature| {
|
||||
(
|
||||
(
|
||||
signature.op_id.clone(),
|
||||
signature.signer.clone(),
|
||||
signature.namespace.clone(),
|
||||
),
|
||||
signature,
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for op in ops {
|
||||
if existing_ops
|
||||
.get(&op.id)
|
||||
.is_some_and(|existing| existing != op)
|
||||
{
|
||||
return Err(NodeError::Unauthorized(format!(
|
||||
"refusing keychain operation {} because that ID already names different immutable content",
|
||||
op.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for signature in signatures {
|
||||
let identity = (
|
||||
signature.op_id.clone(),
|
||||
signature.signer.clone(),
|
||||
signature.namespace.clone(),
|
||||
);
|
||||
if existing_signatures
|
||||
.get(&identity)
|
||||
.is_some_and(|existing| existing != signature)
|
||||
{
|
||||
return Err(NodeError::Unauthorized(format!(
|
||||
"refusing keychain signature for {} because its signer and namespace already name different immutable content",
|
||||
signature.op_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut signatures_imported = 0;
|
||||
let mut ops_imported = 0;
|
||||
for op in ops {
|
||||
let op_signatures = signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.op_id == op.id)
|
||||
.map(stored_keychain_signature_from_signature)
|
||||
.collect::<Vec<_>>();
|
||||
store
|
||||
.insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?;
|
||||
if !existing_ops.contains_key(&op.id) {
|
||||
ops_imported += 1;
|
||||
}
|
||||
for signature in signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.op_id == op.id)
|
||||
{
|
||||
let key = (
|
||||
signature.op_id.clone(),
|
||||
signature.signer.clone(),
|
||||
signature.namespace.clone(),
|
||||
);
|
||||
signatures_imported += usize::from(!existing_signatures.contains_key(&key));
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
@ -9869,93 +9514,6 @@ fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, Node
|
|||
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],
|
||||
|
|
@ -9984,34 +9542,80 @@ fn verify_keychain_sigchain_entries_with_ssh(
|
|||
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()));
|
||||
fn verify_keychain_sshsigchain_with_ssh(
|
||||
records: &[geth_keychain::SshSigchainRecord],
|
||||
trust: &geth_keychain::SshSigchainTrust,
|
||||
) -> Result<geth_keychain::KeychainSshSigchainVerification, NodeError> {
|
||||
geth_ssh_identity::ensure_ssh_keygen_available()?;
|
||||
let verify_dir = tempfile::tempdir()?;
|
||||
|
||||
struct SshSigchainOpenSshVerifier {
|
||||
verify_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl geth_keychain::SshSigchainVerifier for SshSigchainOpenSshVerifier {
|
||||
fn verify(
|
||||
&self,
|
||||
namespace: &str,
|
||||
message: &[u8],
|
||||
public_key: &str,
|
||||
signature: &[u8],
|
||||
) -> bool {
|
||||
let payload_path = self.verify_dir.join("payload");
|
||||
let signature_path = self.verify_dir.join("signature");
|
||||
let allowed_signers_path = self.verify_dir.join("allowed-signers");
|
||||
if std::fs::write(&payload_path, message).is_err()
|
||||
|| std::fs::write(&signature_path, signature).is_err()
|
||||
|| std::fs::write(
|
||||
&allowed_signers_path,
|
||||
format!(
|
||||
"{} {}\n",
|
||||
geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL,
|
||||
public_key
|
||||
),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(payload) = std::fs::File::open(payload_path) else {
|
||||
return false;
|
||||
};
|
||||
std::process::Command::new("ssh-keygen")
|
||||
.arg("-Y")
|
||||
.arg("verify")
|
||||
.arg("-f")
|
||||
.arg(allowed_signers_path)
|
||||
.arg("-I")
|
||||
.arg(geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL)
|
||||
.arg("-n")
|
||||
.arg(namespace)
|
||||
.arg("-s")
|
||||
.arg(signature_path)
|
||||
.stdin(std::process::Stdio::from(payload))
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
}
|
||||
|
||||
let verifier = SshSigchainOpenSshVerifier {
|
||||
verify_dir: verify_dir.path().to_path_buf(),
|
||||
};
|
||||
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))
|
||||
Ok(geth_keychain::verify_keychain_sshsigchain(
|
||||
records, trust, &verifier,
|
||||
)?)
|
||||
}
|
||||
|
||||
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 read_sshsigchain_jsonl_file(
|
||||
input: &Path,
|
||||
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
|
||||
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
|
||||
let mut text = String::new();
|
||||
reader.read_to_string(&mut text)?;
|
||||
if text.len() > geth_keychain::MAX_JSONL_BYTES {
|
||||
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_namespace(name: &str) -> String {
|
||||
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
|
||||
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
|
||||
}
|
||||
|
||||
fn verify_keychain_sigchain_with_ssh(
|
||||
|
|
@ -11390,6 +10994,12 @@ mod tests {
|
|||
))),
|
||||
"daemon_already_running"
|
||||
);
|
||||
assert_eq!(
|
||||
local_control::node_error_code(&NodeError::SshSigchain(
|
||||
geth_keychain::SshSigchainError::EmptyChain
|
||||
)),
|
||||
"sshsigchain_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -11407,6 +11017,108 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sshsigchain_verifies_real_openssh_sshsig_records() {
|
||||
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
|
||||
return;
|
||||
}
|
||||
let dir = tempfile::tempdir().expect("temporary SSHSIGCHAIN directory");
|
||||
let private_key = dir.path().join("root");
|
||||
let generated = std::process::Command::new("ssh-keygen")
|
||||
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
|
||||
.arg(&private_key)
|
||||
.output()
|
||||
.expect("run ssh-keygen");
|
||||
assert!(
|
||||
generated.status.success(),
|
||||
"ssh-keygen key generation failed: {}",
|
||||
String::from_utf8_lossy(&generated.stderr)
|
||||
);
|
||||
let generated_public_key =
|
||||
std::fs::read_to_string(format!("{}.pub", private_key.display()))
|
||||
.expect("read public key");
|
||||
let mut fields = generated_public_key.split_ascii_whitespace();
|
||||
let root_public_key = format!(
|
||||
"{} {}",
|
||||
fields.next().expect("key type"),
|
||||
fields.next().expect("key blob")
|
||||
);
|
||||
let trust = geth_keychain::SshSigchainTrust::new(
|
||||
geth_keychain::SshSigchainChainId([0x42; 32]),
|
||||
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
|
||||
&root_public_key,
|
||||
)
|
||||
.expect("trust tuple");
|
||||
|
||||
let sign = |record: geth_keychain::SshSigchainRecord| {
|
||||
let payload = dir.path().join(format!("record-{}", record.sequence));
|
||||
std::fs::write(&payload, record.signing_bytes().expect("signing bytes"))
|
||||
.expect("write signing payload");
|
||||
let output = geth_ssh_identity::sign_command(
|
||||
&private_key,
|
||||
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
|
||||
&payload,
|
||||
)
|
||||
.output()
|
||||
.expect("sign SSHSIGCHAIN record");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ssh-keygen signing failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let signature =
|
||||
std::fs::read(format!("{}.sig", payload.display())).expect("read SSHSIG signature");
|
||||
record.with_signature(signature).expect("signed record")
|
||||
};
|
||||
|
||||
let init_op = KeychainOp {
|
||||
id: AuthOpId::new("auth-op:sshsigchain-init"),
|
||||
created_at: UnixMillis(1),
|
||||
kind: KeychainOpKind::KeychainInit,
|
||||
};
|
||||
let init = sign(
|
||||
geth_keychain::keychain_sshsigchain_unsigned_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&init_op,
|
||||
&root_public_key,
|
||||
)
|
||||
.expect("unsigned init"),
|
||||
);
|
||||
let root_add_op = KeychainOp {
|
||||
id: AuthOpId::new("auth-op:sshsigchain-root"),
|
||||
created_at: UnixMillis(2),
|
||||
kind: KeychainOpKind::AdminKeyAdd {
|
||||
key: KeyId::new(geth_keychain::admin_key_fingerprint(&root_public_key)),
|
||||
public_key: Some(root_public_key.clone()),
|
||||
principal: Some("root".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
};
|
||||
let root_add = sign(
|
||||
geth_keychain::keychain_sshsigchain_unsigned_record(
|
||||
&trust,
|
||||
1,
|
||||
Some(init.record_hash().expect("init hash")),
|
||||
&root_add_op,
|
||||
&root_public_key,
|
||||
)
|
||||
.expect("unsigned root add"),
|
||||
);
|
||||
|
||||
let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone(), root_add], &trust)
|
||||
.expect("verify real OpenSSH SSHSIG chain");
|
||||
assert_eq!(verified.records, 2);
|
||||
assert_eq!(verified.view.admin_keys.len(), 1);
|
||||
|
||||
let mut tampered = init;
|
||||
tampered.payload.push(0);
|
||||
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RemoteGuardKind {
|
||||
Capability,
|
||||
|
|
|
|||
|
|
@ -547,6 +547,7 @@ pub(crate) fn node_error_code(error: &NodeError) -> &'static str {
|
|||
NodeError::Store(_) => "store_error",
|
||||
NodeError::Config(_) => "config_error",
|
||||
NodeError::Codec(_) => "codec_error",
|
||||
NodeError::SshSigchain(_) => "sshsigchain_error",
|
||||
_ => "node_error",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3079,9 +3079,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
},
|
||||
)
|
||||
.expect("admin add");
|
||||
match response {
|
||||
let added_op_id = match response {
|
||||
geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => {
|
||||
assert_eq!(signatures.len(), 1);
|
||||
let op_id = op.id.to_string();
|
||||
match op.kind {
|
||||
geth_keychain::KeychainOpKind::AdminKeyAdd {
|
||||
public_key,
|
||||
|
|
@ -3093,9 +3094,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
}
|
||||
other => panic!("unexpected op kind: {other:?}"),
|
||||
}
|
||||
op_id
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
};
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
|
|
@ -3193,157 +3195,23 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
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(),
|
||||
op_id: added_op_id.clone(),
|
||||
},
|
||||
)
|
||||
.expect("explain op");
|
||||
.expect("explain operation");
|
||||
match response {
|
||||
geth_control::ControlResponse::KeychainExplained { subject, lines } => {
|
||||
assert_eq!(subject, explain_op_id);
|
||||
assert_eq!(subject, added_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");
|
||||
std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin public key");
|
||||
let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key);
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
|
|
@ -3353,46 +3221,16 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
|
|||
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");
|
||||
.expect("verify local keychain");
|
||||
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:?}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# ADR 0018: Linked SSHSIGCHAIN v2
|
||||
# ADR 0018: Linked SSHSIGCHAIN v1
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for the new generic core and geth keychain profile. Legacy static
|
||||
sigchain publication is deprecated pending explicit v2 command migration.
|
||||
Accepted for the generic core and geth keychain profile. The pre-standard
|
||||
test-only static workflow is removed; it is not an alternate format or a
|
||||
compatibility path.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ rollback, and fork semantics inadequate for a trust foundation.
|
|||
|
||||
## Decision
|
||||
|
||||
Geth adopts SSHSIGCHAIN v2 as the replacement design:
|
||||
Geth adopts SSHSIGCHAIN version 1 as its one portable signed-chain design:
|
||||
|
||||
- root trust is an explicit `(chain ID, profile, namespace, root key)` tuple;
|
||||
- records are linearly ordered by sequence and linked by a digest over their
|
||||
|
|
@ -31,11 +32,11 @@ not a geth transport.
|
|||
|
||||
## Consequences
|
||||
|
||||
The v2 core can be published and implemented by applications without importing
|
||||
The core can be published and implemented by applications without importing
|
||||
geth's resource model. Geth's keychain profile is intentionally narrow and
|
||||
tested against self-bootstrap, fork, and backdated-revocation attacks.
|
||||
|
||||
Existing static v1 bundles cannot safely migrate in place. Operators must pin a
|
||||
new v2 trust tuple and create a new genesis chain. Head persistence and later
|
||||
witness/transparency support remain follow-up work; neither is implied by a
|
||||
single signed checkpoint.
|
||||
The old static bundle code and commands are deleted rather than supported beside
|
||||
SSHSIGCHAIN. There is no migration format because the repository has not been
|
||||
deployed. Head persistence and later witness/transparency support remain
|
||||
follow-up work; neither is implied by a single signed checkpoint.
|
||||
|
|
@ -451,23 +451,20 @@ admin-revoke` operations; `AdminKeyAdd` carries the public key material needed
|
|||
to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
|
||||
the log against the previously accepted admin-key view, mirroring the `git-skm`
|
||||
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. 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
|
||||
local replay rules, application-specific signature namespaces, and
|
||||
allowed-signers projection live in `geth-keychain`. The CLI can export the
|
||||
reduced key registry as OpenSSH `allowed_signers` and can sign and verify
|
||||
arbitrary snapshots, such as externally managed `authorized_keys`, with an
|
||||
active keychain signer under an explicit OpenSSH namespace. The prior static
|
||||
JSONL export, publication, import, checkpoint, and fetch commands were removed:
|
||||
their downloaded `allowed_signers` projection could bootstrap its own trust.
|
||||
`geth keychain verify-sigchain --in <file> --chain-id <id> --root-key
|
||||
<public-key>` is the inspection entry point for the standalone SSHSIGCHAIN
|
||||
format, verified against a pin supplied by the operator. SSHSIGCHAIN signing,
|
||||
persistent heads, publication, and import remain follow-up work rather than a
|
||||
compatibility fallback. `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
|
||||
|
|
@ -479,16 +476,16 @@ identities are immutable in local storage. An attempted re-import with an
|
|||
existing identity but different bytes is rejected before it can replace local
|
||||
trust state; idempotent repeats leave the original bytes unchanged.
|
||||
|
||||
The replacement static-publication design is specified in
|
||||
[`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit
|
||||
The portable signed-chain design is specified in
|
||||
[`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit
|
||||
out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust
|
||||
tuple, a strict sequence plus hash link, bounded fields, and a profile reducer
|
||||
that authorizes each record from only the causally preceding state. Geth's
|
||||
`geth.keychain.v2` profile rejects timestamp validity windows as authorization
|
||||
policy and treats key revocation as a causal record. The legacy static JSONL
|
||||
commands have not yet been migrated to this core and are not a safe bootstrap
|
||||
for new trust; Iroh keychain sync remains the supported replicated path while
|
||||
the explicit v2 command workflow is completed.
|
||||
`geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization
|
||||
policy and treats key revocation as a causal record. The prior test-only static
|
||||
workflow was removed rather than migrated. Iroh keychain sync remains the
|
||||
current replicated local operation-log path while explicit SSHSIGCHAIN
|
||||
production and import workflows are completed.
|
||||
|
||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||
state. `geth node enroll join` explicitly imports an owner admin public key as
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ The following command families are intended to be stable automation surfaces:
|
|||
- `geth peer export|import|list|ping|auth-check`
|
||||
- `geth keychain init|status|admin-add|admin-revoke|allowed-signers|verify`
|
||||
- `geth keychain sign-file|verify-file`
|
||||
- `geth keychain sigchain|verify-sigchain|import-sigchain|verify-checkpoint|fetch|explain|explain-signer`
|
||||
- `geth keychain explain|explain-signer`
|
||||
- `geth auth explain|grant|revoke|sync`
|
||||
- `geth sync status|now`
|
||||
- `geth wait daemon|peer|sync`
|
||||
|
|
@ -61,6 +61,7 @@ settles:
|
|||
- `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv`
|
||||
- `geth cas add-private`
|
||||
- `geth cas get-private`
|
||||
- `geth keychain verify-sigchain`
|
||||
|
||||
Migration expectation: overlay membership records and authorization resources
|
||||
should remain readable, but packet runtime flags, platform activation details,
|
||||
|
|
@ -71,6 +72,11 @@ properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from
|
|||
earlier pre-deployment builds are rejected with a clear error and should be
|
||||
recreated from plaintext.
|
||||
|
||||
The SSHSIGCHAIN verifier is experimental while SSHSIGCHAIN signing, storage,
|
||||
publication, import, head persistence, and independently generated wire vectors
|
||||
are completed. The prior test-only static commands were removed and are not a
|
||||
compatibility path.
|
||||
|
||||
## Adding Commands
|
||||
|
||||
New commands should enter this file in the same change that introduces the CLI
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ admin devices without depending on one always-online coordination server.
|
|||
resource data.
|
||||
- `[ ]` The control plane has documented conflict semantics for concurrent
|
||||
joins, renames, IP conflicts, route changes, and revocations.
|
||||
- `[ ]` Nodes can converge from static sigchain publication, peer live-sync,
|
||||
- `[ ]` Nodes can converge from SSHSIGCHAIN publication, peer live-sync,
|
||||
and Iroh-connected peers without requiring a central coordinator.
|
||||
- `[ ]` Stale or partitioned nodes are detectable in `geth overlay status`
|
||||
and `geth sync status --json`.
|
||||
|
|
@ -181,7 +181,7 @@ admin devices without depending on one always-online coordination server.
|
|||
- `[ ]` Fault-tolerant peer and path selection.
|
||||
Acceptance criteria:
|
||||
- `[ ]` Nodes maintain multiple candidate addresses from Iroh relays, local
|
||||
discovery, imported peer cards, and static publication.
|
||||
discovery, imported peer cards, and configured bootstrap metadata.
|
||||
- `[ ]` Packet routing retries healthy paths and backs off failed paths
|
||||
without granting trust from discovery metadata.
|
||||
- `[ ]` Relay use, direct connections, LAN discovery, and path failures are
|
||||
|
|
@ -555,23 +555,33 @@ resource-scoped capability decisions.
|
|||
- JSON is not used as the signed representation.
|
||||
- Tests verify equivalent operations hash/sign identically across runs.
|
||||
|
||||
- `[~]` Replace the legacy static keychain sigchain with SSHSIGCHAIN v2.
|
||||
- `[~]` Implement SSHSIGCHAIN version 1 as the only portable keychain
|
||||
sigchain format.
|
||||
Acceptance criteria:
|
||||
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v2 record
|
||||
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record
|
||||
format with explicit trust-anchor, chain-link, size-limit, and
|
||||
non-claim documentation.
|
||||
- `[x]` Provide a reusable verifier core and a geth keychain profile that
|
||||
rejects self-bootstrap, non-linked forks, non-canonical payloads, and
|
||||
post-revocation timestamp replay.
|
||||
- `[ ]` Add explicit CLI storage, signing, verification, publication, and
|
||||
import workflows for a pinned v2 trust tuple.
|
||||
- `[ ]` Persist accepted v2 heads and require proof of extension before a
|
||||
static source can advance.
|
||||
- `[ ]` Disable the legacy static publish/fetch/import workflow by default
|
||||
and provide an operator-visible migration path that creates a fresh v2
|
||||
genesis chain.
|
||||
- `[ ]` Add OpenSSH integration tests and independently generated wire test
|
||||
- `[~]` Add explicit CLI storage, signing, verification, publication, and
|
||||
import workflows for a pinned SSHSIGCHAIN trust tuple.
|
||||
- `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file
|
||||
against an operator-pinned chain ID and OpenSSH root public key.
|
||||
- `[ ]` Local record storage, signing, publication, import, and
|
||||
head-advance workflows are complete.
|
||||
- `[ ]` Persist accepted heads and require proof of extension before a source
|
||||
can advance.
|
||||
- `[x]` Delete the previous test-only static export, publication,
|
||||
verification, import, checkpoint, and fetch workflow. It is not a
|
||||
compatibility target because it was never deployed.
|
||||
- `[~]` Add OpenSSH integration tests and independently generated wire test
|
||||
vectors for the published standard.
|
||||
- `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies a
|
||||
linked root/init chain.
|
||||
- `[x]` The specification and reference implementation share a base
|
||||
signing-byte test vector.
|
||||
- `[ ]` Add independently generated cross-implementation vectors.
|
||||
|
||||
- `[x]` SSH-admin-rooted keychain initialization.
|
||||
Acceptance criteria:
|
||||
|
|
@ -602,24 +612,11 @@ resource-scoped capability decisions.
|
|||
- `[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]` Previous test-only static JSONL export, publication, verification,
|
||||
import, checkpoint, and fetch commands were deleted after review found that
|
||||
their downloaded allowed-signers projection could self-bootstrap trust and
|
||||
their timestamp ordering could not prove causal history. They are not a
|
||||
compatibility target; SSHSIGCHAIN is the only portable format.
|
||||
- `[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
|
||||
|
|
@ -629,9 +626,9 @@ resource-scoped capability decisions.
|
|||
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]` `geth-keychain` exposes a transport-neutral SSHSIGCHAIN verifier,
|
||||
bounded JSONL transport codec, and an explicit trust-tuple model alongside
|
||||
local allowed-signers projection and replay verification.
|
||||
- `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications
|
||||
can use distinct signature namespaces and default principals.
|
||||
- `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so
|
||||
|
|
|
|||
|
|
@ -1,202 +1,103 @@
|
|||
# Geth Keychain Sigchain Design
|
||||
# Geth Keychain And SSHSIGCHAIN
|
||||
|
||||
## 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.
|
||||
The geth keychain is the local identity-plane operation log for admin SSH keys,
|
||||
users, devices, nodes, agents, and endpoint bindings. The daemon stores these
|
||||
operations in SQLite and synchronizes authorized records over Iroh. Reducing
|
||||
the operation log produces the current keychain view used by node management,
|
||||
authorization, and OpenSSH `allowed_signers` projection.
|
||||
|
||||
This is intentionally similar to the `git-skm` pattern:
|
||||
[`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable
|
||||
signed-chain format in this repository. It is deliberately separate from the
|
||||
current local keychain operation log: SSHSIGCHAIN starts with an explicit
|
||||
out-of-band trust tuple, has a strict sequence and hash link, and never uses a
|
||||
downloaded `allowed_signers` file as a trust root. The previous test-only
|
||||
static JSONL publication format was removed.
|
||||
|
||||
- `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.
|
||||
## Local keychain model
|
||||
|
||||
## Data Model
|
||||
The durable local log contains `KeychainOp` records and their
|
||||
`KeychainOpSignature` records. Every operation has a deterministic canonical
|
||||
binary signing payload under the `geth.keychain.v1@geth.local` namespace.
|
||||
JSON is an API and storage representation, never the signed payload.
|
||||
|
||||
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.
|
||||
|
||||
The namespace is part of a `KeychainProfile`. geth uses:
|
||||
|
||||
- `geth.keychain.v1@geth.local`
|
||||
- `geth.node-enrollment-request.v1@geth.local`
|
||||
- default admin principal `admin`
|
||||
|
||||
Other applications should create their own profile with explicit namespaces or
|
||||
`KeychainProfile::for_application("<app>", "<domain>")`. For example,
|
||||
`for_application("acme-notes", "example.com")` produces
|
||||
`acme-notes.keychain.v1@example.com`. This prevents signatures from one
|
||||
application's keychain from being replayed into another application's
|
||||
keychain.
|
||||
|
||||
Important operation kinds:
|
||||
Important operation kinds are:
|
||||
|
||||
- `KeychainInit`
|
||||
- `AdminKeyAdd`
|
||||
- `AdminKeyRevoke`
|
||||
- `AdminKeyAdd`, `AdminKeyRevoke`
|
||||
- `UserAdd`, `UserRename`, `UserRevoke`
|
||||
- `DeviceAdd`, `DeviceRevoke`
|
||||
- `DeviceKeyAdd`, `DeviceKeyRevoke`
|
||||
- `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.
|
||||
`AdminKeyAdd` carries the OpenSSH public key material used to reconstruct the
|
||||
active `allowed_signers` view. Local operation and signature identities are
|
||||
append-only: an existing identity with different bytes is rejected rather than
|
||||
replaced.
|
||||
|
||||
## Signature Rules
|
||||
## Local signatures
|
||||
|
||||
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:
|
||||
An operation signature contains its operation ID, signer key fingerprint,
|
||||
signer public key, SSHSIG namespace, OpenSSH signature bytes, and creation
|
||||
time. geth signs through OpenSSH, for example:
|
||||
|
||||
```sh
|
||||
ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload>
|
||||
```
|
||||
|
||||
`<signing-key>` can be:
|
||||
`<signing-key>` may be a local private key, an OpenSSH security-key stub, or a
|
||||
public key whose private half is available through `ssh-agent`. The daemon does
|
||||
not store private keys or passphrases.
|
||||
|
||||
- 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>`.
|
||||
Local verification accepts an operation only when a signature from an admin
|
||||
key in the previously accepted local view verifies over the canonical payload.
|
||||
This is useful for the local Iroh-synchronized operation log, but it is not a
|
||||
portable SSHSIGCHAIN history and MUST NOT be presented as one.
|
||||
|
||||
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.
|
||||
## SSHSIGCHAIN keychain profile
|
||||
|
||||
Verification uses:
|
||||
Geth's SSHSIGCHAIN profile identifier is `geth.keychain.sshsigchain.v1` and its
|
||||
SSHSIG namespace is `sshsigchain.v1`. Its canonical binary payload is a
|
||||
versioned mirror of a keychain operation; it is separate from the
|
||||
human-facing, internally tagged JSON API type so a decoder can prove one unique
|
||||
payload encoding.
|
||||
|
||||
The profile requires:
|
||||
|
||||
- sequence zero to be `KeychainInit` signed by the operator-pinned root key;
|
||||
- sequence one to be an `AdminKeyAdd` recording that root key;
|
||||
- every following signer to be an active admin key in the causally prior
|
||||
profile state;
|
||||
- every keychain operation ID to occur only once in the chain;
|
||||
- each added admin key to have canonical key material matching its declared key
|
||||
fingerprint; and
|
||||
- causal add/revoke records rather than validity windows or payload timestamps
|
||||
as authorization policy.
|
||||
|
||||
The root key seeds authorization at genesis only. A causally valid revocation
|
||||
removes it like any other admin key.
|
||||
|
||||
To inspect an SSHSIGCHAIN JSONL transport file, pin the chain ID and root public
|
||||
key locally:
|
||||
|
||||
```sh
|
||||
ssh-keygen -Y verify \
|
||||
-f <allowed-signers> \
|
||||
-I <signer-principal> \
|
||||
-n geth.keychain.v1@geth.local \
|
||||
-s <signature>
|
||||
geth keychain verify-sigchain \
|
||||
--in geth.sshsigchain.v1.jsonl \
|
||||
--chain-id <64-hex-character-chain-id> \
|
||||
--root-key ~/.ssh/geth-root.pub
|
||||
```
|
||||
|
||||
The signed payload is canonical binary encoding, not JSON.
|
||||
This verifier is experimental while geth adds its own record creation,
|
||||
publication, import, accepted-head persistence, and independently generated
|
||||
wire vectors. Those later workflows must preserve the same explicit trust
|
||||
tuple; they must not introduce a compatibility route for the removed static
|
||||
format.
|
||||
|
||||
## Verification Algorithm
|
||||
## Local commands
|
||||
|
||||
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 `KeychainProfile`
|
||||
- a `KeychainSignatureVerifier`
|
||||
|
||||
The verifier is responsible for the cryptographic backend, such as
|
||||
`ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test
|
||||
verifier. The crate owns the replay order, bootstrap rule, profile namespace
|
||||
check, previous-view authorization rule, `allowed_signers` projection, and
|
||||
reduced keychain view.
|
||||
|
||||
Minimal reusable Rust shape:
|
||||
|
||||
```rust
|
||||
let profile = geth_keychain::KeychainProfile::for_application(
|
||||
"acme-notes",
|
||||
"example.com",
|
||||
)?;
|
||||
let entries = geth_keychain::decode_sigchain_jsonl(sigchain_text)?;
|
||||
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
|
||||
let report = geth_keychain::verify_sigchain_with_profile(
|
||||
&ops,
|
||||
&signatures,
|
||||
&profile,
|
||||
&my_verifier,
|
||||
);
|
||||
```
|
||||
|
||||
## 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[]`.
|
||||
|
||||
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
|
||||
|
||||
Owner bootstrap:
|
||||
Bootstrap an owner node:
|
||||
|
||||
```sh
|
||||
geth init \
|
||||
|
|
@ -205,100 +106,25 @@ geth init \
|
|||
--node-name laptop
|
||||
```
|
||||
|
||||
Add a new admin key:
|
||||
Manage the current local keychain and its OpenSSH projection:
|
||||
|
||||
```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
|
||||
geth keychain allowed-signers --out allowed_signers
|
||||
geth keychain sign-file \
|
||||
--in authorized_keys \
|
||||
--out authorized_keys.sig \
|
||||
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 verify-file --in authorized_keys --signature authorized_keys.sig
|
||||
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:
|
||||
|
||||
```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:
|
||||
|
||||
- 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.
|
||||
The keychain does not manage host `authorized_keys` policy. It can sign a
|
||||
snapshot supplied by the operator, allowing another system to retain its own
|
||||
SSH login policy while usefully checking approval against an active local admin
|
||||
key.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
# SSHSIGCHAIN v2
|
||||
# SSHSIGCHAIN v1
|
||||
|
||||
Status: Draft 0
|
||||
Status: Draft 1 (pre-deployment)
|
||||
|
||||
This document specifies a deliberately small, generic append-only signature
|
||||
chain for applications that use OpenSSH `sshsig` signatures. It is transport
|
||||
chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the
|
||||
first and only defined version of SSHSIGCHAIN. It is transport
|
||||
independent: a chain may be carried by a file, object store, HTTP, database, or
|
||||
mesh protocol. It does not make SSH a transport.
|
||||
|
||||
The reference implementation lives in geth's `geth-keychain` crate. Geth uses
|
||||
the `geth.keychain.v2` profile for identity-plane operations, but the format and
|
||||
the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and
|
||||
verification core do not depend on geth data types.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
SSHSIGCHAIN v2 provides:
|
||||
SSHSIGCHAIN v1 provides:
|
||||
|
||||
- a fixed, deterministic byte sequence for every signed record;
|
||||
- an explicit, out-of-band root public key rather than trust bootstrapped from
|
||||
|
|
@ -46,7 +47,7 @@ enrollment material, release metadata, or another authenticated out-of-band
|
|||
channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT
|
||||
be used to discover or replace them.
|
||||
|
||||
The v2 core accepts a single root key. A threshold or witness scheme is a
|
||||
The v1 core accepts a single root key. A threshold or witness scheme is a
|
||||
separate protocol extension and must bind the same `chain_id`, profile, and
|
||||
head digest; it is not implied by multiple signatures attached to a record.
|
||||
|
||||
|
|
@ -80,8 +81,9 @@ A record has these logical fields:
|
|||
| `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. |
|
||||
|
||||
The profile identifier is 1–128 ASCII bytes containing only letters, digits,
|
||||
`.`, `-`, and `_`. Canonical public-key text is at most 16 KiB, signatures are
|
||||
at most 64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without
|
||||
whitespace. Canonical public-key text is at most 16 KiB, signatures are at most
|
||||
64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
performing unbounded work. Implementations may set smaller limits.
|
||||
|
||||
Timestamps are intentionally not fields in the generic ordering mechanism.
|
||||
|
|
@ -97,7 +99,7 @@ contains exactly `n` bytes; no implicit terminator or alignment is present.
|
|||
|
||||
```text
|
||||
"SSCS" 4 bytes
|
||||
0x02 1 byte (protocol version)
|
||||
0x01 1 byte (protocol version)
|
||||
chain_id 32 bytes
|
||||
sequence u64
|
||||
has_previous 1 byte: 0 or 1
|
||||
|
|
@ -114,13 +116,49 @@ The `signature` field is not in the signing bytes because SSHSIG signs those
|
|||
bytes. The record digest, which binds the signature into the next link, is:
|
||||
|
||||
```text
|
||||
BLAKE3("sshsigchain.record-hash.v2\\0" || signing_bytes || u32(signature_length) || signature)
|
||||
BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature)
|
||||
```
|
||||
|
||||
`previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or
|
||||
JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT
|
||||
be signed or hashed as the record representation.
|
||||
|
||||
### JSONL transport
|
||||
|
||||
This specification defines JSONL as a convenient interchange transport. Each
|
||||
non-empty physical line contains exactly one JSON object with these fields:
|
||||
`chain_id`, `profile`, `sequence`, `previous`, `payload`,
|
||||
`signer_public_key`, and `signature`. `chain_id`, `previous` when present,
|
||||
`payload`, and `signature` are JSON arrays of unsigned octets; `previous` is
|
||||
`null` when absent. `profile` and `signer_public_key` are JSON strings, and
|
||||
`sequence` is an unsigned integer. Unknown or duplicate fields MUST be
|
||||
rejected. Object-member order and JSON whitespace carry no meaning.
|
||||
|
||||
Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete
|
||||
input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those
|
||||
limits before buffering an unbounded line or input. These limits are transport
|
||||
limits in addition to the record limits above.
|
||||
|
||||
### Base test vector
|
||||
|
||||
This unsigned record fixes the field grammar independently of OpenSSH key
|
||||
generation or signature randomness:
|
||||
|
||||
```text
|
||||
chain_id 32 zero bytes
|
||||
profile "example.test"
|
||||
sequence 0
|
||||
previous absent
|
||||
payload 01 02
|
||||
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
|
||||
```
|
||||
|
||||
Its signing bytes, encoded as lowercase hexadecimal, MUST be:
|
||||
|
||||
```text
|
||||
53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332
|
||||
```
|
||||
|
||||
## 6. Verification algorithm
|
||||
|
||||
Given the configured trust input and an ordered candidate record list, a
|
||||
|
|
@ -167,13 +205,15 @@ bytes.
|
|||
|
||||
### geth keychain profile
|
||||
|
||||
Geth's identifier is `geth.keychain.v2`. Its payload is a versioned canonical
|
||||
Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical
|
||||
binary mirror of a keychain operation. The mirror is deliberately separate from
|
||||
the human-facing, internally tagged JSON API type so that a decoder can prove a
|
||||
unique payload byte sequence. Version 1 requires:
|
||||
|
||||
- sequence zero to be `KeychainInit`;
|
||||
- sequence one to be an `AdminKeyAdd` that records the configured root signer;
|
||||
- every signer to be an active admin public key in the preceding profile state;
|
||||
- every keychain operation ID to occur only once in the chain;
|
||||
- each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID
|
||||
matches the declared key ID;
|
||||
- `AdminKeyRevoke` to remove that key before any later record is authorized;
|
||||
|
|
@ -195,7 +235,8 @@ unless its signer is an already pinned trust anchor and its claimed ancestry is
|
|||
verified.
|
||||
|
||||
For stronger equivocation evidence, publish heads to independently operated
|
||||
witnesses or a transparency log. This is intentionally outside v2's small core.
|
||||
witnesses or a transparency log. This is intentionally outside SSHSIGCHAIN's
|
||||
small core.
|
||||
|
||||
## 9. Security considerations
|
||||
|
||||
|
|
@ -217,11 +258,11 @@ witnesses or a transparency log. This is intentionally outside v2's small core.
|
|||
|
||||
## 10. Compatibility
|
||||
|
||||
SSHSIGCHAIN v2 has no compatibility mode with the older geth static JSONL
|
||||
sigchain. That format could bootstrap trust from its own download and ordered
|
||||
operations by mutable timestamps, so treating it as v2 would silently preserve
|
||||
the bugs this specification removes. Migration requires an explicitly pinned
|
||||
v2 trust tuple and a freshly signed v2 genesis sequence.
|
||||
This specification defines one format: SSHSIGCHAIN version 1. It has no
|
||||
compatibility mode, downgrade path, or alternate legacy record grammar.
|
||||
Implementations MUST reject another protocol version. Geth removed its
|
||||
pre-standard test-only static JSONL workflow rather than treating it as an
|
||||
SSHSIGCHAIN variant.
|
||||
|
||||
## References
|
||||
|
||||
Loading…
Reference in a new issue