Skip to main content

soroban_cli/signer/
validation.rs

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
19/// Verify that `signature` over `message` was produced by the secret key
20/// corresponding to `public_key`. Used as a post-sign drift guard for
21/// cached-pubkey signers (Ledger / Secure Store): the signature is produced on a
22/// live device/keychain while the hint and embedded public key are derived from a
23/// cached value, so a stale cache yields a transaction the network silently
24/// rejects. This catches that locally with a clear error.
25pub 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/// Classification of an `Address`-credential auth entry's relationship to the
44/// transaction's host function.
45///
46/// `SourceAccount` credential entries are out of scope here — they are signed
47/// implicitly via the transaction envelope and never reach this classifier.
48#[derive(Debug, PartialEq, Eq)]
49pub enum AuthStyle {
50    /// `root_invocation` matches the host function exactly. Safe to sign:
51    /// the entry is bound to the host function.
52    Strict,
53    /// `root_invocation` does not match the host function exactly. Any transaction
54    /// whose auth tree contains this entry could consume the resulting signature.
55    NonStrict,
56    /// `root_invocation` is not expected for the host function
57    Invalid,
58}
59
60/// Classify an auth invocation against the transaction's host function.
61///
62/// ### Arguments
63/// * `source_host_fn`- The transaction's host function
64/// * `auth_invocation` - The auth entry's root invocation
65pub fn classify_auth_invocation(
66    source_host_fn: &HostFunction,
67    auth_invocation: &SorobanAuthorizedInvocation,
68) -> AuthStyle {
69    // No auth entries are valid for `UploadContractWasm`.
70    if matches!(source_host_fn, HostFunction::UploadContractWasm(_)) {
71        return AuthStyle::Invalid;
72    }
73
74    // Check if the auth entry's root invocation matches the host function exactly.
75    // This is different than just a `root_auth` check, as contracts that authorize with
76    // `require_auth_for_args` at the root are not considered strict auth. This tradeoff is
77    // made to ensure that even a tampered auth entry can be flagged as non-strict.
78    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        // Matching key + untampered signature verifies.
179        verify_signature(&public_key, &message, &signature).unwrap();
180
181        // A different public key is rejected.
182        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        // A tampered signature is rejected.
194        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        // A wrong-length signature is reported as malformed, not as a mismatch.
202        assert!(matches!(
203            verify_signature(&public_key, &message, &signature[..63]),
204            Err(Error::InvalidSignature { len: 63 })
205        ));
206
207        // A cached public key whose bytes are not a valid ed25519 point (here,
208        // 0x02 repeated, which fails curve decompression) is reported as an
209        // invalid key, not a signature mismatch.
210        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}