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