Implement pass-otp compatible TOTP and HOTP
This commit is contained in:
30
Cargo.lock
generated
30
Cargo.lock
generated
@@ -1553,6 +1553,12 @@ dependencies = [
|
|||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbl"
|
name = "dbl"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3966,10 +3972,12 @@ dependencies = [
|
|||||||
"cap-std",
|
"cap-std",
|
||||||
"cap-tempfile",
|
"cap-tempfile",
|
||||||
"clap",
|
"clap",
|
||||||
|
"data-encoding",
|
||||||
"flate2",
|
"flate2",
|
||||||
"gix",
|
"gix",
|
||||||
"gix-config",
|
"gix-config",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hmac",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"pgp",
|
"pgp",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
@@ -4008,6 +4016,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"ironstorage",
|
"ironstorage",
|
||||||
|
"rpassword",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6180,6 +6189,17 @@ version = "0.20.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
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]]
|
[[package]]
|
||||||
name = "rqrr"
|
name = "rqrr"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -6210,6 +6230,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ cap-tempfile = "4.0"
|
|||||||
clap = { version = "4.6", features = ["derive"] }
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
ctrlc = "3.5"
|
ctrlc = "3.5"
|
||||||
|
data-encoding = "2.9"
|
||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
|
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
|
||||||
gix-config = "0.59"
|
gix-config = "0.59"
|
||||||
|
hmac = "0.12"
|
||||||
iced = "0.14"
|
iced = "0.14"
|
||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
keyring-core = "1.0"
|
keyring-core = "1.0"
|
||||||
@@ -33,11 +35,13 @@ rand = "0.8"
|
|||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
||||||
rqrr = { version = "0.10", default-features = false }
|
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"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
security-framework = "3.7"
|
security-framework = "3.7"
|
||||||
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
shlex = "1.3"
|
shlex = "1.3"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
uniffi = "0.32"
|
uniffi = "0.32"
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ bounded caching are documented in
|
|||||||
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
|
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
|
||||||
Clipboard cleanup/race behavior and platform-neutral QR rendering are
|
Clipboard cleanup/race behavior and platform-neutral QR rendering are
|
||||||
documented in [`docs/presentation.md`](docs/presentation.md).
|
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
|
The capability-scoped password-store layout and atomic mutation guarantees are
|
||||||
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
||||||
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ctrlc.workspace = true
|
ctrlc.workspace = true
|
||||||
ironstorage.workspace = true
|
ironstorage.workspace = true
|
||||||
|
rpassword.workspace = true
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -5,26 +5,27 @@ use std::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
fmt,
|
fmt,
|
||||||
io::Write,
|
io::{BufRead as _, IsTerminal as _, Write},
|
||||||
path::Path,
|
path::Path,
|
||||||
process::ExitCode,
|
process::ExitCode,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{
|
command::{
|
||||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
||||||
GeneratedPresentation, GitRequest, HelpTopic, OtpRequest, help_text, otp_version_text,
|
GeneratedPresentation, GitRequest, HelpTopic, InputPlan, OtpInputSource, OtpRequest,
|
||||||
parse_from, version_text,
|
OtpUriPresentation, help_text, otp_version_text, parse_from, version_text,
|
||||||
},
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
crypto::KeyStore,
|
crypto::KeyStore,
|
||||||
generate::{GeneratorConfig, PasswordGenerator},
|
generate::{GeneratorConfig, PasswordGenerator},
|
||||||
git::{GitIdentity, GitRepository},
|
git::{GitIdentity, GitRepository},
|
||||||
|
otp::{OtpError, OtpInput, OtpService},
|
||||||
presentation::{
|
presentation::{
|
||||||
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
|
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
|
||||||
},
|
},
|
||||||
@@ -101,6 +102,10 @@ where
|
|||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
Ok(EXIT_SUCCESS)
|
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()) {
|
request => match Config::load(invocation.config()) {
|
||||||
Ok(config) if needs_secret_store(request) => {
|
Ok(config) if needs_secret_store(request) => {
|
||||||
let mut secrets = match NativeSecretStore::system(
|
let mut secrets = match NativeSecretStore::system(
|
||||||
@@ -141,6 +146,12 @@ fn needs_secret_store(request: &CommandRequest) -> bool {
|
|||||||
request,
|
request,
|
||||||
CommandRequest::Show(_)
|
CommandRequest::Show(_)
|
||||||
| CommandRequest::Generate(_)
|
| CommandRequest::Generate(_)
|
||||||
|
| CommandRequest::Otp(
|
||||||
|
OtpRequest::Code(_)
|
||||||
|
| OtpRequest::Insert(_)
|
||||||
|
| OtpRequest::Append(_)
|
||||||
|
| OtpRequest::Uri(_),
|
||||||
|
)
|
||||||
| CommandRequest::Git(GitRequest::Fetch { .. })
|
| CommandRequest::Git(GitRequest::Fetch { .. })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,16 +163,19 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
|||||||
stdout: &mut O,
|
stdout: &mut O,
|
||||||
stderr: &mut E,
|
stderr: &mut E,
|
||||||
) -> Result<u8, ()> {
|
) -> Result<u8, ()> {
|
||||||
execute_secure_with(
|
execute_secure_with_services(
|
||||||
config,
|
config,
|
||||||
request,
|
request,
|
||||||
secrets,
|
secrets,
|
||||||
&mut NativePresentation,
|
&mut NativePresentation,
|
||||||
|
&mut NativeOtpInteraction,
|
||||||
|
current_unix_seconds,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
|
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
request: &CommandRequest,
|
request: &CommandRequest,
|
||||||
@@ -169,6 +183,35 @@ fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: W
|
|||||||
presentation: &mut P,
|
presentation: &mut P,
|
||||||
stdout: &mut O,
|
stdout: &mut O,
|
||||||
stderr: &mut E,
|
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, ()> {
|
) -> Result<u8, ()> {
|
||||||
match request {
|
match request {
|
||||||
CommandRequest::Show(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),
|
Err(error) => operation_error(stderr, error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CommandRequest::Otp(request) => execute_otp(
|
||||||
|
config,
|
||||||
|
request,
|
||||||
|
secrets,
|
||||||
|
presentation,
|
||||||
|
interaction,
|
||||||
|
clock,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
CommandRequest::Git(GitRequest::Fetch { remote }) => {
|
CommandRequest::Git(GitRequest::Fetch { remote }) => {
|
||||||
let configured = match select_remote(config, remote.as_deref()) {
|
let configured = match select_remote(config, remote.as_deref()) {
|
||||||
Some(configured) => configured,
|
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 {
|
enum GenerationCommitter {
|
||||||
Git(Box<GitRepository>),
|
Git(Box<GitRepository>),
|
||||||
None(NoGitEntryCommitter),
|
None(NoGitEntryCommitter),
|
||||||
@@ -452,7 +837,7 @@ fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> R
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, VecDeque},
|
||||||
error::Error,
|
error::Error,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
fs,
|
fs,
|
||||||
@@ -461,22 +846,26 @@ mod tests {
|
|||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{
|
command::{
|
||||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
|
CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
|
||||||
GenerateRequest, GeneratedPresentation, Presentation, ShowRequest,
|
GenerateRequest, GeneratedPresentation, InputPlan, OtpAppendRequest, OtpCodeRequest,
|
||||||
|
OtpInputSource, OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
|
||||||
|
Presentation, ShowRequest,
|
||||||
},
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
git::GitCredentialProvider as _,
|
git::GitCredentialProvider as _,
|
||||||
|
otp::OtpInput,
|
||||||
presentation::{ClipboardTimeout, QrMatrix},
|
presentation::{ClipboardTimeout, QrMatrix},
|
||||||
repository::SecretBytes,
|
repository::{EntryPath, Repository, SecretBytes},
|
||||||
secret_store::{
|
secret_store::{
|
||||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||||
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||||
},
|
},
|
||||||
|
write::OverwriteDecision,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CliPresentation, PresentationFailure, execute_secure, execute_secure_with, run_with,
|
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_secure,
|
||||||
wait_for_clipboard,
|
execute_secure_with, execute_secure_with_services, run_with, wait_for_clipboard,
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error>>;
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
@@ -490,6 +879,42 @@ mod tests {
|
|||||||
qr: Vec<Vec<u8>>,
|
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 {
|
impl CliPresentation for MemoryPresentation {
|
||||||
fn clipboard(
|
fn clipboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -856,6 +1281,282 @@ mod tests {
|
|||||||
Ok(())
|
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> {
|
fn fixture_secrets(fingerprint: &str) -> Result<SecretStore<MemoryBackend>, SecretStoreError> {
|
||||||
let secrets = SecretStore::new(
|
let secrets = SecretStore::new(
|
||||||
MemoryBackend::default(),
|
MemoryBackend::default(),
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ publish = false
|
|||||||
cap-std.workspace = true
|
cap-std.workspace = true
|
||||||
cap-tempfile.workspace = true
|
cap-tempfile.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
gix.workspace = true
|
gix.workspace = true
|
||||||
gix-config.workspace = true
|
gix-config.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
keyring-core.workspace = true
|
keyring-core.workspace = true
|
||||||
pgp.workspace = true
|
pgp.workspace = true
|
||||||
qrcode.workspace = true
|
qrcode.workspace = true
|
||||||
@@ -21,6 +23,7 @@ regex.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
sha1.workspace = true
|
sha1.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
shlex.workspace = true
|
shlex.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
@@ -46,6 +49,5 @@ hex = "0.4"
|
|||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
rqrr.workspace = true
|
rqrr.workspace = true
|
||||||
rustix = { version = "1.1", features = ["fs"] }
|
rustix = { version = "1.1", features = ["fs"] }
|
||||||
sha2 = "0.10"
|
|
||||||
smallvec = "1.15"
|
smallvec = "1.15"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod crypto;
|
|||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod mutation;
|
pub mod mutation;
|
||||||
|
pub mod otp;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
pub mod read;
|
pub mod read;
|
||||||
pub mod recipient;
|
pub mod recipient;
|
||||||
|
|||||||
1083
crates/storage/src/otp.rs
Normal file
1083
crates/storage/src/otp.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,14 @@ pub struct EntryCommit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EntryCommit {
|
impl EntryCommit {
|
||||||
|
pub(crate) fn new(path: EntryPath, action: EntryAction, message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
action,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &EntryPath {
|
pub fn path(&self) -> &EntryPath {
|
||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
@@ -205,11 +213,11 @@ impl<'a> VaultWriter<'a> {
|
|||||||
.keys
|
.keys
|
||||||
.encrypt(contents.into_secret(), recipients.recipients())?;
|
.encrypt(contents.into_secret(), recipients.recipients())?;
|
||||||
self.repository.write_entry(&path, &ciphertext)?;
|
self.repository.write_entry(&path, &ciphertext)?;
|
||||||
let change = EntryCommit {
|
let change = EntryCommit::new(
|
||||||
path: path.clone(),
|
path.clone(),
|
||||||
action: EntryAction::Insert,
|
EntryAction::Insert,
|
||||||
message: format!("Add given password for {path} to store."),
|
format!("Add given password for {path} to store."),
|
||||||
};
|
);
|
||||||
if let Err(error) = committer.commit(&change) {
|
if let Err(error) = committer.commit(&change) {
|
||||||
if let Err(rollback) = self.restore(&path, original.as_ref()) {
|
if let Err(rollback) = self.restore(&path, original.as_ref()) {
|
||||||
return Err(WriteError::RollbackFailed {
|
return Err(WriteError::RollbackFailed {
|
||||||
@@ -270,11 +278,11 @@ impl<'a> VaultWriter<'a> {
|
|||||||
.resolve_for_entry(path, signing)?;
|
.resolve_for_entry(path, signing)?;
|
||||||
let ciphertext = self.keys.encrypt(contents, recipients.recipients())?;
|
let ciphertext = self.keys.encrypt(contents, recipients.recipients())?;
|
||||||
self.repository.write_entry(path, &ciphertext)?;
|
self.repository.write_entry(path, &ciphertext)?;
|
||||||
let change = EntryCommit {
|
let change = EntryCommit::new(
|
||||||
path: path.clone(),
|
path.clone(),
|
||||||
action: EntryAction::Insert,
|
EntryAction::Insert,
|
||||||
message: format!("Add generated password for {path}."),
|
format!("Add generated password for {path}."),
|
||||||
};
|
);
|
||||||
if let Err(error) = committer.commit(&change) {
|
if let Err(error) = committer.commit(&change) {
|
||||||
if let Err(rollback) = self.restore(path, original.as_ref()) {
|
if let Err(rollback) = self.restore(path, original.as_ref()) {
|
||||||
return Err(WriteError::RollbackFailed {
|
return Err(WriteError::RollbackFailed {
|
||||||
@@ -320,11 +328,11 @@ impl<'a> VaultWriter<'a> {
|
|||||||
.resolve_for_entry(&session.path, signing)?;
|
.resolve_for_entry(&session.path, signing)?;
|
||||||
let ciphertext = self.keys.encrypt(replacement, recipients.recipients())?;
|
let ciphertext = self.keys.encrypt(replacement, recipients.recipients())?;
|
||||||
self.repository.write_entry(&session.path, &ciphertext)?;
|
self.repository.write_entry(&session.path, &ciphertext)?;
|
||||||
let change = EntryCommit {
|
let change = EntryCommit::new(
|
||||||
path: session.path.clone(),
|
session.path.clone(),
|
||||||
action: EntryAction::Edit,
|
EntryAction::Edit,
|
||||||
message: format!("Edit password for {} using {}.", session.path, editor_name),
|
format!("Edit password for {} using {}.", session.path, editor_name),
|
||||||
};
|
);
|
||||||
if let Err(error) = committer.commit(&change) {
|
if let Err(error) = committer.commit(&change) {
|
||||||
if let Err(rollback) = self.restore(&session.path, session.original_ciphertext.as_ref())
|
if let Err(rollback) = self.restore(&session.path, session.original_ciphertext.as_ref())
|
||||||
{
|
{
|
||||||
|
|||||||
471
crates/storage/tests/otp.rs
Normal file
471
crates/storage/tests/otp.rs
Normal 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
50
docs/otp.md
Normal 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.
|
||||||
Reference in New Issue
Block a user