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
15pub 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 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 let secret = decrypt(&envelope, passphrase)?;
42
43 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 resolved_wallet_ids.push(wallet.id.clone());
51 }
52
53 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
75pub 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 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
110pub 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
136pub 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
164pub 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 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 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 check_expiry(&key_file)?;
190
191 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 let parsed = eip712::parse_typed_data(typed_data_json)?;
202
203 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 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 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 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
278pub 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
350fn 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 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 assert!(token.starts_with("ows_key_"));
483
484 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 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 let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
503 assert_eq!(loaded.name, "test-agent");
504 }
505
506 #[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 let chain = ows_core::parse_chain("base").unwrap();
636 let tx_bytes = vec![0u8; 32]; let result =
639 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
640
641 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); 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 let chain = ows_core::parse_chain("ethereum").unwrap(); 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"), 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 let wallet_id = setup_test_wallet(&vault, passphrase);
821 let policy_id = setup_test_policy(&vault);
822
823 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 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 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 let policy_id = setup_test_policy(&vault); 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 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(); 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}