From ad4dd7d7331c101f66cb31b4bfed67dc291e575a Mon Sep 17 00:00:00 2001 From: Bilal Elmoussaoui Date: Thu, 2 Jul 2026 13:42:42 +0200 Subject: [PATCH 1/2] Add a git credential helper --- .github/workflows/CI.yml | 4 + Cargo.lock | 8 ++ Cargo.toml | 1 + README.md | 1 + coverage.sh | 2 + git-credential/Cargo.toml | 22 +++ git-credential/LICENSE | 1 + git-credential/README.md | 20 +++ git-credential/src/main.rs | 281 +++++++++++++++++++++++++++++++++++++ 9 files changed, 340 insertions(+) create mode 100644 git-credential/Cargo.toml create mode 120000 git-credential/LICENSE create mode 100644 git-credential/README.md create mode 100644 git-credential/src/main.rs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 675dd0f6c..8f54349e7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -62,6 +62,10 @@ jobs: run: | cargo build --manifest-path ./portal/Cargo.toml + - name: Build git-credential-oo7 + run: | + cargo build --manifest-path ./git-credential/Cargo.toml + - name: Test (native) run: | cargo test --manifest-path ./client/Cargo.toml --no-default-features --features tokio --features native_crypto --features schema diff --git a/Cargo.lock b/Cargo.lock index 990a71967..31f12a91c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,14 @@ dependencies = [ "temp-dir", ] +[[package]] +name = "git-credential-oo7" +version = "0.6.0-alpha" +dependencies = [ + "oo7", + "tokio", +] + [[package]] name = "hashbrown" version = "0.17.1" diff --git a/Cargo.toml b/Cargo.toml index c127f1db6..e17fbdb3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "cargo-credential", "client", "cli", + "git-credential", "kwallet/cli", "kwallet/parser", "macros", diff --git a/README.md b/README.md index 8a90a768d..6b33fbeb9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The repository consists of the following projects: - [cargo-credential](./cargo-credential/): a [cargo credential](https://doc.rust-lang.org/stable/cargo/reference/registry-authentication.html#registry-authentication) provider - [cli](./cli/): a secret-tool replacement - [client](./client/): the client side library +- [git-credential](./git-credential/): a [git credential helper](https://git-scm.com/docs/gitcredentials), replacement for git-credential-libsecret - [pam](./pam/): PAM integration for the server implementation - [portal](./portal/): [org.freedesktop.impl.portal.Secret](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.impl.portal.Secret.html) implementation - [server](./server/): [org.freedesktop.secrets](https://specifications.freedesktop.org/secret-service-spec/latest/) server implementation diff --git a/coverage.sh b/coverage.sh index 1940fa7c9..04aa588f2 100755 --- a/coverage.sh +++ b/coverage.sh @@ -54,6 +54,7 @@ grcov coverage-raw/combined.info \ --ignore-not-existing \ --ignore "**/portal/*" \ --ignore "**/python/*" \ + --ignore "**/git-credential/*" \ --ignore "**/cli/*" \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ @@ -74,6 +75,7 @@ grcov coverage-raw/combined.info \ --ignore-not-existing \ --ignore "**/portal/*" \ --ignore "**/python/*" \ + --ignore "**/git-credential/*" \ --ignore "**/cli/*" \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ diff --git a/git-credential/Cargo.toml b/git-credential/Cargo.toml new file mode 100644 index 000000000..733615acb --- /dev/null +++ b/git-credential/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "git-credential-oo7" +description = "Git credential helper using oo7" +version.workspace = true +edition.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true +exclude.workspace = true + +[dependencies] +oo7 = { workspace = true, features = ["tokio", "schema"] } +tokio = { workspace = true, features = ["macros", "rt"] } + +[features] +default = ["native_crypto"] +native_crypto = ["oo7/native_crypto"] +openssl_crypto = ["oo7/openssl_crypto"] diff --git a/git-credential/LICENSE b/git-credential/LICENSE new file mode 120000 index 000000000..ea5b60640 --- /dev/null +++ b/git-credential/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/git-credential/README.md b/git-credential/README.md new file mode 100644 index 000000000..6263b22ca --- /dev/null +++ b/git-credential/README.md @@ -0,0 +1,20 @@ + +# git-credential-oo7 + +[![crates.io](https://img.shields.io/crates/v/git-credential-oo7)](https://crates.io/crates/git-credential-oo7) + +A [git credential helper](https://git-scm.com/docs/gitcredentials) built using oo7 instead of [libsecret](https://gitlab.gnome.org/GNOME/libsecret). + +## Installation + +1 - `cargo install git-credential-oo7` + +2 - Set as the default credential helper + +``` +git config --global credential.helper oo7 +``` + +## License + +The project is released under the MIT license. diff --git a/git-credential/src/main.rs b/git-credential/src/main.rs new file mode 100644 index 000000000..3f94b4edc --- /dev/null +++ b/git-credential/src/main.rs @@ -0,0 +1,281 @@ +use std::io::BufRead; + +use oo7::{SecretSchema, dbus::Collection}; + +enum Error { + Oo7(oo7::dbus::Error), + Io(std::io::Error), + InvalidInput(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Oo7(e) => write!(f, "{e}"), + Error::Io(e) => write!(f, "{e}"), + Error::InvalidInput(msg) => write!(f, "{msg}"), + } + } +} + +impl From for Error { + fn from(e: oo7::dbus::Error) -> Self { + Error::Oo7(e) + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +#[derive(SecretSchema, Debug, Default)] +#[schema(name = "org.git.Password")] +struct GitSchema { + user: Option, + object: Option, + protocol: Option, + port: Option, + server: Option, +} + +struct Credential { + schema: GitSchema, + password: Option, + password_expiry_utc: Option, + oauth_refresh_token: Option, +} + +impl Credential { + fn from_stdin() -> Result { + let mut schema = GitSchema::default(); + let mut password = None; + let mut password_expiry_utc = None; + let mut oauth_refresh_token = None; + + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let line = line?; + if line.is_empty() { + break; + } + + let Some((key, value)) = line.split_once('=') else { + return Err(Error::InvalidInput(format!( + "invalid credential line: {line}" + ))); + }; + + match key { + "protocol" => schema.protocol = Some(value.to_owned()), + "host" => { + if let Some((host, port_str)) = value.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + schema.server = Some(host.to_owned()); + schema.port = Some(port); + } else { + schema.server = Some(value.to_owned()); + } + } else { + schema.server = Some(value.to_owned()); + } + } + "path" => schema.object = Some(value.to_owned()), + "username" => schema.user = Some(value.to_owned()), + "password" => password = Some(value.to_owned()), + "password_expiry_utc" => { + password_expiry_utc = Some(value.to_owned()); + } + "oauth_refresh_token" => { + oauth_refresh_token = Some(value.to_owned()); + } + _ => {} + } + } + + Ok(Self { + schema, + password, + password_expiry_utc, + oauth_refresh_token, + }) + } + + fn validate_get(&self) -> Result<(), Error> { + if self.schema.protocol.is_none() + || (self.schema.server.is_none() && self.schema.object.is_none()) + { + return Err(Error::InvalidInput( + "get requires protocol and host or path".into(), + )); + } + Ok(()) + } + + fn validate_store(&self) -> Result<(), Error> { + if self.schema.protocol.is_none() + || (self.schema.server.is_none() && self.schema.object.is_none()) + || self.schema.user.is_none() + || self.password.is_none() + { + return Err(Error::InvalidInput( + "store requires protocol, host/path, username, and password".into(), + )); + } + Ok(()) + } + + fn validate_erase(&self) -> Result<(), Error> { + if self.schema.protocol.is_none() + && self.schema.server.is_none() + && self.schema.object.is_none() + && self.schema.user.is_none() + { + return Err(Error::InvalidInput( + "erase requires at least protocol, host, path, or username".into(), + )); + } + Ok(()) + } + + fn make_label(&self) -> String { + let protocol = self.schema.protocol.as_deref().unwrap_or_default(); + let host = self.schema.server.as_deref().unwrap_or_default(); + let path = self.schema.object.as_deref().unwrap_or_default(); + match self.schema.port { + Some(port) => format!("Git: {protocol}://{host}:{port}/{path}"), + None => format!("Git: {protocol}://{host}/{path}"), + } + } + + fn make_secret(&self) -> String { + let mut secret = self.password.clone().unwrap_or_default(); + if let Some(ref expiry) = self.password_expiry_utc { + secret.push_str(&format!("\npassword_expiry_utc={expiry}")); + } + if let Some(ref token) = self.oauth_refresh_token { + secret.push_str(&format!("\noauth_refresh_token={token}")); + } + secret + } +} + +fn parse_secret(secret: &[u8]) -> (String, Option, Option) { + let text = std::str::from_utf8(secret).unwrap_or_default(); + let mut lines = text.split('\n'); + let password = lines.next().unwrap_or_default().to_owned(); + let mut password_expiry_utc = None; + let mut oauth_refresh_token = None; + for line in lines { + if let Some(val) = line.strip_prefix("password_expiry_utc=") { + password_expiry_utc = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("oauth_refresh_token=") { + oauth_refresh_token = Some(val.to_owned()); + } + } + (password, password_expiry_utc, oauth_refresh_token) +} + +async fn run(action: &str, credential: &Credential, collection: &Collection) -> Result<(), Error> { + match action { + "get" => { + credential.validate_get()?; + + let items = collection.search_items(&credential.schema).await?; + if let Some(item) = items.first() { + let attrs = item.attributes_as::().await?; + let secret = item.secret().await?; + let (password, expiry, oauth) = parse_secret(secret.as_bytes()); + + if let Some(user) = &attrs.user { + println!("username={user}"); + } + println!("password={password}"); + if let Some(expiry) = expiry { + println!("password_expiry_utc={expiry}"); + } + if let Some(oauth) = oauth { + println!("oauth_refresh_token={oauth}"); + } + } + } + "store" => { + credential.validate_store()?; + + collection + .create_item( + &credential.make_label(), + &credential.schema, + credential.make_secret(), + true, + None, + ) + .await?; + } + "erase" => { + credential.validate_erase()?; + + let items = collection.search_items(&credential.schema).await?; + + if let Some(ref password) = credential.password + && let Some(item) = items.first() + { + let secret = item.secret().await?; + let (stored_password, ..) = parse_secret(secret.as_bytes()); + if stored_password != *password { + return Ok(()); + } + } + + for item in &items { + item.delete(None).await?; + } + } + _ => {} + } + + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() != 2 || args[1].is_empty() { + let name = args + .first() + .map(String::as_str) + .unwrap_or("git-credential-oo7"); + eprintln!("usage: {name} "); + std::process::exit(1); + } + + let credential = match Credential::from_stdin() { + Ok(c) => c, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; + + let action = args[1].as_str(); + if !matches!(action, "get" | "store" | "erase") { + return; + } + + let result = async { + let service = oo7::dbus::Service::new().await?; + let collection = service.default_collection().await?; + if collection.is_locked().await? { + collection.unlock(None).await?; + } + + run(action, &credential, &collection).await + } + .await; + + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); + } +} From c7d6a14e809fe1a6aad15a347761cbfb099177ff Mon Sep 17 00:00:00 2001 From: Bilal Elmoussaoui Date: Fri, 3 Jul 2026 13:28:06 +0200 Subject: [PATCH 2/2] client: Add a as_str helper --- cargo-credential/src/main.rs | 16 ++++++++-------- cli/src/main.rs | 9 ++++----- client/src/secret.rs | 9 +++++++++ git-credential/src/main.rs | 8 ++++---- server/src/tests.rs | 6 +++--- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/cargo-credential/src/main.rs b/cargo-credential/src/main.rs index e26de80dc..e7c9be6ae 100644 --- a/cargo-credential/src/main.rs +++ b/cargo-credential/src/main.rs @@ -27,15 +27,15 @@ impl SecretServiceCredential { return Err(Error::NotFound); } + let secret = items[0] + .secret() + .await + .map_err(|err| Error::Other(Box::new(err)))?; let token = Secret::from( - std::str::from_utf8( - &items[0] - .secret() - .await - .map_err(|err| Error::Other(Box::new(err)))?, - ) - .unwrap() - .to_owned(), + secret + .as_str() + .ok_or_else(|| Error::Other("secret is not valid UTF-8".into()))? + .to_owned(), ); Ok(CredentialResponse::Get { diff --git a/cli/src/main.rs b/cli/src/main.rs index 190747f60..e46a74651 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -147,13 +147,12 @@ impl ItemOutput { .unwrap(); let secret_str = secret.map(|s| { - let bytes = s.as_bytes(); if as_hex { - hex::encode(bytes) + hex::encode(s.as_bytes()) } else { - match std::str::from_utf8(bytes) { - Ok(s) => s.to_string(), - Err(_) => hex::encode(bytes), + match s.as_str() { + Some(s) => s.to_string(), + None => hex::encode(s.as_bytes()), } } }); diff --git a/client/src/secret.rs b/client/src/secret.rs index aacb8157f..10fbea7fc 100644 --- a/client/src/secret.rs +++ b/client/src/secret.rs @@ -107,6 +107,15 @@ impl Secret { } } + /// Returns the secret as a string slice, or `None` if it is not valid + /// UTF-8. + pub fn as_str(&self) -> Option<&str> { + match self { + Self::Text(text) => Some(text.as_str()), + Self::Blob(bytes) => std::str::from_utf8(bytes).ok(), + } + } + pub fn as_bytes(&self) -> &[u8] { match self { Self::Text(text) => text.as_bytes(), diff --git a/git-credential/src/main.rs b/git-credential/src/main.rs index 3f94b4edc..fe73de067 100644 --- a/git-credential/src/main.rs +++ b/git-credential/src/main.rs @@ -161,8 +161,8 @@ impl Credential { } } -fn parse_secret(secret: &[u8]) -> (String, Option, Option) { - let text = std::str::from_utf8(secret).unwrap_or_default(); +fn parse_secret(secret: &oo7::Secret) -> (String, Option, Option) { + let text = secret.as_str().unwrap_or_default(); let mut lines = text.split('\n'); let password = lines.next().unwrap_or_default().to_owned(); let mut password_expiry_utc = None; @@ -186,7 +186,7 @@ async fn run(action: &str, credential: &Credential, collection: &Collection) -> if let Some(item) = items.first() { let attrs = item.attributes_as::().await?; let secret = item.secret().await?; - let (password, expiry, oauth) = parse_secret(secret.as_bytes()); + let (password, expiry, oauth) = parse_secret(&secret); if let Some(user) = &attrs.user { println!("username={user}"); @@ -222,7 +222,7 @@ async fn run(action: &str, credential: &Credential, collection: &Collection) -> && let Some(item) = items.first() { let secret = item.secret().await?; - let (stored_password, ..) = parse_secret(secret.as_bytes()); + let (stored_password, ..) = parse_secret(&secret); if stored_password != *password { return Ok(()); } diff --git a/server/src/tests.rs b/server/src/tests.rs index 6841ba6bd..0e1a0395b 100644 --- a/server/src/tests.rs +++ b/server/src/tests.rs @@ -535,7 +535,7 @@ impl MockPrompterService { let pwd = queue.remove(0); tracing::debug!( "MockPrompter: using password from queue (length: {}, queue remaining: {})", - std::str::from_utf8(pwd.as_bytes()).unwrap_or(""), + pwd.as_str().unwrap_or(""), queue.len() ); pwd @@ -543,7 +543,7 @@ impl MockPrompterService { let pwd = unlock_password.lock().await.clone().unwrap(); tracing::debug!( "MockPrompter: using default password (length: {})", - std::str::from_utf8(pwd.as_bytes()).unwrap_or("") + pwd.as_str().unwrap_or("") ); pwd }; @@ -796,7 +796,7 @@ impl MockPrompterServicePlasma { let pwd = self.unlock_password.lock().await.clone().unwrap(); tracing::debug!( "MockPrompterServicePlasma: using default password (length: {})", - std::str::from_utf8(pwd.as_bytes()).unwrap_or("") + pwd.as_str().unwrap_or("") ); MockPrompterServicePlasma::send_secret(&connection, &callback_path, &pwd).await?; };