Skip to main content

ows_lib/
key_ops.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use ows_core::{ApiKeyFile, EncryptedWallet, OwsError};
5use ows_signer::{
6    decrypt, eip712, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, SecretBytes,
7};
8
9use crate::error::OwsLibError;
10use crate::key_store;
11use crate::policy_engine;
12use crate::policy_store;
13use crate::vault;
14
15/// Create an API key for agent access to one or more wallets.
16///
17/// 1. Authenticates with the owner's passphrase
18/// 2. Decrypts the wallet secret for each wallet
19/// 3. Generates a random token (`ows_key_...`)
20/// 4. Re-encrypts each secret under HKDF(token)
21/// 5. Stores the key file with token hash, policy IDs, and encrypted copies
22/// 6. Returns the raw token (shown once to the user)
23pub fn create_api_key(
24    name: &str,
25    wallet_ids: &[String],
26    policy_ids: &[String],
27    passphrase: &str,
28    expires_at: Option<&str>,
29    vault_path: Option<&Path>,
30) -> Result<(String, ApiKeyFile), OwsLibError> {
31    // Validate that all wallets exist and passphrase works
32    let mut wallet_secrets = HashMap::new();
33    let mut resolved_wallet_ids = Vec::with_capacity(wallet_ids.len());
34    let token = key_store::generate_token();
35
36    for wallet_id in wallet_ids {
37        let wallet = vault::load_wallet_by_name_or_id(wallet_id, vault_path)?;
38        let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
39
40        // Decrypt with owner's passphrase to verify it works
41        let secret = decrypt(&envelope, passphrase)?;
42
43        // Re-encrypt under HKDF(token)
44        let hkdf_envelope = encrypt_with_hkdf(secret.expose(), &token)?;
45        let envelope_json = serde_json::to_value(&hkdf_envelope)?;
46
47        wallet_secrets.insert(wallet.id.clone(), envelope_json);
48        // Always persist canonical wallet IDs (UUIDs). Callers may pass names or IDs;
49        // agent signing checks `contains(wallet.id)` when verifying scope.
50        resolved_wallet_ids.push(wallet.id.clone());
51    }
52
53    // Validate that all policies exist
54    for policy_id in policy_ids {
55        policy_store::load_policy(policy_id, vault_path)?;
56    }
57
58    let id = uuid::Uuid::new_v4().to_string();
59    let key_file = ApiKeyFile {
60        id,
61        name: name.to_string(),
62        token_hash: key_store::hash_token(&token),
63        created_at: chrono::Utc::now().to_rfc3339(),
64        wallet_ids: resolved_wallet_ids,
65        policy_ids: policy_ids.to_vec(),
66        expires_at: expires_at.map(String::from),
67        wallet_secrets,
68    };
69
70    key_store::save_api_key(&key_file, vault_path)?;
71
72    Ok((token, key_file))
73}
74
75/// Sign a transaction using an API token (agent mode).
76///
77/// 1. Look up key file by SHA256(token)
78/// 2. Check expiry and wallet scope
79/// 3. Load and evaluate policies
80/// 4. HKDF(token) → decrypt wallet secret
81/// 5. Resolve signing key → sign
82pub fn sign_with_api_key(
83    token: &str,
84    wallet_name_or_id: &str,
85    chain: &ows_core::Chain,
86    tx_bytes: &[u8],
87    index: Option<u32>,
88    vault_path: Option<&Path>,
89) -> Result<crate::types::SignResult, OwsLibError> {
90    let (key, _) = enforce_policy_and_decrypt_key_with_raw_hex(
91        token,
92        wallet_name_or_id,
93        chain,
94        &hex::encode(tx_bytes),
95        index,
96        vault_path,
97    )?;
98
99    // 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers)
100    let signer = signer_for_chain(chain.chain_type);
101    let signable = signer.extract_signable_bytes(tx_bytes)?;
102    let output = signer.sign_transaction(key.expose(), signable)?;
103
104    Ok(crate::types::SignResult {
105        signature: hex::encode(&output.signature),
106        recovery_id: output.recovery_id,
107    })
108}
109
110/// Sign a message using an API token (agent mode).
111pub fn sign_message_with_api_key(
112    token: &str,
113    wallet_name_or_id: &str,
114    chain: &ows_core::Chain,
115    msg_bytes: &[u8],
116    index: Option<u32>,
117    vault_path: Option<&Path>,
118) -> Result<crate::types::SignResult, OwsLibError> {
119    let (key, _) = enforce_policy_and_decrypt_key_with_raw_hex(
120        token,
121        wallet_name_or_id,
122        chain,
123        &hex::encode(msg_bytes),
124        index,
125        vault_path,
126    )?;
127    let signer = signer_for_chain(chain.chain_type);
128    let output = signer.sign_message(key.expose(), msg_bytes)?;
129
130    Ok(crate::types::SignResult {
131        signature: hex::encode(&output.signature),
132        recovery_id: output.recovery_id,
133    })
134}
135
136/// Sign a raw 32-byte hash using an API token (agent mode).
137pub fn sign_hash_with_api_key(
138    token: &str,
139    wallet_name_or_id: &str,
140    chain: &ows_core::Chain,
141    policy_bytes: &[u8],
142    hash_bytes: &[u8],
143    index: Option<u32>,
144    vault_path: Option<&Path>,
145) -> Result<crate::types::SignResult, OwsLibError> {
146    let (key, _) = enforce_policy_and_decrypt_key_with_raw_hex(
147        token,
148        wallet_name_or_id,
149        chain,
150        &hex::encode(policy_bytes),
151        index,
152        vault_path,
153    )?;
154
155    let signer = signer_for_chain(chain.chain_type);
156    let output = signer.sign(key.expose(), hash_bytes)?;
157
158    Ok(crate::types::SignResult {
159        signature: hex::encode(&output.signature),
160        recovery_id: output.recovery_id,
161    })
162}
163
164/// Sign EIP-712 typed data using an API token (agent mode).
165///
166/// EVM-only. Parses the typed data JSON before policy evaluation so that
167/// the structured `TypedDataContext` is available to declarative rules and
168/// executable policies.
169pub fn sign_typed_data_with_api_key(
170    token: &str,
171    wallet_name_or_id: &str,
172    chain: &ows_core::Chain,
173    typed_data_json: &str,
174    index: Option<u32>,
175    vault_path: Option<&Path>,
176) -> Result<crate::types::SignResult, OwsLibError> {
177    // 1. EVM-only gate — cheapest check first
178    if chain.chain_type != ows_core::ChainType::Evm {
179        return Err(OwsLibError::InvalidInput(
180            "EIP-712 typed data signing is only supported for EVM chains".into(),
181        ));
182    }
183
184    // 2. Token lookup
185    let token_hash = key_store::hash_token(token);
186    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
187
188    // 3. Expiry check
189    check_expiry(&key_file)?;
190
191    // 4. Wallet scope check
192    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
193    if !key_file.wallet_ids.contains(&wallet.id) {
194        return Err(OwsLibError::InvalidInput(format!(
195            "API key '{}' does not have access to wallet '{}'",
196            key_file.name, wallet.id,
197        )));
198    }
199
200    // 5. Parse typed data early — validates JSON and extracts domain fields
201    let parsed = eip712::parse_typed_data(typed_data_json)?;
202
203    // 5b. Validate domain.chainId matches the requested chain (if present)
204    // Prevents bypassing AllowedChains by submitting typed data with a different chainId
205    if let Some(domain_chain_id) = parsed.domain.get("chainId").and_then(parse_domain_chain_id) {
206        let expected_chain_id = chain
207            .evm_chain_id_u64()
208            .map_err(OwsLibError::InvalidInput)?;
209        if expected_chain_id != domain_chain_id {
210            return Err(OwsLibError::InvalidInput(format!(
211                "EIP-712 domain chainId ({}) does not match requested chain ({})",
212                domain_chain_id, chain.chain_id,
213            )));
214        }
215    }
216
217    // 6. Build PolicyContext with TypedDataContext
218    let policies = load_policies_for_key(&key_file, vault_path)?;
219    let now = chrono::Utc::now();
220    let date = now.format("%Y-%m-%d").to_string();
221
222    let typed_data_ctx = ows_core::policy::TypedDataContext {
223        verifying_contract: parsed
224            .domain
225            .get("verifyingContract")
226            .and_then(|v| v.as_str())
227            .map(String::from),
228        domain_chain_id: parsed.domain.get("chainId").and_then(parse_domain_chain_id),
229        primary_type: parsed.primary_type.clone(),
230        domain_name: parsed
231            .domain
232            .get("name")
233            .and_then(|v| v.as_str())
234            .map(String::from),
235        domain_version: parsed
236            .domain
237            .get("version")
238            .and_then(|v| v.as_str())
239            .map(String::from),
240        raw_json: typed_data_json.to_string(),
241    };
242
243    let context = ows_core::PolicyContext {
244        chain_id: chain.chain_id.to_string(),
245        wallet_id: wallet.id.clone(),
246        api_key_id: key_file.id.clone(),
247        transaction: ows_core::policy::TransactionContext {
248            to: None,
249            value: None,
250            raw_hex: String::new(),
251            data: None,
252        },
253        spending: noop_spending_context(&date),
254        timestamp: now.to_rfc3339(),
255        typed_data: Some(typed_data_ctx),
256    };
257
258    // 7. Evaluate policies
259    let result = policy_engine::evaluate_policies(&policies, &context);
260    if !result.allow {
261        return Err(OwsLibError::Core(OwsError::PolicyDenied {
262            policy_id: result.policy_id.unwrap_or_default(),
263            reason: result.reason.unwrap_or_else(|| "denied".into()),
264        }));
265    }
266
267    // 8. Decrypt key and sign
268    let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
269    let evm_signer = ows_signer::chains::EvmSigner;
270    let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
271
272    Ok(crate::types::SignResult {
273        signature: hex::encode(&output.signature),
274        recovery_id: output.recovery_id,
275    })
276}
277
278/// Enforce policies for a token-based transaction and return the decrypted
279/// signing key. Used by `sign_and_send` which needs the raw key for broadcast.
280pub fn enforce_policy_and_decrypt_key(
281    token: &str,
282    wallet_name_or_id: &str,
283    chain: &ows_core::Chain,
284    tx_bytes: &[u8],
285    index: Option<u32>,
286    vault_path: Option<&Path>,
287) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> {
288    enforce_policy_and_decrypt_key_with_raw_hex(
289        token,
290        wallet_name_or_id,
291        chain,
292        &hex::encode(tx_bytes),
293        index,
294        vault_path,
295    )
296}
297
298fn enforce_policy_and_decrypt_key_with_raw_hex(
299    token: &str,
300    wallet_name_or_id: &str,
301    chain: &ows_core::Chain,
302    raw_hex: &str,
303    index: Option<u32>,
304    vault_path: Option<&Path>,
305) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> {
306    let token_hash = key_store::hash_token(token);
307    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
308    check_expiry(&key_file)?;
309
310    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
311    if !key_file.wallet_ids.contains(&wallet.id) {
312        return Err(OwsLibError::InvalidInput(format!(
313            "API key '{}' does not have access to wallet '{}'",
314            key_file.name, wallet.id,
315        )));
316    }
317
318    let policies = load_policies_for_key(&key_file, vault_path)?;
319    let now = chrono::Utc::now();
320    let date = now.format("%Y-%m-%d").to_string();
321
322    let context = ows_core::PolicyContext {
323        chain_id: chain.chain_id.to_string(),
324        wallet_id: wallet.id.clone(),
325        api_key_id: key_file.id.clone(),
326        transaction: ows_core::policy::TransactionContext {
327            to: None,
328            value: None,
329            raw_hex: raw_hex.to_string(),
330            data: None,
331        },
332        spending: noop_spending_context(&date),
333        timestamp: now.to_rfc3339(),
334        typed_data: None,
335    };
336
337    let result = policy_engine::evaluate_policies(&policies, &context);
338    if !result.allow {
339        return Err(OwsLibError::Core(OwsError::PolicyDenied {
340            policy_id: result.policy_id.unwrap_or_default(),
341            reason: result.reason.unwrap_or_else(|| "denied".into()),
342        }));
343    }
344
345    let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
346
347    Ok((key, key_file))
348}
349
350// ---------------------------------------------------------------------------
351// Helpers
352// ---------------------------------------------------------------------------
353
354/// Parse a serde_json Value as a u64 chain ID.
355/// Handles both string ("8453") and number (8453) representations.
356fn parse_domain_chain_id(v: &serde_json::Value) -> Option<u64> {
357    v.as_str()
358        .and_then(|s| s.parse::<u64>().ok())
359        .or_else(|| v.as_u64())
360}
361
362fn noop_spending_context(date: &str) -> ows_core::policy::SpendingContext {
363    ows_core::policy::SpendingContext {
364        daily_total: "0".to_string(),
365        date: date.to_string(),
366    }
367}
368
369fn check_expiry(key_file: &ApiKeyFile) -> Result<(), OwsLibError> {
370    if let Some(ref expires) = key_file.expires_at {
371        let now = chrono::Utc::now();
372        let exp = chrono::DateTime::parse_from_rfc3339(expires).map_err(|e| {
373            OwsLibError::Core(OwsError::InvalidInput {
374                message: format!("invalid expires_at timestamp '{}': {}", expires, e),
375            })
376        })?;
377        if now > exp {
378            return Err(OwsLibError::Core(OwsError::ApiKeyExpired {
379                id: key_file.id.clone(),
380            }));
381        }
382    }
383    Ok(())
384}
385
386fn load_policies_for_key(
387    key_file: &ApiKeyFile,
388    vault_path: Option<&Path>,
389) -> Result<Vec<ows_core::Policy>, OwsLibError> {
390    let mut policies = Vec::with_capacity(key_file.policy_ids.len());
391    for pid in &key_file.policy_ids {
392        policies.push(policy_store::load_policy(pid, vault_path)?);
393    }
394    Ok(policies)
395}
396
397fn decrypt_key_from_api_key(
398    key_file: &ApiKeyFile,
399    wallet: &EncryptedWallet,
400    token: &str,
401    chain_type: ows_core::ChainType,
402    index: Option<u32>,
403) -> Result<SecretBytes, OwsLibError> {
404    let envelope_value = key_file.wallet_secrets.get(&wallet.id).ok_or_else(|| {
405        OwsLibError::InvalidInput(format!(
406            "API key has no encrypted secret for wallet {}",
407            wallet.id
408        ))
409    })?;
410
411    let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
412    let secret = decrypt(&envelope, token)?;
413    crate::ops::secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use ows_core::{EncryptedWallet, KeyType, PolicyAction, PolicyRule, WalletAccount};
420    use ows_signer::encrypt;
421
422    /// Create a test wallet in the vault, return its ID.
423    fn setup_test_wallet(vault: &Path, passphrase: &str) -> String {
424        let mnemonic_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
425        let envelope = encrypt(mnemonic_phrase.as_bytes(), passphrase).unwrap();
426        let crypto = serde_json::to_value(&envelope).unwrap();
427
428        let wallet = EncryptedWallet::new(
429            "test-wallet-id".to_string(),
430            "test-wallet".to_string(),
431            vec![WalletAccount {
432                account_id: "eip155:8453:0xabc".to_string(),
433                address: "0xabc".to_string(),
434                chain_id: "eip155:8453".to_string(),
435                derivation_path: "m/44'/60'/0'/0/0".to_string(),
436            }],
437            crypto,
438            KeyType::Mnemonic,
439        );
440
441        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
442        wallet.id
443    }
444
445    fn setup_test_policy(vault: &Path) -> String {
446        let policy = ows_core::Policy {
447            id: "test-policy".to_string(),
448            name: "Test Policy".to_string(),
449            version: 1,
450            created_at: "2026-03-22T10:00:00Z".to_string(),
451            rules: vec![PolicyRule::AllowedChains {
452                chain_ids: vec!["eip155:8453".to_string()],
453            }],
454            executable: None,
455            config: None,
456            action: PolicyAction::Deny,
457        };
458        policy_store::save_policy(&policy, Some(vault)).unwrap();
459        policy.id
460    }
461
462    #[test]
463    fn create_api_key_and_verify_token() {
464        let dir = tempfile::tempdir().unwrap();
465        let vault = dir.path().to_path_buf();
466        let passphrase = "test-pass";
467
468        let wallet_id = setup_test_wallet(&vault, passphrase);
469        let policy_id = setup_test_policy(&vault);
470
471        let (token, key_file) = create_api_key(
472            "test-agent",
473            std::slice::from_ref(&wallet_id),
474            std::slice::from_ref(&policy_id),
475            passphrase,
476            None,
477            Some(&vault),
478        )
479        .unwrap();
480
481        // Token has correct format
482        assert!(token.starts_with("ows_key_"));
483
484        // Key file has correct data
485        assert_eq!(key_file.name, "test-agent");
486        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
487        assert_eq!(key_file.policy_ids, vec![policy_id]);
488        assert_eq!(key_file.token_hash, key_store::hash_token(&token));
489        assert!(key_file.expires_at.is_none());
490
491        // Wallet secret is present and decryptable
492        assert!(key_file.wallet_secrets.contains_key(&wallet_id));
493        let envelope: CryptoEnvelope =
494            serde_json::from_value(key_file.wallet_secrets[&wallet_id].clone()).unwrap();
495        let decrypted = decrypt(&envelope, &token).unwrap();
496        assert_eq!(
497            std::str::from_utf8(decrypted.expose()).unwrap(),
498            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
499        );
500
501        // Key is persisted and loadable
502        let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
503        assert_eq!(loaded.name, "test-agent");
504    }
505
506    /// Regression: API key scope must store resolved wallet UUIDs even when the caller passes a
507    /// wallet *name* (Node bindings and other callers pass strings that may be names).
508    #[test]
509    fn create_api_key_accepts_wallet_name_and_stores_canonical_ids() {
510        let dir = tempfile::tempdir().unwrap();
511        let vault = dir.path().to_path_buf();
512        let passphrase = "test-pass";
513
514        let wallet_id = setup_test_wallet(&vault, passphrase);
515        let policy_id = setup_test_policy(&vault);
516
517        let (token, key_file) = create_api_key(
518            "name-input-agent",
519            &["test-wallet".to_string()],
520            std::slice::from_ref(&policy_id),
521            passphrase,
522            None,
523            Some(&vault),
524        )
525        .unwrap();
526
527        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
528
529        let chain = ows_core::parse_chain("base").unwrap();
530        let tx_bytes = vec![0u8; 32];
531        let result =
532            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
533        assert!(
534            result.is_ok(),
535            "sign_with_api_key failed: {:?}",
536            result.err()
537        );
538    }
539
540    #[test]
541    fn create_api_key_wrong_passphrase_fails() {
542        let dir = tempfile::tempdir().unwrap();
543        let vault = dir.path().to_path_buf();
544
545        let wallet_id = setup_test_wallet(&vault, "correct");
546        let policy_id = setup_test_policy(&vault);
547
548        let result = create_api_key(
549            "agent",
550            &[wallet_id],
551            &[policy_id],
552            "wrong-passphrase",
553            None,
554            Some(&vault),
555        );
556        assert!(result.is_err());
557    }
558
559    #[test]
560    fn create_api_key_nonexistent_wallet_fails() {
561        let dir = tempfile::tempdir().unwrap();
562        let vault = dir.path().to_path_buf();
563        let policy_id = setup_test_policy(&vault);
564
565        let result = create_api_key(
566            "agent",
567            &["nonexistent".to_string()],
568            &[policy_id],
569            "pass",
570            None,
571            Some(&vault),
572        );
573        assert!(result.is_err());
574    }
575
576    #[test]
577    fn create_api_key_nonexistent_policy_fails() {
578        let dir = tempfile::tempdir().unwrap();
579        let vault = dir.path().to_path_buf();
580
581        let wallet_id = setup_test_wallet(&vault, "pass");
582
583        let result = create_api_key(
584            "agent",
585            &[wallet_id],
586            &["nonexistent-policy".to_string()],
587            "pass",
588            None,
589            Some(&vault),
590        );
591        assert!(result.is_err());
592    }
593
594    #[test]
595    fn create_api_key_with_expiry() {
596        let dir = tempfile::tempdir().unwrap();
597        let vault = dir.path().to_path_buf();
598
599        let wallet_id = setup_test_wallet(&vault, "pass");
600        let policy_id = setup_test_policy(&vault);
601
602        let (_, key_file) = create_api_key(
603            "expiring-agent",
604            &[wallet_id],
605            &[policy_id],
606            "pass",
607            Some("2026-12-31T00:00:00Z"),
608            Some(&vault),
609        )
610        .unwrap();
611
612        assert_eq!(key_file.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
613    }
614
615    #[test]
616    fn sign_with_api_key_full_flow() {
617        let dir = tempfile::tempdir().unwrap();
618        let vault = dir.path().to_path_buf();
619        let passphrase = "test-pass";
620
621        let wallet_id = setup_test_wallet(&vault, passphrase);
622        let policy_id = setup_test_policy(&vault);
623
624        let (token, _) = create_api_key(
625            "signer-agent",
626            &[wallet_id],
627            &[policy_id],
628            passphrase,
629            None,
630            Some(&vault),
631        )
632        .unwrap();
633
634        // Sign a dummy transaction on the allowed chain
635        let chain = ows_core::parse_chain("base").unwrap();
636        let tx_bytes = vec![0u8; 32]; // dummy tx
637
638        let result =
639            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
640
641        // The signing should succeed (policy allows eip155:8453)
642        assert!(
643            result.is_ok(),
644            "sign_with_api_key failed: {:?}",
645            result.err()
646        );
647        let sign_result = result.unwrap();
648        assert!(!sign_result.signature.is_empty());
649    }
650
651    #[test]
652    fn imported_private_key_wallet_signs_with_api_key() {
653        let dir = tempfile::tempdir().unwrap();
654        let vault = dir.path().to_path_buf();
655
656        let wallet = crate::import_wallet_private_key(
657            "imported-wallet",
658            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
659            Some("evm"),
660            Some(""),
661            Some(&vault),
662            None,
663            None,
664        )
665        .unwrap();
666        let policy_id = setup_test_policy(&vault);
667
668        let (token, _) = create_api_key(
669            "imported-wallet-agent",
670            std::slice::from_ref(&wallet.id),
671            std::slice::from_ref(&policy_id),
672            "",
673            None,
674            Some(&vault),
675        )
676        .unwrap();
677
678        let chain = ows_core::parse_chain("base").unwrap();
679        let tx_bytes = vec![0u8; 32];
680
681        let tx_result = sign_with_api_key(
682            &token,
683            "imported-wallet",
684            &chain,
685            &tx_bytes,
686            None,
687            Some(&vault),
688        );
689        assert!(
690            tx_result.is_ok(),
691            "sign_with_api_key failed: {:?}",
692            tx_result.err()
693        );
694        assert!(!tx_result.unwrap().signature.is_empty());
695
696        let msg_result = sign_message_with_api_key(
697            &token,
698            "imported-wallet",
699            &chain,
700            b"hello",
701            None,
702            Some(&vault),
703        );
704        assert!(
705            msg_result.is_ok(),
706            "sign_message_with_api_key failed: {:?}",
707            msg_result.err()
708        );
709        assert!(!msg_result.unwrap().signature.is_empty());
710    }
711
712    #[test]
713    fn sign_with_api_key_wrong_chain_denied() {
714        let dir = tempfile::tempdir().unwrap();
715        let vault = dir.path().to_path_buf();
716        let passphrase = "test-pass";
717
718        let wallet_id = setup_test_wallet(&vault, passphrase);
719        let policy_id = setup_test_policy(&vault); // allows only eip155:8453
720
721        let (token, _) = create_api_key(
722            "agent",
723            &[wallet_id],
724            &[policy_id],
725            passphrase,
726            None,
727            Some(&vault),
728        )
729        .unwrap();
730
731        // Try to sign on a chain NOT in the policy allowlist
732        let chain = ows_core::parse_chain("ethereum").unwrap(); // eip155:1
733        let tx_bytes = vec![0u8; 32];
734
735        let result =
736            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
737
738        assert!(result.is_err());
739        match result.unwrap_err() {
740            OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
741                assert!(reason.contains("not in allowlist"));
742            }
743            other => panic!("expected PolicyDenied, got: {other}"),
744        }
745    }
746
747    #[test]
748    fn sign_with_api_key_expired_key_rejected() {
749        let dir = tempfile::tempdir().unwrap();
750        let vault = dir.path().to_path_buf();
751        let passphrase = "test-pass";
752
753        let wallet_id = setup_test_wallet(&vault, passphrase);
754        let policy_id = setup_test_policy(&vault);
755
756        let (token, _) = create_api_key(
757            "agent",
758            &[wallet_id],
759            &[policy_id],
760            passphrase,
761            Some("2020-01-01T00:00:00Z"), // already expired
762            Some(&vault),
763        )
764        .unwrap();
765
766        let chain = ows_core::parse_chain("base").unwrap();
767        let tx_bytes = vec![0u8; 32];
768
769        let result =
770            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
771
772        assert!(result.is_err());
773        match result.unwrap_err() {
774            OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
775            other => panic!("expected ApiKeyExpired, got: {other}"),
776        }
777    }
778
779    #[test]
780    fn sign_with_wrong_token_fails() {
781        let dir = tempfile::tempdir().unwrap();
782        let vault = dir.path().to_path_buf();
783        let passphrase = "test-pass";
784
785        let wallet_id = setup_test_wallet(&vault, passphrase);
786        let policy_id = setup_test_policy(&vault);
787
788        let (_token, _) = create_api_key(
789            "agent",
790            &[wallet_id],
791            &[policy_id],
792            passphrase,
793            None,
794            Some(&vault),
795        )
796        .unwrap();
797
798        let chain = ows_core::parse_chain("base").unwrap();
799        let tx_bytes = vec![0u8; 32];
800
801        let result = sign_with_api_key(
802            "ows_key_wrong_token",
803            "test-wallet",
804            &chain,
805            &tx_bytes,
806            None,
807            Some(&vault),
808        );
809
810        assert!(result.is_err());
811    }
812
813    #[test]
814    fn sign_wallet_not_in_scope_fails() {
815        let dir = tempfile::tempdir().unwrap();
816        let vault = dir.path().to_path_buf();
817        let passphrase = "test-pass";
818
819        // Create two wallets
820        let wallet_id = setup_test_wallet(&vault, passphrase);
821        let policy_id = setup_test_policy(&vault);
822
823        // Create a second wallet
824        let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
825        let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
826        let crypto2 = serde_json::to_value(&envelope2).unwrap();
827        let wallet2 = EncryptedWallet::new(
828            "wallet-2-id".to_string(),
829            "other-wallet".to_string(),
830            vec![],
831            crypto2,
832            KeyType::Mnemonic,
833        );
834        vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
835
836        // API key only has access to first wallet
837        let (token, _) = create_api_key(
838            "agent",
839            &[wallet_id],
840            &[policy_id],
841            passphrase,
842            None,
843            Some(&vault),
844        )
845        .unwrap();
846
847        let chain = ows_core::parse_chain("base").unwrap();
848        let tx_bytes = vec![0u8; 32];
849
850        // Try to sign with the second wallet → should fail
851        let result = sign_with_api_key(
852            &token,
853            "other-wallet",
854            &chain,
855            &tx_bytes,
856            None,
857            Some(&vault),
858        );
859
860        assert!(result.is_err());
861        match result.unwrap_err() {
862            OwsLibError::InvalidInput(msg) => {
863                assert!(msg.contains("does not have access"));
864            }
865            other => panic!("expected InvalidInput, got: {other}"),
866        }
867    }
868
869    fn test_typed_data_json() -> String {
870        serde_json::json!({
871            "types": {
872                "EIP712Domain": [
873                    {"name": "name", "type": "string"},
874                    {"name": "chainId", "type": "uint256"},
875                    {"name": "verifyingContract", "type": "address"}
876                ],
877                "PermitSingle": [
878                    {"name": "spender", "type": "address"},
879                    {"name": "value", "type": "uint256"}
880                ]
881            },
882            "primaryType": "PermitSingle",
883            "domain": {
884                "name": "Permit2",
885                "chainId": "8453",
886                "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
887            },
888            "message": {
889                "spender": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C",
890                "value": "1000000"
891            }
892        })
893        .to_string()
894    }
895
896    fn setup_typed_data_policy(vault: &Path) -> String {
897        let policy = ows_core::Policy {
898            id: "td-policy".to_string(),
899            name: "Typed Data Policy".to_string(),
900            version: 1,
901            created_at: "2026-03-22T10:00:00Z".to_string(),
902            rules: vec![
903                PolicyRule::AllowedChains {
904                    chain_ids: vec!["eip155:8453".to_string()],
905                },
906                PolicyRule::AllowedTypedDataContracts {
907                    contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".to_string()],
908                },
909            ],
910            executable: None,
911            config: None,
912            action: PolicyAction::Deny,
913        };
914        policy_store::save_policy(&policy, Some(vault)).unwrap();
915        policy.id
916    }
917
918    #[test]
919    fn sign_typed_data_with_api_key_happy_path() {
920        let dir = tempfile::tempdir().unwrap();
921        let vault = dir.path().to_path_buf();
922        let passphrase = "test-pass";
923        let wallet_id = setup_test_wallet(&vault, passphrase);
924        let policy_id = setup_typed_data_policy(&vault);
925        let (token, _) = create_api_key(
926            "td-agent",
927            &[wallet_id],
928            &[policy_id],
929            passphrase,
930            None,
931            Some(&vault),
932        )
933        .unwrap();
934        let chain = ows_core::parse_chain("base").unwrap();
935        let result = sign_typed_data_with_api_key(
936            &token,
937            "test-wallet",
938            &chain,
939            &test_typed_data_json(),
940            None,
941            Some(&vault),
942        );
943        assert!(
944            result.is_ok(),
945            "sign_typed_data_with_api_key failed: {:?}",
946            result.err()
947        );
948        let sign_result = result.unwrap();
949        assert!(!sign_result.signature.is_empty());
950        let v = sign_result.recovery_id.unwrap();
951        assert!(v == 27 || v == 28, "unexpected v value: {v}");
952    }
953
954    #[test]
955    fn sign_typed_data_with_api_key_non_evm_rejected() {
956        let dir = tempfile::tempdir().unwrap();
957        let vault = dir.path().to_path_buf();
958        let passphrase = "test-pass";
959        let wallet_id = setup_test_wallet(&vault, passphrase);
960        let policy_id = setup_test_policy(&vault);
961        let (token, _) = create_api_key(
962            "agent",
963            &[wallet_id],
964            &[policy_id],
965            passphrase,
966            None,
967            Some(&vault),
968        )
969        .unwrap();
970        let chain = ows_core::parse_chain("solana").unwrap();
971        let result = sign_typed_data_with_api_key(
972            &token,
973            "test-wallet",
974            &chain,
975            &test_typed_data_json(),
976            None,
977            Some(&vault),
978        );
979        assert!(result.is_err());
980        let err_msg = format!("{}", result.unwrap_err());
981        assert!(err_msg.contains("EVM"));
982    }
983
984    #[test]
985    fn sign_typed_data_with_api_key_wrong_contract_denied() {
986        let dir = tempfile::tempdir().unwrap();
987        let vault = dir.path().to_path_buf();
988        let passphrase = "test-pass";
989        let wallet_id = setup_test_wallet(&vault, passphrase);
990        let policy_id = setup_typed_data_policy(&vault);
991        let (token, _) = create_api_key(
992            "agent",
993            &[wallet_id],
994            &[policy_id],
995            passphrase,
996            None,
997            Some(&vault),
998        )
999        .unwrap();
1000        let wrong_contract_td = serde_json::json!({
1001            "types": {
1002                "EIP712Domain": [
1003                    {"name": "name", "type": "string"},
1004                    {"name": "verifyingContract", "type": "address"}
1005                ],
1006                "Order": [{"name": "maker", "type": "address"}]
1007            },
1008            "primaryType": "Order",
1009            "domain": {
1010                "name": "Seaport",
1011                "verifyingContract": "0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC"
1012            },
1013            "message": {"maker": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C"}
1014        })
1015        .to_string();
1016        let chain = ows_core::parse_chain("base").unwrap();
1017        let result = sign_typed_data_with_api_key(
1018            &token,
1019            "test-wallet",
1020            &chain,
1021            &wrong_contract_td,
1022            None,
1023            Some(&vault),
1024        );
1025        assert!(result.is_err());
1026        match result.unwrap_err() {
1027            OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
1028                assert!(reason.contains("not in allowed list"));
1029            }
1030            other => panic!("expected PolicyDenied, got: {other}"),
1031        }
1032    }
1033
1034    #[test]
1035    fn sign_typed_data_with_api_key_malformed_json_rejected() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let vault = dir.path().to_path_buf();
1038        let passphrase = "test-pass";
1039        let wallet_id = setup_test_wallet(&vault, passphrase);
1040        let policy_id = setup_test_policy(&vault);
1041        let (token, _) = create_api_key(
1042            "agent",
1043            &[wallet_id],
1044            &[policy_id],
1045            passphrase,
1046            None,
1047            Some(&vault),
1048        )
1049        .unwrap();
1050        let chain = ows_core::parse_chain("base").unwrap();
1051        let result = sign_typed_data_with_api_key(
1052            &token,
1053            "test-wallet",
1054            &chain,
1055            "not valid json",
1056            None,
1057            Some(&vault),
1058        );
1059        assert!(result.is_err());
1060    }
1061
1062    #[test]
1063    fn sign_typed_data_with_api_key_expired_key_rejected() {
1064        let dir = tempfile::tempdir().unwrap();
1065        let vault = dir.path().to_path_buf();
1066        let passphrase = "test-pass";
1067        let wallet_id = setup_test_wallet(&vault, passphrase);
1068        let policy_id = setup_test_policy(&vault);
1069        let (token, _) = create_api_key(
1070            "agent",
1071            &[wallet_id],
1072            &[policy_id],
1073            passphrase,
1074            Some("2020-01-01T00:00:00Z"),
1075            Some(&vault),
1076        )
1077        .unwrap();
1078        let chain = ows_core::parse_chain("base").unwrap();
1079        let result = sign_typed_data_with_api_key(
1080            &token,
1081            "test-wallet",
1082            &chain,
1083            &test_typed_data_json(),
1084            None,
1085            Some(&vault),
1086        );
1087        assert!(result.is_err());
1088        match result.unwrap_err() {
1089            OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
1090            other => panic!("expected ApiKeyExpired, got: {other}"),
1091        }
1092    }
1093
1094    #[test]
1095    fn sign_typed_data_with_api_key_wallet_not_in_scope() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let vault = dir.path().to_path_buf();
1098        let passphrase = "test-pass";
1099        let wallet_id = setup_test_wallet(&vault, passphrase);
1100        let policy_id = setup_test_policy(&vault);
1101        let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
1102        let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
1103        let crypto2 = serde_json::to_value(&envelope2).unwrap();
1104        let wallet2 = EncryptedWallet::new(
1105            "wallet-2-id".to_string(),
1106            "other-wallet".to_string(),
1107            vec![],
1108            crypto2,
1109            KeyType::Mnemonic,
1110        );
1111        vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
1112        let (token, _) = create_api_key(
1113            "agent",
1114            &[wallet_id],
1115            &[policy_id],
1116            passphrase,
1117            None,
1118            Some(&vault),
1119        )
1120        .unwrap();
1121        let chain = ows_core::parse_chain("base").unwrap();
1122        let result = sign_typed_data_with_api_key(
1123            &token,
1124            "other-wallet",
1125            &chain,
1126            &test_typed_data_json(),
1127            None,
1128            Some(&vault),
1129        );
1130        assert!(result.is_err());
1131        match result.unwrap_err() {
1132            OwsLibError::InvalidInput(msg) => {
1133                assert!(msg.contains("does not have access"));
1134            }
1135            other => panic!("expected InvalidInput, got: {other}"),
1136        }
1137    }
1138
1139    #[test]
1140    fn sign_typed_data_with_api_key_chain_mismatch_rejected() {
1141        let dir = tempfile::tempdir().unwrap();
1142        let vault = dir.path().to_path_buf();
1143        let passphrase = "test-pass";
1144        let wallet_id = setup_test_wallet(&vault, passphrase);
1145        // Policy allows Base (eip155:8453) — use a policy WITHOUT AllowedTypedDataContracts
1146        // so the only gate is AllowedChains
1147        let policy_id = setup_test_policy(&vault); // allows eip155:8453
1148        let (token, _) = create_api_key(
1149            "agent",
1150            &[wallet_id],
1151            &[policy_id],
1152            passphrase,
1153            None,
1154            Some(&vault),
1155        )
1156        .unwrap();
1157
1158        // Typed data has domain.chainId = 1 (mainnet), but we request chain = base (8453)
1159        let mismatched_td = serde_json::json!({
1160            "types": {
1161                "EIP712Domain": [
1162                    {"name": "name", "type": "string"},
1163                    {"name": "chainId", "type": "uint256"},
1164                    {"name": "verifyingContract", "type": "address"}
1165                ],
1166                "Permit": [{"name": "spender", "type": "address"}]
1167            },
1168            "primaryType": "Permit",
1169            "domain": {
1170                "name": "Token",
1171                "chainId": "1",
1172                "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
1173            },
1174            "message": {"spender": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C"}
1175        })
1176        .to_string();
1177
1178        let chain = ows_core::parse_chain("base").unwrap(); // eip155:8453
1179        let result = sign_typed_data_with_api_key(
1180            &token,
1181            "test-wallet",
1182            &chain,
1183            &mismatched_td,
1184            None,
1185            Some(&vault),
1186        );
1187
1188        assert!(result.is_err());
1189        let err_msg = format!("{}", result.unwrap_err());
1190        assert!(
1191            err_msg.contains("domain chainId"),
1192            "expected chain mismatch error, got: {err_msg}"
1193        );
1194    }
1195
1196    #[cfg(unix)]
1197    #[test]
1198    fn sign_typed_data_with_api_key_executable_policy_receives_raw_json_not_raw_hex() {
1199        use std::os::unix::fs::PermissionsExt;
1200
1201        let dir = tempfile::tempdir().unwrap();
1202        let vault = dir.path().to_path_buf();
1203        let passphrase = "test-pass";
1204        let wallet_id = setup_test_wallet(&vault, passphrase);
1205        let typed_data_json = test_typed_data_json();
1206
1207        let script = vault.join("check-typed-data.py");
1208        std::fs::write(
1209            &script,
1210            format!(
1211                r#"#!/usr/bin/env python3
1212import json
1213import sys
1214
1215payload = json.load(sys.stdin)
1216typed_data = payload.get("typed_data") or {{}}
1217transaction = payload.get("transaction") or {{}}
1218
1219if typed_data.get("raw_json") == {typed_data_json:?} and transaction.get("raw_hex") == "":
1220    print('{{"allow": true}}')
1221else:
1222    print(json.dumps({{"allow": False, "reason": f"raw_hex={{transaction.get('raw_hex')}} raw_json={{typed_data.get('raw_json')}}"}}))
1223"#
1224            ),
1225        )
1226        .unwrap();
1227        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1228
1229        let policy = ows_core::Policy {
1230            id: "typed-data-exe".to_string(),
1231            name: "typed data executable".to_string(),
1232            version: 1,
1233            created_at: "2026-03-22T10:00:00Z".to_string(),
1234            rules: vec![],
1235            executable: Some(script.display().to_string()),
1236            config: None,
1237            action: ows_core::PolicyAction::Deny,
1238        };
1239        policy_store::save_policy(&policy, Some(&vault)).unwrap();
1240
1241        let (token, _) = create_api_key(
1242            "td-exe-agent",
1243            &[wallet_id],
1244            &["typed-data-exe".to_string()],
1245            passphrase,
1246            None,
1247            Some(&vault),
1248        )
1249        .unwrap();
1250
1251        let chain = ows_core::parse_chain("base").unwrap();
1252        let result = sign_typed_data_with_api_key(
1253            &token,
1254            "test-wallet",
1255            &chain,
1256            &typed_data_json,
1257            None,
1258            Some(&vault),
1259        );
1260
1261        assert!(
1262            result.is_ok(),
1263            "typed-data executable policy rejected context: {:?}",
1264            result.err()
1265        );
1266    }
1267}