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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,44 @@ passless config print > ~/.config/passless/config.toml
You can then edit this file to customize the storage backend, security settings, and other options.
Command-line arguments will override settings from the configuration file.

### Password-store Git synchronization

By default, the `pass` backend pulls changes, creates local Git commits, and pushes those commits
when a remote is configured. To retain automatic pulls and local commits but **disable automatic
pushes**, configure:

```toml
[pass]
git_sync = true
git_auto_push = false
```

The default is `git_auto_push = true` when the setting is omitted, preserving existing behavior.
You can also use `--pass-git-auto-push=false` or `PASSLESS_PASS_GIT_AUTO_PUSH=false`.
With automatic pushes disabled, use `git -C ~/.password-store push` when you want to publish
local commits. Setting `git_sync = false` instead disables automatic pull and commit as well.

**Agent profiles use separate Git settings.** An isolated agent with a `pass` backend
uses `[agents.profiles.<id>.storage.pass]` with its own `git_sync` and
`git_auto_push` settings (both default to `true`); it does not inherit the global
`[pass]` values. For example:

```toml
[pass]
git_auto_push = false # Human backend only

[agents.profiles.release-bot.storage.pass]
store_path = "/home/user/.password-store-agents"
path = "fido2"
pin_path = "pin"
git_auto_push = true # This isolated agent still pushes
```

A `same-user` agent shares the human backend and follows `[pass]`. See
[agent storage configuration](docs/agents/configuration.md#storage-backends) for details.
Because a Git push publishes all reachable commits, use **separate Git repositories**
for human and isolated-agent stores when you need strict push isolation.

### Headless automatic interaction

By default, Passless uses the desktop notification service when a WebAuthn operation needs local
Expand Down
14 changes: 13 additions & 1 deletion cmd/passless/src/agent/storage_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,20 @@ pub fn create_storage_bundle_with_options(
ref path,
ref gpg_backend,
ref pin_path,
git_sync,
git_auto_push,
} => {
let gpg = gpg_backend.parse::<GpgBackend>().map_err(|e| {
Error::Storage(format!("invalid GPG backend '{}': {}", gpg_backend, e))
})?;

let cred_storage = crate::storage::PassStorageAdapter::new_with_options(
let cred_storage = crate::storage::PassStorageAdapter::new_with_options_and_git_sync(
store_path.clone(),
PathBuf::from(path),
gpg,
allow_create_without_prompt,
git_sync,
git_auto_push,
)
.map_err(|e| {
Error::Storage(format!(
Expand Down Expand Up @@ -881,6 +885,8 @@ mod tests {
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("/absolute/pin"),
git_sync: true,
git_auto_push: true,
};
let result = config.validate();
assert!(result.is_err());
Expand All @@ -894,6 +900,8 @@ mod tests {
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("../escape"),
git_sync: true,
git_auto_push: true,
};
let result = config.validate();
assert!(result.is_err());
Expand All @@ -911,6 +919,8 @@ mod tests {
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("pin"),
git_sync: true,
git_auto_push: true,
};
let pin_path = config.pin_state_path();
let expected = passless_core::BackendConfig::canonicalize_path(&store.join("pin"));
Expand Down Expand Up @@ -2088,6 +2098,8 @@ mod tests {
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("pin"),
git_sync: true,
git_auto_push: true,
};

let _gpg_home_guard = unsafe { ProcessEnvGuard::set("GNUPGHOME", &gpg_home) };
Expand Down
3 changes: 3 additions & 0 deletions cmd/passless/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ fn run() -> Result<()> {
})?;

let pass_git_sync = config.pass.git_sync;
let pass_git_auto_push = config.pass.git_auto_push;

#[cfg(feature = "agent")]
let agent_enabled = config.agents.enabled;
Expand Down Expand Up @@ -604,6 +605,7 @@ fn run() -> Result<()> {
gpg_backend,
allow_storage_creation,
pass_git_sync,
pass_git_auto_push,
)?;
let pass_sync = storage.sync_handle();
let boxed: Box<dyn CredentialStorage> = Box::new(storage);
Expand Down Expand Up @@ -817,6 +819,7 @@ fn run() -> Result<()> {
gpg_backend,
allow_storage_creation,
pass_git_sync,
pass_git_auto_push,
)?;
let pass_sync = storage.sync_handle();
let pin_storage = PassPinStorage::new_with_sync(
Expand Down
4 changes: 2 additions & 2 deletions cmd/passless/src/pin_storage/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ mod tests {
#[test]
fn cached_pin_configuration_avoids_storage_load() {
let temp = tempfile::tempdir().unwrap();
let sync = PassGitSync::new(temp.path().to_path_buf(), false);
let sync = PassGitSync::new(temp.path().to_path_buf(), false, false);
let storage = PassPinStorage::new_with_sync(
temp.path().to_path_buf(),
PathBuf::from("fido2"),
Expand All @@ -521,7 +521,7 @@ mod tests {
#[test]
fn retry_state_version_and_timestamp_churn_do_not_change_synced_config() {
let temp = tempfile::tempdir().unwrap();
let sync = PassGitSync::new(temp.path().to_path_buf(), false);
let sync = PassGitSync::new(temp.path().to_path_buf(), false, false);
let storage = PassPinStorage::new_with_sync(
temp.path().to_path_buf(),
PathBuf::from("fido2"),
Expand Down
132 changes: 126 additions & 6 deletions cmd/passless/src/storage/pass/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@ pub struct PassGitSync {
struct SyncState {
store_path: PathBuf,
enabled: bool,
auto_push: bool,
depth: usize,
prepared: bool,
dirty: bool,
messages: Vec<String>,
}

impl PassGitSync {
pub fn new(store_path: PathBuf, enabled: bool) -> Self {
pub fn new(store_path: PathBuf, enabled: bool, auto_push: bool) -> Self {
Self {
state: Arc::new(Mutex::new(SyncState {
store_path,
enabled,
auto_push,
depth: 0,
prepared: false,
dirty: false,
Expand Down Expand Up @@ -131,7 +133,7 @@ impl PassGitSync {
}

/// Finish a logical operation. The outermost operation produces at most one
/// Git commit and push, irrespective of how many credential/PIN writes occurred.
/// Git commit (and optionally push), irrespective of how many writes occurred.
pub fn end_operation(&self) {
let Ok(mut state) = self.state.lock() else {
warn!("Failed to acquire pass Git sync lock while ending operation");
Expand Down Expand Up @@ -168,7 +170,7 @@ impl PassGitSync {
_ => "Update Passless authenticator state.".to_string(),
};

match Self::finalize_store(&state.store_path, &message) {
match Self::finalize_store(&state.store_path, &message, state.auto_push) {
Ok(()) => {
state.dirty = false;
state.messages.clear();
Expand Down Expand Up @@ -250,8 +252,23 @@ impl PassGitSync {
.map_err(|error| format!("prepare failed: {error:?}"))
}

fn finalize_store(store_path: &Path, message: &str) -> Result<(), String> {
fn finalize_store(store_path: &Path, message: &str, auto_push: bool) -> Result<(), String> {
debug!("Finalizing password-store Git sync: {message}");

if !auto_push {
// prs-lib's finalize() always pushes when a remote is configured.
// Use its own commit implementation to retain local commits without
// reaching any remote or needing the SSH helper.
let store = Store::open(store_path.to_string_lossy().as_ref())
.map_err(|error| format!("failed to open store: {error:?}"))?;
let sync = store.sync();
return if sync.is_init() {
sync.commit_all(message, false)
.map_err(|error| format!("commit failed: {error:?}"))
} else {
Ok(())
};
}
if let Some(ssh_auth_sock) = configured_ssh_auth_sock() {
return run_sync_helper(
store_path,
Expand Down Expand Up @@ -362,7 +379,7 @@ mod tests {
#[test]
fn nested_operations_do_not_finalize_inner_boundary() {
let temp = tempfile::tempdir().unwrap();
let sync = PassGitSync::new(temp.path().to_path_buf(), true);
let sync = PassGitSync::new(temp.path().to_path_buf(), true, true);

sync.begin_operation();
sync.begin_operation();
Expand All @@ -380,7 +397,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let local = temp.path().join("fido2/pin_retries.local.gpg");
let sync = PassGitSync::new(temp.path().to_path_buf(), false);
let sync = PassGitSync::new(temp.path().to_path_buf(), false, true);

sync.ensure_local_only_path(&local);

Expand All @@ -392,6 +409,109 @@ mod tests {
);
}

#[test]
fn commit_only_mode_never_pushes_to_remote() {
const CHILD: &str = "PASSLESS_GIT_SYNC_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
// prs-lib executes the external git binary. Other unit tests temporarily
// replace the process-wide PATH, so exercise Git in a separate process.
let binary = std::env::current_exe().unwrap();
let path = std::env::var_os("PATH").unwrap_or_default();
let mut git_paths = vec![
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/opt/local/bin"),
];
git_paths.extend(std::env::split_paths(&path));
let output = Command::new(binary)
.arg("--exact")
.arg("storage::pass::sync::tests::commit_only_mode_never_pushes_to_remote")
.arg("--nocapture")
.env(CHILD, "1")
.env("PATH", std::env::join_paths(git_paths).unwrap())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env(
"GIT_CONFIG_GLOBAL",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env_remove(PASS_GIT_SSH_AUTH_SOCK_ENV)
.output()
.unwrap();
assert!(
output.status.success(),
"isolated commit-only Git test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}

let temp = tempfile::tempdir().unwrap();
let store_path = temp.path().join("password-store");
let remote_path = temp.path().join("remote.git");
let repository = Repository::init(&store_path).unwrap();
let remote = Repository::init_bare(&remote_path).unwrap();
repository
.remote("origin", remote_path.to_str().unwrap())
.unwrap();
let mut config = repository.config().unwrap();
config.set_str("user.name", "Passless Test").unwrap();
config
.set_str("user.email", "passless@example.invalid")
.unwrap();

// Seed an actual remote branch. Checking an initially empty bare remote
// would not detect an accidental push to an unexpected branch.
fs::write(store_path.join(".gpg-id"), b"test@example.invalid\n").unwrap();
let mut index = repository.index().unwrap();
index.add_path(Path::new(".gpg-id")).unwrap();
index.write().unwrap();
let tree = repository.find_tree(index.write_tree().unwrap()).unwrap();
let signature = git2::Signature::now("Passless Test", "passless@example.invalid").unwrap();
let seed = repository
.commit(
Some("HEAD"),
&signature,
&signature,
"Initial store",
&tree,
&[],
)
.unwrap();
let branch = repository.head().unwrap().shorthand().unwrap().to_string();
repository
.find_remote("origin")
.unwrap()
.push(&[format!("refs/heads/{branch}:refs/heads/{branch}")], None)
.unwrap();
let remote_ref = format!("refs/heads/{branch}");
assert_eq!(
remote.find_reference(&remote_ref).unwrap().target(),
Some(seed)
);

fs::write(store_path.join("credential.gpg"), b"test encrypted bytes").unwrap();
let sync = PassGitSync::new(store_path.clone(), true, false);
sync.begin_operation();
sync.mark_dirty("Update credential");
sync.end_operation();

assert!(
!sync.state.lock().unwrap().dirty,
"Git commit-only finalization failed"
);
let commit = repository.head().unwrap().peel_to_commit().unwrap();
assert_eq!(commit.message(), Ok("Update credential\n"));
assert_ne!(commit.id(), seed, "the change must be committed locally");
assert_eq!(
remote.find_reference(&remote_ref).unwrap().target(),
Some(seed),
"commit-only mode must leave the remote branch unchanged"
);
}

#[test]
fn helper_is_resolved_next_to_passless_binary() {
let current = Path::new("/usr/bin/passless");
Expand Down
4 changes: 3 additions & 1 deletion cmd/passless/src/storage/pass_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ impl PassStorageAdapter {
gpg_backend,
allow_create_without_prompt,
true,
true,
Comment thread
pando85 marked this conversation as resolved.
)
}

Expand All @@ -61,6 +62,7 @@ impl PassStorageAdapter {
gpg_backend: GpgBackend,
allow_create_without_prompt: bool,
git_sync: bool,
git_auto_push: bool,
) -> Result<Self> {
let inner = InnerPassStorageAdapter::new_with_options(
store_path.clone(),
Expand All @@ -69,7 +71,7 @@ impl PassStorageAdapter {
allow_create_without_prompt,
)?;
let indexed_generation = repository_generation(&store_path, &path);
let sync = PassGitSync::new(store_path.clone(), git_sync);
let sync = PassGitSync::new(store_path.clone(), git_sync, git_auto_push);

let mut adapter = Self {
inner,
Expand Down
4 changes: 4 additions & 0 deletions cmd/passless/tests/agent_config_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ fn pass_storage_pin_path_absolute_rejected() {
path: "fido2".into(),
gpg_backend: "gnupg-bin".into(),
pin_path: PathBuf::from("/absolute/pin"),
git_sync: true,
git_auto_push: true,
};
let err = storage.validate().unwrap_err();
assert!(err.to_string().contains("relative subpath"));
Expand All @@ -312,6 +314,8 @@ fn pass_storage_pin_path_traversal_rejected() {
path: "fido2".into(),
gpg_backend: "gnupg-bin".into(),
pin_path: PathBuf::from("../escape"),
git_sync: true,
git_auto_push: true,
};
let err = storage.validate().unwrap_err();
assert!(err.to_string().contains("path traversal"));
Expand Down
1 change: 1 addition & 0 deletions cmd/passless/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ fn config_print_exits_zero() {
assert!(stdout.contains("backend_type"));
assert!(stdout.contains("[security]"));
assert!(stdout.contains("[pin]"));
assert!(stdout.contains("git_auto_push = true"));
}

#[test]
Expand Down
Loading
Loading