Skip to main content

exo_api/
crosschecked_anchor.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Commitment-only CrossChecked anchor wire types.
18//!
19//! This module deliberately contains no HTTP listener, route registration,
20//! persistence, signer custody, or authority construction. It only validates
21//! and encodes protocol bytes for a caller that already possesses an
22//! independently authorized verification key.
23
24use std::{cmp::Ordering, collections::BTreeMap};
25
26use ciborium::Value;
27use exo_core::{
28    crypto,
29    types::{Did, Hash256, PublicKey, ReceiptOutcome, Signature, Timestamp, TrustReceipt},
30};
31use thiserror::Error;
32
33/// Exact commitment-only anchor path.
34pub const ANCHOR_PATH: &str = "/api/v1/anchors/crosschecked";
35/// Maximum accepted request body size.
36pub const MAX_REQUEST_BODY_BYTES: usize = 8_192;
37/// Maximum permitted request issue-time skew into the future.
38pub const MAX_FUTURE_SKEW_MS: u64 = 60_000;
39/// Maximum positive request validity interval.
40pub const MAX_VALIDITY_MS: u64 = 300_000;
41/// Maximum accepted encoded response size.
42pub const MAX_RESPONSE_BODY_BYTES: usize = 65_536;
43
44const REQUEST_DOMAIN: &str = "exo.crosschecked.anchor_request.v1";
45const IDEMPOTENCY_DOMAIN: &str = "exo.crosschecked.anchor_idempotency.v1";
46const RESPONSE_DOMAIN: &str = "exo.crosschecked.anchor_response.v1";
47const RECEIPT_ACTION_TYPE: &str = "crosschecked.receipt_commitment.record.v1";
48
49/// Errors returned before a request can reach authority or persistence code.
50#[derive(Clone, Debug, Error, PartialEq, Eq)]
51pub enum AnchorCodecError {
52    /// The HTTP-bound protocol context is not the exact commitment-only route.
53    #[error("invalid request context: {0}")]
54    InvalidContext(&'static str),
55    /// The body is empty or exceeds the protocol limit.
56    #[error("invalid body length")]
57    InvalidBodyLength,
58    /// The body is not deterministic RFC 8949 CBOR.
59    #[error("non-canonical CBOR: {0}")]
60    NonCanonicalCbor(&'static str),
61    /// The CBOR shape or an exact field type is invalid.
62    #[error("invalid field: {0}")]
63    InvalidField(&'static str),
64    /// The request's independently derived idempotency value is wrong.
65    #[error("idempotency mismatch")]
66    IdempotencyMismatch,
67    /// The request is outside the closed validity interval rules.
68    #[error("invalid request validity")]
69    InvalidValidity,
70    /// The request signature does not verify over the exact domain.
71    #[error("invalid request signature")]
72    InvalidSignature,
73    /// A response does not bind the expected request or action commitment.
74    #[error("response commitment mismatch")]
75    CommitmentMismatch,
76    /// The nested generic receipt failed its hash, signature, or wire checks.
77    #[error("invalid nested trust receipt")]
78    InvalidReceipt,
79    /// Deterministic serialization failed.
80    #[error("CBOR serialization failed")]
81    Serialization,
82}
83
84/// Exact 16-key commitment-only request.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct CrossCheckedAnchorRequestV1 {
87    pub protocol_version: u64,
88    pub source_code: String,
89    pub receipt_format: String,
90    pub audience: String,
91    pub authority_did: String,
92    pub authority_key_id: String,
93    pub grant_id: [u8; 32],
94    pub scope_alias: [u8; 32],
95    pub action_hash_algorithm: String,
96    pub action_hash: [u8; 32],
97    pub idempotency_key: [u8; 32],
98    pub nonce: [u8; 32],
99    pub issued_at_ms: u64,
100    pub expires_at_ms: u64,
101    pub signature_algorithm: String,
102    pub signature: [u8; 64],
103}
104
105impl CrossCheckedAnchorRequestV1 {
106    /// Encode the exact request signature preimage.
107    ///
108    /// # Errors
109    /// Returns an error only if the in-memory CBOR value cannot be serialized.
110    pub fn signing_preimage(&self) -> Result<Vec<u8>, AnchorCodecError> {
111        encode_value(&Value::Array(vec![
112            text(REQUEST_DOMAIN),
113            text("POST"),
114            text(ANCHOR_PATH),
115            unsigned(self.protocol_version),
116            text(&self.source_code),
117            text(&self.receipt_format),
118            text(&self.audience),
119            text(&self.authority_did),
120            text(&self.authority_key_id),
121            bytes(&self.grant_id),
122            bytes(&self.scope_alias),
123            text(&self.action_hash_algorithm),
124            bytes(&self.action_hash),
125            bytes(&self.idempotency_key),
126            bytes(&self.nonce),
127            unsigned(self.issued_at_ms),
128            unsigned(self.expires_at_ms),
129            text(&self.signature_algorithm),
130        ]))
131    }
132
133    /// Derive the exact protocol idempotency key from semantic commitments.
134    ///
135    /// # Errors
136    /// Returns an error only if the in-memory CBOR value cannot be serialized.
137    pub fn derive_idempotency_key(&self) -> Result<[u8; 32], AnchorCodecError> {
138        let encoded = encode_value(&Value::Array(vec![
139            text(IDEMPOTENCY_DOMAIN),
140            text(&self.authority_did),
141            bytes(&self.grant_id),
142            bytes(&self.scope_alias),
143            bytes(&self.action_hash),
144        ]))?;
145        Ok(*blake3::hash(&encoded).as_bytes())
146    }
147
148    /// Encode the exact 16-key deterministic request map.
149    ///
150    /// # Errors
151    /// Returns an error only if the in-memory CBOR value cannot be serialized.
152    pub fn to_canonical_cbor(&self) -> Result<Vec<u8>, AnchorCodecError> {
153        encode_value(&canonical_map(vec![
154            ("protocol_version", unsigned(self.protocol_version)),
155            ("source_code", text(&self.source_code)),
156            ("receipt_format", text(&self.receipt_format)),
157            ("audience", text(&self.audience)),
158            ("authority_did", text(&self.authority_did)),
159            ("authority_key_id", text(&self.authority_key_id)),
160            ("grant_id", bytes(&self.grant_id)),
161            ("scope_alias", bytes(&self.scope_alias)),
162            ("action_hash_algorithm", text(&self.action_hash_algorithm)),
163            ("action_hash", bytes(&self.action_hash)),
164            ("idempotency_key", bytes(&self.idempotency_key)),
165            ("nonce", bytes(&self.nonce)),
166            ("issued_at_ms", unsigned(self.issued_at_ms)),
167            ("expires_at_ms", unsigned(self.expires_at_ms)),
168            ("signature_algorithm", text(&self.signature_algorithm)),
169            ("signature", bytes(&self.signature)),
170        ]))
171    }
172}
173
174/// Transport-bound inputs needed to validate, but not authorize, a request.
175#[derive(Clone, Copy, Debug)]
176pub struct RequestValidationContext<'a> {
177    pub method: &'a str,
178    pub path: &'a str,
179    pub content_type: &'a str,
180    pub expected_audience: &'a str,
181    pub now_ms: u64,
182    pub authority_public_key: &'a PublicKey,
183}
184
185/// A validated request together with its exact body and signed-body hash.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub struct ValidatedCrossCheckedAnchorRequestV1 {
188    pub request: CrossCheckedAnchorRequestV1,
189    pub canonical_body: Vec<u8>,
190    pub request_hash: Hash256,
191}
192
193/// Minimal, explicitly unverified request fields used only to locate a
194/// previously committed replay record.
195///
196/// Construction proves exact transport context, canonical CBOR, closed static
197/// fields, and the deterministic idempotency derivation. It deliberately does
198/// not prove request freshness, authority state, or signature validity.
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct UnverifiedAnchorReplayLocatorV1 {
201    pub source_code: String,
202    pub authority_did: String,
203    pub authority_key_id: String,
204    pub idempotency_key: [u8; 32],
205    pub action_hash: [u8; 32],
206    pub request_hash: Hash256,
207    pub issued_at_ms: u64,
208    pub expires_at_ms: u64,
209}
210
211/// Exact 10-key commitment-only response.
212///
213/// `exochain_receipt` remains the generic [`TrustReceipt`] semantic type. Its
214/// anchor-local wire adapter changes only the nested `timestamp` encoding from
215/// generic serde's map into the protocol-mandated `[physical_ms, logical]`
216/// array. The generic receipt serializer, hasher, signer, and RFC 3161 paths
217/// are not modified.
218#[derive(Clone, Debug, PartialEq, Eq)]
219pub struct CrossCheckedAnchorResponseV1 {
220    pub protocol_version: u64,
221    pub request_hash: Hash256,
222    pub action_hash: Hash256,
223    pub exochain_receipt: TrustReceipt,
224    pub recording_status: String,
225    pub consensus_finality: String,
226    pub node_did: String,
227    pub node_key_id: String,
228    pub node_recorded_at: Timestamp,
229    pub wrapper_signature: [u8; 64],
230}
231
232impl CrossCheckedAnchorResponseV1 {
233    /// Encode the exact response-wrapper signature preimage.
234    ///
235    /// # Errors
236    /// Returns an error if nested receipt or CBOR serialization fails.
237    pub fn signing_preimage(&self) -> Result<Vec<u8>, AnchorCodecError> {
238        encode_value(&Value::Array(vec![
239            text(RESPONSE_DOMAIN),
240            unsigned(self.protocol_version),
241            bytes(self.request_hash.as_bytes()),
242            bytes(self.action_hash.as_bytes()),
243            receipt_to_anchor_value(&self.exochain_receipt)?,
244            text(&self.recording_status),
245            text(&self.consensus_finality),
246            text(&self.node_did),
247            text(&self.node_key_id),
248            timestamp_value(self.node_recorded_at),
249        ]))
250    }
251
252    /// Encode the exact 10-key deterministic response map.
253    ///
254    /// # Errors
255    /// Returns an error if nested receipt or CBOR serialization fails.
256    pub fn to_canonical_cbor(&self) -> Result<Vec<u8>, AnchorCodecError> {
257        encode_value(&canonical_map(vec![
258            ("protocol_version", unsigned(self.protocol_version)),
259            ("request_hash", bytes(self.request_hash.as_bytes())),
260            ("action_hash", bytes(self.action_hash.as_bytes())),
261            (
262                "exochain_receipt",
263                receipt_to_anchor_value(&self.exochain_receipt)?,
264            ),
265            ("recording_status", text(&self.recording_status)),
266            ("consensus_finality", text(&self.consensus_finality)),
267            ("node_did", text(&self.node_did)),
268            ("node_key_id", text(&self.node_key_id)),
269            ("node_recorded_at", timestamp_value(self.node_recorded_at)),
270            ("wrapper_signature", bytes(&self.wrapper_signature)),
271        ]))
272    }
273}
274
275/// Independent commitments and verification keys required for response decode.
276#[derive(Clone, Copy, Debug)]
277pub struct ResponseValidationContext<'a> {
278    pub expected_request_hash: Hash256,
279    pub expected_action_hash: Hash256,
280    pub expected_authority_chain_hash: Hash256,
281    pub expected_node_did: &'a str,
282    pub expected_node_key_id: &'a str,
283    pub expected_node_recorded_at: Timestamp,
284    /// The independently pinned node key verifies both the receipt and wrapper.
285    pub expected_node_public_key: &'a PublicKey,
286}
287
288/// A validated response together with its exact stable body bytes.
289#[derive(Clone, Debug, PartialEq, Eq)]
290pub struct ValidatedCrossCheckedAnchorResponseV1 {
291    pub response: CrossCheckedAnchorResponseV1,
292    pub canonical_body: Vec<u8>,
293}
294
295/// Strictly decode and validate a commitment-only response.
296///
297/// # Errors
298/// Returns a closed error for any CBOR, commitment, nested receipt, timestamp,
299/// or signature mismatch.
300pub fn decode_and_validate_response(
301    body: &[u8],
302    context: ResponseValidationContext<'_>,
303) -> Result<ValidatedCrossCheckedAnchorResponseV1, AnchorCodecError> {
304    if body.is_empty() || body.len() > MAX_RESPONSE_BODY_BYTES {
305        return Err(AnchorCodecError::InvalidBodyLength);
306    }
307    validate_preferred_cbor(body)?;
308    let value: Value =
309        ciborium::from_reader(body).map_err(|_| AnchorCodecError::NonCanonicalCbor("decode"))?;
310    let response = response_from_value(value)?;
311    validate_response_fields(&response, context)?;
312    if response.to_canonical_cbor()? != body {
313        return Err(AnchorCodecError::NonCanonicalCbor("round trip"));
314    }
315    let signature = Signature::from_bytes(response.wrapper_signature);
316    if !crypto::verify(
317        &response.signing_preimage()?,
318        &signature,
319        context.expected_node_public_key,
320    ) {
321        return Err(AnchorCodecError::InvalidSignature);
322    }
323    Ok(ValidatedCrossCheckedAnchorResponseV1 {
324        response,
325        canonical_body: body.to_vec(),
326    })
327}
328
329/// Strictly decode and validate an exact commitment-only anchor request.
330///
331/// This function performs protocol validation only. A successful return is
332/// not authority, replay-journal, persistence, or route-activation proof.
333///
334/// # Errors
335/// Returns a closed protocol error for any context, CBOR, field, time,
336/// idempotency, or signature mismatch.
337pub fn decode_and_validate_request(
338    body: &[u8],
339    context: RequestValidationContext<'_>,
340) -> Result<ValidatedCrossCheckedAnchorRequestV1, AnchorCodecError> {
341    validate_request_context(body, context.method, context.path, context.content_type)?;
342    validate_preferred_cbor(body)?;
343    let value: Value =
344        ciborium::from_reader(body).map_err(|_| AnchorCodecError::NonCanonicalCbor("decode"))?;
345    let request = request_from_value(value)?;
346    validate_request_fields(&request, context.expected_audience, context.now_ms)?;
347    if request.to_canonical_cbor()? != body {
348        return Err(AnchorCodecError::NonCanonicalCbor("round trip"));
349    }
350    let signature = Signature::from_bytes(request.signature);
351    if !crypto::verify(
352        &request.signing_preimage()?,
353        &signature,
354        context.authority_public_key,
355    ) {
356        return Err(AnchorCodecError::InvalidSignature);
357    }
358    Ok(ValidatedCrossCheckedAnchorRequestV1 {
359        request,
360        canonical_body: body.to_vec(),
361        request_hash: Hash256::digest(body),
362    })
363}
364
365/// Decode only the canonical, static fields required to locate an exact
366/// already-committed replay before live expiry or key-revocation checks.
367///
368/// This function never authorizes a new record. Callers must compare the full
369/// canonical body with the persisted request and cryptographically validate
370/// the persisted response before returning it. A cache miss must continue
371/// through [`decode_and_validate_request`] with a currently authorized key.
372///
373/// # Errors
374/// Returns a closed protocol error for any transport, CBOR, static-field, or
375/// idempotency mismatch.
376pub fn decode_unverified_replay_locator(
377    body: &[u8],
378    method: &str,
379    path: &str,
380    content_type: &str,
381) -> Result<UnverifiedAnchorReplayLocatorV1, AnchorCodecError> {
382    validate_request_context(body, method, path, content_type)?;
383    validate_preferred_cbor(body)?;
384    let value: Value =
385        ciborium::from_reader(body).map_err(|_| AnchorCodecError::NonCanonicalCbor("decode"))?;
386    let request = request_from_value(value)?;
387    validate_request_static_fields(&request)?;
388    if request.to_canonical_cbor()? != body {
389        return Err(AnchorCodecError::NonCanonicalCbor("round trip"));
390    }
391    Ok(UnverifiedAnchorReplayLocatorV1 {
392        source_code: request.source_code,
393        authority_did: request.authority_did,
394        authority_key_id: request.authority_key_id,
395        idempotency_key: request.idempotency_key,
396        action_hash: request.action_hash,
397        request_hash: Hash256::digest(body),
398        issued_at_ms: request.issued_at_ms,
399        expires_at_ms: request.expires_at_ms,
400    })
401}
402
403fn validate_request_context(
404    body: &[u8],
405    method: &str,
406    path: &str,
407    content_type: &str,
408) -> Result<(), AnchorCodecError> {
409    if body.is_empty() || body.len() > MAX_REQUEST_BODY_BYTES {
410        return Err(AnchorCodecError::InvalidBodyLength);
411    }
412    if method != "POST" {
413        return Err(AnchorCodecError::InvalidContext("method"));
414    }
415    if path != ANCHOR_PATH {
416        return Err(AnchorCodecError::InvalidContext("path"));
417    }
418    if content_type != "application/cbor" {
419        return Err(AnchorCodecError::InvalidContext("content type"));
420    }
421    Ok(())
422}
423
424fn validate_request_fields(
425    request: &CrossCheckedAnchorRequestV1,
426    expected_audience: &str,
427    now_ms: u64,
428) -> Result<(), AnchorCodecError> {
429    validate_request_static_fields(request)?;
430    if request.audience != expected_audience {
431        return Err(AnchorCodecError::InvalidField("audience"));
432    }
433    if request.expires_at_ms <= now_ms {
434        return Err(AnchorCodecError::InvalidValidity);
435    }
436    if request.issued_at_ms > now_ms.saturating_add(MAX_FUTURE_SKEW_MS) {
437        return Err(AnchorCodecError::InvalidValidity);
438    }
439    Ok(())
440}
441
442fn validate_request_static_fields(
443    request: &CrossCheckedAnchorRequestV1,
444) -> Result<(), AnchorCodecError> {
445    if request.protocol_version != 1 {
446        return Err(AnchorCodecError::InvalidField("protocol_version"));
447    }
448    require_exact(&request.source_code, "crosschecked", "source_code")?;
449    require_exact(
450        &request.receipt_format,
451        "action_receipt_v3",
452        "receipt_format",
453    )?;
454    require_exact(
455        &request.action_hash_algorithm,
456        "blake3-256",
457        "action_hash_algorithm",
458    )?;
459    require_exact(
460        &request.signature_algorithm,
461        "ed25519",
462        "signature_algorithm",
463    )?;
464    validate_audience(&request.audience)?;
465    validate_did_and_key_id(&request.authority_did, &request.authority_key_id)?;
466    for (name, value) in [
467        ("grant_id", &request.grant_id),
468        ("scope_alias", &request.scope_alias),
469        ("action_hash", &request.action_hash),
470        ("idempotency_key", &request.idempotency_key),
471        ("nonce", &request.nonce),
472    ] {
473        if value.iter().all(|byte| *byte == 0) {
474            return Err(AnchorCodecError::InvalidField(name));
475        }
476    }
477    if request.signature.iter().all(|byte| *byte == 0) {
478        return Err(AnchorCodecError::InvalidField("signature"));
479    }
480    if request.derive_idempotency_key()? != request.idempotency_key {
481        return Err(AnchorCodecError::IdempotencyMismatch);
482    }
483    let validity = request
484        .expires_at_ms
485        .checked_sub(request.issued_at_ms)
486        .ok_or(AnchorCodecError::InvalidValidity)?;
487    if validity == 0 || validity > MAX_VALIDITY_MS {
488        return Err(AnchorCodecError::InvalidValidity);
489    }
490    Ok(())
491}
492
493fn validate_audience(audience: &str) -> Result<(), AnchorCodecError> {
494    if audience.is_empty()
495        || audience.len() > 128
496        || !audience.bytes().all(|byte| {
497            byte.is_ascii_lowercase()
498                || byte.is_ascii_digit()
499                || matches!(byte, b'.' | b'_' | b':' | b'-')
500        })
501    {
502        return Err(AnchorCodecError::InvalidField("audience"));
503    }
504    Ok(())
505}
506
507fn validate_did_and_key_id(did: &str, key_id: &str) -> Result<(), AnchorCodecError> {
508    if did.is_empty() || did.len() > 256 || Did::new(did).is_err() {
509        return Err(AnchorCodecError::InvalidField("authority_did"));
510    }
511    let prefix = format!("{did}#");
512    let fragment = key_id
513        .strip_prefix(&prefix)
514        .ok_or(AnchorCodecError::InvalidField("authority_key_id"))?;
515    if key_id.len() > 321
516        || fragment.is_empty()
517        || fragment.len() > 64
518        || !fragment
519            .bytes()
520            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
521    {
522        return Err(AnchorCodecError::InvalidField("authority_key_id"));
523    }
524    Ok(())
525}
526
527fn require_exact(actual: &str, expected: &str, name: &'static str) -> Result<(), AnchorCodecError> {
528    if actual != expected {
529        return Err(AnchorCodecError::InvalidField(name));
530    }
531    Ok(())
532}
533
534fn request_from_value(value: Value) -> Result<CrossCheckedAnchorRequestV1, AnchorCodecError> {
535    let mut fields = exact_text_map(value, 16)?;
536    let request = CrossCheckedAnchorRequestV1 {
537        protocol_version: take_u64(&mut fields, "protocol_version")?,
538        source_code: take_text(&mut fields, "source_code")?,
539        receipt_format: take_text(&mut fields, "receipt_format")?,
540        audience: take_text(&mut fields, "audience")?,
541        authority_did: take_text(&mut fields, "authority_did")?,
542        authority_key_id: take_text(&mut fields, "authority_key_id")?,
543        grant_id: take_bytes(&mut fields, "grant_id")?,
544        scope_alias: take_bytes(&mut fields, "scope_alias")?,
545        action_hash_algorithm: take_text(&mut fields, "action_hash_algorithm")?,
546        action_hash: take_bytes(&mut fields, "action_hash")?,
547        idempotency_key: take_bytes(&mut fields, "idempotency_key")?,
548        nonce: take_bytes(&mut fields, "nonce")?,
549        issued_at_ms: take_u64(&mut fields, "issued_at_ms")?,
550        expires_at_ms: take_u64(&mut fields, "expires_at_ms")?,
551        signature_algorithm: take_text(&mut fields, "signature_algorithm")?,
552        signature: take_bytes(&mut fields, "signature")?,
553    };
554    if !fields.is_empty() {
555        return Err(AnchorCodecError::InvalidField("unknown key"));
556    }
557    Ok(request)
558}
559
560fn response_from_value(value: Value) -> Result<CrossCheckedAnchorResponseV1, AnchorCodecError> {
561    let mut fields = exact_text_map(value, 10)?;
562    let response = CrossCheckedAnchorResponseV1 {
563        protocol_version: take_u64(&mut fields, "protocol_version")?,
564        request_hash: Hash256::from_bytes(take_bytes(&mut fields, "request_hash")?),
565        action_hash: Hash256::from_bytes(take_bytes(&mut fields, "action_hash")?),
566        exochain_receipt: receipt_from_anchor_value(take_value(&mut fields, "exochain_receipt")?)?,
567        recording_status: take_text(&mut fields, "recording_status")?,
568        consensus_finality: take_text(&mut fields, "consensus_finality")?,
569        node_did: take_text(&mut fields, "node_did")?,
570        node_key_id: take_text(&mut fields, "node_key_id")?,
571        node_recorded_at: timestamp_from_value(take_value(&mut fields, "node_recorded_at")?)?,
572        wrapper_signature: take_bytes(&mut fields, "wrapper_signature")?,
573    };
574    if !fields.is_empty() {
575        return Err(AnchorCodecError::InvalidField("unknown key"));
576    }
577    Ok(response)
578}
579
580fn validate_response_fields(
581    response: &CrossCheckedAnchorResponseV1,
582    context: ResponseValidationContext<'_>,
583) -> Result<(), AnchorCodecError> {
584    if response.protocol_version != 1 {
585        return Err(AnchorCodecError::InvalidField("protocol_version"));
586    }
587    require_exact(
588        &response.recording_status,
589        "node_recorded",
590        "recording_status",
591    )?;
592    require_exact(
593        &response.consensus_finality,
594        "not_asserted",
595        "consensus_finality",
596    )?;
597    validate_did_and_key_id(&response.node_did, &response.node_key_id)?;
598    if response.request_hash == Hash256::ZERO
599        || response.action_hash == Hash256::ZERO
600        || response.exochain_receipt.authority_chain_hash == Hash256::ZERO
601        || response.wrapper_signature.iter().all(|byte| *byte == 0)
602    {
603        return Err(AnchorCodecError::InvalidField("zero response field"));
604    }
605    if response.request_hash != context.expected_request_hash
606        || response.action_hash != context.expected_action_hash
607        || response.exochain_receipt.action_hash != response.action_hash
608        || response.node_did != context.expected_node_did
609        || response.node_key_id != context.expected_node_key_id
610    {
611        return Err(AnchorCodecError::CommitmentMismatch);
612    }
613    if response.exochain_receipt.authority_chain_hash != context.expected_authority_chain_hash {
614        return Err(AnchorCodecError::CommitmentMismatch);
615    }
616    if response.node_recorded_at != response.exochain_receipt.timestamp
617        || response.node_recorded_at != context.expected_node_recorded_at
618    {
619        return Err(AnchorCodecError::InvalidField("node_recorded_at"));
620    }
621    if Did::new(response.exochain_receipt.actor_did.as_str()).is_err()
622        || response.exochain_receipt.actor_did.as_str() != response.node_did
623        || response.exochain_receipt.action_type != RECEIPT_ACTION_TYPE
624        || response.exochain_receipt.outcome != ReceiptOutcome::Executed
625        || response.exochain_receipt.consent_reference.is_some()
626        || response.exochain_receipt.challenge_reference.is_some()
627    {
628        return Err(AnchorCodecError::InvalidReceipt);
629    }
630    let valid_hash = response
631        .exochain_receipt
632        .verify_hash()
633        .map_err(|_| AnchorCodecError::InvalidReceipt)?;
634    let valid_signature = response
635        .exochain_receipt
636        .verify_signature(context.expected_node_public_key)
637        .map_err(|_| AnchorCodecError::InvalidReceipt)?;
638    if !valid_hash || !valid_signature {
639        return Err(AnchorCodecError::InvalidReceipt);
640    }
641    Ok(())
642}
643
644fn receipt_to_anchor_value(receipt: &TrustReceipt) -> Result<Value, AnchorCodecError> {
645    let mut generic = Vec::new();
646    ciborium::into_writer(receipt, &mut generic).map_err(|_| AnchorCodecError::Serialization)?;
647    let mut value: Value =
648        ciborium::from_reader(generic.as_slice()).map_err(|_| AnchorCodecError::Serialization)?;
649    validate_receipt_field_names(&value)?;
650    let Value::Map(fields) = &mut value else {
651        return Err(AnchorCodecError::InvalidReceipt);
652    };
653    let (_, timestamp) = fields
654        .iter_mut()
655        .find(|(key, _)| key == &text("timestamp"))
656        .ok_or(AnchorCodecError::InvalidReceipt)?;
657    *timestamp = timestamp_value(receipt.timestamp);
658    canonicalize_value(&mut value)?;
659    Ok(value)
660}
661
662fn receipt_from_anchor_value(mut value: Value) -> Result<TrustReceipt, AnchorCodecError> {
663    validate_receipt_field_names(&value)?;
664    let Value::Map(fields) = &mut value else {
665        return Err(AnchorCodecError::InvalidReceipt);
666    };
667    let (_, timestamp_value) = fields
668        .iter_mut()
669        .find(|(key, _)| key == &text("timestamp"))
670        .ok_or(AnchorCodecError::InvalidReceipt)?;
671    let timestamp = timestamp_from_value(timestamp_value.clone())?;
672    *timestamp_value = canonical_map(vec![
673        ("physical_ms", unsigned(timestamp.physical_ms)),
674        ("logical", unsigned(u64::from(timestamp.logical))),
675    ]);
676    let generic = encode_value(&value)?;
677    ciborium::from_reader(generic.as_slice()).map_err(|_| AnchorCodecError::InvalidReceipt)
678}
679
680fn validate_receipt_field_names(value: &Value) -> Result<(), AnchorCodecError> {
681    let Value::Map(fields) = value else {
682        return Err(AnchorCodecError::InvalidReceipt);
683    };
684    const EXPECTED: [&str; 10] = [
685        "receipt_hash",
686        "actor_did",
687        "authority_chain_hash",
688        "consent_reference",
689        "action_type",
690        "action_hash",
691        "outcome",
692        "timestamp",
693        "signature",
694        "challenge_reference",
695    ];
696    if fields.len() != EXPECTED.len() {
697        return Err(AnchorCodecError::InvalidReceipt);
698    }
699    let mut seen = BTreeMap::new();
700    for (key, _) in fields {
701        let Value::Text(key) = key else {
702            return Err(AnchorCodecError::InvalidReceipt);
703        };
704        if !EXPECTED.contains(&key.as_str()) || seen.insert(key.as_str(), ()).is_some() {
705            return Err(AnchorCodecError::InvalidReceipt);
706        }
707    }
708    Ok(())
709}
710
711fn timestamp_value(timestamp: Timestamp) -> Value {
712    Value::Array(vec![
713        unsigned(timestamp.physical_ms),
714        unsigned(u64::from(timestamp.logical)),
715    ])
716}
717
718fn timestamp_from_value(value: Value) -> Result<Timestamp, AnchorCodecError> {
719    let Value::Array(mut components) = value else {
720        return Err(AnchorCodecError::InvalidField("timestamp"));
721    };
722    if components.len() != 2 {
723        return Err(AnchorCodecError::InvalidField("timestamp"));
724    }
725    let logical = value_to_u64(
726        components
727            .pop()
728            .ok_or(AnchorCodecError::InvalidField("timestamp"))?,
729        "timestamp",
730    )?;
731    let physical_ms = value_to_u64(
732        components
733            .pop()
734            .ok_or(AnchorCodecError::InvalidField("timestamp"))?,
735        "timestamp",
736    )?;
737    let logical =
738        u32::try_from(logical).map_err(|_| AnchorCodecError::InvalidField("timestamp"))?;
739    Ok(Timestamp::new(physical_ms, logical))
740}
741
742fn value_to_u64(value: Value, name: &'static str) -> Result<u64, AnchorCodecError> {
743    let Value::Integer(value) = value else {
744        return Err(AnchorCodecError::InvalidField(name));
745    };
746    u64::try_from(value).map_err(|_| AnchorCodecError::InvalidField(name))
747}
748
749fn exact_text_map(
750    value: Value,
751    expected_len: usize,
752) -> Result<BTreeMap<String, Value>, AnchorCodecError> {
753    let Value::Map(entries) = value else {
754        return Err(AnchorCodecError::InvalidField("root map"));
755    };
756    if entries.len() != expected_len {
757        return Err(AnchorCodecError::InvalidField("map length"));
758    }
759    let mut fields = BTreeMap::new();
760    for (key, value) in entries {
761        let Value::Text(key) = key else {
762            return Err(AnchorCodecError::InvalidField("non-text key"));
763        };
764        if fields.insert(key, value).is_some() {
765            return Err(AnchorCodecError::InvalidField("duplicate key"));
766        }
767    }
768    Ok(fields)
769}
770
771fn take_text(
772    fields: &mut BTreeMap<String, Value>,
773    name: &'static str,
774) -> Result<String, AnchorCodecError> {
775    match fields.remove(name) {
776        Some(Value::Text(value)) => Ok(value),
777        _ => Err(AnchorCodecError::InvalidField(name)),
778    }
779}
780
781fn take_u64(
782    fields: &mut BTreeMap<String, Value>,
783    name: &'static str,
784) -> Result<u64, AnchorCodecError> {
785    match fields.remove(name) {
786        Some(Value::Integer(value)) => {
787            u64::try_from(value).map_err(|_| AnchorCodecError::InvalidField(name))
788        }
789        _ => Err(AnchorCodecError::InvalidField(name)),
790    }
791}
792
793fn take_value(
794    fields: &mut BTreeMap<String, Value>,
795    name: &'static str,
796) -> Result<Value, AnchorCodecError> {
797    fields
798        .remove(name)
799        .ok_or(AnchorCodecError::InvalidField(name))
800}
801
802fn take_bytes<const N: usize>(
803    fields: &mut BTreeMap<String, Value>,
804    name: &'static str,
805) -> Result<[u8; N], AnchorCodecError> {
806    match fields.remove(name) {
807        Some(Value::Bytes(value)) => value
808            .try_into()
809            .map_err(|_| AnchorCodecError::InvalidField(name)),
810        _ => Err(AnchorCodecError::InvalidField(name)),
811    }
812}
813
814fn canonical_map(entries: Vec<(&str, Value)>) -> Value {
815    let mut encoded_entries: Vec<(Vec<u8>, String, Value)> = entries
816        .into_iter()
817        .map(|(key, value)| {
818            let encoded = deterministic_text_key_bytes(key);
819            (encoded, key.to_owned(), value)
820        })
821        .collect();
822    encoded_entries.sort_by(|left, right| canonical_key_cmp(&left.0, &right.0));
823    Value::Map(
824        encoded_entries
825            .into_iter()
826            .map(|(_, key, value)| (Value::Text(key), value))
827            .collect(),
828    )
829}
830
831fn deterministic_text_key_bytes(key: &str) -> Vec<u8> {
832    let length = key.len();
833    let mut encoded = Vec::with_capacity(length.saturating_add(9));
834    match length {
835        0..=23 => encoded.push(0x60 | u8::try_from(length).unwrap_or(0)),
836        24..=255 => {
837            encoded.push(0x78);
838            encoded.push(u8::try_from(length).unwrap_or(u8::MAX));
839        }
840        256..=65_535 => {
841            encoded.push(0x79);
842            encoded.extend_from_slice(&u16::try_from(length).unwrap_or(u16::MAX).to_be_bytes());
843        }
844        65_536..=4_294_967_295 => {
845            encoded.push(0x7a);
846            encoded.extend_from_slice(&u32::try_from(length).unwrap_or(u32::MAX).to_be_bytes());
847        }
848        _ => {
849            encoded.push(0x7b);
850            encoded.extend_from_slice(&u64::try_from(length).unwrap_or(u64::MAX).to_be_bytes());
851        }
852    }
853    encoded.extend_from_slice(key.as_bytes());
854    encoded
855}
856
857fn canonicalize_value(value: &mut Value) -> Result<(), AnchorCodecError> {
858    match value {
859        Value::Array(values) => {
860            for value in values {
861                canonicalize_value(value)?;
862            }
863        }
864        Value::Map(entries) => {
865            for (key, value) in entries.iter_mut() {
866                canonicalize_value(key)?;
867                canonicalize_value(value)?;
868            }
869            let mut keyed = Vec::with_capacity(entries.len());
870            for (key, value) in std::mem::take(entries) {
871                keyed.push((encode_value(&key)?, key, value));
872            }
873            keyed.sort_by(|left, right| canonical_key_cmp(&left.0, &right.0));
874            *entries = keyed
875                .into_iter()
876                .map(|(_, key, value)| (key, value))
877                .collect();
878        }
879        _ => {}
880    }
881    Ok(())
882}
883
884fn canonical_key_cmp(left: &[u8], right: &[u8]) -> Ordering {
885    left.len().cmp(&right.len()).then_with(|| left.cmp(right))
886}
887
888fn text(value: &str) -> Value {
889    Value::Text(value.to_owned())
890}
891
892fn bytes(value: &[u8]) -> Value {
893    Value::Bytes(value.to_vec())
894}
895
896fn unsigned(value: u64) -> Value {
897    Value::Integer(value.into())
898}
899
900fn encode_value(value: &Value) -> Result<Vec<u8>, AnchorCodecError> {
901    let mut encoded = Vec::new();
902    ciborium::into_writer(value, &mut encoded).map_err(|_| AnchorCodecError::Serialization)?;
903    Ok(encoded)
904}
905
906/// Validate one complete deterministic RFC 8949 CBOR data item.
907pub fn validate_preferred_cbor(input: &[u8]) -> Result<(), AnchorCodecError> {
908    if input.is_empty() {
909        return Err(AnchorCodecError::NonCanonicalCbor("empty"));
910    }
911    let end = scan_item(input, 0, 1)?;
912    if end != input.len() {
913        return Err(AnchorCodecError::NonCanonicalCbor("trailing bytes"));
914    }
915    Ok(())
916}
917
918fn scan_item(input: &[u8], start: usize, depth: usize) -> Result<usize, AnchorCodecError> {
919    if depth > 32 {
920        return Err(AnchorCodecError::NonCanonicalCbor("nesting depth"));
921    }
922    let initial = *input
923        .get(start)
924        .ok_or(AnchorCodecError::NonCanonicalCbor("truncated item"))?;
925    let major = initial >> 5;
926    let additional = initial & 0x1f;
927    if additional == 31 {
928        return Err(AnchorCodecError::NonCanonicalCbor("indefinite length"));
929    }
930    if major == 7 {
931        return match additional {
932            20..=22 => Ok(start + 1),
933            _ => Err(AnchorCodecError::NonCanonicalCbor("simple or float")),
934        };
935    }
936    if major > 6 {
937        return Err(AnchorCodecError::NonCanonicalCbor("major type"));
938    }
939    if major == 6 {
940        return Err(AnchorCodecError::NonCanonicalCbor("tag"));
941    }
942    let (argument, mut cursor) = read_argument(input, start, additional)?;
943    match major {
944        0 | 1 => Ok(cursor),
945        2 | 3 => {
946            let length = usize::try_from(argument)
947                .map_err(|_| AnchorCodecError::NonCanonicalCbor("length overflow"))?;
948            let end = cursor
949                .checked_add(length)
950                .ok_or(AnchorCodecError::NonCanonicalCbor("length overflow"))?;
951            let payload = input
952                .get(cursor..end)
953                .ok_or(AnchorCodecError::NonCanonicalCbor("truncated bytes"))?;
954            if major == 3 && std::str::from_utf8(payload).is_err() {
955                return Err(AnchorCodecError::NonCanonicalCbor("invalid UTF-8"));
956            }
957            Ok(end)
958        }
959        4 => {
960            for _ in 0..argument {
961                cursor = scan_item(input, cursor, depth + 1)?;
962            }
963            Ok(cursor)
964        }
965        5 => {
966            let mut previous_key: Option<&[u8]> = None;
967            for _ in 0..argument {
968                let key_start = cursor;
969                cursor = scan_item(input, cursor, depth + 1)?;
970                let key = &input[key_start..cursor];
971                if previous_key
972                    .is_some_and(|previous| canonical_key_cmp(previous, key) != Ordering::Less)
973                {
974                    return Err(AnchorCodecError::NonCanonicalCbor(
975                        "map key order or duplicate",
976                    ));
977                }
978                previous_key = Some(key);
979                cursor = scan_item(input, cursor, depth + 1)?;
980            }
981            Ok(cursor)
982        }
983        _ => Err(AnchorCodecError::NonCanonicalCbor("major type")),
984    }
985}
986
987fn read_argument(
988    input: &[u8],
989    start: usize,
990    additional: u8,
991) -> Result<(u64, usize), AnchorCodecError> {
992    match additional {
993        value @ 0..=23 => Ok((u64::from(value), start + 1)),
994        24 => {
995            let value = u64::from(read_fixed::<1>(input, start)?[0]);
996            if value < 24 {
997                return Err(AnchorCodecError::NonCanonicalCbor("integer width"));
998            }
999            Ok((value, start + 2))
1000        }
1001        25 => {
1002            let value = u64::from(u16::from_be_bytes(read_fixed(input, start)?));
1003            if value <= u64::from(u8::MAX) {
1004                return Err(AnchorCodecError::NonCanonicalCbor("integer width"));
1005            }
1006            Ok((value, start + 3))
1007        }
1008        26 => {
1009            let value = u64::from(u32::from_be_bytes(read_fixed(input, start)?));
1010            if value <= u64::from(u16::MAX) {
1011                return Err(AnchorCodecError::NonCanonicalCbor("integer width"));
1012            }
1013            Ok((value, start + 5))
1014        }
1015        27 => {
1016            let value = u64::from_be_bytes(read_fixed(input, start)?);
1017            if value <= u64::from(u32::MAX) {
1018                return Err(AnchorCodecError::NonCanonicalCbor("integer width"));
1019            }
1020            Ok((value, start + 9))
1021        }
1022        _ => Err(AnchorCodecError::NonCanonicalCbor("reserved argument")),
1023    }
1024}
1025
1026fn read_fixed<const N: usize>(input: &[u8], start: usize) -> Result<[u8; N], AnchorCodecError> {
1027    let end = start
1028        .checked_add(1 + N)
1029        .ok_or(AnchorCodecError::NonCanonicalCbor("truncated argument"))?;
1030    input
1031        .get(start + 1..end)
1032        .ok_or(AnchorCodecError::NonCanonicalCbor("truncated argument"))?
1033        .try_into()
1034        .map_err(|_| AnchorCodecError::NonCanonicalCbor("truncated argument"))
1035}