diff --git a/Cargo.lock b/Cargo.lock index 46f02c6..51a7cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index bd709ba..8e80cf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 472c9ab..a842edc 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -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. | diff --git a/README.md b/README.md index cf24d6d..d2be64e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 79e6747..11b87fb 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] ctrlc.workspace = true ironstorage.workspace = true +rpassword.workspace = true tempfile = "3" [dev-dependencies] diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index e9742ba..1b6caf8 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -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( stdout: &mut O, stderr: &mut E, ) -> Result { - 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( config: &Config, request: &CommandRequest, @@ -169,6 +183,35 @@ fn execute_secure_with Result { + 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, + presentation: &mut P, + interaction: &mut I, + clock: impl Fn() -> Result, + stdout: &mut O, + stderr: &mut E, ) -> Result { match request { CommandRequest::Show(request) => { @@ -259,6 +302,16 @@ fn execute_secure_with 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( + config: &Config, + request: &OtpRequest, + secrets: &mut SecretStore, + presentation: &mut P, + interaction: &mut I, + clock: impl Fn() -> Result, + stdout: &mut O, + stderr: &mut E, +) -> Result { + 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; + + fn confirm( + &mut self, + prompt: &str, + stderr: &mut dyn Write, + ) -> Result; +} + +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 { + 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 { + 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 { + Err(OtpInteractionError::Input) + } + + fn confirm( + &mut self, + _prompt: &str, + _stderr: &mut dyn Write, + ) -> Result { + 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 for OtpInteractionError { + fn from(error: OtpError) -> Self { + Self::Otp(error) + } +} + +impl Error for OtpInteractionError {} + +fn read_standard_input_line() -> Result, 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 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| OtpInteractionError::Clock) +} + enum GenerationCommitter { Git(Box), None(NoGitEntryCommitter), @@ -452,7 +837,7 @@ fn operation_error(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>; @@ -490,6 +879,42 @@ mod tests { qr: Vec>, } + #[derive(Default)] + struct MemoryOtpInteraction { + terminal: bool, + inputs: VecDeque, + decisions: VecDeque, + plans: Vec, + prompts: Vec, + } + + 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 { + 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 { + 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, SecretStoreError> { let secrets = SecretStore::new( MemoryBackend::default(), diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index a21fbdb..0fc741f 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -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" diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 76388c4..b3d142a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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; diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs new file mode 100644 index 0000000..154228a --- /dev/null +++ b/crates/storage/src/otp.rs @@ -0,0 +1,1083 @@ +//! `pass-otp` compatible URI handling, code generation, and repository mutation. + +use std::{error::Error, fmt, ops::Range, str, sync::Mutex}; + +use data_encoding::BASE32_NOPAD; +use hmac::Hmac; +use sha1::Sha1; +use sha2::{Sha256, Sha512}; +use zeroize::Zeroize as _; + +use crate::{ + command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, + crypto::{CryptoError, KeyStore, SecretProvider}, + recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy}, + repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, + write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision}, +}; + +const DEFAULT_DIGITS: u32 = 6; +const DEFAULT_PERIOD: u64 = 30; +static OTP_MUTATION_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpKind { + Totp, + Hotp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpAlgorithm { + Sha1, + Sha256, + Sha512, +} + +/// A validated key URI whose encoded and decoded secrets zeroize on drop. +pub struct OtpUri { + encoded: SecretBytes, + kind: OtpKind, + secret: SecretBytes, + issuer: Option, + account: String, + algorithm: OtpAlgorithm, + digits: u32, + period: Option, + counter: Option, + counter_value: Option>, +} + +impl OtpUri { + pub fn parse(encoded: SecretBytes) -> Result { + let text = str::from_utf8(encoded.expose()).map_err(|_| OtpError::InvalidUri)?; + if text.is_empty() + || text + .bytes() + .any(|byte| byte.is_ascii_control() || byte == b' ') + { + return Err(OtpError::InvalidUri); + } + let remainder = text + .strip_prefix("otpauth://") + .ok_or(OtpError::InvalidScheme)?; + let (authority_and_label, query) = remainder + .split_once('?') + .ok_or(OtpError::MissingParameters)?; + if query.is_empty() || query.contains('#') { + return Err(OtpError::MissingParameters); + } + let (kind_text, raw_label) = authority_and_label + .split_once('/') + .ok_or(OtpError::MissingAccount)?; + let kind = match kind_text { + "totp" => OtpKind::Totp, + "hotp" => OtpKind::Hotp, + _ => return Err(OtpError::UnsupportedType), + }; + if raw_label.is_empty() || raw_label.contains('/') { + return Err(OtpError::MissingAccount); + } + let label = decode_component(raw_label)?; + let (label_issuer, account) = split_label(&label)?; + + let query_offset = text.len() - query.len(); + let mut secret = None; + let mut issuer = None; + let mut algorithm = None; + let mut digits = None; + let mut period = None; + let mut counter = None; + let mut counter_value = None; + let mut offset = 0; + for parameter in query.split('&') { + if parameter.is_empty() { + return Err(OtpError::InvalidParameter); + } + let (name, raw_value) = parameter + .split_once('=') + .ok_or(OtpError::InvalidParameter)?; + if name.is_empty() || raw_value.is_empty() { + return Err(OtpError::InvalidParameter); + } + match name { + "secret" => { + set_once(&mut secret, decode_secret(raw_value)?, OtpParameter::Secret)?; + } + "issuer" => { + let value = decode_component(raw_value)?; + if value.is_empty() || value.contains(':') { + return Err(OtpError::InvalidIssuer); + } + set_once(&mut issuer, value, OtpParameter::Issuer)?; + } + "algorithm" => { + let value = decode_ascii(raw_value)?; + let value = match value.to_ascii_uppercase().as_str() { + "SHA1" => OtpAlgorithm::Sha1, + "SHA256" => OtpAlgorithm::Sha256, + "SHA512" => OtpAlgorithm::Sha512, + _ => return Err(OtpError::InvalidAlgorithm), + }; + set_once(&mut algorithm, value, OtpParameter::Algorithm)?; + } + "digits" => { + let value = parse_number(raw_value).ok_or(OtpError::InvalidDigits)?; + if !matches!(value, 6 | 8) { + return Err(OtpError::InvalidDigits); + } + set_once(&mut digits, value as u32, OtpParameter::Digits)?; + } + "period" => { + let value = parse_number(raw_value).ok_or(OtpError::InvalidPeriod)?; + if value == 0 { + return Err(OtpError::InvalidPeriod); + } + set_once(&mut period, value, OtpParameter::Period)?; + } + "counter" => { + let value = parse_number(raw_value).ok_or(OtpError::InvalidCounter)?; + set_once(&mut counter, value, OtpParameter::Counter)?; + let start = query_offset + offset + name.len() + 1; + counter_value = Some(start..start + raw_value.len()); + } + _ => {} + } + offset += parameter.len() + 1; + } + + let secret = secret.ok_or(OtpError::MissingSecret)?; + if let (Some(label), Some(parameter)) = (label_issuer.as_ref(), issuer.as_ref()) + && label != parameter + { + return Err(OtpError::IssuerMismatch); + } + let issuer = issuer.or(label_issuer); + let algorithm = algorithm.unwrap_or(OtpAlgorithm::Sha1); + let digits = digits.unwrap_or(DEFAULT_DIGITS); + let (period, counter, counter_value) = match kind { + OtpKind::Totp => { + if counter.is_some() { + return Err(OtpError::UnexpectedCounter); + } + (Some(period.unwrap_or(DEFAULT_PERIOD)), None, None) + } + OtpKind::Hotp => { + if period.is_some() { + return Err(OtpError::UnexpectedPeriod); + } + ( + None, + Some(counter.ok_or(OtpError::MissingCounter)?), + counter_value, + ) + } + }; + + Ok(Self { + encoded, + kind, + secret, + issuer, + account, + algorithm, + digits, + period, + counter, + counter_value, + }) + } + + pub fn parse_str(encoded: &str) -> Result { + Self::parse(SecretBytes::new(encoded.as_bytes().to_vec())) + } + + pub fn from_input(source: &OtpInputSource, input: OtpInput) -> Result { + match source { + OtpInputSource::Uri => Self::parse(input.into_secret()), + OtpInputSource::Secret { issuer, account } => { + build_secret_uri(input.into_secret(), issuer.as_deref(), account.as_deref()) + } + } + } + + pub fn encoded(&self) -> &SecretBytes { + &self.encoded + } + + pub fn kind(&self) -> OtpKind { + self.kind + } + + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn algorithm(&self) -> OtpAlgorithm { + self.algorithm + } + + pub fn digits(&self) -> u32 { + self.digits + } + + pub fn period(&self) -> Option { + self.period + } + + pub fn counter(&self) -> Option { + self.counter + } + + pub fn derived_entry(&self) -> Result { + let path = self.issuer.as_ref().map_or_else( + || self.account.clone(), + |issuer| format!("{issuer}/{}", self.account), + ); + EntryPath::parse(&path).map_err(Into::into) + } + + pub fn code_at(&self, unix_seconds: u64) -> Result { + let period = self.period.ok_or(OtpError::NotTotp)?; + self.code_for_counter(unix_seconds / period) + } + + pub fn code_for_counter(&self, counter: u64) -> Result { + let message = counter.to_be_bytes(); + let mut digest = match self.algorithm { + OtpAlgorithm::Sha1 => hmac_digest::>(self.secret.expose(), &message)?, + OtpAlgorithm::Sha256 => hmac_digest::>(self.secret.expose(), &message)?, + OtpAlgorithm::Sha512 => hmac_digest::>(self.secret.expose(), &message)?, + }; + let offset = usize::from(digest[digest.len() - 1] & 0x0f); + let binary = (u32::from(digest[offset]) & 0x7f) << 24 + | u32::from(digest[offset + 1]) << 16 + | u32::from(digest[offset + 2]) << 8 + | u32::from(digest[offset + 3]); + digest.zeroize(); + let modulus = 10_u32.pow(self.digits); + let code = format!("{:0width$}", binary % modulus, width = self.digits as usize); + Ok(SecretBytes::new(code.into_bytes())) + } + + fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> { + let counter = self.counter.ok_or(OtpError::NotHotp)?; + let incremented = counter.checked_add(1).ok_or(OtpError::CounterOverflow)?; + let range = self.counter_value.clone().ok_or(OtpError::MissingCounter)?; + let mut encoded = Vec::with_capacity(self.encoded.expose().len() + 1); + encoded.extend_from_slice(&self.encoded.expose()[..range.start]); + encoded.extend_from_slice(incremented.to_string().as_bytes()); + encoded.extend_from_slice(&self.encoded.expose()[range.end..]); + Ok((incremented, Self::parse(SecretBytes::new(encoded))?)) + } +} + +impl fmt::Debug for OtpUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OtpUri") + .field("encoded", &"[REDACTED]") + .field("kind", &self.kind) + .field("secret", &"[REDACTED]") + .field("issuer", &self.issuer) + .field("account", &self.account) + .field("algorithm", &self.algorithm) + .field("digits", &self.digits) + .field("period", &self.period) + .field("counter", &self.counter) + .finish() + } +} + +pub struct OtpInput { + secret: SecretBytes, +} + +impl OtpInput { + pub fn hidden(mut first: Vec, mut confirmation: Vec) -> Result { + if let Err(error) = validate_input(&first) { + first.zeroize(); + confirmation.zeroize(); + return Err(error); + } + if let Err(error) = validate_input(&confirmation) { + first.zeroize(); + confirmation.zeroize(); + return Err(error); + } + if first != confirmation { + first.zeroize(); + confirmation.zeroize(); + return Err(OtpError::ConfirmationMismatch); + } + confirmation.zeroize(); + Ok(Self { + secret: SecretBytes::new(first), + }) + } + + pub fn line(mut line: Vec) -> Result { + if let Err(error) = validate_input(&line) { + line.zeroize(); + return Err(error); + } + Ok(Self { + secret: SecretBytes::new(line), + }) + } + + fn into_secret(self) -> SecretBytes { + self.secret + } +} + +impl fmt::Debug for OtpInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("OtpInput([REDACTED])") + } +} + +pub struct OtpInsertPlan { + path: EntryPath, + uri: OtpUri, + original: Option, + force: bool, + confirm_path: bool, +} + +impl OtpInsertPlan { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn requires_path_confirmation(&self) -> bool { + self.confirm_path + } + + pub fn requires_overwrite_confirmation(&self) -> bool { + self.original.is_some() && !self.force + } +} + +impl fmt::Debug for OtpInsertPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OtpInsertPlan") + .field("path", &self.path) + .field("uri", &self.uri) + .field("has_original", &self.original.is_some()) + .field("force", &self.force) + .field("confirm_path", &self.confirm_path) + .finish() + } +} + +pub struct OtpAppendSession { + path: EntryPath, + original: EncryptedEntry, + plaintext: SecretBytes, + existing: Option>, + source: OtpInputSource, + force: bool, +} + +impl OtpAppendSession { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn requires_replace_confirmation(&self) -> bool { + self.existing.is_some() && !self.force + } +} + +impl fmt::Debug for OtpAppendSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OtpAppendSession") + .field("path", &self.path) + .field("plaintext", &"[REDACTED]") + .field("has_uri", &self.existing.is_some()) + .field("source", &self.source) + .field("force", &self.force) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpWriteOutcome { + path: EntryPath, + replaced: bool, +} + +impl OtpWriteOutcome { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn replaced(&self) -> bool { + self.replaced + } +} + +pub struct OtpCodeOutcome { + code: SecretBytes, + counter: Option, +} + +impl OtpCodeOutcome { + pub fn code(&self) -> &SecretBytes { + &self.code + } + + pub fn counter(&self) -> Option { + self.counter + } +} + +impl fmt::Debug for OtpCodeOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OtpCodeOutcome") + .field("code", &"[REDACTED]") + .field("counter", &self.counter) + .finish() + } +} + +pub struct OtpService<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> OtpService<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + pub fn validate(encoded: &str) -> Result<(), OtpError> { + OtpUri::parse_str(encoded).map(|_| ()) + } + + pub fn prepare_insert( + &self, + request: &OtpInsertRequest, + input: OtpInput, + ) -> Result { + let uri = OtpUri::from_input(&request.source, input)?; + let confirm_path = request.entry.is_none(); + let path = request + .entry + .as_deref() + .map(parse_entry) + .transpose()? + .map_or_else(|| uri.derived_entry(), Ok)?; + let original = match self.repository.read_entry(&path) { + Ok(original) => Some(original), + Err(RepositoryError::NotFound { .. }) => None, + Err(error) => return Err(error.into()), + }; + Ok(OtpInsertPlan { + path, + uri, + original, + force: request.force, + confirm_path, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn finish_insert( + &self, + plan: OtpInsertPlan, + path_decision: OverwriteDecision, + overwrite: OverwriteDecision, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + if plan.confirm_path && path_decision == OverwriteDecision::Decline + || plan.original.is_some() && !plan.force && overwrite == OverwriteDecision::Decline + { + return Err(OtpError::Cancelled); + } + let mut plaintext = Vec::with_capacity(plan.uri.encoded().expose().len() + 1); + plaintext.extend_from_slice(plan.uri.encoded().expose()); + plaintext.push(b'\n'); + let replaced = plan.original.is_some(); + self.store( + &plan.path, + SecretBytes::new(plaintext), + plan.original.as_ref(), + EntryAction::Insert, + format!("Add OTP secret for {} to store.", plan.path), + signing, + committer, + )?; + Ok(OtpWriteOutcome { + path: plan.path, + replaced, + }) + } + + pub fn begin_append( + &self, + request: &OtpAppendRequest, + provider: &mut impl SecretProvider, + ) -> Result { + let path = parse_entry(&request.entry)?; + let original = self.repository.read_entry(&path)?; + let plaintext = self.keys.decrypt(&original, provider)?; + let existing = find_uri(&plaintext, &path)?.map(|(range, _)| range); + Ok(OtpAppendSession { + path, + original, + plaintext, + existing, + source: request.source.clone(), + force: request.force, + }) + } + + pub fn finish_append( + &self, + session: OtpAppendSession, + input: OtpInput, + replace: OverwriteDecision, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + if session.existing.is_some() && !session.force && replace == OverwriteDecision::Decline { + return Err(OtpError::Cancelled); + } + let uri = OtpUri::from_input(&session.source, input)?; + let (replacement, replaced, message) = if let Some(range) = &session.existing { + let mut replacement = Vec::with_capacity( + session.plaintext.expose().len() - range.len() + uri.encoded().expose().len(), + ); + replacement.extend_from_slice(&session.plaintext.expose()[..range.start]); + replacement.extend_from_slice(uri.encoded().expose()); + replacement.extend_from_slice(&session.plaintext.expose()[range.end..]); + ( + replacement, + true, + format!("Replace OTP secret for {}.", session.path), + ) + } else { + let mut replacement = Vec::with_capacity( + session.plaintext.expose().len() + uri.encoded().expose().len() + 2, + ); + replacement.extend_from_slice(session.plaintext.expose()); + if !replacement.is_empty() && !replacement.ends_with(b"\n") { + replacement.push(b'\n'); + } + replacement.extend_from_slice(uri.encoded().expose()); + replacement.push(b'\n'); + ( + replacement, + false, + format!("Append OTP secret for {}.", session.path), + ) + }; + self.store( + &session.path, + SecretBytes::new(replacement), + Some(&session.original), + EntryAction::Edit, + message, + signing, + committer, + )?; + Ok(OtpWriteOutcome { + path: session.path, + replaced, + }) + } + + pub fn uri(&self, entry: &str, provider: &mut impl SecretProvider) -> Result { + let path = parse_entry(entry)?; + let ciphertext = self.repository.read_entry(&path)?; + let plaintext = self.keys.decrypt(&ciphertext, provider)?; + find_uri(&plaintext, &path)? + .map(|(_, uri)| uri) + .ok_or(OtpError::MissingUri { entry: path }) + } + + pub fn code( + &self, + entry: &str, + unix_seconds: u64, + signing: Option<&SigningPolicy>, + provider: &mut impl SecretProvider, + committer: &mut impl EntryCommitter, + ) -> Result { + let path = parse_entry(entry)?; + let original = self.repository.read_entry(&path)?; + let plaintext = self.keys.decrypt(&original, provider)?; + let (range, uri) = find_uri(&plaintext, &path)?.ok_or_else(|| OtpError::MissingUri { + entry: path.clone(), + })?; + match uri.kind() { + OtpKind::Totp => Ok(OtpCodeOutcome { + code: uri.code_at(unix_seconds)?, + counter: None, + }), + OtpKind::Hotp => { + let (counter, incremented) = uri.incremented_hotp()?; + let code = incremented.code_for_counter(counter)?; + let mut replacement = Vec::with_capacity( + plaintext.expose().len() - range.len() + incremented.encoded().expose().len(), + ); + replacement.extend_from_slice(&plaintext.expose()[..range.start]); + replacement.extend_from_slice(incremented.encoded().expose()); + replacement.extend_from_slice(&plaintext.expose()[range.end..]); + self.store( + &path, + SecretBytes::new(replacement), + Some(&original), + EntryAction::Edit, + format!("Increment HOTP counter for {path}."), + signing, + committer, + )?; + Ok(OtpCodeOutcome { + code, + counter: Some(counter), + }) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn store( + &self, + path: &EntryPath, + plaintext: SecretBytes, + original: Option<&EncryptedEntry>, + action: EntryAction, + message: String, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result<(), OtpError> { + let _mutation = OTP_MUTATION_LOCK + .lock() + .map_err(|_| OtpError::MutationLockUnavailable)?; + let current = match self.repository.read_entry(path) { + Ok(current) => Some(current), + Err(RepositoryError::NotFound { .. }) => None, + Err(error) => return Err(error.into()), + }; + if current.as_ref() != original { + return Err(OtpError::ConcurrentModification { + entry: path.clone(), + }); + } + let recipients = RecipientPolicyManager::new(self.repository, self.keys) + .resolve_for_entry(path, signing)?; + let ciphertext = self.keys.encrypt(plaintext, recipients.recipients())?; + self.repository.write_entry(path, &ciphertext)?; + let change = EntryCommit::new(path.clone(), action, message); + if let Err(operation) = committer.commit(&change) { + if let Err(rollback) = self.restore(path, original) { + return Err(OtpError::RollbackFailed { + operation, + rollback, + }); + } + return Err(OtpError::Commit(operation)); + } + Ok(()) + } + + fn restore( + &self, + path: &EntryPath, + original: Option<&EncryptedEntry>, + ) -> Result<(), RepositoryError> { + if let Some(original) = original { + self.repository.write_entry(path, original) + } else { + self.repository.remove_entry(path)?; + self.repository + .cleanup_empty_directories(&path.parent_directory()) + .map(|_| ()) + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpParameter { + Secret, + Issuer, + Algorithm, + Digits, + Period, + Counter, +} + +#[derive(Debug)] +pub enum OtpError { + Repository(RepositoryError), + Crypto(CryptoError), + RecipientPolicy(RecipientPolicyError), + InvalidUri, + InvalidScheme, + UnsupportedType, + MissingParameters, + MissingAccount, + InvalidLabel, + InvalidIssuer, + MissingSecret, + InvalidSecret, + InvalidParameter, + DuplicateParameter(OtpParameter), + IssuerMismatch, + InvalidAlgorithm, + InvalidDigits, + InvalidPeriod, + InvalidCounter, + MissingCounter, + UnexpectedCounter, + UnexpectedPeriod, + NotTotp, + NotHotp, + CounterOverflow, + EmptyInput, + InvalidInput, + ConfirmationMismatch, + MissingUri { + entry: EntryPath, + }, + AmbiguousUri { + entry: EntryPath, + }, + Cancelled, + MutationLockUnavailable, + ConcurrentModification { + entry: EntryPath, + }, + Commit(EntryCommitError), + RollbackFailed { + operation: EntryCommitError, + rollback: RepositoryError, + }, +} + +impl fmt::Display for OtpError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Repository(error) => error.fmt(formatter), + Self::Crypto(error) => error.fmt(formatter), + Self::RecipientPolicy(error) => error.fmt(formatter), + Self::InvalidUri => formatter.write_str("OTP key URI is not valid UTF-8 URI text"), + Self::InvalidScheme => formatter.write_str("OTP key URI must use the otpauth scheme"), + Self::UnsupportedType => formatter.write_str("OTP key URI type must be totp or hotp"), + Self::MissingParameters => formatter.write_str("OTP key URI parameters are missing"), + Self::MissingAccount => formatter.write_str("OTP key URI account is missing"), + Self::InvalidLabel => formatter.write_str("OTP key URI label is invalid"), + Self::InvalidIssuer => formatter.write_str("OTP key URI issuer is invalid"), + Self::MissingSecret => formatter.write_str("OTP key URI secret is missing"), + Self::InvalidSecret => formatter.write_str("OTP key URI secret is not valid Base32"), + Self::InvalidParameter => formatter.write_str("OTP key URI parameter is invalid"), + Self::DuplicateParameter(parameter) => { + write!( + formatter, + "OTP key URI has a duplicate {parameter:?} parameter" + ) + } + Self::IssuerMismatch => { + formatter.write_str("OTP key URI label and parameter issuers do not match") + } + Self::InvalidAlgorithm => formatter.write_str("OTP algorithm is invalid"), + Self::InvalidDigits => formatter.write_str("OTP digit count must be 6 or 8"), + Self::InvalidPeriod => formatter.write_str("TOTP period must be a positive integer"), + Self::InvalidCounter => formatter.write_str("HOTP counter must be an integer"), + Self::MissingCounter => formatter.write_str("HOTP counter is missing"), + Self::UnexpectedCounter => formatter.write_str("TOTP URI cannot contain a counter"), + Self::UnexpectedPeriod => formatter.write_str("HOTP URI cannot contain a period"), + Self::NotTotp => formatter.write_str("the OTP token is not time based"), + Self::NotHotp => formatter.write_str("the OTP token is not counter based"), + Self::CounterOverflow => formatter.write_str("HOTP counter cannot be incremented"), + Self::EmptyInput => formatter.write_str("OTP input may not be empty"), + Self::InvalidInput => formatter.write_str("OTP input must be a single line"), + Self::ConfirmationMismatch => { + formatter.write_str("OTP input confirmation does not match") + } + Self::MissingUri { entry } => write!(formatter, "OTP key URI not found in {entry}"), + Self::AmbiguousUri { entry } => { + write!(formatter, "multiple OTP key URIs found in {entry}") + } + Self::Cancelled => formatter.write_str("OTP mutation was declined"), + Self::MutationLockUnavailable => { + formatter.write_str("OTP mutation serialization is unavailable") + } + Self::ConcurrentModification { entry } => { + write!(formatter, "OTP entry changed concurrently: {entry}") + } + Self::Commit(error) => write!(formatter, "cannot commit OTP mutation: {error}"), + Self::RollbackFailed { + operation, + rollback, + } => write!( + formatter, + "OTP commit failed ({operation}) and repository rollback failed ({rollback})" + ), + } + } +} + +impl Error for OtpError {} + +impl From for OtpError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for OtpError { + fn from(error: CryptoError) -> Self { + Self::Crypto(error) + } +} + +impl From for OtpError { + fn from(error: RecipientPolicyError) -> Self { + Self::RecipientPolicy(error) + } +} + +fn validate_input(input: &[u8]) -> Result<(), OtpError> { + if input.is_empty() { + Err(OtpError::EmptyInput) + } else if input.iter().any(|byte| matches!(byte, b'\n' | b'\r')) { + Err(OtpError::InvalidInput) + } else { + Ok(()) + } +} + +fn parse_entry(input: &str) -> Result { + EntryPath::parse(input.trim_end_matches('/')).map_err(Into::into) +} + +fn set_once( + destination: &mut Option, + value: T, + parameter: OtpParameter, +) -> Result<(), OtpError> { + if destination.replace(value).is_some() { + Err(OtpError::DuplicateParameter(parameter)) + } else { + Ok(()) + } +} + +fn split_label(label: &str) -> Result<(Option, String), OtpError> { + if label.is_empty() || label.chars().any(char::is_control) { + return Err(OtpError::InvalidLabel); + } + if let Some((issuer, account)) = label.split_once(':') { + let account = account.trim_start_matches(' '); + if issuer.is_empty() || account.is_empty() || account.contains(':') { + return Err(OtpError::InvalidLabel); + } + Ok((Some(issuer.to_owned()), account.to_owned())) + } else { + Ok((None, label.to_owned())) + } +} + +fn decode_ascii(value: &str) -> Result { + let decoded = decode_component(value)?; + if decoded.is_ascii() { + Ok(decoded) + } else { + Err(OtpError::InvalidParameter) + } +} + +fn decode_component(value: &str) -> Result { + let bytes = decode_component_bytes(value)?; + let decoded = String::from_utf8(bytes).map_err(|_| OtpError::InvalidParameter)?; + if decoded.chars().any(char::is_control) { + Err(OtpError::InvalidParameter) + } else { + Ok(decoded) + } +} + +fn decode_component_bytes(value: &str) -> Result, OtpError> { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let high = bytes.get(index + 1).and_then(|byte| hex(*byte)); + let low = bytes.get(index + 2).and_then(|byte| hex(*byte)); + let (Some(high), Some(low)) = (high, low) else { + decoded.zeroize(); + return Err(OtpError::InvalidParameter); + }; + decoded.push(high << 4 | low); + index += 3; + } + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + Ok(decoded) +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn decode_secret(raw: &str) -> Result { + let mut encoded = decode_component_bytes(raw)?; + for byte in &mut encoded { + byte.make_ascii_uppercase(); + } + if let Some(padding_start) = encoded.iter().position(|byte| *byte == b'=') { + let padding = encoded.len() - padding_start; + let expected = match padding_start % 8 { + 2 => 6, + 4 => 4, + 5 => 3, + 7 => 1, + _ => 0, + }; + if encoded[padding_start..].iter().any(|byte| *byte != b'=') + || encoded.len() % 8 != 0 + || padding != expected + { + encoded.zeroize(); + return Err(OtpError::InvalidSecret); + } + encoded.truncate(padding_start); + } + if encoded.is_empty() { + encoded.zeroize(); + return Err(OtpError::InvalidSecret); + } + let decoded = BASE32_NOPAD + .decode(&encoded) + .map_err(|_| OtpError::InvalidSecret); + encoded.zeroize(); + let decoded = decoded?; + if decoded.is_empty() { + Err(OtpError::InvalidSecret) + } else { + Ok(SecretBytes::new(decoded)) + } +} + +fn parse_number(value: &str) -> Option { + if value.bytes().all(|byte| byte.is_ascii_digit()) { + value.parse().ok() + } else { + None + } +} + +fn build_secret_uri( + secret: SecretBytes, + issuer: Option<&str>, + account: Option<&str>, +) -> Result { + let secret_text = str::from_utf8(secret.expose()).map_err(|_| OtpError::InvalidSecret)?; + decode_secret(secret_text)?; + let issuer = issuer.filter(|value| !value.is_empty()); + let account = account.filter(|value| !value.is_empty()); + if issuer.is_none() && account.is_none() { + return Err(OtpError::MissingAccount); + } + let encoded_issuer = issuer.map(percent_encode); + let encoded_account = account.map(percent_encode); + let label = match (&encoded_issuer, &encoded_account) { + (Some(issuer), Some(account)) => format!("{issuer}:{account}"), + (Some(issuer), None) => issuer.clone(), + (None, Some(account)) => account.clone(), + (None, None) => unreachable!("an issuer or account was required"), + }; + let mut uri = format!("otpauth://totp/{label}?secret={secret_text}"); + if let Some(issuer) = encoded_issuer { + uri.push_str("&issuer="); + uri.push_str(&issuer); + } + OtpUri::parse(SecretBytes::new(uri.into_bytes())) +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'~' | b'_' | b'-' => { + encoded.push(char::from(*byte)); + } + b' ' => encoded.push('+'), + byte => { + use fmt::Write as _; + write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); + } + } + } + encoded +} + +fn hmac_digest(key: &[u8], message: &[u8]) -> Result, OtpError> +where + M: hmac::digest::Mac + hmac::digest::KeyInit, +{ + let mut mac = + ::new_from_slice(key).map_err(|_| OtpError::InvalidSecret)?; + mac.update(message); + let mut output = mac.finalize().into_bytes(); + let digest = output.to_vec(); + output.fill(0); + Ok(digest) +} + +fn find_uri( + plaintext: &SecretBytes, + entry: &EntryPath, +) -> Result, OtpUri)>, OtpError> { + let mut found = None; + let mut start = 0; + while start < plaintext.expose().len() { + let tail = &plaintext.expose()[start..]; + let line_length = tail + .iter() + .position(|byte| *byte == b'\n') + .unwrap_or(tail.len()); + let end = start + line_length; + let line = &plaintext.expose()[start..end]; + if line.starts_with(b"otpauth://") { + let uri = OtpUri::parse(SecretBytes::new(line.to_vec()))?; + if found.is_some() { + return Err(OtpError::AmbiguousUri { + entry: entry.clone(), + }); + } + found = Some((start..end, uri)); + } + if end == plaintext.expose().len() { + break; + } + start = end + 1; + } + Ok(found) +} diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs index c261ee0..a97b8b1 100644 --- a/crates/storage/src/write.rs +++ b/crates/storage/src/write.rs @@ -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()) { diff --git a/crates/storage/tests/otp.rs b/crates/storage/tests/otp.rs new file mode 100644 index 0000000..c3b98fd --- /dev/null +++ b/crates/storage/tests/otp.rs @@ -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>); + +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 { + self.0 + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[derive(Default)] +struct Committer { + changes: Vec, + 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::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> { + Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, provider)?) +} diff --git a/docs/otp.md b/docs/otp.md new file mode 100644 index 0000000..b5d347b --- /dev/null +++ b/docs/otp.md @@ -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 .` 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.