diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c00d591..048f28a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,3 +98,19 @@ jobs: target/debug/cotp* target/aarch64-unknown-linux-gnu/debug/cotp* if-no-files-found: error + + headless: + name: Headless build without desktop clipboard dependencies + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: headless + - run: cargo clippy --locked --no-default-features -- -D warnings + - run: cargo test --locked --no-default-features diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e43b70e..ef8646b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -88,6 +88,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - name: Checkout sources uses: actions/checkout@v7 @@ -128,6 +130,15 @@ jobs: fi done + - name: Create release checksums + working-directory: dist + run: sha256sum *.tar.xz *.zip > SHA256SUMS + + - name: Attest release artifacts + uses: actions/attest-build-provenance@v3 + with: + subject-path: dist/* + - name: Upload binaries to release uses: svenstaro/upload-release-action@v2 with: diff --git a/Cargo.lock b/Cargo.lock index fd00ddd5..1c848259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.5", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -33,7 +44,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", ] @@ -45,8 +56,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.9.3", + "cipher 0.5.2", "ctr", "ctutils", "ghash", @@ -132,6 +143,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -167,6 +187,123 @@ dependencies = [ "tempfile", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic" version = "0.6.1" @@ -176,6 +313,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -215,7 +358,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -224,6 +376,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -270,6 +432,52 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bstr" version = "1.13.1" @@ -299,6 +507,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -320,6 +534,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.9.0" @@ -349,8 +572,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -361,10 +585,20 @@ checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.5.2", "poly1305", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -373,7 +607,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.5", ] [[package]] @@ -398,6 +632,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -416,6 +659,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "3.1.1" @@ -479,6 +732,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -526,6 +788,38 @@ dependencies = [ "x11-clipboard", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cotp" version = "1.11.0" @@ -536,6 +830,8 @@ dependencies = [ "base64 0.23.1", "chacha20poly1305", "clap", + "clap_complete", + "clap_mangen", "color-eyre", "copypasta-ext", "crossterm", @@ -546,18 +842,22 @@ dependencies = [ "getrandom 0.4.3", "globset", "hex", - "hmac", + "hmac 0.13.0", + "image", + "keyring", "md-5", "predicates", + "proptest", "prost", "qrcode", "ratatui", "rpassword", + "rqrr", "rust-argon2", "scrypt", "serde", "serde_json", - "sha1", + "sha1 0.11.0", "sha2 0.11.1", "tempfile", "test-case", @@ -565,6 +865,7 @@ dependencies = [ "url", "urlencoding", "zeroize", + "zip", ] [[package]] @@ -591,6 +892,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -686,7 +996,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -773,6 +1083,36 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "deltae" version = "0.3.2" @@ -785,6 +1125,17 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -852,6 +1203,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -928,6 +1280,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -940,6 +1298,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -965,6 +1344,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.14" @@ -982,7 +1381,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] @@ -992,6 +1391,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1021,6 +1429,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1042,6 +1461,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1057,24 +1491,90 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "futures-util" -version = "0.3.34" +name = "g2p" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "g2gen", + "g2poly", ] +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -1095,6 +1595,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1158,6 +1669,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1175,12 +1697,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -1344,6 +1890,9 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", + "png", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1352,6 +1901,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.8" @@ -1361,6 +1920,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.5" @@ -1421,11 +1990,28 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", "portable-atomic", "thiserror 2.0.21", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -1450,6 +2036,16 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1523,13 +2119,22 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru" version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1613,6 +2218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1659,6 +2265,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset 0.9.1", ] [[package]] @@ -1690,6 +2297,39 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1707,6 +2347,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1775,6 +2445,53 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1790,6 +2507,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owo-colors" version = "4.4.0" @@ -1829,6 +2556,12 @@ dependencies = [ "libm", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1859,7 +2592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", - "hmac", + "hmac 0.13.0", ] [[package]] @@ -1939,7 +2672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -1970,12 +2703,50 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.9.1" @@ -2018,6 +2789,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -2048,6 +2828,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2057,6 +2846,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand 0.10.3", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.14.4" @@ -2104,6 +2913,12 @@ dependencies = [ "image", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -2131,6 +2946,28 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core 0.6.4", ] @@ -2139,6 +2976,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2146,6 +2986,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -2171,10 +3020,10 @@ dependencies = [ "bitflags 2.13.2", "compact_str", "critical-section", - "hashbrown", + "hashbrown 0.17.1", "itertools", "kasuari", - "lru", + "lru 0.18.5", "palette", "serde", "strum", @@ -2234,7 +3083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.2", - "hashbrown", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -2295,6 +3144,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rpassword" version = "7.5.4" @@ -2306,6 +3161,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rqrr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +dependencies = [ + "g2p", + "lru 0.16.4", +] + [[package]] name = "rtoolbox" version = "0.0.6" @@ -2375,6 +3240,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2388,7 +3265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -2424,6 +3301,61 @@ dependencies = [ "sha2 0.11.1", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2473,6 +3405,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2552,6 +3506,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "siphasher" version = "1.0.4" @@ -2650,6 +3610,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2902,6 +3868,36 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2909,9 +3905,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2966,6 +3974,23 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -3053,6 +4078,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3350,13 +4381,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3374,14 +4423,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3390,48 +4456,105 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3481,6 +4604,16 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xml-rs" version = "0.8.29" @@ -3510,6 +4643,82 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1 0.10.7", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3584,8 +4793,92 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index bdc09498..ae60f45a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,14 +26,20 @@ panic = "abort" debug = false strip = "symbols" +[features] +default = ["desktop-clipboard"] +desktop-clipboard = ["dep:copypasta-ext"] + [dependencies] serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" dirs = "7.0.0" rpassword = "7.5.4" data-encoding = "2.11.1" -copypasta-ext = "0.4.4" +copypasta-ext = { version = "0.4.4", optional = true } zeroize = { version = "1.9.1", features = ["zeroize_derive"] } +clap_complete = "4" +clap_mangen = "0.2" clap = { version = "4.6.7", features = ["derive"] } hmac = "0.13.0" sha1 = "0.11.0" @@ -58,9 +64,25 @@ globset = "0.4.20" prost = "0.14.4" tui-input = { version = "0.15.5", default-features = false, features = ["crossterm"] } tempfile = "3.27.0" +rqrr = { version = "0.10", default-features = false } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +zip = { version = "4", default-features = false, features = ["deflate"] } [dev-dependencies] +proptest = "1" assert_cmd = "2.2.2" assert_fs = "1.1.4" predicates = "3.1.4" test-case = "3.4.0" + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust", "vendored"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] } + +[target.'cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))'.dependencies] +keyring = { version = "3.6.3", default-features = false } diff --git a/README.md b/README.md index 30a0fe20..dd196dc4 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,89 @@ use `cotp list` or `cotp list --json` when piping output. | `a` / `e` | Add / edit an account | | `Space` | Show or close the selected account's QR code | | `h` | Hide / reveal codes | +| `f` | Toggle the selected favorite | | `+` / `-` | Increase / decrease an HOTP counter | | `d` or `Delete` | Delete an account, with confirmation | | `?` or `i` | Open the shortcuts and actions menu | | `Esc` | Close the current screen, finish searching, clear a filter, or quit | +| `Ctrl+L` | Lock immediately; enter the vault password to restore pending edits | +| `Ctrl+S` | Save pending account changes without quitting | | `q`, `Ctrl+C`, `Ctrl+D` | Quit; account changes prompt you to save or discard | +### Stable account selection + +Every account has a persistent random ID, available in `cotp list --json`. +Existing vaults upgrade automatically while preserving their secrets. All CLI +indices start at 1 and match the list display. Issuer and label selectors use +case-insensitive glob matching; multiple filters must all match. Commands fail +when the selection is ambiguous instead of choosing the first account. + +```sh +cotp add --issuer GitHub --label you --alias work-github +cotp extract --alias work-github +cotp extract --issuer 'Git*' --label you +cotp edit --alias work-github --set-alias github --label new-name +cotp edit --match-issuer GitHub --match-label you --digits 8 +cotp delete --id +``` + +On `edit`, `--issuer` and `--label` still set new values; use `--match-issuer` +and `--match-label` to select by those fields. Aliases are unique ignoring case; +`edit --clear-alias` removes an alias. Account IDs survive renaming and TUI edits. + +### Tags, favorites, and ordering + +Add metadata with `add --tag work --tag mail --favorite --order 1`. +Tags match without case sensitivity; repeat `--tag` on `list`, `code`, `extract`, +`delete`, or `edit` to require all tags. `--favorite` restricts the selection to favorites. +Filtered list indices retain their positions in the full list. + +```sh +cotp edit --alias github --add-tag work --remove-tag personal --set-favorite true +cotp edit --alias github --order -1 +cotp list --tag work --favorite --metadata-only --json +cotp code --tag work --favorite +cotp edit --alias github --clear-tags --clear-order +``` + +`edit --set-favorite false` removes a favorite. Explicit order values sort first, +lowest to highest; other accounts sort by issuer, label, then persistent ID. +Favorites keep their existing positions. The dashboard displays favorites, aliases, +and tags; press `f` to toggle the selected favorite, then save or discard when quitting. +Dashboard and CLI queries support `tag:work`, `is:favorite`, and combinations such +as `tag:work is:favorite github`. Ordinary query text also matches aliases and IDs. + +### Codes and scripts + +`cotp code QUERY` selects one account by ID, alias, or issuer/label text. +Use `--json` for an object with `id`, `issuer`, `label`, `code`, `expires_at` +(Unix seconds), and `remaining` (whole seconds). HOTP expiry fields are `null`. +`--min-validity 10s` waits for a fresh interval when needed; the requested validity +must be shorter than the account period. `--watch` emits one code per interval, +flushing each line for consumers. `--copy --quiet` copies without printing a code. + +HOTP `code` and legacy `extract` persist the next counter before releasing a code. +`code --preview` and `list` show the current counter without advancing it. +`list --metadata-only --json` lists account identities without generating codes. + +For unattended use, pass `--no-input` to reject every interactive prompt. +`--password-stdin` reads one password line; on Unix, `--password-fd 3` keeps stdin +available for a secret or import. Passwords are never command-line values. +`init` creates a vault with a supplied password of at least eight characters and +refuses to overwrite an existing vault. Imports require `--yes` to apply without +confirmation; deletion uses `--yes`. Password rotation accepts `passwd --new-password-fd`. + +```sh +cotp --no-input --password-fd 3 init 3 "Export accounts", select the accounts and let it generate the QR code(s). Scan each QR code with any scanner and save the resulting `otpauth-migration://...` URI(s), one per line, into a text file. | No | `--google-authenticator` | @@ -187,7 +268,10 @@ To terminate the import: # Configuration -By default database is located in `$HOME/.cotp/db.cotp`. If you want to use a different path, you can use `COTP_DB_PATH` environment variable or use the `--database-path` argument. +Release builds use the platform data directory (`$XDG_DATA_HOME/cotp/db.cotp` on Linux), +with automatic migration from the legacy `$HOME/.cotp/db.cotp`. An existing `./db.cotp` +enables portable mode; debug builds always use that local path. Override the location +with `COTP_DB_PATH`, `--database-path`, or a named vault profile. The first can be configured in shell configuration files, the second in package managers where the configuration of environment variables is not allowed, like Scoop. These are examples of how to do this in bash: @@ -199,10 +283,15 @@ or $ cotp --database-path /home/user/.local/custom-folder/db.cotp ``` -# Planned features +# Feature guides + +- [Configuration, named profiles, shell completions, manuals, and headless builds](docs/configuration.md) +- [QR enrollment, repeatable imports, native migration formats, and encrypted Aegis exports](docs/imports.md) +- [Encrypted snapshot exchange and explicit conflict resolution](docs/exchange.md) +- [YubiKey OATH accounts and touch-required codes](docs/hardware.md) +- [Security model and release provenance](SECURITY.md), [vault format](docs/vault-format.md) -Currently, there is not any planned feature. If you need something new that could improve the software feel free to open -an issue. +Run `cotp --help` and `cotp COMMAND --help` for the complete command reference. # Contribution @@ -231,3 +320,39 @@ python3 ci/render_ui_demo.py The recording uses the actual dashboard widgets and keyboard/mouse handlers, with synthetic accounts and masked secrets. Tutorial captions appear below the application. + +### Encrypted backups and concurrent use + +`cotp backup create ~/backups/cotp --keep 10` creates a private encrypted vault +backup and optionally retains the newest ten cotp backup files in that directory. +`cotp backup list ~/backups/cotp` lists backups without unlocking the vault. +Use `cotp backup verify FILE` to authenticate and validate a backup with its +original password. `cotp -d /path/to/vault backup restore FILE` restores a verified +backup; `--force` is required to replace an existing vault. The original backup +password remains in effect. Restoring an older backup can roll back HOTP counters; +resynchronize affected accounts with the service before consuming codes. +`--password-stdin` also works for backup verification and restoration. + +Vault writes take a cross-process lock and reject stale snapshots, so an open +dashboard cannot silently overwrite another command's changes. If a save reports +that another process changed the vault, reopen it before retrying your edits. +Copying an HOTP code commits the incremented counter before releasing the code; +if the dashboard has unsaved edits, save those first. Displayed HOTP codes are +previews. A clipboard failure after successful persistence still consumes the +counter; this prevents a previously released code from being issued again. + + +The dashboard locks after 300 seconds without keyboard, mouse, or paste activity. +`cotp lock` requests a lock in every dashboard using the selected vault, without +requesting a password. Locking encrypts pending account edits and unfinished forms +in memory and drops the decrypted database and vault key. Enter the password to +resume; locking neither saves nor discards pending work. + +Native clipboard copies expire after 30 seconds, provided the clipboard still +contains the copied value. A newer clipboard value is left intact. This comparison +is best effort: operating-system clipboards do not offer an atomic compare-and-clear. +SSH/OSC52 copies cannot be read back reliably and are never automatically cleared; +the dashboard reports this limitation. Set `idle_timeout_seconds` or +`clipboard_seconds` in the JSON configuration to change these defaults; `0` +disables the respective timer. Clipboard expiration continues after a CLI command +exits, using a helper process that receives the copied content through stdin. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..bef54453 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security + +Report suspected vulnerabilities privately to commoncargo@tutanota.com. Include the +cotp version, operating system, reproduction using synthetic data, and expected +behavior. Never attach a real vault, password, OTP secret, URI, or backup code. + +## Threat model + +The encrypted vault protects secrets at rest when the password is unknown. It does +not protect an unlocked session from malware running as the same user, a compromised +terminal, clipboard history, screen capture, or privileged memory inspection. OTP +codes and QR/URI exports are sensitive. The application makes no claim of a completed +independent security audit. + +Password-based operation is offline. Optional integrations have their own trust +boundaries and require explicit configuration. Back up the encrypted vault before +upgrading; losing the password and all usable backups loses access to its secrets. + +See [vault format](docs/vault-format.md) for the encryption and versioning contract. +The test suite covers all RFC 4226 Appendix D and RFC 6238 Appendix B vectors and +property-based malformed-envelope inputs. Run `cargo test malformed_tests` for the +latter; `PROPTEST_CASES=10000` increases the case count. Never run experimental fuzzing against a real vault. + +## Verify release provenance + +Releases produced by the updated Deploy workflow include SHA256SUMS and GitHub +artifact attestations signed using the workflow's OIDC identity. Download an archive +and check it with: + +```sh +sha256sum --check SHA256SUMS --ignore-missing +gh attestation verify cotp-VERSION-PLATFORM.tar.xz --repo replydev/cotp +``` + +Attestations bind the artifact digest to the repository/workflow that built it; +they do not constitute a security audit. Older releases may not have attestations. diff --git a/converters/authy.py b/converters/authy.py index 12074895..72d3163a 100644 --- a/converters/authy.py +++ b/converters/authy.py @@ -34,4 +34,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/freeotp.py b/converters/freeotp.py index 742ddc87..d791861e 100644 --- a/converters/freeotp.py +++ b/converters/freeotp.py @@ -48,4 +48,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/gauth.py b/converters/gauth.py index ff02c9c0..4e0ac95f 100644 --- a/converters/gauth.py +++ b/converters/gauth.py @@ -36,4 +36,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/mauth.py b/converters/mauth.py index 42838b4d..8f3ccd16 100644 --- a/converters/mauth.py +++ b/converters/mauth.py @@ -42,4 +42,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..c5e33c4d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,84 @@ +# Configuration and shell integration + +Run `cotp completions bash`, `cotp completions zsh`, `cotp completions fish`, or +`cotp completions powershell` and install the output using your shell's completion +loader. `cotp man` writes a roff manual to stdout; `cotp man --directory ./man` +generates pages for all commands. Neither command needs a vault or password. + +Settings are JSON at the platform configuration directory under `cotp/config.json`. +Use `--config FILE` or `COTP_CONFIG` to choose another file. Unknown settings are +errors to catch spelling mistakes. Paths are literal: expand `~` in your shell or +use absolute paths in JSON. Relative vault paths resolve from the working directory. + +```json +{ + "profiles": {"personal": "/home/me/.local/share/cotp/personal.cotp", "work": "/home/me/.local/share/cotp/work.cotp"}, + "default_profile": "personal", + "hide_codes": true, + "idle_timeout_seconds": 300, + "clipboard_seconds": 30, + "keychain": false +} +``` + +Choose a profile with `cotp --profile work` or `COTP_PROFILE=work cotp`. +Vault path precedence is `--database-path`, `COTP_DB_PATH`, selected profile, +configuration `database_path`, then the existing portable/platform default. +Explicit unknown profile names fail instead of opening the wrong vault. +Zero disables inactivity locking or clipboard expiration. Keychain integration is +opt-in; the password prompt remains the default. + +For a build without native desktop clipboard dependencies: + +```sh +cargo build --release --locked --no-default-features +``` + +This build retains the TUI and SSH OSC52 clipboard support. Local desktop copying +requires the default `desktop-clipboard` feature. + +## Optional OS credential store unlock + +By default cotp asks for your vault password. Set `"keychain": true` in your +configuration to permit initial unlock using an explicitly saved password: + +```sh +cotp keychain remember +cotp keychain status +cotp keychain forget +``` + +`remember` requires the current vault password through a terminal prompt, +`--password-stdin`, or `--password-fd`. It authenticates the encrypted vault before +saving anything. Passwords are never accepted through command arguments or +environment variables. Enrollment is explicit: enabling the setting, creating a +vault, or typing a password during normal unlock never saves a credential. +`remember` and `forget` work even while automatic keychain unlock is disabled. +`status` reports presence and whether automatic unlock is enabled; it never prints +the stored password. `forget` deletes only the saved OS credential. + +An explicit `--password-stdin` or `--password-fd` always takes precedence over the +saved password. Missing credentials fall back to the normal terminal prompt; +`--no-input` rejects that prompt. Credential store errors are reported and stop +unlock, rather than falling back silently. A stale password also fails normal vault +authentication: after changing your password, run `keychain remember` again or +`keychain forget`. New vault creation still requires an explicit password or the +usual verified prompt. + +Credentials use service `cotp.vault-password.v1` and a SHA-256 hash of the canonical +vault path as the account identifier. Symlinks to the same vault share a credential; +distinct vault paths have distinct credentials. Moving a vault requires enrolling +its new path. Forget the old path before moving it. The OS credential store protects +the saved password under your operating system account; your vault file remains +encrypted normally. Inactivity locking clears the running session but preserves +the saved credential, and session unlock still requires entering the vault password. + +Supported native stores are Linux Secret Service over D-Bus (for example GNOME +Keyring or KWallet with Secret Service enabled), macOS Keychain, and Windows +Credential Manager. Linux needs a running, unlocked Secret Service session; headless +systems can keep using stdin/file descriptor passwords. Other platforms explicitly +reject keychain commands. The `keyring` 3.6 API is compiled with target-specific +`sync-secret-service` + `crypto-rust` + `vendored`, `apple-native`, or `windows-native` +features; Linux includes D-Bus client code and encrypts secrets during bus transfer. +See the [keyring native store documentation](https://docs.rs/keyring/3.6.3/keyring/#credential-store-features) +and [entry API](https://docs.rs/keyring/3.6.3/keyring/struct.Entry.html). diff --git a/docs/exchange.md b/docs/exchange.md new file mode 100644 index 00000000..affa1a34 --- /dev/null +++ b/docs/exchange.md @@ -0,0 +1,29 @@ +# Exchanging encrypted vaults + +Use `cotp backup create DIRECTORY` to produce an encrypted snapshot. Transfer that +file using your preferred file transport, then authenticate it on the destination: + +```sh +cotp backup verify remote.cotp-backup +cotp merge remote.cotp-backup --dry-run +cotp merge remote.cotp-backup --conflict keep-local +``` + +The source password may differ from the destination password. `--source-password-stdin` +reads the source password without placing it in process arguments. For automation, +provide the destination password separately through the global password FD option. +The source snapshot is authenticated and validated before merging anything. Changes +are written through the same locked, stale-write-protected transaction as other +commands. A rejected conflict or failed authentication writes nothing. + +Account IDs identify accounts across copies; identical account material and names +also deduplicate independently imported entries. Conflicts abort by default. Choose +`--conflict keep-local` or `--conflict take-remote` explicitly. Aliases must remain +unique. Deletions are not propagated: an absent account does not prove deletion. +This is explicit snapshot merging, not a background cloud sync service. + +HOTP accounts require `--hotp max`. Matching HOTP accounts retain the higher counter, +regardless of metadata conflict policy. Choose a single generating device for each +HOTP account: merging counters cannot prevent two disconnected devices from emitting +the same code. `backup restore --force` is full rollback, whereas `merge` preserves +higher counters for matching credentials. diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 00000000..9997d3c5 --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,36 @@ +# YubiKey OATH backend + +Install YubiKey Manager (`ykman`) and enable the key's OATH application. cotp invokes +its CLI directly without a shell, keeping the OTP secret on the device. Password +and touch prompts are handled by ykman. Multiple connected keys can be selected +with the global `--device SERIAL` option. + +```sh +cotp --backend yubikey list --json +cotp --backend yubikey code 'GitHub:me@example.com' +cotp hardware code 'GitHub:me@example.com' --copy --quiet +cotp --device 12345678 hardware add 'me@example.com' --issuer GitHub --touch +cotp hardware delete 'GitHub:me@example.com' +``` + +Enrollment prompts for the secret instead of putting it in process arguments or +shell history. Enrollment and deletion require an interactive terminal. Queries +must match exactly one account; an exact full name takes precedence over substring +matches. `ykman --single` enforces single matching again on generation. HOTP counters +advance on the key, even if output delivery later fails; cotp never retries a code +generation automatically. Listing does not generate codes or require touch. + +Device accounts use their full OATH names as IDs. Local vault UUIDs, tags and aliases +are not copied onto the device. Hardware JSON reports `expires_at: null` because the +CLI adapter does not know each credential's period. No local vault is opened for +hardware commands. Device/password errors propagate as failures rather than falling +back to a software copy of a secret. Without interactive input, ykman's stdin is +closed so a password prompt fails instead of hanging. + +The backend is tested with a deterministic subprocess fixture; physical-device and +touch behavior requires a real key and is not exercised by the regular test suite. +Reference: https://docs.yubico.com/software/yubikey/tools/ykman/OATH_Commands.html + +The shared `code` command accepts the YubiKey backend. Hardware `--watch`, +`--preview`, and nonzero `--min-validity` are rejected because the adapter cannot +guarantee their semantics across all device credentials. diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 00000000..8d0d8864 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,62 @@ +# CLI standard feature integration + +The implementation is assembled on `feature/cli-standard`; `main` is not modified. +Feature branches and their isolated worktrees are retained for review. Worktrees +are siblings of the integration checkout, named `standard-`. + +| Feature branch | Delivered behavior | +| --- | --- | +| `feature/standard-accounts` | Persistent IDs, aliases, one-based selectors, ambiguity errors, metadata-preserving schema migration | +| `feature/standard-storage` | Cross-process locking, stale-write checks, durable HOTP consumption, encrypted backup create/list/verify/restore and retention | +| `feature/standard-imports` | Local PNG/JPEG QR decoding, OTP/migration URI input, automatic format detection, dry runs, duplicate policies, per-entry validation, explicit private plaintext exports | +| `feature/standard-reliability` | Full HOTP/TOTP RFC vectors, malformed-input property tests, cryptographic envelope validation, security/format documentation, release checksums and signed provenance | +| `feature/standard-shell` | JSON settings, named vault profiles, shell completions, generated manual pages, build without native clipboard dependencies | +| `feature/standard-cli` | Single-account `code`, minimum validity, watch, JSON expiry, metadata-only listing, noninteractive setup, password FDs, clean output and documented exit codes | +| `feature/standard-migrations` | 2FAS/Ente/Bitwarden/1Password imports, 1PUX archives, selective export, encrypted Aegis export, fixture-based interoperability tests | +| `feature/standard-session` | Inactivity/manual/cross-process lock, encrypted pending-edit restoration, explicit save shortcut, native clipboard expiration with ownership comparison | +| `feature/standard-hardware` | YubiKey OATH listing/code/enrollment/deletion through ykman, device selection, single-match generation and touch-capable enrollment | +| `feature/standard-exchange` | Authenticated encrypted snapshot merging, dry runs, explicit conflict policy, HOTP max-counter policy, no silent deletion propagation | +| `feature/standard-organization` | Tags, favorites, custom ordering, shared CLI/TUI filtering and account metadata | +| `feature/standard-keychain` | Opt-in native OS credential storage, authenticated remember/forget/status, vault-scoped credentials and explicit-password precedence | +| `feature/standard-review` | Integration review fixes for plaintext/key lifetimes and malformed backup handling | +| `feature/standard-docs` | Consolidated user guides and headless CI checks | +| `fix/standard-converter-tests` | Import-safe Python converter entry points | + +## Integration validation + +The merged implementation passed these checks on Linux aarch64: + +```sh +cargo fmt --all -- --check +cargo clippy -- -D warnings +cargo test --locked --quiet +cargo test --locked --release --quiet +cargo clippy --locked --no-default-features -- -D warnings +cargo test --locked --no-default-features --quiet +uv run --with pytest python -m pytest -q converters +``` + +Each Rust configuration passed 120 unit tests and 24 integration tests. The +cross-process locking helper is marked ignored in the general suite and invoked +explicitly by its parent test. All four Python converter tests passed. macOS, +Windows, physical hardware, native credential stores, and release attestation +were not exercised locally. + +## Deliberate boundaries + +- Snapshot exchange is local and explicit. No hosted service or background network + synchronization is introduced. HOTP use must be assigned to a single generating + device; merging counters does not make disconnected concurrent generation safe. +- Native clipboard clearing is best effort. OSC52 cannot safely read back clipboard + ownership, so SSH copies are not automatically erased. +- YubiKey support requires `ykman`; hardware timing/watch/preview options are rejected + when their semantics cannot be guaranteed. Hardware JSON expiry is unknown. +- Keychain integration is opt-in. Session reauthentication still requires the vault + password; locking does not delete a remembered OS credential. +- New authenticator imports accept the documented export formats. Unsupported + proprietary encrypted formats fail with instructions to export supported input. +- Native device/credential stores and GitHub release attestation require their host + environments. Automated tests use synthetic vaults and backend fixtures; no user + credentials are read or written during verification. + +See the linked guides in README for commands, compatibility changes, and configuration. diff --git a/docs/imports.md b/docs/imports.md new file mode 100644 index 00000000..1d604147 --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,63 @@ +# Enrollment, migration, and plaintext export + +Import PNG/JPEG QR screenshots, newline-separated OTP URIs, Google Authenticator migration URIs, or supported JSON backups directly. QR decoding runs locally and scans every QR code in the image. JSON format detection covers cotp, andOTP, Aegis, FreeOTP+, converted backups, Authy remote-debug exports, and OTP URI lists. Existing explicit format flags and `--path` remain available. + +```sh +cotp import screenshot.png --dry-run +cotp import accounts.json --duplicates skip +cotp import accounts.json --duplicates replace +cotp import accounts.json --duplicates keep-both +cotp import - --yes < otp-uris.txt +cotp import --aegis --path aegis.json +``` + +Imports display account names and parameters on stderr before saving. Interactive imports request confirmation; `--yes` accepts the preview. A dry run never changes existing vault contents. Imports reject the entire batch if any entry is invalid unless `--skip-invalid` is supplied. Errors identify source entries without printing secrets. PNG/JPEG inputs are limited to 32 MiB and bounded decoded image dimensions/memory. + +Repeated imports skip duplicate credentials by default. Identity comprises the normalized secret, OTP scheme, algorithm, digit count, period, and PIN; account display names do not affect identity. Replacement preserves the greater HOTP counter, so an older backup cannot roll the counter backwards. `keep-both` deliberately adds duplicate credentials. + +Plaintext exports now require explicit acknowledgment: + +```sh +cotp export --plaintext --path migration.json +cotp export --plaintext --otp-uri --path otp-uris.json +``` + +The default filename is `exported.plaintext.json`. Exports contain authentication secrets. Files are created privately and atomically, and existing paths are never overwritten. Use the encrypted backup workflow for routine backups. + +## Native migrations + +These formats are detected automatically; explicit flags are available when needed: + +| Application | Flag | Supported input | +| --- | --- | --- | +| 2FAS | `--2fas` | Plaintext schema 4 `.2fas` JSON with `services[].secret` and `services[].otp`; compatible earlier services containers are accepted | +| Ente Auth | `--ente` | Decrypted text export with one `otpauth://` URI per line | +| Bitwarden | `--bitwarden` | Unencrypted Authenticator or Password Manager JSON, `items[].login.totp` as an OTP URI, bare base32 seed, or `steam://` seed | +| 1Password | `--onepassword` | `.1pux` ZIP containing `export.data`, or extracted `export.data` JSON; imports every `details.sections[].fields[].value.totp` | + +2FAS group names become tags and entry positions are retained. Bitwarden favorites and 1Password favorites/tags are retained. Entries without OTP data in password manager exports are ignored. Passwords, notes, attachments, and unrelated fields are never imported. 1PUX attachments are not extracted. The decompressed `export.data` limit is 32 MiB. Encrypted 2FAS, Ente, Bitwarden exports and 1Password CSV/1PIF are not supported; create a plaintext export in the source application first. + +```sh +cotp import backup.2fas --dry-run +cotp import ente-auth.txt --ente --yes +cotp import bitwarden.json --yes +cotp import account.1pux --yes +``` + +Source schemas: [2FAS BackupContent](https://github.com/twofas/2fas-android/blob/main/data/services/src/main/java/com/twofasapp/data/services/domain/BackupContent.kt), [2FAS BackupService](https://github.com/twofas/2fas-android/blob/main/data/services/src/main/java/com/twofasapp/data/services/domain/BackupService.kt), [Ente export documentation](https://ente.com/help/auth/migration/export), [Bitwarden Authenticator export examples](https://bitwarden.com/help/authenticator-import-export/), and [1Password 1PUX specification](https://support.1password.com/1pux-format/). The fixtures in `test_samples/migrations` are synthetic examples built from these structures, containing only dummy credentials. + +## Selected and encrypted exports + +Export selections combine with AND: repeated `--id` values select any of those IDs, `--tag` matches a tag case insensitively, and `--query` matches a case insensitive substring in issuer, label, alias, or tags. Empty selections and unknown IDs are errors. + +```sh +cotp export --plaintext --query example --tag work --path selected.json +cotp export --plaintext --id ACCOUNT_ID --otp-uri --path selected-uris.json +cotp export --aegis-encrypted --path aegis.json +cotp export --aegis-encrypted --export-password-file /private/export-password --path aegis.json +cotp import aegis.json --import-password-file /private/export-password --yes +``` + +`--aegis` produces plaintext Aegis JSON and requires `--plaintext`. `--aegis-encrypted` uses a separate password, obtained through a verified terminal prompt or `--export-password-file`. Import supports the corresponding `--import-password-file`; passwords are never passed as argument values. Password files have a 4096 byte limit and trailing newlines are removed. Aegis export supports TOTP, HOTP, and Steam, including algorithms, digits, periods, HOTP counters, favorites, and tags as Aegis groups. Other OTP types cause an error rather than being silently dropped. cotp-only IDs, aliases, and ordering are preserved by cotp JSON exports. + +The encrypted Aegis container uses a random 256-bit master key, scrypt (`N=32768, r=8, p=1`) password slot, and AES-256-GCM, following [Aegis password slots](https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/java/com/beemdevelopment/aegis/vault/slots/PasswordSlot.java) and [entry serialization](https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java). Exports are created privately without overwriting existing files. diff --git a/docs/vault-format.md b/docs/vault-format.md new file mode 100644 index 00000000..0b8c309d --- /dev/null +++ b/docs/vault-format.md @@ -0,0 +1,33 @@ +# Vault format + +The database is a UTF-8 JSON envelope with `version`, `nonce`, `salt`, and `cipher`. +Envelope version 1 uses standard padded Base64 for the binary fields: + +- `salt`: 16 random bytes, freshly generated on password change. +- `nonce`: 24 random bytes, freshly generated on every encryption. +- `cipher`: XChaCha20-Poly1305 ciphertext with its authentication tag. + +The 32-byte encryption key is derived using Argon2id version 0x13 with 32768 KiB +memory, four iterations and four lanes. The encrypted payload is JSON containing a +separate schema `version` and an `elements` array. Historical schema 1 may instead +be a bare array. Schema migrations preserve OTP secrets and parameters. The +cryptographic envelope version is separate from the payload schema version; do not +change KDF parameters without a new envelope version and migration support. + +Payload schema 3 assigns a persistent random 128-bit hexadecimal ID to each account. +Accounts also carry an optional alias, tags, favorite status, and an optional custom +sort order. Legacy migration assigns missing IDs and repairs collisions. + +An account stores its issuer, label, Base32 secret, type, HMAC algorithm, digit +count, period, optional HOTP counter and optional scheme-specific PIN. Never log +the payload. Plaintext portability exports must be treated like passwords. + +Unknown envelope versions, malformed Base64 and invalid nonce/salt lengths are +rejected. Authentication failure can mean a wrong password or damaged ciphertext; +no plaintext is returned in either case. The envelope metadata is not additional +authenticated data in version 1; its version and lengths are checked before use. + +Writes use a temporary file in the destination directory, sync its content, then +atomically replace the destination. On Unix the directory is synced as well. +Atomic replacement prevents partial-file writes; transaction conflict handling is +a separate layer. Filesystem durability still depends on the filesystem and hardware. diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 030978b3..b165c3d6 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -1,7 +1,5 @@ -use std::io::{self, BufRead}; - use clap::{Args, value_parser}; -use color_eyre::eyre::{self, ErrReport, Result}; +use color_eyre::eyre::Result; use zeroize::Zeroize; @@ -24,6 +22,20 @@ pub struct AddArgs { #[arg(short = 't', long = "type", default_value = "totp")] pub otp_type: OTPType, + /// Unique account alias + #[arg(long)] + pub alias: Option, + + /// Account tag (repeat for multiple tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Mark this account as a favorite + #[arg(long)] + pub favorite: bool, + /// Position before accounts without an explicit order (lowest first) + #[arg(long, allow_hyphen_values = true)] + pub order: Option, + /// Code issuer #[arg(short, long, default_value = "")] pub issuer: String, @@ -70,8 +82,19 @@ pub struct AddArgs { impl SubcommandExecutor for AddArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let otp_element = if self.otp_uri { - let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ").unwrap(); + if let Some(alias) = &self.alias { + super::selector::validate_alias(&database, alias, None)?; + } + let alias = self.alias.clone(); + let tags = self.tags.clone(); + let favorite = self.favorite; + let order = self.order; + let mut otp_element = if self.otp_uri { + let mut otp_uri = if self.take_secret_from_stdin { + crate::utils::stdin_line()? + } else { + crate::utils::prompt_password("Insert the otp uri: ")? + }; let result = OTPElement::from_otp_uri(otp_uri.as_str()); otp_uri.zeroize(); result? @@ -79,20 +102,20 @@ impl SubcommandExecutor for AddArgs { get_from_args(self)? }; - database.add_element(otp_element); + otp_element.alias = alias; + crate::otp::organization::add_tags(&mut otp_element.tags, tags); + otp_element.favorite = favorite; + otp_element.order = order; + database.add_element(otp_element)?; Ok(database) } } fn get_from_args(matches: AddArgs) -> color_eyre::Result { let secret = if matches.take_secret_from_stdin { - if let Some(password) = io::stdin().lock().lines().next() { - password.map_err(ErrReport::from) - } else { - Err(eyre::eyre!("Error during reading from stdin")) - } + crate::utils::stdin_line() } else { - rpassword::prompt_password("Insert the secret: ").map_err(ErrReport::from) + crate::utils::prompt_password("Insert the secret: ") }?; map_args_to_code(secret, matches) } diff --git a/src/arguments/code.rs b/src/arguments/code.rs new file mode 100644 index 00000000..66f64d4b --- /dev/null +++ b/src/arguments/code.rs @@ -0,0 +1,176 @@ +use super::{SubcommandExecutor, selector::AccountSelector}; +use crate::{ + clipboard, + otp::{ + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, + }, +}; +use clap::Args; +use color_eyre::eyre::eyre; +use serde::Serialize; +use std::{ + io::{self, Write}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Args)] +pub struct CodeArgs { + #[command(flatten)] + pub selector: AccountSelector, + /// Emit one JSON object per code, including expiry in Unix seconds + #[arg(long)] + pub json: bool, + /// Wait until the code has this much validity left (e.g. 10s) + #[arg(long, default_value="0s", value_parser=parse_duration)] + pub min_validity: Duration, + /// Emit a new code at each time interval until interrupted + #[arg(long)] + pub watch: bool, + /// Copy the generated code to the clipboard + #[arg(long, alias = "copy-clipboard")] + pub copy: bool, + /// Suppress code output when copying + #[arg(long, requires = "copy", conflicts_with = "json")] + pub quiet: bool, + /// Show the current HOTP code without advancing its counter + #[arg(long)] + pub preview: bool, +} + +#[derive(Serialize)] +struct CodeOutput<'a> { + id: &'a str, + issuer: &'a str, + label: &'a str, + code: &'a str, + expires_at: Option, + remaining: Option, +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.strip_suffix('s').unwrap_or(value); + value + .parse::() + .map(Duration::from_secs) + .map_err(|_| "Use a non-negative number of seconds, for example 10s".into()) +} + +pub(crate) fn period(element: &OTPElement) -> color_eyre::Result> { + match element.type_ { + OTPType::Hotp => Ok(None), + OTPType::Steam => Ok(Some(30)), + _ if element.period == 0 => Err(eyre!("Account has an invalid zero period")), + _ => Ok(Some(element.period)), + } +} + +fn now() -> color_eyre::Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?) +} + +fn timing(time: Duration, period: u64) -> color_eyre::Result<(u64, Duration)> { + let expires = (time.as_secs() / period) + .checked_add(1) + .and_then(|next| next.checked_mul(period)) + .ok_or_else(|| eyre!("Code expiry overflows the clock"))?; + Ok((expires, Duration::from_secs(expires).saturating_sub(time))) +} + +impl SubcommandExecutor for CodeArgs { + fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + let index = self.selector.select(&database)?; + let period = period(&database.elements_ref()[index])?; + if period.is_none() && (self.watch || !self.min_validity.is_zero()) { + return Err(eyre!( + "HOTP has no time interval; --watch and --min-validity are unavailable" + )); + } + if period.is_some_and(|period| self.min_validity >= Duration::from_secs(period)) { + return Err(eyre!( + "Minimum validity must be shorter than the account period" + )); + } + let mut previous_expiry = None; + loop { + let before = now()?; + let timing_before = period.map(|period| timing(before, period)).transpose()?; + if let Some((expiry, remaining)) = timing_before + && (remaining < self.min_validity || previous_expiry == Some(expiry)) + { + thread::sleep(remaining.min(Duration::from_millis(200))); + continue; + } + let code = if period.is_none() && !self.preview { + crate::persistence::consume_hotp(&mut database, index)? + } else { + database.elements_ref()[index].get_otp_code()? + }; + let timing_after = period.map(|period| timing(now()?, period)).transpose()?; + // A code generated across a rollover must never be paired with the next expiry. + if timing_before.map(|value| value.0) != timing_after.map(|value| value.0) + || timing_after.is_some_and(|(_, remaining)| remaining < self.min_validity) + { + continue; + } + if self.copy { + clipboard::copy_string_to_clipboard(&code)?; + } + if !self.quiet { + let mut stdout = io::stdout().lock(); + if self.json { + let element = &database.elements_ref()[index]; + serde_json::to_writer( + &mut stdout, + &CodeOutput { + id: &element.id, + issuer: &element.issuer, + label: &element.label, + code: &code, + expires_at: timing_after.map(|value| value.0), + remaining: timing_after.map(|value| value.1.as_secs()), + }, + )?; + writeln!(stdout)?; + } else { + writeln!(stdout, "{code}")?; + } + stdout.flush()?; + } + if !self.watch { + break; + } + previous_expiry = timing_after.map(|value| value.0); + } + Ok(database) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn timing_respects_boundaries_without_overstating_validity() { + assert_eq!( + timing(Duration::from_secs(60), 30).unwrap(), + (90, Duration::from_secs(30)) + ); + assert_eq!( + timing(Duration::from_millis(89999), 30).unwrap(), + (90, Duration::from_millis(1)) + ); + assert_eq!( + timing(Duration::from_secs(90), 30).unwrap(), + (120, Duration::from_secs(30)) + ); + } + #[test] + fn durations_have_an_explicit_safe_grammar() { + assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10)); + assert_eq!(parse_duration("0").unwrap(), Duration::ZERO); + for invalid in ["-1s", "NaN", "10m", "18446744073709551616s"] { + assert!(parse_duration(invalid).is_err()); + } + } +} diff --git a/src/arguments/delete.rs b/src/arguments/delete.rs index 8ba40492..6962b678 100644 --- a/src/arguments/delete.rs +++ b/src/arguments/delete.rs @@ -14,17 +14,11 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct DeleteArgs { - /// Code Index - #[arg(short, long, required_unless_present_any=["issuer", "label"])] - pub index: Option, - - /// Issuer of the first matching code that will be deleted - #[arg(short = 's', long, required_unless_present_any=["index", "label"])] - pub issuer: Option, - - /// Label of the first matching code that will be deleted - #[arg(short, long, required_unless_present_any=["index","issuer"])] - pub label: Option, + /// Delete the selected account without prompting + #[arg(long, short = 'y')] + pub yes: bool, + #[command(flatten)] + pub selector: super::selector::AccountSelector, } impl SubcommandExecutor for DeleteArgs { @@ -33,21 +27,21 @@ impl SubcommandExecutor for DeleteArgs { return Err(eyre!("There are no elements to delete")); } - let index_to_delete = self - .index - .and_then(|i| i.checked_sub(1)) - // Match by issues or label if index filter is missing - .or_else(|| get_first_matching_element(&otp_database, &self)) - .ok_or(eyre!("No code has been found using the given arguments"))?; + let index_to_delete = self.selector.select(&otp_database)?; if let Some(element) = otp_database.elements_ref().get(index_to_delete) { - print!( + if self.yes { + otp_database.delete_element(index_to_delete); + return Ok(otp_database); + } + crate::utils::ensure_interactive()?; + eprint!( "Are you sure you want to delete the {}th code ({}, {}) [Y,N]: ", index_to_delete + 1, element.issuer, element.label ); - io::stdout().flush()?; + io::stderr().flush()?; let output = read_confirmation_line()?; @@ -91,22 +85,3 @@ fn read_confirmation_line() -> color_eyre::Result { "Unable to read confirmation answer from standard input or terminal" )) } - -fn get_first_matching_element( - otp_database: &OTPDatabase, - delete_args: &DeleteArgs, -) -> Option { - otp_database - .elements_ref() - .iter() - .enumerate() - .find(|(_, element)| { - element - .issuer - .contains(delete_args.issuer.as_deref().unwrap_or_default()) - && element - .label - .contains(delete_args.label.as_deref().unwrap_or_default()) - }) - .map(|(index, _)| index) -} diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index 398b57f2..7aca3f4d 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -1,44 +1,80 @@ use clap::{Args, value_parser}; -use color_eyre::eyre::eyre; use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPDatabase}; -use super::SubcommandExecutor; +use super::{ + SubcommandExecutor, + selector::{AccountSelector, validate_alias}, +}; #[derive(Args)] pub struct EditArgs { - /// Code Index - #[arg(short, long)] - pub index: usize, - - /// Code issuer + /// One-based account index shown by list + #[arg(short, long, required_unless_present_any = ["id", "alias", "match_issuer", "match_label", "tags", "favorite"])] + pub index: Option, + /// Persistent account identifier + #[arg(long)] + pub id: Option, + /// Existing account alias + #[arg(long)] + pub alias: Option, + /// Select by issuer (case-insensitive glob) + #[arg(long)] + pub match_issuer: Option, + /// Select by label (case-insensitive glob) + #[arg(long)] + pub match_label: Option, + /// Require this tag when selecting (repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Select favorite accounts only + #[arg(long)] + pub favorite: bool, + /// Set a unique account alias + #[arg(long, conflicts_with = "clear_alias")] + pub set_alias: Option, + /// Remove the account alias + #[arg(long)] + pub clear_alias: bool, + /// Add an account tag (repeat for multiple tags) + #[arg(long = "add-tag", value_parser = crate::otp::organization::parse_tag)] + pub add_tags: Vec, + /// Remove an account tag (case-insensitive; repeat as needed) + #[arg(long = "remove-tag", value_parser = crate::otp::organization::parse_tag)] + pub remove_tags: Vec, + /// Remove all account tags before adding new tags + #[arg(long)] + pub clear_tags: bool, + /// Set favorite status explicitly: true or false + #[arg(long, action = clap::ArgAction::Set)] + pub set_favorite: Option, + /// Set a custom position (lowest first) + #[arg(long, allow_hyphen_values = true, conflicts_with = "clear_order")] + pub order: Option, + /// Restore alphabetical position + #[arg(long)] + pub clear_order: bool, + /// New code issuer #[arg(short = 's', long)] pub issuer: Option, - - /// Code label + /// New code label #[arg(short, long)] pub label: Option, - /// OTP algorithm #[arg(short, long, value_enum)] pub algorithm: Option, - /// Code digits #[arg(short, long, value_parser=value_parser!(u64).range(1..=10))] pub digits: Option, - /// Code period - #[arg(short = 'e', long)] + #[arg(short = 'e', long, value_parser=value_parser!(u64).range(1..))] pub period: Option, - /// HOTP counter #[arg(short, long)] pub counter: Option, - /// Yandex / MOTP pin #[arg(short, long)] pub pin: Option, - /// Change code secret #[arg(short = 'k', long = "change-secret")] pub change_secret: bool, @@ -46,51 +82,76 @@ pub struct EditArgs { impl SubcommandExecutor for EditArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + let index = AccountSelector { + index: self.index, + id: self.id, + alias: self.alias, + issuer: self.match_issuer, + label: self.match_label, + query: None, + tags: self.tags, + favorite: self.favorite, + } + .select(&database)?; + if let Some(alias) = &self.set_alias { + validate_alias(&database, alias, Some(index))?; + } let secret = self .change_secret - .then(|| rpassword::prompt_password("Insert the secret: ").unwrap()); - - // User provides row number from dashboard which is equal to the array index plus one - let index = self.index; - - if let Some(real_index) = index.checked_sub(1) { - if real_index >= database.elements_ref().len() { - return Err(eyre!("{index} is an invalid index")); - } - - match database.mut_element(real_index) { - Some(element) => { - if let Some(v) = self.issuer { - element.issuer = v; - } - if let Some(v) = self.label { - element.label = v; - } - if let Some(v) = self.digits { - element.digits = v; - } - if let Some(v) = self.period { - element.period = v; - } - if let Some(v) = self.algorithm { - element.algorithm = v; - } - if self.counter.is_some() { - element.counter = self.counter; - } - if self.pin.is_some() { - element.pin = self.pin; - } - if let Some(s) = secret { - element.secret = s; - } - database.mark_modified(); - } - None => return Err(eyre!("No element found at index {index}")), - } - Ok(database) - } else { - Err(eyre!("{index} is an invalid index")) + .then(|| crate::utils::prompt_password("Insert the secret: ")) + .transpose()?; + let element = database + .mut_element(index) + .expect("selected account exists"); + if let Some(value) = self.issuer { + element.issuer = value; + } + if let Some(value) = self.label { + element.label = value; + } + if let Some(value) = self.digits { + element.digits = value; + } + if let Some(value) = self.period { + element.period = value; + } + if let Some(value) = self.algorithm { + element.algorithm = value; + } + if self.counter.is_some() { + element.counter = self.counter; + } + if self.pin.is_some() { + element.pin = self.pin; + } + if let Some(value) = secret { + element.secret = value; + } + if let Some(value) = self.set_alias { + element.alias = Some(value); + } + if self.clear_alias { + element.alias = None; + } + if self.clear_tags { + element.tags.clear(); + } + element.tags.retain(|tag| { + !self + .remove_tags + .iter() + .any(|value| value.eq_ignore_ascii_case(tag)) + }); + crate::otp::organization::add_tags(&mut element.tags, self.add_tags); + if let Some(favorite) = self.set_favorite { + element.favorite = favorite; + } + if self.clear_order { + element.order = None; + } else if self.order.is_some() { + element.order = self.order; } + database.mark_modified(); + Ok(database) } } diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 04fb1818..7685609e 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -13,6 +13,27 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct ExportArgs { + /// Acknowledge that exported files contain unencrypted authentication secrets + #[arg( + long, + required_unless_present = "aegis_encrypted", + conflicts_with = "aegis_encrypted" + )] + pub plaintext: bool, + + /// Export only these account IDs (repeatable) + #[arg(long = "id")] + pub ids: Vec, + /// Export only accounts containing this tag + #[arg(long)] + pub tag: Option, + /// Match issuer, label, alias, or tags (case insensitive substring) + #[arg(long)] + pub query: Option, + /// Read the Aegis export password from a private file + #[arg(long, requires = "aegis_encrypted")] + pub export_password_file: Option, + /// Export file path #[arg(short, long, default_value = ".")] pub path: PathBuf, @@ -25,6 +46,13 @@ pub struct ExportArgs { #[derive(Args)] #[group(required = false, multiple = false)] pub struct ExportFormat { + /// Export an unencrypted Aegis backup + #[arg(long)] + pub aegis: bool, + /// Export an encrypted Aegis backup with a separate password + #[arg(long = "aegis-encrypted")] + pub aegis_encrypted: bool, + /// Export into cotp backup #[arg(short, long)] pub cotp: bool, @@ -45,6 +73,8 @@ pub struct ExportFormat { impl Default for ExportFormat { fn default() -> Self { Self { + aegis: false, + aegis_encrypted: false, cotp: true, andotp: false, otp_uri: false, @@ -56,28 +86,84 @@ impl Default for ExportFormat { impl SubcommandExecutor for ExportArgs { fn run_command(self, database: OTPDatabase) -> color_eyre::Result { let export_format = self.format.unwrap_or_default(); + for id in &self.ids { + if !database.elements_ref().iter().any(|e| &e.id == id) { + return Err(eyre!("Export account ID does not exist")); + } + } + let elements: Vec = database + .elements_ref() + .iter() + .filter(|e| { + (self.ids.is_empty() || self.ids.contains(&e.id)) + && self + .tag + .as_ref() + .is_none_or(|tag| e.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))) + && self.query.as_ref().is_none_or(|query| { + let query = query.to_lowercase(); + [&e.issuer, &e.label] + .into_iter() + .chain(e.alias.iter()) + .chain(e.tags.iter()) + .any(|value| value.to_lowercase().contains(&query)) + }) + }) + .cloned() + .collect(); + if elements.is_empty() { + return Err(eyre!("No accounts match the export selection")); + } + let selected = OTPDatabase::from(elements); let exported_path = if self.path.is_dir() { - self.path.join("exported.cotp") + self.path.join(if export_format.aegis_encrypted { + "exported.aegis.json" + } else { + "exported.plaintext.json" + }) } else { self.path }; - if export_format.cotp { - do_export(&database, exported_path) + if export_format.aegis || export_format.aegis_encrypted { + let password = if export_format.aegis_encrypted { + Some(zeroize::Zeroizing::new( + if let Some(path) = self.export_password_file { + use std::io::Read; + let mut password = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)? + .take(4097) + .read_to_string(&mut password)?; + if password.len() > 4096 { + return Err(eyre!("Export password file is too large")); + } + password.trim_end_matches(['\r', '\n']).to_owned() + } else { + crate::utils::verified_password("New Aegis export password: ", 8)? + }, + )) + } else { + None + }; + let value = + crate::exporters::aegis::encode(&selected, password.as_ref().map(|p| p.as_str()))?; + do_export(&value, exported_path) + } else if export_format.cotp { + do_export(&selected, exported_path) } else if export_format.andotp { - let andotp: &Vec = (&database).into(); + let andotp: &Vec = (&selected).into(); do_export(&andotp, exported_path) } else if export_format.otp_uri { - let otp_uri_list: OtpUriList = (&database).into(); + let otp_uri_list: OtpUriList = (&selected).into(); do_export(&otp_uri_list, exported_path) } else if export_format.freeotp_plus { - let freeotp_plus: FreeOTPPlusJson = (&database).try_into()?; + let freeotp_plus: FreeOTPPlusJson = (&selected).try_into()?; do_export(&freeotp_plus, exported_path) } else { unreachable!("Unreachable code"); } .map(|path| { - println!( + eprintln!( "Exported to path: {}", path.to_str().unwrap_or("Failed to encode path") ); @@ -86,3 +172,60 @@ impl SubcommandExecutor for ExportArgs { .map_err(|e| eyre!("An error occurred while exporting database: {e}")) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::from_otp_uri::FromOtpUri; + + #[test] + fn selection_combines_ids_query_and_tag_and_preserves_metadata() { + let mut database = OTPDatabase::default(); + for (issuer, tag) in [ + ("Example", "work"), + ("Example", "personal"), + ("Other", "work"), + ] { + let mut e = + OTPElement::from_otp_uri(&format!("otpauth://totp/{issuer}:alice?secret=JBSWY3DP")) + .unwrap(); + e.tags.push(tag.to_owned()); + database.add_element(e).unwrap(); + } + let first_id = database.elements_ref()[0].id.clone(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("selected.json"); + let returned = ExportArgs { + plaintext: true, + ids: database + .elements_ref() + .iter() + .map(|e| e.id.clone()) + .collect(), + tag: Some("WORK".into()), + query: Some("EXAMPLE".into()), + export_password_file: None, + path: path.clone(), + format: None, + } + .run_command(database) + .unwrap(); + assert_eq!(returned.elements_ref().len(), 3); + let exported: OTPDatabase = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(exported.elements_ref().len(), 1); + assert_eq!(exported.elements_ref()[0].id, first_id); + assert_eq!(exported.elements_ref()[0].tags, ["work"]); + let missing = ExportArgs { + plaintext: true, + ids: vec!["missing".into()], + tag: None, + query: None, + export_password_file: None, + path: directory.path().join("missing.json"), + format: None, + } + .run_command(returned); + assert!(missing.is_err()); + assert!(!directory.path().join("missing.json").exists()); + } +} diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index 0f271303..1dd8f906 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -1,329 +1,31 @@ -use crate::otp::otp_element::OTPDatabase; -use crate::{clipboard, otp::otp_element::OTPElement}; +use crate::{clipboard, otp::otp_element::OTPDatabase}; use clap::Args; -use color_eyre::eyre::eyre; -use globset::{GlobBuilder, GlobMatcher}; -use super::SubcommandExecutor; +use super::{SubcommandExecutor, selector::AccountSelector}; #[derive(Args, Default)] pub struct ExtractArgs { - /// Code Index - #[arg(short, long, required_unless_present_any = ["issuer", "label"])] - pub index: Option, - - /// Code issuer, may be a glob pattern - #[arg(short = 's', long, required_unless_present_any = ["index", "label"])] - pub issuer: Option, - - /// Code label, may be a glob pattern - #[arg(short, long, required_unless_present_any = ["index", "issuer"])] - pub label: Option, - + #[command(flatten)] + pub selector: AccountSelector, /// Copy the code to the clipboard #[arg(short, long = "copy-clipboard", default_value_t = false)] pub copy_to_clipboard: bool, } -// Contains glob filters for each field we can filter on -struct ExtractFilterGlob { - issuer_glob: Option, - label_glob: Option, - index: Option, -} - -impl TryFrom for ExtractFilterGlob { - type Error = color_eyre::eyre::ErrReport; - - fn try_from(value: ExtractArgs) -> Result { - let issuer_glob = if let Some(issuer) = value.issuer { - Some(create_matcher(&issuer)?) - } else { - None - }; - - let label_glob = if let Some(label) = value.label { - Some(create_matcher(&label)?) - } else { - None - }; - - Ok(Self { - issuer_glob, - label_glob, - index: value.index, - }) - } -} - -fn create_matcher( - glob: &str, -) -> Result>::Error> { - Ok(GlobBuilder::new(glob) - .case_insensitive(true) - .build()? - .compile_matcher()) -} - impl SubcommandExecutor for ExtractArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { - let copy_to_clipboard = self.copy_to_clipboard; - let globbed: ExtractFilterGlob = self.try_into()?; - - let first_with_filters = find_match(&otp_database, globbed); - - if let Some(otp) = first_with_filters { - let code = otp.get_otp_code()?; - println!("{code}"); - if copy_to_clipboard { - let _ = clipboard::copy_string_to_clipboard(code.as_str())?; - println!("Copied to clipboard"); - } - Ok(otp_database) - } else { - Err(eyre!("No such code found with these fields")) + fn run_command(self, mut otp_database: OTPDatabase) -> color_eyre::Result { + let index = self.selector.select(&otp_database)?; + let code = + if otp_database.elements_ref()[index].type_ == crate::otp::otp_type::OTPType::Hotp { + crate::persistence::consume_hotp(&mut otp_database, index)? + } else { + otp_database.elements_ref()[index].get_otp_code()? + }; + if self.copy_to_clipboard { + clipboard::copy_string_to_clipboard(&code)?; + eprintln!("Copied to clipboard"); } - } -} - -fn find_match(otp_database: &OTPDatabase, globbed: ExtractFilterGlob) -> Option<&OTPElement> { - otp_database - .elements - .iter() - .enumerate() - .find(|(index, code)| filter_extract(&globbed, *index, code)) - .map(|(_, code)| code) -} - -fn filter_extract(args: &ExtractFilterGlob, index: usize, candidate: &OTPElement) -> bool { - let match_by_index = args.index.is_none_or(|i| i == index); - - let match_by_issuer = args - .issuer_glob - .as_ref() - .is_none_or(|issuer| issuer.is_match(&candidate.issuer)); - - let match_by_label = args - .label_glob - .as_ref() - .is_none_or(|label| label.is_match(&candidate.label)); - - match_by_index && match_by_issuer && match_by_label -} - -#[cfg(test)] -mod tests { - - use crate::{ - arguments::extract::ExtractArgs, - otp::otp_element::{OTPDatabase, OTPElementBuilder}, - }; - - use super::find_match; - - #[test] - fn test_glob_filtering_good_issuer() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test-iss*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_good_label() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_no_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - label: Some("test-lala*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_none()); - } - - #[test] - fn test_glob_filtering_multiple_filters_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test*".to_string()), - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_multiple_filters_no_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test-no*".to_string()), - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_none()); - } - - #[test] - fn test_glob_filtering_case_insensitive() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("TeSt-iSS*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); + println!("{code}"); + Ok(otp_database) } } diff --git a/src/arguments/import.rs b/src/arguments/import.rs index c9273e46..84756513 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -1,34 +1,61 @@ -use std::path::PathBuf; - -use clap::Args; -use color_eyre::eyre::eyre; - +use super::SubcommandExecutor; use crate::{ - exporters::otp_uri::OtpUriList, importers::{ - aegis::AegisJson, aegis_encrypted::AegisEncryptedDatabase, - authy_remote_debug::AuthyExportedList, converted::ConvertedJsonList, - freeotp_plus::FreeOTPPlusJson, google_authenticator::import_from_google_authenticator, - importer::import_from_path, + aegis_encrypted::AegisEncryptedDatabase, + enrollment::{self, DuplicatePolicy, ImportReport}, }, - otp::otp_element::{OTPDatabase, OTPElement}, + otp::otp_element::OTPDatabase, +}; +use clap::Args; +use color_eyre::eyre::{Result, eyre}; +use std::{ + io::{self, Read, Write}, + path::PathBuf, }; - -use super::SubcommandExecutor; #[derive(Args)] pub struct ImportArgs { #[command(flatten)] pub backup_type: BackupType, - - /// Backup file path - #[arg(short, long)] - pub path: PathBuf, + /// Backup, QR image, or URI text file; use - to read standard input + #[arg(value_name = "INPUT", conflicts_with = "path")] + pub input: Option, + /// Backup file path (legacy alias for positional INPUT) + #[arg(short, long, required_unless_present = "input")] + pub path: Option, + /// Read the encrypted Aegis import password from a private file + #[arg(long)] + pub import_password_file: Option, + /// Preview changes without modifying the vault + #[arg(long)] + pub dry_run: bool, + /// How to handle accounts with the same secret and OTP parameters + #[arg(long, value_enum, default_value = "skip")] + pub duplicates: DuplicatePolicy, + /// Import valid entries even when other entries are invalid + #[arg(long)] + pub skip_invalid: bool, + /// Accept the displayed preview without confirmation + #[arg(long, short = 'y')] + pub yes: bool, } #[derive(Args)] -#[group(required = true, multiple = false)] +#[group(required = false, multiple = false)] pub struct BackupType { + /// Import an unencrypted 2FAS .2fas backup + #[arg(long = "2fas")] + pub twofas: bool, + /// Import an Ente Auth plaintext URI export + #[arg(long)] + pub ente: bool, + /// Import Bitwarden Authenticator or Password Manager plaintext JSON + #[arg(long)] + pub bitwarden: bool, + /// Import a 1Password .1pux ZIP or extracted export.data JSON + #[arg(long)] + pub onepassword: bool, + /// Import from cotp backup #[arg(short, long)] pub cotp: bool, @@ -75,37 +102,142 @@ pub struct BackupType { pub otp_uri: bool, } -impl SubcommandExecutor for ImportArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let path = self.path; - - let backup_type = self.backup_type; +impl BackupType { + fn selected(&self) -> Option<&'static str> { + [ + (self.twofas, "twofas"), + (self.ente, "ente"), + (self.bitwarden, "bitwarden"), + (self.onepassword, "onepassword"), + (self.cotp, "cotp"), + (self.andotp, "andotp"), + (self.aegis, "aegis"), + (self.aegis_encrypted, "aegis-encrypted"), + (self.freeotp_plus, "freeotp-plus"), + (self.authy_exported, "authy-exported"), + (self.google_authenticator, "google-authenticator"), + ( + self.authy || self.microsoft_authenticator || self.freeotp, + "converted", + ), + (self.otp_uri, "otp-uri"), + ] + .into_iter() + .find_map(|(selected, name)| selected.then_some(name)) + } +} - let result = if backup_type.cotp { - import_from_path::(path) - } else if backup_type.andotp { - import_from_path::>(path) - } else if backup_type.aegis { - import_from_path::(path) - } else if backup_type.aegis_encrypted { - import_from_path::(path) - } else if backup_type.freeotp_plus { - import_from_path::(path) - } else if backup_type.authy_exported { - import_from_path::(path) - } else if backup_type.google_authenticator { - import_from_google_authenticator(path) - } else if backup_type.authy || backup_type.microsoft_authenticator || backup_type.freeotp { - import_from_path::(path) - } else if backup_type.otp_uri { - import_from_path::(path) +impl SubcommandExecutor for ImportArgs { + fn run_command(self, mut database: OTPDatabase) -> Result { + let path = self + .input + .or(self.path) + .ok_or_else(|| eyre!("Import input is required"))?; + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + if path.as_os_str() == "-" { + io::stdin() + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } else { + std::fs::File::open(&path)? + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } + if bytes.len() > 32 * 1024 * 1024 { + return Err(eyre!("Import input exceeds 32 MiB")); + } + let report = if bytes.starts_with(b"PK\x03\x04") { + crate::importers::modern::onepassword_archive(&bytes)? + } else if image::guess_format(&bytes).is_ok() { + enrollment::decode_qr(&bytes)? } else { - return Err(eyre!("Invalid arguments provided")); + let content = std::str::from_utf8(&bytes) + .map_err(|_| eyre!("Input is not UTF-8 text or a supported QR image"))? + .trim(); + if content.starts_with("otpauth") + || self.backup_type.google_authenticator + || self.backup_type.ente + { + enrollment::parse_uris(content) + } else { + let value: serde_json::Value = serde_json::from_str(content).map_err(|_| { + eyre!("Invalid JSON; expected a backup, OTP URI text, or QR image") + })?; + let format = match self.backup_type.selected() { + Some(format) => format, + None => enrollment::detect_format(&value)?, + }; + if format == "aegis-encrypted" { + let mut report = ImportReport::default(); + let input: AegisEncryptedDatabase = serde_json::from_value(value) + .map_err(|_| eyre!("Invalid encrypted Aegis backup"))?; + let elements = if let Some(path) = self.import_password_file { + let mut password = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)? + .take(4097) + .read_to_string(&mut password)?; + if password.len() > 4096 { + return Err(eyre!("Import password file is too large")); + } + input.decrypt_with_password(password.trim_end_matches(['\r', '\n'])) + } else { + input.try_into() + }; + report.record( + 1, + elements.map_err(|_| eyre!("Cannot decrypt Aegis backup")), + ); + report + } else { + enrollment::parse_json(value, format)? + } + } }; - - let elements = result.map_err(|e| eyre!("{e}"))?; - - database.add_all(elements); + for error in &report.errors { + eprintln!("{error}"); + } + if !report.errors.is_empty() && !self.skip_invalid { + return Err(eyre!( + "Import rejected: {} invalid entries; use --skip-invalid to accept valid entries", + report.errors.len() + )); + } + if report.elements.is_empty() { + return Err(eyre!("No valid accounts found in import")); + } + for element in &report.elements { + eprintln!( + "{} / {} ({}, {} digits)", + element.issuer.escape_default(), + element.label.escape_default(), + element.type_, + element.digits + ); + } + eprintln!( + "{} valid accounts; duplicate policy: {:?}{}", + report.elements.len(), + self.duplicates, + if self.dry_run { " (dry run)" } else { "" } + ); + if self.dry_run { + return Ok(database); + } + if !self.yes { + crate::utils::ensure_interactive()?; + eprint!("Apply import? [y/N] "); + io::stderr().flush()?; + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + return Err(eyre!("Import cancelled")); + } + } + let (added, replaced, skipped) = + enrollment::apply(&mut database, report.elements, self.duplicates)?; + eprintln!( + "Import complete: {added} added, {replaced} replaced, {skipped} duplicates skipped" + ); Ok(database) } } diff --git a/src/arguments/list.rs b/src/arguments/list.rs index dd53c261..12eb9ea9 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -1,26 +1,33 @@ use clap::Args; -use color_eyre::eyre::{Result, eyre}; +use color_eyre::eyre::Result; use serde::Serialize; - -use crate::otp::otp_element::{OTPDatabase, OTPElement}; +use std::io::{self, Write}; use super::SubcommandExecutor; +use crate::otp::otp_element::OTPDatabase; #[derive(Args)] pub struct ListArgs { + /// Require this tag (case-insensitive; repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// List favorite accounts only + #[arg(long)] + pub favorite: bool, /// List output format #[command(flatten)] pub format: Option, + /// List account metadata without generating or exposing codes + #[arg(long)] + pub metadata_only: bool, } -/// Defines the output formats of the list subcommand #[derive(Args)] #[group(required = false, multiple = false)] pub struct ListFormat { /// List OTP codes in plain format #[arg(short, long)] pub plain: bool, - /// List OTP codes in JSON format #[arg(short = 'e', long)] pub json: bool, @@ -35,102 +42,82 @@ impl Default for ListFormat { } } -/// Defines JSON structure to output using the --json argument in the list subcommand #[derive(Serialize)] struct JsonOtpList<'a> { + index: usize, + id: &'a str, + alias: Option<&'a str>, issuer: &'a str, label: &'a str, - otp_code: String, + tags: &'a [String], + favorite: bool, + order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + otp_code: Option, } -impl<'a> TryFrom<&'a OTPElement> for JsonOtpList<'a> { - type Error = color_eyre::eyre::Error; - - fn try_from(value: &'a OTPElement) -> Result { - let otp_code = value.get_otp_code()?; - Ok(JsonOtpList { - issuer: &value.issuer, - label: &value.label, - otp_code, - }) - } -} - -const NO_ISSUER_TEXT: &str = ""; - impl SubcommandExecutor for ListArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, database: OTPDatabase) -> Result { + let elements = database + .elements_ref() + .iter() + .enumerate() + .filter(|(_, element)| { + crate::otp::organization::matches_filters(element, &self.tags, self.favorite) + }) + .map(|(index, element)| { + Ok(JsonOtpList { + index: index + 1, + id: &element.id, + alias: element.alias.as_deref(), + issuer: &element.issuer, + label: &element.label, + tags: &element.tags, + favorite: element.favorite, + order: element.order, + otp_code: if self.metadata_only { + None + } else { + Some(element.get_otp_code()?) + }, + }) + }) + .collect::>>()?; + let mut stdout = io::stdout().lock(); if self.format.unwrap_or_default().json { - let json_elements = otp_database - .elements - .iter() - .map(TryInto::try_into) - .collect::>>()?; - - let stringified = serde_json::to_string_pretty(&json_elements) - .map_err(|e| eyre!("Error during JSON serialization: {:?}", e))?; - print!("{stringified}"); + serde_json::to_writer_pretty(&mut stdout, &elements)?; + writeln!(stdout)?; } else { - if otp_database.elements.is_empty() { - println!("No elements to list"); - return Ok(otp_database); + write!( + stdout, + "Index\tID\tAlias\tIssuer\tLabel\tTags\tFavorite\tOrder" + )?; + if !self.metadata_only { + write!(stdout, "\tOTP")?; } - let issuer_width = calculate_width(&otp_database, |element| { - let issuer_length = element.issuer.chars().count(); - if issuer_length > 0 { - issuer_length - } else { - NO_ISSUER_TEXT.chars().count() + writeln!(stdout)?; + for element in elements { + write!( + stdout, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + element.index, + element.id.escape_default(), + element.alias.unwrap_or("").escape_default(), + element.issuer.escape_default(), + element.label.escape_default(), + element.tags.join(",").escape_default(), + element.favorite, + element + .order + .map(|value| value.to_string()) + .unwrap_or_default() + )?; + if let Some(code) = element.otp_code { + write!(stdout, "\t{code}")?; } - }); - - let label_width = - calculate_width(&otp_database, |element| element.label.chars().count()); - - println!( - "{0: <6} {1} {2} {3: <10}", - "Index", - "Issuer".to_owned() + " ".repeat(issuer_width - 6).as_ref(), - "Label".to_owned() + " ".repeat(label_width - 5).as_ref(), - "OTP", - ); - otp_database - .elements - .iter() - .enumerate() - .for_each(|(index, e)| { - println!( - "{0: <6} {1} {2} {3: <10}", - index + 1, - if e.issuer.is_empty() { - NO_ISSUER_TEXT.to_owned() - + " " - .repeat(issuer_width - NO_ISSUER_TEXT.chars().count()) - .as_str() - } else { - e.issuer.clone() - + " ".repeat(issuer_width - e.issuer.chars().count()).as_str() - }, - e.label.clone() - + " ".repeat(label_width - e.label.chars().count()).as_str(), - e.get_otp_code().unwrap_or("ERROR".to_string()) - ); - }); + writeln!(stdout)?; + } } - - Ok(otp_database) + Ok(database) } } - -fn calculate_width(otp_database: &OTPDatabase, get_number_of_chars: F) -> usize -where - F: Fn(&OTPElement) -> usize, -{ - otp_database - .elements - .iter() - .map(get_number_of_chars) - .max() - .unwrap_or_default() - + 3 -} diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 3646cddb..9b66d1f2 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -11,13 +11,16 @@ use self::{ }; mod add; +pub(crate) mod code; mod delete; mod edit; mod export; mod extract; mod import; -mod list; +pub(crate) mod list; mod passwd; +pub(crate) mod selector; +pub mod shell; /// Common trait the all the Subcommands must implement to define the command logic #[enum_dispatch] @@ -33,16 +36,34 @@ pub trait SubcommandExecutor { cotp Open the interactive vault cotp add --issuer GitHub --label you Add an account cotp import --help Migrate from another authenticator - cotp list --json Output codes for scripts + cotp code work --json Output one code for scripts DASHBOARD a add · e edit · / search · Enter copy · Space QR code · ? help" )] pub struct CotpArgs { + /// Use the local encrypted vault or a connected YubiKey + #[arg(long, global = true, value_enum, default_value = "vault")] + pub backend: crate::hardware::Backend, + /// Select a YubiKey by its serial number + #[arg(long, global = true)] + pub device: Option, #[command(subcommand)] - command: Option, + pub(crate) command: Option, + /// Disable all interactive prompts + #[arg(long, global = true)] + pub no_input: bool, + /// Read the vault password from this descriptor, leaving stdin for account data + #[arg(long, global = true, conflicts_with = "password_from_stdin")] + pub password_fd: Option, + /// Read settings from this JSON file + #[arg(long, global = true)] + pub config: Option, + /// Choose a named vault profile + #[arg(long, global = true)] + pub profile: Option, /// Fetch the password from standard input - #[arg(long = "password-stdin", default_value_t = false)] + #[arg(long = "password-stdin", default_value_t = false, global = true)] pub password_from_stdin: bool, /// Set the database path #[arg(short = 'd', long = "database-path")] @@ -53,6 +74,22 @@ pub struct CotpArgs { #[derive(Subcommand)] #[enum_dispatch(SubcommandExecutor)] pub enum CotpSubcommands { + /// Create a new encrypted vault, including in scripts with --password-stdin/--password-fd + Init(InitArgs), + /// Generate one uniquely selected account code (HOTP advances unless --preview) + Code(code::CodeArgs), + /// Remember, forget, or inspect this vault's password in the OS credential store + Keychain(crate::keychain::KeychainArgs), + /// Manage accounts on a YubiKey through ykman + Hardware(crate::hardware::HardwareArgs), + /// Merge an authenticated encrypted backup from another device + Merge(crate::exchange::MergeArgs), + /// Generate shell completion scripts without unlocking a vault + Completions(shell::CompletionsArgs), + /// Generate manual pages without unlocking a vault + Man(shell::ManArgs), + /// Lock every running dashboard for this vault without unlocking it + Lock(crate::session_lock::LockArgs), /// Add new OTP code Add(AddArgs), /// Edit an existing OTP Code @@ -69,6 +106,17 @@ pub enum CotpSubcommands { Extract(ExtractArgs), /// Change database password Passwd(PasswdArgs), + /// Create, verify, list, or restore encrypted backups + Backup(crate::backup::BackupArgs), +} + +#[derive(clap::Args)] +pub struct InitArgs; +impl SubcommandExecutor for InitArgs { + fn run_command(self, database: OTPDatabase) -> color_eyre::Result { + eprintln!("Encrypted vault initialized"); + Ok(database) + } } pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result { diff --git a/src/arguments/passwd.rs b/src/arguments/passwd.rs index 1ca741fb..f55e4530 100644 --- a/src/arguments/passwd.rs +++ b/src/arguments/passwd.rs @@ -1,18 +1,29 @@ -use clap::Args; -use zeroize::Zeroize; - -use crate::{otp::otp_element::OTPDatabase, utils}; - use super::SubcommandExecutor; +use crate::{otp::otp_element::OTPDatabase, utils}; +use clap::Args; +use color_eyre::eyre::eyre; +use zeroize::Zeroizing; #[derive(Args)] -pub struct PasswdArgs; +pub struct PasswdArgs { + /// Read the new password from this descriptor (Unix) + #[arg(long)] + pub new_password_fd: Option, +} impl SubcommandExecutor for PasswdArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let mut new_password = utils::verified_password("New password: ", 8); - database.save_with_pw(&new_password)?; - new_password.zeroize(); + let new_password = Zeroizing::new(if let Some(fd) = self.new_password_fd { + utils::password_from_fd(fd)? + } else { + utils::verified_password("New password: ", 8)? + }); + if new_password.chars().count() < 8 { + return Err(eyre!("New password must contain at least 8 characters")); + } + let (key, salt) = database.save_with_pw(&new_password)?; + let key = Zeroizing::new(key); + crate::persistence::unlock(&key, &salt)?; Ok(database) } } diff --git a/src/arguments/selector.rs b/src/arguments/selector.rs new file mode 100644 index 00000000..76623eb9 --- /dev/null +++ b/src/arguments/selector.rs @@ -0,0 +1,259 @@ +//! Shared single-account selection. Indices always match the one-based list display. +use clap::Args; +use color_eyre::eyre::eyre; +use globset::{GlobBuilder, GlobMatcher}; + +use crate::otp::otp_element::{OTPDatabase, OTPElement}; + +#[derive(Args, Clone, Default, Debug)] +#[group(id = "account_selector", required = true, multiple = true)] +pub struct AccountSelector { + /// One-based account index shown by list + #[arg(short, long)] + pub index: Option, + /// Persistent account identifier + #[arg(long)] + pub id: Option, + /// Exact account alias (case-insensitive) + #[arg(long)] + pub alias: Option, + /// Account issuer, optionally a case-insensitive glob + #[arg(short = 's', long)] + pub issuer: Option, + /// Account label, optionally a case-insensitive glob + #[arg(short, long)] + pub label: Option, + /// Require this tag (case-insensitive; repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Match favorite accounts only + #[arg(long)] + pub favorite: bool, + /// Account ID, alias, or text in its issuer/label + #[arg(value_name = "QUERY")] + pub query: Option, +} + +impl AccountSelector { + pub fn select(&self, database: &OTPDatabase) -> color_eyre::Result { + if self.index == Some(0) { + return Err(eyre!("Account indices start at 1")); + } + if self.index.is_none() + && self.id.is_none() + && self.alias.is_none() + && self.issuer.is_none() + && self.label.is_none() + && self.query.is_none() + && self.tags.is_empty() + && !self.favorite + { + return Err(eyre!( + "Specify an account ID, alias, index, issuer, label, or query" + )); + } + if [ + &self.id, + &self.alias, + &self.issuer, + &self.label, + &self.query, + ] + .iter() + .any(|value| value.as_ref().is_some_and(|value| value.trim().is_empty())) + { + return Err(eyre!("Account selectors cannot be empty")); + } + let issuer = self.issuer.as_deref().map(matcher).transpose()?; + let label = self.label.as_deref().map(matcher).transpose()?; + let query = self.query.as_ref().map(|value| value.to_lowercase()); + let matches: Vec = database + .elements_ref() + .iter() + .enumerate() + .filter(|(index, element)| { + crate::otp::organization::matches_filters(element, &self.tags, self.favorite) + && self.index.is_none_or(|value| value == index + 1) + && self.id.as_ref().is_none_or(|value| *value == element.id) + && self.alias.as_ref().is_none_or(|value| { + element + .alias + .as_ref() + .is_some_and(|alias| alias.eq_ignore_ascii_case(value)) + }) + && issuer + .as_ref() + .is_none_or(|filter| filter.is_match(&element.issuer)) + && label + .as_ref() + .is_none_or(|filter| filter.is_match(&element.label)) + && query + .as_ref() + .is_none_or(|query| matches_query(element, query)) + }) + .map(|(index, _)| index) + .collect(); + match matches.as_slice() { + [index] => Ok(*index), + [] => Err(eyre!("No account matches these selectors")), + _ => Err(eyre!( + "Ambiguous account selection: {} accounts match; use --id, --alias, or additional selectors", + matches.len() + )), + } + } +} + +fn matcher(pattern: &str) -> color_eyre::Result { + Ok(GlobBuilder::new(pattern) + .case_insensitive(true) + .build()? + .compile_matcher()) +} + +fn matches_query(element: &OTPElement, query: &str) -> bool { + crate::otp::organization::matches_search(element, query) +} + +pub fn validate_alias( + database: &OTPDatabase, + alias: &str, + except: Option, +) -> color_eyre::Result<()> { + if alias.trim().is_empty() { + return Err(eyre!("An alias cannot be empty")); + } + if database + .elements_ref() + .iter() + .enumerate() + .any(|(index, element)| { + Some(index) != except + && element + .alias + .as_ref() + .is_some_and(|existing| existing.eq_ignore_ascii_case(alias)) + }) + { + return Err(eyre!("Alias is already assigned to another account")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + fn database() -> OTPDatabase { + OTPDatabase::from(vec![ + OTPElementBuilder::default() + .secret("AA") + .issuer("GitHub") + .label("alice") + .id("first") + .alias(Some("work".into())) + .build() + .unwrap(), + OTPElementBuilder::default() + .secret("AA") + .issuer("GitHub") + .label("bob") + .id("second") + .build() + .unwrap(), + ]) + } + + #[test] + fn indices_match_the_display_and_zero_is_rejected() { + let database = database(); + for (input, output) in [(1, 0), (2, 1)] { + assert_eq!( + AccountSelector { + index: Some(input), + ..Default::default() + } + .select(&database) + .unwrap(), + output + ); + } + for input in [0, 3] { + assert!( + AccountSelector { + index: Some(input), + ..Default::default() + } + .select(&database) + .is_err() + ); + } + } + + #[test] + fn ambiguous_globs_are_rejected_and_filters_intersect() { + let database = database(); + let mut selector = AccountSelector { + issuer: Some("gIt*".into()), + ..Default::default() + }; + assert!( + selector + .select(&database) + .unwrap_err() + .to_string() + .contains("Ambiguous") + ); + selector.label = Some("a*".into()); + assert_eq!(selector.select(&database).unwrap(), 0); + selector.index = Some(2); + assert!(selector.select(&database).is_err()); + } + + #[test] + fn ids_aliases_and_queries_select_accounts() { + let database = database(); + for selector in [ + AccountSelector { + id: Some("first".into()), + ..Default::default() + }, + AccountSelector { + alias: Some("WORK".into()), + ..Default::default() + }, + AccountSelector { + query: Some("ali".into()), + ..Default::default() + }, + ] { + assert_eq!(selector.select(&database).unwrap(), 0); + } + assert!(AccountSelector::default().select(&database).is_err()); + assert!( + AccountSelector { + query: Some(String::new()), + ..Default::default() + } + .select(&database) + .is_err() + ); + assert!( + AccountSelector { + issuer: Some("[".into()), + ..Default::default() + } + .select(&database) + .is_err() + ); + } + + #[test] + fn aliases_are_unique_ignoring_case_but_can_be_kept_by_their_owner() { + let database = database(); + assert!(validate_alias(&database, "Work", None).is_err()); + assert!(validate_alias(&database, "Work", Some(0)).is_ok()); + assert!(validate_alias(&database, "", None).is_err()); + } +} diff --git a/src/arguments/shell.rs b/src/arguments/shell.rs new file mode 100644 index 00000000..65e6d61b --- /dev/null +++ b/src/arguments/shell.rs @@ -0,0 +1,57 @@ +use super::{CotpArgs, CotpSubcommands, SubcommandExecutor}; +use crate::otp::otp_element::OTPDatabase; +use clap::{Args, CommandFactory}; +use std::path::PathBuf; + +#[derive(Args)] +pub struct CompletionsArgs { + #[arg(value_enum)] + shell: clap_complete::Shell, +} + +#[derive(Args)] +pub struct ManArgs { + /// Directory for the main page and all subcommand pages; stdout if omitted + #[arg(long)] + directory: Option, +} + +pub fn handle(args: &CotpArgs) -> color_eyre::Result { + match &args.command { + Some(CotpSubcommands::Completions(args)) => { + clap_complete::generate( + args.shell, + &mut CotpArgs::command(), + "cotp", + &mut std::io::stdout(), + ); + } + Some(CotpSubcommands::Man(args)) => { + let command = CotpArgs::command(); + if let Some(directory) = &args.directory { + std::fs::create_dir_all(directory)?; + clap_mangen::generate_to(command, directory)?; + } else { + clap_mangen::Man::new(command).render(&mut std::io::stdout())?; + } + } + _ => return Ok(false), + } + Ok(true) +} + +// These variants are handled before vault initialization. +impl SubcommandExecutor for CompletionsArgs { + fn run_command(self, _: OTPDatabase) -> color_eyre::Result { + Err(color_eyre::eyre::eyre!( + "Completion generation must run before vault initialization" + )) + } +} +impl SubcommandExecutor for ManArgs { + fn run_command(self, _: OTPDatabase) -> color_eyre::Result { + Err(color_eyre::eyre::eyre!( + "Manual generation must run before vault initialization" + )) + } +} diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 00000000..0bbfffb5 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,282 @@ +//! Backups are authenticated encrypted vault files, never plaintext exports. +use crate::{ + crypto::cryptography::{decrypt_string, validate_envelope}, + otp::{ + migrations::migrate, + otp_element::{OTPDatabase, OTPElement}, + }, + persistence::VaultLock, +}; +use clap::{Args, Subcommand}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use zeroize::Zeroizing; + +#[derive(Args)] +pub struct BackupArgs { + #[command(subcommand)] + pub action: BackupAction, +} + +#[derive(Subcommand)] +pub enum BackupAction { + /// Copy the encrypted vault into a new private backup file + Create { + directory: PathBuf, + /// Keep only this many cotp backups in the directory (at least one) + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] + keep: Option, + }, + /// List encrypted backup files without unlocking the vault + List { directory: PathBuf }, + /// Authenticate and validate a backup using its original password + Verify { file: PathBuf }, + /// Restore an authenticated backup; its original password remains in effect + Restore { + file: PathBuf, + /// Replace an existing vault (may roll back HOTP counters) + #[arg(long)] + force: bool, + }, +} + +impl crate::arguments::SubcommandExecutor for BackupArgs { + fn run_command(self, _: OTPDatabase) -> Result { + Err(eyre!("Backup commands must run before opening the vault")) + } +} + +pub fn run( + args: &BackupArgs, + vault: &Path, + password_stdin: bool, + password_fd: Option, +) -> Result<()> { + match &args.action { + BackupAction::Create { directory, keep } => { + let path = create(vault, directory)?; + if let Some(keep) = keep { + prune(directory, usize::from(*keep))?; + } + println!("{}", path.display()); + } + BackupAction::List { directory } => { + for path in list(directory)? { + println!("{}", path.display()); + } + } + BackupAction::Verify { file } => { + let password = password(password_stdin, password_fd)?; + let bytes = fs::read(file)?; + let database = validate(&bytes, &password)?; + println!( + "Verified encrypted backup: {} accounts", + database.elements_ref().len() + ); + } + BackupAction::Restore { file, force } => { + let password = password(password_stdin, password_fd)?; + restore(file, vault, &password, *force)?; + eprintln!( + "Encrypted backup restored. Use the backup's original password to unlock it." + ); + } + } + Ok(()) +} + +fn password(from_stdin: bool, fd: Option) -> Result> { + Ok(Zeroizing::new(if from_stdin { + crate::utils::stdin_line()? + } else if let Some(fd) = fd { + crate::utils::password_from_fd(fd)? + } else { + crate::utils::prompt_password("Backup password: ")? + })) +} + +pub(crate) fn validate(bytes: &[u8], password: &str) -> Result { + let text = std::str::from_utf8(bytes)?; + let (plaintext, key, _salt) = decrypt_string(text, password)?; + let plaintext = Zeroizing::new(plaintext); + let _key = Zeroizing::new(key); + let mut database: OTPDatabase = serde_json::from_str(&plaintext) + .or_else(|_| serde_json::from_str::>(&plaintext).map(Into::into))?; + if database.version > crate::otp::otp_element::CURRENT_DATABASE_VERSION { + return Err(eyre!("Backup uses a newer unsupported vault format")); + } + migrate(&mut database)?; + for element in &mut database.elements { + crate::importers::enrollment::validate(element) + .map_err(|_| eyre!("Backup contains an invalid OTP account"))?; + element + .get_otp_code() + .map_err(|_| eyre!("Backup contains an invalid OTP account"))?; + } + Ok(database) +} + +fn private_directory(path: &Path) -> Result<()> { + if !path.exists() { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path)?; + } + Ok(()) +} + +fn create(vault: &Path, directory: &Path) -> Result { + let bytes = { + let _lock = VaultLock::acquire(vault)?; + fs::read(vault)? + }; + // Reject accidentally pointing backup at a plaintext export. + validate_envelope(&bytes)?; + private_directory(directory)?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let path = directory.join(format!("cotp-{timestamp:030}.cotp-backup")); + // No-clobber persist prevents replacing an existing backup on a timestamp collision. + let mut temporary = tempfile::Builder::new() + .prefix(".cotp-backup-") + .tempfile_in(directory)?; + use std::io::Write; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(&path).map_err(|e| e.error)?; + #[cfg(unix)] + fs::File::open(directory)?.sync_all()?; + Ok(path) +} + +fn list(directory: &Path) -> Result> { + let mut paths = vec![]; + for entry in fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_file() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("cotp-") && name.ends_with(".cotp-backup")) + { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +fn prune(directory: &Path, keep: usize) -> Result<()> { + let paths = list(directory)?; + let remove = paths.len().saturating_sub(keep); + for path in paths.into_iter().take(remove) { + fs::remove_file(path)?; + } + Ok(()) +} + +fn restore(file: &Path, vault: &Path, password: &str, force: bool) -> Result<()> { + let bytes = fs::read(file)?; + validate(&bytes, password)?; + if let Some(parent) = vault.parent().filter(|p| !p.as_os_str().is_empty()) { + private_directory(parent)?; + } + let _lock = VaultLock::acquire(vault)?; + if vault.exists() && !force { + return Err(eyre!( + "Vault already exists. Use --force to replace it; restoring may roll back HOTP counters." + )); + } + crate::database_file::write(vault, &bytes)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key}; + fn encrypted() -> Vec { + let salt = [42; 16]; + let key = argon_derive_key(b"backup password", &salt).unwrap(); + serde_json::to_vec( + &encrypt_string_with_key( + &serde_json::to_string(&OTPDatabase::default()).unwrap(), + &key, + &salt, + ) + .unwrap(), + ) + .unwrap() + } + #[test] + fn encrypted_round_trip_and_failed_restore_preserves_destination() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault"); + let original = encrypted(); + fs::write(&vault, &original).unwrap(); + let backup = create(&vault, &dir.path().join("backups")).unwrap(); + assert_eq!(fs::read(&backup).unwrap(), original); + assert!(validate(&original, "backup password").is_ok()); + fs::write(&vault, b"existing").unwrap(); + assert!(restore(&backup, &vault, "wrong password", true).is_err()); + assert_eq!(fs::read(&vault).unwrap(), b"existing"); + assert!(restore(&backup, &vault, "backup password", false).is_err()); + assert_eq!(fs::read(&vault).unwrap(), b"existing"); + restore(&backup, &vault, "backup password", true).unwrap(); + assert_eq!(fs::read(&vault).unwrap(), original); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&backup).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + #[test] + fn corrupt_source_does_not_create_backup_or_prune_verified_files() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault"); + let backups = dir.path().join("backups"); + fs::create_dir(&backups).unwrap(); + let good = backups.join("cotp-0001.cotp-backup"); + let original = encrypted(); + fs::write(&good, &original).unwrap(); + let mut corrupt: serde_json::Value = serde_json::from_slice(&original).unwrap(); + corrupt["cipher"] = "truncated".into(); + fs::write(&vault, corrupt.to_string()).unwrap(); + let args = BackupArgs { + action: BackupAction::Create { + directory: backups.clone(), + keep: Some(1), + }, + }; + assert!(run(&args, &vault, false, None).is_err()); + assert_eq!(fs::read(&good).unwrap(), original); + assert_eq!(list(&backups).unwrap(), vec![good]); + } + #[test] + fn retention_only_removes_matching_backup_files() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "cotp-0001.cotp-backup", + "cotp-0002.cotp-backup", + "cotp-0003.cotp-backup", + "keep.json", + ] { + fs::write(dir.path().join(name), b"data").unwrap(); + } + prune(dir.path(), 2).unwrap(); + assert!(!dir.path().join("cotp-0001.cotp-backup").exists()); + assert!(dir.path().join("keep.json").exists()); + assert_eq!(list(dir.path()).unwrap().len(), 2); + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs index 2efea28d..8f02e600 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,65 +1,212 @@ use base64::{Engine as _, engine::general_purpose}; use color_eyre::eyre::eyre; +#[cfg(feature = "desktop-clipboard")] use copypasta_ext::prelude::*; -#[cfg(target_os = "linux")] +#[cfg(all(feature = "desktop-clipboard", target_os = "linux"))] use copypasta_ext::wayland_bin::WaylandBinClipboardContext; -use copypasta_ext::x11_bin::ClipboardContext as BinClipboardContext; -use copypasta_ext::x11_fork::ClipboardContext as ForkClipboardContext; +#[cfg(feature = "desktop-clipboard")] +use copypasta_ext::{ + x11_bin::ClipboardContext as BinClipboardContext, + x11_fork::ClipboardContext as ForkClipboardContext, +}; use crossterm::style::Print; -use std::{env, io}; +use std::{ + env, + io::{self, Read, Write}, + process::{Command, Stdio}, + time::Duration, +}; +use zeroize::Zeroizing; pub enum CopyType { Native, OSC52, } +/// Native expiration compares the clipboard with our copy before clearing it. +/// OSC52 has no portable read-back, so it is never cleared automatically. pub fn copy_string_to_clipboard(content: &str) -> color_eyre::Result { - if ssh_clipboard(content) { - Ok(CopyType::OSC52) - } else if wayland_clipboard(content) || other_platform_clipboard(content) { - Ok(CopyType::Native) - } else { - Err(eyre!("Cannot detect clipboard implementation")) - } -} - -fn ssh_clipboard(content: &str) -> bool { - env_var_set("SSH_CONNECTION") - // We do not use copypasta_ext::osc52 module because we have enabled terminal raw mode, so we print with crossterm utilities - // Check https://github.com/timvisee/rust-clipboard-ext/blob/371df19d2f961882a21c957f396d1e24548d1f28/src/osc52.rs#L92 - && crossterm::execute!( - io::stdout(), + if env_var_set("SSH_CONNECTION") { + crossterm::execute!( + io::stderr(), Print(format!( "\x1B]52;c;{}\x07", general_purpose::STANDARD.encode(content) )) - ) - .is_ok() + )?; + return Ok(CopyType::OSC52); + } + for backend in ["wayland", "bin", "fork"] { + if native_set(backend, content) { + let seconds = crate::config::settings().clipboard_seconds; + if seconds > 0 { + schedule_expiration(backend, content, seconds)?; + } + return Ok(CopyType::Native); + } + } + Err(eyre!("Cannot detect clipboard implementation")) } -#[cfg(target_os = "linux")] -fn wayland_clipboard(content: &str) -> bool { - env_var_set("WAYLAND_DISPLAY") - && WaylandBinClipboardContext::new() - .and_then(|mut ctx| ctx.set_contents(content.to_owned())) - .is_ok() + +fn schedule_expiration(backend: &str, content: &str, seconds: u64) -> color_eyre::Result<()> { + // The short-lived CLI cannot own the timer. Pass the sensitive content through + // an anonymous pipe; argv and environment contain only public timer metadata. + let mut child = Command::new(env::current_exe()?) + .args(["--internal-clipboard-expiry", backend, &seconds.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(mut input) = child.stdin.take() { + input.write_all(content.as_bytes())?; + } + // Reap the helper in long-running dashboards; the OS reaps it after CLI exit. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) } -#[cfg(not(target_os = "linux"))] -fn wayland_clipboard(_content: &str) -> bool { +/// Handle the private helper before parsing CLI arguments or unlocking a vault. +pub fn run_expiry_helper() -> color_eyre::Result { + let args: Vec<_> = env::args().collect(); + if args.get(1).map(String::as_str) == Some("--internal-clipboard-write") { + let mut content = Zeroizing::new(String::new()); + io::stdin().take(1_048_577).read_to_string(&mut content)?; + if content.len() > 1_048_576 || !fork_set(&content) { + return Err(eyre!("Cannot write native clipboard")); + } + return Ok(true); + } + if args.get(1).map(String::as_str) != Some("--internal-clipboard-expiry") { + return Ok(false); + } + if args.len() != 4 { + return Err(eyre!("Invalid clipboard helper arguments")); + } + let seconds = args[3].parse::()?; + let mut content = Zeroizing::new(String::new()); + io::stdin().take(1_048_577).read_to_string(&mut content)?; + if content.len() > 1_048_576 { + return Err(eyre!("Clipboard content too large")); + } + std::thread::sleep(Duration::from_secs(seconds)); + clear_owned( + &content, + || native_get(&args[2]), + |value| native_set(&args[2], value), + ); + Ok(true) +} + +fn clear_owned( + expected: &str, + read: impl FnOnce() -> Option, + clear: impl FnOnce(&str) -> bool, +) -> bool { + if let Some(current) = read() { + let current = Zeroizing::new(current); + if current.as_str() == expected { + return clear(""); + } + } false } -fn other_platform_clipboard(content: &str) -> bool { - BinClipboardContext::new() +#[cfg(feature = "desktop-clipboard")] +fn native_set(backend: &str, content: &str) -> bool { + match backend { + #[cfg(target_os = "linux")] + "wayland" if env_var_set("WAYLAND_DISPLAY") => WaylandBinClipboardContext::new() + .and_then(|mut ctx| ctx.set_contents(content.to_owned())) + .is_ok(), + "bin" => BinClipboardContext::new() + .and_then(|mut ctx| ctx.set_contents(content.to_owned())) + .is_ok(), + // X11's provider forks. Isolate it first, so no fork can inherit the + // dashboard's decrypted vault, editor buffers or encryption key. + "fork" => isolated_fork_set(content), + _ => false, + } +} +#[cfg(feature = "desktop-clipboard")] +fn native_get(backend: &str) -> Option { + match backend { + #[cfg(target_os = "linux")] + "wayland" => WaylandBinClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + "bin" => BinClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + "fork" => ForkClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + _ => None, + } +} +#[cfg(not(feature = "desktop-clipboard"))] +fn native_set(_backend: &str, _content: &str) -> bool { + false +} +#[cfg(not(feature = "desktop-clipboard"))] +fn native_get(_backend: &str) -> Option { + None +} + +#[cfg(feature = "desktop-clipboard")] +fn isolated_fork_set(content: &str) -> bool { + let result = (|| -> color_eyre::Result { + let mut child = Command::new(env::current_exe()?) + .arg("--internal-clipboard-write") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(content.as_bytes())?; + } + Ok(child.wait()?.success()) + })(); + result.unwrap_or(false) +} +#[cfg(feature = "desktop-clipboard")] +fn fork_set(content: &str) -> bool { + ForkClipboardContext::new() .and_then(|mut ctx| ctx.set_contents(content.to_owned())) .is_ok() - || ForkClipboardContext::new() - .and_then(|mut ctx| ctx.set_contents(content.to_owned())) - .is_ok() +} +#[cfg(not(feature = "desktop-clipboard"))] +fn fork_set(_content: &str) -> bool { + false +} + +fn env_var_set(name: &str) -> bool { + env::var(name).is_ok_and(|value| !value.trim().is_empty()) } -fn env_var_set(env_var: &str) -> bool { - env::var(env_var) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn expires_only_the_copy_we_still_own() { + assert!(clear_owned( + "123456", + || Some("123456".into()), + |value| { + assert!(value.is_empty()); + true + } + )); + assert!(!clear_owned( + "123456", + || Some("user's newer copy".into()), + |_| panic!("must retain newer clipboard") + )); + assert!(!clear_owned( + "123456", + || None, + |_| panic!("must not erase unreadable clipboard") + )); + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..eb5d272a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,98 @@ +use crate::arguments::CotpArgs; +use color_eyre::eyre::{WrapErr, eyre}; +use serde::Deserialize; +use std::{collections::BTreeMap, path::PathBuf, sync::OnceLock}; + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub database_path: Option, + pub profiles: BTreeMap, + pub default_profile: Option, + pub idle_timeout_seconds: u64, + pub clipboard_seconds: u64, + pub hide_codes: bool, + pub keychain: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + database_path: None, + profiles: BTreeMap::new(), + default_profile: None, + idle_timeout_seconds: 300, + clipboard_seconds: 30, + hide_codes: false, + keychain: false, + } + } +} + +static SETTINGS: OnceLock = OnceLock::new(); + +pub fn settings() -> &'static Settings { + SETTINGS.get_or_init(Settings::default) +} + +pub fn load(args: &mut CotpArgs) -> color_eyre::Result<()> { + let explicit = args + .config + .clone() + .or_else(|| std::env::var_os("COTP_CONFIG").map(PathBuf::from)); + let path = explicit + .clone() + .or_else(|| dirs::config_dir().map(|p| p.join("cotp/config.json"))); + let config: Settings = match path { + Some(path) => match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .wrap_err_with(|| format!("Invalid configuration: {}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound && explicit.is_none() => { + Settings::default() + } + Err(e) => { + return Err(e) + .wrap_err_with(|| format!("Cannot read configuration: {}", path.display())); + } + }, + None => Settings::default(), + }; + let profile = args + .profile + .clone() + .or_else(|| std::env::var("COTP_PROFILE").ok()) + .or_else(|| config.default_profile.clone()); + let configured_path = profile + .as_ref() + .map(|name| { + config + .profiles + .get(name) + .cloned() + .ok_or_else(|| eyre!("Unknown vault profile: {name}")) + }) + .transpose()? + .or_else(|| config.database_path.clone()); + if args.database_path.is_none() && std::env::var_os("COTP_DB_PATH").is_none() { + args.database_path = configured_path.map(|p| p.to_string_lossy().into_owned()); + } + SETTINGS + .set(config) + .map_err(|_| eyre!("Configuration already loaded"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn configuration_validates_names_and_preserves_defaults() { + let config: Settings = + serde_json::from_str(r#"{"profiles":{"work":"/vault/work.cotp"},"hide_codes":true}"#) + .unwrap(); + assert_eq!(config.profiles["work"], PathBuf::from("/vault/work.cotp")); + assert_eq!(config.idle_timeout_seconds, 300); + assert!(config.hide_codes); + assert!(serde_json::from_str::(r#"{"hid_codes":true}"#).is_err()); + } +} diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index 3ca47f8f..87992ad3 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -3,6 +3,7 @@ use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; use color_eyre::eyre::{ErrReport, eyre}; use data_encoding::BASE64; +use zeroize::{Zeroize, Zeroizing}; use super::encrypted_database::EncryptedDatabase; @@ -59,18 +60,13 @@ pub fn decrypt_string( encrypted_text: &str, password: &str, ) -> color_eyre::Result<(String, Vec, Vec)> { - //encrypted text is an encrypted database json serialized object - let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text) - .map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?; - let nonce = BASE64 - .decode(encrypted_database.nonce().as_bytes()) - .expect("Cannot decode Base64 nonce"); - let cipher_text = BASE64 - .decode(encrypted_database.cipher().as_bytes()) - .expect("Cannot decode Base64 cipher"); - let salt = BASE64.decode(encrypted_database.salt().as_bytes()).unwrap(); + let DecodedEnvelope { + nonce, + cipher_text, + salt, + } = decode_envelope(encrypted_text)?; - let key: Vec = argon_derive_key(password.as_bytes(), salt.as_slice())?; + let key = Zeroizing::new(argon_derive_key(password.as_bytes(), salt.as_slice())?); let aead = XChaCha20Poly1305::new_from_slice(&key) .map_err(|e| eyre!("Invalid encryption key length: {e}"))?; @@ -79,8 +75,52 @@ pub fn decrypt_string( let decrypted = aead .decrypt(&nonce, cipher_text.as_slice()) .map_err(|_| eyre!("Wrong password"))?; - let from_utf8 = String::from_utf8(decrypted).map_err(ErrReport::from)?; - Ok((from_utf8, key, salt)) + let from_utf8 = String::from_utf8(decrypted).map_err(|error| { + error.into_bytes().zeroize(); + eyre!("Decrypted vault is not UTF-8") + })?; + Ok((from_utf8, key.to_vec(), salt)) +} + +struct DecodedEnvelope { + nonce: Vec, + cipher_text: Vec, + salt: Vec, +} + +/// Check the encrypted container without a password. Authentication still requires decrypting it. +pub(crate) fn validate_envelope(bytes: &[u8]) -> color_eyre::Result<()> { + decode_envelope(std::str::from_utf8(bytes)?)?; + Ok(()) +} + +fn decode_envelope(encrypted_text: &str) -> color_eyre::Result { + let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text) + .map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?; + if encrypted_database.version() != 1 { + return Err(eyre!("Unsupported encrypted database version")); + } + let nonce = BASE64 + .decode(encrypted_database.nonce().as_bytes()) + .map_err(|_| eyre!("Invalid Base64 nonce"))?; + let cipher_text = BASE64 + .decode(encrypted_database.cipher().as_bytes()) + .map_err(|_| eyre!("Invalid Base64 ciphertext"))?; + let salt = BASE64 + .decode(encrypted_database.salt().as_bytes()) + .map_err(|_| eyre!("Invalid Base64 salt"))?; + if nonce.len() != XCHACHA20_POLY1305_NONCE_LENGTH || salt.len() != ARGON2ID_SALT_LENGTH { + return Err(eyre!("Invalid nonce or salt length")); + } + + if cipher_text.len() < 16 { + return Err(eyre!("Truncated encrypted vault ciphertext")); + } + Ok(DecodedEnvelope { + nonce, + cipher_text, + salt, + }) } #[cfg(test)] @@ -99,3 +139,82 @@ mod tests { assert_eq!(String::from("Secret data@#[]ò"), decrypted); } } + +#[cfg(test)] +mod malformed_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn malformed_json_never_panics(input in ".{0,2048}") { + let _ = decrypt_string(&input, "test password"); + } + #[test] + fn malformed_envelopes_never_panic(nonce in ".{0,40}", salt in ".{0,30}", cipher in ".{0,100}") { + let input = serde_json::json!({"version":1,"nonce":nonce,"salt":salt,"cipher":cipher}); + let _ = decrypt_string(&input.to_string(), "test password"); + } + } + + #[test] + fn tampering_and_wrong_password_fail_authentication() { + let salt = gen_salt().unwrap(); + let key = argon_derive_key(b"correct password", &salt).unwrap(); + let encrypted = encrypt_string_with_key("private vault", &key, &salt).unwrap(); + let serialized = serde_json::to_string(&encrypted).unwrap(); + assert!(decrypt_string(&serialized, "wrong password").is_err()); + let mut value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let mut cipher = BASE64 + .decode(value["cipher"].as_str().unwrap().as_bytes()) + .unwrap(); + cipher[0] ^= 1; + value["cipher"] = BASE64.encode(&cipher).into(); + assert!(decrypt_string(&value.to_string(), "correct password").is_err()); + value["version"] = 65535.into(); + assert!(decrypt_string(&value.to_string(), "correct password").is_err()); + } +} + +#[cfg(test)] +mod envelope_tests { + use super::*; + + #[test] + fn structural_validation_rejects_unsupported_and_truncated_containers() { + let valid = serde_json::json!({"version":1,"nonce":BASE64.encode(&[0;24]),"salt":BASE64.encode(&[0;16]),"cipher":BASE64.encode(&[0;16])}); + assert!(validate_envelope(valid.to_string().as_bytes()).is_ok()); + for (field, value) in [ + ("version", serde_json::json!(2)), + ("nonce", serde_json::json!(BASE64.encode(&[0; 23]))), + ("salt", serde_json::json!(BASE64.encode(&[0; 15]))), + ("cipher", serde_json::json!(BASE64.encode(&[0; 15]))), + ("cipher", serde_json::json!("not base64!")), + ] { + let mut corrupt = valid.clone(); + corrupt[field] = value; + assert!( + validate_envelope(corrupt.to_string().as_bytes()).is_err(), + "{field}" + ); + } + } + + #[test] + fn authenticated_invalid_utf8_returns_a_sanitized_error() { + let salt = [1; 16]; + let key = Zeroizing::new(argon_derive_key(b"password", &salt).unwrap()); + let nonce = XNonce::from([2; 24]); + let aead = XChaCha20Poly1305::new_from_slice(&key).unwrap(); + let cipher = aead.encrypt(&nonce, &[0xff, 0xfe][..]).unwrap(); + let envelope = EncryptedDatabase::new( + 1, + BASE64.encode(&nonce), + BASE64.encode(&salt), + BASE64.encode(&cipher), + ); + let error = + decrypt_string(&serde_json::to_string(&envelope).unwrap(), "password").unwrap_err(); + assert_eq!(error.to_string(), "Decrypted vault is not UTF-8"); + } +} diff --git a/src/crypto/encrypted_database.rs b/src/crypto/encrypted_database.rs index 28f2ef9c..b5469efe 100644 --- a/src/crypto/encrypted_database.rs +++ b/src/crypto/encrypted_database.rs @@ -1,30 +1,34 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct EncryptedDatabase { - version: u16, - nonce: String, - salt: String, - cipher: String, -} - -impl EncryptedDatabase { - pub fn new(version: u16, nonce: String, salt: String, cipher: String) -> EncryptedDatabase { - EncryptedDatabase { - version, - nonce, - salt, - cipher, - } - } - - pub fn nonce(&self) -> &str { - &self.nonce - } - pub fn salt(&self) -> &str { - &self.salt - } - pub fn cipher(&self) -> &str { - &self.cipher - } -} +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct EncryptedDatabase { + version: u16, + nonce: String, + salt: String, + cipher: String, +} + +impl EncryptedDatabase { + pub fn new(version: u16, nonce: String, salt: String, cipher: String) -> EncryptedDatabase { + EncryptedDatabase { + version, + nonce, + salt, + cipher, + } + } + + pub fn version(&self) -> u16 { + self.version + } + + pub fn nonce(&self) -> &str { + &self.nonce + } + pub fn salt(&self) -> &str { + &self.salt + } + pub fn cipher(&self) -> &str { + &self.cipher + } +} diff --git a/src/exchange.rs b/src/exchange.rs new file mode 100644 index 00000000..a204fe88 --- /dev/null +++ b/src/exchange.rs @@ -0,0 +1,294 @@ +//! Explicit, local merging of authenticated encrypted backups. No network transport. +use crate::{ + arguments::SubcommandExecutor, + otp::{ + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, + }, +}; +use clap::{Args, ValueEnum}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + io::{self, BufRead, IsTerminal, Read}, + path::PathBuf, +}; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum ConflictPolicy { + #[default] + Abort, + KeepLocal, + TakeRemote, +} +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum HotpPolicy { + #[default] + Reject, + Max, +} + +#[derive(Args)] +pub struct MergeArgs { + /// An encrypted cotp vault or backup from another device + file: PathBuf, + /// Resolve changed accounts explicitly; no changes are written on abort + #[arg(long, value_enum, default_value = "abort")] + conflict: ConflictPolicy, + /// HOTP is rejected unless max is chosen; never decreases an existing counter + #[arg(long, value_enum, default_value = "reject")] + hotp: HotpPolicy, + /// Read the source vault's password from stdin (separate from destination unlock) + #[arg(long)] + source_password_stdin: bool, + #[arg(long)] + pub dry_run: bool, + #[arg(long)] + json: bool, +} + +#[derive(Default, serde::Serialize)] +struct Report { + added: usize, + updated: usize, + unchanged: usize, + conflicts_kept: usize, +} + +fn same_identity(a: &OTPElement, b: &OTPElement) -> bool { + a.type_ == b.type_ + && a.secret == b.secret + && a.algorithm == b.algorithm + && a.digits == b.digits + && a.period == b.period + && a.pin == b.pin + && a.issuer == b.issuer + && a.label == b.label +} + +fn merge( + local: &OTPDatabase, + remote: OTPDatabase, + conflict: ConflictPolicy, + hotp: HotpPolicy, +) -> Result<(OTPDatabase, Report)> { + let mut candidate = OTPDatabase { + version: local.version, + elements: local.elements.clone(), + needs_modification: false, + }; + let mut report = Report::default(); + for mut incoming in remote.elements { + if incoming.type_ == OTPType::Hotp && matches!(hotp, HotpPolicy::Reject) { + return Err(eyre!( + "Source contains HOTP accounts; choose --hotp max after designating a single device to generate HOTP codes" + )); + } + let existing = candidate + .elements + .iter() + .position(|e| e.id == incoming.id) + .or_else(|| { + candidate + .elements + .iter() + .position(|e| same_identity(e, &incoming)) + }); + if let Some(index) = existing { + let old = &candidate.elements[index]; + incoming.id = old.id.clone(); + let counter = if incoming.type_ == OTPType::Hotp && same_identity(old, &incoming) { + old.counter.max(incoming.counter) + } else { + incoming.counter + }; + // Counters are merged independently of metadata conflict policy. + let mut comparable = incoming.clone(); + comparable.counter = old.counter; + if &comparable == old { + if old.counter != counter { + candidate.elements[index].counter = counter; + candidate.mark_modified(); + report.updated += 1; + } else { + report.unchanged += 1; + } + continue; + } + match conflict { + ConflictPolicy::Abort => { + return Err(eyre!( + "Conflicting account {}; use --conflict keep-local or take-remote", + old.id + )); + } + ConflictPolicy::KeepLocal => { + if old.type_ == OTPType::Hotp + && same_identity(old, &incoming) + && old.counter != counter + { + candidate.elements[index].counter = counter; + candidate.mark_modified(); + report.updated += 1; + } + report.conflicts_kept += 1; + } + ConflictPolicy::TakeRemote => { + incoming.counter = counter; + candidate.elements[index] = incoming; + candidate.mark_modified(); + report.updated += 1; + } + } + } else { + candidate.add_element(incoming)?; + report.added += 1; + } + } + let mut aliases = std::collections::HashSet::new(); + for element in &candidate.elements { + if let Some(alias) = &element.alias + && !aliases.insert(alias.to_lowercase()) + { + return Err(eyre!( + "Merge would create duplicate aliases; resolve aliases before merging" + )); + } + } + Ok((candidate, report)) +} + +impl SubcommandExecutor for MergeArgs { + fn run_command(self, database: OTPDatabase) -> Result { + let password = Zeroizing::new(if self.source_password_stdin { + io::stdin() + .lock() + .lines() + .next() + .ok_or_else(|| eyre!("Missing source password on stdin"))?? + } else { + if !io::stdin().is_terminal() { + return Err(eyre!( + "Use --source-password-stdin for noninteractive merge" + )); + } + crate::utils::prompt_password("Source backup password: ")? + }); + let mut bytes = Vec::new(); + std::fs::File::open(&self.file)? + .take(64 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > 64 * 1024 * 1024 { + return Err(eyre!("Source backup exceeds 64 MiB")); + } + let remote = crate::backup::validate(&bytes, &password)?; + let (candidate, report) = merge(&database, remote, self.conflict, self.hotp)?; + if self.json { + println!("{}", serde_json::to_string(&report)?); + } else { + eprintln!( + "Merge{}: {} added, {} updated, {} unchanged, {} conflicts kept locally", + if self.dry_run { " preview" } else { "" }, + report.added, + report.updated, + report.unchanged, + report.conflicts_kept + ); + } + if self.dry_run { + Ok(database) + } else { + Ok(candidate) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + fn vault(label: &str) -> OTPDatabase { + let mut db = OTPDatabase::default(); + db.add_element( + OTPElementBuilder::default() + .issuer("Example") + .label(label) + .secret("JBSWY3DP") + .build() + .unwrap(), + ) + .unwrap(); + db.needs_modification = false; + db + } + fn clone_vault(db: &OTPDatabase) -> OTPDatabase { + OTPDatabase { + version: db.version, + elements: db.elements.clone(), + needs_modification: false, + } + } + #[test] + fn conflicts_abort_without_mutating_local() { + let local = vault("alice"); + let mut remote = clone_vault(&local); + remote.elements[0].label = "renamed".into(); + assert!(merge(&local, remote, ConflictPolicy::Abort, HotpPolicy::Reject).is_err()); + assert_eq!(local.elements[0].label, "alice"); + assert!(!local.is_modified()); + } + #[test] + fn merging_same_snapshot_twice_is_idempotent() { + let local = vault("alice"); + let remote = vault("bob"); + let again = clone_vault(&remote); + let (merged, report) = + merge(&local, remote, ConflictPolicy::Abort, HotpPolicy::Reject).unwrap(); + assert_eq!(report.added, 1); + let (merged_again, report) = + merge(&merged, again, ConflictPolicy::Abort, HotpPolicy::Reject).unwrap(); + assert_eq!(report.added, 0); + assert_eq!(merged_again.elements.len(), 2); + } + #[test] + fn hotp_requires_policy_and_never_moves_backwards() { + let mut local = vault("alice"); + local.elements[0].type_ = OTPType::Hotp; + local.elements[0].counter = Some(50); + let mut remote = clone_vault(&local); + remote.elements[0].counter = Some(4); + assert!( + merge( + &local, + clone_vault(&remote), + ConflictPolicy::TakeRemote, + HotpPolicy::Reject + ) + .is_err() + ); + let (merged, _) = + merge(&local, remote, ConflictPolicy::TakeRemote, HotpPolicy::Max).unwrap(); + assert_eq!(merged.elements[0].counter, Some(50)); + let mut remote = clone_vault(&local); + remote.elements[0].counter = Some(70); + let (merged, _) = + merge(&local, remote, ConflictPolicy::KeepLocal, HotpPolicy::Max).unwrap(); + assert_eq!(merged.elements[0].counter, Some(70)); + } + #[test] + fn explicit_remote_policy_preserves_account_identity() { + let local = vault("alice"); + let mut remote = clone_vault(&local); + remote.elements[0].label = "new label".into(); + let (merged, report) = merge( + &local, + remote, + ConflictPolicy::TakeRemote, + HotpPolicy::Reject, + ) + .unwrap(); + assert_eq!(merged.elements[0].id, local.elements[0].id); + assert_eq!(merged.elements[0].label, "new label"); + assert_eq!(report.updated, 1); + } +} diff --git a/src/exporters/aegis.rs b/src/exporters/aegis.rs new file mode 100644 index 00000000..d4fc4af1 --- /dev/null +++ b/src/exporters/aegis.rs @@ -0,0 +1,126 @@ +//! Aegis v1 container / v3 database with an scrypt password slot and AES-256-GCM. +use crate::otp::{otp_element::OTPDatabase, otp_type::OTPType}; +use aes_gcm::{ + Aes256Gcm, KeyInit, + aead::{Aead, Nonce}, +}; +use color_eyre::eyre::{Result, eyre}; +use serde_json::{Value, json}; +use zeroize::Zeroizing; + +pub fn encode(database: &OTPDatabase, password: Option<&str>) -> Result { + let mut entries = Vec::new(); + let mut groups = Vec::::new(); + let mut group_ids = std::collections::BTreeMap::::new(); + for element in database.elements_ref() { + if !matches!( + element.type_, + OTPType::Totp | OTPType::Hotp | OTPType::Steam + ) { + return Err(eyre!( + "Aegis export supports TOTP, HOTP, and Steam accounts only" + )); + } + let mut entry_groups = Vec::new(); + for tag in &element.tags { + let id = if let Some(id) = group_ids.get(tag) { + id.clone() + } else { + let id = uuid()?; + group_ids.insert(tag.clone(), id.clone()); + groups.push(json!({"uuid":id,"name":tag})); + id + }; + entry_groups.push(id); + } + entries.push(json!({"type":element.type_.to_string().to_lowercase(),"uuid":uuid()?,"name":element.label,"issuer":element.issuer,"note":"","icon":null,"groups":entry_groups,"favorite":element.favorite,"info":{"secret":element.secret,"algo":element.algorithm.to_string().to_uppercase(),"digits":element.digits,"period":element.period,"counter":element.counter}})); + } + let db = json!({"version":3,"entries":entries,"groups":groups,"icons_optimized":true}); + let Some(password) = password else { + return Ok(json!({"version":1,"header":{"slots":null,"params":null},"db":db})); + }; + if password.is_empty() { + return Err(eyre!("Encrypted export requires a nonempty password")); + } + let plaintext = Zeroizing::new(serde_json::to_vec(&db)?); + let master = Zeroizing::new(random::<32>()?); + let salt = random::<32>()?; + let mut derived = Zeroizing::new([0u8; 32]); + let params = scrypt::Params::new(15, 8, 1).map_err(|_| eyre!("Invalid scrypt parameters"))?; + scrypt::scrypt(password.as_bytes(), &salt, ¶ms, derived.as_mut()) + .map_err(|_| eyre!("Scrypt failed"))?; + let (key, key_params) = encrypt(derived.as_slice(), master.as_slice())?; + let (encrypted, crypt_params) = encrypt(master.as_slice(), &plaintext)?; + Ok( + json!({"version":1,"header":{"slots":[{"type":1,"uuid":uuid()?,"key":hex::encode(key),"key_params":key_params,"n":32768,"r":8,"p":1,"salt":hex::encode(salt),"repaired":true,"is_backup":true}],"params":crypt_params},"db":data_encoding::BASE64.encode(&encrypted)}), + ) +} +fn random() -> Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).map_err(|_| eyre!("OS random generation failed"))?; + Ok(bytes) +} +fn uuid() -> Result { + let mut bytes = random::<16>()?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let s = hex::encode(bytes); + Ok(format!( + "{}-{}-{}-{}-{}", + &s[..8], + &s[8..12], + &s[12..16], + &s[16..20], + &s[20..] + )) +} +fn encrypt(key: &[u8], plaintext: &[u8]) -> Result<(Vec, Value)> { + let nonce = random::<12>()?; + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| eyre!("Invalid AES key"))?; + let nonce_value = + Nonce::::try_from(nonce.as_slice()).map_err(|_| eyre!("Invalid nonce"))?; + let mut ciphertext = cipher + .encrypt(&nonce_value, plaintext) + .map_err(|_| eyre!("Encryption failed"))?; + let tag = ciphertext.split_off(ciphertext.len() - 16); + Ok(( + ciphertext, + json!({"nonce":hex::encode(nonce),"tag":hex::encode(tag)}), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::importers::aegis_encrypted::AegisEncryptedDatabase; + use crate::otp::{from_otp_uri::FromOtpUri, otp_element::OTPElement}; + #[test] + fn encrypted_backup_round_trip_and_wrong_password() { + let mut element = + OTPElement::from_otp_uri("otpauth://hotp/Example:alice?secret=JBSWY3DP&counter=99") + .unwrap(); + element.favorite = true; + element.tags = vec!["work".into()]; + let mut database = OTPDatabase::default(); + database.add_element(element).unwrap(); + let encoded = encode(&database, Some("correct horse battery staple")).unwrap(); + assert!( + !serde_json::to_string(&encoded) + .unwrap() + .contains("JBSWY3DP") + ); + let input: AegisEncryptedDatabase = serde_json::from_value(encoded.clone()).unwrap(); + let restored = input + .decrypt_with_password("correct horse battery staple") + .unwrap(); + assert_eq!(restored[0].secret, "JBSWY3DP"); + assert_eq!(restored[0].counter, Some(99)); + assert!(restored[0].favorite); + assert_eq!(restored[0].tags, ["work"]); + let plain = encode(&database, None).unwrap(); + let restored = crate::importers::enrollment::parse_json(plain, "aegis").unwrap(); + assert_eq!(restored.elements[0].tags, ["work"]); + let input: AegisEncryptedDatabase = serde_json::from_value(encoded).unwrap(); + assert!(input.decrypt_with_password("wrong").is_err()); + } +} diff --git a/src/exporters/mod.rs b/src/exporters/mod.rs index 04ee935f..fe9ea176 100644 --- a/src/exporters/mod.rs +++ b/src/exporters/mod.rs @@ -1,7 +1,6 @@ -use std::{fs::File, io::Write, path::PathBuf}; +use std::{io::Write, path::PathBuf}; use serde::Serialize; -use zeroize::Zeroize; pub mod andotp; pub mod freeotp_plus; @@ -11,18 +10,25 @@ pub fn do_export(to_be_saved: &T, exported_path: PathBuf) -> Result { - if contents == "[]" { - return Err("No contents to export, skipping...".to_owned()); - } - let mut file = File::create(&exported_path).expect("Cannot create file"); - let contents_bytes = contents.as_bytes(); - file.write_all(contents_bytes) - .expect("Failed to write contents"); - contents.zeroize(); - Ok(exported_path) - } - Err(e) => Err(format!("{e:?}")), + let contents = + zeroize::Zeroizing::new(serde_json::to_vec(to_be_saved).map_err(|e| e.to_string())?); + if contents.as_slice() == b"[]" { + return Err("No contents to export, skipping...".to_owned()); } + let parent = exported_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + file.write_all(&contents).map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + file.persist_noclobber(&exported_path).map_err(|e| { + format!( + "Cannot create export (existing files are never overwritten): {}", + e.error + ) + })?; + Ok(exported_path) } + +pub mod aegis; diff --git a/src/hardware.rs b/src/hardware.rs new file mode 100644 index 00000000..c71ca495 --- /dev/null +++ b/src/hardware.rs @@ -0,0 +1,291 @@ +//! Optional YubiKey OATH backend. ykman owns device transport and touch prompts. +use clap::{Args, Subcommand, ValueEnum}; +use color_eyre::eyre::{Result, WrapErr, eyre}; +use std::io::IsTerminal; +use std::process::{Command, Stdio}; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum Backend { + #[default] + Vault, + Yubikey, +} + +#[derive(Args)] +pub struct HardwareArgs { + #[command(subcommand)] + command: HardwareCommand, +} + +#[derive(Subcommand)] +enum HardwareCommand { + /// List accounts stored on the selected key + List { + #[arg(long)] + json: bool, + }, + /// Generate exactly one code (HOTP advances on the device) + Code { + query: String, + #[arg(long)] + copy: bool, + #[arg(long)] + quiet: bool, + #[arg(long)] + json: bool, + }, + /// Enroll an account; ykman securely prompts for its secret + Add { + name: String, + #[arg(long)] + touch: bool, + #[arg(long)] + issuer: Option, + }, + /// Delete a single matching account, with ykman's confirmation + Delete { query: String }, +} + +impl crate::arguments::SubcommandExecutor for HardwareArgs { + fn run_command( + self, + _: crate::otp::otp_element::OTPDatabase, + ) -> Result { + Err(eyre!( + "Hardware commands must run before opening a local vault" + )) + } +} + +fn command(device: Option<&str>) -> Command { + let mut command = Command::new("ykman"); + if let Some(serial) = device { + command.args(["--device", serial]); + } + command.args(["oath", "accounts"]); + command.stderr(Stdio::inherit()); + if crate::utils::no_input() || !std::io::stdin().is_terminal() { + command.stdin(Stdio::null()); + } + command +} + +fn accounts(device: Option<&str>) -> Result> { + let output = command(device) + .arg("list") + .output() + .wrap_err("Cannot run ykman; install YubiKey Manager and enable the OATH application")?; + if !output.status.success() { + return Err(eyre!("YubiKey account listing failed ({})", output.status)); + } + let text = String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 from ykman")?; + Ok(text + .lines() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect()) +} + +fn select<'a>(names: &'a [String], query: &str) -> Result<&'a str> { + if query.is_empty() { + return Err(eyre!("An account query is required")); + } + let exact: Vec<_> = names + .iter() + .filter(|n| n.eq_ignore_ascii_case(query)) + .collect(); + let matches: Vec<_> = if exact.is_empty() { + let query = query.to_lowercase(); + names + .iter() + .filter(|n| n.to_lowercase().contains(&query)) + .collect() + } else { + exact + }; + match matches.as_slice() { + [name] => Ok(name), + [] => Err(eyre!("No YubiKey account matches the query")), + _ => Err(eyre!( + "Ambiguous YubiKey account query ({} matches); use the full account name", + matches.len() + )), + } +} + +pub fn list(device: Option<&str>, json: bool) -> Result<()> { + let names = accounts(device)?; + if json { + let entries: Vec<_> = names + .iter() + .map(|name| serde_json::json!({"id":name,"backend":"yubikey"})) + .collect(); + println!("{}", serde_json::to_string(&entries)?); + } else { + for name in names { + println!("{}", name.escape_default()); + } + } + Ok(()) +} + +pub fn code(device: Option<&str>, query: &str, copy: bool, quiet: bool, json: bool) -> Result<()> { + if quiet && !copy { + return Err(eyre!("--quiet requires --copy")); + } + let names = accounts(device)?; + let name = select(&names, query)?; + // --single also asks ykman to reject ambiguity before advancing an HOTP counter. + let output = command(device) + .args(["code", "--single", "--", name]) + .output() + .wrap_err("Cannot run ykman")?; + if !output.status.success() { + return Err(eyre!("YubiKey code generation failed ({})", output.status)); + } + let text = Zeroizing::new( + String::from_utf8(output.stdout).wrap_err("Invalid code output from ykman")?, + ); + let code = text.trim(); + if !(6..=8).contains(&code.len()) || !code.bytes().all(|b| b.is_ascii_digit()) { + return Err(eyre!("ykman returned an unexpected code format")); + } + if copy { + crate::clipboard::copy_string_to_clipboard(code)?; + } + if !quiet { + if json { + println!( + "{}", + serde_json::json!({"id":name,"backend":"yubikey","code":code,"expires_at":null}) + ); + } else { + println!("{code}"); + } + } + Ok(()) +} + +pub fn handle(args: &crate::arguments::CotpArgs) -> Result { + use crate::arguments::CotpSubcommands; + let device = args.device.as_deref(); + if let Some(CotpSubcommands::Hardware(hardware)) = &args.command { + match &hardware.command { + HardwareCommand::List { json } => list(device, *json)?, + HardwareCommand::Code { + query, + copy, + quiet, + json, + } => code(device, query, *copy, *quiet, *json)?, + HardwareCommand::Add { + name, + touch, + issuer, + } => { + if crate::utils::no_input() || !std::io::stdin().is_terminal() { + return Err(eyre!( + "Hardware enrollment requires an interactive terminal" + )); + } + let mut command = command(device); + command.arg("add"); + if *touch { + command.arg("--touch"); + } + if let Some(issuer) = issuer { + command.args(["--issuer", issuer]); + } + let status = command.args(["--", name]).status()?; + if !status.success() { + return Err(eyre!("YubiKey enrollment failed ({status})")); + } + } + HardwareCommand::Delete { query } => { + if crate::utils::no_input() || !std::io::stdin().is_terminal() { + return Err(eyre!("Hardware deletion requires an interactive terminal")); + } + let names = accounts(device)?; + let name = select(&names, query)?; + let status = command(device).args(["delete", "--", name]).status()?; + if !status.success() { + return Err(eyre!("YubiKey deletion failed ({status})")); + } + } + } + return Ok(true); + } + if args.backend == Backend::Vault { + return Ok(false); + } + match &args.command { + Some(CotpSubcommands::Code(args)) => { + if args.watch || args.preview || !args.min_validity.is_zero() { + return Err(eyre!( + "YubiKey does not support --watch, --preview, or --min-validity" + )); + } + let selector = &args.selector; + if !selector.tags.is_empty() + || selector.favorite + || selector.index.is_some() + || selector.issuer.is_some() + || selector.label.is_some() + || [&selector.query, &selector.id, &selector.alias] + .iter() + .filter(|value| value.is_some()) + .count() + != 1 + { + return Err(eyre!( + "Select a YubiKey account with one QUERY, --id, or --alias" + )); + } + let query = selector + .query + .as_deref() + .or(selector.id.as_deref()) + .or(selector.alias.as_deref()) + .unwrap(); + code(device, query, args.copy, args.quiet, args.json)?; + } + Some(CotpSubcommands::List(list_args)) => { + if !list_args.tags.is_empty() || list_args.favorite { + return Err(eyre!( + "YubiKey accounts do not support tag or favorite filters" + )); + } + list(device, list_args.format.as_ref().is_some_and(|f| f.json))? + } + _ => { + return Err(eyre!( + "Use cotp hardware code QUERY or cotp --backend yubikey list for hardware accounts" + )); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ambiguous_hardware_queries_fail_before_generation() { + let names = vec!["GitHub:personal".into(), "GitHub:work".into()]; + assert!(select(&names, "github").is_err()); + assert_eq!(select(&names, "GITHUB:work").unwrap(), "GitHub:work"); + assert!(select(&names, "").is_err()); + assert!(select(&names, "absent").is_err()); + } + #[test] + fn device_and_query_arguments_are_not_shell_commands() { + let command = command(Some("123456")); + let args: Vec<_> = command + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, ["--device", "123456", "oath", "accounts"]); + assert_eq!(command.get_program(), "ykman"); + } +} diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index a510a5a9..e449d7d2 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -20,6 +20,14 @@ struct AegisHeader { pub(crate) struct AegisDb { //version: u64, entries: Vec, + #[serde(default)] + groups: Vec, +} + +#[derive(Serialize, Deserialize)] +struct AegisGroup { + uuid: String, + name: String, } #[derive(Serialize, Deserialize)] @@ -30,11 +38,20 @@ struct AegisElement { issuer: String, //icon: Option, info: AegisInfo, + #[serde(default)] + favorite: bool, + #[serde(default)] + groups: Vec, } impl From for OTPElement { fn from(value: AegisElement) -> Self { OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: value.favorite, + order: None, secret: value.info.secret, issuer: value.issuer, label: value.name, @@ -52,7 +69,26 @@ impl TryFrom for Vec { type Error = String; fn try_from(aegis_db: AegisDb) -> Result { - Ok(aegis_db.entries.into_iter().map(Into::into).collect()) + Ok(aegis_db + .entries + .into_iter() + .map(|entry| { + let tags = entry + .groups + .iter() + .filter_map(|id| { + aegis_db + .groups + .iter() + .find(|g| &g.uuid == id) + .map(|g| g.name.clone()) + }) + .collect(); + let mut element: OTPElement = entry.into(); + element.tags = tags; + element + }) + .collect()) } } diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index 2132663a..295e1022 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -3,7 +3,6 @@ use aes_gcm::{Aes256Gcm, KeyInit}; // Or `Aes128Gcm` use data_encoding::BASE64; use hex::FromHex; use serde::Deserialize; -use zeroize::Zeroize; use crate::otp::otp_element::OTPElement; use crate::utils; @@ -45,110 +44,107 @@ struct AegisEncryptedSlot { impl TryFrom for Vec { type Error = String; - - fn try_from(aegis_encrypted: AegisEncryptedDatabase) -> Result { - let mut password = utils::password("Insert your Aegis password: ", 0); - let master_key: Option> = get_master_key(&aegis_encrypted, &password); - password.zeroize(); - - match master_key { - Some(mut master_key) => { - let content = BASE64 - .decode(aegis_encrypted.db.as_bytes()) - .map_err(|e| format!("Error during base64 decoding: {e:?}"))?; - - let cipher = Aes256Gcm::new_from_slice(master_key.as_slice()) - .map_err(|e| format!("Invalid master key length: {e:?}"))?; - master_key.zeroize(); - - let nonce_bytes = Vec::from_hex(&aegis_encrypted.header.params.nonce) - .expect("Failed to parse hex nonce"); - let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; - - let payload = [ - content, - Vec::from_hex(&aegis_encrypted.header.params.tag) - .expect("Failed to parse hex tag"), - ] - .concat(); - - let decrypted_db = cipher - .decrypt(&nonce, payload.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}"))?; - - map_results(decrypted_db) - } - None => Err("Failed to derive master key".to_string()), - } + fn try_from(database: AegisEncryptedDatabase) -> Result { + let password = zeroize::Zeroizing::new( + utils::password("Insert your Aegis password: ", 0).map_err(|e| e.to_string())?, + ); + database.decrypt_with_password(&password) } } -fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> Option> { - let mut master_key: Option> = None; - for slot in aegis_encrypted - .header - .slots - .iter() - .filter(|item| item.r#type == 1) - { - match calc_master_key(slot, password) { - Ok(value) => { - master_key = Some(value); +impl AegisEncryptedDatabase { + pub fn decrypt_with_password(self, password: &str) -> Result, String> { + if self.header.slots.len() > 16 { + return Err("Too many Aegis key slots".into()); + } + let mut master_key = None; + for slot in self.header.slots.iter().filter(|slot| slot.r#type == 1) { + if let Ok(key) = calc_master_key(slot, password) { + master_key = Some(zeroize::Zeroizing::new(key)); break; } - Err(e) => println!("{e}"), } + let master_key = + master_key.ok_or("Cannot decrypt Aegis backup: wrong password or invalid key slot")?; + let content = BASE64 + .decode(self.db.as_bytes()) + .map_err(|_| "Invalid Aegis base64 data")?; + let plaintext = + zeroize::Zeroizing::new(decrypt(&master_key, &content, &self.header.params)?); + let db: AegisDb = serde_json::from_slice(&plaintext) + .map_err(|_| "Invalid decrypted Aegis account data")?; + db.try_into() } - master_key -} - -fn map_results(decrypted_db: Vec) -> Result, String> { - let json = String::from_utf8(decrypted_db) - .map_err(|e| format!("Failed to decode from utf-8 bytes: {e:?}"))?; - - serde_json::from_str::(json.as_str()) - .map_err(|e| e.to_string()) - .and_then(TryInto::try_into) } -fn get_params(slot: &AegisEncryptedSlot) -> Result { - let n = slot.n.unwrap(); - let p = slot.p.unwrap(); - let r = slot.r.unwrap(); - - Params::new((n as f32).log2() as u8, r, p) - .map_err(|e| format!("Error during scrypt params creation: {e:?}")) +fn decrypt(key: &[u8], content: &[u8], params: &AegisEncryptedParams) -> Result, String> { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| "Invalid AES key length")?; + let nonce_bytes = Vec::from_hex(¶ms.nonce).map_err(|_| "Invalid Aegis nonce encoding")?; + let nonce = Nonce::::try_from(nonce_bytes.as_slice()) + .map_err(|_| "Invalid Aegis nonce length")?; + let tag = Vec::from_hex(¶ms.tag).map_err(|_| "Invalid Aegis authentication tag")?; + if tag.len() != 16 { + return Err("Invalid Aegis authentication tag length".into()); + } + let mut payload = content.to_vec(); + payload.extend_from_slice(&tag); + cipher + .decrypt(&nonce, payload.as_slice()) + .map_err(|_| "Aegis authentication failed".into()) } fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, String> { - let salt = Vec::from_hex(slot.salt.as_ref().unwrap()).expect("Failed to parse hex salt"); - let mut output: [u8; 32] = [0; 32]; - let params = get_params(slot)?; - - if let Err(e) = scrypt( - password.as_bytes(), - salt.as_slice(), - ¶ms, - output.as_mut_slice(), - ) { - return Err(format!("Error during scrypt key derivation: {e:?}")); + let n = slot.n.ok_or("Missing scrypt N")?; + let r = slot.r.ok_or("Missing scrypt r")?; + let p = slot.p.ok_or("Missing scrypt p")?; + if !n.is_power_of_two() + || !(2..=262144).contains(&n) + || r == 0 + || p == 0 + || u64::from(n) * u64::from(r) > 4_194_304 + || u64::from(n) * u64::from(r) * u64::from(p) > 8_388_608 + { + return Err("Unsupported or excessive Aegis scrypt parameters".into()); } - let cipher = Aes256Gcm::new_from_slice(output.as_slice()) - .map_err(|e| format!("Invalid derived key length: {e:?}"))?; - output.zeroize(); - - let cipher_text = [ - Vec::from_hex(&slot.key).expect("Failed to parse hex key"), - Vec::from_hex(&slot.key_params.tag).expect("Failed to parse hex tag"), - ] - .concat(); - - let nonce_bytes = Vec::from_hex(&slot.key_params.nonce).expect("Failed to parse hex nonce"); - let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; + let salt = Vec::from_hex(slot.salt.as_ref().ok_or("Missing scrypt salt")?) + .map_err(|_| "Invalid scrypt salt")?; + if !(16..=64).contains(&salt.len()) { + return Err("Invalid scrypt salt length".into()); + } + let params = Params::new(n.ilog2() as u8, r, p).map_err(|_| "Invalid scrypt parameters")?; + let mut key = zeroize::Zeroizing::new([0u8; 32]); + scrypt(password.as_bytes(), &salt, ¶ms, key.as_mut()) + .map_err(|_| "Scrypt derivation failed")?; + let encrypted = Vec::from_hex(&slot.key).map_err(|_| "Invalid encrypted Aegis key")?; + decrypt(key.as_slice(), &encrypted, &slot.key_params) +} - cipher - .decrypt(&nonce, cipher_text.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}")) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_slots_and_authentication_parameters_are_errors() { + let input: AegisEncryptedDatabase = serde_json::from_value(serde_json::json!({ + "header":{"slots":[{"type":1,"key":"bad","key_params":{"nonce":"bad","tag":"bad"},"n":3,"r":8,"p":1,"salt":"bad"}],"params":{"nonce":"bad","tag":"bad"}},"db":"bad" + })).unwrap(); + assert!(input.decrypt_with_password("password").is_err()); + for (nonce, tag) in [ + ("bad", "00"), + ("00", "00"), + ("000000000000000000000000", "00"), + ] { + assert!( + decrypt( + &[0; 32], + b"", + &AegisEncryptedParams { + nonce: nonce.into(), + tag: tag.into() + } + ) + .is_err() + ); + } + } } diff --git a/src/importers/authy_remote_debug.rs b/src/importers/authy_remote_debug.rs index 290e03f7..04f40d55 100644 --- a/src/importers/authy_remote_debug.rs +++ b/src/importers/authy_remote_debug.rs @@ -1,96 +1,28 @@ -/* -Import from JSON file exported from a script executed from remote debugging. -For more information see https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93 -*/ - -use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; -use serde::Deserialize; - -const URL_INDEX: usize = 3; -const PARAMETERS_INDEX: usize = 1; -const DIGITS_DEFAULT_VALUE: u64 = 6; - -#[derive(Deserialize)] -struct AuthyExportedJsonElement { - name: String, - secret: String, - uri: String, -} - -// Newtype pattern to bypass compiler check for impl From for Vec -// https://rust-unofficial.github.io/patterns/patterns/behavioural/newtype.html -#[derive(Deserialize)] -pub struct AuthyExportedList(Vec); - -impl AuthyExportedJsonElement { - pub fn get_type(&self) -> String { - let default_value = "totp"; - let args: Vec<&str> = self.uri.split('/').collect(); - String::from(*args.get(2).unwrap_or(&default_value)) - } - - pub fn get_digits(&self) -> u64 { - let args: Vec<&str> = self.uri.split('/').collect(); - args.get(URL_INDEX) - .and_then(|s| { - let mut args: Vec<&str> = s.split('?').collect(); - if args.get(PARAMETERS_INDEX).is_some() { - Some(args.swap_remove(PARAMETERS_INDEX)) - } else { - None - } - }) - .and_then(|s| { - let mut args: Vec<&str> = - s.split('&').filter(|s| s.starts_with("digits=")).collect(); - if !args.is_empty() { - Some(args.swap_remove(0)) - } else { - None - } - }) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DIGITS_DEFAULT_VALUE) - } - - pub fn get_issuer(&self) -> String { - let default_value = ""; - let args: Vec<&str> = self.uri.split('/').collect(); - match args.get(3) { - Some(s) => { - let args: Vec<&str> = s.split('?').collect(); - let issuer = args.first().unwrap_or(&default_value); - match urlencoding::decode(issuer) { - Ok(r) => r.into_owned(), - Err(_e) => (*issuer).to_string(), - } - } - None => String::from(default_value), - } - } -} - -impl From for OTPElement { - fn from(input: AuthyExportedJsonElement) -> Self { - let type_ = OTPType::from(input.get_type().as_str()); - let counter: Option = (type_ == OTPType::Hotp).then_some(0); - let digits = input.get_digits(); - OTPElement { - secret: input.secret.to_uppercase().replace('=', ""), - issuer: input.get_issuer(), - label: input.name, - digits, - type_, - algorithm: OTPAlgorithm::Sha1, - period: 30, - counter, - pin: None, - } - } -} - -impl From for Vec { - fn from(exported_list: AuthyExportedList) -> Self { - exported_list.0.into_iter().map(Into::into).collect() - } -} +//! Import the URI-bearing JSON produced by Authy's remote-debug export script. +use crate::otp::{from_otp_uri::FromOtpUri, otp_element::OTPElement}; +use color_eyre::eyre::ErrReport; +use serde::Deserialize; + +#[derive(Deserialize)] +struct AuthyExportedJsonElement { + name: String, + uri: String, +} + +#[derive(Deserialize)] +pub struct AuthyExportedList(Vec); + +impl TryFrom for Vec { + type Error = ErrReport; + fn try_from(exported: AuthyExportedList) -> Result { + exported + .0 + .into_iter() + .map(|entry| { + let mut element = OTPElement::from_otp_uri(&entry.uri)?; + element.label = entry.name.clone(); + Ok(element) + }) + .collect() + } +} diff --git a/src/importers/converted.rs b/src/importers/converted.rs index 9ad3ede4..1cce920a 100644 --- a/src/importers/converted.rs +++ b/src/importers/converted.rs @@ -19,6 +19,11 @@ impl From for OTPElement { let counter: Option = (OTPType::from(converted_json.type_.as_str()) == OTPType::Hotp) .then_some(converted_json.counter); OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: converted_json.secret, issuer: converted_json.issuer.unwrap_or_default(), label: converted_json.label.unwrap_or_default(), diff --git a/src/importers/enrollment.rs b/src/importers/enrollment.rs new file mode 100644 index 00000000..2f56854c --- /dev/null +++ b/src/importers/enrollment.rs @@ -0,0 +1,357 @@ +//! Local-only enrollment and import planning. Never include secret input in diagnostics. +use crate::otp::{ + from_otp_uri::FromOtpUri, + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, +}; +use clap::ValueEnum; +use color_eyre::eyre::{Result, eyre}; +use serde_json::Value; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum DuplicatePolicy { + #[default] + Skip, + Replace, + KeepBoth, +} + +#[derive(Default)] +pub struct ImportReport { + pub elements: Vec, + pub errors: Vec, +} + +impl ImportReport { + pub fn record(&mut self, index: usize, result: Result>) { + match result { + Ok(elements) => { + for (entry, mut element) in elements.into_iter().enumerate() { + match validate(&mut element) { + Ok(()) => self.elements.push(element), + Err(error) => self + .errors + .push(format!("Entry {index}.{}: {error}", entry + 1)), + } + } + } + // Deserializers may echo invalid input (including secrets). Keep diagnostics redacted. + Err(_) => self.errors.push(format!( + "Entry {index}: invalid or unsupported account data" + )), + } + } +} + +pub fn validate(element: &mut OTPElement) -> Result<()> { + element.secret = element.secret.trim().replace(' ', ""); + if element.type_ == OTPType::Motp { + element.secret.make_ascii_lowercase(); + hex::decode(&element.secret).map_err(|_| eyre!("invalid hex secret"))?; + } else { + element.secret.make_ascii_uppercase(); + element.secret = element.secret.trim_end_matches('=').to_owned(); + data_encoding::BASE32_NOPAD + .decode(element.secret.as_bytes()) + .map_err(|_| eyre!("invalid base32 secret"))?; + } + if element.secret.is_empty() { + return Err(eyre!("empty secret")); + } + if !(1..=10).contains(&element.digits) { + return Err(eyre!("digits must be between 1 and 10")); + } + if element.period == 0 { + return Err(eyre!("period must be positive")); + } + if element.type_ == OTPType::Hotp && element.counter.is_none() { + return Err(eyre!("HOTP counter is required")); + } + if matches!(element.type_, OTPType::Motp | OTPType::Yandex) && element.pin.is_none() { + return Err(eyre!("PIN is required")); + } + Ok(()) +} + +pub fn parse_uris(content: &str) -> ImportReport { + let mut report = ImportReport::default(); + for (index, uri) in content.split_whitespace().enumerate() { + let result = if uri.starts_with("otpauth-migration://") { + super::google_authenticator::parse_migration_uri(uri) + } else if uri.starts_with("otpauth://") { + OTPElement::from_otp_uri(uri).map(|e| vec![e]) + } else { + Err(eyre!("unsupported URI")) + }; + report.record(index + 1, result); + } + report +} + +pub fn decode_qr(bytes: &[u8]) -> Result { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16384); + limits.max_image_height = Some(16384); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let gray = reader.decode()?.to_luma8(); + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( + gray.width() as usize, + gray.height() as usize, + |x, y| gray.get_pixel(x as u32, y as u32).0[0], + ); + let grids = prepared.detect_grids(); + if grids.is_empty() { + return Err(eyre!("No QR code detected in image")); + } + let mut report = ImportReport::default(); + for (index, grid) in grids.into_iter().enumerate() { + match grid.decode() { + Ok((_, text)) => { + let decoded = parse_uris(&text); + report.elements.extend(decoded.elements); + report.errors.extend( + decoded + .errors + .into_iter() + .map(|e| format!("QR {}: {e}", index + 1)), + ); + } + Err(_) => report + .errors + .push(format!("QR {}: could not decode QR data", index + 1)), + } + } + Ok(report) +} + +/// Decode container entries independently, preserving source entry numbers on errors. +pub fn parse_json(value: Value, format: &str) -> Result { + if matches!(format, "twofas" | "bitwarden" | "onepassword") { + return super::modern::parse(&value, format); + } + let mut report = ImportReport::default(); + let (pointer, wrapper): (&str, fn(Value) -> Value) = match format { + "cotp" => ( + "/elements", + |v| serde_json::json!({"version":2,"elements":[v]}), + ), + "andotp" | "converted" | "authy-exported" => ("", |v| serde_json::json!([v])), + "aegis" => ("/db/entries", |v| serde_json::json!({"db":{"entries":[v]}})), + "freeotp-plus" => ( + "/tokens", + |v| serde_json::json!({"tokenOrder":[],"tokens":[v]}), + ), + "otp-uri" => ("/items", |v| serde_json::json!({"items":[v]})), + _ => return Err(eyre!("Unsupported import format")), + }; + let entries = value + .pointer(pointer) + .and_then(Value::as_array) + .ok_or_else(|| eyre!("Import file does not contain the expected account list"))?; + let aegis_groups = value + .pointer("/db/groups") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + for (index, value) in entries.iter().enumerate() { + let value = if format == "aegis" { + serde_json::json!({"db":{"entries":[value],"groups":aegis_groups}}) + } else { + wrapper(value.clone()) + }; + let result = match format { + "cotp" => convert::(value), + "andotp" => convert::>(value), + "converted" => convert::(value), + "aegis" => convert::(value), + "freeotp-plus" => convert::(value), + "authy-exported" => convert::(value), + "otp-uri" => convert::(value), + _ => unreachable!(), + }; + report.record(index + 1, result); + } + Ok(report) +} + +pub fn convert(value: Value) -> Result> +where + T: serde::de::DeserializeOwned + TryInto>, + T::Error: std::fmt::Debug, +{ + serde_json::from_value::(value)? + .try_into() + .map_err(|_| eyre!("Invalid account data")) +} + +pub fn detect_format(value: &Value) -> Result<&'static str> { + if value.get("services").is_some() || value.get("servicesEncrypted").is_some() { + Ok("twofas") + } else if value.get("accounts").is_some() { + Ok("onepassword") + } else if value.get("encrypted").is_some() { + Ok("bitwarden") + } else if value.get("elements").is_some() { + Ok("cotp") + } else if value.pointer("/db/entries").is_some() { + Ok("aegis") + } else if value.get("db").is_some_and(Value::is_string) { + Ok("aegis-encrypted") + } else if value.get("tokens").is_some() { + Ok("freeotp-plus") + } else if value.get("items").is_some() { + Ok("otp-uri") + } else if let Some(first) = value.as_array().and_then(|a| a.first()) { + if first.get("period").is_some() { + Ok("andotp") + } else if first.get("algorithm").is_some() { + Ok("converted") + } else if first.get("uri").is_some() { + Ok("authy-exported") + } else { + Err(eyre!( + "Cannot detect JSON format; specify an import format flag" + )) + } + } else { + Err(eyre!( + "Cannot detect JSON format; specify an import format flag" + )) + } +} + +fn same_account(a: &OTPElement, b: &OTPElement) -> bool { + a.secret + .trim_end_matches('=') + .eq_ignore_ascii_case(b.secret.trim_end_matches('=')) + && a.type_ == b.type_ + && a.algorithm == b.algorithm + && a.digits == b.digits + && a.period == b.period + && a.pin == b.pin +} + +pub fn apply( + database: &mut OTPDatabase, + elements: Vec, + policy: DuplicatePolicy, +) -> Result<(usize, usize, usize)> { + let (mut added, mut replaced, mut skipped) = (0, 0, 0); + for mut element in elements { + let duplicate = database + .elements_ref() + .iter() + .position(|old| same_account(old, &element)); + match (duplicate, policy) { + (Some(_), DuplicatePolicy::Skip) => skipped += 1, + (Some(index), DuplicatePolicy::Replace) => { + let old = database.mut_element(index).unwrap(); + // Importing an older backup must never roll an HOTP counter backwards. + if element.type_ == OTPType::Hotp { + element.counter = old.counter.max(element.counter); + } + element.id = old.id.clone(); + element.alias = old.alias.clone(); + element.tags = old.tags.clone(); + element.favorite = old.favorite; + element.order = old.order; + *old = element; + database.mark_modified(); + replaced += 1; + } + _ => { + database.add_element(element)?; + added += 1; + } + } + } + Ok((added, replaced, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + const URI: &str = "otpauth://totp/Example:alice?secret=JBSWY3DP"; + #[test] + fn detects_and_decodes_every_qr_in_screenshot() { + let qr = qrcode::QrCode::new(URI) + .unwrap() + .render::>() + .min_dimensions(256, 256) + .build(); + let mut screenshot = + image::GrayImage::from_pixel(qr.width() * 2 + 40, qr.height(), image::Luma([255])); + image::imageops::overlay(&mut screenshot, &qr, 0, 0); + image::imageops::overlay(&mut screenshot, &qr, i64::from(qr.width() + 40), 0); + let mut bytes = std::io::Cursor::new(Vec::new()); + screenshot + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let report = decode_qr(bytes.get_ref()).unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + } + #[test] + fn repeated_import_is_idempotent_and_hotp_never_rolls_back() { + let mut database = OTPDatabase::default(); + let uri = "otpauth://hotp/Example:alice?secret=JBSWY3DP&counter=99"; + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::Skip + ) + .unwrap(), + (1, 0, 0) + ); + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::Skip + ) + .unwrap(), + (0, 0, 1) + ); + let old_uri = uri.replace("99", "5"); + assert_eq!( + apply( + &mut database, + parse_uris(&old_uri).elements, + DuplicatePolicy::Replace + ) + .unwrap(), + (0, 1, 0) + ); + assert_eq!(database.elements_ref()[0].counter, Some(99)); + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::KeepBoth + ) + .unwrap(), + (1, 0, 0) + ); + } + #[test] + fn invalid_entries_are_reported_without_exposing_input() { + let report = parse_uris(&format!("{URI}\notpauth://totp/hidden?secret=TOPSECRET")); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.errors.len(), 1); + assert!(!report.errors[0].contains("TOPSECRET")); + } + #[test] + fn json_entries_fail_independently() { + let report = parse_json(serde_json::json!({"items":[URI,42]}), "otp-uri").unwrap(); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.errors.len(), 1); + } + #[test] + fn freeotp_hotp_counter_survives_import() { + let report = parse_json(serde_json::json!({"tokens":[{"type":"HOTP","algo":"SHA1","counter":42,"digits":6,"issuerExt":"Example","label":"alice","period":30,"secret":[72,101,108,108,111]}]}),"freeotp-plus").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements[0].counter, Some(42)); + } +} diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index fab6e723..625c5a47 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -46,12 +46,17 @@ pub struct FreeOTPElement { impl From for OTPElement { fn from(token: FreeOTPElement) -> Self { - let counter: Option = if token.algo.to_uppercase().as_str() == "HOTP" { + let counter: Option = if token.r#type.to_uppercase().as_str() == "HOTP" { Some(token.counter) } else { None }; OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, counter, secret: encode_secret(&token.secret), issuer: token.issuer_ext, @@ -120,6 +125,11 @@ mod tests { assert_eq!( vec![ OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "AAAAAAAAAAAAAAAA".to_string(), issuer: "Example2".to_string(), label: "Label2".to_string(), @@ -131,6 +141,11 @@ mod tests { pin: None }, OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "AAAAAAAA".to_string(), issuer: "Example1".to_string(), label: "Label1".to_string(), diff --git a/src/importers/google_authenticator.rs b/src/importers/google_authenticator.rs index 7287a336..23c02473 100644 --- a/src/importers/google_authenticator.rs +++ b/src/importers/google_authenticator.rs @@ -12,8 +12,6 @@ //! the hand-declared message definitions below (Google's schema is stable and //! tiny, so no `.proto`/`protoc` build step is needed). -use std::{fs::read_to_string, path::PathBuf}; - use base64::{Engine as _, engine::general_purpose}; use color_eyre::eyre::{Result, eyre}; use data_encoding::BASE32_NOPAD; @@ -60,15 +58,9 @@ const ALGORITHM_MD5: i32 = 4; const DIGIT_COUNT_EIGHT: i32 = 2; const OTP_TYPE_HOTP: i32 = 1; -/// Reads a file containing one or more `otpauth-migration://` URIs and decodes -/// every OTP parameter contained in them. -pub fn import_from_google_authenticator(path: PathBuf) -> Result> { - let content = read_to_string(path)?; - import_from_string(&content) -} - /// Parses every `otpauth-migration://` URI found in `content` (tokens are /// split on whitespace, so both single- and multi-line inputs work). +#[cfg(test)] fn import_from_string(content: &str) -> Result> { let uris: Vec<&str> = content .split_whitespace() @@ -91,7 +83,7 @@ fn import_from_string(content: &str) -> Result> { } /// Decodes a single `otpauth-migration://offline?data=...` URI. -fn parse_migration_uri(uri: &str) -> Result> { +pub(crate) fn parse_migration_uri(uri: &str) -> Result> { let parsed = Url::parse(uri)?; if parsed.scheme() != MIGRATION_SCHEME { @@ -130,24 +122,33 @@ fn into_otp_element(params: OtpParameters) -> Result { ALGORITHM_SHA512 => OTPAlgorithm::Sha512, ALGORITHM_MD5 => OTPAlgorithm::Md5, // 0 (unspecified) and 1 (SHA1) both map to SHA1. - _ => OTPAlgorithm::Sha1, + 0 | 1 => OTPAlgorithm::Sha1, + _ => return Err(eyre!("Unsupported migration algorithm")), }; - let digits = if params.digits == DIGIT_COUNT_EIGHT { - 8 - } else { - 6 + let digits = match params.digits { + DIGIT_COUNT_EIGHT => 8, + 0 | 1 => 6, + _ => return Err(eyre!("Unsupported migration digits")), }; - let type_ = if params.otp_type == OTP_TYPE_HOTP { - OTPType::Hotp - } else { - OTPType::Totp + let type_ = match params.otp_type { + OTP_TYPE_HOTP => OTPType::Hotp, + 0 | 2 => OTPType::Totp, + _ => return Err(eyre!("Unsupported migration OTP type")), }; + if params.counter < 0 { + return Err(eyre!("Negative HOTP counter")); + } let counter = (type_ == OTPType::Hotp).then_some(params.counter.max(0) as u64); Ok(OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: BASE32_NOPAD.encode(¶ms.secret), issuer: params.issuer, label: params.name, @@ -214,6 +215,11 @@ mod tests { assert_eq!( vec![OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "JBSWY3DP".to_string(), issuer: "Example".to_string(), label: "alice@google.com".to_string(), @@ -237,6 +243,11 @@ mod tests { assert_eq!( vec![OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "JBSWY3DP".to_string(), issuer: "Acme".to_string(), label: "bob".to_string(), diff --git a/src/importers/mod.rs b/src/importers/mod.rs index 24f27de8..231d7c04 100644 --- a/src/importers/mod.rs +++ b/src/importers/mod.rs @@ -2,7 +2,10 @@ pub mod aegis; pub mod aegis_encrypted; pub mod authy_remote_debug; pub mod converted; +pub mod enrollment; pub mod freeotp_plus; pub mod google_authenticator; +#[cfg(test)] pub mod importer; +pub mod modern; pub mod otp_uri; diff --git a/src/importers/modern.rs b/src/importers/modern.rs new file mode 100644 index 00000000..ad3224a4 --- /dev/null +++ b/src/importers/modern.rs @@ -0,0 +1,332 @@ +//! Native plaintext migration formats. See docs/imports.md for primary schemas. +use super::enrollment::ImportReport; +use crate::otp::{ + from_otp_uri::FromOtpUri, + otp_element::{OTPElement, OTPElementBuilder}, + otp_type::OTPType, +}; +use color_eyre::eyre::{Result, eyre}; +use serde_json::Value; +use std::io::{Cursor, Read}; + +pub fn onepassword_archive(bytes: &[u8]) -> Result { + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| eyre!("Invalid 1PUX archive"))?; + let mut entry = archive + .by_name("export.data") + .map_err(|_| eyre!("1PUX archive is missing export.data"))?; + if entry.size() > 32 * 1024 * 1024 { + return Err(eyre!("1PUX export.data exceeds 32 MiB")); + } + let mut json = zeroize::Zeroizing::new(Vec::new()); + entry + .by_ref() + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut json)?; + if json.len() > 32 * 1024 * 1024 { + return Err(eyre!("1PUX export.data exceeds 32 MiB")); + } + let value = + serde_json::from_slice(&json).map_err(|_| eyre!("Invalid 1PUX export.data JSON"))?; + parse(&value, "onepassword") +} + +fn array<'a>(value: &'a Value, field: &str) -> Result<&'a [Value]> { + value + .get(field) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| eyre!("Missing account container: {field}")) +} +fn text<'a>(value: &'a Value, pointer: &str) -> &'a str { + value.pointer(pointer).and_then(Value::as_str).unwrap_or("") +} + +pub fn parse(value: &Value, format: &str) -> Result { + let mut report = ImportReport::default(); + match format { + "twofas" => { + if value + .get("servicesEncrypted") + .is_some_and(|v| !v.is_null() && v.as_str() != Some("")) + { + return Err(eyre!( + "Encrypted 2FAS backups are not supported; export an unencrypted .2fas file" + )); + } + if value + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v > 4) + { + return Err(eyre!("Unsupported 2FAS backup schema version")); + } + for (index, service) in array(value, "services")?.iter().enumerate() { + report.record( + index + 1, + twofas(service).map(|mut e| { + if let Some(group) = value + .get("groups") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|g| g.get("id") == service.get("groupId")) + && let Some(name) = group.get("name").and_then(Value::as_str) + { + e.tags.push(name.to_owned()); + } + vec![e] + }), + ); + } + } + "bitwarden" => { + if value.get("encrypted").and_then(Value::as_bool) == Some(true) { + return Err(eyre!( + "Encrypted Bitwarden exports are not supported; use an unencrypted JSON export" + )); + } + for (index, item) in array(value, "items")?.iter().enumerate() { + let Some(totp) = item.pointer("/login/totp").filter(|v| !v.is_null()) else { + continue; + }; + if totp.as_str() == Some("") { + continue; + } + let result = totp + .as_str() + .ok_or_else(|| eyre!("Invalid TOTP value")) + .and_then(|secret| { + from_secret_or_uri( + secret, + text(item, "/name"), + text(item, "/login/username"), + ) + }) + .map(|mut e| { + e.favorite = item + .get("favorite") + .and_then(Value::as_bool) + .unwrap_or(false); + vec![e] + }); + report.record(index + 1, result); + } + } + "onepassword" => { + let mut index = 0; + for account in array(value, "accounts")? { + for vault in array(account, "vaults")? { + for item in array(vault, "items")? { + for section in item + .pointer("/details/sections") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + for field in section + .get("fields") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(totp) = + field.pointer("/value/totp").filter(|v| !v.is_null()) + else { + continue; + }; + if totp.as_str() == Some("") { + continue; + } + index += 1; + let result = totp + .as_str() + .ok_or_else(|| eyre!("Invalid TOTP value")) + .and_then(|secret| { + from_secret_or_uri( + secret, + text(item, "/overview/title"), + text(item, "/overview/subtitle"), + ) + }) + .map(|mut e| { + e.tags = item + .pointer("/overview/tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + e.favorite = item + .get("favIndex") + .and_then(Value::as_i64) + .is_some_and(|v| v > 0); + vec![e] + }); + report.record(index, result); + } + } + } + } + } + } + _ => return Err(eyre!("Unknown migration format")), + } + Ok(report) +} + +fn from_secret_or_uri(secret: &str, issuer: &str, label: &str) -> Result { + if secret.starts_with("otpauth://") { + let mut element = OTPElement::from_otp_uri(secret)?; + if element.issuer.is_empty() { + element.issuer = issuer.to_owned(); + } + if element.label.is_empty() { + element.label = label.to_owned(); + } + return Ok(element); + } + if let Some(secret) = secret.strip_prefix("steam://") { + return OTPElementBuilder::default() + .secret(secret) + .issuer(issuer) + .label(label) + .type_(OTPType::Steam) + .digits(5u64) + .build(); + } + OTPElementBuilder::default() + .secret(secret) + .issuer(issuer) + .label(label) + .build() +} + +fn twofas(service: &Value) -> Result { + let otp = service + .get("otp") + .ok_or_else(|| eyre!("Missing OTP parameters"))?; + let secret = service + .get("secret") + .and_then(Value::as_str) + .ok_or_else(|| eyre!("Missing OTP secret"))?; + let kind = otp + .get("tokenType") + .and_then(Value::as_str) + .unwrap_or("TOTP") + .to_ascii_lowercase(); + let issuer = otp + .get("issuer") + .and_then(Value::as_str) + .unwrap_or(text(service, "/name")); + let label = otp + .get("account") + .and_then(Value::as_str) + .or_else(|| otp.get("label").and_then(Value::as_str)) + .unwrap_or(text(service, "/name")); + // A URI funnels all format validation through the same strict path as QR enrollment. + let mut uri = url::Url::parse(&format!("otpauth://{kind}/"))?; + uri.set_path(&format!("{issuer}:{label}")); + { + let mut query = uri.query_pairs_mut(); + query.append_pair("secret", secret); + for (key, default) in [("digits", "6"), ("period", "30"), ("algorithm", "SHA1")] { + match otp.get(key).filter(|v| !v.is_null()) { + Some(Value::String(value)) => { + query.append_pair(key, value); + } + Some(Value::Number(value)) => { + query.append_pair(key, &value.to_string()); + } + None => { + query.append_pair(key, default); + } + _ => return Err(eyre!("Invalid OTP parameter")), + } + } + if let Some(counter) = otp.get("counter").filter(|v| !v.is_null()) { + query.append_pair("counter", &counter.to_string()); + } + } + let mut element = OTPElement::from_otp_uri(uri.as_str())?; + element.order = service.pointer("/order/position").and_then(Value::as_i64); + Ok(element) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_algorithm::OTPAlgorithm; + use serde_json::json; + const TWOFAS: &str = include_str!("../../test_samples/migrations/twofas.2fas"); + const BITWARDEN: &str = include_str!("../../test_samples/migrations/bitwarden.json"); + const ONEPASSWORD: &str = include_str!("../../test_samples/migrations/onepassword.data"); + + #[test] + fn official_schema_fixtures_preserve_parameters_and_metadata() { + let report = parse(&serde_json::from_str(TWOFAS).unwrap(), "twofas").unwrap(); + assert!(report.errors.is_empty()); + let e = &report.elements[0]; + assert_eq!(e.type_, OTPType::Hotp); + assert_eq!(e.counter, Some(42)); + assert_eq!(e.digits, 8); + assert_eq!(e.algorithm, OTPAlgorithm::Sha256); + assert_eq!(e.tags, ["work"]); + assert_eq!(e.order, Some(2)); + let report = parse(&serde_json::from_str(BITWARDEN).unwrap(), "bitwarden").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + assert!(report.elements[0].favorite); + assert_eq!(report.elements[0].period, 60); + assert_eq!(report.elements[1].label, "bob"); + let report = parse(&serde_json::from_str(ONEPASSWORD).unwrap(), "onepassword").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.elements[0].algorithm, OTPAlgorithm::Sha512); + assert_eq!(report.elements[0].tags, ["work"]); + assert!(report.elements[0].favorite); + let report = crate::importers::enrollment::parse_uris(include_str!( + "../../test_samples/migrations/ente.txt" + )); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + assert_eq!(report.elements[1].counter, Some(42)); + } + + #[test] + fn onepassword_zip_reads_export_data_without_extracting_attachments() { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file("export.data", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(ONEPASSWORD.as_bytes()).unwrap(); + writer + .start_file("files/attachment", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"not an OTP").unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + let report = onepassword_archive(&bytes).unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 1); + assert!(onepassword_archive(b"not a ZIP").is_err()); + } + + #[test] + fn unsupported_encryption_is_rejected_and_errors_redact_secrets() { + assert!( + parse( + &json!({"servicesEncrypted":"ciphertext","services":[]}), + "twofas" + ) + .is_err() + ); + assert!(parse(&json!({"encrypted":true,"items":[]}), "bitwarden").is_err()); + assert!(parse(&json!({"schemaVersion":99,"services":[]}), "twofas").is_err()); + let report = parse(&json!({"items":[{"login":{"totp":"TOP_SECRET_INVALID"}},{"login":{"totp":"JBSWY3DP"}}]}), "bitwarden").unwrap(); + assert_eq!(report.errors.len(), 1); + assert!(!report.errors[0].contains("TOP_SECRET")); + assert_eq!(report.elements.len(), 1); + } +} diff --git a/src/importers/otp_uri.rs b/src/importers/otp_uri.rs index 761dd859..25ab9711 100644 --- a/src/importers/otp_uri.rs +++ b/src/importers/otp_uri.rs @@ -28,6 +28,11 @@ mod tests { fn test_conversion() { //Arrange let expected_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("AA"), issuer: String::default(), label: String::from("test"), diff --git a/src/interface/app.rs b/src/interface/app.rs index ce4a6d54..6987eb81 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -13,6 +13,9 @@ pub type AppResult = Result>; pub struct App<'a> { pub running: bool, + pub(crate) sealed: Option, + pub(crate) unlock_password: zeroize::Zeroizing, + pub(crate) last_activity: Instant, pub(crate) table: StatefulTable, pub(crate) database: &'a mut OTPDatabase, pub(crate) current_page: Page, @@ -27,6 +30,7 @@ pub struct App<'a> { pub(crate) hitboxes: Vec<(ratatui::layout::Rect, Action)>, } +#[derive(serde::Serialize, serde::Deserialize)] pub struct Popup { pub(crate) text: String, pub(crate) action: PopupAction, @@ -36,6 +40,9 @@ impl<'a> App<'a> { pub fn new(database: &'a mut OTPDatabase) -> Self { Self { running: true, + sealed: None, + unlock_password: zeroize::Zeroizing::new(String::new()), + last_activity: Instant::now(), table: StatefulTable::new(database.elements_ref()), database, current_page: Page::default(), @@ -47,7 +54,7 @@ impl<'a> App<'a> { }, popup_scroll: 0, notification: None, - hide_codes: false, + hide_codes: crate::config::settings().hide_codes, now: unix_seconds(), form: None, hitboxes: Vec::new(), @@ -69,6 +76,14 @@ impl<'a> App<'a> { } pub fn tick(&mut self, force_update: bool) { + if self.sealed.is_some() { + return; + } + let timeout = crate::config::settings().idle_timeout_seconds; + if timeout > 0 && self.last_activity.elapsed() >= Duration::from_secs(timeout) { + self.lock_session(); + return; + } let now = unix_seconds(); if force_update || now != self.now { self.filter(); @@ -90,3 +105,111 @@ fn unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +#[derive(serde::Serialize, serde::Deserialize)] +struct Snapshot { + database: OTPDatabase, + modified: bool, + form: Option, + search: String, + focus: Focus, + page: Page, + popup: Popup, + popup_scroll: u16, + selected: Option, +} +impl App<'_> { + pub(crate) fn lock_session(&mut self) { + use zeroize::Zeroize; + if self.sealed.is_some() { + return; + } + let result = (|| -> color_eyre::Result { + // Serialize by reference, preserving the unsaved flag omitted by the on-disk format. + #[derive(serde::Serialize)] + struct State<'a> { + database: &'a OTPDatabase, + modified: bool, + form: Option, + search: &'a str, + focus: &'a Focus, + page: &'a Page, + popup: &'a Popup, + popup_scroll: u16, + selected: Option, + } + let state = State { + database: self.database, + modified: self.database.is_modified(), + form: self.form.as_ref().map(|f| f.snapshot()), + search: &self.search_query, + focus: &self.focus, + page: &self.current_page, + popup: &self.popup, + popup_scroll: self.popup_scroll, + selected: self.table.selected_index(), + }; + let plaintext = zeroize::Zeroizing::new(serde_json::to_string(&state)?); + crate::persistence::seal(&plaintext) + })(); + match result { + Ok(cipher) => { + self.sealed = Some(cipher); + *self.database = OTPDatabase::default(); + self.form = None; + self.table = StatefulTable::new(&[]); + self.search_query.zeroize(); + self.popup.text.zeroize(); + if let Some((message, _, _)) = self.notification.as_mut() { + message.zeroize(); + } + self.notification = None; + self.hitboxes.clear(); + crate::persistence::lock(); + } + Err(error) => self.notify(format!("Could not lock: {error}"), true), + } + } + pub(crate) fn unlock_session(&mut self) { + use zeroize::Zeroize; + let Some(cipher) = self.sealed.clone() else { + return; + }; + let result = (|| -> color_eyre::Result<()> { + let (text, key, salt) = + crate::crypto::cryptography::decrypt_string(&cipher, &self.unlock_password)?; + let text = zeroize::Zeroizing::new(text); + let key = zeroize::Zeroizing::new(key); + let state: Snapshot = serde_json::from_str(&text)?; + crate::persistence::unlock(&key, &salt)?; + *self.database = state.database; + self.database.needs_modification = state.modified; + self.form = state.form.map(super::form::AccountForm::restore); + self.search_query = state.search; + self.focus = state.focus; + self.current_page = state.page; + self.popup = state.popup; + self.popup_scroll = state.popup_scroll; + self.filter(); + if let Some(selected) = state.selected { + self.table.state.select( + self.table + .items + .iter() + .position(|row| row.database_index == selected), + ); + } + Ok(()) + })(); + self.unlock_password.zeroize(); + match result { + Ok(()) => { + self.sealed = None; + self.last_activity = Instant::now(); + self.filter(); + self.notify("Unlocked · pending edits restored", false); + } + Err(_) => self.notify("Could not unlock. Check your password.", true), + } + } +} diff --git a/src/interface/enums.rs b/src/interface/enums.rs index 4337beb5..e5826720 100644 --- a/src/interface/enums.rs +++ b/src/interface/enums.rs @@ -1,4 +1,4 @@ -#[derive(Eq, PartialEq, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)] pub enum Focus { MainPage, SearchBar, @@ -6,14 +6,14 @@ pub enum Focus { Form, } -#[derive(Eq, PartialEq, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)] pub enum PopupAction { DeleteOtp, GeneralInfo, SaveBeforeQuit, } -#[derive(Eq, PartialEq, Debug, Default)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug, Default)] pub enum Page { #[default] Main, diff --git a/src/interface/form.rs b/src/interface/form.rs index 0ff24224..044d4ba6 100644 --- a/src/interface/form.rs +++ b/src/interface/form.rs @@ -85,7 +85,57 @@ pub(crate) struct AccountForm { pub(crate) error: Option, } +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct FormSnapshot { + editing: Option, + original: Option, + inputs: [String; 9], + secret: String, + pin: String, + active: usize, + error: Option, +} +impl Drop for FormSnapshot { + fn drop(&mut self) { + self.secret.zeroize(); + self.pin.zeroize(); + self.inputs.zeroize(); + if let Some(error) = self.error.as_mut() { + error.zeroize(); + } + } +} + impl AccountForm { + pub(crate) fn snapshot(&self) -> FormSnapshot { + FormSnapshot { + editing: self.editing, + original: self.original.clone(), + inputs: std::array::from_fn(|i| self.inputs[i].value().to_owned()), + secret: self.secret.value.to_string(), + pin: self.pin.value.to_string(), + active: self.active, + error: self.error.clone(), + } + } + pub(crate) fn restore(mut state: FormSnapshot) -> Self { + Self { + editing: state.editing, + original: state.original.take(), + inputs: std::array::from_fn(|i| Input::new(std::mem::take(&mut state.inputs[i]))), + secret: SecretInput { + value: Zeroizing::new(std::mem::take(&mut state.secret)), + cursor: 0, + }, + pin: SecretInput { + value: Zeroizing::new(std::mem::take(&mut state.pin)), + cursor: 0, + }, + active: state.active, + error: state.error.take(), + } + } + pub(crate) fn new(editing: Option, element: Option<&OTPElement>) -> Self { let mut form = Self { editing, @@ -331,6 +381,19 @@ impl AccountForm { None }; let element = OTPElement { + id: self + .original + .as_ref() + .map(|e| e.id.clone()) + .unwrap_or_default(), + alias: self.original.as_ref().and_then(|e| e.alias.clone()), + tags: self + .original + .as_ref() + .map(|e| e.tags.clone()) + .unwrap_or_default(), + favorite: self.original.as_ref().is_some_and(|e| e.favorite), + order: self.original.as_ref().and_then(|e| e.order), secret: if kind == OTPType::Motp { secret.to_lowercase() } else { @@ -395,7 +458,10 @@ impl App<'_> { *existing = element; self.database.mark_modified(); } else { - self.database.add_element(element); + if let Err(error) = self.database.add_element(element) { + form.error = Some(format!("Could not add account: {error}")); + return; + } } self.cancel_form(); self.search_query.clear(); @@ -446,3 +512,14 @@ pub(crate) fn handle_form_key(key: KeyEvent, app: &mut App) { _ => form.input(key), } } + +impl Drop for AccountForm { + fn drop(&mut self) { + for input in &mut self.inputs { + input.value_and_reset().zeroize(); + } + if let Some(error) = self.error.as_mut() { + error.zeroize(); + } + } +} diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs index e55cc413..917d1973 100644 --- a/src/interface/handlers/main_window.rs +++ b/src/interface/handlers/main_window.rs @@ -64,6 +64,26 @@ pub(super) fn main_handler(key_event: KeyEvent, app: &mut App) { } KeyCode::Char('+') => handle_counter_switch(app, true), KeyCode::Char('-') => handle_counter_switch(app, false), + KeyCode::Char('f') if key_event.modifiers.is_empty() && app.current_page == Page::Main => { + if let Some(index) = app.table.selected_index() { + let element = app + .database + .mut_element(index) + .expect("selected account exists"); + element.favorite = !element.favorite; + let favorite = element.favorite; + app.database.mark_modified(); + app.filter(); + app.notify( + if favorite { + "Added to favorites" + } else { + "Removed from favorites" + }, + false, + ); + } + } KeyCode::Char('h') => { app.hide_codes = !app.hide_codes; app.reset(); diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs index 988e9cd8..984e113d 100644 --- a/src/interface/handlers/mod.rs +++ b/src/interface/handlers/mod.rs @@ -13,10 +13,45 @@ mod popup; mod search_bar; pub fn handle_key_events(key_event: KeyEvent, app: &mut App) { + if app.sealed.is_some() { + match key_event.code { + KeyCode::Enter => app.unlock_session(), + KeyCode::Backspace => { + let mut replacement = zeroize::Zeroizing::new(app.unlock_password.to_string()); + replacement.pop(); + app.unlock_password = replacement; + } + KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + use zeroize::Zeroize; + app.unlock_password.zeroize(); + } + KeyCode::Char(c) + if !key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + app.unlock_password.push(c) + } + _ => {} + } + return; + } + app.last_activity = std::time::Instant::now(); + if key_event.code == KeyCode::Char('l') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + app.lock_session(); + return; + } if app.focus == Focus::Form { super::form::handle_form_key(key_event, app); return; } + if key_event.code == KeyCode::Char('s') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + match crate::persistence::persist(app.database) { + Ok(()) => app.notify("Changes saved to encrypted vault", false), + Err(error) => app.notify(format!("Could not save: {error}"), true), + } + return; + } if key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c' | 'd')) { @@ -32,6 +67,10 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) { } pub fn handle_mouse_events(event: MouseEvent, app: &mut App) { + if app.sealed.is_some() { + return; + } + app.last_activity = std::time::Instant::now(); if event.kind == MouseEventKind::Down(MouseButton::Left) { let action = app .hitboxes @@ -109,6 +148,10 @@ pub fn handle_mouse_events(event: MouseEvent, app: &mut App) { } pub fn handle_paste(text: &str, app: &mut App) { + if app.sealed.is_some() { + return; + } + app.last_activity = std::time::Instant::now(); if let Some(form) = app.form.as_mut() { form.paste(text); return; @@ -135,20 +178,32 @@ pub(crate) fn copy_selected(app: &mut App, uri: bool) { let result = app .table .selected_index() - .and_then(|index| app.database.get_element(index)) .ok_or_else(|| "No account selected".to_string()) - .and_then(|element| { + .and_then(|index| { + let element = app + .database + .get_element(index) + .ok_or_else(|| "No account selected".to_string())?; if uri { Ok(element.get_otpauth_uri()) + } else if element.type_ == crate::otp::otp_type::OTPType::Hotp { + crate::persistence::consume_hotp(app.database, index) + .map_err(|error| error.to_string()) } else { code_for(element) } }) - .and_then(|text| copy_string_to_clipboard(&text).map_err(|error| error.to_string())); + .and_then(|text| { + copy_string_to_clipboard(&zeroize::Zeroizing::new(text)) + .map_err(|error| error.to_string()) + }); let noun = if uri { "OTP URI" } else { "Code" }; match result { Ok(CopyType::Native) => app.notify(format!("✓ {noun} copied to clipboard"), false), - Ok(CopyType::OSC52) => app.notify(format!("✓ {noun} sent to terminal clipboard"), false), + Ok(CopyType::OSC52) => app.notify( + format!("✓ {noun} sent to terminal clipboard · automatic clearing unavailable"), + false, + ), Err(error) => app.notify(format!("Could not copy: {error}"), true), } } diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs index 94a0f7d2..5ab3e17b 100644 --- a/src/interface/handlers/popup.rs +++ b/src/interface/handlers/popup.rs @@ -28,7 +28,7 @@ pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) { }, PopupAction::GeneralInfo => match key_event.code { KeyCode::Down | KeyCode::Char('j') => { - app.popup_scroll = app.popup_scroll.saturating_add(1).min(14) + app.popup_scroll = app.popup_scroll.saturating_add(1).min(15) } KeyCode::Up | KeyCode::Char('k') => { app.popup_scroll = app.popup_scroll.saturating_sub(1) diff --git a/src/interface/row.rs b/src/interface/row.rs index 00009c50..df04ece4 100644 --- a/src/interface/row.rs +++ b/src/interface/row.rs @@ -1,5 +1,6 @@ use crate::otp::{otp_element::OTPElement, otp_type::OTPType}; +#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop)] pub(crate) struct Row { /// Stable database position, independent of the filtered table position. pub(crate) database_index: usize, diff --git a/src/interface/stateful_table.rs b/src/interface/stateful_table.rs index a7ec0938..1f4f56a8 100644 --- a/src/interface/stateful_table.rs +++ b/src/interface/stateful_table.rs @@ -31,11 +31,7 @@ impl StatefulTable { self.items = elements .iter() .enumerate() - .filter(|(_, element)| { - format!("{} {}", element.issuer, element.label) - .to_lowercase() - .contains(&query) - }) + .filter(|(_, element)| crate::otp::organization::matches_search(element, &query)) .map(|(index, element)| Row::new(index, element)) .collect(); let position = selected diff --git a/src/interface/tests.rs b/src/interface/tests.rs index 8eca81b1..c6edbf7b 100644 --- a/src/interface/tests.rs +++ b/src/interface/tests.rs @@ -14,6 +14,11 @@ use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; fn account(issuer: &str, label: &str) -> OTPElement { OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".into(), issuer: issuer.into(), label: label.into(), @@ -742,3 +747,93 @@ fn record_ui_demo() { ) .unwrap(); } + +#[test] +fn locking_wipes_plaintext_and_key_and_restores_pending_database_and_form() { + use crate::{ + crypto::cryptography::{argon_derive_key, gen_salt}, + persistence, + }; + use zeroize::Zeroizing; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + std::fs::write(&path, b"unchanged disk").unwrap(); + persistence::initialize(&path).unwrap(); + let salt = gen_salt().unwrap(); + let key = Zeroizing::new(argon_derive_key(b"password", &salt).unwrap()); + persistence::unlock(&key, &salt).unwrap(); + let mut database = database(); + database.mark_modified(); + let mut app = App::new(&mut database); + app.open_form(false); + app.form.as_mut().unwrap().active = 2; + handle_paste("MYUNFINISHEDSECRET", &mut app); + handle_key_events( + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), + &mut app, + ); + assert!(app.sealed.is_some()); + assert!(app.database.elements_ref().is_empty()); + assert!(!app.database.is_modified()); + assert!(app.table.items.is_empty()); + assert!(app.form.is_none()); + assert!(app.search_query.is_empty()); + assert!(persistence::seal("cannot encrypt without key").is_err()); + assert!(!app.sealed.as_ref().unwrap().contains("MYUNFINISHEDSECRET")); + let locked = text(&render(&mut app, 100, 20)); + assert!(locked.contains("Locked")); + assert!(!locked.contains("alex@")); + app.unlock_password.push_str("wrong password"); + app.unlock_session(); + assert!(app.sealed.is_some()); + assert!(app.unlock_password.is_empty()); + app.unlock_password.push_str("password"); + app.unlock_session(); + assert!(app.sealed.is_none()); + assert_eq!(app.database.elements_ref().len(), 3); + assert!(app.database.is_modified()); + assert_eq!(app.focus, Focus::Form); + let form_state = serde_json::to_string(&app.form.as_ref().unwrap().snapshot()).unwrap(); + assert!(form_state.contains("MYUNFINISHEDSECRET")); + assert_eq!(std::fs::read(&path).unwrap(), b"unchanged disk"); + app.cancel_form(); + app.last_activity = std::time::Instant::now() - std::time::Duration::from_secs(301); + app.tick(false); + assert!(app.sealed.is_some()); + assert!(persistence::seal("locked again").is_err()); +} + +#[test] +fn organization_search_matches_tags_favorites_aliases_and_ids_and_toggle_tracks_identity() { + let mut db = database(); + let first = db.mut_element(0).unwrap(); + first.id = "stable-account-id".into(); + first.alias = Some("work-alias".into()); + first.tags = vec!["Work".into(), "Email".into()]; + first.favorite = true; + let mut app = App::new(&mut db); + let rendered = text(&render(&mut app, 120, 30)); + assert!(rendered.contains("★ GitHub")); + assert!(rendered.contains("@work-alias")); + assert!(rendered.contains("#Work #Email")); + for query in [ + "tag:work is:favorite", + "WORK-ALIAS", + "stable-account-id", + "tag:work tag:email", + ] { + app.search_query = query.into(); + app.filter(); + assert_eq!(app.table.items.len(), 1); + assert_eq!(app.table.selected_index(), Some(0)); + } + app.search_query = "tag:missing".into(); + app.filter(); + assert!(app.table.items.is_empty()); + app.search_query = "is:favorite".into(); + app.filter(); + key(&mut app, KeyCode::Char('f')); + assert!(!app.database.elements_ref()[0].favorite); + assert!(app.database.is_modified()); + assert!(app.table.items.is_empty()); +} diff --git a/src/interface/view.rs b/src/interface/view.rs index 0ebee393..89fbf6cb 100644 --- a/src/interface/view.rs +++ b/src/interface/view.rs @@ -25,6 +25,15 @@ impl App<'_> { ratatui::widgets::Block::default().style(Style::default().bg(BACKGROUND).fg(TEXT)), frame.area(), ); + if self.sealed.is_some() { + let error = self + .notification + .as_ref() + .map(|(message, _, _)| message.as_str()) + .unwrap_or(""); + frame.render_widget(Paragraph::new(format!("cotp · Locked\n\nEnter your vault password and press Enter to unlock.\nPending edits are encrypted in memory.\n\nPassword: {}\n{}", "•".repeat(self.unlock_password.chars().count()), error)).wrap(Wrap { trim: true }), frame.area().inner(Margin::new(1, 1))); + return; + } if frame.area().width < 36 || frame.area().height < 12 { frame.render_widget( Paragraph::new("cotp\n\nEnlarge the terminal to 36 × 12.\nq quit") @@ -88,6 +97,7 @@ impl App<'_> { ("/", "search"), ("Space", "QR"), ("h", "hide"), + ("Ctrl+L", "lock"), ("?", "help"), ("q", "quit"), ] @@ -123,6 +133,7 @@ impl App<'_> { frame.render_widget(Paragraph::new(line), rect); let action = match *key { "Ctrl+U" => Some(Action::ClearSearch), + "Ctrl+L" => None, "↑" => Some(Action::Key(KeyCode::Up)), "↓" | "↑↓" => Some(Action::Key(KeyCode::Down)), "Enter" | "↵" => Some(Action::Key(KeyCode::Enter)), @@ -264,12 +275,21 @@ impl App<'_> { } else { &item.issuer }; + let issuer = if element.favorite { + format!("★ {issuer}") + } else { + issuer.to_owned() + }; + let mut details = item.label.clone(); + if let Some(alias) = &element.alias { + details.push_str(&format!(" · @{alias}")); + } + if !element.tags.is_empty() { + details.push_str(&format!(" · #{}", element.tags.join(" #"))); + } let account = Text::from(vec![ Line::from(Span::styled(issuer, Style::default().fg(TEXT).bold())), - Line::from(Span::styled( - item.label.as_str(), - Style::default().fg(MUTED), - )), + Line::from(Span::styled(details, Style::default().fg(MUTED))), ]); let code = if item.has_error { "Unavailable".into() @@ -506,6 +526,7 @@ impl App<'_> { ("Enter", "Copy code / URI", KeyCode::Enter), ("/ or Ctrl+F", "Filter accounts", KeyCode::Char('/')), ("h", "Hide / reveal codes", KeyCode::Char('h')), + ("f", "Toggle favorite", KeyCode::Char('f')), ("Space", "Show / close QR", KeyCode::Char(' ')), ("+", "Increase HOTP counter", KeyCode::Char('+')), ("−", "Decrease HOTP counter", KeyCode::Char('-')), diff --git a/src/keychain.rs b/src/keychain.rs new file mode 100644 index 00000000..2dd5531c --- /dev/null +++ b/src/keychain.rs @@ -0,0 +1,321 @@ +//! Explicitly enrolled vault passwords in native OS credential stores. +use crate::{arguments::CotpArgs, otp::otp_element::OTPDatabase, utils}; +use clap::{Args, Subcommand}; +use color_eyre::eyre::{Result, WrapErr, eyre}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use zeroize::Zeroizing; + +const SERVICE: &str = "cotp.vault-password.v1"; + +#[derive(Args)] +pub struct KeychainArgs { + #[command(subcommand)] + action: KeychainAction, +} + +#[derive(Subcommand)] +enum KeychainAction { + /// Authenticate the vault password and save it in the OS credential store + Remember, + /// Delete this vault's saved password without unlocking the vault + Forget, + /// Report whether this vault has a saved password (never display the password) + Status, +} + +impl crate::arguments::SubcommandExecutor for KeychainArgs { + fn run_command(self, _: OTPDatabase) -> Result { + Err(eyre!("Keychain commands must run before opening the vault")) + } +} + +trait CredentialStore { + fn get(&self) -> Result>>; + fn set(&self, password: &str) -> Result<()>; + fn delete(&self) -> Result<()>; +} + +struct NativeStore(keyring::Entry); +impl NativeStore { + fn open(path: &Path) -> Result { + if !cfg!(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" + )) { + return Err(eyre!( + "OS keychain integration is unsupported on this platform" + )); + } + keyring::Entry::new(SERVICE, &credential_id(path)?) + .map(Self) + .map_err(|_| backend_error()) + } +} +fn backend_error() -> color_eyre::eyre::Report { + // Native errors can include credential attributes; keep all diagnostics redacted. + eyre!( + "Cannot access the OS credential store; check that it is running, unlocked, and permits cotp access" + ) +} +impl CredentialStore for NativeStore { + fn get(&self) -> Result>> { + match self.0.get_password() { + Ok(password) => Ok(Some(Zeroizing::new(password))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(backend_error()), + } + } + fn set(&self, password: &str) -> Result<()> { + self.0.set_password(password).map_err(|_| backend_error()) + } + fn delete(&self) -> Result<()> { + match self.0.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(backend_error()), + } + } +} + +fn credential_id(path: &Path) -> Result { + let canonical = path + .canonicalize() + .wrap_err("Cannot resolve existing vault path for the OS credential store")?; + let mut hash = Sha256::new(); + hash.update(b"cotp vault path v1\0"); + hash.update(canonical.as_os_str().as_encoded_bytes()); + Ok(hex::encode(hash.finalize())) +} + +/// Explicit password input always takes precedence; creation never consults a keychain. +pub fn unlock_password( + path: &Path, + explicit: Option, + first_run: bool, +) -> Result> { + resolve_password( + explicit.map(Zeroizing::new), + crate::config::settings().keychain, + first_run, + || NativeStore::open(path)?.get(), + || { + if first_run { + utils::verified_password("Choose a password: ", 8).map(Zeroizing::new) + } else { + utils::prompt_password("Password: ").map(Zeroizing::new) + } + }, + ) +} + +fn resolve_password( + explicit: Option>, + enabled: bool, + first_run: bool, + read_saved: impl FnOnce() -> Result>>, + prompt: impl FnOnce() -> Result>, +) -> Result> { + if let Some(password) = explicit { + return Ok(password); + } + if enabled + && !first_run + && let Some(password) = read_saved()? + { + return Ok(password); + } + prompt() +} + +fn remember(store: &impl CredentialStore, path: &Path, password: &str) -> Result<()> { + // Authentication completes before any credential store write. No database is saved. + let bytes = std::fs::read(path).wrap_err("Cannot read vault for password verification")?; + crate::backup::validate(&bytes, password) + .wrap_err("Vault password verification failed; nothing was stored")?; + store.set(password) +} + +pub fn handle(args: &CotpArgs) -> Result { + let Some(crate::arguments::CotpSubcommands::Keychain(command)) = &args.command else { + return Ok(false); + }; + let path = crate::path::init_path(args); + match command.action { + KeychainAction::Remember => { + // Never enroll an existing keychain credential implicitly: require authentication input. + let password = Zeroizing::new(if args.password_from_stdin { + utils::stdin_line()? + } else if let Some(fd) = args.password_fd { + utils::password_from_fd(fd)? + } else { + utils::prompt_password("Vault password to remember: ")? + }); + let store = NativeStore::open(&path)?; + remember(&store, &path, &password)?; + println!("Vault password saved in the OS credential store"); + } + KeychainAction::Forget => { + NativeStore::open(&path)?.delete()?; + println!("Saved vault password removed"); + } + KeychainAction::Status => { + let saved = NativeStore::open(&path)?.get()?.is_some(); + println!( + "{}; automatic keychain unlock {}", + if saved { + "Saved password present" + } else { + "No saved password" + }, + if crate::config::settings().keychain { + "enabled" + } else { + "disabled" + } + ); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + #[derive(Default)] + struct MockStore { + password: RefCell>>, + fail: Cell, + reads: Cell, + writes: Cell, + } + impl CredentialStore for MockStore { + fn get(&self) -> Result>> { + self.reads.set(self.reads.get() + 1); + if self.fail.get() { + return Err(backend_error()); + } + Ok(self.password.borrow().clone()) + } + fn set(&self, password: &str) -> Result<()> { + if self.fail.get() { + return Err(backend_error()); + } + self.writes.set(self.writes.get() + 1); + *self.password.borrow_mut() = Some(Zeroizing::new(password.to_owned())); + Ok(()) + } + fn delete(&self) -> Result<()> { + if self.fail.get() { + return Err(backend_error()); + } + self.password.borrow_mut().take(); + Ok(()) + } + } + + #[test] + fn explicit_password_default_mode_and_new_vaults_never_read_keychain() { + let store = MockStore::default(); + store.fail.set(true); + for (explicit, enabled, first_run, expected) in [ + (Some("explicit"), true, false, "explicit"), + (None, false, false, "prompt"), + (None, true, true, "prompt"), + ] { + let password = resolve_password( + explicit.map(|s| Zeroizing::new(s.into())), + enabled, + first_run, + || store.get(), + || Ok(Zeroizing::new("prompt".into())), + ) + .unwrap(); + assert_eq!(password.as_str(), expected); + } + assert_eq!(store.reads.get(), 0); + } + + #[test] + fn saved_password_unlocks_missing_falls_back_and_backend_error_propagates() { + let store = MockStore::default(); + store.set("saved").unwrap(); + let saved = resolve_password( + None, + true, + false, + || store.get(), + || panic!("must not prompt"), + ) + .unwrap(); + assert_eq!(saved.as_str(), "saved"); + store.delete().unwrap(); + let prompted = resolve_password( + None, + true, + false, + || store.get(), + || Ok(Zeroizing::new("prompt".into())), + ) + .unwrap(); + assert_eq!(prompted.as_str(), "prompt"); + // Represents the same prompt rejection returned by utils::prompt_password in no-input mode. + assert!( + resolve_password( + None, + true, + false, + || store.get(), + || Err(eyre!("Interactive input is disabled")) + ) + .is_err() + ); + store.fail.set(true); + let failed = resolve_password( + None, + true, + false, + || store.get(), + || panic!("backend failure must not prompt"), + ); + assert!(failed.is_err()); + } + + #[test] + fn remember_authenticates_before_write_and_forget_removes_only_credential() { + let store = MockStore::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(remember(&store, &path, "wrong-password").is_err()); + assert_eq!(store.writes.get(), 0); + remember(&store, &path, "12345678").unwrap(); + assert_eq!(store.writes.get(), 1); + assert_eq!(store.get().unwrap().unwrap().as_str(), "12345678"); + store.delete().unwrap(); + assert!(store.get().unwrap().is_none()); + assert_eq!(std::fs::read(path).unwrap(), before); + } + + #[test] + fn credential_scope_is_canonical_and_different_for_distinct_vaults() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.cotp"); + let b = dir.path().join("b.cotp"); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&b, b"b").unwrap(); + let id = credential_id(&a).unwrap(); + assert_eq!(id.len(), 64); + assert_eq!(id, credential_id(&dir.path().join("./a.cotp")).unwrap()); + assert_ne!(id, credential_id(&b).unwrap()); + #[cfg(unix)] + { + let alias = dir.path().join("alias.cotp"); + std::os::unix::fs::symlink(&a, &alias).unwrap(); + assert_eq!(id, credential_id(&alias).unwrap()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 716e22cd..10c42817 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,95 +6,170 @@ use interface::app::AppResult; use interface::event::{Event, EventHandler}; use interface::handlers::{handle_key_events, handle_mouse_events, handle_paste}; use interface::ui::Tui; -use otp::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase}; +use otp::otp_element::OTPDatabase; use path::init_path; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; -use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin}; -use std::{ - io::{self, IsTerminal}, - vec, -}; +use reading::ReadResult; +use std::io::{self, IsTerminal}; use zeroize::Zeroize; mod arguments; +mod backup; mod clipboard; +mod config; mod crypto; mod database_file; +mod exchange; mod exporters; +mod hardware; mod importers; mod interface; +mod keychain; mod otp; mod path; +mod persistence; mod reading; +mod session_lock; mod utils; fn init(args: &CotpArgs) -> color_eyre::Result { - init_path(args); - - match utils::init_app() { - Ok(first_run) => { - if first_run { - // Let's initialize the database file - let mut pw = utils::verified_password("Choose a password: ", 8); - let mut database = OTPDatabase { - version: CURRENT_DATABASE_VERSION, - elements: vec![], - ..Default::default() - }; - let save_result = database.save_with_pw(&pw); - pw.zeroize(); - save_result.map(|(key, salt)| (database, key, salt.to_vec())) - } else if args.password_from_stdin { - get_elements_from_stdin() - } else { - get_elements_from_input() - } + let path = init_path(args); + let explicit_init = matches!(args.command, Some(arguments::CotpSubcommands::Init(_))); + if explicit_init && path.exists() { + return Err(eyre!("Vault already exists; init never overwrites a vault")); + } + if !path.exists() + && !matches!( + args.command, + None | Some( + arguments::CotpSubcommands::Init(_) + | arguments::CotpSubcommands::Add(_) + | arguments::CotpSubcommands::Import(_) + ) + ) + { + return Err(eyre!( + "Vault does not exist. Create it with cotp init, add, or import." + )); + } + if !path.exists() + && matches!(&args.command, Some(arguments::CotpSubcommands::Import(import)) if import.dry_run) + { + return Err(eyre!( + "Dry-run requires an existing vault; create it with cotp init first" + )); + } + let first_run = utils::init_app().map_err(|()| eyre!("Could not create vault directory"))?; + persistence::initialize(&path)?; + let explicit_password = if args.password_from_stdin { + Some(utils::stdin_line()?) + } else { + args.password_fd.map(utils::password_from_fd).transpose()? + }; + let password = keychain::unlock_password(&path, explicit_password, first_run)?; + if first_run { + if password.chars().count() < 8 { + return Err(eyre!( + "New vault password must contain at least 8 characters" + )); } - Err(()) => Err(eyre!("An error occurred during database creation")), + let mut database = OTPDatabase::default(); + let (key, salt) = database.save_with_pw(&password)?; + Ok((database, key, salt.to_vec())) + } else { + reading::read_from_file(&password) } } -fn main() -> AppResult<()> { - color_eyre::install()?; +fn run(mut cotp_args: CotpArgs) -> Result<(), (u8, color_eyre::eyre::Report)> { + if arguments::shell::handle(&cotp_args).map_err(|error| (1, error))? { + return Ok(()); + } + config::load(&mut cotp_args).map_err(|error| (1, error))?; + utils::set_no_input(cotp_args.no_input); + if keychain::handle(&cotp_args).map_err(|error| (1, error))? { + return Ok(()); + } + if hardware::handle(&cotp_args).map_err(|error| (3, error))? { + return Ok(()); + } + if matches!(cotp_args.command, Some(arguments::CotpSubcommands::Lock(_))) { + session_lock::request(&init_path(&cotp_args)).map_err(|error| (1, error))?; + eprintln!("Lock requested for running dashboards"); + return Ok(()); + } - let cotp_args: CotpArgs = CotpArgs::parse(); - let (database, mut key, salt) = match init(&cotp_args) { - Ok(v) => v, - Err(e) => { - println!("{e}"); - std::process::exit(-1); - } + if let Some(arguments::CotpSubcommands::Backup(args)) = &cotp_args.command { + let path = init_path(&cotp_args); + return backup::run( + args, + &path, + cotp_args.password_from_stdin, + cotp_args.password_fd, + ) + .map_err(|error| (1, error)); + } + let dry_run = match &cotp_args.command { + Some(arguments::CotpSubcommands::Import(args)) => args.dry_run, + Some(arguments::CotpSubcommands::Merge(args)) => args.dry_run, + _ => false, }; + let save_upgrade_before_command = matches!( + &cotp_args.command, + None | Some( + arguments::CotpSubcommands::Code(_) + | arguments::CotpSubcommands::Extract(_) + | arguments::CotpSubcommands::List(_) + ) + ); + let (mut database, key, salt) = init(&cotp_args).map_err(|error| (1, error))?; + let mut key = zeroize::Zeroizing::new(key); + persistence::unlock(&key, &salt).map_err(|error| (1, error))?; + otp::migrations::migrate(&mut database).map_err(|error| (1, error))?; + if database.is_modified() && save_upgrade_before_command { + database.save(&key, &salt).map_err(|error| (1, error))?; + } + key.zeroize(); + let mut database = args_parser(cotp_args, database).map_err(|error| (3, error))?; + if database.is_modified() && !dry_run { + persistence::persist(&mut database).map_err(|error| { + ( + 1, + eyre!("An error occurred during database overwriting: {error}"), + ) + })?; + eprintln!("Modifications have been persisted"); + } + Ok(()) +} - let mut reowned_database = match args_parser(cotp_args, database) { - Ok(d) => d, - Err(e) => { - eprintln!("An error occurred: {e}"); - key.zeroize(); - std::process::exit(-2) +fn main() -> std::process::ExitCode { + if let Err(error) = color_eyre::install() { + eprintln!("{error}"); + return std::process::ExitCode::FAILURE; + } + match clipboard::run_expiry_helper() { + Ok(true) => return std::process::ExitCode::SUCCESS, + Ok(false) => {} + Err(error) => { + eprintln!("{error}"); + return std::process::ExitCode::FAILURE; } - }; - - let error_code = if reowned_database.is_modified() { - match reowned_database.save(&key, &salt) { - Ok(()) => { - println!("Modifications have been persisted"); - 0 - } - Err(e) => { - eprintln!("An error occurred during database overwriting: {e}"); - -1 - } + } + let result = run(CotpArgs::parse()); + persistence::lock(); + match result { + Ok(()) => std::process::ExitCode::SUCCESS, + Err((code, error)) => { + eprintln!("{error}"); + std::process::ExitCode::from(code) } - } else { - 0 - }; - key.zeroize(); - std::process::exit(error_code) + } } fn dashboard(mut database: OTPDatabase) -> AppResult { + utils::ensure_interactive()?; if !io::stderr().is_terminal() || !io::stdin().is_terminal() { if database.elements_ref().is_empty() { println!("No codes, type \"cotp -h\" to get help"); @@ -110,7 +185,11 @@ fn dashboard(mut database: OTPDatabase) -> AppResult { let terminal = Terminal::new(backend)?; let mut tui = Tui::new(terminal, EventHandler::new(250)); tui.init()?; + let mut lock_requests = session_lock::Watcher::new(path::DATABASE_PATH.get().unwrap())?; while app.running { + if lock_requests.requested() { + app.lock_session(); + } tui.draw(&mut app)?; match tui.events.next()? { Event::Tick => app.tick(false), diff --git a/src/otp/algorithms/hotp_maker.rs b/src/otp/algorithms/hotp_maker.rs index c160b66e..d4d2e21c 100644 --- a/src/otp/algorithms/hotp_maker.rs +++ b/src/otp/algorithms/hotp_maker.rs @@ -73,3 +73,28 @@ mod tests { ); } } + +#[cfg(test)] +mod rfc_vectors { + use super::*; + use crate::otp::otp_element::format_code; + + #[test] + fn all_rfc4226_vectors() { + let secret = BASE32_NOPAD.encode(b"12345678901234567890"); + let expected = [ + "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", + "399871", "520489", + ]; + for (counter, expected) in expected.into_iter().enumerate() { + assert_eq!( + format_code( + 6, + hotp(&secret, OTPAlgorithm::Sha1, counter as u64).unwrap() + ) + .unwrap(), + expected + ); + } + } +} diff --git a/src/otp/algorithms/totp_maker.rs b/src/otp/algorithms/totp_maker.rs index c82d4157..60abdae1 100644 --- a/src/otp/algorithms/totp_maker.rs +++ b/src/otp/algorithms/totp_maker.rs @@ -8,7 +8,7 @@ use super::hotp_maker::hotp; pub fn totp(secret: &str, algorithm: OTPAlgorithm, period: u64) -> Result { let time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() + .map_err(|_| OtpError::InvalidTime)? .as_secs(); generate_totp(secret, algorithm, time, period, 0) } @@ -20,7 +20,11 @@ fn generate_totp( time_step: u64, skew: i64, ) -> Result { - hotp(secret, algorithm, ((time as i64 + skew) as u64) / time_step) + if time_step == 0 { + return Err(OtpError::InvalidPeriod); + } + let adjusted = time.checked_add_signed(skew).ok_or(OtpError::InvalidTime)?; + hotp(secret, algorithm, adjusted / time_step) } #[cfg(test)] @@ -56,3 +60,73 @@ mod tests { assert_eq!("295439", code) } } + +#[cfg(test)] +mod rfc_vectors { + use super::*; + use crate::otp::otp_element::format_code; + use data_encoding::BASE32_NOPAD; + + // RFC 6238, Appendix B. Each hash uses its specified secret length. + #[test] + fn all_rfc6238_vectors() { + let times = [ + 59, + 1111111109, + 1111111111, + 1234567890, + 2000000000, + 20000000000, + ]; + let cases = [ + ( + OTPAlgorithm::Sha1, + 20, + [ + "94287082", "07081804", "14050471", "89005924", "69279037", "65353130", + ], + ), + ( + OTPAlgorithm::Sha256, + 32, + [ + "46119246", "68084774", "67062674", "91819424", "90698825", "77737706", + ], + ), + ( + OTPAlgorithm::Sha512, + 64, + [ + "90693936", "25091201", "99943326", "93441116", "38618901", "47863826", + ], + ), + ]; + for (algorithm, len, expected) in cases { + let bytes: Vec = b"1234567890".iter().copied().cycle().take(len).collect(); + let secret = BASE32_NOPAD.encode(&bytes); + for (time, expected) in times.into_iter().zip(expected) { + assert_eq!( + format_code(8, generate_totp(&secret, algorithm, time, 30, 0).unwrap()) + .unwrap(), + expected + ); + } + } + } + + #[test] + fn invalid_time_parameters_are_errors() { + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, 1, 0, 0), + Err(OtpError::InvalidPeriod) + ); + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, 0, 30, -1), + Err(OtpError::InvalidTime) + ); + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, u64::MAX, 30, 1), + Err(OtpError::InvalidTime) + ); + } +} diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 04400ff1..b46cacc0 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -1,104 +1,121 @@ -use color_eyre::eyre::ErrReport; +use color_eyre::eyre::{Result, eyre}; use url::Url; use super::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; pub trait FromOtpUri: Sized { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result; + fn from_otp_uri(otp_uri: &str) -> Result; } impl FromOtpUri for OTPElement { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result { - let decoded = urlencoding::decode(otp_uri).map_err(ErrReport::from)?; - let parsed_uri = Url::parse(&decoded).map_err(ErrReport::from)?; - - let otp_type = parsed_uri - .host_str() - .map_or_else(|| "TOTP".to_string(), str::to_uppercase); - - let (issuer, label) = get_issuer_and_label(&parsed_uri)?; - - let secret = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "secret") - .map(|(_k, v)| v.to_uppercase()) - .ok_or(ErrReport::msg("Secret not found in OTP Uri"))?; - - let algorithm = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "algorithm") - .map_or_else(|| "SHA1".to_string(), |(_k, v)| v.to_uppercase()); - - let digits = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "digits") - .map_or(6, |(_k, v)| v.parse::().unwrap_or(6)); - - let period = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "period") - .map_or(30, |(_k, v)| v.parse::().unwrap_or(30)); - - let counter = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "counter") - .and_then(|(_k, v)| v.parse::().ok()); - - Ok(OTPElement { + fn from_otp_uri(otp_uri: &str) -> Result { + let uri = Url::parse(otp_uri).map_err(|_| eyre!("Invalid OTP URI"))?; + if uri.scheme() != "otpauth" { + return Err(eyre!("Expected otpauth:// URI")); + } + let type_ = match uri.host_str().unwrap_or("").to_ascii_lowercase().as_str() { + "totp" => OTPType::Totp, + "hotp" => OTPType::Hotp, + "steam" => OTPType::Steam, + "yandex" => OTPType::Yandex, + "motp" => OTPType::Motp, + _ => return Err(eyre!("Unsupported OTP type")), + }; + let mut params = std::collections::HashMap::new(); + for (key, value) in uri.query_pairs() { + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(eyre!("Duplicate OTP URI parameter")); + } + } + let label = urlencoding::decode(uri.path().trim_start_matches('/')) + .map_err(|_| eyre!("Invalid OTP label encoding"))?; + let (issuer, label) = label + .split_once(':') + .map(|(i, l)| (i.to_owned(), l.to_owned())) + .unwrap_or_else(|| { + ( + params.get("issuer").cloned().unwrap_or_default(), + label.into_owned(), + ) + }); + let secret = params + .get("secret") + .cloned() + .ok_or_else(|| eyre!("Secret not found in OTP URI"))?; + let algorithm = match params + .get("algorithm") + .map(|s| s.to_ascii_uppercase()) + .as_deref() + .unwrap_or("SHA1") + { + "SHA1" => OTPAlgorithm::Sha1, + "SHA256" => OTPAlgorithm::Sha256, + "SHA512" => OTPAlgorithm::Sha512, + "MD5" => OTPAlgorithm::Md5, + _ => return Err(eyre!("Unsupported OTP algorithm")), + }; + let number = |name: &str, default| -> Result { + params + .get(name) + .map(|s| { + s.parse::() + .map_err(|_| eyre!("Invalid numeric OTP parameter: {name}")) + }) + .unwrap_or(Ok(default)) + }; + let counter = if params.contains_key("counter") { + Some(number("counter", 0)?) + } else { + None + }; + let mut element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret, issuer, label, - digits, - type_: OTPType::from(otp_type.as_str()), - algorithm: OTPAlgorithm::from(algorithm.as_str()), - period, + digits: number("digits", 6)?, + type_, + algorithm, + period: number("period", 30)?, counter, - pin: None, - }) + pin: params.get("pin").cloned(), + }; + crate::importers::enrollment::validate(&mut element)?; + Ok(element) } } -fn get(parsed_uri: &Url) -> color_eyre::Result> { - let first_segment: Vec = parsed_uri - .path_segments() - .map(Iterator::collect::>) - .ok_or(ErrReport::msg("Failed to collect path segments"))? - .first() - .ok_or(ErrReport::msg("No path segments found"))? - .split(':') - .collect::>() - .into_iter() - .map(std::borrow::ToOwned::to_owned) - .collect(); - Ok(first_segment) -} - -fn get_issuer_and_label(parsed_uri: &Url) -> color_eyre::Result<(String, String)> { - // Find the first path segments, OTP Uris should not have others - let first_segment = get(parsed_uri)?; - - let first = first_segment.first().and_then(|v| { - urlencoding::decode(v.as_str()) - .map(std::borrow::Cow::into_owned) - .ok() - }); - - let second = first_segment.get(1).and_then(|v| { - urlencoding::decode(v) - .map(std::borrow::Cow::into_owned) - .ok() - }); - - match (first, second) { - (Some(i), Some(l)) => Ok((i, l)), - (Some(l), None) => { - let issuer = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "issuer") - .map(|(_k, v)| v.to_string()) - .unwrap_or_default(); - Ok((issuer, l)) +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_invalid_parameters() { + for query in [ + "period=0", + "period=no", + "digits=11", + "algorithm=SHA42", + "secret=AA", + ] { + assert!( + OTPElement::from_otp_uri(&format!("otpauth://totp/test?secret=JBSWY3DP&{query}")) + .is_err() + ); } - _ => Err(ErrReport::msg("No label found in OTP uri")), + assert!(OTPElement::from_otp_uri("otpauth://hotp/test?secret=JBSWY3DP").is_err()); + assert!(OTPElement::from_otp_uri("otpauth://weird/test?secret=JBSWY3DP").is_err()); + } + #[test] + fn percent_encoded_ampersand_stays_in_label() { + let account = + OTPElement::from_otp_uri("otpauth://totp/Example:user%26name?secret=JBSWY3DP").unwrap(); + assert_eq!(account.label, "user&name"); } } diff --git a/src/otp/migrations/mod.rs b/src/otp/migrations/mod.rs index 18b04265..24f9c615 100644 --- a/src/otp/migrations/mod.rs +++ b/src/otp/migrations/mod.rs @@ -1,27 +1,82 @@ -use super::otp_element::OTPDatabase; -struct Migration<'a> { - to_version: u16, // Database version which we are migrating on - migration_function: &'a dyn Fn(&mut OTPDatabase) -> color_eyre::Result<()>, // Function to execute the migration -} -const MIGRATIONS_LIST: [Migration; 1] = [Migration { - to_version: 2, - migration_function: &migrate_to_2, -}]; +use color_eyre::eyre::eyre; -fn migrate_to_2(database: &mut OTPDatabase) -> color_eyre::Result<()> { - database.version = 2; - Ok(()) -} +use super::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase}; +/// Upgrade legacy vaults without changing account credentials. IDs are also repaired +/// after imports, which can contain missing or duplicate identifiers. pub fn migrate(database: &mut OTPDatabase) -> color_eyre::Result<()> { - let mut binding = MIGRATIONS_LIST; - let migrations = binding.as_mut(); - migrations.sort_unstable_by_key(|c1| c1.to_version); - for i in migrations { - if database.version < i.to_version { - // Do the migration - (i.migration_function)(database)?; - } + if database.version > CURRENT_DATABASE_VERSION { + return Err(eyre!( + "This vault uses a newer database format; upgrade cotp" + )); + } + let changed = database.version < CURRENT_DATABASE_VERSION; + database.ensure_account_ids()?; + database.version = CURRENT_DATABASE_VERSION; + if changed { + database.mark_modified(); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + #[test] + fn legacy_accounts_keep_secrets_and_receive_persistent_unique_ids() { + let element = OTPElementBuilder::default() + .secret("JBSWY3DPEHPK3PXP") + .issuer("Example") + .label("alice") + .counter(Some(17)) + .build() + .unwrap(); + let mut database = OTPDatabase::from(vec![element.clone(), element]); + migrate(&mut database).unwrap(); + assert!(database.is_modified()); + assert_eq!(database.version, CURRENT_DATABASE_VERSION); + assert_ne!(database.elements[0].id, database.elements[1].id); + assert_eq!(database.elements[0].secret, "JBSWY3DPEHPK3PXP"); + assert_eq!(database.elements[0].counter, Some(17)); + let serialized = serde_json::to_string(&database).unwrap(); + let mut restored: OTPDatabase = serde_json::from_str(&serialized).unwrap(); + migrate(&mut restored).unwrap(); + assert!(!restored.is_modified()); + assert_eq!(database.elements, restored.elements); + } + + #[test] + fn version_two_without_metadata_deserializes() { + let mut database: OTPDatabase = serde_json::from_str(r#"{"version":2,"elements":[{"secret":"AA","issuer":"Old","label":"account","digits":6,"type":"TOTP","algorithm":"SHA1","period":30,"counter":null,"pin":null}]}"#).unwrap(); + migrate(&mut database).unwrap(); + assert_eq!(database.elements[0].id.len(), 32); + assert!(database.elements[0].tags.is_empty()); + assert!(database.elements[0].alias.is_none()); + } + + #[test] + fn imported_duplicate_ids_are_repaired_without_changing_existing_id() { + let element = OTPElementBuilder::default() + .id("stable-id") + .secret("AA") + .issuer("Example") + .label("alice") + .build() + .unwrap(); + let mut database = OTPDatabase::from(vec![element.clone(), element]); + migrate(&mut database).unwrap(); + assert_eq!(database.elements[0].id, "stable-id"); + assert_ne!(database.elements[1].id, "stable-id"); + } + + #[test] + fn newer_database_format_is_rejected() { + let mut database = OTPDatabase { + version: CURRENT_DATABASE_VERSION + 1, + ..Default::default() + }; + assert!(migrate(&mut database).is_err()); + } +} diff --git a/src/otp/mod.rs b/src/otp/mod.rs index 4a06bdd3..db868dac 100644 --- a/src/otp/mod.rs +++ b/src/otp/mod.rs @@ -1,6 +1,7 @@ pub mod algorithms; pub mod from_otp_uri; pub mod migrations; +pub mod organization; pub mod otp_algorithm; pub mod otp_element; mod otp_error; diff --git a/src/otp/organization.rs b/src/otp/organization.rs new file mode 100644 index 00000000..4a52a958 --- /dev/null +++ b/src/otp/organization.rs @@ -0,0 +1,85 @@ +//! Account metadata matching shared by the command line and dashboard. +use super::otp_element::OTPElement; + +pub fn matches_filters(element: &OTPElement, tags: &[String], favorite: bool) -> bool { + (!favorite || element.favorite) + && tags.iter().all(|tag| { + element + .tags + .iter() + .any(|value| value.eq_ignore_ascii_case(tag)) + }) +} + +pub fn matches_search(element: &OTPElement, query: &str) -> bool { + let text = format!( + "{} {} {} {}", + element.issuer, + element.label, + element.alias.as_deref().unwrap_or(""), + element.id + ) + .to_lowercase(); + query.split_whitespace().all(|term| { + let term = term.to_lowercase(); + if let Some(tag) = term.strip_prefix("tag:") { + element.tags.iter().any(|value| value.to_lowercase() == tag) + } else if term == "is:favorite" { + element.favorite + } else { + text.contains(&term) + } + }) +} + +pub fn parse_tag(value: &str) -> Result { + let tag = value.trim(); + if tag.is_empty() || tag.chars().count() > 64 || tag.chars().any(char::is_control) { + return Err("Tags must contain 1–64 characters without control characters".into()); + } + Ok(tag.to_owned()) +} + +pub fn add_tags(existing: &mut Vec, tags: impl IntoIterator) { + for tag in tags { + if !existing + .iter() + .any(|value| value.eq_ignore_ascii_case(&tag)) + { + existing.push(tag); + } + } +} + +#[cfg(test)] +mod tests { + use crate::otp::otp_element::{OTPDatabase, OTPElementBuilder}; + + #[test] + fn explicit_maximum_position_precedes_unordered_and_ties_use_labels_then_ids() { + let element = |label: &str, id: &str, order: Option, favorite: bool| { + OTPElementBuilder::default() + .secret("AA") + .issuer("Example") + .label(label) + .id(id) + .order(order) + .favorite(favorite) + .build() + .unwrap() + }; + let mut db = OTPDatabase::from(vec![ + element("Z", "z", None, true), + element("B", "b", Some(i64::MAX), false), + element("A", "b", None, false), + element("A", "a", None, false), + ]); + db.sort(); + let ids: Vec<_> = db + .elements_ref() + .iter() + .map(|element| element.id.as_str()) + .collect(); + assert_eq!(ids, ["b", "a", "b", "z"]); + } +} diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index fd22bc27..80ca4bca 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -1,6 +1,6 @@ use color_eyre::eyre::{ErrReport, eyre}; use derive_builder::Builder; -use std::vec; +use std::{collections::HashSet, vec}; use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key, gen_salt}; use crate::otp::otp_error::OtpError; @@ -9,7 +9,7 @@ use data_encoding::BASE32_NOPAD; use qrcode::QrCode; use qrcode::render::unicode; use serde::{Deserialize, Serialize}; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use super::{ algorithms::{ @@ -21,7 +21,7 @@ use super::{ otp_type::OTPType, }; -pub const CURRENT_DATABASE_VERSION: u16 = 2; +pub const CURRENT_DATABASE_VERSION: u16 = 3; #[derive(Serialize, Deserialize, PartialEq, Hash)] pub struct OTPDatabase { @@ -65,28 +65,51 @@ impl OTPDatabase { } fn overwrite_database_key(&self, key: &Vec, salt: &[u8]) -> color_eyre::Result<()> { - let json: &str = &serde_json::to_string(&self)?; - let encrypted = encrypt_string_with_key(json, key, salt)?; + let json = Zeroizing::new(serde_json::to_string(&self)?); + let encrypted = encrypt_string_with_key(&json, key, salt)?; + drop(json); let content = serde_json::to_vec(&encrypted)?; - crate::database_file::write(DATABASE_PATH.get().unwrap(), &content)?; + crate::persistence::write_database(DATABASE_PATH.get().unwrap(), &content)?; Ok(()) } pub fn save_with_pw(&mut self, password: &str) -> color_eyre::Result<(Vec, [u8; 16])> { let salt = gen_salt()?; - let key = argon_derive_key(password.as_bytes(), &salt)?; + let key = Zeroizing::new(argon_derive_key(password.as_bytes(), &salt)?); self.save(&key, &salt)?; - Ok((key, salt)) + crate::persistence::unlock(&key, &salt)?; + Ok((key.to_vec(), salt)) + } + + pub fn add_all(&mut self, mut elements: Vec) -> color_eyre::Result<()> { + let mut used: HashSet = self + .elements + .iter() + .map(|element| element.id.clone()) + .collect(); + for element in &mut elements { + assign_account_id(element, &mut used)?; + } + self.elements.append(&mut elements); + self.mark_modified(); + Ok(()) } - pub fn add_all(&mut self, mut elements: Vec) { - self.mark_modified(); - self.elements.append(&mut elements); + pub fn add_element(&mut self, element: OTPElement) -> color_eyre::Result<()> { + self.add_all(vec![element]) } - pub fn add_element(&mut self, element: OTPElement) { - self.mark_modified(); - self.elements.push(element); + /// Give every account a unique identifier, preserving IDs already assigned. + pub fn ensure_account_ids(&mut self) -> color_eyre::Result<()> { + let mut used = HashSet::new(); + let mut changed = false; + for element in &mut self.elements { + changed |= assign_account_id(element, &mut used)?; + } + if changed { + self.mark_modified(); + } + Ok(()) } pub fn mark_modified(&mut self) { @@ -111,10 +134,13 @@ impl OTPDatabase { } pub fn sort(&mut self) { - self.elements.sort_unstable_by(|c1, c2| { - c1.issuer - .to_ascii_lowercase() - .cmp(&c2.issuer.to_ascii_lowercase()) + self.elements.sort_by(|left, right| { + // Explicit positions precede the alphabetical accounts; favorites keep their position. + (left.order.is_none(), left.order) + .cmp(&(right.order.is_none(), right.order)) + .then_with(|| left.issuer.to_lowercase().cmp(&right.issuer.to_lowercase())) + .then_with(|| left.label.to_lowercase().cmp(&right.label.to_lowercase())) + .then_with(|| left.id.cmp(&right.id)) }); } } @@ -127,6 +153,22 @@ impl OTPDatabase { build_fn(validate = "Self::validate", error = "ErrReport") )] pub struct OTPElement { + /// Stable vault-local identifier, assigned when the database is migrated or saved. + #[serde(default)] + #[builder(default)] + pub id: String, + #[serde(default)] + #[builder(default)] + pub alias: Option, + #[serde(default)] + #[builder(default)] + pub tags: Vec, + #[serde(default)] + #[builder(default)] + pub favorite: bool, + #[serde(default)] + #[builder(default)] + pub order: Option, #[builder(setter(custom))] pub secret: String, pub issuer: String, @@ -146,6 +188,25 @@ pub struct OTPElement { pub pin: Option, } +fn assign_account_id( + element: &mut OTPElement, + used: &mut HashSet, +) -> color_eyre::Result { + if !element.id.is_empty() && used.insert(element.id.clone()) { + return Ok(false); + } + loop { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|error| eyre!("Cannot generate account identifier: {error}"))?; + let id = hex::encode(bytes); + if used.insert(id.clone()) { + element.id = id; + return Ok(true); + } + } +} + static ALLOWED_DIGITS_RANGE: std::ops::RangeInclusive = 1..=10; impl OTPElement { @@ -296,6 +357,11 @@ mod test { #[test] fn test_serialization_otp_uri_full_element() { let otp_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g"), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -315,6 +381,11 @@ mod test { #[test] fn test_serialization_otp_uri_no_issuer() { let otp_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g"), issuer: String::new(), label: String::from("LabelText"), @@ -334,6 +405,11 @@ mod test { #[test] fn test_deserialization_otp_uri() { let expected = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -361,6 +437,11 @@ mod test { let invalid_digits_value = 11; let element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -385,6 +466,11 @@ mod test { let invalid_digits_value = 10; let element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), diff --git a/src/otp/otp_error.rs b/src/otp/otp_error.rs index f8aefd9d..2742af07 100644 --- a/src/otp/otp_error.rs +++ b/src/otp/otp_error.rs @@ -9,7 +9,9 @@ pub enum OtpError { MissingCounter, // Missing counter for HOTP codes InvalidOffset, // Invalid offset InvalidDigest, // Invalid digest - InvalidDigits, // Invalid Digits value (too high or low) + InvalidPeriod, + InvalidTime, + InvalidDigits, // Invalid Digits value (too high or low) } impl Display for OtpError { @@ -23,6 +25,8 @@ impl Display for OtpError { OtpError::InvalidDigest => f.write_str("Invalid digest"), OtpError::InvalidOffset => f.write_str("Invalid offset"), OtpError::ShortSecret => f.write_str("Secret length less than 16 bytes"), + OtpError::InvalidPeriod => f.write_str("Period must be greater than zero"), + OtpError::InvalidTime => f.write_str("Time is outside the supported range"), OtpError::InvalidDigits => f.write_str("Digits value too high or low"), } } diff --git a/src/persistence.rs b/src/persistence.rs new file mode 100644 index 00000000..4928c357 --- /dev/null +++ b/src/persistence.rs @@ -0,0 +1,287 @@ +//! Optimistic vault transactions protected by an advisory cross-process lock. +use crate::otp::{otp_element::OTPDatabase, otp_type::OTPType}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, + sync::Mutex, +}; +use zeroize::Zeroizing; + +static SESSION: Mutex> = Mutex::new(None); + +pub(crate) struct VaultLock { + _file: File, +} +impl VaultLock { + pub(crate) fn acquire(path: &Path) -> Result { + let path = normalized_path(path)?; + let mut lock_name = path.as_os_str().to_os_string(); + lock_name.push(".lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(PathBuf::from(lock_name))?; + file.try_lock() + .map_err(|e| eyre!("Vault is busy; another process is writing it: {e}"))?; + Ok(Self { _file: file }) + } +} + +fn normalized_path(path: &Path) -> Result { + match path.canonicalize() { + Ok(path) => Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + Ok(parent.canonicalize()?.join( + path.file_name() + .ok_or_else(|| eyre!("Invalid vault path"))?, + )) + } + Err(e) => Err(e.into()), + } +} + +fn read_optional(path: &Path) -> Result>> { + match fs::read(path) { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +struct Session { + path: PathBuf, + snapshot: Option>, + key: Option>>, + salt: Vec, +} +impl Session { + fn open(path: &Path) -> Result { + let path = normalized_path(path)?; + let _lock = VaultLock::acquire(&path)?; + let snapshot = read_optional(&path)?; + Ok(Self { + path, + snapshot, + key: None, + salt: vec![], + }) + } + fn write(&mut self, content: &[u8]) -> Result<()> { + let _lock = VaultLock::acquire(&self.path)?; + if read_optional(&self.path)? != self.snapshot { + return Err(eyre!( + "Vault changed in another process. Reopen the vault and retry; your changes were not written." + )); + } + crate::database_file::write(&self.path, content)?; + self.snapshot = Some(content.to_vec()); + Ok(()) + } +} + +pub fn initialize(path: &Path) -> Result<()> { + *SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))? = Some(Session::open(path)?); + Ok(()) +} + +pub fn unlock(key: &[u8], salt: &[u8]) -> Result<()> { + let mut session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_mut() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + session.key = Some(Zeroizing::new(key.to_vec())); + session.salt = salt.to_vec(); + Ok(()) +} + +pub fn lock() { + if let Ok(mut session) = SESSION.lock() + && let Some(session) = session.as_mut() + { + session.key = None; + session.salt.clear(); + } +} + +/// Seal transient UI state before clearing the only retained vault key. +pub fn seal(plaintext: &str) -> Result { + let session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_ref() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + let key = session + .key + .as_ref() + .ok_or_else(|| eyre!("Vault is locked"))?; + Ok(serde_json::to_string( + &crate::crypto::cryptography::encrypt_string_with_key(plaintext, key, &session.salt)?, + )?) +} + +pub fn write_database(path: &Path, content: &[u8]) -> Result<()> { + let mut session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_mut() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + if normalized_path(path)? != session.path { + return Err(eyre!("Vault path changed during session")); + } + session.write(content) +} + +pub fn persist(database: &mut OTPDatabase) -> Result<()> { + let (key, salt) = { + let session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_ref() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + let key = session + .key + .as_ref() + .ok_or_else(|| eyre!("Vault is locked"))?; + (Zeroizing::new(key.to_vec()), session.salt.clone()) + }; + database.save(&key, &salt) +} + +/// Persist the next counter before handing a consumable HOTP code to a caller. +/// Pending user edits remain pending and are never silently committed by copying. +pub fn consume_hotp(database: &mut OTPDatabase, index: usize) -> Result { + consume_with(database, index, persist) +} + +fn consume_with( + database: &mut OTPDatabase, + index: usize, + save: impl FnOnce(&mut OTPDatabase) -> Result<()>, +) -> Result { + if database.is_modified() { + return Err(eyre!("Save pending changes before consuming HOTP")); + } + let element = database + .mut_element(index) + .ok_or_else(|| eyre!("Account not found"))?; + if element.type_ != OTPType::Hotp { + return Err(eyre!("Selected account is not HOTP")); + } + let counter = element + .counter + .ok_or_else(|| eyre!("Missing HOTP counter"))?; + let next = counter + .checked_add(1) + .ok_or_else(|| eyre!("HOTP counter exhausted"))?; + let code = Zeroizing::new(element.get_otp_code()?); + element.counter = Some(next); + database.mark_modified(); + if let Err(error) = save(database) { + database.mut_element(index).unwrap().counter = Some(counter); + database.needs_modification = false; + return Err(error); + } + Ok(code.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + #[test] + fn contention_and_stale_writes_preserve_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + fs::write(&path, b"initial").unwrap(); + let mut first = Session::open(&path).unwrap(); + let mut second = Session::open(&path).unwrap(); + let lock = VaultLock::acquire(&path).unwrap(); + assert!(VaultLock::acquire(&path).is_err()); + assert!(first.write(b"blocked").is_err()); + drop(lock); + first.write(b"winner").unwrap(); + assert!(second.write(b"stale").is_err()); + assert_eq!(fs::read(&path).unwrap(), b"winner"); + first.write(b"second commit").unwrap(); + } + + #[test] + fn lock_child() { + if let Some(path) = std::env::var_os("COTP_LOCK_TEST_PATH") { + assert!(VaultLock::acquire(Path::new(&path)).is_err()); + } + } + + #[test] + fn lock_excludes_another_process() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + let _lock = VaultLock::acquire(&path).unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "persistence::tests::lock_child"]) + .env("COTP_LOCK_TEST_PATH", path) + .status() + .unwrap(); + assert!(status.success()); + } + + fn hotp_database() -> OTPDatabase { + let element = OTPElementBuilder::default() + .secret("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + .issuer("test") + .label("test") + .type_(OTPType::Hotp) + .counter(0) + .build() + .unwrap(); + OTPDatabase { + elements: vec![element], + ..Default::default() + } + } + + #[test] + fn hotp_failure_rolls_back_and_success_commits_before_release() { + let mut db = hotp_database(); + assert!(consume_with(&mut db, 0, |_| Err(eyre!("Disk full"))).is_err()); + assert_eq!(db.elements[0].counter, Some(0)); + assert!(!db.is_modified()); + let result = consume_with(&mut db, 0, |db| { + assert_eq!(db.elements[0].counter, Some(1)); + db.needs_modification = false; + Ok(()) + }) + .unwrap(); + assert_eq!(result, "755224"); + assert_eq!(db.elements[0].counter, Some(1)); + } + + #[test] + fn pending_changes_and_exhausted_counter_are_not_consumed() { + let mut db = hotp_database(); + db.mark_modified(); + assert!(consume_with(&mut db, 0, |_| panic!("must not save")).is_err()); + assert!(db.is_modified()); + assert_eq!(db.elements[0].counter, Some(0)); + db.needs_modification = false; + db.elements[0].counter = Some(u64::MAX); + assert!(consume_with(&mut db, 0, |_| panic!("must not save")).is_err()); + } +} diff --git a/src/reading.rs b/src/reading.rs index eb752fed..375f0c5e 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -1,44 +1,19 @@ use crate::crypto; use crate::otp::otp_element::{OTPDatabase, OTPElement}; use crate::path::DATABASE_PATH; -use crate::utils; use color_eyre::eyre::{ErrReport, eyre}; use std::fs::read_to_string; -use std::io::{self, BufRead}; -use zeroize::Zeroize; +use zeroize::Zeroizing; pub type ReadResult = (OTPDatabase, Vec, Vec); -pub fn get_elements_from_input() -> color_eyre::Result { - let pw = utils::password("Password: ", 8); - get_elements_with_password(pw) -} - -pub fn get_elements_from_stdin() -> color_eyre::Result { - if let Some(password) = io::stdin().lock().lines().next() { - return get_elements_with_password(password?); - } - Err(eyre!("Failure during stdin reading")) -} - -fn get_elements_with_password(mut password: String) -> color_eyre::Result { - let (elements, key, salt) = read_from_file(&password)?; - password.zeroize(); - Ok((elements, key, salt)) -} - pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec, Vec)> { let encrypted_contents = read_to_string(DATABASE_PATH.get().unwrap()).map_err(ErrReport::from)?; if encrypted_contents.is_empty() { - return match delete_db() { - Ok(()) => Err(eyre!( - "Your database file was empty, please restart to create a new one.", - )), - Err(_) => Err(eyre!( - "Your database file is empty, please remove it manually and restart.", - )), - }; + return Err(eyre!( + "Vault file is empty. Restore a verified backup; the file was not changed." + )); } //rust close files at the end of the function crypto::cryptography::decrypt_string(&encrypted_contents, password) @@ -46,18 +21,17 @@ pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec color_eyre::Result { match read_decrypted_text(password) { - Ok((mut contents, key, salt)) => { + Ok((contents, key, salt)) => { + let contents = Zeroizing::new(contents); + let key = Zeroizing::new(key); let mut database: OTPDatabase = serde_json::from_str(&contents) .or_else(|_| serde_json::from_str::>(&contents).map(Into::into)) .map_err(ErrReport::from)?; - contents.zeroize(); + drop(contents); + crate::otp::migrations::migrate(&mut database)?; database.sort(); - Ok((database, key, salt)) + Ok((database, key.to_vec(), salt)) } Err(e) => Err(e), } } - -fn delete_db() -> io::Result<()> { - std::fs::remove_file(DATABASE_PATH.get().unwrap()) -} diff --git a/src/session_lock.rs b/src/session_lock.rs new file mode 100644 index 00000000..189a19d8 --- /dev/null +++ b/src/session_lock.rs @@ -0,0 +1,71 @@ +//! Secret-free cross-process lock requests. Each dashboard watches the same marker. +use color_eyre::eyre::Result; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn marker(path: &Path) -> Result { + let mut path = path.canonicalize()?.into_os_string(); + path.push(".lock-request"); + Ok(PathBuf::from(path)) +} + +pub fn request(path: &Path) -> Result<()> { + let mut token = [0; 16]; + getrandom::fill(&mut token).map_err(|e| color_eyre::eyre::eyre!(e))?; + crate::database_file::write(&marker(path)?, &token)?; + Ok(()) +} + +pub struct Watcher { + path: PathBuf, + last: Option>, +} +impl Watcher { + pub fn new(path: &Path) -> Result { + let path = marker(path)?; + let last = fs::read(&path).ok(); + Ok(Self { path, last }) + } + pub fn requested(&mut self) -> bool { + let current = fs::read(&self.path).ok(); + if current == self.last { + return false; + } + let changed = current.is_some(); + self.last = current; + changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn all_dashboards_receive_new_requests() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + fs::write(&path, b"encrypted vault").unwrap(); + let mut first = Watcher::new(&path).unwrap(); + let mut second = Watcher::new(&path).unwrap(); + assert!(!first.requested()); + request(&path).unwrap(); + assert!(first.requested()); + assert!(second.requested()); + assert!(!first.requested()); + request(&path).unwrap(); + assert!(first.requested()); + } +} + +#[derive(clap::Args)] +pub struct LockArgs {} +impl crate::arguments::SubcommandExecutor for LockArgs { + fn run_command( + self, + database: crate::otp::otp_element::OTPDatabase, + ) -> Result { + Ok(database) + } +} diff --git a/src/utils.rs b/src/utils.rs index 3cca40ce..14dfd26e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,36 +1,102 @@ use crate::path::DATABASE_PATH; +use color_eyre::eyre::eyre; +use std::io::{self, BufRead, IsTerminal}; +use std::sync::atomic::{AtomicBool, Ordering}; +use zeroize::Zeroize; + +static NO_INPUT: AtomicBool = AtomicBool::new(false); + +pub fn set_no_input(value: bool) { + NO_INPUT.store(value, Ordering::Relaxed); +} + +pub fn no_input() -> bool { + NO_INPUT.load(Ordering::Relaxed) +} + +pub fn ensure_interactive() -> color_eyre::Result<()> { + if no_input() || !io::stdin().is_terminal() { + return Err(eyre!( + "Interactive input is disabled; provide explicit input options (see --help)" + )); + } + Ok(()) +} + +pub fn prompt_password(message: &str) -> color_eyre::Result { + ensure_interactive()?; + Ok(rpassword::prompt_password(message)?) +} + +pub fn stdin_line() -> color_eyre::Result { + read_line(&mut io::stdin().lock()) +} + +pub fn read_line(reader: &mut impl BufRead) -> color_eyre::Result { + let mut value = String::new(); + if reader.read_line(&mut value)? == 0 { + return Err(eyre!("Input ended before a value was supplied")); + } + if value.ends_with('\n') { + value.pop(); + if value.ends_with('\r') { + value.pop(); + } + } + Ok(value) +} + +pub fn password_from_fd(fd: u32) -> color_eyre::Result { + if fd == 0 { + return stdin_line(); + } + #[cfg(unix)] + { + read_line(&mut io::BufReader::new(std::fs::File::open(format!( + "/dev/fd/{fd}" + ))?)) + } + #[cfg(not(unix))] + { + let _ = fd; + Err(eyre!( + "Password file descriptors are supported on Unix; use --password-stdin on this platform" + )) + } +} pub fn init_app() -> Result { - let db_path = DATABASE_PATH.get().unwrap(); // Safe to unwrap because we initialize + let db_path = DATABASE_PATH.get().unwrap(); let db_dir = db_path.parent().unwrap(); - if !db_dir.exists() { - if let Err(_e) = std::fs::create_dir_all(db_dir) { - return Err(()); - } - return Ok(true); + if !db_dir.as_os_str().is_empty() && !db_dir.exists() { + std::fs::create_dir_all(db_dir).map_err(|_| ())?; } Ok(!db_path.exists()) } -pub fn password(message: &str, minimum_length: usize) -> String { +pub fn password(message: &str, minimum_length: usize) -> color_eyre::Result { loop { - let password = rpassword::prompt_password(message).unwrap(); + let mut password = prompt_password(message)?; if password.chars().count() < minimum_length { - println!("Please insert a password with at least {minimum_length} digits."); + password.zeroize(); + eprintln!("Please insert a password with at least {minimum_length} characters."); continue; } - return password; + return Ok(password); } } -pub fn verified_password(message: &str, minimum_length: usize) -> String { +pub fn verified_password(message: &str, minimum_length: usize) -> color_eyre::Result { loop { - let password = password(message, minimum_length); - let verify_password = rpassword::prompt_password("Retype the same password: ").unwrap(); - if password != verify_password { - println!("Passwords do not match"); + let mut password = password(message, minimum_length)?; + let mut verify_password = prompt_password("Retype the same password: ")?; + let matches = password == verify_password; + verify_password.zeroize(); + if !matches { + password.zeroize(); + eprintln!("Passwords do not match"); continue; } - return password; + return Ok(password); } } diff --git a/test_samples/migrations/bitwarden.json b/test_samples/migrations/bitwarden.json new file mode 100644 index 00000000..4bd1cf16 --- /dev/null +++ b/test_samples/migrations/bitwarden.json @@ -0,0 +1,30 @@ +{ + "encrypted": false, + "items": [ + { + "name": "Example", + "type": 1, + "favorite": true, + "login": { + "username": "alice", + "totp": "otpauth://totp/Example:alice?secret=JBSWY3DP&algorithm=SHA256&digits=8&period=60" + } + }, + { + "name": "No OTP", + "type": 1, + "login": { + "username": "bob", + "totp": null + } + }, + { + "name": "Bare seed", + "type": 1, + "login": { + "username": "bob", + "totp": "JBSWY3DP" + } + } + ] +} diff --git a/test_samples/migrations/ente.txt b/test_samples/migrations/ente.txt new file mode 100644 index 00000000..dc7b31e9 --- /dev/null +++ b/test_samples/migrations/ente.txt @@ -0,0 +1,2 @@ +otpauth://totp/Example:alice?secret=JBSWY3DP&issuer=Example&algorithm=SHA256&digits=8&period=60 +otpauth://hotp/Example:bob?secret=JBSWY3DP&counter=42 diff --git a/test_samples/migrations/onepassword.data b/test_samples/migrations/onepassword.data new file mode 100644 index 00000000..0ca228c9 --- /dev/null +++ b/test_samples/migrations/onepassword.data @@ -0,0 +1,52 @@ +{ + "accounts": [ + { + "attrs": { + "name": "Synthetic" + }, + "vaults": [ + { + "attrs": { + "name": "Personal" + }, + "items": [ + { + "favIndex": 1, + "overview": { + "title": "Example", + "subtitle": "alice", + "tags": [ + "work" + ] + }, + "details": { + "sections": [ + { + "title": "Security", + "fields": [ + { + "id": "TOTP_field", + "title": "one-time password", + "value": { + "totp": "otpauth://totp/Example:alice?secret=JBSWY3DP&algorithm=SHA512&digits=8&period=60" + } + } + ] + } + ] + } + }, + { + "overview": { + "title": "No OTP" + }, + "details": { + "sections": [] + } + } + ] + } + ] + } + ] +} diff --git a/test_samples/migrations/twofas.2fas b/test_samples/migrations/twofas.2fas new file mode 100644 index 00000000..5f8c514a --- /dev/null +++ b/test_samples/migrations/twofas.2fas @@ -0,0 +1,32 @@ +{ + "schemaVersion": 4, + "services": [ + { + "name": "Example", + "secret": "JBSWY3DP", + "updatedAt": 0, + "otp": { + "issuer": "Example", + "account": "alice", + "tokenType": "HOTP", + "counter": 42, + "algorithm": "SHA256", + "digits": 8, + "period": 30 + }, + "order": { + "position": 2 + }, + "groupId": "group-work" + } + ], + "groups": [ + { + "id": "group-work", + "name": "work", + "isExpanded": true, + "updatedAt": 0 + } + ], + "servicesEncrypted": null +} diff --git a/tests/add_integration_tests.rs b/tests/add_integration_tests.rs index aac57fbd..b41b8d9b 100644 --- a/tests/add_integration_tests.rs +++ b/tests/add_integration_tests.rs @@ -57,8 +57,8 @@ AA // Assert assertion .success() - .stderr(is_empty()) - .stdout(eq("Modifications have been persisted\n")); + .stdout(is_empty()) + .stderr(eq("Modifications have been persisted\n")); } #[cfg(unix)] @@ -101,7 +101,8 @@ AA )); assert_eq!(std::fs::read(&database).unwrap(), original); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + assert!(dir.path().join("db.cotp.lock").is_file()); cargo_bin_cmd!("cotp") .arg("--password-stdin") .arg("--database-path") diff --git a/tests/exchange_integration_tests.rs b/tests/exchange_integration_tests.rs new file mode 100644 index 00000000..a60efc3d --- /dev/null +++ b/tests/exchange_integration_tests.rs @@ -0,0 +1,76 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn cmd(vault: &Path) -> Command { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cotp")); + cmd.arg("--database-path") + .arg(vault) + .args(["--no-input", "--password-stdin"]); + cmd +} + +#[test] +fn encrypted_merge_authenticates_and_dry_run_preserves_destination() { + let dir = assert_fs::TempDir::new().unwrap(); + let source = dir.path().join("source.cotp"); + let destination = dir.path().join("destination.cotp"); + for path in [&source, &destination] { + cmd(path) + .arg("init") + .write_stdin("test-password\n") + .assert() + .success(); + } + cmd(&source) + .args([ + "add", + "--issuer", + "Example", + "--label", + "alice", + "--secret-stdin", + ]) + .write_stdin("test-password\nJBSWY3DP\n") + .assert() + .success(); + let before = std::fs::read(&destination).unwrap(); + cmd(&destination) + .arg("merge") + .arg(&source) + .args(["--source-password-stdin", "--dry-run", "--json"]) + .write_stdin("test-password\ntest-password\n") + .assert() + .success() + .stdout(predicate::str::contains("\"added\":1")); + assert_eq!(before, std::fs::read(&destination).unwrap()); + cmd(&destination) + .arg("merge") + .arg(&source) + .arg("--source-password-stdin") + .write_stdin("test-password\nwrong-password\n") + .assert() + .failure(); + assert_eq!(before, std::fs::read(&destination).unwrap()); + cmd(&destination) + .arg("merge") + .arg(&source) + .arg("--source-password-stdin") + .write_stdin("test-password\ntest-password\n") + .assert() + .success(); + cmd(&destination) + .args(["list", "--metadata-only", "--json"]) + .write_stdin("test-password\n") + .assert() + .success() + .stdout(predicate::str::contains("alice")); + cmd(&destination) + .arg("merge") + .arg(&source) + .args(["--source-password-stdin", "--json"]) + .write_stdin("test-password\ntest-password\n") + .assert() + .success() + .stdout(predicate::str::contains("\"added\":0")); +} diff --git a/tests/hardware_integration_tests.rs b/tests/hardware_integration_tests.rs new file mode 100644 index 00000000..0f0e4e1f --- /dev/null +++ b/tests/hardware_integration_tests.rs @@ -0,0 +1,71 @@ +#[cfg(unix)] +mod unix { + use assert_cmd::Command; + use predicates::prelude::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn hardware_backend_does_not_open_vault_and_requires_one_match() { + let dir = assert_fs::TempDir::new().unwrap(); + let executable = dir.path().join("ykman"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "oath accounts list") printf 'GitHub:personal\nGitHub:work\n' ;; + "oath accounts code --single -- GitHub:work") printf '123456\n' ;; + *) exit 8 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let run = || { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cotp")); + cmd.current_dir(dir.path()).env("PATH", dir.path()); + cmd + }; + run() + .args(["--backend", "yubikey", "list", "--json"]) + .assert() + .success() + .stdout(predicate::str::contains("GitHub:work")); + run() + .args(["hardware", "code", "github"]) + .assert() + .failure() + .stderr(predicate::str::contains("Ambiguous")); + run() + .args(["hardware", "code", "GitHub:work"]) + .assert() + .success() + .stdout("123456\n"); + run() + .args([ + "--no-input", + "--backend", + "yubikey", + "code", + "GitHub:work", + "--json", + ]) + .assert() + .success() + .stdout(predicate::str::contains("123456")); + for flag in ["--watch", "--preview", "--min-validity=10s"] { + run() + .args(["--backend", "yubikey", "code", "GitHub:work", flag]) + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("does not support")); + } + run() + .args(["--no-input", "hardware", "add", "account"]) + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("interactive")); + assert!(!dir.path().join("db.cotp").exists()); + } +} diff --git a/tests/import_integration_tests.rs b/tests/import_integration_tests.rs new file mode 100644 index 00000000..2fbfdcc6 --- /dev/null +++ b/tests/import_integration_tests.rs @@ -0,0 +1,97 @@ +use assert_cmd::cargo::cargo_bin_cmd; + +fn database(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("db.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + path +} + +#[test] +fn dry_run_and_rejected_entries_leave_vault_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let db = database(&dir); + let before = std::fs::read(&db).unwrap(); + let input = dir.path().join("uris.txt"); + std::fs::write(&input, "otpauth://totp/Example:alice?secret=JBSWY3DP").unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .arg("--dry-run") + .write_stdin("12345678\n") + .assert() + .success(); + assert_eq!(std::fs::read(&db).unwrap(), before); + std::fs::write( + &input, + "otpauth://totp/Example:alice?secret=JBSWY3DP\notpauth://totp/hidden?secret=INVALIDSECRET", + ) + .unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(predicates::str::contains("Import rejected")); + assert_eq!(std::fs::read(&db).unwrap(), before); +} + +#[test] +fn stdin_import_is_repeatable_and_plaintext_export_is_explicit() { + let dir = tempfile::tempdir().unwrap(); + let db = database(&dir); + for _ in 0..2 { + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["import", "-", "--yes"]) + .write_stdin("12345678\notpauth://hotp/Example:alice?secret=JBSWY3DP&counter=42\n") + .assert() + .success(); + } + let export = dir.path().join("export.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["export", "--path"]) + .arg(&export) + .write_stdin("12345678\n") + .assert() + .failure(); + assert!(!export.exists()); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["export", "--plaintext", "--path"]) + .arg(&export) + .write_stdin("12345678\n") + .assert() + .success(); + let value: serde_json::Value = serde_json::from_slice(&std::fs::read(export).unwrap()).unwrap(); + assert_eq!(value["elements"].as_array().unwrap().len(), 1); + assert_eq!(value["elements"][0]["counter"], 42); +} + +#[test] +fn dry_run_does_not_create_a_missing_vault() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("missing/vault.cotp"); + let input = dir.path().join("uris.txt"); + std::fs::write(&input, "otpauth://totp/Example:alice?secret=JBSWY3DP").unwrap(); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .arg("--dry-run") + .write_stdin("12345678\n") + .assert() + .code(1) + .stderr(predicates::str::contains("cotp init")); + assert!(!db.exists()); + assert!(!db.parent().unwrap().exists()); +} diff --git a/tests/keychain_integration_tests.rs b/tests/keychain_integration_tests.rs new file mode 100644 index 00000000..74287b34 --- /dev/null +++ b/tests/keychain_integration_tests.rs @@ -0,0 +1,37 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::contains; + +#[test] +fn no_input_remember_requires_explicit_authentication_without_accessing_native_store() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &vault).unwrap(); + let before = std::fs::read(&vault).unwrap(); + cargo_bin_cmd!("cotp") + .arg("--database-path") + .arg(&vault) + .args(["--no-input", "keychain", "remember"]) + .assert() + .failure() + .stderr(contains("Interactive input is disabled")); + assert_eq!(std::fs::read(vault).unwrap(), before); +} + +#[test] +fn explicit_password_overrides_enabled_keychain_without_accessing_native_store() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + let config = dir.path().join("config.json"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &vault).unwrap(); + std::fs::write(&config, r#"{"keychain":true}"#).unwrap(); + cargo_bin_cmd!("cotp") + .arg("--database-path") + .arg(&vault) + .arg("--config") + .arg(&config) + .args(["--no-input", "--password-stdin", "list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("[]\n"); +} diff --git a/tests/native_migration_integration_tests.rs b/tests/native_migration_integration_tests.rs new file mode 100644 index 00000000..9cba9764 --- /dev/null +++ b/tests/native_migration_integration_tests.rs @@ -0,0 +1,84 @@ +use assert_cmd::cargo::cargo_bin_cmd; + +#[test] +fn native_imports_and_selected_encrypted_aegis_export_work_without_password_arguments() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &db).unwrap(); + for source in [ + "twofas.2fas", + "bitwarden.json", + "onepassword.data", + "ente.txt", + ] { + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["import", "--yes"]) + .arg(format!("test_samples/migrations/{source}")) + .write_stdin("12345678\n") + .assert() + .success(); + } + let password_file = dir.path().join("password"); + std::fs::write(&password_file, "separate backup password\n").unwrap(); + let encrypted = dir.path().join("aegis.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args([ + "export", + "--aegis-encrypted", + "--tag", + "work", + "--query", + "example", + "--export-password-file", + ]) + .arg(&password_file) + .arg("--path") + .arg(&encrypted) + .write_stdin("12345678\n") + .assert() + .success(); + assert!( + !std::fs::read_to_string(&encrypted) + .unwrap() + .contains("JBSWY3DP") + ); + let restored = dir.path().join("restored.cotp"); + std::fs::copy( + "test_samples/cli_integration_test/empty_database", + &restored, + ) + .unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&restored) + .arg("import") + .arg(&encrypted) + .args(["--yes", "--import-password-file"]) + .arg(&password_file) + .write_stdin("12345678\n") + .assert() + .success(); + let plaintext = dir.path().join("restored.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&restored) + .args(["export", "--plaintext", "--path"]) + .arg(&plaintext) + .write_stdin("12345678\n") + .assert() + .success(); + let data: serde_json::Value = + serde_json::from_slice(&std::fs::read(plaintext).unwrap()).unwrap(); + let elements = data["elements"].as_array().unwrap(); + assert_eq!(elements.len(), 2); + assert!( + elements + .iter() + .all(|e| e["tags"] == serde_json::json!(["work"])) + ); + assert!(elements.iter().any(|e| e["counter"] == 42)); +} diff --git a/tests/organization_integration_tests.rs b/tests/organization_integration_tests.rs new file mode 100644 index 00000000..fbc5d005 --- /dev/null +++ b/tests/organization_integration_tests.rs @@ -0,0 +1,124 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use std::path::Path; + +fn command(path: &Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(path); + command +} + +fn list(path: &Path, filters: &[&str]) -> serde_json::Value { + let output = command(path) + .args(["list", "--json", "--metadata-only"]) + .args(filters) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + serde_json::from_slice(&output).unwrap() +} + +#[test] +fn organization_persists_filters_and_orders_without_changing_account_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + command(&path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .success(); + for (label, extra) in [ + ( + "alice", + vec![ + "--tag", + "work", + "--tag", + "WORK", + "--tag", + "mail", + "--favorite", + ], + ), + ("bob", vec![]), + ("carol", vec!["--order", "-1"]), + ] { + command(&path) + .args([ + "add", + "--issuer", + "Example", + "--label", + label, + "--alias", + label, + "--type", + "hotp", + "--counter", + "0", + "--secret-stdin", + ]) + .args(extra) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success(); + } + let all = list(&path, &[]); + assert_eq!(all[0]["label"], "carol"); + assert_eq!(all[1]["label"], "alice"); + assert_eq!(all[2]["label"], "bob"); + let filtered = list(&path, &["--tag", "WORK", "--tag", "mail", "--favorite"]); + assert_eq!(filtered.as_array().unwrap().len(), 1); + assert_eq!(filtered[0]["index"], 2); + assert_eq!(filtered[0]["tags"].as_array().unwrap().len(), 2); + let id = filtered[0]["id"].as_str().unwrap(); + command(&path) + .args(["code", "--tag", "work", "--favorite", "--preview"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("755224\n"); + command(&path) + .args([ + "edit", + "--tag", + "WORK", + "--favorite", + "--add-tag", + "bank", + "--remove-tag", + "MAIL", + "--set-favorite", + "false", + "--order", + "-2", + ]) + .write_stdin("12345678\n") + .assert() + .success(); + let edited = list(&path, &[]); + assert_eq!(edited[0]["id"], id); + assert_eq!(edited[0]["favorite"], false); + assert_eq!(edited[0]["tags"], serde_json::json!(["work", "bank"])); + assert_eq!(list(&path, &["--favorite"]), serde_json::json!([])); + command(&path) + .args(["edit", "--id", id, "--clear-tags", "--clear-order"]) + .write_stdin("12345678\n") + .assert() + .success(); + let cleared = list(&path, &[]); + assert_eq!(cleared[0]["label"], "carol"); + assert_eq!(cleared[1]["tags"], serde_json::json!([])); + assert!(cleared[1]["order"].is_null()); + let before = std::fs::read(&path).unwrap(); + command(&path) + .args(["edit", "--id", id, "--add-tag", " "]) + .write_stdin("12345678\n") + .assert() + .code(2); + assert_eq!(std::fs::read(&path).unwrap(), before); +} diff --git a/tests/scripting_integration_tests.rs b/tests/scripting_integration_tests.rs new file mode 100644 index 00000000..412ae3c3 --- /dev/null +++ b/tests/scripting_integration_tests.rs @@ -0,0 +1,260 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::{contains, is_empty}; + +fn command(path: &std::path::Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(path); + command +} + +fn init(path: &std::path::Path) { + command(path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .success() + .stdout(is_empty()); +} + +#[test] +fn hotp_is_committed_before_output_and_preview_and_list_are_read_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args([ + "add", + "--label", + "alice", + "--alias", + "work", + "--type", + "hotp", + "--counter", + "0", + "--secret-stdin", + ]) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success() + .stdout(is_empty()); + let before = std::fs::read(&path).unwrap(); + for args in [vec!["code", "work", "--preview"], vec!["list", "--json"]] { + command(&path) + .args(args) + .write_stdin("12345678\n") + .assert() + .success() + .stdout(contains("755224")); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + let output = command(&path) + .args(["code", "work", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(value["code"], "755224"); + assert_eq!(value["id"].as_str().unwrap().len(), 32); + assert!(value["expires_at"].is_null()); + assert!(value["remaining"].is_null()); + command(&path) + .args(["code", "work", "--preview"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("287082\n"); + let metadata = command(&path) + .args(["list", "--json", "--metadata-only"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let metadata: serde_json::Value = serde_json::from_slice(&metadata).unwrap(); + assert!(metadata[0].get("otp_code").is_none()); + command(&path) + .args(["code", "work", "--watch"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()); +} + +#[test] +fn no_input_guards_every_command_prompt_and_errors_keep_stdout_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args(["add", "--label", "alice", "--secret-stdin"]) + .write_stdin("12345678\nAA\n") + .assert() + .success(); + for args in [ + vec!["add", "--label", "bob"], + vec!["delete", "alice"], + vec!["passwd"], + ] { + command(&path) + .args(args) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); + } + command(&path) + .args(["code", "missing"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("No account")); + command(&path) + .args(["list"]) + .write_stdin("wrong-password\n") + .assert() + .code(1) + .stdout(is_empty()); + command(&path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .code(1) + .stdout(is_empty()); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--database-path"]) + .arg(&path) + .args(["backup", "verify", "unused.cotp-backup"]) + .assert() + .code(1) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--database-path"]) + .arg(&path) + .arg("list") + .assert() + .code(1) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); +} + +#[cfg(unix)] +#[test] +fn password_descriptor_preserves_stdin_for_import_and_new_password() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + let password = dir.path().join("password"); + std::fs::write(&password, "12345678\n").unwrap(); + let mut shell = assert_cmd::Command::new("sh"); + shell + .args(["-c", "exec 3<\"$1\"; shift; exec \"$@\"", "sh"]) + .arg(&password) + .arg(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-fd", "3", "--database-path"]) + .arg(&path) + .args(["import", "-", "--yes"]) + .write_stdin("otpauth://totp/Test:alice?secret=JBSWY3DP\n") + .assert() + .success() + .stdout(is_empty()); + let before = std::fs::read(&path).unwrap(); + command(&path) + .args(["code", "alice", "--min-validity", "30s"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("shorter")); + assert_eq!(std::fs::read(&path).unwrap(), before); + let output = command(&path) + .args(["code", "alice", "--json", "--min-validity", "1s"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(value["code"].as_str().unwrap().len(), 6); + assert!(value["remaining"].as_u64().unwrap() >= 1); + assert_eq!(value["expires_at"].as_u64().unwrap() % 30, 0); + let mut shell = assert_cmd::Command::new("sh"); + shell + .args(["-c", "exec 3<\"$1\"; shift; exec \"$@\"", "sh"]) + .arg(&password) + .arg(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-fd", "3", "--database-path"]) + .arg(&path) + .args(["passwd", "--new-password-fd", "0"]) + .write_stdin("new-password\n") + .assert() + .success(); + command(&path) + .args(["list", "--metadata-only"]) + .write_stdin("new-password\n") + .assert() + .success(); +} + +#[test] +fn watch_flushes_one_json_line_per_interval() { + use std::{ + io::{BufRead, BufReader, Write}, + process::{Command, Stdio}, + sync::mpsc, + time::Duration, + }; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args(["add", "--label", "watch", "--period", "1", "--secret-stdin"]) + .write_stdin("12345678\nAA\n") + .assert() + .success(); + let mut child = Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(&path) + .args(["code", "watch", "--watch", "--json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"12345678\n") + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().take(2) { + if sender.send(line).is_err() { + break; + } + } + }); + let first = receiver.recv_timeout(Duration::from_secs(10)); + let second = receiver.recv_timeout(Duration::from_secs(3)); + child.kill().unwrap(); + child.wait().unwrap(); + reader.join().unwrap(); + let first: serde_json::Value = serde_json::from_str(&first.unwrap().unwrap()).unwrap(); + let second: serde_json::Value = serde_json::from_str(&second.unwrap().unwrap()).unwrap(); + assert_eq!( + second["expires_at"].as_u64().unwrap(), + first["expires_at"].as_u64().unwrap() + 1 + ); +} diff --git a/tests/selection_integration_tests.rs b/tests/selection_integration_tests.rs new file mode 100644 index 00000000..721e9970 --- /dev/null +++ b/tests/selection_integration_tests.rs @@ -0,0 +1,110 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::contains; + +fn command(path: &std::path::Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--password-stdin", "--database-path"]) + .arg(path); + command +} + +#[test] +fn account_identity_survives_edit_and_selectors_do_not_choose_ambiguous_accounts() { + let dir = assert_fs::TempDir::new().unwrap(); + let path = dir.path().join("accounts.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + for (label, alias, counter) in [("alice", "work", "0"), ("bob", "personal", "1")] { + command(&path) + .args([ + "add", + "--issuer", + "Example", + "--label", + label, + "--alias", + alias, + "--type", + "hotp", + "--counter", + counter, + "--secret-stdin", + ]) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success(); + } + let output = command(&path) + .args(["list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let list: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let id = list + .as_array() + .unwrap() + .iter() + .find(|e| e["alias"] == "work") + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!(id.len(), 32); + command(&path) + .args(["extract", "--alias", "WORK"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout(contains("755224")); + command(&path) + .args(["extract", "--issuer", "example"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("Ambiguous")); + command(&path) + .args(["extract", "--index", "0"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("start at 1")); + command(&path) + .args([ + "edit", + "--id", + &id, + "--issuer", + "Renamed", + "--set-alias", + "primary", + ]) + .write_stdin("12345678\n") + .assert() + .success(); + let output = command(&path) + .args(["list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let list: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let account = list + .as_array() + .unwrap() + .iter() + .find(|e| e["id"] == id) + .unwrap(); + assert_eq!(account["issuer"], "Renamed"); + assert_eq!(account["alias"], "primary"); + command(&path) + .args(["delete", "--issuer", "*"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("Ambiguous")); +} diff --git a/tests/session_integration_tests.rs b/tests/session_integration_tests.rs new file mode 100644 index 00000000..06579e61 --- /dev/null +++ b/tests/session_integration_tests.rs @@ -0,0 +1,37 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn lock_requests_do_not_unlock_or_modify_the_vault() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + std::fs::write(&vault, b"opaque encrypted content").unwrap(); + for _ in 0..2 { + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--database-path", vault.to_str().unwrap(), "lock"]) + .assert() + .success() + .stderr(predicate::str::contains("Lock requested")); + assert_eq!(std::fs::read(&vault).unwrap(), b"opaque encrypted content"); + assert_eq!( + std::fs::read(dir.path().join("vault.cotp.lock-request")) + .unwrap() + .len(), + 16 + ); + } +} + +#[test] +fn clipboard_helper_does_not_open_a_vault() { + let dir = tempfile::tempdir().unwrap(); + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .args(["--internal-clipboard-expiry", "unsupported", "0"]) + .write_stdin("sensitive content supplied only over stdin") + .assert() + .success() + .stdout("") + .stderr(""); + assert!(!dir.path().join("db.cotp").exists()); +} diff --git a/tests/shell_integration_tests.rs b/tests/shell_integration_tests.rs new file mode 100644 index 00000000..8739348e --- /dev/null +++ b/tests/shell_integration_tests.rs @@ -0,0 +1,40 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn completions_and_manual_do_not_open_a_vault() { + let dir = assert_fs::TempDir::new().unwrap(); + for shell in ["bash", "zsh", "fish", "powershell", "elvish"] { + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .args(["completions", shell]) + .assert() + .success() + .stdout(predicate::str::contains("cotp")); + } + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .arg("man") + .assert() + .success() + .stdout(predicate::str::contains(".TH")); + assert!(!dir.path().join("db.cotp").exists()); +} + +#[test] +fn explicit_unknown_profile_is_an_error() { + let dir = assert_fs::TempDir::new().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{}").unwrap(); + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args([ + "--config", + config.to_str().unwrap(), + "--profile", + "missing", + "list", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Unknown vault profile")); +}