Skip to main content

provide_telemetry/
receipts.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5//! Cryptographic redaction receipts.
6//!
7//! This module owns two cross-language governance contracts:
8//!
9//! * **Canonicalization** — the hashed form of a redacted value is its RFC 8785
10//!   (JCS) serialization, not its `Display` text. Hashing the rendered form
11//!   collides across types: the number `1` and the string `"1"` produce the same
12//!   digest, so a receipt built on them cannot distinguish the two.
13//! * **Signing** — `receipt_id|timestamp|field_path|action|original_hash` under
14//!   HMAC-SHA256, lowercase hex.
15//!
16//! Both are pinned by `spec/receipt_fixtures.yaml`, whose vectors came from
17//! independent implementations (`rfc8785` and Python's `hmac`), so reproducing
18//! them means agreeing with the other SDKs rather than with ourselves.
19
20use std::collections::VecDeque;
21use std::sync::{Arc, Mutex, OnceLock};
22
23use hmac::{Hmac, Mac};
24use serde_json::Value;
25use sha2::{Digest, Sha256};
26use std::fmt::Write;
27use uuid::Uuid;
28
29use crate::errors::ConfigurationError;
30pub use crate::jcs::{canonical_json, canonical_number};
31
32type HmacSha256 = Hmac<Sha256>;
33
34/// An immutable audit record for a single redaction event.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct RedactionReceipt {
37    pub receipt_id: String,
38    pub timestamp: String,
39    pub service_name: String,
40    pub field_path: String,
41    pub action: String,
42    pub original_hash: String,
43    pub hmac: Option<String>,
44}
45
46/// Destination for governance receipts.
47///
48/// `emit` returns false to reject a receipt; returning false and panicking both
49/// count into `receipt_failures`. Implementations must not log — see
50/// [`emit_receipt`].
51pub trait ReceiptSink: Send + Sync {
52    fn emit(&self, receipt: &RedactionReceipt) -> bool;
53}
54
55/// Retention cap for [`TestReceiptCollector`].
56pub const TEST_RECEIPT_CAPACITY: usize = 1024;
57
58/// In-memory sink for tests, bounded at [`TEST_RECEIPT_CAPACITY`] receipts.
59///
60/// Only the test collector is capped. A production sink is the caller's own
61/// durable destination, and silently discarding audit records to stay inside a
62/// memory budget is not a decision this library gets to make for them.
63#[derive(Debug, Default)]
64pub struct TestReceiptCollector {
65    receipts: Mutex<VecDeque<RedactionReceipt>>,
66}
67
68impl TestReceiptCollector {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn receipts(&self) -> Vec<RedactionReceipt> {
74        crate::_lock::lock(&self.receipts).iter().cloned().collect()
75    }
76
77    pub fn clear(&self) {
78        crate::_lock::lock(&self.receipts).clear();
79    }
80}
81
82impl ReceiptSink for TestReceiptCollector {
83    fn emit(&self, receipt: &RedactionReceipt) -> bool {
84        let mut receipts = crate::_lock::lock(&self.receipts);
85        if receipts.len() == TEST_RECEIPT_CAPACITY {
86            receipts.pop_front();
87        }
88        receipts.push_back(receipt.clone());
89        true
90    }
91}
92
93/// The inputs to [`sign_receipt`] a caller pins rather than generates. Every
94/// identity-bearing field is a parameter so the fixture vectors can be
95/// reproduced exactly.
96pub struct SignReceiptOptions<'a> {
97    pub receipt_id: &'a str,
98    pub timestamp: &'a str,
99    pub field_path: &'a str,
100    pub action: &'a str,
101    pub service_name: &'a str,
102    /// Signing key. `None` leaves `hmac` empty — an unsigned receipt.
103    pub key: Option<&'a [u8]>,
104}
105
106fn bytes_to_hex(bytes: &[u8]) -> String {
107    let mut hex = String::with_capacity(bytes.len() * 2);
108    for byte in bytes {
109        write!(&mut hex, "{byte:02x}").expect("writing to string cannot fail");
110    }
111    hex
112}
113
114/// The canonical receipt payload, in the byte order every SDK signs.
115pub fn receipt_payload(receipt: &RedactionReceipt) -> String {
116    format!(
117        "{}|{}|{}|{}|{}",
118        receipt.receipt_id,
119        receipt.timestamp,
120        receipt.field_path,
121        receipt.action,
122        receipt.original_hash
123    )
124}
125
126/// Build a receipt over `input`, canonicalizing and signing it.
127pub fn sign_receipt(input: &Value, options: SignReceiptOptions<'_>) -> RedactionReceipt {
128    let mut hasher = Sha256::new();
129    hasher.update(canonical_json(input).as_bytes());
130    let mut receipt = RedactionReceipt {
131        receipt_id: options.receipt_id.to_string(),
132        timestamp: options.timestamp.to_string(),
133        service_name: options.service_name.to_string(),
134        field_path: options.field_path.to_string(),
135        action: options.action.to_string(),
136        original_hash: bytes_to_hex(&hasher.finalize()),
137        hmac: None,
138    };
139    receipt.hmac = options.key.map(|key| {
140        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
141        mac.update(receipt_payload(&receipt).as_bytes());
142        bytes_to_hex(&mac.finalize().into_bytes())
143    });
144    receipt
145}
146
147/// Hand a receipt to its sink, counting refusals.
148///
149/// This path must never log. The logger is what produces redactions, redactions
150/// are what produce receipts, and a sink that fails on every receipt would then
151/// drive an unbounded log -> receipt -> log cycle. A rejection is therefore
152/// recorded only as a counter, which `get_health_snapshot().receipt_failures`
153/// exposes.
154pub fn emit_receipt(receipt: &RedactionReceipt, sink: &dyn ReceiptSink) {
155    let accepted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink.emit(receipt)))
156        .unwrap_or(false);
157    if !accepted {
158        crate::health::increment_receipt_failures();
159    }
160}
161
162#[derive(Clone, Default)]
163struct ReceiptConfig {
164    enabled: bool,
165    signing_key: Option<String>,
166    service_name: Option<String>,
167    sink: Option<Arc<dyn ReceiptSink>>,
168    test_mode: bool,
169}
170
171/// Options for [`enable_receipts`].
172#[derive(Clone, Default)]
173pub struct ReceiptOptions {
174    pub enabled: bool,
175    pub signing_key: Option<String>,
176    pub service_name: Option<String>,
177    /// Required outside test mode: where accepted receipts are delivered.
178    pub sink: Option<Arc<dyn ReceiptSink>>,
179}
180
181const DEFAULT_SERVICE_NAME: &str = "unknown";
182
183static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
184static TEST_COLLECTOR: OnceLock<TestReceiptCollector> = OnceLock::new();
185
186#[cfg_attr(test, mutants::skip)] // Equivalent mutants only swap in Mutex::default().
187fn default_receipt_config_mutex() -> Mutex<ReceiptConfig> {
188    Mutex::new(ReceiptConfig::default())
189}
190
191fn config() -> &'static Mutex<ReceiptConfig> {
192    CONFIG.get_or_init(default_receipt_config_mutex)
193}
194
195fn test_collector() -> &'static TestReceiptCollector {
196    TEST_COLLECTOR.get_or_init(TestReceiptCollector::new)
197}
198
199/// Enable or disable receipt generation.
200///
201/// Enabling receipts outside test mode without a sink is an error. The
202/// alternative — computing and signing a full receipt for every redaction and
203/// then dropping it — lets a service believe it has an audit trail when it has
204/// none, which is worse than having no receipts at all.
205pub fn enable_receipts(options: ReceiptOptions) -> Result<(), ConfigurationError> {
206    let mut current = crate::_lock::lock(config());
207    if options.enabled && !current.test_mode && options.sink.is_none() {
208        return Err(ConfigurationError::new(
209            "receipts are enabled but no ReceiptSink is configured; generated receipts \
210             would be signed and then discarded. Pass a sink, or disable receipts.",
211        ));
212    }
213    *current = ReceiptConfig {
214        enabled: options.enabled,
215        signing_key: options.signing_key,
216        service_name: options.service_name,
217        sink: options.sink,
218        test_mode: current.test_mode,
219    };
220    Ok(())
221}
222
223/// Record one redaction: the hook `pii.rs` calls for every masked field.
224pub(crate) fn record_redaction(field_path: &str, action: &str, original_value: &Value) {
225    let snapshot = crate::_lock::lock(config()).clone();
226    if !snapshot.enabled {
227        return;
228    }
229    let receipt = sign_receipt(
230        original_value,
231        SignReceiptOptions {
232            receipt_id: &Uuid::new_v4().to_string(),
233            timestamp: &crate::logger::now_iso8601(),
234            field_path,
235            action,
236            service_name: snapshot
237                .service_name
238                .as_deref()
239                .unwrap_or(DEFAULT_SERVICE_NAME),
240            key: snapshot.signing_key.as_ref().map(|key| key.as_bytes()),
241        },
242    );
243    // In test mode the built-in collector stands in for a configured sink, so
244    // the suite never exercises the un-sinked path `enable_receipts` rejects.
245    match snapshot.sink {
246        Some(sink) => emit_receipt(&receipt, sink.as_ref()),
247        None => emit_receipt(&receipt, test_collector()),
248    }
249}
250
251pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
252    test_collector().receipts()
253}
254
255pub fn reset_receipts_for_tests() {
256    *crate::_lock::lock(config()) = ReceiptConfig {
257        test_mode: true,
258        ..ReceiptConfig::default()
259    };
260    test_collector().clear();
261}
262
263/// Leave test mode, so `enable_receipts` demands a sink the way it does in a
264/// deployed service.
265pub fn _set_test_mode_for_tests(test_mode: bool) {
266    crate::_lock::lock(config()).test_mode = test_mode;
267}
268
269#[cfg(test)]
270#[path = "receipts_tests.rs"]
271mod tests;