treeship_core/attestation/
sign.rs1use 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#[derive(Debug)]
10pub struct SignResult {
11 pub envelope: Envelope,
13
14 pub artifact_id: ArtifactId,
18
19 pub digest: String,
21}
22
23pub 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 let payload_bytes = serde_json::to_vec(statement)
65 .map_err(|e| SignError(format!("serialize statement: {}", e)))?;
66
67 let pae_bytes = pae(payload_type, &payload_bytes);
69
70 let raw_sig = signer
72 .sign(&pae_bytes)
73 .map_err(|e| SignError(format!("sign: {}", e)))?;
74
75 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 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#[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 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 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 let result = verifier.verify(&restored).unwrap();
256 assert_eq!(result.artifact_id, signed.artifact_id);
257 }
258}