1mod body;
10mod body_details;
11
12pub use body::{
13 Basis, BasisKind, CI_VERDICT_BODY_SCHEMA_VERSION, CheckClass, CheckDescriptor, CiVerdictBody,
14 StateRef,
15};
16pub use body_details::{
17 Conclusion, Execution, FailureClass, FailureDetail, LogRef, Outcome, Repro,
18};
19use chrono::DateTime;
20use objects::object::{ChangeId, ContentHash};
21use serde::{Deserialize, Serialize};
22
23use crate::{Signer, SignerError, verify_payload_signature};
24
25pub const CI_VERDICT_DOMAIN: &[u8; 21] = b"heddle-ci-verdict-v2\0";
27
28pub const SIGNED_VERDICT_FORMAT_VERSION: u8 = 2;
30
31const FIXED_PAYLOAD_LEN: usize = CI_VERDICT_DOMAIN.len() + 32 + 16 + 32;
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum SignerKind {
37 #[default]
39 ServiceAccount,
40 Device,
42}
43
44impl SignerKind {
45 #[must_use]
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Self::ServiceAccount => "service_account",
50 Self::Device => "device",
51 }
52 }
53
54 #[must_use]
56 pub const fn is_advisory_only(self) -> bool {
57 matches!(self, Self::Device)
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SignedVerdict {
64 pub format_version: u8,
66 pub body: CiVerdictBody,
68 pub content_hash: ContentHash,
70 pub change_id: ChangeId,
72 pub tree_digest: ContentHash,
74 pub signer_kind: SignerKind,
76 pub signed_at: String,
78 pub algorithm: String,
80 pub public_key: String,
82 pub signature: String,
84}
85
86impl SignedVerdict {
87 pub fn verify(&self) -> Result<(), SignedVerdictError> {
92 validate_versions(self.format_version, self.body.schema_version)?;
93 validate_signed_at(&self.signed_at)?;
94
95 let recomputed = self.body.content_hash();
96 if recomputed != self.content_hash {
97 return Err(SignedVerdictError::BodyDigestMismatch {
98 signed: self.content_hash,
99 recomputed,
100 });
101 }
102
103 let public_key =
104 hex::decode(&self.public_key).map_err(SignedVerdictError::InvalidPublicKeyEncoding)?;
105 let signature =
106 hex::decode(&self.signature).map_err(SignedVerdictError::InvalidSignatureEncoding)?;
107 let payload = ci_verdict_signing_payload(
108 &self.content_hash,
109 &self.change_id,
110 &self.tree_digest,
111 self.signer_kind,
112 &self.signed_at,
113 );
114
115 verify_payload_signature(&payload, &self.algorithm, &public_key, &signature)
116 .map_err(SignedVerdictError::from)
117 }
118
119 #[must_use]
121 pub const fn is_advisory_only(&self) -> bool {
122 self.signer_kind.is_advisory_only()
123 }
124}
125
126#[must_use]
132pub fn ci_verdict_signing_payload(
133 content_hash: &ContentHash,
134 change_id: &ChangeId,
135 tree_digest: &ContentHash,
136 signer_kind: SignerKind,
137 signed_at: &str,
138) -> Vec<u8> {
139 let mut payload =
140 Vec::with_capacity(FIXED_PAYLOAD_LEN + signer_kind.as_str().len() + signed_at.len() + 2);
141 payload.extend_from_slice(CI_VERDICT_DOMAIN);
142 payload.extend_from_slice(content_hash.as_bytes());
143 payload.extend_from_slice(change_id.as_bytes());
144 payload.extend_from_slice(tree_digest.as_bytes());
145 payload.extend_from_slice(signer_kind.as_str().as_bytes());
146 payload.push(0);
147 payload.extend_from_slice(signed_at.as_bytes());
148 payload.push(0);
149 payload
150}
151
152pub fn signed_verdict_from_signer(
154 body: CiVerdictBody,
155 change_id: &ChangeId,
156 tree_digest: &ContentHash,
157 signer_kind: SignerKind,
158 signed_at: String,
159 signer: &dyn Signer,
160) -> Result<SignedVerdict, SignedVerdictError> {
161 validate_versions(SIGNED_VERDICT_FORMAT_VERSION, body.schema_version)?;
162 validate_signed_at(&signed_at)?;
163
164 let content_hash = body.content_hash();
165 let payload = ci_verdict_signing_payload(
166 &content_hash,
167 change_id,
168 tree_digest,
169 signer_kind,
170 &signed_at,
171 );
172 let signature = signer.sign(&payload)?;
173
174 Ok(SignedVerdict {
175 format_version: SIGNED_VERDICT_FORMAT_VERSION,
176 body,
177 content_hash,
178 change_id: *change_id,
179 tree_digest: *tree_digest,
180 signer_kind,
181 signed_at,
182 algorithm: signer.algorithm().to_string(),
183 public_key: hex::encode(signer.public_key()),
184 signature: hex::encode(signature),
185 })
186}
187
188fn validate_versions(format_version: u8, schema_version: u32) -> Result<(), SignedVerdictError> {
189 if format_version != SIGNED_VERDICT_FORMAT_VERSION {
190 return Err(SignedVerdictError::UnsupportedFormatVersion {
191 found: format_version,
192 supported: SIGNED_VERDICT_FORMAT_VERSION,
193 });
194 }
195 if schema_version != CI_VERDICT_BODY_SCHEMA_VERSION {
196 return Err(SignedVerdictError::UnsupportedSchemaVersion {
197 found: schema_version,
198 supported: CI_VERDICT_BODY_SCHEMA_VERSION,
199 });
200 }
201 Ok(())
202}
203
204fn validate_signed_at(signed_at: &str) -> Result<(), SignedVerdictError> {
205 DateTime::parse_from_rfc3339(signed_at)
206 .map(|_| ())
207 .map_err(|error| SignedVerdictError::InvalidSignedAt(error.to_string()))
208}
209
210#[derive(Debug, thiserror::Error)]
212pub enum SignedVerdictError {
213 #[error("unsupported signed verdict format version {found}; expected {supported}")]
215 UnsupportedFormatVersion {
216 found: u8,
218 supported: u8,
220 },
221 #[error("unsupported CI verdict body schema version {found}; expected {supported}")]
223 UnsupportedSchemaVersion {
224 found: u32,
226 supported: u32,
228 },
229 #[error("CI verdict body digest mismatch: signed {signed}, recomputed {recomputed}")]
231 BodyDigestMismatch {
232 signed: ContentHash,
234 recomputed: ContentHash,
236 },
237 #[error("CI verdict signed_at is not RFC3339: {0}")]
239 InvalidSignedAt(String),
240 #[error("signed verdict public key is not hexadecimal: {0}")]
242 InvalidPublicKeyEncoding(hex::FromHexError),
243 #[error("signed verdict signature is not hexadecimal: {0}")]
245 InvalidSignatureEncoding(hex::FromHexError),
246 #[error("signed verdict cryptographic error: {0}")]
248 Signer(#[from] SignerError),
249}