Skip to main content

chio_listing/outcome/
mod.rs

1use chio_core_types::canonical::{canonical_json_bytes, canonical_json_bytes_from_str};
2use chio_core_types::capability::scope::MonetaryAmount;
3use chio_core_types::crypto::{sha256_hex, PublicKey};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6
7mod contract;
8mod delivery;
9mod evidence;
10mod predicate;
11mod verdict;
12
13pub use contract::*;
14pub use delivery::*;
15pub use evidence::*;
16pub use predicate::*;
17pub use verdict::*;
18
19pub const OUTCOME_ARTIFACT_SCHEMAS: &[(&str, &str)] = &[
20    (
21        "request.schema.json",
22        chio_core_types::capability::governance::VERIFIED_OUTCOME_REQUEST_SCHEMA,
23    ),
24    ("predicate.schema.json", OUTCOME_PREDICATE_SCHEMA),
25    ("pricing.schema.json", OUTCOME_PRICING_SCHEMA),
26    ("sla.schema.json", OUTCOME_SLA_SCHEMA),
27    ("eligibility.schema.json", OUTCOME_ELIGIBILITY_SCHEMA),
28    (
29        "delivery-checkpoint.schema.json",
30        OUTCOME_DELIVERY_CHECKPOINT_SCHEMA,
31    ),
32    (
33        "delivery-acknowledgement.schema.json",
34        OUTCOME_DELIVERY_ACKNOWLEDGEMENT_SCHEMA,
35    ),
36    (
37        "delivery-nonacceptance.schema.json",
38        OUTCOME_DELIVERY_NONACCEPTANCE_SCHEMA,
39    ),
40    (
41        "output-provenance.schema.json",
42        OUTCOME_OUTPUT_PROVENANCE_SCHEMA,
43    ),
44    (
45        "contractual-zero.schema.json",
46        OUTCOME_CONTRACTUAL_ZERO_SCHEMA,
47    ),
48    ("verdict.schema.json", OUTCOME_VERDICT_SCHEMA),
49];
50
51const MAX_TEXT_CHARS: usize = 2_048;
52const I_JSON_MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
53
54#[derive(Debug, thiserror::Error, PartialEq, Eq)]
55pub enum OutcomeError {
56    #[error("invalid outcome field `{0}`")]
57    InvalidField(&'static str),
58    #[error("outcome artifact binding does not match")]
59    BindingMismatch,
60    #[error("outcome artifact authority verification failed")]
61    AuthorityVerification,
62    #[error("outcome artifact is not current")]
63    NotCurrent,
64    #[error("illegal outcome lifecycle transition")]
65    IllegalTransition,
66    #[error("outcome arithmetic overflow")]
67    ArithmeticOverflow,
68    #[error("outcome JSON is invalid: {0}")]
69    InvalidJson(String),
70    #[error("outcome artifact is not canonical: {0}")]
71    Canonicalization(String),
72}
73
74#[derive(Debug, Clone)]
75pub struct OutcomeSignerTrustV1 {
76    principal_id: String,
77    key: PublicKey,
78    key_epoch: u64,
79    max_lifetime_ms: u64,
80}
81
82impl OutcomeSignerTrustV1 {
83    pub fn new(
84        principal_id: String,
85        key: PublicKey,
86        key_epoch: u64,
87        max_lifetime_ms: u64,
88    ) -> Result<Self, OutcomeError> {
89        validate_text("trusted_principal_id", &principal_id)?;
90        validate_time("trusted_key_epoch", key_epoch)?;
91        validate_time("max_lifetime_ms", max_lifetime_ms)?;
92        Ok(Self {
93            principal_id,
94            key,
95            key_epoch,
96            max_lifetime_ms,
97        })
98    }
99
100    #[must_use]
101    pub fn principal_id(&self) -> &str {
102        &self.principal_id
103    }
104
105    #[must_use]
106    pub const fn key(&self) -> &PublicKey {
107        &self.key
108    }
109
110    #[must_use]
111    pub const fn key_epoch(&self) -> u64 {
112        self.key_epoch
113    }
114
115    #[must_use]
116    pub const fn max_lifetime_ms(&self) -> u64 {
117        self.max_lifetime_ms
118    }
119}
120
121pub fn canonical_outcome_bytes(value: &impl Serialize) -> Result<Vec<u8>, OutcomeError> {
122    let encoded = canonical_json_bytes(value)
123        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
124    let input = std::str::from_utf8(&encoded)
125        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
126    canonical_json_bytes_from_str(input)
127        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))
128}
129
130pub fn load_canonical_outcome_json<T>(bytes: &[u8]) -> Result<T, OutcomeError>
131where
132    T: DeserializeOwned,
133{
134    let input =
135        std::str::from_utf8(bytes).map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
136    let canonical = canonical_json_bytes_from_str(input)
137        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
138    if canonical.as_slice() != bytes {
139        return Err(OutcomeError::Canonicalization(
140            "input bytes differ from RFC 8785 form".to_owned(),
141        ));
142    }
143    let value = serde_json::from_slice(bytes)
144        .map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
145    Ok(value)
146}
147
148pub(super) fn domain_digest(domain: &[u8], value: &impl Serialize) -> Result<String, OutcomeError> {
149    let bytes = canonical_outcome_bytes(value)?;
150    let mut preimage = Vec::with_capacity(domain.len() + bytes.len());
151    preimage.extend_from_slice(domain);
152    preimage.extend_from_slice(&bytes);
153    Ok(sha256_hex(&preimage))
154}
155
156pub(super) fn domain_digest_without_field(
157    domain: &[u8],
158    value: &impl Serialize,
159    field: &'static str,
160) -> Result<String, OutcomeError> {
161    let mut value = serde_json::to_value(value)
162        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
163    let object = value
164        .as_object_mut()
165        .ok_or(OutcomeError::InvalidField("artifact_body"))?;
166    if object.remove(field).is_none() {
167        return Err(OutcomeError::InvalidField(field));
168    }
169    domain_digest(domain, &value)
170}
171
172pub(super) fn envelope_digest(value: &impl Serialize) -> Result<String, OutcomeError> {
173    canonical_outcome_bytes(value).map(|bytes| sha256_hex(&bytes))
174}
175
176pub(super) fn validate_text(field: &'static str, value: &str) -> Result<(), OutcomeError> {
177    if value.is_empty()
178        || value.trim() != value
179        || value.chars().count() > MAX_TEXT_CHARS
180        || value.chars().any(char::is_control)
181    {
182        Err(OutcomeError::InvalidField(field))
183    } else {
184        Ok(())
185    }
186}
187
188pub(super) fn validate_digest(field: &'static str, value: &str) -> Result<(), OutcomeError> {
189    if value.len() == 64
190        && value
191            .bytes()
192            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
193    {
194        Ok(())
195    } else {
196        Err(OutcomeError::InvalidField(field))
197    }
198}
199
200pub(super) fn validate_time(field: &'static str, value: u64) -> Result<(), OutcomeError> {
201    if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
202        Err(OutcomeError::InvalidField(field))
203    } else {
204        Ok(())
205    }
206}
207
208pub(super) fn validate_window(issued_at: u64, expires_at: u64) -> Result<(), OutcomeError> {
209    validate_time("issued_at_unix_ms", issued_at)?;
210    validate_time("expires_at_unix_ms", expires_at)?;
211    if expires_at <= issued_at {
212        return Err(OutcomeError::InvalidField("validity_window"));
213    }
214    Ok(())
215}
216
217pub(super) fn validate_current_window(
218    issued_at: u64,
219    expires_at: u64,
220    max_lifetime_ms: u64,
221    trusted_now_unix_ms: u64,
222) -> Result<(), OutcomeError> {
223    validate_window(issued_at, expires_at)?;
224    let lifetime = expires_at
225        .checked_sub(issued_at)
226        .ok_or(OutcomeError::NotCurrent)?;
227    if lifetime > max_lifetime_ms
228        || trusted_now_unix_ms < issued_at
229        || trusted_now_unix_ms >= expires_at
230    {
231        return Err(OutcomeError::NotCurrent);
232    }
233    Ok(())
234}
235
236pub(super) fn validate_money(
237    amount: &MonetaryAmount,
238    allow_zero: bool,
239) -> Result<(), OutcomeError> {
240    if (!allow_zero && amount.units == 0)
241        || amount.units > I_JSON_MAX_SAFE_INTEGER
242        || amount.currency.len() != 3
243        || !amount
244            .currency
245            .bytes()
246            .all(|byte| byte.is_ascii_uppercase())
247    {
248        return Err(OutcomeError::InvalidField("amount"));
249    }
250    Ok(())
251}