Implement secure OS-backed secret storage
This commit is contained in:
125
Cargo.lock
generated
125
Cargo.lock
generated
@@ -202,6 +202,17 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apple-native-keyring-store"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||||
|
dependencies = [
|
||||||
|
"keyring-core",
|
||||||
|
"log",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "approx"
|
name = "approx"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -899,6 +910,15 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.4.0"
|
version = "1.4.0"
|
||||||
@@ -3827,6 +3847,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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3875,6 +3896,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
|||||||
name = "ironstorage"
|
name = "ironstorage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"apple-native-keyring-store",
|
||||||
"cap-std",
|
"cap-std",
|
||||||
"cap-tempfile",
|
"cap-tempfile",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3882,12 +3904,15 @@ dependencies = [
|
|||||||
"gix",
|
"gix",
|
||||||
"gix-config",
|
"gix-config",
|
||||||
"hex",
|
"hex",
|
||||||
|
"keyring-core",
|
||||||
"pgp",
|
"pgp",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha 0.3.1",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
|
"secret-service",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -3896,6 +3921,8 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
|
"windows-native-keyring-store",
|
||||||
|
"zbus-secret-service-keyring-store",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4119,6 +4146,15 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keyring-core"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "khronos-egl"
|
name = "khronos-egl"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -4512,6 +4548,30 @@ version = "0.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
|
checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -4529,6 +4589,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"
|
||||||
@@ -4565,6 +4634,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"
|
||||||
@@ -6231,6 +6311,25 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -7066,7 +7165,9 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8210,6 +8311,18 @@ 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 = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-native-keyring-store"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"keyring-core",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-numerics"
|
name = "windows-numerics"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -8584,6 +8697,7 @@ dependencies = [
|
|||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -8594,6 +8708,17 @@ dependencies = [
|
|||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus-secret-service-keyring-store"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4"
|
||||||
|
dependencies = [
|
||||||
|
"keyring-core",
|
||||||
|
"secret-service",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus_macros"
|
name = "zbus_macros"
|
||||||
version = "5.18.0"
|
version = "5.18.0"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ edition = "2024"
|
|||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] }
|
||||||
cap-std = "4.0"
|
cap-std = "4.0"
|
||||||
cap-tempfile = "4.0"
|
cap-tempfile = "4.0"
|
||||||
clap = { version = "4.6", features = ["derive"] }
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
@@ -23,15 +24,20 @@ gix = { version = "0.86", default-features = false, features = ["blocking-http-t
|
|||||||
gix-config = "0.59"
|
gix-config = "0.59"
|
||||||
iced = "0.14"
|
iced = "0.14"
|
||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
|
keyring-core = "1.0"
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
|
security-framework = "3.7"
|
||||||
|
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
shlex = "1.3"
|
shlex = "1.3"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
uniffi = "0.32"
|
uniffi = "0.32"
|
||||||
url = { version = "2.5", default-features = false }
|
url = { version = "2.5", default-features = false }
|
||||||
|
windows-native-keyring-store = { version = "1.1", default-features = false }
|
||||||
zeroize = "1.8"
|
zeroize = "1.8"
|
||||||
|
zbus-secret-service-keyring-store = { version = "1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ decision.
|
|||||||
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
|
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
|
||||||
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
|
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
|
||||||
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. |
|
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. |
|
||||||
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store`](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store`](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store`](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Preferred per-platform stores. The Apple protected store supports iOS/macOS protected data and biometric access. Use the Linux store's Rust crypto feature. |
|
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
|
||||||
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
|
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
|
||||||
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
|
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
|
||||||
| TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. |
|
| TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. |
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ The shared TOML schema, path rules, editor precedence, and HTTPS remote format
|
|||||||
are documented in [`docs/configuration.md`](docs/configuration.md).
|
are documented in [`docs/configuration.md`](docs/configuration.md).
|
||||||
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
|
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
|
||||||
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
|
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
|
||||||
|
Native credential storage, opaque secret references, user-presence policy, and
|
||||||
|
bounded caching are documented in
|
||||||
|
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
|
||||||
The capability-scoped password-store layout and atomic mutation guarantees are
|
The capability-scoped password-store layout and atomic mutation guarantees are
|
||||||
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
||||||
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ use std::{ffi::OsString, io::Write, process::ExitCode};
|
|||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{
|
command::{
|
||||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic,
|
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
||||||
OtpRequest, help_text, otp_version_text, parse_from, version_text,
|
GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from,
|
||||||
|
version_text,
|
||||||
},
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
|
crypto::KeyStore,
|
||||||
|
git::{GitIdentity, GitRepository},
|
||||||
|
read::{ShowOutput, ShowResult, VaultReader},
|
||||||
|
repository::Repository,
|
||||||
|
secret_store::{
|
||||||
|
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||||
|
SecretStoreBackend,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -73,7 +82,24 @@ where
|
|||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
Ok(EXIT_SUCCESS)
|
Ok(EXIT_SUCCESS)
|
||||||
}
|
}
|
||||||
_ => match Config::load(invocation.config()) {
|
request => match Config::load(invocation.config()) {
|
||||||
|
Ok(config) if needs_secret_store(request) => {
|
||||||
|
let mut secrets = match NativeSecretStore::system(
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
)
|
||||||
|
.and_then(|store| {
|
||||||
|
store.unlock()?;
|
||||||
|
Ok(store)
|
||||||
|
}) {
|
||||||
|
Ok(secrets) => secrets,
|
||||||
|
Err(error) => {
|
||||||
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||||
|
return Ok(EXIT_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr)
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stderr
|
stderr
|
||||||
.write_all(
|
.write_all(
|
||||||
@@ -91,16 +117,177 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn needs_secret_store(request: &CommandRequest) -> bool {
|
||||||
|
matches!(
|
||||||
|
request,
|
||||||
|
CommandRequest::Show(_) | CommandRequest::Git(GitRequest::Fetch { .. })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||||
|
config: &Config,
|
||||||
|
request: &CommandRequest,
|
||||||
|
secrets: &mut SecretStore<B>,
|
||||||
|
stdout: &mut O,
|
||||||
|
stderr: &mut E,
|
||||||
|
) -> Result<u8, ()> {
|
||||||
|
match request {
|
||||||
|
CommandRequest::Show(request) if request.presentation == Presentation::Terminal => {
|
||||||
|
let repository = match Repository::open(config.vault()) {
|
||||||
|
Ok(repository) => repository,
|
||||||
|
Err(error) => return operation_error(stderr, error),
|
||||||
|
};
|
||||||
|
let keys = match KeyStore::load(config.key_material()) {
|
||||||
|
Ok(keys) => keys,
|
||||||
|
Err(error) => return operation_error(stderr, error),
|
||||||
|
};
|
||||||
|
match VaultReader::new(&repository, &keys).execute_show(request, secrets) {
|
||||||
|
Ok(ShowOutput::Display(ShowResult::Entry(secret))) => {
|
||||||
|
stdout.write_all(secret.expose()).map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
Ok(ShowOutput::Display(ShowResult::Directory(tree))) => {
|
||||||
|
stdout
|
||||||
|
.write_all(tree.render_plain().as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
Ok(ShowOutput::Present(_)) => Ok(EXIT_UNAVAILABLE),
|
||||||
|
Err(error) => operation_error(stderr, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandRequest::Git(GitRequest::Fetch { remote }) => {
|
||||||
|
let configured = match select_remote(config, remote.as_deref()) {
|
||||||
|
Some(configured) => configured,
|
||||||
|
None => {
|
||||||
|
stderr
|
||||||
|
.write_all(b"the requested HTTPS Git remote is not configured\n")
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
return Ok(EXIT_CONFIG);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let repository = match Repository::open(config.vault()) {
|
||||||
|
Ok(repository) => repository,
|
||||||
|
Err(error) => return operation_error(stderr, error),
|
||||||
|
};
|
||||||
|
let identity = GitIdentity::new("IronStorage", "ironstorage@localhost")
|
||||||
|
.expect("the built-in Git identity is valid");
|
||||||
|
let git = match GitRepository::open(&repository, identity) {
|
||||||
|
Ok(git) => git,
|
||||||
|
Err(error) => return operation_error(stderr, error),
|
||||||
|
};
|
||||||
|
match git.fetch(configured, secrets) {
|
||||||
|
Ok(_) => Ok(EXIT_SUCCESS),
|
||||||
|
Err(error) => operation_error(stderr, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(EXIT_UNAVAILABLE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_remote<'a>(
|
||||||
|
config: &'a Config,
|
||||||
|
requested: Option<&str>,
|
||||||
|
) -> Option<&'a ironstorage::config::GitRemote> {
|
||||||
|
match requested {
|
||||||
|
Some(requested) => config
|
||||||
|
.git_remotes()
|
||||||
|
.iter()
|
||||||
|
.find(|remote| remote.name().as_str() == requested),
|
||||||
|
None => config.git_remotes().first(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
|
||||||
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||||
|
Ok(EXIT_FAILURE)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{error::Error, ffi::OsString, fs};
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
error::Error,
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use ironstorage::command::{EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE};
|
use ironstorage::{
|
||||||
|
command::{
|
||||||
|
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation,
|
||||||
|
ShowRequest,
|
||||||
|
},
|
||||||
|
config::Config,
|
||||||
|
git::GitCredentialProvider as _,
|
||||||
|
repository::SecretBytes,
|
||||||
|
secret_store::{
|
||||||
|
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||||
|
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
use super::run_with;
|
use super::{execute_secure, run_with};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error>>;
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
||||||
|
|
||||||
|
impl SecretStoreBackend for MemoryBackend {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
if values.contains_key(locator) {
|
||||||
|
return Err(SecretStoreError::AlreadyExists);
|
||||||
|
}
|
||||||
|
values.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecretStoreError::Unavailable)?
|
||||||
|
.get(locator)
|
||||||
|
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||||
|
.ok_or(SecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
let existing = values.get_mut(locator).ok_or(SecretStoreError::Missing)?;
|
||||||
|
*existing = SecretBytes::new(value.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecretStoreError::Unavailable)?
|
||||||
|
.remove(locator)
|
||||||
|
.map(drop)
|
||||||
|
.ok_or(SecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
||||||
let mut stdout = Vec::new();
|
let mut stdout = Vec::new();
|
||||||
@@ -170,7 +357,71 @@ mod tests {
|
|||||||
.expect("writing to memory cannot fail");
|
.expect("writing to memory cannot fail");
|
||||||
assert_eq!(code, EXIT_UNAVAILABLE);
|
assert_eq!(code, EXIT_UNAVAILABLE);
|
||||||
assert!(stdout.is_empty());
|
assert!(stdout.is_empty());
|
||||||
assert!(String::from_utf8(stderr)?.contains("not available yet"));
|
assert!(String::from_utf8(stderr)?.contains("secret store is unavailable"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_session_unlocks_protected_entries_and_https_credentials_by_reference() -> TestResult {
|
||||||
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||||
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
let config_path = temporary.path().join("config.toml");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!(
|
||||||
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'fixture-server'\napplication_id = 'fixture-app'\n",
|
||||||
|
fixtures.join("stores/basic"),
|
||||||
|
FINGERPRINT,
|
||||||
|
fixtures.join("keys"),
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let config = Config::load(Some(&config_path))?;
|
||||||
|
let mut secrets = SecretStore::new(
|
||||||
|
MemoryBackend::default(),
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
);
|
||||||
|
secrets.unlock()?;
|
||||||
|
secrets.create(
|
||||||
|
&SecretReference::openpgp_passphrase(FINGERPRINT)?,
|
||||||
|
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
||||||
|
)?;
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
execute_secure(
|
||||||
|
&config,
|
||||||
|
&CommandRequest::Show(ShowRequest {
|
||||||
|
entry: Some("email/personal".to_owned()),
|
||||||
|
presentation: Presentation::Terminal,
|
||||||
|
}),
|
||||||
|
&mut secrets,
|
||||||
|
&mut stdout,
|
||||||
|
&mut stderr,
|
||||||
|
)
|
||||||
|
.expect("memory output cannot fail"),
|
||||||
|
EXIT_SUCCESS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stdout,
|
||||||
|
fs::read(fixtures.join("expected/basic/email/personal.txt"))?
|
||||||
|
);
|
||||||
|
assert!(stderr.is_empty());
|
||||||
|
|
||||||
|
let remote = &config.git_remotes()[0];
|
||||||
|
secrets.create(
|
||||||
|
&SecretReference::https_git_credential(
|
||||||
|
remote.server_id().as_str(),
|
||||||
|
remote.application_id().as_str(),
|
||||||
|
"fixture-account",
|
||||||
|
)?,
|
||||||
|
SecretBytes::new(b"fixture-token".to_vec()),
|
||||||
|
)?;
|
||||||
|
let credential = secrets.credential(remote.server_id(), remote.application_id())?;
|
||||||
|
assert_eq!(credential.username(), "fixture-account");
|
||||||
|
assert_eq!(credential.password(), b"fixture-token");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ clap.workspace = true
|
|||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
gix.workspace = true
|
gix.workspace = true
|
||||||
gix-config.workspace = true
|
gix-config.workspace = true
|
||||||
|
keyring-core.workspace = true
|
||||||
pgp.workspace = true
|
pgp.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
@@ -24,6 +25,17 @@ toml.workspace = true
|
|||||||
url.workspace = true
|
url.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||||
|
apple-native-keyring-store.workspace = true
|
||||||
|
security-framework.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-native-keyring-store.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
secret-service.workspace = true
|
||||||
|
zbus-secret-service-keyring-store.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use gix::{
|
|||||||
objs::tree::EntryKind,
|
objs::tree::EntryKind,
|
||||||
};
|
};
|
||||||
use sha1::{Digest as _, Sha1};
|
use sha1::{Digest as _, Sha1};
|
||||||
|
use zeroize::Zeroize as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ApplicationId, GitRemote, ServerId},
|
config::{ApplicationId, GitRemote, ServerId},
|
||||||
@@ -184,6 +185,8 @@ pub enum GitError {
|
|||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
CredentialsUnavailable,
|
CredentialsUnavailable,
|
||||||
|
CredentialAccessDenied,
|
||||||
|
CredentialCancelled,
|
||||||
AuthenticationFailed,
|
AuthenticationFailed,
|
||||||
NonFastForward,
|
NonFastForward,
|
||||||
MergeConflicts {
|
MergeConflicts {
|
||||||
@@ -223,6 +226,12 @@ impl fmt::Display for GitError {
|
|||||||
Self::CredentialsUnavailable => {
|
Self::CredentialsUnavailable => {
|
||||||
formatter.write_str("HTTPS Git credentials are unavailable")
|
formatter.write_str("HTTPS Git credentials are unavailable")
|
||||||
}
|
}
|
||||||
|
Self::CredentialAccessDenied => {
|
||||||
|
formatter.write_str("access to HTTPS Git credentials was denied")
|
||||||
|
}
|
||||||
|
Self::CredentialCancelled => {
|
||||||
|
formatter.write_str("HTTPS Git credential authentication was cancelled")
|
||||||
|
}
|
||||||
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
|
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
|
||||||
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
|
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
|
||||||
Self::MergeConflicts { paths } => write!(
|
Self::MergeConflicts { paths } => write!(
|
||||||
@@ -254,7 +263,7 @@ pub struct GitCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GitCredential {
|
impl GitCredential {
|
||||||
pub fn new(username: impl Into<String>, password: Vec<u8>) -> Result<Self, GitError> {
|
pub fn new(username: impl Into<String>, mut password: Vec<u8>) -> Result<Self, GitError> {
|
||||||
let username = username.into();
|
let username = username.into();
|
||||||
if username.is_empty()
|
if username.is_empty()
|
||||||
|| username.contains(['\n', '\r', '\0'])
|
|| username.contains(['\n', '\r', '\0'])
|
||||||
@@ -262,6 +271,7 @@ impl GitCredential {
|
|||||||
|| password.contains(&b'\r')
|
|| password.contains(&b'\r')
|
||||||
|| password.contains(&0)
|
|| password.contains(&0)
|
||||||
{
|
{
|
||||||
|
password.zeroize();
|
||||||
return Err(GitError::CredentialsUnavailable);
|
return Err(GitError::CredentialsUnavailable);
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod mutation;
|
|||||||
pub mod read;
|
pub mod read;
|
||||||
pub mod recipient;
|
pub mod recipient;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub mod secret_store;
|
||||||
pub mod write;
|
pub mod write;
|
||||||
|
|
||||||
/// Product name shared by the presentation adapters.
|
/// Product name shared by the presentation adapters.
|
||||||
|
|||||||
775
crates/storage/src/secret_store.rs
Normal file
775
crates/storage/src/secret_store.rs
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
//! OS-backed secret storage, opaque references, and bounded in-memory caching.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
num::NonZeroUsize,
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{ApplicationId, ServerId},
|
||||||
|
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||||
|
git::{GitCredential, GitCredentialProvider, GitError},
|
||||||
|
repository::SecretBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod platform;
|
||||||
|
|
||||||
|
const RECORD_MAGIC: &[u8] = b"IRONSTORAGE-SECRET\0";
|
||||||
|
const RECORD_VERSION: u8 = 1;
|
||||||
|
const MAX_SECRET_BYTES: usize = 1024;
|
||||||
|
const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
|
||||||
|
const MAX_CACHE_CAPACITY: usize = 128;
|
||||||
|
|
||||||
|
/// The purpose and stable, non-secret identity of an OS credential.
|
||||||
|
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct SecretReference {
|
||||||
|
kind: SecretReferenceKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
enum SecretReferenceKind {
|
||||||
|
OpenPgpPassphrase {
|
||||||
|
fingerprint: String,
|
||||||
|
},
|
||||||
|
HttpsGitCredential {
|
||||||
|
server_id: String,
|
||||||
|
application_id: String,
|
||||||
|
account: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretReference {
|
||||||
|
pub fn openpgp_passphrase(fingerprint: impl Into<String>) -> Result<Self, SecretStoreError> {
|
||||||
|
let fingerprint = fingerprint.into();
|
||||||
|
if !matches!(fingerprint.len(), 40 | 64)
|
||||||
|
|| !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
kind: SecretReferenceKind::OpenPgpPassphrase {
|
||||||
|
fingerprint: fingerprint.to_ascii_uppercase(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn https_git_credential(
|
||||||
|
server_id: impl Into<String>,
|
||||||
|
application_id: impl Into<String>,
|
||||||
|
account: impl Into<String>,
|
||||||
|
) -> Result<Self, SecretStoreError> {
|
||||||
|
let server_id = server_id.into();
|
||||||
|
let application_id = application_id.into();
|
||||||
|
let account = account.into();
|
||||||
|
validate_identifier(&server_id)?;
|
||||||
|
validate_identifier(&application_id)?;
|
||||||
|
if account.is_empty()
|
||||||
|
|| account.len() > 512
|
||||||
|
|| account.trim() != account
|
||||||
|
|| account.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
kind: SecretReferenceKind::HttpsGitCredential {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
account,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account(&self) -> Option<&str> {
|
||||||
|
match &self.kind {
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
||||||
|
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locator(&self) -> SecretLocator {
|
||||||
|
match &self.kind {
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { fingerprint } => {
|
||||||
|
SecretLocator::OpenPgpPassphrase {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecretReferenceKind::HttpsGitCredential {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
..
|
||||||
|
} => SecretLocator::HttpsGitCredential {
|
||||||
|
server_id: server_id.clone(),
|
||||||
|
application_id: application_id.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretReference {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self {
|
||||||
|
kind: SecretReferenceKind::OpenPgpPassphrase { .. },
|
||||||
|
} => formatter.write_str("SecretReference::OpenPgpPassphrase([REDACTED])"),
|
||||||
|
Self {
|
||||||
|
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
||||||
|
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backend key. Git accounts live inside the protected record so a configured
|
||||||
|
/// server/application pair can retrieve its account without TOML metadata.
|
||||||
|
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub enum SecretLocator {
|
||||||
|
OpenPgpPassphrase {
|
||||||
|
fingerprint: String,
|
||||||
|
},
|
||||||
|
HttpsGitCredential {
|
||||||
|
server_id: String,
|
||||||
|
application_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretLocator {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::OpenPgpPassphrase { .. } => {
|
||||||
|
formatter.write_str("SecretLocator::OpenPgpPassphrase([REDACTED])")
|
||||||
|
}
|
||||||
|
Self::HttpsGitCredential { .. } => {
|
||||||
|
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretLocator {
|
||||||
|
fn service_and_user(&self) -> (&'static str, String) {
|
||||||
|
match self {
|
||||||
|
Self::OpenPgpPassphrase { fingerprint } => {
|
||||||
|
("org.ironstorage.openpgp-passphrase", fingerprint.clone())
|
||||||
|
}
|
||||||
|
Self::HttpsGitCredential {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
} => (
|
||||||
|
"org.ironstorage.https-git",
|
||||||
|
format!("{server_id}/{application_id}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SecretProtection {
|
||||||
|
DeviceUnlocked,
|
||||||
|
RequireUserPresence,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SecretProtectionPolicy {
|
||||||
|
openpgp: SecretProtection,
|
||||||
|
git: SecretProtection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretProtectionPolicy {
|
||||||
|
pub const fn new(openpgp: SecretProtection, git: SecretProtection) -> Self {
|
||||||
|
Self { openpgp, git }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn device_unlocked() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SecretProtection::DeviceUnlocked,
|
||||||
|
SecretProtection::DeviceUnlocked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn user_presence_for_openpgp() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SecretProtection::RequireUserPresence,
|
||||||
|
SecretProtection::DeviceUnlocked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_reference(self, reference: &SecretReference) -> SecretProtection {
|
||||||
|
match &reference.kind {
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||||
|
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_locator(self, locator: &SecretLocator) -> SecretProtection {
|
||||||
|
match locator {
|
||||||
|
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
||||||
|
SecretLocator::HttpsGitCredential { .. } => self.git,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SecretProtectionPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::device_unlocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SecretCachePolicy {
|
||||||
|
Disabled,
|
||||||
|
Timed {
|
||||||
|
lifetime: Duration,
|
||||||
|
capacity: NonZeroUsize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretCachePolicy {
|
||||||
|
pub fn timed(lifetime: Duration, capacity: NonZeroUsize) -> Result<Self, SecretStoreError> {
|
||||||
|
if lifetime.is_zero()
|
||||||
|
|| lifetime > MAX_CACHE_LIFETIME
|
||||||
|
|| capacity.get() > MAX_CACHE_CAPACITY
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::InvalidCachePolicy);
|
||||||
|
}
|
||||||
|
Ok(Self::Timed { lifetime, capacity })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SecretStoreError {
|
||||||
|
InvalidReference,
|
||||||
|
InvalidCachePolicy,
|
||||||
|
AlreadyExists,
|
||||||
|
Missing,
|
||||||
|
Locked,
|
||||||
|
Denied,
|
||||||
|
Cancelled,
|
||||||
|
Corrupted,
|
||||||
|
UnsupportedProtection,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SecretStoreError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::InvalidReference => "the secret reference is invalid",
|
||||||
|
Self::InvalidCachePolicy => "the secret cache policy is invalid",
|
||||||
|
Self::AlreadyExists => "the referenced secret already exists",
|
||||||
|
Self::Missing => "the referenced secret does not exist",
|
||||||
|
Self::Locked => "the secret store is locked",
|
||||||
|
Self::Denied => "access to the secret store was denied",
|
||||||
|
Self::Cancelled => "secret-store authentication was cancelled",
|
||||||
|
Self::Corrupted => "the stored secret record is corrupted",
|
||||||
|
Self::UnsupportedProtection => {
|
||||||
|
"the requested secret protection is unsupported on this platform"
|
||||||
|
}
|
||||||
|
Self::Unavailable => "the operating-system secret store is unavailable",
|
||||||
|
};
|
||||||
|
formatter.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for SecretStoreError {}
|
||||||
|
|
||||||
|
/// Mockable contract implemented by each operating-system adapter.
|
||||||
|
pub trait SecretStoreBackend: Send + Sync {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError>;
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError>;
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError>;
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError>;
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachedSecret {
|
||||||
|
value: SecretBytes,
|
||||||
|
expires_at: Instant,
|
||||||
|
sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreState {
|
||||||
|
unlocked: bool,
|
||||||
|
sequence: u64,
|
||||||
|
cache: BTreeMap<SecretLocator, CachedSecret>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SecretStore<B> {
|
||||||
|
backend: B,
|
||||||
|
cache_policy: SecretCachePolicy,
|
||||||
|
protections: SecretProtectionPolicy,
|
||||||
|
state: Mutex<StoreState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> fmt::Debug for SecretStore<B> {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("SecretStore")
|
||||||
|
.field("cache_policy", &self.cache_policy)
|
||||||
|
.field("protections", &self.protections)
|
||||||
|
.field("contents", &"[REDACTED]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: SecretStoreBackend> SecretStore<B> {
|
||||||
|
pub fn new(
|
||||||
|
backend: B,
|
||||||
|
cache_policy: SecretCachePolicy,
|
||||||
|
protections: SecretProtectionPolicy,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
backend,
|
||||||
|
cache_policy,
|
||||||
|
protections,
|
||||||
|
state: Mutex::new(StoreState {
|
||||||
|
unlocked: false,
|
||||||
|
sequence: 0,
|
||||||
|
cache: BTreeMap::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_locked(&self) -> bool {
|
||||||
|
self.state.lock().map_or(true, |state| !state.unlocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
self.backend.unlock()?;
|
||||||
|
state.unlocked = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
state.unlocked = false;
|
||||||
|
state.cache.clear();
|
||||||
|
self.backend.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: SecretBytes,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
validate_secret(&value)?;
|
||||||
|
let locator = reference.locator();
|
||||||
|
let encoded = encode_record(reference, &value)?;
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
self.backend.create(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(reference),
|
||||||
|
encoded.expose(),
|
||||||
|
)?;
|
||||||
|
self.cache_insert(&mut state, locator, encoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve(&self, reference: &SecretReference) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
let locator = reference.locator();
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
let encoded = match self.cache_get(&mut state, &locator) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => self
|
||||||
|
.backend
|
||||||
|
.retrieve(&locator, self.protections.for_reference(reference))?,
|
||||||
|
};
|
||||||
|
let record = decode_record(encoded)?;
|
||||||
|
if &record.reference != reference {
|
||||||
|
return Err(SecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
let output = copy_secret(&record.value);
|
||||||
|
self.cache_insert(
|
||||||
|
&mut state,
|
||||||
|
locator,
|
||||||
|
encode_record(&record.reference, &record.value)?,
|
||||||
|
);
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: SecretBytes,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
validate_secret(&value)?;
|
||||||
|
let locator = reference.locator();
|
||||||
|
let encoded = encode_record(reference, &value)?;
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
self.require_exact_record(reference, &locator)?;
|
||||||
|
self.backend.replace(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(reference),
|
||||||
|
encoded.expose(),
|
||||||
|
)?;
|
||||||
|
self.cache_insert(&mut state, locator, encoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||||
|
let locator = reference.locator();
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
self.require_exact_record(reference, &locator)?;
|
||||||
|
self.backend
|
||||||
|
.delete(&locator, self.protections.for_reference(reference))?;
|
||||||
|
state.cache.remove(&locator);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve_git_record(
|
||||||
|
&self,
|
||||||
|
server: &ServerId,
|
||||||
|
application: &ApplicationId,
|
||||||
|
) -> Result<SecretRecord, SecretStoreError> {
|
||||||
|
let locator = SecretLocator::HttpsGitCredential {
|
||||||
|
server_id: server.as_str().to_owned(),
|
||||||
|
application_id: application.as_str().to_owned(),
|
||||||
|
};
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
if let Some(value) = self.cache_get(&mut state, &locator) {
|
||||||
|
return decode_record(value);
|
||||||
|
}
|
||||||
|
let encoded = self
|
||||||
|
.backend
|
||||||
|
.retrieve(&locator, self.protections.for_locator(&locator))?;
|
||||||
|
let record = decode_record(encoded)?;
|
||||||
|
if record.reference.locator() != locator {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
self.cache_insert(
|
||||||
|
&mut state,
|
||||||
|
locator,
|
||||||
|
encode_record(&record.reference, &record.value)?,
|
||||||
|
);
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_exact_record(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let encoded = self
|
||||||
|
.backend
|
||||||
|
.retrieve(locator, self.protections.for_reference(reference))?;
|
||||||
|
let record = decode_record(encoded)?;
|
||||||
|
if &record.reference != reference {
|
||||||
|
return Err(SecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlocked_state(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, SecretStoreError> {
|
||||||
|
let state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
if !state.unlocked {
|
||||||
|
return Err(SecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_get(&self, state: &mut StoreState, locator: &SecretLocator) -> Option<SecretBytes> {
|
||||||
|
let SecretCachePolicy::Timed { .. } = self.cache_policy else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
state.cache.retain(|_, entry| entry.expires_at > now);
|
||||||
|
state
|
||||||
|
.cache
|
||||||
|
.get(locator)
|
||||||
|
.map(|entry| copy_secret(&entry.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_insert(&self, state: &mut StoreState, locator: SecretLocator, value: SecretBytes) {
|
||||||
|
let SecretCachePolicy::Timed { lifetime, capacity } = self.cache_policy else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
state.cache.retain(|_, entry| entry.expires_at > now);
|
||||||
|
state.sequence = state.sequence.wrapping_add(1);
|
||||||
|
let sequence = state.sequence;
|
||||||
|
state.cache.insert(
|
||||||
|
locator,
|
||||||
|
CachedSecret {
|
||||||
|
value,
|
||||||
|
expires_at: now + lifetime,
|
||||||
|
sequence,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
while state.cache.len() > capacity.get() {
|
||||||
|
let oldest = state
|
||||||
|
.cache
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, entry)| entry.sequence)
|
||||||
|
.map(|(locator, _)| locator.clone());
|
||||||
|
if let Some(oldest) = oldest {
|
||||||
|
state.cache.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type NativeSecretStore = SecretStore<platform::NativeSecretBackend>;
|
||||||
|
|
||||||
|
impl NativeSecretStore {
|
||||||
|
pub fn system(
|
||||||
|
cache_policy: SecretCachePolicy,
|
||||||
|
protections: SecretProtectionPolicy,
|
||||||
|
) -> Result<Self, SecretStoreError> {
|
||||||
|
Ok(Self::new(
|
||||||
|
platform::NativeSecretBackend::new()?,
|
||||||
|
cache_policy,
|
||||||
|
protections,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: SecretStoreBackend> SecretProvider for SecretStore<B> {
|
||||||
|
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||||
|
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())
|
||||||
|
.map_err(|_| SecretProviderError::Unavailable)?;
|
||||||
|
self.retrieve(&reference).map_err(provider_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
||||||
|
fn credential(
|
||||||
|
&self,
|
||||||
|
server: &ServerId,
|
||||||
|
application: &ApplicationId,
|
||||||
|
) -> Result<GitCredential, GitError> {
|
||||||
|
let record = self
|
||||||
|
.retrieve_git_record(server, application)
|
||||||
|
.map_err(git_provider_error)?;
|
||||||
|
let Some(account) = record.reference.account() else {
|
||||||
|
return Err(GitError::CredentialsUnavailable);
|
||||||
|
};
|
||||||
|
if record.value.expose().contains(&b'\n')
|
||||||
|
|| record.value.expose().contains(&b'\r')
|
||||||
|
|| record.value.expose().contains(&0)
|
||||||
|
{
|
||||||
|
return Err(GitError::CredentialsUnavailable);
|
||||||
|
}
|
||||||
|
GitCredential::new(account, record.value.expose().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_error(error: SecretStoreError) -> SecretProviderError {
|
||||||
|
match error {
|
||||||
|
SecretStoreError::Cancelled => SecretProviderError::Cancelled,
|
||||||
|
_ => SecretProviderError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_provider_error(error: SecretStoreError) -> GitError {
|
||||||
|
match error {
|
||||||
|
SecretStoreError::Cancelled => GitError::CredentialCancelled,
|
||||||
|
SecretStoreError::Denied => GitError::CredentialAccessDenied,
|
||||||
|
_ => GitError::CredentialsUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SecretRecord {
|
||||||
|
reference: SecretReference,
|
||||||
|
value: SecretBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_record(
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: &SecretBytes,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
validate_secret(value)?;
|
||||||
|
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
|
||||||
|
encoded.extend_from_slice(RECORD_MAGIC);
|
||||||
|
encoded.push(RECORD_VERSION);
|
||||||
|
match &reference.kind {
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { fingerprint } => {
|
||||||
|
encoded.push(1);
|
||||||
|
write_field(&mut encoded, fingerprint.as_bytes())?;
|
||||||
|
}
|
||||||
|
SecretReferenceKind::HttpsGitCredential {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
account,
|
||||||
|
} => {
|
||||||
|
encoded.push(2);
|
||||||
|
write_field(&mut encoded, server_id.as_bytes())?;
|
||||||
|
write_field(&mut encoded, application_id.as_bytes())?;
|
||||||
|
write_field(&mut encoded, account.as_bytes())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
||||||
|
encoded.extend_from_slice(&length.to_be_bytes());
|
||||||
|
encoded.extend_from_slice(value.expose());
|
||||||
|
Ok(SecretBytes::new(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError> {
|
||||||
|
let bytes = encoded.expose();
|
||||||
|
let Some(mut remainder) = bytes.strip_prefix(RECORD_MAGIC) else {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
};
|
||||||
|
let Some((&version, rest)) = remainder.split_first() else {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
};
|
||||||
|
if version != RECORD_VERSION {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let Some((&kind, rest)) = rest.split_first() else {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
};
|
||||||
|
remainder = rest;
|
||||||
|
let reference = match kind {
|
||||||
|
1 => {
|
||||||
|
let (fingerprint, rest) = read_field(remainder)?;
|
||||||
|
remainder = rest;
|
||||||
|
SecretReference::openpgp_passphrase(read_text(fingerprint)?)
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)?
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let (server_id, rest) = read_field(remainder)?;
|
||||||
|
let (application_id, rest) = read_field(rest)?;
|
||||||
|
let (account, rest) = read_field(rest)?;
|
||||||
|
remainder = rest;
|
||||||
|
SecretReference::https_git_credential(
|
||||||
|
read_text(server_id)?,
|
||||||
|
read_text(application_id)?,
|
||||||
|
read_text(account)?,
|
||||||
|
)
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)?
|
||||||
|
}
|
||||||
|
_ => return Err(SecretStoreError::Corrupted),
|
||||||
|
};
|
||||||
|
if remainder.len() < 4 {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let length = u32::from_be_bytes(
|
||||||
|
remainder[..4]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||||
|
) as usize;
|
||||||
|
let value = &remainder[4..];
|
||||||
|
if length == 0 || length != value.len() || length > MAX_SECRET_BYTES {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(SecretRecord {
|
||||||
|
reference,
|
||||||
|
value: SecretBytes::new(value.to_vec()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_field(output: &mut Vec<u8>, field: &[u8]) -> Result<(), SecretStoreError> {
|
||||||
|
let length = u16::try_from(field.len()).map_err(|_| SecretStoreError::InvalidReference)?;
|
||||||
|
output.extend_from_slice(&length.to_be_bytes());
|
||||||
|
output.extend_from_slice(field);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_field(input: &[u8]) -> Result<(&[u8], &[u8]), SecretStoreError> {
|
||||||
|
if input.len() < 2 {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let length = u16::from_be_bytes(
|
||||||
|
input[..2]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||||
|
) as usize;
|
||||||
|
if input.len() < 2 + length {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok((&input[2..2 + length], &input[2 + length..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(input: &[u8]) -> Result<String, SecretStoreError> {
|
||||||
|
std::str::from_utf8(input)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier(value: &str) -> Result<(), SecretStoreError> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > 128
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_secret(value: &SecretBytes) -> Result<(), SecretStoreError> {
|
||||||
|
if value.expose().is_empty() || value.expose().len() > MAX_SECRET_BYTES {
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_secret(value: &SecretBytes) -> SecretBytes {
|
||||||
|
SecretBytes::new(value.expose().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RECORD_MAGIC, RECORD_VERSION, SecretBytes, SecretStoreError, decode_record};
|
||||||
|
|
||||||
|
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
||||||
|
let mut record = Vec::new();
|
||||||
|
record.extend_from_slice(RECORD_MAGIC);
|
||||||
|
record.push(RECORD_VERSION);
|
||||||
|
record.push(1);
|
||||||
|
record.extend_from_slice(&(fingerprint.len() as u16).to_be_bytes());
|
||||||
|
record.extend_from_slice(fingerprint);
|
||||||
|
record.extend_from_slice(&(secret.len() as u32).to_be_bytes());
|
||||||
|
record.extend_from_slice(secret);
|
||||||
|
SecretBytes::new(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_classifies_invalid_stored_fields_as_corruption() {
|
||||||
|
assert!(matches!(
|
||||||
|
decode_record(openpgp_record(b"not-a-fingerprint", b"secret")),
|
||||||
|
Err(SecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_record(openpgp_record(
|
||||||
|
b"0123456789ABCDEF0123456789ABCDEF01234567",
|
||||||
|
b""
|
||||||
|
)),
|
||||||
|
Err(SecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
236
crates/storage/src/secret_store/platform.rs
Normal file
236
crates/storage/src/secret_store/platform.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//! Safe adapters around native credential-store crates.
|
||||||
|
//!
|
||||||
|
//! This is the only module that selects operating-system implementations.
|
||||||
|
//! IronStorage performs no direct FFI and contains no unsafe code: the Apple,
|
||||||
|
//! Windows, and Linux crates own their respective Security Framework,
|
||||||
|
//! Credential Manager, and Secret Service boundaries. Operations are serialized
|
||||||
|
//! by the parent `SecretStore`, as required by the Windows adapter contract.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use keyring_core::{CredentialStore, Entry};
|
||||||
|
|
||||||
|
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
|
||||||
|
use crate::repository::SecretBytes;
|
||||||
|
|
||||||
|
pub struct NativeSecretBackend {
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
store: Arc<CredentialStore>,
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
protected: Arc<CredentialStore>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
keychain: Arc<CredentialStore>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
protected: Arc<CredentialStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeSecretBackend {
|
||||||
|
pub fn new() -> Result<Self, SecretStoreError> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let store: Arc<CredentialStore> =
|
||||||
|
zbus_secret_service_keyring_store::Store::new().map_err(map_error)?;
|
||||||
|
Ok(Self { store })
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let store: Arc<CredentialStore> =
|
||||||
|
windows_native_keyring_store::Store::new().map_err(map_error)?;
|
||||||
|
Ok(Self { store })
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
let protected: Arc<CredentialStore> =
|
||||||
|
apple_native_keyring_store::protected::Store::new().map_err(map_error)?;
|
||||||
|
Ok(Self { protected })
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let keychain: Arc<CredentialStore> =
|
||||||
|
apple_native_keyring_store::keychain::Store::new().map_err(map_error)?;
|
||||||
|
let protected: Arc<CredentialStore> =
|
||||||
|
apple_native_keyring_store::protected::Store::new().map_err(map_error)?;
|
||||||
|
Ok(Self {
|
||||||
|
keychain,
|
||||||
|
protected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "ios",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows"
|
||||||
|
)))]
|
||||||
|
{
|
||||||
|
Err(SecretStoreError::Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<Entry, SecretStoreError> {
|
||||||
|
let (service, user) = locator.service_and_user();
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
{
|
||||||
|
if protection == SecretProtection::RequireUserPresence {
|
||||||
|
return Err(SecretStoreError::UnsupportedProtection);
|
||||||
|
}
|
||||||
|
self.store.build(service, &user, None).map_err(map_error)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
let modifiers = presence_modifiers(protection);
|
||||||
|
self.protected
|
||||||
|
.build(service, &user, modifiers.as_ref())
|
||||||
|
.map_err(map_error)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match protection {
|
||||||
|
SecretProtection::DeviceUnlocked => {
|
||||||
|
self.keychain.build(service, &user, None).map_err(map_error)
|
||||||
|
}
|
||||||
|
SecretProtection::RequireUserPresence => {
|
||||||
|
let modifiers = presence_modifiers(protection);
|
||||||
|
self.protected
|
||||||
|
.build(service, &user, modifiers.as_ref())
|
||||||
|
.map_err(map_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "ios",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows"
|
||||||
|
)))]
|
||||||
|
{
|
||||||
|
let _ = (service, user, protection);
|
||||||
|
Err(SecretStoreError::Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStoreBackend for NativeSecretBackend {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let entry = self.entry(locator, protection)?;
|
||||||
|
match entry.get_secret() {
|
||||||
|
Ok(existing) => {
|
||||||
|
drop(SecretBytes::new(existing));
|
||||||
|
Err(SecretStoreError::AlreadyExists)
|
||||||
|
}
|
||||||
|
Err(keyring_core::Error::NoEntry) => entry.set_secret(value).map_err(map_error),
|
||||||
|
Err(error) => Err(map_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
self.entry(locator, protection)?
|
||||||
|
.get_secret()
|
||||||
|
.map(SecretBytes::new)
|
||||||
|
.map_err(map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let entry = self.entry(locator, protection)?;
|
||||||
|
let existing = entry.get_secret().map_err(map_error)?;
|
||||||
|
drop(SecretBytes::new(existing));
|
||||||
|
entry.set_secret(value).map_err(map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.entry(locator, protection)?
|
||||||
|
.delete_credential()
|
||||||
|
.map_err(map_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn presence_modifiers(protection: SecretProtection) -> Option<HashMap<&'static str, &'static str>> {
|
||||||
|
match protection {
|
||||||
|
SecretProtection::DeviceUnlocked => None,
|
||||||
|
SecretProtection::RequireUserPresence => {
|
||||||
|
Some(HashMap::from([("access-policy", "require-user-presence")]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_error(error: keyring_core::Error) -> SecretStoreError {
|
||||||
|
match error {
|
||||||
|
keyring_core::Error::NoEntry => SecretStoreError::Missing,
|
||||||
|
keyring_core::Error::NoStorageAccess(error) => platform_access_error(&error),
|
||||||
|
keyring_core::Error::BadEncoding(bytes) => {
|
||||||
|
drop(SecretBytes::new(bytes));
|
||||||
|
SecretStoreError::Corrupted
|
||||||
|
}
|
||||||
|
keyring_core::Error::BadDataFormat(bytes, _) => {
|
||||||
|
drop(SecretBytes::new(bytes));
|
||||||
|
SecretStoreError::Corrupted
|
||||||
|
}
|
||||||
|
keyring_core::Error::BadStoreFormat(_) | keyring_core::Error::Ambiguous(_) => {
|
||||||
|
SecretStoreError::Corrupted
|
||||||
|
}
|
||||||
|
keyring_core::Error::TooLong(_, _) | keyring_core::Error::Invalid(_, _) => {
|
||||||
|
SecretStoreError::InvalidReference
|
||||||
|
}
|
||||||
|
keyring_core::Error::NotSupportedByStore(_) | keyring_core::Error::NoDefaultStore => {
|
||||||
|
SecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
keyring_core::Error::PlatformFailure(error) => {
|
||||||
|
if platform_cancelled(&error) {
|
||||||
|
SecretStoreError::Cancelled
|
||||||
|
} else {
|
||||||
|
SecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => SecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn platform_cancelled(error: &keyring_core::error::PlatformError) -> bool {
|
||||||
|
error
|
||||||
|
.downcast_ref::<security_framework::base::Error>()
|
||||||
|
.is_some_and(|error| error.code() == -128)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
|
fn platform_cancelled(_error: &keyring_core::error::PlatformError) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn platform_access_error(error: &keyring_core::error::PlatformError) -> SecretStoreError {
|
||||||
|
match error.downcast_ref::<secret_service::Error>() {
|
||||||
|
Some(secret_service::Error::Prompt) => SecretStoreError::Cancelled,
|
||||||
|
_ => SecretStoreError::Denied,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn platform_access_error(_error: &keyring_core::error::PlatformError) -> SecretStoreError {
|
||||||
|
SecretStoreError::Denied
|
||||||
|
}
|
||||||
351
crates/storage/tests/secret_store.rs
Normal file
351
crates/storage/tests/secret_store.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
error::Error,
|
||||||
|
num::NonZeroUsize,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ironstorage::{
|
||||||
|
config::ConfigLoader,
|
||||||
|
crypto::KeyStore,
|
||||||
|
git::{GitCredentialProvider as _, GitError},
|
||||||
|
repository::{EncryptedEntry, SecretBytes},
|
||||||
|
secret_store::{
|
||||||
|
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||||
|
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use support::compatibility::FixtureSet;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MemoryState {
|
||||||
|
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||||
|
fault: Option<SecretStoreError>,
|
||||||
|
retrieves: usize,
|
||||||
|
protections: Vec<SecretProtection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct MemoryBackend(Arc<Mutex<MemoryState>>);
|
||||||
|
|
||||||
|
impl MemoryBackend {
|
||||||
|
fn fail_next(&self, error: SecretStoreError) {
|
||||||
|
self.0.lock().expect("test mutex").fault = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt_first(&self) {
|
||||||
|
let mut state = self.0.lock().expect("test mutex");
|
||||||
|
let value = state.values.values_mut().next().expect("stored test value");
|
||||||
|
*value = SecretBytes::new(b"not an IronStorage record".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieves(&self) -> usize {
|
||||||
|
self.0.lock().expect("test mutex").retrieves
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protections(&self) -> Vec<SecretProtection> {
|
||||||
|
self.0.lock().expect("test mutex").protections.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
|
||||||
|
match state.fault.take() {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStoreBackend for MemoryBackend {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)?;
|
||||||
|
state.protections.push(protection);
|
||||||
|
if state.values.contains_key(locator) {
|
||||||
|
return Err(SecretStoreError::AlreadyExists);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.values
|
||||||
|
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)?;
|
||||||
|
state.retrieves += 1;
|
||||||
|
state.protections.push(protection);
|
||||||
|
state
|
||||||
|
.values
|
||||||
|
.get(locator)
|
||||||
|
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||||
|
.ok_or(SecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)?;
|
||||||
|
state.protections.push(protection);
|
||||||
|
let existing = state
|
||||||
|
.values
|
||||||
|
.get_mut(locator)
|
||||||
|
.ok_or(SecretStoreError::Missing)?;
|
||||||
|
*existing = SecretBytes::new(value.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)?;
|
||||||
|
state.protections.push(protection);
|
||||||
|
state
|
||||||
|
.values
|
||||||
|
.remove(locator)
|
||||||
|
.map(drop)
|
||||||
|
.ok_or(SecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
|
Self::take_fault(&mut state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(backend: MemoryBackend) -> SecretStore<MemoryBackend> {
|
||||||
|
SecretStore::new(
|
||||||
|
backend,
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint_reference() -> SecretReference {
|
||||||
|
SecretReference::openpgp_passphrase("0123456789ABCDEF0123456789ABCDEF01234567")
|
||||||
|
.expect("valid fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_is_explicit_and_create_never_silently_replaces() -> TestResult {
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let store = store(backend);
|
||||||
|
let reference = fingerprint_reference();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Locked)
|
||||||
|
));
|
||||||
|
store.unlock()?;
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
store.create(&reference, SecretBytes::new(b"first".to_vec()))?;
|
||||||
|
assert_eq!(store.retrieve(&reference)?.expose(), b"first");
|
||||||
|
assert_eq!(
|
||||||
|
store.create(&reference, SecretBytes::new(b"other".to_vec())),
|
||||||
|
Err(SecretStoreError::AlreadyExists)
|
||||||
|
);
|
||||||
|
store.replace(&reference, SecretBytes::new(b"second".to_vec()))?;
|
||||||
|
assert_eq!(store.retrieve(&reference)?.expose(), b"second");
|
||||||
|
store.delete(&reference)?;
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert_eq!(store.delete(&reference), Err(SecretStoreError::Missing));
|
||||||
|
store.lock()?;
|
||||||
|
assert!(store.is_locked());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestResult {
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
backend.fail_next(SecretStoreError::Cancelled);
|
||||||
|
let store = store(backend.clone());
|
||||||
|
assert_eq!(store.unlock(), Err(SecretStoreError::Cancelled));
|
||||||
|
assert!(store.is_locked());
|
||||||
|
store.unlock()?;
|
||||||
|
|
||||||
|
let reference = SecretReference::https_git_credential(
|
||||||
|
"fixture-server",
|
||||||
|
"fixture-app",
|
||||||
|
"secret-account-name",
|
||||||
|
)?;
|
||||||
|
backend.fail_next(SecretStoreError::Denied);
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Denied)
|
||||||
|
));
|
||||||
|
backend.fail_next(SecretStoreError::Unavailable);
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Unavailable)
|
||||||
|
));
|
||||||
|
store.create(&reference, SecretBytes::new(b"token".to_vec()))?;
|
||||||
|
backend.corrupt_first();
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&reference),
|
||||||
|
Err(SecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
|
||||||
|
let rendered = format!("{reference:?} {store:?} {}", SecretStoreError::Corrupted);
|
||||||
|
assert!(!rendered.contains("secret-account-name"));
|
||||||
|
assert!(!rendered.contains("not an IronStorage record"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let policy = SecretCachePolicy::timed(
|
||||||
|
Duration::from_secs(60),
|
||||||
|
NonZeroUsize::new(1).expect("non-zero"),
|
||||||
|
)?;
|
||||||
|
let store = SecretStore::new(
|
||||||
|
backend.clone(),
|
||||||
|
policy,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
);
|
||||||
|
store.unlock()?;
|
||||||
|
let alice = SecretReference::https_git_credential("server", "application", "alice")?;
|
||||||
|
store.create(&alice, SecretBytes::new(b"token".to_vec()))?;
|
||||||
|
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||||
|
assert_eq!(
|
||||||
|
backend.retrieves(),
|
||||||
|
0,
|
||||||
|
"create populated the explicit cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bob = SecretReference::https_git_credential("server", "application", "bob")?;
|
||||||
|
assert!(matches!(
|
||||||
|
store.retrieve(&bob),
|
||||||
|
Err(SecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
store.replace(&bob, SecretBytes::new(b"other".to_vec())),
|
||||||
|
Err(SecretStoreError::Missing)
|
||||||
|
);
|
||||||
|
assert_eq!(store.delete(&bob), Err(SecretStoreError::Missing));
|
||||||
|
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||||
|
let passphrase = fingerprint_reference();
|
||||||
|
store.create(&passphrase, SecretBytes::new(b"passphrase".to_vec()))?;
|
||||||
|
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||||
|
assert_eq!(backend.retrieves(), 3, "capacity evicted the older record");
|
||||||
|
store.lock()?;
|
||||||
|
store.unlock()?;
|
||||||
|
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||||
|
assert_eq!(backend.retrieves(), 4, "lock discarded cached bytes");
|
||||||
|
assert!(
|
||||||
|
SecretCachePolicy::timed(
|
||||||
|
Duration::from_secs(16 * 60),
|
||||||
|
NonZeroUsize::new(1).expect("non-zero")
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let key = fixture.key("alice")?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let mut store = SecretStore::new(
|
||||||
|
backend.clone(),
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::user_presence_for_openpgp(),
|
||||||
|
);
|
||||||
|
store.unlock()?;
|
||||||
|
let passphrase = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
|
||||||
|
store.create(
|
||||||
|
&passphrase,
|
||||||
|
SecretBytes::new(key.passphrase.as_bytes().to_vec()),
|
||||||
|
)?;
|
||||||
|
let entry = fixture
|
||||||
|
.generated
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg")
|
||||||
|
.expect("compatibility entry exists");
|
||||||
|
assert_eq!(
|
||||||
|
keys.decrypt(
|
||||||
|
&EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?),
|
||||||
|
&mut store,
|
||||||
|
)?
|
||||||
|
.expose(),
|
||||||
|
fixture.read("expected/basic/email/personal.txt")?
|
||||||
|
);
|
||||||
|
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
fs_config(&temporary)?;
|
||||||
|
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
||||||
|
.load(Some(&temporary.path().join("config.toml")))?;
|
||||||
|
let remote = &config.git_remotes()[0];
|
||||||
|
backend.fail_next(SecretStoreError::Cancelled);
|
||||||
|
assert!(matches!(
|
||||||
|
store.credential(remote.server_id(), remote.application_id()),
|
||||||
|
Err(GitError::CredentialCancelled)
|
||||||
|
));
|
||||||
|
backend.fail_next(SecretStoreError::Denied);
|
||||||
|
assert!(matches!(
|
||||||
|
store.credential(remote.server_id(), remote.application_id()),
|
||||||
|
Err(GitError::CredentialAccessDenied)
|
||||||
|
));
|
||||||
|
let git = SecretReference::https_git_credential(
|
||||||
|
remote.server_id().as_str(),
|
||||||
|
remote.application_id().as_str(),
|
||||||
|
"alice",
|
||||||
|
)?;
|
||||||
|
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
||||||
|
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||||
|
assert_eq!(credential.username(), "alice");
|
||||||
|
assert_eq!(credential.password(), b"https-token");
|
||||||
|
assert!(
|
||||||
|
backend
|
||||||
|
.protections()
|
||||||
|
.contains(&SecretProtection::RequireUserPresence)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fs_config(temporary: &tempfile::TempDir) -> TestResult {
|
||||||
|
std::fs::create_dir_all(temporary.path().join("keys"))?;
|
||||||
|
std::fs::create_dir_all(temporary.path().join("vault"))?;
|
||||||
|
std::fs::create_dir_all(temporary.path().join("native"))?;
|
||||||
|
std::fs::write(
|
||||||
|
temporary.path().join("config.toml"),
|
||||||
|
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'server'\napplication_id = 'application'\n",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ Git remotes are HTTPS-only. URLs containing user information, passwords,
|
|||||||
queries, or fragments are rejected. `server_id` and `application_id` are opaque
|
queries, or fragments are rejected. `server_id` and `application_id` are opaque
|
||||||
references used to retrieve credentials from the operating-system secret
|
references used to retrieve credentials from the operating-system secret
|
||||||
store; duplicate names and duplicate reference pairs are errors.
|
store; duplicate names and duplicate reference pairs are errors.
|
||||||
|
The HTTPS account name is stored inside the protected credential record, not in
|
||||||
|
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
|
||||||
|
|
||||||
Passwords, passphrases, tokens, credentials, private keys, and other secret
|
Passwords, passphrases, tokens, credentials, private keys, and other secret
|
||||||
values are forbidden in TOML. Unknown fields are rejected. Parse errors never
|
values are forbidden in TOML. Unknown fields are rejected. Parse errors never
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ debug representation is redacted and its allocation is zeroed on drop. The
|
|||||||
OpenPGP backend's password type also zeroes its owned storage. Plaintext enters
|
OpenPGP backend's password type also zeroes its owned storage. Plaintext enters
|
||||||
the message encoder through an owning reader instead of being copied into an
|
the message encoder through an owning reader instead of being copied into an
|
||||||
ordinary intermediate buffer.
|
ordinary intermediate buffer.
|
||||||
|
The production provider is the storage-owned native secret store documented in
|
||||||
|
[`secure-secret-storage.md`](secure-secret-storage.md); protected-key
|
||||||
|
passphrases are addressed only by their primary fingerprint.
|
||||||
|
|
||||||
Detached `.gpg-id.sig` files use binary-document signatures with SHA-256 and
|
Detached `.gpg-id.sig` files use binary-document signatures with SHA-256 and
|
||||||
issuer fingerprint/key-ID metadata. Verification succeeds only when the valid
|
issuer fingerprint/key-ID metadata. Verification succeeds only when the valid
|
||||||
|
|||||||
54
docs/secure-secret-storage.md
Normal file
54
docs/secure-secret-storage.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Secure secret storage
|
||||||
|
|
||||||
|
`crates/storage` owns the complete secret-storage contract. Configuration,
|
||||||
|
password-store repositories, Git configuration, command arguments, and logs
|
||||||
|
contain only opaque identifiers; passphrases, tokens, and HTTPS passwords are
|
||||||
|
stored by the operating system.
|
||||||
|
|
||||||
|
`SecretReference` has two validated forms. OpenPGP passphrases are keyed by the
|
||||||
|
primary fingerprint. HTTPS Git credentials are keyed by purpose, server ID,
|
||||||
|
application ID, and account. The account is kept inside the protected record,
|
||||||
|
so a configured server/application pair can retrieve it without adding an
|
||||||
|
account or secret value to TOML. References, locators, store state, and errors
|
||||||
|
all use redacted `Debug` output.
|
||||||
|
|
||||||
|
Stored values use a small versioned binary envelope containing their reference
|
||||||
|
and secret bytes. Retrieval validates the envelope and exact reference before
|
||||||
|
release. Malformed or ambiguous records produce a typed `Corrupted` error;
|
||||||
|
asking for another account at the same Git locator produces `Missing` without
|
||||||
|
altering the stored account. `create` refuses to overwrite, `replace` requires
|
||||||
|
an exact existing record, and missing, denied, cancelled, locked, corrupted,
|
||||||
|
unsupported-policy, and unavailable-store results remain distinct. Returned
|
||||||
|
and cached bytes use `SecretBytes`, which zeroizes its allocation when dropped.
|
||||||
|
|
||||||
|
## Operating-system adapters
|
||||||
|
|
||||||
|
The platform-selection code is isolated in
|
||||||
|
`crates/storage/src/secret_store/platform.rs` and calls only safe Rust APIs:
|
||||||
|
|
||||||
|
- macOS uses legacy Keychain for command-line-compatible device-unlocked
|
||||||
|
credentials and Protected Data for `RequireUserPresence`; iOS uses Protected
|
||||||
|
Data. User cancellation is mapped from the native Security Framework status.
|
||||||
|
- Windows uses Credential Manager. Store operations are serialized because the
|
||||||
|
upstream adapter documents unreliable same-entry sequencing across threads.
|
||||||
|
- Linux uses Secret Service through zbus with the Rust cryptography feature. It
|
||||||
|
does not launch `secret-tool`, a shell, or any other helper. A missing session
|
||||||
|
service is a typed `Unavailable` result.
|
||||||
|
|
||||||
|
IronStorage contains no direct platform FFI or unsafe Rust. The selected adapter
|
||||||
|
crates own their OS calls, so there is no project-local FFI safety contract
|
||||||
|
beyond providing validated UTF-8 identifiers and bounded byte slices.
|
||||||
|
|
||||||
|
## Locking and caching
|
||||||
|
|
||||||
|
A new store starts logically locked. `unlock` must succeed before create,
|
||||||
|
retrieve, replace, or delete; `lock` immediately clears all cached values even
|
||||||
|
if the platform lock operation reports an error. Caching is disabled unless a
|
||||||
|
caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped
|
||||||
|
at 128 entries and 15 minutes, expire lazily, and are always cleared on lock.
|
||||||
|
|
||||||
|
The same unlocked store implements the OpenPGP `SecretProvider` and HTTPS Git
|
||||||
|
`GitCredentialProvider`. The CLI uses it for terminal `show` and embedded `git
|
||||||
|
fetch`, proving that protected keys and remote authentication are resolved only
|
||||||
|
through opaque references. Tests inject a memory backend and never access a
|
||||||
|
developer or CI user keyring.
|
||||||
Reference in New Issue
Block a user