diff --git a/parachain/primitives/src/identity.rs b/parachain/primitives/src/identity.rs index e39ddbc256..a457556fc6 100644 --- a/parachain/primitives/src/identity.rs +++ b/parachain/primitives/src/identity.rs @@ -544,6 +544,8 @@ impl Identity { return Ok(Identity::Google(IdentityString::new(v[1].as_bytes().to_vec()))); } else if v[0] == "pumpx" { return Ok(Identity::Pumpx(IdentityString::new(v[1].as_bytes().to_vec()))); + } else if v[0] == "passkey" { + return Ok(Identity::Passkey(IdentityString::new(v[1].as_bytes().to_vec()))); } else { return Err("Unknown did type"); } @@ -628,6 +630,9 @@ impl Identity { Web2IdentityType::Pumpx => { Identity::Pumpx(IdentityString::new(handle.as_bytes().to_vec())) }, + Web2IdentityType::Passkey => { + Identity::Passkey(IdentityString::new(handle.as_bytes().to_vec())) + }, } } } @@ -640,6 +645,7 @@ pub enum Web2IdentityType { Email, Google, Pumpx, + Passkey, } impl From for Identity { diff --git a/tee-worker/omni-executor/.gitignore b/tee-worker/omni-executor/.gitignore index 092be53f11..f74f13c7c4 100644 --- a/tee-worker/omni-executor/.gitignore +++ b/tee-worker/omni-executor/.gitignore @@ -14,3 +14,8 @@ omni-executor storage_db test_get_health_storage_db test_get_shielding_key_storage_db + +executor-storage/test_passkey_*/ +executor-storage/test_passkey_integration_*/ +executor-storage/test_passkey_challenge_storage_*/ +executor-storage/test_passkey_storage_*/ diff --git a/tee-worker/omni-executor/Cargo.lock b/tee-worker/omni-executor/Cargo.lock index 405dc1aab2..b2ef299461 100644 --- a/tee-worker/omni-executor/Cargo.lock +++ b/tee-worker/omni-executor/Cargo.lock @@ -3295,6 +3295,7 @@ dependencies = [ "ff", "generic-array", "group", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -3957,16 +3958,22 @@ dependencies = [ name = "executor-crypto" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", + "ciborium", "ethers", + "hex", "jsonwebtoken 9.3.1", + "p256", "parity-scale-codec", "rand 0.8.5", "ring 0.17.14", "rsa", "secp256k1 0.29.1", "serde", + "serde_json", "serde_with", + "sha2 0.10.9", "sp-core 35.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "tracing", ] @@ -8297,6 +8304,7 @@ dependencies = [ "alloy-signer-local", "async-trait", "base58", + "base64 0.22.1", "binance-api", "chrono", "config-loader", diff --git a/tee-worker/omni-executor/Cargo.toml b/tee-worker/omni-executor/Cargo.toml index 720e181339..f5c305d12b 100644 --- a/tee-worker/omni-executor/Cargo.toml +++ b/tee-worker/omni-executor/Cargo.toml @@ -65,6 +65,7 @@ log = "0.4.22" metrics = "0.24.1" metrics-exporter-prometheus = "0.16.2" mockall = "0.13.1" +p256 = { version = "0.13", features = ["ecdsa"] } parity-scale-codec = "3.6.12" rand = "0.8.5" regex = "1.7" diff --git a/tee-worker/omni-executor/executor-crypto/Cargo.toml b/tee-worker/omni-executor/executor-crypto/Cargo.toml index 8cdd2c4a81..b57e2c688c 100644 --- a/tee-worker/omni-executor/executor-crypto/Cargo.toml +++ b/tee-worker/omni-executor/executor-crypto/Cargo.toml @@ -5,15 +5,21 @@ authors = ['Trust Computing GmbH '] edition.workspace = true [dependencies] +base64 = { workspace = true } +ciborium = { workspace = true } ethers = { workspace = true } +hex = { workspace = true } jsonwebtoken = { workspace = true } +p256 = { workspace = true } parity-scale-codec = { workspace = true } rand = { workspace = true } ring = { workspace = true } rsa = { workspace = true, features = ["sha2"] } secp256k1 = { workspace = true, features = ["std", "global-context", "recovery", "rand-std"] } serde = { workspace = true } +serde_json = { workspace = true } serde_with = { workspace = true } +sha2 = { workspace = true } sp-core = { workspace = true } tracing = { workspace = true } diff --git a/tee-worker/omni-executor/executor-crypto/src/lib.rs b/tee-worker/omni-executor/executor-crypto/src/lib.rs index 3e02c6c2bb..3e6dc4ce30 100644 --- a/tee-worker/omni-executor/executor-crypto/src/lib.rs +++ b/tee-worker/omni-executor/executor-crypto/src/lib.rs @@ -1,5 +1,6 @@ pub mod aes256; pub mod jwt; +pub mod passkey; pub mod secp256k1; pub mod shielding_key; pub mod traits; diff --git a/tee-worker/omni-executor/executor-crypto/src/passkey.rs b/tee-worker/omni-executor/executor-crypto/src/passkey.rs new file mode 100644 index 0000000000..6c711aa75f --- /dev/null +++ b/tee-worker/omni-executor/executor-crypto/src/passkey.rs @@ -0,0 +1,400 @@ +// Copyright 2020-2024 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +use base64::Engine; +use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use sha2::{Digest, Sha256}; +use std::fmt; + +#[derive(Debug)] +pub enum PasskeyError { + InvalidPublicKeyFormat, + InvalidSignatureFormat, + ParseError(String), + AttestationParseError(String), + ChallengeVerificationFailed, + OriginVerificationFailed, +} + +impl fmt::Display for PasskeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PasskeyError::InvalidPublicKeyFormat => write!(f, "Invalid public key format"), + PasskeyError::InvalidSignatureFormat => write!(f, "Invalid signature format"), + PasskeyError::ParseError(msg) => write!(f, "Parse error: {}", msg), + PasskeyError::AttestationParseError(msg) => { + write!(f, "Attestation parse error: {}", msg) + }, + PasskeyError::ChallengeVerificationFailed => write!(f, "Challenge verification failed"), + PasskeyError::OriginVerificationFailed => write!(f, "Origin verification failed"), + } + } +} + +impl std::error::Error for PasskeyError {} + +#[derive(Debug, Clone)] +pub struct PasskeyPublicKey { + pub verifying_key: VerifyingKey, +} + +#[derive(Debug, Clone)] +pub struct ParsedAttestationObject { + pub auth_data: Vec, + pub fmt: String, + pub att_stmt: Vec, // Simplified - could be parsed further +} + +#[derive(Debug, Clone)] +pub struct ClientData { + pub type_: String, + pub challenge: String, + pub origin: String, + pub cross_origin: Option, +} + +#[derive(Debug, Clone)] +pub struct AttestationResult { + pub credential_id: String, + pub public_key: PasskeyPublicKey, +} + +pub struct PasskeyVerifier; + +impl PasskeyVerifier { + pub fn verify_client_data_json( + client_data_json: &str, + omni_account: &[u8; 32], + expected_origin: &str, + expected_type: &str, + verify_and_consume_challenge: F, + ) -> Result<(), PasskeyError> + where + F: FnOnce(&str, &[u8; 32]) -> Result<(), PasskeyError>, + { + // Parse client data JSON + let client_data = Self::parse_client_data_json(client_data_json)?; + + // Verify origin + if client_data.origin != expected_origin { + return Err(PasskeyError::OriginVerificationFailed); + } + + // Verify type + if client_data.type_ != expected_type { + return Err(PasskeyError::AttestationParseError(format!( + "Invalid type, expected {}", + expected_type + ))); + } + + // Verify and consume challenge using the provided function + verify_and_consume_challenge(&client_data.challenge, omni_account) + .map_err(|_| PasskeyError::ChallengeVerificationFailed)?; + + Ok(()) + } + + pub fn from_sec1_bytes(sec1_bytes: &[u8]) -> Result { + let verifying_key = VerifyingKey::from_sec1_bytes(sec1_bytes) + .map_err(|_| PasskeyError::InvalidPublicKeyFormat)?; + Ok(PasskeyPublicKey { verifying_key }) + } + + pub fn verify_attestation(attestation_object: &str) -> Result { + let parsed_attestation_object = Self::parse_attestation_object(attestation_object)?; + + let (credential_id, public_key) = + Self::extract_credential_and_key_from_auth_data(&parsed_attestation_object.auth_data)?; + + Ok(AttestationResult { credential_id, public_key }) + } + + pub fn verify_rp_id_hash( + auth_data_bytes: &[u8], + expected_rp_id: &str, + ) -> Result<(), PasskeyError> { + if auth_data_bytes.len() < 32 { + return Err(PasskeyError::ParseError( + "Auth data too short to contain RP ID hash".to_string(), + )); + } + let rp_id_hash_from_auth_data = &auth_data_bytes[0..32]; + let expected_rp_id_hash = Sha256::digest(expected_rp_id.as_bytes()); + if rp_id_hash_from_auth_data != &expected_rp_id_hash[..] { + return Err(PasskeyError::ParseError(format!( + "RP ID hash mismatch. Expected RP ID: '{}', hash: {:02x?}, but got: {:02x?}", + expected_rp_id, + &expected_rp_id_hash[..], + rp_id_hash_from_auth_data + ))); + } + + Ok(()) + } + + pub fn parse_client_data_json(client_data_json_b64: &str) -> Result { + // Decode base64 + let client_data_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(client_data_json_b64) + .map_err(|e| { + PasskeyError::AttestationParseError(format!("Base64 decode error: {}", e)) + })?; + + // Parse JSON + let client_data_json = std::str::from_utf8(&client_data_bytes) + .map_err(|e| PasskeyError::AttestationParseError(format!("UTF-8 error: {}", e)))?; + + let json_value: serde_json::Value = serde_json::from_str(client_data_json) + .map_err(|e| PasskeyError::AttestationParseError(format!("JSON parse error: {}", e)))?; + + let client_data = ClientData { + type_: json_value + .get("type") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PasskeyError::AttestationParseError("Missing type field".to_string()) + })? + .to_string(), + challenge: json_value + .get("challenge") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PasskeyError::AttestationParseError("Missing challenge field".to_string()) + })? + .to_string(), + origin: json_value + .get("origin") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PasskeyError::AttestationParseError("Missing origin field".to_string()) + })? + .to_string(), + cross_origin: json_value.get("crossOrigin").and_then(|v| v.as_bool()), + }; + + Ok(client_data) + } + + fn parse_attestation_object( + attestation_object_b64: &str, + ) -> Result { + // Decode base64 + let attestation_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(attestation_object_b64) + .map_err(|e| { + PasskeyError::AttestationParseError(format!("Base64 decode error: {}", e)) + })?; + + // Parse CBOR using ciborium + // WebAuthn attestation object structure: + // { + // "fmt": text string, + // "attStmt": map, + // "authData": bytes + // } + let cbor_value: ciborium::Value = ciborium::de::from_reader(&attestation_bytes[..]) + .map_err(|e| { + PasskeyError::AttestationParseError(format!("CBOR decode error: {}", e)) + })?; + + // Extract the map from CBOR value + let map = match cbor_value { + ciborium::Value::Map(m) => m, + _ => { + return Err(PasskeyError::AttestationParseError( + "Attestation object must be a CBOR map".to_string(), + )) + }, + }; + + // Extract authData (required field) + let auth_data = Self::extract_field_from_cbor_map(&map, "authData")?; + let auth_data_bytes = match auth_data { + ciborium::Value::Bytes(bytes) => bytes.clone(), + _ => { + return Err(PasskeyError::AttestationParseError( + "authData must be bytes".to_string(), + )) + }, + }; + + // Extract fmt (format, required field) + let fmt = Self::extract_field_from_cbor_map(&map, "fmt")?; + let fmt_string = match fmt { + ciborium::Value::Text(text) => text.clone(), + _ => return Err(PasskeyError::AttestationParseError("fmt must be text".to_string())), + }; + + // Extract attStmt (attestation statement, optional but usually present) + let att_stmt = match Self::extract_field_from_cbor_map(&map, "attStmt") { + Ok(val) => { + // Serialize attStmt back to bytes for storage (if needed) + let mut att_stmt_bytes = Vec::new(); + ciborium::ser::into_writer(&val, &mut att_stmt_bytes).map_err(|e| { + PasskeyError::AttestationParseError(format!( + "Failed to serialize attStmt: {}", + e + )) + })?; + att_stmt_bytes + }, + Err(_) => vec![], // attStmt is optional for "none" format + }; + + Ok(ParsedAttestationObject { auth_data: auth_data_bytes, fmt: fmt_string, att_stmt }) + } + + fn extract_field_from_cbor_map<'a>( + map: &'a [(ciborium::Value, ciborium::Value)], + field_name: &str, + ) -> Result<&'a ciborium::Value, PasskeyError> { + for (key, value) in map { + if let ciborium::Value::Text(key_text) = key { + if key_text == field_name { + return Ok(value); + } + } + } + Err(PasskeyError::AttestationParseError(format!( + "Required field '{}' not found in attestation object", + field_name + ))) + } + + fn extract_credential_and_key_from_auth_data( + auth_data: &[u8], + ) -> Result<(String, PasskeyPublicKey), PasskeyError> { + if auth_data.len() < 55 { + return Err(PasskeyError::AttestationParseError( + "Auth data too short to contain credential data".to_string(), + )); + } + + // Check if attested credential data is present (AT flag) + let flags = auth_data[32]; + let at_flag = (flags & 0x40) != 0; + + if !at_flag { + return Err(PasskeyError::AttestationParseError( + "No attested credential data present".to_string(), + )); + } + + // Skip: RP ID hash (32) + flags (1) + counter (4) + AAGUID (16) = 53 bytes + // Then: credential ID length (2 bytes) + credential ID + public key (COSE format) + + let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize; + let cred_id_start = 55; + let pub_key_start = 55 + cred_id_len; + + if auth_data.len() < pub_key_start { + return Err(PasskeyError::AttestationParseError( + "Auth data too short for credential ID".to_string(), + )); + } + + // Extract credential ID + let credential_id_bytes = &auth_data[cred_id_start..cred_id_start + cred_id_len]; + let credential_id = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(credential_id_bytes); + + // Extract and parse COSE public key (CBOR-encoded) + let cose_key_bytes = &auth_data[pub_key_start..]; + + // Parse COSE key using ciborium + let cose_key: ciborium::Value = ciborium::de::from_reader(cose_key_bytes).map_err(|e| { + PasskeyError::AttestationParseError(format!("Failed to parse COSE key: {}", e)) + })?; + + // COSE key is a CBOR map with integer keys + // For ES256 (P-256), we need: + // -1: x coordinate (bytes) + // -2: y coordinate (bytes) + // -3: curve (1 = P-256) + let cose_map = match cose_key { + ciborium::Value::Map(m) => m, + _ => { + return Err(PasskeyError::AttestationParseError( + "COSE key must be a map".to_string(), + )) + }, + }; + + let x_coord = Self::extract_cose_key_param(&cose_map, -2)?; + let y_coord = Self::extract_cose_key_param(&cose_map, -3)?; + + // Construct SEC1 uncompressed public key: 0x04 || x || y + let mut sec1_bytes = vec![0x04]; + sec1_bytes.extend_from_slice(&x_coord); + sec1_bytes.extend_from_slice(&y_coord); + + let verifying_key = VerifyingKey::from_sec1_bytes(&sec1_bytes) + .map_err(|_| PasskeyError::InvalidPublicKeyFormat)?; + + Ok((credential_id, PasskeyPublicKey { verifying_key })) + } + + fn extract_cose_key_param( + cose_map: &[(ciborium::Value, ciborium::Value)], + key: i32, + ) -> Result, PasskeyError> { + for (k, v) in cose_map { + if let ciborium::Value::Integer(int_key) = k { + if *int_key == key.into() { + if let ciborium::Value::Bytes(bytes) = v { + return Ok(bytes.clone()); + } + } + } + } + Err(PasskeyError::AttestationParseError(format!( + "COSE key parameter {} not found or invalid", + key + ))) + } + + pub fn verify_passkey_signature_only( + auth_data_hex: &str, + client_data_json: &str, + signature_hex: &str, + public_key: &PasskeyPublicKey, + ) -> Result { + // Decode signature from hex + let signature_bytes = + hex::decode(signature_hex).map_err(|_| PasskeyError::InvalidSignatureFormat)?; + // Validate signature length (64 bytes for P-256: 32 bytes r + 32 bytes s) + if signature_bytes.len() != 64 { + return Err(PasskeyError::InvalidSignatureFormat); + } + let ecdsa_signature = Signature::from_slice(&signature_bytes) + .map_err(|_| PasskeyError::InvalidSignatureFormat)?; + + let client_data_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(client_data_json) + .map_err(|e| PasskeyError::ParseError(format!("Client data decode error: {}", e)))?; + + let auth_data_bytes = hex::decode(auth_data_hex) + .map_err(|e| PasskeyError::ParseError(format!("Auth data decode error: {}", e)))?; + let client_data_hash = Sha256::digest(&client_data_bytes); + // Per WebAuthn spec: signature = sign(authenticatorData || SHA256(clientDataJSON)) + let mut signing_data = Vec::new(); + signing_data.extend_from_slice(&auth_data_bytes); + signing_data.extend_from_slice(&client_data_hash); + + Ok(public_key.verifying_key.verify(&signing_data, &ecdsa_signature).is_ok()) + } +} diff --git a/tee-worker/omni-executor/executor-primitives/src/auth.rs b/tee-worker/omni-executor/executor-primitives/src/auth.rs index 89811c1baa..3d61479cd3 100644 --- a/tee-worker/omni-executor/executor-primitives/src/auth.rs +++ b/tee-worker/omni-executor/executor-primitives/src/auth.rs @@ -189,9 +189,9 @@ pub struct OAuth2Data { #[derive(Encode, Decode, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PasskeyData { - pub user_id: String, + pub user_id: UserId, // Only support `UserId::Email` now + pub client_id: String, pub credential_id: String, - pub pubkey: String, // uncompressed, compressed, or COSE-encoded - have to figure out what authenticator sends pub signature: String, // raw 64 byte, or DER encoded - have to figure out what authenticator sends pub auth_data: String, pub client_data_json: String, diff --git a/tee-worker/omni-executor/executor-storage/src/lib.rs b/tee-worker/omni-executor/executor-storage/src/lib.rs index dafbd47a71..53005bf2f1 100644 --- a/tee-worker/omni-executor/executor-storage/src/lib.rs +++ b/tee-worker/omni-executor/executor-storage/src/lib.rs @@ -12,6 +12,12 @@ pub use heima_jwt::HeimaJwtStorage; mod intent_id; pub use intent_id::IntentIdStorage; mod asset_lock; +mod passkey; +pub use passkey::{PasskeyError, PasskeyRecord, PasskeyStorage, PasskeyStorageKey}; +mod passkey_challenge; +pub use passkey_challenge::{ + PasskeyChallengeError, PasskeyChallengeRecord, PasskeyChallengeStorage, +}; mod pumpx_account_profile; pub use pumpx_account_profile::PumpxProfileStorage; mod wildmeta_timestamp; diff --git a/tee-worker/omni-executor/executor-storage/src/passkey.rs b/tee-worker/omni-executor/executor-storage/src/passkey.rs new file mode 100644 index 0000000000..c262e6f4ed --- /dev/null +++ b/tee-worker/omni-executor/executor-storage/src/passkey.rs @@ -0,0 +1,326 @@ +// Copyright 2020-2024 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +use crate::{Storage, StorageDB}; +use executor_crypto::hashing::{blake2_256, twox_128}; +use executor_primitives::AccountId; +use parity_scale_codec::{Decode, Encode}; +use std::sync::Arc; + +const STORAGE_NAME: &str = "passkey_storage"; + +/// Storage key type: (AccountId, Hash(credential_id)) +pub type PasskeyStorageKey = (AccountId, [u8; 32]); + +/// Passkey data structure +#[derive(Encode, Decode, Clone, Debug, PartialEq, Eq)] +pub struct PasskeyRecord { + pub credential_id: String, + pub pubkey: Vec, // Store SEC1 bytes directly + pub created_at: u64, +} + +/// Errors that can occur during passkey operations +#[derive(Debug, PartialEq, Eq)] +pub enum PasskeyError { + StorageError, + DuplicatePasskey, + ValidationError, +} + +/// Passkey storage using composite key of (omni_account, Hash(credential_id)) +pub struct PasskeyStorage { + db: Arc, +} + +impl PasskeyStorage { + pub fn new(db: Arc) -> Self { + Self { db } + } + + fn make_key(omni_account: &AccountId, credential_id: &str) -> PasskeyStorageKey { + (omni_account.clone(), blake2_256(credential_id.as_bytes())) + } + + pub fn get_passkey( + &self, + omni_account: &AccountId, + credential_id: &str, + ) -> Result, ()> { + let key = Self::make_key(omni_account, credential_id); + self.get(&key) + } + + pub fn remove_passkey( + &self, + omni_account: &AccountId, + credential_id: &str, + ) -> Result<(), PasskeyError> { + let key = Self::make_key(omni_account, credential_id); + self.remove(&key).map_err(|_| PasskeyError::StorageError) + } + + pub fn exists_passkey(&self, omni_account: &AccountId, credential_id: &str) -> bool { + let key = Self::make_key(omni_account, credential_id); + self.contains_key(&key) + } + + pub fn add_passkey( + &self, + omni_account: &AccountId, + credential_id: &str, + pubkey: &[u8], // Accept SEC1 bytes directly + ) -> Result<(), PasskeyError> { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let record = PasskeyRecord { + credential_id: credential_id.to_string(), + pubkey: pubkey.to_vec(), + created_at: current_time, + }; + + let key = Self::make_key(omni_account, credential_id); + if self.contains_key(&key) { + return Err(PasskeyError::DuplicatePasskey); + } + self.insert(&key, record).map_err(|_| PasskeyError::StorageError) + } + + /// List all passkeys for a given omni_account + /// Returns a vector of tuples (credential_id, created_at) + pub fn list_passkeys( + &self, + omni_account: &AccountId, + ) -> Result, PasskeyError> { + let mut passkeys = Vec::new(); + + // The storage key structure is: + // storage_key = twox_128(storage_name) + blake2_128_concat(encoded_tuple_key) + // where blake2_128_concat(x) = blake2_128(x) + x + // + // For a tuple key (AccountId, [u8; 32]), the encoded form is: + // AccountId.encode() + [u8; 32].encode() + // = AccountId bytes + credential_id_hash bytes + // + // We want to match all keys for a given AccountId, so we need to construct + // a prefix that covers: + // twox_128(storage_name) + blake2_128(full_key) + AccountId.encode() + ... + // + // However, blake2_128 hashes the ENTIRE encoded key, so we can't just match + // on the AccountId portion. Instead, we iterate through ALL keys in this storage + // and filter by AccountId. + + let storage_prefix = twox_128(STORAGE_NAME.as_bytes()); + let db = self.db(); + let iter = db.prefix_iterator(storage_prefix); + + for item in iter { + match item { + Ok((key, value)) => { + // Verify the key belongs to our storage namespace + if !key.starts_with(&storage_prefix) { + // We've moved past our namespace, stop iteration + break; + } + + // The key structure after storage_prefix is: + // blake2_128(encoded_tuple) + encoded_tuple + // where encoded_tuple = AccountId + [u8; 32] + // + // Skip the blake2_128 hash (16 bytes) to get to the encoded tuple + let encoded_tuple_start = storage_prefix.len() + 16; + if key.len() < encoded_tuple_start + 32 { + // Invalid key structure, skip + continue; + } + + // Extract the AccountId from the key (32 bytes after the hash) + let key_account_bytes = &key[encoded_tuple_start..encoded_tuple_start + 32]; + + // Compare with our target omni_account + if key_account_bytes == >::as_ref(omni_account) { + // This key belongs to our account, decode the record + match PasskeyRecord::decode(&mut &value[..]) { + Ok(record) => { + passkeys.push((record.credential_id.clone(), record.created_at)); + }, + Err(e) => { + tracing::warn!( + "Failed to decode passkey record for account {:?}: {:?}", + omni_account, + e + ); + // Continue iteration despite decode error + }, + } + } + }, + Err(e) => { + tracing::error!("Error iterating through passkeys: {:?}", e); + return Err(PasskeyError::StorageError); + }, + } + } + + Ok(passkeys) + } +} + +impl Storage for PasskeyStorage { + fn db(&self) -> Arc { + self.db.clone() + } + + fn name(&self) -> &'static str { + STORAGE_NAME + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + fn create_test_storage() -> PasskeyStorage { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let db_path = format!("test_passkey_storage_{}", COUNTER.fetch_add(1, Ordering::SeqCst)); + if Path::new(&db_path).exists() { + fs::remove_dir_all(&db_path).unwrap(); + } + let db = Arc::new(StorageDB::open_default(&db_path).unwrap()); + PasskeyStorage::new(db) + } + + #[test] + fn test_passkey_add_and_get() { + let storage = create_test_storage(); + let omni_account = AccountId::from([1u8; 32]); + + // Add a passkey + let test_pubkey = b"test_pubkey_123"; + storage.add_passkey(&omni_account, "cred123", test_pubkey).unwrap(); + + // Test retrieval + let retrieved = storage.get_passkey(&omni_account, "cred123").unwrap().unwrap(); + assert_eq!(retrieved.credential_id, "cred123"); + assert_eq!(retrieved.pubkey, test_pubkey.to_vec()); + + // Test existence check + assert!(storage.exists_passkey(&omni_account, "cred123")); + assert!(!storage.exists_passkey(&omni_account, "nonexistent")); + } + + #[test] + fn test_passkey_removal() { + let storage = create_test_storage(); + let omni_account = AccountId::from([2u8; 32]); + + // Add a passkey + let test_pubkey = b"test_pubkey_456"; + storage.add_passkey(&omni_account, "cred456", test_pubkey).unwrap(); + + // Verify it exists + assert!(storage.exists_passkey(&omni_account, "cred456")); + + // Remove it + storage.remove_passkey(&omni_account, "cred456").unwrap(); + + // Verify it's gone + assert!(!storage.exists_passkey(&omni_account, "cred456")); + assert!(storage.get_passkey(&omni_account, "cred456").unwrap().is_none()); + } + + #[test] + fn test_duplicate_detection() { + let storage = create_test_storage(); + let omni_account = AccountId::from([3u8; 32]); + + // Add first passkey + let test_pubkey1 = b"test_pubkey_789"; + storage.add_passkey(&omni_account, "cred789", test_pubkey1).unwrap(); + + // Try to add with same omni_account + credential_id + let test_pubkey2 = b"test_pubkey_789_new"; + let result = storage.add_passkey(&omni_account, "cred789", test_pubkey2); + assert_eq!(result, Err(PasskeyError::DuplicatePasskey)); + } + + #[test] + fn test_multiple_passkeys_per_omni_account() { + let storage = create_test_storage(); + let omni_account = AccountId::from([4u8; 32]); + + // Add multiple passkeys for the same omni account + let test_pubkey1 = b"test_pubkey_1"; + let test_pubkey2 = b"test_pubkey_2"; + storage.add_passkey(&omni_account, "cred1", test_pubkey1).unwrap(); + storage.add_passkey(&omni_account, "cred2", test_pubkey2).unwrap(); + + // Both should exist independently + assert!(storage.exists_passkey(&omni_account, "cred1")); + assert!(storage.exists_passkey(&omni_account, "cred2")); + + let record1 = storage.get_passkey(&omni_account, "cred1").unwrap().unwrap(); + let record2 = storage.get_passkey(&omni_account, "cred2").unwrap().unwrap(); + + assert_eq!(record1.pubkey, test_pubkey1.to_vec()); + assert_eq!(record2.pubkey, test_pubkey2.to_vec()); + } + + #[test] + fn test_list_passkeys() { + let storage = create_test_storage(); + let omni_account1 = AccountId::from([5u8; 32]); + let omni_account2 = AccountId::from([6u8; 32]); + + // Add multiple passkeys for account1 + let test_pubkey1 = b"test_pubkey_1"; + let test_pubkey2 = b"test_pubkey_2"; + let test_pubkey3 = b"test_pubkey_3"; + storage.add_passkey(&omni_account1, "cred1", test_pubkey1).unwrap(); + storage.add_passkey(&omni_account1, "cred2", test_pubkey2).unwrap(); + storage.add_passkey(&omni_account1, "cred3", test_pubkey3).unwrap(); + + // Add a passkey for account2 + let test_pubkey4 = b"test_pubkey_4"; + storage.add_passkey(&omni_account2, "cred4", test_pubkey4).unwrap(); + + // List passkeys for account1 + let passkeys1 = storage.list_passkeys(&omni_account1).unwrap(); + assert_eq!(passkeys1.len(), 3); + + // Verify all three passkeys are present + let cred_ids: Vec = passkeys1.iter().map(|(cid, _)| cid.clone()).collect(); + assert!(cred_ids.contains(&"cred1".to_string())); + assert!(cred_ids.contains(&"cred2".to_string())); + assert!(cred_ids.contains(&"cred3".to_string())); + + // List passkeys for account2 + let passkeys2 = storage.list_passkeys(&omni_account2).unwrap(); + assert_eq!(passkeys2.len(), 1); + assert_eq!(passkeys2[0].0, "cred4"); + + // List passkeys for non-existent account + let omni_account3 = AccountId::from([7u8; 32]); + let passkeys3 = storage.list_passkeys(&omni_account3).unwrap(); + assert_eq!(passkeys3.len(), 0); + } +} diff --git a/tee-worker/omni-executor/executor-storage/src/passkey_challenge.rs b/tee-worker/omni-executor/executor-storage/src/passkey_challenge.rs new file mode 100644 index 0000000000..43bf7f7d40 --- /dev/null +++ b/tee-worker/omni-executor/executor-storage/src/passkey_challenge.rs @@ -0,0 +1,444 @@ +// Copyright 2020-2024 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +use crate::{Storage, StorageDB}; +use executor_primitives::AccountId; +use parity_scale_codec::{Decode, Encode}; +use std::sync::Arc; +use tracing::debug; + +const STORAGE_NAME: &str = "passkey_challenge_storage"; + +/// Passkey challenge record with expiration +#[derive(Encode, Decode, Clone, Debug, PartialEq, Eq)] +pub struct PasskeyChallengeRecord { + pub omni_account: AccountId, + pub created_at: u64, + pub expires_at: u64, +} + +/// Errors that can occur during challenge operations +#[derive(Debug, PartialEq, Eq)] +pub enum PasskeyChallengeError { + StorageError, + ChallengeExpired, + ChallengeNotFound, + InvalidChallenge, +} + +/// PassKey challenge storage with expiration management +#[derive(Clone)] +pub struct PasskeyChallengeStorage { + db: Arc, +} + +impl PasskeyChallengeStorage { + pub fn new(db: Arc) -> Self { + Self { db } + } + + pub fn store_challenge( + &self, + omni_account: &AccountId, + challenge: &str, + timeout_seconds: u64, + ) -> Result<(), PasskeyChallengeError> { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let record = PasskeyChallengeRecord { + omni_account: omni_account.clone(), + created_at: current_time, + expires_at: current_time + timeout_seconds, + }; + + self.insert(&challenge, record).map_err(|_| PasskeyChallengeError::StorageError) + } + + pub fn verify_and_consume_challenge( + &self, + challenge: &str, + omni_account: &AccountId, + ) -> Result<(), PasskeyChallengeError> { + let record = self + .get(&challenge) + .map_err(|_| PasskeyChallengeError::StorageError)? + .ok_or(PasskeyChallengeError::ChallengeNotFound)?; + + debug!("TEST, input omni: {:?}, record: {:?}", omni_account, record); + if record.omni_account != *omni_account { + return Err(PasskeyChallengeError::InvalidChallenge); + } + + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if current_time > record.expires_at { + self.remove(&challenge).map_err(|_| PasskeyChallengeError::StorageError)?; + return Err(PasskeyChallengeError::ChallengeExpired); + } + + self.remove(&challenge).map_err(|_| PasskeyChallengeError::StorageError)?; + + let cleanup_storage = self.clone(); + + let _ = std::thread::spawn(move || { + if let Err(e) = cleanup_storage.cleanup_expired24h_challenges() { + tracing::error!( + "Failed to cleanup expired passkey challenges after verification: {:?}", + e + ); + } + }); + + Ok(()) + } + + /// Clean up challenges that expired more than 24 hours ago + /// + /// This method only removes challenges that expired at least 24 hours ago. + /// This grace period ensures users get accurate error messages (ChallengeExpired + /// vs ChallengeNotFound) for recently expired challenges. + /// + /// It's recommended to call this periodically (e.g., every hour) via a background job. + pub fn cleanup_expired24h_challenges(&self) -> Result { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Only cleanup challenges that expired 24 hours ago or more + const CLEANUP_GRACE_PERIOD_SECONDS: u64 = 24 * 60 * 60; // 24 hours + let cleanup_threshold = current_time.saturating_sub(CLEANUP_GRACE_PERIOD_SECONDS); + + let mut cleaned_count = 0u32; + let mut expired_keys = Vec::new(); + + // Calculate the prefix for our storage namespace + // storage_key = twox_128(storage_name) + blake2_128_concat(key) + // For prefix iteration, we only need the storage name hash + use executor_crypto::hashing::twox_128; + let storage_prefix = twox_128(STORAGE_NAME.as_bytes()); + + let db = self.db(); + let iter = db.prefix_iterator(storage_prefix); + + // Iterate through all records in our namespace + for item in iter { + match item { + Ok((key, value)) => { + // Verify the key belongs to our storage (starts with our prefix) + if !key.starts_with(&storage_prefix) { + // We've moved past our namespace, stop iteration + break; + } + + // Decode the challenge record + match PasskeyChallengeRecord::decode(&mut &value[..]) { + Ok(record) => { + // Only cleanup if expired AND past the 24-hour grace period + if record.expires_at < cleanup_threshold { + // Mark for deletion (we can't delete while iterating) + expired_keys.push(key.to_vec()); + cleaned_count += 1; + } + }, + Err(e) => { + // Log decode error but continue cleanup + tracing::warn!( + "Failed to decode challenge record during cleanup: {:?}", + e + ); + // Optionally delete corrupted records + expired_keys.push(key.to_vec()); + }, + } + }, + Err(e) => { + tracing::error!("Error iterating through challenges during cleanup: {:?}", e); + return Err(PasskeyChallengeError::StorageError); + }, + } + } + + // Delete all expired challenges + for key in expired_keys { + if let Err(e) = db.delete(&key) { + tracing::error!("Failed to delete expired challenge: {:?}", e); + // Continue cleanup even if some deletions fail + } + } + + if cleaned_count > 0 { + tracing::info!("Cleaned up {} passkey challenges that expired >24h ago", cleaned_count); + } + + Ok(cleaned_count) + } + + /// Check if a challenge exists and is valid (without consuming it) + pub fn is_challenge_valid( + &self, + challenge: &str, + omni_account: &AccountId, + ) -> Result { + let record = self.get(&challenge).map_err(|_| PasskeyChallengeError::StorageError)?; + + match record { + Some(record) => { + // Check if challenge matches the account + if record.omni_account != *omni_account { + return Ok(false); + } + + // Check expiration + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + Ok(current_time <= record.expires_at) + }, + None => Ok(false), + } + } +} + +impl Storage<&str, PasskeyChallengeRecord> for PasskeyChallengeStorage { + fn db(&self) -> Arc { + self.db.clone() + } + + fn name(&self) -> &'static str { + STORAGE_NAME + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + fn create_test_storage() -> PasskeyChallengeStorage { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let db_path = + format!("test_passkey_challenge_storage_{}", COUNTER.fetch_add(1, Ordering::SeqCst)); + if Path::new(&db_path).exists() { + fs::remove_dir_all(&db_path).unwrap(); + } + let db = Arc::new(StorageDB::open_default(&db_path).unwrap()); + PasskeyChallengeStorage::new(db) + } + + #[test] + fn test_store_and_verify_challenge() { + let storage = create_test_storage(); + let omni_account = AccountId::from([1u8; 32]); + let challenge = "test_challenge_123"; + + // Store challenge with 60 second timeout + storage.store_challenge(&omni_account, challenge, 60).unwrap(); + + // Verify challenge is valid + assert!(storage.is_challenge_valid(challenge, &omni_account).unwrap()); + + // Verify and consume challenge + storage.verify_and_consume_challenge(challenge, &omni_account).unwrap(); + + // Challenge should be consumed (no longer valid) + assert!(!storage.is_challenge_valid(challenge, &omni_account).unwrap()); + } + + #[test] + fn test_challenge_expiration() { + let storage = create_test_storage(); + let omni_account = AccountId::from([2u8; 32]); + let challenge = "expired_challenge"; + + // Store challenge with 0 second timeout (immediately expired) + storage.store_challenge(&omni_account, challenge, 0).unwrap(); + + // Wait at least 1 second to ensure expiration (time precision is in seconds) + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Should return ChallengeExpired (not ChallengeNotFound) because cleanup + // only removes challenges expired >24h ago + let result = storage.verify_and_consume_challenge(challenge, &omni_account); + assert_eq!(result, Err(PasskeyChallengeError::ChallengeExpired)); + } + + #[test] + fn test_invalid_account() { + let storage = create_test_storage(); + let omni_account1 = AccountId::from([3u8; 32]); + let omni_account2 = AccountId::from([4u8; 32]); + let challenge = "account_mismatch_challenge"; + + // Store challenge for account1 + storage.store_challenge(&omni_account1, challenge, 60).unwrap(); + + // Try to verify with account2 (should fail) + let result = storage.verify_and_consume_challenge(challenge, &omni_account2); + assert_eq!(result, Err(PasskeyChallengeError::InvalidChallenge)); + + // Original challenge should still exist for account1 + assert!(storage.is_challenge_valid(challenge, &omni_account1).unwrap()); + } + + #[test] + fn test_challenge_not_found() { + let storage = create_test_storage(); + let omni_account = AccountId::from([5u8; 32]); + + // Try to verify non-existent challenge + let result = storage.verify_and_consume_challenge("nonexistent", &omni_account); + assert_eq!(result, Err(PasskeyChallengeError::ChallengeNotFound)); + } + + #[test] + fn test_cleanup_no_expired_challenges() { + let storage = create_test_storage(); + let omni_account = AccountId::from([6u8; 32]); + + // Store some valid challenges (not expired) + storage.store_challenge(&omni_account, "challenge1", 60).unwrap(); + storage.store_challenge(&omni_account, "challenge2", 60).unwrap(); + storage.store_challenge(&omni_account, "challenge3", 60).unwrap(); + + // Run cleanup - should clean 0 challenges (none expired >24h) + let cleaned = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned, 0); + + // All challenges should still be valid + assert!(storage.is_challenge_valid("challenge1", &omni_account).unwrap()); + assert!(storage.is_challenge_valid("challenge2", &omni_account).unwrap()); + assert!(storage.is_challenge_valid("challenge3", &omni_account).unwrap()); + } + + #[test] + fn test_cleanup_all_expired_challenges() { + let storage = create_test_storage(); + let omni_account = AccountId::from([7u8; 32]); + + // Store challenges with 0 second timeout (immediately expired) + storage.store_challenge(&omni_account, "expired1", 0).unwrap(); + storage.store_challenge(&omni_account, "expired2", 0).unwrap(); + storage.store_challenge(&omni_account, "expired3", 0).unwrap(); + + // Wait at least 1 second to ensure expiration + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Run cleanup - should clean 0 (challenges expired <24h ago, still in grace period) + let cleaned = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned, 0); + + // Challenges are expired but still exist (within 24h grace period) + // They will return ChallengeExpired when verified, not ChallengeNotFound + let result1 = storage.verify_and_consume_challenge("expired1", &omni_account); + assert_eq!(result1, Err(PasskeyChallengeError::ChallengeExpired)); + } + + #[test] + fn test_cleanup_mixed_expired_and_valid() { + let storage = create_test_storage(); + let omni_account = AccountId::from([8u8; 32]); + + // Store mix of expired and valid challenges + storage.store_challenge(&omni_account, "expired1", 0).unwrap(); + storage.store_challenge(&omni_account, "valid1", 60).unwrap(); + storage.store_challenge(&omni_account, "expired2", 0).unwrap(); + storage.store_challenge(&omni_account, "valid2", 60).unwrap(); + + // Wait at least 1 second to ensure expiration + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Run cleanup - should clean 0 (expired challenges still in 24h grace period) + let cleaned = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned, 0); + + // Valid challenges should still exist + assert!(storage.is_challenge_valid("valid1", &omni_account).unwrap()); + assert!(storage.is_challenge_valid("valid2", &omni_account).unwrap()); + + // Expired challenges still exist (in grace period) but return ChallengeExpired + let result1 = storage.verify_and_consume_challenge("expired1", &omni_account); + assert_eq!(result1, Err(PasskeyChallengeError::ChallengeExpired)); + } + + #[test] + fn test_cleanup_multiple_accounts() { + let storage = create_test_storage(); + let account1 = AccountId::from([9u8; 32]); + let account2 = AccountId::from([10u8; 32]); + + // Store expired challenges for different accounts + storage.store_challenge(&account1, "account1_expired", 0).unwrap(); + storage.store_challenge(&account2, "account2_expired", 0).unwrap(); + storage.store_challenge(&account1, "account1_valid", 60).unwrap(); + + // Wait at least 1 second to ensure expiration + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Cleanup should clean 0 (expired challenges still in 24h grace period) + let cleaned = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned, 0); + + // Valid challenge should still exist + assert!(storage.is_challenge_valid("account1_valid", &account1).unwrap()); + + // Expired challenges return ChallengeExpired (not removed yet) + let result1 = storage.verify_and_consume_challenge("account1_expired", &account1); + assert_eq!(result1, Err(PasskeyChallengeError::ChallengeExpired)); + } + + #[test] + fn test_cleanup_idempotent() { + let storage = create_test_storage(); + let omni_account = AccountId::from([11u8; 32]); + + // Store expired challenge + storage.store_challenge(&omni_account, "expired", 0).unwrap(); + // Wait at least 1 second to ensure expiration + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Cleanup won't remove challenges expired <24h ago + let cleaned1 = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned1, 0); + + // Second cleanup should also find nothing + let cleaned2 = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned2, 0); + + // Idempotent - still nothing + let cleaned3 = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned3, 0); + } + + #[test] + fn test_cleanup_empty_storage() { + let storage = create_test_storage(); + + // Cleanup on empty storage should work fine + let cleaned = storage.cleanup_expired24h_challenges().unwrap(); + assert_eq!(cleaned, 0); + } +} diff --git a/tee-worker/omni-executor/executor-storage/tests/passkey_integration.rs b/tee-worker/omni-executor/executor-storage/tests/passkey_integration.rs new file mode 100644 index 0000000000..1818c6d3d5 --- /dev/null +++ b/tee-worker/omni-executor/executor-storage/tests/passkey_integration.rs @@ -0,0 +1,294 @@ +//! Integration tests for complete passkey flows +//! +//! These tests verify the end-to-end functionality of passkey operations +//! including registration, authentication, challenge management, and cleanup. + +use executor_primitives::AccountId; +use executor_storage::{ + PasskeyChallengeError, PasskeyChallengeStorage, PasskeyError, PasskeyStorage, StorageDB, +}; +use std::sync::Arc; + +/// Helper to create a test storage database +fn create_test_storage() -> Arc { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::SystemTime; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + // Use timestamp + counter for unique database paths + let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros(); + let count = COUNTER.fetch_add(1, Ordering::SeqCst); + let db_path = format!("test_passkey_integration_{}_{}", timestamp, count); + Arc::new(StorageDB::open_default(&db_path).unwrap()) +} + +/// Helper to create a test omni account +fn create_test_account(id: u8) -> AccountId { + AccountId::from([id; 32]) +} + +#[test] +fn test_complete_passkey_registration_flow() { + // This test simulates the complete passkey registration workflow: + // 1. Request challenge + // 2. Store passkey + // 3. Verify passkey is stored correctly + + let storage_db = create_test_storage(); + let challenge_storage = PasskeyChallengeStorage::new(storage_db.clone()); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let omni_account = create_test_account(1); + + // Step 1: Generate and store challenge (simulates omni_requestPasskeyChallenge) + let challenge = "test_challenge_reg"; + let timeout = 300; // 5 minutes + challenge_storage + .store_challenge(&omni_account, challenge, timeout) + .expect("Failed to store challenge"); + + // Step 2: Store the passkey (simulates omni_attachPasskey after attestation verification) + let credential_id = "test_credential_123"; + let pubkey = b"test_pubkey_bytes_for_registration"; + + passkey_storage + .add_passkey(&omni_account, credential_id, pubkey) + .expect("Failed to add passkey"); + + // Step 3: Verify the passkey was stored correctly + let stored_passkey = passkey_storage + .get_passkey(&omni_account, credential_id) + .expect("Failed to get passkey") + .expect("Passkey not found"); + + assert_eq!(stored_passkey.credential_id, credential_id); + assert_eq!(stored_passkey.pubkey, pubkey.to_vec()); +} + +#[test] +fn test_complete_passkey_authentication_flow() { + // This test simulates the complete passkey authentication workflow: + // 1. Register a passkey + // 2. Request challenge for authentication + // 3. Verify and consume challenge + + let storage_db = create_test_storage(); + let challenge_storage = PasskeyChallengeStorage::new(storage_db.clone()); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let omni_account = create_test_account(2); + + // Step 1: Register a passkey + let credential_id = "test_credential_auth"; + let pubkey = b"test_pubkey_bytes_for_auth"; + + passkey_storage + .add_passkey(&omni_account, credential_id, pubkey) + .expect("Failed to add passkey"); + + // Step 2: Generate authentication challenge + let challenge = "test_challenge_auth"; + challenge_storage + .store_challenge(&omni_account, challenge, 300) + .expect("Failed to store challenge"); + + // Step 3: Verify and consume challenge (simulates authentication) + challenge_storage + .verify_and_consume_challenge(challenge, &omni_account) + .expect("Failed to verify challenge"); + + // Verify challenge was consumed (should fail on second attempt) + let result = challenge_storage.verify_and_consume_challenge(challenge, &omni_account); + assert!(matches!(result, Err(PasskeyChallengeError::ChallengeNotFound))); +} + +#[test] +fn test_passkey_challenge_expired() { + // This test verifies that expired challenges return ChallengeExpired error + + let storage_db = create_test_storage(); + let challenge_storage = PasskeyChallengeStorage::new(storage_db.clone()); + + let omni_account = create_test_account(5); + + // Create challenge with immediate expiration + challenge_storage + .store_challenge(&omni_account, "expired", 0) + .expect("Failed to store challenge"); + + // Wait for expiration + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Verify we get ChallengeExpired error (not ChallengeNotFound) + let result = challenge_storage.verify_and_consume_challenge("expired", &omni_account); + assert!(matches!(result, Err(PasskeyChallengeError::ChallengeExpired))); +} + +#[test] +fn test_multiple_passkeys_per_account() { + // This test verifies handling of multiple passkeys for the same account + + let storage_db = create_test_storage(); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let omni_account = create_test_account(6); + + // Register multiple passkeys (e.g., phone and laptop) + let phone_credential = "phone_credential"; + let phone_pubkey = b"phone_pubkey_bytes"; + + let laptop_credential = "laptop_credential"; + let laptop_pubkey = b"laptop_pubkey_bytes"; + + passkey_storage + .add_passkey(&omni_account, phone_credential, phone_pubkey) + .expect("Failed to add phone passkey"); + + passkey_storage + .add_passkey(&omni_account, laptop_credential, laptop_pubkey) + .expect("Failed to add laptop passkey"); + + // Verify both passkeys exist independently + let phone = passkey_storage + .get_passkey(&omni_account, phone_credential) + .expect("Failed to get phone passkey") + .expect("Phone passkey not found"); + + let laptop = passkey_storage + .get_passkey(&omni_account, laptop_credential) + .expect("Failed to get laptop passkey") + .expect("Laptop passkey not found"); + + assert_eq!(phone.pubkey, phone_pubkey.to_vec()); + assert_eq!(laptop.pubkey, laptop_pubkey.to_vec()); +} + +#[test] +fn test_passkey_removal_flow() { + // This test verifies the complete passkey removal workflow + + let storage_db = create_test_storage(); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let omni_account = create_test_account(7); + + // Register a passkey + let credential_id = "test_credential_remove"; + let pubkey = b"test_pubkey_remove"; + + passkey_storage + .add_passkey(&omni_account, credential_id, pubkey) + .expect("Failed to add passkey"); + + // Verify passkey exists + assert!(passkey_storage.exists_passkey(&omni_account, credential_id)); + + // Remove the passkey + passkey_storage + .remove_passkey(&omni_account, credential_id) + .expect("Failed to remove passkey"); + + // Verify passkey no longer exists + assert!(!passkey_storage.exists_passkey(&omni_account, credential_id)); + + // Verify get_passkey returns None + let result = passkey_storage + .get_passkey(&omni_account, credential_id) + .expect("Failed to query passkey"); + + assert!(result.is_none()); +} + +#[test] +fn test_challenge_account_mismatch() { + // This test verifies that challenges are properly scoped to specific accounts + + let storage_db = create_test_storage(); + let challenge_storage = PasskeyChallengeStorage::new(storage_db.clone()); + + let account1 = create_test_account(8); + let account2 = create_test_account(9); + + // Store challenge for account1 + challenge_storage + .store_challenge(&account1, "test_challenge", 300) + .expect("Failed to store challenge"); + + // Try to verify with account2 (should fail) + let result = challenge_storage.verify_and_consume_challenge("test_challenge", &account2); + + assert!(matches!(result, Err(PasskeyChallengeError::InvalidChallenge))); + + // Verify challenge still exists for account1 + challenge_storage + .verify_and_consume_challenge("test_challenge", &account1) + .expect("Challenge should still be valid for account1"); +} + +#[test] +fn test_passkey_duplicate_prevention() { + // This test verifies that duplicate passkeys are properly prevented + + let storage_db = create_test_storage(); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let omni_account = create_test_account(10); + + let credential_id = "test_credential_dup"; + let pubkey = b"test_pubkey_dup"; + + // Add passkey first time (should succeed) + passkey_storage + .add_passkey(&omni_account, credential_id, pubkey) + .expect("Failed to add passkey"); + + // Try to add the same passkey again (should fail) + let duplicate_result = passkey_storage.add_passkey(&omni_account, credential_id, pubkey); + + assert!(matches!(duplicate_result, Err(PasskeyError::DuplicatePasskey))); +} + +#[test] +fn test_concurrent_authentication_sessions() { + // This test verifies that multiple authentication sessions can be managed simultaneously + + let storage_db = create_test_storage(); + let challenge_storage = PasskeyChallengeStorage::new(storage_db.clone()); + let passkey_storage = PasskeyStorage::new(storage_db.clone()); + + let account1 = create_test_account(12); + let account2 = create_test_account(13); + + // Register passkeys for both accounts + passkey_storage + .add_passkey(&account1, "cred1", b"pubkey1") + .expect("Failed to add passkey for account1"); + + passkey_storage + .add_passkey(&account2, "cred2", b"pubkey2") + .expect("Failed to add passkey for account2"); + + // Create challenges for both accounts + challenge_storage + .store_challenge(&account1, "challenge1", 300) + .expect("Failed to store challenge1"); + + challenge_storage + .store_challenge(&account2, "challenge2", 300) + .expect("Failed to store challenge2"); + + // Verify challenges independently + challenge_storage + .verify_and_consume_challenge("challenge1", &account1) + .expect("Failed to verify challenge1"); + + challenge_storage + .verify_and_consume_challenge("challenge2", &account2) + .expect("Failed to verify challenge2"); + + // Verify passkeys exist + let pk1 = passkey_storage.get_passkey(&account1, "cred1").unwrap().unwrap(); + let pk2 = passkey_storage.get_passkey(&account2, "cred2").unwrap().unwrap(); + + assert_eq!(pk1.pubkey, b"pubkey1"); + assert_eq!(pk2.pubkey, b"pubkey2"); +} diff --git a/tee-worker/omni-executor/rpc-server/Cargo.toml b/tee-worker/omni-executor/rpc-server/Cargo.toml index 1185b92d3f..a45f2ff91b 100644 --- a/tee-worker/omni-executor/rpc-server/Cargo.toml +++ b/tee-worker/omni-executor/rpc-server/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true alloy = { workspace = true } async-trait = { workspace = true } base58 = { workspace = true } +base64 = { workspace = true } chrono = { workspace = true } email_address = { workspace = true } ethers = { workspace = true } diff --git a/tee-worker/omni-executor/rpc-server/src/detailed_error.rs b/tee-worker/omni-executor/rpc-server/src/detailed_error.rs index 806bb0c954..3a3a483af0 100644 --- a/tee-worker/omni-executor/rpc-server/src/detailed_error.rs +++ b/tee-worker/omni-executor/rpc-server/src/detailed_error.rs @@ -1,6 +1,12 @@ use crate::error_code::{ EMAIL_SERVICE_ERROR_CODE, GAS_ESTIMATION_FAILED_CODE, INVALID_BACKEND_RESPONSE_CODE, - INVALID_USEROP_CODE, PUMPX_SERVICE_ERROR_CODE, SIGNER_SERVICE_ERROR_CODE, + INVALID_USEROP_CODE, PASSKEY_ALREADY_EXISTS_CODE, PASSKEY_ATTESTATION_PARSE_ERROR_CODE, + PASSKEY_CHALLENGE_EXPIRED_CODE, PASSKEY_CHALLENGE_NOT_FOUND_CODE, + PASSKEY_CLIENT_DATA_PARSE_ERROR_CODE, PASSKEY_COUNTER_VALIDATION_FAILED_CODE, + PASSKEY_INVALID_CHALLENGE_CODE, PASSKEY_NOT_FOUND_CODE, + PASSKEY_ORIGIN_VERIFICATION_FAILED_CODE, PASSKEY_PARSE_ERROR_CODE, PASSKEY_REPLAY_ATTACK_CODE, + PASSKEY_RP_ID_MISMATCH_CODE, PASSKEY_SIGNATURE_INVALID_CODE, + PASSKEY_USER_VERIFICATION_FAILED_CODE, PUMPX_SERVICE_ERROR_CODE, SIGNER_SERVICE_ERROR_CODE, STORAGE_SERVICE_ERROR_CODE, WILDMETA_SERVICE_ERROR_CODE, }; use jsonrpsee::types::{ErrorCode, ErrorObject, ErrorObjectOwned}; @@ -150,6 +156,121 @@ impl DetailedError { pub fn gas_estimation_failed() -> Self { Self::new(GAS_ESTIMATION_FAILED_CODE, "Gas estimaton failed") } + + // Passkey/WebAuthn error factory methods + pub fn passkey_challenge_not_found() -> Self { + Self::new(PASSKEY_CHALLENGE_NOT_FOUND_CODE, "Passkey challenge not found") + .with_reason("Challenge may have expired or was never created") + .with_suggestion("Request a new challenge using omni_requestPasskeyChallenge") + } + + pub fn passkey_challenge_expired() -> Self { + Self::new(PASSKEY_CHALLENGE_EXPIRED_CODE, "Passkey challenge has expired") + .with_reason("Challenge exceeded its 5-minute validity period") + .with_suggestion("Request a new challenge using omni_requestPasskeyChallenge") + } + + pub fn passkey_invalid_challenge(reason: &str) -> Self { + Self::new(PASSKEY_INVALID_CHALLENGE_CODE, "Invalid passkey challenge") + .with_reason(reason) + .with_suggestion( + "Ensure the challenge matches the one received from omni_requestPasskeyChallenge", + ) + } + + pub fn passkey_not_found(credential_id: &str) -> Self { + Self::new(PASSKEY_NOT_FOUND_CODE, "Passkey not found") + .with_field("credential_id") + .with_received(credential_id) + .with_reason("No passkey registered for this account and credential ID") + .with_suggestion("Register a new passkey using omni_attachPasskey") + } + + pub fn passkey_signature_invalid(reason: &str) -> Self { + Self::new(PASSKEY_SIGNATURE_INVALID_CODE, "Passkey signature verification failed") + .with_reason(reason) + .with_suggestion("Ensure the passkey signature is correctly generated and matches the registered public key") + } + + pub fn passkey_parse_error(field: &str, error: &str) -> Self { + Self::new(PASSKEY_PARSE_ERROR_CODE, "Failed to parse passkey data") + .with_field(field) + .with_reason(error) + .with_suggestion("Check the passkey data format and encoding") + } + + pub fn passkey_rp_id_mismatch(expected_rp_id: &str, client_id: &str) -> Self { + Self::new(PASSKEY_RP_ID_MISMATCH_CODE, "Passkey RP ID validation failed") + .with_field("rp_id") + .with_expected(expected_rp_id) + .with_reason(format!( + "RP ID hash in authenticator data does not match expected RP ID for client '{}'", + client_id + )) + .with_suggestion("Ensure the passkey was created for the correct relying party domain") + } + + pub fn passkey_replay_attack(counter: u32) -> Self { + Self::new(PASSKEY_REPLAY_ATTACK_CODE, "Replay attack detected") + .with_field("signature_counter") + .with_received(counter.to_string()) + .with_reason( + "Signature counter is not greater than stored value - possible replay attack", + ) + .with_suggestion("This authentication request may have been captured and replayed by an attacker. Contact support if you believe this is an error.") + } + + pub fn passkey_counter_validation_failed() -> Self { + Self::new( + PASSKEY_COUNTER_VALIDATION_FAILED_CODE, + "Passkey counter validation failed", + ) + .with_reason("Authenticator may have been cloned or downgraded") + .with_suggestion("The authenticator returned a zero counter when a non-zero counter was expected. This may indicate device cloning or tampering.") + } + + pub fn passkey_user_verification_failed(flag_type: &str) -> Self { + Self::new(PASSKEY_USER_VERIFICATION_FAILED_CODE, "Passkey user verification failed") + .with_field(flag_type) + .with_reason(format!("{} flag not set in authenticator data", flag_type)) + .with_suggestion( + "Ensure user presence and verification are enabled on the authenticator", + ) + } + + pub fn passkey_attestation_parse_error(error: &str) -> Self { + Self::new( + PASSKEY_ATTESTATION_PARSE_ERROR_CODE, + "Failed to parse passkey attestation object", + ) + .with_reason(error) + .with_suggestion( + "Ensure the attestation object is properly CBOR-encoded and base64url-encoded", + ) + } + + pub fn passkey_already_exists(credential_id: &str) -> Self { + Self::new(PASSKEY_ALREADY_EXISTS_CODE, "Passkey already registered") + .with_field("credential_id") + .with_received(credential_id) + .with_reason("A passkey with this credential ID already exists for this account") + .with_suggestion("Use the existing passkey or remove it before registering a new one") + } + + pub fn passkey_origin_verification_failed(expected_origin: &str) -> Self { + Self::new(PASSKEY_ORIGIN_VERIFICATION_FAILED_CODE, "Origin verification failed") + .with_field("client_data_json") + .with_expected(format!("Origin: {}", expected_origin)) + .with_reason("Origin in client data does not match expected value") + .with_suggestion("Ensure the WebAuthn ceremony is initiated from the correct origin") + } + + pub fn passkey_client_data_parse_error(error: &str) -> Self { + Self::new(PASSKEY_CLIENT_DATA_PARSE_ERROR_CODE, "Failed to parse client data JSON") + .with_field("client_data_json") + .with_reason(error) + .with_suggestion("Ensure the client data is properly base64url-encoded JSON") + } } impl From for ErrorObjectOwned { diff --git a/tee-worker/omni-executor/rpc-server/src/error_code.rs b/tee-worker/omni-executor/rpc-server/src/error_code.rs index be54a8280d..febe95a5b2 100644 --- a/tee-worker/omni-executor/rpc-server/src/error_code.rs +++ b/tee-worker/omni-executor/rpc-server/src/error_code.rs @@ -8,6 +8,22 @@ pub const INVALID_RPC_EXTENSION: i32 = -32002; pub const AUTH_VERIFICATION_FAILED_CODE: i32 = -32003; pub const GAS_ESTIMATION_FAILED_CODE: i32 = -32005; +// Passkey/WebAuthn Error Codes (-32140 to -32159) +pub const PASSKEY_CHALLENGE_NOT_FOUND_CODE: i32 = -32140; +pub const PASSKEY_CHALLENGE_EXPIRED_CODE: i32 = -32141; +pub const PASSKEY_INVALID_CHALLENGE_CODE: i32 = -32142; +pub const PASSKEY_NOT_FOUND_CODE: i32 = -32143; +pub const PASSKEY_SIGNATURE_INVALID_CODE: i32 = -32144; +pub const PASSKEY_PARSE_ERROR_CODE: i32 = -32145; +pub const PASSKEY_RP_ID_MISMATCH_CODE: i32 = -32146; +pub const PASSKEY_REPLAY_ATTACK_CODE: i32 = -32147; +pub const PASSKEY_COUNTER_VALIDATION_FAILED_CODE: i32 = -32148; +pub const PASSKEY_USER_VERIFICATION_FAILED_CODE: i32 = -32149; +pub const PASSKEY_ATTESTATION_PARSE_ERROR_CODE: i32 = -32150; +pub const PASSKEY_ALREADY_EXISTS_CODE: i32 = -32151; +pub const PASSKEY_ORIGIN_VERIFICATION_FAILED_CODE: i32 = -32152; +pub const PASSKEY_CLIENT_DATA_PARSE_ERROR_CODE: i32 = -32153; + // Error when calling external services (-32160 to -32179) pub const SIGNER_SERVICE_ERROR_CODE: i32 = -32160; pub const PUMPX_SERVICE_ERROR_CODE: i32 = -32161; diff --git a/tee-worker/omni-executor/rpc-server/src/methods/mod.rs b/tee-worker/omni-executor/rpc-server/src/methods/mod.rs index 8134f72ae6..1d77a4ea6e 100644 --- a/tee-worker/omni-executor/rpc-server/src/methods/mod.rs +++ b/tee-worker/omni-executor/rpc-server/src/methods/mod.rs @@ -2,7 +2,7 @@ use crate::server::RpcContext; use executor_core::intent_executor::IntentExecutor; use jsonrpsee::RpcModule; -mod omni; +pub mod omni; use omni::*; pub const PROTECTED_METHODS: [&str; 8] = [ diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/attach_passkey.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/attach_passkey.rs new file mode 100644 index 0000000000..d11c9db1fe --- /dev/null +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/attach_passkey.rs @@ -0,0 +1,170 @@ +use crate::{ + detailed_error::DetailedError, error_code::AUTH_VERIFICATION_FAILED_CODE, server::RpcContext, + utils::validation::parse_rpc_params, verify_auth::verify_auth, Deserialize, Serialize, +}; + +use executor_core::intent_executor::IntentExecutor; +use executor_crypto::passkey::{AttestationResult, PasskeyVerifier}; +use executor_primitives::{to_omni_auth, utils::hex::hex_encode, UserAuth, UserId}; +use executor_storage::{ + PasskeyChallengeError, PasskeyChallengeStorage, PasskeyError, PasskeyStorage, +}; +use heima_primitives::Identity; +use jsonrpsee::{types::ErrorObject, RpcModule}; +use tracing::*; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct AttachPasskeyParams { + pub user_id: UserId, + pub user_auth: UserAuth, + pub client_id: String, + pub attestation_object: String, // Base64 encoded WebAuthn attestation object + pub client_data_json: String, // Base64 encoded WebAuthn client data JSON +} + +#[derive(Serialize, Clone)] +pub struct AttachPasskeyResponse { + pub success: bool, + pub message: String, +} + +pub fn register_attach_passkey( + module: &mut RpcModule>, +) { + module + .register_async_method("omni_attachPasskey", |params, ctx, _| async move { + let params = parse_rpc_params::(params)?; + + debug!("Received omni_attachPasskey, params: {:?}", params); + + let identity = Identity::try_from(params.user_id.clone()).map_err(|_| { + error!("Invalid existing user ID format"); + DetailedError::parse_error("Invalid user ID format").to_rpc_error() + })?; + + let auth = to_omni_auth(¶ms.user_auth, ¶ms.user_id, ¶ms.client_id) + .map_err(|e| { + error!("Failed to convert to OmniAuth: {:?}", e); + DetailedError::parse_error("Failed to convert to OmniAuth").to_rpc_error() + })?; + + verify_auth(ctx.clone(), &auth).await.map_err(|e| { + error!("Failed to verify existing user authentication: {:?}", e); + e.to_detailed_error().to_rpc_error() + })?; + + let omni_account = identity.to_omni_account(¶ms.client_id); + + let expected_origin = super::get_origin_for_client(¶ms.client_id); + + // Verify client data JSON and consume challenge + let challenge_storage = PasskeyChallengeStorage::new(ctx.storage_db.clone()); + PasskeyVerifier::verify_client_data_json( + ¶ms.client_data_json, + omni_account.as_ref(), + expected_origin, + "webauthn.create", // For passkey registration/attachment + |challenge, omni_account| { + challenge_storage + .verify_and_consume_challenge(challenge, &(*omni_account).into()) + .map_err(|e| { + match e { + PasskeyChallengeError::ChallengeNotFound => { + error!("Challenge not found for passkey attachment"); + }, + PasskeyChallengeError::ChallengeExpired => { + error!("Challenge expired for passkey attachment"); + }, + PasskeyChallengeError::InvalidChallenge => { + error!("Invalid challenge for passkey attachment"); + }, + _ => { + error!( + "Challenge verification failed during passkey attachment: {:?}", + e + ); + }, + } + executor_crypto::passkey::PasskeyError::ChallengeVerificationFailed + }) + }, + ) + .map_err(|e| { + error!("Client data verification failed during passkey attachment: {:?}", e); + match e { + executor_crypto::passkey::PasskeyError::ChallengeVerificationFailed => { + DetailedError::passkey_invalid_challenge( + "Challenge mismatch, expired, or not found", + ) + }, + executor_crypto::passkey::PasskeyError::OriginVerificationFailed => { + DetailedError::passkey_origin_verification_failed(expected_origin) + }, + executor_crypto::passkey::PasskeyError::AttestationParseError(err) => { + DetailedError::passkey_client_data_parse_error(&err) + }, + _ => DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Client data verification failed", + ) + .with_field("client_data_json") + .with_reason(format!("Verification error: {:?}", e)), + } + .to_rpc_error() + })?; + + let AttestationResult { credential_id, public_key } = + PasskeyVerifier::verify_attestation(¶ms.attestation_object).map_err(|e| { + error!( + "WebAuthn attestation verification failed during passkey attachment: {:?}", + e + ); + match e { + executor_crypto::passkey::PasskeyError::AttestationParseError(err) => { + DetailedError::passkey_attestation_parse_error(&err) + }, + _ => DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Attestation verification failed", + ) + .with_field("attestation_object") + .with_reason(format!("Verification error: {:?}", e)), + } + .to_rpc_error() + })?; + + let public_key_sec1_bytes = public_key.verifying_key.to_sec1_bytes(); + let passkey_storage = PasskeyStorage::new(ctx.storage_db.clone()); + passkey_storage + .add_passkey(&omni_account, &credential_id, &public_key_sec1_bytes) + .map_err(|e| { + error!( + "Failed to attach passkey to omni_account {}: {:?}", + hex_encode(omni_account.as_ref()), + e + ); + match e { + PasskeyError::DuplicatePasskey => { + DetailedError::passkey_already_exists(&credential_id) + }, + PasskeyError::StorageError => { + DetailedError::storage_service_error("passkey attachment") + }, + _ => DetailedError::internal_error("Failed to attach passkey") + .with_reason(format!("Storage error: {:?}", e)) + .with_suggestion("Please try again later"), + } + .to_rpc_error() + })?; + + Ok::(AttachPasskeyResponse { + success: true, + message: format!( + "Passkey ({}) successfully attached to account {}", + credential_id, + hex_encode(omni_account.as_ref()) + ), + }) + }) + .expect("Failed to register omni_attachPasskey method"); +} diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/get_hyperliquid_signature_data.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/get_hyperliquid_signature_data.rs index b933000a08..a3a588bb33 100644 --- a/tee-worker/omni-executor/rpc-server/src/methods/omni/get_hyperliquid_signature_data.rs +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/get_hyperliquid_signature_data.rs @@ -10,9 +10,13 @@ use crate::{ }; use chrono::Utc; use executor_core::intent_executor::IntentExecutor; +use executor_crypto::passkey::{AttestationResult, PasskeyVerifier}; use executor_primitives::{ to_omni_auth, utils::hex::hex_encode, ChainId, ClientAuth, Identity, UserAuth, UserId, }; +use executor_storage::{ + PasskeyChallengeError, PasskeyChallengeStorage, PasskeyError, PasskeyStorage, +}; use hyperliquid_rust_sdk::{ ApproveAgent, ApproveBuilderFee, Eip712, SendAsset, UserDexAbstraction, Withdraw3, }; @@ -31,6 +35,17 @@ pub struct GetHyperliquidSignatureDataParams { pub client_auth: Option, pub action_type: HyperliquidActionType, pub chain_id: ChainId, + pub attach_passkey: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AttachPasskeyData { + /// The attestation object from the WebAuthn registration ceremony (base64url encoded) + /// This contains the credential public key, credential ID, and attestation statement + pub attestation_object: String, + /// The client data JSON from the WebAuthn registration ceremony (base64url encoded) + /// This contains the challenge, origin, and other client-side data + pub client_data_json: String, } #[derive(Debug, Deserialize, Clone)] @@ -115,9 +130,10 @@ pub fn register_get_hyperliquid_signature_data< ) { module .register_async_method("omni_getHyperliquidSignatureData", |params, ctx, _| async move { - debug!("Received omni_getHyperliquidSignatureData, params: {:?}", params); let params = parse_rpc_params::(params)?; + debug!("Received omni_getHyperliquidSignatureData, params: {:?}", params); + // Make sure `user_id` is non-evm type if matches!(params.user_id, UserId::Evm(_)) { error!("Invalid user_id type, expected non-evm"); @@ -126,6 +142,159 @@ pub fn register_get_hyperliquid_signature_data< ); } + if let Some(attach_passkey_data) = ¶ms.attach_passkey { + let user_auth = params.user_auth.as_ref().ok_or_else(|| { + error!("user_auth is required when attach_passkey is provided"); + DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Missing required authentication", + ) + .with_field("user_auth") + .with_expected("User authentication data") + .with_reason("user_auth must be provided when attaching a passkey") + .with_suggestion("Provide user_auth to authenticate before attaching a passkey") + .to_rpc_error() + })?; + let auth = + to_omni_auth(user_auth, ¶ms.user_id, ¶ms.client_id).map_err(|e| { + error!("Failed to convert to OmniAuth: {:?}", e); + DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Failed to convert authentication data", + ) + .with_field("user_auth") + .with_reason(format!("OmniAuth conversion error: {:?}", e)) + .to_rpc_error() + })?; + + verify_auth(ctx.clone(), &auth).await.map_err(|e| { + error!("Failed to verify user authentication: {:?}", e); + DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Authentication verification failed", + ) + .with_field("user_auth") + .with_reason(format!("Verification error: {:?}", e)) + .with_suggestion("Please check your authentication credentials") + .to_rpc_error() + })?; + + let identity = Identity::try_from(params.user_id.clone()).map_err(|_| { + error!("Invalid existing user ID format"); + DetailedError::parse_error("Invalid user ID format").to_rpc_error() + })?; + let omni_account = identity.to_omni_account(¶ms.client_id); + + // Determine expected origin based on client_id + let expected_origin = super::get_origin_for_client(¶ms.client_id); + + // Verify client data JSON and consume challenge + let challenge_storage = PasskeyChallengeStorage::new(ctx.storage_db.clone()); + PasskeyVerifier::verify_client_data_json( + &attach_passkey_data.client_data_json, + omni_account.as_ref(), + expected_origin, + "webauthn.create", // For passkey registration/attachment + |challenge, omni_account| { + challenge_storage + .verify_and_consume_challenge(challenge, &(*omni_account).into()) + .map_err(|e| { + match e { + PasskeyChallengeError::ChallengeNotFound => { + error!("Challenge not found for passkey attachment"); + }, + PasskeyChallengeError::ChallengeExpired => { + error!("Challenge expired for passkey attachment"); + }, + PasskeyChallengeError::InvalidChallenge => { + error!("Invalid challenge for passkey attachment"); + }, + _ => { + error!("Challenge verification failed during passkey attachment: {:?}", e); + }, + } + executor_crypto::passkey::PasskeyError::ChallengeVerificationFailed + }) + }, + ) + .map_err(|e| { + error!("Client data verification failed during passkey attachment: {:?}", e); + match e { + executor_crypto::passkey::PasskeyError::ChallengeVerificationFailed => { + DetailedError::passkey_invalid_challenge( + "Challenge mismatch, expired, or not found", + ) + .with_field("attach_passkey.client_data_json") + }, + executor_crypto::passkey::PasskeyError::OriginVerificationFailed => { + DetailedError::passkey_origin_verification_failed(expected_origin) + .with_field("attach_passkey.client_data_json") + }, + executor_crypto::passkey::PasskeyError::AttestationParseError(err) => { + DetailedError::passkey_client_data_parse_error(&err) + .with_field("attach_passkey.client_data_json") + }, + _ => DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Client data verification failed", + ) + .with_field("attach_passkey.client_data_json") + .with_reason(format!("Verification error: {:?}", e)), + } + .to_rpc_error() + })?; + + // Verify attestation and extract credential_id and public_key + let AttestationResult { credential_id, public_key } = + PasskeyVerifier::verify_attestation(&attach_passkey_data.attestation_object) + .map_err(|e| { + error!("WebAuthn attestation verification failed during passkey attachment: {:?}", e); + match e { + executor_crypto::passkey::PasskeyError::AttestationParseError( + err, + ) => DetailedError::passkey_attestation_parse_error(&err) + .with_field("attach_passkey.attestation_object"), + _ => DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Attestation verification failed", + ) + .with_field("attach_passkey.attestation_object") + .with_reason(format!("Verification error: {:?}", e)), + } + .to_rpc_error() + })?; + + // Store public key as direct SEC1 bytes for direct usage without parsing + let public_key_sec1_bytes = public_key.verifying_key.to_sec1_bytes(); + + // Store the new passkey to the authenticated user's account + let passkey_storage = PasskeyStorage::new(ctx.storage_db.clone()); + passkey_storage + .add_passkey(&omni_account, &credential_id, &public_key_sec1_bytes) + .map_err(|e| { + error!( + "Failed to attach passkey to omni_account {}: {:?}", + hex_encode(omni_account.as_ref()), + e + ); + let detailed_error = match e { + PasskeyError::DuplicatePasskey => { + DetailedError::passkey_already_exists(&credential_id) + .with_field("attach_passkey") + }, + PasskeyError::StorageError => { + DetailedError::storage_service_error("passkey attachment") + .with_field("attach_passkey") + }, + _ => DetailedError::internal_error("Failed to attach passkey") + .with_field("attach_passkey") + .with_reason(format!("Storage error: {:?}", e)) + .with_suggestion("Please try again later"), + }; + detailed_error.to_rpc_error() + })?; + } + // Unified authentication logic let main_address = if let Some(user_auth) = ¶ms.user_auth { // User authentication provided diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/list_passkey.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/list_passkey.rs new file mode 100644 index 0000000000..86d64ad74f --- /dev/null +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/list_passkey.rs @@ -0,0 +1,58 @@ +use crate::{ + detailed_error::DetailedError, server::RpcContext, utils::validation::parse_rpc_params, + Deserialize, Serialize, +}; + +use executor_core::intent_executor::IntentExecutor; +use executor_primitives::UserId; +use executor_storage::PasskeyStorage; +use heima_primitives::Identity; +use jsonrpsee::{types::ErrorObject, RpcModule}; +use tracing::error; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ListPasskeyParams { + pub user_id: UserId, + pub client_id: String, +} + +#[derive(Serialize, Clone)] +pub struct PasskeyInfo { + pub credential_id: String, + pub created_at: u64, +} + +#[derive(Serialize, Clone)] +pub struct ListPasskeyResponse { + pub passkeys: Vec, +} + +pub fn register_list_passkey( + module: &mut RpcModule>, +) { + module + .register_async_method("omni_listPasskey", |params, ctx, _| async move { + let params = parse_rpc_params::(params)?; + + let identity = Identity::try_from(params.user_id.clone()).map_err(|_| { + error!("Invalid user ID format"); + DetailedError::parse_error("Invalid user ID format").to_rpc_error() + })?; + + let omni_account = identity.to_omni_account(¶ms.client_id); + let passkey_storage = PasskeyStorage::new(ctx.storage_db.clone()); + + let passkeys = passkey_storage.list_passkeys(&omni_account).map_err(|e| { + error!("Failed to list passkeys: {:?}", e); + DetailedError::storage_service_error("passkey listing").to_rpc_error() + })?; + + let passkey_infos = passkeys + .into_iter() + .map(|(credential_id, created_at)| PasskeyInfo { credential_id, created_at }) + .collect(); + + Ok::(ListPasskeyResponse { passkeys: passkey_infos }) + }) + .expect("Failed to register omni_listPasskey method"); +} diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/mod.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/mod.rs index 0132f4e08b..ac6bbc886f 100644 --- a/tee-worker/omni-executor/rpc-server/src/methods/omni/mod.rs +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/mod.rs @@ -70,6 +70,18 @@ use login_with_oauth2::*; mod get_hyperliquid_signature_data; use get_hyperliquid_signature_data::*; +mod attach_passkey; +use attach_passkey::*; + +mod remove_passkey; +use remove_passkey::*; + +mod list_passkey; +use list_passkey::*; + +mod request_passkey_challenge; +use request_passkey_challenge::*; + #[cfg(test)] mod test_protected_method; @@ -118,6 +130,10 @@ pub fn register_omni(response: &ApiResponse, op: &str) -> } Ok(()) } + +// Passkey helper functions +pub fn get_rp_id_for_client(client_id: &str) -> &str { + match client_id { + "wildmeta" => "app.wildmeta.ai", + _ => "localhost", // Development/testing + } +} + +pub fn get_origin_for_client(client_id: &str) -> &str { + match client_id { + "wildmeta" => "https://app.wildmeta.ai", + _ => "http://localhost:3000", // Development/testing + } +} diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/remove_passkey.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/remove_passkey.rs new file mode 100644 index 0000000000..32f5f3613c --- /dev/null +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/remove_passkey.rs @@ -0,0 +1,79 @@ +use crate::{ + detailed_error::DetailedError, server::RpcContext, utils::validation::parse_rpc_params, + verify_auth::verify_auth, Deserialize, Serialize, +}; + +use executor_core::intent_executor::IntentExecutor; +use executor_primitives::{to_omni_auth, UserAuth, UserId}; +use executor_storage::{PasskeyError, PasskeyStorage}; +use heima_primitives::Identity; +use jsonrpsee::{types::ErrorObject, RpcModule}; +use tracing::*; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RemovePasskeyParams { + pub user_id: UserId, + pub user_auth: UserAuth, + pub client_id: String, + pub credential_id: String, +} + +#[derive(Serialize, Clone)] +pub struct RemovePasskeyResponse { + pub success: bool, + pub message: String, +} + +pub fn register_remove_passkey( + module: &mut RpcModule>, +) { + module + .register_async_method("omni_removePasskey", |params, ctx, _| async move { + let params = parse_rpc_params::(params)?; + + debug!("Received omni_removePasskey, params: {:?}", params); + + let identity = Identity::try_from(params.user_id.clone()).map_err(|_| { + error!("Invalid user ID format"); + DetailedError::parse_error("Invalid user ID format").to_rpc_error() + })?; + + let auth = to_omni_auth(¶ms.user_auth, ¶ms.user_id, ¶ms.client_id) + .map_err(|e| { + error!("Failed to convert to OmniAuth: {:?}", e); + DetailedError::parse_error("Failed to convert to OmniAuth").to_rpc_error() + })?; + + verify_auth(ctx.clone(), &auth).await.map_err(|e| { + error!("Failed to verify user authentication: {:?}", e); + e.to_detailed_error().to_rpc_error() + })?; + + let omni_account = identity.to_omni_account(¶ms.client_id); + let passkey_storage = PasskeyStorage::new(ctx.storage_db.clone()); + + if !passkey_storage.exists_passkey(&omni_account, ¶ms.credential_id) { + error!("Passkey not found for this account and credential"); + return Err(DetailedError::passkey_not_found(¶ms.credential_id).to_rpc_error()); + } + + passkey_storage + .remove_passkey(&omni_account, ¶ms.credential_id) + .map_err(|e| match e { + PasskeyError::StorageError => { + error!("Failed to remove passkey: storage error"); + DetailedError::storage_service_error("passkey removal").to_rpc_error() + }, + _ => { + error!("Failed to remove passkey: {:?}", e); + DetailedError::storage_service_error("passkey removal").to_rpc_error() + }, + })?; + + Ok::(RemovePasskeyResponse { + success: true, + message: format!("Passkey {} successfully removed", params.credential_id), + }) + }) + .expect("Failed to register omni_removePasskey method"); +} diff --git a/tee-worker/omni-executor/rpc-server/src/methods/omni/request_passkey_challenge.rs b/tee-worker/omni-executor/rpc-server/src/methods/omni/request_passkey_challenge.rs new file mode 100644 index 0000000000..d0be57b4fb --- /dev/null +++ b/tee-worker/omni-executor/rpc-server/src/methods/omni/request_passkey_challenge.rs @@ -0,0 +1,79 @@ +use crate::{ + detailed_error::DetailedError, server::RpcContext, utils::validation::parse_rpc_params, + Deserialize, Serialize, +}; +use executor_core::intent_executor::IntentExecutor; +use executor_primitives::UserId; +use executor_storage::PasskeyChallengeStorage; +use heima_primitives::Identity; +use jsonrpsee::{types::ErrorObject, RpcModule}; +use tracing::*; + +const CHALLENGE_TIMEOUT_SECONDS: u64 = 300; // 5 minutes + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RequestPasskeyChallengeParams { + pub user_id: UserId, + pub client_id: String, +} + +#[derive(Serialize, Clone)] +pub struct RequestPasskeyChallengeResponse { + pub challenge: String, // Base64 encoded 32 bytes + pub timeout: u64, // Challenge validity in seconds +} + +pub fn register_request_passkey_challenge< + CrossChainIntentExecutor: IntentExecutor + Send + Sync + 'static, +>( + module: &mut RpcModule>, +) { + module + .register_async_method("omni_requestPasskeyChallenge", |params, ctx, _| async move { + let params = parse_rpc_params::(params)?; + + debug!("Received omni_requestPasskeyChallenge, params: {:?}", params); + + let identity = Identity::try_from(params.user_id.clone()).map_err(|_| { + error!("Invalid existing user ID format"); + DetailedError::parse_error("Invalid user ID format").to_rpc_error() + })?; + + // Generate a random 32-byte challenge + use rand::RngCore; + let mut challenge_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut challenge_bytes); + + // Base64 encode the challenge (URL-safe, no padding) + use base64::Engine; + let challenge = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes); + + // Store challenge with expiration + let timeout = CHALLENGE_TIMEOUT_SECONDS; + let challenge_storage = PasskeyChallengeStorage::new(ctx.storage_db.clone()); + let omni_account = identity.to_omni_account(¶ms.client_id); + + challenge_storage.store_challenge(&omni_account, &challenge, timeout).map_err( + |_| { + error!("Failed to store challenge"); + DetailedError::storage_service_error("passkey challenge storage") + .with_suggestion( + "Please try again later or contact support if the issue persists", + ) + .to_rpc_error() + }, + )?; + + debug!( + "TEST, passkey challenge: {:?}, user_id: {:?}, client_id: {:?}, omni: {:?}", + challenge, params.user_id, params.client_id, omni_account + ); + + Ok::(RequestPasskeyChallengeResponse { + challenge, + timeout, + }) + }) + .expect("Failed to register omni_requestPasskeyChallenge method"); +} diff --git a/tee-worker/omni-executor/rpc-server/src/verify_auth.rs b/tee-worker/omni-executor/rpc-server/src/verify_auth.rs index 0d2a66fcb4..802f581bc9 100644 --- a/tee-worker/omni-executor/rpc-server/src/verify_auth.rs +++ b/tee-worker/omni-executor/rpc-server/src/verify_auth.rs @@ -1,11 +1,14 @@ -use crate::server::RpcContext; +use crate::{detailed_error::DetailedError, server::RpcContext}; use executor_core::intent_executor::IntentExecutor; use executor_crypto::hashing::blake2_256; use executor_primitives::{ signature::HeimaMultiSignature, utils::hex::hex_encode, Hash, Hashable, Identity, OAuth2Data, - OAuth2Provider, OmniAuth, VerificationCode, Web2IdentityType, + OAuth2Provider, OmniAuth, PasskeyData, VerificationCode, Web2IdentityType, +}; +use executor_storage::{ + OAuth2StateVerifierStorage, PasskeyChallengeStorage, Storage, StorageDB, + VerificationCodeStorage, }; -use executor_storage::{OAuth2StateVerifierStorage, Storage, StorageDB, VerificationCodeStorage}; use heima_authentication::{ auth_token::{AuthTokenClaims, AuthTokenValidator, Error as AuthTokenError, Validation}, constants::AUTH_TOKEN_ID_TYPE, @@ -17,6 +20,7 @@ use oauth_providers::{ }; use parity_scale_codec::Encode; use std::{fmt::Display, sync::Arc}; +use tracing::debug; #[derive(Debug, PartialEq)] pub enum AuthenticationError { @@ -26,6 +30,7 @@ pub enum AuthenticationError { OAuth2Error(String), OAuth2SubClaimMismatch, AuthTokenError(AuthTokenError), + PasskeyError(String), } impl Display for AuthenticationError { @@ -49,6 +54,77 @@ impl Display for AuthenticationError { AuthenticationError::AuthTokenError(err) => { write!(f, "Auth token error: {:?}", err) }, + AuthenticationError::PasskeyError(msg) => { + write!(f, "Passkey error: {}", msg) + }, + } + } +} + +impl AuthenticationError { + /// Convert AuthenticationError to DetailedError with proper error codes and context + pub fn to_detailed_error(&self) -> DetailedError { + use crate::error_code::AUTH_VERIFICATION_FAILED_CODE; + match self { + AuthenticationError::PasskeyError(msg) => { + // Try to match specific passkey error types for better error messages + if msg.contains("Challenge not found") + || msg.contains("Challenge") && msg.contains("not found") + { + DetailedError::passkey_challenge_not_found() + } else if msg.contains("Challenge expired") { + DetailedError::passkey_challenge_expired() + } else if msg.contains("Invalid challenge") { + DetailedError::passkey_invalid_challenge(msg) + } else if msg.contains("Passkey not found") { + // Extract credential_id if present in message + DetailedError::passkey_not_found("") + } else if msg.contains("signature verification failed") + || msg.contains("Invalid signature") + { + DetailedError::passkey_signature_invalid(msg) + } else if msg.contains("Failed to parse") { + DetailedError::passkey_parse_error("", msg) + } else if msg.contains("RP ID") { + // Try to extract RP ID info from message + let client_id = msg + .split("client_id: '") + .nth(1) + .and_then(|s| s.split('\'').next()) + .unwrap_or(""); + let expected_rp_id = msg + .split("Expected RP ID: '") + .nth(1) + .and_then(|s| s.split('\'').next()) + .unwrap_or(""); + DetailedError::passkey_rp_id_mismatch(expected_rp_id, client_id) + } else if msg.contains("Replay attack detected") { + // Extract counter if present + let counter = msg + .split("counter (") + .nth(1) + .and_then(|s| s.split(')').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + DetailedError::passkey_replay_attack(counter) + } else if msg.contains("Counter validation failed") + || msg.contains("cloned or downgraded") + { + DetailedError::passkey_counter_validation_failed() + } else if msg.contains("User presence flag") { + DetailedError::passkey_user_verification_failed("user_presence") + } else if msg.contains("User verification flag") { + DetailedError::passkey_user_verification_failed("user_verification") + } else { + // Generic passkey error + DetailedError::new( + AUTH_VERIFICATION_FAILED_CODE, + "Passkey authentication failed", + ) + .with_reason(msg) + } + }, + _ => DetailedError::new(AUTH_VERIFICATION_FAILED_CODE, self.to_string()), } } } @@ -74,8 +150,8 @@ pub async fn verify_auth { - todo!() + OmniAuth::Passkey(ref passkey_data) => { + verify_passkey_authentication(ctx, passkey_data).map(|_| ()) }, } } @@ -301,6 +377,136 @@ fn verify_id_token_claims( Ok(()) } +pub fn verify_passkey_authentication< + CrossChainIntentExecutor: IntentExecutor + Send + Sync + 'static, +>( + ctx: Arc>, + passkey_data: &PasskeyData, +) -> Result<(), AuthenticationError> { + use crate::methods::omni::{get_origin_for_client, get_rp_id_for_client}; + use executor_crypto::passkey::{ClientData, PasskeyVerifier}; + use executor_storage::PasskeyStorage; + + let identity = Identity::try_from(passkey_data.user_id.clone()).map_err(|_| { + AuthenticationError::PasskeyError("Invalid user ID format".to_string()) + })?; + let omni_account = identity.to_omni_account(&passkey_data.client_id); + + let client_data: ClientData = + PasskeyVerifier::parse_client_data_json(&passkey_data.client_data_json).map_err(|e| { + AuthenticationError::PasskeyError(format!("Failed to parse client data: {}", e)) + })?; + + let expected_origin = get_origin_for_client(&passkey_data.client_id); + if client_data.origin != expected_origin { + return Err(AuthenticationError::PasskeyError(format!( + "Client data origin mismatch: expected '{}', got '{}'", + expected_origin, + client_data.origin.as_str() + ))); + } + + const EXPECTED_PASSKEY_TYPE: &str = "webauthn.get"; + if client_data.type_ != EXPECTED_PASSKEY_TYPE { + return Err(AuthenticationError::PasskeyError(format!( + "Invalid client data type: expected '{}', got '{}'", + EXPECTED_PASSKEY_TYPE, + client_data.type_.as_str() + ))); + } + + debug!( + "TEST, client_data: {:?}, user_id: {:?}, client_id: {:?}, omni: {:?}", + client_data, passkey_data.user_id, passkey_data.client_id, omni_account + ); + + // Verify challenge + let challenge_storage = PasskeyChallengeStorage::new(ctx.storage_db.clone()); + challenge_storage + .verify_and_consume_challenge(&client_data.challenge, &omni_account) + .map_err(|e| { + use executor_storage::PasskeyChallengeError; + match e { + PasskeyChallengeError::ChallengeNotFound => { + AuthenticationError::PasskeyError("Challenge not found".to_string()) + }, + PasskeyChallengeError::ChallengeExpired => { + AuthenticationError::PasskeyError("Challenge expired".to_string()) + }, + PasskeyChallengeError::InvalidChallenge => { + AuthenticationError::PasskeyError("Invalid challenge".to_string()) + }, + _ => AuthenticationError::PasskeyError("Challenge verification failed".to_string()), + } + })?; + + // Look up the stored passkey record using omni_account + credential_id + let passkey_record = PasskeyStorage::new(ctx.storage_db.clone()) + .get_passkey(&omni_account, &passkey_data.credential_id) + .map_err(|_| AuthenticationError::PasskeyError("Storage error".to_string()))? + .ok_or_else(|| { + AuthenticationError::PasskeyError( + "Passkey not found for this account and credential".to_string(), + ) + })?; + let public_key = PasskeyVerifier::from_sec1_bytes(&passkey_record.pubkey).map_err(|e| { + AuthenticationError::PasskeyError(format!("Invalid stored public key: {}", e)) + })?; + + // Parse auth_data bytes for validation + let auth_data_bytes = hex::decode(&passkey_data.auth_data).map_err(|_| { + AuthenticationError::PasskeyError("Invalid auth data hex format".to_string()) + })?; + + if auth_data_bytes.len() < 37 { + return Err(AuthenticationError::PasskeyError("Auth data too short".to_string())); + } + + // CRITICAL SECURITY CHECK: Verify RP ID hash + // The first 32 bytes of auth data must be SHA-256(RP ID) to prevent phishing attacks + // This ensures the authenticator signed for the correct domain + let expected_rp_id = get_rp_id_for_client(&passkey_data.client_id); + + PasskeyVerifier::verify_rp_id_hash(&auth_data_bytes, expected_rp_id).map_err(|e| { + AuthenticationError::PasskeyError(format!( + "RP ID validation failed: {}. Expected RP ID: '{}' for client_id: '{}'", + e, expected_rp_id, passkey_data.client_id + )) + })?; + + // Check flags (UP bit must be set, UV bit for security) + let flags = auth_data_bytes[32]; + let up_flag = (flags & 0x01) != 0; + let uv_flag = (flags & 0x04) != 0; + + if !up_flag { + return Err(AuthenticationError::PasskeyError("User presence flag not set".to_string())); + } + + if !uv_flag { + return Err(AuthenticationError::PasskeyError( + "User verification flag not set".to_string(), + )); + } + + // Verify the passkey signature (pure cryptographic verification) + let is_valid = PasskeyVerifier::verify_passkey_signature_only( + &passkey_data.auth_data, + &passkey_data.client_data_json, + &passkey_data.signature, + &public_key, + ) + .map_err(|e| { + AuthenticationError::PasskeyError(format!("Passkey signature verification failed: {}", e)) + })?; + + if !is_valid { + return Err(AuthenticationError::Web3InvalidSignature); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*;