treeship-core 0.10.4

Portable trust receipts for agent workflows - core library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
use std::collections::HashMap;
use ed25519_dalek::{VerifyingKey, Verifier as DalekVerifier, Signature as DalekSignature};

use crate::attestation::{
    pae,
    artifact_id_from_pae, digest_from_pae, ArtifactId,
    Ed25519Signer, Signer,
    Envelope,
};

/// The result of a successful verification.
#[derive(Debug)]
pub struct VerifyResult {
    /// Content-addressed ID **re-derived** from the envelope during verification.
    /// If the envelope payload or payloadType was tampered with since signing,
    /// this will differ from any stored artifact ID — a reliable tamper signal.
    pub artifact_id: ArtifactId,

    /// Full SHA-256 digest of the PAE bytes: "sha256:<hex>".
    pub digest: String,

    /// Key IDs whose signatures were successfully verified.
    pub verified_key_ids: Vec<String>,

    /// The payloadType from the envelope.
    pub payload_type: String,
}

/// Error from verification.
#[derive(Debug)]
pub enum VerifyError {
    /// The payload could not be base64-decoded.
    PayloadDecode(String),
    /// A key ID in the envelope has no corresponding trusted public key.
    UnknownKey(String),
    /// A signature was cryptographically invalid.
    InvalidSignature(String),
    /// No valid signature was found from any trusted key (VerifyAny only).
    NoValidSignature,
    /// The signature bytes were malformed (wrong length etc.).
    MalformedSignature(String),
}

impl std::fmt::Display for VerifyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::PayloadDecode(e)      => write!(f, "payload decode: {}", e),
            Self::UnknownKey(id)        => write!(f, "unknown key: {}", id),
            Self::InvalidSignature(id)  => write!(f, "invalid signature for key: {}", id),
            Self::NoValidSignature      => write!(f, "no valid signature from any trusted key"),
            Self::MalformedSignature(e) => write!(f, "malformed signature bytes: {}", e),
        }
    }
}

impl std::error::Error for VerifyError {}

/// Holds trusted public keys and verifies DSSE envelopes against them.
///
/// Separate from `Signer` — signing requires a private key, verification
/// requires only public keys. Verifiers are cheap to clone and pass around.
#[derive(Clone)]
pub struct Verifier {
    /// Map of key_id → VerifyingKey (Ed25519 public key).
    keys: HashMap<String, VerifyingKey>,
}

impl Verifier {
    /// Creates a Verifier with the given trusted key map.
    pub fn new(keys: HashMap<String, VerifyingKey>) -> Self {
        Self { keys }
    }

    /// Convenience: creates a single-key Verifier from an `Ed25519Signer`.
    /// Most useful in tests and local-only workflows.
    pub fn from_signer(signer: &Ed25519Signer) -> Self {
        let mut keys = HashMap::new();
        keys.insert(signer.key_id().to_string(), signer.verifying_key());
        Self { keys }
    }

    /// Adds a trusted public key.
    pub fn add_key(&mut self, key_id: impl Into<String>, pub_key: VerifyingKey) {
        self.keys.insert(key_id.into(), pub_key);
    }

    /// Verifies all signatures in the envelope.
    ///
    /// Returns `Ok(VerifyResult)` only if **every** signature in the envelope
    /// is valid and its key is trusted. Any unknown key or invalid signature
    /// returns `Err`.
    ///
    /// Use this for strict verification where all listed signers must be valid
    /// (e.g., hybrid Ed25519 + ML-DSA in v2 where both are required).
    pub fn verify(&self, envelope: &Envelope) -> Result<VerifyResult, VerifyError> {
        // An envelope with zero signatures has nothing to verify. The for-loop
        // below would be a no-op and `verified` would stay empty, returning
        // `Ok` to any caller that only checks `Result::is_ok()`. Reject up
        // front so an unsigned envelope cannot masquerade as verified.
        if envelope.signatures.is_empty() {
            return Err(VerifyError::NoValidSignature);
        }

        let pae_bytes = self.reconstruct_pae(envelope)?;
        let mut verified = Vec::new();

        for sig in &envelope.signatures {
            let pub_key = self.keys.get(&sig.keyid)
                .ok_or_else(|| VerifyError::UnknownKey(sig.keyid.clone()))?;

            let raw_sig = self.decode_sig(sig)?;
            self.verify_sig(pub_key, &pae_bytes, &raw_sig, &sig.keyid)?;
            verified.push(sig.keyid.clone());
        }

        Ok(self.build_result(pae_bytes, verified, &envelope.payload_type))
    }

    /// Verifies that at least one signature in the envelope is valid from a
    /// trusted key. Signatures from unknown keys are skipped.
    ///
    /// Use this during key rotation when old and new keys may coexist, or
    /// when accepting envelopes from multiple possible signers.
    pub fn verify_any(&self, envelope: &Envelope) -> Result<VerifyResult, VerifyError> {
        let pae_bytes = self.reconstruct_pae(envelope)?;
        let mut verified = Vec::new();

        for sig in &envelope.signatures {
            let pub_key = match self.keys.get(&sig.keyid) {
                Some(k) => k,
                None    => continue, // skip unknown keys
            };
            let raw_sig = match self.decode_sig(sig) {
                Ok(b)  => b,
                Err(_) => continue, // skip malformed sigs
            };
            if self.verify_sig(pub_key, &pae_bytes, &raw_sig, &sig.keyid).is_ok() {
                verified.push(sig.keyid.clone());
            }
        }

        if verified.is_empty() {
            return Err(VerifyError::NoValidSignature);
        }

        Ok(self.build_result(pae_bytes, verified, &envelope.payload_type))
    }

    // --- private helpers ---

    fn reconstruct_pae(&self, envelope: &Envelope) -> Result<Vec<u8>, VerifyError> {
        let payload_bytes = base64::Engine::decode(
            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
            &envelope.payload,
        ).map_err(|e| VerifyError::PayloadDecode(e.to_string()))?;

        Ok(pae(&envelope.payload_type, &payload_bytes))
    }

    fn decode_sig(&self, sig: &crate::attestation::Signature) -> Result<Vec<u8>, VerifyError> {
        base64::Engine::decode(
            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
            &sig.sig,
        ).map_err(|e| VerifyError::MalformedSignature(e.to_string()))
    }

    fn verify_sig(
        &self,
        pub_key:  &VerifyingKey,
        pae:      &[u8],
        raw_sig:  &[u8],
        key_id:   &str,
    ) -> Result<(), VerifyError> {
        let sig_bytes: [u8; 64] = raw_sig.try_into()
            .map_err(|_| VerifyError::MalformedSignature(
                format!("signature for {} is {} bytes, expected 64", key_id, raw_sig.len())
            ))?;

        let dalek_sig = DalekSignature::from_bytes(&sig_bytes);

        pub_key.verify(pae, &dalek_sig)
            .map_err(|_| VerifyError::InvalidSignature(key_id.to_string()))
    }

    fn build_result(
        &self,
        pae_bytes:    Vec<u8>,
        verified:     Vec<String>,
        payload_type: &str,
    ) -> VerifyResult {
        VerifyResult {
            artifact_id:     artifact_id_from_pae(&pae_bytes),
            digest:          digest_from_pae(&pae_bytes),
            verified_key_ids: verified,
            payload_type:    payload_type.to_string(),
        }
    }
}

/// Convenience: verify an envelope with a single known public key.
pub fn verify_with_key(
    envelope: &Envelope,
    key_id:   &str,
    pub_key:  VerifyingKey,
) -> Result<VerifyResult, VerifyError> {
    let mut keys = HashMap::new();
    keys.insert(key_id.to_string(), pub_key);
    let v = Verifier::new(keys);
    v.verify_any(envelope)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attestation::{sign, Ed25519Signer};
    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
    use serde::{Deserialize, Serialize};

    #[derive(Debug, Serialize, Deserialize)]
    struct TestStmt { actor: String, action: String }

    const PT: &str = "application/vnd.treeship.action.v1+json";

    fn stmt() -> TestStmt {
        TestStmt { actor: "agent://researcher".into(), action: "tool.call".into() }
    }

    fn make_signer() -> Ed25519Signer {
        Ed25519Signer::generate("key_test_01").unwrap()
    }

    // --- round-trip ---

    #[test]
    fn verify_roundtrip() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();
        let result   = verifier.verify(&signed.envelope).unwrap();

        assert_eq!(result.artifact_id, signed.artifact_id);
        assert_eq!(result.digest, signed.digest);
        assert_eq!(result.verified_key_ids, vec!["key_test_01"]);
        assert_eq!(result.payload_type, PT);
    }

    #[test]
    fn verify_any_roundtrip() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();
        verifier.verify_any(&signed.envelope).unwrap();
    }

    // --- tamper detection ---

    #[test]
    fn tampered_payload_fails() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();

        // Replace the payload with different content. The signature was
        // computed over PAE(original_payload) — after tampering the PAE
        // is different and the signature fails.
        let malicious = TestStmt { actor: "agent://attacker".into(), action: "steal".into() };
        let malicious_bytes = serde_json::to_vec(&malicious).unwrap();

        let mut tampered = signed.envelope.clone();
        tampered.payload = URL_SAFE_NO_PAD.encode(malicious_bytes);

        let err = verifier.verify(&tampered).unwrap_err();
        assert!(
            matches!(err, VerifyError::InvalidSignature(_)),
            "Expected InvalidSignature, got: {}", err
        );
    }

    #[test]
    fn tampered_payload_type_fails() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign("application/vnd.treeship.action.v1+json", &stmt(), &signer).unwrap();

        // Change the payloadType without re-signing.
        // PAE includes payloadType, so the reconstructed PAE ≠ signed PAE.
        let mut tampered = signed.envelope.clone();
        tampered.payload_type = "application/vnd.treeship.approval.v1+json".into();

        assert!(
            verifier.verify(&tampered).is_err(),
            "verify must fail when payloadType is tampered"
        );
    }

    // --- key rejection ---

    #[test]
    fn wrong_key_fails() {
        let signer      = make_signer();
        // Build a verifier with a different keypair but the same key_id.
        // Simulates an attacker substituting their public key.
        let wrong       = Ed25519Signer::generate("key_test_01").unwrap();
        let verifier    = Verifier::from_signer(&wrong);

        let signed = sign(PT, &stmt(), &signer).unwrap();
        assert!(
            verifier.verify(&signed.envelope).is_err(),
            "verify with wrong public key must fail"
        );
    }

    #[test]
    fn unknown_key_fails() {
        let signer   = make_signer();
        let verifier = Verifier::new(HashMap::new()); // no keys

        let signed = sign(PT, &stmt(), &signer).unwrap();
        assert!(
            verifier.verify(&signed.envelope).is_err(),
            "verify with no trusted keys must fail"
        );
    }

    #[test]
    fn verify_any_skips_unknown_keys() {
        let signer   = make_signer();
        // Verifier only knows about key_test_01
        let verifier = Verifier::from_signer(&signer);

        // Envelope only has key_test_01 — verifier should accept it
        let signed = sign(PT, &stmt(), &signer).unwrap();
        let result = verifier.verify_any(&signed.envelope).unwrap();
        assert_eq!(result.verified_key_ids.len(), 1);
    }

    #[test]
    fn verify_rejects_empty_signature_envelope() {
        // P0 #4: an envelope with zero signatures must not verify. Without
        // the explicit check, the for-loop is a no-op and `verify` returns
        // `Ok(...)` with an empty `verified_key_ids` list — callers that
        // only check `Result::is_ok()` would accept unsigned envelopes.
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();

        // Strip the signatures off an otherwise-valid envelope.
        let mut unsigned = signed.envelope.clone();
        unsigned.signatures.clear();

        let err = verifier.verify(&unsigned).unwrap_err();
        assert!(
            matches!(err, VerifyError::NoValidSignature),
            "expected NoValidSignature for zero-signature envelope, got: {err}"
        );

        // verify_any already rejects this via its `verified.is_empty()` guard,
        // but assert it explicitly to keep both paths covered.
        assert!(matches!(
            verifier.verify_any(&unsigned).unwrap_err(),
            VerifyError::NoValidSignature
        ));
    }

    #[test]
    fn verify_any_all_unknown_fails() {
        let signer   = make_signer();
        let verifier = Verifier::new(HashMap::new());
        let signed   = sign(PT, &stmt(), &signer).unwrap();
        assert!(matches!(
            verifier.verify_any(&signed.envelope).unwrap_err(),
            VerifyError::NoValidSignature
        ));
    }

    // --- ID consistency ---

    #[test]
    fn artifact_id_matches_sign() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();
        let verified = verifier.verify(&signed.envelope).unwrap();

        // The ID is derived from the same PAE bytes during both sign and verify.
        // A mismatch here means the envelope was tampered with between sign and verify.
        assert_eq!(
            signed.artifact_id, verified.artifact_id,
            "ID from sign and verify must match"
        );
    }

    // --- multi-key verifier ---

    #[test]
    fn multi_key_verifier() {
        let s1 = Ed25519Signer::generate("key_1").unwrap();
        let s2 = Ed25519Signer::generate("key_2").unwrap();

        let mut verifier = Verifier::from_signer(&s1);
        verifier.add_key("key_2", s2.verifying_key());

        // Sign with s1 — verifier knows both keys, should accept
        let signed = sign(PT, &stmt(), &s1).unwrap();
        let result = verifier.verify(&signed.envelope).unwrap();
        assert_eq!(result.verified_key_ids, vec!["key_1"]);

        // Sign with s2 — should also work
        let signed2 = sign(PT, &stmt(), &s2).unwrap();
        let result2 = verifier.verify(&signed2.envelope).unwrap();
        assert_eq!(result2.verified_key_ids, vec!["key_2"]);
    }

    // --- serialization ---

    #[test]
    fn json_marshal_unmarshal() {
        let signer   = make_signer();
        let verifier = Verifier::from_signer(&signer);
        let signed   = sign(PT, &stmt(), &signer).unwrap();

        let json     = signed.envelope.to_json().unwrap();
        let restored = Envelope::from_json(&json).unwrap();

        let result = verifier.verify(&restored).unwrap();
        assert_eq!(result.artifact_id, signed.artifact_id);
    }
}