Skip to main content

chio_kernel_core/
passport_verify.rs

1//! Portable passport verification.
2//!
3//! The WASM-compiled kernel verifies passports through this surface. It is
4//! pure compute over a minimal portable passport
5//! envelope: given bytes on the wire, a trusted authority key set, and a
6//! clock, it answers "is this envelope signed by a trusted authority,
7//! well-formed, and currently inside its validity window?".
8//!
9//! # Scope (what this module does NOT do)
10//!
11//! The native `chio-credentials` crate owns the full passport format
12//! (embedded reputation credentials, merkle roots, enterprise identity
13//! provenance, issuer-chain validation, cross-issuer portfolios,
14//! lifecycle resolution). None of that lives in `chio-kernel-core`:
15//! `chio-credentials` pulls `std`, `chrono`, and `chio-reputation`, which
16//! would break the `no_std + alloc` posture of this crate.
17//!
18//! What `passport_verify` offers instead is the thin trust primitive the
19//! portable kernel actually needs at runtime: a signed wire envelope
20//! that a browser / mobile / edge adapter can verify offline with the
21//! same cryptographic path the native sidecar uses. The envelope wraps
22//! an arbitrary JSON payload, so adapters can attach whatever passport
23//! shape they want and still reuse the same pure-compute verify.
24//!
25//! # `no_std` status
26//!
27//! This module imports only `chio_core_types::crypto::PublicKey` /
28//! `Signature` / `canonical_json_bytes` and the kernel-core
29//! [`Clock`](crate::clock::Clock) trait. It contains zero `std::*`
30//! imports. It participates in the same scripted portability proof as the
31//! rest of `chio-kernel-core`: host plus `wasm32-unknown-unknown` builds with
32//! `--no-default-features` via `scripts/check-portable-kernel.sh`.
33
34use alloc::string::{String, ToString};
35use alloc::vec::Vec;
36
37use serde::{Deserialize, Serialize};
38
39use chio_core_types::canonical_json_bytes;
40use chio_core_types::crypto::{PublicKey, Signature};
41
42use crate::clock::Clock;
43
44/// Schema tag for the portable passport envelope. Versioned so future
45/// envelope shapes can evolve without breaking older verifiers.
46pub const PORTABLE_PASSPORT_SCHEMA: &str = "chio.portable-agent-passport.v1";
47
48/// Body of a portable passport envelope.
49///
50/// The `payload_canonical_bytes` field carries the opaque canonical-JSON
51/// serialization of the native passport (or any projection of it) that
52/// the envelope authenticates. Keeping the payload as a byte blob means
53/// verification is independent of the passport schema the adapter uses
54/// on top -- relying parties only need to know they received bytes
55/// signed by a trusted issuer inside the validity window.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct PortablePassportBody {
59    /// Schema identifier; must equal [`PORTABLE_PASSPORT_SCHEMA`].
60    pub schema: String,
61    /// Subject identifier (typically the agent DID) the passport binds to.
62    pub subject: String,
63    /// Issuer public key that signed this envelope.
64    pub issuer: PublicKey,
65    /// Unix timestamp (seconds) the envelope was issued at.
66    pub issued_at: u64,
67    /// Unix timestamp (seconds) the envelope expires at.
68    pub expires_at: u64,
69    /// Canonical-JSON bytes of the authenticated payload.
70    #[serde(with = "payload_bytes_hex")]
71    pub payload_canonical_bytes: Vec<u8>,
72}
73
74/// Signed portable passport envelope.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct PortablePassportEnvelope {
78    pub body: PortablePassportBody,
79    pub signature: Signature,
80}
81
82/// The subset of a verified portable passport that callers actually
83/// need downstream. Mirrors [`crate::VerifiedCapability`] in shape.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct VerifiedPassport {
86    /// Subject identifier the envelope binds to.
87    pub subject: String,
88    /// Issuer public key that signed the envelope.
89    pub issuer: PublicKey,
90    /// Unix timestamp the envelope was issued at.
91    pub issued_at: u64,
92    /// Unix timestamp the envelope expires at.
93    pub expires_at: u64,
94    /// Clock value at which verification succeeded.
95    pub evaluated_at: u64,
96    /// Canonical-JSON bytes of the authenticated payload (caller may
97    /// decode these into the native `AgentPassport` or any other
98    /// projection downstream).
99    pub payload_canonical_bytes: Vec<u8>,
100}
101
102/// Errors raised by [`verify_passport`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum VerifyError {
105    /// Envelope bytes could not be parsed as a signed portable passport.
106    InvalidEnvelope(String),
107    /// Envelope schema tag did not equal [`PORTABLE_PASSPORT_SCHEMA`].
108    InvalidSchema,
109    /// Subject field was empty.
110    MissingSubject,
111    /// `issued_at` is strictly greater than `expires_at`.
112    InvalidValidityWindow,
113    /// Issuer public key is not in the trusted authority set.
114    UntrustedIssuer,
115    /// Canonical-JSON signature did not verify against the issuer key.
116    InvalidSignature,
117    /// Envelope is not yet valid (clock is before `issued_at`).
118    NotYetValid,
119    /// Envelope has expired (clock is at or after `expires_at`).
120    Expired,
121    /// Internal canonical-JSON failure while re-hashing the envelope body.
122    Internal(String),
123}
124
125/// Verify a portable passport envelope.
126///
127/// Performs four checks:
128/// 1. `envelope_bytes` parses as a [`PortablePassportEnvelope`].
129/// 2. The issuer is in `authority_keys`.
130/// 3. The envelope signature is valid over the canonical-JSON form of
131///    its body.
132/// 4. The current time (from `clock`) is within
133///    `[issued_at, expires_at)`.
134///
135/// On success returns a [`VerifiedPassport`] snapshot. This is
136/// deliberately pure: there is no revocation lookup, no payload
137/// decoding, and no issuer-chain validation. Those stay in the native
138/// `chio-credentials` / `chio-kernel` path.
139pub fn verify_passport(
140    envelope_bytes: &[u8],
141    authority_keys: &[PublicKey],
142    clock: &dyn Clock,
143) -> Result<VerifiedPassport, VerifyError> {
144    let envelope: PortablePassportEnvelope = serde_json::from_slice(envelope_bytes)
145        .map_err(|error| VerifyError::InvalidEnvelope(error.to_string()))?;
146    verify_parsed_passport(&envelope, authority_keys, clock)
147}
148
149/// Verify an already-parsed portable passport envelope. Useful for
150/// adapters that materialize the envelope from a non-JSON transport
151/// (CBOR, protobuf, etc.) before handing it to the kernel core.
152pub fn verify_parsed_passport(
153    envelope: &PortablePassportEnvelope,
154    authority_keys: &[PublicKey],
155    clock: &dyn Clock,
156) -> Result<VerifiedPassport, VerifyError> {
157    if envelope.body.schema != PORTABLE_PASSPORT_SCHEMA {
158        return Err(VerifyError::InvalidSchema);
159    }
160    if envelope.body.subject.is_empty() {
161        return Err(VerifyError::MissingSubject);
162    }
163    if envelope.body.issued_at > envelope.body.expires_at {
164        return Err(VerifyError::InvalidValidityWindow);
165    }
166    if !authority_keys.contains(&envelope.body.issuer) {
167        return Err(VerifyError::UntrustedIssuer);
168    }
169
170    let body_bytes = canonical_json_bytes(&envelope.body)
171        .map_err(|error| VerifyError::Internal(error.to_string()))?;
172    if !envelope
173        .body
174        .issuer
175        .verify(&body_bytes, &envelope.signature)
176    {
177        return Err(VerifyError::InvalidSignature);
178    }
179
180    let now = clock.now_unix_secs();
181    if now < envelope.body.issued_at {
182        return Err(VerifyError::NotYetValid);
183    }
184    if now >= envelope.body.expires_at {
185        return Err(VerifyError::Expired);
186    }
187
188    Ok(VerifiedPassport {
189        subject: envelope.body.subject.clone(),
190        issuer: envelope.body.issuer.clone(),
191        issued_at: envelope.body.issued_at,
192        expires_at: envelope.body.expires_at,
193        evaluated_at: now,
194        payload_canonical_bytes: envelope.body.payload_canonical_bytes.clone(),
195    })
196}
197
198/// Hex (de)serialization for the payload byte blob. JSON can't carry a
199/// raw `Vec<u8>` round-trippably, and Chio already uses lowercase hex
200/// for `Signature` / `PublicKey` wire encoding, so the envelope payload
201/// follows the same convention.
202mod payload_bytes_hex {
203    use alloc::string::String;
204    use alloc::vec::Vec;
205
206    use serde::{Deserialize, Deserializer, Serialize, Serializer};
207
208    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
209        encode_hex(bytes).serialize(serializer)
210    }
211
212    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
213        let hex_str = String::deserialize(deserializer)?;
214        decode_hex(&hex_str).map_err(serde::de::Error::custom)
215    }
216
217    fn encode_hex(bytes: &[u8]) -> String {
218        let mut out = String::with_capacity(bytes.len() * 2);
219        for byte in bytes {
220            let hi = NIBBLES[(byte >> 4) as usize];
221            let lo = NIBBLES[(byte & 0x0f) as usize];
222            out.push(hi);
223            out.push(lo);
224        }
225        out
226    }
227
228    fn decode_hex(hex_str: &str) -> Result<Vec<u8>, &'static str> {
229        if !hex_str.len().is_multiple_of(2) {
230            return Err("odd-length hex string");
231        }
232        let bytes_in = hex_str.as_bytes();
233        let mut out = Vec::with_capacity(bytes_in.len() / 2);
234        let mut idx = 0;
235        while idx < bytes_in.len() {
236            let hi = from_hex_nibble(bytes_in[idx])?;
237            let lo = from_hex_nibble(bytes_in[idx + 1])?;
238            out.push((hi << 4) | lo);
239            idx += 2;
240        }
241        Ok(out)
242    }
243
244    const NIBBLES: [char; 16] = [
245        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
246    ];
247
248    fn from_hex_nibble(byte: u8) -> Result<u8, &'static str> {
249        match byte {
250            b'0'..=b'9' => Ok(byte - b'0'),
251            b'a'..=b'f' => Ok(byte - b'a' + 10),
252            b'A'..=b'F' => Ok(byte - b'A' + 10),
253            _ => Err("invalid hex character"),
254        }
255    }
256}