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}