diff --git a/Cargo.lock b/Cargo.lock index 332d3c737c..36c5bedcb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6468,6 +6468,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.1", "js-sys", + "serde_core", "sha1_smol", "wasm-bindgen", ] diff --git a/crypto-ffi/bindings/js/packages/browser/test/utils.ts b/crypto-ffi/bindings/js/packages/browser/test/utils.ts index 99f3f63e56..e923ec63ef 100644 --- a/crypto-ffi/bindings/js/packages/browser/test/utils.ts +++ b/crypto-ffi/bindings/js/packages/browser/test/utils.ts @@ -54,7 +54,8 @@ export async function setup() { async authenticate( _idp: string, _keyAuth: string, - _acmeAud: string + _acmeAud: string, + _acquisition_snapshot: Uint8Array ) { return "dummy-id-token"; }, diff --git a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/testutils/TestUtils.kt b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/testutils/TestUtils.kt index 0ce9bec9bd..09ae608ce9 100644 --- a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/testutils/TestUtils.kt +++ b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/testutils/TestUtils.kt @@ -74,7 +74,8 @@ class MockPkiEnvironmentHooks : PkiEnvironmentHooks { override suspend fun authenticate( idp: String, keyAuth: String, - acmeAud: String + acmeAud: String, + acquisitionSnapshot: ByteArray ): String { return "mock-id-token" } diff --git a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift index ffa425304d..21d75b3d3f 100644 --- a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift +++ b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift @@ -930,7 +930,8 @@ final class WireCoreCryptoTests: XCTestCase { func authenticate( idp: String, keyAuth: String, - acmeAud: String + acmeAud: String, + acquisitionSnapshot: Data ) async -> String { return "mock-id-token" } diff --git a/crypto-ffi/src/e2ei/mod.rs b/crypto-ffi/src/e2ei/mod.rs index 052b5ea3e2..95ff97ec65 100644 --- a/crypto-ffi/src/e2ei/mod.rs +++ b/crypto-ffi/src/e2ei/mod.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_lock::Mutex; use jwt_simple::prelude::{ES256KeyPair, ES384KeyPair, ES512KeyPair, Ed25519KeyPair}; -use wire_e2e_identity::{HashAlgorithm, JwsAlgorithm}; +use wire_e2e_identity::{HashAlgorithm, JwsAlgorithm, acquisition::states}; use x509_cert::der::Encode as _; use crate::{Ciphersuite as FfiCiphersuite, ClientId, CoreCryptoError, CoreCryptoResult, Credential, PkiEnvironment}; @@ -50,6 +50,17 @@ impl TryFrom for JwsAlgorithm { } } +impl From for FfiCiphersuite { + fn from(value: JwsAlgorithm) -> Self { + match value { + JwsAlgorithm::Ed25519 => FfiCiphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, + JwsAlgorithm::P256 => FfiCiphersuite::MLS_128_DHKEMP256_AES128GCM_SHA256_P256, + JwsAlgorithm::P384 => FfiCiphersuite::MLS_256_DHKEMP384_AES256GCM_SHA384_P384, + JwsAlgorithm::P521 => FfiCiphersuite::MLS_256_DHKEMP521_AES256GCM_SHA512_P521, + } + } +} + /// Configuration for an X509 credential acquisition flow. #[derive(Debug, Clone, uniffi::Record)] pub struct X509CredentialAcquisitionConfiguration { @@ -106,6 +117,7 @@ pub struct X509CredentialAcquisition { enum AcquisitionState { Initialized(Box), + DpopChallengeCompleted(Box>), InProgress, Finalized, } @@ -167,6 +179,23 @@ impl X509CredentialAcquisition { }) } + /// Deserialize a credential acquisition flow. + #[uniffi::constructor(name = "fromBytes")] + pub fn from_bytes(pki_environment: Arc, bytes: &[u8]) -> CoreCryptoResult { + let snapshot = wire_e2e_identity::X509CredentialAcquisition::::deserialize( + Arc::new(pki_environment.as_ref().clone().into()), + bytes, + ) + .map_err(CoreCryptoError::generic())?; + + let ciphersuite: FfiCiphersuite = snapshot.sign_alg().into(); + + Ok(Self { + state: Mutex::new(AcquisitionState::DpopChallengeCompleted(snapshot.into())), + ciphersuite, + }) + } + /// Complete the DPoP and OIDC challenges and return the acquired X509 credential. pub async fn finalize(&self) -> CoreCryptoResult { let state = { @@ -186,6 +215,14 @@ impl X509CredentialAcquisition { .map_err(|err| CoreCryptoError::E2ei { e2ei_error: err.to_string(), }), + AcquisitionState::DpopChallengeCompleted(inner) => { + inner + .complete_oidc_challenge() + .await + .map_err(|err| CoreCryptoError::E2ei { + e2ei_error: err.to_string(), + }) + } AcquisitionState::InProgress => { return Err(CoreCryptoError::ad_hoc( "x509 credential acquisition is already in progress", diff --git a/crypto-ffi/src/pki_env.rs b/crypto-ffi/src/pki_env.rs index 9322555524..5de8a13f18 100644 --- a/crypto-ffi/src/pki_env.rs +++ b/crypto-ffi/src/pki_env.rs @@ -159,6 +159,7 @@ pub trait PkiEnvironmentHooks: Send + Sync { idp: String, key_auth: String, acme_aud: String, + acquisition_snapshot: Vec, ) -> Result; /// Get a nonce from the backend. @@ -205,8 +206,12 @@ impl pki_env::hooks::PkiEnvironmentHooks for PkiEnvironmentHooksShim { idp: String, key_auth: String, acme_aud: String, + acquisition_snapshot: Vec, ) -> Result { - self.0.authenticate(idp, key_auth, acme_aud).await.map_err(Into::into) + self.0 + .authenticate(idp, key_auth, acme_aud, acquisition_snapshot) + .await + .map_err(Into::into) } async fn get_backend_nonce(&self) -> Result { diff --git a/crypto/src/test_utils/mod.rs b/crypto/src/test_utils/mod.rs index e84f7dccc8..14da94a5e2 100644 --- a/crypto/src/test_utils/mod.rs +++ b/crypto/src/test_utils/mod.rs @@ -512,6 +512,7 @@ impl PkiEnvironmentHooks for DummyPkiEnvironmentHooks { _idp: String, _key_auth: String, _acme_aud: String, + _acquisition_snapshot: Vec, ) -> Result { Ok("dummy-id-token".to_string()) } diff --git a/e2e-identity/Cargo.toml b/e2e-identity/Cargo.toml index 42db7492cf..5f83d9965e 100644 --- a/e2e-identity/Cargo.toml +++ b/e2e-identity/Cargo.toml @@ -32,7 +32,7 @@ jwt-simple.workspace = true derive_more = { workspace = true, features = ["deref", "from", "into"] } url.workspace = true zeroize.workspace = true -uuid.workspace = true +uuid = { workspace = true, features = ["serde"] } x509-cert = { workspace = true, features = ["builder", "hazmat"] } time = { workspace = true, features = [ "serde", diff --git a/e2e-identity/src/acquisition/mod.rs b/e2e-identity/src/acquisition/mod.rs index ed0f0a5baa..719e4c8257 100644 --- a/e2e-identity/src/acquisition/mod.rs +++ b/e2e-identity/src/acquisition/mod.rs @@ -17,17 +17,22 @@ mod dpop_challenge; mod error; mod initial; mod oidc_challenge; +mod serialization; pub mod identity; pub mod thumbprint; -#[derive(Debug)] +pub use error::Error as AcquisitionError; +pub use serialization::ClientIdDef; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct X509CredentialConfiguration { pub acme_url: String, pub idp_url: String, pub sign_alg: JwsAlgorithm, pub hash_alg: HashAlgorithm, pub display_name: String, + #[serde(with = "ClientIdDef")] pub client_id: ClientId, pub handle: String, pub domain: String, @@ -38,10 +43,10 @@ pub struct X509CredentialConfiguration { pub mod states { use crate::acme::{AcmeAccount, AcmeChallenge, AcmeOrder}; - #[derive(Debug)] + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct Initialized; - #[derive(Debug)] + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct DpopChallengeCompleted { pub nonce: String, pub acme_account: AcmeAccount, @@ -86,8 +91,10 @@ pub mod states { /// .complete_dpop_challenge().await? /// .complete_oidc_challenge().await?; /// ``` +#[derive(serde::Serialize)] pub struct X509CredentialAcquisition { /// A reference to the PKI environment that stores trust anchors. + #[serde(skip)] pki_env: Arc, /// The configuration used for acquisition. config: X509CredentialConfiguration, @@ -114,6 +121,11 @@ fn get_header(resp: &HttpResponse, header: &'static str) -> Result { } impl X509CredentialAcquisition { + /// The signing algorithm used for certificate acquisition. + pub fn sign_alg(&self) -> JwsAlgorithm { + self.config.sign_alg + } + /// Send an HTTP request to the ACME server and return the result in the form of a /// pair (nonce, deserialized JSON response). The nonce is returned so it can be /// used by the caller to construct the body of the next ACME request. diff --git a/e2e-identity/src/acquisition/oidc_challenge.rs b/e2e-identity/src/acquisition/oidc_challenge.rs index 9ae383698a..40a0a81587 100644 --- a/e2e-identity/src/acquisition/oidc_challenge.rs +++ b/e2e-identity/src/acquisition/oidc_challenge.rs @@ -20,10 +20,11 @@ impl X509CredentialAcquisition { let oidc_challenge_token = &self.data.oidc_challenge.token; let thumbprint = JwkThumbprint::generate(&self.acme_jwk, self.config.hash_alg)?.kid; let key_auth = format!("{oidc_challenge_token}.{thumbprint}"); + let snapshot = serde_json::to_vec(&self)?; let url = &self.data.oidc_challenge.url; let id_token = hooks - .authenticate(self.config.idp_url.clone(), key_auth, url.to_string()) + .authenticate(self.config.idp_url.clone(), key_auth, url.to_string(), snapshot) .await?; let oidc_challenge_request = RustyAcme::oidc_chall_request( diff --git a/e2e-identity/src/acquisition/serialization.rs b/e2e-identity/src/acquisition/serialization.rs new file mode 100644 index 0000000000..d9253c5f6c --- /dev/null +++ b/e2e-identity/src/acquisition/serialization.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use jwt_simple::prelude::Jwk; +use rusty_jwt_tools::prelude::{ClientId, Pem}; +use uuid::Uuid; + +use super::{Result, X509CredentialAcquisition, X509CredentialConfiguration, states}; +use crate::pki_env::PkiEnvironment; + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(remote = "ClientId")] +pub struct ClientIdDef { + /// base64url encoded UUIDv4 unique user identifier + pub user_id: Uuid, + /// the device id assigned by the backend in hex + pub device_id: u64, + /// the backend domain of the client + pub domain: String, +} + +#[derive(serde::Deserialize, serde::Serialize)] +struct X509CredentialAcquisitionSerialisationHelper { + config: X509CredentialConfiguration, + sign_kp: Pem, + acme_kp: Pem, + acme_jwk: Jwk, + data: T, +} + +impl X509CredentialAcquisition { + pub fn deserialize(pki_env: Arc, bytes: &[u8]) -> Result { + let helper: X509CredentialAcquisitionSerialisationHelper = + serde_json::from_slice(bytes)?; + + Ok(Self { + pki_env, + config: helper.config, + sign_kp: helper.sign_kp, + acme_kp: helper.acme_kp, + acme_jwk: helper.acme_jwk, + data: helper.data, + }) + } +} + +#[cfg(test)] +mod tests { + use core_crypto_keystore::{ConnectionType, Database, DatabaseKey}; + use rusty_jwt_tools::prelude::{HashAlgorithm, JwsAlgorithm}; + + use super::*; + use crate::{ + acme::{AcmeAccount, AcmeChallenge, AcmeOrder}, + pki_env::hooks::{HttpHeader, HttpMethod, HttpResponse, PkiEnvironmentHooks, PkiEnvironmentHooksError}, + }; + + #[derive(Debug)] + struct UnusedPkiEnvironmentHooks; + + #[async_trait::async_trait] + impl PkiEnvironmentHooks for UnusedPkiEnvironmentHooks { + async fn http_request( + &self, + _method: HttpMethod, + _url: String, + _headers: Vec, + _body: Vec, + ) -> std::result::Result { + unreachable!("serialization round-trip should not perform HTTP requests") + } + + async fn authenticate( + &self, + _idp: String, + _key_auth: String, + _acme_aud: String, + _acquisition_snapshot: Vec, + ) -> std::result::Result { + unreachable!("serialization round-trip should not authenticate") + } + + async fn get_backend_nonce(&self) -> std::result::Result { + unreachable!("serialization round-trip should not request backend nonces") + } + + async fn fetch_backend_access_token( + &self, + _dpop: String, + ) -> std::result::Result { + unreachable!("serialization round-trip should not fetch backend access tokens") + } + } + + #[tokio::test] + async fn can_serialize_and_deserialize_dpop_challenge_completed_acquisition() { + let pki_env = Arc::new( + PkiEnvironment::new( + Arc::new(UnusedPkiEnvironmentHooks), + Database::open(ConnectionType::InMemory, &DatabaseKey::generate()) + .await + .unwrap(), + ) + .await + .unwrap(), + ); + let client_id = ClientId::try_new(Uuid::new_v4().to_string(), 1, "wire.example").unwrap(); + let config = X509CredentialConfiguration { + acme_url: "acme.example".into(), + idp_url: "idp.example".into(), + sign_alg: JwsAlgorithm::P256, + hash_alg: HashAlgorithm::SHA256, + display_name: "Alice".into(), + client_id, + handle: "alice".into(), + domain: "wire.example".into(), + team: Some("team".into()), + validity_period: std::time::Duration::from_secs(3600), + }; + let initialized = X509CredentialAcquisition::try_new(pki_env.clone(), config).unwrap(); + let acquisition = X509CredentialAcquisition:: { + pki_env: initialized.pki_env, + config: initialized.config, + sign_kp: initialized.sign_kp, + acme_kp: initialized.acme_kp, + acme_jwk: initialized.acme_jwk, + data: states::DpopChallengeCompleted { + nonce: "acme-nonce".into(), + acme_account: AcmeAccount::default(), + order: AcmeOrder::default(), + oidc_challenge: AcmeChallenge::new_user(), + }, + }; + + let serialized = serde_json::to_vec(&acquisition).unwrap(); + let deserialized = + X509CredentialAcquisition::::deserialize(pki_env, &serialized).unwrap(); + + assert_eq!( + serde_json::to_value(&acquisition).unwrap(), + serde_json::to_value(&deserialized).unwrap() + ); + } +} diff --git a/e2e-identity/src/pki_env/hooks.rs b/e2e-identity/src/pki_env/hooks.rs index 8a5bec22d5..7eee26047f 100644 --- a/e2e-identity/src/pki_env/hooks.rs +++ b/e2e-identity/src/pki_env/hooks.rs @@ -137,6 +137,7 @@ pub trait PkiEnvironmentHooks: std::fmt::Debug + Send + Sync { idp: String, key_auth: String, acme_aud: String, + acquisition_snapshot: Vec, ) -> Result; /// Get a nonce from the backend diff --git a/e2e-identity/tests/utils/hooks.rs b/e2e-identity/tests/utils/hooks.rs index 5957391f97..6a5078e20a 100644 --- a/e2e-identity/tests/utils/hooks.rs +++ b/e2e-identity/tests/utils/hooks.rs @@ -77,6 +77,7 @@ impl PkiEnvironmentHooks for TestPkiEnvironmentHooks { idp: String, key_auth: String, acme_aud: String, + _acquisition_snapshot: Vec, ) -> Result { let oauth_cfg = OauthCfg { client_id: "wireapp".to_string(),