diff --git a/crates/sui-e2e-tests/tests/passkey_session_e2e_test.rs b/crates/sui-e2e-tests/tests/passkey_session_e2e_test.rs new file mode 100644 index 00000000000000..9568eef0f34753 --- /dev/null +++ b/crates/sui-e2e-tests/tests/passkey_session_e2e_test.rs @@ -0,0 +1,346 @@ +// todo +// // Copyright (c) Mysten Labs, Inc. +// // SPDX-License-Identifier: Apache-2.0 +// use fastcrypto::{hash::HashFunction, traits::ToFromBytes}; +// use p256::pkcs8::DecodePublicKey; +// use passkey_authenticator::{Authenticator, UserValidationMethod}; +// use passkey_client::Client; +// use passkey_types::{ +// ctap2::Aaguid, +// rand::random_vec, +// webauthn::{ +// AttestationConveyancePreference, CredentialCreationOptions, CredentialRequestOptions, +// PublicKeyCredentialCreationOptions, PublicKeyCredentialParameters, +// PublicKeyCredentialRequestOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialType, +// PublicKeyCredentialUserEntity, UserVerificationRequirement, +// }, +// Bytes, Passkey, +// }; +// use shared_crypto::intent::{Intent, IntentMessage, INTENT_PREFIX_LENGTH}; +// use std::net::SocketAddr; +// use sui_core::authority_client::AuthorityAPI; +// use sui_macros::sim_test; +// use sui_test_transaction_builder::TestTransactionBuilder; +// use sui_types::error::UserInputError; +// use sui_types::error::{SuiError, SuiResult}; +// use sui_types::signature::GenericSignature; +// use sui_types::transaction::Transaction; +// use sui_types::{ +// base_types::SuiAddress, +// crypto::{PublicKey, SignatureScheme}, +// passkey_authenticator::{to_signing_message, PasskeyAuthenticator}, +// transaction::TransactionData, +// }; +// use sui_types::{ +// crypto::{DefaultHash, Signature}, +// passkey_authenticator::to_signing_digest, +// }; +// use test_cluster::TestCluster; +// use test_cluster::TestClusterBuilder; +// use url::Url; + +// struct MyUserValidationMethod {} +// #[async_trait::async_trait] +// impl UserValidationMethod for MyUserValidationMethod { +// async fn check_user_presence(&self) -> bool { +// true +// } + +// async fn check_user_verification(&self) -> bool { +// true +// } + +// fn is_verification_enabled(&self) -> Option { +// Some(true) +// } + +// fn is_presence_enabled(&self) -> bool { +// true +// } +// } + +// /// A helper struct for passkey response and transaction construction. +// pub struct PasskeyResponse { +// user_sig_bytes: Vec, +// authenticator_data: Vec, +// client_data_json: String, +// intent_msg: IntentMessage, +// } + +// /// Submits a transaction to the test cluster and returns the result. +// async fn execute_tx(tx: Transaction, test_cluster: &TestCluster) -> SuiResult { +// test_cluster +// .authority_aggregator() +// .authority_clients +// .values() +// .next() +// .unwrap() +// .authority_client() +// .handle_transaction(tx, Some(SocketAddr::new([127, 0, 0, 1].into(), 0))) +// .await +// .map(|_| ()) +// } + +// /// Register a new passkey, derive its address, fund it with gas and create a test +// /// transaction, then get a response from the passkey from signing. +// async fn create_credential_and_sign_test_tx( +// test_cluster: &TestCluster, +// sender: Option, +// change_intent: bool, +// change_tx: bool, +// ) -> PasskeyResponse { +// // set up authenticator and client +// let my_aaguid = Aaguid::new_empty(); +// let user_validation_method = MyUserValidationMethod {}; +// let store: Option = None; +// let my_authenticator = Authenticator::new(my_aaguid, store, user_validation_method); +// let mut my_client = Client::new(my_authenticator); +// let origin = Url::parse("https://www.sui.io").unwrap(); + +// // Create credential. +// let challenge_bytes_from_rp: Bytes = random_vec(32).into(); +// let user_entity = PublicKeyCredentialUserEntity { +// id: random_vec(32).into(), +// display_name: "Johnny Passkey".into(), +// name: "jpasskey@example.org".into(), +// }; +// let request = CredentialCreationOptions { +// public_key: PublicKeyCredentialCreationOptions { +// rp: PublicKeyCredentialRpEntity { +// id: None, // Leaving the ID as None means use the effective domain +// name: origin.domain().unwrap().into(), +// }, +// user: user_entity, +// challenge: challenge_bytes_from_rp, +// pub_key_cred_params: vec![PublicKeyCredentialParameters { +// ty: PublicKeyCredentialType::PublicKey, +// alg: coset::iana::Algorithm::ES256, +// }], +// timeout: None, +// exclude_credentials: None, +// authenticator_selection: None, +// hints: None, +// attestation: AttestationConveyancePreference::None, +// attestation_formats: None, +// extensions: None, +// }, +// }; +// let my_webauthn_credential = my_client.register(&origin, request, None).await.unwrap(); +// let verifying_key = p256::ecdsa::VerifyingKey::from_public_key_der( +// my_webauthn_credential +// .response +// .public_key +// .unwrap() +// .as_slice(), +// ) +// .unwrap(); + +// // Derive compact pubkey from DER format. +// let encoded_point = verifying_key.to_encoded_point(false); +// let x = encoded_point.x(); +// let y = encoded_point.y(); +// let prefix = if y.unwrap()[31] % 2 == 0 { 0x02 } else { 0x03 }; +// let mut pk_bytes = vec![prefix]; +// pk_bytes.extend_from_slice(x.unwrap()); +// let pk = PublicKey::try_from_bytes(SignatureScheme::PasskeyAuthenticator, &pk_bytes).unwrap(); + +// // Compute sui address as sender, fund gas and make a test transaction. +// let sender = match sender { +// Some(s) => s, +// None => SuiAddress::from(&pk), +// }; +// let rgp = test_cluster.get_reference_gas_price().await; +// let gas = test_cluster +// .fund_address_and_return_gas(rgp, Some(20000000000), sender) +// .await; +// let tx_data = TestTransactionBuilder::new(sender, gas, rgp) +// .transfer_sui(None, SuiAddress::ZERO) +// .build(); +// let intent_msg = IntentMessage::new(Intent::sui_transaction(), tx_data); + +// // Compute the challenge = blake2b_hash(intent_msg(tx)) for passkey credential request. +// // If change_intent, mangle the intent bytes. If change_tx, mangle the hashed tx bytes. +// let mut extended = [0; INTENT_PREFIX_LENGTH + DefaultHash::OUTPUT_SIZE]; +// let passkey_digest = if change_intent { +// extended[..INTENT_PREFIX_LENGTH].copy_from_slice(&Intent::personal_message().to_bytes()); +// extended[INTENT_PREFIX_LENGTH..].copy_from_slice(&to_signing_digest(&intent_msg)); +// extended +// } else if change_tx { +// extended[..INTENT_PREFIX_LENGTH].copy_from_slice(&intent_msg.intent.to_bytes()); +// extended[INTENT_PREFIX_LENGTH..].copy_from_slice(&random_vec(32)); +// extended +// } else { +// to_signing_message(&intent_msg) +// }; + +// // Request a signature from passkey with challenge set to passkey_digest. +// let credential_request = CredentialRequestOptions { +// public_key: PublicKeyCredentialRequestOptions { +// challenge: Bytes::from(passkey_digest.to_vec()), +// timeout: None, +// rp_id: Some(String::from(origin.domain().unwrap())), +// allow_credentials: None, +// user_verification: UserVerificationRequirement::default(), +// attestation: Default::default(), +// attestation_formats: None, +// extensions: None, +// hints: None, +// }, +// }; + +// let authenticated_cred = my_client +// .authenticate(&origin, credential_request, None) +// .await +// .unwrap(); + +// // Parse signature from der format in response and normalize it to lower s. +// let sig_bytes_der = authenticated_cred.response.signature.as_slice(); +// let sig = p256::ecdsa::Signature::from_der(sig_bytes_der).unwrap(); +// let sig_bytes = sig.normalize_s().unwrap_or(sig).to_bytes(); + +// let mut user_sig_bytes = vec![SignatureScheme::Secp256r1.flag()]; +// user_sig_bytes.extend_from_slice(&sig_bytes); +// user_sig_bytes.extend_from_slice(&pk_bytes); + +// // Parse authenticator_data and client_data_json from response. +// let authenticator_data = authenticated_cred.response.authenticator_data.as_slice(); +// let client_data_json = authenticated_cred.response.client_data_json.as_slice(); + +// PasskeyResponse { +// user_sig_bytes, +// authenticator_data: authenticator_data.to_vec(), +// client_data_json: String::from_utf8_lossy(client_data_json).to_string(), +// intent_msg, +// } +// } + +// fn make_good_passkey_tx(response: PasskeyResponse) -> Transaction { +// let sig = GenericSignature::PasskeyAuthenticator( +// PasskeyAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&response.user_sig_bytes).unwrap(), +// ) +// .unwrap(), +// ); +// Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]) +// } + +// #[sim_test] +// async fn test_passkey_feature_deny() { +// use sui_protocol_config::ProtocolConfig; +// let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| { +// config.set_passkey_auth_for_testing(false); +// config +// }); +// let test_cluster = TestClusterBuilder::new().build().await; +// let response = create_credential_and_sign_test_tx(&test_cluster, None, false, false).await; +// let tx = make_good_passkey_tx(response); +// let err = execute_tx(tx, &test_cluster).await.unwrap_err(); +// assert!(matches!( +// err, +// SuiError::UserInputError { +// error: UserInputError::Unsupported(..) +// } +// )); +// } + +// #[sim_test] +// async fn test_passkey_authenticator_verifies() { +// let test_cluster = TestClusterBuilder::new().build().await; +// let response = create_credential_and_sign_test_tx(&test_cluster, None, false, false).await; +// let tx = make_good_passkey_tx(response); +// let res = execute_tx(tx, &test_cluster).await; +// assert!(res.is_ok()); +// } + +// #[sim_test] +// async fn test_passkey_fails_mismatched_challenge() { +// let test_cluster = TestClusterBuilder::new().build().await; + +// // Tweak intent in challenge that is sent to passkey. +// let response = create_credential_and_sign_test_tx(&test_cluster, None, true, false).await; +// let sig = GenericSignature::PasskeyAuthenticator( +// PasskeyAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&response.user_sig_bytes).unwrap(), +// ) +// .unwrap(), +// ); +// let tx = Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]); +// let res = execute_tx(tx, &test_cluster).await; +// let err = res.unwrap_err(); +// assert_eq!( +// err, +// SuiError::InvalidSignature { +// error: "Invalid challenge".to_string() +// } +// ); + +// // Tweak tx_digest bytes in challenge that is sent to passkey. +// let response = create_credential_and_sign_test_tx(&test_cluster, None, false, true).await; +// let sig = GenericSignature::PasskeyAuthenticator( +// PasskeyAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&response.user_sig_bytes).unwrap(), +// ) +// .unwrap(), +// ); +// let tx = Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]); +// let res = execute_tx(tx, &test_cluster).await; +// let err = res.unwrap_err(); +// assert_eq!( +// err, +// SuiError::InvalidSignature { +// error: "Invalid challenge".to_string() +// } +// ); +// } + +// #[sim_test] +// async fn test_passkey_fails_to_verify_sig() { +// let test_cluster = TestClusterBuilder::new().build().await; +// let response = create_credential_and_sign_test_tx(&test_cluster, None, false, false).await; +// let mut modified_sig = response.user_sig_bytes.clone(); +// modified_sig[1] = 0x00; +// let sig = GenericSignature::PasskeyAuthenticator( +// PasskeyAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&modified_sig).unwrap(), +// ) +// .unwrap(), +// ); +// let tx = Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]); +// let res = execute_tx(tx, &test_cluster).await; +// let err = res.unwrap_err(); +// assert_eq!( +// err, +// SuiError::InvalidSignature { +// error: "Fails to verify".to_string() +// } +// ); +// } + +// #[sim_test] +// async fn test_passkey_fails_wrong_author() { +// let test_cluster = TestClusterBuilder::new().build().await; +// // Modify sender that receives gas and construct test txn. +// let response = +// create_credential_and_sign_test_tx(&test_cluster, Some(SuiAddress::ZERO), false, false) +// .await; +// let sig = GenericSignature::PasskeyAuthenticator( +// PasskeyAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&response.user_sig_bytes).unwrap(), +// ) +// .unwrap(), +// ); +// let tx = Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]); +// let res = execute_tx(tx, &test_cluster).await; +// let err = res.unwrap_err(); +// assert!(matches!(err, SuiError::SignerSignatureAbsent { .. })); +// } diff --git a/crates/sui-keys/src/key_derive.rs b/crates/sui-keys/src/key_derive.rs index b86e72901116d0..185382de979516 100644 --- a/crates/sui-keys/src/key_derive.rs +++ b/crates/sui-keys/src/key_derive.rs @@ -64,7 +64,8 @@ pub fn derive_key_pair_from_path( SignatureScheme::BLS12381 | SignatureScheme::MultiSig | SignatureScheme::ZkLoginAuthenticator - | SignatureScheme::PasskeyAuthenticator => Err(SuiError::UnsupportedFeatureError { + | SignatureScheme::PasskeyAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => Err(SuiError::UnsupportedFeatureError { error: format!("key derivation not supported {:?}", key_scheme), }), } @@ -162,7 +163,8 @@ pub fn validate_path( SignatureScheme::BLS12381 | SignatureScheme::MultiSig | SignatureScheme::ZkLoginAuthenticator - | SignatureScheme::PasskeyAuthenticator => Err(SuiError::UnsupportedFeatureError { + | SignatureScheme::PasskeyAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => Err(SuiError::UnsupportedFeatureError { error: format!("key derivation not supported {:?}", key_scheme), }), } diff --git a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__Testnet_version_54.snap b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__Testnet_version_54.snap index 40241a8b59b3d6..26dcfda259fd65 100644 --- a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__Testnet_version_54.snap +++ b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__Testnet_version_54.snap @@ -272,6 +272,7 @@ hmac_hmac_sha3_256_input_cost_per_byte: 2 hmac_hmac_sha3_256_input_cost_per_block: 2 check_zklogin_id_cost_base: 200 check_zklogin_issuer_cost_base: 200 +<<<<<<< HEAD bcs_per_byte_serialized_cost: 2 bcs_legacy_min_output_size_cost: 1 bcs_failure_cost: 52 @@ -301,14 +302,22 @@ vector_destroy_empty_base_cost: 52 vector_swap_base_cost: 52 debug_print_base_cost: 52 debug_print_stack_trace_base_cost: 52 +======= +>>>>>>> 92fc2625bf (feat: add session based passkey authenticator) execution_version: 3 consensus_bad_nodes_stake_threshold: 20 max_jwk_votes_per_validator_per_epoch: 240 max_age_of_jwk_in_epochs: 1 random_beacon_reduction_allowed_delta: 800 +<<<<<<< HEAD random_beacon_reduction_lower_bound: 1000 random_beacon_dkg_timeout_round: 3000 random_beacon_min_round_interval_ms: 500 +======= +random_beacon_reduction_lower_bound: 1600 +random_beacon_dkg_timeout_round: 3000 +random_beacon_min_round_interval_ms: 200 +>>>>>>> 92fc2625bf (feat: add session based passkey authenticator) random_beacon_dkg_version: 1 consensus_max_transaction_size_bytes: 262144 consensus_max_transactions_in_block_bytes: 6291456 diff --git a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_54.snap b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_54.snap index 3e39160917226a..85c311c6454c28 100644 --- a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_54.snap +++ b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_54.snap @@ -64,6 +64,10 @@ feature_flags: enable_coin_deny_list_v2: true passkey_auth: true authority_capabilities_v2: true +<<<<<<< HEAD +======= + passkey_session_auth: true +>>>>>>> 92fc2625bf (feat: add session based passkey authenticator) max_tx_size_bytes: 131072 max_input_objects: 2048 max_size_written_objects: 5000000 @@ -281,6 +285,7 @@ check_zklogin_id_cost_base: 200 check_zklogin_issuer_cost_base: 200 vdf_verify_vdf_cost: 1500 vdf_hash_to_input_cost: 100 +<<<<<<< HEAD bcs_per_byte_serialized_cost: 2 bcs_legacy_min_output_size_cost: 1 bcs_failure_cost: 52 @@ -310,14 +315,22 @@ vector_destroy_empty_base_cost: 52 vector_swap_base_cost: 52 debug_print_base_cost: 52 debug_print_stack_trace_base_cost: 52 +======= +>>>>>>> 92fc2625bf (feat: add session based passkey authenticator) execution_version: 3 consensus_bad_nodes_stake_threshold: 20 max_jwk_votes_per_validator_per_epoch: 240 max_age_of_jwk_in_epochs: 1 random_beacon_reduction_allowed_delta: 800 +<<<<<<< HEAD random_beacon_reduction_lower_bound: 1000 random_beacon_dkg_timeout_round: 3000 random_beacon_min_round_interval_ms: 500 +======= +random_beacon_reduction_lower_bound: 1600 +random_beacon_dkg_timeout_round: 3000 +random_beacon_min_round_interval_ms: 200 +>>>>>>> 92fc2625bf (feat: add session based passkey authenticator) random_beacon_dkg_version: 1 consensus_max_transaction_size_bytes: 262144 consensus_max_transactions_in_block_bytes: 6291456 diff --git a/crates/sui-types/src/base_types.rs b/crates/sui-types/src/base_types.rs index 9c28ad12d17666..0f371f6a5e0e8d 100644 --- a/crates/sui-types/src/base_types.rs +++ b/crates/sui-types/src/base_types.rs @@ -771,6 +771,7 @@ impl TryFrom<&GenericSignature> for SuiAddress { SuiAddress::try_from_unpadded(&zklogin.inputs) } GenericSignature::PasskeyAuthenticator(s) => Ok(SuiAddress::from(&s.get_pk()?)), + GenericSignature::PasskeySessionAuthenticator(s) => Ok(SuiAddress::from(&s.get_pk()?)), } } } diff --git a/crates/sui-types/src/crypto.rs b/crates/sui-types/src/crypto.rs index 97379e6fd964bc..57caeaf2b59f12 100644 --- a/crates/sui-types/src/crypto.rs +++ b/crates/sui-types/src/crypto.rs @@ -359,7 +359,8 @@ impl PublicKey { SignatureScheme::Secp256r1 => Ok(PublicKey::Secp256r1( (&Secp256r1PublicKey::from_bytes(key_bytes)?).into(), )), - SignatureScheme::PasskeyAuthenticator => Ok(PublicKey::Passkey( + SignatureScheme::PasskeyAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => Ok(PublicKey::Passkey( (&Secp256r1PublicKey::from_bytes(key_bytes)?).into(), )), _ => Err(eyre!("Unsupported curve")), @@ -1003,7 +1004,8 @@ impl SuiSignature for S { let (sig, pk) = &self.get_verification_inputs()?; match scheme { - SignatureScheme::ZkLoginAuthenticator => {} // Pass this check because zk login does not derive address from pubkey. + SignatureScheme::ZkLoginAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => {} // Pass this check because these two schemes do not derive address from pubkey. _ => { let address = SuiAddress::from(pk); if author != address { @@ -1664,6 +1666,7 @@ pub enum SignatureScheme { MultiSig, ZkLoginAuthenticator, PasskeyAuthenticator, + PasskeySessionAuthenticator, } impl SignatureScheme { @@ -1676,6 +1679,7 @@ impl SignatureScheme { SignatureScheme::BLS12381 => 0x04, // This is currently not supported for user Sui Address. SignatureScheme::ZkLoginAuthenticator => 0x05, SignatureScheme::PasskeyAuthenticator => 0x06, + SignatureScheme::PasskeySessionAuthenticator => 0x07, } } @@ -1695,6 +1699,7 @@ impl SignatureScheme { 0x04 => Ok(SignatureScheme::BLS12381), 0x05 => Ok(SignatureScheme::ZkLoginAuthenticator), 0x06 => Ok(SignatureScheme::PasskeyAuthenticator), + 0x07 => Ok(SignatureScheme::PasskeySessionAuthenticator), _ => Err(SuiError::KeyConversionError( "Invalid key scheme".to_string(), )), diff --git a/crates/sui-types/src/lib.rs b/crates/sui-types/src/lib.rs index f9b6d0abf477e4..359a54f009a12d 100644 --- a/crates/sui-types/src/lib.rs +++ b/crates/sui-types/src/lib.rs @@ -70,6 +70,7 @@ pub mod multisig; pub mod multisig_legacy; pub mod object; pub mod passkey_authenticator; +pub mod passkey_session_authenticator; pub mod programmable_transaction_builder; pub mod quorum_driver_types; pub mod randomness_state; diff --git a/crates/sui-types/src/passkey_session_authenticator.rs b/crates/sui-types/src/passkey_session_authenticator.rs new file mode 100644 index 00000000000000..3c33fe1a7919db --- /dev/null +++ b/crates/sui-types/src/passkey_session_authenticator.rs @@ -0,0 +1,352 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +use crate::crypto::PublicKey; +use crate::crypto::Secp256r1SuiSignature; +use crate::crypto::SuiSignatureInner; +use crate::signature_verification::VerifiedDigestCache; +use crate::{ + base_types::{EpochId, SuiAddress}, + crypto::{DefaultHash, Signature, SignatureScheme, SuiSignature}, + digests::ZKLoginInputsDigest, + error::{SuiError, SuiResult}, + signature::{AuthenticatorTrait, VerifyParams}, +}; +use fastcrypto::hash::{HashFunction, Sha256}; +use fastcrypto::rsa::{Base64UrlUnpadded, Encoding}; +use fastcrypto::secp256r1::{Secp256r1PublicKey, Secp256r1Signature}; +use fastcrypto::traits::VerifyingKey; +use fastcrypto::{error::FastCryptoError, traits::ToFromBytes}; +use once_cell::sync::OnceCell; +use passkey_types::webauthn::{ClientDataType, CollectedClientData}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use shared_crypto::intent::{IntentMessage, INTENT_PREFIX_LENGTH}; +use std::hash::Hash; +use std::hash::Hasher; +use std::sync::Arc; + +#[cfg(test)] +#[path = "unit_tests/passkey_session_authenticator_test.rs"] +mod passkey_session_authenticator_test; + +/// An passkey session authenticator with parsed fields. See field defition below. Can be initialized from [struct RawPasskeySessionAuthenticator]. +#[derive(Debug, Clone, JsonSchema)] +pub struct PasskeySessionAuthenticator { + /// `authenticatorData` is a bytearray that encodes + /// [Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) + /// structure returned by the authenticator attestation + /// response as is. + authenticator_data: Vec, + + /// `clientDataJSON` contains a JSON-compatible + /// UTF-8 encoded string of the client data which + /// is passed to the authenticator by the client + /// during the authentication request (see [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata)) + client_data_json: String, + + /// Normalized r1 signature returned by passkey. + /// Initialized from `passkey_signature` in `RawPasskeySessionAuthenticator`. + #[serde(skip)] + passkey_signature: Secp256r1Signature, + + /// Compact r1 public key upon passkey creation. + /// Initialized from `passkey_signature` in `RawPasskeySessionAuthenticator`. + #[serde(skip)] + passkey_pk: Secp256r1PublicKey, + + /// Ephemeral signature that can be of any scheme. + ephemeral_signature: Signature, + + /// challenge field parsed from clientDataJSON. This should be eph_flag || eph_pk || max_epoch. + parsed_challenge: Vec, + + /// Maximum epoch that the ephemeral key is valid for. + max_epoch: EpochId, + + /// Initialization of bytes for passkey in serialized form. + #[serde(skip)] + bytes: OnceCell>, +} + +/// An raw passkey session authenticator struct used during deserialization. Can be converted to [struct RawPasskeySessionAuthenticator]. +#[derive(Serialize, Deserialize, Debug)] +pub struct RawPasskeySessionAuthenticator { + pub authenticator_data: Vec, + pub client_data_json: String, + pub passkey_signature: Signature, + pub max_epoch: EpochId, + pub ephemeral_signature: Signature, +} + +/// Convert [struct RawPasskeySessionAuthenticator] to [struct RawPasskeySessionAuthenticator] with validations. +impl TryFrom for PasskeySessionAuthenticator { + type Error = SuiError; + + fn try_from(raw: RawPasskeySessionAuthenticator) -> Result { + let client_data_json_parsed: CollectedClientData = + serde_json::from_str(&raw.client_data_json).map_err(|_| { + SuiError::InvalidSignature { + error: "Invalid client data json".to_string(), + } + })?; + + if client_data_json_parsed.ty != ClientDataType::Get { + return Err(SuiError::InvalidSignature { + error: "Invalid client data type".to_string(), + }); + }; + + let parsed_challenge = Base64UrlUnpadded::decode_vec(&client_data_json_parsed.challenge) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid encoded challenge".to_string(), + })?; + + if raw.passkey_signature.scheme() != SignatureScheme::Secp256r1 { + return Err(SuiError::InvalidSignature { + error: "Invalid signature scheme".to_string(), + }); + }; + + let passkey_pk = Secp256r1PublicKey::from_bytes(raw.passkey_signature.public_key_bytes()) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid r1 pk".to_string(), + })?; + + let passkey_signature = Secp256r1Signature::from_bytes( + raw.passkey_signature.signature_bytes(), + ) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid r1 sig".to_string(), + })?; + + Ok(PasskeySessionAuthenticator { + authenticator_data: raw.authenticator_data, + client_data_json: raw.client_data_json, + passkey_signature, + passkey_pk, + ephemeral_signature: raw.ephemeral_signature, + parsed_challenge, + max_epoch: raw.max_epoch, + bytes: OnceCell::new(), + }) + } +} + +impl<'de> Deserialize<'de> for PasskeySessionAuthenticator { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let serializable = RawPasskeySessionAuthenticator::deserialize(deserializer)?; + serializable + .try_into() + .map_err(|e: SuiError| Error::custom(e.to_string())) + } +} + +impl Serialize for PasskeySessionAuthenticator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let mut bytes = Vec::with_capacity(Secp256r1SuiSignature::LENGTH); + bytes.push(SignatureScheme::Secp256r1.flag()); + bytes.extend_from_slice(self.passkey_signature.as_ref()); + bytes.extend_from_slice(self.passkey_pk.as_ref()); + + let raw = RawPasskeySessionAuthenticator { + authenticator_data: self.authenticator_data.clone(), + client_data_json: self.client_data_json.clone(), + passkey_signature: Signature::Secp256r1SuiSignature( + Secp256r1SuiSignature::from_bytes(&bytes).unwrap(), // This is safe because we just created the valid bytes. + ), + max_epoch: self.max_epoch, + ephemeral_signature: self.ephemeral_signature.clone(), + }; + raw.serialize(serializer) + } +} +impl PasskeySessionAuthenticator { + /// A constructor for [struct PasskeySessionAuthenticator] with custom + /// defined fields. Used for testing. + pub fn new_for_testing( + authenticator_data: Vec, + client_data_json: String, + passkey_signature: Signature, + ephemeral_signature: Signature, + max_epoch: EpochId, + ) -> Result { + let raw = RawPasskeySessionAuthenticator { + authenticator_data, + client_data_json, + passkey_signature, + ephemeral_signature, + max_epoch, + }; + raw.try_into() + } + + /// Returns the public key of the passkey authenticator. + pub fn get_pk(&self) -> SuiResult { + Ok(PublicKey::Passkey((&self.passkey_pk).into())) + } +} + +/// Necessary trait for [struct SenderSignedData]. +impl PartialEq for PasskeySessionAuthenticator { + fn eq(&self, other: &Self) -> bool { + self.as_ref() == other.as_ref() + } +} + +/// Necessary trait for [struct SenderSignedData]. +impl Eq for PasskeySessionAuthenticator {} + +/// Necessary trait for [struct SenderSignedData]. +impl Hash for PasskeySessionAuthenticator { + fn hash(&self, state: &mut H) { + self.as_ref().hash(state); + } +} + +impl AuthenticatorTrait for PasskeySessionAuthenticator { + fn verify_user_authenticator_epoch( + &self, + epoch: EpochId, + max_epoch_upper_bound_delta: Option, + ) -> SuiResult { + // the checks here ensure that `current_epoch + max_epoch_upper_bound_delta >= self.max_epoch >= current_epoch`. + // 1. if the config for upper bound is set, ensure that the max epoch in signature is not larger than epoch + upper_bound. + if let Some(delta) = max_epoch_upper_bound_delta { + let max_epoch_upper_bound = epoch + delta; + if self.max_epoch > max_epoch_upper_bound { + return Err(SuiError::InvalidSignature { + error: format!( + "Passkey session max epoch too large {}, current epoch {}, max accepted: {}", + self.max_epoch, + epoch, + max_epoch_upper_bound + ), + }); + } + } + + // 2. ensure that max epoch in signature is greater than the current epoch. + if epoch > self.max_epoch { + return Err(SuiError::InvalidSignature { + error: format!( + "Passkey session expired at epoch {}, current epoch {}", + self.max_epoch, epoch + ), + }); + } + Ok(()) + } + + /// Verify an intent message of a transaction with a passkey session authenticator. + fn verify_claims( + &self, + intent_msg: &IntentMessage, + author: SuiAddress, + _aux_verify_data: &VerifyParams, + _zklogin_inputs_cache: Arc>, + ) -> SuiResult + where + T: Serialize, + { + // Check if the challenge field is consistent with the ephemeral public key registered and its max epoch. + let mut expected_register_msg = vec![self.ephemeral_signature.as_ref()[0]]; + expected_register_msg.extend_from_slice(self.ephemeral_signature.public_key_bytes()); + expected_register_msg.extend_from_slice(&self.max_epoch.to_be_bytes()); + + if self.parsed_challenge != expected_register_msg { + return Err(SuiError::InvalidSignature { + error: "Invalid parsed challenge".to_string(), + }); + }; + + // Check if author is derived from the public key. + if author != SuiAddress::from(&self.get_pk()?) { + return Err(SuiError::InvalidSignature { + error: "Invalid author".to_string(), + }); + }; + + // Check if the ephemeral signature verifies against the transaction blake2b_hash(intent_message). + self.ephemeral_signature + .verify_secure( + intent_msg, + author, + SignatureScheme::PasskeySessionAuthenticator, + ) + .map_err(|_| SuiError::InvalidSignature { + error: "Fails to verify ephemeral sig".to_string(), + })?; + + // Construct msg = authenticator_data || sha256(client_data_json). + let mut message = self.authenticator_data.clone(); + let client_data_hash = Sha256::digest(self.client_data_json.as_bytes()).digest; + message.extend_from_slice(&client_data_hash); + + // Verify the passkey signature against pk and message. + self.passkey_pk + .verify(&message, &self.passkey_signature) + .map_err(|_| SuiError::InvalidSignature { + error: "Fails to verify passkey sig".to_string(), + }) + } +} + +impl ToFromBytes for PasskeySessionAuthenticator { + fn from_bytes(bytes: &[u8]) -> Result { + // The first byte matches the flag of PasskeySessionAuthenticator. + if bytes.first().ok_or(FastCryptoError::InvalidInput)? + != &SignatureScheme::PasskeySessionAuthenticator.flag() + { + return Err(FastCryptoError::InvalidInput); + } + let passkey: PasskeySessionAuthenticator = + bcs::from_bytes(&bytes[1..]).map_err(|_| FastCryptoError::InvalidSignature)?; + + Ok(passkey) + } +} + +impl AsRef<[u8]> for PasskeySessionAuthenticator { + fn as_ref(&self) -> &[u8] { + self.bytes + .get_or_try_init::<_, eyre::Report>(|| { + let as_bytes = bcs::to_bytes(self).expect("BCS serialization should not fail"); + let mut bytes = Vec::with_capacity(1 + as_bytes.len()); + bytes.push(SignatureScheme::PasskeySessionAuthenticator.flag()); + bytes.extend_from_slice(as_bytes.as_slice()); + Ok(bytes) + }) + .expect("OnceCell invariant violated") + } +} +/// Compute the digest that the signature committed over as `intent || hash(tx_data)`, total +/// of 3 + 32 = 35 bytes. +pub fn to_signing_message( + intent_msg: &IntentMessage, +) -> [u8; INTENT_PREFIX_LENGTH + DefaultHash::OUTPUT_SIZE] { + let mut extended = [0; INTENT_PREFIX_LENGTH + DefaultHash::OUTPUT_SIZE]; + extended[..INTENT_PREFIX_LENGTH].copy_from_slice(&intent_msg.intent.to_bytes()); + extended[INTENT_PREFIX_LENGTH..].copy_from_slice(&to_signing_digest(intent_msg)); + extended +} + +/// Compute the BCS hash of the value in intent message. In the case of transaction data, +/// this is the BCS hash of `struct TransactionData`, different from the transaction digest +/// itself that computes the BCS hash of the Rust type prefix and `struct TransactionData`. +/// (See `fn digest` in `impl Message for SenderSignedData`). +pub fn to_signing_digest( + intent_msg: &IntentMessage, +) -> [u8; DefaultHash::OUTPUT_SIZE] { + let mut hasher = DefaultHash::default(); + bcs::serialize_into(&mut hasher, &intent_msg.value) + .expect("Message serialization should not fail"); + hasher.finalize().digest +} diff --git a/crates/sui-types/src/signature.rs b/crates/sui-types/src/signature.rs index 156961eebf384f..988ce9326fa2d8 100644 --- a/crates/sui-types/src/signature.rs +++ b/crates/sui-types/src/signature.rs @@ -9,6 +9,7 @@ use crate::digests::ZKLoginInputsDigest; use crate::error::SuiError; use crate::multisig_legacy::MultiSigLegacy; use crate::passkey_authenticator::PasskeyAuthenticator; +use crate::passkey_session_authenticator::PasskeySessionAuthenticator; use crate::signature_verification::VerifiedDigestCache; use crate::zk_login_authenticator::ZkLoginAuthenticator; use crate::{base_types::SuiAddress, crypto::Signature, error::SuiResult, multisig::MultiSig}; @@ -91,6 +92,7 @@ pub enum GenericSignature { Signature, ZkLoginAuthenticator, PasskeyAuthenticator, + PasskeySessionAuthenticator, } impl GenericSignature { @@ -237,6 +239,12 @@ impl ToFromBytes for GenericSignature { let passkey = PasskeyAuthenticator::from_bytes(bytes)?; Ok(GenericSignature::PasskeyAuthenticator(passkey)) } + SignatureScheme::PasskeySessionAuthenticator => { + let passkey_session = PasskeySessionAuthenticator::from_bytes(bytes)?; + Ok(GenericSignature::PasskeySessionAuthenticator( + passkey_session, + )) + } _ => Err(FastCryptoError::InvalidInput), }, Err(_) => Err(FastCryptoError::InvalidInput), @@ -253,6 +261,7 @@ impl AsRef<[u8]> for GenericSignature { GenericSignature::Signature(s) => s.as_ref(), GenericSignature::ZkLoginAuthenticator(s) => s.as_ref(), GenericSignature::PasskeyAuthenticator(s) => s.as_ref(), + GenericSignature::PasskeySessionAuthenticator(s) => s.as_ref(), } } } diff --git a/crates/sui-types/src/transaction.rs b/crates/sui-types/src/transaction.rs index 47efab07ee6cd1..3bd78994206110 100644 --- a/crates/sui-types/src/transaction.rs +++ b/crates/sui-types/src/transaction.rs @@ -2355,6 +2355,15 @@ impl SenderSignedData { }); } } + GenericSignature::PasskeySessionAuthenticator(_) => { + if !config.passkey_session_auth() { + return Err(SuiError::UserInputError { + error: UserInputError::Unsupported( + "passkey session is not enabled on this network".to_string(), + ), + }); + } + } GenericSignature::Signature(_) | GenericSignature::MultiSigLegacy(_) => (), } } diff --git a/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs b/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs new file mode 100644 index 00000000000000..4263b91d28c31e --- /dev/null +++ b/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs @@ -0,0 +1,496 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use crate::committee::EpochId; +use crate::crypto::get_key_pair; +use crate::passkey_session_authenticator::{ + PasskeySessionAuthenticator, RawPasskeySessionAuthenticator, +}; +use crate::{ + base_types::{dbg_addr, ObjectID, SuiAddress}, + crypto::{PublicKey, Signature, SignatureScheme}, + error::SuiError, + object::Object, + signature::GenericSignature, + signature_verification::VerifiedDigestCache, + transaction::{TransactionData, TEST_ONLY_GAS_UNIT_FOR_TRANSFER}, +}; +use fastcrypto::ed25519::Ed25519KeyPair; + +use fastcrypto::traits::KeyPair; +use fastcrypto::traits::ToFromBytes; +use p256::pkcs8::DecodePublicKey; +use passkey_authenticator::{Authenticator, UserValidationMethod}; +use passkey_client::Client; +use passkey_types::{ + ctap2::Aaguid, + rand::random_vec, + webauthn::{ + AttestationConveyancePreference, CredentialCreationOptions, CredentialRequestOptions, + PublicKeyCredentialCreationOptions, PublicKeyCredentialParameters, + PublicKeyCredentialRequestOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialType, + PublicKeyCredentialUserEntity, UserVerificationRequirement, + }, + Bytes, Passkey, +}; +use rand::rngs::StdRng; +use rand::SeedableRng; +use shared_crypto::intent::PersonalMessage; +use shared_crypto::intent::{Intent, IntentMessage}; +use url::Url; + +/// Helper struct to initialize passkey client. +pub struct MyUserValidationMethod {} +#[async_trait::async_trait] +impl UserValidationMethod for MyUserValidationMethod { + async fn check_user_presence(&self) -> bool { + true + } + + async fn check_user_verification(&self) -> bool { + true + } + + fn is_verification_enabled(&self) -> Option { + Some(true) + } + + fn is_presence_enabled(&self) -> bool { + true + } +} + +/// Response with fields from passkey authentication. +#[derive(Debug)] +pub struct PasskeySessionResponse { + user_sig_bytes: Vec, + authenticator_data: Vec, + client_data_json: String, + sender: SuiAddress, + kp: Ed25519KeyPair, + intent_msg: IntentMessage, +} + +/// Create a new passkey credential, derives its address +/// and request a signature from passkey for a test transaction. +async fn create_credential_and_commit_ephemeral_pk( + origin: &Url, + request: CredentialCreationOptions, + max_epoch: EpochId, +) -> PasskeySessionResponse { + // Set up authenticator and client. + let my_aaguid = Aaguid::new_empty(); + let user_validation_method = MyUserValidationMethod {}; + let store: Option = None; + let my_authenticator = Authenticator::new(my_aaguid, store, user_validation_method); + let mut my_client = Client::new(my_authenticator); + + // Create credential with the request option. + let my_webauthn_credential = my_client.register(origin, request, None).await.unwrap(); + let verifying_key = p256::ecdsa::VerifyingKey::from_public_key_der( + my_webauthn_credential + .response + .public_key + .unwrap() + .as_slice(), + ) + .unwrap(); + + // Derive its compact pubkey from DER format. + let encoded_point = verifying_key.to_encoded_point(false); + let x = encoded_point.x(); + let y = encoded_point.y(); + let prefix = if y.unwrap()[31] % 2 == 0 { 0x02 } else { 0x03 }; + let mut pk_bytes = vec![prefix]; + pk_bytes.extend_from_slice(x.unwrap()); + let pk = + PublicKey::try_from_bytes(SignatureScheme::PasskeySessionAuthenticator, &pk_bytes).unwrap(); + + // Derives its sui address and make a test transaction with it as sender. + let sender = SuiAddress::from(&pk); + let recipient = dbg_addr(2); + let object_id = ObjectID::ZERO; + let object = Object::immutable_with_id_for_testing(object_id); + let gas_price = 1000; + let tx_data = TransactionData::new_transfer_sui( + recipient, + sender, + None, + object.compute_object_reference(), + gas_price * TEST_ONLY_GAS_UNIT_FOR_TRANSFER, + gas_price, + ); + let intent_msg = IntentMessage::new(Intent::sui_transaction(), tx_data); + + // Compute the challenge as eph_flag || eph_pk || max_epoch. This is the challenge for the passkey to sign. + let kp: Ed25519KeyPair = get_key_pair().1; + let mut register_msg = vec![SignatureScheme::ED25519.flag()]; + register_msg.extend_from_slice(kp.public().as_bytes()); + register_msg.extend_from_slice(&max_epoch.to_be_bytes()); + + // Send the challenge to the passkey to sign with the rp_id. + let credential_request = CredentialRequestOptions { + public_key: PublicKeyCredentialRequestOptions { + challenge: Bytes::from(register_msg), + timeout: None, + rp_id: Some(String::from(origin.domain().unwrap())), + allow_credentials: None, + user_verification: UserVerificationRequirement::default(), + attestation: Default::default(), + attestation_formats: None, + extensions: None, + hints: None, + }, + }; + + let authenticated_cred = my_client + .authenticate(origin, credential_request, None) + .await + .unwrap(); + + // Parse the response, gets the signature from der format and normalize it to lower s. + let sig_bytes_der = authenticated_cred.response.signature.as_slice(); + let sig = p256::ecdsa::Signature::from_der(sig_bytes_der).unwrap(); + let sig_bytes = sig.normalize_s().unwrap_or(sig).to_bytes(); + + // Parse authenticator_data and client_data_json from response. + let authenticator_data = authenticated_cred.response.authenticator_data.as_slice(); + let client_data_json = authenticated_cred.response.client_data_json.as_slice(); + + // Prepare flag || sig || pk. + let mut user_sig_bytes = vec![SignatureScheme::Secp256r1.flag()]; + user_sig_bytes.extend_from_slice(&sig_bytes); + user_sig_bytes.extend_from_slice(&pk_bytes); + + PasskeySessionResponse:: { + user_sig_bytes, + authenticator_data: authenticator_data.to_vec(), + client_data_json: String::from_utf8_lossy(client_data_json).to_string(), + sender, + kp, + intent_msg, + } +} + +fn make_credential_creation_option(origin: &Url) -> CredentialCreationOptions { + let challenge_bytes_from_rp: Bytes = random_vec(32).into(); + let user_entity = PublicKeyCredentialUserEntity { + id: random_vec(32).into(), + display_name: "Johnny Passkey".into(), + name: "jpasskey@example.org".into(), + }; + CredentialCreationOptions { + public_key: PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity { + id: None, // Leaving the ID as None means use the effective domain + name: origin.domain().unwrap().into(), + }, + user: user_entity, + challenge: challenge_bytes_from_rp, + pub_key_cred_params: vec![PublicKeyCredentialParameters { + ty: PublicKeyCredentialType::PublicKey, + alg: coset::iana::Algorithm::ES256, + }], + timeout: None, + exclude_credentials: None, + authenticator_selection: None, + hints: None, + attestation: AttestationConveyancePreference::None, + attestation_formats: None, + extensions: None, + }, + } +} + +#[tokio::test] +async fn test_passkey_serde() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let max_epoch = 2; + let response = create_credential_and_commit_ephemeral_pk(&origin, request, max_epoch).await; + + let raw = RawPasskeySessionAuthenticator { + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + authenticator_data: response.authenticator_data, + client_data_json: response.client_data_json, + }; + let passkey: PasskeySessionAuthenticator = raw.try_into().unwrap(); + let serialized = bcs::to_bytes(&passkey).unwrap(); + + // deser back to passkey authenticator is the same + let deserialized: PasskeySessionAuthenticator = bcs::from_bytes(&serialized).unwrap(); + assert_eq!(passkey, deserialized); + + // serde round trip for generic signature is the same + let signature = GenericSignature::PasskeySessionAuthenticator(passkey); + + let serialized_str = serde_json::to_string(&signature).unwrap(); + let deserialized: GenericSignature = serde_json::from_str(&serialized_str).unwrap(); + assert_eq!(deserialized.as_ref(), signature.as_ref()); +} + +#[tokio::test] +async fn test_passkey_authenticator_verify_max_epoch() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let max_epoch = 2; + let response = create_credential_and_commit_ephemeral_pk(&origin, request, max_epoch).await; + + let sig = GenericSignature::PasskeySessionAuthenticator( + PasskeySessionAuthenticator::new_for_testing( + response.authenticator_data, + response.client_data_json, + Signature::from_bytes(&response.user_sig_bytes).unwrap(), + Signature::new_secure(&response.intent_msg, &response.kp), + max_epoch, + ) + .unwrap(), + ); + + let res = sig.verify_authenticator( + &response.intent_msg, + response.sender, + max_epoch, + &Default::default(), + Arc::new(VerifiedDigestCache::new_empty()), + ); + assert!(res.is_ok()); + + // current epoch is max_epoch + 1, fails to verify bc expired + let res = sig.verify_authenticator( + &response.intent_msg, + response.sender, + max_epoch + 1, + &Default::default(), + Arc::new(VerifiedDigestCache::new_empty()), + ); + let err = res.unwrap_err(); + assert!(err.to_string().contains("Passkey session expired at epoch")); + + // todo: fails bc max_epoch too large +} + +#[tokio::test] +async fn test_passkey_authenticator_invalid_parsed_challenge() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let max_epoch = 2; + let response = create_credential_and_commit_ephemeral_pk(&origin, request, max_epoch).await; + + let sig = GenericSignature::PasskeySessionAuthenticator( + PasskeySessionAuthenticator::new_for_testing( + response.authenticator_data, + response.client_data_json, + Signature::from_bytes(&response.user_sig_bytes).unwrap(), + Signature::new_secure(&response.intent_msg, &response.kp), + max_epoch + 1, // this is inconsistent with the max_epoch committed in challenge + ) + .unwrap(), + ); + + let res = sig.verify_authenticator( + &response.intent_msg, + response.sender, + max_epoch, + &Default::default(), + Arc::new(VerifiedDigestCache::new_empty()), + ); + let err = res.unwrap_err(); + assert!(err.to_string().contains("Invalid parsed challenge")); +} + +#[tokio::test] +async fn test_passkey_fails_invalid_json() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let client_data_json_missing_type = r#"{"challenge":"9-fH7nX8Nb1JvUynz77mv1kXOkGkg1msZb2qhvZssGI","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data.clone(), + client_data_json: client_data_json_missing_type.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid client data json".to_string() + } + ); +} + +#[tokio::test] +async fn test_passkey_fails_invalid_challenge() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let fake_client_data_json = r#"{"type":"webauthn.get","challenge":"wrong_base64_encoding","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data, + client_data_json: fake_client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid encoded challenge".to_string() + } + ); +} + +#[tokio::test] +async fn test_passkey_fails_wrong_client_data_type() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let fake_client_data_json = r#"{"type":"webauthn.create","challenge":"9-fH7nX8Nb1JvUynz77mv1kXOkGkg1msZb2qhvZssGI","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data, + client_data_json: fake_client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid client data type".to_string() + } + ); +} + +// todo: needs to be fixed with typescript +// #[tokio::test] +// async fn test_passkey_fails_not_normalized_signature() { +// // crafts a particular not normalized signature, fails to verify. this is produced from typescript client https://github.com/joyqvq/sui-webauthn-poc/tree/joy/tx-example +// let tx_data: TransactionData = bcs::from_bytes(&Base64::decode("AAAAAHaTZLc0GGZ6RNYAqPC8LWZV7xHO+54zf71arV1MwFUtAcDum6pkbPZZN/iYq0zJpOxiV2wrZAnVU0bnNpOjombGAgAAAAAAAAAgAIiQFrz1abd2rNdo76dQS026yMAS1noA7FiGsggyt9V2k2S3NBhmekTWAKjwvC1mVe8RzvueM3+9Wq1dTMBVLegDAAAAAAAAgIQeAAAAAAAA").unwrap()).unwrap(); +// let kp = Ed25519KeyPair::generate(&mut StdRng::from_seed([0; 32])); +// let response = PasskeySessionResponse:: { +// user_sig_bytes: Hex::decode("02bbd02ace0bad3b32eb3a891dc5c85e56274f52695d24db41b247ec694d1531d6fe1a5bec11a8063d1eb0512e7971bfd23395c2cb8862f73049d0f78fd204c6d602276d5f3a22f3e698cdd2272a63da8bfdd9344de73312c7f7f9eca21bfc304f2e").unwrap(), +// authenticator_data: Hex::decode("49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000").unwrap(), +// client_data_json: r#"{"type":"webauthn.get","challenge":"AAAAZgUD1inhS1l9qUfZePaivu6IbIo_SxCGmYcfTwrmcFU","origin":"http://localhost:5173","crossOrigin":false}"#.to_string(), +// intent_msg: IntentMessage::new(Intent::sui_transaction(), tx_data), +// kp, +// sender: SuiAddress::from_str("0x769364b73418667a44d600a8f0bc2d6655ef11cefb9e337fbd5aad5d4cc0552d").unwrap() +// }; +// let sig = GenericSignature::PasskeySessionAuthenticator( +// PasskeySessionAuthenticator::new_for_testing( +// response.authenticator_data, +// response.client_data_json, +// Signature::from_bytes(&response.user_sig_bytes).unwrap(), +// Signature::new_secure(&response.intent_msg, &response.kp), +// 10, +// ) +// .unwrap(), +// ); + +// let res = sig.verify_authenticator( +// &response.intent_msg, +// response.sender, +// 0, +// &Default::default(), +// Arc::new(VerifiedDigestCache::new_empty()), +// ); +// let err = res.unwrap_err(); +// assert_eq!( +// err, +// SuiError::InvalidSignature { +// error: "Fails to verify".to_string() +// } +// ); +// } + +// todo: need to fix with real passkey output +// #[tokio::test] +// async fn test_real_passkey_output() { +// // response from a real passkey authenticator created in iCloud, from typescript client: https://github.com/joyqvq/sui-webauthn-poc/tree/joy/tx-example +// let address = +// SuiAddress::from_str("0xac8564f638fbf673fc92eb85b5abe5f7c29bdaa60a4a10329868fbe6c551dda2") +// .unwrap(); +// let sig = GenericSignature::from_bytes(&Base64::decode("BiVJlg3liA6MaHQ0Fw9kdmBbj+SuuaKGMseZXPO6gx2XYx0AAAAAigF7InR5cGUiOiJ3ZWJhdXRobi5nZXQiLCJjaGFsbGVuZ2UiOiJBQUFBdF9taklCMXZiVnBZTTZXVjZZX29peDZKOGFOXzlzYjhTS0ZidWtCZmlRdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyIsImNyb3NzT3JpZ2luIjpmYWxzZX1iApjskL9Xyfopyg9Av7MSrcchSpfWqAYoJ+qfSId4gNmoQ1YNgj2alDpRIbq9kthmyGY25+k24FrW114PEoy5C+8DPRcOCTtACi3ZywtZ4UILhwV+Suh79rWtbKqDqhBQwxM=").unwrap()).unwrap(); +// let tx_data: TransactionData = bcs::from_bytes(&Base64::decode("AAAAAKyFZPY4+/Zz/JLrhbWr5ffCm9qmCkoQMpho++bFUd2iAUwOMmeNHuxq2hS4PvO1uivs9exQGefW2wNQAt7tRkkdAgAAAAAAAAAgCsJHAaWbb8oUlZsGdsyW3Atf3d51wBEr9HLkrBF0/UushWT2OPv2c/yS64W1q+X3wpvapgpKEDKYaPvmxVHdougDAAAAAAAAgIQeAAAAAAAA").unwrap()).unwrap(); +// let res = sig.verify_authenticator( +// &IntentMessage::new(Intent::sui_transaction(), tx_data), +// address, +// 0, +// &Default::default(), +// Arc::new(VerifiedDigestCache::new_empty()), +// ); +// assert!(res.is_ok()); +// } + +#[tokio::test] +async fn test_passkey_fails_invalid_ephemeral_signature() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let max_epoch = 2; + let kp = Ed25519KeyPair::generate(&mut StdRng::from_seed([1; 32])); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, max_epoch).await; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data.clone(), + client_data_json: response.client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &kp), // ephemeral signature is signed by a different key than what's committed to passkey. + }; + let sig = GenericSignature::PasskeySessionAuthenticator(raw.try_into().unwrap()); + let res = sig.verify_authenticator( + &response.intent_msg, + response.sender, + 0, + &Default::default(), + Arc::new(VerifiedDigestCache::new_empty()), + ); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid parsed challenge".to_string() + } + ); + let wrong_intent_msg = IntentMessage::new( + Intent::sui_transaction(), + PersonalMessage { + message: "Hello".as_bytes().to_vec(), + }, + ); + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data, + client_data_json: response.client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch, + ephemeral_signature: Signature::new_secure(&wrong_intent_msg, &response.kp), // ephemeral signature committed to a wrong intent msg. + }; + let sig = GenericSignature::PasskeySessionAuthenticator(raw.try_into().unwrap()); + let res = sig.verify_authenticator( + &response.intent_msg, + response.sender, + 0, + &Default::default(), + Arc::new(VerifiedDigestCache::new_empty()), + ); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Fails to verify ephemeral sig".to_string() + } + ); +} + +// todo: invalid author check +// todo: test invalid passkey signature +// todo: sign a different txn with same ephemeral key also verifies