add linked sshsigchain v2 core
This commit is contained in:
parent
cb8c4e6fd4
commit
8780350d41
8 changed files with 1305 additions and 0 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1676,9 +1676,11 @@ dependencies = [
|
||||||
name = "geth-keychain"
|
name = "geth-keychain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
"geth-codec",
|
"geth-codec",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,17 @@
|
||||||
//! create their own profile with `KeychainProfile::for_application` or
|
//! create their own profile with `KeychainProfile::for_application` or
|
||||||
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
|
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
|
||||||
|
|
||||||
|
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,
|
||||||
|
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
|
||||||
|
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
|
||||||
|
verify_keychain_v2_sigchain, verify_sshsigchain,
|
||||||
|
};
|
||||||
|
|
||||||
use geth_types::{
|
use geth_types::{
|
||||||
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
|
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
991
crates/geth-keychain/src/sshsigchain.rs
Normal file
991
crates/geth-keychain/src/sshsigchain.rs
Normal file
|
|
@ -0,0 +1,991 @@
|
||||||
|
//! Reference implementation of the small, generic SSHSIGCHAIN v2 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.
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
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 MAX_PROFILE_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;
|
||||||
|
|
||||||
|
const SIGNING_MAGIC: &[u8] = b"SSCS";
|
||||||
|
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ChainId(pub [u8; 32]);
|
||||||
|
|
||||||
|
impl ChainId {
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_hex(self) -> String {
|
||||||
|
hex::encode(self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_hex(value: &str) -> Result<Self, SshSigchainError> {
|
||||||
|
let bytes = hex::decode(value).map_err(|_| SshSigchainError::InvalidChainId)?;
|
||||||
|
let array: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| SshSigchainError::InvalidChainId)?;
|
||||||
|
Ok(Self(array))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Digest(pub [u8; 32]);
|
||||||
|
|
||||||
|
impl Digest {
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_hex(self) -> String {
|
||||||
|
hex::encode(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SshSigchainTrust {
|
||||||
|
pub chain_id: ChainId,
|
||||||
|
pub profile: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub root_public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshSigchainTrust {
|
||||||
|
pub fn new(
|
||||||
|
chain_id: ChainId,
|
||||||
|
profile: impl Into<String>,
|
||||||
|
namespace: impl Into<String>,
|
||||||
|
root_public_key: impl AsRef<str>,
|
||||||
|
) -> Result<Self, SshSigchainError> {
|
||||||
|
let profile = validate_profile(profile.into())?;
|
||||||
|
let namespace = validate_namespace(namespace.into())?;
|
||||||
|
let root_public_key = canonical_ssh_public_key(root_public_key.as_ref())?;
|
||||||
|
Ok(Self {
|
||||||
|
chain_id,
|
||||||
|
profile,
|
||||||
|
namespace,
|
||||||
|
root_public_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct SshSigchainRecord {
|
||||||
|
pub chain_id: ChainId,
|
||||||
|
pub profile: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub previous: Option<Digest>,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub signer_public_key: String,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshSigchainRecord {
|
||||||
|
pub fn unsigned(
|
||||||
|
chain_id: ChainId,
|
||||||
|
profile: impl Into<String>,
|
||||||
|
sequence: u64,
|
||||||
|
previous: Option<Digest>,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
signer_public_key: impl AsRef<str>,
|
||||||
|
) -> Result<Self, SshSigchainError> {
|
||||||
|
let record = Self {
|
||||||
|
chain_id,
|
||||||
|
profile: validate_profile(profile.into())?,
|
||||||
|
sequence,
|
||||||
|
previous,
|
||||||
|
payload,
|
||||||
|
signer_public_key: canonical_ssh_public_key(signer_public_key.as_ref())?,
|
||||||
|
signature: Vec::new(),
|
||||||
|
};
|
||||||
|
record.validate(false)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_signature(mut self, signature: Vec<u8>) -> Result<Self, SshSigchainError> {
|
||||||
|
self.signature = signature;
|
||||||
|
self.validate(true)?;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact bytes passed to `ssh-keygen -Y sign` / `-Y verify`.
|
||||||
|
pub fn signing_bytes(&self) -> Result<Vec<u8>, SshSigchainError> {
|
||||||
|
self.validate(false)?;
|
||||||
|
let mut bytes = Vec::with_capacity(
|
||||||
|
SIGNING_MAGIC.len()
|
||||||
|
+ 1
|
||||||
|
+ 32
|
||||||
|
+ 8
|
||||||
|
+ 1
|
||||||
|
+ 32
|
||||||
|
+ 2
|
||||||
|
+ self.profile.len()
|
||||||
|
+ 4
|
||||||
|
+ self.payload.len()
|
||||||
|
+ 2
|
||||||
|
+ self.signer_public_key.len(),
|
||||||
|
);
|
||||||
|
bytes.extend_from_slice(SIGNING_MAGIC);
|
||||||
|
bytes.push(SSH_SIGCHAIN_VERSION);
|
||||||
|
bytes.extend_from_slice(&self.chain_id.0);
|
||||||
|
bytes.extend_from_slice(&self.sequence.to_be_bytes());
|
||||||
|
match self.previous {
|
||||||
|
Some(previous) => {
|
||||||
|
bytes.push(1);
|
||||||
|
bytes.extend_from_slice(&previous.0);
|
||||||
|
}
|
||||||
|
None => bytes.push(0),
|
||||||
|
}
|
||||||
|
push_u16_bytes(&mut bytes, self.profile.as_bytes())?;
|
||||||
|
push_u32_bytes(&mut bytes, &self.payload)?;
|
||||||
|
push_u16_bytes(&mut bytes, self.signer_public_key.as_bytes())?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash which the next record must name as its `previous` field.
|
||||||
|
pub fn record_hash(&self) -> Result<Digest, SshSigchainError> {
|
||||||
|
self.validate(true)?;
|
||||||
|
let signed = self.signing_bytes()?;
|
||||||
|
let mut input =
|
||||||
|
Vec::with_capacity(RECORD_HASH_DOMAIN.len() + signed.len() + 4 + self.signature.len());
|
||||||
|
input.extend_from_slice(RECORD_HASH_DOMAIN);
|
||||||
|
input.extend_from_slice(&signed);
|
||||||
|
push_u32_bytes(&mut input, &self.signature)?;
|
||||||
|
Ok(Digest(*blake3::hash(&input).as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self, signature_required: bool) -> Result<(), SshSigchainError> {
|
||||||
|
validate_profile(self.profile.clone())?;
|
||||||
|
if self.payload.len() > MAX_PAYLOAD_BYTES {
|
||||||
|
return Err(SshSigchainError::PayloadTooLarge(self.payload.len()));
|
||||||
|
}
|
||||||
|
if canonical_ssh_public_key(&self.signer_public_key)? != self.signer_public_key {
|
||||||
|
return Err(SshSigchainError::NonCanonicalPublicKey);
|
||||||
|
}
|
||||||
|
if self.signature.len() > MAX_SIGNATURE_BYTES {
|
||||||
|
return Err(SshSigchainError::SignatureTooLarge(self.signature.len()));
|
||||||
|
}
|
||||||
|
if signature_required && self.signature.is_empty() {
|
||||||
|
return Err(SshSigchainError::MissingSignature);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SshSigchainVerifier {
|
||||||
|
fn verify(&self, namespace: &str, message: &[u8], public_key: &str, signature: &[u8]) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application-specific authorization and reducer hooks. The initial state is
|
||||||
|
/// derived from the out-of-band trust object, never from a downloaded record.
|
||||||
|
pub trait SshSigchainPolicy {
|
||||||
|
type State;
|
||||||
|
|
||||||
|
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String>;
|
||||||
|
fn authorize(&self, state: &Self::State, record: &SshSigchainRecord) -> Result<(), String>;
|
||||||
|
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SshSigchainVerification<S> {
|
||||||
|
pub state: S,
|
||||||
|
pub records: usize,
|
||||||
|
pub head: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_sshsigchain<P, V>(
|
||||||
|
records: &[SshSigchainRecord],
|
||||||
|
trust: &SshSigchainTrust,
|
||||||
|
verifier: &V,
|
||||||
|
policy: &P,
|
||||||
|
) -> Result<SshSigchainVerification<P::State>, SshSigchainError>
|
||||||
|
where
|
||||||
|
P: SshSigchainPolicy,
|
||||||
|
V: SshSigchainVerifier + ?Sized,
|
||||||
|
{
|
||||||
|
if records.is_empty() {
|
||||||
|
return Err(SshSigchainError::EmptyChain);
|
||||||
|
}
|
||||||
|
if records.len() > MAX_RECORDS {
|
||||||
|
return Err(SshSigchainError::TooManyRecords(records.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = policy
|
||||||
|
.initial_state(trust)
|
||||||
|
.map_err(SshSigchainError::Policy)?;
|
||||||
|
let mut previous = None;
|
||||||
|
for (index, record) in records.iter().enumerate() {
|
||||||
|
record.validate(true)?;
|
||||||
|
let expected_sequence =
|
||||||
|
u64::try_from(index).map_err(|_| SshSigchainError::SequenceOverflow)?;
|
||||||
|
if record.chain_id != trust.chain_id {
|
||||||
|
return Err(SshSigchainError::WrongChainId { index });
|
||||||
|
}
|
||||||
|
if record.profile != trust.profile {
|
||||||
|
return Err(SshSigchainError::WrongProfile { index });
|
||||||
|
}
|
||||||
|
if record.sequence != expected_sequence {
|
||||||
|
return Err(SshSigchainError::UnexpectedSequence {
|
||||||
|
index,
|
||||||
|
expected: expected_sequence,
|
||||||
|
actual: record.sequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if record.previous != previous {
|
||||||
|
return Err(SshSigchainError::UnexpectedPrevious { index });
|
||||||
|
}
|
||||||
|
if index == 0 && record.signer_public_key != trust.root_public_key {
|
||||||
|
return Err(SshSigchainError::RootSignerMismatch);
|
||||||
|
}
|
||||||
|
let message = record.signing_bytes()?;
|
||||||
|
if !verifier.verify(
|
||||||
|
&trust.namespace,
|
||||||
|
&message,
|
||||||
|
&record.signer_public_key,
|
||||||
|
&record.signature,
|
||||||
|
) {
|
||||||
|
return Err(SshSigchainError::InvalidSignature { index });
|
||||||
|
}
|
||||||
|
policy
|
||||||
|
.authorize(&state, record)
|
||||||
|
.map_err(SshSigchainError::Policy)?;
|
||||||
|
policy
|
||||||
|
.apply(&mut state, record)
|
||||||
|
.map_err(SshSigchainError::Policy)?;
|
||||||
|
previous = Some(record.record_hash()?);
|
||||||
|
}
|
||||||
|
Ok(SshSigchainVerification {
|
||||||
|
state,
|
||||||
|
records: records.len(),
|
||||||
|
head: previous.expect("non-empty chain has a head"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct KeychainV2Policy;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct KeychainV2State {
|
||||||
|
initialized: bool,
|
||||||
|
admin_public_keys: BTreeMap<KeyId, String>,
|
||||||
|
ops: Vec<KeychainOp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct KeychainV2Verification {
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?;
|
||||||
|
if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION {
|
||||||
|
return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
|
||||||
|
}
|
||||||
|
let op = KeychainOp::from(decoded.op);
|
||||||
|
let canonical = keychain_v2_payload(&op)?;
|
||||||
|
if canonical != bytes {
|
||||||
|
return Err(SshSigchainError::NonCanonicalPayload);
|
||||||
|
}
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keychain_v2_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 {
|
||||||
|
return Err(SshSigchainError::WrongKeychainProfile(
|
||||||
|
trust.profile.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SshSigchainRecord::unsigned(
|
||||||
|
trust.chain_id,
|
||||||
|
KEYCHAIN_V2_PROFILE,
|
||||||
|
sequence,
|
||||||
|
previous,
|
||||||
|
keychain_v2_payload(op)?,
|
||||||
|
signer_public_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_keychain_v2_sigchain<V>(
|
||||||
|
records: &[SshSigchainRecord],
|
||||||
|
trust: &SshSigchainTrust,
|
||||||
|
verifier: &V,
|
||||||
|
) -> Result<KeychainV2Verification, SshSigchainError>
|
||||||
|
where
|
||||||
|
V: SshSigchainVerifier + ?Sized,
|
||||||
|
{
|
||||||
|
if trust.profile != KEYCHAIN_V2_PROFILE {
|
||||||
|
return Err(SshSigchainError::WrongKeychainProfile(
|
||||||
|
trust.profile.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?;
|
||||||
|
Ok(KeychainV2Verification {
|
||||||
|
view: reduce_keychain_ops(&verified.state.ops),
|
||||||
|
records: verified.records,
|
||||||
|
head: verified.head,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshSigchainPolicy for KeychainV2Policy {
|
||||||
|
type State = KeychainV2State;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
initialized: false,
|
||||||
|
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
|
||||||
|
ops: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize(&self, state: &Self::State, record: &SshSigchainRecord) -> Result<(), String> {
|
||||||
|
if state
|
||||||
|
.admin_public_keys
|
||||||
|
.values()
|
||||||
|
.any(|key| key == &record.signer_public_key)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("record signer is not an active admin key at this chain position".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())?;
|
||||||
|
match &op.kind {
|
||||||
|
KeychainOpKind::KeychainInit => {
|
||||||
|
if state.initialized || record.sequence != 0 {
|
||||||
|
return Err("KeychainInit is valid only as sequence 0".to_owned());
|
||||||
|
}
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key,
|
||||||
|
public_key,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !state.initialized {
|
||||||
|
return Err("keychain must start with KeychainInit".to_owned());
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let public_key = public_key.as_deref().ok_or_else(|| {
|
||||||
|
"AdminKeyAdd requires its canonical public key in v2".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());
|
||||||
|
}
|
||||||
|
state.admin_public_keys.insert(key.clone(), public_key);
|
||||||
|
}
|
||||||
|
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||||
|
if !state.initialized {
|
||||||
|
return Err("keychain must start with KeychainInit".to_owned());
|
||||||
|
}
|
||||||
|
state.admin_public_keys.remove(key);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !state.initialized {
|
||||||
|
return Err("keychain must start with KeychainInit".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.ops.push(op);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct KeychainV2Payload {
|
||||||
|
version: u16,
|
||||||
|
op: CanonicalKeychainOp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard
|
||||||
|
/// deliberately cannot deserialize that representation, so v2 uses this
|
||||||
|
/// profile-local externally tagged mirror for its signed binary payload.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct CanonicalKeychainOp {
|
||||||
|
id: AuthOpId,
|
||||||
|
created_at: UnixMillis,
|
||||||
|
kind: CanonicalKeychainOpKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
enum CanonicalKeychainOpKind {
|
||||||
|
KeychainInit,
|
||||||
|
AdminKeyAdd {
|
||||||
|
key: KeyId,
|
||||||
|
public_key: Option<String>,
|
||||||
|
principal: Option<String>,
|
||||||
|
valid_after_ms: Option<i64>,
|
||||||
|
valid_before_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
AdminKeyRevoke {
|
||||||
|
key: KeyId,
|
||||||
|
},
|
||||||
|
UserAdd {
|
||||||
|
user: UserId,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
UserRename {
|
||||||
|
user: UserId,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
UserRevoke {
|
||||||
|
user: UserId,
|
||||||
|
},
|
||||||
|
DeviceAdd {
|
||||||
|
device: DeviceId,
|
||||||
|
user: UserId,
|
||||||
|
},
|
||||||
|
DeviceRevoke {
|
||||||
|
device: DeviceId,
|
||||||
|
},
|
||||||
|
DeviceKeyAdd {
|
||||||
|
device: DeviceId,
|
||||||
|
key: KeyId,
|
||||||
|
},
|
||||||
|
DeviceKeyRevoke {
|
||||||
|
device: DeviceId,
|
||||||
|
key: KeyId,
|
||||||
|
},
|
||||||
|
NodeAdd {
|
||||||
|
node: NodeId,
|
||||||
|
device: DeviceId,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
NodeRename {
|
||||||
|
node: NodeId,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
NodeRevoke {
|
||||||
|
node: NodeId,
|
||||||
|
},
|
||||||
|
NodeEndpointAdd {
|
||||||
|
node: NodeId,
|
||||||
|
endpoint: String,
|
||||||
|
},
|
||||||
|
NodeEndpointRevoke {
|
||||||
|
node: NodeId,
|
||||||
|
endpoint: String,
|
||||||
|
},
|
||||||
|
AgentBind {
|
||||||
|
agent: AgentId,
|
||||||
|
node: NodeId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&KeychainOp> for CanonicalKeychainOp {
|
||||||
|
fn from(value: &KeychainOp) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id.clone(),
|
||||||
|
created_at: value.created_at,
|
||||||
|
kind: CanonicalKeychainOpKind::from(&value.kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CanonicalKeychainOp> for KeychainOp {
|
||||||
|
fn from(value: CanonicalKeychainOp) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
created_at: value.created_at,
|
||||||
|
kind: KeychainOpKind::from(value.kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&KeychainOpKind> for CanonicalKeychainOpKind {
|
||||||
|
fn from(value: &KeychainOpKind) -> Self {
|
||||||
|
match value {
|
||||||
|
KeychainOpKind::KeychainInit => Self::KeychainInit,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key,
|
||||||
|
public_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
} => Self::AdminKeyAdd {
|
||||||
|
key: key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
principal: principal.clone(),
|
||||||
|
valid_after_ms: *valid_after_ms,
|
||||||
|
valid_before_ms: *valid_before_ms,
|
||||||
|
},
|
||||||
|
KeychainOpKind::AdminKeyRevoke { key } => Self::AdminKeyRevoke { key: key.clone() },
|
||||||
|
KeychainOpKind::UserAdd { user, name } => Self::UserAdd {
|
||||||
|
user: user.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::UserRename { user, name } => Self::UserRename {
|
||||||
|
user: user.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::UserRevoke { user } => Self::UserRevoke { user: user.clone() },
|
||||||
|
KeychainOpKind::DeviceAdd { device, user } => Self::DeviceAdd {
|
||||||
|
device: device.clone(),
|
||||||
|
user: user.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::DeviceRevoke { device } => Self::DeviceRevoke {
|
||||||
|
device: device.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::DeviceKeyAdd { device, key } => Self::DeviceKeyAdd {
|
||||||
|
device: device.clone(),
|
||||||
|
key: key.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::DeviceKeyRevoke { device, key } => Self::DeviceKeyRevoke {
|
||||||
|
device: device.clone(),
|
||||||
|
key: key.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::NodeAdd { node, device, name } => Self::NodeAdd {
|
||||||
|
node: node.clone(),
|
||||||
|
device: device.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::NodeRename { node, name } => Self::NodeRename {
|
||||||
|
node: node.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::NodeRevoke { node } => Self::NodeRevoke { node: node.clone() },
|
||||||
|
KeychainOpKind::NodeEndpointAdd { node, endpoint } => Self::NodeEndpointAdd {
|
||||||
|
node: node.clone(),
|
||||||
|
endpoint: endpoint.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::NodeEndpointRevoke { node, endpoint } => Self::NodeEndpointRevoke {
|
||||||
|
node: node.clone(),
|
||||||
|
endpoint: endpoint.clone(),
|
||||||
|
},
|
||||||
|
KeychainOpKind::AgentBind { agent, node } => Self::AgentBind {
|
||||||
|
agent: agent.clone(),
|
||||||
|
node: node.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CanonicalKeychainOpKind> for KeychainOpKind {
|
||||||
|
fn from(value: CanonicalKeychainOpKind) -> Self {
|
||||||
|
match value {
|
||||||
|
CanonicalKeychainOpKind::KeychainInit => Self::KeychainInit,
|
||||||
|
CanonicalKeychainOpKind::AdminKeyAdd {
|
||||||
|
key,
|
||||||
|
public_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
} => Self::AdminKeyAdd {
|
||||||
|
key,
|
||||||
|
public_key,
|
||||||
|
principal,
|
||||||
|
valid_after_ms,
|
||||||
|
valid_before_ms,
|
||||||
|
},
|
||||||
|
CanonicalKeychainOpKind::AdminKeyRevoke { key } => Self::AdminKeyRevoke { key },
|
||||||
|
CanonicalKeychainOpKind::UserAdd { user, name } => Self::UserAdd { user, name },
|
||||||
|
CanonicalKeychainOpKind::UserRename { user, name } => Self::UserRename { user, name },
|
||||||
|
CanonicalKeychainOpKind::UserRevoke { user } => Self::UserRevoke { user },
|
||||||
|
CanonicalKeychainOpKind::DeviceAdd { device, user } => Self::DeviceAdd { device, user },
|
||||||
|
CanonicalKeychainOpKind::DeviceRevoke { device } => Self::DeviceRevoke { device },
|
||||||
|
CanonicalKeychainOpKind::DeviceKeyAdd { device, key } => {
|
||||||
|
Self::DeviceKeyAdd { device, key }
|
||||||
|
}
|
||||||
|
CanonicalKeychainOpKind::DeviceKeyRevoke { device, key } => {
|
||||||
|
Self::DeviceKeyRevoke { device, key }
|
||||||
|
}
|
||||||
|
CanonicalKeychainOpKind::NodeAdd { node, device, name } => {
|
||||||
|
Self::NodeAdd { node, device, name }
|
||||||
|
}
|
||||||
|
CanonicalKeychainOpKind::NodeRename { node, name } => Self::NodeRename { node, name },
|
||||||
|
CanonicalKeychainOpKind::NodeRevoke { node } => Self::NodeRevoke { node },
|
||||||
|
CanonicalKeychainOpKind::NodeEndpointAdd { node, endpoint } => {
|
||||||
|
Self::NodeEndpointAdd { node, endpoint }
|
||||||
|
}
|
||||||
|
CanonicalKeychainOpKind::NodeEndpointRevoke { node, endpoint } => {
|
||||||
|
Self::NodeEndpointRevoke { node, endpoint }
|
||||||
|
}
|
||||||
|
CanonicalKeychainOpKind::AgentBind { agent, node } => Self::AgentBind { agent, node },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SshSigchainError {
|
||||||
|
#[error("SSH sigchain chain ID must be 32 bytes encoded as lowercase or uppercase hexadecimal")]
|
||||||
|
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")]
|
||||||
|
InvalidNamespace,
|
||||||
|
#[error("SSH public key must be a canonical two-field OpenSSH public key")]
|
||||||
|
InvalidPublicKey,
|
||||||
|
#[error("SSH public key is not canonical")]
|
||||||
|
NonCanonicalPublicKey,
|
||||||
|
#[error("SSH sigchain payload is {0} bytes, over the {MAX_PAYLOAD_BYTES}-byte limit")]
|
||||||
|
PayloadTooLarge(usize),
|
||||||
|
#[error("SSH sigchain signature is {0} bytes, over the {MAX_SIGNATURE_BYTES}-byte limit")]
|
||||||
|
SignatureTooLarge(usize),
|
||||||
|
#[error("SSH sigchain record has no signature")]
|
||||||
|
MissingSignature,
|
||||||
|
#[error("SSH sigchain cannot be empty")]
|
||||||
|
EmptyChain,
|
||||||
|
#[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
|
||||||
|
TooManyRecords(usize),
|
||||||
|
#[error("SSH sigchain sequence counter overflow")]
|
||||||
|
SequenceOverflow,
|
||||||
|
#[error("record {index} names a different chain ID")]
|
||||||
|
WrongChainId { index: usize },
|
||||||
|
#[error("record {index} names a different profile")]
|
||||||
|
WrongProfile { index: usize },
|
||||||
|
#[error("record {index} has sequence {actual}, expected {expected}")]
|
||||||
|
UnexpectedSequence {
|
||||||
|
index: usize,
|
||||||
|
expected: u64,
|
||||||
|
actual: u64,
|
||||||
|
},
|
||||||
|
#[error("record {index} does not link to the immediately previous record")]
|
||||||
|
UnexpectedPrevious { index: usize },
|
||||||
|
#[error("genesis record signer does not equal the explicit root public key")]
|
||||||
|
RootSignerMismatch,
|
||||||
|
#[error("record {index} has an invalid OpenSSH signature")]
|
||||||
|
InvalidSignature { index: usize },
|
||||||
|
#[error("SSH sigchain policy rejected record: {0}")]
|
||||||
|
Policy(String),
|
||||||
|
#[error("failed to encode keychain v2 payload: {0}")]
|
||||||
|
PayloadEncoding(String),
|
||||||
|
#[error("failed to decode keychain v2 payload: {0}")]
|
||||||
|
PayloadDecoding(String),
|
||||||
|
#[error("unsupported keychain v2 payload version {0}")]
|
||||||
|
UnsupportedPayloadVersion(u16),
|
||||||
|
#[error("keychain v2 payload is not its unique canonical encoding")]
|
||||||
|
NonCanonicalPayload,
|
||||||
|
#[error("expected geth keychain v2 profile, got {0}")]
|
||||||
|
WrongKeychainProfile(String),
|
||||||
|
#[error("length does not fit the SSH sigchain wire format")]
|
||||||
|
LengthOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u16_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<(), SshSigchainError> {
|
||||||
|
let length = u16::try_from(value.len()).map_err(|_| SshSigchainError::LengthOverflow)?;
|
||||||
|
out.extend_from_slice(&length.to_be_bytes());
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u32_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<(), SshSigchainError> {
|
||||||
|
let length = u32::try_from(value.len()).map_err(|_| SshSigchainError::LengthOverflow)?;
|
||||||
|
out.extend_from_slice(&length.to_be_bytes());
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_profile(value: String) -> Result<String, SshSigchainError> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > MAX_PROFILE_BYTES
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
|
||||||
|
{
|
||||||
|
return Err(SshSigchainError::InvalidProfile);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > MAX_PROFILE_BYTES
|
||||||
|
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||||
|
{
|
||||||
|
return Err(SshSigchainError::InvalidNamespace);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_ssh_public_key(value: &str) -> Result<String, SshSigchainError> {
|
||||||
|
if value.len() > MAX_PUBLIC_KEY_BYTES || value.trim() != value {
|
||||||
|
return Err(SshSigchainError::InvalidPublicKey);
|
||||||
|
}
|
||||||
|
let mut fields = value.split_ascii_whitespace();
|
||||||
|
let key_type = fields.next().ok_or(SshSigchainError::InvalidPublicKey)?;
|
||||||
|
let encoded = fields.next().ok_or(SshSigchainError::InvalidPublicKey)?;
|
||||||
|
if fields.next().is_some()
|
||||||
|
|| key_type.is_empty()
|
||||||
|
|| !key_type
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'@')
|
||||||
|
{
|
||||||
|
return Err(SshSigchainError::InvalidPublicKey);
|
||||||
|
}
|
||||||
|
let decoded = general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.or_else(|_| general_purpose::STANDARD_NO_PAD.decode(encoded))
|
||||||
|
.map_err(|_| SshSigchainError::InvalidPublicKey)?;
|
||||||
|
if decoded.is_empty() {
|
||||||
|
return Err(SshSigchainError::InvalidPublicKey);
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{key_type} {}",
|
||||||
|
general_purpose::STANDARD.encode(decoded)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{KeychainOpKind, admin_key_fingerprint};
|
||||||
|
use geth_types::{AuthOpId, UnixMillis};
|
||||||
|
|
||||||
|
const ROOT_KEY: &str = "ssh-ed25519 AQID";
|
||||||
|
const SECOND_KEY: &str = "ssh-ed25519 BAUG";
|
||||||
|
|
||||||
|
struct TestVerifier;
|
||||||
|
|
||||||
|
impl SshSigchainVerifier for TestVerifier {
|
||||||
|
fn verify(
|
||||||
|
&self,
|
||||||
|
namespace: &str,
|
||||||
|
message: &[u8],
|
||||||
|
public_key: &str,
|
||||||
|
signature: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
signature == test_signature(namespace, message, public_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_signature(namespace: &str, message: &[u8], public_key: &str) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(namespace.as_bytes());
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.extend_from_slice(message);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.extend_from_slice(public_key.as_bytes());
|
||||||
|
blake3::hash(&bytes).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trust() -> SshSigchainTrust {
|
||||||
|
SshSigchainTrust::new(
|
||||||
|
ChainId([7; 32]),
|
||||||
|
KEYCHAIN_V2_PROFILE,
|
||||||
|
SSH_SIGCHAIN_NAMESPACE,
|
||||||
|
ROOT_KEY,
|
||||||
|
)
|
||||||
|
.expect("trust")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op(id: &str, created_at: i64, kind: KeychainOpKind) -> KeychainOp {
|
||||||
|
KeychainOp {
|
||||||
|
id: AuthOpId::new(id),
|
||||||
|
created_at: UnixMillis(created_at),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_record(
|
||||||
|
trust: &SshSigchainTrust,
|
||||||
|
sequence: u64,
|
||||||
|
previous: Option<Digest>,
|
||||||
|
op: &KeychainOp,
|
||||||
|
signer: &str,
|
||||||
|
) -> SshSigchainRecord {
|
||||||
|
let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer)
|
||||||
|
.expect("unsigned record");
|
||||||
|
let signature = test_signature(
|
||||||
|
&trust.namespace,
|
||||||
|
&record.signing_bytes().expect("bytes"),
|
||||||
|
signer,
|
||||||
|
);
|
||||||
|
record.with_signature(signature).expect("signed record")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_add_op() -> KeychainOp {
|
||||||
|
op(
|
||||||
|
"op:add-root",
|
||||||
|
2,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: KeyId::new(admin_key_fingerprint(ROOT_KEY)),
|
||||||
|
public_key: Some(ROOT_KEY.to_owned()),
|
||||||
|
principal: Some("admin".to_owned()),
|
||||||
|
valid_after_ms: None,
|
||||||
|
valid_before_ms: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_accepts_a_linked_rooted_keychain() {
|
||||||
|
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 verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier)
|
||||||
|
.expect("valid chain");
|
||||||
|
assert_eq!(verified.records, 3);
|
||||||
|
assert_eq!(
|
||||||
|
verified.view.admin_keys,
|
||||||
|
vec![KeyId::new(admin_key_fingerprint(ROOT_KEY))]
|
||||||
|
);
|
||||||
|
assert!(verified.view.users.contains_key(&"user:alice".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() {
|
||||||
|
let trust = trust();
|
||||||
|
let init = signed_record(
|
||||||
|
&trust,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||||
|
SECOND_KEY,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier),
|
||||||
|
Err(SshSigchainError::RootSignerMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_rejects_a_non_linked_fork() {
|
||||||
|
let trust = trust();
|
||||||
|
let init = signed_record(
|
||||||
|
&trust,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||||
|
ROOT_KEY,
|
||||||
|
);
|
||||||
|
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier),
|
||||||
|
Err(SshSigchainError::UnexpectedPrevious { index: 1 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_revocation_is_causal_not_timestamp_ordered() {
|
||||||
|
let trust = trust();
|
||||||
|
let init = signed_record(
|
||||||
|
&trust,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
&op("op:init", 100, KeychainOpKind::KeychainInit),
|
||||||
|
ROOT_KEY,
|
||||||
|
);
|
||||||
|
let add = signed_record(
|
||||||
|
&trust,
|
||||||
|
1,
|
||||||
|
Some(init.record_hash().expect("hash")),
|
||||||
|
&root_add_op(),
|
||||||
|
ROOT_KEY,
|
||||||
|
);
|
||||||
|
let revoke = signed_record(
|
||||||
|
&trust,
|
||||||
|
2,
|
||||||
|
Some(add.record_hash().expect("hash")),
|
||||||
|
&op(
|
||||||
|
"op:revoke-root",
|
||||||
|
300,
|
||||||
|
KeychainOpKind::AdminKeyRevoke {
|
||||||
|
key: KeyId::new(admin_key_fingerprint(ROOT_KEY)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ROOT_KEY,
|
||||||
|
);
|
||||||
|
// The attacker backdates this payload, but cannot move it before the
|
||||||
|
// revocation because its sequence and parent are immutable.
|
||||||
|
let forged = signed_record(
|
||||||
|
&trust,
|
||||||
|
3,
|
||||||
|
Some(revoke.record_hash().expect("hash")),
|
||||||
|
&op(
|
||||||
|
"op:backdated-user",
|
||||||
|
200,
|
||||||
|
KeychainOpKind::UserAdd {
|
||||||
|
user: "user:mallory".into(),
|
||||||
|
name: "Mallory".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ROOT_KEY,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_keychain_v2_sigchain(&[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");
|
||||||
|
let mut noncanonical = payload;
|
||||||
|
noncanonical.push(0);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_keychain_v2_payload(&noncanonical),
|
||||||
|
Err(SshSigchainError::NonCanonicalPayload)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
docs/adr/0018-sshsigchain-v2.md
Normal file
41
docs/adr/0018-sshsigchain-v2.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# ADR 0018: Linked SSHSIGCHAIN v2
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted for the new generic core and geth keychain profile. Legacy static
|
||||||
|
sigchain publication is deprecated pending explicit v2 command migration.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The prior static JSONL keychain flow replayed records after sorting mutable
|
||||||
|
timestamps and used a downloaded allowed-signers projection to verify its own
|
||||||
|
checkpoint. That permits self-bootstrapping trust and makes revocation,
|
||||||
|
rollback, and fork semantics inadequate for a trust foundation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Geth adopts SSHSIGCHAIN v2 as the replacement 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
|
||||||
|
signed content and SSHSIG signature;
|
||||||
|
- the initial root is only a genesis requirement, not a permanent bypass;
|
||||||
|
- profile authorization runs against the causally preceding state;
|
||||||
|
- key lifecycle is represented by causal add/revoke records, not timestamp
|
||||||
|
validity fields; and
|
||||||
|
- payloads use deterministic binary codecs with a strict round-trip check.
|
||||||
|
|
||||||
|
The generic core is transport-independent. Geth continues using Iroh for all
|
||||||
|
node-to-node communication; SSH remains an identity/signature integration and
|
||||||
|
not a geth transport.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The v2 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.
|
||||||
|
|
@ -474,6 +474,17 @@ over the canonical payload. See `docs/sigchain-keychain.md` for the detailed
|
||||||
sigchain design. This is currently a pull-based signed operation log, not a
|
sigchain design. This is currently a pull-based signed operation log, not a
|
||||||
CRDT or Keyhive-style convergent authority.
|
CRDT or Keyhive-style convergent authority.
|
||||||
|
|
||||||
|
The replacement static-publication design is specified in
|
||||||
|
[`sshsigchain-v2.md`](sshsigchain-v2.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.
|
||||||
|
|
||||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||||
state. `geth node enroll join` explicitly imports an owner admin public key as
|
state. `geth node enroll join` explicitly imports an owner admin public key as
|
||||||
the new node's trust anchor, imports the signed peer card only as untrusted
|
the new node's trust anchor, imports the signed peer card only as untrusted
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,24 @@ resource-scoped capability decisions.
|
||||||
- JSON is not used as the signed representation.
|
- JSON is not used as the signed representation.
|
||||||
- Tests verify equivalent operations hash/sign identically across runs.
|
- Tests verify equivalent operations hash/sign identically across runs.
|
||||||
|
|
||||||
|
- `[~]` Replace the legacy static keychain sigchain with SSHSIGCHAIN v2.
|
||||||
|
Acceptance criteria:
|
||||||
|
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v2 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
|
||||||
|
vectors for the published standard.
|
||||||
|
|
||||||
- `[x]` SSH-admin-rooted keychain initialization.
|
- `[x]` SSH-admin-rooted keychain initialization.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` `geth keychain init` records a local `KeychainInit`.
|
- `[x]` `geth keychain init` records a local `KeychainInit`.
|
||||||
|
|
|
||||||
229
docs/sshsigchain-v2.md
Normal file
229
docs/sshsigchain-v2.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# SSHSIGCHAIN v2
|
||||||
|
|
||||||
|
Status: Draft 0
|
||||||
|
|
||||||
|
This document specifies a deliberately small, generic append-only signature
|
||||||
|
chain for applications that use OpenSSH `sshsig` signatures. 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
|
||||||
|
verification core do not depend on geth data types.
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
SSHSIGCHAIN v2 provides:
|
||||||
|
|
||||||
|
- a fixed, deterministic byte sequence for every signed record;
|
||||||
|
- an explicit, out-of-band root public key rather than trust bootstrapped from
|
||||||
|
downloaded chain content;
|
||||||
|
- one causally ordered, hash-linked history, so a later record cannot be made
|
||||||
|
earlier by changing a timestamp;
|
||||||
|
- application-defined authorization and state transitions evaluated at each
|
||||||
|
chain position;
|
||||||
|
- bounded field and chain sizes suitable for processing untrusted transport
|
||||||
|
input; and
|
||||||
|
- direct use of OpenSSH's `ssh-keygen -Y sign` / `-Y verify` SSHSIG format.
|
||||||
|
|
||||||
|
It does not provide quorum, transparency, equivocation detection, secure clock
|
||||||
|
attestation, encrypted payloads, or a distributed consensus protocol. A valid
|
||||||
|
signature from an authorized key is still authority to make whatever change the
|
||||||
|
application profile permits.
|
||||||
|
|
||||||
|
## 2. Terminology and required inputs
|
||||||
|
|
||||||
|
An implementation has four separately configured trust inputs:
|
||||||
|
|
||||||
|
1. `chain_id`: exactly 32 random bytes, rendered as 64 hexadecimal characters
|
||||||
|
when displayed.
|
||||||
|
2. `profile`: an ASCII identifier naming the application payload rules.
|
||||||
|
3. `namespace`: the OpenSSH SSHSIG namespace passed to `ssh-keygen -Y`.
|
||||||
|
4. `root_public_key`: one canonical OpenSSH public-key line.
|
||||||
|
|
||||||
|
These values are a trust anchor. They must come from local configuration,
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 3. Canonical public-key text
|
||||||
|
|
||||||
|
The public-key field is exactly two ASCII whitespace-separated fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<key-type> SP <base64-encoded-OpenSSH-key-blob>
|
||||||
|
```
|
||||||
|
|
||||||
|
Comments, leading/trailing whitespace, and extra fields are prohibited. The
|
||||||
|
base64 form is the standard canonical encoding of the decoded key blob. An
|
||||||
|
implementation MUST compare canonical text, not an operator-supplied comment,
|
||||||
|
when binding a record signer to policy state. The SSHSIG verifier MUST still
|
||||||
|
verify the actual OpenSSH key and signature; textual validation alone is not a
|
||||||
|
cryptographic verification.
|
||||||
|
|
||||||
|
## 4. Record model
|
||||||
|
|
||||||
|
A record has these logical fields:
|
||||||
|
|
||||||
|
| Field | Type | Rule |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `chain_id` | 32 bytes | Must equal the configured chain ID. |
|
||||||
|
| `profile` | UTF-8 ASCII identifier | Must equal the configured profile. |
|
||||||
|
| `sequence` | unsigned 64-bit integer | Starts at zero and increases by exactly one. |
|
||||||
|
| `previous` | absent or 32 bytes | Absent only at sequence zero; otherwise the preceding record digest. |
|
||||||
|
| `payload` | opaque byte string | Profile-defined canonical payload, at most 1 MiB. |
|
||||||
|
| `signer_public_key` | canonical key text | The key presented to SSHSIG verification. |
|
||||||
|
| `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
|
||||||
|
performing unbounded work. Implementations may set smaller limits.
|
||||||
|
|
||||||
|
Timestamps are intentionally not fields in the generic ordering mechanism.
|
||||||
|
Applications may put timestamps in their payload, but MUST treat them as signed
|
||||||
|
metadata rather than a way to order, revoke, or retroactively authorize
|
||||||
|
records. Sequence and `previous` define causal order.
|
||||||
|
|
||||||
|
## 5. Signing bytes
|
||||||
|
|
||||||
|
The signing byte string is the following binary grammar. `u16` and `u32` are
|
||||||
|
unsigned big-endian lengths. `u64` is an unsigned big-endian integer. `bytes[n]`
|
||||||
|
contains exactly `n` bytes; no implicit terminator or alignment is present.
|
||||||
|
|
||||||
|
```text
|
||||||
|
"SSCS" 4 bytes
|
||||||
|
0x02 1 byte (protocol version)
|
||||||
|
chain_id 32 bytes
|
||||||
|
sequence u64
|
||||||
|
has_previous 1 byte: 0 or 1
|
||||||
|
previous 32 bytes, only when has_previous is 1
|
||||||
|
profile_length u16
|
||||||
|
profile bytes[profile_length]
|
||||||
|
payload_length u32
|
||||||
|
payload bytes[payload_length]
|
||||||
|
signer_key_length u16
|
||||||
|
signer_public_key bytes[signer_key_length]
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## 6. Verification algorithm
|
||||||
|
|
||||||
|
Given the configured trust input and an ordered candidate record list, a
|
||||||
|
conforming verifier MUST:
|
||||||
|
|
||||||
|
1. Reject an empty or over-limit chain.
|
||||||
|
2. For record `i`, require `sequence == i`, the configured chain ID and
|
||||||
|
profile, and the exact predecessor digest (or absent predecessor for `i=0`).
|
||||||
|
3. Require the sequence-zero record's canonical signer key to equal the
|
||||||
|
configured root public key. This is the only bootstrap rule.
|
||||||
|
4. Invoke OpenSSH SSHSIG verification using the configured namespace, the
|
||||||
|
canonical signing bytes, the record's public key, and its signature. Reject
|
||||||
|
on failure.
|
||||||
|
5. Ask the profile whether that signer is authorized by the state resulting
|
||||||
|
from records `0..i-1`. Reject on failure.
|
||||||
|
6. Apply the profile's deterministic transition. Reject on failure.
|
||||||
|
7. Hash the complete signed record and use that hash as the required
|
||||||
|
predecessor for the next record.
|
||||||
|
|
||||||
|
Records are never re-sorted by payload timestamp, identifier, signature time,
|
||||||
|
or transport arrival time. A verifier that cannot obtain its configured trust
|
||||||
|
input MUST fail closed.
|
||||||
|
|
||||||
|
For OpenSSH interoperability, implementations use commands equivalent to:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -Y sign -f <private-key> -n <namespace> <signing-bytes-file>
|
||||||
|
ssh-keygen -Y verify -f <one-key-allowed-signers> -I <principal> \
|
||||||
|
-n <namespace> -s <signature-file> < <signing-bytes-file>
|
||||||
|
```
|
||||||
|
|
||||||
|
The allowed-signers file used for this individual cryptographic check contains
|
||||||
|
only the record's already-canonical signer key. Authorization remains the
|
||||||
|
profile's job; a downloaded allowed-signers projection is never a root of
|
||||||
|
trust.
|
||||||
|
|
||||||
|
## 7. Application profiles
|
||||||
|
|
||||||
|
A profile defines its payload codec and reducer. It MUST document its profile
|
||||||
|
identifier, payload versioning, signer authorization rules, transition rules,
|
||||||
|
and any limits beyond this base specification. A profile MUST reject a payload
|
||||||
|
that decodes successfully but does not round-trip to exactly the same canonical
|
||||||
|
bytes.
|
||||||
|
|
||||||
|
### geth keychain profile
|
||||||
|
|
||||||
|
Geth's identifier is `geth.keychain.v2`. 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`;
|
||||||
|
- every signer to be an active admin public key in the preceding profile state;
|
||||||
|
- 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;
|
||||||
|
and
|
||||||
|
- no `valid_after_ms` or `valid_before_ms` policy fields. Key lifecycle is
|
||||||
|
causally represented by add and revoke records, not untrusted timestamps.
|
||||||
|
|
||||||
|
The configured root key seeds the profile's initial admin authorization state.
|
||||||
|
It can be revoked by a valid record; it is not a permanent bypass after genesis.
|
||||||
|
|
||||||
|
## 8. Publication and rollback
|
||||||
|
|
||||||
|
This base format proves only one supplied history. A publisher that controls a
|
||||||
|
root key can present different valid descendants to different readers. Clients
|
||||||
|
that need rollback or fork detection SHOULD persist the accepted `(chain_id,
|
||||||
|
head digest, sequence)` and only accept a later bundle after proving that it is
|
||||||
|
an extension of the stored head. A signed checkpoint alone is insufficient
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 9. Security considerations
|
||||||
|
|
||||||
|
- SSHSIG namespaces are mandatory domain separation. Reusing a namespace for a
|
||||||
|
different protocol is unsafe.
|
||||||
|
- The root key, chain ID, profile, and namespace form one trust tuple. Changing
|
||||||
|
any member starts a different trust domain and needs an explicit operator
|
||||||
|
action.
|
||||||
|
- A compromised active admin key can still append records until a causally
|
||||||
|
later revocation is accepted. This is inherent to single-key authority, not a
|
||||||
|
claim of compromise recovery.
|
||||||
|
- SSHSIG signatures do not give a trusted signing time. Do not make expiry or
|
||||||
|
ordering decisions from payload timestamps without a separate, authenticated
|
||||||
|
time design.
|
||||||
|
- This protocol does not encrypt payloads and does not make bearer secrets or
|
||||||
|
untrusted discovery data into node identity.
|
||||||
|
- Verifiers should use OpenSSH versions that support `ssh-keygen -Y` and should
|
||||||
|
reject unsupported algorithms according to their local OpenSSH policy.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OpenSSH `PROTOCOL.sshsig`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
|
||||||
|
- [OpenBSD `ssh-keygen(1)` manual](https://man.openbsd.org/ssh-keygen.1)
|
||||||
Loading…
Reference in a new issue