treeship_core/attestation/
sign.rs1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use serde::Serialize;
3
4use crate::attestation::{
5 pae,
6 artifact_id_from_pae, digest_from_pae, ArtifactId,
7 Signer,
8 Envelope, Signature,
9};
10
11#[derive(Debug)]
13pub struct SignResult {
14 pub envelope: Envelope,
16
17 pub artifact_id: ArtifactId,
21
22 pub digest: String,
24}
25
26pub fn sign<T: Serialize>(
57 payload_type: &str,
58 statement: &T,
59 signer: &dyn Signer,
60) -> Result<SignResult, SignError> {
61 if payload_type.is_empty() {
62 return Err(SignError("payload_type must not be empty".into()));
63 }
64
65 let payload_bytes = serde_json::to_vec(statement)
68 .map_err(|e| SignError(format!("serialize statement: {}", e)))?;
69
70 let pae_bytes = pae(payload_type, &payload_bytes);
72
73 let raw_sig = signer
75 .sign(&pae_bytes)
76 .map_err(|e| SignError(format!("sign: {}", e)))?;
77
78 let envelope = Envelope {
80 payload: URL_SAFE_NO_PAD.encode(&payload_bytes),
81 payload_type: payload_type.to_string(),
82 signatures: vec![Signature {
83 keyid: signer.key_id().to_string(),
84 sig: URL_SAFE_NO_PAD.encode(&raw_sig),
85 }],
86 };
87
88 let artifact_id = artifact_id_from_pae(&pae_bytes);
90 let digest = digest_from_pae(&pae_bytes);
91
92 Ok(SignResult { envelope, artifact_id, digest })
93}
94
95#[derive(Debug)]
97pub struct SignError(pub String);
98
99impl std::fmt::Display for SignError {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 write!(f, "attestation sign: {}", self.0)
102 }
103}
104
105impl std::error::Error for SignError {}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::attestation::{Ed25519Signer, Verifier};
111 use serde::{Deserialize, Serialize};
112
113 #[derive(Debug, PartialEq, Serialize, Deserialize)]
114 struct TestStmt {
115 #[serde(rename = "type")]
116 type_: String,
117 actor: String,
118 action: String,
119 }
120
121 fn make_signer() -> Ed25519Signer {
122 Ed25519Signer::generate("key_test_01").unwrap()
123 }
124
125 const TEST_PT: &str = "application/vnd.treeship.action.v1+json";
126
127 fn make_stmt() -> TestStmt {
128 TestStmt {
129 type_: "treeship/action/v1".into(),
130 actor: "agent://researcher".into(),
131 action: "tool.call".into(),
132 }
133 }
134
135 #[test]
136 fn sign_produces_envelope() {
137 let signer = make_signer();
138 let result = sign(TEST_PT, &make_stmt(), &signer).unwrap();
139 assert!(!result.envelope.payload.is_empty());
140 assert_eq!(result.envelope.payload_type, TEST_PT);
141 assert_eq!(result.envelope.signatures.len(), 1);
142 assert_eq!(result.envelope.signatures[0].keyid, "key_test_01");
143 }
144
145 #[test]
146 fn artifact_id_format() {
147 let signer = make_signer();
148 let r = sign(TEST_PT, &make_stmt(), &signer).unwrap();
149 assert!(r.artifact_id.starts_with("art_"), "must start with art_: {}", r.artifact_id);
150 assert_eq!(r.artifact_id.len(), 36, "art_ + 32 hex: {}", r.artifact_id);
151 }
152
153 #[test]
154 fn digest_format() {
155 let signer = make_signer();
156 let r = sign(TEST_PT, &make_stmt(), &signer).unwrap();
157 assert!(r.digest.starts_with("sha256:"), "must start with sha256:");
158 assert_eq!(r.digest.len(), 71, "sha256: + 64 hex: {}", r.digest);
159 }
160
161 #[test]
162 fn empty_payload_type_errors() {
163 let signer = make_signer();
164 assert!(sign("", &make_stmt(), &signer).is_err());
165 }
166
167 #[test]
168 fn id_matches_verify() {
169 let signer = make_signer();
171 let verifier = Verifier::from_signer(&signer);
172 let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
173 let verified = verifier.verify(&signed.envelope).unwrap();
174 assert_eq!(signed.artifact_id, verified.artifact_id);
175 }
176
177 #[test]
178 fn digest_matches_verify() {
179 let signer = make_signer();
180 let verifier = Verifier::from_signer(&signer);
181 let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
182 let verified = verifier.verify(&signed.envelope).unwrap();
183 assert_eq!(signed.digest, verified.digest);
184 }
185
186 #[test]
187 fn payload_roundtrip() {
188 let signer = make_signer();
189 let original = make_stmt();
190 let r = sign(TEST_PT, &original, &signer).unwrap();
191 let decoded: TestStmt = r.envelope.unmarshal_statement().unwrap();
192 assert_eq!(decoded, original);
193 }
194
195 #[test]
196 fn id_deterministic() {
197 let signer = make_signer();
199 let r1 = sign(TEST_PT, &make_stmt(), &signer).unwrap();
200 let r2 = sign(TEST_PT, &make_stmt(), &signer).unwrap();
201 assert_eq!(r1.artifact_id, r2.artifact_id);
202 }
203
204 #[test]
205 fn id_differs_by_content() {
206 let signer = make_signer();
207 let s1 = TestStmt { type_: "treeship/action/v1".into(), actor: "a".into(), action: "x".into() };
208 let s2 = TestStmt { type_: "treeship/action/v1".into(), actor: "b".into(), action: "x".into() };
209 let r1 = sign(TEST_PT, &s1, &signer).unwrap();
210 let r2 = sign(TEST_PT, &s2, &signer).unwrap();
211 assert_ne!(r1.artifact_id, r2.artifact_id);
212 }
213
214 #[test]
215 fn id_differs_by_payload_type() {
216 let signer = make_signer();
217 let r1 = sign("application/vnd.treeship.action.v1+json", &make_stmt(), &signer).unwrap();
218 let r2 = sign("application/vnd.treeship.approval.v1+json", &make_stmt(), &signer).unwrap();
219 assert_ne!(r1.artifact_id, r2.artifact_id);
220 }
221
222 #[test]
223 fn json_serialization_roundtrip() {
224 let signer = make_signer();
225 let verifier = Verifier::from_signer(&signer);
226 let signed = sign(TEST_PT, &make_stmt(), &signer).unwrap();
227
228 let json = signed.envelope.to_json().unwrap();
229 let restored = Envelope::from_json(&json).unwrap();
230
231 let result = verifier.verify(&restored).unwrap();
233 assert_eq!(result.artifact_id, signed.artifact_id);
234 }
235}