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}