1use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
14use serde::Serialize;
15
16use crate::manifest::{AttributionManifest, SignatureBlock, Variant};
17use crate::TargetMeta;
18use crate::{CanonicalUrl, Channel, MediaRef, Sharer, Timestamp, Token};
19
20#[derive(Serialize)]
24struct ImmutableCore<'m> {
25 schema: u16,
26 token: Token,
27 target: &'m CanonicalUrl,
28 sharer: &'m Sharer,
29 channel: &'m Channel,
30 minted_at: Timestamp,
31 meta: &'m TargetMeta,
32 parent: Option<Token>,
33 content: &'m Option<MediaRef>,
34 variants: &'m [Variant],
35 private: bool,
36}
37
38#[must_use]
44pub fn canonical_core_bytes(m: &AttributionManifest) -> Vec<u8> {
45 let core = ImmutableCore {
46 schema: m.schema,
47 token: m.token,
48 target: &m.target,
49 sharer: &m.sharer,
50 channel: &m.channel,
51 minted_at: m.minted_at,
52 meta: &m.meta,
53 parent: m.parent,
54 content: &m.content,
55 variants: &m.variants,
56 private: m.private,
57 };
58 serde_json::to_vec(&core).expect("core fields always serialize")
59}
60
61fn hex(bytes: &[u8]) -> String {
62 use core::fmt::Write as _;
63 let mut out = String::with_capacity(bytes.len() * 2);
64 for b in bytes {
65 let _ = write!(out, "{b:02x}");
66 }
67 out
68}
69
70fn unhex(s: &str) -> Option<Vec<u8>> {
71 if s.len() % 2 != 0 {
72 return None;
73 }
74 (0..s.len())
75 .step_by(2)
76 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
77 .collect()
78}
79
80#[must_use]
83pub fn sign_manifest(manifest: &AttributionManifest, key: &SigningKey) -> SignatureBlock {
84 let sig = key.sign(&canonical_core_bytes(manifest));
85 SignatureBlock {
86 alg: "ed25519".to_owned(),
87 key: hex(key.verifying_key().as_bytes()),
88 sig: hex(&sig.to_bytes()),
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum SignatureStatus {
96 Unsigned,
98 Valid {
100 key: String,
102 },
103 Invalid,
105}
106
107#[must_use]
109pub fn verify_manifest(manifest: &AttributionManifest) -> SignatureStatus {
110 let Some(block) = &manifest.signature else {
111 return SignatureStatus::Unsigned;
112 };
113 if block.alg != "ed25519" {
114 return SignatureStatus::Invalid;
115 }
116 let (Some(key_bytes), Some(sig_bytes)) = (unhex(&block.key), unhex(&block.sig)) else {
117 return SignatureStatus::Invalid;
118 };
119 let (Ok(key_arr), Ok(sig_arr)) = (
120 <[u8; 32]>::try_from(key_bytes.as_slice()),
121 <[u8; 64]>::try_from(sig_bytes.as_slice()),
122 ) else {
123 return SignatureStatus::Invalid;
124 };
125 let Ok(key) = VerifyingKey::from_bytes(&key_arr) else {
126 return SignatureStatus::Invalid;
127 };
128 let sig = Signature::from_bytes(&sig_arr);
129 if key.verify(&canonical_core_bytes(manifest), &sig).is_ok() {
130 SignatureStatus::Valid {
131 key: block.key.clone(),
132 }
133 } else {
134 SignatureStatus::Invalid
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::{MintOptions, MintSpec};
142
143 fn minted() -> AttributionManifest {
144 let mut entropy = |b: &mut [u8]| {
145 b.fill(42);
146 Ok(())
147 };
148 crate::mint(
149 MintSpec::new(
150 CanonicalUrl::new("ws://trust/artifact").unwrap(),
151 Sharer::new("lead").unwrap(),
152 Channel::subagent_general(),
153 ),
154 &MintOptions::default(),
155 &mut entropy,
156 Timestamp::from_unix_ms(1),
157 )
158 .unwrap()
159 }
160
161 fn key() -> SigningKey {
162 SigningKey::from_bytes(&[7u8; 32]) }
164
165 #[test]
166 fn signature_round_trip_vector() {
167 let mut m = minted();
168 let block = sign_manifest(&m, &key());
169 assert_eq!(block.alg, "ed25519");
170 assert_eq!(
173 block.key,
174 "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"
175 );
176 m.signature = Some(block);
177 assert_eq!(
178 verify_manifest(&m),
179 SignatureStatus::Valid {
180 key: "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c".into()
181 }
182 );
183 }
184
185 #[test]
186 fn mutations_never_invalidate_the_signature() {
187 let mut m = minted();
188 m.signature = Some(sign_manifest(&m, &key()));
189 crate::apply_change(&mut m, &crate::Change::Revoked, Timestamp::from_unix_ms(9));
191 crate::apply_change(
192 &mut m,
193 &crate::Change::LabelSet {
194 key: "team".into(),
195 value: "research".into(),
196 },
197 Timestamp::from_unix_ms(10),
198 );
199 assert!(
200 matches!(verify_manifest(&m), SignatureStatus::Valid { .. }),
201 "the three-zone design: mutations don't touch what was signed"
202 );
203 }
204
205 #[test]
206 fn tampered_core_is_invalid_absent_is_unsigned() {
207 let mut m = minted();
208 assert_eq!(verify_manifest(&m), SignatureStatus::Unsigned);
209 m.signature = Some(sign_manifest(&m, &key()));
210 m.sharer = Sharer::new("impostor").unwrap();
212 assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
213 }
214}