Skip to main content

Using __check_auth in interesting ways

Tutorial 1: time based restriction on token transfers

Imagine a multi-sig account contract where certain actions, like transferring tokens, need to be controlled by the time elapsed between consecutive transfers. For example, a token transfer should only be allowed if a certain period of time has passed since the last transfer. This can be useful in scenarios where you want to limit the frequency of transactions to prevent misuse or to implement rate limiting.

Base concepts

Time-based restrictions

The idea is to enforce a minimum time interval between consecutive token transfers. Each token can have its own time limit, and the contract will track the last transfer time for each token.

Data storage

The contract will store the time limit and last transfer time for each token in its storage to keep track of these values across transactions.

Code walkthrough

Contract structure and imports

#![no_std]

use soroban_sdk::{
auth::{Context, CustomAccountInterface}, contract, contracterror, contractimpl, contracttype, symbol_short, Address,
BytesN, Env, Symbol, Vec,
};
#[contract]
struct AccountContract;

#[contracttype]
#[derive(Clone)]
pub struct Signature {
pub public_key: BytesN<32>,
pub signature: BytesN<64>,
}

#[contracttype]
#[derive(Clone)]
enum DataKey {
SignerCnt,
Signer(BytesN<32>),
TimeLimit(Address),
LastTransferTime(Address),
}

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum AccError {
NotEnoughSigners = 1,
NegativeAmount = 2,
BadSignatureOrder = 3,
UnknownSigner = 4,
InvalidContext = 5,
TimeLimitExceeded = 6,
}

const TRANSFER_FN: Symbol = symbol_short!("transfer");

Contract initialization

  1. Initializes the contract with a list of signers' public keys
  2. Stores the count of signers in the contract's storage
#[contractimpl]
impl AccountContract {

pub fn init(env: Env, signers: Vec<BytesN<32>>) {
for signer in signers.iter() {
env.storage().instance().set(&DataKey::Signer(signer), &());
}
env.storage()
.instance()
.set(&DataKey::SignerCnt, &signers.len());
}
}

Setting time limits

  1. Allows setting a time limit for a specific token
  2. Ensures that only the contract itself can set these limits by requiring the contract's authorization
#[contractimpl]

impl AccountContract {
pub fn set_time(env: Env, token: Address, time_limit: u64) {
env.current_contract_address().require_auth();
env.storage()
.instance()
.set(&DataKey::TimeLimit(token), &time_limit);
}
}

Custom authentication and authorization

  1. Authenticates the signatures
  2. Checks if all required signers have signed
  3. Iterates through the authorization context to verify the authorization policy
#[contractimpl]
impl CustomAccountInterface for AccountContract {

type Error = AccError;
type Signature = Vec<Signature>;

#[allow(non_snake_case)]
fn __check_auth(
env: Env,
signature_payload: BytesN<32>,
signatures: Vec<Signature>,
auth_context: Vec<Context>,
) -> Result<(), AccError> {
authenticate(&env, &signature_payload, &signatures)?;

let tot_signers: u32 = env
.storage()
.instance()
.get::<_, u32>(&DataKey::SignerCnt)
.unwrap();

let all_signed = tot_signers == signatures.len();

let curr_contract = env.current_contract_address();

for context in auth_context.iter() {
verify_authorization_policy(
&env,
&context,
&curr_contract,
all_signed,
)?;
}
Ok(())
}

}

Authentication logic

  1. Verifies that the signatures are in the correct order and that each signer is authorized
  2. Uses ed25519_verify function to verify each signature
 fn authenticate(
env: &Env,
signature_payload: &BytesN<32>,
signatures: &Vec<Signature>,
) -> Result<(), AccError> {
for i in 0..signatures.len() {
let signature = signatures.get_unchecked(i);
if i > 0 {
let prev_signature = signatures.get_unchecked(i - 1);
if prev_signature.public_key >= signature.public_key {
return Err(AccError::BadSignatureOrder);
}
}
if !env
.storage()
.instance()
.has(&DataKey::Signer(signature.public_key.clone()))
{
return Err(AccError::UnknownSigner);
}
env.crypto().ed25519_verify(
&signature.public_key,
&signature_payload.clone().into(),
&signature.signature,
);
}
Ok(())
}

Authorization policy verification

  1. Checks if the function being called is a transfer or approve function
  2. Enforces the time-based restriction by comparing the current time with the last transfer time
  3. Updates the last transfer time if the transfer is allowed
fn verify_authorization_policy(
env: &Env,
context: &Context,
curr_contract: &Address,
all_signed: bool,
) -> Result<(), AccError> {
let contract_context = match context {
Context::Contract(c) => {
if &c.contract == curr_contract {
if !all_signed {
return Err(AccError::NotEnoughSigners);
}
}
c
}
Context::CreateContractHostFn(_) => return Err(AccError::InvalidContext),
};

if contract_context.fn_name != TRANSFER_FN
&& contract_context.fn_name != Symbol::new(env, "approve")
{
return Ok(());
}

let current_time = env.ledger().timestamp();
let time_limit: Option<u64> =
env.storage()
.instance()
.get::<_, u64>(&DataKey::TimeLimit(contract_context.contract.clone()));

if let Some(limit) = time_limit {
let last_transfer_time: u64 = env.storage()
.instance()
.get::<_, u64>(&DataKey::LastTransferTime(contract_context.contract.clone()))
.unwrap_or(0);

if current_time - last_transfer_time < limit {
return Err(AccError::TimeLimitExceeded);
}

env.storage()
.instance()
.set(&DataKey::LastTransferTime(contract_context.contract.clone()), &current_time);
}
Ok(())
}

Summary

The contract begins by initializing with a set of authorized signers. It allows setting a time limit for token transfers, which controls how frequently a token can be transferred. The __check_auth function is the core of the authorization process, ensuring that all necessary signatures are valid and checking the time-based restriction for token transfers. If the required time has not passed since the last transfer, the contract will deny the operation, enforcing the desired rate limiting. By tracking the last transfer time and enforcing a minimum time interval between transfers, the contract effectively limits the frequency of token transfers, resolving the issue of potential abuse through rapid consecutive transactions.

Complete code

Here are all the snippets stacked together in a single file for convenience:

#![no_std]
use soroban_sdk::{
auth::{Context, CustomAccountInterface}, contract, contracterror, contractimpl, contracttype, symbol_short, Address,
BytesN, Env, Symbol, Vec,
};
#[contract]
struct AccountContract;

#[contracttype]
#[derive(Clone)]
pub struct Signature {
pub public_key: BytesN<32>,
pub signature: BytesN<64>,
}

#[contracttype]
#[derive(Clone)]
enum DataKey {
SignerCnt,
Signer(BytesN<32>),
TimeLimit(Address),
LastTransferTime(Address),
}

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum AccError {
NotEnoughSigners = 1,
NegativeAmount = 2,
BadSignatureOrder = 3,
UnknownSigner = 4,
InvalidContext = 5,
TimeLimitExceeded = 6,
}

const TRANSFER_FN: Symbol = symbol_short!("transfer");

#[contractimpl]
impl AccountContract {
pub fn init(env: Env, signers: Vec<BytesN<32>>) {
for signer in signers.iter() {
env.storage().instance().set(&DataKey::Signer(signer), &());
}
env.storage()
.instance()
.set(&DataKey::SignerCnt, &signers.len());
}

pub fn set_time(env: Env, token: Address, time_limit: u64) {
env.current_contract_address().require_auth();
env.storage()
.instance()
.set(&DataKey::TimeLimit(token), &time_limit);
}
}

#[contractimpl]
impl CustomAccountInterface for AccountContract {

type Error = AccError;
type Signature = Vec<Signature>;

#[allow(non_snake_case)]
fn __check_auth(
env: Env,
signature_payload: BytesN<32>,
signatures: Vec<Signature>,
auth_context: Vec<Context>,
) -> Result<(), AccError> {
authenticate(&env, &signature_payload, &signatures)?;

let tot_signers: u32 = env
.storage()
.instance()
.get::<_, u32>(&DataKey::SignerCnt)
.unwrap();

let all_signed = tot_signers == signatures.len();

let curr_contract = env.current_contract_address();

for context in auth_context.iter() {
verify_authorization_policy(
&env,
&context,
&curr_contract,
all_signed,
)?;
}
Ok(())
}

}

fn authenticate(
env: &Env,
signature_payload: &BytesN<32>,
signatures: &Vec<Signature>,
) -> Result<(), AccError> {
for i in 0..signatures.len() {
let signature = signatures.get_unchecked(i);
if i > 0 {
let prev_signature = signatures.get_unchecked(i - 1);
if prev_signature.public_key >= signature.public_key {
return Err(AccError::BadSignatureOrder);
}
}
if !env
.storage()
.instance()
.has(&DataKey::Signer(signature.public_key.clone()))
{
return Err(AccError::UnknownSigner);
}
env.crypto().ed25519_verify(
&signature.public_key,
&signature_payload.clone().into(),
&signature.signature,
);
}
Ok(())
}

fn verify_authorization_policy(
env: &Env,
context: &Context,
curr_contract: &Address,
all_signed: bool,
) -> Result<(), AccError> {
let contract_context = match context {
Context::Contract(c) => {
if &c.contract == curr_contract {
if !all_signed {
return Err(AccError::NotEnoughSigners);
}
}
c
}
Context::CreateContractHostFn(_) => return Err(AccError::InvalidContext),
};

if contract_context.fn_name != TRANSFER_FN
&& contract_context.fn_name != Symbol::new(env, "approve")
{
return Ok(());
}

let current_time = env.ledger().timestamp();
let time_limit: Option<u64> =
env.storage()
.instance()
.get::<_, u64>(&DataKey::TimeLimit(contract_context.contract.clone()));

if let Some(limit) = time_limit {
let last_transfer_time: u64 = env.storage()
.instance()
.get::<_, u64>(&DataKey::LastTransferTime(contract_context.contract.clone()))
.unwrap_or(0);

if current_time - last_transfer_time < limit {
return Err(AccError::TimeLimitExceeded);
}

env.storage()
.instance()
.set(&DataKey::LastTransferTime(contract_context.contract.clone()), &current_time);
}
Ok(())
}

mod test;

These are the test cases:

#![cfg(test)]
extern crate std;

use ed25519_dalek::Keypair;
use ed25519_dalek::Signer;
use rand::thread_rng;
use soroban_sdk::auth::ContractContext;
use soroban_sdk::symbol_short;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::testutils::AuthorizedFunction;
use soroban_sdk::testutils::AuthorizedInvocation;
use soroban_sdk::Val;
use soroban_sdk::{
auth::Context, testutils::BytesN as _, vec, Address, BytesN, Env, IntoVal, Symbol,
};
use soroban_sdk::testutils::Ledger;
use soroban_sdk::testutils::LedgerInfo;
use crate::AccError;
use crate::{AccountContract, AccountContractClient, Signature};

fn generate_keypair() -> Keypair {
Keypair::generate(&mut thread_rng())
}

fn signer_public_key(e: &Env, signer: &Keypair) -> BytesN<32> {
signer.public.to_bytes().into_val(e)
}

fn create_account_contract(e: &Env) -> AccountContractClient {
AccountContractClient::new(e, &e.register_contract(None, AccountContract {}))
}

fn sign(e: &Env, signer: &Keypair, payload: &BytesN<32>) -> Val {
Signature {
public_key: signer_public_key(e, signer),
signature: signer
.sign(payload.to_array().as_slice())
.to_bytes()
.into_val(e),
}
.into_val(e)
}

fn token_auth_context(e: &Env, token_id: &Address, fn_name: Symbol, amount: i128) -> Context {
Context::Contract(ContractContext {
contract: token_id.clone(),
fn_name,
args: ((), (), amount).into_val(e),
})
}

#[test]
fn test_token_auth() {
let env = Env::default();
env.mock_all_auths();

let account_contract = create_account_contract(&env);

let mut signers = [generate_keypair(), generate_keypair()];
if signers[0].public.as_bytes() > signers[1].public.as_bytes() {
signers.swap(0, 1);
}
account_contract.init(&vec![
&env,
signer_public_key(&env, &signers[0]),
signer_public_key(&env, &signers[1]),
]);

let payload = BytesN::random(&env);
let token = Address::generate(&env);

env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address,
&payload,
vec![&env, sign(&env, &signers[0], &payload)].into(),
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1000),
],
)
.unwrap();
env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address,
&payload,
vec![&env, sign(&env, &signers[0], &payload)].into(),
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1000),
],
)
.unwrap();

// Add a time limit of 1000 seconds for the token.
account_contract.set_time(&token, &1000);

assert_eq!(
env.auths(),
std::vec![(
account_contract.address.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
account_contract.address.clone(),
symbol_short!("set_time"),
(token.clone(), 1000_u64).into_val(&env),
)),
sub_invocations: std::vec![]
}
)]
);

// Attempting a transfer within the time limit should fail.
env.ledger().set(LedgerInfo {
timestamp: 0,
protocol_version: 1,
sequence_number: 10,
network_id: Default::default(),
base_reserve: 10,
min_temp_entry_ttl: 16,
min_persistent_entry_ttl: 16,
max_entry_ttl: 100_000,
});

env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address,
&payload,
vec![&env, sign(&env, &signers[0], &payload)].into(),
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1001)
],
)
.err()
.unwrap()
.unwrap() == AccError::TimeLimitExceeded;

// Simulate passing of time to allow the next transfer.
env.ledger().set(LedgerInfo {
timestamp: 1000,
protocol_version: 1,
sequence_number: 10,
network_id: Default::default(),
base_reserve: 10,
min_temp_entry_ttl: 16,
min_persistent_entry_ttl: 16,
max_entry_ttl: 100_000,
});

env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address,
&payload,
vec![&env, sign(&env, &signers[0], &payload)].into(),
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1001),
],
)
.unwrap();
}

Tutorial 2: implementing a smart wallet (WebAuthn)

Imagine a world where traditional passwords are obsolete. In this world, WebAuthn (Web Authentication) has become the standard for secure online interactions. Alice, a blockchain enthusiast, wants to create a wallet that leverages WebAuthn technology for enhanced security. She decides to implement a WebAuthn wallet on Stellar, allowing users to manage their digital assets using their device's biometric features or security keys (e.g., YubiKey, Google Titan Security Key, etc.).

Base concepts

WebAuthn is a web standard for passwordless authentication. It allows users to authenticate using biometrics (like fingerprints or facial recognition).

The WebAuthn wallet implemented in this tutorial will be able to:

  1. Register and manage user credentials (public keys)
  2. Authenticate users for transaction signing
  3. Differentiate between admin and regular users
  4. Allow contract updates for future improvements
info

This tutorial's code credit goes to @kalepail's work on passkeys, which you can explore more here.

Code walkthrough

Contract structure and imports

This section sets up the contract structure and imports necessary components from the Soroban SDK.

#![no_std]

use soroban_sdk::{
auth::{Context, CustomAccountInterface},
contract, contracterror, contractimpl, contracttype,
crypto::Hash,
panic_with_error, symbol_short, Bytes, BytesN, Env, FromVal, Symbol, Vec,
};

#[contract]
pub struct Contract;

Error definitions

This enum defines possible errors that can occur during contract execution.

#[contracterror]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Error {
NotFound = 1,
NotPermitted = 2,
ClientDataJsonChallengeIncorrect = 3,
Secp256r1PublicKeyParse = 4,
Secp256r1SignatureParse = 5,
Secp256r1VerifyFailed = 6,
JsonParseError = 7,
}

Core contract functions

These functions handle adding and removing signers, updating the contract, and managing admin counts.

// Implementing the Contract struct with various methods
#[contractimpl]
impl Contract {
// Method to add a new signer, potentially as an admin
pub fn add(env: Env, id: Bytes, pk: BytesN<65>, mut admin: bool) -> Result<(), Error> {
// Check if the instance storage has the ADMIN_SIGNER_COUNT key
if env.storage().instance().has(&ADMIN_SIGNER_COUNT) {
// Require authentication from the current contract address
env.current_contract_address().require_auth();
} else {
// If it's the first signer, ensure they are an admin
admin = true;
}

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// If the signer is an admin
if admin {
// Check if the ID exists in temporary storage
if env.storage().temporary().has(&id) {
// Remove the ID from temporary storage
env.storage().temporary().remove(&id);
}

// Update the admin signer count by incrementing it
Self::update_admin_signer_count(&env, true);

// Store the public key in persistent storage with the given ID
env.storage().persistent().set(&id, &pk);

// Extend the TTL for the persistent storage entry
env.storage()
.persistent()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);
} else {
// If the signer is not an admin
// Check if the ID exists in persistent storage
if env.storage().persistent().has(&id) {
// Update the admin signer count by decrementing it
Self::update_admin_signer_count(&env, false);

// Remove the ID from persistent storage
env.storage().persistent().remove(&id);
}

// Store the public key in temporary storage with the given ID
env.storage().temporary().set(&id, &pk);

// Extend the TTL for the temporary storage entry
env.storage()
.temporary()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);
}

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Publish an event indicating a new signer has been added
env.events()
.publish((EVENT_TAG, symbol_short!("add"), id, pk), admin);

// Return Ok indicating success
Ok(())
}

// Method to remove a signer
pub fn remove(env: Env, id: Bytes) -> Result<(), Error> {
// Require authentication from the current contract address
env.current_contract_address().require_auth();

// Check if the ID exists in temporary storage
if env.storage().temporary().has(&id) {
// Remove the ID from temporary storage
env.storage().temporary().remove(&id);
} else if env.storage().persistent().has(&id) {
// If the ID exists in persistent storage, decrement the admin signer count
Self::update_admin_signer_count(&env, false);

// Remove the ID from persistent storage
env.storage().persistent().remove(&id);
}

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Publish an event indicating a signer has been removed
env.events()
.publish((EVENT_TAG, symbol_short!("remove"), id), ());

// Return Ok indicating success
Ok(())
}

// Method to update the contract with new WASM code
pub fn update(env: Env, hash: BytesN<32>) -> Result<(), Error> {
// Require authentication from the current contract address
env.current_contract_address().require_auth();

// Update the contract's WASM code with the new hash
env.deployer().update_current_contract_wasm(hash);

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Return Ok indicating success
Ok(())
}

// Helper method to update the count of admin signers
fn update_admin_signer_count(env: &Env, add: bool) {
// Get the current count of admin signers from instance storage, defaulting to 0
let count = env
.storage()
.instance()
.get::<Symbol, i32>(&ADMIN_SIGNER_COUNT)
.unwrap_or(0)
+ if add { 1 } else { -1 };

// If the count is less than or equal to 0, trigger an error
if count <= 0 {
panic_with_error!(env, Error::NotPermitted)
}

// Update the admin signer count in instance storage
env.storage()
.instance()
.set::<Symbol, i32>(&ADMIN_SIGNER_COUNT, &count);
}
}

Signature structure

This structure represents a WebAuthn signature.

#[contracttype]
pub struct Signature {
pub id: Bytes,
pub authenticator_data: Bytes,
pub client_data_json: Bytes,
pub signature: BytesN<64>,
}

CustomAccountInterface implementation

This implements the core authentication logic for the WebAuthn wallet.

#[contractimpl]
impl CustomAccountInterface for Contract {
// Defining the error and signature types for the trait
type Error = Error;
type Signature = Signature;

#[allow(non_snake_case)]
fn __check_auth(
env: Env, // The environment context
signature_payload: Hash<32>, // The payload that needs to be signed
signature: Signature, // The signature provided by the client
auth_contexts: Vec<Context>, // Contexts for authentication
) -> Result<(), Error> {
// Destructure the signature into its components
let Signature {
id,
mut authenticator_data,
client_data_json,
signature,
} = signature;

// Set the maximum time-to-live (TTL) for storage entries
let max_ttl = env.storage().max_ttl();

// Try to retrieve the public key associated with the id from temporary storage
let pk = match env.storage().temporary().get(&id) {
Some(pk) => {
// Check if a session signer is trying to perform protected actions
for context in auth_contexts.iter() {
match context {
// If the context is a contract call
Context::Contract(c) => {
// Ensure that the current contract is not performing restricted actions
if c.contract == env.current_contract_address()
&& (c.fn_name != symbol_short!("remove")
|| (c.fn_name == symbol_short!("remove")
&& Bytes::from_val(&env, &c.args.get(0).unwrap()) != id))
{
return Err(Error::NotPermitted);
}
}
_ => {} // Allow other contexts (e.g., deploying new contracts)
};
}

// Extend the TTL for the temporary storage entry
env.storage()
.temporary()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);

pk // Return the public key
}
// If not found in temporary storage, try persistent storage
None => {
env.storage()
.persistent()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);

env.storage().persistent().get(&id).ok_or(Error::NotFound)?
}
};

// Extend the authenticator data with the SHA-256 hash of the client data JSON
authenticator_data.extend_from_array(&env.crypto().sha256(&client_data_json).to_array());

// Verify the signature using the secp256r1 elliptic curve algorithm
env.crypto()
.secp256r1_verify(&pk, &env.crypto().sha256(&authenticator_data), &signature);

// Parse the client data JSON, extracting the base64 URL encoded challenge
let client_data_json = client_data_json.to_buffer::<1024>();
let client_data_json = client_data_json.as_slice();
let (client_data_json, _): (ClientDataJson, _) =
serde_json_core::de::from_slice(client_data_json).map_err(|_| Error::JsonParseError)?;

// Build the expected challenge from the signature payload
let mut expected_challenge = [0u8; 43];
base64_url::encode(&mut expected_challenge, &signature_payload.to_array());

// Check that the challenge inside the client data JSON matches the expected challenge
if client_data_json.challenge.as_bytes() != expected_challenge {
return Err(Error::ClientDataJsonChallengeIncorrect);
}

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

Ok(()) // Return success
}
}

Base64 url encoding

This function provides Base64 URL encoding functionality used in the WebAuthn process.

// Define the Base64 URL alphabet as a constant byte array.
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

// The `encode` function takes a mutable reference to a destination byte array `dst` and a source byte array `src`.
pub fn encode(dst: &mut [u8], src: &[u8]) {
// Initialize destination index `di` and source index `si` to 0.
let mut di: usize = 0;
let mut si: usize = 0;
// Calculate the length of the source array that is a multiple of 3.
let n = (src.len() / 3) * 3;-

// Process the source array in chunks of 3 bytes.
while si < n {
// Combine 3 bytes into a single 24-bit value.
let val = (src[si] as usize) << 16 | (src[si + 1] as usize) << 8 | (src[si + 2] as usize);
// Encode the 24-bit value into 4 Base64 characters.
dst[di] = ALPHABET[val >> 18 & 0x3F];
dst[di + 1] = ALPHABET[val >> 12 & 0x3F];
dst[di + 2] = ALPHABET[val >> 6 & 0x3F];
dst[di + 3] = ALPHABET[val & 0x3F];
// Increment the source index by 3 and the destination index by 4.
si += 3;
di += 4;
}

// Calculate the remaining number of bytes in the source array.
let remain = src.len() - si;

// If there are no remaining bytes, return early.
if remain == 0 {
return;
}

// Initialize a 24-bit value with the remaining byte(s).
let mut val = (src[si] as usize) << 16;

// If there are 2 remaining bytes, add the second byte to the 24-bit value.
if remain == 2 {
val |= (src[si + 1] as usize) << 8;
}

// Encode the remaining bytes into 2 or 3 Base64 characters.
dst[di] = ALPHABET[val >> 18 & 0x3F];
dst[di + 1] = ALPHABET[val >> 12 & 0x3F];

// If there are 2 remaining bytes, encode the third Base64 character.
if remain == 2 {
dst[di + 2] = ALPHABET[val >> 6 & 0x3F];
}
}

Written explanation of code

The WebAuthn wallet contract manages user credentials and authentication. It allows adding and removing signers, distinguishing between admin and regular users. The add function registers new signers, storing admin keys persistently and regular keys temporarily. The remove function deletes signers, and update allows contract upgrades. The core of the wallet's security is the __check_auth function, which verifies WebAuthn signatures. It checks the signature against the stored public key, verifies the client data JSON, and ensures the challenge matches the expected value. The contract uses Soroban's storage capabilities to manage keys and admin counts, with different TTLs (Time To Live) for persistent and temporary storage.

Test cases

These are test cases to ensure our WebAuthn wallet is functioning correctly. We'll use the Soroban SDK's testing utilities to create and run these tests.

#[cfg(test)]
mod test {
use std::println;
extern crate std;

use soroban_sdk::{
vec,
Bytes,
BytesN,
Env,
IntoVal,
};

use crate::{Contract, ContractClient, Error, Signature};

#[test]
fn test() {
let env = Env::default();
let deployee_address = env.register_contract(None, Contract);
let deployee_client = ContractClient::new(&env, &deployee_address);

// Test data
let id = Bytes::from_array(
&env,
&[243, 248, 216, 74, 226, 218, 85, 102, 196, 167, 14, 151, 124, 42, 73, 136, 138, 102, 187, 140],
);
let pk = BytesN::from_array(
&env,
&[4, 163, 142, 245, 242, 113, 55, 104, 189, 52, 128, 238, 206, 174, 194, 177, 4, 100,
161, 243, 177, 255, 10, 53, 57, 194, 205, 45, 208, 10, 131, 167, 93, 44, 123, 126, 95,
219, 207, 230, 175, 90, 96, 41, 121, 197, 127, 180, 74, 236, 160, 0, 60, 185, 211, 174,
133, 215, 200, 208, 230, 51, 210, 94, 214],
);

// Test adding a signer
deployee_client.add(&id, &pk, &true);

// Test authentication
let signature_payload = BytesN::from_array(
&env,
&[150, 22, 248, 96, 91, 4, 111, 72, 170, 101, 57, 225, 210, 199, 91, 29, 159, 227, 209,
6, 231, 63, 222, 209, 232, 57, 112, 98, 140, 118, 206, 245],
);

let signature = Signature {
authenticator_data: Bytes::from_array(
&env,
&[75, 74, 206, 229, 181, 139, 119, 89, 254, 159, 95, 149, 227, 164, 109, 143, 188,
228, 143, 219, 181, 216, 77, 123, 142, 172, 60, 20, 162, 154, 181, 187, 29, 0, 0, 0, 0],
),
client_data_json: Bytes::from_array(
&env,
&[123, 34, 116, 121, 112, 101, 34, 58, 34, 119, 101, 98, 97, 117, 116, 104, 110, 46,
103, 101, 116, 34, 44, 34, 99, 104, 97, 108, 108, 101, 110, 103, 101, 34, 58, 34,
108, 104, 98, 52, 89, 70, 115, 69, 98, 48, 105, 113, 90, 84, 110, 104, 48, 115,
100, 98, 72, 90, 95, 106, 48, 81, 98, 110, 80, 57, 55, 82, 54, 68, 108, 119, 89,
111, 120, 50, 122, 118, 85, 34, 44, 34, 111, 114, 105, 103, 105, 110, 34, 58, 34,
104, 116, 116, 112, 115, 58, 47, 47, 112, 97, 115, 115, 107, 101, 121, 45, 107,
105, 116, 45, 100, 101, 109, 111, 46, 112, 97, 103, 101, 115, 46, 100, 101, 118, 34, 125],
),
id: id.clone(),
signature: BytesN::from_array(
&env,
&[74, 48, 29, 120, 181, 135, 255, 178, 105, 76, 82, 118, 29, 135, 193, 72, 123, 144,
138, 214, 125, 27, 33, 159, 169, 200, 151, 55, 7, 250, 111, 172, 86, 89, 162, 167,
148, 105, 144, 68, 21, 249, 61, 253, 80, 61, 54, 29, 14, 162, 12, 173, 206, 194,
144, 227, 11, 225, 74, 254, 191, 221, 103, 86],
),
};

let result: Result<(), Result<Error, _>> = env.try_invoke_contract_check_auth(
&deployee_address,
&signature_payload,
signature.into_val(&env),
&vec![&env],
);

println!("{:?}", result);
assert!(result.is_ok());

}
}

Summary

This WebAuthn wallet implementation provides a secure and user-friendly way to manage digital assets on Stellar. It leverages the security benefits of WebAuthn while maintaining the flexibility needed for blockchain interactions.

Complete code

Here are all the snippets stacked together in a single file for convenience:

#![no_std]

use soroban_sdk::{
auth::{Context, CustomAccountInterface},
contract, contracterror, contractimpl, contracttype,
crypto::Hash,
panic_with_error, symbol_short, Bytes, BytesN, Env, FromVal, Symbol, Vec,
};

#[contract]
pub struct Contract;

// Error definitions for the contract
#[contracterror]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Error {
NotFound = 1,
NotPermitted = 2,
ClientDataJsonChallengeIncorrect = 3,
Secp256r1PublicKeyParse = 4,
Secp256r1SignatureParse = 5,
Secp256r1VerifyFailed = 6,
JsonParseError = 7,
}

// Structure representing a WebAuthn signature
#[contracttype]
pub struct Signature {
pub id: Bytes,
pub authenticator_data: Bytes,
pub client_data_json: Bytes,
pub signature: BytesN<64>,
}

// Implementing the Contract struct with various methods
#[contractimpl]
impl Contract {
// Method to add a new signer, potentially as an admin
pub fn add(env: Env, id: Bytes, pk: BytesN<65>, mut admin: bool) -> Result<(), Error> {
// Check if the instance storage has the ADMIN_SIGNER_COUNT key
if env.storage().instance().has(&ADMIN_SIGNER_COUNT) {
// Require authentication from the current contract address
env.current_contract_address().require_auth();
} else {
// If it's the first signer, ensure they are an admin
admin = true;
}

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// If the signer is an admin
if admin {
// Check if the ID exists in temporary storage
if env.storage().temporary().has(&id) {
// Remove the ID from temporary storage
env.storage().temporary().remove(&id);
}

// Update the admin signer count by incrementing it
Self::update_admin_signer_count(&env, true);

// Store the public key in persistent storage with the given ID
env.storage().persistent().set(&id, &pk);

// Extend the TTL for the persistent storage entry
env.storage()
.persistent()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);
} else {
// If the signer is not an admin
// Check if the ID exists in persistent storage
if env.storage().persistent().has(&id) {
// Update the admin signer count by decrementing it
Self::update_admin_signer_count(&env, false);

// Remove the ID from persistent storage
env.storage().persistent().remove(&id);
}

// Store the public key in temporary storage with the given ID
env.storage().temporary().set(&id, &pk);

// Extend the TTL for the temporary storage entry
env.storage()
.temporary()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);
}

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Publish an event indicating a new signer has been added
env.events()
.publish((EVENT_TAG, symbol_short!("add"), id, pk), admin);

// Return Ok indicating success
Ok(())
}

// Method to remove a signer
pub fn remove(env: Env, id: Bytes) -> Result<(), Error> {
// Require authentication from the current contract address
env.current_contract_address().require_auth();

// Check if the ID exists in temporary storage
if env.storage().temporary().has(&id) {
// Remove the ID from temporary storage
env.storage().temporary().remove(&id);
} else if env.storage().persistent().has(&id) {
// If the ID exists in persistent storage, decrement the admin signer count
Self::update_admin_signer_count(&env, false);

// Remove the ID from persistent storage
env.storage().persistent().remove(&id);
}

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Publish an event indicating a signer has been removed
env.events()
.publish((EVENT_TAG, symbol_short!("remove"), id), ());

// Return Ok indicating success
Ok(())
}

// Method to update the contract with new WASM code
pub fn update(env: Env, hash: BytesN<32>) -> Result<(), Error> {
// Require authentication from the current contract address
env.current_contract_address().require_auth();

// Update the contract's WASM code with the new hash
env.deployer().update_current_contract_wasm(hash);

// Get the maximum time-to-live (TTL) for the storage entries
let max_ttl = env.storage().max_ttl();

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

// Return Ok indicating success
Ok(())
}

// Helper method to update the count of admin signers
fn update_admin_signer_count(env: &Env, add: bool) {
// Get the current count of admin signers from instance storage, defaulting to 0
let count = env
.storage()
.instance()
.get::<Symbol, i32>(&ADMIN_SIGNER_COUNT)
.unwrap_or(0)
+ if add { 1 } else { -1 };

// If the count is less than or equal to 0, trigger an error
if count <= 0 {
panic_with_error!(env, Error::NotPermitted)
}

// Update the admin signer count in instance storage
env.storage()
.instance()
.set::<Symbol, i32>(&ADMIN_SIGNER_COUNT, &count);
}
}

// Implementing the core authentication logic for the WebAuthn wallet
#[contractimpl]
impl CustomAccountInterface for Contract {
// Defining the error and signature types for the trait
type Error = Error;
type Signature = Signature;

#[allow(non_snake_case)]
fn __check_auth(
env: Env, // The environment context
signature_payload: Hash<32>, // The payload that needs to be signed
signature: Signature, // The signature provided by the client
auth_contexts: Vec<Context>, // Contexts for authentication
) -> Result<(), Error> {
// Destructure the signature into its components
let Signature {
id,
mut authenticator_data,
client_data_json,
signature,
} = signature;

// Get the maximum time-to-live (TTL) for storage entries
let max_ttl = env.storage().max_ttl();

// Try to retrieve the public key associated with the id from temporary storage
let pk = match env.storage().temporary().get(&id) {
Some(pk) => {
// Check if a session signer is trying to perform protected actions
for context in auth_contexts.iter() {
match context {
// If the context is a contract call
Context::Contract(c) => {
// Ensure that the current contract is not performing restricted actions
if c.contract == env.current_contract_address()
&& (c.fn_name != symbol_short!("remove")
|| (c.fn_name == symbol_short!("remove")
&& Bytes::from_val(&env, &c.args.get(0).unwrap()) != id))
{
return Err(Error::NotPermitted);
}
}
_ => {} // Allow other contexts (e.g., deploying new contracts)
};
}

// Extend the TTL for the temporary storage entry
env.storage()
.temporary()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);

pk // Return the public key
}
// If not found in temporary storage, try persistent storage
None => {
env.storage()
.persistent()
.extend_ttl(&id, max_ttl - WEEK_OF_LEDGERS, max_ttl);

env.storage().persistent().get(&id).ok_or(Error::NotFound)?
}
};

// Extend the authenticator data with the SHA-256 hash of the client data JSON
authenticator_data.extend_from_array(&env.crypto().sha256(&client_data_json).to_array());

// Verify the signature using the secp256r1 elliptic curve algorithm
env.crypto()
.secp256r1_verify(&pk, &env.crypto().sha256(&authenticator_data), &signature);

// Parse the client data JSON, extracting the base64 URL encoded challenge
let client_data_json = client_data_json.to_buffer::<1024>();
let client_data_json = client_data_json.as_slice();
let (client_data_json, _): (ClientDataJson, _) =
serde_json_core::de::from_slice(client_data_json).map_err(|_| Error::JsonParseError)?;

// Build the expected challenge from the signature payload
let mut expected_challenge = [0u8; 43];
base64_url::encode(&mut expected_challenge, &signature_payload.to_array());

// Check that the challenge inside the client data JSON matches the expected challenge
if client_data_json.challenge.as_bytes() != expected_challenge {
return Err(Error::ClientDataJsonChallengeIncorrect);
}

// Extend the TTL for the instance storage
env.storage()
.instance()
.extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl);

Ok(()) // Return success
}
}

// Define the Base64 URL alphabet as a constant byte array.
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

// The `encode` function takes a mutable reference to a destination byte array `dst` and a source byte array `src`.
pub fn encode(dst: &mut [u8], src: &[u8]) {
// Initialize destination index `di` and source index `si` to 0.
let mut di: usize = 0;
let mut si: usize = 0;
// Calculate the length of the source array that is a multiple of 3.
let n = (src.len() / 3) * 3;

// Process the source array in chunks of 3 bytes.
while si < n {
// Combine 3 bytes into a single 24-bit value.
let val = (src[si] as usize) << 16 | (src[si + 1] as usize) << 8 | (src[si + 2] as usize);
// Encode the 24-bit value into 4 Base64 characters.
dst[di] = ALPHABET[val >> 18 & 0x3F];
dst[di + 1] = ALPHABET[val >> 12 & 0x3F];
dst[di + 2] = ALPHABET[val >> 6 & 0x3F];
dst[di + 3] = ALPHABET[val & 0x3F];
// Increment the source index by 3 and the destination index by 4.
si += 3;
di += 4;
}

// Calculate the remaining number of bytes in the source array.
let remain = src.len() - si;

// If there are no remaining bytes, return early.
if remain == 0 {
return;
}

// Initialize a 24-bit value with the remaining byte(s).
let mut val = (src[si] as usize) << 16;

// If there are 2 remaining bytes, add the second byte to the 24-bit value.
if remain == 2 {
val |= (src[si + 1] as usize) << 8;
}

// Encode the remaining bytes into 2 or 3 Base64 characters.
dst[di] = ALPHABET[val >> 18 & 0x3F];
dst[di + 1] = ALPHABET[val >> 12 & 0x3F];

// If there are 2 remaining bytes, encode the third Base64 character.
if remain == 2 {
dst[di + 2] = ALPHABET[val >> 6 & 0x3F];
}
}

#[cfg(test)]
mod test {
use std::println;
extern crate std;

use soroban_sdk::{
vec,
Bytes,
BytesN,
Env,
IntoVal,
};

use crate::{Contract, ContractClient, Error, Signature};

#[test]
fn test() {
let env = Env::default();
let deployee_address = env.register_contract(None, Contract);
let deployee_client = ContractClient::new(&env, &deployee_address);

// Test data
let id = Bytes::from_array(
&env,
&[243, 248, 216, 74, 226, 218, 85, 102, 196, 167, 14, 151, 124, 42, 73, 136, 138, 102, 187, 140],
);
let pk = BytesN::from_array(
&env,
&[4, 163, 142, 245, 242, 113, 55, 104, 189, 52, 128, 238, 206, 174, 194, 177, 4, 100,
161, 243, 177, 255, 10, 53, 57, 194, 205, 45, 208, 10, 131, 167, 93, 44, 123, 126, 95,
219, 207, 230, 175, 90, 96, 41, 121, 197, 127, 180, 74, 236, 160, 0, 60, 185, 211, 174,
133, 215, 200, 208, 230, 51, 210, 94, 214],
);

// Test adding a signer
deployee_client.add(&id, &pk, &true);

// Test authentication
let signature_payload = BytesN::from_array(
&env,
&[150, 22, 248, 96, 91, 4, 111, 72, 170, 101, 57, 225, 210, 199, 91, 29, 159, 227, 209,
6, 231, 63, 222, 209, 232, 57, 112, 98, 140, 118, 206, 245],
);

let signature = Signature {
authenticator_data: Bytes::from_array(
&env,
&[75, 74, 206, 229, 181, 139, 119, 89, 254, 159, 95, 149, 227, 164, 109, 143, 188,
228, 143, 219, 181, 216, 77, 123, 142, 172, 60, 20, 162, 154, 181, 187, 29, 0, 0, 0, 0],
),
client_data_json: Bytes::from_array(
&env,
&[123, 34, 116, 121, 112, 101, 34, 58, 34, 119, 101, 98, 97, 117, 116, 104, 110, 46,
103, 101, 116, 34, 44, 34, 99, 104, 97, 108, 108, 101, 110, 103, 101, 34, 58, 34,
108, 104, 98, 52, 89, 70, 115, 69, 98, 48, 105, 113, 90, 84, 110, 104, 48, 115,
100, 98, 72, 90, 95, 106, 48, 81, 98, 110, 80, 57, 55, 82, 54, 68, 108, 119, 89,
111, 120, 50, 122, 118, 85, 34, 44, 34, 111, 114, 105, 103, 105, 110, 34, 58, 34,
104, 116, 116, 112, 115, 58, 47, 47, 112, 97, 115, 115, 107, 101, 121, 45, 107,
105, 116, 45, 100, 101, 109, 111, 46, 112, 97, 103, 101, 115, 46, 100, 101, 118, 34, 125],
),
id: id.clone(),
signature: BytesN::from_array(
&env,
&[74, 48, 29, 120, 181, 135, 255, 178, 105, 76, 82, 118, 29, 135, 193, 72, 123, 144,
138, 214, 125, 27, 33, 159, 169, 200, 151, 55, 7, 250, 111, 172, 86, 89, 162, 167,
148, 105, 144, 68, 21, 249, 61, 253, 80, 61, 54, 29, 14, 162, 12, 173, 206, 194,
144, 227, 11, 225, 74, 254, 191, 221, 103, 86],
),
};

let result: Result<(), Result<Error, _>> = env.try_invoke_contract_check_auth(
&deployee_address,
&signature_payload,
signature.into_val(&env),
&vec![&env],
);

println!("{:?}", result);
assert!(result.is_ok());

}
}