Skip to main content

ows_core/
api_key.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// An API key file stored at `~/.ows/keys/<id>.json`.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ApiKeyFile {
7    pub id: String,
8    pub name: String,
9    /// SHA-256 hash of the raw token (hex-encoded).
10    pub token_hash: String,
11    pub created_at: String,
12    /// Wallet IDs this key can access.
13    pub wallet_ids: Vec<String>,
14    /// Policy IDs attached to this key (AND semantics).
15    pub policy_ids: Vec<String>,
16    /// Optional expiry timestamp.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub expires_at: Option<String>,
19    /// Per-wallet encrypted mnemonic copies, keyed by wallet ID.
20    /// Each value is a CryptoEnvelope encrypted with HKDF(token).
21    pub wallet_secrets: HashMap<String, serde_json::Value>,
22}
23
24#[cfg(test)]
25mod tests {
26    use super::*;
27
28    #[test]
29    fn test_api_key_file_serde_roundtrip() {
30        let key = ApiKeyFile {
31            id: "7a2f1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c".into(),
32            name: "claude-agent".into(),
33            token_hash: "e3b0c44298fc1c149afbf4c8996fb924".into(),
34            created_at: "2026-03-22T10:30:00Z".into(),
35            wallet_ids: vec!["3198bc9c-6672-5ab3-d995-4942343ae5b6".into()],
36            policy_ids: vec!["base-agent-limits".into()],
37            expires_at: None,
38            wallet_secrets: HashMap::from([(
39                "3198bc9c-6672-5ab3-d995-4942343ae5b6".into(),
40                serde_json::json!({
41                    "cipher": "aes-256-gcm",
42                    "cipherparams": { "iv": "aabbccdd" },
43                    "ciphertext": "deadbeef",
44                    "auth_tag": "cafebabe",
45                    "kdf": "hkdf-sha256",
46                    "kdfparams": { "dklen": 32, "salt": "0011", "info": "ows-api-key-v1" }
47                }),
48            )]),
49        };
50
51        let json = serde_json::to_string_pretty(&key).unwrap();
52        let deserialized: ApiKeyFile = serde_json::from_str(&json).unwrap();
53        assert_eq!(deserialized.id, key.id);
54        assert_eq!(deserialized.name, "claude-agent");
55        assert_eq!(deserialized.wallet_ids.len(), 1);
56        assert_eq!(deserialized.policy_ids, vec!["base-agent-limits"]);
57        assert!(deserialized.expires_at.is_none());
58        assert!(deserialized
59            .wallet_secrets
60            .contains_key("3198bc9c-6672-5ab3-d995-4942343ae5b6"));
61    }
62
63    #[test]
64    fn test_api_key_file_with_expiry() {
65        let key = ApiKeyFile {
66            id: "test-id".into(),
67            name: "expiring-key".into(),
68            token_hash: "abc123".into(),
69            created_at: "2026-03-22T10:30:00Z".into(),
70            wallet_ids: vec![],
71            policy_ids: vec![],
72            expires_at: Some("2026-04-01T00:00:00Z".into()),
73            wallet_secrets: HashMap::new(),
74        };
75
76        let json = serde_json::to_string(&key).unwrap();
77        assert!(json.contains("expires_at"));
78        let deserialized: ApiKeyFile = serde_json::from_str(&json).unwrap();
79        assert_eq!(
80            deserialized.expires_at.as_deref(),
81            Some("2026-04-01T00:00:00Z")
82        );
83    }
84
85    #[test]
86    fn test_api_key_file_no_expiry_omits_field() {
87        let key = ApiKeyFile {
88            id: "test-id".into(),
89            name: "no-expiry".into(),
90            token_hash: "abc123".into(),
91            created_at: "2026-03-22T10:30:00Z".into(),
92            wallet_ids: vec![],
93            policy_ids: vec![],
94            expires_at: None,
95            wallet_secrets: HashMap::new(),
96        };
97
98        let json = serde_json::to_string(&key).unwrap();
99        assert!(!json.contains("expires_at"));
100    }
101}