Skip to main content

chio_kernel_mobile/
lib.rs

1//! Mobile FFI for the Chio kernel core.
2//!
3//! This adapter wraps the portable [`chio_kernel_core`](chio_kernel_core)
4//! surface in an ergonomic, JSON-in / JSON-out Rust API and projects it
5//! across the C ABI using UniFFI. The UDL file in
6//! `src/chio_kernel_mobile.udl` drives binding generation for Swift
7//! (iOS) and Kotlin (Android); see `bindings/README.md` for the bindgen
8//! workflow.
9//!
10//! # Why JSON-in / JSON-out
11//!
12//! Chio's type graph (capability tokens, scopes, receipts, passport
13//! envelopes) is large and deeply nested. Projecting every field into
14//! UDL would double the FFI surface for zero additional safety: the
15//! app-side Chio SDK already knows how to serialize these types, and
16//! the kernel-core entry points accept the parsed Rust structs. We
17//! marshal once via serde at the boundary and keep the UDL interface
18//! small.
19//!
20//! # Exposed entry points
21//!
22//! - [`evaluate`] -- evaluate a tool-call request against a capability.
23//! - [`sign_receipt`] -- sign an `ChioReceiptBody` with a 32-byte seed.
24//! - [`verify_capability`] -- offline capability verification.
25//! - [`verify_passport`] -- offline portable-passport envelope
26//!   verification.
27//! - [`attest_app_attest`] -- App Attest challenge entry point.
28//! - [`verify_app_attest_evidence`] -- App Attest evidence verifier.
29//! - [`attest_play_integrity`] -- Play Integrity challenge entry point.
30//! - [`verify_play_integrity_evidence`] -- Play Integrity JWS verifier.
31//! - [`verify_mobile_receipt`] -- mobile attestation receipt verifier.
32//!
33//! # Offline guarantees
34//!
35//! None of these entry points perform I/O. A mobile app can invoke
36//! the pure-verification entry points while offline -- for example to gate a sensitive tool
37//! call with a cached capability and queue the resulting receipt for
38//! upload when connectivity returns.
39//!
40//! # `unsafe` posture
41//!
42//! The crate source itself contains no `unsafe` code. UniFFI's
43//! build-script-generated scaffolding declares `#[no_mangle]`
44//! `extern "C"` symbols (required for the C ABI that Swift and Kotlin
45//! link against); that is trusted generated code, not crate-author
46//! code. We therefore do not apply `#![deny(unsafe_code)]` at the
47//! crate root because it would also reject the generated scaffolding.
48//! An equivalent hand-written lint applies to every module in this
49//! crate via `#![forbid(unsafe_code)]` on each module below except
50//! where the scaffolding is pulled in.
51
52// UniFFI-generated scaffolding (included at the bottom of this file)
53// emits a `const UNIFFI_META_CONST_UDL_*` item preceded by a doc
54// comment that has a blank line between it and the item. Rustc +
55// clippy in the strict workspace configuration flag that as
56// `empty-line-after-doc-comments`; since we don't author the
57// generated file, we allow it crate-wide here.
58#![allow(clippy::empty_line_after_doc_comments)]
59
60mod clock;
61mod errors;
62mod rng;
63
64pub use clock::MobileClock;
65pub use errors::ChioMobileError;
66pub use rng::MobileRng;
67
68use serde::{Deserialize, Serialize};
69
70use chio_core_types::capability::{
71    attenuation::ScopeHash, crypto_floor::CapabilityCryptoFloor, features::CapabilityNegotiation,
72    token::CapabilityToken,
73};
74use chio_core_types::crypto::{Ed25519Backend, Keypair, PublicKey};
75use chio_core_types::receipt::body::ChioReceiptBody;
76use chio_custody_hw::{
77    verify_app_attest, verify_mobile_receipt_chain, verify_play_integrity,
78    AppAttestVerificationInput, AttestationError, PlayIntegrityVerificationInput,
79};
80use chio_kernel_core::passport_verify::{verify_passport as core_verify_passport, VerifyError};
81use chio_kernel_core::{
82    evaluate_with_full_floor_and_root, sign_receipt as core_sign_receipt,
83    sign_receipt_relaying_trusted_body as core_relay_trusted_body,
84    verify_capability_full_with_root, BudgetRegistry, BudgetSplitError, CapabilityError,
85    CapabilityFeatureContext, Clock, EvaluateInput, FixedClock, Guard, InMemoryBudgetRegistry,
86    PortableToolCallRequest, ReceiptSigningError, Verdict,
87};
88
89// ---------------------------------------------------------------------------
90// UniFFI record types (mirror `VerifiedCapability` / `VerifiedPassport`).
91// ---------------------------------------------------------------------------
92
93/// Verified capability snapshot projected across the FFI.
94///
95/// Mirrors [`chio_kernel_core::VerifiedCapability`] but swaps the
96/// structured `ChioScope` for its canonical JSON encoding. Mobile
97/// callers that want to inspect the scope pass `scope_json` through
98/// their host-side Chio SDK decoder.
99#[derive(Debug, Clone)]
100pub struct VerifiedCapability {
101    pub id: String,
102    pub subject_hex: String,
103    pub issuer_hex: String,
104    pub scope_json: String,
105    pub issued_at: u64,
106    pub expires_at: u64,
107    pub evaluated_at: u64,
108}
109
110/// Verified portable-passport envelope metadata projected across the FFI.
111///
112/// Mirrors [`chio_kernel_core::passport_verify::VerifiedPassport`] with
113/// the payload byte blob rendered as lowercase hex so Swift / Kotlin
114/// can surface it as `Data` / `ByteArray` after a single decode.
115#[derive(Debug, Clone)]
116pub struct PortablePassportMetadata {
117    pub subject: String,
118    pub issuer_hex: String,
119    pub issued_at: u64,
120    pub expires_at: u64,
121    pub evaluated_at: u64,
122    pub payload_canonical_hex: String,
123}
124
125// ---------------------------------------------------------------------------
126// `evaluate` wire format.
127// ---------------------------------------------------------------------------
128
129/// Shape of the JSON object accepted by [`evaluate`].
130///
131/// Deliberately flat so mobile hosts can build it with `serde_json::json!`
132/// (Swift / Kotlin) without extra envelope types.
133#[derive(Debug, Deserialize)]
134struct EvaluateRequest {
135    /// Capability token JSON (serialized `CapabilityToken`).
136    capability: serde_json::Value,
137    /// Trusted issuer public keys, lowercase hex (Ed25519).
138    trusted_issuers: Vec<String>,
139    /// Portable tool-call request.
140    request: EvaluateRequestBody,
141    /// Optional Unix timestamp. `None` / missing / <= 0 falls back to
142    /// [`MobileClock`].
143    #[serde(default)]
144    now_secs: Option<i64>,
145    /// Optional peer-negotiated feature profile. When omitted, mobile
146    /// defaults to a single-trusted-CA `t1_default` profile with current
147    /// chain-binding semantics enabled.
148    #[serde(default)]
149    peer_capabilities: Option<CapabilityNegotiation>,
150    /// Authenticated direct-root token for delegated negotiated family features.
151    #[serde(default)]
152    direct_root_capability: Option<serde_json::Value>,
153    /// Optional chain-binding trust roots, keyed by issuer hex. Attenuated or
154    /// delegated tokens require an entry; absent issuers fail-closed.
155    #[serde(default)]
156    capability_trust_roots: std::collections::BTreeMap<String, ScopeHash>,
157    /// Optional parent-budget snapshots used to seed sibling-sum
158    /// enforcement before delegated tokens are evaluated.
159    #[serde(default)]
160    parent_budget_snapshots: Vec<ParentBudgetSnapshot>,
161}
162
163/// Shape of the JSON object accepted by [`verify_capability_with_context`].
164#[derive(Debug, Deserialize)]
165struct VerifyCapabilityRequest {
166    /// Capability token JSON (serialized `CapabilityToken`).
167    token: serde_json::Value,
168    /// Trusted issuer public keys, lowercase hex (Ed25519).
169    trusted_issuers: Vec<String>,
170    /// Optional Unix timestamp. `None` / missing / < 0 falls back to
171    /// [`MobileClock`].
172    #[serde(default)]
173    now_secs: Option<i64>,
174    /// Optional peer-negotiated profile.
175    #[serde(default)]
176    peer_capabilities: Option<CapabilityNegotiation>,
177    /// Authenticated direct-root token for delegated negotiated family features.
178    #[serde(default)]
179    direct_root_capability: Option<serde_json::Value>,
180    /// Optional chain-binding trust roots, keyed by issuer hex.
181    #[serde(default)]
182    capability_trust_roots: std::collections::BTreeMap<String, ScopeHash>,
183    /// Optional parent-budget snapshots used to seed sibling-sum
184    /// enforcement before delegated tokens are verified.
185    #[serde(default)]
186    parent_budget_snapshots: Vec<ParentBudgetSnapshot>,
187}
188
189#[derive(Debug, Clone, Deserialize)]
190struct ParentBudgetSnapshot {
191    parent_token_id: String,
192    parent_share_bps: u16,
193    #[serde(default)]
194    admitted_children: Vec<AdmittedChildBudget>,
195}
196
197#[derive(Debug, Clone, Deserialize)]
198struct AdmittedChildBudget {
199    child_token_id: String,
200    share_bps: u16,
201}
202
203/// Tool-call request payload.
204#[derive(Debug, Deserialize)]
205struct EvaluateRequestBody {
206    request_id: String,
207    tool_name: String,
208    server_id: String,
209    agent_id: String,
210    #[serde(default)]
211    arguments: serde_json::Value,
212    #[serde(default)]
213    governed_intent: Option<serde_json::Value>,
214    #[serde(default)]
215    approval_token: Option<serde_json::Value>,
216    #[serde(default)]
217    approval_tokens: Vec<serde_json::Value>,
218    #[serde(default)]
219    threshold_approval_proposal: Option<serde_json::Value>,
220    #[serde(default)]
221    supplemental_authorization: Option<serde_json::Value>,
222}
223
224impl EvaluateRequestBody {
225    fn has_unsupported_authorization_extensions(&self) -> bool {
226        self.governed_intent.is_some()
227            || self.approval_token.is_some()
228            || !self.approval_tokens.is_empty()
229            || self.threshold_approval_proposal.is_some()
230            || self.supplemental_authorization.is_some()
231    }
232}
233
234/// Shape of the JSON object returned by [`evaluate`].
235#[derive(Debug, Serialize)]
236struct EvaluateResponse {
237    verdict: &'static str,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    reason: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    matched_grant_index: Option<usize>,
242}
243
244// ---------------------------------------------------------------------------
245// FFI entry points.
246// ---------------------------------------------------------------------------
247
248fn decode_hex_argument(label: &str, value: &str) -> Result<Vec<u8>, ChioMobileError> {
249    let trimmed = value.strip_prefix("0x").unwrap_or(value);
250    if trimmed.is_empty() {
251        return Err(ChioMobileError::InvalidHex {
252            message: format!("{label}: value must not be empty"),
253        });
254    }
255    hex::decode(trimmed).map_err(|error| ChioMobileError::InvalidHex {
256        message: format!("{label}: {error}"),
257    })
258}
259
260/// Decode the canonical-content preimage for WYSIWYS signing.
261///
262/// Unlike [`decode_hex_argument`], an empty hex string (or a bare `0x`) decodes
263/// to an empty `Vec<u8>` rather than being rejected. A zero-chunk stream receipt
264/// has an empty-byte canonical preimage, which encodes to the empty hex string;
265/// rejecting it would make valid empty-stream receipts fail closed at the mobile
266/// signer even though their `content_hash` is the sha256 of the empty preimage.
267/// All non-empty values still go through the strict hex decode.
268fn decode_canonical_content_hex(value: &str) -> Result<Vec<u8>, ChioMobileError> {
269    let trimmed = value.strip_prefix("0x").unwrap_or(value);
270    if trimmed.is_empty() {
271        return Ok(Vec::new());
272    }
273    decode_hex_argument("canonical content", value)
274}
275
276fn map_attestation_error(error: AttestationError) -> ChioMobileError {
277    ChioMobileError::AttestationRejected {
278        message: format!("{}: {error}", error.urn()),
279    }
280}
281
282fn chio_hash(bytes: &[u8]) -> [u8; 32] {
283    use sha2::{Digest, Sha256};
284    Sha256::digest(bytes).into()
285}
286
287fn fixed_clock_from_secs(now_secs: i64) -> Option<FixedClock> {
288    if now_secs < 0 {
289        None
290    } else {
291        Some(FixedClock::new(now_secs as u64))
292    }
293}
294
295fn seed_budget_registry(
296    budgets: &mut InMemoryBudgetRegistry,
297    snapshots: &[ParentBudgetSnapshot],
298) -> Result<(), ChioMobileError> {
299    for snapshot in snapshots {
300        budgets
301            .register_parent(snapshot.parent_token_id.clone(), snapshot.parent_share_bps)
302            .map_err(|error| budget_seed_error("parent budget snapshot", &error))?;
303        for child in &snapshot.admitted_children {
304            budgets
305                .try_admit_child(
306                    snapshot.parent_token_id.as_str(),
307                    child.child_token_id.clone(),
308                    child.share_bps,
309                )
310                .map_err(|error| budget_seed_error("admitted child budget snapshot", &error))?;
311        }
312    }
313    Ok(())
314}
315
316fn budget_seed_error(context: &str, error: &BudgetSplitError) -> ChioMobileError {
317    ChioMobileError::InvalidCapability {
318        message: format!("{context}: {error}"),
319    }
320}
321
322fn decode_trusted_issuers(values: &[String]) -> Result<Vec<PublicKey>, ChioMobileError> {
323    values
324        .iter()
325        .map(|hex_str| {
326            PublicKey::from_hex(hex_str).map_err(|error| ChioMobileError::InvalidHex {
327                message: format!("trusted issuer: {error}"),
328            })
329        })
330        .collect()
331}
332
333/// Evaluate a tool-call request against a capability token.
334///
335/// Input: a JSON object matching [`EvaluateRequest`]. Output: a JSON
336/// object describing the verdict. Returns an error only when the
337/// inputs cannot be parsed; a kernel-core `Deny` verdict is encoded
338/// in the JSON response so callers can render the reason without
339/// unwrapping an exception.
340pub fn evaluate(request_json: String) -> Result<String, ChioMobileError> {
341    let parsed: EvaluateRequest =
342        serde_json::from_str(&request_json).map_err(|error| ChioMobileError::InvalidJson {
343            message: format!("evaluate request: {error}"),
344        })?;
345    if parsed.request.has_unsupported_authorization_extensions() {
346        return Err(ChioMobileError::InvalidCapability {
347            message: "mobile portable evaluation cannot authenticate governed approvals or supplemental authorization".to_string(),
348        });
349    }
350
351    let capability: CapabilityToken =
352        serde_json::from_value(parsed.capability).map_err(|error| {
353            ChioMobileError::InvalidJson {
354                message: format!("capability token: {error}"),
355            }
356        })?;
357    let direct_root_capability = parsed
358        .direct_root_capability
359        .map(serde_json::from_value)
360        .transpose()
361        .map_err(|error| ChioMobileError::InvalidJson {
362            message: format!("direct root capability: {error}"),
363        })?;
364
365    let trusted = decode_trusted_issuers(&parsed.trusted_issuers)?;
366
367    let portable_request = PortableToolCallRequest {
368        request_id: parsed.request.request_id,
369        tool_name: parsed.request.tool_name,
370        server_id: parsed.request.server_id,
371        agent_id: parsed.request.agent_id,
372        arguments: parsed.request.arguments,
373    };
374
375    // Clock selection: if the host supplied a positive `now_secs` we
376    // honour it (useful for deterministic testing harnesses on the
377    // Swift/Kotlin side); otherwise fall back to `MobileClock`.
378    let fixed_clock: Option<FixedClock> = match parsed.now_secs {
379        Some(secs) if secs > 0 => Some(FixedClock::new(secs as u64)),
380        _ => None,
381    };
382    let mobile_clock = MobileClock::new();
383    let clock: &dyn Clock = match &fixed_clock {
384        Some(c) => c,
385        None => &mobile_clock,
386    };
387
388    // Mobile callers don't register custom guards today; the kernel
389    // core still runs the full check pipeline (signature, time,
390    // subject binding, scope) with an empty guard slice.
391    let guards: &[&dyn Guard] = &[];
392
393    // Route through `evaluate_with_full_floor` so attenuated or delegated
394    // tokens are bound to a registered trust-root scope hash before dispatch.
395    let peer_profile = parsed
396        .peer_capabilities
397        .clone()
398        .unwrap_or_else(CapabilityNegotiation::t1_default);
399    let trust_root_map = parsed.capability_trust_roots.clone();
400    let trust_resolver = move |issuer: &PublicKey| -> Option<ScopeHash> {
401        trust_root_map.get(&issuer.to_hex()).cloned()
402    };
403
404    // Seed the per-request registry from caller-owned parent snapshots
405    // so delegated tokens can be evaluated without fabricating missing
406    // parent shares.
407    let mut budgets = InMemoryBudgetRegistry::new();
408    seed_budget_registry(&mut budgets, &parsed.parent_budget_snapshots)?;
409    let verdict = evaluate_with_full_floor_and_root(
410        EvaluateInput {
411            request: &portable_request,
412            capability: &capability,
413            trusted_issuers: &trusted,
414            clock,
415            guards,
416            session_filesystem_roots: None,
417        },
418        CapabilityCryptoFloor::AllowClassical,
419        &peer_profile,
420        direct_root_capability.as_ref(),
421        &trust_resolver,
422        &mut budgets,
423    );
424
425    let response = match verdict.verdict {
426        Verdict::Allow => EvaluateResponse {
427            verdict: "allow",
428            reason: None,
429            matched_grant_index: verdict.matched_grant_index,
430        },
431        Verdict::Deny => EvaluateResponse {
432            verdict: "deny",
433            reason: verdict.reason,
434            matched_grant_index: verdict.matched_grant_index,
435        },
436        Verdict::PendingApproval => EvaluateResponse {
437            verdict: "deny",
438            reason: Some(
439                "kernel-core returned PendingApproval; mobile FFI treats as fail-closed deny"
440                    .to_string(),
441            ),
442            matched_grant_index: verdict.matched_grant_index,
443        },
444    };
445
446    serde_json::to_string(&response).map_err(|error| ChioMobileError::Internal {
447        message: format!("serialize evaluate response: {error}"),
448    })
449}
450
451/// Build a verified `Ed25519Backend` from a lowercase-hex 32-byte seed,
452/// rejecting wrong-length and all-zero seeds fail-closed.
453fn backend_from_seed_hex(signing_seed_hex: &str) -> Result<Ed25519Backend, ChioMobileError> {
454    let seed_bytes = decode_hex_argument("signing seed", signing_seed_hex)?;
455    if seed_bytes.len() != 32 {
456        return Err(ChioMobileError::InvalidHex {
457            message: format!(
458                "signing seed: expected 32-byte Ed25519 seed, got {} bytes",
459                seed_bytes.len()
460            ),
461        });
462    }
463    if seed_bytes.iter().all(|byte| *byte == 0) {
464        return Err(ChioMobileError::WeakEntropy {
465            message: "refusing to sign with an all-zero Ed25519 seed".to_string(),
466        });
467    }
468    let mut seed = [0u8; 32];
469    seed.copy_from_slice(&seed_bytes);
470    let keypair = Keypair::from_seed(&seed);
471    Ok(Ed25519Backend::new(keypair))
472}
473
474/// Map a kernel-core [`ReceiptSigningError`] onto the mobile FFI error surface.
475fn map_signing_error(error: ReceiptSigningError) -> ChioMobileError {
476    match error {
477        ReceiptSigningError::KernelKeyMismatch => ChioMobileError::KernelKeyMismatch {
478            message: "receipt body kernel_key does not match the public key derived from the signing seed".to_string(),
479        },
480        // WYSIWYS mismatch. The public `sign_receipt` recomputes
481        // `content_hash` over the caller-supplied canonical content preimage
482        // inside the trust boundary and produces this variant on a
483        // render-A / sign-B mismatch. Surfaced as a distinct, fail-closed error.
484        ReceiptSigningError::ContentHashMismatch { recomputed, claimed } => {
485            ChioMobileError::SigningFailed {
486                message: format!(
487                    "receipt content_hash mismatch: body claimed {claimed} but signer recomputed {recomputed} over the canonical content (WYSIWYS refused)"
488                ),
489            }
490        }
491        ReceiptSigningError::SigningFailed(msg) => ChioMobileError::SigningFailed { message: msg },
492    }
493}
494
495/// Sign a receipt body with the Ed25519 seed `signing_seed_hex` (PUBLIC WYSIWYS
496/// signer; fail-closed).
497///
498/// WYSIWYS: `canonical_content_hex` is the lowercase-hex encoding of
499/// the exact byte preimage `body.content_hash` was derived from. The signer
500/// recomputes `sha256_hex(canonical_content)` *inside the trust boundary* via
501/// `chio_kernel_core::sign_receipt` and refuses to sign when it disagrees with
502/// `body.content_hash`. This closes the render-A / sign-B forgery at the mobile
503/// boundary: a caller can no longer render content A while signing a body
504/// claiming hash(B). The public signer does NOT relay a trusted body; callers
505/// that only forward an upstream-minted body and cannot carry the preimage must
506/// use [`sign_receipt_relaying_trusted_body`] through the relay seam.
507///
508/// The receipt body's `kernel_key` must equal the public key derived from the
509/// seed; otherwise the kernel-core signer fails fast with
510/// [`ReceiptSigningError::KernelKeyMismatch`].
511///
512/// Returns the signed `ChioReceipt` as JSON so the caller can queue it
513/// for upload to the receipt-log sink.
514pub fn sign_receipt(
515    body_json: String,
516    canonical_content_hex: String,
517    signing_seed_hex: String,
518) -> Result<String, ChioMobileError> {
519    let body: ChioReceiptBody =
520        serde_json::from_str(&body_json).map_err(|error| ChioMobileError::InvalidJson {
521            message: format!("receipt body: {error}"),
522        })?;
523
524    let canonical_content = decode_canonical_content_hex(&canonical_content_hex)?;
525    let backend = backend_from_seed_hex(&signing_seed_hex)?;
526
527    let receipt =
528        core_sign_receipt(body, &backend, &canonical_content).map_err(map_signing_error)?;
529
530    serde_json::to_string(&receipt).map_err(|error| ChioMobileError::Internal {
531        message: format!("serialize signed receipt: {error}"),
532    })
533}
534
535/// Relay-sign an already-minted, upstream-trusted receipt body.
536///
537/// This is NOT the default public signer. It trusts the caller-supplied
538/// `body.content_hash` and does NOT recompute it, routing through
539/// `chio_kernel_core::sign_receipt_relaying_trusted_body`. It exists only to
540/// forward a body an upstream trusted producer (the kernel) already minted,
541/// where the WYSIWYS recompute already ran. Content-bearing mobile callers that
542/// construct receipts at the boundary MUST use [`sign_receipt`] instead so the
543/// recompute gate runs over the canonical content preimage.
544///
545/// The receipt body's `kernel_key` must equal the public key derived from the
546/// seed; otherwise the kernel-core signer fails fast with
547/// [`ReceiptSigningError::KernelKeyMismatch`].
548pub fn sign_receipt_relaying_trusted_body(
549    body_json: String,
550    signing_seed_hex: String,
551) -> Result<String, ChioMobileError> {
552    let body: ChioReceiptBody =
553        serde_json::from_str(&body_json).map_err(|error| ChioMobileError::InvalidJson {
554            message: format!("receipt body: {error}"),
555        })?;
556
557    let backend = backend_from_seed_hex(&signing_seed_hex)?;
558
559    let receipt = core_relay_trusted_body(body, &backend).map_err(map_signing_error)?;
560
561    serde_json::to_string(&receipt).map_err(|error| ChioMobileError::Internal {
562        message: format!("serialize signed receipt: {error}"),
563    })
564}
565
566/// Verify a capability token against a single trusted authority key.
567///
568/// Uses [`MobileClock`] to evaluate the time-bound window. Adapters
569/// that need a pinned clock should call [`evaluate`] with `now_secs`
570/// populated instead.
571pub fn verify_capability(
572    token_json: String,
573    authority_pub_hex: String,
574) -> Result<VerifiedCapability, ChioMobileError> {
575    let token: CapabilityToken =
576        serde_json::from_str(&token_json).map_err(|error| ChioMobileError::InvalidJson {
577            message: format!("capability token: {error}"),
578        })?;
579
580    let authority =
581        PublicKey::from_hex(&authority_pub_hex).map_err(|error| ChioMobileError::InvalidHex {
582            message: format!("authority public key: {error}"),
583        })?;
584
585    verify_capability_with_parts(
586        token,
587        vec![authority],
588        None,
589        CapabilityNegotiation::t1_default(),
590        None,
591        std::collections::BTreeMap::new(),
592        &[],
593    )
594}
595
596/// Verify a capability token with the full portable JSON context.
597///
598/// This entry point complements [`verify_capability`] by giving mobile
599/// hosts a way to pass trust roots and parent-budget snapshots for
600/// delegated tokens.
601pub fn verify_capability_with_context(
602    request_json: String,
603) -> Result<VerifiedCapability, ChioMobileError> {
604    let parsed: VerifyCapabilityRequest =
605        serde_json::from_str(&request_json).map_err(|error| ChioMobileError::InvalidJson {
606            message: format!("verify capability request: {error}"),
607        })?;
608    let token: CapabilityToken =
609        serde_json::from_value(parsed.token).map_err(|error| ChioMobileError::InvalidJson {
610            message: format!("capability token: {error}"),
611        })?;
612    let direct_root_capability = parsed
613        .direct_root_capability
614        .map(serde_json::from_value)
615        .transpose()
616        .map_err(|error| ChioMobileError::InvalidJson {
617            message: format!("direct root capability: {error}"),
618        })?;
619    let trusted = decode_trusted_issuers(&parsed.trusted_issuers)?;
620    let peer_profile = parsed
621        .peer_capabilities
622        .clone()
623        .unwrap_or_else(CapabilityNegotiation::t1_default);
624
625    verify_capability_with_parts(
626        token,
627        trusted,
628        parsed.now_secs,
629        peer_profile,
630        direct_root_capability,
631        parsed.capability_trust_roots,
632        &parsed.parent_budget_snapshots,
633    )
634}
635
636fn verify_capability_with_parts(
637    token: CapabilityToken,
638    trusted: Vec<PublicKey>,
639    now_secs: Option<i64>,
640    peer_profile: CapabilityNegotiation,
641    direct_root_capability: Option<CapabilityToken>,
642    capability_trust_roots: std::collections::BTreeMap<String, ScopeHash>,
643    parent_budget_snapshots: &[ParentBudgetSnapshot],
644) -> Result<VerifiedCapability, ChioMobileError> {
645    let fixed_clock = now_secs.and_then(fixed_clock_from_secs);
646    let mobile_clock = MobileClock::new();
647    let clock: &dyn Clock = match &fixed_clock {
648        Some(clock) => clock,
649        None => &mobile_clock,
650    };
651    let trust_resolver = |issuer: &PublicKey| -> Option<ScopeHash> {
652        capability_trust_roots.get(&issuer.to_hex()).cloned()
653    };
654    let mut budgets = InMemoryBudgetRegistry::new();
655    seed_budget_registry(&mut budgets, parent_budget_snapshots)?;
656    let verified = verify_capability_full_with_root(
657        &token,
658        &trusted,
659        clock,
660        CapabilityCryptoFloor::AllowClassical,
661        CapabilityFeatureContext {
662            peer: &peer_profile,
663            direct_root: direct_root_capability.as_ref(),
664        },
665        &trust_resolver,
666        &mut budgets,
667    )
668    .map_err(|error| match error {
669        CapabilityError::UntrustedIssuer => ChioMobileError::InvalidCapability {
670            message: "capability issuer is not in the trusted authority set".to_string(),
671        },
672        CapabilityError::InvalidSignature => ChioMobileError::InvalidCapability {
673            message: "capability signature failed to verify".to_string(),
674        },
675        CapabilityError::NotYetValid => ChioMobileError::InvalidCapability {
676            message: "capability is not yet valid".to_string(),
677        },
678        CapabilityError::Expired => ChioMobileError::InvalidCapability {
679            message: "capability has expired".to_string(),
680        },
681        CapabilityError::CryptoFloorRejected(message) => ChioMobileError::InvalidCapability {
682            message: format!("capability crypto floor rejected: {message}"),
683        },
684        CapabilityError::AttenuationViolation(message) => ChioMobileError::InvalidCapability {
685            message: format!("capability rejected by chain binding: {message}"),
686        },
687        CapabilityError::BudgetSplitRejected(err) => ChioMobileError::InvalidCapability {
688            message: format!("capability rejected by sibling-sum budget split: {err}"),
689        },
690        CapabilityError::Internal(msg) => ChioMobileError::Internal {
691            message: format!("capability verification failed: {msg}"),
692        },
693    })?;
694
695    let scope_json =
696        serde_json::to_string(&verified.scope).map_err(|error| ChioMobileError::Internal {
697            message: format!("serialize capability scope: {error}"),
698        })?;
699
700    Ok(VerifiedCapability {
701        id: verified.id,
702        subject_hex: verified.subject_hex,
703        issuer_hex: verified.issuer_hex,
704        scope_json,
705        issued_at: verified.issued_at,
706        expires_at: verified.expires_at,
707        evaluated_at: verified.evaluated_at,
708    })
709}
710
711/// Verify a portable passport envelope.
712///
713/// `envelope_json` is the JSON-encoded `PortablePassportEnvelope`;
714/// `issuer_pub_hex` is the trusted authority public key. When
715/// `now_secs <= 0` the implementation falls back to [`MobileClock`].
716pub fn verify_passport(
717    envelope_json: String,
718    issuer_pub_hex: String,
719    now_secs: i64,
720) -> Result<PortablePassportMetadata, ChioMobileError> {
721    let issuer =
722        PublicKey::from_hex(&issuer_pub_hex).map_err(|error| ChioMobileError::InvalidHex {
723            message: format!("authority public key: {error}"),
724        })?;
725
726    let fixed_clock: Option<FixedClock> = if now_secs > 0 {
727        Some(FixedClock::new(now_secs as u64))
728    } else {
729        None
730    };
731    let mobile_clock = MobileClock::new();
732    let clock: &dyn Clock = match &fixed_clock {
733        Some(c) => c,
734        None => &mobile_clock,
735    };
736
737    let verified =
738        core_verify_passport(envelope_json.as_bytes(), &[issuer], clock).map_err(|error| {
739            match error {
740                VerifyError::InvalidEnvelope(msg) => ChioMobileError::InvalidPassport {
741                    message: format!("invalid envelope: {msg}"),
742                },
743                VerifyError::InvalidSchema => ChioMobileError::InvalidPassport {
744                    message: "envelope schema tag does not match portable passport v1".to_string(),
745                },
746                VerifyError::MissingSubject => ChioMobileError::InvalidPassport {
747                    message: "envelope subject is empty".to_string(),
748                },
749                VerifyError::InvalidValidityWindow => ChioMobileError::InvalidPassport {
750                    message: "envelope validity window is inverted".to_string(),
751                },
752                VerifyError::UntrustedIssuer => ChioMobileError::InvalidPassport {
753                    message: "envelope issuer is not in the trusted authority set".to_string(),
754                },
755                VerifyError::InvalidSignature => ChioMobileError::InvalidPassport {
756                    message: "envelope signature failed to verify".to_string(),
757                },
758                VerifyError::NotYetValid => ChioMobileError::InvalidPassport {
759                    message: "envelope is not yet valid".to_string(),
760                },
761                VerifyError::Expired => ChioMobileError::InvalidPassport {
762                    message: "envelope has expired".to_string(),
763                },
764                VerifyError::Internal(msg) => ChioMobileError::Internal {
765                    message: format!("passport verification failed: {msg}"),
766                },
767            }
768        })?;
769
770    Ok(PortablePassportMetadata {
771        subject: verified.subject,
772        issuer_hex: verified.issuer.to_hex(),
773        issued_at: verified.issued_at,
774        expires_at: verified.expires_at,
775        evaluated_at: verified.evaluated_at,
776        payload_canonical_hex: hex::encode(&verified.payload_canonical_bytes),
777    })
778}
779
780/// Produce an App Attest challenge envelope bound to `challenge_hex`.
781///
782/// The iOS host app still calls DeviceCheck to produce the platform
783/// attestation object. This entry point returns the exact server
784/// challenge envelope the native platform evidence must bind to before
785/// `verify_app_attest_evidence` accepts it.
786pub fn attest_app_attest(key_id: String, challenge_hex: String) -> Result<String, ChioMobileError> {
787    let challenge = decode_hex_argument("App Attest challenge", &challenge_hex)?;
788    if key_id.trim().is_empty() {
789        return Err(ChioMobileError::AttestationRejected {
790            message: "App Attest key_id is empty".to_string(),
791        });
792    }
793
794    serde_json::to_string(&serde_json::json!({
795        "schema": "chio.mobile.app-attest.challenge.v1",
796        "platform": "app_attest",
797        "key_id": key_id,
798        "challenge_hex": hex::encode(&challenge),
799        "challenge_sha256": hex::encode(chio_hash(&challenge)),
800        "verifier": "chio-custody-hw::attestation::verify_app_attest",
801        "status": "requires_platform_evidence"
802    }))
803    .map_err(|error| ChioMobileError::Internal {
804        message: format!("serialize App Attest challenge envelope: {error}"),
805    })
806}
807
808/// Verify App Attest platform evidence against the issued challenge.
809pub fn verify_app_attest_evidence(
810    key_id: String,
811    challenge_hex: String,
812    app_id: String,
813    attestation_cbor_hex: String,
814    previous_counter: i64,
815) -> Result<String, ChioMobileError> {
816    let challenge = decode_hex_argument("App Attest challenge", &challenge_hex)?;
817    let attestation_cbor =
818        decode_hex_argument("App Attest attestation object", &attestation_cbor_hex)?;
819    let previous_counter = if previous_counter < 0 {
820        None
821    } else {
822        let counter = u32::try_from(previous_counter).map_err(|error| {
823            ChioMobileError::AttestationRejected {
824                message: format!("App Attest previous_counter: {error}"),
825            }
826        })?;
827        Some(counter)
828    };
829    let verified = verify_app_attest(AppAttestVerificationInput {
830        attestation_cbor: &attestation_cbor,
831        key_id: &key_id,
832        challenge: &challenge,
833        app_id: &app_id,
834        previous_counter,
835        production: true,
836        allow_development_fixture: false,
837    })
838    .map_err(map_attestation_error)?;
839
840    serde_json::to_string(&serde_json::json!({
841        "schema": "chio.mobile.attestation-evidence.v1",
842        "platform": "app_attest",
843        "key_id": verified.key_id,
844        "app_id": verified.app_id,
845        "counter": verified.counter,
846        "challenge_hash": verified.challenge_hash_hex,
847        "app_id_hash": verified.app_id_hash_hex,
848        "credential_public_key_sha256": verified.credential_public_key_sha256_hex,
849        "apple_root_sha256": verified.root_fingerprint_sha256_hex
850    }))
851    .map_err(|error| ChioMobileError::Internal {
852        message: format!("serialize App Attest evidence envelope: {error}"),
853    })
854}
855
856/// Produce a Play Integrity challenge envelope bound to `nonce_hex`.
857///
858/// The Android host app still calls the Play Integrity API to produce the
859/// JWS. This entry point returns the nonce envelope that the JWS must bind
860/// to before `verify_play_integrity_evidence` accepts it.
861pub fn attest_play_integrity(nonce_hex: String) -> Result<String, ChioMobileError> {
862    let nonce = decode_hex_argument("Play Integrity nonce", &nonce_hex)?;
863    serde_json::to_string(&serde_json::json!({
864        "schema": "chio.mobile.play-integrity.challenge.v1",
865        "platform": "play_integrity",
866        "nonce_hex": hex::encode(&nonce),
867        "nonce_sha256": hex::encode(chio_hash(&nonce)),
868        "verifier": "chio-custody-hw::attestation::verify_play_integrity",
869        "status": "requires_platform_evidence"
870    }))
871    .map_err(|error| ChioMobileError::Internal {
872        message: format!("serialize Play Integrity challenge envelope: {error}"),
873    })
874}
875
876/// Verify a Play Integrity JWS against an issuer nonce and JWKS.
877pub fn verify_play_integrity_evidence(
878    token: String,
879    expected_nonce: String,
880    expected_package_name: String,
881    expected_audience: String,
882    jwks_json: String,
883) -> Result<String, ChioMobileError> {
884    let verified = verify_play_integrity(PlayIntegrityVerificationInput {
885        token: &token,
886        expected_nonce: &expected_nonce,
887        expected_package_name: &expected_package_name,
888        expected_audience: &expected_audience,
889        jwks_json: &jwks_json,
890        allow_caller_supplied_jwks: false,
891    })
892    .map_err(map_attestation_error)?;
893
894    serde_json::to_string(&serde_json::json!({
895        "schema": "chio.mobile.attestation-evidence.v1",
896        "platform": "play_integrity",
897        "package_name": verified.package_name,
898        "nonce": verified.nonce,
899        "app_recognition_verdict": verified.app_recognition_verdict,
900        "device_recognition_verdict": verified.device_recognition_verdict
901    }))
902    .map_err(|error| ChioMobileError::Internal {
903        message: format!("serialize Play Integrity evidence envelope: {error}"),
904    })
905}
906
907/// Shape-check a mobile receipt against App Attest or Play Integrity evidence.
908///
909/// This does not authorize a capability or prove device integrity. It returns
910/// an explicit non-authoritative status until full receipt-chain verification
911/// is wired to trusted issuer pins and challenge binding.
912pub fn verify_mobile_receipt(
913    receipt_json: String,
914    evidence_json: String,
915) -> Result<String, ChioMobileError> {
916    let _: serde_json::Value =
917        serde_json::from_str(&receipt_json).map_err(|error| ChioMobileError::InvalidJson {
918            message: format!("mobile receipt: {error}"),
919        })?;
920    let _: serde_json::Value =
921        serde_json::from_str(&evidence_json).map_err(|error| ChioMobileError::InvalidJson {
922            message: format!("mobile attestation evidence: {error}"),
923        })?;
924
925    let verified = verify_mobile_receipt_chain(&receipt_json, &evidence_json)
926        .map_err(map_attestation_error)?;
927    serde_json::to_string(&serde_json::json!({
928        "schema": "chio.mobile.receipt-verification.v1",
929        "status": "shape_only",
930        "receipt_kind": "trace_observation",
931        "boundary_class": "detect_only",
932        "result": "observed",
933        "authoritative": false,
934        "authorized": false,
935        "receipt_schema": verified.receipt_schema,
936        "evidence_schema": verified.evidence_schema,
937        "platform": verified.platform
938    }))
939    .map_err(|error| ChioMobileError::Internal {
940        message: format!("serialize mobile receipt verification: {error}"),
941    })
942}
943
944// ---------------------------------------------------------------------------
945// UniFFI scaffolding inclusion.
946// ---------------------------------------------------------------------------
947//
948// This macro pulls in the `extern "C"` shim that `build.rs` writes
949// into `$OUT_DIR`. The file name matches the UDL stem; UniFFI looks
950// up the scaffolding by that key. Must live at the crate root so the
951// generated symbols are visible to the linker.
952uniffi::include_scaffolding!("chio_kernel_mobile");