lean_ctx_ocla/
receipt_verification.rs1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30#[non_exhaustive]
31pub enum ReceiptVerificationError {
32 #[error("receipt signing key is not admitted by the server trust snapshot")]
34 SignerNotAdmitted,
35 #[error("{0}")]
37 InvalidReceipt(String),
38}
39
40pub 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
64pub 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 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