diff --git a/Cargo.lock b/Cargo.lock index 2fc4a9e6c..d7ae86a22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,6 +2025,7 @@ dependencies = [ "defuse-time", "defuse-wallet", "digest-io", + "hex", "hex-literal", "impl-tools 0.12.0", "near-account-id", diff --git a/contracts/wallet/Cargo.toml b/contracts/wallet/Cargo.toml index 75e845648..366eee5fc 100644 --- a/contracts/wallet/Cargo.toml +++ b/contracts/wallet/Cargo.toml @@ -84,6 +84,7 @@ near-contract = [ "dep:impl-tools", "dep:near-sdk", "digest", + "near-sdk/deterministic-account-ids", "serde", "std", ] @@ -92,6 +93,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 diff --git a/contracts/wallet/signatures/ed25519/src/lib.rs b/contracts/wallet/signatures/ed25519/src/lib.rs index cb92db615..c79544add 100644 --- a/contracts/wallet/signatures/ed25519/src/lib.rs +++ b/contracts/wallet/signatures/ed25519/src/lib.rs @@ -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). @@ -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; }; @@ -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()) } } @@ -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; diff --git a/contracts/wallet/signatures/no-sign/src/lib.rs b/contracts/wallet/signatures/no-sign/src/lib.rs index 16808996e..a1b76442c 100644 --- a/contracts/wallet/signatures/no-sign/src/lib.rs +++ b/contracts/wallet/signatures/no-sign/src/lib.rs @@ -6,7 +6,7 @@ use core::{ str::FromStr, }; -use defuse_wallet::{RequestMessage, SignatureSchema}; +use defuse_wallet::SignatureSchema; /// [`SignatureSchema`] which always rejects the signature. /// @@ -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 } } diff --git a/contracts/wallet/signatures/webauthn/ed25519/src/lib.rs b/contracts/wallet/signatures/webauthn/ed25519/src/lib.rs index 0053bbc6a..933de6cb2 100644 --- a/contracts/wallet/signatures/webauthn/ed25519/src/lib.rs +++ b/contracts/wallet/signatures/webauthn/ed25519/src/lib.rs @@ -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") diff --git a/contracts/wallet/signatures/webauthn/p256/src/lib.rs b/contracts/wallet/signatures/webauthn/p256/src/lib.rs index aa7d21f4e..f61473d4d 100644 --- a/contracts/wallet/signatures/webauthn/p256/src/lib.rs +++ b/contracts/wallet/signatures/webauthn/p256/src/lib.rs @@ -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") diff --git a/contracts/wallet/signatures/webauthn/src/lib.rs b/contracts/wallet/signatures/webauthn/src/lib.rs index 804f7abfb..c02763052 100644 --- a/contracts/wallet/signatures/webauthn/src/lib.rs +++ b/contracts/wallet/signatures/webauthn/src/lib.rs @@ -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}; @@ -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) = ::PublicKey::try_from(public_key) else { return false; @@ -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::::verify(&public_key, msg.hash(), &proof.assertion, &signature) + Webauthn::::verify(&public_key, *hash, &proof.assertion, &signature) } } diff --git a/contracts/wallet/src/auth.rs b/contracts/wallet/src/auth.rs new file mode 100644 index 000000000..cc5ba14a8 --- /dev/null +++ b/contracts/wallet/src/auth.rs @@ -0,0 +1,593 @@ +//! [NEP-641](https://github.com/near/NEPs/blob/master/neps/nep-0641.md) +//! off-chain authorization resolution. +//! +//! See [`w_resolve_auth()`](crate::contract::Wallet::w_resolve_auth). + +use core::time::Duration; +use std::collections::BTreeSet; + +use defuse_time::Timestamp; +use near_account_id::AccountId; + +#[cfg(feature = "borsh")] +use ::{ + defuse_borsh_utils::{As, DurationSeconds as BorshDurationSeconds}, + defuse_time::borsh::TimestampNanoSeconds, +}; +#[cfg(feature = "arbitrary")] +use defuse_time::arbitrary::RangeNanos; +#[cfg(feature = "serde")] +use serde_with::DurationSeconds; + +/// Domain prefix for signing [`AuthMessage`]. +/// +/// This prefix doesn't break NEP-461 assumptions, since first four bytes +/// borsh-deserialize to `1380009294u32`, which is in `[1 << 30, 1 << 31)` +/// range for on-chain messages and is not an allocated NEP discriminant. +/// +/// Although it shares the `NEAR_WALLET_CONTRACT` prefix with +/// [`WALLET_DOMAIN`](crate::WALLET_DOMAIN), the domains are unambiguous: +/// the byte following the shared prefix differs (`/` vs `_`), and a borsh +/// payload following either domain cannot legally re-encode the other's +/// suffix (the leading `chain_id` length prefix would have to be over 1GB). +pub const WALLET_AUTH_DOMAIN: &[u8] = b"NEAR_WALLET_CONTRACT_AUTH/V1"; + +/// Signable authorization message resolved via +/// [`w_resolve_auth()`](crate::contract::Wallet::w_resolve_auth) contract +/// method. +/// +/// # Replay protection +/// +/// Unlike [`RequestMessage`](crate::RequestMessage), this message doesn't +/// contain a nonce, since `w_resolve_auth()` is a stateless view method and +/// cannot commit nonces. Replay protection is layered instead: +/// * within a dApp: the dApp MUST issue a fresh, unique [`payload`](Self::payload) +/// per authorization and only accept payloads it has issued (see NEP-641), +/// * across dApps: [`recipient`](Self::recipient) binding, +/// * across purposes: [`purpose`](Self::purpose) binding, +/// * across networks: [`chain_id`](Self::chain_id) binding, +/// * across accounts: [`signer`](Self::signer) binding, +/// * over time: [`created_at`](Self::created_at)/[`timeout`](Self::timeout) +/// validity window. +#[cfg_attr( + feature = "serde", + ::cfg_eval::cfg_eval, + ::serde_with::serde_as, + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "borsh", + derive(::borsh::BorshSerialize, ::borsh::BorshDeserialize), + cfg_attr(feature = "borsh-schema", derive(::borsh::BorshSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AuthMessage { + /// Chain id (e.g. `mainnet`). + /// MUST be equal to `chain_id` of the network. + pub chain_id: String, + + /// Binding to the wallet-contract instance this authorization is for. + pub signer: AuthSignerBinding, + + /// MUST be equal to the `purpose` argument of `w_resolve_auth()`. + pub purpose: String, + + /// MUST be equal to the `recipient` argument of `w_resolve_auth()`. + pub recipient: String, + + /// dApp-issued payload, returned verbatim on successful resolution. + pub payload: String, + + #[cfg_attr( + feature = "arbitrary", + arbitrary(with = ::arbitrary_with::As::>::arbitrary), + )] + #[cfg_attr( + feature = "borsh-schema", + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + schema(with_funcs( + definitions = "As::>::add_definitions_recursively", + declaration = "As::>::declaration", + )) + ) + )] + #[cfg_attr( + all(feature = "borsh", not(feature = "borsh-schema")), + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + ) + )] + /// Timestamp when this authorization was created (in RFC-3339 format). + /// + /// Clients are recommended to set `created_at` slightly (e.g. 60 seconds) + /// before the actual time of signing, so that verification doesn't fail + /// on-chain due to lagging block timestamps. + pub created_at: Timestamp, + + #[cfg_attr( + feature = "borsh-schema", + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + schema(with_funcs( + definitions = "As::>::add_definitions_recursively", + declaration = "As::>::declaration", + )) + ) + )] + #[cfg_attr( + all(feature = "borsh", not(feature = "borsh-schema")), + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + ) + )] + #[cfg_attr( + feature = "serde", + serde_as(as = "DurationSeconds"), + serde(rename = "timeout_secs") + )] + /// Maximum timeout for validity of this authorization after `created_at`. + /// The actual timeout is `min(msg.timeout, contract.timeout)`. + pub timeout: Duration, +} + +impl AuthMessage { + /// Returns canonical hash of the authorization message: + /// + /// ```text + /// SHA3-256(b"NEAR_WALLET_CONTRACT_AUTH/V1" || borsh(msg)) + /// ``` + /// + /// This hash is what gets signed by the wallet's key. For `WebAuthn` + /// schemas, it is used as the challenge of the assertion. + #[cfg(all(feature = "digest", feature = "borsh"))] + pub fn hash(&self) -> [u8; 32] { + use defuse_digest::{Digest, sha3::Sha3_256}; + use digest_io::IoWrapper; + + let mut hasher = IoWrapper(Sha3_256::new_with_prefix(WALLET_AUTH_DOMAIN)); + // serialize directly to hasher + ::borsh::to_writer(&mut hasher, self).expect("borsh: failed to serialize"); + + hasher.0.finalize().into() + } +} + +/// Binding of an [`AuthMessage`] to the wallet-contract instance it +/// authorizes for. +/// +/// Prevents an authorization signed for one wallet-contract account from +/// being replayed against another account controlled by the same key. +/// +/// # `deny_unknown_fields` +/// +/// Deserialization is strict: a binding carrying any field this schema does +/// not know is rejected. This lets a FUTURE wallet-contract variant of the +/// **same signature curve** be added to a [`Code`](Self::Code) binding's +/// [`allowed_factory_ids`](Self::Code::allowed_factory_ids) without +/// re-introducing cross-account replay — provided that variant's `Code` +/// binding carries a DIFFERENT (e.g. added, required) field. A message shaped +/// for the new variant then fails to parse on the old contract (unknown +/// field), and a message shaped for the old variant fails to parse on the new +/// one (missing required field), so no single signed message resolves under +/// both. Without this, serde would silently ignore the extra field and the +/// old contract would accept the new variant's message — the very replay the +/// per-curve invariant exists to prevent. +#[cfg_attr( + feature = "serde", + ::cfg_eval::cfg_eval, + ::serde_with::serde_as, + derive(::serde::Serialize, ::serde::Deserialize), + serde(tag = "type", rename_all = "snake_case", deny_unknown_fields), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "borsh", + derive(::borsh::BorshSerialize, ::borsh::BorshDeserialize), + borsh(use_discriminant = true), + cfg_attr(feature = "borsh-schema", derive(::borsh::BorshSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum AuthSignerBinding { + /// Conventional NEP-641 binding to an exact account id. + /// + /// MUST be equal to `env::current_account_id()`. + /// + /// Clients SHOULD prefer this binding whenever the account id is + /// already known. + SignerId { + /// `AccountId` of the wallet-contract instance. + signer_id: AccountId, + } = 0, + + /// Binding to the deterministic account id (NEP-616) via its + /// `StateInit`: the *config* part of the **initial** + /// [`State`](crate::State). + /// + /// The contract reconstructs + /// `StateInit { code: env::current_global_contract_id(), data: + /// State { ..config, public_key } }` — taking the code identity from + /// the code it is currently running under and `public_key` from its + /// own storage — derives the deterministic account id from it and + /// verifies it equals `env::current_account_id()`. A match proves the + /// envelope was intended for this exact account: the account id + /// commits to the code, the full initial config and the public key + /// (which the signature additionally binds). + /// + /// The binding stays constructible client-side *before* the signing + /// ceremony reveals which key answers (e.g. `WebAuthn` passkey + /// discovery) — it doesn't even depend on which wallet-contract + /// variant the credential maps to. + /// + /// Because the binding commits to the account's *initial* state (which + /// determines its account id forever), post-creation config mutations + /// (added extensions, signature-mode changes) do NOT invalidate it. + /// + /// The envelope pins the set of *canonical factory code identities* it may + /// resolve under ([`allowed_factory_ids`](Self::Code::allowed_factory_ids)), + /// which — together with the derived-account-id check — collapses the set + /// of accounts a single signed message can authorize down to exactly one + /// per curve. See that field's docs for the invariant that MUST hold. + Code { + /// The canonical wallet-contract factory account ids (NEP-591 global + /// contracts deployed by account id) this authorization may resolve + /// under. Resolution succeeds only if the code this contract is + /// currently running under is one of these — a signed, explicit + /// allow-list of canonical factories that rejects any other code + /// identity (a rogue or not-yet-declared factory) as + /// [`SignerBindingMismatch`](crate::AuthError::SignerBindingMismatch). + /// + /// The client can populate this *before* the signing ceremony reveals + /// which curve (and thus which factory) answers, because all canonical + /// factory ids are fixed constants independent of the curve — each + /// per-curve wallet-contract instance enforces only its own membership. + /// + /// # Invariant (MUST hold) + /// + /// `allowed_factory_ids` MUST NOT contain two factories of the **same + /// signature curve**. The signed message commits to the *set*, not to + /// which member answered; a signature verifies under every factory of + /// its own curve that appears here. So if two same-curve factories were + /// listed and the key holder had an account under each, the **same + /// signed message would resolve `RESOLVED` against both accounts** — a + /// cross-account replay. With at most one factory per curve, the + /// signature's own curve selects a unique factory, and the set is a + /// singleton per curve. + allowed_factory_ids: BTreeSet, + + /// [`State::signature_enabled`](field@crate::State::signature_enabled) + /// the account was initialized with. + signature_enabled: bool, + + /// [`State::subwallet_id`](field@crate::State::subwallet_id) + /// the account was initialized with. + subwallet_id: u32, + + #[cfg_attr( + feature = "borsh-schema", + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + schema(with_funcs( + definitions = "As::>::add_definitions_recursively", + declaration = "As::>::declaration", + )) + ) + )] + #[cfg_attr( + all(feature = "borsh", not(feature = "borsh-schema")), + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + ) + )] + #[cfg_attr( + feature = "serde", + serde_as(as = "DurationSeconds"), + serde(rename = "timeout_secs") + )] + /// [`Nonces::timeout()`](crate::Nonces::timeout) the account was + /// initialized with (initial nonce bitmaps are always empty, so + /// the timeout fully determines the initial [`Nonces`](crate::Nonces)). + timeout: Duration, + + /// [`State::extensions`](field@crate::State::extensions) the + /// account was initialized with. + extensions: BTreeSet, + } = 1, +} + +/// Result of [`w_resolve_auth()`](crate::contract::Wallet::w_resolve_auth), +/// serialized exactly as specified by NEP-641. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE"), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizationResolution { + /// The authorization is valid for this account. + Resolved { + /// The unwrapped, original payload issued by the dApp. + payload: String, + }, + + /// The authorization requires further resolution on other accounts. + /// + /// Single-signer wallet contracts NEVER return this variant; it is part + /// of this type only to represent the full NEP-641 wire format + /// (e.g. when deserializing responses from multisig contracts). + Pending { + /// The unwrapped, original payload issued by the dApp. + payload: String, + /// Sub-authorizations the caller must recursively resolve. + pending_authorizations: Vec, + }, + + /// The authorization is invalid. + Invalid { + /// Machine-readable error kind. + error_kind: AuthErrorKind, + /// Human-readable error message. + error_message: String, + }, +} + +/// Sub-authorization to be recursively resolved by the caller (NEP-641). +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingAuthorization { + /// Account to resolve the sub-authorization on. + pub account_id: AccountId, + /// Purpose to resolve the sub-authorization with. + pub purpose: String, + /// Extracted sub-authorization blob. + pub authorization: String, +} + +/// Machine-readable error kind of +/// [`AuthorizationResolution::Invalid`] (NEP-641). +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + serde(rename_all = "SCREAMING_SNAKE_CASE"), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthErrorKind { + /// The authorization blob is malformed or doesn't commit to the + /// supplied arguments / this account. + InvalidInput, + /// Cryptographic signature verification failed. + InvalidSignature, +} + +/// An error that can occur when resolving an authorization. +/// +/// Unlike [`ContractError`](crate::ContractError), these errors are +/// returned as [`AuthorizationResolution::Invalid`] from the view method, +/// never panicked. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("authorization: {0}")] + MalformedAuthorization(String), + + #[error("purpose mismatch")] + PurposeMismatch, + + #[error("recipient mismatch")] + RecipientMismatch, + + #[error("invalid chain_id")] + InvalidChainId, + + #[error("expired or from the future")] + ExpiredOrFuture, + + #[error("signer binding mismatch")] + SignerBindingMismatch, + + #[error("signature is disabled")] + SignatureDisabled, + + #[error("invalid signature")] + InvalidSignature, +} + +impl AuthError { + /// Maps to NEP-641 [`AuthErrorKind`]. + #[must_use] + #[inline] + pub const fn kind(&self) -> AuthErrorKind { + match self { + Self::InvalidSignature => AuthErrorKind::InvalidSignature, + _ => AuthErrorKind::InvalidInput, + } + } +} + +impl From for AuthorizationResolution { + #[inline] + fn from(err: AuthError) -> Self { + Self::Invalid { + error_kind: err.kind(), + error_message: err.to_string(), + } + } +} + +/// The `authorization` blob accepted by +/// [`w_resolve_auth()`](crate::contract::Wallet::w_resolve_auth), +/// passed as its JSON string representation. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedAuthMessage { + /// The signed authorization message. + pub message: AuthMessage, + + /// [Schema](crate::SignatureSchema)-specific proof over + /// [`message.hash()`](AuthMessage::hash), in the same format as the + /// `proof` argument of + /// [`w_execute_signed()`](crate::contract::Wallet::w_execute_signed). + pub proof: String, +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + use rstest::rstest; + + use super::*; + + fn sample_msg() -> 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), + } + } + + #[rstest] + fn code_binding_json() { + let msg = sample_msg(); + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "chain_id": "mainnet", + "signer": { + "type": "code", + "allowed_factory_ids": [ + "ed25519-passkey-wallet-contract.trezu.near", + "p256-passkey-wallet-contract.trezu.near", + ], + "signature_enabled": true, + "subwallet_id": 0, + "timeout_secs": 3600, + "extensions": [], + }, + "purpose": "PROVE_OWNERSHIP", + "recipient": "trezu.app", + "payload": "Login to trezu.app at 2026-07-16T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "timeout_secs": 3600, + }) + ); + let roundtrip: AuthMessage = serde_json::from_value(json).unwrap(); + assert_eq!(roundtrip, msg); + } + + /// `deny_unknown_fields` must actually reject extra fields (serde has + /// historically ignored it on internally-tagged enums) — this is what lets + /// a future same-curve factory be added to `allowed_factory_ids` safely. + #[rstest] + fn binding_denies_unknown_field() { + // baseline parses + let mut json = serde_json::to_value(sample_msg()).unwrap(); + assert!(serde_json::from_value::(json.clone()).is_ok()); + + // a `Code` binding carrying an unknown (future-variant) field is rejected + json["signer"]["future_variant_field"] = serde_json::json!("x"); + assert!( + serde_json::from_value::(json).is_err(), + "deny_unknown_fields must reject unknown Code binding fields", + ); + + // ...and likewise for the SignerId variant + let signer_id_json = serde_json::json!({ + "chain_id": "mainnet", + "signer": { + "type": "signer_id", + "signer_id": "0s0000000000000000000000000000000000000000", + "future_variant_field": "x", + }, + "purpose": "PROVE_OWNERSHIP", + "recipient": "trezu.app", + "payload": "x", + "created_at": "1970-01-01T00:00:00Z", + "timeout_secs": 3600, + }); + assert!(serde_json::from_value::(signer_id_json).is_err()); + } + + #[rstest] + fn signer_id_binding_json() { + let binding = AuthSignerBinding::SignerId { + signer_id: "0s0000000000000000000000000000000000000000" + .parse() + .unwrap(), + }; + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "type": "signer_id", + "signer_id": "0s0000000000000000000000000000000000000000", + }) + ); + } + + /// Known-answer vector: pins the canonical borsh layout + domain prefix. + #[rstest] + fn hash_vector() { + assert_eq!( + sample_msg().hash(), + // pinned known-answer; recomputing differently means a breaking + // change to the wire format + hex!("443784c84117260c1b84acc2839155df5a840ef566139251fa3f221b037cba82"), + ); + } + + #[rstest] + fn resolution_json() { + assert_eq!( + serde_json::to_string(&AuthorizationResolution::Resolved { + payload: "hello".to_string(), + }) + .unwrap(), + r#"{"status":"RESOLVED","payload":"hello"}"#, + ); + + assert_eq!( + serde_json::to_string(&AuthorizationResolution::Invalid { + error_kind: AuthErrorKind::InvalidSignature, + error_message: "invalid signature".to_string(), + }) + .unwrap(), + r#"{"status":"INVALID","error_kind":"INVALID_SIGNATURE","error_message":"invalid signature"}"#, + ); + } +} diff --git a/contracts/wallet/src/contract.rs b/contracts/wallet/src/contract.rs index 566690fdd..c491851c2 100644 --- a/contracts/wallet/src/contract.rs +++ b/contracts/wallet/src/contract.rs @@ -11,7 +11,7 @@ use std::{collections::BTreeSet, fmt::Display}; use borsh::{BorshDeserialize, BorshSerialize}; -use defuse_near_promise::{NearPromise, actions::NearAction}; +use defuse_near_promise::{NearPromise, StateInit, StateInitV1, actions::NearAction}; use defuse_time::Timestamp; use impl_tools::autoimpl; use near_account_id::{AccountId, AccountIdRef}; @@ -19,7 +19,8 @@ use near_sdk::{FunctionError, Promise, env, ext_contract}; pub use crate::ContractError as Error; use crate::{ - Request, RequestMessage, SignatureSchema, State, WalletOp, + AuthError, AuthSignerBinding, AuthorizationResolution, Request, RequestMessage, + SignatureSchema, SignedAuthMessage, State, WalletOp, events::{Actor, WalletEvent}, }; @@ -84,6 +85,37 @@ pub trait Wallet { /// Returns a timestamp when nonces were last cleaned up. fn w_last_cleaned_at(&self) -> Timestamp; + + /// Resolve an off-chain authorization + /// ([NEP-641](https://github.com/near/NEPs/blob/master/neps/nep-0641.md)). + /// + /// MUST be a view method: it doesn't modify contract state and is + /// callable via `view_call` RPC without a signed transaction. + /// + /// The `authorization` blob is a JSON-serialized [`SignedAuthMessage`]. + /// + /// Being a single-signer wallet, this MUST return either + /// [`RESOLVED`](AuthorizationResolution::Resolved) or + /// [`INVALID`](AuthorizationResolution::Invalid), never + /// [`PENDING`](AuthorizationResolution::Pending). It MUST return + /// `INVALID` (instead of panicking) in following cases: + /// * `authorization` is not a valid JSON-serialized [`SignedAuthMessage`] + /// * signature is [currently disabled](WalletOp::SetSignatureMode) + /// * [`message.purpose`](crate::AuthMessage::purpose) or + /// [`message.recipient`](crate::AuthMessage::recipient) don't match the + /// supplied arguments + /// * [`message.chain_id`](crate::AuthMessage::chain_id) is from another network + /// * [`message.created_at`](crate::AuthMessage::created_at) is expired or + /// from the future + /// * [`message.signer`](crate::AuthMessage::signer) binding doesn't match this + /// account / its current code and config + /// * `proof` is [invalid](SignatureSchema::verify_hash) + fn w_resolve_auth( + &self, + purpose: String, + recipient: String, + authorization: String, + ) -> AuthorizationResolution; } /// Reference implementation of [`Wallet`] standard, generic over the underlying @@ -119,7 +151,7 @@ pub struct WalletImpl( impl Wallet for WalletImpl where - S: SignatureSchema, + S: SignatureSchema, { #[inline] fn w_execute_signed(&mut self, msg: RequestMessage, proof: String) { @@ -172,6 +204,19 @@ where fn w_last_cleaned_at(&self) -> Timestamp { self.0.nonces.last_cleaned_at() } + + #[inline] + fn w_resolve_auth( + &self, + purpose: String, + recipient: String, + authorization: String, + ) -> AuthorizationResolution { + self.resolve_auth(&purpose, &recipient, &authorization) + .map_or_else(Into::into, |payload| AuthorizationResolution::Resolved { + payload, + }) + } } impl WalletImpl @@ -209,6 +254,130 @@ where self.execute_request(msg.request, &Actor::SignedRequest(hash)) } + fn resolve_auth( + &self, + purpose: &str, + recipient: &str, + authorization: &str, + ) -> Result + where + S::PublicKey: Clone + BorshSerialize, + { + let SignedAuthMessage { + message: msg, + proof, + } = near_sdk::serde_json::from_str(authorization) + .map_err(|err| AuthError::MalformedAuthorization(err.to_string()))?; + + // same policy as `execute_signed()` + if !self.0.is_signature_allowed() { + return Err(AuthError::SignatureDisabled); + } + + // check purpose binding + if msg.purpose != purpose { + return Err(AuthError::PurposeMismatch); + } + + // check recipient binding + if msg.recipient != recipient { + return Err(AuthError::RecipientMismatch); + } + + // check chain_id + if msg.chain_id != env::chain_id() { + return Err(AuthError::InvalidChainId); + } + + // check validity window: same rule as `Nonces::commit()`, sans bitmap + let now = Timestamp::now(); + if !(now - self.0.nonces.timeout().min(msg.timeout) <= msg.created_at + && msg.created_at <= now) + { + return Err(AuthError::ExpiredOrFuture); + } + + // check signer binding + match &msg.signer { + AuthSignerBinding::SignerId { signer_id } => { + if *signer_id != env::current_account_id() { + return Err(AuthError::SignerBindingMismatch); + } + } + AuthSignerBinding::Code { + allowed_factory_ids, + signature_enabled, + subwallet_id, + timeout, + extensions, + } => { + // Reconstruct the `StateInit` this account must have been + // created with: the code identity is the code this account + // is currently running under, the config comes from the + // envelope, and `public_key` comes from the contract's own + // state (and is additionally bound by the signature + // verification below). The derived deterministic account + // id commits to all three, so it can only match + // `env::current_account_id()` if this envelope was + // intended for this exact account. + // + // NOTE: requires near-sdk >= 5.29.0, where + // `current_global_contract_id()` was fixed to return the + // global contract's account id (rather than the current + // account's own id) for GlobalByAccount deployments. + let Some(code) = env::current_global_contract_id() else { + // not running under a global contract: this cannot be + // a deterministic wallet-contract instance + return Err(AuthError::SignerBindingMismatch); + }; + + // Enforce the signed canonical-factory allow-list: the code + // this instance runs under MUST be one of the factory account + // ids the signer committed to. This is what caps the set of + // accounts a single signed message can authorize to one per + // curve (see `AuthSignerBinding::Code::allowed_factory_ids`). + // A rogue or not-yet-declared factory — anything the signer + // did not list — is rejected here even before the derivation + // check below. + let near_sdk::GlobalContractId::AccountId(factory_id) = &code else { + // deployed by code hash, not by account id: not a + // canonical (by-account) factory this envelope allows + return Err(AuthError::SignerBindingMismatch); + }; + if !allowed_factory_ids + .iter() + .any(|allowed| allowed.as_str() == factory_id.as_str()) + { + return Err(AuthError::SignerBindingMismatch); + } + + let initial_state = State { + signature_enabled: *signature_enabled, + subwallet_id: *subwallet_id, + public_key: self.0.public_key.clone(), + nonces: crate::Nonces::new(*timeout), + extensions: extensions.clone(), + }; + + let state_init = StateInit::V1(StateInitV1 { + code, + data: initial_state.as_storage(), + }); + + if state_init.derive_account_id() != env::current_account_id() { + return Err(AuthError::SignerBindingMismatch); + } + } + } + + // verify signature over the domain-separated authorization hash + if !S::verify_hash(&self.0.public_key, &msg.hash(), &proof) { + return Err(AuthError::InvalidSignature); + } + + Ok(msg.payload) + } + fn execute_extension(&mut self, request: Request) -> Result<()> { if env::attached_deposit().is_zero() { return Err(Error::InsufficientDeposit); @@ -347,7 +516,7 @@ impl From> for WalletImpl { /// /// ```rust /// # use core::fmt::{self, Display}; -/// use defuse_wallet::{RequestMessage, SignatureSchema, wallet}; +/// use defuse_wallet::{SignatureSchema, wallet}; /// use near_sdk::near; /// /// // Define the contract struct and impl @@ -370,17 +539,19 @@ impl From> for WalletImpl { /// /// Public key stored in the contract's state. /// type PublicKey = MyPublicKey; /// -/// /// Verify given proof over the request message in respect to the public -/// /// key and return whether verification passed. +/// /// Verify given proof over a 32-byte domain-separated digest in +/// /// respect to the public key and return whether verification passed. /// /// -/// /// Used by the `w_execute_signed(msg, proof)` contract method. -/// fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { -/// todo!("verify signature over `msg` in respect to the public key") +/// /// Used by the `w_execute_signed(msg, proof)` and +/// /// `w_resolve_auth(purpose, recipient, authorization)` contract methods. +/// fn verify_hash(public_key: &Self::PublicKey, hash: &[u8; 32], proof: &str) -> bool { +/// todo!("verify signature over `hash` in respect to the public key") /// } /// } /// /// // Public key is stored in the contract's state. /// #[near(serializers = [borsh])] +/// #[derive(Clone)] /// pub struct MyPublicKey([u8; 64]); /// /// // `Display` is needed for `w_public_key()` contract method. @@ -473,6 +644,19 @@ macro_rules! wallet { fn w_last_cleaned_at(&self) -> $crate::Timestamp { self.0.w_last_cleaned_at() } + + /// Resolve an off-chain authorization (NEP-641). + /// + /// This is a view method: it never modifies contract state and + /// returns `INVALID` instead of panicking. + fn w_resolve_auth( + &self, + purpose: ::std::string::String, + recipient: ::std::string::String, + authorization: ::std::string::String, + ) -> $crate::AuthorizationResolution { + self.0.w_resolve_auth(purpose, recipient, authorization) + } } }; } diff --git a/contracts/wallet/src/lib.rs b/contracts/wallet/src/lib.rs index f31b19086..e9adcd162 100644 --- a/contracts/wallet/src/lib.rs +++ b/contracts/wallet/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +mod auth; #[cfg(feature = "near-contract")] pub mod contract; mod error; @@ -9,7 +10,7 @@ mod nonces; mod request; mod schema; mod state; -pub use self::{error::*, message::*, nonces::*, request::*, schema::*, state::*}; +pub use self::{auth::*, error::*, message::*, nonces::*, request::*, schema::*, state::*}; pub use defuse_time::Timestamp; pub use near_account_id::{AccountId, AccountIdRef}; diff --git a/contracts/wallet/src/schema.rs b/contracts/wallet/src/schema.rs index 61aa7c20f..3b4f21132 100644 --- a/contracts/wallet/src/schema.rs +++ b/contracts/wallet/src/schema.rs @@ -15,10 +15,24 @@ pub trait SignatureSchema { /// method. type PublicKey; + /// Verify given proof over a 32-byte domain-separated digest in respect + /// to the public key and return whether verification passed. + /// + /// The digest is either a [canonical request hash](RequestMessage::hash) + /// (for `w_execute_signed(msg, proof)`) or a + /// [canonical authorization hash](crate::AuthMessage::hash) + /// (for `w_resolve_auth(purpose, recipient, authorization)`). + #[must_use = "check if verification passed"] + fn verify_hash(public_key: &Self::PublicKey, hash: &[u8; 32], proof: &str) -> bool; + /// Verify given proof over the request message in respect to the /// public key and return whether verification passed. /// /// Used by the `w_execute_signed(msg, proof)` contract method. + #[cfg(all(feature = "digest", feature = "borsh"))] #[must_use = "check if verification passed"] - fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool; + #[inline] + fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { + Self::verify_hash(public_key, &msg.hash(), proof) + } } diff --git a/contracts/wallet/tests/fixtures/nep641-auth.json b/contracts/wallet/tests/fixtures/nep641-auth.json new file mode 100644 index 000000000..c4900886a --- /dev/null +++ b/contracts/wallet/tests/fixtures/nep641-auth.json @@ -0,0 +1,69 @@ +{ + "comment": "Shared NEP-641 AuthMessage test vectors. Consumed by this crate's tests AND by the near-connect-passkey executor (vitest) — keep the copies in sync. hash = SHA3-256('NEAR_WALLET_CONTRACT_AUTH/V1' || borsh(message)), hex-encoded.", + "vectors": [ + { + "name": "code_binding_account_id_defaults", + "message": { + "chain_id": "mainnet", + "signer": { + "type": "code", + "allowed_factory_ids": [ + "ed25519-passkey-wallet-contract.trezu.near", + "p256-passkey-wallet-contract.trezu.near" + ], + "signature_enabled": true, + "subwallet_id": 0, + "timeout_secs": 3600, + "extensions": [] + }, + "purpose": "PROVE_OWNERSHIP", + "recipient": "trezu.app", + "payload": "Login to trezu.app at 2026-07-16T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "timeout_secs": 3600 + }, + "hash": "443784c84117260c1b84acc2839155df5a840ef566139251fa3f221b037cba82" + }, + { + "name": "signer_id_binding", + "message": { + "chain_id": "mainnet", + "signer": { + "type": "signer_id", + "signer_id": "0se5eba21e8f191e1880e453794bc551dfa50a3419" + }, + "purpose": "trezu/proposal:VoteApprove", + "recipient": "trezu.app", + "payload": "withdraw 100 USDC to bob.near", + "created_at": "2026-07-16T12:34:56.789Z", + "timeout_secs": 300 + }, + "hash": "eb9d444523a691fd4df281c6c70ed9884ac6142472ec30ead18e153a40466083" + }, + { + "name": "code_binding_mutated_config", + "message": { + "chain_id": "mainnet", + "signer": { + "type": "code", + "allowed_factory_ids": [ + "p256-passkey-wallet-contract.trezu.near" + ], + "signature_enabled": false, + "subwallet_id": 5, + "timeout_secs": 900, + "extensions": [ + "2fa.trezu.near", + "recovery.trezu.near" + ] + }, + "purpose": "PROVE_OWNERSHIP", + "recipient": "app.trezu.app", + "payload": "nonce:8c6ae081d689008b", + "created_at": "2026-07-16T00:00:00Z", + "timeout_secs": 3600 + }, + "hash": "370eb1e56e7ba8c01153afdcd2b3215965f946e8b8fd89c508be32b19aeccf9a" + } + ] +} diff --git a/contracts/wallet/tests/nep641_fixtures.rs b/contracts/wallet/tests/nep641_fixtures.rs new file mode 100644 index 000000000..85f53d1e7 --- /dev/null +++ b/contracts/wallet/tests/nep641_fixtures.rs @@ -0,0 +1,52 @@ +//! Shared NEP-641 [`AuthMessage`] test vectors. +//! +//! The fixture file is also consumed by the `near-connect-passkey` executor +//! (vitest) to lock the exact wire format across implementations. Any change +//! to these vectors is a breaking change to the NEP-641 authorization format +//! of the wallet contract. + +use defuse_wallet::AuthMessage; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Fixtures { + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct Vector { + name: String, + message: AuthMessage, + #[serde(with = "hex::serde")] + hash: [u8; 32], +} + +#[test] +fn nep641_auth_vectors() { + let fixtures: Fixtures = serde_json::from_str(include_str!("fixtures/nep641-auth.json")) + .expect("fixtures must deserialize as AuthMessage"); + + assert!(!fixtures.vectors.is_empty()); + + for Vector { + name, + message, + hash, + } in fixtures.vectors + { + assert_eq!( + message.hash(), + hash, + "hash mismatch for vector '{name}': got {}", + hex::encode(message.hash()), + ); + + // JSON round-trip must be lossless + let json = serde_json::to_value(&message).unwrap(); + assert_eq!( + serde_json::from_value::(json).unwrap(), + message, + "JSON round-trip mismatch for vector '{name}'", + ); + } +} diff --git a/crates/wallet/sdk/src/client.rs b/crates/wallet/sdk/src/client.rs index 92367ee8d..5f20dc88e 100644 --- a/crates/wallet/sdk/src/client.rs +++ b/crates/wallet/sdk/src/client.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::BTreeSet}; -use defuse_wallet::{Request, RequestMessage, Timestamp}; +use defuse_wallet::{AuthorizationResolution, Request, RequestMessage, Timestamp}; use derive_more::From; use near_kit::{AccountId, AccountIdRef}; use serde::Serialize; @@ -28,6 +28,8 @@ pub trait WalletContract { fn w_timeout_secs(&self) -> u32; fn w_last_cleaned_at(&self) -> Timestamp; + + fn w_resolve_auth(&self, args: WResolveAuthArgs<'_>) -> AuthorizationResolution; } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -97,3 +99,10 @@ impl From for WExecuteExtensionArgs<'_> { pub struct WIsExtensionEnabledArgs<'a> { pub account_id: Cow<'a, AccountIdRef>, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WResolveAuthArgs<'a> { + pub purpose: Cow<'a, str>, + pub recipient: Cow<'a, str>, + pub authorization: Cow<'a, str>, +}