Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 125 additions & 99 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ near-account-id = "2.6.0"
near-contract-standards = "5.28.3"
near-gas = "0.3.6"
near-global-contracts = "0.2.2"
near-kit = { version = "0.11.2", default-features = false }
near-kit = { version = "0.12.1", default-features = false }
near-plugins = "0.5.3"
near-sdk = "5.28.3"
near-sdk = "5.29.0"
near-sdk-core = "4.1.3"
near-sdk-env = "0.1.4"
near-token = "0.3.4"
Expand Down
1 change: 1 addition & 0 deletions contracts/defuse/core/src/public_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ const _: () = {
match pk {
NearPublicKey::Ed25519(pk) => Self::Ed25519(pk.into()),
NearPublicKey::Secp256k1(pk) => Self::Secp256k1(pk.into()),
NearPublicKey::MlDsa65(_) | NearPublicKey::MlDsa65Hash(_) => todo!(),
Comment thread
frol marked this conversation as resolved.
Outdated
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions contracts/wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ digest-io = { workspace = true, optional = true }
impl-tools = { workspace = true, optional = true }
schemars = { workspace = true, features = ["derive"], optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
serde_with = { workspace = true, optional = true }

near-sdk = { workspace = true, optional = true }
Expand Down Expand Up @@ -82,7 +83,9 @@ near-contract = [
"defuse-near-promise/near-contract",
"dep:impl-tools",
"dep:near-sdk",
"dep:serde_json",
"digest",
"near-sdk/deterministic-account-ids",
"serde",
"std",
]
Expand All @@ -91,6 +94,7 @@ near-contract = [
defuse-wallet = { path = ".", features = ["abi", "arbitrary", "borsh", "digest", "schemars-v0_8", "serde", "near-contract"] }

bs58.workspace = true
hex = { workspace = true, features = ["serde"] }
hex-literal.workspace = true
rstest.workspace = true
serde_json.workspace = true
Expand Down
9 changes: 5 additions & 4 deletions contracts/wallet/signatures/ed25519/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use defuse_crypto::{
Curve,
ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature},
};
use defuse_wallet::{RequestMessage, SignatureSchema};
use defuse_wallet::SignatureSchema;

/// Simple [`Ed25519`] wallet [signature schema](SignatureSchema)
/// over [canonical request hash](RequestMessage::hash).
Expand All @@ -21,7 +21,7 @@ pub struct WalletEd25519;
impl SignatureSchema for WalletEd25519 {
type PublicKey = Ed25519PublicKey;

fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool {
fn verify_hash(public_key: &Self::PublicKey, hash: &[u8; 32], proof: &str) -> bool {
let Ok(signature) = Ed25519Signature::from_str(proof) else {
return false;
};
Expand All @@ -30,7 +30,7 @@ impl SignatureSchema for WalletEd25519 {
return false;
};

Ed25519::verify(&public_key, &msg.hash(), &signature.into())
Ed25519::verify(&public_key, hash, &signature.into())
}
}

Expand All @@ -39,7 +39,8 @@ mod tests {
use std::time::Duration;

use defuse_wallet::{
AccountId, Gas, NearPromise, NearToken, Request, WalletOp, actions::FunctionCall,
AccountId, Gas, NearPromise, NearToken, Request, RequestMessage, WalletOp,
actions::FunctionCall,
};
use hex_literal::hex;
use rstest::rstest;
Expand Down
8 changes: 7 additions & 1 deletion contracts/wallet/signatures/ed25519/src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use defuse_crypto::{
Signer,
ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature},
};
use defuse_wallet_sdk::{Proof, RequestMessage, WalletSigner};
use defuse_wallet_sdk::{AuthMessage, Proof, RequestMessage, WalletSigner};

use crate::WalletEd25519;

Expand Down Expand Up @@ -55,4 +55,10 @@ where

Ok(Ed25519Signature::from(sig).to_string())
}

async fn sign_auth_msg(&self, msg: &AuthMessage) -> Result<Proof, Self::Error> {
let sig = self.0.sign(&msg.hash()).await?;

Ok(Ed25519Signature::from(sig).to_string())
}
}
4 changes: 2 additions & 2 deletions contracts/wallet/signatures/no-sign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use core::{
str::FromStr,
};

use defuse_wallet::{RequestMessage, SignatureSchema};
use defuse_wallet::SignatureSchema;

/// [`SignatureSchema`] which always rejects the signature.
///
Expand All @@ -19,7 +19,7 @@ impl SignatureSchema for NoSign {
type PublicKey = NoPublicKey;

#[inline]
fn verify(_public_key: &Self::PublicKey, _msg: &RequestMessage, _proof: &str) -> bool {
fn verify_hash(_public_key: &Self::PublicKey, _hash: &[u8; 32], _proof: &str) -> bool {
false
}
}
Expand Down
19 changes: 12 additions & 7 deletions contracts/wallet/signatures/webauthn/ed25519/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
use defuse_wallet::wallet;
use defuse_wallet_webauthn::{WalletWebauthn, ed25519::Ed25519, webauthn::IgnoreUserVerification};
use defuse_wallet_webauthn::{WalletWebauthn, ed25519::Ed25519, webauthn::RequireUserVerification};

wallet! {
#[wallet(
schema = WalletWebauthn<
Ed25519,
// `UV` (User Verified) flag is only set by FIDO2-capable devices with
// PIN / biometric setup.
// Require the `UV` (User Verified) flag: every signature MUST be
// authorized by a biometric / PIN / screen-lock verification, not
// mere user presence. This wallet's passkey is the sole key over
// funds, so on-chain enforcement is required — the client also
// requests `userVerification: "required"`, but a proof submitted
// directly to the relayer would bypass that; the contract must not
// accept a user-presence-only assertion.
//
// FIDO U2F (CTAP 1) authenticators (such as old Ledger and Yubikey
// devices) only set `UP` (User Present) flag and doesn't support `UV`
// (User Verified).
IgnoreUserVerification,
// Trade-off: FIDO U2F (CTAP 1) authenticators (e.g. old Ledger /
// YubiKey without a PIN) only set `UP` and cannot satisfy this.
// Platform passkeys (Apple/Google/Windows) always perform UV.
RequireUserVerification,
>,
metadata(
standard(standard = "wallet-webauthn-ed25519", version = "1.0.0")
Expand Down
19 changes: 12 additions & 7 deletions contracts/wallet/signatures/webauthn/p256/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
use defuse_wallet::wallet;
use defuse_wallet_webauthn::{WalletWebauthn, p256::P256, webauthn::IgnoreUserVerification};
use defuse_wallet_webauthn::{WalletWebauthn, p256::P256, webauthn::RequireUserVerification};

wallet! {
#[wallet(
schema = WalletWebauthn<
P256,
// `UV` (User Verified) flag is only set by FIDO2-capable devices with
// PIN / biometric setup.
// Require the `UV` (User Verified) flag: every signature MUST be
// authorized by a biometric / PIN / screen-lock verification, not
// mere user presence. This wallet's passkey is the sole key over
// funds, so on-chain enforcement is required — the client also
// requests `userVerification: "required"`, but a proof submitted
// directly to the relayer would bypass that; the contract must not
// accept a user-presence-only assertion.
//
// FIDO U2F (CTAP 1) authenticators (such as old Ledger and Yubikey
// devices) only set `UP` (User Present) flag and doesn't support `UV`
// (User Verified).
IgnoreUserVerification,
// Trade-off: FIDO U2F (CTAP 1) authenticators (e.g. old Ledger /
// YubiKey without a PIN) only set `UP` and cannot satisfy this.
// Platform passkeys (Apple/Google/Windows) always perform UV.
RequireUserVerification,
>,
metadata(
standard(standard = "wallet-webauthn-p256", version = "1.0.0")
Expand Down
33 changes: 33 additions & 0 deletions contracts/wallet/signatures/webauthn/src/ed25519.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,37 @@ mod tests {
"signer produced invalid signature"
);
}

/// End-to-end NEP-641 flow: the challenge is
/// [`AuthMessage::hash()`](defuse_wallet::AuthMessage::hash), exactly as
/// used by `w_resolve_auth()`.
#[tokio::test]
async fn sign_verify_auth_ok() {
type SS = WalletWebauthn<Ed25519, IgnoreUserVerification>;

let signer = MockWalletWebauthnSigner::new(ed25519_dalek::SigningKey::generate(&mut rng()));

let msg = crate::tests::sample_auth_message();

let proof = WalletSigner::<SS>::sign_auth_msg(&signer, &msg)
.await
.unwrap();

assert!(
SS::verify_hash(
&WalletSigner::<SS>::public_key(&signer),
&msg.hash(),
&proof
),
"signer produced invalid signature"
);
assert!(
!SS::verify_hash(
&WalletSigner::<SS>::public_key(&signer),
&[0xab; 32],
&proof
),
"assertion over another challenge should not verify"
);
}
}
43 changes: 38 additions & 5 deletions contracts/wallet/signatures/webauthn/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub use defuse_webauthn as webauthn;
use core::marker::PhantomData;

use defuse_crypto::Curve;
use defuse_wallet::{RequestMessage, SignatureSchema};
use defuse_wallet::SignatureSchema;
use defuse_webauthn::{Algorithm, UserVerification, Webauthn, WebauthnAssertion};
use serde::{Deserialize, Serialize, de::DeserializeOwned};

Expand All @@ -29,7 +29,7 @@ where
{
type PublicKey = A::PublicKey;

fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool {
fn verify_hash(public_key: &Self::PublicKey, hash: &[u8; 32], proof: &str) -> bool {
// try to convert public key
let Ok(public_key) = <A::Curve as Curve>::PublicKey::try_from(public_key) else {
return false;
Expand All @@ -45,13 +45,13 @@ where
return false;
};

// Verify `msg.hash()` according to webauthn spec.
// Verify the given digest according to webauthn spec.
//
// We `msg.hash()` as the challenge, since:
// We use the canonical message hash as the challenge, since:
// * Authenticators are general-purpose signers and they usually
// implement blind singing.
// * This reduces length of the `proof` submitted on-chain.
Webauthn::<A, UV>::verify(&public_key, msg.hash(), &proof.assertion, &signature)
Webauthn::<A, UV>::verify(&public_key, *hash, &proof.assertion, &signature)
}
}

Expand All @@ -65,6 +65,39 @@ pub trait WalletWebauthnAlgorithm: Algorithm {
type Signature: DeserializeOwned;
}

#[cfg(test)]
pub(crate) mod tests {
use core::time::Duration;
use std::collections::BTreeSet;

use defuse_wallet::{AuthMessage, AuthSignerBinding, Timestamp};

/// Sample NEP-641 [`AuthMessage`] with the passkey-style
/// [`Code`](AuthSignerBinding::Code) binding.
pub fn sample_auth_message() -> AuthMessage {
AuthMessage {
chain_id: "mainnet".to_string(),
signer: AuthSignerBinding::Code {
allowed_factory_ids: BTreeSet::from([
"p256-passkey-wallet-contract.trezu.near".parse().unwrap(),
"ed25519-passkey-wallet-contract.trezu.near"
.parse()
.unwrap(),
]),
signature_enabled: true,
subwallet_id: 0,
timeout: Duration::from_hours(1),
extensions: BTreeSet::new(),
},
purpose: "PROVE_OWNERSHIP".to_string(),
recipient: "trezu.app".to_string(),
payload: "Login to trezu.app at 2026-07-16T00:00:00Z".to_string(),
created_at: Timestamp::UNIX_EPOCH,
timeout: Duration::from_hours(1),
}
}
}

/// JSON proof used by [`WalletWebauthn`]
#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))]
#[cfg_attr(
Expand Down
30 changes: 23 additions & 7 deletions contracts/wallet/signatures/webauthn/src/mock.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use defuse_crypto::{Curve, Signer};
use defuse_wallet::RequestMessage;
use defuse_wallet::{AuthMessage, RequestMessage};
use defuse_wallet_sdk::{Proof, WalletSigner};
use defuse_webauthn::{UserVerification, mock::MockWebauthnSigner};
use impl_tools::autoimpl;
Expand Down Expand Up @@ -32,6 +32,24 @@ where
}
}

impl<A, UV, S> MockWalletWebauthnSigner<A, UV, S>
where
A: WalletWebauthnAlgorithm,
UV: UserVerification,
A::Signature: Serialize + From<<A::Curve as Curve>::Signature>,
S: Signer<A::Curve>,
{
async fn sign_hash(&self, hash: [u8; 32]) -> Result<Proof, S::Error> {
let (assertion, signature) = self.0.sign(hash).await?;

Ok(serde_json::to_string(&WalletWebauthnProof::<A::Signature> {
signature: signature.into(),
assertion,
})
.expect("JSON: failed to serialize"))
}
}

impl<A, UV, S> WalletSigner<WalletWebauthn<A, UV>> for MockWalletWebauthnSigner<A, UV, S>
where
A: WalletWebauthnAlgorithm,
Expand All @@ -50,12 +68,10 @@ where
}

async fn sign_request_msg(&self, msg: &RequestMessage) -> Result<Proof, Self::Error> {
let (assertion, signature) = self.0.sign(msg.hash()).await?;
self.sign_hash(msg.hash()).await
}

Ok(serde_json::to_string(&WalletWebauthnProof::<A::Signature> {
signature: signature.into(),
assertion,
})
.expect("JSON: failed to serialize"))
async fn sign_auth_msg(&self, msg: &AuthMessage) -> Result<Proof, Self::Error> {
self.sign_hash(msg.hash()).await
}
}
33 changes: 33 additions & 0 deletions contracts/wallet/signatures/webauthn/src/p256.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,37 @@ mod tests {
"signer produced invalid signature"
);
}

/// End-to-end NEP-641 flow: the challenge is
/// [`AuthMessage::hash()`](defuse_wallet::AuthMessage::hash), exactly as
/// used by `w_resolve_auth()`.
#[tokio::test]
async fn sign_verify_auth_ok() {
type SS = WalletWebauthn<P256, IgnoreUserVerification>;

let signer = MockWalletWebauthnSigner::new(SigningKey::generate_from_rng(&mut rng()));

let msg = crate::tests::sample_auth_message();

let proof = WalletSigner::<SS>::sign_auth_msg(&signer, &msg)
.await
.unwrap();

assert!(
SS::verify_hash(
&WalletSigner::<SS>::public_key(&signer),
&msg.hash(),
&proof
),
"signer produced invalid signature"
);
assert!(
!SS::verify_hash(
&WalletSigner::<SS>::public_key(&signer),
&[0xab; 32],
&proof
),
"assertion over another challenge should not verify"
);
}
}
Loading
Loading