Fix CLI passphrase provisioning

Closes #60
This commit is contained in:
Hermes Agent
2026-08-10 07:43:19 +00:00
parent b503de8b2e
commit c82c792699
4 changed files with 514 additions and 35 deletions

View File

@@ -39,8 +39,8 @@ use ironstorage::{
},
repository::{DirectoryPath, Repository, SecretBytes},
secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
SecretStoreBackend,
NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
},
write::{
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
@@ -128,7 +128,7 @@ where
)
.and_then(|store| {
store.unlock()?;
Ok(store)
Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
}) {
Ok(secrets) => secrets,
Err(error) => {
@@ -1010,6 +1010,30 @@ trait CliInteraction: OtpInteraction {
struct NativeOtpInteraction;
struct NativeOpenPgpPassphrasePrompt;
impl OpenPgpPassphrasePrompt for NativeOpenPgpPassphrasePrompt {
fn request_passphrase(
&mut self,
key: &ironstorage::crypto::KeyInfo,
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
if !std::io::stdin().is_terminal() {
return Err(OpenPgpPassphrasePromptError::Unavailable);
}
rpassword::prompt_password(format!(
"Enter OpenPGP passphrase for {}: ",
key.fingerprint()
))
.map(|value| SecretBytes::new(value.into_bytes()))
.map_err(|error| match error.kind() {
std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof => {
OpenPgpPassphrasePromptError::Cancelled
}
_ => OpenPgpPassphrasePromptError::Unavailable,
})
}
}
impl OtpInteraction for NativeOtpInteraction {
fn standard_input_is_terminal(&self) -> bool {
std::io::stdin().is_terminal()
@@ -1477,13 +1501,15 @@ mod tests {
RemoveRequest, ShowRequest,
},
config::Config,
crypto::KeyInfo,
git::GitCredentialProvider as _,
otp::OtpInput,
presentation::{ClipboardTimeout, QrMatrix},
repository::{EntryPath, Repository, SecretBytes},
secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy,
SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore,
SecretStoreBackend, SecretStoreError,
},
write::{InsertContent, OverwriteDecision},
};
@@ -1499,6 +1525,47 @@ mod tests {
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
#[derive(Clone, Default)]
struct MemoryOpenPgpPrompt(Arc<Mutex<MemoryOpenPgpPromptState>>);
#[derive(Default)]
struct MemoryOpenPgpPromptState {
responses: VecDeque<Result<Vec<u8>, OpenPgpPassphrasePromptError>>,
requests: Vec<String>,
}
impl MemoryOpenPgpPrompt {
fn with_passphrase(passphrase: &[u8]) -> Self {
let prompt = Self::default();
prompt
.0
.lock()
.expect("test mutex")
.responses
.push_back(Ok(passphrase.to_vec()));
prompt
}
fn requests(&self) -> Vec<String> {
self.0.lock().expect("test mutex").requests.clone()
}
}
impl OpenPgpPassphrasePrompt for MemoryOpenPgpPrompt {
fn request_passphrase(
&mut self,
key: &KeyInfo,
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
let mut state = self.0.lock().expect("test mutex");
state.requests.push(key.fingerprint().as_str().to_owned());
state
.responses
.pop_front()
.unwrap_or(Err(OpenPgpPassphrasePromptError::Unavailable))
.map(SecretBytes::new)
}
}
#[derive(Default)]
struct MemoryPresentation {
clipboard: Vec<Vec<u8>>,
@@ -1802,6 +1869,62 @@ mod tests {
Ok(())
}
#[test]
fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult {
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
const PASSPHRASE: &[u8] = b"fixture-alice-passphrase";
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let temporary = tempfile::tempdir()?;
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
fixtures.join("stores/basic"),
FINGERPRINT,
fixtures.join("keys"),
),
)?;
let config = Config::load(Some(&config_path))?;
let prompt = MemoryOpenPgpPrompt::with_passphrase(PASSPHRASE);
let mut secrets = SecretStore::new(
MemoryBackend::default(),
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.with_openpgp_passphrase_prompt(prompt.clone());
secrets.unlock()?;
let request = CommandRequest::Show(ShowRequest {
entry: Some("email/personal".to_owned()),
presentation: Presentation::Terminal,
});
for _ in 0..2 {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
assert_eq!(
execute_secure(&config, &request, &mut secrets, &mut stdout, &mut stderr)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(
stdout,
fs::read(fixtures.join("expected/basic/email/personal.txt"))?
);
assert!(stderr.is_empty());
}
assert_eq!(prompt.requests(), [FINGERPRINT.to_owned()]);
assert_eq!(
secrets
.retrieve(&SecretReference::openpgp_passphrase(FINGERPRINT)?)?
.expose(),
PASSPHRASE
);
Ok(())
}
#[test]
fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult {
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";