Skip to main content

brainos_vault/
keyring.rs

1//! Linux `secret-service` backend (GNOME Keyring / KDE Wallet).
2//!
3//! Collection = default collection; items are identified by attributes
4//! `{brain="1", tool=<tool>, key=<key>}` and labelled `brain:<tool>:<key>`.
5
6use std::collections::HashMap;
7
8use chrono::Utc;
9use secret_service::{EncryptionType, SecretService};
10
11use crate::inject::{CredentialMetadata, CredentialValue, InjectedCredential, InjectionShape};
12use crate::vault::VaultError;
13
14#[derive(Default)]
15pub struct SecretServiceBackend;
16
17impl SecretServiceBackend {
18    pub fn new() -> Self {
19        Self
20    }
21
22    fn label(tool: &str, key: &str) -> String {
23        format!("brain:{tool}:{key}")
24    }
25
26    fn attrs<'a>(tool: &'a str, key: &'a str) -> HashMap<&'a str, &'a str> {
27        let mut m = HashMap::with_capacity(3);
28        m.insert("brain", "1");
29        m.insert("tool", tool);
30        m.insert("key", key);
31        m
32    }
33
34    async fn connect(&self) -> Result<SecretService<'_>, VaultError> {
35        SecretService::connect(EncryptionType::Dh)
36            .await
37            .map_err(|e| VaultError::BackendUnavailable(format!("secret-service: {e}")))
38    }
39
40    fn encode_shape(shape: &InjectionShape) -> String {
41        serde_json::to_string(shape).unwrap_or_else(|_| "{}".to_string())
42    }
43
44    fn decode_shape(raw: &str) -> Result<InjectionShape, VaultError> {
45        serde_json::from_str(raw).map_err(|e| VaultError::InvalidData(format!("shape parse: {e}")))
46    }
47
48    pub async fn store(
49        &self,
50        tool: &str,
51        key: &str,
52        value: CredentialValue,
53        shape: InjectionShape,
54    ) -> Result<(), VaultError> {
55        let ss = self.connect().await?;
56        let collection = ss
57            .get_default_collection()
58            .await
59            .map_err(|e| VaultError::Backend(format!("get collection: {e}")))?;
60
61        let shape_json = Self::encode_shape(&shape);
62        let created_at = Utc::now().to_rfc3339();
63        let mut attrs = Self::attrs(tool, key);
64        attrs.insert("shape", shape_json.as_str());
65        attrs.insert("created_at", created_at.as_str());
66
67        collection
68            .create_item(
69                &Self::label(tool, key),
70                attrs,
71                value.as_str().as_bytes(),
72                true,
73                "text/plain",
74            )
75            .await
76            .map_err(|e| VaultError::Backend(format!("create_item: {e}")))?;
77        Ok(())
78    }
79
80    pub async fn get(&self, tool: &str, key: &str) -> Result<InjectedCredential, VaultError> {
81        let ss = self.connect().await?;
82        let attrs = Self::attrs(tool, key);
83        let items = ss
84            .search_items(attrs)
85            .await
86            .map_err(|e| VaultError::Backend(format!("search: {e}")))?;
87
88        let item = items
89            .unlocked
90            .into_iter()
91            .chain(items.locked)
92            .next()
93            .ok_or_else(|| VaultError::NotFound {
94                tool: tool.to_string(),
95                key: key.to_string(),
96            })?;
97
98        item.unlock()
99            .await
100            .map_err(|e| VaultError::Backend(format!("unlock: {e}")))?;
101
102        let secret = item
103            .get_secret()
104            .await
105            .map_err(|e| VaultError::Backend(format!("get_secret: {e}")))?;
106        let value =
107            String::from_utf8(secret).map_err(|e| VaultError::InvalidData(format!("utf8: {e}")))?;
108
109        let item_attrs = item
110            .get_attributes()
111            .await
112            .map_err(|e| VaultError::Backend(format!("get_attributes: {e}")))?;
113        let shape = item_attrs
114            .get("shape")
115            .map(|s| Self::decode_shape(s))
116            .transpose()?
117            .ok_or_else(|| VaultError::InvalidData("missing shape attribute".into()))?;
118
119        Ok(InjectedCredential {
120            shape,
121            value: CredentialValue::new(value),
122        })
123    }
124
125    pub async fn delete(&self, tool: &str, key: &str) -> Result<(), VaultError> {
126        let ss = self.connect().await?;
127        let attrs = Self::attrs(tool, key);
128        let items = ss
129            .search_items(attrs)
130            .await
131            .map_err(|e| VaultError::Backend(format!("search: {e}")))?;
132
133        let item = items
134            .unlocked
135            .into_iter()
136            .chain(items.locked)
137            .next()
138            .ok_or_else(|| VaultError::NotFound {
139                tool: tool.to_string(),
140                key: key.to_string(),
141            })?;
142
143        item.delete()
144            .await
145            .map_err(|e| VaultError::Backend(format!("delete: {e}")))
146    }
147
148    pub async fn list(&self, tool: Option<&str>) -> Result<Vec<CredentialMetadata>, VaultError> {
149        let ss = self.connect().await?;
150        let mut attrs = HashMap::new();
151        attrs.insert("brain", "1");
152        if let Some(t) = tool {
153            attrs.insert("tool", t);
154        }
155
156        let items = ss
157            .search_items(attrs)
158            .await
159            .map_err(|e| VaultError::Backend(format!("search: {e}")))?;
160
161        let mut out = Vec::new();
162        for item in items.unlocked.into_iter().chain(items.locked) {
163            let item_attrs = match item.get_attributes().await {
164                Ok(m) => m,
165                Err(err) => {
166                    tracing::warn!(error = %err, "vault/secret-service: skip item with no attrs");
167                    continue;
168                }
169            };
170            let tool_name = item_attrs.get("tool").cloned().unwrap_or_default();
171            let key_name = item_attrs.get("key").cloned().unwrap_or_default();
172            let created_at = item_attrs.get("created_at").cloned().unwrap_or_default();
173            let shape = item_attrs
174                .get("shape")
175                .map(|s| Self::decode_shape(s))
176                .transpose()
177                .ok()
178                .flatten()
179                .unwrap_or(InjectionShape::EnvVar {
180                    name: "UNKNOWN".to_string(),
181                });
182            out.push(CredentialMetadata {
183                tool: tool_name,
184                key: key_name,
185                backend: "secret-service".to_string(),
186                created_at,
187                last_used_at: None,
188                shape,
189            });
190        }
191        Ok(out)
192    }
193}