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
13pub 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 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 let secret = decrypt(&envelope, passphrase)?;
40
41 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 resolved_wallet_ids.push(wallet.id.clone());
49 }
50
51 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
73pub 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 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 check_expiry(&key_file)?;
94
95 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 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 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 let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
136
137 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
148pub 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
206pub 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
261fn 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 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 assert!(token.starts_with("ows_key_"));
386
387 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 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 let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
406 assert_eq!(loaded.name, "test-agent");
407 }
408
409 #[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 let chain = ows_core::parse_chain("base").unwrap();
539 let tx_bytes = vec![0u8; 32]; let result =
542 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
543
544 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); 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 let chain = ows_core::parse_chain("ethereum").unwrap(); 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"), 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 let wallet_id = setup_test_wallet(&vault, passphrase);
724 let policy_id = setup_test_policy(&vault);
725
726 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 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 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}