diff --git a/xyz-iinuwa-credential-manager-portal-gtk/Cargo.lock b/xyz-iinuwa-credential-manager-portal-gtk/Cargo.lock index 5dd90dcc..6fb4c1a3 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/Cargo.lock +++ b/xyz-iinuwa-credential-manager-portal-gtk/Cargo.lock @@ -323,6 +323,29 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "aws-lc-rs" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -365,6 +388,29 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -443,7 +489,7 @@ dependencies = [ "dbus", "dbus-tokio", "futures", - "itertools", + "itertools 0.13.0", "log", "serde", "serde-xml-rs", @@ -494,12 +540,24 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytemuck" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" @@ -556,6 +614,8 @@ version = "1.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -565,6 +625,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.17.2" @@ -622,6 +691,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -900,6 +989,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1072,6 +1167,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -1375,6 +1476,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1616,6 +1723,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "http" version = "1.3.1" @@ -1633,6 +1749,17 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1662,6 +1789,15 @@ dependencies = [ "heapless", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1712,6 +1848,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1737,6 +1883,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.172" @@ -1752,10 +1904,20 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.48.5", +] + [[package]] name = "libwebauthn" version = "0.1.2" -source = "git+https://github.com/linux-credentials/libwebauthn?rev=79e4b280a8585317ec774a3fa49f8711c74fde5a#79e4b280a8585317ec774a3fa49f8711c74fde5a" +source = "git+https://github.com/linux-credentials/libwebauthn?rev=528af8de3adfbc329b6bd6dca7bf48714e674f96#528af8de3adfbc329b6bd6dca7bf48714e674f96" dependencies = [ "aes", "async-trait", @@ -2383,6 +2545,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2411,6 +2583,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quote" version = "1.0.40" @@ -2553,6 +2734,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2603,7 +2790,10 @@ version = "0.23.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ + "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2637,6 +2827,7 @@ version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3497,6 +3688,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3788,11 +3991,14 @@ dependencies = [ "async-trait", "base64", "cosey", + "futures-lite", "gettext-rs", "gtk4", "libwebauthn", "openssl", + "qrcode", "ring", + "rustls", "serde", "serde_json", "tokio", diff --git a/xyz-iinuwa-credential-manager-portal-gtk/Cargo.toml b/xyz-iinuwa-credential-manager-portal-gtk/Cargo.toml index 85ad6aec..ce66920f 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/Cargo.toml +++ b/xyz-iinuwa-credential-manager-portal-gtk/Cargo.toml @@ -20,9 +20,12 @@ serde_json = "1.0.140" tracing = "0.1.41" tracing-subscriber = "0.3" zbus = "5.5.0" -libwebauthn = { git = "https://github.com/linux-credentials/libwebauthn", rev = "79e4b280a8585317ec774a3fa49f8711c74fde5a" } +libwebauthn = { git = "https://github.com/linux-credentials/libwebauthn", rev = "528af8de3adfbc329b6bd6dca7bf48714e674f96" } async-trait = "0.1.88" -tokio = { version = "1", features = ["rt-multi-thread"] } +tokio = { version = "1.45.0", features = ["rt-multi-thread"] } +futures-lite = "2.6.0" +qrcode = "0.14.1" # this is temporary until we move COSE -> Vec serialization methods into libwebauthn cosey = "0.3.2" +rustls = { version = "0.23.27", default-features = false, features = ["std", "tls12", "ring", "log", "logging", "prefer-post-quantum"] } diff --git a/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui b/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui index 3f63456a..77fe2e7c 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui +++ b/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui @@ -119,6 +119,51 @@ + + + hybrid_qr + Scan the QR code to connect your device + + + vertical + + + + + + ExampleApplicationWindow + + + + + + + + + + + ExampleApplicationWindow + + + + + + + + + + + ExampleApplicationWindow + + + + + + + + + + choose_credential diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/cose.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/cose.rs index b798033f..d55c0430 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/cose.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/cose.rs @@ -47,6 +47,7 @@ impl TryFrom for CoseKeyAlgorithmIdentifier { debug!("Unknown public key algorithm type: {:?}", value); Err(Error::Unsupported) } + Ctap2COSEAlgorithmIdentifier::Unknown => Err(Error::Unsupported), } } } diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/hybrid.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/hybrid.rs new file mode 100644 index 00000000..60f1569c --- /dev/null +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/hybrid.rs @@ -0,0 +1,256 @@ +use std::fmt::Debug; +use std::task::Poll; + +use async_std::channel::Receiver; +use async_std::stream::Stream; +use futures_lite::FutureExt; +use libwebauthn::fido::{AuthenticatorData, AuthenticatorDataFlags}; +use libwebauthn::ops::webauthn::{Assertion, GetAssertionResponse}; +use libwebauthn::proto::ctap2::{Ctap2PublicKeyCredentialDescriptor, Ctap2Transport}; +use libwebauthn::transport::cable::qr_code_device::{CableQrCodeDevice, QrCodeOperationHint}; +use libwebauthn::transport::Device; +use libwebauthn::webauthn::{Error as WebAuthnError, WebAuthn}; + +use crate::{dbus::CredentialRequest, tokio_runtime}; + +use super::AuthenticatorResponse; + +pub(crate) trait HybridHandler { + type Stream: Stream; + fn start(&self, request: &CredentialRequest) -> Self::Stream; +} + +#[derive(Debug)] +pub struct InternalHybridHandler {} +impl InternalHybridHandler { + pub fn new() -> Self { + Self {} + } +} + +impl HybridHandler for InternalHybridHandler { + type Stream = InternalHybridStream; + + fn start(&self, request: &CredentialRequest) -> Self::Stream { + let request = request.clone(); + let (tx, rx) = async_std::channel::unbounded(); + async_std::task::spawn(async move { + let hint = match request { + CredentialRequest::CreatePublicKeyCredentialRequest(_) => { + QrCodeOperationHint::MakeCredential + } + CredentialRequest::GetPublicKeyCredentialRequest(_) => { + QrCodeOperationHint::GetAssertionRequest + } + }; + let mut device = CableQrCodeDevice::new_transient(hint); + let qr_code = device.qr_code.to_string(); + if let Err(err) = tx.send(HybridStateInternal::Init(qr_code)).await { + tracing::error!("Failed to send caBLE update: {:?}", err); + return; + }; + tokio_runtime::get().spawn(async move { + let (mut channel, _) = device.channel().await.unwrap(); + let response: AuthenticatorResponse = loop { + match &request { + CredentialRequest::CreatePublicKeyCredentialRequest(make_request) => { + match channel.webauthn_make_credential(&make_request).await { + Ok(response) => break Ok(response.into()), + Err(WebAuthnError::Ctap(ctap_error)) => { + if ctap_error.is_retryable_user_error() { + tracing::debug!("Oops, try again! Error: {}", ctap_error); + continue; + } + break Err(WebAuthnError::Ctap(ctap_error)); + } + Err(err) => break Err(err), + }; + } + CredentialRequest::GetPublicKeyCredentialRequest(get_request) => { + match channel.webauthn_get_assertion(&get_request).await { + Ok(response) => break Ok(response.into()), + Err(WebAuthnError::Ctap(ctap_error)) => { + if ctap_error.is_retryable_user_error() { + println!("Oops, try again! Error: {}", ctap_error); + continue; + } + break Err(WebAuthnError::Ctap(ctap_error)); + } + Err(err) => break Err(err), + }; + } + } + } + .unwrap(); + if let Err(err) = tx.send(HybridStateInternal::Completed(response)).await { + tracing::error!("Failed to send caBLE update: {:?}", err) + } + }); + }); + InternalHybridStream { rx } + } +} + +pub struct InternalHybridStream { + rx: Receiver, +} + +impl Stream for InternalHybridStream { + type Item = HybridStateInternal; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + match self.rx.recv().poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(state)) => Poll::Ready(Some(state)), + Poll::Ready(Err(_)) => Poll::Ready(None), + } + } +} + +#[derive(Debug)] +pub struct DummyHybridHandler { + stream: DummyHybridStateStream, +} + +impl DummyHybridHandler { + #[cfg(test)] + pub fn new(states: Vec) -> Self { + Self { + stream: DummyHybridStateStream { states }, + } + } +} + +impl Default for DummyHybridHandler { + fn default() -> Self { + Self { + stream: DummyHybridStateStream::default(), + } + } +} +impl HybridHandler for DummyHybridHandler { + type Stream = DummyHybridStateStream; + + fn start(&self, _request: &CredentialRequest) -> Self::Stream { + self.stream.clone() + } +} + +#[derive(Clone, Debug)] +pub struct DummyHybridStateStream { + states: Vec, +} + +impl Default for DummyHybridStateStream { + fn default() -> Self { + let qr_code = String::from("FIDO:/078241338926040702789239694720083010994762289662861130514766991835876383562063181103169246410435938367110394959927031730060360967994421343201235185697538107096654083332"); + // SHA256("webauthn.io") + let rp_id_hash = [ + 0x74, 0xa6, 0xea, 0x92, 0x13, 0xc9, 0x9c, 0x2f, 0x74, 0xb2, 0x24, 0x92, 0xb3, 0x20, + 0xcf, 0x40, 0x26, 0x2a, 0x94, 0xc1, 0xa9, 0x50, 0xa0, 0x39, 0x7f, 0x29, 0x25, 0xb, + 0x60, 0x84, 0x1e, 0xf0, + ]; + + let auth_data = AuthenticatorData { + rp_id_hash, + flags: AuthenticatorDataFlags::USER_PRESENT | AuthenticatorDataFlags::USER_VERIFIED, + signature_count: 1, + attested_credential: None, + extensions: None, + }; + + let assertion = Assertion { + credential_id: Some(Ctap2PublicKeyCredentialDescriptor { + id: vec![0xca, 0xb1, 0xe].into(), + r#type: libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialType::PublicKey, + transports: Some(vec![Ctap2Transport::Hybrid]), + }), + authenticator_data: auth_data, + signature: Vec::new(), + user: None, + credentials_count: Some(1), + user_selected: None, + large_blob_key: None, + unsigned_extensions_output: None, + enterprise_attestation: None, + attestation_statement: None, + }; + let response = GetAssertionResponse { + assertions: vec![assertion], + }; + DummyHybridStateStream { + states: vec![ + HybridStateInternal::Init(qr_code), + HybridStateInternal::Waiting, + HybridStateInternal::Connecting, + HybridStateInternal::Completed(response.into()), + ], + } + } +} + +impl Stream for DummyHybridStateStream { + type Item = HybridStateInternal; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> Poll> { + if self.states.len() == 0 { + Poll::Ready(None) + } else { + Poll::Ready(Some((self.get_mut()).states.remove(0))) + } + } +} + +#[derive(Clone, Debug)] +pub enum HybridStateInternal { + /// The FIDO string to be displayed to the user, which contains QR secret + /// and public key. + Init(String), + + /// Awaiting BLE advert from phone. + Waiting, + /// BLE advertisement has been received from phone, tunnel is being established + Connecting, + + /// Authenticator data + Completed(AuthenticatorResponse), + + // This isn't actually sent from the server. + UserCancelled, +} + +#[derive(Clone, Debug)] +pub enum HybridState { + /// The FIDO string to be displayed to the user, which contains QR secret + /// and public key. + Init(String), + + /// Awaiting BLE advert from phone. + Waiting, + /// BLE advertisement has been received from phone, tunnel is being established + Connecting, + + /// Authenticator data + Completed, + + // This isn't actually sent from the server. + UserCancelled, +} + +impl From for HybridState { + fn from(value: HybridStateInternal) -> Self { + match value { + HybridStateInternal::Init(qr_code) => HybridState::Init(qr_code), + HybridStateInternal::Waiting => HybridState::Waiting, + HybridStateInternal::Connecting => HybridState::Connecting, + HybridStateInternal::Completed(_) => HybridState::Completed, + HybridStateInternal::UserCancelled => HybridState::UserCancelled, + } + } +} diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs index 7931ad2e..2bac255e 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs @@ -1,8 +1,14 @@ +pub mod hybrid; + use std::{ - sync::{Arc, Mutex, OnceLock}, + fmt::Debug, + sync::{Arc, Mutex}, + task::Poll, time::Duration, }; +use async_std::stream::Stream; +use futures_lite::{FutureExt, StreamExt}; use libwebauthn::{ self, ops::webauthn::{GetAssertionResponse, MakeCredentialResponse}, @@ -15,7 +21,6 @@ use async_std::{ channel::TryRecvError, sync::{Arc as AsyncArc, Mutex as AsyncMutex}, }; -use tokio::runtime::Runtime; use tracing::{debug, warn}; use crate::{ @@ -23,11 +28,14 @@ use crate::{ CredentialRequest, CredentialResponse, GetAssertionResponseInternal, MakeCredentialResponseInternal, }, + tokio_runtime, view_model::{Device, Transport}, }; +use hybrid::{HybridHandler, HybridState, HybridStateInternal}; + #[derive(Debug)] -pub struct CredentialService { +pub struct CredentialService { devices: Vec, usb_state: AsyncArc>, @@ -36,17 +44,26 @@ pub struct CredentialService { cred_request: CredentialRequest, // Place to store data to be returned to the caller cred_response: Arc>>, + + hybrid_handler: H, } -impl CredentialService { +impl CredentialService { pub fn new( cred_request: CredentialRequest, cred_response: Arc>>, + hybrid_handler: H, ) -> Self { - let devices = vec![Device { - id: String::from("0"), - transport: Transport::Usb, - }]; + let devices = vec![ + Device { + id: String::from("0"), + transport: Transport::Usb, + }, + Device { + id: String::from("1"), + transport: Transport::HybridQr, + }, + ]; let usb_state = AsyncArc::new(AsyncMutex::new(UsbState::Idle)); Self { devices, @@ -56,6 +73,8 @@ impl CredentialService { cred_request, cred_response, + + hybrid_handler, } } @@ -85,7 +104,7 @@ impl CredentialService { let mut expected_answers = hid_devices.len(); for mut device in hid_devices { let tx = blinking_tx.clone(); - tokio().spawn(async move { + tokio_runtime::get().spawn(async move { let (mut channel, _state_rx) = device.channel().await.unwrap(); let res = channel .blink_and_wait_for_user_presence(Duration::from_secs(300)) @@ -125,9 +144,9 @@ impl CredentialService { let cred_request = self.cred_request.clone(); let signal_tx = self.usb_uv_handler.signal_tx.clone(); let pin_rx = self.usb_uv_handler.pin_rx.clone(); - tokio().spawn(async move { + tokio_runtime::get().spawn(async move { let (mut channel, state_rx) = device.channel().await.unwrap(); - tokio().spawn(async move { + tokio_runtime::get().spawn(async move { handle_usb_updates(signal_tx, pin_rx, state_rx).await; debug!("Reached end of USB update task"); }); @@ -317,6 +336,72 @@ impl CredentialService { // let mut data = self.output_data.lock().unwrap(); // data.replace((self.cred_response)); } + + pub(crate) fn get_hybrid_credential(&self) -> HybridStateStream { + let stream = self.hybrid_handler.start(&self.cred_request); + HybridStateStream { + inner: stream, + cred_response: self.cred_response.clone(), + } + } +} + +pub struct HybridStateStream { + inner: H, + cred_response: Arc>>, +} + +impl Stream for HybridStateStream +where + H: Stream + Unpin + Sized, +{ + type Item = HybridState; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let cred_response = &self.cred_response.clone(); + match Box::pin(Box::pin(self).as_mut().inner.next()).poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Some(state)) => { + if let HybridStateInternal::Completed(hybrid_response) = &state { + let response = match hybrid_response { + AuthenticatorResponse::CredentialCreated(make_response) => { + CredentialResponse::CreatePublicKeyCredentialResponse( + MakeCredentialResponseInternal::new( + make_response.clone(), + vec![String::from("hybrid")], + String::from("cross-platform"), + ), + ) + } + + AuthenticatorResponse::CredentialsAsserted(GetAssertionResponse { + assertions, + }) if assertions.len() == 1 => { + CredentialResponse::GetPublicKeyCredentialResponse( + GetAssertionResponseInternal::new( + assertions[0].clone(), + String::from("cross-platform"), + ), + ) + } + AuthenticatorResponse::CredentialsAsserted(GetAssertionResponse { + assertions, + }) => { + assert!(!assertions.is_empty()); + todo!("need to support selection from multiple credentials"); + } + }; + let mut cred_response = cred_response.lock().unwrap(); + cred_response.replace(response); + } + Poll::Ready(Some(state.into())) + } + Poll::Ready(None) => Poll::Ready(None), + } + } } #[derive(Clone, Debug, Default)] @@ -454,12 +539,152 @@ enum UsbUvMessage { NeedsUserPresence, ReceivedCredential(AuthenticatorResponse), } -fn tokio() -> &'static Runtime { - static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME.get_or_init(|| Runtime::new().expect("Tokio runtime to start")) -} +#[derive(Debug, Clone)] enum AuthenticatorResponse { CredentialCreated(MakeCredentialResponse), CredentialsAsserted(GetAssertionResponse), } + +impl From for AuthenticatorResponse { + fn from(value: MakeCredentialResponse) -> Self { + Self::CredentialCreated(value) + } +} + +impl From for AuthenticatorResponse { + fn from(value: GetAssertionResponse) -> Self { + Self::CredentialsAsserted(value) + } +} + +#[cfg(test)] +mod test { + use std::sync::{Arc, Mutex}; + + use async_std::stream::StreamExt; + + use crate::dbus::{ + CreateCredentialRequest, CreatePublicKeyCredentialRequest, CredentialRequest, + }; + + use super::{ + hybrid::{DummyHybridHandler, HybridStateInternal}, + AuthenticatorResponse, CredentialService, + }; + + #[test] + fn test_hybrid_sets_credential() { + let request = create_credential_request(); + let response = Arc::new(Mutex::new(None)); + let qr_code = String::from("FIDO:/078241338926040702789239694720083010994762289662861130514766991835876383562063181103169246410435938367110394959927031730060360967994421343201235185697538107096654083332"); + let authenticator_response = create_authenticator_response(); + + let hybrid_handler = DummyHybridHandler::new(vec![ + HybridStateInternal::Init(qr_code), + HybridStateInternal::Waiting, + HybridStateInternal::Connecting, + HybridStateInternal::Completed(authenticator_response), + ]); + let cred_service = CredentialService::new(request, response, hybrid_handler); + let mut stream = cred_service.get_hybrid_credential(); + async_std::task::block_on(async { while let Some(_) = stream.next().await {} }); + assert!(cred_service.cred_response.lock().unwrap().is_some()); + } + + fn create_credential_request() -> CredentialRequest { + let request_json = r#" + { + "rp": { + "name": "webauthn.io", + "id": "webauthn.io" + }, + "user": { + "id": "d2ViYXV0aG5pby0xMjM4OTF5", + "name": "123891y", + "displayName": "123891y" + }, + "challenge": "Ox0AXQz7WUER7BGQFzvVrQbReTkS3sepVGj26qfUhhrWSarkDbGF4T4NuCY1aAwHYzOzKMJJ2YRSatetl0D9bQ", + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -8 + }, + { + "type": "public-key", + "alg": -7 + }, + { + "type": "public-key", + "alg": -257 + } + ], + "timeout": 60000, + "excludeCredentials": [], + "authenticatorSelection": { + "residentKey": "preferred", + "requireResidentKey": false, + "userVerification": "preferred" + }, + "attestation": "none", + "hints": [], + "extensions": { + "credProps": true + } + }"#.to_string(); + let (req, _) = CreateCredentialRequest { + origin: Some("webauthn.io".to_string()), + is_same_origin: Some(true), + r#type: "public-key".to_string(), + public_key: Some(CreatePublicKeyCredentialRequest { + request_json: request_json, + }), + } + .try_into_ctap2_request() + .unwrap(); + CredentialRequest::CreatePublicKeyCredentialRequest(req) + } + + fn create_authenticator_response() -> AuthenticatorResponse { + use libwebauthn::{ + fido::{AuthenticatorData, AuthenticatorDataFlags}, + ops::webauthn::{Assertion, GetAssertionResponse}, + proto::ctap2::{Ctap2PublicKeyCredentialDescriptor, Ctap2Transport}, + }; + // SHA256("webauthn.io") + let rp_id_hash = [ + 0x74, 0xa6, 0xea, 0x92, 0x13, 0xc9, 0x9c, 0x2f, 0x74, 0xb2, 0x24, 0x92, 0xb3, 0x20, + 0xcf, 0x40, 0x26, 0x2a, 0x94, 0xc1, 0xa9, 0x50, 0xa0, 0x39, 0x7f, 0x29, 0x25, 0xb, + 0x60, 0x84, 0x1e, 0xf0, + ]; + + let auth_data = AuthenticatorData { + rp_id_hash, + flags: AuthenticatorDataFlags::USER_PRESENT | AuthenticatorDataFlags::USER_VERIFIED, + signature_count: 1, + attested_credential: None, + extensions: None, + }; + + let assertion = Assertion { + credential_id: Some(Ctap2PublicKeyCredentialDescriptor { + id: vec![0xca, 0xb1, 0xe].into(), + r#type: libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialType::PublicKey, + transports: Some(vec![Ctap2Transport::Hybrid]), + }), + authenticator_data: auth_data, + signature: Vec::new(), + user: None, + credentials_count: Some(1), + user_selected: None, + large_blob_key: None, + unsigned_extensions_output: None, + enterprise_attestation: None, + attestation_statement: None, + }; + GetAssertionResponse { + assertions: vec![assertion], + } + .into() + } +} diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs index 002cdaee..cfdee7a6 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs @@ -28,6 +28,7 @@ use zbus::{ use crate::application::ExampleApplication; use crate::config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE}; +use crate::credential_service::hybrid::InternalHybridHandler; use crate::credential_service::CredentialService; use crate::view_model::CredentialType; use crate::view_model::Operation; @@ -65,7 +66,11 @@ fn start_gui_thread(rx: Receiver<(CredentialRequest, Sender, - is_same_origin: Option, + pub(crate) origin: Option, + pub(crate) is_same_origin: Option, #[zvariant(rename = "type")] - r#type: String, + pub(crate) r#type: String, #[zvariant(rename = "publicKey")] - public_key: Option, + pub(crate) public_key: Option, } impl CreateCredentialRequest { - fn try_into_ctap2_request( + pub(crate) fn try_into_ctap2_request( &self, ) -> std::result::Result<(MakeCredentialRequest, String), webauthn::Error> { if self.public_key.is_none() { diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/main.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/main.rs index e91e3dcb..50e2a7ed 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/main.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/main.rs @@ -6,6 +6,7 @@ mod cose; mod credential_service; mod dbus; mod serde; +mod tokio_runtime; #[allow(dead_code)] mod view_model; mod webauthn; @@ -18,6 +19,10 @@ use async_std::task; fn main() { // Initialize logger tracing_subscriber::fmt::init(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + _ = tokio_runtime::get(); println!("Starting..."); task::block_on(run()).unwrap(); diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/meson.build b/xyz-iinuwa-credential-manager-portal-gtk/src/meson.build index a7e8227a..e44a361b 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/meson.build +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/meson.build @@ -55,3 +55,17 @@ cargo_build = custom_target( 'cp', backend_executable_name / 'src' / rust_target / backend_executable_name, '@OUTPUT@', ] ) + +test( + 'cargo-unit-tests', + cargo, + env: [cargo_env], + args: [ + 'test', + '--bins', + '--no-fail-fast', cargo_options, + '--', + '--nocapture', + ], + protocol: 'exitcode', +) diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/tokio_runtime.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/tokio_runtime.rs new file mode 100644 index 00000000..072fe98b --- /dev/null +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/tokio_runtime.rs @@ -0,0 +1,9 @@ +use std::sync::OnceLock; + +use tokio::runtime::Runtime; + +static RUNTIME: OnceLock = OnceLock::new(); + +pub fn get() -> &'static Runtime { + RUNTIME.get_or_init(|| Runtime::new().expect("Tokio runtime to start")) +} diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs index 8b2f911a..80f87119 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs @@ -3,10 +3,13 @@ pub mod device; use async_std::channel::{Receiver, Sender}; use glib::clone; -use gtk::gio; -use gtk::glib; +use gtk::gdk::Texture; +use gtk::gdk_pixbuf::Pixbuf; +use gtk::gio::{self, Cancellable, MemoryInputStream}; +use gtk::glib::{self, Bytes}; use gtk::prelude::*; use gtk::subclass::prelude::*; +use qrcode::QrCode; use tracing::debug; use self::credential::CredentialObject; @@ -53,6 +56,14 @@ mod imp { pub(super) tx: RefCell>>, // hybrid_qr_state: HybridState, // hybrid_qr_code_data: Option>, + #[property(get, set)] + pub qr_code_paintable: RefCell>, + + #[property(get, set)] + pub qr_code_visible: RefCell, + + #[property(get, set)] + pub qr_spinner_visible: RefCell, } // The central trait for subclassing a GObject @@ -140,7 +151,23 @@ impl ViewModel { ViewUpdate::UsbNeedsUserPresence => { view_model.set_prompt("Touch your device"); } + ViewUpdate::HybridNeedsQrCode(qr_code) => { + view_model.set_prompt("Scan the QR code with your device to begin authentication."); + let texture = view_model.draw_qr_code(&qr_code); + view_model.set_qr_code_paintable(&texture); + view_model.set_qr_code_visible(true); + view_model.set_qr_spinner_visible(true); + } + ViewUpdate::HybridConnecting => { + view_model.set_qr_code_visible(false); + _ = view_model.qr_code_paintable().take(); + view_model.set_prompt( + "Device connected. Follow the instructions on your device", + ); + view_model.set_qr_spinner_visible(true); + } ViewUpdate::Completed => { + view_model.set_qr_spinner_visible(false); view_model.set_completed(true); } } @@ -249,6 +276,9 @@ impl ViewModel { Transport::Usb => { self.set_prompt("Insert your security key."); } + Transport::HybridQr => { + self.set_prompt(""); + } Transport::Internal => {} _ => { todo!(); @@ -274,6 +304,15 @@ impl ViewModel { self.send_event(ViewEvent::UsbPinEntered(pin)).await; } + fn draw_qr_code(&self, qr_data: &str) -> Texture { + let qr_code = QrCode::new(qr_data).expect("QR code to be valid"); + let svg_xml = qr_code.render::().build(); + let stream = MemoryInputStream::from_bytes(&Bytes::from(svg_xml.as_bytes())); + let pixbuf = Pixbuf::from_stream_at_scale(&stream, 450, 450, true, None::<&Cancellable>) + .expect("SVG to render"); + Texture::for_pixbuf(&pixbuf) + } + fn get_sender(&self) -> Sender { let tx: Sender; { diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs index 0059ace8..642ba197 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs @@ -10,11 +10,13 @@ use async_std::{ }; use tracing::info; +// TODO: turn CredentialService into a trait so we don't have to do specify handler types manually. +use crate::credential_service::hybrid::InternalHybridHandler; use crate::credential_service::CredentialService; #[derive(Debug)] pub(crate) struct ViewModel { - credential_service: Arc>, + credential_service: Arc>>, tx_update: Sender, rx_event: Receiver, bg_update: Sender, @@ -41,7 +43,7 @@ pub(crate) struct ViewModel { impl ViewModel { pub(crate) fn new( operation: Operation, - credential_service: CredentialService, + credential_service: CredentialService, rx_event: Receiver, tx_update: Sender, ) -> Self { @@ -148,6 +150,9 @@ impl ViewModel { .cancel_device_discovery_usb() .await .unwrap(), + Transport::HybridQr => { + todo!("Implement cancellation for Hybrid QR"); + } _ => { todo!() } @@ -159,15 +164,6 @@ impl ViewModel { match device.transport { Transport::Usb => { let cred_service = self.credential_service.clone(); - /* - _ = self - .credential_service - .lock() - .await - .start_device_discovery_usb() - .await - .unwrap(); - */ let tx = self.bg_update.clone(); async_std::task::spawn(async move { // TODO: add cancellation @@ -202,6 +198,41 @@ impl ViewModel { } }); } + Transport::HybridQr => { + let tx = self.bg_update.clone(); + let cred_service = self.credential_service.clone(); + let mut stream = cred_service.lock().await.get_hybrid_credential(); + async_std::task::spawn(async move { + while let Some(state) = stream.next().await { + let state = state.into(); + match state { + HybridState::Idle => {} + HybridState::Started(_) => { + tx.send(BackgroundEvent::HybridQrStateChanged(state)) + .await + .unwrap(); + } + HybridState::Waiting => { + tx.send(BackgroundEvent::HybridQrStateChanged(state)) + .await + .unwrap(); + } + HybridState::Connecting => { + tx.send(BackgroundEvent::HybridQrStateChanged(state)) + .await + .unwrap(); + } + HybridState::Completed => { + tx.send(BackgroundEvent::HybridQrStateChanged(state)) + .await + .unwrap(); + } + HybridState::UserCancelled => break, + }; + } + tracing::debug!("Broke out of hybrid QR state stream"); + }); + } _ => { todo!() } @@ -291,6 +322,38 @@ impl ViewModel { UsbState::NotListening | UsbState::Waiting | UsbState::UserCancelled => {} } } + Event::Background(BackgroundEvent::HybridQrStateChanged(state)) => { + self.hybrid_qr_state = state.clone(); + tracing::debug!("Received HybridQrState::{:?}", &state); + match state { + HybridState::Idle => { + self.hybrid_qr_code_data = None; + } + HybridState::Started(qr_code) => { + self.hybrid_qr_code_data = Some(qr_code.clone().into_bytes()); + self.tx_update + .send(ViewUpdate::HybridNeedsQrCode(qr_code)) + .await + .unwrap(); + } + HybridState::Waiting => {} + HybridState::Connecting => { + self.hybrid_qr_code_data = None; + self.tx_update + .send(ViewUpdate::HybridConnecting) + .await + .unwrap(); + } + HybridState::Completed => { + self.hybrid_qr_code_data = None; + self.credential_service.lock().await.complete_auth(); + self.tx_update.send(ViewUpdate::Completed).await.unwrap(); + } + HybridState::UserCancelled => { + self.hybrid_qr_code_data = None; + } + }; + } }; } } @@ -315,11 +378,15 @@ pub enum ViewUpdate { UsbNeedsUserPresence, Completed, SelectingDevice, + + HybridNeedsQrCode(String), + HybridConnecting, } pub enum BackgroundEvent { UsbPressed, UsbStateChanged(UsbState), + HybridQrStateChanged(HybridState), } pub enum Event { @@ -358,10 +425,13 @@ pub enum HybridState { #[default] Idle, - /// Awaiting BLE advert from phone. + /// QR code flow is starting + Started(String), + + /// QR code is being displayed, awaiting QR code scan and BLE advert from phone. Waiting, - /// Connecting to caBLE tunnel. + /// BLE advert received, connecting to caBLE tunnel with shared secret. Connecting, /* I don't think is necessary to signal. @@ -375,6 +445,22 @@ pub enum HybridState { UserCancelled, } +impl From for HybridState { + fn from(value: crate::credential_service::hybrid::HybridState) -> Self { + match value { + crate::credential_service::hybrid::HybridState::Init(qr_code) => { + HybridState::Started(qr_code) + } + crate::credential_service::hybrid::HybridState::Waiting => HybridState::Waiting, + crate::credential_service::hybrid::HybridState::Connecting => HybridState::Connecting, + crate::credential_service::hybrid::HybridState::Completed => HybridState::Completed, + crate::credential_service::hybrid::HybridState::UserCancelled => { + HybridState::UserCancelled + } + } + } +} + #[derive(Debug)] pub enum Operation { Create { cred_type: CredentialType }, diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs index be5d16da..ea70f5c1 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs @@ -1,12 +1,13 @@ use std::cell::RefCell; use glib::Properties; -use gtk::prelude::*; +use gtk::gdk::Texture; use gtk::subclass::prelude::*; use gtk::{ gio, glib::{self, clone}, }; +use gtk::{prelude::*, Picture}; use crate::application::ExampleApplication; use crate::config::{APP_ID, PROFILE}; @@ -14,6 +15,8 @@ use crate::view_model::gtk::{device::DeviceObject, ViewModel}; use crate::view_model::Transport; mod imp { + use gtk::Picture; + use super::*; #[derive(Debug, Properties, gtk::CompositeTemplate)] @@ -31,6 +34,9 @@ mod imp { #[template_child] pub usb_pin_entry: TemplateChild, + + #[template_child] + pub qr_code_pic: TemplateChild, } #[gtk::template_callbacks] @@ -72,6 +78,7 @@ mod imp { view_model: RefCell::default(), stack: TemplateChild::default(), usb_pin_entry: TemplateChild::default(), + qr_code_pic: TemplateChild::default(), } } } @@ -146,6 +153,7 @@ impl ExampleApplicationWindow { let view_model = &self.view_model(); let view_model = view_model.as_ref().expect("view model to exist"); let stack: >k::Stack = &self.imp().stack.get(); + let qr_code_pic: &Picture = &self.imp().qr_code_pic.get(); view_model.connect_selected_device_notify(clone!( #[weak] stack, @@ -159,11 +167,22 @@ impl ExampleApplicationWindow { // If so, we need to transition this to choose_credential as well. // For now, we'll skip it. Ok(Transport::Usb) => stack.set_visible_child_name("usb"), + Ok(Transport::HybridQr) => stack.set_visible_child_name("hybrid_qr"), _ => {} }; } )); + view_model.connect_qr_code_paintable_notify(clone!( + #[weak] + qr_code_pic, + move |vm| { + let paintable = vm.qr_code_paintable(); + let paintable = paintable.and_downcast_ref::(); + qr_code_pic.set_paintable(paintable); + } + )); + view_model.connect_selected_credential_notify(clone!( #[weak] stack, @@ -179,6 +198,7 @@ impl ExampleApplicationWindow { .expect("selected device to exist at notify"); match d.transport().try_into() { Ok(Transport::Usb) => stack.set_visible_child_name("usb"), + Ok(Transport::HybridQr) => stack.set_visible_child_name("hybrid_qr"), _ => {} }; }