1use std::collections::HashMap;
2use std::path::Path;
3
4use ows_core::{ApiKeyFile, OwsError};
5use ows_signer::{
6 decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, 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 token_hash = key_store::hash_token(token);
92 let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
93
94 check_expiry(&key_file)?;
96
97 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
99 if !key_file.wallet_ids.contains(&wallet.id) {
100 return Err(OwsLibError::InvalidInput(format!(
101 "API key '{}' does not have access to wallet '{}'",
102 key_file.name, wallet.id,
103 )));
104 }
105
106 let policies = load_policies_for_key(&key_file, vault_path)?;
108 let now = chrono::Utc::now();
109 let date = now.format("%Y-%m-%d").to_string();
110
111 let tx_hex = hex::encode(tx_bytes);
112
113 let context = ows_core::PolicyContext {
114 chain_id: chain.chain_id.to_string(),
115 wallet_id: wallet.id.clone(),
116 api_key_id: key_file.id.clone(),
117 transaction: ows_core::policy::TransactionContext {
118 to: None,
119 value: None,
120 raw_hex: tx_hex,
121 data: None,
122 },
123 spending: noop_spending_context(&date),
124 timestamp: now.to_rfc3339(),
125 };
126
127 let result = policy_engine::evaluate_policies(&policies, &context);
129 if !result.allow {
130 return Err(OwsLibError::Core(OwsError::PolicyDenied {
131 policy_id: result.policy_id.unwrap_or_default(),
132 reason: result.reason.unwrap_or_else(|| "denied".into()),
133 }));
134 }
135
136 let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
138
139 let signer = signer_for_chain(chain.chain_type);
141 let signable = signer.extract_signable_bytes(tx_bytes)?;
142 let output = signer.sign_transaction(key.expose(), signable)?;
143
144 Ok(crate::types::SignResult {
145 signature: hex::encode(&output.signature),
146 recovery_id: output.recovery_id,
147 })
148}
149
150pub fn sign_message_with_api_key(
152 token: &str,
153 wallet_name_or_id: &str,
154 chain: &ows_core::Chain,
155 msg_bytes: &[u8],
156 index: Option<u32>,
157 vault_path: Option<&Path>,
158) -> Result<crate::types::SignResult, OwsLibError> {
159 let token_hash = key_store::hash_token(token);
160 let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
161
162 check_expiry(&key_file)?;
163
164 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
165 if !key_file.wallet_ids.contains(&wallet.id) {
166 return Err(OwsLibError::InvalidInput(format!(
167 "API key '{}' does not have access to wallet '{}'",
168 key_file.name, wallet.id,
169 )));
170 }
171
172 let policies = load_policies_for_key(&key_file, vault_path)?;
173 let now = chrono::Utc::now();
174 let date = now.format("%Y-%m-%d").to_string();
175
176 let context = ows_core::PolicyContext {
177 chain_id: chain.chain_id.to_string(),
178 wallet_id: wallet.id.clone(),
179 api_key_id: key_file.id.clone(),
180 transaction: ows_core::policy::TransactionContext {
181 to: None,
182 value: None,
183 raw_hex: hex::encode(msg_bytes),
184 data: None,
185 },
186 spending: noop_spending_context(&date),
187 timestamp: now.to_rfc3339(),
188 };
189
190 let result = policy_engine::evaluate_policies(&policies, &context);
191 if !result.allow {
192 return Err(OwsLibError::Core(OwsError::PolicyDenied {
193 policy_id: result.policy_id.unwrap_or_default(),
194 reason: result.reason.unwrap_or_else(|| "denied".into()),
195 }));
196 }
197
198 let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
199 let signer = signer_for_chain(chain.chain_type);
200 let output = signer.sign_message(key.expose(), msg_bytes)?;
201
202 Ok(crate::types::SignResult {
203 signature: hex::encode(&output.signature),
204 recovery_id: output.recovery_id,
205 })
206}
207
208pub fn enforce_policy_and_decrypt_key(
211 token: &str,
212 wallet_name_or_id: &str,
213 chain: &ows_core::Chain,
214 tx_bytes: &[u8],
215 index: Option<u32>,
216 vault_path: Option<&Path>,
217) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> {
218 let token_hash = key_store::hash_token(token);
219 let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
220 check_expiry(&key_file)?;
221
222 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
223 if !key_file.wallet_ids.contains(&wallet.id) {
224 return Err(OwsLibError::InvalidInput(format!(
225 "API key '{}' does not have access to wallet '{}'",
226 key_file.name, wallet.id,
227 )));
228 }
229
230 let policies = load_policies_for_key(&key_file, vault_path)?;
231 let now = chrono::Utc::now();
232 let date = now.format("%Y-%m-%d").to_string();
233
234 let tx_hex = hex::encode(tx_bytes);
235
236 let context = ows_core::PolicyContext {
237 chain_id: chain.chain_id.to_string(),
238 wallet_id: wallet.id.clone(),
239 api_key_id: key_file.id.clone(),
240 transaction: ows_core::policy::TransactionContext {
241 to: None,
242 value: None,
243 raw_hex: tx_hex,
244 data: None,
245 },
246 spending: noop_spending_context(&date),
247 timestamp: now.to_rfc3339(),
248 };
249
250 let result = policy_engine::evaluate_policies(&policies, &context);
251 if !result.allow {
252 return Err(OwsLibError::Core(OwsError::PolicyDenied {
253 policy_id: result.policy_id.unwrap_or_default(),
254 reason: result.reason.unwrap_or_else(|| "denied".into()),
255 }));
256 }
257
258 let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
259
260 Ok((key, key_file))
261}
262
263fn noop_spending_context(date: &str) -> ows_core::policy::SpendingContext {
268 ows_core::policy::SpendingContext {
269 daily_total: "0".to_string(),
270 date: date.to_string(),
271 }
272}
273
274fn check_expiry(key_file: &ApiKeyFile) -> Result<(), OwsLibError> {
275 if let Some(ref expires) = key_file.expires_at {
276 let now = chrono::Utc::now().to_rfc3339();
277 if now.as_str() > expires.as_str() {
278 return Err(OwsLibError::Core(OwsError::ApiKeyExpired {
279 id: key_file.id.clone(),
280 }));
281 }
282 }
283 Ok(())
284}
285
286fn load_policies_for_key(
287 key_file: &ApiKeyFile,
288 vault_path: Option<&Path>,
289) -> Result<Vec<ows_core::Policy>, OwsLibError> {
290 let mut policies = Vec::with_capacity(key_file.policy_ids.len());
291 for pid in &key_file.policy_ids {
292 policies.push(policy_store::load_policy(pid, vault_path)?);
293 }
294 Ok(policies)
295}
296
297fn decrypt_key_from_api_key(
298 key_file: &ApiKeyFile,
299 wallet_id: &str,
300 token: &str,
301 chain_type: ows_core::ChainType,
302 index: Option<u32>,
303) -> Result<SecretBytes, OwsLibError> {
304 let envelope_value = key_file.wallet_secrets.get(wallet_id).ok_or_else(|| {
305 OwsLibError::InvalidInput(format!(
306 "API key has no encrypted secret for wallet {wallet_id}"
307 ))
308 })?;
309
310 let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
311 let secret = decrypt(&envelope, token)?;
312
313 let phrase = std::str::from_utf8(secret.expose())
315 .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into()))?;
316 let mnemonic = Mnemonic::from_phrase(phrase)?;
317 let signer = signer_for_chain(chain_type);
318 let path = signer.default_derivation_path(index.unwrap_or(0));
319 let curve = signer.curve();
320 Ok(HdDeriver::derive_from_mnemonic_cached(
321 &mnemonic, "", &path, curve,
322 )?)
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use ows_core::{EncryptedWallet, KeyType, PolicyAction, PolicyRule, WalletAccount};
329 use ows_signer::encrypt;
330
331 fn setup_test_wallet(vault: &Path, passphrase: &str) -> String {
333 let mnemonic_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
334 let envelope = encrypt(mnemonic_phrase.as_bytes(), passphrase).unwrap();
335 let crypto = serde_json::to_value(&envelope).unwrap();
336
337 let wallet = EncryptedWallet::new(
338 "test-wallet-id".to_string(),
339 "test-wallet".to_string(),
340 vec![WalletAccount {
341 account_id: "eip155:8453:0xabc".to_string(),
342 address: "0xabc".to_string(),
343 chain_id: "eip155:8453".to_string(),
344 derivation_path: "m/44'/60'/0'/0/0".to_string(),
345 }],
346 crypto,
347 KeyType::Mnemonic,
348 );
349
350 vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
351 wallet.id
352 }
353
354 fn setup_test_policy(vault: &Path) -> String {
355 let policy = ows_core::Policy {
356 id: "test-policy".to_string(),
357 name: "Test Policy".to_string(),
358 version: 1,
359 created_at: "2026-03-22T10:00:00Z".to_string(),
360 rules: vec![PolicyRule::AllowedChains {
361 chain_ids: vec!["eip155:8453".to_string()],
362 }],
363 executable: None,
364 config: None,
365 action: PolicyAction::Deny,
366 };
367 policy_store::save_policy(&policy, Some(vault)).unwrap();
368 policy.id
369 }
370
371 #[test]
372 fn create_api_key_and_verify_token() {
373 let dir = tempfile::tempdir().unwrap();
374 let vault = dir.path().to_path_buf();
375 let passphrase = "test-pass";
376
377 let wallet_id = setup_test_wallet(&vault, passphrase);
378 let policy_id = setup_test_policy(&vault);
379
380 let (token, key_file) = create_api_key(
381 "test-agent",
382 std::slice::from_ref(&wallet_id),
383 std::slice::from_ref(&policy_id),
384 passphrase,
385 None,
386 Some(&vault),
387 )
388 .unwrap();
389
390 assert!(token.starts_with("ows_key_"));
392
393 assert_eq!(key_file.name, "test-agent");
395 assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
396 assert_eq!(key_file.policy_ids, vec![policy_id]);
397 assert_eq!(key_file.token_hash, key_store::hash_token(&token));
398 assert!(key_file.expires_at.is_none());
399
400 assert!(key_file.wallet_secrets.contains_key(&wallet_id));
402 let envelope: CryptoEnvelope =
403 serde_json::from_value(key_file.wallet_secrets[&wallet_id].clone()).unwrap();
404 let decrypted = decrypt(&envelope, &token).unwrap();
405 assert_eq!(
406 std::str::from_utf8(decrypted.expose()).unwrap(),
407 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
408 );
409
410 let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
412 assert_eq!(loaded.name, "test-agent");
413 }
414
415 #[test]
418 fn create_api_key_accepts_wallet_name_and_stores_canonical_ids() {
419 let dir = tempfile::tempdir().unwrap();
420 let vault = dir.path().to_path_buf();
421 let passphrase = "test-pass";
422
423 let wallet_id = setup_test_wallet(&vault, passphrase);
424 let policy_id = setup_test_policy(&vault);
425
426 let (token, key_file) = create_api_key(
427 "name-input-agent",
428 &["test-wallet".to_string()],
429 std::slice::from_ref(&policy_id),
430 passphrase,
431 None,
432 Some(&vault),
433 )
434 .unwrap();
435
436 assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
437
438 let chain = ows_core::parse_chain("base").unwrap();
439 let tx_bytes = vec![0u8; 32];
440 let result =
441 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
442 assert!(
443 result.is_ok(),
444 "sign_with_api_key failed: {:?}",
445 result.err()
446 );
447 }
448
449 #[test]
450 fn create_api_key_wrong_passphrase_fails() {
451 let dir = tempfile::tempdir().unwrap();
452 let vault = dir.path().to_path_buf();
453
454 let wallet_id = setup_test_wallet(&vault, "correct");
455 let policy_id = setup_test_policy(&vault);
456
457 let result = create_api_key(
458 "agent",
459 &[wallet_id],
460 &[policy_id],
461 "wrong-passphrase",
462 None,
463 Some(&vault),
464 );
465 assert!(result.is_err());
466 }
467
468 #[test]
469 fn create_api_key_nonexistent_wallet_fails() {
470 let dir = tempfile::tempdir().unwrap();
471 let vault = dir.path().to_path_buf();
472 let policy_id = setup_test_policy(&vault);
473
474 let result = create_api_key(
475 "agent",
476 &["nonexistent".to_string()],
477 &[policy_id],
478 "pass",
479 None,
480 Some(&vault),
481 );
482 assert!(result.is_err());
483 }
484
485 #[test]
486 fn create_api_key_nonexistent_policy_fails() {
487 let dir = tempfile::tempdir().unwrap();
488 let vault = dir.path().to_path_buf();
489
490 let wallet_id = setup_test_wallet(&vault, "pass");
491
492 let result = create_api_key(
493 "agent",
494 &[wallet_id],
495 &["nonexistent-policy".to_string()],
496 "pass",
497 None,
498 Some(&vault),
499 );
500 assert!(result.is_err());
501 }
502
503 #[test]
504 fn create_api_key_with_expiry() {
505 let dir = tempfile::tempdir().unwrap();
506 let vault = dir.path().to_path_buf();
507
508 let wallet_id = setup_test_wallet(&vault, "pass");
509 let policy_id = setup_test_policy(&vault);
510
511 let (_, key_file) = create_api_key(
512 "expiring-agent",
513 &[wallet_id],
514 &[policy_id],
515 "pass",
516 Some("2026-12-31T00:00:00Z"),
517 Some(&vault),
518 )
519 .unwrap();
520
521 assert_eq!(key_file.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
522 }
523
524 #[test]
525 fn sign_with_api_key_full_flow() {
526 let dir = tempfile::tempdir().unwrap();
527 let vault = dir.path().to_path_buf();
528 let passphrase = "test-pass";
529
530 let wallet_id = setup_test_wallet(&vault, passphrase);
531 let policy_id = setup_test_policy(&vault);
532
533 let (token, _) = create_api_key(
534 "signer-agent",
535 &[wallet_id],
536 &[policy_id],
537 passphrase,
538 None,
539 Some(&vault),
540 )
541 .unwrap();
542
543 let chain = ows_core::parse_chain("base").unwrap();
545 let tx_bytes = vec![0u8; 32]; let result =
548 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
549
550 assert!(
552 result.is_ok(),
553 "sign_with_api_key failed: {:?}",
554 result.err()
555 );
556 let sign_result = result.unwrap();
557 assert!(!sign_result.signature.is_empty());
558 }
559
560 #[test]
561 fn sign_with_api_key_wrong_chain_denied() {
562 let dir = tempfile::tempdir().unwrap();
563 let vault = dir.path().to_path_buf();
564 let passphrase = "test-pass";
565
566 let wallet_id = setup_test_wallet(&vault, passphrase);
567 let policy_id = setup_test_policy(&vault); let (token, _) = create_api_key(
570 "agent",
571 &[wallet_id],
572 &[policy_id],
573 passphrase,
574 None,
575 Some(&vault),
576 )
577 .unwrap();
578
579 let chain = ows_core::parse_chain("ethereum").unwrap(); let tx_bytes = vec![0u8; 32];
582
583 let result =
584 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
585
586 assert!(result.is_err());
587 match result.unwrap_err() {
588 OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
589 assert!(reason.contains("not in allowlist"));
590 }
591 other => panic!("expected PolicyDenied, got: {other}"),
592 }
593 }
594
595 #[test]
596 fn sign_with_api_key_expired_key_rejected() {
597 let dir = tempfile::tempdir().unwrap();
598 let vault = dir.path().to_path_buf();
599 let passphrase = "test-pass";
600
601 let wallet_id = setup_test_wallet(&vault, passphrase);
602 let policy_id = setup_test_policy(&vault);
603
604 let (token, _) = create_api_key(
605 "agent",
606 &[wallet_id],
607 &[policy_id],
608 passphrase,
609 Some("2020-01-01T00:00:00Z"), Some(&vault),
611 )
612 .unwrap();
613
614 let chain = ows_core::parse_chain("base").unwrap();
615 let tx_bytes = vec![0u8; 32];
616
617 let result =
618 sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
619
620 assert!(result.is_err());
621 match result.unwrap_err() {
622 OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
623 other => panic!("expected ApiKeyExpired, got: {other}"),
624 }
625 }
626
627 #[test]
628 fn sign_with_wrong_token_fails() {
629 let dir = tempfile::tempdir().unwrap();
630 let vault = dir.path().to_path_buf();
631 let passphrase = "test-pass";
632
633 let wallet_id = setup_test_wallet(&vault, passphrase);
634 let policy_id = setup_test_policy(&vault);
635
636 let (_token, _) = create_api_key(
637 "agent",
638 &[wallet_id],
639 &[policy_id],
640 passphrase,
641 None,
642 Some(&vault),
643 )
644 .unwrap();
645
646 let chain = ows_core::parse_chain("base").unwrap();
647 let tx_bytes = vec![0u8; 32];
648
649 let result = sign_with_api_key(
650 "ows_key_wrong_token",
651 "test-wallet",
652 &chain,
653 &tx_bytes,
654 None,
655 Some(&vault),
656 );
657
658 assert!(result.is_err());
659 }
660
661 #[test]
662 fn sign_wallet_not_in_scope_fails() {
663 let dir = tempfile::tempdir().unwrap();
664 let vault = dir.path().to_path_buf();
665 let passphrase = "test-pass";
666
667 let wallet_id = setup_test_wallet(&vault, passphrase);
669 let policy_id = setup_test_policy(&vault);
670
671 let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
673 let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
674 let crypto2 = serde_json::to_value(&envelope2).unwrap();
675 let wallet2 = EncryptedWallet::new(
676 "wallet-2-id".to_string(),
677 "other-wallet".to_string(),
678 vec![],
679 crypto2,
680 KeyType::Mnemonic,
681 );
682 vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
683
684 let (token, _) = create_api_key(
686 "agent",
687 &[wallet_id],
688 &[policy_id],
689 passphrase,
690 None,
691 Some(&vault),
692 )
693 .unwrap();
694
695 let chain = ows_core::parse_chain("base").unwrap();
696 let tx_bytes = vec![0u8; 32];
697
698 let result = sign_with_api_key(
700 &token,
701 "other-wallet",
702 &chain,
703 &tx_bytes,
704 None,
705 Some(&vault),
706 );
707
708 assert!(result.is_err());
709 match result.unwrap_err() {
710 OwsLibError::InvalidInput(msg) => {
711 assert!(msg.contains("does not have access"));
712 }
713 other => panic!("expected InvalidInput, got: {other}"),
714 }
715 }
716}