Skip to main content

treeship_core/attestation/
sign.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use serde::Serialize;
3
4use crate::attestation::{
5    artifact_id_from_pae, digest_from_pae, pae, ArtifactId, Envelope, Signature, Signer,
6};
7
8/// The result of a successful `sign` call.
9#[derive(Debug)]
10pub struct SignResult {
11    /// The sealed DSSE envelope — ready to store or transmit.
12    pub envelope: Envelope,
13
14    /// The content-addressed artifact ID derived from the PAE bytes.
15    /// Stored alongside the envelope. Recomputed during verification —
16    /// if the content was tampered with, the recomputed ID will differ.
17    pub artifact_id: ArtifactId,
18
19    /// The full SHA-256 digest of the PAE bytes: "sha256:<hex>".
20    pub digest: String,
21}
22
23/// Signs a statement and returns a sealed DSSE envelope.
24///
25/// # Steps
26///
27/// 1. Serialize `statement` to compact JSON bytes.
28/// 2. Construct `PAE(payload_type, json_bytes)`.
29/// 3. Sign the PAE bytes with `signer` — **never** the raw JSON.
30/// 4. base64url-encode the payload and signature.
31/// 5. Derive the content-addressed artifact ID from the PAE bytes.
32///
33/// # Errors
34///
35/// Returns an error if serialization or signing fails.
36///
37/// # Examples
38///
39/// ```
40/// use serde::Serialize;
41/// use treeship_core::attestation::{sign, Ed25519Signer};
42///
43/// #[derive(Serialize)]
44/// struct Action { actor: String, action: String }
45///
46/// let signer = Ed25519Signer::generate("key_test").unwrap();
47/// let stmt   = Action { actor: "agent://test".into(), action: "tool.call".into() };
48/// let result = sign("application/vnd.treeship.action.v1+json", &stmt, &signer).unwrap();
49///
50/// assert!(result.artifact_id.starts_with("art_"));
51/// assert!(result.digest.starts_with("sha256:"));
52/// ```
53pub fn sign<T: Serialize>(
54    payload_type: &str,
55    statement: &T,
56    signer: &dyn Signer,
57) -> Result<SignResult, SignError> {
58    if payload_type.is_empty() {
59        return Err(SignError("payload_type must not be empty".into()));
60    }
61
62    // 1. Serialize to compact JSON — no indentation, deterministic field order
63    //    within a struct (Rust's serde_json serializes fields in declaration order).
64    let payload_bytes = serde_json::to_vec(statement)
65        .map_err(|e| SignError(format!("serialize statement: {}", e)))?;
66
67    // 2. Build the PAE byte string. This is what gets signed.
68    let pae_bytes = pae(payload_type, &payload_bytes);
69
70    // 3. Sign the PAE bytes.
71    let raw_sig = signer
72        .sign(&pae_bytes)
73        .map_err(|e| SignError(format!("sign: {}", e)))?;
74
75    // 4. Build the DSSE envelope.
76    let envelope = Envelope {
77        payload: URL_SAFE_NO_PAD.encode(&payload_bytes),
78        payload_type: payload_type.to_string(),
79        signatures: vec![Signature {
80            keyid: signer.key_id().to_string(),
81            sig: URL_SAFE_NO_PAD.encode(&raw_sig),
82        }],
83    };
84
85    // 5. Derive ID and digest from the PAE bytes — same bytes that were signed.
86    let artifact_id = artifact_id_from_pae(&pae_bytes);
87    let digest = digest_from_pae(&pae_bytes);
88
89    Ok(SignResult {
90        envelope,
91        artifact_id,
92        digest,
93    })
94}
95
96/// Error from the sign operation.
97#[derive(Debug)]
98pub struct SignError(pub String);
99
100impl std::fmt::Display for SignError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "attestation sign: {}", self.0)
103    }
104}
105
106impl std::error::Error for SignError {}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::attestation::{Ed25519Signer, Verifier};
112    use serde::{Deserialize, Serialize};
113
114    #[derive(Debug, PartialEq, Serialize, Deserialize)]
115    struct TestStmt {
116        #[serde(rename = "type")]
117        type_: String,
118        actor: String,
119        action: String,
120    }
121
122    fn make_signer() -> Ed25519Signer {
123        Ed25519Signer::generate("key_test_01").unwrap()
124    }
125
126    const TEST_PT: &str = "application/vnd.treeship.action.v1+json";
127
128    fn make_stmt() -> TestStmt {
129        TestStmt {
130            type_: "treeship/action/v1".into(),
131            actor: "agent://researcher".into(),
132            action: "tool.call".into(),
133        }
134    }
135
136    #[test]
137    fn sign_produces_envelope() {
138        let signer = make_signer();
139        let result = sign(TEST_PT, &make_stmt(), &signer).unwrap();
140        assert!(!result.envelope.payload.is_empty());
141        assert_eq!(result.envelope.payload_type, TEST_PT);
142        assert_eq!(result.envelope.signatures.len(), 1);
143        assert_eq!(result.envelope.signatures[0].keyid, "key_test_01");
144    }
145
146    #[test]
147    fn artifact_id_format() {
148        let signer = make_signer();
149        let r = sign(TEST_PT, &make_stmt(), &signer).unwrap();
150        assert!(
151            r.artifact_id.starts_with("art_"),
152            "must start with art_: {}",
153            r.artifact_id
154        );
155        assert_eq!(r.artifact_id.len(), 36, "art_ + 32 hex: {}", r.artifact_id);
156    }
157
158    #[test]
159    fn digest_format() {
160        let signer = make_signer();
161        let r = sign(TEST_PT, &make_stmt(), &signer).unwrap();
162        assert!(r.digest.starts_with("sha256:"), "must start with sha256:");
163        assert_eq!(r.digest.len(), 71, "sha256: + 64 hex: {}", r.digest);
164    }
165
166    #[test]
167    fn empty_payload_type_errors() {
168        let signer = make_signer();
169        assert!(sign("", &make_stmt(), &signer).is_err());
170    }
171
172    #[test]
173    fn id_matches_verify() {
174        // The ID derived during sign must equal the ID re-derived during verify.
175        let signer = make_signer();
176        let verifier = Verifier::from_signer(&signer);
177        let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
178        let verified = verifier.verify(&signed.envelope).unwrap();
179        assert_eq!(signed.artifact_id, verified.artifact_id);
180    }
181
182    #[test]
183    fn digest_matches_verify() {
184        let signer = make_signer();
185        let verifier = Verifier::from_signer(&signer);
186        let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
187        let verified = verifier.verify(&signed.envelope).unwrap();
188        assert_eq!(signed.digest, verified.digest);
189    }
190
191    #[test]
192    fn payload_roundtrip() {
193        let signer = make_signer();
194        let original = make_stmt();
195        let r = sign(TEST_PT, &original, &signer).unwrap();
196        let decoded: TestStmt = r.envelope.unmarshal_statement().unwrap();
197        assert_eq!(decoded, original);
198    }
199
200    #[test]
201    fn id_deterministic() {
202        // Two calls with identical content must produce the same ID.
203        let signer = make_signer();
204        let r1 = sign(TEST_PT, &make_stmt(), &signer).unwrap();
205        let r2 = sign(TEST_PT, &make_stmt(), &signer).unwrap();
206        assert_eq!(r1.artifact_id, r2.artifact_id);
207    }
208
209    #[test]
210    fn id_differs_by_content() {
211        let signer = make_signer();
212        let s1 = TestStmt {
213            type_: "treeship/action/v1".into(),
214            actor: "a".into(),
215            action: "x".into(),
216        };
217        let s2 = TestStmt {
218            type_: "treeship/action/v1".into(),
219            actor: "b".into(),
220            action: "x".into(),
221        };
222        let r1 = sign(TEST_PT, &s1, &signer).unwrap();
223        let r2 = sign(TEST_PT, &s2, &signer).unwrap();
224        assert_ne!(r1.artifact_id, r2.artifact_id);
225    }
226
227    #[test]
228    fn id_differs_by_payload_type() {
229        let signer = make_signer();
230        let r1 = sign(
231            "application/vnd.treeship.action.v1+json",
232            &make_stmt(),
233            &signer,
234        )
235        .unwrap();
236        let r2 = sign(
237            "application/vnd.treeship.approval.v1+json",
238            &make_stmt(),
239            &signer,
240        )
241        .unwrap();
242        assert_ne!(r1.artifact_id, r2.artifact_id);
243    }
244
245    #[test]
246    fn json_serialization_roundtrip() {
247        let signer = make_signer();
248        let verifier = Verifier::from_signer(&signer);
249        let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
250
251        let json = signed.envelope.to_json().unwrap();
252        let restored = Envelope::from_json(&json).unwrap();
253
254        // The restored envelope must still verify correctly.
255        let result = verifier.verify(&restored).unwrap();
256        assert_eq!(result.artifact_id, signed.artifact_id);
257    }
258}