add linked sshsigchain v2 core
This commit is contained in:
parent
cb8c4e6fd4
commit
8780350d41
8 changed files with 1305 additions and 0 deletions
|
|
@ -15,6 +15,17 @@
|
|||
//! create their own profile with `KeychainProfile::for_application` or
|
||||
//! `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::{
|
||||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue