Skip to main content

appcore_security/
request_hash.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: request_hash.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/20 12:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/20 12:00:00 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Canonical request framing for request-bound Runtime credentials.
12
13use sha2::{Digest, Sha256};
14use std::io::{self, Write};
15
16const REQUEST_HASH_DOMAIN_V2: &[u8] = b"appcore.request-hash.v2\0";
17const REQUEST_HASH_PREFIX_V2: &str = "v2:";
18
19/// Details of an incoming query or command request used to verify its integrity.
20#[derive(Debug, Clone)]
21pub struct RequestValidationDetails {
22    /// Request purpose.
23    pub purpose: String,
24    /// Command or query name.
25    pub name: String,
26    /// Request identity.
27    pub id: String,
28    /// Optional idempotency key.
29    pub idempotency_key: Option<String>,
30    /// Canonical serialized payload.
31    pub payload: String,
32    /// Optional authenticated subject.
33    pub subject: Option<String>,
34    /// Optional target audience.
35    pub audience: Option<String>,
36}
37
38/// Borrowed canonical payload used during request-bound token verification.
39#[derive(Debug, Clone, Copy)]
40pub enum RequestPayloadRef<'a> {
41    /// An already canonical text payload.
42    Text(&'a str),
43    /// A structured payload serialized canonically by `serde_json`.
44    Json(&'a serde_json::Value),
45}
46
47/// Borrowed request details that avoid copying an in-flight payload.
48#[derive(Debug, Clone, Copy)]
49pub struct RequestValidationDetailsRef<'a> {
50    /// Request purpose.
51    pub purpose: &'a str,
52    /// Command or query name.
53    pub name: &'a str,
54    /// Request identity.
55    pub id: &'a str,
56    /// Optional idempotency key.
57    pub idempotency_key: Option<&'a str>,
58    /// Canonical text or structured JSON payload.
59    pub payload: RequestPayloadRef<'a>,
60    /// Optional authenticated subject.
61    pub subject: Option<&'a str>,
62    /// Optional target audience.
63    pub audience: Option<&'a str>,
64}
65
66impl RequestValidationDetailsRef<'_> {
67    /// Materializes the current owned contract for compatible verifiers.
68    pub fn to_owned(self) -> Result<RequestValidationDetails, serde_json::Error> {
69        let payload = match self.payload {
70            RequestPayloadRef::Text(payload) => payload.to_string(),
71            RequestPayloadRef::Json(payload) => serde_json::to_string(payload)?,
72        };
73        Ok(RequestValidationDetails {
74            purpose: self.purpose.to_string(),
75            name: self.name.to_string(),
76            id: self.id.to_string(),
77            idempotency_key: self.idempotency_key.map(str::to_string),
78            payload,
79            subject: self.subject.map(str::to_string),
80            audience: self.audience.map(str::to_string),
81        })
82    }
83}
84
85/// Computes the deterministic V2 SHA-256 hash of a canonically framed request.
86pub fn compute_request_hash(details: &RequestValidationDetails) -> String {
87    let mut hasher = Sha256::new();
88    hasher.update(REQUEST_HASH_DOMAIN_V2);
89    update_required(&mut hasher, 1, &details.purpose);
90    update_required(&mut hasher, 2, &details.name);
91    update_required(&mut hasher, 3, &details.id);
92    update_optional(&mut hasher, 4, details.idempotency_key.as_deref());
93    update_required(&mut hasher, 5, &details.payload);
94    update_optional(&mut hasher, 6, details.subject.as_deref());
95    update_optional(&mut hasher, 7, details.audience.as_deref());
96
97    let digest = hasher.finalize();
98    let mut output = String::with_capacity(REQUEST_HASH_PREFIX_V2.len() + digest.len() * 2);
99    output.push_str(REQUEST_HASH_PREFIX_V2);
100    push_hex(&mut output, &digest);
101    output
102}
103
104/// Computes the same deterministic V2 hash without materializing borrowed JSON.
105pub fn compute_borrowed_request_hash(
106    details: &RequestValidationDetailsRef<'_>,
107) -> Result<String, serde_json::Error> {
108    let mut hasher = Sha256::new();
109    hasher.update(REQUEST_HASH_DOMAIN_V2);
110    update_required(&mut hasher, 1, details.purpose);
111    update_required(&mut hasher, 2, details.name);
112    update_required(&mut hasher, 3, details.id);
113    update_optional(&mut hasher, 4, details.idempotency_key);
114    update_payload(&mut hasher, 5, details.payload)?;
115    update_optional(&mut hasher, 6, details.subject);
116    update_optional(&mut hasher, 7, details.audience);
117
118    let digest = hasher.finalize();
119    let mut output = String::with_capacity(REQUEST_HASH_PREFIX_V2.len() + digest.len() * 2);
120    output.push_str(REQUEST_HASH_PREFIX_V2);
121    push_hex(&mut output, &digest);
122    Ok(output)
123}
124
125fn update_required(hasher: &mut Sha256, tag: u8, value: &str) {
126    hasher.update([tag]);
127    hasher.update((value.len() as u64).to_be_bytes());
128    hasher.update(value.as_bytes());
129}
130
131fn update_optional(hasher: &mut Sha256, tag: u8, value: Option<&str>) {
132    hasher.update([tag]);
133    match value {
134        Some(value) => {
135            hasher.update([1]);
136            hasher.update((value.len() as u64).to_be_bytes());
137            hasher.update(value.as_bytes());
138        }
139        None => hasher.update([0]),
140    }
141}
142
143fn update_payload(
144    hasher: &mut Sha256,
145    tag: u8,
146    payload: RequestPayloadRef<'_>,
147) -> Result<(), serde_json::Error> {
148    match payload {
149        RequestPayloadRef::Text(payload) => update_required(hasher, tag, payload),
150        RequestPayloadRef::Json(payload) => {
151            let mut counter = JsonByteCounter::default();
152            serde_json::to_writer(&mut counter, payload)?;
153            hasher.update([tag]);
154            hasher.update(counter.bytes.to_be_bytes());
155            serde_json::to_writer(JsonHashWriter(hasher), payload)?;
156        }
157    }
158    Ok(())
159}
160
161#[derive(Default)]
162struct JsonByteCounter {
163    bytes: u64,
164}
165
166impl Write for JsonByteCounter {
167    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
168        let length = u64::try_from(bytes.len())
169            .map_err(|_| io::Error::other("request payload length overflowed"))?;
170        self.bytes = self
171            .bytes
172            .checked_add(length)
173            .ok_or_else(|| io::Error::other("request payload length overflowed"))?;
174        Ok(bytes.len())
175    }
176
177    fn flush(&mut self) -> io::Result<()> {
178        Ok(())
179    }
180}
181
182struct JsonHashWriter<'a>(&'a mut Sha256);
183
184impl Write for JsonHashWriter<'_> {
185    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
186        self.0.update(bytes);
187        Ok(bytes.len())
188    }
189
190    fn flush(&mut self) -> io::Result<()> {
191        Ok(())
192    }
193}
194
195fn push_hex(output: &mut String, bytes: &[u8]) {
196    const HEX: &[u8; 16] = b"0123456789abcdef";
197    for byte in bytes {
198        output.push(HEX[(byte >> 4) as usize] as char);
199        output.push(HEX[(byte & 0x0f) as usize] as char);
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::{
206        compute_borrowed_request_hash, compute_request_hash, RequestPayloadRef,
207        RequestValidationDetails, RequestValidationDetailsRef,
208    };
209    use serde_json::json;
210
211    #[test]
212    fn borrowed_text_hash_matches_owned_contract() {
213        let owned = owned_details("hello");
214        let borrowed = borrowed_details(RequestPayloadRef::Text("hello"));
215
216        assert_eq!(
217            compute_borrowed_request_hash(&borrowed).unwrap(),
218            compute_request_hash(&owned)
219        );
220    }
221
222    #[test]
223    fn borrowed_json_hash_matches_canonical_owned_contract() {
224        let payload = json!({"text": "é日本語العربية", "values": [1, 2, 3]});
225        let encoded = serde_json::to_string(&payload).unwrap();
226        let owned = owned_details(&encoded);
227        let borrowed = borrowed_details(RequestPayloadRef::Json(&payload));
228
229        assert_eq!(
230            compute_borrowed_request_hash(&borrowed).unwrap(),
231            compute_request_hash(&owned)
232        );
233        assert_eq!(borrowed.to_owned().unwrap().payload, encoded);
234    }
235
236    fn owned_details(payload: &str) -> RequestValidationDetails {
237        RequestValidationDetails {
238            purpose: "query".to_string(),
239            name: "runtime.status".to_string(),
240            id: "query-1".to_string(),
241            idempotency_key: Some("request-1".to_string()),
242            payload: payload.to_string(),
243            subject: Some("subject-1".to_string()),
244            audience: Some("runtime".to_string()),
245        }
246    }
247
248    fn borrowed_details(payload: RequestPayloadRef<'_>) -> RequestValidationDetailsRef<'_> {
249        RequestValidationDetailsRef {
250            purpose: "query",
251            name: "runtime.status",
252            id: "query-1",
253            idempotency_key: Some("request-1"),
254            payload,
255            subject: Some("subject-1"),
256            audience: Some("runtime"),
257        }
258    }
259}