Skip to main content

crypto/
ci_verdict.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Signed, provenance-bound CI verdicts.
3//!
4//! A verdict signature binds the canonical [`CiVerdictBody`] content hash, the
5//! rewrite-stable change id, the exact evaluated tree, the signer kind, and the
6//! signing time. Trust in the embedded key and timestamp freshness are policy
7//! decisions for the caller; this module proves integrity and authenticity.
8
9mod 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
25/// NUL-terminated domain separator for the v2 CI-verdict signing scheme.
26pub const CI_VERDICT_DOMAIN: &[u8; 21] = b"heddle-ci-verdict-v2\0";
27
28/// Current serialized [`SignedVerdict`] format version.
29pub const SIGNED_VERDICT_FORMAT_VERSION: u8 = 2;
30
31const FIXED_PAYLOAD_LEN: usize = CI_VERDICT_DOMAIN.len() + 32 + 16 + 32;
32
33/// What kind of principal signed a verdict.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum SignerKind {
37    /// A trusted automation principal. Trust-set membership remains caller policy.
38    #[default]
39    ServiceAccount,
40    /// A human device key. Device verdicts are always advisory-only.
41    Device,
42}
43
44impl SignerKind {
45    /// Stable token used in both JSON and the signature preimage.
46    #[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    /// Whether this signer kind is forbidden from satisfying a required gate.
55    #[must_use]
56    pub const fn is_advisory_only(self) -> bool {
57        matches!(self, Self::Device)
58    }
59}
60
61/// A rich CI verdict body plus its provenance-bound signature.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SignedVerdict {
64    /// Serialized envelope format. Only v2 is accepted.
65    pub format_version: u8,
66    /// Complete conclusion-bearing content covered by [`Self::content_hash`].
67    pub body: CiVerdictBody,
68    /// BLAKE3 hash of the body's canonical JSON bytes.
69    pub content_hash: ContentHash,
70    /// Rewrite-stable change identifier for the checked state.
71    pub change_id: ChangeId,
72    /// Root tree digest for the exact tree that was checked.
73    pub tree_digest: ContentHash,
74    /// What kind of principal signed the verdict.
75    pub signer_kind: SignerKind,
76    /// RFC3339 timestamp, signed to prevent freshness-presentation rewrites.
77    pub signed_at: String,
78    /// Signature algorithm understood by Heddle's shared signing spine.
79    pub algorithm: String,
80    /// Hex-encoded public key bytes.
81    pub public_key: String,
82    /// Hex-encoded signature bytes.
83    pub signature: String,
84}
85
86impl SignedVerdict {
87    /// Verify the body digest and signature over every provenance binding.
88    ///
89    /// Trust-set membership, freshness windows, and required-gate eligibility
90    /// remain caller policy. In particular, [`SignerKind::Device`] is advisory-only.
91    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    /// Whether policy must treat this verdict as advisory-only.
120    #[must_use]
121    pub const fn is_advisory_only(&self) -> bool {
122        self.signer_kind.is_advisory_only()
123    }
124}
125
126/// Build the canonical bytes signed by a [`SignedVerdict`].
127///
128/// Layout: `v2-tag || content-hash || change-id || tree-digest || signer-kind
129/// || NUL || signed-at || NUL`. The first four fields have fixed widths; the two
130/// trailing UTF-8 fields are framed by their fixed enum vocabulary/final NUL.
131#[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
152/// Sign a rich CI verdict with Heddle's shared [`Signer`] spine.
153pub 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/// Errors returned while creating or verifying a signed CI verdict.
211#[derive(Debug, thiserror::Error)]
212pub enum SignedVerdictError {
213    /// The serialized envelope format is not supported.
214    #[error("unsupported signed verdict format version {found}; expected {supported}")]
215    UnsupportedFormatVersion {
216        /// Version found in the envelope.
217        found: u8,
218        /// Only version accepted by this implementation.
219        supported: u8,
220    },
221    /// The embedded body schema cannot be verified by this implementation.
222    #[error("unsupported CI verdict body schema version {found}; expected {supported}")]
223    UnsupportedSchemaVersion {
224        /// Version found in the body.
225        found: u32,
226        /// Only version accepted by this implementation.
227        supported: u32,
228    },
229    /// The embedded body no longer hashes to the signed content hash.
230    #[error("CI verdict body digest mismatch: signed {signed}, recomputed {recomputed}")]
231    BodyDigestMismatch {
232        /// Digest carried by the signed envelope.
233        signed: ContentHash,
234        /// Digest recomputed from the embedded body.
235        recomputed: ContentHash,
236    },
237    /// The signed timestamp is not RFC3339.
238    #[error("CI verdict signed_at is not RFC3339: {0}")]
239    InvalidSignedAt(String),
240    /// The embedded public key is not valid hexadecimal.
241    #[error("signed verdict public key is not hexadecimal: {0}")]
242    InvalidPublicKeyEncoding(hex::FromHexError),
243    /// The embedded signature is not valid hexadecimal.
244    #[error("signed verdict signature is not hexadecimal: {0}")]
245    InvalidSignatureEncoding(hex::FromHexError),
246    /// The shared signing backend rejected the operation.
247    #[error("signed verdict cryptographic error: {0}")]
248    Signer(#[from] SignerError),
249}