use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
use serde::Serialize;
use crate::manifest::{AttributionManifest, SignatureBlock, Variant};
use crate::TargetMeta;
use crate::{CanonicalUrl, Channel, MediaRef, Sharer, Timestamp, Token};
#[derive(Serialize)]
struct ImmutableCore<'m> {
schema: u16,
token: Token,
target: &'m CanonicalUrl,
sharer: &'m Sharer,
channel: &'m Channel,
minted_at: Timestamp,
meta: &'m TargetMeta,
parent: Option<Token>,
content: &'m Option<MediaRef>,
variants: &'m [Variant],
private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
contract: &'m Option<crate::Contract>,
#[serde(skip_serializing_if = "Option::is_none")]
outline: &'m Option<MediaRef>,
}
#[must_use]
pub fn canonical_core_bytes(m: &AttributionManifest) -> Vec<u8> {
let core = ImmutableCore {
schema: m.schema,
token: m.token,
target: &m.target,
sharer: &m.sharer,
channel: &m.channel,
minted_at: m.minted_at,
meta: &m.meta,
parent: m.parent,
content: &m.content,
variants: &m.variants,
private: m.private,
contract: &m.contract,
outline: &m.outline,
};
serde_json::to_vec(&core).expect("core fields always serialize")
}
fn hex(bytes: &[u8]) -> String {
use core::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[must_use]
pub fn sign_manifest(manifest: &AttributionManifest, key: &SigningKey) -> SignatureBlock {
let sig = key.sign(&canonical_core_bytes(manifest));
SignatureBlock {
alg: "ed25519".to_owned(),
key: hex(key.verifying_key().as_bytes()),
sig: hex(&sig.to_bytes()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureStatus {
Unsigned,
Valid {
key: String,
},
Invalid,
}
#[must_use]
pub fn verify_manifest(manifest: &AttributionManifest) -> SignatureStatus {
let Some(block) = &manifest.signature else {
return SignatureStatus::Unsigned;
};
if block.alg != "ed25519" {
return SignatureStatus::Invalid;
}
let (Some(key_bytes), Some(sig_bytes)) = (unhex(&block.key), unhex(&block.sig)) else {
return SignatureStatus::Invalid;
};
let (Ok(key_arr), Ok(sig_arr)) = (
<[u8; 32]>::try_from(key_bytes.as_slice()),
<[u8; 64]>::try_from(sig_bytes.as_slice()),
) else {
return SignatureStatus::Invalid;
};
let Ok(key) = VerifyingKey::from_bytes(&key_arr) else {
return SignatureStatus::Invalid;
};
let sig = Signature::from_bytes(&sig_arr);
if key.verify(&canonical_core_bytes(manifest), &sig).is_ok() {
SignatureStatus::Valid {
key: block.key.clone(),
}
} else {
SignatureStatus::Invalid
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{MintOptions, MintSpec};
fn minted() -> AttributionManifest {
let mut entropy = |b: &mut [u8]| {
b.fill(42);
Ok(())
};
crate::mint(
MintSpec::new(
CanonicalUrl::new("ws://trust/artifact").unwrap(),
Sharer::new("lead").unwrap(),
Channel::subagent_general(),
),
&MintOptions::default(),
&mut entropy,
Timestamp::from_unix_ms(1),
)
.unwrap()
}
fn key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32]) }
#[test]
fn signature_round_trip_vector() {
let mut m = minted();
let block = sign_manifest(&m, &key());
assert_eq!(block.alg, "ed25519");
assert_eq!(
block.key,
"ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"
);
m.signature = Some(block);
assert_eq!(
verify_manifest(&m),
SignatureStatus::Valid {
key: "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c".into()
}
);
}
#[test]
fn mutations_never_invalidate_the_signature() {
let mut m = minted();
m.signature = Some(sign_manifest(&m, &key()));
crate::apply_change(&mut m, &crate::Change::Revoked, Timestamp::from_unix_ms(9));
crate::apply_change(
&mut m,
&crate::Change::LabelSet {
key: "team".into(),
value: "research".into(),
},
Timestamp::from_unix_ms(10),
);
assert!(
matches!(verify_manifest(&m), SignatureStatus::Valid { .. }),
"the three-zone design: mutations don't touch what was signed"
);
}
#[test]
fn contract_free_canonical_bytes_never_mention_the_field() {
let m = minted();
let bytes = String::from_utf8(canonical_core_bytes(&m)).unwrap();
assert!(
!bytes.contains("contract"),
"absent contract must leave canonical bytes untouched"
);
assert!(
!bytes.contains("outline"),
"absent outline must leave canonical bytes untouched"
);
}
#[test]
fn outline_pointer_is_signed_with_the_core() {
let mut entropy = |b: &mut [u8]| {
b.fill(42);
Ok(())
};
let media = MediaRef {
uri: CanonicalUrl::new("cas://ab").unwrap(),
content_type: "application/waggle-outline+json".into(),
size: 512,
sha256: crate::Sha256Hex::new(&"ab".repeat(32)).unwrap(),
};
let mut m = crate::mint(
MintSpec::new(
CanonicalUrl::new("ws://trust/outlined").unwrap(),
Sharer::new("lead").unwrap(),
Channel::subagent_general(),
)
.outline(media),
&MintOptions::default(),
&mut entropy,
Timestamp::from_unix_ms(1),
)
.unwrap();
m.signature = Some(sign_manifest(&m, &key()));
assert!(matches!(verify_manifest(&m), SignatureStatus::Valid { .. }));
m.outline = None;
assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
}
#[test]
fn contract_bearing_manifests_sign_and_tamper_detect() {
let mut entropy = |b: &mut [u8]| {
b.fill(42);
Ok(())
};
let contract = crate::Contract::new(
vec![crate::Region::new(Some("Pricing".into()), 10, 40, 0).unwrap()],
1000,
)
.unwrap();
let mut m = crate::mint(
MintSpec::new(
CanonicalUrl::new("ws://trust/contracted").unwrap(),
Sharer::new("lead").unwrap(),
Channel::subagent_general(),
)
.contract(contract),
&MintOptions::default(),
&mut entropy,
Timestamp::from_unix_ms(1),
)
.unwrap();
m.signature = Some(sign_manifest(&m, &key()));
assert!(matches!(verify_manifest(&m), SignatureStatus::Valid { .. }));
m.contract = None;
assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
}
#[test]
fn tampered_core_is_invalid_absent_is_unsigned() {
let mut m = minted();
assert_eq!(verify_manifest(&m), SignatureStatus::Unsigned);
m.signature = Some(sign_manifest(&m, &key()));
m.sharer = Sharer::new("impostor").unwrap();
assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
}
}