From 922c9c3a7715c44278c029bf6487a5fad516fd7a Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Fri, 20 Feb 2026 16:15:07 -0500 Subject: [PATCH 1/6] Add SIWS is_valid_signature instruction and ABNF tests --- Cargo.lock | 1 + interface/Cargo.toml | 1 + interface/src/lib.rs | 315 ++++++++++++++++ program/idl.json | 34 ++ program/src/actions/is_valid_signature.rs | 352 ++++++++++++++++++ .../src/actions/is_valid_signature_abnf.rs | 239 ++++++++++++ program/src/actions/mod.rs | 25 +- program/src/instruction.rs | 19 + program/tests/is_valid_signature_test.rs | 305 +++++++++++++++ 9 files changed, 1287 insertions(+), 4 deletions(-) create mode 100644 program/src/actions/is_valid_signature.rs create mode 100644 program/src/actions/is_valid_signature_abnf.rs create mode 100644 program/tests/is_valid_signature_test.rs diff --git a/Cargo.lock b/Cargo.lock index 7b271930..482162c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8928,6 +8928,7 @@ version = "1.4.0" dependencies = [ "anyhow", "bytemuck", + "serde", "solana-sdk", "solana-secp256r1-program", "swig", diff --git a/interface/Cargo.toml b/interface/Cargo.toml index a14ab477..0823578a 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -20,3 +20,4 @@ swig-compact-instructions = { path = "../instructions", default-features = false swig-state = { path = "../state" } anyhow = "1.0.75" solana-secp256r1-program = "2.2.1" +serde = { version = "1.0.217", features = ["derive"] } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 9496296d..38b42dd1 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use solana_sdk::{ hash as sha256, instruction::{AccountMeta, Instruction}, @@ -14,6 +15,7 @@ use swig::actions::{ create_session_v1::CreateSessionV1Args, create_sub_account_v1::CreateSubAccountV1Args, create_v1::CreateV1Args, + is_valid_signature::IsValidSignatureArgs, remove_authority_v1::RemoveAuthorityV1Args, sub_account_sign_v1::SubAccountSignV1Args, toggle_sub_account_v1::ToggleSubAccountV1Args, @@ -43,6 +45,138 @@ use swig_state::{ IntoBytes, Transmutable, }; +/// SIWS challenge payload for Swig auth. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SiwsChallengeV1 { + /// RFC 4501 DNS authority requesting the signing. + pub domain: String, + /// Base58 swig_wallet_address PDA that is being authenticated. + pub address: String, + /// Human-readable intent. + pub statement: Option, + /// RFC 3986 URI subject of the signing. + pub uri: String, + /// SIWS version, typically "1". + pub version: String, + /// SIWS chain identifier. + pub chain_id: Option, + /// Replay protection nonce. + pub nonce: String, + /// ISO 8601 timestamp. + pub issued_at: String, + /// Optional ISO 8601 expiry. + pub expiration_time: Option, + /// Optional ISO 8601 not-before. + pub not_before: Option, + /// Optional request identifier. + pub request_id: Option, + /// SIWS resources carrying Swig context (swig, role_id, scopes, etc). + pub resources: Vec, +} + +impl SiwsChallengeV1 { + pub fn to_message_string(&self) -> anyhow::Result { + if self.domain.is_empty() { + return Err(anyhow::anyhow!("SIWS domain cannot be empty")); + } + if self.address.is_empty() { + return Err(anyhow::anyhow!("SIWS address cannot be empty")); + } + + let mut message = format!( + "{} wants you to sign in with your Solana account:\n{}", + self.domain, self.address + ); + + let mut advanced_fields = Vec::new(); + advanced_fields.push(format!("URI: {}", self.uri)); + advanced_fields.push(format!("Version: {}", self.version)); + if let Some(chain_id) = self.chain_id.as_ref() { + advanced_fields.push(format!("Chain ID: {}", chain_id)); + } + advanced_fields.push(format!("Nonce: {}", self.nonce)); + advanced_fields.push(format!("Issued At: {}", self.issued_at)); + if let Some(expiration_time) = self.expiration_time.as_ref() { + advanced_fields.push(format!("Expiration Time: {}", expiration_time)); + } + if let Some(not_before) = self.not_before.as_ref() { + advanced_fields.push(format!("Not Before: {}", not_before)); + } + if let Some(request_id) = self.request_id.as_ref() { + advanced_fields.push(format!("Request ID: {}", request_id)); + } + if !self.resources.is_empty() { + let mut resources_field = String::from("Resources:"); + for resource in &self.resources { + resources_field.push_str("\n- "); + resources_field.push_str(resource); + } + advanced_fields.push(resources_field); + } + + if self.statement.is_some() || !advanced_fields.is_empty() { + message.push_str("\n\n"); + } + if let Some(statement) = self.statement.as_ref() { + message.push_str(statement); + if !advanced_fields.is_empty() { + message.push_str("\n\n"); + } + } + if !advanced_fields.is_empty() { + message.push_str(&advanced_fields.join("\n")); + } + + Ok(message) + } + + pub fn to_message_bytes(&self) -> anyhow::Result> { + Ok(self.to_message_string()?.into_bytes()) + } + + pub fn to_json_bytes(&self) -> anyhow::Result> { + self.to_message_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::SiwsChallengeV1; + + #[test] + fn serializes_abnf_siws_message() { + let challenge = SiwsChallengeV1 { + domain: "example.com".to_string(), + address: "3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS".to_string(), + statement: Some("Sign in to Swig".to_string()), + uri: "https://example.com/login".to_string(), + version: "1".to_string(), + chain_id: Some("solana:devnet".to_string()), + nonce: "abc123ef".to_string(), + issued_at: "2026-01-01T00:00:00Z".to_string(), + expiration_time: None, + not_before: None, + request_id: None, + resources: vec![ + "urn:swig:v1:swig:swig123".to_string(), + "urn:swig:v1:role_id:1".to_string(), + "urn:swig:v1:scope:ProgramScope".to_string(), + ], + }; + + let message = challenge.to_message_string().unwrap(); + assert!(message.starts_with( + "example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nSign in to Swig\n\nURI: https://example.com/login" + )); + assert!(message.contains("\nVersion: 1")); + assert!(message.contains("\nChain ID: solana:devnet")); + assert!(message.contains("\nNonce: abc123ef")); + assert!(message.contains("\nIssued At: 2026-01-01T00:00:00Z")); + assert!(message.contains("\nResources:\n- urn:swig:v1:swig:swig123")); + } +} + pub enum ClientAction { TokenLimit(TokenLimit), TokenDestinationLimit(TokenDestinationLimit), @@ -937,6 +1071,187 @@ impl SignV2Instruction { } } +pub struct IsValidSignatureInstruction; +impl IsValidSignatureInstruction { + pub fn new_with_ed25519_authority( + swig_account: Pubkey, + swig_wallet_address: Pubkey, + authority: Pubkey, + role_id: u32, + challenge: &SiwsChallengeV1, + ) -> anyhow::Result { + let challenge_bytes = challenge.to_message_bytes()?; + let args = IsValidSignatureArgs::new(role_id, challenge_bytes.len() as u16); + let arg_bytes = args + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; + + Ok(Instruction { + program_id: Pubkey::from(swig::ID), + accounts: vec![ + AccountMeta::new(swig_account, false), + AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(authority, true), + ], + data: [arg_bytes, &challenge_bytes, &[2]].concat(), + }) + } + + pub fn new_with_secp256k1_authority( + swig_account: Pubkey, + swig_wallet_address: Pubkey, + mut authority_payload_fn: F, + current_slot: u64, + counter: u32, + role_id: u32, + challenge: &SiwsChallengeV1, + ) -> anyhow::Result + where + F: FnMut(&[u8]) -> [u8; 65], + { + let challenge_bytes = challenge.to_message_bytes()?; + let args = IsValidSignatureArgs::new(role_id, challenge_bytes.len() as u16); + let arg_bytes = args + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; + + let accounts = vec![ + AccountMeta::new(swig_account, false), + AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(system_program::ID, false), + ]; + + let mut account_payload_bytes = Vec::new(); + for account in &accounts { + account_payload_bytes.extend_from_slice( + accounts_payload_from_meta(account) + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize account meta {:?}", e))?, + ); + } + + let nonced_payload = prepare_secp256k1_payload( + current_slot, + counter, + &challenge_bytes, + &account_payload_bytes, + &[], + ); + let signature = authority_payload_fn(&nonced_payload); + let mut authority_payload = Vec::new(); + authority_payload.extend_from_slice(¤t_slot.to_le_bytes()); + authority_payload.extend_from_slice(&counter.to_le_bytes()); + authority_payload.extend_from_slice(&signature); + + Ok(Instruction { + program_id: Pubkey::from(swig::ID), + accounts, + data: [arg_bytes, &challenge_bytes, &authority_payload].concat(), + }) + } + + pub fn new_with_secp256r1_authority( + swig_account: Pubkey, + swig_wallet_address: Pubkey, + mut authority_payload_fn: F, + current_slot: u64, + counter: u32, + role_id: u32, + challenge: &SiwsChallengeV1, + public_key: &[u8; 33], + ) -> anyhow::Result> + where + F: FnMut(&[u8]) -> [u8; 64], + { + let challenge_bytes = challenge.to_message_bytes()?; + let args = IsValidSignatureArgs::new(role_id, challenge_bytes.len() as u16); + let arg_bytes = args + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; + + let accounts = vec![ + AccountMeta::new(swig_account, false), + AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(system_program::ID, false), + AccountMeta::new_readonly(solana_sdk::sysvar::instructions::ID, false), + ]; + + let mut account_payload_bytes = Vec::new(); + for account in &accounts { + account_payload_bytes.extend_from_slice( + accounts_payload_from_meta(account) + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize account meta {:?}", e))?, + ); + } + + let slot_bytes = current_slot.to_le_bytes(); + let counter_bytes = counter.to_le_bytes(); + let message_hash = keccak::hash( + &[ + &challenge_bytes, + &account_payload_bytes, + &slot_bytes[..], + &counter_bytes[..], + ] + .concat(), + ) + .to_bytes(); + let signature = authority_payload_fn(&message_hash); + + let secp256r1_verify_ix = + new_secp256r1_instruction_with_signature(&message_hash, &signature, public_key); + + let mut authority_payload = Vec::new(); + authority_payload.extend_from_slice(¤t_slot.to_le_bytes()); + authority_payload.extend_from_slice(&counter.to_le_bytes()); + authority_payload.push(3); // instructions sysvar index + authority_payload.extend_from_slice(&[0u8; 4]); // minimum payload length for secp256r1 auth + + let main_ix = Instruction { + program_id: Pubkey::from(swig::ID), + accounts, + data: [arg_bytes, &challenge_bytes, &authority_payload].concat(), + }; + + Ok(vec![secp256r1_verify_ix, main_ix]) + } + + pub fn new_with_program_exec_authority( + swig_account: Pubkey, + swig_wallet_address: Pubkey, + payer: Pubkey, + preceding_instruction: Instruction, + role_id: u32, + challenge: &SiwsChallengeV1, + ) -> anyhow::Result> { + use solana_sdk::sysvar::instructions::ID as INSTRUCTIONS_ID; + + let challenge_bytes = challenge.to_message_bytes()?; + let args = IsValidSignatureArgs::new(role_id, challenge_bytes.len() as u16); + let arg_bytes = args + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; + + let mut accounts = vec![ + AccountMeta::new(swig_account, false), + AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(payer, true), + ]; + let instruction_sysvar_index = accounts.len() as u8; + accounts.push(AccountMeta::new_readonly(INSTRUCTIONS_ID, false)); + + let authority_payload = vec![instruction_sysvar_index]; + let main_ix = Instruction { + program_id: Pubkey::from(swig::ID), + accounts, + data: [arg_bytes, &challenge_bytes, &authority_payload].concat(), + }; + + Ok(vec![preceding_instruction, main_ix]) + } +} + pub struct RemoveAuthorityInstruction; impl RemoveAuthorityInstruction { pub fn new_with_ed25519_authority( diff --git a/program/idl.json b/program/idl.json index 5fff7d6d..bd458844 100644 --- a/program/idl.json +++ b/program/idl.json @@ -575,6 +575,40 @@ "type": "u8", "value": 15 } + }, + { + "name": "IsValidSignature", + "accounts": [ + { + "name": "swig", + "isMut": true, + "isSigner": false, + "docs": [ + "the swig smart wallet" + ] + }, + { + "name": "swigWalletAddress", + "isMut": true, + "isSigner": false, + "docs": [ + "the swig wallet address PDA" + ] + }, + { + "name": "authorityContext", + "isMut": false, + "isSigner": false, + "docs": [ + "authority context account or placeholder" + ] + } + ], + "args": [], + "discriminant": { + "type": "u8", + "value": 16 + } } ], "metadata": { diff --git a/program/src/actions/is_valid_signature.rs b/program/src/actions/is_valid_signature.rs new file mode 100644 index 00000000..41b667ca --- /dev/null +++ b/program/src/actions/is_valid_signature.rs @@ -0,0 +1,352 @@ +//! SIWS challenge validation for off-chain signature proofs. +//! +//! This instruction is intended to be simulated off-chain (never broadcast). +//! It authenticates a Swig role authority against a SIWS challenge payload and +//! verifies that the role satisfies all requested `urn:swig:v1:scope:*` +//! resources. + +use no_padding::NoPadding; +use pinocchio::{ + account_info::AccountInfo, + program_error::ProgramError, + pubkey::find_program_address, + sysvars::{clock::Clock, Sysvar}, + ProgramResult, +}; +use swig_assertions::{check_self_owned, check_stack_height, check_system_owner}; +use swig_state::{ + action::{Action, Permission}, + swig::{swig_wallet_address_seeds, Swig}, + Discriminator, IntoBytes, SwigAuthenticateError, Transmutable, TransmutableMut, +}; + +use super::is_valid_signature_abnf::parse_siws_challenge; +use crate::{ + error::SwigError, + instruction::{ + accounts::{Context, IsValidSignatureAccounts}, + SwigInstruction, + }, + AccountClassification, +}; + +const URN_SWIG_PREFIX: &str = "urn:swig:v1:swig:"; +const URN_SWIG_WALLET_PREFIX: &str = "urn:swig:v1:swig_wallet_address:"; +const URN_SWIG_PROGRAM_PREFIX: &str = "urn:swig:v1:swig_program:"; +const URN_ROLE_ID_PREFIX: &str = "urn:swig:v1:role_id:"; +const URN_SCOPE_PREFIX: &str = "urn:swig:v1:scope:"; + +#[derive(Debug, NoPadding)] +#[repr(C, align(8))] +pub struct IsValidSignatureArgs { + instruction: SwigInstruction, + pub challenge_len: u16, + pub role_id: u32, +} + +impl IsValidSignatureArgs { + pub fn new(role_id: u32, challenge_len: u16) -> Self { + Self { + instruction: SwigInstruction::IsValidSignature, + challenge_len, + role_id, + } + } +} + +impl Transmutable for IsValidSignatureArgs { + const LEN: usize = core::mem::size_of::(); +} + +impl IntoBytes for IsValidSignatureArgs { + fn into_bytes(&self) -> Result<&[u8], ProgramError> { + Ok(unsafe { core::slice::from_raw_parts(self as *const Self as *const u8, Self::LEN) }) + } +} + +pub struct IsValidSignature<'a> { + pub args: &'a IsValidSignatureArgs, + pub challenge_payload: &'a [u8], + pub authority_payload: &'a [u8], +} + +impl<'a> IsValidSignature<'a> { + pub fn from_instruction_bytes(data: &'a [u8]) -> Result { + if data.len() < IsValidSignatureArgs::LEN { + return Err(SwigError::InvalidInstructionDataTooShort.into()); + } + + let (args_data, rest) = unsafe { data.split_at_unchecked(IsValidSignatureArgs::LEN) }; + let args = unsafe { IsValidSignatureArgs::load_unchecked(args_data)? }; + if rest.len() < args.challenge_len as usize { + return Err(SwigError::InvalidInstructionDataTooShort.into()); + } + + let (challenge_payload, authority_payload) = + unsafe { rest.split_at_unchecked(args.challenge_len as usize) }; + + Ok(Self { + args, + challenge_payload, + authority_payload, + }) + } +} + +#[inline(never)] +pub fn is_valid_signature( + ctx: Context, + all_accounts: &[AccountInfo], + data: &[u8], + account_classifiers: &[AccountClassification], +) -> ProgramResult { + check_stack_height(1, SwigError::Cpi)?; + check_self_owned(ctx.accounts.swig, SwigError::OwnerMismatchSwigAccount)?; + check_system_owner( + ctx.accounts.swig_wallet_address, + SwigError::OwnerMismatchSwigAccount, + )?; + + if account_classifiers.len() < 2 { + return Err(ProgramError::NotEnoughAccountKeys); + } + + if !matches!( + account_classifiers[0], + AccountClassification::ThisSwigV2 { .. } + ) || !matches!( + account_classifiers[1], + AccountClassification::SwigWalletAddress + ) { + return Err(SwigError::InvalidSwigAccountDiscriminator.into()); + } + + let validate = IsValidSignature::from_instruction_bytes(data)?; + let parsed_challenge = parse_siws_challenge(validate.challenge_payload)?; + + let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + if unsafe { *swig_account_data.get_unchecked(0) } != Discriminator::SwigConfigAccount as u8 { + return Err(SwigError::InvalidSwigAccountDiscriminator.into()); + } + + let (swig_header, swig_roles) = unsafe { swig_account_data.split_at_mut_unchecked(Swig::LEN) }; + let swig = unsafe { Swig::load_mut_unchecked(swig_header)? }; + + let swig_wallet_seeds = swig_wallet_address_seeds(ctx.accounts.swig.key().as_ref()); + let (derived_wallet, _) = find_program_address(&swig_wallet_seeds, &crate::ID); + if derived_wallet != *ctx.accounts.swig_wallet_address.key() { + return Err(SwigError::InvalidSeedSwigAccount.into()); + } + + let role = Swig::get_mut_role(validate.args.role_id, swig_roles)?; + if role.is_none() { + return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); + } + let role = role.unwrap(); + + let clock = Clock::get()?; + let slot = clock.slot; + if role.authority.session_based() { + role.authority.authenticate_session( + all_accounts, + validate.authority_payload, + validate.challenge_payload, + slot, + )?; + } else { + role.authority.authenticate( + all_accounts, + validate.authority_payload, + validate.challenge_payload, + slot, + )?; + } + + let expected_swig = bs58::encode(ctx.accounts.swig.key().as_ref()).into_string(); + let expected_wallet = + bs58::encode(ctx.accounts.swig_wallet_address.key().as_ref()).into_string(); + let expected_program = bs58::encode(crate::ID.as_ref()).into_string(); + + if parsed_challenge.address != expected_wallet { + return Err(SwigAuthenticateError::PermissionDenied.into()); + } + + let parsed_resources = parse_resources(&parsed_challenge.resources)?; + if parsed_resources.swig != Some(expected_swig.as_str()) + || parsed_resources.swig_wallet_address != Some(expected_wallet.as_str()) + || parsed_resources.swig_program != Some(expected_program.as_str()) + || parsed_resources.role_id != Some(validate.args.role_id) + { + return Err(SwigAuthenticateError::PermissionDenied.into()); + } + + let permission_bitmap = collect_permission_bitmap(role.actions)?; + for scope in parsed_resources.scopes { + if !scope_is_allowed(scope, &permission_bitmap) { + return Err(SwigAuthenticateError::PermissionDeniedMissingPermission.into()); + } + } + + Ok(()) +} + +struct ParsedResources<'a> { + swig: Option<&'a str>, + swig_wallet_address: Option<&'a str>, + swig_program: Option<&'a str>, + role_id: Option, + scopes: Vec<&'a str>, +} + +fn parse_resources<'a>(resources: &[&'a str]) -> Result, ProgramError> { + let mut parsed = ParsedResources { + swig: None, + swig_wallet_address: None, + swig_program: None, + role_id: None, + scopes: Vec::new(), + }; + + for resource in resources { + if let Some(value) = resource.strip_prefix(URN_SWIG_PREFIX) { + if parsed.swig.is_some() { + return Err(ProgramError::InvalidInstructionData); + } + parsed.swig = Some(value); + continue; + } + if let Some(value) = resource.strip_prefix(URN_SWIG_WALLET_PREFIX) { + if parsed.swig_wallet_address.is_some() { + return Err(ProgramError::InvalidInstructionData); + } + parsed.swig_wallet_address = Some(value); + continue; + } + if let Some(value) = resource.strip_prefix(URN_SWIG_PROGRAM_PREFIX) { + if parsed.swig_program.is_some() { + return Err(ProgramError::InvalidInstructionData); + } + parsed.swig_program = Some(value); + continue; + } + if let Some(value) = resource.strip_prefix(URN_ROLE_ID_PREFIX) { + if parsed.role_id.is_some() { + return Err(ProgramError::InvalidInstructionData); + } + parsed.role_id = Some( + value + .parse::() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ); + continue; + } + if let Some(scope) = resource.strip_prefix(URN_SCOPE_PREFIX) { + parsed.scopes.push(scope); + } + } + + Ok(parsed) +} + +fn collect_permission_bitmap(actions: &[u8]) -> Result<[bool; 21], ProgramError> { + let mut bitmap = [false; 21]; + let mut cursor = 0usize; + + while cursor < actions.len() { + if cursor + Action::LEN > actions.len() { + return Err(ProgramError::InvalidAccountData); + } + let action = unsafe { Action::load_unchecked(&actions[cursor..cursor + Action::LEN])? }; + let permission = action.permission()?; + let permission_index = permission as usize; + if permission_index < bitmap.len() { + bitmap[permission_index] = true; + } + let boundary = action.boundary() as usize; + if boundary <= cursor || boundary > actions.len() { + return Err(ProgramError::InvalidAccountData); + } + cursor = boundary; + } + + Ok(bitmap) +} + +fn scope_is_allowed(scope: &str, permissions: &[bool; 21]) -> bool { + let has_all = permissions[Permission::All as usize]; + if has_all { + return true; + } + + let has_all_but_manage = permissions[Permission::AllButManageAuthority as usize]; + if has_all_but_manage { + return !matches!(scope, "ManageAuthority" | "SubAccount"); + } + + match scope { + "None" => true, + "SolLimit" => permissions[Permission::SolLimit as usize], + "SolRecurringLimit" => permissions[Permission::SolRecurringLimit as usize], + "Program" => { + permissions[Permission::Program as usize] + || permissions[Permission::ProgramCurated as usize] + || permissions[Permission::ProgramAll as usize] + }, + "ProgramScope" => permissions[Permission::ProgramScope as usize], + "TokenLimit" => permissions[Permission::TokenLimit as usize], + "TokenRecurringLimit" => permissions[Permission::TokenRecurringLimit as usize], + "All" => permissions[Permission::All as usize], + "ManageAuthority" => permissions[Permission::ManageAuthority as usize], + "SubAccount" => permissions[Permission::SubAccount as usize], + "StakeLimit" => permissions[Permission::StakeLimit as usize], + "StakeRecurringLimit" => permissions[Permission::StakeRecurringLimit as usize], + "StakeAll" => permissions[Permission::StakeAll as usize], + "ProgramAll" => permissions[Permission::ProgramAll as usize], + "ProgramCurated" => { + permissions[Permission::ProgramCurated as usize] + || permissions[Permission::ProgramAll as usize] + }, + "AllButManageAuthority" => permissions[Permission::AllButManageAuthority as usize], + "SolDestinationLimit" => permissions[Permission::SolDestinationLimit as usize], + "SolRecurringDestinationLimit" => { + permissions[Permission::SolRecurringDestinationLimit as usize] + }, + "TokenDestinationLimit" => permissions[Permission::TokenDestinationLimit as usize], + "TokenRecurringDestinationLimit" => { + permissions[Permission::TokenRecurringDestinationLimit as usize] + }, + "CloseSwigAuthority" => permissions[Permission::CloseSwigAuthority as usize], + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::{parse_resources, scope_is_allowed}; + + #[test] + fn parses_swig_resource_context() { + let resources = vec![ + "urn:swig:v1:swig:swig123", + "urn:swig:v1:swig_wallet_address:wallet123", + "urn:swig:v1:swig_program:program123", + "urn:swig:v1:role_id:7", + "urn:swig:v1:scope:TokenLimit", + ]; + let parsed = parse_resources(&resources).unwrap(); + assert_eq!(parsed.swig, Some("swig123")); + assert_eq!(parsed.swig_wallet_address, Some("wallet123")); + assert_eq!(parsed.swig_program, Some("program123")); + assert_eq!(parsed.role_id, Some(7)); + assert_eq!(parsed.scopes, vec!["TokenLimit"]); + } + + #[test] + fn all_but_manage_does_not_allow_manage_or_subaccount_scope() { + let mut bitmap = [false; 21]; + bitmap[15] = true; // AllButManageAuthority + assert!(scope_is_allowed("TokenLimit", &bitmap)); + assert!(!scope_is_allowed("ManageAuthority", &bitmap)); + assert!(!scope_is_allowed("SubAccount", &bitmap)); + } +} diff --git a/program/src/actions/is_valid_signature_abnf.rs b/program/src/actions/is_valid_signature_abnf.rs new file mode 100644 index 00000000..03f8f8f1 --- /dev/null +++ b/program/src/actions/is_valid_signature_abnf.rs @@ -0,0 +1,239 @@ +use pinocchio::program_error::ProgramError; + +const SIWS_HEADER_SUFFIX: &str = " wants you to sign in with your Solana account:"; +const FIELD_URI_PREFIX: &str = "URI: "; +const FIELD_VERSION_PREFIX: &str = "Version: "; +const FIELD_CHAIN_ID_PREFIX: &str = "Chain ID: "; +const FIELD_NONCE_PREFIX: &str = "Nonce: "; +const FIELD_ISSUED_AT_PREFIX: &str = "Issued At: "; +const FIELD_EXPIRATION_TIME_PREFIX: &str = "Expiration Time: "; +const FIELD_NOT_BEFORE_PREFIX: &str = "Not Before: "; +const FIELD_REQUEST_ID_PREFIX: &str = "Request ID: "; +const FIELD_RESOURCES: &str = "Resources:"; + +pub(super) struct ParsedSiwsChallenge<'a> { + pub(super) address: &'a str, + pub(super) resources: Vec<&'a str>, +} + +pub(super) fn parse_siws_challenge( + challenge: &[u8], +) -> Result, ProgramError> { + let challenge_str = + core::str::from_utf8(challenge).map_err(|_| ProgramError::InvalidInstructionData)?; + let mut lines: Vec<&str> = challenge_str + .split('\n') + .map(trim_optional_carriage_return) + .collect(); + while matches!(lines.last(), Some(line) if line.is_empty()) { + lines.pop(); + } + + if lines.len() < 2 { + return Err(ProgramError::InvalidInstructionData); + } + + let Some(domain) = lines[0].strip_suffix(SIWS_HEADER_SUFFIX) else { + return Err(ProgramError::InvalidInstructionData); + }; + if domain.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + + let address = lines[1]; + if !is_valid_solana_address(address) { + return Err(ProgramError::InvalidInstructionData); + } + + let mut cursor = 2usize; + let mut resources = Vec::new(); + if cursor < lines.len() { + if !lines[cursor].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + cursor += 1; + + if cursor < lines.len() && !is_advanced_field_start(lines[cursor]) { + if lines[cursor].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + cursor += 1; + + if cursor == lines.len() { + return Ok(ParsedSiwsChallenge { address, resources }); + } + + if !lines[cursor].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + cursor += 1; + } + + if cursor < lines.len() { + resources = parse_advanced_fields(&lines[cursor..])?; + } + } + + Ok(ParsedSiwsChallenge { address, resources }) +} + +fn parse_advanced_fields<'a>(lines: &[&'a str]) -> Result, ProgramError> { + let mut resources = Vec::new(); + let mut cursor = 0usize; + let mut min_field_index = 0usize; + + while cursor < lines.len() { + let line = lines[cursor]; + if min_field_index <= 0 && line.starts_with(FIELD_URI_PREFIX) { + if line[FIELD_URI_PREFIX.len()..].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 1; + cursor += 1; + continue; + } + if min_field_index <= 1 && line.starts_with(FIELD_VERSION_PREFIX) { + if &line[FIELD_VERSION_PREFIX.len()..] != "1" { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 2; + cursor += 1; + continue; + } + if min_field_index <= 2 && line.starts_with(FIELD_CHAIN_ID_PREFIX) { + let chain_id = &line[FIELD_CHAIN_ID_PREFIX.len()..]; + if !is_valid_chain_id(chain_id) { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 3; + cursor += 1; + continue; + } + if min_field_index <= 3 && line.starts_with(FIELD_NONCE_PREFIX) { + let nonce = &line[FIELD_NONCE_PREFIX.len()..]; + if !is_valid_nonce(nonce) { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 4; + cursor += 1; + continue; + } + if min_field_index <= 4 && line.starts_with(FIELD_ISSUED_AT_PREFIX) { + if line[FIELD_ISSUED_AT_PREFIX.len()..].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 5; + cursor += 1; + continue; + } + if min_field_index <= 5 && line.starts_with(FIELD_EXPIRATION_TIME_PREFIX) { + if line[FIELD_EXPIRATION_TIME_PREFIX.len()..].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 6; + cursor += 1; + continue; + } + if min_field_index <= 6 && line.starts_with(FIELD_NOT_BEFORE_PREFIX) { + if line[FIELD_NOT_BEFORE_PREFIX.len()..].is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + min_field_index = 7; + cursor += 1; + continue; + } + if min_field_index <= 7 && line.starts_with(FIELD_REQUEST_ID_PREFIX) { + min_field_index = 8; + cursor += 1; + continue; + } + if min_field_index <= 8 && line == FIELD_RESOURCES { + cursor += 1; + while cursor < lines.len() { + let Some(resource) = lines[cursor].strip_prefix("- ") else { + return Err(ProgramError::InvalidInstructionData); + }; + if resource.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + resources.push(resource); + cursor += 1; + } + return Ok(resources); + } + + return Err(ProgramError::InvalidInstructionData); + } + + Ok(resources) +} + +#[inline(always)] +fn trim_optional_carriage_return(line: &str) -> &str { + line.strip_suffix('\r').unwrap_or(line) +} + +#[inline(always)] +fn is_advanced_field_start(line: &str) -> bool { + line.starts_with(FIELD_URI_PREFIX) + || line.starts_with(FIELD_VERSION_PREFIX) + || line.starts_with(FIELD_CHAIN_ID_PREFIX) + || line.starts_with(FIELD_NONCE_PREFIX) + || line.starts_with(FIELD_ISSUED_AT_PREFIX) + || line.starts_with(FIELD_EXPIRATION_TIME_PREFIX) + || line.starts_with(FIELD_NOT_BEFORE_PREFIX) + || line.starts_with(FIELD_REQUEST_ID_PREFIX) + || line == FIELD_RESOURCES +} + +#[inline(always)] +fn is_valid_chain_id(chain_id: &str) -> bool { + matches!( + chain_id, + "mainnet" + | "testnet" + | "devnet" + | "localnet" + | "solana:mainnet" + | "solana:testnet" + | "solana:devnet" + ) +} + +#[inline(always)] +fn is_valid_nonce(nonce: &str) -> bool { + nonce.len() >= 8 && nonce.bytes().all(|value| value.is_ascii_alphanumeric()) +} + +#[inline(always)] +fn is_valid_solana_address(address: &str) -> bool { + let len = address.len(); + (32..=44).contains(&len) && address.bytes().all(is_base58_character) +} + +#[inline(always)] +fn is_base58_character(value: u8) -> bool { + matches!( + value, + b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z' + ) +} + +#[cfg(test)] +mod tests { + use super::parse_siws_challenge; + + #[test] + fn parses_challenge_address_and_resources() { + let challenge = b"example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nSign in to Swig\n\nURI: https://example.com\nVersion: 1\nChain ID: solana:devnet\nNonce: abcdef12\nIssued At: 2026-01-01T00:00:00Z\nResources:\n- urn:swig:v1:swig:swig123\n- urn:swig:v1:swig_wallet_address:3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n- urn:swig:v1:swig_program:program123\n- urn:swig:v1:role_id:1\n- urn:swig:v1:scope:ProgramScope"; + let parsed = parse_siws_challenge(challenge).unwrap(); + assert_eq!(parsed.address, "3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS"); + assert_eq!(parsed.resources.len(), 5); + } + + #[test] + fn rejects_out_of_order_advanced_fields() { + let invalid = b"example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nNonce: abcdef12\nVersion: 1"; + assert!(parse_siws_challenge(invalid).is_err()); + } +} diff --git a/program/src/actions/mod.rs b/program/src/actions/mod.rs index cd439b92..f727d82f 100644 --- a/program/src/actions/mod.rs +++ b/program/src/actions/mod.rs @@ -11,6 +11,8 @@ pub mod close_token_account_v1; pub mod create_session_v1; pub mod create_sub_account_v1; pub mod create_v1; +pub mod is_valid_signature; +mod is_valid_signature_abnf; pub mod migrate_to_wallet_address_v1; pub mod remove_authority_v1; pub mod sign_v2; @@ -25,7 +27,7 @@ use pinocchio::{account_info::AccountInfo, msg, program_error::ProgramError, Pro use self::{ add_authority_v1::*, close_swig_v1::*, close_token_account_v1::*, create_session_v1::*, - create_sub_account_v1::*, create_v1::*, migrate_to_wallet_address_v1::*, + create_sub_account_v1::*, create_v1::*, is_valid_signature::*, migrate_to_wallet_address_v1::*, remove_authority_v1::*, sign_v2::*, sub_account_sign_v1::*, toggle_sub_account_v1::*, transfer_assets_v1::*, update_authority_v1::*, withdraw_from_sub_account_v1::*, }; @@ -34,9 +36,9 @@ use crate::{ accounts::{ AddAuthorityV1Accounts, CloseSwigV1Accounts, CloseTokenAccountV1Accounts, CreateSessionV1Accounts, CreateSubAccountV1Accounts, CreateV1Accounts, - MigrateToWalletAddressV1Accounts, RemoveAuthorityV1Accounts, SignV2Accounts, - SubAccountSignV1Accounts, ToggleSubAccountV1Accounts, TransferAssetsV1Accounts, - UpdateAuthorityV1Accounts, WithdrawFromSubAccountV1Accounts, + IsValidSignatureAccounts, MigrateToWalletAddressV1Accounts, RemoveAuthorityV1Accounts, + SignV2Accounts, SubAccountSignV1Accounts, ToggleSubAccountV1Accounts, + TransferAssetsV1Accounts, UpdateAuthorityV1Accounts, WithdrawFromSubAccountV1Accounts, }, SwigInstruction, }, @@ -94,6 +96,9 @@ pub fn process_action( }, SwigInstruction::CloseTokenAccountV1 => process_close_token_account_v1(accounts, data), SwigInstruction::CloseSwigV1 => process_close_swig_v1(accounts, data), + SwigInstruction::IsValidSignature => { + process_is_valid_signature(accounts, account_classification, data) + }, } } @@ -224,3 +229,15 @@ fn process_close_swig_v1(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult let account_ctx = CloseSwigV1Accounts::context(accounts)?; close_swig_v1(account_ctx, accounts, data) } + +/// Processes an IsValidSignature instruction. +/// +/// Validates an SIWS challenge and requested scope resources for a role. +fn process_is_valid_signature( + accounts: &[AccountInfo], + account_classification: &[AccountClassification], + data: &[u8], +) -> ProgramResult { + let account_ctx = IsValidSignatureAccounts::context(accounts)?; + is_valid_signature(account_ctx, accounts, data, account_classification) +} diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 4cf12509..4218c293 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -227,4 +227,23 @@ pub enum SwigInstruction { #[account(2, writable, name="destination", desc="destination for SOL and rent")] #[account(3, name="system_program", desc="the system program")] CloseSwigV1 = 15, + + /// Validates an SIWS challenge and checks requested scope permissions. + /// + /// This instruction is designed for off-chain transaction simulation. + /// It authenticates the role authority against the SIWS challenge payload, + /// validates challenge resource bindings, and ensures all requested + /// `urn:swig:v1:scope:*` resources are allowed by the role. + /// + /// Required accounts: + /// 1. `[writable]` Swig wallet account + /// 2. `[writable]` Swig wallet address PDA + /// 3. Authority context: + /// - Ed25519: authority signer account + /// - Secp256r1: instructions sysvar passed via authority payload index + /// - Secp256k1 / ProgramExec: placeholder or sysvar per authority payload + #[account(0, writable, name="swig", desc="the swig smart wallet")] + #[account(1, writable, name="swig_wallet_address", desc="the swig wallet address PDA")] + #[account(2, name="authority_context", desc="authority context account or placeholder")] + IsValidSignature = 16, } diff --git a/program/tests/is_valid_signature_test.rs b/program/tests/is_valid_signature_test.rs new file mode 100644 index 00000000..28e77955 --- /dev/null +++ b/program/tests/is_valid_signature_test.rs @@ -0,0 +1,305 @@ +#![cfg(not(feature = "program_scope_test"))] + +mod common; +use common::*; +use solana_sdk::{ + instruction::{AccountMeta, Instruction, InstructionError}, + message::{v0, VersionedMessage}, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + transaction::{TransactionError, VersionedTransaction}, +}; +use swig_interface::{AuthorityConfig, ClientAction, IsValidSignatureInstruction, SiwsChallengeV1}; +use swig_state::{ + action::sol_limit::SolLimit, authority::AuthorityType, swig::swig_wallet_address_seeds, + swig::SwigWithRoles, IntoBytes, +}; + +fn role_id_for_authority( + context: &Context, + swig_pubkey: &Pubkey, + authority: &Pubkey, +) -> anyhow::Result { + let swig_account = context + .svm + .get_account(swig_pubkey) + .ok_or(anyhow::anyhow!("Swig account not found"))?; + let swig = SwigWithRoles::from_bytes(&swig_account.data) + .map_err(|e| anyhow::anyhow!("Failed to deserialize swig {:?}", e))?; + let role_id = swig + .lookup_role_id(authority.as_ref()) + .map_err(|e| anyhow::anyhow!("Failed to lookup role id {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found for authority"))?; + Ok(role_id) +} + +fn build_challenge( + swig: Pubkey, + swig_wallet_address: Pubkey, + role_id: u32, + scopes: &[&str], +) -> SiwsChallengeV1 { + let mut resources = vec![ + format!("urn:swig:v1:swig:{swig}"), + format!("urn:swig:v1:swig_wallet_address:{swig_wallet_address}"), + format!("urn:swig:v1:swig_program:{}", program_id()), + format!("urn:swig:v1:role_id:{role_id}"), + ]; + resources.extend( + scopes + .iter() + .map(|scope| format!("urn:swig:v1:scope:{scope}")), + ); + + SiwsChallengeV1 { + domain: "example.com".to_string(), + address: swig_wallet_address.to_string(), + statement: Some("Sign in to Swig".to_string()), + uri: "https://example.com/login".to_string(), + version: "1".to_string(), + chain_id: Some("solana:devnet".to_string()), + nonce: "abc123ef".to_string(), + issued_at: "2026-01-01T00:00:00Z".to_string(), + expiration_time: None, + not_before: None, + request_id: None, + resources, + } +} + +#[test_log::test] +fn test_is_valid_signature_ed25519_happy_path() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + context + .svm + .airdrop(&swig_authority.pubkey(), 1_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + + let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + let challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + + let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ) + .unwrap(); + + let message = v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_ok(), "is_valid_signature should succeed"); +} + +#[test_log::test] +fn test_is_valid_signature_rejects_missing_scope_permission() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let limited_authority = Keypair::new(); + context + .svm + .airdrop(&swig_authority.pubkey(), 1_000_000_000) + .unwrap(); + context + .svm + .airdrop(&limited_authority.pubkey(), 1_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: limited_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], + ) + .unwrap(); + + let limited_role_id = role_id_for_authority(&context, &swig, &limited_authority.pubkey()) + .unwrap(); + let challenge = build_challenge( + swig, + swig_wallet_address, + limited_role_id, + &["ManageAuthority"], + ); + + let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + limited_authority.pubkey(), + limited_role_id, + &challenge, + ) + .unwrap(); + + let message = v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + limited_authority.insecure_clone(), + ], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject missing scope permission" + ); + assert_eq!( + result.unwrap_err().err, + TransactionError::InstructionError(0, InstructionError::Custom(3006)) + ); +} + +#[test_log::test] +fn test_is_valid_signature_rejects_role_resource_mismatch() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + context + .svm + .airdrop(&swig_authority.pubkey(), 1_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + + let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + let wrong_role_id = role_id + 1; + let challenge = build_challenge(swig, swig_wallet_address, wrong_role_id, &["ProgramScope"]); + + let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ) + .unwrap(); + + let message = v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject mismatched role_id resource" + ); + assert_eq!( + result.unwrap_err().err, + TransactionError::InstructionError(0, InstructionError::Custom(3005)) + ); +} + +#[test_log::test] +fn test_is_valid_signature_rejects_malformed_abnf_challenge() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + context + .svm + .airdrop(&swig_authority.pubkey(), 1_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + + let malformed_challenge = b"example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nNonce: abcdef12\nVersion: 1"; + + let args = swig_interface::swig::actions::is_valid_signature::IsValidSignatureArgs::new( + role_id, + malformed_challenge.len() as u16, + ); + let arg_bytes = args.into_bytes().unwrap(); + + let validate_ix = Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(swig, false), + AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(swig_authority.pubkey(), true), + ], + data: [arg_bytes, malformed_challenge, &[2]].concat(), + }; + + let message = v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject malformed ABNF challenge" + ); + assert_eq!( + result.unwrap_err().err, + TransactionError::InstructionError(0, InstructionError::InvalidInstructionData) + ); +} From 0a21cc50c010c82c22244139df7c0c83efad028c Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Fri, 20 Feb 2026 16:18:11 -0500 Subject: [PATCH 2/6] Ignore IDE workspace files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 810b4195..de6d74e4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ test-ledger target certs -swig.code-workspace \ No newline at end of file +swig.code-workspace +.idea/ From e64346104445a9891ae8ba5427efca2f86070180 Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Fri, 20 Feb 2026 16:29:49 -0500 Subject: [PATCH 3/6] Remove serde and unwraps from SIWS flow --- Cargo.lock | 1 - interface/Cargo.toml | 1 - interface/src/lib.rs | 33 +- program/src/actions/is_valid_signature.rs | 12 +- .../src/actions/is_valid_signature_abnf.rs | 5 +- program/tests/is_valid_signature_test.rs | 353 +++++++++++------- 6 files changed, 245 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 482162c0..7b271930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8928,7 +8928,6 @@ version = "1.4.0" dependencies = [ "anyhow", "bytemuck", - "serde", "solana-sdk", "solana-secp256r1-program", "swig", diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 0823578a..a14ab477 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -20,4 +20,3 @@ swig-compact-instructions = { path = "../instructions", default-features = false swig-state = { path = "../state" } anyhow = "1.0.75" solana-secp256r1-program = "2.2.1" -serde = { version = "1.0.217", features = ["derive"] } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 38b42dd1..622671f6 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1,4 +1,3 @@ -use serde::{Deserialize, Serialize}; use solana_sdk::{ hash as sha256, instruction::{AccountMeta, Instruction}, @@ -46,8 +45,7 @@ use swig_state::{ }; /// SIWS challenge payload for Swig auth. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone)] pub struct SiwsChallengeV1 { /// RFC 4501 DNS authority requesting the signing. pub domain: String, @@ -165,7 +163,10 @@ mod tests { ], }; - let message = challenge.to_message_string().unwrap(); + let message = match challenge.to_message_string() { + Ok(message) => message, + Err(error) => panic!("SIWS serialization should succeed: {error:?}"), + }; assert!(message.starts_with( "example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nSign in to Swig\n\nURI: https://example.com/login" )); @@ -429,7 +430,10 @@ impl AddAuthorityInstruction { num_actions, ); - write.extend_from_slice(args.into_bytes().unwrap()); + write.extend_from_slice( + args.into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?, + ); write.extend_from_slice(new_authority_config.authority); write.extend_from_slice(&action_bytes); write.extend_from_slice(&[3]); @@ -478,8 +482,11 @@ impl AddAuthorityInstruction { let mut account_payload_bytes = Vec::new(); for account in &accounts { - account_payload_bytes - .extend_from_slice(accounts_payload_from_meta(account).into_bytes().unwrap()); + account_payload_bytes.extend_from_slice( + accounts_payload_from_meta(account) + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize account meta {:?}", e))?, + ); } let mut signature_bytes = Vec::new(); @@ -1575,7 +1582,10 @@ impl UpdateAuthorityInstruction { ); let mut write = Vec::new(); - write.extend_from_slice(args.into_bytes().unwrap()); + write.extend_from_slice( + args.into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?, + ); write.extend_from_slice(&encoded_data); write.extend_from_slice(&[3]); // Ed25519 authority type @@ -1651,8 +1661,11 @@ impl UpdateAuthorityInstruction { let mut account_payload_bytes = Vec::new(); for account in &accounts { - account_payload_bytes - .extend_from_slice(accounts_payload_from_meta(account).into_bytes().unwrap()); + account_payload_bytes.extend_from_slice( + accounts_payload_from_meta(account) + .into_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize account meta {:?}", e))?, + ); } let mut signature_bytes = Vec::new(); diff --git a/program/src/actions/is_valid_signature.rs b/program/src/actions/is_valid_signature.rs index 41b667ca..215a65b3 100644 --- a/program/src/actions/is_valid_signature.rs +++ b/program/src/actions/is_valid_signature.rs @@ -138,11 +138,8 @@ pub fn is_valid_signature( return Err(SwigError::InvalidSeedSwigAccount.into()); } - let role = Swig::get_mut_role(validate.args.role_id, swig_roles)?; - if role.is_none() { - return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); - } - let role = role.unwrap(); + let role = Swig::get_mut_role(validate.args.role_id, swig_roles)? + .ok_or(SwigError::InvalidAuthorityNotFoundByRoleId)?; let clock = Clock::get()?; let slot = clock.slot; @@ -333,7 +330,10 @@ mod tests { "urn:swig:v1:role_id:7", "urn:swig:v1:scope:TokenLimit", ]; - let parsed = parse_resources(&resources).unwrap(); + let parsed = match parse_resources(&resources) { + Ok(parsed) => parsed, + Err(error) => panic!("parse_resources should succeed: {error:?}"), + }; assert_eq!(parsed.swig, Some("swig123")); assert_eq!(parsed.swig_wallet_address, Some("wallet123")); assert_eq!(parsed.swig_program, Some("program123")); diff --git a/program/src/actions/is_valid_signature_abnf.rs b/program/src/actions/is_valid_signature_abnf.rs index 03f8f8f1..89a39f3c 100644 --- a/program/src/actions/is_valid_signature_abnf.rs +++ b/program/src/actions/is_valid_signature_abnf.rs @@ -226,7 +226,10 @@ mod tests { #[test] fn parses_challenge_address_and_resources() { let challenge = b"example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nSign in to Swig\n\nURI: https://example.com\nVersion: 1\nChain ID: solana:devnet\nNonce: abcdef12\nIssued At: 2026-01-01T00:00:00Z\nResources:\n- urn:swig:v1:swig:swig123\n- urn:swig:v1:swig_wallet_address:3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n- urn:swig:v1:swig_program:program123\n- urn:swig:v1:role_id:1\n- urn:swig:v1:scope:ProgramScope"; - let parsed = parse_siws_challenge(challenge).unwrap(); + let parsed = match parse_siws_challenge(challenge) { + Ok(parsed) => parsed, + Err(error) => panic!("parse_siws_challenge should succeed: {error:?}"), + }; assert_eq!(parsed.address, "3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS"); assert_eq!(parsed.resources.len(), 5); } diff --git a/program/tests/is_valid_signature_test.rs b/program/tests/is_valid_signature_test.rs index 28e77955..0598af45 100644 --- a/program/tests/is_valid_signature_test.rs +++ b/program/tests/is_valid_signature_test.rs @@ -68,47 +68,66 @@ fn build_challenge( } } +fn must(result: Result, context: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("{context}: {error:?}"), + } +} + #[test_log::test] fn test_is_valid_signature_ed25519_happy_path() { - let mut context = setup_test_context().unwrap(); + let mut context = must(setup_test_context(), "setup test context"); let swig_authority = Keypair::new(); - context - .svm - .airdrop(&swig_authority.pubkey(), 1_000_000_000) - .unwrap(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); let id = rand::random::<[u8; 32]>(); - let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); let (swig_wallet_address, _) = Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); - let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); let challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); - let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( - swig, - swig_wallet_address, - swig_authority.pubkey(), - role_id, - &challenge, - ) - .unwrap(); + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); - let message = v0::Message::try_compile( - &context.default_payer.pubkey(), - &[validate_ix], - &[], - context.svm.latest_blockhash(), - ) - .unwrap(); - let tx = VersionedTransaction::try_new( - VersionedMessage::V0(message), - &[ - context.default_payer.insecure_clone(), - swig_authority.insecure_clone(), - ], - ) - .unwrap(); + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); let result = context.svm.send_transaction(tx); assert!(result.is_ok(), "is_valid_signature should succeed"); @@ -116,37 +135,46 @@ fn test_is_valid_signature_ed25519_happy_path() { #[test_log::test] fn test_is_valid_signature_rejects_missing_scope_permission() { - let mut context = setup_test_context().unwrap(); + let mut context = must(setup_test_context(), "setup test context"); let swig_authority = Keypair::new(); let limited_authority = Keypair::new(); - context - .svm - .airdrop(&swig_authority.pubkey(), 1_000_000_000) - .unwrap(); - context - .svm - .airdrop(&limited_authority.pubkey(), 1_000_000_000) - .unwrap(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + must( + context + .svm + .airdrop(&limited_authority.pubkey(), 1_000_000_000), + "airdrop limited authority", + ); let id = rand::random::<[u8; 32]>(); - let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); let (swig_wallet_address, _) = Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); - add_authority_with_ed25519_root( - &mut context, - &swig, - &swig_authority, - AuthorityConfig { - authority_type: AuthorityType::Ed25519, - authority: limited_authority.pubkey().as_ref(), - }, - vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], - ) - .unwrap(); + must( + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: limited_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], + ), + "add limited authority", + ); - let limited_role_id = role_id_for_authority(&context, &swig, &limited_authority.pubkey()) - .unwrap(); + let limited_role_id = must( + role_id_for_authority(&context, &swig, &limited_authority.pubkey()), + "lookup limited role id", + ); let challenge = build_challenge( swig, swig_wallet_address, @@ -154,110 +182,144 @@ fn test_is_valid_signature_rejects_missing_scope_permission() { &["ManageAuthority"], ); - let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( - swig, - swig_wallet_address, - limited_authority.pubkey(), - limited_role_id, - &challenge, - ) - .unwrap(); + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + limited_authority.pubkey(), + limited_role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); - let message = v0::Message::try_compile( - &context.default_payer.pubkey(), - &[validate_ix], - &[], - context.svm.latest_blockhash(), - ) - .unwrap(); - let tx = VersionedTransaction::try_new( - VersionedMessage::V0(message), - &[ - context.default_payer.insecure_clone(), - limited_authority.insecure_clone(), - ], - ) - .unwrap(); + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + limited_authority.insecure_clone(), + ], + ), + "build transaction", + ); let result = context.svm.send_transaction(tx); assert!( result.is_err(), "is_valid_signature should reject missing scope permission" ); - assert_eq!( - result.unwrap_err().err, - TransactionError::InstructionError(0, InstructionError::Custom(3006)) - ); + match result { + Ok(_) => panic!("expected missing scope permission failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::Custom(3006)) + ); + }, + } } #[test_log::test] fn test_is_valid_signature_rejects_role_resource_mismatch() { - let mut context = setup_test_context().unwrap(); + let mut context = must(setup_test_context(), "setup test context"); let swig_authority = Keypair::new(); - context - .svm - .airdrop(&swig_authority.pubkey(), 1_000_000_000) - .unwrap(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); let id = rand::random::<[u8; 32]>(); - let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); let (swig_wallet_address, _) = Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); - let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); let wrong_role_id = role_id + 1; let challenge = build_challenge(swig, swig_wallet_address, wrong_role_id, &["ProgramScope"]); - let validate_ix = IsValidSignatureInstruction::new_with_ed25519_authority( - swig, - swig_wallet_address, - swig_authority.pubkey(), - role_id, - &challenge, - ) - .unwrap(); + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); - let message = v0::Message::try_compile( - &context.default_payer.pubkey(), - &[validate_ix], - &[], - context.svm.latest_blockhash(), - ) - .unwrap(); - let tx = VersionedTransaction::try_new( - VersionedMessage::V0(message), - &[ - context.default_payer.insecure_clone(), - swig_authority.insecure_clone(), - ], - ) - .unwrap(); + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); let result = context.svm.send_transaction(tx); assert!( result.is_err(), "is_valid_signature should reject mismatched role_id resource" ); - assert_eq!( - result.unwrap_err().err, - TransactionError::InstructionError(0, InstructionError::Custom(3005)) - ); + match result { + Ok(_) => panic!("expected role resource mismatch failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::Custom(3005)) + ); + }, + } } #[test_log::test] fn test_is_valid_signature_rejects_malformed_abnf_challenge() { - let mut context = setup_test_context().unwrap(); + let mut context = must(setup_test_context(), "setup test context"); let swig_authority = Keypair::new(); - context - .svm - .airdrop(&swig_authority.pubkey(), 1_000_000_000) - .unwrap(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); let id = rand::random::<[u8; 32]>(); - let (swig, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); let (swig_wallet_address, _) = Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); - let role_id = role_id_for_authority(&context, &swig, &swig_authority.pubkey()).unwrap(); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); let malformed_challenge = b"example.com wants you to sign in with your Solana account:\n3KMf9P7w2nQx5R8tUvYcBdEghJkMNpQrS\n\nNonce: abcdef12\nVersion: 1"; @@ -265,7 +327,7 @@ fn test_is_valid_signature_rejects_malformed_abnf_challenge() { role_id, malformed_challenge.len() as u16, ); - let arg_bytes = args.into_bytes().unwrap(); + let arg_bytes = must(args.into_bytes(), "serialize args"); let validate_ix = Instruction { program_id: program_id(), @@ -277,29 +339,38 @@ fn test_is_valid_signature_rejects_malformed_abnf_challenge() { data: [arg_bytes, malformed_challenge, &[2]].concat(), }; - let message = v0::Message::try_compile( - &context.default_payer.pubkey(), - &[validate_ix], - &[], - context.svm.latest_blockhash(), - ) - .unwrap(); - let tx = VersionedTransaction::try_new( - VersionedMessage::V0(message), - &[ - context.default_payer.insecure_clone(), - swig_authority.insecure_clone(), - ], - ) - .unwrap(); + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); let result = context.svm.send_transaction(tx); assert!( result.is_err(), "is_valid_signature should reject malformed ABNF challenge" ); - assert_eq!( - result.unwrap_err().err, - TransactionError::InstructionError(0, InstructionError::InvalidInstructionData) - ); + match result { + Ok(_) => panic!("expected malformed challenge failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::InvalidInstructionData) + ); + }, + } } From ecba4adb69c387595b345ac495fbff3a54c3ae85 Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Sun, 22 Feb 2026 00:21:04 -0500 Subject: [PATCH 4/6] clarify siws abnf serialization naming --- interface/src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 622671f6..9d405053 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -133,9 +133,22 @@ impl SiwsChallengeV1 { Ok(self.to_message_string()?.into_bytes()) } - pub fn to_json_bytes(&self) -> anyhow::Result> { + /// Serializes this SIWS challenge to ABNF text bytes. + /// + /// This is equivalent to [`Self::to_message_bytes`], and is provided to + /// make the output format explicit for API consumers. + pub fn to_abnf_bytes(&self) -> anyhow::Result> { self.to_message_bytes() } + + /// Deprecated: this method returns ABNF text bytes, not JSON bytes. + #[deprecated( + since = "0.1.0", + note = "returns ABNF text bytes; use to_abnf_bytes() or to_message_bytes()" + )] + pub fn to_json_bytes(&self) -> anyhow::Result> { + self.to_abnf_bytes() + } } #[cfg(test)] From 799f44e2967a55d72b18802471e96ca68cd30649 Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Sun, 22 Feb 2026 00:40:44 -0500 Subject: [PATCH 5/6] interface: remove redundant SIWS byte wrapper methods --- interface/src/lib.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 9d405053..487b2d5a 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -132,23 +132,6 @@ impl SiwsChallengeV1 { pub fn to_message_bytes(&self) -> anyhow::Result> { Ok(self.to_message_string()?.into_bytes()) } - - /// Serializes this SIWS challenge to ABNF text bytes. - /// - /// This is equivalent to [`Self::to_message_bytes`], and is provided to - /// make the output format explicit for API consumers. - pub fn to_abnf_bytes(&self) -> anyhow::Result> { - self.to_message_bytes() - } - - /// Deprecated: this method returns ABNF text bytes, not JSON bytes. - #[deprecated( - since = "0.1.0", - note = "returns ABNF text bytes; use to_abnf_bytes() or to_message_bytes()" - )] - pub fn to_json_bytes(&self) -> anyhow::Result> { - self.to_abnf_bytes() - } } #[cfg(test)] From 72470a15167ef2e6fdeb9d52159feb6665532d7a Mon Sep 17 00:00:00 2001 From: Edward Chan Date: Wed, 25 Feb 2026 12:52:02 -0500 Subject: [PATCH 6/6] Make is_valid_signature accounts readonly and expand coverage --- interface/src/lib.rs | 16 +- program/idl.json | 4 +- program/src/actions/is_valid_signature.rs | 13 +- program/src/instruction.rs | 8 +- program/tests/is_valid_signature_test.rs | 591 +++++++++++++++++++++- 5 files changed, 611 insertions(+), 21 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 487b2d5a..b71b057a 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1092,8 +1092,8 @@ impl IsValidSignatureInstruction { Ok(Instruction { program_id: Pubkey::from(swig::ID), accounts: vec![ - AccountMeta::new(swig_account, false), - AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(swig_account, false), + AccountMeta::new_readonly(swig_wallet_address, false), AccountMeta::new_readonly(authority, true), ], data: [arg_bytes, &challenge_bytes, &[2]].concat(), @@ -1119,8 +1119,8 @@ impl IsValidSignatureInstruction { .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; let accounts = vec![ - AccountMeta::new(swig_account, false), - AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(swig_account, false), + AccountMeta::new_readonly(swig_wallet_address, false), AccountMeta::new_readonly(system_program::ID, false), ]; @@ -1173,8 +1173,8 @@ impl IsValidSignatureInstruction { .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; let accounts = vec![ - AccountMeta::new(swig_account, false), - AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(swig_account, false), + AccountMeta::new_readonly(swig_wallet_address, false), AccountMeta::new_readonly(system_program::ID, false), AccountMeta::new_readonly(solana_sdk::sysvar::instructions::ID, false), ]; @@ -1237,8 +1237,8 @@ impl IsValidSignatureInstruction { .map_err(|e| anyhow::anyhow!("Failed to serialize args {:?}", e))?; let mut accounts = vec![ - AccountMeta::new(swig_account, false), - AccountMeta::new(swig_wallet_address, false), + AccountMeta::new_readonly(swig_account, false), + AccountMeta::new_readonly(swig_wallet_address, false), AccountMeta::new_readonly(payer, true), ]; let instruction_sysvar_index = accounts.len() as u8; diff --git a/program/idl.json b/program/idl.json index bd458844..103da8ed 100644 --- a/program/idl.json +++ b/program/idl.json @@ -581,7 +581,7 @@ "accounts": [ { "name": "swig", - "isMut": true, + "isMut": false, "isSigner": false, "docs": [ "the swig smart wallet" @@ -589,7 +589,7 @@ }, { "name": "swigWalletAddress", - "isMut": true, + "isMut": false, "isSigner": false, "docs": [ "the swig wallet address PDA" diff --git a/program/src/actions/is_valid_signature.rs b/program/src/actions/is_valid_signature.rs index 215a65b3..c33b0718 100644 --- a/program/src/actions/is_valid_signature.rs +++ b/program/src/actions/is_valid_signature.rs @@ -17,7 +17,7 @@ use swig_assertions::{check_self_owned, check_stack_height, check_system_owner}; use swig_state::{ action::{Action, Permission}, swig::{swig_wallet_address_seeds, Swig}, - Discriminator, IntoBytes, SwigAuthenticateError, Transmutable, TransmutableMut, + Discriminator, IntoBytes, SwigAuthenticateError, Transmutable, }; use super::is_valid_signature_abnf::parse_siws_challenge; @@ -124,13 +124,13 @@ pub fn is_valid_signature( let validate = IsValidSignature::from_instruction_bytes(data)?; let parsed_challenge = parse_siws_challenge(validate.challenge_payload)?; - let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + let swig_account_data = unsafe { ctx.accounts.swig.borrow_data_unchecked() }; if unsafe { *swig_account_data.get_unchecked(0) } != Discriminator::SwigConfigAccount as u8 { return Err(SwigError::InvalidSwigAccountDiscriminator.into()); } - let (swig_header, swig_roles) = unsafe { swig_account_data.split_at_mut_unchecked(Swig::LEN) }; - let swig = unsafe { Swig::load_mut_unchecked(swig_header)? }; + let (swig_header, swig_roles) = unsafe { swig_account_data.split_at_unchecked(Swig::LEN) }; + let swig = unsafe { Swig::load_unchecked(swig_header)? }; let swig_wallet_seeds = swig_wallet_address_seeds(ctx.accounts.swig.key().as_ref()); let (derived_wallet, _) = find_program_address(&swig_wallet_seeds, &crate::ID); @@ -138,7 +138,10 @@ pub fn is_valid_signature( return Err(SwigError::InvalidSeedSwigAccount.into()); } - let role = Swig::get_mut_role(validate.args.role_id, swig_roles)? + // `IsValidSignature` is intended for off-chain simulation and should not + // mutate on-chain authority state (e.g. secp signature odometers). + let mut swig_roles_for_auth = swig_roles.to_vec(); + let role = Swig::get_mut_role(validate.args.role_id, &mut swig_roles_for_auth)? .ok_or(SwigError::InvalidAuthorityNotFoundByRoleId)?; let clock = Clock::get()?; diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 4218c293..fcc11761 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -236,14 +236,14 @@ pub enum SwigInstruction { /// `urn:swig:v1:scope:*` resources are allowed by the role. /// /// Required accounts: - /// 1. `[writable]` Swig wallet account - /// 2. `[writable]` Swig wallet address PDA + /// 1. Swig wallet account + /// 2. Swig wallet address PDA /// 3. Authority context: /// - Ed25519: authority signer account /// - Secp256r1: instructions sysvar passed via authority payload index /// - Secp256k1 / ProgramExec: placeholder or sysvar per authority payload - #[account(0, writable, name="swig", desc="the swig smart wallet")] - #[account(1, writable, name="swig_wallet_address", desc="the swig wallet address PDA")] + #[account(0, name="swig", desc="the swig smart wallet")] + #[account(1, name="swig_wallet_address", desc="the swig wallet address PDA")] #[account(2, name="authority_context", desc="authority context account or placeholder")] IsValidSignature = 16, } diff --git a/program/tests/is_valid_signature_test.rs b/program/tests/is_valid_signature_test.rs index 0598af45..202539a4 100644 --- a/program/tests/is_valid_signature_test.rs +++ b/program/tests/is_valid_signature_test.rs @@ -1,8 +1,12 @@ #![cfg(not(feature = "program_scope_test"))] mod common; +use alloy_primitives::B256; +use alloy_signer::SignerSync; +use alloy_signer_local::{LocalSigner, PrivateKeySigner}; use common::*; use solana_sdk::{ + clock::Clock, instruction::{AccountMeta, Instruction, InstructionError}, message::{v0, VersionedMessage}, pubkey::Pubkey, @@ -12,8 +16,11 @@ use solana_sdk::{ }; use swig_interface::{AuthorityConfig, ClientAction, IsValidSignatureInstruction, SiwsChallengeV1}; use swig_state::{ - action::sol_limit::SolLimit, authority::AuthorityType, swig::swig_wallet_address_seeds, - swig::SwigWithRoles, IntoBytes, + action::sol_limit::SolLimit, + authority::{secp256k1::Secp256k1Authority, secp256r1::Secp256r1Authority, AuthorityType}, + swig::swig_wallet_address_seeds, + swig::SwigWithRoles, + IntoBytes, }; fn role_id_for_authority( @@ -34,6 +41,107 @@ fn role_id_for_authority( Ok(role_id) } +fn role_id_for_authority_bytes( + context: &Context, + swig_pubkey: &Pubkey, + authority: &[u8], +) -> anyhow::Result { + let swig_account = context + .svm + .get_account(swig_pubkey) + .ok_or(anyhow::anyhow!("Swig account not found"))?; + let swig = SwigWithRoles::from_bytes(&swig_account.data) + .map_err(|e| anyhow::anyhow!("Failed to deserialize swig {:?}", e))?; + let role_id = swig + .lookup_role_id(authority) + .map_err(|e| anyhow::anyhow!("Failed to lookup role id {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found for authority"))?; + Ok(role_id) +} + +fn secp256k1_authority_bytes(wallet: &PrivateKeySigner) -> [u8; 64] { + let key_bytes = wallet + .credential() + .verifying_key() + .to_encoded_point(false) + .to_bytes(); + let mut authority = [0u8; 64]; + authority.copy_from_slice(&key_bytes[1..]); + authority +} + +fn secp256k1_signature_odometer( + context: &Context, + swig_pubkey: &Pubkey, + authority_bytes: &[u8], +) -> anyhow::Result { + let swig_account = context + .svm + .get_account(swig_pubkey) + .ok_or(anyhow::anyhow!("Swig account not found"))?; + let swig = SwigWithRoles::from_bytes(&swig_account.data) + .map_err(|e| anyhow::anyhow!("Failed to deserialize swig {:?}", e))?; + let role_id = swig + .lookup_role_id(authority_bytes) + .map_err(|e| anyhow::anyhow!("Failed to lookup role id {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found for authority"))?; + let role = swig + .get_role(role_id) + .map_err(|e| anyhow::anyhow!("Failed to get role {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found"))?; + let authority = role + .authority + .as_any() + .downcast_ref::() + .ok_or(anyhow::anyhow!("Role authority is not secp256k1"))?; + Ok(authority.signature_odometer) +} + +fn secp256r1_signature_odometer( + context: &Context, + swig_pubkey: &Pubkey, + public_key: &[u8], +) -> anyhow::Result { + let swig_account = context + .svm + .get_account(swig_pubkey) + .ok_or(anyhow::anyhow!("Swig account not found"))?; + let swig = SwigWithRoles::from_bytes(&swig_account.data) + .map_err(|e| anyhow::anyhow!("Failed to deserialize swig {:?}", e))?; + let role_id = swig + .lookup_role_id(public_key) + .map_err(|e| anyhow::anyhow!("Failed to lookup role id {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found for authority"))?; + let role = swig + .get_role(role_id) + .map_err(|e| anyhow::anyhow!("Failed to get role {:?}", e))? + .ok_or(anyhow::anyhow!("Role not found"))?; + let authority = role + .authority + .as_any() + .downcast_ref::() + .ok_or(anyhow::anyhow!("Role authority is not secp256r1"))?; + Ok(authority.signature_odometer) +} + +fn create_test_secp256r1_keypair() -> (openssl::ec::EcKey, [u8; 33]) { + use openssl::{ + bn::BigNumContext, + ec::{EcGroup, EcKey, PointConversionForm}, + nid::Nid, + }; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let signing_key = EcKey::generate(&group).unwrap(); + let mut ctx = BigNumContext::new().unwrap(); + let pubkey_bytes = signing_key + .public_key() + .to_bytes(&group, PointConversionForm::COMPRESSED, &mut ctx) + .unwrap(); + let pubkey_array: [u8; 33] = pubkey_bytes.try_into().unwrap(); + (signing_key, pubkey_array) +} + fn build_challenge( swig: Pubkey, swig_wallet_address: Pubkey, @@ -133,6 +241,485 @@ fn test_is_valid_signature_ed25519_happy_path() { assert!(result.is_ok(), "is_valid_signature should succeed"); } +#[test_log::test] +fn test_is_valid_signature_secp256k1_happy_path() { + let mut context = must(setup_test_context(), "setup test context"); + let wallet = LocalSigner::random(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_secp256k1(&mut context, &wallet, id), + "create swig secp256k1", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let authority_bytes = secp256k1_authority_bytes(&wallet); + let role_id = must( + role_id_for_authority_bytes(&context, &swig, &authority_bytes), + "lookup role id for secp256k1 authority", + ); + let challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + let current_slot = context.svm.get_sysvar::().slot; + let next_counter = must( + secp256k1_signature_odometer(&context, &swig, &authority_bytes), + "read secp256k1 odometer", + ) + 1; + + let signing_fn = |payload: &[u8]| -> [u8; 65] { + let mut hash = [0u8; 32]; + hash.copy_from_slice(&payload[..32]); + wallet.sign_hash_sync(&B256::from(hash)).unwrap().as_bytes() + }; + + let validate_ix = must( + IsValidSignatureInstruction::new_with_secp256k1_authority( + swig, + swig_wallet_address, + signing_fn, + current_slot, + next_counter, + role_id, + &challenge, + ), + "build is_valid_signature secp256k1 instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[context.default_payer.insecure_clone()], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "secp256k1 is_valid_signature should succeed: {:?}", + result.err() + ); +} + +#[test_log::test] +fn test_is_valid_signature_secp256r1_happy_path() { + let mut context = must(setup_test_context(), "setup test context"); + let (signing_key, public_key) = create_test_secp256r1_keypair(); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_secp256r1(&mut context, &public_key, id), + "create swig secp256r1", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority_bytes(&context, &swig, &public_key), + "lookup role id for secp256r1 authority", + ); + let challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + let current_slot = context.svm.get_sysvar::().slot; + let next_counter = must( + secp256r1_signature_odometer(&context, &swig, &public_key), + "read secp256r1 odometer", + ) + 1; + + let signing_fn = |message_hash: &[u8]| -> [u8; 64] { + use solana_secp256r1_program::sign_message; + sign_message(message_hash, &signing_key.private_key_to_der().unwrap()).unwrap() + }; + + let validate_ixs = must( + IsValidSignatureInstruction::new_with_secp256r1_authority( + swig, + swig_wallet_address, + signing_fn, + current_slot, + next_counter, + role_id, + &challenge, + &public_key, + ), + "build is_valid_signature secp256r1 instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &validate_ixs, + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[context.default_payer.insecure_clone()], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "secp256r1 is_valid_signature should succeed: {:?}", + result.err() + ); +} + +#[test_log::test] +fn test_is_valid_signature_rejects_swig_resource_mismatch() { + let mut context = must(setup_test_context(), "setup test context"); + let swig_authority = Keypair::new(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); + + let mut challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + challenge.resources[0] = format!("urn:swig:v1:swig:{}", Pubkey::new_unique()); + + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject swig resource mismatch" + ); + match result { + Ok(_) => panic!("expected swig resource mismatch failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::Custom(3005)) + ); + }, + } +} + +#[test_log::test] +fn test_is_valid_signature_rejects_program_resource_mismatch() { + let mut context = must(setup_test_context(), "setup test context"); + let swig_authority = Keypair::new(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); + + let mut challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + challenge.resources[2] = format!("urn:swig:v1:swig_program:{}", Pubkey::new_unique()); + + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject program id mismatch" + ); + match result { + Ok(_) => panic!("expected program id mismatch failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::Custom(3005)) + ); + }, + } +} + +#[test_log::test] +fn test_is_valid_signature_rejects_challenge_wallet_address_mismatch() { + let mut context = must(setup_test_context(), "setup test context"); + let swig_authority = Keypair::new(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); + + let mut challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + challenge.address = Pubkey::new_unique().to_string(); + + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "is_valid_signature should reject challenge address mismatch" + ); + match result { + Ok(_) => panic!("expected challenge address mismatch failure"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::Custom(3005)) + ); + }, + } +} + +#[test_log::test] +fn test_is_valid_signature_rejects_duplicate_urn_resources() { + let mut context = must(setup_test_context(), "setup test context"); + let swig_authority = Keypair::new(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); + + let mut challenge = build_challenge(swig, swig_wallet_address, role_id, &["ProgramScope"]); + challenge.resources.push(format!("urn:swig:v1:swig:{swig}")); + + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "duplicate URN resources should be rejected" + ); + match result { + Ok(_) => panic!("expected duplicate URN rejection"), + Err(error) => { + assert_eq!( + error.err, + TransactionError::InstructionError(0, InstructionError::InvalidInstructionData) + ); + }, + } +} + +#[test_log::test] +fn test_is_valid_signature_allows_empty_scopes() { + let mut context = must(setup_test_context(), "setup test context"); + let swig_authority = Keypair::new(); + must( + context.svm.airdrop(&swig_authority.pubkey(), 1_000_000_000), + "airdrop swig authority", + ); + + let id = rand::random::<[u8; 32]>(); + let (swig, _) = must( + create_swig_ed25519(&mut context, &swig_authority, id), + "create swig ed25519", + ); + let (swig_wallet_address, _) = + Pubkey::find_program_address(&swig_wallet_address_seeds(swig.as_ref()), &program_id()); + let role_id = must( + role_id_for_authority(&context, &swig, &swig_authority.pubkey()), + "lookup role id for swig authority", + ); + let challenge = build_challenge(swig, swig_wallet_address, role_id, &[]); + + let validate_ix = must( + IsValidSignatureInstruction::new_with_ed25519_authority( + swig, + swig_wallet_address, + swig_authority.pubkey(), + role_id, + &challenge, + ), + "build is_valid_signature instruction", + ); + + let message = must( + v0::Message::try_compile( + &context.default_payer.pubkey(), + &[validate_ix], + &[], + context.svm.latest_blockhash(), + ), + "compile transaction message", + ); + let tx = must( + VersionedTransaction::try_new( + VersionedMessage::V0(message), + &[ + context.default_payer.insecure_clone(), + swig_authority.insecure_clone(), + ], + ), + "build transaction", + ); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "is_valid_signature should allow challenge without scopes" + ); +} + #[test_log::test] fn test_is_valid_signature_rejects_missing_scope_permission() { let mut context = must(setup_test_context(), "setup test context");