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
99 changes: 87 additions & 12 deletions cmd/passless/src/storage/pass/gpg_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,40 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf,
))
})?;

let start_dir = if parent.exists() {
parent
find_nearest_gpg_id_for_dir(store_root, parent)?.ok_or_else(|| {
Error::Storage(format!(
"No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \
Make sure the password store is initialized with: pass init <gpg-key-id>",
target.display(),
store_root.display()
))
})
}

/// Find the effective `.gpg-id` for a directory.
///
/// The lookup starts at `start_dir` itself and walks towards `store_root`,
/// matching `pass`'s closest-policy-wins semantics. `Ok(None)` means that no
/// recipient policy applies to the directory; I/O and containment failures are
/// returned as errors.
pub fn find_nearest_gpg_id_for_dir(
store_root: &Path,
start_dir: &Path,
) -> Result<Option<(PathBuf, String)>> {
if !start_dir.starts_with(store_root) {
return Err(Error::Storage(format!(
"Directory '{}' is not within store root '{}'",
start_dir.display(),
store_root.display()
)));
}

let start_dir = if start_dir.exists() {
start_dir
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf())
.unwrap_or_else(|_| start_dir.to_path_buf())
} else {
parent.to_path_buf()
start_dir.to_path_buf()
};

let root = if store_root.exists() {
Expand All @@ -40,7 +68,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf,

if !start_dir.starts_with(&root) {
return Err(Error::Storage(format!(
"Resolved target path '{}' is not within store root '{}'",
"Resolved directory '{}' is not within store root '{}'",
start_dir.display(),
root.display()
)));
Expand All @@ -55,7 +83,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf,
match std::fs::read_to_string(&gpg_id_path) {
Ok(content) => {
debug!("Found .gpg-id at: {:?}", gpg_id_path);
return Ok((gpg_id_path, content));
return Ok(Some((gpg_id_path, content)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
Expand All @@ -79,12 +107,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf,
}
}

Err(Error::Storage(format!(
"No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \
Make sure the password store is initialized with: pass init <gpg-key-id>",
target.display(),
store_root.display()
)))
Ok(None)
}

/// Resolve GPG recipients for a target file using hierarchical .gpg-id lookup.
Expand Down Expand Up @@ -194,3 +217,55 @@ pub fn parse_raw_key_ids(content: &str) -> Vec<String> {
ids.sort();
ids
}

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

#[test]
fn directory_lookup_prefers_scope_policy() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let scope = root.join("fido2");
fs::create_dir_all(&scope).unwrap();
fs::write(root.join(".gpg-id"), "ROOT\n").unwrap();
fs::write(scope.join(".gpg-id"), "SCOPE\n").unwrap();

let (path, content) = find_nearest_gpg_id_for_dir(root, &scope)
.unwrap()
.expect("scope should have an effective policy");

assert_eq!(path, scope.join(".gpg-id"));
assert_eq!(content, "SCOPE\n");
}

#[test]
fn directory_lookup_inherits_root_policy() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let scope = root.join("fido2");
fs::create_dir_all(&scope).unwrap();
fs::write(root.join(".gpg-id"), "ROOT\n").unwrap();

let (path, content) = find_nearest_gpg_id_for_dir(root, &scope)
.unwrap()
.expect("root policy should apply to scope");

assert_eq!(path, root.join(".gpg-id"));
assert_eq!(content, "ROOT\n");
}

#[test]
fn unrelated_subtree_policy_does_not_initialize_scope() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let scope = root.join("fido2");
let other = root.join("personal");
fs::create_dir_all(&scope).unwrap();
fs::create_dir_all(&other).unwrap();
fs::write(other.join(".gpg-id"), "OTHER\n").unwrap();

assert!(find_nearest_gpg_id_for_dir(root, &scope).unwrap().is_none());
}
}
6 changes: 3 additions & 3 deletions cmd/passless/src/storage/pass/init/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use passless_core::error::Result;
use std::path::PathBuf;

pub struct Complete {
pub(super) store_path: PathBuf,
pub(super) scope_path: PathBuf,
pub(super) fingerprint: String,
pub(super) allow_create_without_prompt: bool,
}
Expand All @@ -18,8 +18,8 @@ impl Complete {
let _ = show_info_notification(
"✅ Password Store Initialized",
&format!(
"Password store successfully initialized at:\n{}\n\nGPG Key: {}",
self.store_path.display(),
"Passless password-store scope successfully initialized at:\n{}\n\nGPG Key: {}",
self.scope_path.display(),
self.fingerprint
),
);
Expand Down
2 changes: 2 additions & 0 deletions cmd/passless/src/storage/pass/init/directory_created.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use prs_lib::crypto::{self, Config, IsContext, Proto};

pub struct DirectoryCreated {
pub(super) store_path: PathBuf,
pub(super) scope_path: PathBuf,
pub(super) gpg_backend: GpgBackend,
pub(super) allow_create_without_prompt: bool,
}
Expand All @@ -26,6 +27,7 @@ impl DirectoryCreated {

Ok(GpgKeySelected {
store_path: self.store_path,
scope_path: self.scope_path,
fingerprint,
allow_create_without_prompt: self.allow_create_without_prompt,
})
Expand Down
5 changes: 4 additions & 1 deletion cmd/passless/src/storage/pass/init/gpg_key_selected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ use log::info;

pub struct GpgKeySelected {
pub(super) store_path: PathBuf,
pub(super) scope_path: PathBuf,
pub(super) fingerprint: String,
pub(super) allow_create_without_prompt: bool,
}

impl GpgKeySelected {
pub fn write_gpg_id(self) -> Result<StoreInitialized> {
let gpg_id_file = self.store_path.join(".gpg-id");
let gpg_id_file = self.scope_path.join(".gpg-id");

fs::write(&gpg_id_file, format!("{}\n", self.fingerprint)).map_err(|e| {
let msg = format!("Failed to write .gpg-id file: {}", e);
Expand All @@ -31,6 +32,8 @@ impl GpgKeySelected {

Ok(StoreInitialized {
store_path: self.store_path,
scope_path: self.scope_path,
gpg_id_path: gpg_id_file,
fingerprint: self.fingerprint,
allow_create_without_prompt: self.allow_create_without_prompt,
})
Expand Down
10 changes: 8 additions & 2 deletions cmd/passless/src/storage/pass/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ use passless_core::error::Result;

use std::path::Path;

/// Initialize password store, prompting user via desktop notifications if needed
/// Initialize password store, prompting user via desktop notifications if needed.
///
/// `store_path` is the password-store / Git repository root. `path` is the
/// Passless-owned relative scope inside that store. Existing recipient policy
/// may be inherited from any `.gpg-id` between that scope and `store_path`.
pub fn ensure_initialized(
store_path: &Path,
path: &Path,
gpg_backend: GpgBackend,
allow_create_without_prompt: bool,
) -> Result<()> {
let init = Uninitialized::new(store_path.to_path_buf(), gpg_backend);
let scope_path = store_path.join(path);
let init = Uninitialized::new(store_path.to_path_buf(), scope_path, gpg_backend);

match init.check_if_initialized() {
Ok(init) => init
Expand Down
29 changes: 25 additions & 4 deletions cmd/passless/src/storage/pass/init/store_initialized.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,41 @@ use crate::notification::{show_error_notification, show_info_notification};
use passless_core::error::{Error, Result};

use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

use log::{info, warn};

pub struct StoreInitialized {
pub(super) store_path: PathBuf,
pub(super) scope_path: PathBuf,
pub(super) gpg_id_path: PathBuf,
pub(super) fingerprint: String,
pub(super) allow_create_without_prompt: bool,
}

impl StoreInitialized {
pub fn setup_git(self) -> Result<Complete> {
info!("Setting up git");
initialize_git_repo(&self.store_path, self.allow_create_without_prompt)?;
initialize_git_repo(
&self.store_path,
&self.gpg_id_path,
self.allow_create_without_prompt,
)?;
Ok(Complete {
store_path: self.store_path,
scope_path: self.scope_path,
fingerprint: self.fingerprint,
allow_create_without_prompt: self.allow_create_without_prompt,
})
}
}

fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) -> Result<()> {
fn initialize_git_repo(
store_path: &PathBuf,
gpg_id_path: &Path,
allow_create_without_prompt: bool,
) -> Result<()> {
let output = Command::new("git")
.arg("init")
.current_dir(store_path)
Expand All @@ -57,8 +68,18 @@ fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool)
"*.gpg diff=gpg\n[attr]binary -diff -merge -text\n",
);

let gpg_id_relative = gpg_id_path.strip_prefix(store_path).map_err(|_| {
Error::Storage(format!(
"GPG policy path '{}' is outside password store root '{}'",
gpg_id_path.display(),
store_path.display()
))
})?;

let _ = Command::new("git")
.args(["add", ".gpg-id", ".gitattributes"])
.arg("add")
.arg(gpg_id_relative)
.arg(".gitattributes")
.current_dir(store_path)
.output();

Expand Down
37 changes: 22 additions & 15 deletions cmd/passless/src/storage/pass/init/uninitialized.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use super::directory_created::DirectoryCreated;

use crate::notification::{YesNoResult, show_info_notification, show_yes_no_notification};
use crate::storage::pass::GpgBackend;
use crate::storage::pass::{GpgBackend, gpg_id};
use crate::util::create_secure_dir_all;

use passless_core::error::{Error, Result};
Expand All @@ -14,30 +14,36 @@ use log::{debug, info, warn};

pub struct Uninitialized {
pub(super) store_path: PathBuf,
pub(super) scope_path: PathBuf,
pub(super) gpg_backend: GpgBackend,
}

impl Uninitialized {
pub fn new(store_path: PathBuf, gpg_backend: GpgBackend) -> Self {
pub fn new(store_path: PathBuf, scope_path: PathBuf, gpg_backend: GpgBackend) -> Self {
Self {
store_path,
scope_path,
gpg_backend,
}
}

/// Check if already initialized; returns special error if yes (success case)
/// Check whether an effective recipient policy already applies to the
/// configured Passless scope; returns a special error if yes (success case).
pub fn check_if_initialized(self) -> Result<Self> {
let gpg_id_file = self.store_path.join(".gpg-id");

if gpg_id_file.exists() {
if let Some((gpg_id_file, _)) =
gpg_id::find_nearest_gpg_id_for_dir(&self.store_path, &self.scope_path)?
{
debug!(
"Password store already initialized at {:?}",
self.store_path
"Password store scope {:?} already initialized by {:?}",
self.scope_path, gpg_id_file
);
return Err(Error::Config("ALREADY_INITIALIZED".to_string()));
}

info!("Password store not initialized at {:?}", self.store_path);
info!(
"Password store scope not initialized at {:?}",
self.scope_path
);
Ok(self)
}

Expand All @@ -46,8 +52,8 @@ impl Uninitialized {
match show_yes_no_notification(
"Password Store Not Initialized",
&format!(
"The password store directory does not exist at:\n{}\n\nWould you like to initialize it now?",
self.store_path.display()
"The Passless password-store scope is not initialized at:\n{}\n\nWould you like to initialize it now?",
self.scope_path.display()
),
) {
Ok(YesNoResult::Accepted) => info!("User agreed to initialize"),
Expand All @@ -66,17 +72,18 @@ impl Uninitialized {
}
}

if !self.store_path.exists() {
create_secure_dir_all(&self.store_path).map_err(|e| {
let msg = format!("Failed to create store directory: {}", e);
if !self.scope_path.exists() {
create_secure_dir_all(&self.scope_path).map_err(|e| {
let msg = format!("Failed to create password store scope: {}", e);
let _ = crate::notification::show_error_notification("Initialization Failed", &msg);
Error::Storage(msg)
})?;
info!("Created store directory at {:?}", self.store_path);
info!("Created password store scope at {:?}", self.scope_path);
}

Ok(DirectoryCreated {
store_path: self.store_path,
scope_path: self.scope_path,
gpg_backend: self.gpg_backend,
allow_create_without_prompt,
})
Expand Down
7 changes: 6 additions & 1 deletion cmd/passless/src/storage/pass/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@ impl PassStorageAdapter {

// Ensure the password store is initialized
// This will prompt the user via notifications if not initialized
self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?;
self::init::ensure_initialized(
&store_path,
&path,
gpg_backend,
allow_create_without_prompt,
)?;

if !store_path.exists() {
return Err(Error::Storage(format!(
Expand Down
Loading