Skip to main content

lean_ctx_ocla/
receipt_verification.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared production verification of canonical receipt signatures and signer trust.
4//!
5//! This does not validate execution lineage, artifact bytes, tenant authorization,
6//! replay, or outcome quality. Consumers must complete those checks before learning.
7
8use std::fmt::Write as _;
9
10use base64::{Engine as _, engine::general_purpose::STANDARD};
11use ed25519_dalek::{Signature, VerifyingKey};
12use lean_ctx_protocol::{ReceiptDocumentV1, Sha256Digest, UtcTimestamp};
13use sha2::{Digest, Sha256};
14
15/// Host-owned trust snapshot authorizing one receipt-signing key.
16///
17/// Resolve this snapshot and the verifying key from trusted host configuration,
18/// never from receipt-supplied key material. This is not an admission token.
19#[derive(Debug, Clone)]
20pub struct ReceiptSignerAdmissionV1 {
21    pub key_id: String,
22    pub public_key_digest: Sha256Digest,
23    pub admitted_at: UtcTimestamp,
24    pub expires_at: UtcTimestamp,
25    pub revoked_at: Option<UtcTimestamp>,
26}
27
28/// A receipt or its signer failed the shared production trust checks.
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30#[non_exhaustive]
31pub enum ReceiptVerificationError {
32    /// The host snapshot does not admit this key at the requested instant.
33    #[error("receipt signing key is not admitted by the server trust snapshot")]
34    SignerNotAdmitted,
35    /// Receipt shape, signer identity, issuance time, or signature is invalid.
36    #[error("{0}")]
37    InvalidReceipt(String),
38}
39
40/// Check a trusted key snapshot at one issuance or verification instant.
41///
42/// Admission is inclusive; expiry and revocation are exclusive. Canonical
43/// `UtcTimestamp` values have fixed-width UTC second precision, so their lexical
44/// order is chronological. This check alone does not verify any signature.
45pub fn validate_signer_admission(
46    admission: &ReceiptSignerAdmissionV1,
47    verifying_key: &VerifyingKey,
48    at: &UtcTimestamp,
49) -> Result<(), ReceiptVerificationError> {
50    if admission.key_id.is_empty()
51        || key_digest(verifying_key) != admission.public_key_digest.as_str()
52        || at.as_str() < admission.admitted_at.as_str()
53        || at.as_str() >= admission.expires_at.as_str()
54        || admission
55            .revoked_at
56            .as_ref()
57            .is_some_and(|revoked| at.as_str() >= revoked.as_str())
58    {
59        return Err(ReceiptVerificationError::SignerNotAdmitted);
60    }
61    Ok(())
62}
63
64/// Verify canonical receipt shape, trusted signer identity and strict signature.
65///
66/// The host-resolved admission must cover both issuance and verification time;
67/// future-issued receipts are rejected. Success authenticates the receipt
68/// signer's claims, not independently signed evaluator artifacts or a complete
69/// execution chain. No filesystem access, caller-supplied trust discovery, or
70/// learning side effect occurs here.
71pub fn verify_receipt_signature(
72    receipt: &ReceiptDocumentV1,
73    trusted_signer: &ReceiptSignerAdmissionV1,
74    verifying_key: &VerifyingKey,
75    verified_at: &UtcTimestamp,
76) -> Result<(), ReceiptVerificationError> {
77    receipt.validate().map_err(verification_error)?;
78    if receipt.signer.key_id != trusted_signer.key_id
79        || receipt.issued_at.as_str() > verified_at.as_str()
80    {
81        return Err(ReceiptVerificationError::InvalidReceipt(
82            "receipt signer or issuance time is not trusted".to_owned(),
83        ));
84    }
85    validate_signer_admission(trusted_signer, verifying_key, &receipt.issued_at)?;
86    validate_signer_admission(trusted_signer, verifying_key, verified_at)?;
87    let signature_bytes = STANDARD
88        .decode(&receipt.signature)
89        .map_err(verification_error)?;
90    let signature = Signature::from_slice(&signature_bytes).map_err(verification_error)?;
91    verifying_key
92        .verify_strict(
93            &receipt.signing_bytes().map_err(verification_error)?,
94            &signature,
95        )
96        .map_err(verification_error)
97}
98
99fn key_digest(verifying_key: &VerifyingKey) -> String {
100    sha256_digest(verifying_key.as_bytes())
101}
102
103pub(crate) fn sha256_digest(bytes: &[u8]) -> String {
104    let mut value = String::with_capacity(71);
105    value.push_str("sha256:");
106    for byte in Sha256::digest(bytes) {
107        write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
108    }
109    value
110}
111
112fn verification_error(error: impl std::fmt::Display) -> ReceiptVerificationError {
113    ReceiptVerificationError::InvalidReceipt(error.to_string())
114}
115
116#[cfg(test)]
117mod tests {
118    use ed25519_dalek::SigningKey;
119
120    use super::*;
121
122    fn time(value: &str) -> UtcTimestamp {
123        UtcTimestamp::new(value).expect("canonical test timestamp")
124    }
125
126    fn fixture() -> (ReceiptSignerAdmissionV1, VerifyingKey) {
127        // Deterministic test-only key, never a production trust authority.
128        let key = SigningKey::from_bytes(&[17; 32]).verifying_key();
129        let admission = ReceiptSignerAdmissionV1 {
130            key_id: "test-receipt-key".to_owned(),
131            public_key_digest: Sha256Digest::new(key_digest(&key)).expect("canonical key digest"),
132            admitted_at: time("2026-08-23T12:00:00Z"),
133            expires_at: time("2026-08-23T14:00:00Z"),
134            revoked_at: None,
135        };
136        (admission, key)
137    }
138
139    #[test]
140    fn admission_is_inclusive_and_expiry_is_exclusive() {
141        let (admission, key) = fixture();
142        for at in ["2026-08-23T12:00:00Z", "2026-08-23T13:59:59Z"] {
143            validate_signer_admission(&admission, &key, &time(at)).expect("admitted instant");
144        }
145        for at in ["2026-08-23T11:59:59Z", "2026-08-23T14:00:00Z"] {
146            assert!(validate_signer_admission(&admission, &key, &time(at)).is_err());
147        }
148    }
149
150    #[test]
151    fn revocation_rejects_its_boundary_and_later_instants() {
152        let (mut admission, key) = fixture();
153        admission.revoked_at = Some(time("2026-08-23T13:00:00Z"));
154        validate_signer_admission(&admission, &key, &time("2026-08-23T12:59:59Z"))
155            .expect("not revoked yet");
156        for at in ["2026-08-23T13:00:00Z", "2026-08-23T13:00:01Z"] {
157            assert!(validate_signer_admission(&admission, &key, &time(at)).is_err());
158        }
159    }
160
161    #[test]
162    fn key_digest_and_nonempty_identity_are_required() {
163        let (mut admission, key) = fixture();
164        let now = admission.admitted_at.clone();
165        let other_key = SigningKey::from_bytes(&[99; 32]).verifying_key();
166        assert!(validate_signer_admission(&admission, &other_key, &now).is_err());
167        admission.key_id.clear();
168        assert!(validate_signer_admission(&admission, &key, &now).is_err());
169    }
170
171    #[test]
172    fn empty_or_inverted_trust_window_admits_nothing() {
173        let (mut admission, key) = fixture();
174        for expires_at in ["2026-08-23T12:00:00Z", "2026-08-23T11:00:00Z"] {
175            admission.expires_at = time(expires_at);
176            assert!(validate_signer_admission(&admission, &key, &admission.admitted_at).is_err());
177        }
178    }
179}
180// SPDX-License-Identifier: Apache-2.0