Skip to main content

zlayer_secrets/
registry_credentials.rs

1//! Typed credential store for Docker/OCI registry authentication.
2//!
3//! Built on top of any [`SecretsStore`] implementation, this module provides
4//! structured storage for registry credentials. Metadata (registry, username,
5//! auth type) is stored as JSON in the `registry_credentials_meta` scope, while
6//! the actual password/token is stored as a secret in the `registry_credentials`
7//! scope. Both are keyed by a UUID identifier.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore};
13//! use zlayer_secrets::registry_credentials::{RegistryCredentialStore, RegistryAuthType};
14//!
15//! # async fn example() -> zlayer_secrets::Result<()> {
16//! let key = EncryptionKey::generate();
17//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
18//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
19//! let reg_store = RegistryCredentialStore::new(store);
20//!
21//! let cred = reg_store.create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token).await?;
22//! let password = reg_store.get_password(&cred.id).await?;
23//! assert_eq!(password.expose(), "ghp_xxxx");
24//! # Ok(())
25//! # }
26//! ```
27
28use serde::{Deserialize, Serialize};
29use tracing::{debug, info};
30use uuid::Uuid;
31
32use crate::{Result, Secret, SecretsError, SecretsStore};
33
34/// Scope used for storing registry password/token secrets.
35const REGISTRY_CRED_SCOPE: &str = "registry_credentials";
36
37/// Scope used for storing registry credential metadata (JSON).
38const REGISTRY_CRED_META_SCOPE: &str = "registry_credentials_meta";
39
40/// Docker/OCI registry credential metadata.
41///
42/// The actual password/token is stored separately as a [`Secret`] in the
43/// `registry_credentials` scope, keyed by [`id`](RegistryCredential::id).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RegistryCredential {
46    /// Unique identifier (UUID v4).
47    pub id: String,
48    /// Registry hostname, e.g. `"docker.io"`, `"ghcr.io"`.
49    pub registry: String,
50    /// Username for authentication.
51    pub username: String,
52    /// Whether this credential uses basic auth or a bearer token.
53    pub auth_type: RegistryAuthType,
54}
55
56/// Authentication method for a registry credential.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum RegistryAuthType {
60    /// HTTP Basic authentication (username + password).
61    Basic,
62    /// Bearer token authentication.
63    Token,
64}
65
66/// Store for Docker/OCI registry credentials.
67///
68/// Wraps a [`SecretsStore`] and organises data into two scopes:
69/// - **`registry_credentials_meta`**: JSON-serialised [`RegistryCredential`]
70///   metadata (everything except the password).
71/// - **`registry_credentials`**: The raw password/token as an encrypted secret.
72pub struct RegistryCredentialStore<S: SecretsStore> {
73    store: S,
74}
75
76impl<S: SecretsStore> std::fmt::Debug for RegistryCredentialStore<S> {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("RegistryCredentialStore")
79            .field("store", &"<secrets store>")
80            .finish()
81    }
82}
83
84impl<S: SecretsStore> RegistryCredentialStore<S> {
85    /// Create a new registry credential store backed by the provided secrets store.
86    pub fn new(store: S) -> Self {
87        Self { store }
88    }
89
90    /// Store a new registry credential.
91    ///
92    /// Generates a UUID for the credential, stores the password as an encrypted
93    /// secret in the `registry_credentials` scope, and the metadata as JSON in
94    /// the `registry_credentials_meta` scope.
95    ///
96    /// # Arguments
97    /// * `registry` - Registry hostname (e.g. `"docker.io"`)
98    /// * `username` - Username for authentication
99    /// * `password` - Password or token value (stored encrypted)
100    /// * `auth_type` - Authentication method
101    ///
102    /// # Errors
103    /// Returns a [`SecretsError`] if serialisation or storage fails.
104    pub async fn create(
105        &self,
106        registry: &str,
107        username: &str,
108        password: &str,
109        auth_type: RegistryAuthType,
110    ) -> Result<RegistryCredential> {
111        let id = Uuid::new_v4().to_string();
112
113        let cred = RegistryCredential {
114            id: id.clone(),
115            registry: registry.to_string(),
116            username: username.to_string(),
117            auth_type,
118        };
119
120        // Store metadata as JSON.
121        let meta_json = serde_json::to_string(&cred)
122            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
123        self.store
124            .set_secret(REGISTRY_CRED_META_SCOPE, &id, &Secret::new(meta_json))
125            .await?;
126
127        // Store the password/token as an encrypted secret.
128        self.store
129            .set_secret(REGISTRY_CRED_SCOPE, &id, &Secret::new(password))
130            .await?;
131
132        info!(id = %id, registry = %registry, username = %username, "Created registry credential");
133        Ok(cred)
134    }
135
136    /// Retrieve credential metadata (without the password).
137    ///
138    /// Returns `None` if no credential with the given `id` exists.
139    ///
140    /// # Errors
141    /// Returns a [`SecretsError`] on storage/decryption errors.
142    pub async fn get(&self, id: &str) -> Result<Option<RegistryCredential>> {
143        let secret = match self.store.get_secret(REGISTRY_CRED_META_SCOPE, id).await {
144            Ok(s) => s,
145            Err(SecretsError::NotFound { .. }) => {
146                debug!(id = %id, "Registry credential not found");
147                return Ok(None);
148            }
149            Err(e) => return Err(e),
150        };
151
152        let cred: RegistryCredential = serde_json::from_str(secret.expose()).map_err(|e| {
153            SecretsError::Storage(format!("corrupt registry credential '{id}': {e}"))
154        })?;
155
156        Ok(Some(cred))
157    }
158
159    /// Retrieve the password/token for a registry credential.
160    ///
161    /// # Errors
162    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
163    pub async fn get_password(&self, id: &str) -> Result<Secret> {
164        self.store.get_secret(REGISTRY_CRED_SCOPE, id).await
165    }
166
167    /// List all registry credentials (metadata only, no passwords).
168    ///
169    /// # Errors
170    /// Returns a [`SecretsError`] on storage/decryption errors.
171    pub async fn list(&self) -> Result<Vec<RegistryCredential>> {
172        let metas = self.store.list_secrets(REGISTRY_CRED_META_SCOPE).await?;
173
174        let mut creds = Vec::with_capacity(metas.len());
175        for meta in metas {
176            if let Some(cred) = self.get(&meta.name).await? {
177                creds.push(cred);
178            }
179        }
180
181        Ok(creds)
182    }
183
184    /// Delete a registry credential and its associated secret.
185    ///
186    /// Both the metadata and the password secret are removed.
187    ///
188    /// # Errors
189    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
190    pub async fn delete(&self, id: &str) -> Result<()> {
191        // Delete metadata first; if it doesn't exist, the whole credential is missing.
192        self.store
193            .delete_secret(REGISTRY_CRED_META_SCOPE, id)
194            .await?;
195
196        // Delete the password secret. Ignore NotFound here in case only metadata
197        // existed (defensive).
198        match self.store.delete_secret(REGISTRY_CRED_SCOPE, id).await {
199            Ok(()) | Err(SecretsError::NotFound { .. }) => {}
200            Err(e) => return Err(e),
201        }
202
203        info!(id = %id, "Deleted registry credential");
204        Ok(())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::{EncryptionKey, PersistentSecretsStore};
212
213    async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
214        let temp_dir = tempfile::tempdir().unwrap();
215        let db_path = temp_dir.path().join("test_registry_creds.sqlite");
216        let key = EncryptionKey::generate();
217        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
218        (store, temp_dir)
219    }
220
221    #[tokio::test]
222    async fn test_create_and_get() {
223        let (store, _temp) = create_test_store().await;
224        let reg_store = RegistryCredentialStore::new(store);
225
226        let cred = reg_store
227            .create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
228            .await
229            .unwrap();
230
231        assert_eq!(cred.registry, "ghcr.io");
232        assert_eq!(cred.username, "ci-bot");
233        assert_eq!(cred.auth_type, RegistryAuthType::Token);
234        assert!(!cred.id.is_empty());
235
236        let retrieved = reg_store.get(&cred.id).await.unwrap();
237        assert!(retrieved.is_some());
238        let retrieved = retrieved.unwrap();
239        assert_eq!(retrieved.id, cred.id);
240        assert_eq!(retrieved.registry, "ghcr.io");
241        assert_eq!(retrieved.username, "ci-bot");
242        assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
243    }
244
245    #[tokio::test]
246    async fn test_get_password() {
247        let (store, _temp) = create_test_store().await;
248        let reg_store = RegistryCredentialStore::new(store);
249
250        let cred = reg_store
251            .create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
252            .await
253            .unwrap();
254
255        let password = reg_store.get_password(&cred.id).await.unwrap();
256        assert_eq!(password.expose(), "s3cret!");
257    }
258
259    #[tokio::test]
260    async fn test_list() {
261        let (store, _temp) = create_test_store().await;
262        let reg_store = RegistryCredentialStore::new(store);
263
264        reg_store
265            .create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
266            .await
267            .unwrap();
268        reg_store
269            .create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
270            .await
271            .unwrap();
272
273        let list = reg_store.list().await.unwrap();
274        assert_eq!(list.len(), 2);
275
276        let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
277        assert!(registries.contains(&"docker.io"));
278        assert!(registries.contains(&"ghcr.io"));
279    }
280
281    #[tokio::test]
282    async fn test_delete() {
283        let (store, _temp) = create_test_store().await;
284        let reg_store = RegistryCredentialStore::new(store);
285
286        let cred = reg_store
287            .create("docker.io", "user", "pw", RegistryAuthType::Basic)
288            .await
289            .unwrap();
290
291        reg_store.delete(&cred.id).await.unwrap();
292
293        assert!(reg_store.get(&cred.id).await.unwrap().is_none());
294        assert!(reg_store.get_password(&cred.id).await.is_err());
295    }
296
297    #[tokio::test]
298    async fn test_create_overwrites() {
299        let (store, _temp) = create_test_store().await;
300        let reg_store = RegistryCredentialStore::new(store);
301
302        let cred1 = reg_store
303            .create("docker.io", "user", "pw1", RegistryAuthType::Basic)
304            .await
305            .unwrap();
306
307        // Create a second credential -- gets its own id, so it's a separate entry,
308        // not an overwrite by design. But if we manually set the same scope+key
309        // it would overwrite. This test verifies two creates produce two entries.
310        let cred2 = reg_store
311            .create("docker.io", "user", "pw2", RegistryAuthType::Basic)
312            .await
313            .unwrap();
314
315        assert_ne!(cred1.id, cred2.id);
316
317        let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
318        let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
319        assert_eq!(pw1.expose(), "pw1");
320        assert_eq!(pw2.expose(), "pw2");
321    }
322
323    #[tokio::test]
324    async fn test_get_nonexistent_returns_none() {
325        let (store, _temp) = create_test_store().await;
326        let reg_store = RegistryCredentialStore::new(store);
327
328        let result = reg_store.get("nonexistent-id").await.unwrap();
329        assert!(result.is_none());
330    }
331
332    #[tokio::test]
333    async fn test_get_password_nonexistent_returns_error() {
334        let (store, _temp) = create_test_store().await;
335        let reg_store = RegistryCredentialStore::new(store);
336
337        let result = reg_store.get_password("nonexistent-id").await;
338        assert!(result.is_err());
339        assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
340    }
341
342    #[tokio::test]
343    async fn test_delete_nonexistent_returns_error() {
344        let (store, _temp) = create_test_store().await;
345        let reg_store = RegistryCredentialStore::new(store);
346
347        let result = reg_store.delete("nonexistent-id").await;
348        assert!(result.is_err());
349        assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
350    }
351
352    #[tokio::test]
353    async fn test_serde_auth_type_roundtrip() {
354        let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
355        assert_eq!(basic, "\"basic\"");
356
357        let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
358        assert_eq!(token, "\"token\"");
359
360        let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
361        assert_eq!(parsed, RegistryAuthType::Basic);
362
363        let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
364        assert_eq!(parsed, RegistryAuthType::Token);
365    }
366}