mod body;
mod body_details;
pub use body::{
Basis, BasisKind, CI_VERDICT_BODY_SCHEMA_VERSION, CheckClass, CheckDescriptor, CiVerdictBody,
StateRef,
};
pub use body_details::{
Conclusion, Execution, FailureClass, FailureDetail, LogRef, Outcome, Repro,
};
use chrono::DateTime;
use objects::object::{ChangeId, ContentHash};
use serde::{Deserialize, Serialize};
use crate::{Signer, SignerError, verify_payload_signature};
pub const CI_VERDICT_DOMAIN: &[u8; 21] = b"heddle-ci-verdict-v2\0";
pub const SIGNED_VERDICT_FORMAT_VERSION: u8 = 2;
const FIXED_PAYLOAD_LEN: usize = CI_VERDICT_DOMAIN.len() + 32 + 16 + 32;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerKind {
#[default]
ServiceAccount,
Device,
}
impl SignerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ServiceAccount => "service_account",
Self::Device => "device",
}
}
#[must_use]
pub const fn is_advisory_only(self) -> bool {
matches!(self, Self::Device)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedVerdict {
pub format_version: u8,
pub body: CiVerdictBody,
pub content_hash: ContentHash,
pub change_id: ChangeId,
pub tree_digest: ContentHash,
pub signer_kind: SignerKind,
pub signed_at: String,
pub algorithm: String,
pub public_key: String,
pub signature: String,
}
impl SignedVerdict {
pub fn verify(&self) -> Result<(), SignedVerdictError> {
validate_versions(self.format_version, self.body.schema_version)?;
validate_signed_at(&self.signed_at)?;
let recomputed = self.body.content_hash();
if recomputed != self.content_hash {
return Err(SignedVerdictError::BodyDigestMismatch {
signed: self.content_hash,
recomputed,
});
}
let public_key =
hex::decode(&self.public_key).map_err(SignedVerdictError::InvalidPublicKeyEncoding)?;
let signature =
hex::decode(&self.signature).map_err(SignedVerdictError::InvalidSignatureEncoding)?;
let payload = ci_verdict_signing_payload(
&self.content_hash,
&self.change_id,
&self.tree_digest,
self.signer_kind,
&self.signed_at,
);
verify_payload_signature(&payload, &self.algorithm, &public_key, &signature)
.map_err(SignedVerdictError::from)
}
#[must_use]
pub const fn is_advisory_only(&self) -> bool {
self.signer_kind.is_advisory_only()
}
}
#[must_use]
pub fn ci_verdict_signing_payload(
content_hash: &ContentHash,
change_id: &ChangeId,
tree_digest: &ContentHash,
signer_kind: SignerKind,
signed_at: &str,
) -> Vec<u8> {
let mut payload =
Vec::with_capacity(FIXED_PAYLOAD_LEN + signer_kind.as_str().len() + signed_at.len() + 2);
payload.extend_from_slice(CI_VERDICT_DOMAIN);
payload.extend_from_slice(content_hash.as_bytes());
payload.extend_from_slice(change_id.as_bytes());
payload.extend_from_slice(tree_digest.as_bytes());
payload.extend_from_slice(signer_kind.as_str().as_bytes());
payload.push(0);
payload.extend_from_slice(signed_at.as_bytes());
payload.push(0);
payload
}
pub fn signed_verdict_from_signer(
body: CiVerdictBody,
change_id: &ChangeId,
tree_digest: &ContentHash,
signer_kind: SignerKind,
signed_at: String,
signer: &dyn Signer,
) -> Result<SignedVerdict, SignedVerdictError> {
validate_versions(SIGNED_VERDICT_FORMAT_VERSION, body.schema_version)?;
validate_signed_at(&signed_at)?;
let content_hash = body.content_hash();
let payload = ci_verdict_signing_payload(
&content_hash,
change_id,
tree_digest,
signer_kind,
&signed_at,
);
let signature = signer.sign(&payload)?;
Ok(SignedVerdict {
format_version: SIGNED_VERDICT_FORMAT_VERSION,
body,
content_hash,
change_id: *change_id,
tree_digest: *tree_digest,
signer_kind,
signed_at,
algorithm: signer.algorithm().to_string(),
public_key: hex::encode(signer.public_key()),
signature: hex::encode(signature),
})
}
fn validate_versions(format_version: u8, schema_version: u32) -> Result<(), SignedVerdictError> {
if format_version != SIGNED_VERDICT_FORMAT_VERSION {
return Err(SignedVerdictError::UnsupportedFormatVersion {
found: format_version,
supported: SIGNED_VERDICT_FORMAT_VERSION,
});
}
if schema_version != CI_VERDICT_BODY_SCHEMA_VERSION {
return Err(SignedVerdictError::UnsupportedSchemaVersion {
found: schema_version,
supported: CI_VERDICT_BODY_SCHEMA_VERSION,
});
}
Ok(())
}
fn validate_signed_at(signed_at: &str) -> Result<(), SignedVerdictError> {
DateTime::parse_from_rfc3339(signed_at)
.map(|_| ())
.map_err(|error| SignedVerdictError::InvalidSignedAt(error.to_string()))
}
#[derive(Debug, thiserror::Error)]
pub enum SignedVerdictError {
#[error("unsupported signed verdict format version {found}; expected {supported}")]
UnsupportedFormatVersion {
found: u8,
supported: u8,
},
#[error("unsupported CI verdict body schema version {found}; expected {supported}")]
UnsupportedSchemaVersion {
found: u32,
supported: u32,
},
#[error("CI verdict body digest mismatch: signed {signed}, recomputed {recomputed}")]
BodyDigestMismatch {
signed: ContentHash,
recomputed: ContentHash,
},
#[error("CI verdict signed_at is not RFC3339: {0}")]
InvalidSignedAt(String),
#[error("signed verdict public key is not hexadecimal: {0}")]
InvalidPublicKeyEncoding(hex::FromHexError),
#[error("signed verdict signature is not hexadecimal: {0}")]
InvalidSignatureEncoding(hex::FromHexError),
#[error("signed verdict cryptographic error: {0}")]
Signer(#[from] SignerError),
}