use std::collections::BTreeMap;
use std::fmt;
use boundgraph_core::{
CanonicalError, CheckerId, Digest, Evidence, EvidenceKind, Signature, SigningKeyId, Verdict,
};
use ed25519_dalek::{Signer as _, Verifier as _};
pub const PUBLIC_KEY_LEN: usize = 32;
pub const SEED_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SignError {
#[error("the evidence could not be encoded for signing: {0}")]
Encode(String),
#[error("the signer {key} is unavailable: {detail}")]
Unavailable {
key: SigningKeyId,
detail: String,
},
}
impl From<CanonicalError> for SignError {
fn from(e: CanonicalError) -> Self {
Self::Encode(e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum VerifyError {
#[error("the signature over evidence from {checker} does not verify under {key}")]
Invalid {
checker: CheckerId,
key: SigningKeyId,
},
#[error("no public key is known for {0}")]
UnknownKey(SigningKeyId),
#[error("the public key for {key} is malformed: {detail}")]
MalformedKey {
key: SigningKeyId,
detail: String,
},
#[error("the evidence could not be encoded for verification: {0}")]
Encode(String),
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PublicKey([u8; PUBLIC_KEY_LEN]);
impl PublicKey {
pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_LEN]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_LEN] {
&self.0
}
pub fn to_hex(&self) -> String {
use fmt::Write as _;
self.0.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn to_dalek(self, id: &SigningKeyId) -> Result<ed25519_dalek::VerifyingKey, VerifyError> {
ed25519_dalek::VerifyingKey::from_bytes(&self.0).map_err(|e| VerifyError::MalformedKey {
key: id.clone(),
detail: e.to_string(),
})
}
}
impl fmt::Debug for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PublicKey({})", self.to_hex())
}
}
pub trait Sign: Send + Sync + fmt::Debug {
fn key_id(&self) -> SigningKeyId;
fn sign(&self, payload: &[u8]) -> Result<Signature, SignError>;
}
pub struct LocalKey {
id: SigningKeyId,
key: ed25519_dalek::SigningKey,
}
impl LocalKey {
pub fn from_seed(id: impl Into<String>, seed: [u8; SEED_LEN]) -> Self {
Self {
id: SigningKeyId(id.into()),
key: ed25519_dalek::SigningKey::from_bytes(&seed),
}
}
pub fn generate(id: impl Into<String>) -> Self {
use rand::RngCore as _;
let mut seed = [0u8; SEED_LEN];
rand::rng().fill_bytes(&mut seed);
Self::from_seed(id, seed)
}
pub fn public_key(&self) -> PublicKey {
PublicKey(self.key.verifying_key().to_bytes())
}
}
impl fmt::Debug for LocalKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LocalKey")
.field("id", &self.id)
.field("public_key", &self.public_key())
.finish()
}
}
impl Sign for LocalKey {
fn key_id(&self) -> SigningKeyId {
self.id.clone()
}
fn sign(&self, payload: &[u8]) -> Result<Signature, SignError> {
Ok(Signature::from_bytes(self.key.sign(payload).to_bytes()))
}
}
pub fn sign_evidence(
verdict: Verdict,
checker: CheckerId,
inputs: impl IntoIterator<Item = Digest>,
kind: EvidenceKind,
signer: &dyn Sign,
) -> Result<Evidence, SignError> {
let mut evidence = Evidence::new(
verdict,
checker,
inputs,
Signature::from_bytes([0u8; 64]),
signer.key_id(),
kind,
);
let payload = evidence.signing_payload()?;
evidence.signature = signer.sign(&payload)?;
Ok(evidence)
}
pub fn verify(evidence: &Evidence, key: &PublicKey) -> Result<(), VerifyError> {
let verifying = key.to_dalek(&evidence.key)?;
let payload = evidence
.signing_payload()
.map_err(|e| VerifyError::Encode(e.to_string()))?;
let signature = ed25519_dalek::Signature::from_bytes(evidence.signature.as_bytes());
verifying
.verify(&payload, &signature)
.map_err(|_| VerifyError::Invalid {
checker: evidence.checker.clone(),
key: evidence.key.clone(),
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeyRing {
keys: BTreeMap<SigningKeyId, PublicKey>,
}
impl KeyRing {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, id: impl Into<String>, key: PublicKey) -> Self {
self.keys.insert(SigningKeyId(id.into()), key);
self
}
#[must_use]
pub fn trusting(self, key: &LocalKey) -> Self {
let id = key.key_id();
self.with(id.0, key.public_key())
}
pub fn get(&self, id: &SigningKeyId) -> Option<&PublicKey> {
self.keys.get(id)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn verify(&self, evidence: &Evidence) -> Result<(), VerifyError> {
let key = self
.get(&evidence.key)
.ok_or_else(|| VerifyError::UnknownKey(evidence.key.clone()))?;
verify(evidence, key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> LocalKey {
LocalKey::from_seed("host-key-1", [7u8; SEED_LEN])
}
fn evidence(signer: &dyn Sign) -> Evidence {
sign_evidence(
Verdict::Pass,
CheckerId::new("json-schema", 1),
[Digest::of(b"payload"), Digest::of(b"schema")],
EvidenceKind::Deterministic,
signer,
)
.expect("signing should succeed")
}
#[test]
fn signed_evidence_verifies_under_the_signing_key() {
let key = key();
assert!(verify(&evidence(&key), &key.public_key()).is_ok());
}
#[test]
fn the_signing_key_identity_is_recorded_in_the_evidence() {
let key = key();
assert_eq!(evidence(&key).key, SigningKeyId("host-key-1".into()));
}
#[test]
fn a_key_ring_resolves_the_recorded_identity() {
let key = key();
let ring = KeyRing::new().trusting(&key);
assert_eq!(ring.len(), 1);
assert!(ring.verify(&evidence(&key)).is_ok());
}
#[test]
fn a_key_the_ring_does_not_hold_is_not_a_tampering_report() {
let key = key();
let err = KeyRing::new().verify(&evidence(&key)).unwrap_err();
assert_eq!(
err,
VerifyError::UnknownKey(SigningKeyId("host-key-1".into()))
);
}
#[test]
fn signing_the_same_evidence_twice_gives_the_same_bytes() {
let key = key();
let first = evidence(&key);
for _ in 0..8 {
assert_eq!(evidence(&key), first);
}
}
#[test]
fn input_order_does_not_change_the_signature() {
let key = key();
let forwards = sign_evidence(
Verdict::Pass,
CheckerId::new("c", 1),
[Digest::of(b"a"), Digest::of(b"b")],
EvidenceKind::Deterministic,
&key,
)
.unwrap();
let backwards = sign_evidence(
Verdict::Pass,
CheckerId::new("c", 1),
[Digest::of(b"b"), Digest::of(b"a")],
EvidenceKind::Deterministic,
&key,
)
.unwrap();
assert_eq!(forwards, backwards);
}
#[test]
fn the_placeholder_signature_never_escapes() {
let key = key();
assert_ne!(evidence(&key).signature, Signature::from_bytes([0u8; 64]));
}
#[test]
fn a_tampered_evidence_value_fails_verification() {
let key = key();
let public = key.public_key();
let good = evidence(&key);
let mut flipped_verdict = good.clone();
flipped_verdict.verdict = Verdict::Fail("actually it failed".into());
let mut flipped_checker = good.clone();
flipped_checker.checker = CheckerId::new("json-schema", 2);
let mut flipped_inputs = good.clone();
flipped_inputs.inputs = vec![Digest::of(b"a different payload")];
let mut flipped_key_id = good.clone();
flipped_key_id.key = SigningKeyId("some-other-key".into());
let mut flipped_kind = good.clone();
flipped_kind.kind = EvidenceKind::Quorum { n: 3, k: 2 };
let mut flipped_signature = good.clone();
flipped_signature.signature = Signature::from_bytes([9u8; 64]);
for (what, tampered) in [
("verdict", flipped_verdict),
("checker", flipped_checker),
("inputs", flipped_inputs),
("key identity", flipped_key_id),
("kind", flipped_kind),
("signature", flipped_signature),
] {
assert!(
verify(&tampered, &public).is_err(),
"altering the {what} must break verification"
);
}
}
#[test]
fn a_pass_cannot_be_moved_onto_another_artefact() {
let key = key();
let mut harvested = evidence(&key);
harvested.inputs = vec![Digest::of(b"a failing patch")];
assert!(verify(&harvested, &key.public_key()).is_err());
}
#[test]
fn evidence_does_not_verify_under_the_wrong_key() {
let key = key();
let other = LocalKey::from_seed("host-key-2", [9u8; SEED_LEN]);
let err = verify(&evidence(&key), &other.public_key()).unwrap_err();
assert!(matches!(err, VerifyError::Invalid { .. }));
}
#[test]
fn an_all_zero_signature_does_not_verify() {
let key = key();
let mut unsigned = evidence(&key);
unsigned.signature = Signature::from_bytes([0u8; 64]);
assert!(verify(&unsigned, &key.public_key()).is_err());
}
#[test]
fn a_malformed_public_key_is_its_own_error() {
let key = key();
let mut bytes = [0u8; PUBLIC_KEY_LEN];
bytes[0] = 2;
let broken = PublicKey::from_bytes(bytes);
let err = verify(&evidence(&key), &broken).unwrap_err();
assert!(matches!(err, VerifyError::MalformedKey { .. }));
}
#[test]
fn a_signing_failure_produces_no_evidence() {
#[derive(Debug)]
struct Broken;
impl Sign for Broken {
fn key_id(&self) -> SigningKeyId {
SigningKeyId("kms-key-1".into())
}
fn sign(&self, _payload: &[u8]) -> Result<Signature, SignError> {
Err(SignError::Unavailable {
key: self.key_id(),
detail: "the KMS did not answer".into(),
})
}
}
let err = sign_evidence(
Verdict::Pass,
CheckerId::new("json-schema", 1),
[Digest::of(b"payload")],
EvidenceKind::Deterministic,
&Broken,
)
.unwrap_err();
assert!(matches!(err, SignError::Unavailable { .. }));
}
#[test]
fn the_same_seed_always_gives_the_same_key() {
let a = LocalKey::from_seed("k", [3u8; SEED_LEN]);
let b = LocalKey::from_seed("k", [3u8; SEED_LEN]);
assert_eq!(a.public_key(), b.public_key());
}
#[test]
fn generated_keys_differ() {
assert_ne!(
LocalKey::generate("k").public_key(),
LocalKey::generate("k").public_key()
);
}
#[test]
fn debug_output_does_not_contain_the_private_seed() {
let seed = [0xab; SEED_LEN];
let rendered = format!("{:?}", LocalKey::from_seed("k", seed));
let seed_hex = PublicKey::from_bytes(seed).to_hex();
assert!(!rendered.contains(&seed_hex), "the seed leaked: {rendered}");
assert!(rendered.contains("host") || rendered.contains('k'));
}
#[test]
fn a_public_key_renders_as_hex() {
let key = PublicKey::from_bytes([0u8; PUBLIC_KEY_LEN]);
assert_eq!(key.to_hex().len(), PUBLIC_KEY_LEN * 2);
assert!(format!("{key:?}").contains(&key.to_hex()));
}
#[test]
fn an_empty_ring_trusts_nothing() {
let ring = KeyRing::new();
assert!(ring.is_empty());
assert!(ring.get(&SigningKeyId("anything".into())).is_none());
}
}