Merge refactor/internal/shared-cache-credentials: share image cache and credentials via crates
cc
This commit is contained in:
11 files changed
+1352
-1092
No files matched your search
Generated
+221
-28
@@ -14,7 +14,7 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -169,6 +169,20 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ashpd"
|
||||||
|
version = "0.13.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2",
|
||||||
|
"futures-util",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"zbus 5.15.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -433,6 +447,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-padding"
|
name = "block-padding"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -584,7 +607,7 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -706,6 +729,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -912,6 +941,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -1010,7 +1048,7 @@ dependencies = [
|
|||||||
"hkdf",
|
"hkdf",
|
||||||
"num",
|
"num",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1029,11 +1067,22 @@ 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",
|
||||||
"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.0",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1542,7 +1591,7 @@ 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]]
|
[[package]]
|
||||||
@@ -1610,6 +1659,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -2030,7 +2088,7 @@ dependencies = [
|
|||||||
"dbus-secret-service",
|
"dbus-secret-service",
|
||||||
"log",
|
"log",
|
||||||
"secret-service",
|
"secret-service",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2201,7 +2259,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]]
|
||||||
@@ -2944,7 +3002,7 @@ dependencies = [
|
|||||||
"symphonia",
|
"symphonia",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2954,6 +3012,8 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"filetime",
|
"filetime",
|
||||||
"log",
|
"log",
|
||||||
|
"md-5",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2984,7 +3044,7 @@ dependencies = [
|
|||||||
"hkdf",
|
"hkdf",
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3004,6 +3064,23 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qbz-credentials"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"ashpd",
|
||||||
|
"base64",
|
||||||
|
"dirs",
|
||||||
|
"keyring",
|
||||||
|
"log",
|
||||||
|
"rand 0.10.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qbz-integrations"
|
name = "qbz-integrations"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3100,7 +3177,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"rand 0.9.3",
|
"rand 0.9.3",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3730,8 +3807,8 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3857,7 +3934,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3868,7 +3945,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]]
|
||||||
@@ -4530,7 +4618,7 @@ dependencies = [
|
|||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
"toml_write",
|
"toml_write",
|
||||||
"winnow",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4542,7 +4630,7 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime 1.0.0+spec-1.1.0",
|
"toml_datetime 1.0.0+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4551,7 +4639,7 @@ version = "1.0.9+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4693,9 +4781,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
@@ -4755,7 +4843,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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5533,6 +5621,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -5694,9 +5791,39 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros",
|
"zbus_macros 4.4.0",
|
||||||
"zbus_names",
|
"zbus_names 3.0.0",
|
||||||
"zvariant",
|
"zvariant 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-recursion",
|
||||||
|
"async-trait",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"ordered-stream",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zbus_macros 5.15.0",
|
||||||
|
"zbus_names 4.3.2",
|
||||||
|
"zvariant 5.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5709,7 +5836,22 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
"zvariant_utils",
|
"zvariant_utils 2.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"zbus_names 4.3.2",
|
||||||
|
"zvariant 5.11.0",
|
||||||
|
"zvariant_utils 3.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5720,7 +5862,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant",
|
"zvariant 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant 5.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5848,7 +6001,21 @@ dependencies = [
|
|||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant_derive",
|
"zvariant_derive 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant_derive 5.11.0",
|
||||||
|
"zvariant_utils 3.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5861,7 +6028,20 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
"zvariant_utils",
|
"zvariant_utils 2.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"zvariant_utils 3.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5874,3 +6054,16 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
@@ -19,6 +19,7 @@ members = [
|
|||||||
"qconnect-transport-ws",
|
"qconnect-transport-ws",
|
||||||
"qconnect-app",
|
"qconnect-app",
|
||||||
"qbzd",
|
"qbzd",
|
||||||
|
"qbz-credentials",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
# File system utilities
|
# File system utilities
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
filetime = "0.2"
|
filetime = "0.2"
|
||||||
|
|
||||||
|
# Image cache index + URL hashing
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
md-5 = "0.10"
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! Image cache service
|
||||||
|
//!
|
||||||
|
//! LRU disk cache for Qobuz album/artist images.
|
||||||
|
//! - Stores images keyed by MD5 hash of URL
|
||||||
|
//! - Tracks last-access time for LRU eviction
|
||||||
|
//! - Respects a configurable max size
|
||||||
|
//! - Framework-agnostic: shared by the Tauri app and the Slint shell so
|
||||||
|
//! both read and write the same `~/.cache/qbz/images` cache.
|
||||||
|
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Cache statistics.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ImageCacheStats {
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub file_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImageCacheService {
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCacheService {
|
||||||
|
pub fn new() -> Result<Self, String> {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.ok_or_else(|| "Could not find cache directory".to_string())?
|
||||||
|
.join("qbz")
|
||||||
|
.join("images");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&cache_dir)
|
||||||
|
.map_err(|e| format!("Failed to create image cache dir: {}", e))?;
|
||||||
|
|
||||||
|
let db_path = cache_dir.join("image_cache.db");
|
||||||
|
let conn = Connection::open(&db_path)
|
||||||
|
.map_err(|e| format!("Failed to open image cache database: {}", e))?;
|
||||||
|
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||||
|
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS cached_images (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_accessed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_images (last_accessed);",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to create image cache table: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self { cache_dir, conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_hash(url: &str) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(url.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path(&self, hash: &str) -> PathBuf {
|
||||||
|
self.cache_dir.join(format!("{}.img", hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a cached image path, updating last-access time.
|
||||||
|
/// Returns None if not cached.
|
||||||
|
pub fn get(&self, url: &str) -> Option<PathBuf> {
|
||||||
|
let hash = Self::url_hash(url);
|
||||||
|
let path = self.cache_path(&hash);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
// File missing — clean up stale DB entry
|
||||||
|
let _ = self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last-accessed time
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let _ = self.conn.execute(
|
||||||
|
"UPDATE cached_images SET last_accessed = ?1 WHERE hash = ?2",
|
||||||
|
params![now, hash],
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store image bytes in the cache.
|
||||||
|
/// Returns the local file path on success.
|
||||||
|
pub fn store(&self, url: &str, bytes: &[u8]) -> Result<PathBuf, String> {
|
||||||
|
let hash = Self::url_hash(url);
|
||||||
|
let path = self.cache_path(&hash);
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?;
|
||||||
|
|
||||||
|
let file_size = bytes.len() as i64;
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO cached_images (hash, url, file_size, last_accessed)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![hash, url, file_size, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to insert image cache entry: {}", e))?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least-recently-accessed entries until total size is under max_bytes.
|
||||||
|
pub fn evict(&self, max_bytes: u64) -> Result<u64, String> {
|
||||||
|
let total: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(SUM(file_size), 0) FROM cached_images",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to query cache size: {}", e))?;
|
||||||
|
|
||||||
|
if (total as u64) <= max_bytes {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut to_free = (total as u64) - max_bytes;
|
||||||
|
let mut freed: u64 = 0;
|
||||||
|
|
||||||
|
// Get LRU entries (oldest access first)
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT hash, file_size FROM cached_images ORDER BY last_accessed ASC")
|
||||||
|
.map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
|
||||||
|
|
||||||
|
let entries: Vec<(String, i64)> = stmt
|
||||||
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.map_err(|e| format!("Failed to query LRU entries: {}", e))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (hash, file_size) in entries {
|
||||||
|
if to_free == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let path = self.cache_path(&hash);
|
||||||
|
if path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
let _ = self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
|
||||||
|
let size = file_size as u64;
|
||||||
|
freed += size;
|
||||||
|
to_free = to_free.saturating_sub(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(freed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache statistics.
|
||||||
|
pub fn stats(&self) -> Result<ImageCacheStats, String> {
|
||||||
|
let (total_bytes, file_count): (i64, i64) = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM cached_images",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to query image cache stats: {}", e))?;
|
||||||
|
|
||||||
|
Ok(ImageCacheStats {
|
||||||
|
total_bytes: total_bytes as u64,
|
||||||
|
file_count: file_count as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the entire cache.
|
||||||
|
pub fn clear(&self) -> Result<u64, String> {
|
||||||
|
let stats = self.stats()?;
|
||||||
|
|
||||||
|
// Delete all files
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "img").unwrap_or(false) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear database
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM cached_images", [])
|
||||||
|
.map_err(|e| format!("Failed to clear image cache table: {}", e))?;
|
||||||
|
|
||||||
|
Ok(stats.total_bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Usage
|
//! ## Usage
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```ignore
|
||||||
//! use qbz_cache::{AudioCache, PlaybackCache};
|
//! use qbz_cache::{AudioCache, PlaybackCache};
|
||||||
//! use std::sync::Arc;
|
//! use std::sync::Arc;
|
||||||
//!
|
//!
|
||||||
@@ -56,7 +56,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod audio_cache;
|
mod audio_cache;
|
||||||
|
mod image_cache;
|
||||||
mod playback_cache;
|
mod playback_cache;
|
||||||
|
|
||||||
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
|
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
|
||||||
|
pub use image_cache::{ImageCacheService, ImageCacheStats};
|
||||||
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "qbz-credentials"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Encrypted credential and OAuth token storage for QBZ (keyring + AES-256-GCM file)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
sha2 = "0.11"
|
||||||
|
rand = "0.10"
|
||||||
|
dirs = "6"
|
||||||
|
keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
ashpd = { version = "0.13", default-features = false, features = ["tokio", "secret"] }
|
||||||
@@ -0,0 +1,864 @@
|
|||||||
|
//! Secure credential storage with fallback
|
||||||
|
//!
|
||||||
|
//! The encrypted AES-256-GCM file in the app config directory is the source
|
||||||
|
//! of truth for every credential. The OS keyring is used as an optional
|
||||||
|
//! best-effort cache:
|
||||||
|
//!
|
||||||
|
//! - Writes always go to the file first; the keyring is written opportunistically.
|
||||||
|
//! - Reads try the keyring first (cheaper when it works) and fall back to the file.
|
||||||
|
//! - Any keyring operation that fails or times out marks the keyring as broken
|
||||||
|
//! for the rest of the process, so later reads/writes skip it entirely.
|
||||||
|
//!
|
||||||
|
//! This matters on Linux systems where GNOME Keyring / KWallet may be locked
|
||||||
|
//! with a password that no longer matches the current session (see issue #329).
|
||||||
|
//! Without the timeout + session memoization, every login triggers a blocking
|
||||||
|
//! "unlock keyring" dialog and the user has to dismiss 3-4 prompts per session.
|
||||||
|
//! With them, the dialog appears at most once per session and the app continues
|
||||||
|
//! through the encrypted file path regardless of what the user does with it.
|
||||||
|
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
Aes256Gcm, Nonce,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||||
|
use keyring::Entry;
|
||||||
|
use rand::RngExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const SERVICE_NAME: &str = "qbz";
|
||||||
|
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
|
||||||
|
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
|
||||||
|
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy";
|
||||||
|
const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token";
|
||||||
|
const INSTALLATION_SALT_FILE_NAME: &str = ".qbz-cred-salt";
|
||||||
|
const MACHINE_ID_FALLBACK_FILE_NAME: &str = ".qbz-machine-id";
|
||||||
|
|
||||||
|
// Legacy XOR key for migration (only used for reading old format)
|
||||||
|
const LEGACY_OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QobuzCredentials {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypted data format stored in file
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct EncryptedCredentials {
|
||||||
|
/// Version for future format changes
|
||||||
|
version: u8,
|
||||||
|
/// Base64-encoded nonce (12 bytes for AES-GCM)
|
||||||
|
nonce: String,
|
||||||
|
/// Base64-encoded ciphertext
|
||||||
|
ciphertext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the fallback credentials file path
|
||||||
|
fn get_fallback_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(FALLBACK_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the legacy fallback file path (for migration)
|
||||||
|
fn get_legacy_fallback_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(LEGACY_FALLBACK_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the per-installation salt file path used for key derivation.
|
||||||
|
fn get_installation_salt_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(INSTALLATION_SALT_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get path for persistent fallback machine identifier.
|
||||||
|
fn get_machine_id_fallback_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(MACHINE_ID_FALLBACK_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a persistent installation salt, or create one on first use.
|
||||||
|
fn load_or_create_installation_salt() -> Result<Vec<u8>, String> {
|
||||||
|
let path =
|
||||||
|
get_installation_salt_path().ok_or("Could not determine config directory for salt")?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let encoded =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("Failed to read salt file: {}", e))?;
|
||||||
|
let decoded = BASE64
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.map_err(|e| format!("Failed to decode salt file: {}", e))?;
|
||||||
|
if decoded.len() != 32 {
|
||||||
|
return Err("Invalid installation salt length".to_string());
|
||||||
|
}
|
||||||
|
return Ok(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create salt directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
rand::rng().fill(&mut salt);
|
||||||
|
fs::write(&path, BASE64.encode(salt))
|
||||||
|
.map_err(|e| format!("Failed to write installation salt: {}", e))?;
|
||||||
|
|
||||||
|
Ok(salt.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a persistent machine identifier fallback, or create one on first use.
|
||||||
|
fn load_or_create_machine_id_fallback() -> Result<Vec<u8>, String> {
|
||||||
|
let path = get_machine_id_fallback_path()
|
||||||
|
.ok_or("Could not determine config directory for machine fallback id")?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let encoded = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read machine fallback id: {}", e))?;
|
||||||
|
let decoded = BASE64
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.map_err(|e| format!("Failed to decode machine fallback id: {}", e))?;
|
||||||
|
if decoded.len() != 32 {
|
||||||
|
return Err("Invalid machine fallback id length".to_string());
|
||||||
|
}
|
||||||
|
return Ok(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create machine fallback directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut machine_fallback = [0u8; 32];
|
||||||
|
rand::rng().fill(&mut machine_fallback);
|
||||||
|
fs::write(&path, BASE64.encode(machine_fallback))
|
||||||
|
.map_err(|e| format!("Failed to write machine fallback id: {}", e))?;
|
||||||
|
|
||||||
|
Ok(machine_fallback.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get machine-specific identifier for key derivation
|
||||||
|
fn get_machine_id() -> Result<Vec<u8>, String> {
|
||||||
|
// Try /etc/machine-id first (Linux)
|
||||||
|
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
|
||||||
|
let trimmed = id.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Ok(trimmed.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to hostname
|
||||||
|
if let Ok(hostname) = std::env::var("HOSTNAME") {
|
||||||
|
if !hostname.trim().is_empty() {
|
||||||
|
return Ok(hostname.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use username
|
||||||
|
if let Ok(user) = std::env::var("USER") {
|
||||||
|
if !user.trim().is_empty() {
|
||||||
|
return Ok(user.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persisted random fallback for environments without stable machine/user IDs.
|
||||||
|
load_or_create_machine_id_fallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve per-app secret from XDG Desktop Portal (cached for session lifetime).
|
||||||
|
/// Returns None if portal is unavailable (headless, old DEs, non-Linux).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn get_portal_secret() -> Option<Vec<u8>> {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static PORTAL_SECRET: OnceLock<Option<Vec<u8>>> = OnceLock::new();
|
||||||
|
PORTAL_SECRET
|
||||||
|
.get_or_init(|| {
|
||||||
|
let rt = tokio::runtime::Handle::try_current().ok()?;
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let _ = tx.send(ashpd::desktop::secret::retrieve().await.ok());
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
|
||||||
|
Ok(secret) => {
|
||||||
|
if secret.is_some() {
|
||||||
|
log::info!("[Credentials] Using XDG portal secret for key derivation");
|
||||||
|
}
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("[Credentials] XDG portal secret unavailable (timeout/missing)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive encryption key from XDG portal secret + machine ID + installation salt.
|
||||||
|
/// Portal secret adds DE-agnostic, Flatpak-safe entropy when available.
|
||||||
|
fn derive_key() -> Result<[u8; 32], String> {
|
||||||
|
let machine_id = get_machine_id()?;
|
||||||
|
let installation_salt = load_or_create_installation_salt()?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let portal_secret = get_portal_secret();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let portal_secret: Option<Vec<u8>> = None;
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&installation_salt);
|
||||||
|
if let Some(ref secret) = portal_secret {
|
||||||
|
hasher.update(secret);
|
||||||
|
}
|
||||||
|
hasher.update(&machine_id);
|
||||||
|
hasher.update(&installation_salt);
|
||||||
|
|
||||||
|
let result = hasher.finalize();
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&result);
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt credentials using AES-256-GCM
|
||||||
|
fn encrypt_credentials(credentials: &QobuzCredentials) -> Result<String, String> {
|
||||||
|
let key = derive_key()?;
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
|
||||||
|
|
||||||
|
// Generate random nonce
|
||||||
|
let mut nonce_raw = [0u8; 12];
|
||||||
|
rand::rng().fill(&mut nonce_raw);
|
||||||
|
let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into();
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(credentials)
|
||||||
|
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, json.as_bytes())
|
||||||
|
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
|
let encrypted = EncryptedCredentials {
|
||||||
|
version: 1,
|
||||||
|
nonce: BASE64.encode(nonce_bytes),
|
||||||
|
ciphertext: BASE64.encode(ciphertext),
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_string(&encrypted)
|
||||||
|
.map_err(|e| format!("Failed to serialize encrypted data: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt credentials using AES-256-GCM
|
||||||
|
fn decrypt_credentials(encrypted_json: &str) -> Result<QobuzCredentials, String> {
|
||||||
|
let encrypted: EncryptedCredentials = serde_json::from_str(encrypted_json)
|
||||||
|
.map_err(|e| format!("Failed to parse encrypted data: {}", e))?;
|
||||||
|
|
||||||
|
if encrypted.version != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"Unsupported encryption version: {}",
|
||||||
|
encrypted.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = derive_key()?;
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
|
||||||
|
|
||||||
|
let nonce_bytes = BASE64
|
||||||
|
.decode(&encrypted.nonce)
|
||||||
|
.map_err(|e| format!("Failed to decode nonce: {}", e))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = BASE64
|
||||||
|
.decode(&encrypted.ciphertext)
|
||||||
|
.map_err(|e| format!("Failed to decode ciphertext: {}", e))?;
|
||||||
|
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext.as_ref())
|
||||||
|
.map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?;
|
||||||
|
|
||||||
|
let json = String::from_utf8(plaintext)
|
||||||
|
.map_err(|e| format!("Failed to decode decrypted data: {}", e))?;
|
||||||
|
|
||||||
|
serde_json::from_str(&json).map_err(|e| format!("Failed to parse credentials: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy XOR deobfuscation (for migration only)
|
||||||
|
fn legacy_deobfuscate(data: &[u8]) -> Vec<u8> {
|
||||||
|
data.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, b)| b ^ LEGACY_OBFUSCATION_KEY[i % LEGACY_OBFUSCATION_KEY.len()])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to load credentials from legacy XOR format
|
||||||
|
fn load_legacy_credentials(path: &PathBuf) -> Result<Option<QobuzCredentials>, String> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read legacy file: {}", e))?;
|
||||||
|
|
||||||
|
let obfuscated = BASE64
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.map_err(|e| format!("Failed to decode legacy data: {}", e))?;
|
||||||
|
|
||||||
|
let json_bytes = legacy_deobfuscate(&obfuscated);
|
||||||
|
let json = String::from_utf8(json_bytes)
|
||||||
|
.map_err(|e| format!("Failed to decode legacy credentials: {}", e))?;
|
||||||
|
|
||||||
|
serde_json::from_str(&json)
|
||||||
|
.map_err(|e| format!("Failed to parse legacy credentials: {}", e))
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save credentials to fallback file (AES-256-GCM encrypted)
|
||||||
|
fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> {
|
||||||
|
let path = get_fallback_path().ok_or("Could not determine config directory")?;
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = encrypt_credentials(credentials)?;
|
||||||
|
|
||||||
|
fs::write(&path, encrypted).map_err(|e| format!("Failed to write credentials file: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("Credentials saved to encrypted fallback file");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load credentials from fallback file
|
||||||
|
fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
|
||||||
|
let path = match get_fallback_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
// Check for legacy file and migrate if found
|
||||||
|
if let Some(legacy_path) = get_legacy_fallback_path() {
|
||||||
|
if legacy_path.exists() {
|
||||||
|
log::info!("Found legacy credentials file, attempting migration...");
|
||||||
|
if let Ok(Some(creds)) = load_legacy_credentials(&legacy_path) {
|
||||||
|
// Save in new format
|
||||||
|
if save_to_fallback(&creds).is_ok() {
|
||||||
|
// Remove legacy file
|
||||||
|
let _ = fs::remove_file(&legacy_path);
|
||||||
|
log::info!("Successfully migrated credentials to new encrypted format");
|
||||||
|
return Ok(Some(creds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the current file is in legacy format (migration from old .qbz-auth)
|
||||||
|
let current_path = get_fallback_path();
|
||||||
|
if let Some(ref p) = current_path {
|
||||||
|
if p.exists() {
|
||||||
|
// Try reading as JSON first (new format)
|
||||||
|
if let Ok(content) = fs::read_to_string(p) {
|
||||||
|
if content.trim().starts_with('{') && content.contains("\"version\"") {
|
||||||
|
// It's the new format, will be handled below
|
||||||
|
} else {
|
||||||
|
// Might be legacy format
|
||||||
|
log::info!("Attempting to read legacy format from current file...");
|
||||||
|
if let Ok(Some(creds)) = load_legacy_credentials(p) {
|
||||||
|
// Save in new format
|
||||||
|
if save_to_fallback(&creds).is_ok() {
|
||||||
|
log::info!(
|
||||||
|
"Successfully migrated credentials to new encrypted format"
|
||||||
|
);
|
||||||
|
return Ok(Some(creds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("Failed to read credentials file: {}", e))?;
|
||||||
|
|
||||||
|
// Check if it's the new format or legacy
|
||||||
|
if content.trim().starts_with('{') && content.contains("\"version\"") {
|
||||||
|
// New encrypted format
|
||||||
|
match decrypt_credentials(&content) {
|
||||||
|
Ok(creds) => {
|
||||||
|
log::info!("Credentials loaded from encrypted fallback file");
|
||||||
|
Ok(Some(creds))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to decrypt credentials: {}", e);
|
||||||
|
// Try legacy format as fallback
|
||||||
|
if let Ok(Some(creds)) = load_legacy_credentials(&path) {
|
||||||
|
log::info!("Loaded from legacy format, will re-encrypt on next save");
|
||||||
|
return Ok(Some(creds));
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy format - try to load and migrate
|
||||||
|
log::info!("Found legacy format, migrating...");
|
||||||
|
if let Ok(Some(creds)) = load_legacy_credentials(&path) {
|
||||||
|
// Save in new format
|
||||||
|
if save_to_fallback(&creds).is_ok() {
|
||||||
|
log::info!("Successfully migrated credentials to new encrypted format");
|
||||||
|
}
|
||||||
|
return Ok(Some(creds));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear fallback credentials file
|
||||||
|
fn clear_fallback() -> Result<(), String> {
|
||||||
|
if let Some(path) = get_fallback_path() {
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to remove credentials file: {}", e))?;
|
||||||
|
log::info!("Fallback credentials file removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also clear legacy file if exists
|
||||||
|
if let Some(legacy_path) = get_legacy_fallback_path() {
|
||||||
|
if legacy_path.exists() {
|
||||||
|
let _ = fs::remove_file(&legacy_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if fallback file exists
|
||||||
|
fn has_fallback_credentials() -> bool {
|
||||||
|
get_fallback_path().map(|p| p.exists()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyring session state ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Linux Secret Service (the backing store for the `keyring` crate on Linux)
|
||||||
|
// is allowed to prompt the user for a passphrase when its collection is
|
||||||
|
// locked. That prompt blocks the calling thread indefinitely. A user whose
|
||||||
|
// GNOME Keyring got out of sync with their login password sees the dialog
|
||||||
|
// appear, dismisses it (because they can't remember the old password), and
|
||||||
|
// we then re-try another keyring operation which produces the dialog again
|
||||||
|
// — and again, and again, for every keyring touch in the login flow.
|
||||||
|
//
|
||||||
|
// The defense here has two parts:
|
||||||
|
//
|
||||||
|
// 1. Each keyring call is executed on a worker thread with a hard wall-clock
|
||||||
|
// timeout. If the call hasn't returned within the timeout, we assume the
|
||||||
|
// user is staring at a dialog they can't satisfy and we give up — the
|
||||||
|
// worker thread stays blocked in the background, but our control flow
|
||||||
|
// moves on. (The worker eventually resolves when the user dismisses the
|
||||||
|
// dialog; it's a small thread leak once per broken-keyring session.)
|
||||||
|
//
|
||||||
|
// 2. The first failure or timeout latches a process-wide flag
|
||||||
|
// (`KEYRING_STATE`) that short-circuits every subsequent keyring touch.
|
||||||
|
// One prompt max per session; everything after that goes straight to the
|
||||||
|
// encrypted file. A restart is required to retry the keyring, which is
|
||||||
|
// also the point where the user's keyring might have been repaired.
|
||||||
|
//
|
||||||
|
// Errors that mean "the entry doesn't exist" (`keyring::Error::NoEntry`)
|
||||||
|
// don't count as a failure — they're just data the caller has to handle.
|
||||||
|
|
||||||
|
const KEYRING_UNTESTED: u8 = 0;
|
||||||
|
const KEYRING_WORKING: u8 = 1;
|
||||||
|
const KEYRING_BROKEN: u8 = 2;
|
||||||
|
|
||||||
|
static KEYRING_STATE: AtomicU8 = AtomicU8::new(KEYRING_UNTESTED);
|
||||||
|
|
||||||
|
/// Per-operation wall-clock limit for any Secret Service / keyring call.
|
||||||
|
const KEYRING_OP_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||||
|
|
||||||
|
fn keyring_is_broken() -> bool {
|
||||||
|
KEYRING_STATE.load(Ordering::Relaxed) == KEYRING_BROKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_keyring_broken(reason: &str) {
|
||||||
|
let previous = KEYRING_STATE.swap(KEYRING_BROKEN, Ordering::Relaxed);
|
||||||
|
if previous != KEYRING_BROKEN {
|
||||||
|
log::warn!(
|
||||||
|
"[Credentials] Disabling system keyring for the rest of this session \
|
||||||
|
(falling back to encrypted file only): {}",
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_keyring_working() {
|
||||||
|
// Only promote from UNTESTED to WORKING. Never climb back out of BROKEN —
|
||||||
|
// once we've given up on the keyring for this session, stay given up.
|
||||||
|
let _ = KEYRING_STATE.compare_exchange(
|
||||||
|
KEYRING_UNTESTED,
|
||||||
|
KEYRING_WORKING,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a blocking keyring closure on a worker thread and return its result
|
||||||
|
/// within `KEYRING_OP_TIMEOUT`. Times out cleanly if the closure is still
|
||||||
|
/// stuck (typically because the user is looking at an unlock dialog).
|
||||||
|
fn run_with_keyring_timeout<T, F>(op_name: &'static str, f: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce() -> T + Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = tx.send(f());
|
||||||
|
});
|
||||||
|
rx.recv_timeout(KEYRING_OP_TIMEOUT).map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"keyring {} did not complete within {}ms (likely blocked on a user dialog)",
|
||||||
|
op_name,
|
||||||
|
KEYRING_OP_TIMEOUT.as_millis()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a value from the keyring. Returns `None` if the entry does not exist
|
||||||
|
/// OR if the keyring is unavailable / broken for this session. The caller
|
||||||
|
/// must always have a file-based fallback ready.
|
||||||
|
fn keyring_get(key: &str) -> Option<String> {
|
||||||
|
if keyring_is_broken() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let service = SERVICE_NAME.to_string();
|
||||||
|
let key_owned = key.to_string();
|
||||||
|
match run_with_keyring_timeout("get", move || {
|
||||||
|
Entry::new(&service, &key_owned).and_then(|e| e.get_password())
|
||||||
|
}) {
|
||||||
|
Ok(Ok(value)) => {
|
||||||
|
mark_keyring_working();
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
Ok(Err(keyring::Error::NoEntry)) => {
|
||||||
|
mark_keyring_working();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
mark_keyring_broken(&format!("get failed: {}", e));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
mark_keyring_broken(&reason);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a value to the keyring. Returns `true` on success, `false` on any
|
||||||
|
/// failure (timeout, locked collection, no backend, etc.). The caller must
|
||||||
|
/// have already persisted the value through its authoritative path (file).
|
||||||
|
fn keyring_set(key: &str, value: &str) -> bool {
|
||||||
|
if keyring_is_broken() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let service = SERVICE_NAME.to_string();
|
||||||
|
let key_owned = key.to_string();
|
||||||
|
let value_owned = value.to_string();
|
||||||
|
match run_with_keyring_timeout("set", move || {
|
||||||
|
Entry::new(&service, &key_owned).and_then(|e| e.set_password(&value_owned))
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
mark_keyring_working();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
mark_keyring_broken(&format!("set failed: {}", e));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
mark_keyring_broken(&reason);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a keyring entry. Silent no-op if the keyring is broken or the
|
||||||
|
/// entry already doesn't exist.
|
||||||
|
fn keyring_delete(key: &str) {
|
||||||
|
if keyring_is_broken() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let service = SERVICE_NAME.to_string();
|
||||||
|
let key_owned = key.to_string();
|
||||||
|
match run_with_keyring_timeout("delete", move || {
|
||||||
|
Entry::new(&service, &key_owned).and_then(|e| e.delete_credential())
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) | Ok(Err(keyring::Error::NoEntry)) => {
|
||||||
|
mark_keyring_working();
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::debug!("[Credentials] Keyring delete failed (not critical): {}", e);
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
mark_keyring_broken(&reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save Qobuz email+password credentials.
|
||||||
|
///
|
||||||
|
/// File is authoritative: we write it first and fail the operation if that
|
||||||
|
/// fails. The keyring is a best-effort write-through cache.
|
||||||
|
pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> {
|
||||||
|
log::info!("[Credentials] Saving Qobuz credentials");
|
||||||
|
|
||||||
|
let credentials = QobuzCredentials {
|
||||||
|
email: email.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
save_to_fallback(&credentials)?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&credentials).unwrap_or_default();
|
||||||
|
if !json.is_empty() && keyring_set(QOBUZ_CREDENTIALS_KEY, &json) {
|
||||||
|
log::debug!("[Credentials] Qobuz credentials also saved to keyring");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load Qobuz email+password credentials. Prefers the keyring when it
|
||||||
|
/// responds quickly, otherwise reads the encrypted fallback file.
|
||||||
|
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
|
||||||
|
log::debug!("[Credentials] Loading Qobuz credentials");
|
||||||
|
|
||||||
|
if let Some(json) = keyring_get(QOBUZ_CREDENTIALS_KEY) {
|
||||||
|
match serde_json::from_str::<QobuzCredentials>(&json) {
|
||||||
|
Ok(credentials) => {
|
||||||
|
log::debug!("[Credentials] Loaded Qobuz credentials from keyring");
|
||||||
|
return Ok(Some(credentials));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[Credentials] Keyring entry could not be parsed ({}), falling back to file",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load_from_fallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report whether any saved Qobuz credentials exist (keyring or file).
|
||||||
|
pub fn has_saved_credentials() -> bool {
|
||||||
|
if keyring_get(QOBUZ_CREDENTIALS_KEY).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
has_fallback_credentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear saved Qobuz credentials from both the keyring and the fallback file.
|
||||||
|
pub fn clear_qobuz_credentials() -> Result<(), String> {
|
||||||
|
keyring_delete(QOBUZ_CREDENTIALS_KEY);
|
||||||
|
clear_fallback()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth token persistence ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// OAuth login produces a `user_auth_token` instead of email+password.
|
||||||
|
// We persist it encrypted the same way as regular credentials so the user
|
||||||
|
// doesn't have to re-authenticate via browser on every app start.
|
||||||
|
// The token is re-used at bootstrap via `POST /user/login` with the
|
||||||
|
// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and
|
||||||
|
// we clear the stored token so the user sees the login screen normally.
|
||||||
|
|
||||||
|
fn get_oauth_token_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAUTH_TOKEN_KEY: &str = "qobuz-oauth-token";
|
||||||
|
|
||||||
|
/// Persist the OAuth `user_auth_token`.
|
||||||
|
///
|
||||||
|
/// File is authoritative: the encrypted token is written to the config
|
||||||
|
/// directory unconditionally. The keyring is a best-effort write-through
|
||||||
|
/// cache — if it fails (or times out behind an unlock dialog), the login
|
||||||
|
/// flow still completes because the file is already on disk. Inverts the
|
||||||
|
/// previous keyring-first ordering, which forced a prompt on every login
|
||||||
|
/// for users with a broken Secret Service collection (issue #329).
|
||||||
|
pub fn save_oauth_token(token: &str) -> Result<(), String> {
|
||||||
|
let placeholder = QobuzCredentials {
|
||||||
|
email: token.to_string(),
|
||||||
|
password: String::new(),
|
||||||
|
};
|
||||||
|
let encrypted = encrypt_credentials(&placeholder)?;
|
||||||
|
|
||||||
|
let path = get_oauth_token_path().ok_or("Could not determine config directory")?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write OAuth token file: {}", e))?;
|
||||||
|
log::info!("[Credentials] OAuth token saved to encrypted file");
|
||||||
|
|
||||||
|
if keyring_set(OAUTH_TOKEN_KEY, &encrypted) {
|
||||||
|
log::debug!("[Credentials] OAuth token also saved to keyring");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a previously saved OAuth `user_auth_token`, or `None` if absent.
|
||||||
|
/// Prefers the keyring when it responds quickly, otherwise reads the file.
|
||||||
|
pub fn load_oauth_token() -> Result<Option<String>, String> {
|
||||||
|
if let Some(encrypted) = keyring_get(OAUTH_TOKEN_KEY) {
|
||||||
|
if !encrypted.is_empty() {
|
||||||
|
if let Ok(placeholder) = decrypt_credentials(&encrypted) {
|
||||||
|
log::debug!("[Credentials] OAuth token loaded from keyring");
|
||||||
|
return Ok(Some(placeholder.email));
|
||||||
|
}
|
||||||
|
// Legacy format: pre-encryption builds stored the raw token in
|
||||||
|
// the keyring. Accept it for this one read; the next successful
|
||||||
|
// `save_oauth_token` call will rewrite it encrypted to both the
|
||||||
|
// keyring and the file.
|
||||||
|
log::debug!("[Credentials] Keyring held legacy plaintext token; will re-encrypt on next save");
|
||||||
|
return Ok(Some(encrypted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = match get_oauth_token_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("Failed to read OAuth token file: {}", e))?;
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match decrypt_credentials(&content) {
|
||||||
|
Ok(placeholder) => {
|
||||||
|
log::debug!("[Credentials] OAuth token loaded from encrypted file");
|
||||||
|
Ok(Some(placeholder.email))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[Credentials] Failed to decrypt OAuth token file: {}", e);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the stored OAuth token (logout or token expiry).
|
||||||
|
pub fn clear_oauth_token() -> Result<(), String> {
|
||||||
|
keyring_delete(OAUTH_TOKEN_KEY);
|
||||||
|
|
||||||
|
if let Some(path) = get_oauth_token_path() {
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to remove OAuth token file: {}", e))?;
|
||||||
|
log::info!("[Credentials] OAuth token file cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Returns true if the config directory is writable (required for encryption salt).
|
||||||
|
/// NixOS sandbox builds and CI environments lack a writable HOME.
|
||||||
|
fn has_writable_config_dir() -> bool {
|
||||||
|
// Nix build sandbox sets HOME to /homeless-shelter
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if home.contains("homeless-shelter") || home.contains("/nix/store") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also skip if NIX_BUILD_TOP is set (nix-build sandbox)
|
||||||
|
if std::env::var("NIX_BUILD_TOP").is_ok() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(path) = dirs::config_dir() {
|
||||||
|
let test_dir = path.join("qbz");
|
||||||
|
if std::fs::create_dir_all(&test_dir).is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_roundtrip() {
|
||||||
|
// Skip in environments without a writable config dir (NixOS sandbox, CI)
|
||||||
|
if std::env::var("CI").is_ok() || !has_writable_config_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials = QobuzCredentials {
|
||||||
|
email: "test@example.com".to_string(),
|
||||||
|
password: format!("test-pass-{}", std::process::id()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted = encrypt_credentials(&credentials).expect("Encryption failed");
|
||||||
|
let decrypted = decrypt_credentials(&encrypted).expect("Decryption failed");
|
||||||
|
|
||||||
|
assert_eq!(decrypted.email, credentials.email);
|
||||||
|
assert_eq!(decrypted.password, credentials.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_credentials_roundtrip() {
|
||||||
|
// Skip in environments without keyring or writable config dir
|
||||||
|
if std::env::var("CI").is_ok() || !has_writable_config_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any stale credentials from previous runs (may have different key/salt)
|
||||||
|
let _ = clear_qobuz_credentials();
|
||||||
|
|
||||||
|
let email = "test@example.com";
|
||||||
|
let password = format!("test-secret-{}", std::process::id());
|
||||||
|
|
||||||
|
// Save
|
||||||
|
save_qobuz_credentials(email, &password).expect("Failed to save");
|
||||||
|
|
||||||
|
// Load — if decryption fails due to environment issues, skip rather than panic
|
||||||
|
let loaded = match load_qobuz_credentials() {
|
||||||
|
Ok(Some(creds)) => creds,
|
||||||
|
Ok(None) => {
|
||||||
|
eprintln!("Skipping: credentials not found after save (keyring issue)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping: cannot load credentials in this environment: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
let _ = clear_qobuz_credentials();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(loaded.email, email);
|
||||||
|
assert_eq!(loaded.password, password);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clear_qobuz_credentials().expect("Failed to clear");
|
||||||
|
|
||||||
|
// Verify cleared
|
||||||
|
let after_clear = load_qobuz_credentials().expect("Failed to check");
|
||||||
|
assert!(after_clear.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+20
@@ -5074,6 +5074,8 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"filetime",
|
"filetime",
|
||||||
"log",
|
"log",
|
||||||
|
"md-5",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5124,6 +5126,23 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qbz-credentials"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"ashpd",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"dirs",
|
||||||
|
"keyring",
|
||||||
|
"log",
|
||||||
|
"rand 0.10.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qbz-integrations"
|
name = "qbz-integrations"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -5205,6 +5224,7 @@ dependencies = [
|
|||||||
"qbz-cast",
|
"qbz-cast",
|
||||||
"qbz-cmaf",
|
"qbz-cmaf",
|
||||||
"qbz-core",
|
"qbz-core",
|
||||||
|
"qbz-credentials",
|
||||||
"qbz-integrations",
|
"qbz-integrations",
|
||||||
"qbz-library",
|
"qbz-library",
|
||||||
"qbz-models",
|
"qbz-models",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ qbz-qobuz = { path = "../crates/qbz-qobuz" }
|
|||||||
qbz-core = { path = "../crates/qbz-core" }
|
qbz-core = { path = "../crates/qbz-core" }
|
||||||
qbz-integrations = { path = "../crates/qbz-integrations" }
|
qbz-integrations = { path = "../crates/qbz-integrations" }
|
||||||
qbz-cache = { path = "../crates/qbz-cache" }
|
qbz-cache = { path = "../crates/qbz-cache" }
|
||||||
|
qbz-credentials = { path = "../crates/qbz-credentials" }
|
||||||
qbz-library = { path = "../crates/qbz-library" }
|
qbz-library = { path = "../crates/qbz-library" }
|
||||||
qbz-cast = { path = "../crates/qbz-cast" }
|
qbz-cast = { path = "../crates/qbz-cast" }
|
||||||
qbz-cmaf = { path = "../crates/qbz-cmaf" }
|
qbz-cmaf = { path = "../crates/qbz-cmaf" }
|
||||||
|
|||||||
@@ -1,864 +1,8 @@
|
|||||||
//! Secure credential storage with fallback
|
//! Credential storage — Tauri-side wiring.
|
||||||
//!
|
//!
|
||||||
//! The encrypted AES-256-GCM file in the app config directory is the source
|
//! The implementation now lives in the `qbz-credentials` crate so the
|
||||||
//! of truth for every credential. The OS keyring is used as an optional
|
//! Slint shell shares the exact same keyring + AES-256-GCM file store.
|
||||||
//! best-effort cache:
|
//! This module re-exports it; the Tauri app and the Slint shell read and
|
||||||
//!
|
//! write the same credentials and OAuth token.
|
||||||
//! - Writes always go to the file first; the keyring is written opportunistically.
|
|
||||||
//! - Reads try the keyring first (cheaper when it works) and fall back to the file.
|
|
||||||
//! - Any keyring operation that fails or times out marks the keyring as broken
|
|
||||||
//! for the rest of the process, so later reads/writes skip it entirely.
|
|
||||||
//!
|
|
||||||
//! This matters on Linux systems where GNOME Keyring / KWallet may be locked
|
|
||||||
//! with a password that no longer matches the current session (see issue #329).
|
|
||||||
//! Without the timeout + session memoization, every login triggers a blocking
|
|
||||||
//! "unlock keyring" dialog and the user has to dismiss 3-4 prompts per session.
|
|
||||||
//! With them, the dialog appears at most once per session and the app continues
|
|
||||||
//! through the encrypted file path regardless of what the user does with it.
|
|
||||||
|
|
||||||
use aes_gcm::{
|
pub use qbz_credentials::*;
|
||||||
aead::{Aead, KeyInit},
|
|
||||||
Aes256Gcm, Nonce,
|
|
||||||
};
|
|
||||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
||||||
use keyring::Entry;
|
|
||||||
use rand::RngExt;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
const SERVICE_NAME: &str = "qbz";
|
|
||||||
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
|
|
||||||
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
|
|
||||||
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy";
|
|
||||||
const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token";
|
|
||||||
const INSTALLATION_SALT_FILE_NAME: &str = ".qbz-cred-salt";
|
|
||||||
const MACHINE_ID_FALLBACK_FILE_NAME: &str = ".qbz-machine-id";
|
|
||||||
|
|
||||||
// Legacy XOR key for migration (only used for reading old format)
|
|
||||||
const LEGACY_OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct QobuzCredentials {
|
|
||||||
pub email: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypted data format stored in file
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct EncryptedCredentials {
|
|
||||||
/// Version for future format changes
|
|
||||||
version: u8,
|
|
||||||
/// Base64-encoded nonce (12 bytes for AES-GCM)
|
|
||||||
nonce: String,
|
|
||||||
/// Base64-encoded ciphertext
|
|
||||||
ciphertext: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the fallback credentials file path
|
|
||||||
fn get_fallback_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|p| p.join("qbz").join(FALLBACK_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the legacy fallback file path (for migration)
|
|
||||||
fn get_legacy_fallback_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|p| p.join("qbz").join(LEGACY_FALLBACK_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the per-installation salt file path used for key derivation.
|
|
||||||
fn get_installation_salt_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|p| p.join("qbz").join(INSTALLATION_SALT_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get path for persistent fallback machine identifier.
|
|
||||||
fn get_machine_id_fallback_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|p| p.join("qbz").join(MACHINE_ID_FALLBACK_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a persistent installation salt, or create one on first use.
|
|
||||||
fn load_or_create_installation_salt() -> Result<Vec<u8>, String> {
|
|
||||||
let path =
|
|
||||||
get_installation_salt_path().ok_or("Could not determine config directory for salt")?;
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
let encoded =
|
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read salt file: {}", e))?;
|
|
||||||
let decoded = BASE64
|
|
||||||
.decode(encoded.trim())
|
|
||||||
.map_err(|e| format!("Failed to decode salt file: {}", e))?;
|
|
||||||
if decoded.len() != 32 {
|
|
||||||
return Err("Invalid installation salt length".to_string());
|
|
||||||
}
|
|
||||||
return Ok(decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("Failed to create salt directory: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
rand::rng().fill(&mut salt);
|
|
||||||
fs::write(&path, BASE64.encode(salt))
|
|
||||||
.map_err(|e| format!("Failed to write installation salt: {}", e))?;
|
|
||||||
|
|
||||||
Ok(salt.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a persistent machine identifier fallback, or create one on first use.
|
|
||||||
fn load_or_create_machine_id_fallback() -> Result<Vec<u8>, String> {
|
|
||||||
let path = get_machine_id_fallback_path()
|
|
||||||
.ok_or("Could not determine config directory for machine fallback id")?;
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
let encoded = fs::read_to_string(&path)
|
|
||||||
.map_err(|e| format!("Failed to read machine fallback id: {}", e))?;
|
|
||||||
let decoded = BASE64
|
|
||||||
.decode(encoded.trim())
|
|
||||||
.map_err(|e| format!("Failed to decode machine fallback id: {}", e))?;
|
|
||||||
if decoded.len() != 32 {
|
|
||||||
return Err("Invalid machine fallback id length".to_string());
|
|
||||||
}
|
|
||||||
return Ok(decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("Failed to create machine fallback directory: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut machine_fallback = [0u8; 32];
|
|
||||||
rand::rng().fill(&mut machine_fallback);
|
|
||||||
fs::write(&path, BASE64.encode(machine_fallback))
|
|
||||||
.map_err(|e| format!("Failed to write machine fallback id: {}", e))?;
|
|
||||||
|
|
||||||
Ok(machine_fallback.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get machine-specific identifier for key derivation
|
|
||||||
fn get_machine_id() -> Result<Vec<u8>, String> {
|
|
||||||
// Try /etc/machine-id first (Linux)
|
|
||||||
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
|
|
||||||
let trimmed = id.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
return Ok(trimmed.as_bytes().to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to hostname
|
|
||||||
if let Ok(hostname) = std::env::var("HOSTNAME") {
|
|
||||||
if !hostname.trim().is_empty() {
|
|
||||||
return Ok(hostname.as_bytes().to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: use username
|
|
||||||
if let Ok(user) = std::env::var("USER") {
|
|
||||||
if !user.trim().is_empty() {
|
|
||||||
return Ok(user.as_bytes().to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persisted random fallback for environments without stable machine/user IDs.
|
|
||||||
load_or_create_machine_id_fallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve per-app secret from XDG Desktop Portal (cached for session lifetime).
|
|
||||||
/// Returns None if portal is unavailable (headless, old DEs, non-Linux).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn get_portal_secret() -> Option<Vec<u8>> {
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
static PORTAL_SECRET: OnceLock<Option<Vec<u8>>> = OnceLock::new();
|
|
||||||
PORTAL_SECRET
|
|
||||||
.get_or_init(|| {
|
|
||||||
let rt = tokio::runtime::Handle::try_current().ok()?;
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
rt.spawn(async move {
|
|
||||||
let _ = tx.send(ashpd::desktop::secret::retrieve().await.ok());
|
|
||||||
});
|
|
||||||
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
|
|
||||||
Ok(secret) => {
|
|
||||||
if secret.is_some() {
|
|
||||||
log::info!("[Credentials] Using XDG portal secret for key derivation");
|
|
||||||
}
|
|
||||||
secret
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
log::debug!("[Credentials] XDG portal secret unavailable (timeout/missing)");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive encryption key from XDG portal secret + machine ID + installation salt.
|
|
||||||
/// Portal secret adds DE-agnostic, Flatpak-safe entropy when available.
|
|
||||||
fn derive_key() -> Result<[u8; 32], String> {
|
|
||||||
let machine_id = get_machine_id()?;
|
|
||||||
let installation_salt = load_or_create_installation_salt()?;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let portal_secret = get_portal_secret();
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let portal_secret: Option<Vec<u8>> = None;
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(&installation_salt);
|
|
||||||
if let Some(ref secret) = portal_secret {
|
|
||||||
hasher.update(secret);
|
|
||||||
}
|
|
||||||
hasher.update(&machine_id);
|
|
||||||
hasher.update(&installation_salt);
|
|
||||||
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
key.copy_from_slice(&result);
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt credentials using AES-256-GCM
|
|
||||||
fn encrypt_credentials(credentials: &QobuzCredentials) -> Result<String, String> {
|
|
||||||
let key = derive_key()?;
|
|
||||||
let cipher =
|
|
||||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
|
|
||||||
|
|
||||||
// Generate random nonce
|
|
||||||
let mut nonce_raw = [0u8; 12];
|
|
||||||
rand::rng().fill(&mut nonce_raw);
|
|
||||||
let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into();
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let json = serde_json::to_string(credentials)
|
|
||||||
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(nonce, json.as_bytes())
|
|
||||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
|
||||||
|
|
||||||
let encrypted = EncryptedCredentials {
|
|
||||||
version: 1,
|
|
||||||
nonce: BASE64.encode(nonce_bytes),
|
|
||||||
ciphertext: BASE64.encode(ciphertext),
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::to_string(&encrypted)
|
|
||||||
.map_err(|e| format!("Failed to serialize encrypted data: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt credentials using AES-256-GCM
|
|
||||||
fn decrypt_credentials(encrypted_json: &str) -> Result<QobuzCredentials, String> {
|
|
||||||
let encrypted: EncryptedCredentials = serde_json::from_str(encrypted_json)
|
|
||||||
.map_err(|e| format!("Failed to parse encrypted data: {}", e))?;
|
|
||||||
|
|
||||||
if encrypted.version != 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"Unsupported encryption version: {}",
|
|
||||||
encrypted.version
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = derive_key()?;
|
|
||||||
let cipher =
|
|
||||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
|
|
||||||
|
|
||||||
let nonce_bytes = BASE64
|
|
||||||
.decode(&encrypted.nonce)
|
|
||||||
.map_err(|e| format!("Failed to decode nonce: {}", e))?;
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let ciphertext = BASE64
|
|
||||||
.decode(&encrypted.ciphertext)
|
|
||||||
.map_err(|e| format!("Failed to decode ciphertext: {}", e))?;
|
|
||||||
|
|
||||||
let plaintext = cipher
|
|
||||||
.decrypt(nonce, ciphertext.as_ref())
|
|
||||||
.map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?;
|
|
||||||
|
|
||||||
let json = String::from_utf8(plaintext)
|
|
||||||
.map_err(|e| format!("Failed to decode decrypted data: {}", e))?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json).map_err(|e| format!("Failed to parse credentials: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy XOR deobfuscation (for migration only)
|
|
||||||
fn legacy_deobfuscate(data: &[u8]) -> Vec<u8> {
|
|
||||||
data.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, b)| b ^ LEGACY_OBFUSCATION_KEY[i % LEGACY_OBFUSCATION_KEY.len()])
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to load credentials from legacy XOR format
|
|
||||||
fn load_legacy_credentials(path: &PathBuf) -> Result<Option<QobuzCredentials>, String> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let encoded =
|
|
||||||
fs::read_to_string(path).map_err(|e| format!("Failed to read legacy file: {}", e))?;
|
|
||||||
|
|
||||||
let obfuscated = BASE64
|
|
||||||
.decode(encoded.trim())
|
|
||||||
.map_err(|e| format!("Failed to decode legacy data: {}", e))?;
|
|
||||||
|
|
||||||
let json_bytes = legacy_deobfuscate(&obfuscated);
|
|
||||||
let json = String::from_utf8(json_bytes)
|
|
||||||
.map_err(|e| format!("Failed to decode legacy credentials: {}", e))?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json)
|
|
||||||
.map_err(|e| format!("Failed to parse legacy credentials: {}", e))
|
|
||||||
.map(Some)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save credentials to fallback file (AES-256-GCM encrypted)
|
|
||||||
fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> {
|
|
||||||
let path = get_fallback_path().ok_or("Could not determine config directory")?;
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted = encrypt_credentials(credentials)?;
|
|
||||||
|
|
||||||
fs::write(&path, encrypted).map_err(|e| format!("Failed to write credentials file: {}", e))?;
|
|
||||||
|
|
||||||
log::info!("Credentials saved to encrypted fallback file");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load credentials from fallback file
|
|
||||||
fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
|
|
||||||
let path = match get_fallback_path() {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !path.exists() {
|
|
||||||
// Check for legacy file and migrate if found
|
|
||||||
if let Some(legacy_path) = get_legacy_fallback_path() {
|
|
||||||
if legacy_path.exists() {
|
|
||||||
log::info!("Found legacy credentials file, attempting migration...");
|
|
||||||
if let Ok(Some(creds)) = load_legacy_credentials(&legacy_path) {
|
|
||||||
// Save in new format
|
|
||||||
if save_to_fallback(&creds).is_ok() {
|
|
||||||
// Remove legacy file
|
|
||||||
let _ = fs::remove_file(&legacy_path);
|
|
||||||
log::info!("Successfully migrated credentials to new encrypted format");
|
|
||||||
return Ok(Some(creds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check if the current file is in legacy format (migration from old .qbz-auth)
|
|
||||||
let current_path = get_fallback_path();
|
|
||||||
if let Some(ref p) = current_path {
|
|
||||||
if p.exists() {
|
|
||||||
// Try reading as JSON first (new format)
|
|
||||||
if let Ok(content) = fs::read_to_string(p) {
|
|
||||||
if content.trim().starts_with('{') && content.contains("\"version\"") {
|
|
||||||
// It's the new format, will be handled below
|
|
||||||
} else {
|
|
||||||
// Might be legacy format
|
|
||||||
log::info!("Attempting to read legacy format from current file...");
|
|
||||||
if let Ok(Some(creds)) = load_legacy_credentials(p) {
|
|
||||||
// Save in new format
|
|
||||||
if save_to_fallback(&creds).is_ok() {
|
|
||||||
log::info!(
|
|
||||||
"Successfully migrated credentials to new encrypted format"
|
|
||||||
);
|
|
||||||
return Ok(Some(creds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content =
|
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read credentials file: {}", e))?;
|
|
||||||
|
|
||||||
// Check if it's the new format or legacy
|
|
||||||
if content.trim().starts_with('{') && content.contains("\"version\"") {
|
|
||||||
// New encrypted format
|
|
||||||
match decrypt_credentials(&content) {
|
|
||||||
Ok(creds) => {
|
|
||||||
log::info!("Credentials loaded from encrypted fallback file");
|
|
||||||
Ok(Some(creds))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to decrypt credentials: {}", e);
|
|
||||||
// Try legacy format as fallback
|
|
||||||
if let Ok(Some(creds)) = load_legacy_credentials(&path) {
|
|
||||||
log::info!("Loaded from legacy format, will re-encrypt on next save");
|
|
||||||
return Ok(Some(creds));
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Legacy format - try to load and migrate
|
|
||||||
log::info!("Found legacy format, migrating...");
|
|
||||||
if let Ok(Some(creds)) = load_legacy_credentials(&path) {
|
|
||||||
// Save in new format
|
|
||||||
if save_to_fallback(&creds).is_ok() {
|
|
||||||
log::info!("Successfully migrated credentials to new encrypted format");
|
|
||||||
}
|
|
||||||
return Ok(Some(creds));
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear fallback credentials file
|
|
||||||
fn clear_fallback() -> Result<(), String> {
|
|
||||||
if let Some(path) = get_fallback_path() {
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.map_err(|e| format!("Failed to remove credentials file: {}", e))?;
|
|
||||||
log::info!("Fallback credentials file removed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also clear legacy file if exists
|
|
||||||
if let Some(legacy_path) = get_legacy_fallback_path() {
|
|
||||||
if legacy_path.exists() {
|
|
||||||
let _ = fs::remove_file(&legacy_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if fallback file exists
|
|
||||||
fn has_fallback_credentials() -> bool {
|
|
||||||
get_fallback_path().map(|p| p.exists()).unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Keyring session state ────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Linux Secret Service (the backing store for the `keyring` crate on Linux)
|
|
||||||
// is allowed to prompt the user for a passphrase when its collection is
|
|
||||||
// locked. That prompt blocks the calling thread indefinitely. A user whose
|
|
||||||
// GNOME Keyring got out of sync with their login password sees the dialog
|
|
||||||
// appear, dismisses it (because they can't remember the old password), and
|
|
||||||
// we then re-try another keyring operation which produces the dialog again
|
|
||||||
// — and again, and again, for every keyring touch in the login flow.
|
|
||||||
//
|
|
||||||
// The defense here has two parts:
|
|
||||||
//
|
|
||||||
// 1. Each keyring call is executed on a worker thread with a hard wall-clock
|
|
||||||
// timeout. If the call hasn't returned within the timeout, we assume the
|
|
||||||
// user is staring at a dialog they can't satisfy and we give up — the
|
|
||||||
// worker thread stays blocked in the background, but our control flow
|
|
||||||
// moves on. (The worker eventually resolves when the user dismisses the
|
|
||||||
// dialog; it's a small thread leak once per broken-keyring session.)
|
|
||||||
//
|
|
||||||
// 2. The first failure or timeout latches a process-wide flag
|
|
||||||
// (`KEYRING_STATE`) that short-circuits every subsequent keyring touch.
|
|
||||||
// One prompt max per session; everything after that goes straight to the
|
|
||||||
// encrypted file. A restart is required to retry the keyring, which is
|
|
||||||
// also the point where the user's keyring might have been repaired.
|
|
||||||
//
|
|
||||||
// Errors that mean "the entry doesn't exist" (`keyring::Error::NoEntry`)
|
|
||||||
// don't count as a failure — they're just data the caller has to handle.
|
|
||||||
|
|
||||||
const KEYRING_UNTESTED: u8 = 0;
|
|
||||||
const KEYRING_WORKING: u8 = 1;
|
|
||||||
const KEYRING_BROKEN: u8 = 2;
|
|
||||||
|
|
||||||
static KEYRING_STATE: AtomicU8 = AtomicU8::new(KEYRING_UNTESTED);
|
|
||||||
|
|
||||||
/// Per-operation wall-clock limit for any Secret Service / keyring call.
|
|
||||||
const KEYRING_OP_TIMEOUT: Duration = Duration::from_millis(2500);
|
|
||||||
|
|
||||||
fn keyring_is_broken() -> bool {
|
|
||||||
KEYRING_STATE.load(Ordering::Relaxed) == KEYRING_BROKEN
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_keyring_broken(reason: &str) {
|
|
||||||
let previous = KEYRING_STATE.swap(KEYRING_BROKEN, Ordering::Relaxed);
|
|
||||||
if previous != KEYRING_BROKEN {
|
|
||||||
log::warn!(
|
|
||||||
"[Credentials] Disabling system keyring for the rest of this session \
|
|
||||||
(falling back to encrypted file only): {}",
|
|
||||||
reason
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_keyring_working() {
|
|
||||||
// Only promote from UNTESTED to WORKING. Never climb back out of BROKEN —
|
|
||||||
// once we've given up on the keyring for this session, stay given up.
|
|
||||||
let _ = KEYRING_STATE.compare_exchange(
|
|
||||||
KEYRING_UNTESTED,
|
|
||||||
KEYRING_WORKING,
|
|
||||||
Ordering::Relaxed,
|
|
||||||
Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a blocking keyring closure on a worker thread and return its result
|
|
||||||
/// within `KEYRING_OP_TIMEOUT`. Times out cleanly if the closure is still
|
|
||||||
/// stuck (typically because the user is looking at an unlock dialog).
|
|
||||||
fn run_with_keyring_timeout<T, F>(op_name: &'static str, f: F) -> Result<T, String>
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: FnOnce() -> T + Send + 'static,
|
|
||||||
{
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
thread::spawn(move || {
|
|
||||||
let _ = tx.send(f());
|
|
||||||
});
|
|
||||||
rx.recv_timeout(KEYRING_OP_TIMEOUT).map_err(|_| {
|
|
||||||
format!(
|
|
||||||
"keyring {} did not complete within {}ms (likely blocked on a user dialog)",
|
|
||||||
op_name,
|
|
||||||
KEYRING_OP_TIMEOUT.as_millis()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a value from the keyring. Returns `None` if the entry does not exist
|
|
||||||
/// OR if the keyring is unavailable / broken for this session. The caller
|
|
||||||
/// must always have a file-based fallback ready.
|
|
||||||
fn keyring_get(key: &str) -> Option<String> {
|
|
||||||
if keyring_is_broken() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let service = SERVICE_NAME.to_string();
|
|
||||||
let key_owned = key.to_string();
|
|
||||||
match run_with_keyring_timeout("get", move || {
|
|
||||||
Entry::new(&service, &key_owned).and_then(|e| e.get_password())
|
|
||||||
}) {
|
|
||||||
Ok(Ok(value)) => {
|
|
||||||
mark_keyring_working();
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
Ok(Err(keyring::Error::NoEntry)) => {
|
|
||||||
mark_keyring_working();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
mark_keyring_broken(&format!("get failed: {}", e));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(reason) => {
|
|
||||||
mark_keyring_broken(&reason);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a value to the keyring. Returns `true` on success, `false` on any
|
|
||||||
/// failure (timeout, locked collection, no backend, etc.). The caller must
|
|
||||||
/// have already persisted the value through its authoritative path (file).
|
|
||||||
fn keyring_set(key: &str, value: &str) -> bool {
|
|
||||||
if keyring_is_broken() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let service = SERVICE_NAME.to_string();
|
|
||||||
let key_owned = key.to_string();
|
|
||||||
let value_owned = value.to_string();
|
|
||||||
match run_with_keyring_timeout("set", move || {
|
|
||||||
Entry::new(&service, &key_owned).and_then(|e| e.set_password(&value_owned))
|
|
||||||
}) {
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
mark_keyring_working();
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
mark_keyring_broken(&format!("set failed: {}", e));
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Err(reason) => {
|
|
||||||
mark_keyring_broken(&reason);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a keyring entry. Silent no-op if the keyring is broken or the
|
|
||||||
/// entry already doesn't exist.
|
|
||||||
fn keyring_delete(key: &str) {
|
|
||||||
if keyring_is_broken() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let service = SERVICE_NAME.to_string();
|
|
||||||
let key_owned = key.to_string();
|
|
||||||
match run_with_keyring_timeout("delete", move || {
|
|
||||||
Entry::new(&service, &key_owned).and_then(|e| e.delete_credential())
|
|
||||||
}) {
|
|
||||||
Ok(Ok(())) | Ok(Err(keyring::Error::NoEntry)) => {
|
|
||||||
mark_keyring_working();
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
log::debug!("[Credentials] Keyring delete failed (not critical): {}", e);
|
|
||||||
}
|
|
||||||
Err(reason) => {
|
|
||||||
mark_keyring_broken(&reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save Qobuz email+password credentials.
|
|
||||||
///
|
|
||||||
/// File is authoritative: we write it first and fail the operation if that
|
|
||||||
/// fails. The keyring is a best-effort write-through cache.
|
|
||||||
pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> {
|
|
||||||
log::info!("[Credentials] Saving Qobuz credentials");
|
|
||||||
|
|
||||||
let credentials = QobuzCredentials {
|
|
||||||
email: email.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
save_to_fallback(&credentials)?;
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&credentials).unwrap_or_default();
|
|
||||||
if !json.is_empty() && keyring_set(QOBUZ_CREDENTIALS_KEY, &json) {
|
|
||||||
log::debug!("[Credentials] Qobuz credentials also saved to keyring");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load Qobuz email+password credentials. Prefers the keyring when it
|
|
||||||
/// responds quickly, otherwise reads the encrypted fallback file.
|
|
||||||
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
|
|
||||||
log::debug!("[Credentials] Loading Qobuz credentials");
|
|
||||||
|
|
||||||
if let Some(json) = keyring_get(QOBUZ_CREDENTIALS_KEY) {
|
|
||||||
match serde_json::from_str::<QobuzCredentials>(&json) {
|
|
||||||
Ok(credentials) => {
|
|
||||||
log::debug!("[Credentials] Loaded Qobuz credentials from keyring");
|
|
||||||
return Ok(Some(credentials));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"[Credentials] Keyring entry could not be parsed ({}), falling back to file",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load_from_fallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report whether any saved Qobuz credentials exist (keyring or file).
|
|
||||||
pub fn has_saved_credentials() -> bool {
|
|
||||||
if keyring_get(QOBUZ_CREDENTIALS_KEY).is_some() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
has_fallback_credentials()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear saved Qobuz credentials from both the keyring and the fallback file.
|
|
||||||
pub fn clear_qobuz_credentials() -> Result<(), String> {
|
|
||||||
keyring_delete(QOBUZ_CREDENTIALS_KEY);
|
|
||||||
clear_fallback()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── OAuth token persistence ──────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// OAuth login produces a `user_auth_token` instead of email+password.
|
|
||||||
// We persist it encrypted the same way as regular credentials so the user
|
|
||||||
// doesn't have to re-authenticate via browser on every app start.
|
|
||||||
// The token is re-used at bootstrap via `POST /user/login` with the
|
|
||||||
// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and
|
|
||||||
// we clear the stored token so the user sees the login screen normally.
|
|
||||||
|
|
||||||
fn get_oauth_token_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
const OAUTH_TOKEN_KEY: &str = "qobuz-oauth-token";
|
|
||||||
|
|
||||||
/// Persist the OAuth `user_auth_token`.
|
|
||||||
///
|
|
||||||
/// File is authoritative: the encrypted token is written to the config
|
|
||||||
/// directory unconditionally. The keyring is a best-effort write-through
|
|
||||||
/// cache — if it fails (or times out behind an unlock dialog), the login
|
|
||||||
/// flow still completes because the file is already on disk. Inverts the
|
|
||||||
/// previous keyring-first ordering, which forced a prompt on every login
|
|
||||||
/// for users with a broken Secret Service collection (issue #329).
|
|
||||||
pub fn save_oauth_token(token: &str) -> Result<(), String> {
|
|
||||||
let placeholder = QobuzCredentials {
|
|
||||||
email: token.to_string(),
|
|
||||||
password: String::new(),
|
|
||||||
};
|
|
||||||
let encrypted = encrypt_credentials(&placeholder)?;
|
|
||||||
|
|
||||||
let path = get_oauth_token_path().ok_or("Could not determine config directory")?;
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
|
||||||
}
|
|
||||||
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write OAuth token file: {}", e))?;
|
|
||||||
log::info!("[Credentials] OAuth token saved to encrypted file");
|
|
||||||
|
|
||||||
if keyring_set(OAUTH_TOKEN_KEY, &encrypted) {
|
|
||||||
log::debug!("[Credentials] OAuth token also saved to keyring");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a previously saved OAuth `user_auth_token`, or `None` if absent.
|
|
||||||
/// Prefers the keyring when it responds quickly, otherwise reads the file.
|
|
||||||
pub fn load_oauth_token() -> Result<Option<String>, String> {
|
|
||||||
if let Some(encrypted) = keyring_get(OAUTH_TOKEN_KEY) {
|
|
||||||
if !encrypted.is_empty() {
|
|
||||||
if let Ok(placeholder) = decrypt_credentials(&encrypted) {
|
|
||||||
log::debug!("[Credentials] OAuth token loaded from keyring");
|
|
||||||
return Ok(Some(placeholder.email));
|
|
||||||
}
|
|
||||||
// Legacy format: pre-encryption builds stored the raw token in
|
|
||||||
// the keyring. Accept it for this one read; the next successful
|
|
||||||
// `save_oauth_token` call will rewrite it encrypted to both the
|
|
||||||
// keyring and the file.
|
|
||||||
log::debug!("[Credentials] Keyring held legacy plaintext token; will re-encrypt on next save");
|
|
||||||
return Ok(Some(encrypted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = match get_oauth_token_path() {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content =
|
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read OAuth token file: {}", e))?;
|
|
||||||
if content.trim().is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
match decrypt_credentials(&content) {
|
|
||||||
Ok(placeholder) => {
|
|
||||||
log::debug!("[Credentials] OAuth token loaded from encrypted file");
|
|
||||||
Ok(Some(placeholder.email))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("[Credentials] Failed to decrypt OAuth token file: {}", e);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete the stored OAuth token (logout or token expiry).
|
|
||||||
pub fn clear_oauth_token() -> Result<(), String> {
|
|
||||||
keyring_delete(OAUTH_TOKEN_KEY);
|
|
||||||
|
|
||||||
if let Some(path) = get_oauth_token_path() {
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.map_err(|e| format!("Failed to remove OAuth token file: {}", e))?;
|
|
||||||
log::info!("[Credentials] OAuth token file cleared");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Returns true if the config directory is writable (required for encryption salt).
|
|
||||||
/// NixOS sandbox builds and CI environments lack a writable HOME.
|
|
||||||
fn has_writable_config_dir() -> bool {
|
|
||||||
// Nix build sandbox sets HOME to /homeless-shelter
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
if home.contains("homeless-shelter") || home.contains("/nix/store") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also skip if NIX_BUILD_TOP is set (nix-build sandbox)
|
|
||||||
if std::env::var("NIX_BUILD_TOP").is_ok() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if let Some(path) = dirs::config_dir() {
|
|
||||||
let test_dir = path.join("qbz");
|
|
||||||
if std::fs::create_dir_all(&test_dir).is_ok() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encryption_roundtrip() {
|
|
||||||
// Skip in environments without a writable config dir (NixOS sandbox, CI)
|
|
||||||
if std::env::var("CI").is_ok() || !has_writable_config_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let credentials = QobuzCredentials {
|
|
||||||
email: "test@example.com".to_string(),
|
|
||||||
password: format!("test-pass-{}", std::process::id()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let encrypted = encrypt_credentials(&credentials).expect("Encryption failed");
|
|
||||||
let decrypted = decrypt_credentials(&encrypted).expect("Decryption failed");
|
|
||||||
|
|
||||||
assert_eq!(decrypted.email, credentials.email);
|
|
||||||
assert_eq!(decrypted.password, credentials.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_credentials_roundtrip() {
|
|
||||||
// Skip in environments without keyring or writable config dir
|
|
||||||
if std::env::var("CI").is_ok() || !has_writable_config_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any stale credentials from previous runs (may have different key/salt)
|
|
||||||
let _ = clear_qobuz_credentials();
|
|
||||||
|
|
||||||
let email = "test@example.com";
|
|
||||||
let password = format!("test-secret-{}", std::process::id());
|
|
||||||
|
|
||||||
// Save
|
|
||||||
save_qobuz_credentials(email, &password).expect("Failed to save");
|
|
||||||
|
|
||||||
// Load — if decryption fails due to environment issues, skip rather than panic
|
|
||||||
let loaded = match load_qobuz_credentials() {
|
|
||||||
Ok(Some(creds)) => creds,
|
|
||||||
Ok(None) => {
|
|
||||||
eprintln!("Skipping: credentials not found after save (keyring issue)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"Skipping: cannot load credentials in this environment: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
let _ = clear_qobuz_credentials();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(loaded.email, email);
|
|
||||||
assert_eq!(loaded.password, password);
|
|
||||||
|
|
||||||
// Clear
|
|
||||||
clear_qobuz_credentials().expect("Failed to clear");
|
|
||||||
|
|
||||||
// Verify cleared
|
|
||||||
let after_clear = load_qobuz_credentials().expect("Failed to check");
|
|
||||||
assert!(after_clear.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +1,12 @@
|
|||||||
//! Image cache service
|
//! Image cache — Tauri-side wiring.
|
||||||
//!
|
//!
|
||||||
//! LRU disk cache for Qobuz album/artist images.
|
//! The cache implementation now lives in the `qbz-cache` crate so the
|
||||||
//! - Stores images keyed by MD5 hash of URL
|
//! Slint shell shares the exact same code and on-disk cache. This module
|
||||||
//! - Tracks last-access time for LRU eviction
|
//! re-exports it and keeps the Tauri managed-state wrapper.
|
||||||
//! - Respects configurable max size (default 200MB)
|
|
||||||
//! - Can be disabled entirely via settings
|
|
||||||
|
|
||||||
use md5::{Digest, Md5};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// Cache statistics returned to the frontend
|
pub use qbz_cache::{ImageCacheService, ImageCacheStats};
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct ImageCacheStats {
|
|
||||||
pub total_bytes: u64,
|
|
||||||
pub file_count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ImageCacheService {
|
|
||||||
cache_dir: PathBuf,
|
|
||||||
conn: Connection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheService {
|
|
||||||
pub fn new() -> Result<Self, String> {
|
|
||||||
let cache_dir = dirs::cache_dir()
|
|
||||||
.ok_or_else(|| "Could not find cache directory".to_string())?
|
|
||||||
.join("qbz")
|
|
||||||
.join("images");
|
|
||||||
|
|
||||||
std::fs::create_dir_all(&cache_dir)
|
|
||||||
.map_err(|e| format!("Failed to create image cache dir: {}", e))?;
|
|
||||||
|
|
||||||
let db_path = cache_dir.join("image_cache.db");
|
|
||||||
let conn = Connection::open(&db_path)
|
|
||||||
.map_err(|e| format!("Failed to open image cache database: {}", e))?;
|
|
||||||
|
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
|
||||||
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
|
|
||||||
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS cached_images (
|
|
||||||
hash TEXT PRIMARY KEY,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
file_size INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_accessed INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_images (last_accessed);",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to create image cache table: {}", e))?;
|
|
||||||
|
|
||||||
Ok(Self { cache_dir, conn })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url_hash(url: &str) -> String {
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
hasher.update(url.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cache_path(&self, hash: &str) -> PathBuf {
|
|
||||||
self.cache_dir.join(format!("{}.img", hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a cached image path, updating last-access time.
|
|
||||||
/// Returns None if not cached.
|
|
||||||
pub fn get(&self, url: &str) -> Option<PathBuf> {
|
|
||||||
let hash = Self::url_hash(url);
|
|
||||||
let path = self.cache_path(&hash);
|
|
||||||
|
|
||||||
if !path.exists() {
|
|
||||||
// File missing — clean up stale DB entry
|
|
||||||
let _ = self
|
|
||||||
.conn
|
|
||||||
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last-accessed time
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs() as i64;
|
|
||||||
let _ = self.conn.execute(
|
|
||||||
"UPDATE cached_images SET last_accessed = ?1 WHERE hash = ?2",
|
|
||||||
params![now, hash],
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store image bytes in the cache.
|
|
||||||
/// Returns the local file path on success.
|
|
||||||
pub fn store(&self, url: &str, bytes: &[u8]) -> Result<PathBuf, String> {
|
|
||||||
let hash = Self::url_hash(url);
|
|
||||||
let path = self.cache_path(&hash);
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?;
|
|
||||||
|
|
||||||
let file_size = bytes.len() as i64;
|
|
||||||
self.conn
|
|
||||||
.execute(
|
|
||||||
"INSERT OR REPLACE INTO cached_images (hash, url, file_size, last_accessed)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![hash, url, file_size, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to insert image cache entry: {}", e))?;
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evict least-recently-accessed entries until total size is under max_bytes.
|
|
||||||
pub fn evict(&self, max_bytes: u64) -> Result<u64, String> {
|
|
||||||
let total: i64 = self
|
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(SUM(file_size), 0) FROM cached_images",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to query cache size: {}", e))?;
|
|
||||||
|
|
||||||
if (total as u64) <= max_bytes {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut to_free = (total as u64) - max_bytes;
|
|
||||||
let mut freed: u64 = 0;
|
|
||||||
|
|
||||||
// Get LRU entries (oldest access first)
|
|
||||||
let mut stmt = self
|
|
||||||
.conn
|
|
||||||
.prepare("SELECT hash, file_size FROM cached_images ORDER BY last_accessed ASC")
|
|
||||||
.map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
|
|
||||||
|
|
||||||
let entries: Vec<(String, i64)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.map_err(|e| format!("Failed to query LRU entries: {}", e))?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (hash, file_size) in entries {
|
|
||||||
if to_free == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let path = self.cache_path(&hash);
|
|
||||||
if path.exists() {
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
let _ = self
|
|
||||||
.conn
|
|
||||||
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
|
|
||||||
let size = file_size as u64;
|
|
||||||
freed += size;
|
|
||||||
to_free = to_free.saturating_sub(size);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(freed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get cache statistics.
|
|
||||||
pub fn stats(&self) -> Result<ImageCacheStats, String> {
|
|
||||||
let (total_bytes, file_count): (i64, i64) = self
|
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM cached_images",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to query image cache stats: {}", e))?;
|
|
||||||
|
|
||||||
Ok(ImageCacheStats {
|
|
||||||
total_bytes: total_bytes as u64,
|
|
||||||
file_count: file_count as u64,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the entire cache.
|
|
||||||
pub fn clear(&self) -> Result<u64, String> {
|
|
||||||
let stats = self.stats()?;
|
|
||||||
|
|
||||||
// Delete all files
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().map(|e| e == "img").unwrap_or(false) {
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear database
|
|
||||||
self.conn
|
|
||||||
.execute("DELETE FROM cached_images", [])
|
|
||||||
.map_err(|e| format!("Failed to clear image cache table: {}", e))?;
|
|
||||||
|
|
||||||
Ok(stats.total_bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ImageCacheState {
|
pub struct ImageCacheState {
|
||||||
pub service: Arc<Mutex<Option<ImageCacheService>>>,
|
pub service: Arc<Mutex<Option<ImageCacheService>>>,
|
||||||
|
|||||||
Reference in new issue
Block a user