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

30
Cargo.lock generated
View File

@@ -1553,6 +1553,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbl"
version = "0.3.2"
@@ -3966,10 +3972,12 @@ dependencies = [
"cap-std",
"cap-tempfile",
"clap",
"data-encoding",
"flate2",
"gix",
"gix-config",
"hex",
"hmac",
"keyring-core",
"pgp",
"qrcode",
@@ -4008,6 +4016,7 @@ version = "0.1.0"
dependencies = [
"ctrlc",
"ironstorage",
"rpassword",
"tempfile",
]
@@ -6180,6 +6189,17 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rqrr"
version = "0.10.1"
@@ -6210,6 +6230,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"

View File

@@ -21,9 +21,11 @@ cap-tempfile = "4.0"
clap = { version = "4.6", features = ["derive"] }
crossterm = "0.29"
ctrlc = "3.5"
data-encoding = "2.9"
flate2 = "1.1"
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
gix-config = "0.59"
hmac = "0.12"
iced = "0.14"
ironstorage = { path = "crates/storage" }
keyring-core = "1.0"
@@ -33,11 +35,13 @@ rand = "0.8"
regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
rqrr = { version = "0.10", default-features = false }
rpassword = "7.5"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
security-framework = "3.7"
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
serde = { version = "1", features = ["derive"] }
sha1 = "0.10"
sha2 = "0.10"
shlex = "1.3"
toml = "0.9"
uniffi = "0.32"

View File

@@ -51,9 +51,10 @@ decision.
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
| TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. |
| TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. |
| Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. |
| CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. |
| Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. |
| QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | `qrcode` selected without image features for storage-owned matrices and terminal rendering. `rqrr` is test-only round-trip verification. Apple camera scanning should use AVFoundation and pass only decoded bytes to Rust. |
| Atomic file replacement and scoped filesystem access | [`cap-std` 4.0](https://crates.io/crates/cap-std/4.0.2), [`cap-tempfile` 4.0](https://crates.io/crates/cap-tempfile/4.0.2) | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | Selected. Directory capabilities prevent vault escape; temporary files are anonymous where supported and are synced before atomic replacement. |

View File

@@ -33,6 +33,8 @@ bounded caching are documented in
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
Clipboard cleanup/race behavior and platform-neutral QR rendering are
documented in [`docs/presentation.md`](docs/presentation.md).
Pass-OTP URI compatibility, RFC code generation, and atomic HOTP counters are
documented in [`docs/otp.md`](docs/otp.md).
The capability-scoped password-store layout and atomic mutation guarantees are
documented in [`docs/repository-core.md`](docs/repository-core.md).
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and

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(),

View File

@@ -10,9 +10,11 @@ publish = false
cap-std.workspace = true
cap-tempfile.workspace = true
clap.workspace = true
data-encoding.workspace = true
flate2.workspace = true
gix.workspace = true
gix-config.workspace = true
hmac.workspace = true
keyring-core.workspace = true
pgp.workspace = true
qrcode.workspace = true
@@ -21,6 +23,7 @@ regex.workspace = true
reqwest.workspace = true
serde.workspace = true
sha1.workspace = true
sha2.workspace = true
shlex.workspace = true
toml.workspace = true
url.workspace = true
@@ -46,6 +49,5 @@ hex = "0.4"
rand_chacha = "0.3"
rqrr.workspace = true
rustix = { version = "1.1", features = ["fs"] }
sha2 = "0.10"
smallvec = "1.15"
tempfile = "3"

View File

@@ -11,6 +11,7 @@ pub mod crypto;
pub mod generate;
pub mod git;
pub mod mutation;
pub mod otp;
pub mod presentation;
pub mod read;
pub mod recipient;

1083
crates/storage/src/otp.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -83,6 +83,14 @@ pub struct EntryCommit {
}
impl EntryCommit {
pub(crate) fn new(path: EntryPath, action: EntryAction, message: String) -> Self {
Self {
path,
action,
message,
}
}
pub fn path(&self) -> &EntryPath {
&self.path
}
@@ -205,11 +213,11 @@ impl<'a> VaultWriter<'a> {
.keys
.encrypt(contents.into_secret(), recipients.recipients())?;
self.repository.write_entry(&path, &ciphertext)?;
let change = EntryCommit {
path: path.clone(),
action: EntryAction::Insert,
message: format!("Add given password for {path} to store."),
};
let change = EntryCommit::new(
path.clone(),
EntryAction::Insert,
format!("Add given password for {path} to store."),
);
if let Err(error) = committer.commit(&change) {
if let Err(rollback) = self.restore(&path, original.as_ref()) {
return Err(WriteError::RollbackFailed {
@@ -270,11 +278,11 @@ impl<'a> VaultWriter<'a> {
.resolve_for_entry(path, signing)?;
let ciphertext = self.keys.encrypt(contents, recipients.recipients())?;
self.repository.write_entry(path, &ciphertext)?;
let change = EntryCommit {
path: path.clone(),
action: EntryAction::Insert,
message: format!("Add generated password for {path}."),
};
let change = EntryCommit::new(
path.clone(),
EntryAction::Insert,
format!("Add generated password for {path}."),
);
if let Err(error) = committer.commit(&change) {
if let Err(rollback) = self.restore(path, original.as_ref()) {
return Err(WriteError::RollbackFailed {
@@ -320,11 +328,11 @@ impl<'a> VaultWriter<'a> {
.resolve_for_entry(&session.path, signing)?;
let ciphertext = self.keys.encrypt(replacement, recipients.recipients())?;
self.repository.write_entry(&session.path, &ciphertext)?;
let change = EntryCommit {
path: session.path.clone(),
action: EntryAction::Edit,
message: format!("Edit password for {} using {}.", session.path, editor_name),
};
let change = EntryCommit::new(
session.path.clone(),
EntryAction::Edit,
format!("Edit password for {} using {}.", session.path, editor_name),
);
if let Err(error) = committer.commit(&change) {
if let Err(rollback) = self.restore(&session.path, session.original_ciphertext.as_ref())
{

471
crates/storage/tests/otp.rs Normal file
View File

@@ -0,0 +1,471 @@
#![forbid(unsafe_code)]
mod support;
use std::collections::BTreeMap;
use data_encoding::BASE32_NOPAD;
use ironstorage::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
otp::{OtpAlgorithm, OtpError, OtpInput, OtpKind, OtpService, OtpUri},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self(
fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
)
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.0
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[derive(Default)]
struct Committer {
changes: Vec<EntryCommit>,
fail: bool,
}
impl EntryCommitter for Committer {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.changes.push(change.clone());
if self.fail {
Err(EntryCommitError::new("simulated Git failure"))
} else {
Ok(())
}
}
}
#[test]
fn key_uri_parsing_defaults_derivation_and_validation_are_typed() -> TestResult {
let text = "otpauth://totp/Example:alice%40example.test?secret=jbswy3dpehpk3pxp&issuer=Example";
let uri = OtpUri::parse_str(text)?;
assert_eq!(uri.encoded().expose(), text.as_bytes());
assert_eq!(uri.kind(), OtpKind::Totp);
assert_eq!(uri.issuer(), Some("Example"));
assert_eq!(uri.account(), "alice@example.test");
assert_eq!(uri.algorithm(), OtpAlgorithm::Sha1);
assert_eq!(uri.digits(), 6);
assert_eq!(uri.period(), Some(30));
assert_eq!(uri.counter(), None);
assert_eq!(
uri.derived_entry()?.to_string(),
"Example/alice@example.test"
);
assert!(!format!("{uri:?}").contains("jbswy3dpehpk3pxp"));
let derived = OtpUri::from_input(
&OtpInputSource::Secret {
issuer: Some("ACME Co".to_owned()),
account: Some("alice@example.test".to_owned()),
},
OtpInput::line(b"JBSWY3DPEHPK3PXP".to_vec())?,
)?;
assert_eq!(
derived.encoded().expose(),
b"otpauth://totp/ACME+Co:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=ACME+Co"
);
assert_eq!(
derived.derived_entry()?.to_string(),
"ACME Co/alice@example.test"
);
let custom = OtpUri::parse_str(
"otpauth://totp/custom?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60",
)?;
assert_eq!(custom.period(), Some(60));
assert_eq!(custom.digits(), 8);
assert_eq!(
custom.code_at(119)?.expose(),
custom.code_for_counter(1)?.expose()
);
for (invalid, expected) in [
("https://example.test/not-otp", OtpError::InvalidScheme),
(
"otpauth://totp/account?issuer=Example",
OtpError::MissingSecret,
),
(
"otpauth://hotp/account?secret=JBSWY3DPEHPK3PXP",
OtpError::MissingCounter,
),
(
"otpauth://totp/account?secret=not-base32!",
OtpError::InvalidSecret,
),
(
"otpauth://totp/account?secret=MY========",
OtpError::InvalidSecret,
),
(
"otpauth://totp/A:account?secret=JBSWY3DPEHPK3PXP&issuer=B",
OtpError::IssuerMismatch,
),
] {
assert_eq!(
std::mem::discriminant(&OtpUri::parse_str(invalid).expect_err("invalid URI")),
std::mem::discriminant(&expected)
);
}
assert!(matches!(
OtpUri::parse_str("otpauth://totp/account?secret=JBSWY3DPEHPK3PXP&secret=JBSWY3DPEHPK3PXP"),
Err(OtpError::DuplicateParameter(_))
));
assert!(matches!(
OtpInput::hidden(b"one".to_vec(), b"two".to_vec()),
Err(OtpError::ConfirmationMismatch)
));
assert!(matches!(
OtpInput::line(b"two\nlines".to_vec()),
Err(OtpError::InvalidInput)
));
Ok(())
}
#[test]
fn rfc4226_hotp_vectors_pass() -> TestResult {
let secret = BASE32_NOPAD.encode(b"12345678901234567890");
let uri = OtpUri::parse_str(&format!("otpauth://hotp/RFC4226?secret={secret}&counter=0"))?;
for (counter, expected) in [
"755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871",
"520489",
]
.into_iter()
.enumerate()
{
assert_eq!(
uri.code_for_counter(counter as u64)?.expose(),
expected.as_bytes()
);
}
Ok(())
}
#[test]
fn rfc6238_totp_vectors_cover_all_algorithms() -> TestResult {
let vectors = [
(59, "94287082", "46119246", "90693936"),
(1_111_111_109, "07081804", "68084774", "25091201"),
(1_111_111_111, "14050471", "67062674", "99943326"),
(1_234_567_890, "89005924", "91819424", "93441116"),
(2_000_000_000, "69279037", "90698825", "38618901"),
(20_000_000_000, "65353130", "77737706", "47863826"),
];
let tokens = [
token(b"12345678901234567890", "SHA1")?,
token(b"12345678901234567890123456789012", "SHA256")?,
token(
b"1234567890123456789012345678901234567890123456789012345678901234",
"SHA512",
)?,
];
for (timestamp, sha1, sha256, sha512) in vectors {
for (token, expected) in tokens.iter().zip([sha1, sha256, sha512]) {
assert_eq!(token.code_at(timestamp)?.expose(), expected.as_bytes());
}
}
Ok(())
}
#[test]
fn pass_otp_fixtures_round_trip_code_uri_insert_and_append() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = OtpService::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let mut committer = Committer::default();
let fixture_uri = fixture
.read("expected/basic/otp/totp.txt")?
.split(|byte| *byte == b'\n')
.find(|line| line.starts_with(b"otpauth://"))
.expect("fixture URI")
.to_vec();
OtpService::validate(std::str::from_utf8(&fixture_uri)?)?;
assert_eq!(
service.uri("otp/totp", &mut provider)?.encoded().expose(),
fixture_uri
);
let code = service.code("otp/totp", 59, None, &mut provider, &mut committer)?;
assert_eq!(code.code().expose().len(), 6);
assert!(code.code().expose().iter().all(u8::is_ascii_digit));
assert!(committer.changes.is_empty());
let replacement_uri = "otpauth://totp/Replaced:alice?secret=JBSWY3DPEHPK3PXP&issuer=Replaced";
let replace_request = OtpAppendRequest {
entry: "otp/totp".to_owned(),
force: true,
echo: true,
source: OtpInputSource::Uri,
};
let replace_session = service.begin_append(&replace_request, &mut provider)?;
assert!(!replace_session.requires_replace_confirmation());
service.finish_append(
replace_session,
OtpInput::line(replacement_uri.as_bytes().to_vec())?,
OverwriteDecision::Decline,
None,
&mut committer,
)?;
assert_eq!(
decrypt(&repository, &keys, "otp/totp", &mut provider)?.expose(),
format!("fixture-password\n{replacement_uri}\n").as_bytes()
);
assert_eq!(
committer.changes.last().expect("replace commit").message(),
"Replace OTP secret for otp/totp."
);
let hotp_path = EntryPath::parse("otp/hotp")?;
let hotp_before = repository.read_entry(&hotp_path)?;
let hotp = service.code("otp/hotp", 0, None, &mut provider, &mut committer)?;
assert_eq!(hotp.counter(), Some(1));
assert_eq!(hotp.code().expose().len(), 8);
assert_eq!(
committer.changes.last().expect("HOTP commit").message(),
"Increment HOTP counter for otp/hotp."
);
let hotp_plaintext = keys.decrypt(&repository.read_entry(&hotp_path)?, &mut provider)?;
assert!(
hotp_plaintext
.expose()
.windows(9)
.any(|part| part == b"counter=1")
);
assert_ne!(repository.read_entry(&hotp_path)?, hotp_before);
let inserted_uri = "otpauth://totp/New:alice?secret=JBSWY3DPEHPK3PXP&issuer=New";
let insert = OtpInsertRequest {
entry: Some("otp/new".to_owned()),
force: false,
echo: false,
source: OtpInputSource::Uri,
};
let plan =
service.prepare_insert(&insert, OtpInput::line(inserted_uri.as_bytes().to_vec())?)?;
assert!(!plan.requires_path_confirmation());
assert!(!plan.requires_overwrite_confirmation());
service.finish_insert(
plan,
OverwriteDecision::Allow,
OverwriteDecision::Allow,
None,
&mut committer,
)?;
assert_eq!(
decrypt(&repository, &keys, "otp/new", &mut provider)?.expose(),
format!("{inserted_uri}\n").as_bytes()
);
let derived = OtpInsertRequest {
entry: None,
force: false,
echo: false,
source: OtpInputSource::Secret {
issuer: Some("Issuer".to_owned()),
account: Some("account".to_owned()),
},
};
let plan = service.prepare_insert(
&derived,
OtpInput::hidden(b"JBSWY3DPEHPK3PXP".to_vec(), b"JBSWY3DPEHPK3PXP".to_vec())?,
)?;
assert_eq!(plan.path().to_string(), "Issuer/account");
assert!(plan.requires_path_confirmation());
service.finish_insert(
plan,
OverwriteDecision::Allow,
OverwriteDecision::Allow,
None,
&mut committer,
)?;
assert_eq!(
decrypt(&repository, &keys, "Issuer/account", &mut provider)?.expose(),
b"otpauth://totp/Issuer:account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer\n"
);
let original = decrypt(&repository, &keys, "email/personal", &mut provider)?;
let append = OtpAppendRequest {
entry: "email/personal".to_owned(),
force: false,
echo: false,
source: OtpInputSource::Uri,
};
let session = service.begin_append(&append, &mut provider)?;
assert!(!session.requires_replace_confirmation());
service.finish_append(
session,
OtpInput::line(inserted_uri.as_bytes().to_vec())?,
OverwriteDecision::Allow,
None,
&mut committer,
)?;
let appended = decrypt(&repository, &keys, "email/personal", &mut provider)?;
assert!(appended.expose().starts_with(original.expose()));
assert!(
appended
.expose()
.ends_with(format!("{inserted_uri}\n").as_bytes())
);
assert_eq!(
committer.changes.last().expect("append commit").message(),
"Append OTP secret for email/personal."
);
Ok(())
}
#[test]
fn malformed_ambiguous_declined_and_commit_failures_never_mutate() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = OtpService::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let mut committer = Committer::default();
let path = EntryPath::parse("otp/totp")?;
let original = repository.read_entry(&path)?;
let request = OtpAppendRequest {
entry: "otp/totp".to_owned(),
force: false,
echo: false,
source: OtpInputSource::Uri,
};
let session = service.begin_append(&request, &mut provider)?;
assert!(session.requires_replace_confirmation());
assert!(matches!(
service.finish_append(
session,
OtpInput::line(b"otpauth://totp/new?secret=JBSWY3DPEHPK3PXP".to_vec())?,
OverwriteDecision::Decline,
None,
&mut committer,
),
Err(OtpError::Cancelled)
));
assert_eq!(repository.read_entry(&path)?, original);
assert!(committer.changes.is_empty());
let forced = OtpAppendRequest {
force: true,
..request.clone()
};
let first = service.begin_append(&forced, &mut provider)?;
let stale = service.begin_append(&forced, &mut provider)?;
service.finish_append(
first,
OtpInput::line(b"otpauth://totp/first?secret=JBSWY3DPEHPK3PXP".to_vec())?,
OverwriteDecision::Allow,
None,
&mut committer,
)?;
let committed = repository.read_entry(&path)?;
assert!(matches!(
service.finish_append(
stale,
OtpInput::line(b"otpauth://totp/stale?secret=JBSWY3DPEHPK3PXP".to_vec())?,
OverwriteDecision::Allow,
None,
&mut committer,
),
Err(OtpError::ConcurrentModification { .. })
));
assert_eq!(repository.read_entry(&path)?, committed);
assert_eq!(committer.changes.len(), 1);
let non_otp = EntryPath::parse("email/personal")?;
let non_otp_before = repository.read_entry(&non_otp)?;
assert!(matches!(
service.uri("email/personal", &mut provider),
Err(OtpError::MissingUri { .. })
));
assert_eq!(repository.read_entry(&non_otp)?, non_otp_before);
assert_eq!(committer.changes.len(), 1);
let hotp = EntryPath::parse("otp/hotp")?;
let hotp_before = repository.read_entry(&hotp)?;
committer.fail = true;
assert!(matches!(
service.code("otp/hotp", 0, None, &mut provider, &mut committer),
Err(OtpError::Commit(_))
));
assert!(
decrypt(&repository, &keys, "otp/hotp", &mut provider)?
.expose()
.windows(9)
.any(|part| part == b"counter=0")
);
assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(0));
assert_eq!(repository.read_entry(&hotp)?, hotp_before);
let duplicate_path = EntryPath::parse("otp/duplicate")?;
let duplicate_plaintext = SecretBytes::new(
b"password\notpauth://totp/one?secret=JBSWY3DPEHPK3PXP\notpauth://totp/two?secret=JBSWY3DPEHPK3PXP\n"
.to_vec(),
);
let recipients =
RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&duplicate_path, None)?;
repository.write_entry(
&duplicate_path,
&keys.encrypt(duplicate_plaintext, recipients.recipients())?,
)?;
let duplicate_before = repository.read_entry(&duplicate_path)?;
let duplicate_request = OtpAppendRequest {
entry: "otp/duplicate".to_owned(),
force: true,
echo: true,
source: OtpInputSource::Uri,
};
assert!(matches!(
service.begin_append(&duplicate_request, &mut provider),
Err(OtpError::AmbiguousUri { .. })
));
assert_eq!(repository.read_entry(&duplicate_path)?, duplicate_before);
Ok(())
}
fn token(secret: &[u8], algorithm: &str) -> Result<OtpUri, OtpError> {
OtpUri::parse_str(&format!(
"otpauth://totp/RFC6238?secret={}&algorithm={algorithm}&digits=8&period=30",
BASE32_NOPAD.encode(secret)
))
}
fn decrypt(
repository: &Repository,
keys: &KeyStore,
path: &str,
provider: &mut impl SecretProvider,
) -> Result<SecretBytes, Box<dyn std::error::Error>> {
Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, provider)?)
}

50
docs/otp.md Normal file
View File

@@ -0,0 +1,50 @@
# Pass-OTP compatibility
`crates/storage` owns OTP key URI parsing, code generation, entry discovery and
mutation, HOTP counter updates, and presentation payloads. The CLI only reads
input, asks for typed confirmations, invokes the Rust service, and presents the
returned zeroizing bytes. Runtime code never launches `pass`, `gpg`,
`oathtool`, `otptool`, `qrencode`, or a shell.
## Key URIs and entry layout
IronStorage reads and writes the standard `otpauth://totp/...` and
`otpauth://hotp/...` lines used by `pass-otp`. URI validation covers a Base32
secret, decoded issuer and account label, SHA-1/SHA-256/SHA-512 algorithm,
six- or eight-digit output, positive TOTP period, and required HOTP counter.
Defaults are SHA-1, six digits, and a 30-second TOTP period. Duplicate known
parameters, mismatched label/query issuers, invalid type-specific parameters,
and malformed percent or Base32 encoding are typed failures.
An explicitly supplied URI is preserved byte-for-byte. `otp insert --secret`
constructs the same default TOTP URI shape as upstream and percent-encodes its
issuer and account. Without an explicit entry path, the decoded issuer and
account produce `issuer/account`; an account without an issuer produces the
account path. The frontend must confirm that derived path before storage
changes anything.
Within a multiline password entry, the URI must begin at the start of its own
line. `otp append` adds a line when none exists or replaces the existing line
while preserving every other byte. Multiple URI lines are rejected as
ambiguous instead of guessing which token to use. Validation, confirmation,
recipient resolution, encryption, the atomic repository write, and the
embedded Git commit form one storage-owned operation with rollback on commit
failure.
## Codes and counters
HOTP implements RFC 4226 dynamic truncation. TOTP implements RFC 6238 by using
the selected Unix-time step as the HOTP counter. The HMAC digest and formatted
code are zeroized after use, and tests cover the published SHA-1, SHA-256, and
SHA-512 vectors, alternate periods, six/eight digits, and counters.
For `pass-otp` compatibility, a stored HOTP counter records the last-used
counter. Code generation checks for concurrent entry changes, increments the
counter, rewrites only its URI value, atomically encrypts the updated entry,
and commits `Increment HOTP counter for <entry>.` before returning the code. A
validation, encryption, concurrency, or Git failure therefore never exposes a
code whose counter update was not committed.
OTP codes support terminal or secret-safe clipboard presentation. URI output
supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
Clipboard and QR requests never print the underlying code or URI as plaintext.