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::{decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, SecretBytes};
6
7use crate::error::OwsLibError;
8use crate::key_store;
9use crate::policy_engine;
10use crate::policy_store;
11use crate::vault;
12
13/// Create an API key for agent access to one or more wallets.
14///
15/// 1. Authenticates with the owner's passphrase
16/// 2. Decrypts the wallet secret for each wallet
17/// 3. Generates a random token (`ows_key_...`)
18/// 4. Re-encrypts each secret under HKDF(token)
19/// 5. Stores the key file with token hash, policy IDs, and encrypted copies
20/// 6. Returns the raw token (shown once to the user)
21pub fn create_api_key(
22    name: &str,
23    wallet_ids: &[String],
24    policy_ids: &[String],
25    passphrase: &str,
26    expires_at: Option<&str>,
27    vault_path: Option<&Path>,
28) -> Result<(String, ApiKeyFile), OwsLibError> {
29    // Validate that all wallets exist and passphrase works
30    let mut wallet_secrets = HashMap::new();
31    let mut resolved_wallet_ids = Vec::with_capacity(wallet_ids.len());
32    let token = key_store::generate_token();
33
34    for wallet_id in wallet_ids {
35        let wallet = vault::load_wallet_by_name_or_id(wallet_id, vault_path)?;
36        let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
37
38        // Decrypt with owner's passphrase to verify it works
39        let secret = decrypt(&envelope, passphrase)?;
40
41        // Re-encrypt under HKDF(token)
42        let hkdf_envelope = encrypt_with_hkdf(secret.expose(), &token)?;
43        let envelope_json = serde_json::to_value(&hkdf_envelope)?;
44
45        wallet_secrets.insert(wallet.id.clone(), envelope_json);
46        // Always persist canonical wallet IDs (UUIDs). Callers may pass names or IDs;
47        // agent signing checks `contains(wallet.id)` when verifying scope.
48        resolved_wallet_ids.push(wallet.id.clone());
49    }
50
51    // Validate that all policies exist
52    for policy_id in policy_ids {
53        policy_store::load_policy(policy_id, vault_path)?;
54    }
55
56    let id = uuid::Uuid::new_v4().to_string();
57    let key_file = ApiKeyFile {
58        id,
59        name: name.to_string(),
60        token_hash: key_store::hash_token(&token),
61        created_at: chrono::Utc::now().to_rfc3339(),
62        wallet_ids: resolved_wallet_ids,
63        policy_ids: policy_ids.to_vec(),
64        expires_at: expires_at.map(String::from),
65        wallet_secrets,
66    };
67
68    key_store::save_api_key(&key_file, vault_path)?;
69
70    Ok((token, key_file))
71}
72
73/// Sign a transaction using an API token (agent mode).
74///
75/// 1. Look up key file by SHA256(token)
76/// 2. Check expiry and wallet scope
77/// 3. Load and evaluate policies
78/// 4. HKDF(token) → decrypt wallet secret
79/// 5. Resolve signing key → sign
80pub fn sign_with_api_key(
81    token: &str,
82    wallet_name_or_id: &str,
83    chain: &ows_core::Chain,
84    tx_bytes: &[u8],
85    index: Option<u32>,
86    vault_path: Option<&Path>,
87) -> Result<crate::types::SignResult, OwsLibError> {
88    // 1. Look up key file
89    let token_hash = key_store::hash_token(token);
90    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
91
92    // 2. Check expiry
93    check_expiry(&key_file)?;
94
95    // 3. Resolve wallet and check scope
96    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
97    if !key_file.wallet_ids.contains(&wallet.id) {
98        return Err(OwsLibError::InvalidInput(format!(
99            "API key '{}' does not have access to wallet '{}'",
100            key_file.name, wallet.id,
101        )));
102    }
103
104    // 4. Load policies and build context
105    let policies = load_policies_for_key(&key_file, vault_path)?;
106    let now = chrono::Utc::now();
107    let date = now.format("%Y-%m-%d").to_string();
108
109    let tx_hex = hex::encode(tx_bytes);
110
111    let context = ows_core::PolicyContext {
112        chain_id: chain.chain_id.to_string(),
113        wallet_id: wallet.id.clone(),
114        api_key_id: key_file.id.clone(),
115        transaction: ows_core::policy::TransactionContext {
116            to: None,
117            value: None,
118            raw_hex: tx_hex,
119            data: None,
120        },
121        spending: noop_spending_context(&date),
122        timestamp: now.to_rfc3339(),
123    };
124
125    // 5. Evaluate policies
126    let result = policy_engine::evaluate_policies(&policies, &context);
127    if !result.allow {
128        return Err(OwsLibError::Core(OwsError::PolicyDenied {
129            policy_id: result.policy_id.unwrap_or_default(),
130            reason: result.reason.unwrap_or_else(|| "denied".into()),
131        }));
132    }
133
134    // 6. Decrypt wallet secret from key file using HKDF(token)
135    let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
136
137    // 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers)
138    let signer = signer_for_chain(chain.chain_type);
139    let signable = signer.extract_signable_bytes(tx_bytes)?;
140    let output = signer.sign_transaction(key.expose(), signable)?;
141
142    Ok(crate::types::SignResult {
143        signature: hex::encode(&output.signature),
144        recovery_id: output.recovery_id,
145    })
146}
147
148/// Sign a message using an API token (agent mode).
149pub fn sign_message_with_api_key(
150    token: &str,
151    wallet_name_or_id: &str,
152    chain: &ows_core::Chain,
153    msg_bytes: &[u8],
154    index: Option<u32>,
155    vault_path: Option<&Path>,
156) -> Result<crate::types::SignResult, OwsLibError> {
157    let token_hash = key_store::hash_token(token);
158    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
159
160    check_expiry(&key_file)?;
161
162    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
163    if !key_file.wallet_ids.contains(&wallet.id) {
164        return Err(OwsLibError::InvalidInput(format!(
165            "API key '{}' does not have access to wallet '{}'",
166            key_file.name, wallet.id,
167        )));
168    }
169
170    let policies = load_policies_for_key(&key_file, vault_path)?;
171    let now = chrono::Utc::now();
172    let date = now.format("%Y-%m-%d").to_string();
173
174    let context = ows_core::PolicyContext {
175        chain_id: chain.chain_id.to_string(),
176        wallet_id: wallet.id.clone(),
177        api_key_id: key_file.id.clone(),
178        transaction: ows_core::policy::TransactionContext {
179            to: None,
180            value: None,
181            raw_hex: hex::encode(msg_bytes),
182            data: None,
183        },
184        spending: noop_spending_context(&date),
185        timestamp: now.to_rfc3339(),
186    };
187
188    let result = policy_engine::evaluate_policies(&policies, &context);
189    if !result.allow {
190        return Err(OwsLibError::Core(OwsError::PolicyDenied {
191            policy_id: result.policy_id.unwrap_or_default(),
192            reason: result.reason.unwrap_or_else(|| "denied".into()),
193        }));
194    }
195
196    let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
197    let signer = signer_for_chain(chain.chain_type);
198    let output = signer.sign_message(key.expose(), msg_bytes)?;
199
200    Ok(crate::types::SignResult {
201        signature: hex::encode(&output.signature),
202        recovery_id: output.recovery_id,
203    })
204}
205
206/// Enforce policies for a token-based transaction and return the decrypted
207/// signing key. Used by `sign_and_send` which needs the raw key for broadcast.
208pub fn enforce_policy_and_decrypt_key(
209    token: &str,
210    wallet_name_or_id: &str,
211    chain: &ows_core::Chain,
212    tx_bytes: &[u8],
213    index: Option<u32>,
214    vault_path: Option<&Path>,
215) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> {
216    let token_hash = key_store::hash_token(token);
217    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
218    check_expiry(&key_file)?;
219
220    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
221    if !key_file.wallet_ids.contains(&wallet.id) {
222        return Err(OwsLibError::InvalidInput(format!(
223            "API key '{}' does not have access to wallet '{}'",
224            key_file.name, wallet.id,
225        )));
226    }
227
228    let policies = load_policies_for_key(&key_file, vault_path)?;
229    let now = chrono::Utc::now();
230    let date = now.format("%Y-%m-%d").to_string();
231
232    let tx_hex = hex::encode(tx_bytes);
233
234    let context = ows_core::PolicyContext {
235        chain_id: chain.chain_id.to_string(),
236        wallet_id: wallet.id.clone(),
237        api_key_id: key_file.id.clone(),
238        transaction: ows_core::policy::TransactionContext {
239            to: None,
240            value: None,
241            raw_hex: tx_hex,
242            data: None,
243        },
244        spending: noop_spending_context(&date),
245        timestamp: now.to_rfc3339(),
246    };
247
248    let result = policy_engine::evaluate_policies(&policies, &context);
249    if !result.allow {
250        return Err(OwsLibError::Core(OwsError::PolicyDenied {
251            policy_id: result.policy_id.unwrap_or_default(),
252            reason: result.reason.unwrap_or_else(|| "denied".into()),
253        }));
254    }
255
256    let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
257
258    Ok((key, key_file))
259}
260
261// ---------------------------------------------------------------------------
262// Helpers
263// ---------------------------------------------------------------------------
264
265fn noop_spending_context(date: &str) -> ows_core::policy::SpendingContext {
266    ows_core::policy::SpendingContext {
267        daily_total: "0".to_string(),
268        date: date.to_string(),
269    }
270}
271
272fn check_expiry(key_file: &ApiKeyFile) -> Result<(), OwsLibError> {
273    if let Some(ref expires) = key_file.expires_at {
274        let now = chrono::Utc::now().to_rfc3339();
275        if now.as_str() > expires.as_str() {
276            return Err(OwsLibError::Core(OwsError::ApiKeyExpired {
277                id: key_file.id.clone(),
278            }));
279        }
280    }
281    Ok(())
282}
283
284fn load_policies_for_key(
285    key_file: &ApiKeyFile,
286    vault_path: Option<&Path>,
287) -> Result<Vec<ows_core::Policy>, OwsLibError> {
288    let mut policies = Vec::with_capacity(key_file.policy_ids.len());
289    for pid in &key_file.policy_ids {
290        policies.push(policy_store::load_policy(pid, vault_path)?);
291    }
292    Ok(policies)
293}
294
295fn decrypt_key_from_api_key(
296    key_file: &ApiKeyFile,
297    wallet: &EncryptedWallet,
298    token: &str,
299    chain_type: ows_core::ChainType,
300    index: Option<u32>,
301) -> Result<SecretBytes, OwsLibError> {
302    let envelope_value = key_file.wallet_secrets.get(&wallet.id).ok_or_else(|| {
303        OwsLibError::InvalidInput(format!(
304            "API key has no encrypted secret for wallet {}",
305            wallet.id
306        ))
307    })?;
308
309    let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
310    let secret = decrypt(&envelope, token)?;
311    crate::ops::secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use ows_core::{EncryptedWallet, KeyType, PolicyAction, PolicyRule, WalletAccount};
318    use ows_signer::encrypt;
319
320    /// Create a test wallet in the vault, return its ID.
321    fn setup_test_wallet(vault: &Path, passphrase: &str) -> String {
322        let mnemonic_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
323        let envelope = encrypt(mnemonic_phrase.as_bytes(), passphrase).unwrap();
324        let crypto = serde_json::to_value(&envelope).unwrap();
325
326        let wallet = EncryptedWallet::new(
327            "test-wallet-id".to_string(),
328            "test-wallet".to_string(),
329            vec![WalletAccount {
330                account_id: "eip155:8453:0xabc".to_string(),
331                address: "0xabc".to_string(),
332                chain_id: "eip155:8453".to_string(),
333                derivation_path: "m/44'/60'/0'/0/0".to_string(),
334            }],
335            crypto,
336            KeyType::Mnemonic,
337        );
338
339        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
340        wallet.id
341    }
342
343    fn setup_test_policy(vault: &Path) -> String {
344        let policy = ows_core::Policy {
345            id: "test-policy".to_string(),
346            name: "Test Policy".to_string(),
347            version: 1,
348            created_at: "2026-03-22T10:00:00Z".to_string(),
349            rules: vec![PolicyRule::AllowedChains {
350                chain_ids: vec!["eip155:8453".to_string()],
351            }],
352            executable: None,
353            config: None,
354            action: PolicyAction::Deny,
355        };
356        policy_store::save_policy(&policy, Some(vault)).unwrap();
357        policy.id
358    }
359
360    #[test]
361    fn create_api_key_and_verify_token() {
362        let dir = tempfile::tempdir().unwrap();
363        let vault = dir.path().to_path_buf();
364        let passphrase = "test-pass";
365
366        let wallet_id = setup_test_wallet(&vault, passphrase);
367        let policy_id = setup_test_policy(&vault);
368
369        let (token, key_file) = create_api_key(
370            "test-agent",
371            std::slice::from_ref(&wallet_id),
372            std::slice::from_ref(&policy_id),
373            passphrase,
374            None,
375            Some(&vault),
376        )
377        .unwrap();
378
379        // Token has correct format
380        assert!(token.starts_with("ows_key_"));
381
382        // Key file has correct data
383        assert_eq!(key_file.name, "test-agent");
384        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
385        assert_eq!(key_file.policy_ids, vec![policy_id]);
386        assert_eq!(key_file.token_hash, key_store::hash_token(&token));
387        assert!(key_file.expires_at.is_none());
388
389        // Wallet secret is present and decryptable
390        assert!(key_file.wallet_secrets.contains_key(&wallet_id));
391        let envelope: CryptoEnvelope =
392            serde_json::from_value(key_file.wallet_secrets[&wallet_id].clone()).unwrap();
393        let decrypted = decrypt(&envelope, &token).unwrap();
394        assert_eq!(
395            std::str::from_utf8(decrypted.expose()).unwrap(),
396            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
397        );
398
399        // Key is persisted and loadable
400        let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
401        assert_eq!(loaded.name, "test-agent");
402    }
403
404    /// Regression: API key scope must store resolved wallet UUIDs even when the caller passes a
405    /// wallet *name* (Node bindings and other callers pass strings that may be names).
406    #[test]
407    fn create_api_key_accepts_wallet_name_and_stores_canonical_ids() {
408        let dir = tempfile::tempdir().unwrap();
409        let vault = dir.path().to_path_buf();
410        let passphrase = "test-pass";
411
412        let wallet_id = setup_test_wallet(&vault, passphrase);
413        let policy_id = setup_test_policy(&vault);
414
415        let (token, key_file) = create_api_key(
416            "name-input-agent",
417            &["test-wallet".to_string()],
418            std::slice::from_ref(&policy_id),
419            passphrase,
420            None,
421            Some(&vault),
422        )
423        .unwrap();
424
425        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
426
427        let chain = ows_core::parse_chain("base").unwrap();
428        let tx_bytes = vec![0u8; 32];
429        let result =
430            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
431        assert!(
432            result.is_ok(),
433            "sign_with_api_key failed: {:?}",
434            result.err()
435        );
436    }
437
438    #[test]
439    fn create_api_key_wrong_passphrase_fails() {
440        let dir = tempfile::tempdir().unwrap();
441        let vault = dir.path().to_path_buf();
442
443        let wallet_id = setup_test_wallet(&vault, "correct");
444        let policy_id = setup_test_policy(&vault);
445
446        let result = create_api_key(
447            "agent",
448            &[wallet_id],
449            &[policy_id],
450            "wrong-passphrase",
451            None,
452            Some(&vault),
453        );
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn create_api_key_nonexistent_wallet_fails() {
459        let dir = tempfile::tempdir().unwrap();
460        let vault = dir.path().to_path_buf();
461        let policy_id = setup_test_policy(&vault);
462
463        let result = create_api_key(
464            "agent",
465            &["nonexistent".to_string()],
466            &[policy_id],
467            "pass",
468            None,
469            Some(&vault),
470        );
471        assert!(result.is_err());
472    }
473
474    #[test]
475    fn create_api_key_nonexistent_policy_fails() {
476        let dir = tempfile::tempdir().unwrap();
477        let vault = dir.path().to_path_buf();
478
479        let wallet_id = setup_test_wallet(&vault, "pass");
480
481        let result = create_api_key(
482            "agent",
483            &[wallet_id],
484            &["nonexistent-policy".to_string()],
485            "pass",
486            None,
487            Some(&vault),
488        );
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn create_api_key_with_expiry() {
494        let dir = tempfile::tempdir().unwrap();
495        let vault = dir.path().to_path_buf();
496
497        let wallet_id = setup_test_wallet(&vault, "pass");
498        let policy_id = setup_test_policy(&vault);
499
500        let (_, key_file) = create_api_key(
501            "expiring-agent",
502            &[wallet_id],
503            &[policy_id],
504            "pass",
505            Some("2026-12-31T00:00:00Z"),
506            Some(&vault),
507        )
508        .unwrap();
509
510        assert_eq!(key_file.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
511    }
512
513    #[test]
514    fn sign_with_api_key_full_flow() {
515        let dir = tempfile::tempdir().unwrap();
516        let vault = dir.path().to_path_buf();
517        let passphrase = "test-pass";
518
519        let wallet_id = setup_test_wallet(&vault, passphrase);
520        let policy_id = setup_test_policy(&vault);
521
522        let (token, _) = create_api_key(
523            "signer-agent",
524            &[wallet_id],
525            &[policy_id],
526            passphrase,
527            None,
528            Some(&vault),
529        )
530        .unwrap();
531
532        // Sign a dummy transaction on the allowed chain
533        let chain = ows_core::parse_chain("base").unwrap();
534        let tx_bytes = vec![0u8; 32]; // dummy tx
535
536        let result =
537            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
538
539        // The signing should succeed (policy allows eip155:8453)
540        assert!(
541            result.is_ok(),
542            "sign_with_api_key failed: {:?}",
543            result.err()
544        );
545        let sign_result = result.unwrap();
546        assert!(!sign_result.signature.is_empty());
547    }
548
549    #[test]
550    fn imported_private_key_wallet_signs_with_api_key() {
551        let dir = tempfile::tempdir().unwrap();
552        let vault = dir.path().to_path_buf();
553
554        let wallet = crate::import_wallet_private_key(
555            "imported-wallet",
556            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
557            Some("evm"),
558            Some(""),
559            Some(&vault),
560            None,
561            None,
562        )
563        .unwrap();
564        let policy_id = setup_test_policy(&vault);
565
566        let (token, _) = create_api_key(
567            "imported-wallet-agent",
568            std::slice::from_ref(&wallet.id),
569            std::slice::from_ref(&policy_id),
570            "",
571            None,
572            Some(&vault),
573        )
574        .unwrap();
575
576        let chain = ows_core::parse_chain("base").unwrap();
577        let tx_bytes = vec![0u8; 32];
578
579        let tx_result = sign_with_api_key(
580            &token,
581            "imported-wallet",
582            &chain,
583            &tx_bytes,
584            None,
585            Some(&vault),
586        );
587        assert!(
588            tx_result.is_ok(),
589            "sign_with_api_key failed: {:?}",
590            tx_result.err()
591        );
592        assert!(!tx_result.unwrap().signature.is_empty());
593
594        let msg_result = sign_message_with_api_key(
595            &token,
596            "imported-wallet",
597            &chain,
598            b"hello",
599            None,
600            Some(&vault),
601        );
602        assert!(
603            msg_result.is_ok(),
604            "sign_message_with_api_key failed: {:?}",
605            msg_result.err()
606        );
607        assert!(!msg_result.unwrap().signature.is_empty());
608    }
609
610    #[test]
611    fn sign_with_api_key_wrong_chain_denied() {
612        let dir = tempfile::tempdir().unwrap();
613        let vault = dir.path().to_path_buf();
614        let passphrase = "test-pass";
615
616        let wallet_id = setup_test_wallet(&vault, passphrase);
617        let policy_id = setup_test_policy(&vault); // allows only eip155:8453
618
619        let (token, _) = create_api_key(
620            "agent",
621            &[wallet_id],
622            &[policy_id],
623            passphrase,
624            None,
625            Some(&vault),
626        )
627        .unwrap();
628
629        // Try to sign on a chain NOT in the policy allowlist
630        let chain = ows_core::parse_chain("ethereum").unwrap(); // eip155:1
631        let tx_bytes = vec![0u8; 32];
632
633        let result =
634            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
635
636        assert!(result.is_err());
637        match result.unwrap_err() {
638            OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
639                assert!(reason.contains("not in allowlist"));
640            }
641            other => panic!("expected PolicyDenied, got: {other}"),
642        }
643    }
644
645    #[test]
646    fn sign_with_api_key_expired_key_rejected() {
647        let dir = tempfile::tempdir().unwrap();
648        let vault = dir.path().to_path_buf();
649        let passphrase = "test-pass";
650
651        let wallet_id = setup_test_wallet(&vault, passphrase);
652        let policy_id = setup_test_policy(&vault);
653
654        let (token, _) = create_api_key(
655            "agent",
656            &[wallet_id],
657            &[policy_id],
658            passphrase,
659            Some("2020-01-01T00:00:00Z"), // already expired
660            Some(&vault),
661        )
662        .unwrap();
663
664        let chain = ows_core::parse_chain("base").unwrap();
665        let tx_bytes = vec![0u8; 32];
666
667        let result =
668            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
669
670        assert!(result.is_err());
671        match result.unwrap_err() {
672            OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
673            other => panic!("expected ApiKeyExpired, got: {other}"),
674        }
675    }
676
677    #[test]
678    fn sign_with_wrong_token_fails() {
679        let dir = tempfile::tempdir().unwrap();
680        let vault = dir.path().to_path_buf();
681        let passphrase = "test-pass";
682
683        let wallet_id = setup_test_wallet(&vault, passphrase);
684        let policy_id = setup_test_policy(&vault);
685
686        let (_token, _) = create_api_key(
687            "agent",
688            &[wallet_id],
689            &[policy_id],
690            passphrase,
691            None,
692            Some(&vault),
693        )
694        .unwrap();
695
696        let chain = ows_core::parse_chain("base").unwrap();
697        let tx_bytes = vec![0u8; 32];
698
699        let result = sign_with_api_key(
700            "ows_key_wrong_token",
701            "test-wallet",
702            &chain,
703            &tx_bytes,
704            None,
705            Some(&vault),
706        );
707
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn sign_wallet_not_in_scope_fails() {
713        let dir = tempfile::tempdir().unwrap();
714        let vault = dir.path().to_path_buf();
715        let passphrase = "test-pass";
716
717        // Create two wallets
718        let wallet_id = setup_test_wallet(&vault, passphrase);
719        let policy_id = setup_test_policy(&vault);
720
721        // Create a second wallet
722        let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
723        let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
724        let crypto2 = serde_json::to_value(&envelope2).unwrap();
725        let wallet2 = EncryptedWallet::new(
726            "wallet-2-id".to_string(),
727            "other-wallet".to_string(),
728            vec![],
729            crypto2,
730            KeyType::Mnemonic,
731        );
732        vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
733
734        // API key only has access to first wallet
735        let (token, _) = create_api_key(
736            "agent",
737            &[wallet_id],
738            &[policy_id],
739            passphrase,
740            None,
741            Some(&vault),
742        )
743        .unwrap();
744
745        let chain = ows_core::parse_chain("base").unwrap();
746        let tx_bytes = vec![0u8; 32];
747
748        // Try to sign with the second wallet → should fail
749        let result = sign_with_api_key(
750            &token,
751            "other-wallet",
752            &chain,
753            &tx_bytes,
754            None,
755            Some(&vault),
756        );
757
758        assert!(result.is_err());
759        match result.unwrap_err() {
760            OwsLibError::InvalidInput(msg) => {
761                assert!(msg.contains("does not have access"));
762            }
763            other => panic!("expected InvalidInput, got: {other}"),
764        }
765    }
766}