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 #[serde(skip_serializing_if = "Option::is_none")]
39 contract: &'m Option<crate::Contract>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 outline: &'m Option<MediaRef>,
43}
44
45#[must_use]
51pub fn canonical_core_bytes(m: &AttributionManifest) -> Vec<u8> {
52 let core = ImmutableCore {
53 schema: m.schema,
54 token: m.token,
55 target: &m.target,
56 sharer: &m.sharer,
57 channel: &m.channel,
58 minted_at: m.minted_at,
59 meta: &m.meta,
60 parent: m.parent,
61 content: &m.content,
62 variants: &m.variants,
63 private: m.private,
64 contract: &m.contract,
65 outline: &m.outline,
66 };
67 serde_json::to_vec(&core).expect("core fields always serialize")
68}
69
70fn hex(bytes: &[u8]) -> String {
71 use core::fmt::Write as _;
72 let mut out = String::with_capacity(bytes.len() * 2);
73 for b in bytes {
74 let _ = write!(out, "{b:02x}");
75 }
76 out
77}
78
79fn unhex(s: &str) -> Option<Vec<u8>> {
80 if s.len() % 2 != 0 {
81 return None;
82 }
83 (0..s.len())
84 .step_by(2)
85 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
86 .collect()
87}
88
89#[must_use]
92pub fn sign_manifest(manifest: &AttributionManifest, key: &SigningKey) -> SignatureBlock {
93 let sig = key.sign(&canonical_core_bytes(manifest));
94 SignatureBlock {
95 alg: "ed25519".to_owned(),
96 key: hex(key.verifying_key().as_bytes()),
97 sig: hex(&sig.to_bytes()),
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SignatureStatus {
105 Unsigned,
107 Valid {
109 key: String,
111 },
112 Invalid,
114}
115
116#[must_use]
118pub fn verify_manifest(manifest: &AttributionManifest) -> SignatureStatus {
119 let Some(block) = &manifest.signature else {
120 return SignatureStatus::Unsigned;
121 };
122 if block.alg != "ed25519" {
123 return SignatureStatus::Invalid;
124 }
125 let (Some(key_bytes), Some(sig_bytes)) = (unhex(&block.key), unhex(&block.sig)) else {
126 return SignatureStatus::Invalid;
127 };
128 let (Ok(key_arr), Ok(sig_arr)) = (
129 <[u8; 32]>::try_from(key_bytes.as_slice()),
130 <[u8; 64]>::try_from(sig_bytes.as_slice()),
131 ) else {
132 return SignatureStatus::Invalid;
133 };
134 let Ok(key) = VerifyingKey::from_bytes(&key_arr) else {
135 return SignatureStatus::Invalid;
136 };
137 let sig = Signature::from_bytes(&sig_arr);
138 if key.verify(&canonical_core_bytes(manifest), &sig).is_ok() {
139 SignatureStatus::Valid {
140 key: block.key.clone(),
141 }
142 } else {
143 SignatureStatus::Invalid
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::{MintOptions, MintSpec};
151
152 fn minted() -> AttributionManifest {
153 let mut entropy = |b: &mut [u8]| {
154 b.fill(42);
155 Ok(())
156 };
157 crate::mint(
158 MintSpec::new(
159 CanonicalUrl::new("ws://trust/artifact").unwrap(),
160 Sharer::new("lead").unwrap(),
161 Channel::subagent_general(),
162 ),
163 &MintOptions::default(),
164 &mut entropy,
165 Timestamp::from_unix_ms(1),
166 )
167 .unwrap()
168 }
169
170 fn key() -> SigningKey {
171 SigningKey::from_bytes(&[7u8; 32]) }
173
174 #[test]
175 fn signature_round_trip_vector() {
176 let mut m = minted();
177 let block = sign_manifest(&m, &key());
178 assert_eq!(block.alg, "ed25519");
179 assert_eq!(
182 block.key,
183 "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"
184 );
185 m.signature = Some(block);
186 assert_eq!(
187 verify_manifest(&m),
188 SignatureStatus::Valid {
189 key: "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c".into()
190 }
191 );
192 }
193
194 #[test]
195 fn mutations_never_invalidate_the_signature() {
196 let mut m = minted();
197 m.signature = Some(sign_manifest(&m, &key()));
198 crate::apply_change(&mut m, &crate::Change::Revoked, Timestamp::from_unix_ms(9));
200 crate::apply_change(
201 &mut m,
202 &crate::Change::LabelSet {
203 key: "team".into(),
204 value: "research".into(),
205 },
206 Timestamp::from_unix_ms(10),
207 );
208 assert!(
209 matches!(verify_manifest(&m), SignatureStatus::Valid { .. }),
210 "the three-zone design: mutations don't touch what was signed"
211 );
212 }
213
214 #[test]
215 fn contract_free_canonical_bytes_never_mention_the_field() {
216 let m = minted();
221 let bytes = String::from_utf8(canonical_core_bytes(&m)).unwrap();
222 assert!(
223 !bytes.contains("contract"),
224 "absent contract must leave canonical bytes untouched"
225 );
226 assert!(
227 !bytes.contains("outline"),
228 "absent outline must leave canonical bytes untouched"
229 );
230 }
231
232 #[test]
233 fn outline_pointer_is_signed_with_the_core() {
234 let mut entropy = |b: &mut [u8]| {
235 b.fill(42);
236 Ok(())
237 };
238 let media = MediaRef {
239 uri: CanonicalUrl::new("cas://ab").unwrap(),
240 content_type: "application/waggle-outline+json".into(),
241 size: 512,
242 sha256: crate::Sha256Hex::new(&"ab".repeat(32)).unwrap(),
243 };
244 let mut m = crate::mint(
245 MintSpec::new(
246 CanonicalUrl::new("ws://trust/outlined").unwrap(),
247 Sharer::new("lead").unwrap(),
248 Channel::subagent_general(),
249 )
250 .outline(media),
251 &MintOptions::default(),
252 &mut entropy,
253 Timestamp::from_unix_ms(1),
254 )
255 .unwrap();
256 m.signature = Some(sign_manifest(&m, &key()));
257 assert!(matches!(verify_manifest(&m), SignatureStatus::Valid { .. }));
258 m.outline = None;
260 assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
261 }
262
263 #[test]
264 fn contract_bearing_manifests_sign_and_tamper_detect() {
265 let mut entropy = |b: &mut [u8]| {
266 b.fill(42);
267 Ok(())
268 };
269 let contract = crate::Contract::new(
270 vec![crate::Region::new(Some("Pricing".into()), 10, 40, 0).unwrap()],
271 1000,
272 )
273 .unwrap();
274 let mut m = crate::mint(
275 MintSpec::new(
276 CanonicalUrl::new("ws://trust/contracted").unwrap(),
277 Sharer::new("lead").unwrap(),
278 Channel::subagent_general(),
279 )
280 .contract(contract),
281 &MintOptions::default(),
282 &mut entropy,
283 Timestamp::from_unix_ms(1),
284 )
285 .unwrap();
286 m.signature = Some(sign_manifest(&m, &key()));
287 assert!(matches!(verify_manifest(&m), SignatureStatus::Valid { .. }));
288 m.contract = None;
290 assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
291 }
292
293 #[test]
294 fn tampered_core_is_invalid_absent_is_unsigned() {
295 let mut m = minted();
296 assert_eq!(verify_manifest(&m), SignatureStatus::Unsigned);
297 m.signature = Some(sign_manifest(&m, &key()));
298 m.sharer = Sharer::new("impostor").unwrap();
300 assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
301 }
302}