Implement pass-otp compatible TOTP and HOTP

This commit is contained in:
Hermes Agent
2026-08-10 01:22:53 +00:00
parent b8c614e141
commit 4bd39b1ef2
12 changed files with 2382 additions and 28 deletions

View File

@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
ctrlc.workspace = true
ironstorage.workspace = true
rpassword.workspace = true
tempfile = "3"
[dev-dependencies]

View File

@@ -5,26 +5,27 @@ use std::{
error::Error,
ffi::OsString,
fmt,
io::Write,
io::{BufRead as _, IsTerminal as _, Write},
path::Path,
process::ExitCode,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use ironstorage::{
command::{
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
GeneratedPresentation, GitRequest, HelpTopic, OtpRequest, help_text, otp_version_text,
parse_from, version_text,
GeneratedPresentation, GitRequest, HelpTopic, InputPlan, OtpInputSource, OtpRequest,
OtpUriPresentation, help_text, otp_version_text, parse_from, version_text,
},
config::Config,
crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator},
git::{GitIdentity, GitRepository},
otp::{OtpError, OtpInput, OtpService},
presentation::{
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
},
@@ -101,6 +102,10 @@ where
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
CommandRequest::Otp(OtpRequest::Validate { uri }) => match OtpService::validate(uri) {
Ok(()) => Ok(EXIT_SUCCESS),
Err(error) => operation_error(&mut stderr, error),
},
request => match Config::load(invocation.config()) {
Ok(config) if needs_secret_store(request) => {
let mut secrets = match NativeSecretStore::system(
@@ -141,6 +146,12 @@ fn needs_secret_store(request: &CommandRequest) -> bool {
request,
CommandRequest::Show(_)
| CommandRequest::Generate(_)
| CommandRequest::Otp(
OtpRequest::Code(_)
| OtpRequest::Insert(_)
| OtpRequest::Append(_)
| OtpRequest::Uri(_),
)
| CommandRequest::Git(GitRequest::Fetch { .. })
)
}
@@ -152,16 +163,19 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
stdout: &mut O,
stderr: &mut E,
) -> Result<u8, ()> {
execute_secure_with(
execute_secure_with_services(
config,
request,
secrets,
&mut NativePresentation,
&mut NativeOtpInteraction,
current_unix_seconds,
stdout,
stderr,
)
}
#[cfg(test)]
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
config: &Config,
request: &CommandRequest,
@@ -169,6 +183,35 @@ fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: W
presentation: &mut P,
stdout: &mut O,
stderr: &mut E,
) -> Result<u8, ()> {
execute_secure_with_services(
config,
request,
secrets,
presentation,
&mut UnavailableOtpInteraction,
current_unix_seconds,
stdout,
stderr,
)
}
#[allow(clippy::too_many_arguments)]
fn execute_secure_with_services<
B: SecretStoreBackend,
P: CliPresentation,
I: OtpInteraction,
O: Write,
E: Write,
>(
config: &Config,
request: &CommandRequest,
secrets: &mut SecretStore<B>,
presentation: &mut P,
interaction: &mut I,
clock: impl Fn() -> Result<u64, OtpInteractionError>,
stdout: &mut O,
stderr: &mut E,
) -> Result<u8, ()> {
match request {
CommandRequest::Show(request) => {
@@ -259,6 +302,16 @@ fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: W
Err(error) => operation_error(stderr, error),
}
}
CommandRequest::Otp(request) => execute_otp(
config,
request,
secrets,
presentation,
interaction,
clock,
stdout,
stderr,
),
CommandRequest::Git(GitRequest::Fetch { remote }) => {
let configured = match select_remote(config, remote.as_deref()) {
Some(configured) => configured,
@@ -288,6 +341,338 @@ fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: W
}
}
#[allow(clippy::too_many_arguments)]
fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O: Write, E: Write>(
config: &Config,
request: &OtpRequest,
secrets: &mut SecretStore<B>,
presentation: &mut P,
interaction: &mut I,
clock: impl Fn() -> Result<u64, OtpInteractionError>,
stdout: &mut O,
stderr: &mut E,
) -> Result<u8, ()> {
let repository = match Repository::open(config.vault()) {
Ok(repository) => repository,
Err(error) => return operation_error(stderr, error),
};
let keys = match KeyStore::load(config.key_material()) {
Ok(keys) => keys,
Err(error) => return operation_error(stderr, error),
};
let service = OtpService::new(&repository, &keys);
match request {
OtpRequest::Code(request) => {
let timestamp = match clock() {
Ok(timestamp) => timestamp,
Err(error) => return operation_error(stderr, error),
};
let mut committer =
match generation_committer(&repository, request.entry.trim_end_matches('/')) {
Ok(committer) => committer,
Err(error) => return operation_error(stderr, error),
};
let outcome =
match service.code(&request.entry, timestamp, None, secrets, &mut committer) {
Ok(outcome) => outcome,
Err(error) => return operation_error(stderr, error),
};
if request.clipboard {
match presentation.clipboard(
outcome.code(),
config.clipboard_timeout(),
&format!("OTP code for {}", request.entry),
stdout,
) {
Ok(()) => Ok(EXIT_SUCCESS),
Err(error) => operation_error(stderr, error),
}
} else {
stdout
.write_all(outcome.code().expose())
.and_then(|()| stdout.write_all(b"\n"))
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
}
OtpRequest::Insert(request) => {
let input = match interaction.read_input(
request.input_plan(interaction.standard_input_is_terminal()),
&request.source,
request.entry.as_deref().unwrap_or("this token"),
stderr,
) {
Ok(input) => input,
Err(error) => return operation_error(stderr, error),
};
let plan = match service.prepare_insert(request, input) {
Ok(plan) => plan,
Err(error) => return operation_error(stderr, error),
};
let path_decision = if plan.requires_path_confirmation() {
match interaction.confirm(&format!("Insert into {}?", plan.path()), stderr) {
Ok(decision) => decision,
Err(error) => return operation_error(stderr, error),
}
} else {
OverwriteDecision::Allow
};
if path_decision == OverwriteDecision::Decline {
return operation_error(stderr, OtpError::Cancelled);
}
let overwrite = if plan.requires_overwrite_confirmation() {
match interaction.confirm(
&format!("An entry already exists for {}. Overwrite it?", plan.path()),
stderr,
) {
Ok(decision) => decision,
Err(error) => return operation_error(stderr, error),
}
} else {
OverwriteDecision::Allow
};
let mut committer = match generation_committer(&repository, &plan.path().to_string()) {
Ok(committer) => committer,
Err(error) => return operation_error(stderr, error),
};
match service.finish_insert(plan, path_decision, overwrite, None, &mut committer) {
Ok(_) => Ok(EXIT_SUCCESS),
Err(error) => operation_error(stderr, error),
}
}
OtpRequest::Append(request) => {
let session = match service.begin_append(request, secrets) {
Ok(session) => session,
Err(error) => return operation_error(stderr, error),
};
let replace = if session.requires_replace_confirmation() {
match interaction.confirm(
&format!(
"An OTP secret already exists for {}. Overwrite it?",
session.path()
),
stderr,
) {
Ok(decision) => decision,
Err(error) => return operation_error(stderr, error),
}
} else {
OverwriteDecision::Allow
};
if replace == OverwriteDecision::Decline {
return operation_error(stderr, OtpError::Cancelled);
}
let path = session.path().to_string();
let input = match interaction.read_input(
request.input_plan(interaction.standard_input_is_terminal()),
&request.source,
&request.entry,
stderr,
) {
Ok(input) => input,
Err(error) => return operation_error(stderr, error),
};
let mut committer = match generation_committer(&repository, &path) {
Ok(committer) => committer,
Err(error) => return operation_error(stderr, error),
};
match service.finish_append(session, input, replace, None, &mut committer) {
Ok(_) => Ok(EXIT_SUCCESS),
Err(error) => operation_error(stderr, error),
}
}
OtpRequest::Uri(request) => {
let uri = match service.uri(&request.entry, secrets) {
Ok(uri) => uri,
Err(error) => return operation_error(stderr, error),
};
let result = match request.presentation {
OtpUriPresentation::Terminal => stdout
.write_all(uri.encoded().expose())
.and_then(|()| stdout.write_all(b"\n"))
.map_err(|_| PresentationFailure::Output),
OtpUriPresentation::Clipboard => presentation.clipboard(
uri.encoded(),
config.clipboard_timeout(),
&format!("OTP key URI for {}", request.entry),
stdout,
),
OtpUriPresentation::QrCode => presentation.qr_code(uri.encoded(), stdout),
};
match result {
Ok(()) => Ok(EXIT_SUCCESS),
Err(error) => operation_error(stderr, error),
}
}
OtpRequest::Validate { .. } | OtpRequest::Help | OtpRequest::Version => {
Ok(EXIT_UNAVAILABLE)
}
}
}
trait OtpInteraction {
fn standard_input_is_terminal(&self) -> bool;
fn read_input(
&mut self,
plan: InputPlan,
source: &OtpInputSource,
prompt: &str,
stderr: &mut dyn Write,
) -> Result<OtpInput, OtpInteractionError>;
fn confirm(
&mut self,
prompt: &str,
stderr: &mut dyn Write,
) -> Result<OverwriteDecision, OtpInteractionError>;
}
struct NativeOtpInteraction;
impl OtpInteraction for NativeOtpInteraction {
fn standard_input_is_terminal(&self) -> bool {
std::io::stdin().is_terminal()
}
fn read_input(
&mut self,
plan: InputPlan,
source: &OtpInputSource,
prompt: &str,
stderr: &mut dyn Write,
) -> Result<OtpInput, OtpInteractionError> {
let subject = match source {
OtpInputSource::Uri => "otpauth:// URI",
OtpInputSource::Secret { .. } => "secret",
};
match plan {
InputPlan::HiddenConfirmed => {
let first = rpassword::prompt_password(format!("Enter {subject} for {prompt}: "))
.map_err(|_| OtpInteractionError::Input)?
.into_bytes();
let confirmation =
rpassword::prompt_password(format!("Retype {subject} for {prompt}: "))
.map_err(|_| OtpInteractionError::Input)?
.into_bytes();
OtpInput::hidden(first, confirmation).map_err(Into::into)
}
InputPlan::EchoedLine => {
write!(stderr, "Enter {subject} for {prompt}: ")
.and_then(|()| stderr.flush())
.map_err(|_| OtpInteractionError::Output)?;
OtpInput::line(read_standard_input_line()?).map_err(Into::into)
}
InputPlan::StandardInputLine => {
OtpInput::line(read_standard_input_line()?).map_err(Into::into)
}
InputPlan::StandardInputToEnd => Err(OtpInteractionError::Input),
}
}
fn confirm(
&mut self,
prompt: &str,
stderr: &mut dyn Write,
) -> Result<OverwriteDecision, OtpInteractionError> {
loop {
write!(stderr, "{prompt} [y/N] ")
.and_then(|()| stderr.flush())
.map_err(|_| OtpInteractionError::Output)?;
let answer = read_standard_input_line()?;
match answer.as_slice() {
b"y" | b"Y" | b"yes" | b"YES" | b"Yes" => {
return Ok(OverwriteDecision::Allow);
}
b"" | b"n" | b"N" | b"no" | b"NO" | b"No" => {
return Ok(OverwriteDecision::Decline);
}
_ => {}
}
}
}
}
#[cfg(test)]
struct UnavailableOtpInteraction;
#[cfg(test)]
impl OtpInteraction for UnavailableOtpInteraction {
fn standard_input_is_terminal(&self) -> bool {
false
}
fn read_input(
&mut self,
_plan: InputPlan,
_source: &OtpInputSource,
_prompt: &str,
_stderr: &mut dyn Write,
) -> Result<OtpInput, OtpInteractionError> {
Err(OtpInteractionError::Input)
}
fn confirm(
&mut self,
_prompt: &str,
_stderr: &mut dyn Write,
) -> Result<OverwriteDecision, OtpInteractionError> {
Err(OtpInteractionError::Input)
}
}
#[derive(Debug)]
enum OtpInteractionError {
Otp(OtpError),
Input,
Output,
Clock,
}
impl fmt::Display for OtpInteractionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Otp(error) => error.fmt(formatter),
Self::Input => formatter.write_str("OTP input could not be read"),
Self::Output => formatter.write_str("OTP prompt could not be written"),
Self::Clock => formatter.write_str("the system clock is before the Unix epoch"),
}
}
}
impl From<OtpError> for OtpInteractionError {
fn from(error: OtpError) -> Self {
Self::Otp(error)
}
}
impl Error for OtpInteractionError {}
fn read_standard_input_line() -> Result<Vec<u8>, OtpInteractionError> {
let mut value = Vec::new();
let read = std::io::stdin()
.lock()
.read_until(b'\n', &mut value)
.map_err(|_| OtpInteractionError::Input)?;
if read == 0 {
return Err(OtpInteractionError::Input);
}
if value.ends_with(b"\n") {
value.pop();
}
if value.ends_with(b"\r") {
value.pop();
}
Ok(value)
}
fn current_unix_seconds() -> Result<u64, OtpInteractionError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|_| OtpInteractionError::Clock)
}
enum GenerationCommitter {
Git(Box<GitRepository>),
None(NoGitEntryCommitter),
@@ -452,7 +837,7 @@ fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> R
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
collections::{BTreeMap, VecDeque},
error::Error,
ffi::OsString,
fs,
@@ -461,22 +846,26 @@ mod tests {
use ironstorage::{
command::{
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
GenerateRequest, GeneratedPresentation, Presentation, ShowRequest,
CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
GenerateRequest, GeneratedPresentation, InputPlan, OtpAppendRequest, OtpCodeRequest,
OtpInputSource, OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, ShowRequest,
},
config::Config,
git::GitCredentialProvider as _,
otp::OtpInput,
presentation::{ClipboardTimeout, QrMatrix},
repository::SecretBytes,
repository::{EntryPath, Repository, SecretBytes},
secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
},
write::OverwriteDecision,
};
use super::{
CliPresentation, PresentationFailure, execute_secure, execute_secure_with, run_with,
wait_for_clipboard,
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_secure,
execute_secure_with, execute_secure_with_services, run_with, wait_for_clipboard,
};
type TestResult = Result<(), Box<dyn Error>>;
@@ -490,6 +879,42 @@ mod tests {
qr: Vec<Vec<u8>>,
}
#[derive(Default)]
struct MemoryOtpInteraction {
terminal: bool,
inputs: VecDeque<OtpInput>,
decisions: VecDeque<OverwriteDecision>,
plans: Vec<InputPlan>,
prompts: Vec<String>,
}
impl OtpInteraction for MemoryOtpInteraction {
fn standard_input_is_terminal(&self) -> bool {
self.terminal
}
fn read_input(
&mut self,
plan: InputPlan,
_source: &OtpInputSource,
prompt: &str,
_stderr: &mut dyn std::io::Write,
) -> Result<OtpInput, OtpInteractionError> {
self.plans.push(plan);
self.prompts.push(prompt.to_owned());
self.inputs.pop_front().ok_or(OtpInteractionError::Input)
}
fn confirm(
&mut self,
prompt: &str,
_stderr: &mut dyn std::io::Write,
) -> Result<OverwriteDecision, OtpInteractionError> {
self.prompts.push(prompt.to_owned());
self.decisions.pop_front().ok_or(OtpInteractionError::Input)
}
}
impl CliPresentation for MemoryPresentation {
fn clipboard(
&mut self,
@@ -856,6 +1281,282 @@ mod tests {
Ok(())
}
#[test]
fn otp_validate_runs_without_configuration_and_redacts_invalid_input() -> TestResult {
let valid = "otpauth://totp/account?secret=JBSWY3DPEHPK3PXP";
let mut stdout = Vec::new();
let mut stderr = Vec::new();
assert_eq!(
run_with(
["ironstorage", "otp", "validate", valid],
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
let invalid = "otpauth://totp/account?secret=PRIVATE-NOT-BASE32";
assert_eq!(
run_with(
["ironstorage", "otp", "validate", invalid],
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_FAILURE
);
assert!(
!stderr
.windows(invalid.len())
.any(|part| part == invalid.as_bytes())
);
Ok(())
}
#[test]
fn cli_otp_code_and_uri_use_secret_safe_presentation_channels() -> TestResult {
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
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 = {:?}\nclipboard_timeout_seconds = 1\n",
fixtures.join("stores/basic"),
FINGERPRINT,
fixtures.join("keys"),
),
)?;
let config = Config::load(Some(&config_path))?;
let mut secrets = fixture_secrets(FINGERPRINT)?;
let mut presentation = MemoryPresentation::default();
let mut interaction = MemoryOtpInteraction::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest {
entry: "otp/totp".to_owned(),
clipboard: true,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(59),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
let code = presentation.clipboard.last().expect("clipboard code");
assert_eq!(code.len(), 6);
assert!(code.iter().all(u8::is_ascii_digit));
assert!(!stdout.windows(code.len()).any(|part| part == code));
assert!(stderr.is_empty());
stdout.clear();
let uri = fs::read(fixtures.join("expected/basic/otp/totp.txt"))?;
let uri = uri
.split(|byte| *byte == b'\n')
.find(|line| line.starts_with(b"otpauth://"))
.expect("fixture URI");
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest {
entry: "otp/totp".to_owned(),
presentation: OtpUriPresentation::QrCode,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(59),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(presentation.qr.last().map(Vec::as_slice), Some(uri));
assert!(!stdout.windows(uri.len()).any(|part| part == uri));
assert!(stderr.is_empty());
stdout.clear();
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest {
entry: "otp/totp".to_owned(),
presentation: OtpUriPresentation::Clipboard,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(59),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(presentation.clipboard.last().map(Vec::as_slice), Some(uri));
assert!(!stdout.windows(uri.len()).any(|part| part == uri));
assert!(stderr.is_empty());
Ok(())
}
#[test]
fn cli_otp_insert_append_and_hotp_increment_mutate_encrypted_entries() -> TestResult {
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let temporary = tempfile::tempdir()?;
let vault = temporary.path().join("vault");
fs::create_dir_all(vault.join("email"))?;
fs::create_dir_all(vault.join("otp"))?;
for path in [
".gpg-id",
".gpg-id.sig",
"email/personal.gpg",
"otp/hotp.gpg",
] {
fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?;
}
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
vault,
FINGERPRINT,
fixtures.join("keys"),
),
)?;
let config = Config::load(Some(&config_path))?;
let mut secrets = fixture_secrets(FINGERPRINT)?;
let mut presentation = MemoryPresentation::default();
let mut interaction = MemoryOtpInteraction {
terminal: true,
..MemoryOtpInteraction::default()
};
interaction.inputs.push_back(OtpInput::hidden(
b"JBSWY3DPEHPK3PXP".to_vec(),
b"JBSWY3DPEHPK3PXP".to_vec(),
)?);
interaction.decisions.push_back(OverwriteDecision::Allow);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Insert(OtpInsertRequest {
entry: None,
force: false,
echo: false,
source: OtpInputSource::Secret {
issuer: Some("Issuer".to_owned()),
account: Some("account".to_owned()),
},
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(0),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(interaction.plans, vec![InputPlan::HiddenConfirmed]);
assert!(vault.join("Issuer/account.gpg").is_file());
interaction.terminal = false;
interaction.inputs.push_back(OtpInput::line(
b"otpauth://totp/New:alice?secret=JBSWY3DPEHPK3PXP&issuer=New".to_vec(),
)?);
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Append(OtpAppendRequest {
entry: "email/personal".to_owned(),
force: false,
echo: false,
source: OtpInputSource::Uri,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(0),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(
interaction.plans.last(),
Some(&InputPlan::StandardInputLine)
);
stdout.clear();
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest {
entry: "otp/hotp".to_owned(),
clipboard: false,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(0),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(stdout.len(), 9);
assert!(stdout[..8].iter().all(u8::is_ascii_digit));
let repository = Repository::open(&vault)?;
let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?;
let derived = keys.decrypt(
&repository.read_entry(&EntryPath::parse("Issuer/account")?)?,
&mut secrets,
)?;
assert_eq!(
derived.expose(),
b"otpauth://totp/Issuer:account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer\n"
);
let appended = keys.decrypt(
&repository.read_entry(&EntryPath::parse("email/personal")?)?,
&mut secrets,
)?;
assert!(
appended
.expose()
.windows(14)
.any(|part| part == b"otpauth://totp")
);
let hotp = keys.decrypt(
&repository.read_entry(&EntryPath::parse("otp/hotp")?)?,
&mut secrets,
)?;
assert!(hotp.expose().windows(9).any(|part| part == b"counter=1"));
assert!(stderr.is_empty());
Ok(())
}
fn fixture_secrets(fingerprint: &str) -> Result<SecretStore<MemoryBackend>, SecretStoreError> {
let secrets = SecretStore::new(
MemoryBackend::default(),