mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-11 05:09:55 +02:00
feat(core): protect secrets on native platforms
This commit is contained in:
282
Cargo.lock
generated
282
Cargo.lock
generated
@@ -219,6 +219,18 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -244,6 +256,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -421,6 +444,15 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -483,6 +515,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.65"
|
version = "1.2.65"
|
||||||
@@ -864,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"syn 1.0.109",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1049,6 +1090,12 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enum-assoc"
|
name = "enum-assoc"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1060,6 +1107,27 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1930,6 +1998,7 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2511,6 +2580,15 @@ version = "2.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2811,6 +2889,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -2837,6 +2929,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2863,6 +2964,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -3032,6 +3144,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "papaya"
|
name = "papaya"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -3866,6 +3988,25 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secret-service"
|
||||||
|
version = "5.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"cbc",
|
||||||
|
"futures-util",
|
||||||
|
"generic-array",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hkdf",
|
||||||
|
"num",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -3974,6 +4115,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -4485,6 +4637,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn-mid"
|
name = "syn-mid"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -4550,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4684,6 +4847,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4977,6 +5141,17 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -5233,9 +5408,13 @@ dependencies = [
|
|||||||
"iroh-relay",
|
"iroh-relay",
|
||||||
"irpc",
|
"irpc",
|
||||||
"irpc-iroh",
|
"irpc-iroh",
|
||||||
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
|
"ndk-context",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
|
"secret-service",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -5248,6 +5427,7 @@ dependencies = [
|
|||||||
"uniffi",
|
"uniffi",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5443,7 +5623,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5917,6 +6097,62 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-recursion",
|
||||||
|
"async-trait",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"ordered-stream",
|
||||||
|
"rustix",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.52"
|
version = "0.8.52"
|
||||||
@@ -6016,3 +6252,43 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant_derive",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ uniffi = { version = "=0.29.4", features = ["tokio"] }
|
|||||||
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||||
|
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "0.21.1"
|
||||||
|
ndk-context = "0.1.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
iroh-relay = { version = "1.0.3", features = ["server"] }
|
iroh-relay = { version = "1.0.3", features = ["server"] }
|
||||||
|
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{error::VnidropError, util::now_ms};
|
use crate::{error::VnidropError, util::now_ms};
|
||||||
|
|
||||||
|
#[cfg(any(test, target_os = "android"))]
|
||||||
|
mod android;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||||
|
mod apple;
|
||||||
|
#[cfg(any(test, target_os = "linux"))]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows;
|
||||||
|
|
||||||
const SECRET_BYTES: usize = 32;
|
const SECRET_BYTES: usize = 32;
|
||||||
const HANDLE_NAMESPACE: &str = "vnidrop";
|
const HANDLE_NAMESPACE: &str = "vnidrop";
|
||||||
const HANDLE_VERSION: &str = "v1";
|
const HANDLE_VERSION: &str = "v1";
|
||||||
@@ -120,6 +129,38 @@ pub(crate) trait SecureSecretStore: Send + Sync {
|
|||||||
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>;
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
_app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
Ok(Arc::new(apple::AppleKeychainSecretStore::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
_app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
android::native::create_store_from_android_runtime().map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
|
||||||
|
.map(|store| Arc::new(store) as Arc<dyn SecureSecretStore>)
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
_app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
linux::LinuxSecretServiceStore::connect()
|
||||||
|
.map(|store| Arc::new(store) as Arc<dyn SecureSecretStore>)
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum SecretMetadataState {
|
enum SecretMetadataState {
|
||||||
Staged,
|
Staged,
|
||||||
|
|||||||
430
crates/vnidrop/src/secure_secret/android.rs
Normal file
430
crates/vnidrop/src/secure_secret/android.rs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
use std::{
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
|
||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
|
||||||
|
const RECORD_MAGIC: &[u8; 8] = b"VNDASK01";
|
||||||
|
const RECORD_STAGED: u8 = 0;
|
||||||
|
const RECORD_SEALED: u8 = 1;
|
||||||
|
const RECORD_EXTENSION: &str = "vns";
|
||||||
|
const KEY_ALIAS_PREFIX: &str = "vnidrop.secret.v1.";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct AndroidSealedValue {
|
||||||
|
pub(crate) nonce: Vec<u8>,
|
||||||
|
pub(crate) ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs AES-GCM operations with a non-exportable key held by Android Keystore.
|
||||||
|
///
|
||||||
|
/// Implementations create one key per alias, let Keystore generate the encryption
|
||||||
|
/// nonce, and never return key material to Rust.
|
||||||
|
pub(crate) trait AndroidKeystore: Send + Sync {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError>;
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError>;
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android secret-store adapter whose ordinary storage contains authenticated
|
||||||
|
/// ciphertext only.
|
||||||
|
///
|
||||||
|
/// `no_backup_dir` must be the directory returned by Android
|
||||||
|
/// `Context.getNoBackupFilesDir()`. The Android host owns acquiring that Context;
|
||||||
|
/// secret values never cross that initialization boundary.
|
||||||
|
pub(crate) struct AndroidSecureSecretStore {
|
||||||
|
records_dir: PathBuf,
|
||||||
|
keystore: Arc<dyn AndroidKeystore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidSecureSecretStore {
|
||||||
|
pub(crate) fn new(
|
||||||
|
no_backup_dir: &Path,
|
||||||
|
keystore: Arc<dyn AndroidKeystore>,
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
if !no_backup_dir.is_absolute() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let records_dir = no_backup_dir.join("vnidrop-protected-secrets-v1");
|
||||||
|
fs::create_dir_all(&records_dir).map_err(map_io_error)?;
|
||||||
|
set_private_directory_permissions(&records_dir)?;
|
||||||
|
Ok(Self {
|
||||||
|
records_dir,
|
||||||
|
keystore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_path(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
let encoded = HEXLOWER.encode(handle.as_str().as_bytes());
|
||||||
|
self.records_dir
|
||||||
|
.join(format!("{encoded}.{RECORD_EXTENSION}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alias(handle: &SecretHandle) -> String {
|
||||||
|
let digest = blake3::hash(handle.as_str().as_bytes());
|
||||||
|
format!("{KEY_ALIAS_PREFIX}{}", HEXLOWER.encode(digest.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_record(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
state: u8,
|
||||||
|
sealed: Option<&AndroidSealedValue>,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let bytes = encode_record(handle, state, sealed)?;
|
||||||
|
let path = self.record_path(handle);
|
||||||
|
let temporary = path.with_extension(format!("{RECORD_EXTENSION}.tmp"));
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&temporary)
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
set_private_file_permissions(&temporary)?;
|
||||||
|
file.write_all(&bytes).map_err(map_io_error)?;
|
||||||
|
file.sync_all().map_err(map_io_error)?;
|
||||||
|
fs::rename(&temporary, &path).map_err(map_io_error)?;
|
||||||
|
sync_directory(&self.records_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_record(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
File::open(self.record_path(handle))
|
||||||
|
.map_err(map_io_error)?
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
decode_record(&bytes, handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for AndroidSecureSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.write_record(handle, RECORD_STAGED, None)?;
|
||||||
|
let alias = Self::alias(handle);
|
||||||
|
let sealed = self.keystore.seal(&alias, &material.0)?;
|
||||||
|
if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
self.write_record(handle, RECORD_SEALED, Some(&sealed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let sealed = self.read_record(handle)?;
|
||||||
|
let plaintext = self.keystore.open(&Self::alias(handle), &sealed)?;
|
||||||
|
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
match self.keystore.delete(&Self::alias(handle)) {
|
||||||
|
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
let path = self.record_path(handle);
|
||||||
|
match fs::remove_file(path) {
|
||||||
|
Ok(()) => sync_directory(&self.records_dir)?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(map_io_error(error)),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for entry in fs::read_dir(&self.records_dir).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|value| value.to_str()) != Some(RECORD_EXTENSION) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
let decoded = HEXLOWER
|
||||||
|
.decode(stem.as_bytes())
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let handle =
|
||||||
|
String::from_utf8(decoded).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
handles.push(SecretHandle(handle));
|
||||||
|
}
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_record(
|
||||||
|
handle: &SecretHandle,
|
||||||
|
state: u8,
|
||||||
|
sealed: Option<&AndroidSealedValue>,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let handle_bytes = handle.as_str().as_bytes();
|
||||||
|
let handle_len =
|
||||||
|
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let (nonce, ciphertext) = match (state, sealed) {
|
||||||
|
(RECORD_STAGED, None) => (&[][..], &[][..]),
|
||||||
|
(RECORD_SEALED, Some(value)) => (value.nonce.as_slice(), value.ciphertext.as_slice()),
|
||||||
|
_ => return Err(SecureSecretStoreError::Corrupted),
|
||||||
|
};
|
||||||
|
let nonce_len = u16::try_from(nonce.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let mut record = Vec::with_capacity(
|
||||||
|
RECORD_MAGIC.len() + 1 + 2 + 2 + 4 + handle_bytes.len() + nonce.len() + ciphertext.len(),
|
||||||
|
);
|
||||||
|
record.extend_from_slice(RECORD_MAGIC);
|
||||||
|
record.push(state);
|
||||||
|
record.extend_from_slice(&handle_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(&nonce_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(&ciphertext_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(handle_bytes);
|
||||||
|
record.extend_from_slice(nonce);
|
||||||
|
record.extend_from_slice(ciphertext);
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_record(
|
||||||
|
bytes: &[u8],
|
||||||
|
expected_handle: &SecretHandle,
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
const HEADER_LEN: usize = 8 + 1 + 2 + 2 + 4;
|
||||||
|
if bytes.len() < HEADER_LEN || &bytes[..8] != RECORD_MAGIC {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let state = bytes[8];
|
||||||
|
let handle_len = usize::from(u16::from_be_bytes([bytes[9], bytes[10]]));
|
||||||
|
let nonce_len = usize::from(u16::from_be_bytes([bytes[11], bytes[12]]));
|
||||||
|
let ciphertext_len = usize::try_from(u32::from_be_bytes([
|
||||||
|
bytes[13], bytes[14], bytes[15], bytes[16],
|
||||||
|
]))
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let expected_len = HEADER_LEN
|
||||||
|
.checked_add(handle_len)
|
||||||
|
.and_then(|value| value.checked_add(nonce_len))
|
||||||
|
.and_then(|value| value.checked_add(ciphertext_len))
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if bytes.len() != expected_len || state != RECORD_SEALED {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle_end = HEADER_LEN + handle_len;
|
||||||
|
let handle = std::str::from_utf8(&bytes[HEADER_LEN..handle_end])
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
if handle != expected_handle.as_str() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let nonce_end = handle_end + nonce_len;
|
||||||
|
if nonce_len == 0 || ciphertext_len == 0 {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: bytes[handle_end..nonce_end].to_vec(),
|
||||||
|
ciphertext: bytes[nonce_end..].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_io_error(error: std::io::Error) -> SecureSecretStoreError {
|
||||||
|
match error.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
|
||||||
|
std::io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_directory(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
File::open(path)
|
||||||
|
.and_then(|directory| directory.sync_all())
|
||||||
|
.map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_private_directory_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_private_directory_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_private_file_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[path = "android_native.rs"]
|
||||||
|
pub(crate) mod native;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::secure_secret::SECRET_BYTES;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeKeystore {
|
||||||
|
keys: Mutex<HashMap<String, u8>>,
|
||||||
|
delete_failure: Mutex<Option<SecureSecretStoreError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidKeystore for FakeKeystore {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
let mask = 0xa7;
|
||||||
|
self.keys.lock().unwrap().insert(alias.to_string(), mask);
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: vec![4; 12],
|
||||||
|
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let mask = *self
|
||||||
|
.keys
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(alias)
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)?;
|
||||||
|
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
if let Some(error) = self.delete_failure.lock().unwrap().take() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
self.keys.lock().unwrap().remove(alias);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc<FakeKeystore>) {
|
||||||
|
let directory = TempDir::new().unwrap();
|
||||||
|
let keystore = Arc::new(FakeKeystore::default());
|
||||||
|
let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap();
|
||||||
|
(directory, store, keystore)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle() -> SecretHandle {
|
||||||
|
SecretHandle("vnidrop/v1/endpoint-identity/test".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() {
|
||||||
|
let (directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
let plaintext = vec![0x5a; SECRET_BYTES];
|
||||||
|
|
||||||
|
store
|
||||||
|
.put(&handle, SecretMaterial::new(plaintext.clone()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let persisted = fs::read(store.record_path(&handle)).unwrap();
|
||||||
|
assert!(!persisted
|
||||||
|
.windows(plaintext.len())
|
||||||
|
.any(|window| window == plaintext));
|
||||||
|
|
||||||
|
drop(store);
|
||||||
|
let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap();
|
||||||
|
assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
assert_eq!(restarted.get(&handle).unwrap().0, plaintext);
|
||||||
|
|
||||||
|
restarted.delete(&handle).unwrap();
|
||||||
|
assert!(restarted.list_handles().unwrap().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
restarted.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_crash_record_remains_discoverable_and_fails_closed() {
|
||||||
|
let (_directory, store, _keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store.write_record(&handle, RECORD_STAGED, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
store.delete(&handle).unwrap();
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampering_and_missing_keystore_keys_are_distinct_failures() {
|
||||||
|
let (_directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store
|
||||||
|
.put(&handle, SecretMaterial::new(vec![9; SECRET_BYTES]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
keystore.keys.lock().unwrap().clear();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(store.record_path(&handle), b"tampered").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_key_deletion_retains_the_record_for_safe_retry() {
|
||||||
|
let (_directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store
|
||||||
|
.put(&handle, SecretMaterial::new(vec![7; SECRET_BYTES]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
*keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.delete(&handle),
|
||||||
|
Err(SecureSecretStoreError::Locked)
|
||||||
|
));
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
|
||||||
|
store.delete(&handle).unwrap();
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
431
crates/vnidrop/src/secure_secret/android_native.rs
Normal file
431
crates/vnidrop/src/secure_secret/android_native.rs
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
use jni::{
|
||||||
|
errors::Error as JniError,
|
||||||
|
objects::{JByteArray, JObject, JString, JValue},
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ANDROID_KEYSTORE: &str = "AndroidKeyStore";
|
||||||
|
const AES: &str = "AES";
|
||||||
|
const TRANSFORMATION: &str = "AES/GCM/NoPadding";
|
||||||
|
|
||||||
|
/// JNI-backed Android Keystore engine. The VM pointer comes from the Android
|
||||||
|
/// runtime; no Context or secret bytes are exposed through UniFFI.
|
||||||
|
pub(crate) struct AndroidJniKeystore {
|
||||||
|
vm: JavaVM,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidJniKeystore {
|
||||||
|
/// Constructs the engine after the Android runtime has initialized
|
||||||
|
/// `ndk-context` with the process Java VM.
|
||||||
|
pub(crate) fn from_android_runtime() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
let context = std::panic::catch_unwind(ndk_context::android_context)
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let vm = context.vm();
|
||||||
|
if vm.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
// Android owns the process VM for longer than every core instance.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm.cast()) }
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
Ok(Self { vm })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_env<T>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut JNIEnv<'_>) -> Result<T, SecureSecretStoreError>,
|
||||||
|
) -> Result<T, SecureSecretStoreError> {
|
||||||
|
let mut env = self
|
||||||
|
.vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
operation(&mut env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_backup_files_dir(&self) -> Result<PathBuf, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let context = std::panic::catch_unwind(ndk_context::android_context)
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?
|
||||||
|
.context();
|
||||||
|
if context.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
// ndk-context retains this process Context for the Android runtime lifetime.
|
||||||
|
let context = unsafe { JObject::from_raw(context.cast()) };
|
||||||
|
let directory = env
|
||||||
|
.call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if directory.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let path = env
|
||||||
|
.call_method(&directory, "getAbsolutePath", "()Ljava/lang/String;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let path = JString::from(path);
|
||||||
|
let path: String = env
|
||||||
|
.get_string(&path)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.into();
|
||||||
|
Ok(PathBuf::from(path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_store_from_android_runtime(
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, SecureSecretStoreError> {
|
||||||
|
let keystore = Arc::new(AndroidJniKeystore::from_android_runtime()?);
|
||||||
|
let no_backup_dir = keystore.no_backup_files_dir()?;
|
||||||
|
Ok(Arc::new(AndroidSecureSecretStore::new(
|
||||||
|
&no_backup_dir,
|
||||||
|
keystore,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidKeystore for AndroidJniKeystore {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let alias_string = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias_string);
|
||||||
|
let contains = env
|
||||||
|
.call_method(
|
||||||
|
&key_store,
|
||||||
|
"containsAlias",
|
||||||
|
"(Ljava/lang/String;)Z",
|
||||||
|
&[JValue::Object(&alias_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.z()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if !contains {
|
||||||
|
generate_key(env, alias)?;
|
||||||
|
}
|
||||||
|
let key = get_key(env, &key_store, alias)?;
|
||||||
|
let cipher = cipher_instance(env)?;
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;)V",
|
||||||
|
&[JValue::Int(1), JValue::Object(&key)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let plaintext = env
|
||||||
|
.byte_array_from_slice(plaintext)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let plaintext_object = JObject::from(plaintext);
|
||||||
|
let ciphertext = env
|
||||||
|
.call_method(
|
||||||
|
&cipher,
|
||||||
|
"doFinal",
|
||||||
|
"([B)[B",
|
||||||
|
&[JValue::Object(&plaintext_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let nonce = env
|
||||||
|
.call_method(&cipher, "getIV", "()[B", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: env
|
||||||
|
.convert_byte_array(JByteArray::from(nonce))
|
||||||
|
.map_err(|error| map_jni_error(env, error))?,
|
||||||
|
ciphertext: env
|
||||||
|
.convert_byte_array(JByteArray::from(ciphertext))
|
||||||
|
.map_err(|error| map_jni_error(env, error))?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let key = get_key(env, &key_store, alias)?;
|
||||||
|
let cipher = cipher_instance(env)?;
|
||||||
|
let nonce = env
|
||||||
|
.byte_array_from_slice(&sealed.nonce)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let nonce_object = JObject::from(nonce);
|
||||||
|
let parameters = env
|
||||||
|
.new_object(
|
||||||
|
"javax/crypto/spec/GCMParameterSpec",
|
||||||
|
"(I[B)V",
|
||||||
|
&[JValue::Int(128), JValue::Object(&nonce_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[
|
||||||
|
JValue::Int(2),
|
||||||
|
JValue::Object(&key),
|
||||||
|
JValue::Object(¶meters),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let ciphertext = env
|
||||||
|
.byte_array_from_slice(&sealed.ciphertext)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let ciphertext_object = JObject::from(ciphertext);
|
||||||
|
let plaintext = env
|
||||||
|
.call_method(
|
||||||
|
&cipher,
|
||||||
|
"doFinal",
|
||||||
|
"([B)[B",
|
||||||
|
&[JValue::Object(&ciphertext_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.convert_byte_array(JByteArray::from(plaintext))
|
||||||
|
.map_err(|error| map_jni_error(env, error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
env.call_method(
|
||||||
|
&key_store,
|
||||||
|
"deleteEntry",
|
||||||
|
"(Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&alias_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_key_store<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let provider = env
|
||||||
|
.new_string(ANDROID_KEYSTORE)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let provider_object = JObject::from(provider);
|
||||||
|
let key_store = env
|
||||||
|
.call_static_method(
|
||||||
|
"java/security/KeyStore",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||||
|
&[JValue::Object(&provider_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&key_store,
|
||||||
|
"load",
|
||||||
|
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||||
|
&[JValue::Object(&JObject::null())],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(key_store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_key(env: &mut JNIEnv<'_>, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let algorithm = env
|
||||||
|
.new_string(AES)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let provider = env
|
||||||
|
.new_string(ANDROID_KEYSTORE)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let algorithm_object = JObject::from(algorithm);
|
||||||
|
let provider_object = JObject::from(provider);
|
||||||
|
let generator = env
|
||||||
|
.call_static_method(
|
||||||
|
"javax/crypto/KeyGenerator",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
|
||||||
|
&[
|
||||||
|
JValue::Object(&algorithm_object),
|
||||||
|
JValue::Object(&provider_object),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
let builder = env
|
||||||
|
.new_object(
|
||||||
|
"android/security/keystore/KeyGenParameterSpec$Builder",
|
||||||
|
"(Ljava/lang/String;I)V",
|
||||||
|
&[JValue::Object(&alias_object), JValue::Int(3)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let modes = java_string_array(env, "GCM")?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setBlockModes",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Object(&modes)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let paddings = java_string_array(env, "NoPadding")?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setEncryptionPaddings",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Object(&paddings)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setKeySize",
|
||||||
|
"(I)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Int(256)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setRandomizedEncryptionRequired",
|
||||||
|
"(Z)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Bool(1)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let parameters = env
|
||||||
|
.call_method(
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroid/security/keystore/KeyGenParameterSpec;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&generator,
|
||||||
|
"init",
|
||||||
|
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[JValue::Object(¶meters)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(&generator, "generateKey", "()Ljavax/crypto/SecretKey;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_key<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
key_store: &JObject<'local>,
|
||||||
|
alias: &str,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
let key = env
|
||||||
|
.call_method(
|
||||||
|
key_store,
|
||||||
|
"getKey",
|
||||||
|
"(Ljava/lang/String;[C)Ljava/security/Key;",
|
||||||
|
&[
|
||||||
|
JValue::Object(&alias_object),
|
||||||
|
JValue::Object(&JObject::null()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if key.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cipher_instance<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let transformation = env
|
||||||
|
.new_string(TRANSFORMATION)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let transformation_object = JObject::from(transformation);
|
||||||
|
env.call_static_method(
|
||||||
|
"javax/crypto/Cipher",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||||
|
&[JValue::Object(&transformation_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn java_string_array<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let class = env
|
||||||
|
.find_class("java/lang/String")
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let array = env
|
||||||
|
.new_object_array(1, class, JObject::null())
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let value = env
|
||||||
|
.new_string(value)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.set_object_array_element(&array, 0, value)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(JObject::from(array))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreError {
|
||||||
|
let has_exception = env.exception_check().unwrap_or(false);
|
||||||
|
if !has_exception {
|
||||||
|
return SecureSecretStoreError::Unavailable;
|
||||||
|
}
|
||||||
|
let exception = match env.exception_occurred() {
|
||||||
|
Ok(exception) => exception,
|
||||||
|
Err(_) => return SecureSecretStoreError::Unavailable,
|
||||||
|
};
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
if is_instance_of(
|
||||||
|
env,
|
||||||
|
&exception,
|
||||||
|
"android/security/keystore/UserNotAuthenticatedException",
|
||||||
|
) {
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
} else if is_instance_of(
|
||||||
|
env,
|
||||||
|
&exception,
|
||||||
|
"android/security/keystore/KeyPermanentlyInvalidatedException",
|
||||||
|
) || is_instance_of(env, &exception, "javax/crypto/AEADBadTagException")
|
||||||
|
|| is_instance_of(env, &exception, "javax/crypto/BadPaddingException")
|
||||||
|
{
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
} else if is_instance_of(env, &exception, "java/security/UnrecoverableKeyException") {
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
} else {
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool {
|
||||||
|
env.is_instance_of(object, class).unwrap_or(false)
|
||||||
|
}
|
||||||
372
crates/vnidrop/src/secure_secret/apple.rs
Normal file
372
crates/vnidrop/src/secure_secret/apple.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
use security_framework::{
|
||||||
|
access_control::{ProtectionMode, SecAccessControl},
|
||||||
|
item::{ItemClass, ItemSearchOptions, Limit, SearchResult},
|
||||||
|
passwords::{
|
||||||
|
delete_generic_password_options, generic_password, set_generic_password_options,
|
||||||
|
PasswordOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const SERVICE: &str = "com.vnidrop.secure-secrets.v1";
|
||||||
|
const ACCOUNT_ATTRIBUTE: &str = "acct";
|
||||||
|
const ERR_SEC_PARAM: i32 = -50;
|
||||||
|
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
|
||||||
|
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
|
||||||
|
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
|
||||||
|
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
|
||||||
|
const ERR_SEC_DECODE: i32 = -26_275;
|
||||||
|
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34_018;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum AppleAccessibility {
|
||||||
|
AfterFirstUnlockThisDeviceOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct AppleKeychainPolicy {
|
||||||
|
accessibility: AppleAccessibility,
|
||||||
|
synchronizable: bool,
|
||||||
|
data_protection_keychain: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppleKeychainPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly,
|
||||||
|
synchronizable: false,
|
||||||
|
data_protection_keychain: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait AppleKeychainApi: Send + Sync {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32>;
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32>;
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32>;
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SystemAppleKeychain;
|
||||||
|
|
||||||
|
impl SystemAppleKeychain {
|
||||||
|
fn options(service: &str, account: &str) -> PasswordOptions {
|
||||||
|
let mut options = PasswordOptions::new_generic_password(service, account);
|
||||||
|
options.set_access_synchronized(Some(false));
|
||||||
|
options.use_protected_keychain();
|
||||||
|
options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainApi for SystemAppleKeychain {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32> {
|
||||||
|
debug_assert_eq!(policy, AppleKeychainPolicy::default());
|
||||||
|
let access_control = SecAccessControl::create_with_protection(
|
||||||
|
Some(ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.code())?;
|
||||||
|
let mut options = Self::options(service, account);
|
||||||
|
options.set_access_control(access_control);
|
||||||
|
set_generic_password_options(material, options).map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
|
||||||
|
generic_password(Self::options(service, account)).map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
|
||||||
|
delete_generic_password_options(Self::options(service, account))
|
||||||
|
.map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
|
||||||
|
let mut options = ItemSearchOptions::new();
|
||||||
|
options
|
||||||
|
.class(ItemClass::generic_password())
|
||||||
|
.service(service)
|
||||||
|
.cloud_sync(Some(false))
|
||||||
|
.load_attributes(true)
|
||||||
|
.limit(Limit::All);
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
options.ignore_legacy_keychains();
|
||||||
|
|
||||||
|
let results = match options.search() {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => return Ok(Vec::new()),
|
||||||
|
Err(error) => return Err(error.code()),
|
||||||
|
};
|
||||||
|
results
|
||||||
|
.into_iter()
|
||||||
|
.map(|result| match result {
|
||||||
|
SearchResult::Dict(_) => result
|
||||||
|
.simplify_dict()
|
||||||
|
.and_then(|attributes| attributes.get(ACCOUNT_ATTRIBUTE).cloned())
|
||||||
|
.ok_or(ERR_SEC_DECODE),
|
||||||
|
_ => Err(ERR_SEC_DECODE),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores VniDrop's protected material in Apple's device-local data-protection Keychain.
|
||||||
|
pub(crate) struct AppleKeychainSecretStore {
|
||||||
|
api: Arc<dyn AppleKeychainApi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainSecretStore {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
api: Arc::new(SystemAppleKeychain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_api(api: impl AppleKeychainApi + 'static) -> Self {
|
||||||
|
Self { api: Arc::new(api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for AppleKeychainSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.put(
|
||||||
|
SERVICE,
|
||||||
|
handle.as_str(),
|
||||||
|
&material.0,
|
||||||
|
AppleKeychainPolicy::default(),
|
||||||
|
)
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let material = self.api.get(SERVICE, handle.as_str()).map_err(map_status)?;
|
||||||
|
SecretMaterial::new(material).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.delete(SERVICE, handle.as_str())
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.list_accounts(SERVICE)
|
||||||
|
.map(|accounts| accounts.into_iter().map(SecretHandle).collect())
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_status(status: i32) -> SecureSecretStoreError {
|
||||||
|
match status {
|
||||||
|
ERR_SEC_ITEM_NOT_FOUND => SecureSecretStoreError::Missing,
|
||||||
|
ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED => SecureSecretStoreError::Locked,
|
||||||
|
ERR_SEC_DECODE | ERR_SEC_PARAM => SecureSecretStoreError::Corrupted,
|
||||||
|
ERR_SEC_NOT_AVAILABLE | ERR_SEC_MISSING_ENTITLEMENT => SecureSecretStoreError::Unavailable,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct RecordingKeychain {
|
||||||
|
state: Arc<Mutex<RecordingState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingState {
|
||||||
|
entries: HashMap<(String, String), Vec<u8>>,
|
||||||
|
last_policy: Option<AppleKeychainPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainApi for RecordingKeychain {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.last_policy = Some(policy);
|
||||||
|
state.entries.insert(
|
||||||
|
(service.to_string(), account.to_string()),
|
||||||
|
material.to_vec(),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.get(&(service.to_string(), account.to_string()))
|
||||||
|
.cloned()
|
||||||
|
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.remove(&(service.to_string(), account.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
|
||||||
|
Ok(self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.keys()
|
||||||
|
.filter(|(entry_service, _)| entry_service == service)
|
||||||
|
.map(|(_, account)| account.clone())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(value: &str) -> SecretHandle {
|
||||||
|
SecretHandle(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
api.state.lock().unwrap().entries.insert(
|
||||||
|
("com.example.unrelated".to_string(), "leave-me".to_string()),
|
||||||
|
vec![0x77; 32],
|
||||||
|
);
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
let owned = handle("vnidrop/v1/endpoint-identity/apple-test");
|
||||||
|
|
||||||
|
store
|
||||||
|
.put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened_store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
assert_eq!(
|
||||||
|
reopened_store.get(&owned).unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x31; 32]).unwrap()
|
||||||
|
);
|
||||||
|
reopened_store
|
||||||
|
.put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reopened_store.get(&owned).unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x42; 32]).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]);
|
||||||
|
reopened_store.delete(&owned).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
reopened_store.get(&owned),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert!(api
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_always_requests_device_local_non_synchronizing_protection() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
&handle("vnidrop/v1/relationship-grant/apple-policy"),
|
||||||
|
SecretMaterial::new(vec![0x51; 32]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
api.state.lock().unwrap().last_policy,
|
||||||
|
Some(AppleKeychainPolicy {
|
||||||
|
accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly,
|
||||||
|
synchronizable: false,
|
||||||
|
data_protection_keychain: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apple_statuses_map_to_fail_closed_contract_outcomes() {
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(ERR_SEC_INTERACTION_NOT_ALLOWED),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(ERR_SEC_AUTH_FAILED),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(ERR_SEC_ITEM_NOT_FOUND),
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(ERR_SEC_DECODE),
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(ERR_SEC_NOT_AVAILABLE),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status(-1),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
let secret = vec![0x6d; 31];
|
||||||
|
api.state.lock().unwrap().entries.insert(
|
||||||
|
(
|
||||||
|
SERVICE.to_string(),
|
||||||
|
"vnidrop/v1/pairing-eligibility/corrupt".to_string(),
|
||||||
|
),
|
||||||
|
secret.clone(),
|
||||||
|
);
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api);
|
||||||
|
|
||||||
|
let error = store
|
||||||
|
.get(&handle("vnidrop/v1/pairing-eligibility/corrupt"))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(&error, SecureSecretStoreError::Corrupted));
|
||||||
|
assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret)));
|
||||||
|
}
|
||||||
|
}
|
||||||
310
crates/vnidrop/src/secure_secret/linux.rs
Normal file
310
crates/vnidrop/src/secure_secret/linux.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use secret_service::{blocking::SecretService, EncryptionType, Error};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
|
||||||
|
HANDLE_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATTRIBUTE_APPLICATION: &str = "application";
|
||||||
|
const ATTRIBUTE_HANDLE: &str = "vnidrop-handle";
|
||||||
|
const APPLICATION_ID: &str = "com.vnidrop.VniDrop";
|
||||||
|
const ITEM_LABEL: &str = "VniDrop protected secret";
|
||||||
|
|
||||||
|
trait LinuxSecretServiceApi: Send + Sync {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError>;
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemLinuxSecretService;
|
||||||
|
|
||||||
|
impl SystemLinuxSecretService {
|
||||||
|
fn connect() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
SecretService::connect(EncryptionType::Dh).map_err(map_error)?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service(&self) -> Result<SecretService<'_>, SecureSecretStoreError> {
|
||||||
|
SecretService::connect(EncryptionType::Dh).map_err(map_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceApi for SystemLinuxSecretService {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let collection = service.get_default_collection().map_err(map_error)?;
|
||||||
|
if collection.is_locked().map_err(map_error)? {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
collection
|
||||||
|
.create_item(
|
||||||
|
ITEM_LABEL,
|
||||||
|
HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]),
|
||||||
|
material,
|
||||||
|
true,
|
||||||
|
"application/octet-stream",
|
||||||
|
)
|
||||||
|
.map_err(map_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
let mut items = result.unlocked.into_iter();
|
||||||
|
let item = items.next().ok_or(SecureSecretStoreError::Missing)?;
|
||||||
|
if items.next().is_some() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
item.get_secret().map_err(map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
if result.unlocked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
for item in result.unlocked {
|
||||||
|
item.delete().map_err(map_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([(ATTRIBUTE_APPLICATION, APPLICATION_ID)]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
.unlocked
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.get_attributes()
|
||||||
|
.map_err(map_error)?
|
||||||
|
.remove(ATTRIBUTE_HANDLE)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct LinuxSecretServiceStore {
|
||||||
|
api: Arc<dyn LinuxSecretServiceApi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceStore {
|
||||||
|
pub(super) fn connect() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Ok(Self {
|
||||||
|
api: Arc::new(SystemLinuxSecretService::connect()?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_api(api: Arc<dyn LinuxSecretServiceApi>) -> Self {
|
||||||
|
Self { api }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for LinuxSecretServiceStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api.put(handle.as_str(), &material.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let bytes = self.api.get(handle.as_str())?;
|
||||||
|
SecretMaterial::new(bytes).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api.delete(handle.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let expected_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
|
||||||
|
let mut handles = self
|
||||||
|
.api
|
||||||
|
.list_handles()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| {
|
||||||
|
if handle.starts_with(&expected_prefix) {
|
||||||
|
Ok(SecretHandle(handle))
|
||||||
|
} else {
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_error(error: Error) -> SecureSecretStoreError {
|
||||||
|
match error {
|
||||||
|
Error::Locked | Error::Prompt => SecureSecretStoreError::Locked,
|
||||||
|
Error::NoResult => SecureSecretStoreError::Missing,
|
||||||
|
Error::Crypto(_) | Error::Zvariant(_) => SecureSecretStoreError::Corrupted,
|
||||||
|
Error::Unavailable | Error::Zbus(_) | Error::ZbusFdo(_) => {
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingSecretService {
|
||||||
|
values: Mutex<HashMap<String, Vec<u8>>>,
|
||||||
|
failure: Mutex<Option<SecureSecretStoreError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingSecretService {
|
||||||
|
fn failure(&self) -> Result<(), SecureSecretStoreError> {
|
||||||
|
match &*self.failure.lock().unwrap() {
|
||||||
|
Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked),
|
||||||
|
Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing),
|
||||||
|
Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted),
|
||||||
|
Some(SecureSecretStoreError::Unavailable) => {
|
||||||
|
Err(SecureSecretStoreError::Unavailable)
|
||||||
|
}
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceApi for RecordingSecretService {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(handle.to_string(), material.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(handle)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(handle)
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
Ok(self.values.lock().unwrap().keys().cloned().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(suffix: &str) -> SecretHandle {
|
||||||
|
SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_survives_restart_and_deletes_only_the_selected_item() {
|
||||||
|
let api = Arc::new(RecordingSecretService::default());
|
||||||
|
let first = handle("first");
|
||||||
|
let second = handle("second");
|
||||||
|
let material = SecretMaterial::new(vec![0x5a; 32]).unwrap();
|
||||||
|
let store = LinuxSecretServiceStore::with_api(api.clone());
|
||||||
|
store.put(&first, material.clone()).unwrap();
|
||||||
|
store
|
||||||
|
.put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let restarted = LinuxSecretServiceStore::with_api(api);
|
||||||
|
assert_eq!(restarted.get(&first).unwrap(), material);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.list_handles().unwrap(),
|
||||||
|
vec![first.clone(), second]
|
||||||
|
);
|
||||||
|
restarted.delete(&first).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
restarted.get(&first),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_typed_and_secret_material_is_redacted() {
|
||||||
|
let api = Arc::new(RecordingSecretService::default());
|
||||||
|
let store = LinuxSecretServiceStore::with_api(api.clone());
|
||||||
|
let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap();
|
||||||
|
assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)");
|
||||||
|
|
||||||
|
for failure in [
|
||||||
|
SecureSecretStoreError::Locked,
|
||||||
|
SecureSecretStoreError::Unavailable,
|
||||||
|
SecureSecretStoreError::Corrupted,
|
||||||
|
] {
|
||||||
|
*api.failure.lock().unwrap() = Some(failure);
|
||||||
|
assert!(store.get(&handle("failure")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_service_errors_map_without_exposing_details() {
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Locked),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::NoResult),
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Crypto("distinctive-secret")),
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Unavailable),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
472
crates/vnidrop/src/secure_secret/windows.rs
Normal file
472
crates/vnidrop/src/secure_secret/windows.rs
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
ffi::OsStr,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{self, Write},
|
||||||
|
os::windows::ffi::OsStrExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
ptr,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{
|
||||||
|
GetLastError, LocalFree, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS,
|
||||||
|
ERROR_CALL_NOT_IMPLEMENTED, ERROR_FILE_EXISTS, ERROR_NOT_SUPPORTED,
|
||||||
|
ERROR_PASSWORD_RESTRICTION,
|
||||||
|
},
|
||||||
|
Security::Cryptography::{
|
||||||
|
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
|
||||||
|
},
|
||||||
|
Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
|
||||||
|
const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI";
|
||||||
|
const ENVELOPE_VERSION: u8 = 1;
|
||||||
|
const FILE_EXTENSION: &str = "dpapi";
|
||||||
|
const MAX_ENVELOPE_BYTES: usize = 64 * 1024;
|
||||||
|
const DEFAULT_CONTEXT: &[u8] = b"com.vnidrop.secure-secret.dpapi.v1.current-user";
|
||||||
|
const PROTECTED_PAYLOAD_MAGIC: &[u8] = b"VNIDROP-SECRET-V1";
|
||||||
|
|
||||||
|
/// Current-user DPAPI storage backed by atomically published protected blobs.
|
||||||
|
pub(crate) struct WindowsDpapiSecretStore {
|
||||||
|
directory: PathBuf,
|
||||||
|
protector: Arc<DpapiProtector>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsDpapiSecretStore {
|
||||||
|
pub(crate) fn new(directory: impl AsRef<Path>) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Self::with_protector(directory, Arc::new(DpapiProtector::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_protector(
|
||||||
|
directory: impl AsRef<Path>,
|
||||||
|
protector: Arc<DpapiProtector>,
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
let directory = directory.as_ref().to_path_buf();
|
||||||
|
fs::create_dir_all(&directory).map_err(map_io_error)?;
|
||||||
|
cleanup_interrupted_writes(&directory)?;
|
||||||
|
Ok(Self {
|
||||||
|
directory,
|
||||||
|
protector,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
let digest = blake3::hash(handle.as_str().as_bytes());
|
||||||
|
self.directory.join(format!(
|
||||||
|
"{}.{}",
|
||||||
|
HEXLOWER.encode(digest.as_bytes()),
|
||||||
|
FILE_EXTENSION
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_protector_for_test(
|
||||||
|
directory: impl AsRef<Path>,
|
||||||
|
protector: Arc<DpapiProtector>,
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Self::with_protector(directory, protector)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn path_for_test(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
self.path_for(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for WindowsDpapiSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let destination = self.path_for(handle);
|
||||||
|
if destination.exists() {
|
||||||
|
return ensure_same_value(self, handle, &material);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ciphertext = self.protector.protect(handle, &material.0)?;
|
||||||
|
let envelope = encode_envelope(handle, &ciphertext)?;
|
||||||
|
let temporary = self.directory.join(format!(
|
||||||
|
"{}.tmp-{}",
|
||||||
|
destination
|
||||||
|
.file_stem()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.ok_or(SecureSecretStoreError::Unavailable)?,
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let mut temporary_guard = TemporaryFile::new(temporary);
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(temporary_guard.path())
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
file.write_all(&envelope).map_err(map_io_error)?;
|
||||||
|
file.sync_all().map_err(map_io_error)?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
match move_write_through(temporary_guard.path(), &destination) {
|
||||||
|
Ok(()) => {
|
||||||
|
temporary_guard.disarm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.raw_os_error().map(|code| code as u32),
|
||||||
|
Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS)
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
ensure_same_value(self, handle, &material)
|
||||||
|
}
|
||||||
|
Err(error) => Err(map_io_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let envelope = fs::read(self.path_for(handle)).map_err(map_io_error)?;
|
||||||
|
let ciphertext = decode_envelope(&envelope, handle)?;
|
||||||
|
let plaintext = self.protector.unprotect(handle, ciphertext)?;
|
||||||
|
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
fs::remove_file(self.path_for(handle)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
let mut unique = HashSet::new();
|
||||||
|
for entry in fs::read_dir(&self.directory).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension() != Some(OsStr::new(FILE_EXTENSION)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let envelope = fs::read(&path).map_err(map_io_error)?;
|
||||||
|
let handle = decode_handle(&envelope)?;
|
||||||
|
if self.path_for(&handle) != path || !unique.insert(handle.clone()) {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_same_value(
|
||||||
|
store: &WindowsDpapiSecretStore,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
expected: &SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
if store.get(handle)? == *expected {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_envelope(
|
||||||
|
handle: &SecretHandle,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let handle_bytes = handle.as_str().as_bytes();
|
||||||
|
let handle_len =
|
||||||
|
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let capacity = ENVELOPE_MAGIC.len() + 1 + 2 + 4 + handle_bytes.len() + ciphertext.len();
|
||||||
|
if capacity > MAX_ENVELOPE_BYTES {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let mut envelope = Vec::with_capacity(capacity);
|
||||||
|
envelope.extend_from_slice(ENVELOPE_MAGIC);
|
||||||
|
envelope.push(ENVELOPE_VERSION);
|
||||||
|
envelope.extend_from_slice(&handle_len.to_le_bytes());
|
||||||
|
envelope.extend_from_slice(&ciphertext_len.to_le_bytes());
|
||||||
|
envelope.extend_from_slice(handle_bytes);
|
||||||
|
envelope.extend_from_slice(ciphertext);
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_handle(envelope: &[u8]) -> Result<SecretHandle, SecureSecretStoreError> {
|
||||||
|
let (handle, _) = decode_envelope_parts(envelope)?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_envelope<'a>(
|
||||||
|
envelope: &'a [u8],
|
||||||
|
expected_handle: &SecretHandle,
|
||||||
|
) -> Result<&'a [u8], SecureSecretStoreError> {
|
||||||
|
let (handle, ciphertext) = decode_envelope_parts(envelope)?;
|
||||||
|
if handle != *expected_handle {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_envelope_parts(envelope: &[u8]) -> Result<(SecretHandle, &[u8]), SecureSecretStoreError> {
|
||||||
|
const HEADER_BYTES: usize = 8 + 1 + 2 + 4;
|
||||||
|
if envelope.len() < HEADER_BYTES
|
||||||
|
|| envelope.len() > MAX_ENVELOPE_BYTES
|
||||||
|
|| &envelope[..8] != ENVELOPE_MAGIC
|
||||||
|
|| envelope[8] != ENVELOPE_VERSION
|
||||||
|
{
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle_len = usize::from(u16::from_le_bytes([envelope[9], envelope[10]]));
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::from_le_bytes([envelope[11], envelope[12], envelope[13], envelope[14]]) as usize;
|
||||||
|
let handle_end = HEADER_BYTES
|
||||||
|
.checked_add(handle_len)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
let envelope_end = handle_end
|
||||||
|
.checked_add(ciphertext_len)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if handle_len == 0 || ciphertext_len == 0 || envelope_end != envelope.len() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle = std::str::from_utf8(&envelope[HEADER_BYTES..handle_end])
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
Ok((
|
||||||
|
SecretHandle(handle.to_owned()),
|
||||||
|
&envelope[handle_end..envelope_end],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_interrupted_writes(directory: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
for entry in fs::read_dir(directory).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_string_lossy().contains(".tmp-") {
|
||||||
|
fs::remove_file(entry.path()).map_err(map_io_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemporaryFile {
|
||||||
|
path: PathBuf,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemporaryFile {
|
||||||
|
fn new(path: PathBuf) -> Self {
|
||||||
|
Self { path, armed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TemporaryFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
let _ = fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_write_through(source: &Path, destination: &Path) -> io::Result<()> {
|
||||||
|
let source = wide_path(source);
|
||||||
|
let destination = wide_path(destination);
|
||||||
|
// The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename.
|
||||||
|
let moved = unsafe {
|
||||||
|
MoveFileExW(
|
||||||
|
source.as_ptr(),
|
||||||
|
destination.as_ptr(),
|
||||||
|
MOVEFILE_WRITE_THROUGH,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if moved == 0 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide_path(path: &Path) -> Vec<u16> {
|
||||||
|
path.as_os_str().encode_wide().chain(Some(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DpapiProtector {
|
||||||
|
context: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DpapiProtector {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
context: DEFAULT_CONTEXT.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_context_for_test(context: &[u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
context: context.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entropy(&self, handle: &SecretHandle) -> [u8; 32] {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
hasher.update(&self.context);
|
||||||
|
hasher.update(&[0]);
|
||||||
|
hasher.update(handle.as_str().as_bytes());
|
||||||
|
*hasher.finalize().as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protect(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let mut payload = encode_protected_payload(plaintext)?;
|
||||||
|
let input = blob(&payload)?;
|
||||||
|
let entropy_bytes = self.entropy(handle);
|
||||||
|
let entropy = blob(&entropy_bytes)?;
|
||||||
|
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||||
|
// Omitting CRYPTPROTECT_LOCAL_MACHINE binds the blob to the current Windows user.
|
||||||
|
let success = unsafe {
|
||||||
|
CryptProtectData(
|
||||||
|
&input,
|
||||||
|
ptr::null(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut output,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let result = if success == 0 {
|
||||||
|
Err(map_dpapi_error(false))
|
||||||
|
} else {
|
||||||
|
copy_and_free(output, false)
|
||||||
|
};
|
||||||
|
payload.fill(0);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unprotect(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let input = blob(ciphertext)?;
|
||||||
|
let entropy_bytes = self.entropy(handle);
|
||||||
|
let entropy = blob(&entropy_bytes)?;
|
||||||
|
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||||
|
let success = unsafe {
|
||||||
|
CryptUnprotectData(
|
||||||
|
&input,
|
||||||
|
ptr::null_mut(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut output,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if success == 0 {
|
||||||
|
return Err(map_dpapi_error(true));
|
||||||
|
}
|
||||||
|
let mut payload = copy_and_free(output, true)?;
|
||||||
|
let plaintext = decode_protected_payload(&payload).map(<[u8]>::to_vec);
|
||||||
|
payload.fill(0);
|
||||||
|
plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_protected_payload(plaintext: &[u8]) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32);
|
||||||
|
payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC);
|
||||||
|
payload.extend_from_slice(&length.to_le_bytes());
|
||||||
|
payload.extend_from_slice(plaintext);
|
||||||
|
let digest = blake3::hash(&payload);
|
||||||
|
payload.extend_from_slice(digest.as_bytes());
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> {
|
||||||
|
let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4;
|
||||||
|
if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let length = u32::from_le_bytes(
|
||||||
|
payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?,
|
||||||
|
) as usize;
|
||||||
|
let material_end = header_end
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if material_end
|
||||||
|
.checked_add(32)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?
|
||||||
|
!= payload.len()
|
||||||
|
{
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let expected = blake3::hash(&payload[..material_end]);
|
||||||
|
if expected.as_bytes() != &payload[material_end..] {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(&payload[header_end..material_end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob(bytes: &[u8]) -> Result<CRYPT_INTEGER_BLOB, SecureSecretStoreError> {
|
||||||
|
Ok(CRYPT_INTEGER_BLOB {
|
||||||
|
cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?,
|
||||||
|
pbData: bytes.as_ptr().cast_mut(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_and_free(
|
||||||
|
output: CRYPT_INTEGER_BLOB,
|
||||||
|
clear_before_free: bool,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
if output.pbData.is_null() || output.cbData == 0 {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let result = unsafe {
|
||||||
|
let bytes = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
|
||||||
|
if clear_before_free {
|
||||||
|
ptr::write_bytes(output.pbData, 0, output.cbData as usize);
|
||||||
|
}
|
||||||
|
LocalFree(output.pbData.cast());
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError {
|
||||||
|
let code = unsafe { GetLastError() };
|
||||||
|
match code {
|
||||||
|
ERROR_ACCESS_DENIED | ERROR_PASSWORD_RESTRICTION => SecureSecretStoreError::Locked,
|
||||||
|
ERROR_NOT_SUPPORTED | ERROR_CALL_NOT_IMPLEMENTED => SecureSecretStoreError::Unavailable,
|
||||||
|
_ if unprotecting => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_io_error(error: io::Error) -> SecureSecretStoreError {
|
||||||
|
match error.kind() {
|
||||||
|
io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
|
||||||
|
io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked,
|
||||||
|
io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "windows_tests.rs"]
|
||||||
|
mod tests;
|
||||||
187
crates/vnidrop/src/secure_secret/windows_tests.rs
Normal file
187
crates/vnidrop/src/secure_secret/windows_tests.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use std::{fs, sync::Arc};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
use super::{DpapiProtector, WindowsDpapiSecretStore};
|
||||||
|
use crate::{
|
||||||
|
repository::Repository,
|
||||||
|
secure_secret::{
|
||||||
|
CustodyCrashPoint, SecretCustody, SecretHandle, SecretKind, SecretMaterial,
|
||||||
|
SecureSecretStore, SecureSecretStoreError,
|
||||||
|
},
|
||||||
|
VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn handle(suffix: &str) -> SecretHandle {
|
||||||
|
SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn material(seed: u8) -> SecretMaterial {
|
||||||
|
SecretMaterial::new(vec![seed; 32]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_survives_adapter_restart_and_never_persists_plaintext() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let handle = handle("restart");
|
||||||
|
let secret = material(0xa7);
|
||||||
|
|
||||||
|
WindowsDpapiSecretStore::new(directory.path())
|
||||||
|
.unwrap()
|
||||||
|
.put(&handle, secret.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
assert_eq!(restarted.get(&handle).unwrap(), secret);
|
||||||
|
assert_eq!(restarted.list_handles().unwrap(), vec![handle]);
|
||||||
|
|
||||||
|
for entry in fs::read_dir(directory.path()).unwrap() {
|
||||||
|
let bytes = fs::read(entry.unwrap().path()).unwrap();
|
||||||
|
assert!(!bytes.windows(32).any(|window| window == [0xa7; 32]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_only_the_selected_protected_value() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
let retained = handle("retained");
|
||||||
|
let removed = handle("removed");
|
||||||
|
store.put(&retained, material(1)).unwrap();
|
||||||
|
store.put(&removed, material(2)).unwrap();
|
||||||
|
|
||||||
|
store.delete(&removed).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&removed),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert_eq!(store.get(&retained).unwrap(), material(1));
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![retained]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_put_is_idempotent_but_cannot_replace_secret_material() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
let handle = handle("immutable");
|
||||||
|
|
||||||
|
store.put(&handle, material(6)).unwrap();
|
||||||
|
store.put(&handle, material(6)).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.put(&handle, material(7)),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
assert_eq!(store.get(&handle).unwrap(), material(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_corrupt_and_wrong_context_values_fail_closed() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let handle = handle("failure-mapping");
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
|
||||||
|
store.put(&handle, material(3)).unwrap();
|
||||||
|
fs::write(store.path_for_test(&handle), b"not a protected envelope").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
|
||||||
|
let isolated = tempfile::tempdir().unwrap();
|
||||||
|
let original = WindowsDpapiSecretStore::with_protector_for_test(
|
||||||
|
isolated.path(),
|
||||||
|
Arc::new(DpapiProtector::with_context_for_test(b"first-context")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
original.put(&handle, material(4)).unwrap();
|
||||||
|
let wrong_context = WindowsDpapiSecretStore::with_protector_for_test(
|
||||||
|
isolated.path(),
|
||||||
|
Arc::new(DpapiProtector::with_context_for_test(b"second-context")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
wrong_context.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_temporary_writes_are_removed_on_restart() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let temporary = directory.path().join("interrupted.tmp-123");
|
||||||
|
fs::write(&temporary, material(5).0).unwrap();
|
||||||
|
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
|
||||||
|
assert!(!temporary.exists());
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unusable_backing_path_is_reported_as_unavailable() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let file = directory.path().join("not-a-directory");
|
||||||
|
fs::write(&file, b"occupied").unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
WindowsDpapiSecretStore::new(&file),
|
||||||
|
Err(SecureSecretStoreError::Unavailable)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn endpoint_migration_survives_activation_crash_without_changing_identity() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let app_data = directory.path().join("app-data");
|
||||||
|
fs::create_dir(&app_data).unwrap();
|
||||||
|
let legacy = app_data.join("iroh.secret");
|
||||||
|
let original = SecretKey::generate();
|
||||||
|
fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap();
|
||||||
|
|
||||||
|
let repository = Repository::open(&app_data).await.unwrap();
|
||||||
|
let protected_directory = app_data.join("protected-secrets");
|
||||||
|
let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store);
|
||||||
|
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
|
||||||
|
assert!(custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(legacy.exists());
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
|
||||||
|
let repository = Repository::open(&app_data).await.unwrap();
|
||||||
|
let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||||
|
let (custody, _) = SecretCustody::start(repository.protected_secrets(), restarted_store)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let handle = custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!legacy.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement = SecretKey::generate();
|
||||||
|
fs::write(&legacy, HEXLOWER.encode(&replacement.to_bytes())).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
custody.migrate_legacy_endpoint_identity(&legacy).await,
|
||||||
|
Err(VnidropError::SecureStorageCorrupted { .. })
|
||||||
|
));
|
||||||
|
assert!(legacy.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user