add linked sshsigchain v2 core

This commit is contained in:
Eric Wendland 2026-07-18 20:22:58 +02:00
commit 8780350d41
8 changed files with 1305 additions and 0 deletions

View file

@ -6,7 +6,9 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
base64.workspace = true
blake3.workspace = true
hex.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -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,
};

View 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)
));
}
}