soroban_cli/signer/
mod.rs

1use crate::xdr::{
2    self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization,
3    InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol,
4    ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry,
5    SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope,
6    TransactionV1Envelope, Uint256, VecM, WriteXdr,
7};
8use ed25519_dalek::ed25519::signature::Signer as _;
9use sha2::{Digest, Sha256};
10
11use crate::{config::network::Network, print::Print, utils::transaction_hash};
12
13pub mod ledger;
14
15#[cfg(feature = "additional-libs")]
16mod keyring;
17pub mod secure_store;
18
19#[derive(thiserror::Error, Debug)]
20pub enum Error {
21    #[error("Contract addresses are not supported to sign auth entries {address}")]
22    ContractAddressAreNotSupported { address: String },
23    #[error(transparent)]
24    Ed25519(#[from] ed25519_dalek::SignatureError),
25    #[error("Missing signing key for account {address}")]
26    MissingSignerForAddress { address: String },
27    #[error(transparent)]
28    TryFromSlice(#[from] std::array::TryFromSliceError),
29    #[error("User cancelled signing, perhaps need to add -y")]
30    UserCancelledSigning,
31    #[error(transparent)]
32    Xdr(#[from] xdr::Error),
33    #[error("Only Transaction envelope V1 type is supported")]
34    UnsupportedTransactionEnvelopeType,
35    #[error(transparent)]
36    Url(#[from] url::ParseError),
37    #[error(transparent)]
38    Open(#[from] std::io::Error),
39    #[error("Returning a signature from Lab is not yet supported; Transaction can be found and submitted in lab")]
40    ReturningSignatureFromLab,
41    #[error(transparent)]
42    SecureStore(#[from] secure_store::Error),
43    #[error(transparent)]
44    Ledger(#[from] ledger::Error),
45}
46
47fn requires_auth(txn: &Transaction) -> Option<xdr::Operation> {
48    let [op @ Operation {
49        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }),
50        ..
51    }] = txn.operations.as_slice()
52    else {
53        return None;
54    };
55    matches!(
56        auth.first().map(|x| &x.root_invocation.function),
57        Some(&SorobanAuthorizedFunction::ContractFn(_))
58    )
59    .then(move || op.clone())
60}
61
62// Use the given source_key and signers, to sign all SorobanAuthorizationEntry's in the given
63// transaction. If unable to sign, return an error.
64pub fn sign_soroban_authorizations(
65    raw: &Transaction,
66    source_key: &ed25519_dalek::SigningKey,
67    signers: &[ed25519_dalek::SigningKey],
68    signature_expiration_ledger: u32,
69    network_passphrase: &str,
70) -> Result<Option<Transaction>, Error> {
71    let mut tx = raw.clone();
72    let Some(mut op) = requires_auth(&tx) else {
73        return Ok(None);
74    };
75
76    let Operation {
77        body: OperationBody::InvokeHostFunction(ref mut body),
78        ..
79    } = op
80    else {
81        return Ok(None);
82    };
83
84    let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
85
86    let verification_key = source_key.verifying_key();
87    let source_address = verification_key.as_bytes();
88
89    let signed_auths = body
90        .auth
91        .as_slice()
92        .iter()
93        .map(|raw_auth| {
94            let mut auth = raw_auth.clone();
95            let SorobanAuthorizationEntry {
96                credentials: SorobanCredentials::Address(ref mut credentials),
97                ..
98            } = auth
99            else {
100                // Doesn't need special signing
101                return Ok(auth);
102            };
103            let SorobanAddressCredentials { ref address, .. } = credentials;
104
105            // See if we have a signer for this authorizationEntry
106            // If not, then we Error
107            let needle = match address {
108                ScAddress::MuxedAccount(_) => todo!("muxed accounts are not supported"),
109                ScAddress::ClaimableBalance(_) => todo!("claimable balance not supported"),
110                ScAddress::LiquidityPool(_) => todo!("liquidity pool not supported"),
111                ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a,
112                ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(c))) => {
113                    // This address is for a contract. This means we're using a custom
114                    // smart-contract account. Currently the CLI doesn't support that yet.
115                    return Err(Error::MissingSignerForAddress {
116                        address: stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c))
117                            .to_string(),
118                    });
119                }
120            };
121            let signer = if let Some(s) = signers
122                .iter()
123                .find(|s| needle == s.verifying_key().as_bytes())
124            {
125                s
126            } else if needle == source_address {
127                // This is the source address, so we can sign it
128                source_key
129            } else {
130                // We don't have a signer for this address
131                return Err(Error::MissingSignerForAddress {
132                    address: stellar_strkey::Strkey::PublicKeyEd25519(
133                        stellar_strkey::ed25519::PublicKey(*needle),
134                    )
135                    .to_string(),
136                });
137            };
138
139            sign_soroban_authorization_entry(
140                raw_auth,
141                signer,
142                signature_expiration_ledger,
143                &network_id,
144            )
145        })
146        .collect::<Result<Vec<_>, Error>>()?;
147
148    body.auth = signed_auths.try_into()?;
149    tx.operations = vec![op].try_into()?;
150    Ok(Some(tx))
151}
152
153fn sign_soroban_authorization_entry(
154    raw: &SorobanAuthorizationEntry,
155    signer: &ed25519_dalek::SigningKey,
156    signature_expiration_ledger: u32,
157    network_id: &Hash,
158) -> Result<SorobanAuthorizationEntry, Error> {
159    let mut auth = raw.clone();
160    let SorobanAuthorizationEntry {
161        credentials: SorobanCredentials::Address(ref mut credentials),
162        ..
163    } = auth
164    else {
165        // Doesn't need special signing
166        return Ok(auth);
167    };
168    let SorobanAddressCredentials { nonce, .. } = credentials;
169
170    let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization {
171        network_id: network_id.clone(),
172        invocation: auth.root_invocation.clone(),
173        nonce: *nonce,
174        signature_expiration_ledger,
175    })
176    .to_xdr(Limits::none())?;
177
178    let payload = Sha256::digest(preimage);
179    let signature = signer.sign(&payload);
180
181    let map = ScMap::sorted_from(vec![
182        (
183            ScVal::Symbol(ScSymbol("public_key".try_into()?)),
184            ScVal::Bytes(
185                signer
186                    .verifying_key()
187                    .to_bytes()
188                    .to_vec()
189                    .try_into()
190                    .map_err(Error::Xdr)?,
191            ),
192        ),
193        (
194            ScVal::Symbol(ScSymbol("signature".try_into()?)),
195            ScVal::Bytes(
196                signature
197                    .to_bytes()
198                    .to_vec()
199                    .try_into()
200                    .map_err(Error::Xdr)?,
201            ),
202        ),
203    ])
204    .map_err(Error::Xdr)?;
205    credentials.signature = ScVal::Vec(Some(
206        vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?,
207    ));
208    credentials.signature_expiration_ledger = signature_expiration_ledger;
209    auth.credentials = SorobanCredentials::Address(credentials.clone());
210    Ok(auth)
211}
212
213pub struct Signer {
214    pub kind: SignerKind,
215    pub print: Print,
216}
217
218#[allow(clippy::module_name_repetitions, clippy::large_enum_variant)]
219pub enum SignerKind {
220    Local(LocalKey),
221    Ledger(ledger::LedgerType),
222    Lab,
223    SecureStore(SecureStoreEntry),
224}
225
226// Instead of using `sign_tx` and `sign_tx_env` directly, it is advised to instead use the sign_with module
227// which allows for signing with a local key, lab or a ledger device
228impl Signer {
229    pub async fn sign_tx(
230        &self,
231        tx: Transaction,
232        network: &Network,
233    ) -> Result<TransactionEnvelope, Error> {
234        let tx_env = TransactionEnvelope::Tx(TransactionV1Envelope {
235            tx,
236            signatures: VecM::default(),
237        });
238        self.sign_tx_env(&tx_env, network).await
239    }
240
241    pub async fn sign_tx_env(
242        &self,
243        tx_env: &TransactionEnvelope,
244        network: &Network,
245    ) -> Result<TransactionEnvelope, Error> {
246        match &tx_env {
247            TransactionEnvelope::Tx(TransactionV1Envelope { tx, signatures }) => {
248                let tx_hash = transaction_hash(tx, &network.network_passphrase)?;
249                self.print
250                    .infoln(format!("Signing transaction: {}", hex::encode(tx_hash),));
251                let decorated_signature = match &self.kind {
252                    SignerKind::Local(key) => key.sign_tx_hash(tx_hash)?,
253                    SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print)?,
254                    SignerKind::Ledger(ledger) => ledger.sign_transaction_hash(&tx_hash).await?,
255                    SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash)?,
256                };
257                let mut sigs = signatures.clone().into_vec();
258                sigs.push(decorated_signature);
259                Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
260                    tx: tx.clone(),
261                    signatures: sigs.try_into()?,
262                }))
263            }
264            _ => Err(Error::UnsupportedTransactionEnvelopeType),
265        }
266    }
267}
268
269pub struct LocalKey {
270    pub key: ed25519_dalek::SigningKey,
271}
272
273impl LocalKey {
274    pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
275        let hint = SignatureHint(self.key.verifying_key().to_bytes()[28..].try_into()?);
276        let signature = Signature(self.key.sign(&tx_hash).to_bytes().to_vec().try_into()?);
277        Ok(DecoratedSignature { hint, signature })
278    }
279}
280
281pub struct Lab;
282
283impl Lab {
284    const URL: &str = "https://lab.stellar.org/transaction/cli-sign";
285
286    pub fn sign_tx_env(
287        tx_env: &TransactionEnvelope,
288        network: &Network,
289        printer: &Print,
290    ) -> Result<DecoratedSignature, Error> {
291        let xdr = tx_env.to_xdr_base64(Limits::none())?;
292
293        let mut url = url::Url::parse(Self::URL)?;
294        url.query_pairs_mut()
295            .append_pair("networkPassphrase", &network.network_passphrase)
296            .append_pair("xdr", &xdr);
297        let url = url.to_string();
298
299        printer.globeln(format!("Opening lab to sign transaction: {url}"));
300        open::that(url)?;
301
302        Err(Error::ReturningSignatureFromLab)
303    }
304}
305
306pub struct SecureStoreEntry {
307    pub name: String,
308    pub hd_path: Option<usize>,
309}
310
311impl SecureStoreEntry {
312    pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
313        let hint = SignatureHint(
314            secure_store::get_public_key(&self.name, self.hd_path)?.0[28..].try_into()?,
315        );
316
317        let signed_tx_hash = secure_store::sign_tx_data(&self.name, self.hd_path, &tx_hash)?;
318
319        let signature = Signature(signed_tx_hash.clone().try_into()?);
320        Ok(DecoratedSignature { hint, signature })
321    }
322}