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();
275        let exp = chrono::DateTime::parse_from_rfc3339(expires).map_err(|e| {
276            OwsLibError::Core(OwsError::InvalidInput {
277                message: format!("invalid expires_at timestamp '{}': {}", expires, e),
278            })
279        })?;
280        if now > exp {
281            return Err(OwsLibError::Core(OwsError::ApiKeyExpired {
282                id: key_file.id.clone(),
283            }));
284        }
285    }
286    Ok(())
287}
288
289fn load_policies_for_key(
290    key_file: &ApiKeyFile,
291    vault_path: Option<&Path>,
292) -> Result<Vec<ows_core::Policy>, OwsLibError> {
293    let mut policies = Vec::with_capacity(key_file.policy_ids.len());
294    for pid in &key_file.policy_ids {
295        policies.push(policy_store::load_policy(pid, vault_path)?);
296    }
297    Ok(policies)
298}
299
300fn decrypt_key_from_api_key(
301    key_file: &ApiKeyFile,
302    wallet: &EncryptedWallet,
303    token: &str,
304    chain_type: ows_core::ChainType,
305    index: Option<u32>,
306) -> Result<SecretBytes, OwsLibError> {
307    let envelope_value = key_file.wallet_secrets.get(&wallet.id).ok_or_else(|| {
308        OwsLibError::InvalidInput(format!(
309            "API key has no encrypted secret for wallet {}",
310            wallet.id
311        ))
312    })?;
313
314    let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
315    let secret = decrypt(&envelope, token)?;
316    crate::ops::secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use ows_core::{EncryptedWallet, KeyType, PolicyAction, PolicyRule, WalletAccount};
323    use ows_signer::encrypt;
324
325    /// Create a test wallet in the vault, return its ID.
326    fn setup_test_wallet(vault: &Path, passphrase: &str) -> String {
327        let mnemonic_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
328        let envelope = encrypt(mnemonic_phrase.as_bytes(), passphrase).unwrap();
329        let crypto = serde_json::to_value(&envelope).unwrap();
330
331        let wallet = EncryptedWallet::new(
332            "test-wallet-id".to_string(),
333            "test-wallet".to_string(),
334            vec![WalletAccount {
335                account_id: "eip155:8453:0xabc".to_string(),
336                address: "0xabc".to_string(),
337                chain_id: "eip155:8453".to_string(),
338                derivation_path: "m/44'/60'/0'/0/0".to_string(),
339            }],
340            crypto,
341            KeyType::Mnemonic,
342        );
343
344        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
345        wallet.id
346    }
347
348    fn setup_test_policy(vault: &Path) -> String {
349        let policy = ows_core::Policy {
350            id: "test-policy".to_string(),
351            name: "Test Policy".to_string(),
352            version: 1,
353            created_at: "2026-03-22T10:00:00Z".to_string(),
354            rules: vec![PolicyRule::AllowedChains {
355                chain_ids: vec!["eip155:8453".to_string()],
356            }],
357            executable: None,
358            config: None,
359            action: PolicyAction::Deny,
360        };
361        policy_store::save_policy(&policy, Some(vault)).unwrap();
362        policy.id
363    }
364
365    #[test]
366    fn create_api_key_and_verify_token() {
367        let dir = tempfile::tempdir().unwrap();
368        let vault = dir.path().to_path_buf();
369        let passphrase = "test-pass";
370
371        let wallet_id = setup_test_wallet(&vault, passphrase);
372        let policy_id = setup_test_policy(&vault);
373
374        let (token, key_file) = create_api_key(
375            "test-agent",
376            std::slice::from_ref(&wallet_id),
377            std::slice::from_ref(&policy_id),
378            passphrase,
379            None,
380            Some(&vault),
381        )
382        .unwrap();
383
384        // Token has correct format
385        assert!(token.starts_with("ows_key_"));
386
387        // Key file has correct data
388        assert_eq!(key_file.name, "test-agent");
389        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
390        assert_eq!(key_file.policy_ids, vec![policy_id]);
391        assert_eq!(key_file.token_hash, key_store::hash_token(&token));
392        assert!(key_file.expires_at.is_none());
393
394        // Wallet secret is present and decryptable
395        assert!(key_file.wallet_secrets.contains_key(&wallet_id));
396        let envelope: CryptoEnvelope =
397            serde_json::from_value(key_file.wallet_secrets[&wallet_id].clone()).unwrap();
398        let decrypted = decrypt(&envelope, &token).unwrap();
399        assert_eq!(
400            std::str::from_utf8(decrypted.expose()).unwrap(),
401            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
402        );
403
404        // Key is persisted and loadable
405        let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
406        assert_eq!(loaded.name, "test-agent");
407    }
408
409    /// Regression: API key scope must store resolved wallet UUIDs even when the caller passes a
410    /// wallet *name* (Node bindings and other callers pass strings that may be names).
411    #[test]
412    fn create_api_key_accepts_wallet_name_and_stores_canonical_ids() {
413        let dir = tempfile::tempdir().unwrap();
414        let vault = dir.path().to_path_buf();
415        let passphrase = "test-pass";
416
417        let wallet_id = setup_test_wallet(&vault, passphrase);
418        let policy_id = setup_test_policy(&vault);
419
420        let (token, key_file) = create_api_key(
421            "name-input-agent",
422            &["test-wallet".to_string()],
423            std::slice::from_ref(&policy_id),
424            passphrase,
425            None,
426            Some(&vault),
427        )
428        .unwrap();
429
430        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
431
432        let chain = ows_core::parse_chain("base").unwrap();
433        let tx_bytes = vec![0u8; 32];
434        let result =
435            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
436        assert!(
437            result.is_ok(),
438            "sign_with_api_key failed: {:?}",
439            result.err()
440        );
441    }
442
443    #[test]
444    fn create_api_key_wrong_passphrase_fails() {
445        let dir = tempfile::tempdir().unwrap();
446        let vault = dir.path().to_path_buf();
447
448        let wallet_id = setup_test_wallet(&vault, "correct");
449        let policy_id = setup_test_policy(&vault);
450
451        let result = create_api_key(
452            "agent",
453            &[wallet_id],
454            &[policy_id],
455            "wrong-passphrase",
456            None,
457            Some(&vault),
458        );
459        assert!(result.is_err());
460    }
461
462    #[test]
463    fn create_api_key_nonexistent_wallet_fails() {
464        let dir = tempfile::tempdir().unwrap();
465        let vault = dir.path().to_path_buf();
466        let policy_id = setup_test_policy(&vault);
467
468        let result = create_api_key(
469            "agent",
470            &["nonexistent".to_string()],
471            &[policy_id],
472            "pass",
473            None,
474            Some(&vault),
475        );
476        assert!(result.is_err());
477    }
478
479    #[test]
480    fn create_api_key_nonexistent_policy_fails() {
481        let dir = tempfile::tempdir().unwrap();
482        let vault = dir.path().to_path_buf();
483
484        let wallet_id = setup_test_wallet(&vault, "pass");
485
486        let result = create_api_key(
487            "agent",
488            &[wallet_id],
489            &["nonexistent-policy".to_string()],
490            "pass",
491            None,
492            Some(&vault),
493        );
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn create_api_key_with_expiry() {
499        let dir = tempfile::tempdir().unwrap();
500        let vault = dir.path().to_path_buf();
501
502        let wallet_id = setup_test_wallet(&vault, "pass");
503        let policy_id = setup_test_policy(&vault);
504
505        let (_, key_file) = create_api_key(
506            "expiring-agent",
507            &[wallet_id],
508            &[policy_id],
509            "pass",
510            Some("2026-12-31T00:00:00Z"),
511            Some(&vault),
512        )
513        .unwrap();
514
515        assert_eq!(key_file.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
516    }
517
518    #[test]
519    fn sign_with_api_key_full_flow() {
520        let dir = tempfile::tempdir().unwrap();
521        let vault = dir.path().to_path_buf();
522        let passphrase = "test-pass";
523
524        let wallet_id = setup_test_wallet(&vault, passphrase);
525        let policy_id = setup_test_policy(&vault);
526
527        let (token, _) = create_api_key(
528            "signer-agent",
529            &[wallet_id],
530            &[policy_id],
531            passphrase,
532            None,
533            Some(&vault),
534        )
535        .unwrap();
536
537        // Sign a dummy transaction on the allowed chain
538        let chain = ows_core::parse_chain("base").unwrap();
539        let tx_bytes = vec![0u8; 32]; // dummy tx
540
541        let result =
542            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
543
544        // The signing should succeed (policy allows eip155:8453)
545        assert!(
546            result.is_ok(),
547            "sign_with_api_key failed: {:?}",
548            result.err()
549        );
550        let sign_result = result.unwrap();
551        assert!(!sign_result.signature.is_empty());
552    }
553
554    #[test]
555    fn imported_private_key_wallet_signs_with_api_key() {
556        let dir = tempfile::tempdir().unwrap();
557        let vault = dir.path().to_path_buf();
558
559        let wallet = crate::import_wallet_private_key(
560            "imported-wallet",
561            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
562            Some("evm"),
563            Some(""),
564            Some(&vault),
565            None,
566            None,
567        )
568        .unwrap();
569        let policy_id = setup_test_policy(&vault);
570
571        let (token, _) = create_api_key(
572            "imported-wallet-agent",
573            std::slice::from_ref(&wallet.id),
574            std::slice::from_ref(&policy_id),
575            "",
576            None,
577            Some(&vault),
578        )
579        .unwrap();
580
581        let chain = ows_core::parse_chain("base").unwrap();
582        let tx_bytes = vec![0u8; 32];
583
584        let tx_result = sign_with_api_key(
585            &token,
586            "imported-wallet",
587            &chain,
588            &tx_bytes,
589            None,
590            Some(&vault),
591        );
592        assert!(
593            tx_result.is_ok(),
594            "sign_with_api_key failed: {:?}",
595            tx_result.err()
596        );
597        assert!(!tx_result.unwrap().signature.is_empty());
598
599        let msg_result = sign_message_with_api_key(
600            &token,
601            "imported-wallet",
602            &chain,
603            b"hello",
604            None,
605            Some(&vault),
606        );
607        assert!(
608            msg_result.is_ok(),
609            "sign_message_with_api_key failed: {:?}",
610            msg_result.err()
611        );
612        assert!(!msg_result.unwrap().signature.is_empty());
613    }
614
615    #[test]
616    fn sign_with_api_key_wrong_chain_denied() {
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); // allows only eip155:8453
623
624        let (token, _) = create_api_key(
625            "agent",
626            &[wallet_id],
627            &[policy_id],
628            passphrase,
629            None,
630            Some(&vault),
631        )
632        .unwrap();
633
634        // Try to sign on a chain NOT in the policy allowlist
635        let chain = ows_core::parse_chain("ethereum").unwrap(); // eip155:1
636        let tx_bytes = vec![0u8; 32];
637
638        let result =
639            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
640
641        assert!(result.is_err());
642        match result.unwrap_err() {
643            OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
644                assert!(reason.contains("not in allowlist"));
645            }
646            other => panic!("expected PolicyDenied, got: {other}"),
647        }
648    }
649
650    #[test]
651    fn sign_with_api_key_expired_key_rejected() {
652        let dir = tempfile::tempdir().unwrap();
653        let vault = dir.path().to_path_buf();
654        let passphrase = "test-pass";
655
656        let wallet_id = setup_test_wallet(&vault, passphrase);
657        let policy_id = setup_test_policy(&vault);
658
659        let (token, _) = create_api_key(
660            "agent",
661            &[wallet_id],
662            &[policy_id],
663            passphrase,
664            Some("2020-01-01T00:00:00Z"), // already expired
665            Some(&vault),
666        )
667        .unwrap();
668
669        let chain = ows_core::parse_chain("base").unwrap();
670        let tx_bytes = vec![0u8; 32];
671
672        let result =
673            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
674
675        assert!(result.is_err());
676        match result.unwrap_err() {
677            OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
678            other => panic!("expected ApiKeyExpired, got: {other}"),
679        }
680    }
681
682    #[test]
683    fn sign_with_wrong_token_fails() {
684        let dir = tempfile::tempdir().unwrap();
685        let vault = dir.path().to_path_buf();
686        let passphrase = "test-pass";
687
688        let wallet_id = setup_test_wallet(&vault, passphrase);
689        let policy_id = setup_test_policy(&vault);
690
691        let (_token, _) = create_api_key(
692            "agent",
693            &[wallet_id],
694            &[policy_id],
695            passphrase,
696            None,
697            Some(&vault),
698        )
699        .unwrap();
700
701        let chain = ows_core::parse_chain("base").unwrap();
702        let tx_bytes = vec![0u8; 32];
703
704        let result = sign_with_api_key(
705            "ows_key_wrong_token",
706            "test-wallet",
707            &chain,
708            &tx_bytes,
709            None,
710            Some(&vault),
711        );
712
713        assert!(result.is_err());
714    }
715
716    #[test]
717    fn sign_wallet_not_in_scope_fails() {
718        let dir = tempfile::tempdir().unwrap();
719        let vault = dir.path().to_path_buf();
720        let passphrase = "test-pass";
721
722        // Create two wallets
723        let wallet_id = setup_test_wallet(&vault, passphrase);
724        let policy_id = setup_test_policy(&vault);
725
726        // Create a second wallet
727        let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
728        let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
729        let crypto2 = serde_json::to_value(&envelope2).unwrap();
730        let wallet2 = EncryptedWallet::new(
731            "wallet-2-id".to_string(),
732            "other-wallet".to_string(),
733            vec![],
734            crypto2,
735            KeyType::Mnemonic,
736        );
737        vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
738
739        // API key only has access to first wallet
740        let (token, _) = create_api_key(
741            "agent",
742            &[wallet_id],
743            &[policy_id],
744            passphrase,
745            None,
746            Some(&vault),
747        )
748        .unwrap();
749
750        let chain = ows_core::parse_chain("base").unwrap();
751        let tx_bytes = vec![0u8; 32];
752
753        // Try to sign with the second wallet → should fail
754        let result = sign_with_api_key(
755            &token,
756            "other-wallet",
757            &chain,
758            &tx_bytes,
759            None,
760            Some(&vault),
761        );
762
763        assert!(result.is_err());
764        match result.unwrap_err() {
765            OwsLibError::InvalidInput(msg) => {
766                assert!(msg.contains("does not have access"));
767            }
768            other => panic!("expected InvalidInput, got: {other}"),
769        }
770    }
771}