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}
37
38/// The bytes a signature covers.
39///
40/// # Panics
41/// Never in practice: the immutable core is plain data with no
42/// fallible serialization path; the `expect` documents the invariant.
43#[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/// Sign a manifest's immutable core. The host sets the result on
81/// `manifest.signature` before appending.
82#[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/// What verification concluded — three-valued on purpose: absent is not
93/// invalid, and consumers choose their own policy per trust context.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum SignatureStatus {
96    /// No signature block present.
97    Unsigned,
98    /// Present and correct; carries the signer's public key (hex).
99    Valid {
100        /// The verifying key, hex-encoded.
101        key: String,
102    },
103    /// Present and WRONG — tampered core, wrong key, or malformed block.
104    Invalid,
105}
106
107/// Verify a manifest's signature over its immutable core.
108#[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]) // fixed seed: these are VECTORS
163    }
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        // The vector: fixed seed + fixed entropy + fixed clock ⇒ exact
171        // signature, forever. A change here is a canonical-bytes break.
172        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        // Lifecycle + cosmetic churn — the mutable zones.
190        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        // Tamper with the IMMUTABLE core after signing.
211        m.sharer = Sharer::new("impostor").unwrap();
212        assert_eq!(verify_manifest(&m), SignatureStatus::Invalid);
213    }
214}