1use crate::xdr::{HostFunction, SorobanAuthorizedFunction, SorobanAuthorizedInvocation};
2
3#[derive(thiserror::Error, Debug)]
4pub enum Error {
5 #[error(
6 "the produced signature does not match the cached public key {public_key}; \
7the cached key may be stale — check the correct device is connected / the alias's \
8hd-path is right, or re-add the key"
9 )]
10 SignatureMismatch { public_key: String },
11
12 #[error("the cached public key {public_key} is not a valid ed25519 public key")]
13 InvalidPublicKey { public_key: String },
14
15 #[error("the signer returned a malformed ed25519 signature ({len} bytes, expected 64)")]
16 InvalidSignature { len: usize },
17}
18
19pub fn verify_signature(
26 public_key: &stellar_strkey::ed25519::PublicKey,
27 message: &[u8; 32],
28 signature: &[u8],
29) -> Result<(), Error> {
30 use ed25519_dalek::{Signature, VerifyingKey};
31 let vk = VerifyingKey::from_bytes(&public_key.0).map_err(|_| Error::InvalidPublicKey {
32 public_key: format!("{public_key}"),
33 })?;
34 let sig = Signature::from_slice(signature).map_err(|_| Error::InvalidSignature {
35 len: signature.len(),
36 })?;
37 vk.verify_strict(message, &sig)
38 .map_err(|_| Error::SignatureMismatch {
39 public_key: format!("{public_key}"),
40 })
41}
42
43#[derive(Debug, PartialEq, Eq)]
49pub enum AuthStyle {
50 Strict,
53 NonStrict,
56 Invalid,
58}
59
60pub fn classify_auth_invocation(
66 source_host_fn: &HostFunction,
67 auth_invocation: &SorobanAuthorizedInvocation,
68) -> AuthStyle {
69 if matches!(source_host_fn, HostFunction::UploadContractWasm(_)) {
71 return AuthStyle::Invalid;
72 }
73
74 let is_strict = match (source_host_fn, &auth_invocation.function) {
79 (HostFunction::InvokeContract(op), SorobanAuthorizedFunction::ContractFn(args)) => {
80 args == op
81 }
82 (
83 HostFunction::CreateContract(op),
84 SorobanAuthorizedFunction::CreateContractHostFn(args),
85 ) => args == op,
86 (
87 HostFunction::CreateContractV2(op),
88 SorobanAuthorizedFunction::CreateContractV2HostFn(args),
89 ) => args == op,
90 _ => false,
91 };
92
93 if is_strict {
94 AuthStyle::Strict
95 } else {
96 AuthStyle::NonStrict
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::xdr::{
104 AccountId, BytesM, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress,
105 CreateContractArgsV2, Hash, InvokeContractArgs, PublicKey, ScAddress, ScSymbol, ScVal,
106 Uint256, VecM,
107 };
108 use stellar_strkey::ed25519;
109
110 const SOURCE_ACCOUNT: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
111
112 fn source_bytes() -> [u8; 32] {
113 ed25519::PublicKey::from_string(SOURCE_ACCOUNT).unwrap().0
114 }
115
116 fn ed25519_address(bytes: [u8; 32]) -> ScAddress {
117 ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes))))
118 }
119
120 fn host_fn_invoke(contract: [u8; 32], fn_name: &str, args: &[ScVal]) -> HostFunction {
121 HostFunction::InvokeContract(InvokeContractArgs {
122 contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash(contract))),
123 function_name: ScSymbol(fn_name.try_into().unwrap()),
124 args: args.try_into().unwrap(),
125 })
126 }
127
128 fn host_fn_create(wasm_hash: [u8; 32], args: &[ScVal]) -> HostFunction {
129 HostFunction::CreateContractV2(CreateContractArgsV2 {
130 contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
131 address: ed25519_address(source_bytes()),
132 salt: Uint256([0u8; 32]),
133 }),
134 executable: ContractExecutable::Wasm(wasm_hash.into()),
135 constructor_args: args.try_into().unwrap(),
136 })
137 }
138
139 fn invocation_contract(
140 contract: [u8; 32],
141 fn_name: &str,
142 args: &[ScVal],
143 ) -> SorobanAuthorizedInvocation {
144 SorobanAuthorizedInvocation {
145 function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
146 contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash(contract))),
147 function_name: ScSymbol(fn_name.try_into().unwrap()),
148 args: args.to_vec().try_into().unwrap(),
149 }),
150 sub_invocations: VecM::default(),
151 }
152 }
153
154 fn invocation_create(wasm_hash: [u8; 32], args: &[ScVal]) -> SorobanAuthorizedInvocation {
155 SorobanAuthorizedInvocation {
156 function: SorobanAuthorizedFunction::CreateContractV2HostFn(CreateContractArgsV2 {
157 contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
158 address: ed25519_address(source_bytes()),
159 salt: Uint256([0u8; 32]),
160 }),
161 executable: ContractExecutable::Wasm(wasm_hash.into()),
162 constructor_args: args.try_into().unwrap(),
163 }),
164 sub_invocations: VecM::default(),
165 }
166 }
167
168 #[test]
169 fn test_verify_signature_roundtrip() {
170 use ed25519_dalek::{ed25519::signature::Signer as _, SigningKey};
171
172 let signing_key = SigningKey::from_bytes(&[7u8; 32]);
173 let public_key =
174 ed25519::PublicKey::from_payload(signing_key.verifying_key().as_bytes()).unwrap();
175 let message = [42u8; 32];
176 let signature = signing_key.sign(&message).to_bytes();
177
178 verify_signature(&public_key, &message, &signature).unwrap();
180
181 let cached_pk = ed25519::PublicKey::from_payload(
183 SigningKey::from_bytes(&[9u8; 32])
184 .verifying_key()
185 .as_bytes(),
186 )
187 .unwrap();
188 assert!(matches!(
189 verify_signature(&cached_pk, &message, &signature),
190 Err(Error::SignatureMismatch { .. })
191 ));
192
193 let mut tampered = signature;
195 tampered[0] ^= 0xff;
196 assert!(matches!(
197 verify_signature(&public_key, &message, &tampered),
198 Err(Error::SignatureMismatch { .. })
199 ));
200
201 assert!(matches!(
203 verify_signature(&public_key, &message, &signature[..63]),
204 Err(Error::InvalidSignature { len: 63 })
205 ));
206
207 let invalid_pk = ed25519::PublicKey([0x02u8; 32]);
211 assert!(matches!(
212 verify_signature(&invalid_pk, &message, &signature),
213 Err(Error::InvalidPublicKey { .. })
214 ));
215 }
216
217 #[test]
218 fn test_matching_root_invocation_is_strict() {
219 let contract = [1u8; 32];
220 let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())];
221
222 let host_fn = host_fn_invoke(contract, "hello", args);
223 let invocation = invocation_contract(contract, "hello", args);
224
225 let style = classify_auth_invocation(&host_fn, &invocation);
226 assert_eq!(style, AuthStyle::Strict);
227 }
228
229 #[test]
230 fn test_subinvocations_dont_affect_root_match() {
231 let contract = [1u8; 32];
232 let other = [99u8; 32];
233 let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())];
234
235 let host_fn = host_fn_invoke(contract, "hello", args);
236 let mut invocation = invocation_contract(contract, "hello", args);
237 invocation.sub_invocations = [invocation_contract(other, "other", &[])]
238 .try_into()
239 .unwrap();
240
241 let style = classify_auth_invocation(&host_fn, &invocation);
242 assert_eq!(style, AuthStyle::Strict);
243 }
244
245 #[test]
246 fn test_different_root_contract_is_non_strict() {
247 let contract = [1u8; 32];
248 let other = [99u8; 32];
249
250 let host_fn = host_fn_invoke(contract, "hello", &[]);
251 let invocation = invocation_contract(other, "hello", &[]);
252
253 let style = classify_auth_invocation(&host_fn, &invocation);
254 assert_eq!(style, AuthStyle::NonStrict);
255 }
256
257 #[test]
258 fn test_different_function_same_contract_is_non_strict() {
259 let contract = [1u8; 32];
260
261 let host_fn = host_fn_invoke(contract, "hello", &[]);
262 let invocation = invocation_contract(contract, "transfer", &[]);
263
264 let style = classify_auth_invocation(&host_fn, &invocation);
265 assert_eq!(style, AuthStyle::NonStrict);
266 }
267
268 #[test]
269 fn test_different_args_is_non_strict() {
270 let contract = [1u8; 32];
271 let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())];
272 let wrong = &[ScVal::U32(43), ScVal::Symbol("hello".try_into().unwrap())];
273
274 let host_fn = host_fn_invoke(contract, "hello", args);
275 let invocation = invocation_contract(contract, "hello", wrong);
276
277 let style = classify_auth_invocation(&host_fn, &invocation);
278 assert_eq!(style, AuthStyle::NonStrict);
279 }
280
281 #[test]
282 fn test_upload_wasm_with_auth_entry_is_invalid() {
283 let contract = [1u8; 32];
284 let wasm_hash: BytesM = [42u8; 32].try_into().unwrap();
285
286 let host_fn = HostFunction::UploadContractWasm(wasm_hash);
287 let invocation = invocation_contract(contract, "hello", &[]);
288
289 let style = classify_auth_invocation(&host_fn, &invocation);
290 assert_eq!(style, AuthStyle::Invalid);
291 }
292
293 #[test]
294 fn test_matching_create_contract_root_is_strict() {
295 let contract = [1u8; 32];
296 let wasm_hash = [42u8; 32];
297 let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())];
298
299 let host_fn = host_fn_create(wasm_hash, args);
300 let mut invocation = invocation_create(wasm_hash, args);
301 invocation.sub_invocations = [invocation_contract(contract, "__constructor", args)]
302 .try_into()
303 .unwrap();
304
305 let style = classify_auth_invocation(&host_fn, &invocation);
306 assert_eq!(style, AuthStyle::Strict);
307 }
308}