Add additive KDBX importer
This commit is contained in:
420
Cargo.lock
generated
420
Cargo.lock
generated
@@ -31,7 +31,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -42,10 +42,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"cpufeatures 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "aes-gcm"
|
name = "aes-gcm"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@@ -53,8 +64,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"aes",
|
"aes 0.8.4",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"ctr",
|
"ctr",
|
||||||
"ghash",
|
"ghash",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -66,7 +77,7 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
|
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes 0.8.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -566,6 +577,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
@@ -680,7 +697,18 @@ version = "0.10.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -698,6 +726,21 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "block-padding"
|
name = "block-padding"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -707,6 +750,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -745,7 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -901,7 +953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30"
|
checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -987,7 +1039,7 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911"
|
checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1005,7 +1057,16 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1026,7 +1087,7 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330"
|
checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1058,18 +1119,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"cipher 0.5.2",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core 0.10.1",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"inout",
|
"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]]
|
[[package]]
|
||||||
@@ -1175,9 +1260,9 @@ version = "0.7.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
|
checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"dbl",
|
"dbl",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1189,6 +1274,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -1251,6 +1342,18 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
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]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -1354,6 +1457,12 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1477,6 +1586,15 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "csscolorparser"
|
name = "csscolorparser"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -1502,7 +1620,7 @@ version = "0.9.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1516,6 +1634,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "cursor-icon"
|
name = "cursor-icon"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1531,7 +1658,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1707,7 +1834,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1778,7 +1905,7 @@ version = "0.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
|
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1787,12 +1914,24 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1858,12 +1997,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
|
checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rfc6979",
|
"rfc6979",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"signature",
|
"signature",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1887,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
|
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"cmac",
|
"cmac",
|
||||||
"ctr",
|
"ctr",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1900,7 +2039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der",
|
"der",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"rfc6979",
|
"rfc6979",
|
||||||
"signature",
|
"signature",
|
||||||
@@ -1927,7 +2066,7 @@ dependencies = [
|
|||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1947,7 +2086,7 @@ dependencies = [
|
|||||||
"base16ct",
|
"base16ct",
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"crypto-bigint",
|
"crypto-bigint",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"ff",
|
"ff",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"group",
|
"group",
|
||||||
@@ -3331,7 +3470,7 @@ version = "0.58.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc"
|
checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bstr",
|
"bstr",
|
||||||
"gix-command",
|
"gix-command",
|
||||||
"gix-credentials",
|
"gix-credentials",
|
||||||
@@ -3804,6 +3943,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex-literal"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hexf-parse"
|
name = "hexf-parse"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -3816,7 +3961,7 @@ version = "0.12.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3825,7 +3970,16 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -3867,6 +4021,15 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
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]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -3909,7 +4072,7 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3926,6 +4089,30 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "iced"
|
name = "iced"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -4201,7 +4388,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477"
|
checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4275,10 +4462,20 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-padding",
|
"block-padding 0.3.3",
|
||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "instability"
|
name = "instability"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -4335,8 +4532,9 @@ dependencies = [
|
|||||||
"gix",
|
"gix",
|
||||||
"gix-config",
|
"gix-config",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"image",
|
"image",
|
||||||
|
"keepass",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"pgp",
|
"pgp",
|
||||||
@@ -4350,7 +4548,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"shlex 1.3.0",
|
"shlex 1.3.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -4567,7 +4765,7 @@ dependencies = [
|
|||||||
"ecdsa",
|
"ecdsa",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"signature",
|
"signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4591,6 +4789,40 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -4877,7 +5109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5625,7 +5857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
|
checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"ctr",
|
"ctr",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
@@ -5720,7 +5952,7 @@ dependencies = [
|
|||||||
"ecdsa",
|
"ecdsa",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"primeorder",
|
"primeorder",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5732,7 +5964,7 @@ dependencies = [
|
|||||||
"ecdsa",
|
"ecdsa",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"primeorder",
|
"primeorder",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5746,7 +5978,7 @@ dependencies = [
|
|||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"primeorder",
|
"primeorder",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5919,13 +6151,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"aes",
|
"aes 0.8.4",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aes-kw",
|
"aes-kw",
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitfields",
|
"bitfields",
|
||||||
"block-padding",
|
"block-padding 0.3.3",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"buffer-redux",
|
"buffer-redux",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
@@ -5933,15 +6165,15 @@ dependencies = [
|
|||||||
"camellia",
|
"camellia",
|
||||||
"cast5",
|
"cast5",
|
||||||
"cfb-mode",
|
"cfb-mode",
|
||||||
"cipher",
|
"cipher 0.4.4",
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"crc24",
|
"crc24",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"cx448",
|
"cx448",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"des",
|
"des",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"dsa",
|
"dsa",
|
||||||
"eax",
|
"eax",
|
||||||
"ecdsa",
|
"ecdsa",
|
||||||
@@ -5970,13 +6202,13 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha1-checked",
|
"sha1-checked",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"sha3",
|
"sha3",
|
||||||
"signature",
|
"signature",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"snafu",
|
"snafu",
|
||||||
"subtle",
|
"subtle",
|
||||||
"twofish",
|
"twofish 0.7.1",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -6298,6 +6530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6684,7 +6917,7 @@ version = "0.13.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -6724,7 +6957,7 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6771,7 +7004,7 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
|
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6807,8 +7040,8 @@ version = "0.9.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -6831,6 +7064,18 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6975,6 +7220,16 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
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]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -7053,14 +7308,23 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secrecy"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secret-service"
|
name = "secret-service"
|
||||||
version = "5.1.0"
|
version = "5.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes 0.8.4",
|
||||||
"cbc",
|
"cbc 0.1.2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
@@ -7068,7 +7332,7 @@ dependencies = [
|
|||||||
"num",
|
"num",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7211,7 +7475,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7220,7 +7484,7 @@ version = "0.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
|
checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"sha1",
|
"sha1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -7233,7 +7497,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"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]]
|
[[package]]
|
||||||
@@ -7242,7 +7517,7 @@ version = "0.10.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"keccak",
|
"keccak",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7301,7 +7576,7 @@ version = "2.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7757,7 +8032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
@@ -7775,7 +8050,7 @@ dependencies = [
|
|||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"phf",
|
"phf",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"siphasher",
|
"siphasher",
|
||||||
"terminfo",
|
"terminfo",
|
||||||
@@ -8194,7 +8469,16 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013"
|
checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -8413,7 +8697,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8781,7 +9065,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ iced = { version = "0.14", features = ["canvas", "tokio"] }
|
|||||||
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] }
|
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] }
|
||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
keyring-core = "1.0"
|
keyring-core = "1.0"
|
||||||
|
keepass = "0.13.20"
|
||||||
muda = "0.19"
|
muda = "0.19"
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
|||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use ironstorage::{
|
|||||||
crypto::KeyStore,
|
crypto::KeyStore,
|
||||||
generate::{GeneratorConfig, PasswordGenerator},
|
generate::{GeneratorConfig, PasswordGenerator},
|
||||||
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
|
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
|
||||||
|
kdbx::KdbxImporter,
|
||||||
mutation::{
|
mutation::{
|
||||||
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
|
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
|
||||||
},
|
},
|
||||||
@@ -177,6 +178,7 @@ fn needs_secret_store(request: &CommandRequest) -> bool {
|
|||||||
| CommandRequest::Remove(_)
|
| CommandRequest::Remove(_)
|
||||||
| CommandRequest::Move(_)
|
| CommandRequest::Move(_)
|
||||||
| CommandRequest::Copy(_)
|
| CommandRequest::Copy(_)
|
||||||
|
| CommandRequest::ImportKdbx(_)
|
||||||
| CommandRequest::Otp(
|
| CommandRequest::Otp(
|
||||||
OtpRequest::Code(_)
|
OtpRequest::Code(_)
|
||||||
| OtpRequest::Insert(_)
|
| OtpRequest::Insert(_)
|
||||||
@@ -651,6 +653,32 @@ fn execute_secure_with_services<
|
|||||||
interaction,
|
interaction,
|
||||||
stderr,
|
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(
|
CommandRequest::Otp(request) => execute_otp(
|
||||||
config,
|
config,
|
||||||
request,
|
request,
|
||||||
@@ -1026,6 +1054,8 @@ trait OtpInteraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trait CliInteraction: OtpInteraction {
|
trait CliInteraction: OtpInteraction {
|
||||||
|
fn read_kdbx_password(&mut self, source: &Path) -> Result<SecretBytes, CliInteractionError>;
|
||||||
|
|
||||||
fn read_insert(
|
fn read_insert(
|
||||||
&mut self,
|
&mut self,
|
||||||
plan: InputPlan,
|
plan: InputPlan,
|
||||||
@@ -1130,6 +1160,12 @@ impl OtpInteraction for NativeOtpInteraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CliInteraction for NativeOtpInteraction {
|
impl CliInteraction for NativeOtpInteraction {
|
||||||
|
fn read_kdbx_password(&mut self, source: &Path) -> Result<SecretBytes, CliInteractionError> {
|
||||||
|
rpassword::prompt_password(format!("Enter password for {}: ", source.display()))
|
||||||
|
.map(|password| SecretBytes::new(password.into_bytes()))
|
||||||
|
.map_err(|_| CliInteractionError::Input)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_insert(
|
fn read_insert(
|
||||||
&mut self,
|
&mut self,
|
||||||
plan: InputPlan,
|
plan: InputPlan,
|
||||||
@@ -1213,6 +1249,10 @@ impl OtpInteraction for UnavailableOtpInteraction {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl CliInteraction for UnavailableOtpInteraction {
|
impl CliInteraction for UnavailableOtpInteraction {
|
||||||
|
fn read_kdbx_password(&mut self, _source: &Path) -> Result<SecretBytes, CliInteractionError> {
|
||||||
|
Err(CliInteractionError::Input)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_insert(
|
fn read_insert(
|
||||||
&mut self,
|
&mut self,
|
||||||
_plan: InputPlan,
|
_plan: InputPlan,
|
||||||
@@ -1611,6 +1651,7 @@ mod tests {
|
|||||||
inputs: VecDeque<OtpInput>,
|
inputs: VecDeque<OtpInput>,
|
||||||
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
|
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
|
||||||
edit_replacements: VecDeque<SecretBytes>,
|
edit_replacements: VecDeque<SecretBytes>,
|
||||||
|
kdbx_passwords: VecDeque<SecretBytes>,
|
||||||
decisions: VecDeque<OverwriteDecision>,
|
decisions: VecDeque<OverwriteDecision>,
|
||||||
plans: Vec<InputPlan>,
|
plans: Vec<InputPlan>,
|
||||||
prompts: Vec<String>,
|
prompts: Vec<String>,
|
||||||
@@ -1644,6 +1685,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl super::CliInteraction for MemoryOtpInteraction {
|
impl super::CliInteraction for MemoryOtpInteraction {
|
||||||
|
fn read_kdbx_password(
|
||||||
|
&mut self,
|
||||||
|
_source: &std::path::Path,
|
||||||
|
) -> Result<SecretBytes, super::CliInteractionError> {
|
||||||
|
self.kdbx_passwords
|
||||||
|
.pop_front()
|
||||||
|
.ok_or(super::CliInteractionError::Input)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_insert(
|
fn read_insert(
|
||||||
&mut self,
|
&mut self,
|
||||||
_plan: InputPlan,
|
_plan: InputPlan,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub enum UiAction {
|
|||||||
GenerateOtp,
|
GenerateOtp,
|
||||||
CopyOtp,
|
CopyOtp,
|
||||||
ImportOtp,
|
ImportOtp,
|
||||||
|
ImportKdbx,
|
||||||
ShowOtpUri,
|
ShowOtpUri,
|
||||||
CopyOtpUri,
|
CopyOtpUri,
|
||||||
ShowOtpQr,
|
ShowOtpQr,
|
||||||
@@ -106,6 +107,7 @@ impl UiAction {
|
|||||||
Self::GenerateOtp => "generate-otp",
|
Self::GenerateOtp => "generate-otp",
|
||||||
Self::CopyOtp => "copy-otp",
|
Self::CopyOtp => "copy-otp",
|
||||||
Self::ImportOtp => "import-otp",
|
Self::ImportOtp => "import-otp",
|
||||||
|
Self::ImportKdbx => "import-kdbx",
|
||||||
Self::ShowOtpUri => "show-otp-uri",
|
Self::ShowOtpUri => "show-otp-uri",
|
||||||
Self::CopyOtpUri => "copy-otp-uri",
|
Self::CopyOtpUri => "copy-otp-uri",
|
||||||
Self::ShowOtpQr => "show-otp-qr",
|
Self::ShowOtpQr => "show-otp-qr",
|
||||||
@@ -302,6 +304,12 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
|
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
|
||||||
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
|
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
|
||||||
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", 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::GitPull, MenuGroup::Tools, "Pull", None),
|
||||||
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
|
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
|
||||||
spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None),
|
spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None),
|
||||||
@@ -454,7 +462,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|||||||
&& !context.switching_vault
|
&& !context.switching_vault
|
||||||
&& !context.modal_open
|
&& !context.modal_open
|
||||||
}
|
}
|
||||||
UiAction::ImportOtp => {
|
UiAction::ImportOtp | UiAction::ImportKdbx => {
|
||||||
context.storage_ready
|
context.storage_ready
|
||||||
&& context.unlocked
|
&& context.unlocked
|
||||||
&& !context.dirty
|
&& !context.dirty
|
||||||
@@ -623,12 +631,20 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
|||||||
| UiAction::CopyOtpUri
|
| UiAction::CopyOtpUri
|
||||||
| UiAction::ShowOtpQr
|
| UiAction::ShowOtpQr
|
||||||
| UiAction::RemoveOtp => "Close the current screen first",
|
| UiAction::RemoveOtp => "Close the current screen first",
|
||||||
UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable",
|
UiAction::ImportOtp | UiAction::ImportKdbx if !context.storage_ready => {
|
||||||
UiAction::ImportOtp if !context.unlocked => "Unlock the password store first",
|
"Shared configuration is unavailable"
|
||||||
UiAction::ImportOtp if context.dirty => "Save or discard the current draft first",
|
}
|
||||||
UiAction::ImportOtp if context.saving => "Wait for the active save",
|
UiAction::ImportOtp | UiAction::ImportKdbx if !context.unlocked => {
|
||||||
UiAction::ImportOtp if context.switching_vault => "Wait for vault validation",
|
"Unlock the password store first"
|
||||||
UiAction::ImportOtp => "Close the current screen 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
|
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
||||||
if !context.storage_ready =>
|
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::GenerateOtp => &["totp", "hotp", "one time password"],
|
||||||
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
||||||
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
||||||
|
UiAction::ImportKdbx => &["keepass", "kdbx", "import database"],
|
||||||
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
|
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
|
||||||
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
|
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
|
||||||
UiAction::ShowOtpQr => &["otp qr", "export otp"],
|
UiAction::ShowOtpQr => &["otp qr", "export otp"],
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, 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<Option<PathBuf>, 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<Option<PathBuf>, 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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||||
use ashpd::{
|
use ashpd::{
|
||||||
@@ -96,6 +120,45 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, String> {
|
||||||
|
pick_file("Import KeePass Database", "Import").await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
|
||||||
|
pick_file("Select KeePass Key File", "Select").await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, 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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
|
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||||
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;
|
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ use ironstorage::{
|
|||||||
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
||||||
GitProgressPhase, GitSnapshot,
|
GitProgressPhase, GitSnapshot,
|
||||||
},
|
},
|
||||||
|
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||||||
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
||||||
otp::{OtpCodeValidity, OtpKind},
|
otp::{OtpCodeValidity, OtpKind},
|
||||||
presentation::{ClipboardWait, NativeClipboardManager, QrMatrix},
|
presentation::{ClipboardWait, NativeClipboardManager, QrMatrix},
|
||||||
@@ -184,6 +185,20 @@ enum Message {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
completion: OtpCompletion,
|
completion: OtpCompletion,
|
||||||
},
|
},
|
||||||
|
KdbxSourceChanged(String),
|
||||||
|
KdbxKeyFileChanged(String),
|
||||||
|
KdbxPasswordChanged(Zeroizing<String>),
|
||||||
|
PickKdbxSource,
|
||||||
|
KdbxSourcePicked(Result<Option<PathBuf>, String>),
|
||||||
|
PickKdbxKeyFile,
|
||||||
|
KdbxKeyFilePicked(Result<Option<PathBuf>, String>),
|
||||||
|
ToggleKdbxQuickAdd,
|
||||||
|
ToggleKdbxConfirmation,
|
||||||
|
SubmitKdbxImport,
|
||||||
|
KdbxFinished {
|
||||||
|
generation: u64,
|
||||||
|
result: Box<Result<(ironstorage::kdbx::KdbxImportOutcome, TreeModel), String>>,
|
||||||
|
},
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
PollNativeMenu,
|
PollNativeMenu,
|
||||||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||||||
@@ -374,6 +389,7 @@ enum UtilityView {
|
|||||||
Mutation(MutationForm),
|
Mutation(MutationForm),
|
||||||
Git(GitForm),
|
Git(GitForm),
|
||||||
Otp(OtpForm),
|
Otp(OtpForm),
|
||||||
|
Kdbx(KdbxForm),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +418,72 @@ struct OtpForm {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
struct KdbxForm {
|
||||||
|
source: String,
|
||||||
|
key_file: String,
|
||||||
|
password: Zeroizing<String>,
|
||||||
|
quick_add: bool,
|
||||||
|
confirmed: bool,
|
||||||
|
running: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl OtpForm {
|
||||||
fn new(entry: String) -> Self {
|
fn new(entry: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -837,6 +919,7 @@ enum PendingAction {
|
|||||||
SearchContents(GrepRequest),
|
SearchContents(GrepRequest),
|
||||||
Mutate(MutationForm),
|
Mutate(MutationForm),
|
||||||
Git(DesktopGitRequest),
|
Git(DesktopGitRequest),
|
||||||
|
ImportKdbx(KdbxForm),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -972,6 +1055,7 @@ impl App {
|
|||||||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||||||
|| matches!(utility, UtilityView::Git(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)
|
||||||
}) {
|
}) {
|
||||||
self.status = "Wait for the active workflow to finish…".to_owned();
|
self.status = "Wait for the active workflow to finish…".to_owned();
|
||||||
} else {
|
} 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")]
|
#[cfg(target_os = "macos")]
|
||||||
Message::PollNativeMenu => {
|
Message::PollNativeMenu => {
|
||||||
if self.native_menu.is_none() {
|
if self.native_menu.is_none() {
|
||||||
@@ -2419,6 +2627,9 @@ impl App {
|
|||||||
self.entry_path.trim().to_owned(),
|
self.entry_path.trim().to_owned(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
UiAction::ImportKdbx => {
|
||||||
|
self.utility = Some(UtilityView::Kdbx(KdbxForm::default()));
|
||||||
|
}
|
||||||
UiAction::GenerateOtp
|
UiAction::GenerateOtp
|
||||||
| UiAction::CopyOtp
|
| UiAction::CopyOtp
|
||||||
| UiAction::ShowOtpUri
|
| UiAction::ShowOtpUri
|
||||||
@@ -2632,9 +2843,47 @@ impl App {
|
|||||||
self.begin_authentication()
|
self.begin_authentication()
|
||||||
}
|
}
|
||||||
PendingAction::Git(request) => self.begin_git(request),
|
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<Message> {
|
||||||
|
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)) {
|
fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) {
|
||||||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||||||
&& !form.running
|
&& !form.running
|
||||||
@@ -3307,6 +3556,12 @@ impl App {
|
|||||||
form.uri.clear();
|
form.uri.clear();
|
||||||
form.error = Some(reason.clone());
|
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;
|
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}")));
|
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 => {
|
UtilityView::Help => {
|
||||||
content = content
|
content = content
|
||||||
.push(text("IronStorage Help").size(28))
|
.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::Search(form) if form.running)
|
||||||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||||||
|| matches!(utility, UtilityView::Git(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 {
|
let done = if busy {
|
||||||
done
|
done
|
||||||
} else {
|
} else {
|
||||||
@@ -4684,6 +5000,24 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
|||||||
.push(done)
|
.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],
|
UtilityView::About | UtilityView::Help => row![done],
|
||||||
};
|
};
|
||||||
container(
|
container(
|
||||||
@@ -5337,6 +5671,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
|
|||||||
form.source.path().display()
|
form.source.path().display()
|
||||||
),
|
),
|
||||||
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
|
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
|
||||||
|
PendingAction::ImportKdbx(form) => format!("Import {}", form.source),
|
||||||
};
|
};
|
||||||
container(
|
container(
|
||||||
column![
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
| UiAction::GenerateOtp
|
| UiAction::GenerateOtp
|
||||||
| UiAction::CopyOtp
|
| UiAction::CopyOtp
|
||||||
| UiAction::ImportOtp
|
| UiAction::ImportOtp
|
||||||
|
| UiAction::ImportKdbx
|
||||||
| UiAction::ShowOtpUri
|
| UiAction::ShowOtpUri
|
||||||
| UiAction::CopyOtpUri
|
| UiAction::CopyOtpUri
|
||||||
| UiAction::ShowOtpQr
|
| UiAction::ShowOtpQr
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 …` |
|
| 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 | `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 |
|
| 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` |
|
| remove | confirmed removal form | `d d` | `:remove [OPTIONS] ENTRY` |
|
||||||
| move | move form | `m` | `:move [OPTIONS] SOURCE DESTINATION` |
|
| move | move form | `m` | `:move [OPTIONS] SOURCE DESTINATION` |
|
||||||
| copy | copy form | `c` | `:copy [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 init/status/log/diff/add/commit | Git dashboard/detail pane | — | `:git SUBCOMMAND …` |
|
||||||
| git remote/config | Git dashboard | — | `:git remote …`, `:git config …` |
|
| git remote/config | Git dashboard | — | `:git remote …`, `:git config …` |
|
||||||
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |
|
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub enum Action {
|
|||||||
InsertEntry,
|
InsertEntry,
|
||||||
GenerateEntry,
|
GenerateEntry,
|
||||||
Grep,
|
Grep,
|
||||||
|
ImportKdbx,
|
||||||
RemoveEntry,
|
RemoveEntry,
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
CopyEntry,
|
CopyEntry,
|
||||||
@@ -66,6 +67,7 @@ pub enum WorkflowAction {
|
|||||||
InsertEntry,
|
InsertEntry,
|
||||||
GenerateEntry,
|
GenerateEntry,
|
||||||
Grep,
|
Grep,
|
||||||
|
ImportKdbx,
|
||||||
RemoveEntry,
|
RemoveEntry,
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
CopyEntry,
|
CopyEntry,
|
||||||
@@ -80,6 +82,7 @@ impl Action {
|
|||||||
Self::InsertEntry => Some(WorkflowAction::InsertEntry),
|
Self::InsertEntry => Some(WorkflowAction::InsertEntry),
|
||||||
Self::GenerateEntry => Some(WorkflowAction::GenerateEntry),
|
Self::GenerateEntry => Some(WorkflowAction::GenerateEntry),
|
||||||
Self::Grep => Some(WorkflowAction::Grep),
|
Self::Grep => Some(WorkflowAction::Grep),
|
||||||
|
Self::ImportKdbx => Some(WorkflowAction::ImportKdbx),
|
||||||
Self::RemoveEntry => Some(WorkflowAction::RemoveEntry),
|
Self::RemoveEntry => Some(WorkflowAction::RemoveEntry),
|
||||||
Self::MoveEntry => Some(WorkflowAction::MoveEntry),
|
Self::MoveEntry => Some(WorkflowAction::MoveEntry),
|
||||||
Self::CopyEntry => Some(WorkflowAction::CopyEntry),
|
Self::CopyEntry => Some(WorkflowAction::CopyEntry),
|
||||||
@@ -498,6 +501,13 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
|
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
|
||||||
modes: BROWSER_LIKE,
|
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 {
|
ActionSpec {
|
||||||
action: Action::RemoveEntry,
|
action: Action::RemoveEntry,
|
||||||
label: "remove entry",
|
label: "remove entry",
|
||||||
|
|||||||
@@ -1200,6 +1200,7 @@ impl App {
|
|||||||
| Action::InsertEntry
|
| Action::InsertEntry
|
||||||
| Action::GenerateEntry
|
| Action::GenerateEntry
|
||||||
| Action::Grep
|
| Action::Grep
|
||||||
|
| Action::ImportKdbx
|
||||||
| Action::RemoveEntry
|
| Action::RemoveEntry
|
||||||
| Action::MoveEntry
|
| Action::MoveEntry
|
||||||
| Action::CopyEntry
|
| Action::CopyEntry
|
||||||
@@ -1212,6 +1213,7 @@ impl App {
|
|||||||
| WorkflowAction::InsertEntry
|
| WorkflowAction::InsertEntry
|
||||||
| WorkflowAction::GenerateEntry
|
| WorkflowAction::GenerateEntry
|
||||||
| WorkflowAction::Grep
|
| WorkflowAction::Grep
|
||||||
|
| WorkflowAction::ImportKdbx
|
||||||
| WorkflowAction::RemoveEntry
|
| WorkflowAction::RemoveEntry
|
||||||
| WorkflowAction::MoveEntry
|
| WorkflowAction::MoveEntry
|
||||||
| WorkflowAction::CopyEntry
|
| WorkflowAction::CopyEntry
|
||||||
@@ -1626,7 +1628,8 @@ impl App {
|
|||||||
| CommandInvocation::Storage(request @ CommandRequest::Grep(_))
|
| CommandInvocation::Storage(request @ CommandRequest::Grep(_))
|
||||||
| CommandInvocation::Storage(request @ CommandRequest::Remove(_))
|
| CommandInvocation::Storage(request @ CommandRequest::Remove(_))
|
||||||
| CommandInvocation::Storage(request @ CommandRequest::Move(_))
|
| CommandInvocation::Storage(request @ CommandRequest::Move(_))
|
||||||
| CommandInvocation::Storage(request @ CommandRequest::Copy(_)) => {
|
| CommandInvocation::Storage(request @ CommandRequest::Copy(_))
|
||||||
|
| CommandInvocation::Storage(request @ CommandRequest::ImportKdbx(_)) => {
|
||||||
self.transition(Transition::Dismiss);
|
self.transition(Transition::Dismiss);
|
||||||
let workflow = match &request {
|
let workflow = match &request {
|
||||||
CommandRequest::Init(_) => WorkflowAction::Initialize,
|
CommandRequest::Init(_) => WorkflowAction::Initialize,
|
||||||
@@ -1636,6 +1639,7 @@ impl App {
|
|||||||
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
|
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
|
||||||
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
|
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
|
||||||
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
|
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
|
||||||
|
CommandRequest::ImportKdbx(_) => WorkflowAction::ImportKdbx,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
self.open_workflow(workflow, Some(request));
|
self.open_workflow(workflow, Some(request));
|
||||||
@@ -1727,6 +1731,10 @@ impl App {
|
|||||||
WorkflowForm::grep(Some(request))
|
WorkflowForm::grep(Some(request))
|
||||||
}
|
}
|
||||||
(WorkflowAction::Grep, None) => WorkflowForm::grep(None),
|
(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))) => {
|
(WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => {
|
||||||
WorkflowForm::remove(Some(request), None)
|
WorkflowForm::remove(Some(request), None)
|
||||||
}
|
}
|
||||||
@@ -2132,6 +2140,7 @@ fn workflow_label(workflow: WorkflowAction) -> &'static str {
|
|||||||
WorkflowAction::InsertEntry => "entry insertion",
|
WorkflowAction::InsertEntry => "entry insertion",
|
||||||
WorkflowAction::GenerateEntry => "entry generation",
|
WorkflowAction::GenerateEntry => "entry generation",
|
||||||
WorkflowAction::Grep => "decrypted grep",
|
WorkflowAction::Grep => "decrypted grep",
|
||||||
|
WorkflowAction::ImportKdbx => "KDBX import",
|
||||||
WorkflowAction::RemoveEntry => "entry removal",
|
WorkflowAction::RemoveEntry => "entry removal",
|
||||||
WorkflowAction::MoveEntry => "entry move",
|
WorkflowAction::MoveEntry => "entry move",
|
||||||
WorkflowAction::CopyEntry => "entry copy",
|
WorkflowAction::CopyEntry => "entry copy",
|
||||||
|
|||||||
@@ -9,8 +9,25 @@ use crate::action::{ACTIONS, Action};
|
|||||||
const HISTORY_LIMIT: usize = 100;
|
const HISTORY_LIMIT: usize = 100;
|
||||||
|
|
||||||
const ROOT_COMMANDS: &[&str] = &[
|
const ROOT_COMMANDS: &[&str] = &[
|
||||||
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
|
"init",
|
||||||
"git", "otp", "lock", "unlock", "help", "version", "quit",
|
"list",
|
||||||
|
"show",
|
||||||
|
"find",
|
||||||
|
"grep",
|
||||||
|
"insert",
|
||||||
|
"edit",
|
||||||
|
"generate",
|
||||||
|
"remove",
|
||||||
|
"move",
|
||||||
|
"copy",
|
||||||
|
"import-kdbx",
|
||||||
|
"git",
|
||||||
|
"otp",
|
||||||
|
"lock",
|
||||||
|
"unlock",
|
||||||
|
"help",
|
||||||
|
"version",
|
||||||
|
"quit",
|
||||||
];
|
];
|
||||||
const GIT_COMMANDS: &[&str] = &[
|
const GIT_COMMANDS: &[&str] = &[
|
||||||
"init",
|
"init",
|
||||||
@@ -31,8 +48,20 @@ const GIT_COMMANDS: &[&str] = &[
|
|||||||
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
|
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
|
||||||
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
|
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
|
||||||
const HELP_TOPICS: &[&str] = &[
|
const HELP_TOPICS: &[&str] = &[
|
||||||
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
|
"init",
|
||||||
"git", "otp",
|
"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.
|
/// 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 entries or directories",
|
||||||
":copy [OPTIONS] SOURCE DESTINATION",
|
":copy [OPTIONS] SOURCE DESTINATION",
|
||||||
),
|
),
|
||||||
|
coverage("import a KeePass database", ":import-kdbx [OPTIONS] SOURCE"),
|
||||||
coverage("initialize Git", ":git init"),
|
coverage("initialize Git", ":git init"),
|
||||||
coverage("show Git status", ":git status"),
|
coverage("show Git status", ":git status"),
|
||||||
coverage("show Git log", ":git log [OPTIONS]"),
|
coverage("show Git log", ":git log [OPTIONS]"),
|
||||||
@@ -171,6 +201,12 @@ pub const TUI_COVERAGE: &[TuiCoverage] = &[
|
|||||||
Some(Action::CopyEntry),
|
Some(Action::CopyEntry),
|
||||||
":copy [OPTIONS] SOURCE DESTINATION",
|
":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("initialize Git", "Git dashboard", None, ":git init"),
|
||||||
tui("show Git status", "Git dashboard", None, ":git status"),
|
tui("show Git status", "Git dashboard", None, ":git status"),
|
||||||
tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"),
|
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::Remove(_) => "remove",
|
||||||
CommandRequest::Move(_) => "move",
|
CommandRequest::Move(_) => "move",
|
||||||
CommandRequest::Copy(_) => "copy",
|
CommandRequest::Copy(_) => "copy",
|
||||||
|
CommandRequest::ImportKdbx(_) => "KDBX import",
|
||||||
CommandRequest::Git(request) => match request {
|
CommandRequest::Git(request) => match request {
|
||||||
GitRequest::Init => "Git init",
|
GitRequest::Init => "Git init",
|
||||||
GitRequest::Status => "Git status",
|
GitRequest::Status => "Git status",
|
||||||
|
|||||||
@@ -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) => {
|
WorkflowSubmission::Remove(request) => {
|
||||||
let target = request.entry.clone();
|
let target = request.entry.clone();
|
||||||
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
|
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use ironstorage::{
|
|||||||
RemoveRequest,
|
RemoveRequest,
|
||||||
},
|
},
|
||||||
crypto::KeyInfo,
|
crypto::KeyInfo,
|
||||||
|
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||||||
otp::OtpInput,
|
otp::OtpInput,
|
||||||
|
repository::SecretBytes,
|
||||||
write::{InsertContent, OverwriteDecision},
|
write::{InsertContent, OverwriteDecision},
|
||||||
};
|
};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
@@ -120,6 +122,16 @@ pub struct GrepForm {
|
|||||||
focus: usize,
|
focus: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KdbxForm {
|
||||||
|
source: String,
|
||||||
|
key_file: String,
|
||||||
|
password: SecretText,
|
||||||
|
quick_add: bool,
|
||||||
|
confirmed: bool,
|
||||||
|
focus: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RemoveForm {
|
pub struct RemoveForm {
|
||||||
target: String,
|
target: String,
|
||||||
@@ -161,6 +173,7 @@ pub enum WorkflowForm {
|
|||||||
Insert(InsertForm),
|
Insert(InsertForm),
|
||||||
Generate(GenerateForm),
|
Generate(GenerateForm),
|
||||||
Grep(GrepForm),
|
Grep(GrepForm),
|
||||||
|
Kdbx(KdbxForm),
|
||||||
Remove(RemoveForm),
|
Remove(RemoveForm),
|
||||||
Move(TransferForm),
|
Move(TransferForm),
|
||||||
Copy(TransferForm),
|
Copy(TransferForm),
|
||||||
@@ -180,6 +193,10 @@ pub enum WorkflowSubmission {
|
|||||||
overwrite: OverwriteDecision,
|
overwrite: OverwriteDecision,
|
||||||
},
|
},
|
||||||
Grep(GrepRequest),
|
Grep(GrepRequest),
|
||||||
|
Kdbx {
|
||||||
|
request: KdbxImportRequest,
|
||||||
|
password: SecretBytes,
|
||||||
|
},
|
||||||
Remove(RemoveRequest),
|
Remove(RemoveRequest),
|
||||||
Move {
|
Move {
|
||||||
request: MoveRequest,
|
request: MoveRequest,
|
||||||
@@ -321,6 +338,21 @@ impl WorkflowForm {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn kdbx(request: Option<KdbxImportRequest>) -> 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<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
|
pub fn remove(request: Option<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
|
||||||
let request = request.unwrap_or_else(|| RemoveRequest {
|
let request = request.unwrap_or_else(|| RemoveRequest {
|
||||||
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
|
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
|
||||||
@@ -368,6 +400,7 @@ impl WorkflowForm {
|
|||||||
Self::Insert(_) => "Insert entry",
|
Self::Insert(_) => "Insert entry",
|
||||||
Self::Generate(_) => "Generate password",
|
Self::Generate(_) => "Generate password",
|
||||||
Self::Grep(_) => "Search decrypted entries",
|
Self::Grep(_) => "Search decrypted entries",
|
||||||
|
Self::Kdbx(_) => "Import KeePass KDBX",
|
||||||
Self::Remove(_) => "Remove entry or folder",
|
Self::Remove(_) => "Remove entry or folder",
|
||||||
Self::Move(_) => "Move or rename",
|
Self::Move(_) => "Move or rename",
|
||||||
Self::Copy(_) => "Copy entry or folder",
|
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 == 3, "Line numbers", yes_no(form.line_number)),
|
||||||
row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)),
|
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![
|
Self::Remove(form) => vec![
|
||||||
row(form.focus == 0, "Target", &form.target),
|
row(form.focus == 0, "Target", &form.target),
|
||||||
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
|
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
|
||||||
@@ -633,6 +693,26 @@ impl WorkflowForm {
|
|||||||
fixed_strings: form.fixed_strings,
|
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) => {
|
Self::Remove(form) => {
|
||||||
if form.target.trim().is_empty() {
|
if form.target.trim().is_empty() {
|
||||||
return Err("Removal target is required".to_owned());
|
return Err("Removal target is required".to_owned());
|
||||||
@@ -718,6 +798,7 @@ impl WorkflowForm {
|
|||||||
Self::Insert(_) => 5,
|
Self::Insert(_) => 5,
|
||||||
Self::Generate(_) => 6,
|
Self::Generate(_) => 6,
|
||||||
Self::Grep(_) => 5,
|
Self::Grep(_) => 5,
|
||||||
|
Self::Kdbx(_) => 5,
|
||||||
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
||||||
Self::Otp(_) => 4,
|
Self::Otp(_) => 4,
|
||||||
}
|
}
|
||||||
@@ -730,6 +811,7 @@ impl WorkflowForm {
|
|||||||
Self::Insert(form) => &mut form.focus,
|
Self::Insert(form) => &mut form.focus,
|
||||||
Self::Generate(form) => &mut form.focus,
|
Self::Generate(form) => &mut form.focus,
|
||||||
Self::Grep(form) => &mut form.focus,
|
Self::Grep(form) => &mut form.focus,
|
||||||
|
Self::Kdbx(form) => &mut form.focus,
|
||||||
Self::Remove(form) => &mut form.focus,
|
Self::Remove(form) => &mut form.focus,
|
||||||
Self::Move(form) | Self::Copy(form) => &mut form.focus,
|
Self::Move(form) | Self::Copy(form) => &mut form.focus,
|
||||||
Self::Otp(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 == 2 => form.invert_match ^= true,
|
||||||
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
|
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
|
||||||
Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= 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 == 1 => form.recursive ^= true,
|
||||||
Self::Remove(form) if form.focus == 2 => form.force ^= true,
|
Self::Remove(form) if form.focus == 2 => form.force ^= true,
|
||||||
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
|
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
|
||||||
@@ -813,6 +897,13 @@ impl WorkflowForm {
|
|||||||
Self::Grep(form) if form.focus == 0 => {
|
Self::Grep(form) if form.focus == 0 => {
|
||||||
form.pattern.pop();
|
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 => {
|
Self::Remove(form) if form.focus == 0 => {
|
||||||
form.target.pop();
|
form.target.pop();
|
||||||
}
|
}
|
||||||
@@ -851,6 +942,11 @@ impl WorkflowForm {
|
|||||||
form.length.push(character)
|
form.length.push(character)
|
||||||
}
|
}
|
||||||
Self::Grep(form) if form.focus == 0 => form.pattern.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::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 == 0 => form.source.push(character),
|
||||||
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
|
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
|
||||||
@@ -919,6 +1015,37 @@ fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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) {
|
fn type_text(form: &mut WorkflowForm, text: &str) {
|
||||||
for character in text.chars() {
|
for character in text.chars() {
|
||||||
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
|
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ gix-config.workspace = true
|
|||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
keyring-core.workspace = true
|
keyring-core.workspace = true
|
||||||
|
keepass.workspace = true
|
||||||
pgp.workspace = true
|
pgp.workspace = true
|
||||||
qrcode.workspace = true
|
qrcode.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
@@ -49,6 +50,7 @@ arboard.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
keepass = { workspace = true, features = ["save_kdbx4"] }
|
||||||
nix = { version = "0.31", features = ["fs"] }
|
nix = { version = "0.31", features = ["fs"] }
|
||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
smallvec = "1.15"
|
smallvec = "1.15"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
|
|||||||
use clap_complete::{Shell, generate};
|
use clap_complete::{Shell, generate};
|
||||||
|
|
||||||
use crate::PRODUCT_NAME;
|
use crate::PRODUCT_NAME;
|
||||||
|
use crate::kdbx::{KdbxImportMode, KdbxImportRequest};
|
||||||
|
|
||||||
pub const EXIT_SUCCESS: u8 = 0;
|
pub const EXIT_SUCCESS: u8 = 0;
|
||||||
pub const EXIT_FAILURE: u8 = 1;
|
pub const EXIT_FAILURE: u8 = 1;
|
||||||
@@ -52,6 +53,7 @@ pub enum CommandRequest {
|
|||||||
Remove(RemoveRequest),
|
Remove(RemoveRequest),
|
||||||
Move(MoveRequest),
|
Move(MoveRequest),
|
||||||
Copy(CopyRequest),
|
Copy(CopyRequest),
|
||||||
|
ImportKdbx(KdbxImportRequest),
|
||||||
Git(GitRequest),
|
Git(GitRequest),
|
||||||
Otp(OtpRequest),
|
Otp(OtpRequest),
|
||||||
Completion { shell: CompletionShell },
|
Completion { shell: CompletionShell },
|
||||||
@@ -329,6 +331,7 @@ pub enum HelpTopic {
|
|||||||
Remove,
|
Remove,
|
||||||
Move,
|
Move,
|
||||||
Copy,
|
Copy,
|
||||||
|
ImportKdbx,
|
||||||
Git,
|
Git,
|
||||||
Otp,
|
Otp,
|
||||||
}
|
}
|
||||||
@@ -347,6 +350,7 @@ impl HelpTopic {
|
|||||||
"rm" | "remove" | "delete" => Some(Self::Remove),
|
"rm" | "remove" | "delete" => Some(Self::Remove),
|
||||||
"mv" | "rename" => Some(Self::Move),
|
"mv" | "rename" => Some(Self::Move),
|
||||||
"cp" | "copy" => Some(Self::Copy),
|
"cp" | "copy" => Some(Self::Copy),
|
||||||
|
"import-kdbx" => Some(Self::ImportKdbx),
|
||||||
"git" => Some(Self::Git),
|
"git" => Some(Self::Git),
|
||||||
"otp" => Some(Self::Otp),
|
"otp" => Some(Self::Otp),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -366,6 +370,7 @@ impl HelpTopic {
|
|||||||
Self::Remove => "rm",
|
Self::Remove => "rm",
|
||||||
Self::Move => "mv",
|
Self::Move => "mv",
|
||||||
Self::Copy => "cp",
|
Self::Copy => "cp",
|
||||||
|
Self::ImportKdbx => "import-kdbx",
|
||||||
Self::Git => "git",
|
Self::Git => "git",
|
||||||
Self::Otp => "otp",
|
Self::Otp => "otp",
|
||||||
}
|
}
|
||||||
@@ -480,6 +485,8 @@ enum CommandArguments {
|
|||||||
Move(MoveArguments),
|
Move(MoveArguments),
|
||||||
#[command(name = "cp", visible_alias = "copy")]
|
#[command(name = "cp", visible_alias = "copy")]
|
||||||
Copy(CopyArguments),
|
Copy(CopyArguments),
|
||||||
|
#[command(name = "import-kdbx")]
|
||||||
|
ImportKdbx(ImportKdbxArguments),
|
||||||
Git(GitArguments),
|
Git(GitArguments),
|
||||||
Otp(OtpArguments),
|
Otp(OtpArguments),
|
||||||
Completion(CompletionArguments),
|
Completion(CompletionArguments),
|
||||||
@@ -616,6 +623,15 @@ struct CopyArguments {
|
|||||||
destination: String,
|
destination: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct ImportKdbxArguments {
|
||||||
|
source: PathBuf,
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
key_file: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
quick_add: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
struct GitArguments {
|
struct GitArguments {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -824,6 +840,17 @@ fn convert_arguments(arguments: CliArguments) -> Result<CliInvocation, CliParseE
|
|||||||
destination: arguments.destination,
|
destination: arguments.destination,
|
||||||
force: arguments.force,
|
force: arguments.force,
|
||||||
}),
|
}),
|
||||||
|
Some(CommandArguments::ImportKdbx(arguments)) => {
|
||||||
|
CommandRequest::ImportKdbx(KdbxImportRequest::new(
|
||||||
|
arguments.source,
|
||||||
|
arguments.key_file,
|
||||||
|
if arguments.quick_add {
|
||||||
|
KdbxImportMode::QuickAdd
|
||||||
|
} else {
|
||||||
|
KdbxImportMode::AddAndUpdate
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(CommandArguments::Git(arguments)) => {
|
Some(CommandArguments::Git(arguments)) => {
|
||||||
CommandRequest::Git(convert_git(arguments.command)?)
|
CommandRequest::Git(convert_git(arguments.command)?)
|
||||||
}
|
}
|
||||||
@@ -987,6 +1014,7 @@ fn normalize_dispatch(mut arguments: Vec<OsString>) -> Vec<OsString> {
|
|||||||
"rename",
|
"rename",
|
||||||
"cp",
|
"cp",
|
||||||
"copy",
|
"copy",
|
||||||
|
"import-kdbx",
|
||||||
"git",
|
"git",
|
||||||
"otp",
|
"otp",
|
||||||
"help",
|
"help",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use crate::{
|
|||||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||||
PushOutcome, ReqwestGitTransport,
|
PushOutcome, ReqwestGitTransport,
|
||||||
},
|
},
|
||||||
|
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
|
||||||
mutation::{MutationOutcome, TreeMutator},
|
mutation::{MutationOutcome, TreeMutator},
|
||||||
otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri},
|
otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri},
|
||||||
presentation::{ClipboardTimeout, QrMatrix},
|
presentation::{ClipboardTimeout, QrMatrix},
|
||||||
@@ -55,6 +56,7 @@ pub enum DesktopErrorKind {
|
|||||||
EntryExists,
|
EntryExists,
|
||||||
Mutation,
|
Mutation,
|
||||||
Otp,
|
Otp,
|
||||||
|
Import,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -545,6 +547,27 @@ impl DesktopStorage {
|
|||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.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(
|
pub fn otp_code_active(
|
||||||
&self,
|
&self,
|
||||||
handle: &NativeAuthenticationHandle,
|
handle: &NativeAuthenticationHandle,
|
||||||
|
|||||||
560
crates/storage/src/kdbx.rs
Normal file
560
crates/storage/src/kdbx.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
mode: KdbxImportMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdbxImportRequest {
|
||||||
|
pub fn new(
|
||||||
|
source: impl Into<PathBuf>,
|
||||||
|
key_file: Option<PathBuf>,
|
||||||
|
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<EntryPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<KdbxImportOutcome, KdbxImportError> {
|
||||||
|
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<Vec<ImportItem>, 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<keepass::db::GroupId>,
|
||||||
|
used: &mut BTreeSet<PathBuf>,
|
||||||
|
items: &mut Vec<ImportItem>,
|
||||||
|
) -> 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<PathBuf>,
|
||||||
|
items: &mut Vec<ImportItem>,
|
||||||
|
) -> Result<(), KdbxImportError> {
|
||||||
|
let title = entry_title(&entry);
|
||||||
|
let attachments = entry
|
||||||
|
.attachments_named()
|
||||||
|
.map(|(name, attachment)| (name.to_owned(), attachment.data.get().to_vec()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let attachment_names = attachments
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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<PathBuf>,
|
||||||
|
) -> Result<EntryPath, RepositoryError> {
|
||||||
|
let mut path = components.iter().collect::<PathBuf>();
|
||||||
|
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::<String>();
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>();
|
||||||
|
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::<String>();
|
||||||
|
if name.is_empty() {
|
||||||
|
"field".to_owned()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_field(output: &mut Vec<u8>, 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<String> {
|
||||||
|
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::<String>();
|
||||||
|
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<RepositoryError> for KdbxImportError {
|
||||||
|
fn from(error: RepositoryError) -> Self {
|
||||||
|
Self::Repository(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WriteError> for KdbxImportError {
|
||||||
|
fn from(error: WriteError) -> Self {
|
||||||
|
Self::Write(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GitError> for KdbxImportError {
|
||||||
|
fn from(error: GitError) -> Self {
|
||||||
|
Self::Git(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod desktop;
|
|||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod kdbx;
|
||||||
pub mod mutation;
|
pub mod mutation;
|
||||||
pub mod otp;
|
pub mod otp;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ironstorage::command::{
|
|||||||
OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
|
OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
|
||||||
otp_version_text, parse_from, version_text,
|
otp_version_text, parse_from, version_text,
|
||||||
};
|
};
|
||||||
|
use ironstorage::kdbx::{KdbxImportMode, KdbxImportRequest};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error>>;
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
@@ -360,3 +361,31 @@ fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult
|
|||||||
);
|
);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
246
crates/storage/tests/kdbx_import.rs
Normal file
246
crates/storage/tests/kdbx_import.rs
Normal file
@@ -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<String, Vec<u8>>);
|
||||||
|
|
||||||
|
impl FixtureSecrets {
|
||||||
|
fn all(fixture: &FixtureSet) -> Self {
|
||||||
|
Self(
|
||||||
|
fixture
|
||||||
|
.generated
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| {
|
||||||
|
(
|
||||||
|
key.primary_fingerprint.clone(),
|
||||||
|
key.passphrase.as_bytes().to_vec(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretProvider for FixtureSecrets {
|
||||||
|
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||||
|
self.0
|
||||||
|
.get(key.fingerprint().as_str())
|
||||||
|
.cloned()
|
||||||
|
.map(SecretBytes::new)
|
||||||
|
.ok_or(SecretProviderError::Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<u8>, Box<dyn std::error::Error>> {
|
||||||
|
Ok(VaultWriter::new(repository, keys)
|
||||||
|
.begin_edit(
|
||||||
|
&ironstorage::command::EditRequest {
|
||||||
|
entry: entry.to_owned(),
|
||||||
|
},
|
||||||
|
secrets,
|
||||||
|
)?
|
||||||
|
.plaintext()
|
||||||
|
.expose()
|
||||||
|
.to_vec())
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ corresponding storage effects.
|
|||||||
| `generate` | length, no-symbols, force, in-place, clipboard and QR | `generate`; generation and CLI presentation tests |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 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 |
|
| pass-otp insert/add/append | URI or secret input and issuer/account derivation | `otp`; fixture, transaction and CLI tests |
|
||||||
|
|||||||
@@ -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: `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: `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 |
|
| 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: 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: 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 |
|
| 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user