diff --git a/Cargo.lock b/Cargo.lock index 75a16f1..e28ed77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,7 +31,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "bytes", - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -42,10 +42,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -53,8 +64,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -66,7 +77,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" dependencies = [ - "aes", + "aes 0.8.4", ] [[package]] @@ -566,6 +577,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -680,7 +697,18 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", ] [[package]] @@ -698,6 +726,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + [[package]] name = "block-padding" version = "0.3.3" @@ -707,6 +750,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -745,7 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", - "cipher", + "cipher 0.4.4", ] [[package]] @@ -901,7 +953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" dependencies = [ "byteorder", - "cipher", + "cipher 0.4.4", ] [[package]] @@ -987,7 +1039,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1005,7 +1057,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -1026,7 +1087,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1058,18 +1119,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -1175,9 +1260,9 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ - "cipher", + "cipher 0.4.4", "dbl", - "digest", + "digest 0.10.7", ] [[package]] @@ -1189,6 +1274,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1251,6 +1342,18 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -1354,6 +1457,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1477,6 +1586,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -1502,7 +1620,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1516,6 +1634,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1531,7 +1658,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1707,7 +1834,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1778,7 +1905,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1787,12 +1914,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1858,12 +1997,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" dependencies = [ - "digest", + "digest 0.10.7", "num-bigint-dig", "num-traits", "pkcs8", "rfc6979", - "sha2", + "sha2 0.10.9", "signature", "zeroize", ] @@ -1887,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ "aead", - "cipher", + "cipher 0.4.4", "cmac", "ctr", "subtle", @@ -1900,7 +2039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1927,7 +2066,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1947,7 +2086,7 @@ dependencies = [ "base16ct", "base64ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -3331,7 +3470,7 @@ version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" dependencies = [ - "base64", + "base64 0.22.1", "bstr", "gix-command", "gix-credentials", @@ -3804,6 +3943,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -3816,7 +3961,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -3825,7 +3970,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -3867,6 +4021,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -3909,7 +4072,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3926,6 +4089,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.14.0" @@ -4201,7 +4388,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -4275,10 +4462,20 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -4335,8 +4532,9 @@ dependencies = [ "gix", "gix-config", "hex", - "hmac", + "hmac 0.12.1", "image", + "keepass", "keyring-core", "nix 0.31.3", "pgp", @@ -4350,7 +4548,7 @@ dependencies = [ "security-framework", "serde", "sha1", - "sha2", + "sha2 0.10.9", "shlex 1.3.0", "smallvec", "tempfile", @@ -4567,7 +4765,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", "signature", ] @@ -4591,6 +4789,40 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keepass" +version = "0.13.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014931bc1d5d427563b2c75e7b240a92353463e89cb2b4e21c98fb776c1c28" +dependencies = [ + "aes 0.9.2", + "base64 0.23.1", + "block-modes", + "byteorder", + "cbc 0.2.1", + "chacha20", + "chrono", + "cipher 0.5.2", + "flate2", + "getrandom 0.4.3", + "hex", + "hex-literal", + "hmac 0.13.0", + "hybrid-array", + "indexmap", + "js-sys", + "quick-xml", + "rust-argon2", + "salsa20", + "secrecy", + "serde", + "sha2 0.11.0", + "thiserror 2.0.19", + "twofish 0.8.0", + "uuid", + "zeroize", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -4877,7 +5109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -5625,7 +5857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ "aead", - "cipher", + "cipher 0.4.4", "ctr", "subtle", ] @@ -5720,7 +5952,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5732,7 +5964,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5746,7 +5978,7 @@ dependencies = [ "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5919,13 +6151,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ "aead", - "aes", + "aes 0.8.4", "aes-gcm", "aes-kw", "argon2", - "base64", + "base64 0.22.1", "bitfields", - "block-padding", + "block-padding 0.3.3", "blowfish", "buffer-redux", "byteorder", @@ -5933,15 +6165,15 @@ dependencies = [ "camellia", "cast5", "cfb-mode", - "cipher", - "const-oid", + "cipher 0.4.4", + "const-oid 0.9.6", "crc24", "curve25519-dalek", "cx448", "derive_builder", "derive_more", "des", - "digest", + "digest 0.10.7", "dsa", "eax", "ecdsa", @@ -5970,13 +6202,13 @@ dependencies = [ "rsa", "sha1", "sha1-checked", - "sha2", + "sha2 0.10.9", "sha3", "signature", "smallvec", "snafu", "subtle", - "twofish", + "twofish 0.7.1", "x25519-dalek", "zeroize", ] @@ -6298,6 +6530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", + "serde", ] [[package]] @@ -6684,7 +6917,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -6724,7 +6957,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -6771,7 +7004,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -6807,8 +7040,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -6831,6 +7064,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rust-argon2" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" +dependencies = [ + "base64 0.22.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6975,6 +7220,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7053,14 +7308,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" dependencies = [ - "aes", - "cbc", + "aes 0.8.4", + "cbc 0.1.2", "futures-util", "generic-array", "getrandom 0.2.17", @@ -7068,7 +7332,7 @@ dependencies = [ "num", "once_cell", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -7211,7 +7475,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -7220,7 +7484,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", + "digest 0.10.7", "sha1", "zeroize", ] @@ -7233,7 +7497,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -7242,7 +7517,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -7301,7 +7576,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -7757,7 +8032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "fancy-regex", "filedescriptor", @@ -7775,7 +8050,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -8194,7 +8469,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "twofish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -8413,7 +8697,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -8781,7 +9065,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 2f3a840..55c5433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ iced = { version = "0.14", features = ["canvas", "tokio"] } image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } ironstorage = { path = "crates/storage" } keyring-core = "1.0" +keepass = "0.13.20" muda = "0.19" pgp = { version = "0.20", default-features = false } qrcode = { version = "0.14", default-features = false } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index bd2a709..d0428ac 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -24,6 +24,7 @@ The current direct dependencies are: | [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 | | [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | +| [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT | | [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 | | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | | [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 | diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 0d18cd6..67fccdf 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -26,6 +26,7 @@ use ironstorage::{ crypto::KeyStore, generate::{GeneratorConfig, PasswordGenerator}, git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, + kdbx::KdbxImporter, mutation::{ MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, }, @@ -177,6 +178,7 @@ fn needs_secret_store(request: &CommandRequest) -> bool { | CommandRequest::Remove(_) | CommandRequest::Move(_) | CommandRequest::Copy(_) + | CommandRequest::ImportKdbx(_) | CommandRequest::Otp( OtpRequest::Code(_) | OtpRequest::Insert(_) @@ -651,6 +653,32 @@ fn execute_secure_with_services< interaction, stderr, ), + CommandRequest::ImportKdbx(request) => { + let password = match interaction.read_kdbx_password(request.source()) { + Ok(password) => password, + Err(error) => return operation_error(stderr, error), + }; + match KdbxImporter::new(&repository, &keys).import( + request, + password, + secrets, + git_identity(), + ) { + Ok(outcome) => { + writeln!( + stdout, + "KDBX import: {} added, {} updated, {} unchanged, {} skipped", + outcome.added(), + outcome.updated(), + outcome.unchanged(), + outcome.skipped() + ) + .map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Err(error) => operation_error(stderr, error), + } + } CommandRequest::Otp(request) => execute_otp( config, request, @@ -1026,6 +1054,8 @@ trait OtpInteraction { } trait CliInteraction: OtpInteraction { + fn read_kdbx_password(&mut self, source: &Path) -> Result; + fn read_insert( &mut self, plan: InputPlan, @@ -1130,6 +1160,12 @@ impl OtpInteraction for NativeOtpInteraction { } impl CliInteraction for NativeOtpInteraction { + fn read_kdbx_password(&mut self, source: &Path) -> Result { + rpassword::prompt_password(format!("Enter password for {}: ", source.display())) + .map(|password| SecretBytes::new(password.into_bytes())) + .map_err(|_| CliInteractionError::Input) + } + fn read_insert( &mut self, plan: InputPlan, @@ -1213,6 +1249,10 @@ impl OtpInteraction for UnavailableOtpInteraction { #[cfg(test)] impl CliInteraction for UnavailableOtpInteraction { + fn read_kdbx_password(&mut self, _source: &Path) -> Result { + Err(CliInteractionError::Input) + } + fn read_insert( &mut self, _plan: InputPlan, @@ -1611,6 +1651,7 @@ mod tests { inputs: VecDeque, insert_inputs: VecDeque, edit_replacements: VecDeque, + kdbx_passwords: VecDeque, decisions: VecDeque, plans: Vec, prompts: Vec, @@ -1644,6 +1685,15 @@ mod tests { } impl super::CliInteraction for MemoryOtpInteraction { + fn read_kdbx_password( + &mut self, + _source: &std::path::Path, + ) -> Result { + self.kdbx_passwords + .pop_front() + .ok_or(super::CliInteractionError::Input) + } + fn read_insert( &mut self, _plan: InputPlan, diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index ddc878f..b3c9804 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -60,6 +60,7 @@ pub enum UiAction { GenerateOtp, CopyOtp, ImportOtp, + ImportKdbx, ShowOtpUri, CopyOtpUri, ShowOtpQr, @@ -106,6 +107,7 @@ impl UiAction { Self::GenerateOtp => "generate-otp", Self::CopyOtp => "copy-otp", Self::ImportOtp => "import-otp", + Self::ImportKdbx => "import-kdbx", Self::ShowOtpUri => "show-otp-uri", Self::CopyOtpUri => "copy-otp-uri", Self::ShowOtpQr => "show-otp-qr", @@ -302,6 +304,12 @@ pub const ACTIONS: &[ActionSpec] = &[ spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None), spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None), spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None), + spec( + UiAction::ImportKdbx, + MenuGroup::Tools, + "Import KeePass Database…", + None, + ), spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None), spec(UiAction::GitPush, MenuGroup::Tools, "Push", None), spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None), @@ -454,7 +462,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.switching_vault && !context.modal_open } - UiAction::ImportOtp => { + UiAction::ImportOtp | UiAction::ImportKdbx => { context.storage_ready && context.unlocked && !context.dirty @@ -623,12 +631,20 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta | UiAction::CopyOtpUri | UiAction::ShowOtpQr | UiAction::RemoveOtp => "Close the current screen first", - UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable", - UiAction::ImportOtp if !context.unlocked => "Unlock the password store first", - UiAction::ImportOtp if context.dirty => "Save or discard the current draft first", - UiAction::ImportOtp if context.saving => "Wait for the active save", - UiAction::ImportOtp if context.switching_vault => "Wait for vault validation", - UiAction::ImportOtp => "Close the current screen first", + UiAction::ImportOtp | UiAction::ImportKdbx if !context.storage_ready => { + "Shared configuration is unavailable" + } + UiAction::ImportOtp | UiAction::ImportKdbx if !context.unlocked => { + "Unlock the password store first" + } + UiAction::ImportOtp | UiAction::ImportKdbx if context.dirty => { + "Save or discard the current draft first" + } + UiAction::ImportOtp | UiAction::ImportKdbx if context.saving => "Wait for the active save", + UiAction::ImportOtp | UiAction::ImportKdbx if context.switching_vault => { + "Wait for vault validation" + } + UiAction::ImportOtp | UiAction::ImportKdbx => "Close the current screen first", UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync if !context.storage_ready => { @@ -695,6 +711,7 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { UiAction::GenerateOtp => &["totp", "hotp", "one time password"], UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"], UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"], + UiAction::ImportKdbx => &["keepass", "kdbx", "import database"], UiAction::ShowOtpUri => &["show otpauth", "otp secret"], UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"], UiAction::ShowOtpQr => &["otp qr", "export otp"], diff --git a/apps/desktop/src/folder_picker.rs b/apps/desktop/src/folder_picker.rs index a0a8bd8..6f66a49 100644 --- a/apps/desktop/src/folder_picker.rs +++ b/apps/desktop/src/folder_picker.rs @@ -32,6 +32,30 @@ pub async fn pick_qr_image() -> Result, String> { .transpose() } +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub async fn pick_kdbx_file() -> Result, String> { + pick_file("Import KeePass Database", "KeePass database", &["kdbx"]).await +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub async fn pick_key_file() -> Result, String> { + pick_file("Select KeePass Key File", "Key file", &["key", "keyx"]).await +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +async fn pick_file( + title: &str, + filter_name: &str, + extensions: &[&str], +) -> Result, String> { + Ok(rfd::AsyncFileDialog::new() + .set_title(title) + .add_filter(filter_name, extensions) + .pick_file() + .await + .map(|file| file.path().to_owned())) +} + #[cfg(target_os = "linux")] pub async fn pick_folder(initial: Option) -> Result, String> { use ashpd::{ @@ -96,6 +120,45 @@ pub async fn pick_qr_image() -> Result, String> { .map_err(|error| error.to_string()) } +#[cfg(target_os = "linux")] +pub async fn pick_kdbx_file() -> Result, String> { + pick_file("Import KeePass Database", "Import").await +} + +#[cfg(target_os = "linux")] +pub async fn pick_key_file() -> Result, String> { + pick_file("Select KeePass Key File", "Select").await +} + +#[cfg(target_os = "linux")] +async fn pick_file(title: &str, accept_label: &str) -> Result, String> { + use ashpd::{ + PortalError, + desktop::{file_chooser::SelectedFiles, request::ResponseError}, + }; + + let response = SelectedFiles::open_file() + .title(title) + .accept_label(accept_label) + .modal(true) + .multiple(false) + .send() + .await + .and_then(|request| request.response()); + let selected = match response { + Ok(selected) => selected, + Err(ashpd::Error::Response(ResponseError::Cancelled)) + | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + selected + .uris() + .first() + .ok_or_else(|| "file portal returned no selection".to_owned()) + .and_then(|uri| file_uri_path(uri.as_str())) + .map(Some) +} + #[cfg(target_os = "linux")] fn file_uri_path(uri: &str) -> Result { let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?; diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 04a5849..1ecbf90 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -52,6 +52,7 @@ use ironstorage::{ GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl, GitProgressPhase, GitSnapshot, }, + kdbx::{KdbxImportMode, KdbxImportRequest}, mutation::{MutationAction, MutationOutcome, MutationSelection}, otp::{OtpCodeValidity, OtpKind}, presentation::{ClipboardWait, NativeClipboardManager, QrMatrix}, @@ -184,6 +185,20 @@ enum Message { generation: u64, completion: OtpCompletion, }, + KdbxSourceChanged(String), + KdbxKeyFileChanged(String), + KdbxPasswordChanged(Zeroizing), + PickKdbxSource, + KdbxSourcePicked(Result, String>), + PickKdbxKeyFile, + KdbxKeyFilePicked(Result, String>), + ToggleKdbxQuickAdd, + ToggleKdbxConfirmation, + SubmitKdbxImport, + KdbxFinished { + generation: u64, + result: Box>, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -374,6 +389,7 @@ enum UtilityView { Mutation(MutationForm), Git(GitForm), Otp(OtpForm), + Kdbx(KdbxForm), Help, } @@ -402,6 +418,72 @@ struct OtpForm { error: Option, } +#[derive(Clone, Eq, PartialEq)] +struct KdbxForm { + source: String, + key_file: String, + password: Zeroizing, + quick_add: bool, + confirmed: bool, + running: bool, + error: Option, + summary: Option, +} + +impl std::fmt::Debug for KdbxForm { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("KdbxForm") + .field("source", &self.source) + .field("key_file", &self.key_file) + .field("password", &"[REDACTED]") + .field("quick_add", &self.quick_add) + .field("confirmed", &self.confirmed) + .field("running", &self.running) + .field("error", &self.error) + .field("summary", &self.summary) + .finish() + } +} + +impl Default for KdbxForm { + fn default() -> Self { + Self { + source: String::new(), + key_file: String::new(), + password: Zeroizing::new(String::new()), + quick_add: false, + confirmed: false, + running: false, + error: None, + summary: None, + } + } +} + +impl KdbxForm { + fn request(&self) -> Result<(KdbxImportRequest, SecretBytes), String> { + if self.source.trim().is_empty() { + return Err("Choose a KDBX database file.".to_owned()); + } + if !self.confirmed { + return Err("Confirm the additive import before continuing.".to_owned()); + } + Ok(( + KdbxImportRequest::new( + self.source.trim(), + (!self.key_file.trim().is_empty()).then(|| self.key_file.trim().into()), + if self.quick_add { + KdbxImportMode::QuickAdd + } else { + KdbxImportMode::AddAndUpdate + }, + ), + SecretBytes::new(self.password.as_bytes().to_vec()), + )) + } +} + impl OtpForm { fn new(entry: String) -> Self { Self { @@ -837,6 +919,7 @@ enum PendingAction { SearchContents(GrepRequest), Mutate(MutationForm), Git(DesktopGitRequest), + ImportKdbx(KdbxForm), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -972,6 +1055,7 @@ impl App { || matches!(utility, UtilityView::Mutation(form) if form.running) || matches!(utility, UtilityView::Git(form) if form.running) || matches!(utility, UtilityView::Otp(form) if form.running) + || matches!(utility, UtilityView::Kdbx(form) if form.running) }) { self.status = "Wait for the active workflow to finish…".to_owned(); } else { @@ -1867,6 +1951,130 @@ impl App { } } } + Message::KdbxSourceChanged(source) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility + && !form.running + { + form.source = source; + form.error = None; + form.summary = None; + } + } + Message::KdbxKeyFileChanged(key_file) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility + && !form.running + { + form.key_file = key_file; + form.error = None; + form.summary = None; + } + } + Message::KdbxPasswordChanged(password) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility + && !form.running + { + form.password = password; + form.error = None; + form.summary = None; + } + } + Message::PickKdbxSource => { + return Task::perform(folder_picker::pick_kdbx_file(), Message::KdbxSourcePicked); + } + Message::KdbxSourcePicked(result) => match result { + Ok(Some(path)) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.source = path.display().to_string(); + form.error = None; + form.summary = None; + } + } + Ok(None) => {} + Err(error) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.error = Some(error); + } + } + }, + Message::PickKdbxKeyFile => { + return Task::perform(folder_picker::pick_key_file(), Message::KdbxKeyFilePicked); + } + Message::KdbxKeyFilePicked(result) => match result { + Ok(Some(path)) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.key_file = path.display().to_string(); + form.error = None; + form.summary = None; + } + } + Ok(None) => {} + Err(error) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.error = Some(error); + } + } + }, + Message::ToggleKdbxQuickAdd => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility + && !form.running + { + form.quick_add = !form.quick_add; + form.summary = None; + } + } + Message::ToggleKdbxConfirmation => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility + && !form.running + { + form.confirmed = !form.confirmed; + } + } + Message::SubmitKdbxImport => { + let Some(UtilityView::Kdbx(form)) = &self.utility else { + return Task::none(); + }; + if form.running { + return Task::none(); + } + if self.handle.is_none() { + self.after_authentication = Some(PendingAction::ImportKdbx(form.clone())); + return self.begin_authentication(); + } + return self.begin_kdbx_import(form.clone()); + } + Message::KdbxFinished { generation, result } => { + if generation != self.workflow_generation { + return Task::none(); + } + match *result { + Ok((outcome, tree)) => { + self.navigation.replace(&tree); + self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty())); + let summary = format!( + "KDBX import: {} added, {} updated, {} unchanged, {} skipped.", + outcome.added(), + outcome.updated(), + outcome.unchanged(), + outcome.skipped() + ); + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.running = false; + form.error = None; + form.summary = Some(summary.clone()); + form.password = Zeroizing::new(String::new()); + form.confirmed = false; + } + self.status = summary; + } + Err(error) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.running = false; + form.error = Some(error.clone()); + } + self.status = format!("KDBX import failed: {error}"); + } + } + } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if self.native_menu.is_none() { @@ -2419,6 +2627,9 @@ impl App { self.entry_path.trim().to_owned(), ))); } + UiAction::ImportKdbx => { + self.utility = Some(UtilityView::Kdbx(KdbxForm::default())); + } UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ShowOtpUri @@ -2632,9 +2843,47 @@ impl App { self.begin_authentication() } PendingAction::Git(request) => self.begin_git(request), + PendingAction::ImportKdbx(form) if self.handle.is_none() => { + self.after_authentication = Some(PendingAction::ImportKdbx(form)); + self.begin_authentication() + } + PendingAction::ImportKdbx(form) => self.begin_kdbx_import(form), } } + fn begin_kdbx_import(&mut self, form: KdbxForm) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let (request, password) = match form.request() { + Ok(request) => request, + Err(error) => { + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + }; + self.workflow_generation = self.workflow_generation.wrapping_add(1); + let generation = self.workflow_generation; + if let Some(UtilityView::Kdbx(form)) = &mut self.utility { + form.running = true; + form.error = None; + form.summary = None; + } + self.status = "Importing KeePass database through crates/storage…".to_owned(); + Task::perform( + async move { + Box::new( + storage + .import_kdbx_active(&handle, &request, password) + .map_err(|error| error.to_string()), + ) + }, + move |result| Message::KdbxFinished { generation, result }, + ) + } + fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) { if let Some(UtilityView::Search(form)) = &mut self.utility && !form.running @@ -3307,6 +3556,12 @@ impl App { form.uri.clear(); form.error = Some(reason.clone()); } + Some(UtilityView::Kdbx(form)) => { + form.running = false; + form.password = Zeroizing::new(String::new()); + form.confirmed = false; + form.error = Some(reason.clone()); + } _ => {} } self.authentication = AuthenticationView::Locked; @@ -4444,6 +4699,66 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa content = content.push(text(format!("OTP error: {error}"))); } } + UtilityView::Kdbx(form) => { + content = content + .push(text("Import KeePass Database").size(28)) + .push(text( + "The import is additive: full mode adds new entries and updates changed entries; quick-add mode only adds entries that do not exist. Nothing is deleted.", + )) + .push(text("KDBX database")) + .push( + row![ + text_input("Database.kdbx", &form.source) + .on_input(Message::KdbxSourceChanged) + .on_submit(Message::SubmitKdbxImport), + if form.running { + button("Choose…") + } else { + button("Choose…").on_press(Message::PickKdbxSource) + }, + ] + .spacing(8), + ) + .push(text("Optional KeePass key file")) + .push( + row![ + text_input("No key file", &form.key_file) + .on_input(Message::KdbxKeyFileChanged) + .on_submit(Message::SubmitKdbxImport), + if form.running { + button("Choose…") + } else { + button("Choose…").on_press(Message::PickKdbxKeyFile) + }, + ] + .spacing(8), + ) + .push(text("Database password")) + .push( + text_input("Password", &form.password) + .secure(true) + .on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value))) + .on_submit(Message::SubmitKdbxImport), + ) + .push(search_option( + "Quick add: only add entries not already present", + form.quick_add, + Message::ToggleKdbxQuickAdd, + form.running, + )) + .push(search_option( + "Confirm additive import and per-entry Git commits", + form.confirmed, + Message::ToggleKdbxConfirmation, + form.running, + )); + if let Some(summary) = &form.summary { + content = content.push(text(summary)); + } + if let Some(error) = &form.error { + content = content.push(text(format!("KDBX import error: {error}"))); + } + } UtilityView::Help => { content = content .push(text("IronStorage Help").size(28)) @@ -4487,7 +4802,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa || matches!(utility, UtilityView::Search(form) if form.running) || matches!(utility, UtilityView::Mutation(form) if form.running) || matches!(utility, UtilityView::Git(form) if form.running) - || matches!(utility, UtilityView::Otp(form) if form.running); + || matches!(utility, UtilityView::Otp(form) if form.running) + || matches!(utility, UtilityView::Kdbx(form) if form.running); let done = if busy { done } else { @@ -4684,6 +5000,24 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .push(done) } } + UtilityView::Kdbx(form) => { + let import = button(if form.running { + "Importing…" + } else if form.quick_add { + "Quick Add" + } else { + "Import" + }); + row![ + if form.running { + import + } else { + import.on_press(Message::SubmitKdbxImport) + }, + done, + ] + .spacing(8) + } UtilityView::About | UtilityView::Help => row![done], }; container( @@ -5337,6 +5671,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { form.source.path().display() ), PendingAction::Git(request) => format!("Git {}", git_request_name(request)), + PendingAction::ImportKdbx(form) => format!("Import {}", form.source), }; container( column![ @@ -7161,4 +7496,39 @@ mod tests { })) )); } + + #[test] + fn kdbx_form_builds_a_confirmed_storage_request_and_is_cleared_on_lock() { + let mut form = KdbxForm { + source: "/tmp/passwords.kdbx".to_owned(), + key_file: "/tmp/passwords.key".to_owned(), + password: Zeroizing::new("database password".to_owned()), + quick_add: true, + confirmed: true, + ..KdbxForm::default() + }; + let (request, password) = form.request().expect("confirmed request"); + assert_eq!( + request.source(), + std::path::Path::new("/tmp/passwords.kdbx") + ); + assert_eq!( + request.key_file(), + Some(std::path::Path::new("/tmp/passwords.key")) + ); + assert_eq!(request.mode(), KdbxImportMode::QuickAdd); + assert_eq!(password.expose(), b"database password"); + assert!(!format!("{form:?}").contains("database password")); + + form.running = true; + let mut app = App::new().0; + app.utility = Some(UtilityView::Kdbx(form)); + app.authentication_lost("locked".to_owned()); + let Some(UtilityView::Kdbx(form)) = app.utility else { + panic!("expected KDBX form"); + }; + assert!(form.password.is_empty()); + assert!(!form.confirmed); + assert!(!form.running); + } } diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index dcce13c..acf027c 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -168,6 +168,7 @@ fn accelerator(action: UiAction) -> Option { | UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ImportOtp + | UiAction::ImportKdbx | UiAction::ShowOtpUri | UiAction::CopyOtpUri | UiAction::ShowOtpQr diff --git a/apps/tui/COMMANDS.md b/apps/tui/COMMANDS.md index 15df53f..4c060c8 100644 --- a/apps/tui/COMMANDS.md +++ b/apps/tui/COMMANDS.md @@ -21,7 +21,7 @@ Secret-bearing commands are masked and omitted from history. | Tree | `j`/`k`, arrows, `h`/`l`, `Enter`, `/`, `n`/`N` | move, collapse/expand, open, filter, cycle matches | | Entry | `j`/`k`, arrows, `Tab`/`Shift-Tab`, `v`/`V`, `y`, `e`, `Esc` | focus, reveal/hide, timed copy, edit, close | | Editor | `i`, `a`, `d`, `K`/`J`, `g`, `C-s` | edit/add/remove/reorder/generate/save fields | -| Store | `I`, `i`, `p`, `\\`, `d d`, `m`, `c` | init, insert, generate, grep, remove, move, copy | +| Store | `I`, `i`, `p`, `\\`, `K`, `d d`, `m`, `c` | init, insert, generate, grep, KDBX import, remove, move, copy | | Git | `g p`, `g P` | pull, push; all other Git operations use `:git …` | | OTP | `o c`, `o y`, `o u`, `o x`, `o q` | code, copy code, URI, copy URI, QR | | OTP write | `o i`, `o a`, `o v` | insert, append, validate URI forms | @@ -62,6 +62,7 @@ where one is listed. | remove | confirmed removal form | `d d` | `:remove [OPTIONS] ENTRY` | | move | move form | `m` | `:move [OPTIONS] SOURCE DESTINATION` | | copy | copy form | `c` | `:copy [OPTIONS] SOURCE DESTINATION` | +| KeePass KDBX import | masked additive-import form | `K` | `:import-kdbx [--key-file PATH] [--quick-add] SOURCE` | | git init/status/log/diff/add/commit | Git dashboard/detail pane | — | `:git SUBCOMMAND …` | | git remote/config | Git dashboard | — | `:git remote …`, `:git config …` | | git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` | diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index ba204d9..f01d64a 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -45,6 +45,7 @@ pub enum Action { InsertEntry, GenerateEntry, Grep, + ImportKdbx, RemoveEntry, MoveEntry, CopyEntry, @@ -66,6 +67,7 @@ pub enum WorkflowAction { InsertEntry, GenerateEntry, Grep, + ImportKdbx, RemoveEntry, MoveEntry, CopyEntry, @@ -80,6 +82,7 @@ impl Action { Self::InsertEntry => Some(WorkflowAction::InsertEntry), Self::GenerateEntry => Some(WorkflowAction::GenerateEntry), Self::Grep => Some(WorkflowAction::Grep), + Self::ImportKdbx => Some(WorkflowAction::ImportKdbx), Self::RemoveEntry => Some(WorkflowAction::RemoveEntry), Self::MoveEntry => Some(WorkflowAction::MoveEntry), Self::CopyEntry => Some(WorkflowAction::CopyEntry), @@ -498,6 +501,13 @@ pub static ACTIONS: &[ActionSpec] = &[ bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")), modes: BROWSER_LIKE, }, + ActionSpec { + action: Action::ImportKdbx, + label: "import KeePass KDBX", + command: "import-kdbx", + bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")), + modes: BROWSER_LIKE, + }, ActionSpec { action: Action::RemoveEntry, label: "remove entry", diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index a85a4f7..8de1e7a 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -1200,6 +1200,7 @@ impl App { | Action::InsertEntry | Action::GenerateEntry | Action::Grep + | Action::ImportKdbx | Action::RemoveEntry | Action::MoveEntry | Action::CopyEntry @@ -1212,6 +1213,7 @@ impl App { | WorkflowAction::InsertEntry | WorkflowAction::GenerateEntry | WorkflowAction::Grep + | WorkflowAction::ImportKdbx | WorkflowAction::RemoveEntry | WorkflowAction::MoveEntry | WorkflowAction::CopyEntry @@ -1626,7 +1628,8 @@ impl App { | CommandInvocation::Storage(request @ CommandRequest::Grep(_)) | CommandInvocation::Storage(request @ CommandRequest::Remove(_)) | CommandInvocation::Storage(request @ CommandRequest::Move(_)) - | CommandInvocation::Storage(request @ CommandRequest::Copy(_)) => { + | CommandInvocation::Storage(request @ CommandRequest::Copy(_)) + | CommandInvocation::Storage(request @ CommandRequest::ImportKdbx(_)) => { self.transition(Transition::Dismiss); let workflow = match &request { CommandRequest::Init(_) => WorkflowAction::Initialize, @@ -1636,6 +1639,7 @@ impl App { CommandRequest::Remove(_) => WorkflowAction::RemoveEntry, CommandRequest::Move(_) => WorkflowAction::MoveEntry, CommandRequest::Copy(_) => WorkflowAction::CopyEntry, + CommandRequest::ImportKdbx(_) => WorkflowAction::ImportKdbx, _ => unreachable!(), }; self.open_workflow(workflow, Some(request)); @@ -1727,6 +1731,10 @@ impl App { WorkflowForm::grep(Some(request)) } (WorkflowAction::Grep, None) => WorkflowForm::grep(None), + (WorkflowAction::ImportKdbx, Some(CommandRequest::ImportKdbx(request))) => { + WorkflowForm::kdbx(Some(request)) + } + (WorkflowAction::ImportKdbx, None) => WorkflowForm::kdbx(None), (WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => { WorkflowForm::remove(Some(request), None) } @@ -2132,6 +2140,7 @@ fn workflow_label(workflow: WorkflowAction) -> &'static str { WorkflowAction::InsertEntry => "entry insertion", WorkflowAction::GenerateEntry => "entry generation", WorkflowAction::Grep => "decrypted grep", + WorkflowAction::ImportKdbx => "KDBX import", WorkflowAction::RemoveEntry => "entry removal", WorkflowAction::MoveEntry => "entry move", WorkflowAction::CopyEntry => "entry copy", diff --git a/apps/tui/src/command.rs b/apps/tui/src/command.rs index ffe1159..07f0a92 100644 --- a/apps/tui/src/command.rs +++ b/apps/tui/src/command.rs @@ -9,8 +9,25 @@ use crate::action::{ACTIONS, Action}; const HISTORY_LIMIT: usize = 100; const ROOT_COMMANDS: &[&str] = &[ - "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", - "git", "otp", "lock", "unlock", "help", "version", "quit", + "init", + "list", + "show", + "find", + "grep", + "insert", + "edit", + "generate", + "remove", + "move", + "copy", + "import-kdbx", + "git", + "otp", + "lock", + "unlock", + "help", + "version", + "quit", ]; const GIT_COMMANDS: &[&str] = &[ "init", @@ -31,8 +48,20 @@ const GIT_COMMANDS: &[&str] = &[ const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"]; const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"]; const HELP_TOPICS: &[&str] = &[ - "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", - "git", "otp", + "init", + "list", + "show", + "find", + "grep", + "insert", + "edit", + "generate", + "remove", + "move", + "copy", + "import-kdbx", + "git", + "otp", ]; /// Auditable mapping from every milestone-01 command family to the TUI command surface. @@ -54,6 +83,7 @@ pub const COMMAND_COVERAGE: &[CommandCoverage] = &[ "copy entries or directories", ":copy [OPTIONS] SOURCE DESTINATION", ), + coverage("import a KeePass database", ":import-kdbx [OPTIONS] SOURCE"), coverage("initialize Git", ":git init"), coverage("show Git status", ":git status"), coverage("show Git log", ":git log [OPTIONS]"), @@ -171,6 +201,12 @@ pub const TUI_COVERAGE: &[TuiCoverage] = &[ Some(Action::CopyEntry), ":copy [OPTIONS] SOURCE DESTINATION", ), + tui( + "import a KeePass database", + "KDBX import form", + Some(Action::ImportKdbx), + ":import-kdbx [OPTIONS] SOURCE", + ), tui("initialize Git", "Git dashboard", None, ":git init"), tui("show Git status", "Git dashboard", None, ":git status"), tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"), @@ -669,6 +705,7 @@ pub fn operation_name(request: &CommandRequest) -> &'static str { CommandRequest::Remove(_) => "remove", CommandRequest::Move(_) => "move", CommandRequest::Copy(_) => "copy", + CommandRequest::ImportKdbx(_) => "KDBX import", CommandRequest::Git(request) => match request { GitRequest::Init => "Git init", GitRequest::Status => "Git status", diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index f33c502..1f7eb98 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -1258,6 +1258,21 @@ fn execute_workflow( }, ) } + WorkflowSubmission::Kdbx { request, password } => { + let outcome = ironstorage::kdbx::KdbxImporter::new(&repository, &keys) + .import(&request, password, provider, identity) + .map_err(|error| error.to_string())?; + ( + None, + format!( + "KDBX import: {} added, {} updated, {} unchanged, {} skipped", + outcome.added(), + outcome.updated(), + outcome.unchanged(), + outcome.skipped() + ), + ) + } WorkflowSubmission::Remove(request) => { let target = request.entry.clone(); let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity) diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 360e457..4280dc4 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -10,7 +10,9 @@ use ironstorage::{ RemoveRequest, }, crypto::KeyInfo, + kdbx::{KdbxImportMode, KdbxImportRequest}, otp::OtpInput, + repository::SecretBytes, write::{InsertContent, OverwriteDecision}, }; use zeroize::Zeroize; @@ -120,6 +122,16 @@ pub struct GrepForm { focus: usize, } +#[derive(Debug)] +pub struct KdbxForm { + source: String, + key_file: String, + password: SecretText, + quick_add: bool, + confirmed: bool, + focus: usize, +} + #[derive(Debug)] pub struct RemoveForm { target: String, @@ -161,6 +173,7 @@ pub enum WorkflowForm { Insert(InsertForm), Generate(GenerateForm), Grep(GrepForm), + Kdbx(KdbxForm), Remove(RemoveForm), Move(TransferForm), Copy(TransferForm), @@ -180,6 +193,10 @@ pub enum WorkflowSubmission { overwrite: OverwriteDecision, }, Grep(GrepRequest), + Kdbx { + request: KdbxImportRequest, + password: SecretBytes, + }, Remove(RemoveRequest), Move { request: MoveRequest, @@ -321,6 +338,21 @@ impl WorkflowForm { }) } + pub fn kdbx(request: Option) -> Self { + let request = request + .unwrap_or_else(|| KdbxImportRequest::new("", None, KdbxImportMode::AddAndUpdate)); + Self::Kdbx(KdbxForm { + source: request.source().to_string_lossy().into_owned(), + key_file: request + .key_file() + .map_or_else(String::new, |path| path.to_string_lossy().into_owned()), + password: SecretText::default(), + quick_add: request.mode() == KdbxImportMode::QuickAdd, + confirmed: false, + focus: 0, + }) + } + pub fn remove(request: Option, selected: Option<(&str, bool)>) -> Self { let request = request.unwrap_or_else(|| RemoveRequest { entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()), @@ -368,6 +400,7 @@ impl WorkflowForm { Self::Insert(_) => "Insert entry", Self::Generate(_) => "Generate password", Self::Grep(_) => "Search decrypted entries", + Self::Kdbx(_) => "Import KeePass KDBX", Self::Remove(_) => "Remove entry or folder", Self::Move(_) => "Move or rename", Self::Copy(_) => "Copy entry or folder", @@ -453,6 +486,33 @@ impl WorkflowForm { row(form.focus == 3, "Line numbers", yes_no(form.line_number)), row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)), ], + Self::Kdbx(form) => vec![ + row(form.focus == 0, "KDBX file", &form.source), + row( + form.focus == 1, + "Key file", + if form.key_file.is_empty() { + "(none)" + } else { + &form.key_file + }, + ), + row( + form.focus == 2, + "Database password", + if form.password.is_empty() { + "(empty)" + } else { + "••••••••" + }, + ), + row( + form.focus == 3, + "Only add new entries", + yes_no(form.quick_add), + ), + row(form.focus == 4, "Confirm import", yes_no(form.confirmed)), + ], Self::Remove(form) => vec![ row(form.focus == 0, "Target", &form.target), row(form.focus == 1, "Recursive folder", yes_no(form.recursive)), @@ -633,6 +693,26 @@ impl WorkflowForm { fixed_strings: form.fixed_strings, })) } + Self::Kdbx(form) => { + if form.source.trim().is_empty() { + return Err("KDBX file path is required".to_owned()); + } + if !form.confirmed { + return Err("Explicitly confirm the KDBX import".to_owned()); + } + Ok(WorkflowSubmission::Kdbx { + request: KdbxImportRequest::new( + form.source.trim(), + (!form.key_file.trim().is_empty()).then(|| form.key_file.trim().into()), + if form.quick_add { + KdbxImportMode::QuickAdd + } else { + KdbxImportMode::AddAndUpdate + }, + ), + password: SecretBytes::new(form.password.bytes()), + }) + } Self::Remove(form) => { if form.target.trim().is_empty() { return Err("Removal target is required".to_owned()); @@ -718,6 +798,7 @@ impl WorkflowForm { Self::Insert(_) => 5, Self::Generate(_) => 6, Self::Grep(_) => 5, + Self::Kdbx(_) => 5, Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4, Self::Otp(_) => 4, } @@ -730,6 +811,7 @@ impl WorkflowForm { Self::Insert(form) => &mut form.focus, Self::Generate(form) => &mut form.focus, Self::Grep(form) => &mut form.focus, + Self::Kdbx(form) => &mut form.focus, Self::Remove(form) => &mut form.focus, Self::Move(form) | Self::Copy(form) => &mut form.focus, Self::Otp(form) => &mut form.focus, @@ -770,6 +852,8 @@ impl WorkflowForm { Self::Grep(form) if form.focus == 2 => form.invert_match ^= true, Self::Grep(form) if form.focus == 3 => form.line_number ^= true, Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= true, + Self::Kdbx(form) if form.focus == 3 => form.quick_add ^= true, + Self::Kdbx(form) if form.focus == 4 => form.confirmed ^= true, Self::Remove(form) if form.focus == 1 => form.recursive ^= true, Self::Remove(form) if form.focus == 2 => form.force ^= true, Self::Remove(form) if form.focus == 3 => form.confirmed ^= true, @@ -813,6 +897,13 @@ impl WorkflowForm { Self::Grep(form) if form.focus == 0 => { form.pattern.pop(); } + Self::Kdbx(form) if form.focus == 0 => { + form.source.pop(); + } + Self::Kdbx(form) if form.focus == 1 => { + form.key_file.pop(); + } + Self::Kdbx(form) if form.focus == 2 => form.password.pop(), Self::Remove(form) if form.focus == 0 => { form.target.pop(); } @@ -851,6 +942,11 @@ impl WorkflowForm { form.length.push(character) } Self::Grep(form) if form.focus == 0 => form.pattern.push(character), + Self::Kdbx(form) if form.focus == 0 => form.source.push(character), + Self::Kdbx(form) if form.focus == 1 => form.key_file.push(character), + Self::Kdbx(form) if form.focus == 2 && character != '\n' => { + form.password.push(character) + } Self::Remove(form) if form.focus == 0 => form.target.push(character), Self::Move(form) | Self::Copy(form) if form.focus == 0 => form.source.push(character), Self::Move(form) | Self::Copy(form) if form.focus == 1 => { @@ -919,6 +1015,37 @@ fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static mod tests { use super::*; + #[test] + fn kdbx_form_keeps_password_secret_and_submits_both_import_modes() { + let mut form = WorkflowForm::kdbx(Some(KdbxImportRequest::new( + "fixture.kdbx", + Some("fixture.key".into()), + KdbxImportMode::AddAndUpdate, + ))); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + for character in "database password".chars() { + form.handle_key(KeyCode::Char(character), KeyModifiers::NONE); + } + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + form.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + form.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + + let WorkflowSubmission::Kdbx { request, password } = + form.submission().expect("confirmed import") + else { + panic!("expected KDBX submission"); + }; + assert_eq!(request.source(), std::path::Path::new("fixture.kdbx")); + assert_eq!( + request.key_file(), + Some(std::path::Path::new("fixture.key")) + ); + assert_eq!(request.mode(), KdbxImportMode::QuickAdd); + assert_eq!(password.expose(), b"database password"); + } + fn type_text(form: &mut WorkflowForm, text: &str) { for character in text.chars() { form.handle_key(KeyCode::Char(character), KeyModifiers::NONE); diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 4e6face..783dce1 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,6 +18,7 @@ gix-config.workspace = true hmac.workspace = true image.workspace = true keyring-core.workspace = true +keepass.workspace = true pgp.workspace = true qrcode.workspace = true rand.workspace = true @@ -49,6 +50,7 @@ arboard.workspace = true [dev-dependencies] hex = "0.4" +keepass = { workspace = true, features = ["save_kdbx4"] } nix = { version = "0.31", features = ["fs"] } rand_chacha = "0.3" smallvec = "1.15" diff --git a/crates/storage/src/command.rs b/crates/storage/src/command.rs index 64d3d93..bdfca07 100644 --- a/crates/storage/src/command.rs +++ b/crates/storage/src/command.rs @@ -6,6 +6,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind}; use clap_complete::{Shell, generate}; use crate::PRODUCT_NAME; +use crate::kdbx::{KdbxImportMode, KdbxImportRequest}; pub const EXIT_SUCCESS: u8 = 0; pub const EXIT_FAILURE: u8 = 1; @@ -52,6 +53,7 @@ pub enum CommandRequest { Remove(RemoveRequest), Move(MoveRequest), Copy(CopyRequest), + ImportKdbx(KdbxImportRequest), Git(GitRequest), Otp(OtpRequest), Completion { shell: CompletionShell }, @@ -329,6 +331,7 @@ pub enum HelpTopic { Remove, Move, Copy, + ImportKdbx, Git, Otp, } @@ -347,6 +350,7 @@ impl HelpTopic { "rm" | "remove" | "delete" => Some(Self::Remove), "mv" | "rename" => Some(Self::Move), "cp" | "copy" => Some(Self::Copy), + "import-kdbx" => Some(Self::ImportKdbx), "git" => Some(Self::Git), "otp" => Some(Self::Otp), _ => None, @@ -366,6 +370,7 @@ impl HelpTopic { Self::Remove => "rm", Self::Move => "mv", Self::Copy => "cp", + Self::ImportKdbx => "import-kdbx", Self::Git => "git", Self::Otp => "otp", } @@ -480,6 +485,8 @@ enum CommandArguments { Move(MoveArguments), #[command(name = "cp", visible_alias = "copy")] Copy(CopyArguments), + #[command(name = "import-kdbx")] + ImportKdbx(ImportKdbxArguments), Git(GitArguments), Otp(OtpArguments), Completion(CompletionArguments), @@ -616,6 +623,15 @@ struct CopyArguments { destination: String, } +#[derive(Args)] +struct ImportKdbxArguments { + source: PathBuf, + #[arg(long, value_name = "PATH")] + key_file: Option, + #[arg(long)] + quick_add: bool, +} + #[derive(Args)] struct GitArguments { #[command(subcommand)] @@ -824,6 +840,17 @@ fn convert_arguments(arguments: CliArguments) -> Result { + CommandRequest::ImportKdbx(KdbxImportRequest::new( + arguments.source, + arguments.key_file, + if arguments.quick_add { + KdbxImportMode::QuickAdd + } else { + KdbxImportMode::AddAndUpdate + }, + )) + } Some(CommandArguments::Git(arguments)) => { CommandRequest::Git(convert_git(arguments.command)?) } @@ -987,6 +1014,7 @@ fn normalize_dispatch(mut arguments: Vec) -> Vec { "rename", "cp", "copy", + "import-kdbx", "git", "otp", "help", diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index 95d896e..f933165 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -27,6 +27,7 @@ use crate::{ GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome, PushOutcome, ReqwestGitTransport, }, + kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter}, mutation::{MutationOutcome, TreeMutator}, otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri}, presentation::{ClipboardTimeout, QrMatrix}, @@ -55,6 +56,7 @@ pub enum DesktopErrorKind { EntryExists, Mutation, Otp, + Import, } #[derive(Debug)] @@ -545,6 +547,27 @@ impl DesktopStorage { .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } + pub fn import_kdbx_active( + &self, + handle: &NativeAuthenticationHandle, + request: &KdbxImportRequest, + password: SecretBytes, + ) -> Result<(KdbxImportOutcome, TreeModel), DesktopError> { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let repository = self.repository()?; + let keys = self.keys()?; + let mut provider = handle.clone(); + let outcome = KdbxImporter::new(&repository, keys) + .import(request, password, &mut provider, GitIdentity::ironstorage()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Import, error))?; + let tree = VaultReader::new(&repository, keys) + .list(&DirectoryPath::root()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))?; + Ok((outcome, tree)) + } + pub fn otp_code_active( &self, handle: &NativeAuthenticationHandle, diff --git a/crates/storage/src/kdbx.rs b/crates/storage/src/kdbx.rs new file mode 100644 index 0000000..309c232 --- /dev/null +++ b/crates/storage/src/kdbx.rs @@ -0,0 +1,560 @@ +//! Direct, additive KeePass KDBX imports into a pass-compatible repository. + +use std::{ + collections::BTreeSet, + error::Error, + fmt, fs, + path::{Path, PathBuf}, +}; + +use keepass::{ + Database, DatabaseKey, + db::{Entry, EntryRef, GroupRef, Icon, fields}, +}; + +use crate::{ + command::{EditRequest, InsertInput, InsertRequest}, + crypto::{KeyStore, SecretProvider}, + git::{AutomaticEntryCommitter, GitError, GitIdentity}, + repository::{EntryPath, Repository, RepositoryError, SecretBytes}, + write::{InsertContent, OverwriteDecision, VaultWriter, WriteError}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum KdbxImportMode { + AddAndUpdate, + QuickAdd, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KdbxImportRequest { + source: PathBuf, + key_file: Option, + mode: KdbxImportMode, +} + +impl KdbxImportRequest { + pub fn new( + source: impl Into, + key_file: Option, + mode: KdbxImportMode, + ) -> Self { + Self { + source: source.into(), + key_file, + mode, + } + } + + pub fn source(&self) -> &Path { + &self.source + } + + pub fn key_file(&self) -> Option<&Path> { + self.key_file.as_deref() + } + + pub fn mode(&self) -> KdbxImportMode { + self.mode + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct KdbxImportOutcome { + added: usize, + updated: usize, + unchanged: usize, + skipped: usize, + changed_paths: Vec, +} + +impl KdbxImportOutcome { + pub fn added(&self) -> usize { + self.added + } + + pub fn updated(&self) -> usize { + self.updated + } + + pub fn unchanged(&self) -> usize { + self.unchanged + } + + pub fn skipped(&self) -> usize { + self.skipped + } + + pub fn changed_paths(&self) -> &[EntryPath] { + &self.changed_paths + } +} + +pub struct KdbxImporter<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> KdbxImporter<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + pub fn import( + &self, + request: &KdbxImportRequest, + password: SecretBytes, + secrets: &mut impl SecretProvider, + identity: GitIdentity, + ) -> Result { + let mut source = + fs::File::open(request.source()).map_err(|source| KdbxImportError::Io { + operation: "open KDBX database", + path: request.source.clone(), + source, + })?; + let password = std::str::from_utf8(password.expose()) + .map_err(|_| KdbxImportError::PasswordEncoding)?; + let mut key = DatabaseKey::new().with_password(password); + if let Some(path) = request.key_file() { + let mut file = fs::File::open(path).map_err(|source| KdbxImportError::Io { + operation: "open KDBX key file", + path: path.to_owned(), + source, + })?; + key = key + .with_keyfile(&mut file) + .map_err(|source| KdbxImportError::Io { + operation: "read KDBX key file", + path: path.to_owned(), + source, + })?; + } + let database = Database::open(&mut source, key).map_err(KdbxImportError::Open)?; + let items = collect_items(&database)?; + let writer = VaultWriter::new(self.repository, self.keys); + let mut outcome = KdbxImportOutcome::default(); + + for item in items { + let path = item.path.to_string(); + let exists = writer.entry_exists(&path)?; + if exists && request.mode() == KdbxImportMode::QuickAdd { + outcome.skipped += 1; + continue; + } + let mut committer = + AutomaticEntryCommitter::for_entry(self.repository, &path, identity.clone())?; + if exists { + let session = writer.begin_edit(&EditRequest { entry: path }, secrets)?; + if session.plaintext().expose() == item.contents.expose() { + outcome.unchanged += 1; + continue; + } + writer.finish_edit( + session, + item.contents, + "IronStorage KDBX importer", + None, + &mut committer, + )?; + outcome.updated += 1; + } else { + writer.insert( + &InsertRequest { + entry: path, + input: InsertInput::Multiline, + force: false, + }, + InsertContent::multiline(item.contents.expose().to_vec()), + OverwriteDecision::Decline, + None, + &mut committer, + )?; + outcome.added += 1; + } + outcome.changed_paths.push(item.path); + } + Ok(outcome) + } +} + +struct ImportItem { + path: EntryPath, + contents: SecretBytes, +} + +fn collect_items(database: &Database) -> Result, KdbxImportError> { + let mut items = Vec::new(); + let mut used = BTreeSet::new(); + let recycle_bin = database.recycle_bin().map(|group| group.id()); + collect_group(database.root(), &[], recycle_bin, &mut used, &mut items)?; + Ok(items) +} + +fn collect_group( + group: GroupRef<'_>, + components: &[String], + recycle_bin: Option, + used: &mut BTreeSet, + items: &mut Vec, +) -> Result<(), KdbxImportError> { + for entry in group.entries() { + collect_entry(entry, components, used, items)?; + } + for child in group.groups() { + if Some(child.id()) == recycle_bin { + continue; + } + let mut child_components = components.to_vec(); + let name = sanitize_component(&child.name); + if !name.is_empty() { + child_components.push(name); + } + collect_group(child, &child_components, recycle_bin, used, items)?; + } + Ok(()) +} + +fn collect_entry( + entry: EntryRef<'_>, + components: &[String], + used: &mut BTreeSet, + items: &mut Vec, +) -> Result<(), KdbxImportError> { + let title = entry_title(&entry); + let attachments = entry + .attachments_named() + .map(|(name, attachment)| (name.to_owned(), attachment.data.get().to_vec())) + .collect::>(); + let attachment_names = attachments + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + items.push(ImportItem { + path: reserve_path(components, &title, used)?, + contents: render_entry(&entry, &attachment_names), + }); + + for (name, data) in attachments { + items.push(ImportItem { + path: reserve_path(components, &sanitize_component(&name), used)?, + contents: SecretBytes::new(data), + }); + } + + let history_len = entry + .history + .as_ref() + .map_or(0, |history| history.get_entries().len()); + if history_len != 0 { + let mut history_components = vec!["History".to_owned()]; + history_components.extend_from_slice(components); + for index in 0..history_len { + if let Some(historical) = entry.historical(index) { + items.push(ImportItem { + path: reserve_path(&history_components, &entry_title(&historical), used)?, + contents: render_entry(&historical, &[]), + }); + } + } + } + Ok(()) +} + +fn reserve_path( + components: &[String], + title: &str, + used: &mut BTreeSet, +) -> Result { + let mut path = components.iter().collect::(); + let title = if title.is_empty() { "notitle" } else { title }; + path.push(title); + let original = path.clone(); + let mut suffix = 1_u64; + while !used.insert(path.clone()) { + path = original.clone(); + path.set_file_name(format!("{title}-{suffix}")); + suffix = suffix.saturating_add(1); + } + EntryPath::parse(path) +} + +fn entry_title(entry: &Entry) -> String { + let title = entry.get_title().filter(|title| !title.trim().is_empty()); + let candidate = title + .or_else(|| entry.get_url().filter(|url| !url.trim().is_empty())) + .or_else(|| { + entry + .get_username() + .filter(|login| !login.trim().is_empty()) + }) + .unwrap_or("notitle"); + let candidate = if title.is_none() { + url::Url::parse(candidate) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_else(|| candidate.to_owned()) + } else { + candidate.to_owned() + }; + sanitize_component(&candidate) +} + +fn sanitize_component(value: &str) -> String { + let cleaned = value + .chars() + .map(|character| { + if character.is_control() || "<>:\"/\\|?*".contains(character) { + '-' + } else { + character + } + }) + .collect::(); + match cleaned.trim() { + "." | ".." => "-".to_owned(), + cleaned => cleaned.to_owned(), + } +} + +fn render_entry(entry: &Entry, attachment_names: &[&str]) -> SecretBytes { + let mut output = Vec::new(); + let password = normalize_newlines(entry.get_password().unwrap_or_default()); + let first_line = password + .split_once('\n') + .map_or(password.as_str(), |(first, _)| first); + output.extend_from_slice(first_line.as_bytes()); + output.push(b'\n'); + + if password.contains('\n') { + append_field(&mut output, "password_multiline", &password); + } + append_field( + &mut output, + "login", + entry.get_username().unwrap_or_default(), + ); + append_field(&mut output, "url", entry.get_url().unwrap_or_default()); + + let otp = otp_uri(entry); + if let Some(otp) = &otp { + output.extend_from_slice(otp.as_bytes()); + output.push(b'\n'); + } + append_field( + &mut output, + "comments", + entry.get(fields::NOTES).unwrap_or_default(), + ); + if !attachment_names.is_empty() { + append_field(&mut output, "attachments", &attachment_names.join(", ")); + } + if !entry.tags.is_empty() { + append_field(&mut output, "tags", &entry.tags.join(", ")); + } + if let Some(autotype) = &entry.autotype { + append_field( + &mut output, + "autotype_enabled", + if autotype.enabled { "true" } else { "false" }, + ); + append_field( + &mut output, + "autotype_sequence", + autotype.default_sequence.as_deref().unwrap_or_default(), + ); + if !autotype.associations.is_empty() { + let associations = autotype + .associations + .iter() + .map(|association| format!("{} => {}", association.window, association.sequence)) + .collect::>() + .join("\n"); + append_field(&mut output, "autotype_associations", &associations); + } + } + if let Some(icon) = entry.icon() { + let icon = match icon { + Icon::BuiltIn(index) => index.to_string(), + Icon::Custom(id) => id.to_string(), + }; + append_field(&mut output, "icon", &icon); + } + + let mut custom = entry + .fields + .iter() + .filter(|(name, value)| { + !fields::KNOWN_FIELDS.contains(&name.as_str()) + && !(name.eq_ignore_ascii_case(fields::OTP) && otp.is_some()) + && !value.get().is_empty() + }) + .map(|(name, value)| (field_name(name), value.get().as_str())) + .collect::>(); + custom.sort_by_cached_key(|(name, _)| name.to_lowercase()); + let mut names = BTreeSet::from([ + "attachments".to_owned(), + "autotype_associations".to_owned(), + "autotype_enabled".to_owned(), + "autotype_sequence".to_owned(), + "comments".to_owned(), + "icon".to_owned(), + "login".to_owned(), + "password_multiline".to_owned(), + "tags".to_owned(), + "url".to_owned(), + ]); + for (mut name, value) in custom { + let original = name.clone(); + let mut suffix = 2_u64; + while !names.insert(name.to_lowercase()) { + name = format!("{original}_{suffix}"); + suffix = suffix.saturating_add(1); + } + append_field(&mut output, &name, value); + } + SecretBytes::new(output) +} + +fn field_name(value: &str) -> String { + let name = value + .chars() + .map(|character| { + if character == ':' || character.is_whitespace() || character.is_control() { + '_' + } else { + character + } + }) + .collect::(); + if name.is_empty() { + "field".to_owned() + } else { + name + } +} + +fn append_field(output: &mut Vec, name: &str, value: &str) { + if value.is_empty() { + return; + } + output.extend_from_slice(name.as_bytes()); + output.extend_from_slice(b": "); + let value = normalize_newlines(value); + for byte in value.bytes() { + output.push(byte); + if byte == b'\n' { + output.push(b' '); + } + } + output.push(b'\n'); +} + +fn normalize_newlines(value: &str) -> String { + value.replace("\r\n", "\n").replace('\r', "\n") +} + +fn otp_uri(entry: &Entry) -> Option { + let raw = entry + .get(fields::OTP) + .filter(|value| !value.trim().is_empty()); + if let Some(raw) = raw + && raw.starts_with("otpauth://") + && crate::otp::OtpUri::parse_str(raw).is_ok() + { + return Some(raw.to_owned()); + } + let secret = raw + .filter(|value| !value.starts_with("otpauth://")) + .or_else(|| entry.get("TimeOtp-Secret-Base32")) + .or_else(|| entry.get("TOTP Seed"))?; + let secret = secret + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + let title = entry + .get_title() + .filter(|title| !title.is_empty()) + .unwrap_or("Imported"); + let mut uri = url::Url::parse("otpauth://totp/Imported").expect("static OTP URI is valid"); + uri.set_path(title); + { + let mut query = uri.query_pairs_mut(); + query.append_pair("secret", &secret); + query.append_pair("issuer", "Imported"); + query.append_pair( + "digits", + entry + .get("TimeOtp-Length") + .or_else(|| (entry.get("TOTP Settings") == Some("30;S")).then_some("6")) + .unwrap_or("6"), + ); + query.append_pair("period", entry.get("TimeOtp-Period").unwrap_or("30")); + if let Some(algorithm) = entry.get("TimeOtp-Algorithm") { + let algorithm = algorithm.replace("HMAC-", "").replace('-', ""); + query.append_pair("algorithm", &algorithm); + } + } + let uri = uri.to_string(); + crate::otp::OtpUri::parse_str(&uri).ok().map(|_| uri) +} + +#[derive(Debug)] +pub enum KdbxImportError { + Io { + operation: &'static str, + path: PathBuf, + source: std::io::Error, + }, + PasswordEncoding, + Open(keepass::db::DatabaseOpenError), + Repository(RepositoryError), + Write(WriteError), + Git(GitError), +} + +impl fmt::Display for KdbxImportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { + operation, path, .. + } => write!(formatter, "cannot {operation}: {}", path.display()), + Self::PasswordEncoding => formatter.write_str("KDBX password is not valid UTF-8"), + Self::Open(_) => formatter.write_str("cannot decrypt or parse the KDBX database"), + Self::Repository(error) => error.fmt(formatter), + Self::Write(error) => error.fmt(formatter), + Self::Git(error) => error.fmt(formatter), + } + } +} + +impl Error for KdbxImportError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Open(error) => Some(error), + Self::Repository(error) => Some(error), + Self::Write(error) => Some(error), + Self::Git(error) => Some(error), + Self::PasswordEncoding => None, + } + } +} + +impl From for KdbxImportError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for KdbxImportError { + fn from(error: WriteError) -> Self { + Self::Write(error) + } +} + +impl From for KdbxImportError { + fn from(error: GitError) -> Self { + Self::Git(error) + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 3c4f88b..2efab1b 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -13,6 +13,7 @@ pub mod desktop; pub mod document; pub mod generate; pub mod git; +pub mod kdbx; pub mod mutation; pub mod otp; pub mod presentation; diff --git a/crates/storage/tests/command_contract.rs b/crates/storage/tests/command_contract.rs index 058da07..11bdde2 100644 --- a/crates/storage/tests/command_contract.rs +++ b/crates/storage/tests/command_contract.rs @@ -8,6 +8,7 @@ use ironstorage::command::{ OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text, otp_version_text, parse_from, version_text, }; +use ironstorage::kdbx::{KdbxImportMode, KdbxImportRequest}; type TestResult = Result<(), Box>; @@ -360,3 +361,31 @@ fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult ); Ok(()) } + +#[test] +fn kdbx_import_contract_supports_update_and_quick_add_modes() -> TestResult { + assert_eq!( + request(&["import-kdbx", "vault.kdbx"])?, + CommandRequest::ImportKdbx(KdbxImportRequest::new( + "vault.kdbx", + None, + KdbxImportMode::AddAndUpdate, + )) + ); + assert_eq!( + request(&[ + "import-kdbx", + "vault.kdbx", + "--key-file", + "vault.key", + "--quick-add", + ])?, + CommandRequest::ImportKdbx(KdbxImportRequest::new( + "vault.kdbx", + Some("vault.key".into()), + KdbxImportMode::QuickAdd, + )) + ); + assert!(help_text(Some(HelpTopic::ImportKdbx)).contains("--quick-add")); + Ok(()) +} diff --git a/crates/storage/tests/kdbx_import.rs b/crates/storage/tests/kdbx_import.rs new file mode 100644 index 0000000..eb4bce9 --- /dev/null +++ b/crates/storage/tests/kdbx_import.rs @@ -0,0 +1,246 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{collections::BTreeMap, fs, io::Cursor}; + +use ironstorage::{ + crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + git::GitIdentity, + kdbx::{KdbxImportMode, KdbxImportRequest, KdbxImporter}, + repository::{Repository, SecretBytes}, + write::VaultWriter, +}; +use keepass::{ + Database, DatabaseKey, + db::{AutoType, Value, fields}, +}; +use support::compatibility::{FixtureSet, TestResult}; + +struct FixtureSecrets(BTreeMap>); + +impl FixtureSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self( + fixture + .generated + .keys + .iter() + .map(|key| { + ( + key.primary_fingerprint.clone(), + key.passphrase.as_bytes().to_vec(), + ) + }) + .collect(), + ) + } +} + +impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.0 + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[test] +fn imports_adds_updates_and_quick_adds_without_deleting() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + let source = store.path().join("source.kdbx"); + let unrelated = repository.read_entry(&ironstorage::repository::EntryPath::parse( + "email/personal", + )?)?; + save_database( + &source, + "database password", + "first password", + "line one\nwww.bahn.de Login:\n\nend", + b"first attachment", + None, + )?; + let request = KdbxImportRequest::new(&source, None, KdbxImportMode::AddAndUpdate); + let importer = KdbxImporter::new(&repository, &keys); + + let first = importer.import( + &request, + SecretBytes::new(b"database password".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + )?; + + assert_eq!((first.added(), first.updated()), (2, 0)); + assert_eq!( + plaintext(&repository, &keys, &mut secrets, "Work/Example")?, + b"first password\nlogin: alice\nurl: https://example.test\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\ncomments: line one\n www.bahn.de Login:\n \n end\nattachments: manual.txt\nautotype_enabled: true\nautotype_sequence: {USERNAME}{TAB}{PASSWORD}{ENTER}\nCustom_Label: alpha\n beta: still one field\n" + ); + assert_eq!( + plaintext(&repository, &keys, &mut secrets, "Work/manual.txt")?, + b"first attachment" + ); + + let second = importer.import( + &request, + SecretBytes::new(b"database password".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + )?; + assert_eq!( + (second.unchanged(), second.added(), second.updated()), + (2, 0, 0) + ); + + save_database( + &source, + "database password", + "updated password", + "updated\ncomments", + b"updated attachment", + None, + )?; + let updated = importer.import( + &request, + SecretBytes::new(b"database password".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + )?; + assert_eq!((updated.updated(), updated.added()), (2, 0)); + assert!( + plaintext(&repository, &keys, &mut secrets, "Work/Example")? + .starts_with(b"updated password\n") + ); + + save_database( + &source, + "database password", + "ignored by quick add", + "ignored", + b"ignored", + None, + )?; + let quick = importer.import( + &KdbxImportRequest::new(&source, None, KdbxImportMode::QuickAdd), + SecretBytes::new(b"database password".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + )?; + assert_eq!((quick.skipped(), quick.added(), quick.updated()), (2, 0, 0)); + assert!( + plaintext(&repository, &keys, &mut secrets, "Work/Example")? + .starts_with(b"updated password\n") + ); + assert_eq!( + repository.read_entry(&ironstorage::repository::EntryPath::parse( + "email/personal" + )?)?, + unrelated + ); + Ok(()) +} + +#[test] +fn password_and_key_file_are_both_required_before_any_store_change() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + let source = store.path().join("protected.kdbx"); + let key_file = store.path().join("database.key"); + fs::write(&key_file, b"key file material")?; + save_database( + &source, + "password", + "secret", + "notes", + b"attachment", + Some(b"key file material"), + )?; + let request = KdbxImportRequest::new(&source, Some(key_file), KdbxImportMode::AddAndUpdate); + let importer = KdbxImporter::new(&repository, &keys); + + assert!( + importer + .import( + &request, + SecretBytes::new(b"wrong".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + ) + .is_err() + ); + assert!(!VaultWriter::new(&repository, &keys).entry_exists("Work/Example")?); + + let outcome = importer.import( + &request, + SecretBytes::new(b"password".to_vec()), + &mut secrets, + GitIdentity::ironstorage(), + )?; + assert_eq!(outcome.added(), 2); + Ok(()) +} + +fn save_database( + path: &std::path::Path, + database_password: &str, + entry_password: &str, + notes: &str, + attachment: &[u8], + key_file: Option<&[u8]>, +) -> TestResult { + let mut database = Database::new(); + { + let mut root = database.root_mut(); + let mut group = root.add_group(); + group.name = "Work".to_owned(); + let mut entry = group.add_entry(); + entry.set_unprotected(fields::TITLE, "Example"); + entry.set_unprotected(fields::USERNAME, "alice"); + entry.set_protected(fields::PASSWORD, entry_password); + entry.set_unprotected(fields::URL, "https://example.test"); + entry.set_unprotected(fields::NOTES, notes); + entry.set_protected( + fields::OTP, + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ); + entry.set_unprotected("Custom Label", "alpha\nbeta: still one field"); + entry.autotype = Some(AutoType { + enabled: true, + default_sequence: Some("{USERNAME}{TAB}{PASSWORD}{ENTER}".to_owned()), + ..AutoType::default() + }); + entry.add_attachment("manual.txt", Value::protected(attachment.to_vec())); + } + let mut key = DatabaseKey::new().with_password(database_password); + if let Some(key_file) = key_file { + key = key.with_keyfile(&mut Cursor::new(key_file))?; + } + database.save(&mut fs::File::create(path)?, key)?; + Ok(()) +} + +fn plaintext( + repository: &Repository, + keys: &KeyStore, + secrets: &mut impl SecretProvider, + entry: &str, +) -> Result, Box> { + Ok(VaultWriter::new(repository, keys) + .begin_edit( + &ironstorage::command::EditRequest { + entry: entry.to_owned(), + }, + secrets, + )? + .plaintext() + .expose() + .to_vec()) +} diff --git a/docs/cli-parity.md b/docs/cli-parity.md index d3f93d8..063bb9c 100644 --- a/docs/cli-parity.md +++ b/docs/cli-parity.md @@ -20,6 +20,7 @@ corresponding storage effects. | `generate` | length, no-symbols, force, in-place, clipboard and QR | `generate`; generation and CLI presentation tests | | `rm`, `remove`, `delete` | force and recursive behavior | `mutation`; tree-mutation and complete CLI workflow tests | | `mv`, `rename`, `cp`, `copy` | same aliases, destination and overwrite behavior | `mutation`; tree-mutation and complete CLI workflow tests | +| KeePass KDBX import | `import-kdbx [--key-file PATH] [--quick-add] SOURCE`; additive nested import with stable multiline fields | `kdbx`; KDBX compatibility and command-contract tests | | `git` | `init`, `status`, `log`, helper-free decrypted `diff`, `add`, `commit`, `remote`, safe local `config`, `fetch`, `pull`, `push`, `sync` | `git`; embedded Git, smart-HTTP and complete CLI workflow tests | | pass-otp default/code/show | `otp ENTRY`, `otp code`, `otp show`, clipboard | `otp`; RFC, fixture and CLI tests | | pass-otp insert/add/append | URI or secret input and issuer/account derivation | `otp`; fixture, transaction and CLI tests | diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md index fbe64a2..a73550b 100644 --- a/docs/desktop-audit.md +++ b/docs/desktop-audit.md @@ -24,6 +24,7 @@ requires every registered action ID to remain present in this document. | Base pass: `find` | `find` (Edit) | Name-search form and typed result activation | Yes | | Base pass: `grep` | `search-contents` (Edit) | Authenticated decrypted-search form and typed result activation | Yes | | Base pass: `mv`/`rename`, `cp`/`copy`, `rm`/`remove` | `move-entry`, `copy-entry`, `delete-entry` (Entry) | Sidebar context controls and validated mutation forms; delete is confirmed | Yes | +| KeePass migration: additive KDBX import and quick add | `import-kdbx` (Tools) | Native file/key picker, protected password input, explicit confirmation, and storage-owned tree refresh | Yes | | Pass OTP: code/show and timed copy | `generate-otp`, `copy-otp` (Entry) | OTP panel shows typed metadata, code, validity, and HOTP confirmation | Yes | | Pass OTP: insert/add/append and validate | `import-otp` (Entry) | URI/QR import form; storage validates, replaces, and commits | Yes | | Pass OTP: URI terminal/clipboard/QR | `show-otp-uri`, `copy-otp-uri`, `show-otp-qr` (Entry) | Explicit secret view, timed copy, and storage-provided QR matrix | Yes |