Skip to main content

waggle_core/
trust.rs

1//! Trust (design doc `14 CP-11`): Ed25519 signatures over the manifest's
2//! **immutable core** — the three-zone design's payoff: lifecycle and
3//! cosmetic mutations never invalidate a signature, because they never
4//! touch what was signed.
5//!
6//! Sans-I/O as always: keys are parameters (hosts load them; the CLI
7//! keeps a seed at `~/.waggle/identity`), and verification is a pure
8//! function of the manifest. Canonical bytes are the serde encoding of
9//! the immutable-core fields in declaration order — deterministic
10//! because every map in the core is a `BTreeMap` and field order is
11//! fixed by the struct.
12
13use 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/// The immutable core, borrowed — exactly the zone a signature covers.
21/// Adding a mutable field here would break the "mutations never
22/// invalidate" property; the compiler makes that a conscious act.
23#[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    /// Skipped when absent so every pre-contract manifest keeps its
37    /// exact canonical bytes — existing signatures stay valid (19 §4.2).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    contract: &'m Option<crate::Contract>,
40    /// Same absence rule as `contract` (20 §3).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    outline: &'m Option<MediaRef>,
43}
44
45/// The bytes a signature covers.
46///
47/// # Panics
48/// Never in practice: the immutable core is plain data with no
49/// fallible serialization path; the `expect` documents the invariant.
50#[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/// Sign a manifest's immutable core. The host sets the result on
90/// `manifest.signature` before appending.
91#[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/// What verification concluded — three-valued on purpose: absent is not
102/// invalid, and consumers choose their own policy per trust context.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SignatureStatus {
105    /// No signature block present.
106    Unsigned,
107    /// Present and correct; carries the signer's public key (hex).
108    Valid {
109        /// The verifying key, hex-encoded.
110        key: String,
111    },
112    /// Present and WRONG — tampered core, wrong key, or malformed block.
113    Invalid,
114}
115
116/// Verify a manifest's signature over its immutable core.
117#[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]) // fixed seed: these are VECTORS
172    }
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        // The vector: fixed seed + fixed entropy + fixed clock ⇒ exact
180        // signature, forever. A change here is a canonical-bytes break.
181        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        // Lifecycle + cosmetic churn — the mutable zones.
199        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        // The 19 §4.2 / 20 §3 compatibility rule, checked at the byte
217        // level: a manifest without a contract or outline serializes
218        // exactly as it did before the fields existed (the pinned vector
219        // in `signature_round_trip_vector` proves the same end to end).
220        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        // The pointer is signed: swapping the outline invalidates.
259        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        // The contract is signed: tampering with it invalidates.
289        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        // Tamper with the IMMUTABLE core after signing.
299        m.sharer = Sharer::new("impostor").unwrap();
300        assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
301    }
302}