1use 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
33pub const ANCHOR_PATH: &str = "/api/v1/anchors/crosschecked";
35pub const MAX_REQUEST_BODY_BYTES: usize = 8_192;
37pub const MAX_FUTURE_SKEW_MS: u64 = 60_000;
39pub const MAX_VALIDITY_MS: u64 = 300_000;
41pub 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#[derive(Clone, Debug, Error, PartialEq, Eq)]
51pub enum AnchorCodecError {
52 #[error("invalid request context: {0}")]
54 InvalidContext(&'static str),
55 #[error("invalid body length")]
57 InvalidBodyLength,
58 #[error("non-canonical CBOR: {0}")]
60 NonCanonicalCbor(&'static str),
61 #[error("invalid field: {0}")]
63 InvalidField(&'static str),
64 #[error("idempotency mismatch")]
66 IdempotencyMismatch,
67 #[error("invalid request validity")]
69 InvalidValidity,
70 #[error("invalid request signature")]
72 InvalidSignature,
73 #[error("response commitment mismatch")]
75 CommitmentMismatch,
76 #[error("invalid nested trust receipt")]
78 InvalidReceipt,
79 #[error("CBOR serialization failed")]
81 Serialization,
82}
83
84#[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 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 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 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#[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#[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#[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#[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 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 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#[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 pub expected_node_public_key: &'a PublicKey,
286}
287
288#[derive(Clone, Debug, PartialEq, Eq)]
290pub struct ValidatedCrossCheckedAnchorResponseV1 {
291 pub response: CrossCheckedAnchorResponseV1,
292 pub canonical_body: Vec<u8>,
293}
294
295pub 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
329pub 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
365pub 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
906pub 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}