lit_rust_sdk/
auth.rs

1use crate::error::LitSdkError;
2use ed25519_dalek::SigningKey;
3use ethers::signers::{LocalWallet, Signer};
4use ethers::types::Address as EthAddress;
5use ethers::utils::{keccak256, to_checksum};
6use rand::rngs::OsRng;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct SessionKeyPair {
13    pub public_key: String,
14    pub secret_key: String,
15}
16
17pub fn generate_session_key_pair() -> SessionKeyPair {
18    let mut csprng = OsRng;
19    let signing_key = SigningKey::generate(&mut csprng);
20    let verifying_key = signing_key.verifying_key();
21
22    SessionKeyPair {
23        public_key: hex::encode(verifying_key.to_bytes()),
24        secret_key: hex::encode(signing_key.to_bytes()),
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct AuthData {
31    pub auth_method_id: String,
32    pub auth_method_type: u32,
33    pub access_token: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub public_key: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub metadata: Option<serde_json::Value>,
38}
39
40pub async fn create_eth_wallet_auth_data(
41    private_key_hex: &str,
42    nonce: &str,
43) -> Result<AuthData, LitSdkError> {
44    use chrono::{SecondsFormat, Utc};
45    use siwe::{Message, TimeStamp};
46
47    let wallet: LocalWallet = private_key_hex
48        .parse::<LocalWallet>()
49        .map_err(|e| LitSdkError::Config(format!("invalid private key: {e}")))?;
50    let checksum_address = to_checksum(&wallet.address(), None);
51
52    // Match JS defaults from `createSiweMessage` in `@lit-protocol/auth-helpers`.
53    let issued_at: TimeStamp = Utc::now()
54        .to_rfc3339_opts(SecondsFormat::Millis, true)
55        .parse::<TimeStamp>()
56        .map_err(|e| LitSdkError::Config(format!("invalid issued_at timestamp: {e}")))?;
57    let expiration_time: TimeStamp = (Utc::now() + chrono::Duration::days(7))
58        .to_rfc3339_opts(SecondsFormat::Millis, true)
59        .parse::<TimeStamp>()
60        .map_err(|e| LitSdkError::Config(format!("invalid expiration timestamp: {e}")))?;
61
62    let message = Message {
63        domain: "localhost"
64            .parse::<http::uri::Authority>()
65            .map_err(|e| LitSdkError::Config(format!("invalid domain: {e}")))?,
66        address: wallet.address().0,
67        statement: Some("This is a test statement.  You can put anything you want here.".into()),
68        uri: "https://localhost/login"
69            .parse::<iri_string::types::UriString>()
70            .map_err(|e| LitSdkError::Config(format!("invalid uri: {e}")))?,
71        version: siwe::Version::V1,
72        chain_id: 1,
73        nonce: nonce.to_string(),
74        issued_at,
75        expiration_time: Some(expiration_time),
76        not_before: None,
77        request_id: None,
78        resources: vec![],
79    };
80
81    let siwe_message = message.to_string();
82    let auth_sig = sign_siwe_with_eoa(private_key_hex, &siwe_message).await?;
83
84    let method_id_hash = keccak256(format!("{checksum_address}:lit").as_bytes());
85    let auth_method_id = format!("0x{}", hex::encode(method_id_hash));
86
87    Ok(AuthData {
88        auth_method_id,
89        auth_method_type: 1,
90        access_token: serde_json::to_string(&auth_sig)
91            .map_err(|e| LitSdkError::Config(format!("failed to serialize authSig: {e}")))?,
92        public_key: None,
93        metadata: None,
94    })
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct AuthSig {
100    pub sig: String,
101    pub derived_via: String,
102    pub signed_message: String,
103    pub address: String,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub algo: Option<String>,
106}
107
108#[derive(Debug, Clone)]
109pub struct AuthConfig {
110    pub capability_auth_sigs: Vec<AuthSig>,
111    pub expiration: String,
112    pub statement: String,
113    pub domain: String,
114    pub resources: Vec<ResourceAbilityRequest>,
115}
116
117#[derive(Debug, Clone, Default)]
118pub struct CustomAuthParams {
119    pub lit_action_code: Option<String>,
120    pub lit_action_ipfs_id: Option<String>,
121    pub js_params: Option<serde_json::Value>,
122}
123
124#[derive(Debug, Clone)]
125pub struct ResourceAbilityRequest {
126    pub ability: LitAbility,
127    pub resource_id: String,
128    pub data: Option<serde_json::Value>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum LitAbility {
133    AccessControlConditionDecryption,
134    AccessControlConditionSigning,
135    PKPSigning,
136    PaymentDelegation,
137    LitActionExecution,
138    /// Recap-only ability used by JS EOA auth contexts for resolved auth.
139    ResolvedAuthContext,
140}
141
142impl FromStr for LitAbility {
143    type Err = ();
144
145    fn from_str(s: &str) -> Result<Self, Self::Err> {
146        match s {
147            "access-control-condition-decryption" => {
148                Ok(LitAbility::AccessControlConditionDecryption)
149            }
150            "access-control-condition-signing" => Ok(LitAbility::AccessControlConditionSigning),
151            "pkp-signing" => Ok(LitAbility::PKPSigning),
152            "lit-payment-delegation" => Ok(LitAbility::PaymentDelegation),
153            "lit-action-execution" => Ok(LitAbility::LitActionExecution),
154            "lit-resolved-auth-context" => Ok(LitAbility::ResolvedAuthContext),
155            _ => Err(()),
156        }
157    }
158}
159
160impl LitAbility {
161    pub fn as_str(&self) -> &'static str {
162        match self {
163            LitAbility::AccessControlConditionDecryption => "access-control-condition-decryption",
164            LitAbility::AccessControlConditionSigning => "access-control-condition-signing",
165            LitAbility::PKPSigning => "pkp-signing",
166            LitAbility::PaymentDelegation => "lit-payment-delegation",
167            LitAbility::LitActionExecution => "lit-action-execution",
168            LitAbility::ResolvedAuthContext => "lit-resolved-auth-context",
169        }
170    }
171
172    fn recap_namespace_and_ability(&self) -> (&'static str, &'static str) {
173        match self {
174            LitAbility::AccessControlConditionDecryption => ("Threshold", "Decryption"),
175            LitAbility::AccessControlConditionSigning => ("Threshold", "Signing"),
176            LitAbility::PKPSigning => ("Threshold", "Signing"),
177            LitAbility::PaymentDelegation => ("Auth", "Auth"),
178            LitAbility::LitActionExecution => ("Threshold", "Execution"),
179            LitAbility::ResolvedAuthContext => ("Auth", "Auth"),
180        }
181    }
182
183    pub fn resource_prefix(&self) -> &'static str {
184        match self {
185            LitAbility::AccessControlConditionDecryption
186            | LitAbility::AccessControlConditionSigning => "lit-accesscontrolcondition",
187            LitAbility::PKPSigning => "lit-pkp",
188            LitAbility::PaymentDelegation => "lit-paymentdelegation",
189            LitAbility::LitActionExecution => "lit-litaction",
190            LitAbility::ResolvedAuthContext => "lit-resolvedauthcontext",
191        }
192    }
193
194    pub fn resource_key(&self, resource_id: &str) -> String {
195        format!("{}://{}", self.resource_prefix(), resource_id)
196    }
197}
198
199/// Build a SIWE message (with a single recap URN) matching JS behavior.
200pub fn create_siwe_message_with_resources(
201    wallet_address: &str,
202    session_public_key_hex: &str,
203    auth_config: &AuthConfig,
204    nonce: &str,
205) -> Result<String, LitSdkError> {
206    use chrono::{SecondsFormat, Utc};
207    use siwe::{Message, TimeStamp};
208    use siwe_recap::Capability;
209    use std::collections::BTreeMap;
210
211    let uri = format!("lit:session:{}", session_public_key_hex);
212
213    let eth_addr: EthAddress = wallet_address
214        .parse::<EthAddress>()
215        .map_err(|e| LitSdkError::Config(format!("invalid wallet address: {e}")))?;
216
217    let issued_at: TimeStamp = Utc::now()
218        .to_rfc3339_opts(SecondsFormat::Millis, true)
219        .parse::<TimeStamp>()
220        .map_err(|e| LitSdkError::Config(format!("invalid issued_at timestamp: {e}")))?;
221
222    let expiration_time: Option<TimeStamp> = Some(
223        auth_config
224            .expiration
225            .parse::<TimeStamp>()
226            .map_err(|e| LitSdkError::Config(format!("invalid expiration timestamp: {e}")))?,
227    );
228
229    let message = Message {
230        domain: auth_config
231            .domain
232            .parse::<http::uri::Authority>()
233            .map_err(|e| LitSdkError::Config(format!("invalid domain: {e}")))?,
234        address: eth_addr.0,
235        statement: Some(auth_config.statement.clone()),
236        uri: uri
237            .parse::<iri_string::types::UriString>()
238            .map_err(|e| LitSdkError::Config(format!("invalid session uri: {e}")))?,
239        version: siwe::Version::V1,
240        chain_id: 1,
241        nonce: nonce.to_string(),
242        issued_at,
243        expiration_time,
244        not_before: None,
245        request_id: None,
246        resources: vec![],
247    };
248
249    if auth_config.resources.is_empty() {
250        return Ok(message.to_string());
251    }
252
253    let mut cap = Capability::<serde_json::Value>::default();
254    for req in &auth_config.resources {
255        let (ns, ability) = req.ability.recap_namespace_and_ability();
256        let resource_key = req.ability.resource_key(&req.resource_id);
257
258        let nb_map: BTreeMap<String, serde_json::Value> = match &req.data {
259            Some(val) => val
260                .as_object()
261                .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
262                .unwrap_or_default(),
263            None => BTreeMap::new(),
264        };
265
266        cap.with_action_convert(resource_key, format!("{}/{}", ns, ability), vec![nb_map])
267            .map_err(|e| LitSdkError::Config(format!("failed to add recap attenuation: {e}")))?;
268    }
269
270    let message = cap
271        .build_message(message)
272        .map_err(|e| LitSdkError::Config(e.to_string()))?;
273
274    Ok(message.to_string())
275}
276
277pub fn validate_delegation_auth_sig(
278    delegation_auth_sig: &AuthSig,
279    session_public_key_hex: &str,
280) -> Result<(), LitSdkError> {
281    let expected_session_key_uri = if session_public_key_hex.starts_with("lit:session:") {
282        session_public_key_hex.to_string()
283    } else {
284        format!("lit:session:{session_public_key_hex}")
285    };
286
287    let msg = siwe::Message::from_str(&delegation_auth_sig.signed_message)
288        .map_err(|e| LitSdkError::Crypto(format!("invalid delegation SIWE message: {e}")))?;
289
290    if let Some(exp) = msg.expiration_time {
291        let exp_str = exp.to_string();
292        let exp_dt = chrono::DateTime::parse_from_rfc3339(&exp_str)
293            .map_err(|e| LitSdkError::Crypto(format!("invalid delegation expiration: {e}")))?;
294        if exp_dt <= chrono::Utc::now() {
295            return Err(LitSdkError::Crypto(format!(
296                "delegation signature expired at {exp_str}"
297            )));
298        }
299    }
300
301    let uri = msg.uri.to_string();
302    if uri != expected_session_key_uri {
303        return Err(LitSdkError::Crypto(
304            "session key URI in delegation signature does not match".into(),
305        ));
306    }
307
308    Ok(())
309}
310
311fn base64url_decode_vec(input: &str) -> Result<Vec<u8>, LitSdkError> {
312    use base64ct::{Base64Url, Base64UrlUnpadded, Encoding};
313
314    Base64UrlUnpadded::decode_vec(input)
315        .or_else(|_| Base64Url::decode_vec(input))
316        .map_err(|e| LitSdkError::Crypto(format!("invalid base64url payload: {e}")))
317}
318
319fn resource_ability_requests_from_recap_urn(
320    urn: &str,
321) -> Result<Vec<ResourceAbilityRequest>, LitSdkError> {
322    let encoded = urn
323        .strip_prefix("urn:recap:")
324        .ok_or_else(|| LitSdkError::Crypto("invalid recap URN".into()))?;
325
326    let decoded = base64url_decode_vec(encoded)?;
327    let payload: serde_json::Value = serde_json::from_slice(&decoded)
328        .map_err(|e| LitSdkError::Crypto(format!("invalid recap JSON: {e}")))?;
329
330    let att = payload
331        .get("att")
332        .and_then(|v| v.as_object())
333        .ok_or_else(|| LitSdkError::Crypto("invalid recap attenuation payload".into()))?;
334
335    let mut out = Vec::new();
336
337    for (resource_key, ability_map) in att {
338        let Some(ability_map) = ability_map.as_object() else {
339            continue;
340        };
341
342        let (resource_prefix, resource_id) = resource_key
343            .split_once("://")
344            .unwrap_or((resource_key.as_str(), "*"));
345
346        for (ability_key, restrictions) in ability_map {
347            let (namespace, recap_ability) = ability_key
348                .split_once('/')
349                .unwrap_or((ability_key.as_str(), ""));
350
351            let ability = match (resource_prefix, namespace, recap_ability) {
352                ("lit-pkp", "Threshold", "Signing") => LitAbility::PKPSigning,
353                ("lit-accesscontrolcondition", "Threshold", "Signing") => {
354                    LitAbility::AccessControlConditionSigning
355                }
356                ("lit-accesscontrolcondition", "Threshold", "Decryption") => {
357                    LitAbility::AccessControlConditionDecryption
358                }
359                ("lit-litaction", "Threshold", "Execution") => LitAbility::LitActionExecution,
360                ("lit-paymentdelegation", "Auth", "Auth") => LitAbility::PaymentDelegation,
361                ("lit-resolvedauthcontext", "Auth", "Auth") => LitAbility::ResolvedAuthContext,
362                _ => continue,
363            };
364
365            let mut data = None;
366            if let Some(arr) = restrictions.as_array() {
367                if let Some(obj) = arr.iter().find(|v| v.is_object()) {
368                    if obj.as_object().map(|o| !o.is_empty()).unwrap_or(false) {
369                        data = Some(obj.clone());
370                    }
371                }
372            }
373
374            out.push(ResourceAbilityRequest {
375                ability,
376                resource_id: resource_id.to_string(),
377                data,
378            });
379        }
380    }
381
382    Ok(out)
383}
384
385pub fn auth_config_from_delegation_auth_sig(
386    delegation_auth_sig: &AuthSig,
387) -> Result<AuthConfig, LitSdkError> {
388    let msg = siwe::Message::from_str(&delegation_auth_sig.signed_message)
389        .map_err(|e| LitSdkError::Crypto(format!("invalid SIWE message: {e}")))?;
390
391    let expiration = msg
392        .expiration_time
393        .map(|t| t.to_string())
394        .unwrap_or_else(|| (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339());
395
396    let mut resources: Vec<ResourceAbilityRequest> = Vec::new();
397    for uri in &msg.resources {
398        let s = uri.to_string();
399        if !s.starts_with("urn:recap:") {
400            continue;
401        }
402        if let Ok(mut decoded) = resource_ability_requests_from_recap_urn(&s) {
403            resources.append(&mut decoded);
404        }
405    }
406
407    if resources.is_empty() {
408        resources.push(ResourceAbilityRequest {
409            ability: LitAbility::PKPSigning,
410            resource_id: "*".into(),
411            data: None,
412        });
413    }
414
415    Ok(AuthConfig {
416        capability_auth_sigs: vec![],
417        expiration,
418        statement: msg.statement.unwrap_or_default(),
419        domain: msg.domain.to_string(),
420        resources,
421    })
422}
423
424pub fn pkp_eth_address_from_pubkey(pkp_public_key_hex: &str) -> Result<String, LitSdkError> {
425    let pkp_hex = pkp_public_key_hex.trim_start_matches("0x");
426    let pkp_bytes = hex::decode(pkp_hex)
427        .map_err(|e| LitSdkError::Config(format!("invalid pkp public key hex: {e}")))?;
428    if pkp_bytes.len() < 2 {
429        return Err(LitSdkError::Config("pkp public key too short".into()));
430    }
431    let hash = keccak256(&pkp_bytes[1..]);
432    let addr = EthAddress::from_slice(&hash[12..]);
433    Ok(to_checksum(&addr, None))
434}
435
436/// Sign a SIWE message with an EOA private key (EIP-191 personal_sign).
437pub async fn sign_siwe_with_eoa(
438    private_key_hex: &str,
439    siwe_message: &str,
440) -> Result<AuthSig, LitSdkError> {
441    let wallet: LocalWallet = private_key_hex
442        .parse::<LocalWallet>()
443        .map_err(|e| LitSdkError::Config(format!("invalid private key: {e}")))?;
444    let address = ethers::utils::to_checksum(&wallet.address(), None);
445
446    let sig = wallet
447        .sign_message(siwe_message)
448        .await
449        .map_err(|e| LitSdkError::Crypto(e.to_string()))?;
450
451    Ok(AuthSig {
452        sig: sig.to_string(),
453        derived_via: "web3.eth.personal.sign".into(),
454        signed_message: siwe_message.into(),
455        address,
456        algo: None,
457    })
458}
459
460#[derive(Debug, Clone)]
461pub struct AuthContext {
462    pub session_key_pair: SessionKeyPair,
463    pub auth_config: AuthConfig,
464    pub delegation_auth_sig: AuthSig,
465}