runx-receipts 0.6.19

Pure Rust receipt model, canonicalization, and verification parity for runx.
Documentation
use runx_contracts::Receipt;
use serde::Serialize;

use crate::{canonical_receipt_body_digest, content_addressed_receipt_id};

use super::{ReceiptFinding, ReceiptFindingCode, ReceiptProofContext, verify_receipt_proof};

pub const VERIFY_VERDICT_SCHEMA: &str = "runx.verify_verdict.v1";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReceiptVerifySignatureMode {
    LocalDevelopment,
    Production,
}

impl ReceiptVerifySignatureMode {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::LocalDevelopment => "local-development",
            Self::Production => "production",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReceiptVerifyVerdict {
    pub schema: &'static str,
    pub receipt_id: Option<String>,
    pub valid: bool,
    pub digest: ReceiptVerifyCheck,
    pub content_address: ReceiptVerifyCheck,
    pub signature: ReceiptVerifySignatureCheck,
    pub lineage: ReceiptVerifyLineageCheck,
    pub findings: Vec<ReceiptVerifyFinding>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReceiptVerifyCheck {
    pub status: &'static str,
    pub expected: Option<String>,
    pub actual: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReceiptVerifySignatureCheck {
    pub mode: &'static str,
    pub status: &'static str,
    pub kid: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReceiptVerifyLineageCheck {
    pub status: &'static str,
    pub message: &'static str,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReceiptVerifyFinding {
    pub code: String,
    pub path: String,
    pub message: String,
}

#[must_use]
pub fn verify_receipt_document_verdict(
    document: &[u8],
    context: &ReceiptProofContext<'_>,
    signature_mode: ReceiptVerifySignatureMode,
) -> ReceiptVerifyVerdict {
    match serde_json::from_slice::<Receipt>(document) {
        Ok(receipt) => verify_receipt_verdict(&receipt, context, signature_mode),
        Err(error) => parse_error_verdict(error.to_string(), signature_mode),
    }
}

#[must_use]
pub fn verify_receipt_verdict(
    receipt: &Receipt,
    context: &ReceiptProofContext<'_>,
    signature_mode: ReceiptVerifySignatureMode,
) -> ReceiptVerifyVerdict {
    let digest = digest_check(receipt);
    let content_address = content_address_check(receipt);
    let verification = verify_receipt_proof(receipt, context);
    let findings = verification
        .findings
        .iter()
        .map(verdict_finding)
        .collect::<Vec<_>>();
    let signature = signature_check(receipt, signature_mode, &verification.findings);
    let valid = verification.valid
        && digest.status == "valid"
        && content_address.status == "valid"
        && signature.status == "valid";

    ReceiptVerifyVerdict {
        schema: VERIFY_VERDICT_SCHEMA,
        receipt_id: Some(receipt.id.to_string()),
        valid,
        digest,
        content_address,
        signature,
        lineage: lineage_check(),
        findings,
    }
}

fn parse_error_verdict(
    message: String,
    signature_mode: ReceiptVerifySignatureMode,
) -> ReceiptVerifyVerdict {
    ReceiptVerifyVerdict {
        schema: VERIFY_VERDICT_SCHEMA,
        receipt_id: None,
        valid: false,
        digest: not_evaluated_check(),
        content_address: not_evaluated_check(),
        signature: ReceiptVerifySignatureCheck {
            mode: signature_mode.as_str(),
            status: "not_evaluated",
            kid: None,
        },
        lineage: lineage_check(),
        findings: vec![ReceiptVerifyFinding {
            code: "receipt_parse_error".to_owned(),
            path: "$".to_owned(),
            message,
        }],
    }
}

fn digest_check(receipt: &Receipt) -> ReceiptVerifyCheck {
    match canonical_receipt_body_digest(receipt) {
        Ok(expected) => ReceiptVerifyCheck {
            status: if receipt.digest == expected {
                "valid"
            } else {
                "invalid"
            },
            expected: Some(expected),
            actual: Some(receipt.digest.to_string()),
        },
        Err(_error) => ReceiptVerifyCheck {
            status: "not_evaluated",
            expected: None,
            actual: Some(receipt.digest.to_string()),
        },
    }
}

fn content_address_check(receipt: &Receipt) -> ReceiptVerifyCheck {
    match content_addressed_receipt_id(receipt) {
        Ok(expected) => ReceiptVerifyCheck {
            status: if receipt.id == expected {
                "valid"
            } else {
                "invalid"
            },
            expected: Some(expected),
            actual: Some(receipt.id.to_string()),
        },
        Err(_error) => ReceiptVerifyCheck {
            status: "not_evaluated",
            expected: None,
            actual: Some(receipt.id.to_string()),
        },
    }
}

fn not_evaluated_check() -> ReceiptVerifyCheck {
    ReceiptVerifyCheck {
        status: "not_evaluated",
        expected: None,
        actual: None,
    }
}

fn signature_check(
    receipt: &Receipt,
    signature_mode: ReceiptVerifySignatureMode,
    findings: &[ReceiptFinding],
) -> ReceiptVerifySignatureCheck {
    ReceiptVerifySignatureCheck {
        mode: signature_mode.as_str(),
        status: if findings
            .iter()
            .any(|finding| is_signature_finding(finding.code))
        {
            "invalid"
        } else {
            "valid"
        },
        kid: if receipt.issuer.kid.is_empty() {
            None
        } else {
            Some(receipt.issuer.kid.to_string())
        },
    }
}

fn lineage_check() -> ReceiptVerifyLineageCheck {
    ReceiptVerifyLineageCheck {
        status: "unverified",
        message: "single receipt verification cannot prove receipt-tree lineage",
    }
}

fn verdict_finding(finding: &ReceiptFinding) -> ReceiptVerifyFinding {
    ReceiptVerifyFinding {
        code: finding_code_name(finding.code),
        path: finding.path.clone(),
        message: finding.message.clone(),
    }
}

fn is_signature_finding(code: ReceiptFindingCode) -> bool {
    matches!(
        code,
        ReceiptFindingCode::SignatureVerifierMissing
            | ReceiptFindingCode::SignatureInvalid
            | ReceiptFindingCode::SignatureKeyMissing
            | ReceiptFindingCode::SignatureKeyMalformed
            | ReceiptFindingCode::SignatureKeyHashMismatch
            | ReceiptFindingCode::SignatureUnsupportedIssuer
            | ReceiptFindingCode::SignatureUnsupportedAlgorithm
            | ReceiptFindingCode::SignatureMalformed
    )
}

fn finding_code_name(code: ReceiptFindingCode) -> String {
    let debug = format!("{code:?}");
    let mut output = String::new();
    for (index, ch) in debug.chars().enumerate() {
        if ch.is_ascii_uppercase() {
            if index > 0 {
                output.push('_');
            }
            output.push(ch.to_ascii_lowercase());
        } else {
            output.push(ch);
        }
    }
    output
}