Skip to main content

quorum_crypto_core/
envelope.rs

1//! Signed audit envelope wrapping any serializable payload.
2//!
3//! Supports multi-signature with chaining — each signer attests the payload
4//! AND all previous signatures, preventing signature stripping attacks.
5
6use crate::CryptoError;
7use serde::{Deserialize, Serialize};
8
9/// Status of signature verification on an audit envelope.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SignatureStatus {
13    /// All signatures verified.
14    Verified,
15    /// Signatures present but not yet verified.
16    Unverified,
17    /// No signatures (unsigned payload — dev mode or legacy).
18    Unsigned,
19    /// At least one signature verification failed.
20    Invalid,
21}
22
23/// Role of a signer in the signature chain.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SignerRole {
27    /// Agent that produced the content.
28    Author,
29    /// Agent that scored/reviewed the content.
30    Evaluator,
31    /// Orchestrator attestation (job metadata, round results).
32    Orchestrator,
33    /// Human operator approval (HITL buffer release).
34    Operator,
35    /// Third-party co-signer / witness.
36    Witness,
37}
38
39/// A single signature in the envelope's signature chain.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EnvelopeSignature {
42    /// Algorithm used (e.g., "ed25519", "secp256k1", "ml-dsa-65").
43    pub algorithm: String,
44    /// Signer's public key (hex-encoded).
45    pub public_key: String,
46    /// Signature bytes (base64-encoded).
47    pub signature: String,
48    /// Role of this signer.
49    pub role: SignerRole,
50    /// Signer identity (agent_id, operator principal, etc.).
51    pub signer_id: String,
52}
53
54/// A signed wrapper around any serializable payload.
55///
56/// The envelope carries the payload plus a chain of signatures. Each signature
57/// in the chain signs the canonical bytes of the payload AND all previous
58/// signatures, creating a tamper-evident chain:
59///
60/// ```text
61/// sig[0] = sign(canonical(payload))
62/// sig[1] = sign(canonical(payload) + sig[0].signature_bytes)
63/// sig[2] = sign(canonical(payload) + sig[0].signature_bytes + sig[1].signature_bytes)
64/// ```
65///
66/// Removing or reordering any signature invalidates all subsequent signatures.
67///
68/// # Security note
69///
70/// Fields are `pub` for serialization compatibility. **Mutating any signed field
71/// (`payload`, `subject`, `timestamp`, `agent_id`, `signatures`) invalidates
72/// `status` without detection.** Always call `verify_chain()` after
73/// deserialization or if the envelope may have been modified. For defense in
74/// depth, prefer reading via accessor methods and treat `status` as advisory
75/// until re-verified.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AuditEnvelope<T: Serialize> {
78    payload: T,
79    subject: String,
80    timestamp: u64,
81    agent_id: String,
82    #[serde(default)]
83    signatures: Vec<EnvelopeSignature>,
84    status: SignatureStatus,
85
86    // Legacy single-signature fields (backward compat)
87    #[serde(default, skip_serializing_if = "String::is_empty")]
88    algorithm: String,
89    #[serde(default, skip_serializing_if = "String::is_empty")]
90    public_key: String,
91    #[serde(default, skip_serializing_if = "String::is_empty")]
92    signature: String,
93}
94
95impl<T: Serialize> AuditEnvelope<T> {
96    /// Create an unsigned envelope (dev mode / legacy).
97    pub fn unsigned(payload: T, subject: &str, agent_id: &str) -> Self {
98        Self {
99            payload,
100            subject: subject.to_string(),
101            timestamp: now_secs(),
102            agent_id: agent_id.to_string(),
103            signatures: Vec::new(),
104            status: SignatureStatus::Unsigned,
105            algorithm: String::new(),
106            public_key: String::new(),
107            signature: String::new(),
108        }
109    }
110
111    /// Create a signed envelope with a single author signature.
112    pub async fn signed(
113        payload: T,
114        subject: &str,
115        agent_id: &str,
116        signer: &dyn crate::AuditSigner,
117    ) -> Result<Self, CryptoError> {
118        let timestamp = now_secs();
119        let payload_json =
120            serde_json::to_vec(&payload).map_err(|e| CryptoError::Serialization(e.to_string()))?;
121        let canonical = crate::canonical_bytes(subject, &payload_json, timestamp, agent_id);
122
123        let sig_bytes = signer.sign(&canonical).await?;
124        let sig_b64 = b64_encode(&sig_bytes);
125
126        let envelope_sig = EnvelopeSignature {
127            algorithm: signer.algorithm().to_string(),
128            public_key: signer.public_key_display(),
129            signature: sig_b64.clone(),
130            role: SignerRole::Author,
131            signer_id: agent_id.to_string(),
132        };
133
134        Ok(Self {
135            payload,
136            subject: subject.to_string(),
137            timestamp,
138            agent_id: agent_id.to_string(),
139            signatures: vec![envelope_sig],
140            status: SignatureStatus::Unverified,
141            // Legacy compat
142            algorithm: signer.algorithm().to_string(),
143            public_key: signer.public_key_display(),
144            signature: sig_b64,
145        })
146    }
147
148    /// Add a co-signature to the chain. The new signer signs the payload
149    /// canonical bytes + all existing signatures, creating an ordered chain.
150    pub async fn co_sign(
151        &mut self,
152        signer: &dyn crate::AuditSigner,
153        role: SignerRole,
154        signer_id: &str,
155    ) -> Result<(), CryptoError> {
156        let payload_json = serde_json::to_vec(&self.payload)
157            .map_err(|e| CryptoError::Serialization(e.to_string()))?;
158
159        let chained = canonical_bytes_chained(
160            &self.subject,
161            &payload_json,
162            self.timestamp,
163            &self.agent_id,
164            &self.signatures,
165        );
166
167        let sig_bytes = signer.sign(&chained).await?;
168
169        self.signatures.push(EnvelopeSignature {
170            algorithm: signer.algorithm().to_string(),
171            public_key: signer.public_key_display(),
172            signature: b64_encode(&sig_bytes),
173            role,
174            signer_id: signer_id.to_string(),
175        });
176
177        // Reset status — needs re-verification
178        self.status = SignatureStatus::Unverified;
179        Ok(())
180    }
181
182    /// Verify the entire signature chain using a verifier registry.
183    ///
184    /// Each signature is verified against the payload canonical bytes + all
185    /// prior signatures. If any signature fails, the chain is invalid.
186    pub fn verify_chain(
187        &mut self,
188        registry: &crate::VerifierRegistry,
189    ) -> Result<bool, CryptoError> {
190        // Migrate legacy single-signature to chain if needed
191        if self.signatures.is_empty() && !self.signature.is_empty() {
192            self.signatures.push(EnvelopeSignature {
193                algorithm: self.algorithm.clone(),
194                public_key: self.public_key.clone(),
195                signature: self.signature.clone(),
196                role: SignerRole::Author,
197                signer_id: self.agent_id.clone(),
198            });
199        }
200
201        if self.signatures.is_empty() {
202            self.status = SignatureStatus::Unsigned;
203            return Ok(true);
204        }
205
206        let payload_json = serde_json::to_vec(&self.payload)
207            .map_err(|e| CryptoError::Serialization(e.to_string()))?;
208
209        for (i, sig) in self.signatures.iter().enumerate() {
210            // Canonical bytes for signature i include all prior signatures
211            let canonical = canonical_bytes_chained(
212                &self.subject,
213                &payload_json,
214                self.timestamp,
215                &self.agent_id,
216                &self.signatures[..i], // prior signatures only
217            );
218
219            let sig_bytes = b64_decode(&sig.signature).map_err(|e| {
220                CryptoError::VerificationFailed(format!(
221                    "Invalid base64 in signature {i} ({}): {e}",
222                    sig.signer_id
223                ))
224            })?;
225
226            let pubkey_bytes =
227                hex::decode(sig.public_key.trim_start_matches("0x")).map_err(|e| {
228                    CryptoError::InvalidKey(format!("Invalid hex public key in signature {i}: {e}"))
229                })?;
230
231            if !registry.verify(&sig.algorithm, &canonical, &sig_bytes, &pubkey_bytes)? {
232                self.status = SignatureStatus::Invalid;
233                return Ok(false);
234            }
235        }
236
237        self.status = SignatureStatus::Verified;
238        Ok(true)
239    }
240
241    /// Backward-compatible verify (legacy single-signature envelopes).
242    /// Delegates to `verify_chain`.
243    pub fn verify(&mut self, registry: &crate::VerifierRegistry) -> Result<bool, CryptoError> {
244        self.verify_chain(registry)
245    }
246
247    /// Number of signatures in the chain.
248    pub fn signature_count(&self) -> usize {
249        self.signatures.len()
250    }
251
252    /// Check if the chain contains a signature with the given role.
253    pub fn has_role(&self, role: &SignerRole) -> bool {
254        self.signatures.iter().any(|s| &s.role == role)
255    }
256
257    /// Read-only access to the payload.
258    pub fn payload(&self) -> &T {
259        &self.payload
260    }
261
262    /// Read-only access to the subject.
263    pub fn subject(&self) -> &str {
264        &self.subject
265    }
266
267    /// Read-only access to the agent_id.
268    pub fn agent_id(&self) -> &str {
269        &self.agent_id
270    }
271
272    /// Read-only access to the timestamp.
273    pub fn timestamp(&self) -> u64 {
274        self.timestamp
275    }
276
277    /// Current verification status. **Advisory only** — always call
278    /// `verify_chain()` after deserialization or if the envelope may have been
279    /// modified externally.
280    pub fn status(&self) -> &SignatureStatus {
281        &self.status
282    }
283
284    /// Read-only access to the signature chain.
285    pub fn signatures(&self) -> &[EnvelopeSignature] {
286        &self.signatures
287    }
288
289    /// Explicitly invalidate the cached verification status.
290    /// Call this after any mutation to signed fields.
291    pub fn invalidate(&mut self) {
292        if self.signatures.is_empty() {
293            self.status = SignatureStatus::Unsigned;
294        } else {
295            self.status = SignatureStatus::Unverified;
296        }
297    }
298
299    /// Mutable access to signatures — **invalidates status**.
300    /// Use only for testing or deserialization fixup.
301    #[doc(hidden)]
302    pub fn signatures_mut(&mut self) -> &mut Vec<EnvelopeSignature> {
303        self.status = SignatureStatus::Unverified;
304        &mut self.signatures
305    }
306
307    /// Mutable access to payload — **invalidates status**.
308    /// Use only for testing tamper detection.
309    #[doc(hidden)]
310    pub fn payload_mut(&mut self) -> &mut T {
311        self.status = SignatureStatus::Unverified;
312        &mut self.payload
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Chained canonical bytes
318// ---------------------------------------------------------------------------
319
320/// Build canonical bytes for a chained signature.
321///
322/// The canonical form includes the base payload fields (same as `canonical_bytes`)
323/// plus the **full metadata** of all prior signatures in the chain — algorithm,
324/// public key, signature bytes, role, and signer_id. This prevents tampering
325/// with any field of an intermediate signature without invalidating downstream
326/// signatures.
327fn canonical_bytes_chained(
328    subject: &str,
329    payload_json: &[u8],
330    timestamp: u64,
331    agent_id: &str,
332    prior_signatures: &[EnvelopeSignature],
333) -> Vec<u8> {
334    // Start with the standard canonical bytes
335    let mut buf = crate::canonical_bytes(subject, payload_json, timestamp, agent_id);
336
337    // Append each prior signature's full metadata (not just the signature string)
338    for sig in prior_signatures {
339        buf.push(0x00); // separator
340        buf.extend_from_slice(sig.algorithm.as_bytes());
341        buf.push(0x00);
342        buf.extend_from_slice(sig.public_key.as_bytes());
343        buf.push(0x00);
344        buf.extend_from_slice(sig.signature.as_bytes());
345        buf.push(0x00);
346        // Serialize role as its debug string for deterministic encoding
347        buf.extend_from_slice(format!("{:?}", sig.role).as_bytes());
348        buf.push(0x00);
349        buf.extend_from_slice(sig.signer_id.as_bytes());
350    }
351
352    buf
353}
354
355// ---------------------------------------------------------------------------
356// Helpers
357// ---------------------------------------------------------------------------
358
359fn now_secs() -> u64 {
360    std::time::SystemTime::now()
361        .duration_since(std::time::UNIX_EPOCH)
362        .unwrap_or_default()
363        .as_secs()
364}
365
366fn b64_encode(data: &[u8]) -> String {
367    use base64::Engine;
368    base64::engine::general_purpose::STANDARD.encode(data)
369}
370
371fn b64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
372    use base64::Engine;
373    base64::engine::general_purpose::STANDARD.decode(s)
374}
375
376// ---------------------------------------------------------------------------
377// Tests
378// ---------------------------------------------------------------------------
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::signer::{Ed25519Signer, Secp256k1Signer};
384    use crate::verifier::VerifierRegistry;
385
386    #[tokio::test]
387    async fn envelope_ed25519_sign_verify() {
388        let signer = Ed25519Signer::generate();
389        let mut envelope = AuditEnvelope::signed(
390            serde_json::json!({"content": "hello"}),
391            "proposal",
392            "agent-1",
393            &signer,
394        )
395        .await
396        .unwrap();
397
398        assert_eq!((*envelope.status()), SignatureStatus::Unverified);
399        assert_eq!(envelope.signature_count(), 1);
400
401        let registry = VerifierRegistry::with_defaults();
402        assert!(envelope.verify(&registry).unwrap());
403        assert_eq!((*envelope.status()), SignatureStatus::Verified);
404    }
405
406    #[tokio::test]
407    async fn envelope_secp256k1_sign_verify() {
408        let signer = Secp256k1Signer::generate();
409        let mut envelope =
410            AuditEnvelope::signed("evaluation result", "evaluation", "agent-2", &signer)
411                .await
412                .unwrap();
413
414        let registry = VerifierRegistry::with_defaults();
415        assert!(envelope.verify(&registry).unwrap());
416    }
417
418    #[tokio::test]
419    async fn envelope_tampered_payload_fails() {
420        let signer = Ed25519Signer::generate();
421        let mut envelope = AuditEnvelope::signed(
422            serde_json::json!({"score": 8.5}),
423            "evaluation",
424            "agent-1",
425            &signer,
426        )
427        .await
428        .unwrap();
429
430        // Tamper with payload
431        *envelope.payload_mut() = serde_json::json!({"score": 10.0});
432
433        let registry = VerifierRegistry::with_defaults();
434        assert!(!envelope.verify(&registry).unwrap());
435        assert_eq!((*envelope.status()), SignatureStatus::Invalid);
436    }
437
438    #[test]
439    fn unsigned_envelope_verifies_trivially() {
440        let mut envelope = AuditEnvelope::<String>::unsigned(
441            "unsigned content".to_string(),
442            "proposal",
443            "agent-dev",
444        );
445
446        let registry = VerifierRegistry::with_defaults();
447        assert!(envelope.verify(&registry).unwrap());
448        assert_eq!((*envelope.status()), SignatureStatus::Unsigned);
449    }
450
451    // --- Multi-signature chain tests ---
452
453    #[tokio::test]
454    async fn multi_sig_chain_verify() {
455        let author = Ed25519Signer::generate();
456        let evaluator = Secp256k1Signer::generate();
457        let orchestrator = Ed25519Signer::generate();
458
459        let mut envelope = AuditEnvelope::signed(
460            serde_json::json!({"content": "proposal text"}),
461            "proposal",
462            "agent-author",
463            &author,
464        )
465        .await
466        .unwrap();
467
468        // Evaluator co-signs
469        envelope
470            .co_sign(&evaluator, SignerRole::Evaluator, "agent-evaluator")
471            .await
472            .unwrap();
473
474        // Orchestrator co-signs
475        envelope
476            .co_sign(&orchestrator, SignerRole::Orchestrator, "orchestrator-1")
477            .await
478            .unwrap();
479
480        assert_eq!(envelope.signature_count(), 3);
481        assert!(envelope.has_role(&SignerRole::Author));
482        assert!(envelope.has_role(&SignerRole::Evaluator));
483        assert!(envelope.has_role(&SignerRole::Orchestrator));
484
485        let registry = VerifierRegistry::with_defaults();
486        assert!(envelope.verify_chain(&registry).unwrap());
487        assert_eq!((*envelope.status()), SignatureStatus::Verified);
488    }
489
490    #[tokio::test]
491    async fn chain_detects_removed_signature() {
492        let author = Ed25519Signer::generate();
493        let evaluator = Ed25519Signer::generate();
494        let orchestrator = Ed25519Signer::generate();
495
496        let mut envelope = AuditEnvelope::signed(
497            serde_json::json!({"content": "test"}),
498            "proposal",
499            "agent-1",
500            &author,
501        )
502        .await
503        .unwrap();
504
505        envelope
506            .co_sign(&evaluator, SignerRole::Evaluator, "agent-2")
507            .await
508            .unwrap();
509        envelope
510            .co_sign(&orchestrator, SignerRole::Orchestrator, "orch-1")
511            .await
512            .unwrap();
513
514        // Remove the evaluator's signature (middle of chain)
515        envelope.signatures_mut().remove(1);
516
517        // Orchestrator's signature should now fail (it committed to the evaluator's sig)
518        let registry = VerifierRegistry::with_defaults();
519        assert!(!envelope.verify_chain(&registry).unwrap());
520        assert_eq!((*envelope.status()), SignatureStatus::Invalid);
521    }
522
523    #[tokio::test]
524    async fn chain_detects_reordered_signatures() {
525        let author = Ed25519Signer::generate();
526        let evaluator = Ed25519Signer::generate();
527
528        let mut envelope = AuditEnvelope::signed(
529            serde_json::json!({"content": "test"}),
530            "proposal",
531            "agent-1",
532            &author,
533        )
534        .await
535        .unwrap();
536
537        envelope
538            .co_sign(&evaluator, SignerRole::Evaluator, "agent-2")
539            .await
540            .unwrap();
541
542        // Swap signature order
543        envelope.signatures_mut().swap(0, 1);
544
545        let registry = VerifierRegistry::with_defaults();
546        // First signature (was evaluator, now verifying as first with no priors) should fail
547        assert!(!envelope.verify_chain(&registry).unwrap());
548    }
549
550    #[tokio::test]
551    async fn chain_detects_tampered_payload_with_multi_sig() {
552        let author = Ed25519Signer::generate();
553        let evaluator = Ed25519Signer::generate();
554
555        let mut envelope = AuditEnvelope::signed(
556            serde_json::json!({"score": 8.0}),
557            "evaluation",
558            "agent-1",
559            &author,
560        )
561        .await
562        .unwrap();
563
564        envelope
565            .co_sign(&evaluator, SignerRole::Evaluator, "agent-2")
566            .await
567            .unwrap();
568
569        // Tamper with payload — both signatures should fail
570        *envelope.payload_mut() = serde_json::json!({"score": 10.0});
571
572        let registry = VerifierRegistry::with_defaults();
573        assert!(!envelope.verify_chain(&registry).unwrap());
574    }
575
576    #[tokio::test]
577    async fn legacy_single_sig_migrates_to_chain() {
578        let signer = Ed25519Signer::generate();
579        let mut envelope = AuditEnvelope::signed(
580            serde_json::json!({"content": "legacy"}),
581            "proposal",
582            "agent-1",
583            &signer,
584        )
585        .await
586        .unwrap();
587
588        // Simulate legacy: clear signatures array, keep single-sig fields
589        let legacy_sig = envelope.signatures()[0].signature.clone();
590        envelope.signatures_mut().clear();
591        // Legacy fields are already populated by signed()
592
593        assert_eq!(envelope.signature_count(), 0);
594        assert!(!envelope.signature.is_empty());
595
596        // verify_chain should auto-migrate
597        let registry = VerifierRegistry::with_defaults();
598        assert!(envelope.verify_chain(&registry).unwrap());
599        assert_eq!(envelope.signature_count(), 1); // migrated
600        assert_eq!(envelope.signatures()[0].signature, legacy_sig);
601    }
602
603    #[tokio::test]
604    async fn has_role_queries() {
605        let signer = Ed25519Signer::generate();
606        let mut envelope = AuditEnvelope::signed(serde_json::json!({}), "test", "agent-1", &signer)
607            .await
608            .unwrap();
609
610        assert!(envelope.has_role(&SignerRole::Author));
611        assert!(!envelope.has_role(&SignerRole::Evaluator));
612        assert!(!envelope.has_role(&SignerRole::Operator));
613
614        let eval_signer = Ed25519Signer::generate();
615        envelope
616            .co_sign(&eval_signer, SignerRole::Operator, "human-1")
617            .await
618            .unwrap();
619
620        assert!(envelope.has_role(&SignerRole::Operator));
621    }
622
623    #[tokio::test]
624    async fn chain_detects_tampered_role() {
625        let author = Ed25519Signer::generate();
626        let evaluator = Ed25519Signer::generate();
627        let orchestrator = Ed25519Signer::generate();
628
629        let mut envelope = AuditEnvelope::signed(
630            serde_json::json!({"content": "test"}),
631            "proposal",
632            "agent-1",
633            &author,
634        )
635        .await
636        .unwrap();
637
638        envelope
639            .co_sign(&evaluator, SignerRole::Evaluator, "agent-2")
640            .await
641            .unwrap();
642        envelope
643            .co_sign(&orchestrator, SignerRole::Orchestrator, "orch-1")
644            .await
645            .unwrap();
646
647        // Tamper: change evaluator's role to Operator
648        envelope.signatures_mut()[1].role = SignerRole::Operator;
649
650        // Orchestrator's signature should fail (it committed to Evaluator role)
651        let registry = VerifierRegistry::with_defaults();
652        assert!(!envelope.verify_chain(&registry).unwrap());
653    }
654
655    #[tokio::test]
656    async fn chain_detects_tampered_signer_id() {
657        let author = Ed25519Signer::generate();
658        let evaluator = Ed25519Signer::generate();
659        let orchestrator = Ed25519Signer::generate();
660
661        let mut envelope = AuditEnvelope::signed(
662            serde_json::json!({"content": "test"}),
663            "proposal",
664            "agent-1",
665            &author,
666        )
667        .await
668        .unwrap();
669
670        envelope
671            .co_sign(&evaluator, SignerRole::Evaluator, "agent-2")
672            .await
673            .unwrap();
674        envelope
675            .co_sign(&orchestrator, SignerRole::Orchestrator, "orch-1")
676            .await
677            .unwrap();
678
679        // Tamper: change evaluator's signer_id
680        envelope.signatures_mut()[1].signer_id = "impersonator".to_string();
681
682        // Orchestrator's signature should fail (it committed to "agent-2")
683        let registry = VerifierRegistry::with_defaults();
684        assert!(!envelope.verify_chain(&registry).unwrap());
685    }
686}