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().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 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 assert!(token.starts_with("ows_key_"));
381
382 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 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 let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
401 assert_eq!(loaded.name, "test-agent");
402 }
403
404 #[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 let chain = ows_core::parse_chain("base").unwrap();
534 let tx_bytes = vec![0u8; 32]; let result =
537 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
538
539 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); 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 let chain = ows_core::parse_chain("ethereum").unwrap(); 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"), 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 let wallet_id = setup_test_wallet(&vault, passphrase);
719 let policy_id = setup_test_policy(&vault);
720
721 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 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 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}