Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cargo build
cargo test
```

All 91 tests should pass (31 unit + 40 integration + 14 GPG integration + 6 cross-compatibility). They cover:
All 95 tests should pass (33 unit + 40 integration + 16 GPG integration + 6 cross-compatibility). They cover:
- AES-256-CTR encryption/decryption round-trips
- HMAC-SHA1 known-answer vectors
- Key file TLV serialization/deserialization
Expand All @@ -44,6 +44,9 @@ All 91 tests should pass (31 unit + 40 integration + 14 GPG integration + 6 cros
- GPG rm-gpg-user: remove, --no-commit, user not found (GPG integration)
- GPG ls-gpg-users: list, no users, named key (GPG integration)
- GPG unlock roundtrip: add user, lock, unlock via GPG (GPG integration)
- GPG unlock with passphrase-protected key (pinentry/loopback) (GPG integration)
- GPG unlock clear error when no secret key matches any collaborator (GPG integration)
- GPG decrypt command builder: never passes `--batch` (suppresses pinentry) (unit)
- GPG multi-user: add 2 users, remove 1, verify count (GPG integration)
- Cross-tool: key exchange, encrypt/decrypt, named keys, binary files (cross-compatibility)

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ gitveil unlock [<key-file>...]

Without arguments, attempts GPG-based unlock using keys in `.git-crypt/`. With key file arguments, uses symmetric key files.

If your GPG private key is passphrase-protected, gitveil will let `gpg-agent` invoke pinentry to prompt you (terminal or GUI), just like `git-crypt`. Only collaborator files matching a secret key in your local keyring are tried, so pinentry fires at most once.

### `gitveil add-gpg-user`

Add a GPG user as a collaborator.
Expand Down
58 changes: 49 additions & 9 deletions src/commands/unlock.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::io::Cursor;
use std::path::PathBuf;

Expand All @@ -9,7 +10,7 @@ use crate::git::config::configure_filters;
use crate::git::repo::{
find_git_dir, find_repo_root, get_encrypted_files, git_crypt_dir, key_path,
};
use crate::gpg::operations::gpg_decrypt_from_file;
use crate::gpg::operations::{gpg_decrypt_from_file, gpg_list_secret_key_fingerprints};
use crate::key::key_file::KeyFile;

/// Unlock the repository: load key, configure filters, and decrypt working copy.
Expand Down Expand Up @@ -53,8 +54,19 @@ pub fn unlock(key_files: &[PathBuf], quiet: bool) -> Result<(), GitVeilError> {
return Err(GitVeilError::NotInitialized);
}

// Enumerate the fingerprints of secret keys in the local GPG keyring
// so we only attempt to decrypt collaborator files we actually have a
// private key for. This stops pinentry from being invoked once per
// collaborator and stops gpg from spamming stderr with "no secret
// key" for keys we obviously cannot decrypt.
let secret_fps: HashSet<String> = gpg_list_secret_key_fingerprints()?
.into_iter()
.map(|f| f.to_ascii_lowercase())
.collect();

let mut unlocked_any = false;
let mut last_gpg_error: Option<String> = None;
let mut any_collaborator_found = false;

// Iterate over key directories (skip symlinks)
let key_dirs: Vec<_> = std::fs::read_dir(&keys_dir)?
Expand All @@ -69,7 +81,22 @@ pub fn unlock(key_files: &[PathBuf], quiet: bool) -> Result<(), GitVeilError> {
let key_dir = key_dir_entry.path();
let gpg_files = find_gpg_files(&key_dir);

if !gpg_files.is_empty() {
any_collaborator_found = true;
}

for gpg_file in &gpg_files {
let stem = gpg_file
.file_stem()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();

// Skip files encrypted to recipients we don't have a secret
// key for. Filenames are the recipient's primary fingerprint.
if !secret_fps.contains(&stem) {
continue;
}

match gpg_decrypt_from_file(gpg_file) {
Ok(key_data) => {
let mut cursor = Cursor::new(key_data.as_slice());
Expand Down Expand Up @@ -103,14 +130,27 @@ pub fn unlock(key_files: &[PathBuf], quiet: bool) -> Result<(), GitVeilError> {
}

if !unlocked_any {
let detail = last_gpg_error
.map(|e| format!(" Last error: {}", e))
.unwrap_or_default();
return Err(GitVeilError::Gpg(format!(
"failed to decrypt any GPG-encrypted key. \
Do you have the right GPG private key?{}",
detail
)));
if !any_collaborator_found {
return Err(GitVeilError::Gpg(
"no GPG-encrypted keys found in .git-crypt/keys/".into(),
));
}
if let Some(detail) = last_gpg_error {
// A matching key was attempted but decryption failed (e.g.,
// the user cancelled the pinentry prompt, or the secret key
// is no longer available to gpg-agent).
return Err(GitVeilError::Gpg(format!(
"GPG decryption failed. {}",
detail
)));
}
// Collaborators exist but none match a secret key in our keyring.
return Err(GitVeilError::Gpg(
"no GPG secret key in your keyring matches any collaborator on this \
repository. Make sure the right private key is imported (try \
`gpg --list-secret-keys`)."
.into(),
));
}
}

Expand Down
132 changes: 125 additions & 7 deletions src/gpg/operations.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::io::Write;
use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use zeroize::Zeroizing;
Expand Down Expand Up @@ -132,26 +132,144 @@ pub fn gpg_encrypt_to_file(
Ok(())
}

/// Build the gpg command line for decrypting a single file.
///
/// This intentionally does NOT pass `--batch`: decrypting a collaborator's
/// key requires the user's private GPG key, which is often passphrase-
/// protected. With `--batch`, gpg-agent refuses to launch pinentry and
/// decryption fails with "Inappropriate ioctl for device". Matching
/// git-crypt's behavior, the caller is responsible for connecting stdin
/// and stderr to the user's terminal so pinentry can prompt.
fn build_decrypt_command(gpg: &str, path: &Path) -> Command {
let mut cmd = Command::new(gpg);
cmd.args(["--yes", "-q", "-d"]);
cmd.arg(path);
cmd
}

/// Decrypt a GPG-encrypted file and return the plaintext.
///
/// Mirrors git-crypt: stdin and stderr are inherited from the parent so
/// pinentry (terminal or graphical) can prompt the user for a passphrase
/// when the secret key is protected. Stdout is piped so the decrypted
/// bytes can be collected.
///
/// The returned buffer is wrapped in `Zeroizing` to ensure key material
/// is scrubbed from memory when dropped.
pub fn gpg_decrypt_from_file(path: &Path) -> Result<Zeroizing<Vec<u8>>, GitVeilError> {
let gpg = get_gpg_program();
let mut cmd = build_decrypt_command(&gpg, path);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());

let mut child = cmd
.spawn()
.map_err(|e| GitVeilError::Gpg(format!("failed to run {}: {}", gpg, e)))?;

let mut buf = Vec::new();
if let Some(mut stdout) = child.stdout.take() {
stdout
.read_to_end(&mut buf)
.map_err(|e| GitVeilError::Gpg(format!("failed to read gpg stdout: {}", e)))?;
}

let status = child
.wait()
.map_err(|e| GitVeilError::Gpg(format!("failed to wait for gpg: {}", e)))?;

if !status.success() {
return Err(GitVeilError::Gpg(format!(
"gpg decryption failed for {}",
path.display()
)));
}

Ok(Zeroizing::new(buf))
}

/// List the primary fingerprints of all secret keys in the local GPG keyring.
///
/// Used by `unlock` to skip `.gpg` files we obviously cannot decrypt, so
/// pinentry only fires once (for our own key) and gpg doesn't spam stderr
/// with "no secret key" messages for every other collaborator.
pub fn gpg_list_secret_key_fingerprints() -> Result<Vec<String>, GitVeilError> {
let gpg = get_gpg_program();

let output = Command::new(&gpg)
.args(["--batch", "--yes", "-q", "-d"])
.arg(path)
.args(["--batch", "--with-colons", "--list-secret-keys"])
.output()
.map_err(|e| GitVeilError::Gpg(format!("failed to run {}: {}", gpg, e)))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitVeilError::Gpg(format!(
"gpg decryption failed for {}: {}",
path.display(),
stderr
"gpg --list-secret-keys failed: {}",
stderr.trim()
)));
}

Ok(Zeroizing::new(output.stdout))
let stdout = String::from_utf8_lossy(&output.stdout);
let mut fingerprints = Vec::new();
// Colon-format primary fingerprints follow a `sec:` record; subkey
// fingerprints follow `ssb:`. We only want primaries because
// `add-gpg-user` stores collaborator files keyed by primary fingerprint.
let mut expect_primary_fpr = false;

for line in stdout.lines() {
if line.starts_with("sec:") {
expect_primary_fpr = true;
} else if line.starts_with("ssb:") {
expect_primary_fpr = false;
} else if expect_primary_fpr && line.starts_with("fpr:") {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() > 9 {
let fp = parts[9].to_string();
if validate_fingerprint(&fp).is_ok() {
fingerprints.push(fp);
}
}
expect_primary_fpr = false;
}
}

Ok(fingerprints)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn decrypt_command_does_not_use_batch() {
let cmd = build_decrypt_command("gpg", Path::new("dummy.gpg"));
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(
!args.iter().any(|a| a == "--batch"),
"decrypt must not use --batch (it suppresses the pinentry passphrase prompt). Args: {:?}",
args,
);
}

#[test]
fn decrypt_command_includes_decrypt_and_path() {
let cmd = build_decrypt_command("gpg", Path::new("some/file.gpg"));
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(
args.iter().any(|a| a == "-d" || a == "--decrypt"),
"decrypt must request decryption. Args: {:?}",
args,
);
assert!(
args.iter().any(|a| a.ends_with("file.gpg")),
"decrypt must include the file path. Args: {:?}",
args,
);
}
}
Loading
Loading