From f0d3cf42f88937d0aaf7165f53d638fb1b6bf7f7 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 22 Jun 2026 21:20:09 -0500 Subject: [PATCH] feat(ffi): external-token Ed25519 sign callback for federation-identity mint Adds `ciris_verify_create_federation_identity_with_callback` + a `CallbackHardwareSigner` (impl `HardwareSigner`) so a YubiKey-backed federation identity can be minted when the token is reachable only by an app's native layer (e.g. YubiKit over NFC on Android), not by this library. The core composes the `self_key_record` + the platform-sealed ML-DSA-65 half exactly as `create_federation_identity` does, and delegates ONLY the one classical Ed25519 signature to a caller-supplied C callback. The caller pre-reads the 32-byte Ed25519 public key + (optionally) the slot-9c PIV attestation DER, so the core never touches the token. Unit-tested (delegation plumbing + failure path). Builds on the v6.13.0 #112/#113 YubiKey provisioning fixes. Additive: a new FFI symbol + module, no breaking changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GG4YtiJZkpoJWeS8Y8bMdY --- Cargo.lock | 1 + src/ciris-verify-ffi/Cargo.toml | 1 + src/ciris-verify-ffi/src/callback_signer.rs | 235 ++++++++++++++++++++ src/ciris-verify-ffi/src/lib.rs | 151 +++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 src/ciris-verify-ffi/src/callback_signer.rs diff --git a/Cargo.lock b/Cargo.lock index b67b0362..0cbc8959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -873,6 +873,7 @@ version = "8.3.0" dependencies = [ "aes-gcm", "android_logger", + "async-trait", "base64 0.21.7", "cbindgen", "chrono", diff --git a/src/ciris-verify-ffi/Cargo.toml b/src/ciris-verify-ffi/Cargo.toml index de00bbf6..0a3b7197 100644 --- a/src/ciris-verify-ffi/Cargo.toml +++ b/src/ciris-verify-ffi/Cargo.toml @@ -44,6 +44,7 @@ ciris-keyring.workspace = true # (X25519 + ML-KEM-768) exposed on the wheel via `wheel_self_enc` — also always-on. ciris-crypto = { workspace = true, features = ["scope-privacy", "self-enc"] } tokio = { workspace = true, features = ["rt-multi-thread"] } +async-trait.workspace = true prost.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] } diff --git a/src/ciris-verify-ffi/src/callback_signer.rs b/src/ciris-verify-ffi/src/callback_signer.rs new file mode 100644 index 00000000..11763455 --- /dev/null +++ b/src/ciris-verify-ffi/src/callback_signer.rs @@ -0,0 +1,235 @@ +//! A [`HardwareSigner`] whose Ed25519 signature is produced by a **caller-supplied +//! C callback** — the seam for hardware that the Rust core cannot reach directly. +//! +//! Motivation: on mobile a YubiKey is reached over NFC/USB by the app's native +//! layer (YubiKit), not by this library. To mint a YubiKey-backed federation +//! identity (`ciris_verify_create_federation_identity_with_callback`), the core +//! composes the record and the ML-DSA half as usual but **delegates the one +//! classical Ed25519 signature** to the tapped token via this callback. The public +//! key + the optional PIV attestation are read by the caller and passed in, so the +//! core never touches the token. +//! +//! The callback is invoked synchronously (blocking) from `sign`, on the same thread +//! that drives the mint — the established pattern for the device-bound flows. + +use std::os::raw::c_void; + +use async_trait::async_trait; +use ciris_keyring::{ + ClassicalAlgorithm, HardwareSigner, HardwareType, KeyGenConfig, KeyringError, + PlatformAttestation, SoftwareAttestation, StorageDescriptor, +}; + +/// C ABI for the Ed25519 sign delegate. +/// +/// Writes a 64-byte EdDSA signature over `msg[0..msg_len]` into `out_sig` (capacity +/// `out_sig_cap`, must be ≥ 64), sets `*out_sig_len`, and returns `0` on success +/// (non-zero ⇒ the signer reports a hardware fault). `ctx` is the opaque pointer +/// passed to [`CallbackHardwareSigner::new`] (e.g. the native YubiKit session). +pub type FfiEd25519SignCallback = unsafe extern "C" fn( + ctx: *mut c_void, + msg: *const u8, + msg_len: usize, + out_sig: *mut u8, + out_sig_cap: usize, + out_sig_len: *mut usize, +) -> i32; + +/// Wraps the opaque callback context so the signer can be `Send + Sync` (required by +/// the trait). The pointer is only ever dereferenced by the caller's own callback, +/// on the single thread that drives the mint — never shared or moved across threads +/// by this type. +struct CallbackCtx(*mut c_void); +// SAFETY: the raw pointer is opaque to us; it is handed back verbatim to the +// caller's callback, which owns its thread-safety. The mint that uses this signer +// runs the callback on one thread within a single tap/connection window. +unsafe impl Send for CallbackCtx {} +unsafe impl Sync for CallbackCtx {} + +/// A [`HardwareSigner`] backed by an external Ed25519 token via a C callback. +pub struct CallbackHardwareSigner { + alias: String, + public_key: Vec, + /// Optional slot-9c PIV attestation cert (DER), supplied by the caller. + attestation_der: Vec, + ctx: CallbackCtx, + cb: FfiEd25519SignCallback, +} + +impl CallbackHardwareSigner { + /// `public_key` must be the 32-byte Ed25519 key the callback signs with. + pub fn new( + alias: String, + public_key: Vec, + attestation_der: Vec, + ctx: *mut c_void, + cb: FfiEd25519SignCallback, + ) -> Self { + Self { + alias, + public_key, + attestation_der, + ctx: CallbackCtx(ctx), + cb, + } + } +} + +#[async_trait] +impl HardwareSigner for CallbackHardwareSigner { + fn algorithm(&self) -> ClassicalAlgorithm { + ClassicalAlgorithm::Ed25519 + } + + fn hardware_type(&self) -> HardwareType { + HardwareType::ExternalSecureElement + } + + async fn public_key(&self) -> Result, KeyringError> { + Ok(self.public_key.clone()) + } + + async fn sign(&self, data: &[u8]) -> Result, KeyringError> { + let mut out = vec![0u8; 64]; + let mut out_len: usize = 0; + let rc = unsafe { + (self.cb)( + self.ctx.0, + data.as_ptr(), + data.len(), + out.as_mut_ptr(), + out.len(), + &mut out_len as *mut usize, + ) + }; + if rc != 0 { + return Err(KeyringError::HardwareError { + reason: format!("external Ed25519 sign callback failed (rc={rc})"), + }); + } + if out_len > out.len() { + return Err(KeyringError::HardwareError { + reason: format!("sign callback over-wrote the buffer ({out_len} > 64)"), + }); + } + out.truncate(out_len); + Ok(out) + } + + async fn attestation(&self) -> Result { + Ok(PlatformAttestation::Software(SoftwareAttestation { + key_derivation: "external-token-callback".into(), + storage: "external-secure-element".into(), + security_warning: if self.attestation_der.is_empty() { + "external Ed25519 token; no PIV attestation supplied".into() + } else { + "external YubiKey PIV slot-9c attestation supplied by caller".into() + }, + })) + } + + async fn generate_key(&self, _config: &KeyGenConfig) -> Result<(), KeyringError> { + Err(KeyringError::NotSupported { + operation: "in-band keygen — the external token owns the private key".into(), + }) + } + + async fn key_exists(&self, alias: &str) -> Result { + Ok(alias == self.alias) + } + + async fn delete_key(&self, _alias: &str) -> Result<(), KeyringError> { + Err(KeyringError::NotSupported { + operation: "key deletion — manage the external token out of band".into(), + }) + } + + fn current_alias(&self) -> &str { + &self.alias + } + + fn storage_descriptor(&self) -> StorageDescriptor { + StorageDescriptor::Hardware { + hardware_type: HardwareType::ExternalSecureElement, + blob_path: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A stub delegate: writes a deterministic 64-byte "signature" whose first byte + // encodes the message length, so the test can assert the callback saw the right + // bytes and that `sign` returns them verbatim (plumbing test — no real crypto). + unsafe extern "C" fn echo_sign( + _ctx: *mut c_void, + _msg: *const u8, + msg_len: usize, + out_sig: *mut u8, + out_sig_cap: usize, + out_sig_len: *mut usize, + ) -> i32 { + if out_sig_cap < 64 { + return 1; + } + for i in 0..64 { + *out_sig.add(i) = 0xAB; + } + *out_sig.add(0) = (msg_len & 0xff) as u8; + *out_sig_len = 64; + 0 + } + + unsafe extern "C" fn failing_sign( + _ctx: *mut c_void, + _msg: *const u8, + _msg_len: usize, + _out_sig: *mut u8, + _out_sig_cap: usize, + _out_sig_len: *mut usize, + ) -> i32 { + 7 + } + + fn block_on(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(f) + } + + #[test] + fn delegates_sign_and_reports_pubkey() { + let pk = vec![9u8; 32]; + let s = CallbackHardwareSigner::new( + "tester".into(), + pk.clone(), + vec![1, 2, 3], + std::ptr::null_mut(), + echo_sign, + ); + assert_eq!(block_on(s.public_key()).unwrap(), pk); + assert_eq!(s.algorithm(), ClassicalAlgorithm::Ed25519); + assert_eq!(s.current_alias(), "tester"); + + let sig = block_on(s.sign(b"hello")).unwrap(); // 5 bytes + assert_eq!(sig.len(), 64); + assert_eq!(sig[0], 5); // callback saw msg_len == 5 + assert_eq!(sig[1], 0xAB); + } + + #[test] + fn surfaces_callback_failure() { + let s = CallbackHardwareSigner::new( + "tester".into(), + vec![0u8; 32], + vec![], + std::ptr::null_mut(), + failing_sign, + ); + let err = block_on(s.sign(b"x")).unwrap_err(); + assert!(matches!(err, KeyringError::HardwareError { .. })); + } +} diff --git a/src/ciris-verify-ffi/src/lib.rs b/src/ciris-verify-ffi/src/lib.rs index 2e359a1f..edee6beb 100644 --- a/src/ciris-verify-ffi/src/lib.rs +++ b/src/ciris-verify-ffi/src/lib.rs @@ -46,6 +46,7 @@ #![allow(clippy::missing_safety_doc)] // FFI functions are inherently unsafe mod bootstrap_keyset; +mod callback_signer; mod conformance; mod constructor; @@ -1812,6 +1813,156 @@ pub unsafe extern "C" fn ciris_verify_create_federation_identity( }) } +/// Like [`ciris_verify_create_federation_identity`], but the Ed25519 half is an +/// EXTERNAL token reached via a caller-supplied sign callback (e.g. a YubiKey over +/// NFC on mobile, driven by YubiKit). The caller pre-reads the 32-byte Ed25519 +/// public key + (optionally) the slot-9c PIV attestation DER; the core composes the +/// `self_key_record` + the platform-sealed ML-DSA-65 half as usual and delegates +/// ONLY the classical signature to `sign_cb`. See the `callback_signer` module. +/// +/// # Safety +/// +/// `config_json` (NUL-terminated UTF-8) and `result_out` must be valid non-null +/// pointers. `ed25519_pubkey` must point to `pubkey_len` (== 32) bytes; +/// `attestation_der` may be NULL when `attestation_len` == 0. `sign_cb` is invoked +/// synchronously with `sign_ctx`. On success `*result_out` is a heap C string the +/// caller frees with `ciris_verify_free_string`. +#[no_mangle] +pub unsafe extern "C" fn ciris_verify_create_federation_identity_with_callback( + config_json: *const c_char, + ed25519_pubkey: *const u8, + pubkey_len: usize, + attestation_der: *const u8, + attestation_len: usize, + sign_ctx: *mut c_void, + sign_cb: crate::callback_signer::FfiEd25519SignCallback, + result_out: *mut *mut c_char, +) -> i32 { + ffi_guard!("ciris_verify_create_federation_identity_with_callback", { + if config_json.is_null() || result_out.is_null() || ed25519_pubkey.is_null() { + return CirisVerifyError::InvalidArgument as i32; + } + if pubkey_len != 32 { + return CirisVerifyError::InvalidArgument as i32; + } + let cfg_str = match std::ffi::CStr::from_ptr(config_json).to_str() { + Ok(s) => s, + Err(_) => return CirisVerifyError::InvalidArgument as i32, + }; + + let emit = |value: serde_json::Value| -> i32 { + match std::ffi::CString::new(value.to_string()) { + Ok(c) => { + *result_out = c.into_raw(); + CirisVerifyError::Success as i32 + }, + Err(_) => CirisVerifyError::InternalError as i32, + } + }; + let err = |msg: String| serde_json::json!({ "ok": false, "error": msg }); + + let cfg: serde_json::Value = match serde_json::from_str(cfg_str) { + Ok(v) => v, + Err(e) => return emit(err(format!("invalid config JSON: {e}"))), + }; + let alias = cfg + .get("alias") + .and_then(|v| v.as_str()) + .unwrap_or("federation-user") + .to_string(); + let identity_type = cfg + .get("identity_type") + .and_then(|v| v.as_str()) + .unwrap_or("user") + .to_string(); + let fed_key_id = cfg + .get("fed_key_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + let label = cfg + .get("label") + .and_then(|v| v.as_str()) + .map(str::to_string); + let seal_alias = cfg + .get("seal_alias") + .and_then(|v| v.as_str()) + .map(str::to_string); + let valid_from = cfg + .get("valid_from") + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + let write_outbox = cfg + .get("write_outbox") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + + let pubkey = std::slice::from_raw_parts(ed25519_pubkey, pubkey_len).to_vec(); + let attestation = if attestation_der.is_null() || attestation_len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(attestation_der, attestation_len).to_vec() + }; + + let signer: std::sync::Arc = + std::sync::Arc::new(crate::callback_signer::CallbackHardwareSigner::new( + alias, + pubkey, + attestation, + sign_ctx, + sign_cb, + )); + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => return emit(err(format!("runtime: {e}"))), + }; + + let outcome: Result = rt.block_on(async { + let created = ciris_verify_core::federation_identity::create_federation_identity( + signer, + &identity_type, + fed_key_id, + label.as_deref(), + &valid_from, + seal_alias.as_deref(), + ) + .await + .map_err(|e| format!("create identity: {e}"))?; + + let outbox_path = if write_outbox { + Some( + created + .object + .write_to_outbox(&created.key_id) + .map_err(|e| format!("write outbox: {e}"))? + .display() + .to_string(), + ) + } else { + None + }; + let ceg_object = serde_json::to_value(&created.object) + .map_err(|e| format!("serialize CEG object: {e}"))?; + Ok(serde_json::json!({ + "ok": true, + "key_id": created.key_id, + "code": created.code, + "outbox_path": outbox_path, + "ceg_object": ceg_object, + })) + }); + + match outcome { + Ok(v) => emit(v), + Err(msg) => emit(err(msg)), + } + }) +} + /// Free memory allocated by CIRISVerify functions. /// /// # Safety