diff --git a/src-tauri/creddy_cli/src/cli/docker.rs b/src-tauri/creddy_cli/src/cli/docker.rs index e69de29..4d8d6ba 100644 --- a/src-tauri/creddy_cli/src/cli/docker.rs +++ b/src-tauri/creddy_cli/src/cli/docker.rs @@ -0,0 +1,10 @@ +use std::io; + +use crate::proto::DockerCredential; + + +pub fn docker_store() -> anyhow::Result<()> { + let input: DockerCredential = serde_json::from_reader(io::stdin())?; + dbg!(input); + Ok(()) +} diff --git a/src-tauri/creddy_cli/src/cli.rs b/src-tauri/creddy_cli/src/cli/mod.rs similarity index 98% rename from src-tauri/creddy_cli/src/cli.rs rename to src-tauri/creddy_cli/src/cli/mod.rs index 60bd449..e5afae2 100644 --- a/src-tauri/creddy_cli/src/cli.rs +++ b/src-tauri/creddy_cli/src/cli/mod.rs @@ -22,6 +22,8 @@ use crate::proto::{ ShortcutAction, }; +mod docker; + #[derive(Debug, Parser)] #[command( @@ -200,11 +202,11 @@ pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<( pub fn docker_credential_helper(cmd: DockerCmd) -> anyhow::Result<()> { - let req = match cmd { + match cmd { DockerCmd::Get => todo!(), - DockerCmd::Store => todo!(), + DockerCmd::Store => docker::docker_store(), DockerCmd::Erase => todo!(), - }; + } } diff --git a/src-tauri/creddy_cli/src/proto.rs b/src-tauri/creddy_cli/src/proto.rs index dd7a508..37ffece 100644 --- a/src-tauri/creddy_cli/src/proto.rs +++ b/src-tauri/creddy_cli/src/proto.rs @@ -41,6 +41,7 @@ impl Display for CliResponse { match self { CliResponse::Credential(CliCredential::AwsBase(_)) => write!(f, "Credential (AwsBase)"), CliResponse::Credential(CliCredential::AwsSession(_)) => write!(f, "Credential (AwsSession)"), + CliResponse::Credential(CliCredential::Docker(_)) => write!(f, "Credential (Docker)"), CliResponse::Empty => write!(f, "Empty"), } } @@ -84,7 +85,7 @@ fn default_aws_version() -> usize { 1 } #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct DockerCredential { - #[serde(rename = "PascalCase")] + #[serde(rename = "ServerURL")] pub server_url: String, pub username: String, pub secret: String, diff --git a/src-tauri/migrations/20240919135710_docker_creds.sql b/src-tauri/migrations/20240919135710_docker_creds.sql new file mode 100644 index 0000000..ea45622 --- /dev/null +++ b/src-tauri/migrations/20240919135710_docker_creds.sql @@ -0,0 +1,11 @@ +CREATE TABLE docker_credentials ( + id BLOB UNIQUE NOT NULL, + -- The Docker credential helper protocol only sends the server_url, so + -- we should guarantee that we will only ever have one matching credential. + -- Also, it's easier to go from unique -> not-unique than vice versa if we + -- decide that's necessary in the future + server_url TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + secret_enc BLOB NOT NULL, + nonce BLOB NOT NULL +); diff --git a/src-tauri/src/credentials/docker.rs b/src-tauri/src/credentials/docker.rs new file mode 100644 index 0000000..c03a576 --- /dev/null +++ b/src-tauri/src/credentials/docker.rs @@ -0,0 +1,197 @@ +use chacha20poly1305::XNonce; +use serde::{Serialize, Deserialize}; +use sqlx::{ + FromRow, + Sqlite, + Transaction, + types::Uuid, +}; + +use super::{Credential, Crypto, PersistentCredential}; + +use crate::errors::*; + + +#[derive(Debug, Clone, FromRow)] +pub struct DockerRow { + id: Uuid, + server_url: String, + username: String, + secret_enc: Vec, + nonce: Vec, +} + + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct DockerCredential { + #[serde(rename = "ServerURL")] + pub server_url: String, + pub username: String, + pub secret: String, +} + + +impl PersistentCredential for DockerCredential { + type Row = DockerRow; + + fn type_name() -> &'static str { "docker" } + + fn into_credential(self) -> Credential { Credential::Docker(self) } + + fn row_id(row: &DockerRow) -> Uuid { row.id } + + fn from_row(row: DockerRow, crypto: &Crypto) -> Result { + let nonce = XNonce::clone_from_slice(&row.nonce); + let secret_bytes = crypto.decrypt(&nonce, &row.secret_enc)?; + let secret = String::from_utf8(secret_bytes) + .map_err(|_| LoadCredentialsError::InvalidData)?; + + Ok(DockerCredential { + server_url: row.server_url, + username: row.username, + secret + }) + } + + async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError> { + let (nonce, ciphertext) = crypto.encrypt(self.secret.as_bytes())?; + let nonce_bytes = &nonce.as_slice(); + + sqlx::query!( + "INSERT OR REPLACE INTO docker_credentials ( + id, + server_url, + username, + secret_enc, + nonce + ) + VALUES (?, ?, ?, ?, ?)", + id, self.server_url, self.username, ciphertext, nonce_bytes, + ).execute(&mut **txn).await?; + + Ok(()) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::CredentialRecord; + use creddy_cli::proto::DockerCredential as CliDockerCredential; + use sqlx::SqlitePool; + use sqlx::types::uuid::uuid; + + + + fn test_credential() -> DockerCredential { + DockerCredential { + server_url: "https://registry.jfmonty2.com".into(), + username: "joe@jfmonty2.com".into(), + secret: "correct horse battery staple".into(), + } + } + + fn test_credential_2() -> DockerCredential { + DockerCredential { + server_url: "https://index.docker.io/v1".into(), + username: "test@example.com".into(), + secret: "a very secure passphrase".into(), + } + } + + fn test_record() -> CredentialRecord { + CredentialRecord { + id: uuid!("00000000-0000-0000-0000-000000000000"), + name: "docker_test".into(), + is_default: false, + credential: Credential::Docker(test_credential()), + } + } + + fn test_record_2() -> CredentialRecord { + CredentialRecord { + id: uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"), + name: "docker_test_2".into(), + is_default: false, + credential: Credential::Docker(test_credential_2()), + } + } + + + #[sqlx::test] + fn test_save(pool: SqlitePool) { + let crypt = Crypto::random(); + test_record().save(&crypt, &pool).await + .expect("Failed to save record"); + } + + #[sqlx::test(fixtures("docker_credentials"))] + fn test_load(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let id = uuid!("00000000-0000-0000-0000-000000000000"); + let loaded = DockerCredential::load(&id, &crypt, &pool).await + .expect("Failed to load record"); + + assert_eq!(test_credential(), loaded); + } + + #[sqlx::test(fixtures("docker_credentials"))] + async fn test_overwrite(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let mut record = test_record_2(); + // give it the same id as test_record so that it overwrites + let id = uuid!("00000000-0000-0000-0000-000000000000"); + record.id = id; + record.save(&crypt, &pool).await + .expect("Failed to overwrite original record with second record"); + + let loaded = DockerCredential::load(&id, &crypt, &pool).await + .expect("Failed to load again after overwriting"); + + assert_eq!(test_credential_2(), loaded); + } + + #[sqlx::test(fixtures("docker_credentials"))] + async fn test_list(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let records = CredentialRecord::list(&crypt, &pool).await + .expect("Failed to list credentials"); + + assert_eq!(test_record(), records[0]); + } + + + // make sure that CLI credentials and app credentials don't drift apart + #[test] + fn test_cli_to_app() { + let cli_creds = CliDockerCredential { + server_url: "https://registry.jfmonty2.com".into(), + username: "joe@jfmonty2.com".into(), + secret: "correct horse battery staple".into(), + }; + + let json = serde_json::to_string(&cli_creds).unwrap(); + let computed: DockerCredential = serde_json::from_str(&json) + .expect("Failed to deserialize Docker credentials from CLI -> main app"); + + assert_eq!(test_credential(), computed); + } + + #[test] + fn test_app_to_cli() { + let app_creds = test_credential(); + let json = serde_json::to_string(&app_creds).unwrap(); + + let computed: CliDockerCredential = serde_json::from_str(&json) + .expect("Failed to deserialize Docker credentials from main app -> CLI"); + + let expected = CliDockerCredential { + server_url: "https://registry.jfmonty2.com".into(), + username: "joe@jfmonty2.com".into(), + secret: "correct horse battery staple".into(), + }; + assert_eq!(expected, computed); + } +} diff --git a/src-tauri/src/credentials/fixtures/docker_credentials.sql b/src-tauri/src/credentials/fixtures/docker_credentials.sql new file mode 100644 index 0000000..11ae35e --- /dev/null +++ b/src-tauri/src/credentials/fixtures/docker_credentials.sql @@ -0,0 +1,11 @@ +INSERT INTO credentials (id, name, credential_type, is_default, created_at) +VALUES (X'00000000000000000000000000000000', 'docker_test', 'docker', 0, 1726756380); + +INSERT INTO docker_credentials (id, server_url, username, secret_enc, nonce) +VALUES ( + X'00000000000000000000000000000000', + 'https://registry.jfmonty2.com', + 'joe@jfmonty2.com', + X'C0B36EE54539D4113A8F73E99FB96B2BF4D87E91F7C3B48256C07E83E3E7EC738888B2FDE2B4DB0BE48BEFDE', + X'C5F7F627BBE09A1BB275BE8D2390596C76143881A7766E60' +); diff --git a/src-tauri/src/credentials/mod.rs b/src-tauri/src/credentials/mod.rs index 3954dd5..a4ae4d6 100644 --- a/src-tauri/src/credentials/mod.rs +++ b/src-tauri/src/credentials/mod.rs @@ -17,6 +17,9 @@ pub use aws::{AwsBaseCredential, AwsSessionCredential}; mod crypto; pub use crypto::Crypto; +mod docker; +pub use docker::DockerCredential; + mod record; pub use record::CredentialRecord; @@ -32,6 +35,7 @@ pub use ssh::SshKey; pub enum Credential { AwsBase(AwsBaseCredential), AwsSession(AwsSessionCredential), + Docker(DockerCredential), Ssh(SshKey), } @@ -99,15 +103,15 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result, LoadCredentialsError> { let q = format!( "SELECT details.* - FROM + FROM {} details JOIN credentials c ON c.id = details.id - ORDER BY c.created_at", + ORDER BY c.created_at", Self::table_name(), ); let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool); - + let mut creds = Vec::new(); while let Some(row) = rows.try_next().await? { let id = Self::row_id(&row); diff --git a/src-tauri/src/credentials/record.rs b/src-tauri/src/credentials/record.rs index 206888d..0f59629 100644 --- a/src-tauri/src/credentials/record.rs +++ b/src-tauri/src/credentials/record.rs @@ -20,6 +20,7 @@ use super::{ AwsBaseCredential, Credential, Crypto, + DockerCredential, PersistentCredential, SshKey, }; @@ -51,6 +52,7 @@ impl CredentialRecord { let type_name = match &self.credential { Credential::AwsBase(_) => AwsBaseCredential::type_name(), Credential::Ssh(_) => SshKey::type_name(), + Credential::Docker(_) => DockerCredential::type_name(), _ => return Err(SaveCredentialsError::NotPersistent), }; @@ -86,6 +88,7 @@ impl CredentialRecord { match &self.credential { Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await, + Credential::Docker(d) => d.save_details(&self.id, crypto, &mut txn).await, _ => Err(SaveCredentialsError::NotPersistent), }?; @@ -167,6 +170,11 @@ impl CredentialRecord { .ok_or(LoadCredentialsError::InvalidData)?; records.push(Self::from_parts(parent, credential)); } + for (id, credential) in DockerCredential::list(crypto, pool).await? { + let parent = parent_map.remove(&id) + .ok_or(LoadCredentialsError::InvalidData)?; + records.push(Self::from_parts(parent, credential)); + } Ok(records) }