use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const VERIFICATION_SCHEMA: &str = "vela.proof_verification.v0.1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProofVerification {
pub schema: String,
pub verification_id: String,
pub proof_id: String,
pub tool: String,
pub tool_version: String,
pub script_locator: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lake_manifest_hash: Option<String>,
pub verifier_output_hash: String,
pub status: String,
pub verified_at: String,
pub verifier_actor: String,
pub verifier_pubkey: String,
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct VerificationDraft {
pub proof_id: String,
pub tool: String,
pub tool_version: String,
pub script_locator: String,
pub lake_manifest_hash: Option<String>,
pub verifier_output_hash: String,
pub status: String,
pub verified_at: String,
pub verifier_actor: String,
}
impl ProofVerification {
pub fn build(
draft: VerificationDraft,
signing_key: &ed25519_dalek::SigningKey,
) -> Result<Self, String> {
if !draft.proof_id.starts_with("vpf_") {
return Err(format!(
"proof_id must start with `vpf_`, got `{}`",
draft.proof_id
));
}
if !matches!(
draft.tool.as_str(),
"lean4" | "coq" | "isabelle" | "agda" | "metamath" | "rocq" | "other"
) {
return Err(format!(
"tool must be one of lean4|coq|isabelle|agda|metamath|rocq|other; got `{}`",
draft.tool
));
}
if !matches!(
draft.status.as_str(),
"verified" | "failed" | "toolchain_mismatch"
) {
return Err(format!(
"status must be one of verified|failed|toolchain_mismatch; got `{}`",
draft.status
));
}
let mut record = ProofVerification {
schema: VERIFICATION_SCHEMA.to_string(),
verification_id: String::new(),
proof_id: draft.proof_id,
tool: draft.tool,
tool_version: draft.tool_version,
script_locator: draft.script_locator,
lake_manifest_hash: draft.lake_manifest_hash,
verifier_output_hash: draft.verifier_output_hash,
status: draft.status,
verified_at: draft.verified_at,
verifier_actor: draft.verifier_actor,
verifier_pubkey: hex::encode(signing_key.verifying_key().to_bytes()),
signature: String::new(),
};
let preimage = record.preimage_bytes()?;
use ed25519_dalek::Signer;
let sig = signing_key.sign(&preimage);
record.signature = hex::encode(sig.to_bytes());
record.verification_id = record.derive_id()?;
Ok(record)
}
pub fn preimage_bytes(&self) -> Result<Vec<u8>, String> {
let mut preimage = self.clone();
preimage.signature = String::new();
preimage.verification_id = String::new();
crate::canonical::to_canonical_bytes(&preimage)
.map_err(|e| format!("canonicalize verification preimage: {e}"))
}
pub fn derive_id(&self) -> Result<String, String> {
let mut preimage = self.clone();
preimage.verification_id = String::new();
let bytes = crate::canonical::to_canonical_bytes(&preimage)
.map_err(|e| format!("canonicalize verification id preimage: {e}"))?;
let digest = Sha256::digest(&bytes);
Ok(format!("vpv_{}", &hex::encode(digest)[..16]))
}
pub fn verify(&self) -> Result<(), String> {
if self.schema != VERIFICATION_SCHEMA {
return Err(format!(
"verification.schema must be `{VERIFICATION_SCHEMA}`, got `{}`",
self.schema
));
}
let derived = self.derive_id()?;
if derived != self.verification_id {
return Err(format!(
"verification_id mismatch: stored `{}`, derived `{}`",
self.verification_id, derived
));
}
let pk_bytes = hex::decode(&self.verifier_pubkey)
.map_err(|e| format!("verifier_pubkey not hex: {e}"))?;
if pk_bytes.len() != 32 {
return Err(format!(
"verifier_pubkey must be 32 bytes (got {})",
pk_bytes.len()
));
}
let pk = ed25519_dalek::VerifyingKey::from_bytes(
pk_bytes
.as_slice()
.try_into()
.map_err(|e| format!("verifier_pubkey: {e}"))?,
)
.map_err(|e| format!("verifier_pubkey malformed: {e}"))?;
let sig_bytes =
hex::decode(&self.signature).map_err(|e| format!("signature not hex: {e}"))?;
if sig_bytes.len() != 64 {
return Err(format!(
"signature must be 64 bytes (got {})",
sig_bytes.len()
));
}
let sig = ed25519_dalek::Signature::from_bytes(
sig_bytes
.as_slice()
.try_into()
.map_err(|e| format!("signature: {e}"))?,
);
let preimage = self.preimage_bytes()?;
use ed25519_dalek::Verifier;
pk.verify(&preimage, &sig)
.map_err(|e| format!("verification signature does not verify: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_key() -> ed25519_dalek::SigningKey {
use rand::rngs::OsRng;
ed25519_dalek::SigningKey::generate(&mut OsRng)
}
fn good_draft() -> VerificationDraft {
VerificationDraft {
proof_id: "vpf_egz_n2".to_string(),
tool: "lean4".to_string(),
tool_version: "4.29.1".to_string(),
script_locator:
"sha256:58dec20d4f8c474d222009c9d3b7cae2ef010bfac48fd5c2ad7c9c8d894428ec"
.to_string(),
lake_manifest_hash: Some("sha256:0".repeat(64)),
verifier_output_hash: format!("sha256:{}", "0".repeat(64)),
status: "verified".to_string(),
verified_at: "2026-05-11T00:00:00+00:00".to_string(),
verifier_actor: "github-action:vela/.github/workflows/verify-carina-proofs.yml"
.to_string(),
}
}
#[test]
fn build_roundtrip() {
let sk = make_key();
let record = ProofVerification::build(good_draft(), &sk).unwrap();
assert!(record.verification_id.starts_with("vpv_"));
record.verify().unwrap();
}
#[test]
fn id_changes_when_status_changes() {
let sk = make_key();
let a = ProofVerification::build(good_draft(), &sk).unwrap();
let mut draft = good_draft();
draft.status = "failed".to_string();
let b = ProofVerification::build(draft, &sk).unwrap();
assert_ne!(a.verification_id, b.verification_id);
}
#[test]
fn tampered_signature_rejected() {
let sk = make_key();
let mut record = ProofVerification::build(good_draft(), &sk).unwrap();
record.signature = "0".repeat(128);
let err = record.verify().unwrap_err();
assert!(
err.contains("mismatch") || err.contains("does not verify"),
"got: {err}"
);
}
#[test]
fn invalid_status_rejected() {
let sk = make_key();
let mut draft = good_draft();
draft.status = "bogus".to_string();
let err = ProofVerification::build(draft, &sk).unwrap_err();
assert!(err.contains("status must be"), "got: {err}");
}
}