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