Skip to main content

chio_kernel_core/
passport_verify.rs

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