Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,707 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qs-bitwarden-ssh-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rand_core",
|
||||
"rsa",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"signature",
|
||||
"ssh-encoding",
|
||||
"ssh-key",
|
||||
"tokio",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"sha2",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-cipher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"ssh-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-encoding"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"pem-rfc7468",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-key"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"num-bigint-dig",
|
||||
"rand_core",
|
||||
"rsa",
|
||||
"sha2",
|
||||
"signature",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "qs-bitwarden-ssh-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Headless SSH-agent companion for the qs-bitwarden-cli Quickshell panel"
|
||||
|
||||
# Why each dependency is here, and why its features are cut this far down, is
|
||||
# recorded in docs/decisions/0001-ssh-agent-dependencies.md. Anything added
|
||||
# here needs the same review: this process holds decrypted private keys.
|
||||
[dependencies]
|
||||
# Key parsing, public blobs, fingerprints, and the signing primitives. Default
|
||||
# features are off so ECDSA, DSA, and OpenSSH key encryption never compile in:
|
||||
# v1 signs with Ed25519 and RSA SHA-2 only.
|
||||
ssh-key = { version = "0.6.7", default-features = false, features = ["alloc", "ed25519", "rsa"] }
|
||||
# Wire primitives for the allowlisted agent frame decoder (Task 5). Same
|
||||
# version ssh-key uses, declared directly because this crate encodes and
|
||||
# decodes frames itself rather than through an agent framework.
|
||||
ssh-encoding = { version = "0.2", default-features = false, features = ["alloc"] }
|
||||
# Declared only to turn ed25519-dalek's zeroize-on-drop impl on for the whole
|
||||
# graph. ssh-key depends on dalek with default features off and does not ask
|
||||
# for `zeroize`, so without this line the transient SigningKey built for each
|
||||
# signature leaves its 32 secret bytes in freed memory.
|
||||
ed25519-dalek = { version = "2.2", default-features = false, features = ["zeroize"] }
|
||||
# Same reasoning in reverse: rsa zeroizes unconditionally, and pinning it here
|
||||
# keeps the version that does so under this crate's own review.
|
||||
rsa = { version = "0.9.10", default-features = false, features = ["sha2"] }
|
||||
# Zeroizing<Vec<u8>> for PEM text and FIFO payloads, from the first byte read.
|
||||
zeroize = { version = "1.9", default-features = false, features = ["alloc"] }
|
||||
# Current-thread async runtime: independent socket tasks with bounded channels,
|
||||
# no thread pool. `net` carries UnixListener and SO_PEERCRED via peer_cred().
|
||||
tokio = { version = "1.53", default-features = false, features = ["rt", "net", "io-util", "io-std", "sync", "time", "macros"] }
|
||||
# RLIMIT_CORE=0 and PR_SET_DUMPABLE=0 before the first secret is read.
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs", "process", "thread"] }
|
||||
# The NDJSON control channel the panel speaks on stdin/stdout.
|
||||
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
|
||||
serde_json = { version = "1", default-features = false, features = ["alloc"] }
|
||||
signature = { version = "2", default-features = false, features = ["alloc"] }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
# Test-only key generation, so no private key material is committed.
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
|
||||
[profile.release]
|
||||
# A key-holding process should not leave a core file or unwind through
|
||||
# arbitrary Drop impls on panic; abort keeps secret memory out of a longer
|
||||
# unwind path and out of a dumpable child.
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,9 @@
|
||||
# The release helper ships as bytes in this repository, so the toolchain that
|
||||
# produced them is part of the artifact. rustup honours this file; a distro
|
||||
# cargo ignores it, which is why the reproducible build (Task 16) runs under
|
||||
# rustup in a pinned container and compares output byte for byte.
|
||||
[toolchain]
|
||||
channel = "1.98.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["x86_64-unknown-linux-gnu"]
|
||||
profile = "minimal"
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Bounded signature requests, single-use approvals, and process grants.
|
||||
|
||||
use crate::keystore::{AuthorizationPermit, KeyStore};
|
||||
use crate::peer::PeerContext;
|
||||
|
||||
const MAX_PENDING: usize = 4;
|
||||
/// How long a person has to answer a prompt before the request is abandoned.
|
||||
///
|
||||
/// This is a human deadline, not a machine one: the panel has to open, the
|
||||
/// user has to notice it, read a fingerprint, and decide. Thirty seconds --
|
||||
/// the figure the original design carried -- turned out to be shorter than
|
||||
/// that takes in practice, and expired prompts under a user who was simply
|
||||
/// reading them. See docs/decisions/0003-request-deadline.md.
|
||||
///
|
||||
/// The bound that actually reclaims resources promptly is the client
|
||||
/// disconnect, which the server watches for while a request is pending.
|
||||
pub const REQUEST_LIFETIME_MS: u64 = 120_000;
|
||||
const MAX_GRANT_SECONDS: u64 = 900;
|
||||
|
||||
/// Stable authorization failures.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ApprovalError {
|
||||
WrongUid,
|
||||
QueueFull,
|
||||
UnknownRequest,
|
||||
IdExhausted,
|
||||
}
|
||||
|
||||
/// Unique process-lifetime request identifier.
|
||||
pub type RequestId = u64;
|
||||
|
||||
/// Unique process-lifetime grant identifier.
|
||||
pub type GrantId = u64;
|
||||
|
||||
/// Result of submitting a sign request.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Submit {
|
||||
Pending(RequestId),
|
||||
Granted(Authorization),
|
||||
}
|
||||
|
||||
/// Public-only authorization which must still pass the keystore's final gate.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct Authorization {
|
||||
epoch: u64,
|
||||
public_blob: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Authorization {
|
||||
/// Recheck epoch, lock state, and key identity at the final signing point.
|
||||
pub fn finalize(self, store: &KeyStore) -> Option<AuthorizationPermit> {
|
||||
let permit = store.authorize(&self.public_blob)?;
|
||||
// `authorize` is current-state authoritative. The explicit epoch check
|
||||
// keeps a token from a previous unlock from crossing after a reload.
|
||||
(store.epoch() == self.epoch).then_some(permit)
|
||||
}
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
id: RequestId,
|
||||
epoch: u64,
|
||||
public_blob: Vec<u8>,
|
||||
peer: PeerContext,
|
||||
deadline_ms: u64,
|
||||
}
|
||||
|
||||
/// Public grant projection safe for panel status.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Grant {
|
||||
pub id: GrantId,
|
||||
pub public_blob: Vec<u8>,
|
||||
pub peer: PeerContext,
|
||||
pub epoch: u64,
|
||||
pub expires_at_ms: u64,
|
||||
}
|
||||
|
||||
/// Single-owner authorization state.
|
||||
pub struct ApprovalManager {
|
||||
expected_uid: u32,
|
||||
next_id: RequestId,
|
||||
next_grant_id: GrantId,
|
||||
pending: Vec<Pending>,
|
||||
grants: Vec<Grant>,
|
||||
}
|
||||
|
||||
impl ApprovalManager {
|
||||
pub fn new(expected_uid: u32) -> Self {
|
||||
Self {
|
||||
expected_uid,
|
||||
next_id: 1,
|
||||
next_grant_id: 1,
|
||||
pending: Vec::new(),
|
||||
grants: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&mut self,
|
||||
epoch: u64,
|
||||
public_blob: &[u8],
|
||||
peer: PeerContext,
|
||||
now_ms: u64,
|
||||
) -> Result<Submit, ApprovalError> {
|
||||
if peer.uid != self.expected_uid {
|
||||
return Err(ApprovalError::WrongUid);
|
||||
}
|
||||
self.expire(now_ms);
|
||||
if self.grants.iter().any(|grant| {
|
||||
grant.epoch == epoch
|
||||
&& grant.public_blob == public_blob
|
||||
&& grant.peer.shares_grant_scope(&peer)
|
||||
}) {
|
||||
return Ok(Submit::Granted(Authorization {
|
||||
epoch,
|
||||
public_blob: public_blob.to_vec(),
|
||||
}));
|
||||
}
|
||||
if self.pending.len() >= MAX_PENDING {
|
||||
return Err(ApprovalError::QueueFull);
|
||||
}
|
||||
let id = self.next_id;
|
||||
self.next_id = self
|
||||
.next_id
|
||||
.checked_add(1)
|
||||
.ok_or(ApprovalError::IdExhausted)?;
|
||||
self.pending.push(Pending {
|
||||
id,
|
||||
epoch,
|
||||
public_blob: public_blob.to_vec(),
|
||||
peer,
|
||||
deadline_ms: now_ms.saturating_add(REQUEST_LIFETIME_MS),
|
||||
});
|
||||
Ok(Submit::Pending(id))
|
||||
}
|
||||
|
||||
pub fn approve(
|
||||
&mut self,
|
||||
id: RequestId,
|
||||
grant_seconds: u64,
|
||||
now_ms: u64,
|
||||
) -> Result<Authorization, ApprovalError> {
|
||||
self.expire(now_ms);
|
||||
let index = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|request| request.id == id)
|
||||
.ok_or(ApprovalError::UnknownRequest)?;
|
||||
let request = self.pending.remove(index);
|
||||
if grant_seconds > 0 {
|
||||
let grant_id = self.next_grant_id;
|
||||
self.next_grant_id = self
|
||||
.next_grant_id
|
||||
.checked_add(1)
|
||||
.ok_or(ApprovalError::IdExhausted)?;
|
||||
let duration_ms = grant_seconds.min(MAX_GRANT_SECONDS).saturating_mul(1_000);
|
||||
self.grants.push(Grant {
|
||||
id: grant_id,
|
||||
public_blob: request.public_blob.clone(),
|
||||
peer: request.peer,
|
||||
epoch: request.epoch,
|
||||
expires_at_ms: now_ms.saturating_add(duration_ms),
|
||||
});
|
||||
}
|
||||
Ok(Authorization {
|
||||
epoch: request.epoch,
|
||||
public_blob: request.public_blob,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self, id: RequestId) {
|
||||
self.pending.retain(|request| request.id != id);
|
||||
}
|
||||
|
||||
pub fn expire(&mut self, now_ms: u64) {
|
||||
self.pending.retain(|request| request.deadline_ms > now_ms);
|
||||
self.grants.retain(|grant| grant.expires_at_ms > now_ms);
|
||||
}
|
||||
|
||||
/// Lock, logout, account change, suspend, screen lock, disable, and epoch
|
||||
/// change all use this same deny/cancel operation.
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.pending.clear();
|
||||
self.grants.clear();
|
||||
}
|
||||
|
||||
pub fn revoke_grant(&mut self, id: GrantId) {
|
||||
self.grants.retain(|grant| grant.id != id);
|
||||
}
|
||||
|
||||
pub fn revoke_all_grants(&mut self) {
|
||||
self.grants.clear();
|
||||
}
|
||||
|
||||
pub fn revoke_peer(&mut self, peer: &PeerContext) {
|
||||
self.grants
|
||||
.retain(|grant| !grant.peer.shares_grant_scope(peer));
|
||||
}
|
||||
|
||||
/// Reserve an identifier for a request the caller holds itself -- one
|
||||
/// waiting on an unlock rather than on an approval. Drawn from the same
|
||||
/// sequence, so no two live requests can ever share an id.
|
||||
pub fn reserve_request_id(&mut self) -> Result<RequestId, ApprovalError> {
|
||||
let id = self.next_id;
|
||||
self.next_id = self
|
||||
.next_id
|
||||
.checked_add(1)
|
||||
.ok_or(ApprovalError::IdExhausted)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Same-UID enforcement, for requests the caller holds itself rather than
|
||||
/// registering as pending.
|
||||
pub fn expects_uid(&self, uid: u32) -> bool {
|
||||
uid == self.expected_uid
|
||||
}
|
||||
|
||||
/// How many more requests may exist across both the pending set and any
|
||||
/// the caller is holding. The four-request bound covers them together.
|
||||
pub fn capacity_remaining(&self, held: usize) -> usize {
|
||||
MAX_PENDING.saturating_sub(self.pending.len() + held)
|
||||
}
|
||||
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
pub fn is_pending(&self, id: RequestId) -> bool {
|
||||
self.pending.iter().any(|request| request.id == id)
|
||||
}
|
||||
|
||||
pub fn grants(&self) -> &[Grant] {
|
||||
&self.grants
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Strict, bounded panel-to-companion control messages.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CONTROL_VERSION: u8 = 1;
|
||||
pub const MAX_CONTROL_LINE: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ControlMessage {
|
||||
Hello {
|
||||
v: u8,
|
||||
},
|
||||
KeyLoadBegin {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
#[serde(rename = "loadId")]
|
||||
load_id: String,
|
||||
},
|
||||
KeyLoadEnd {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
status: LoadStatus,
|
||||
},
|
||||
VaultLocked {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
},
|
||||
VaultLoggedOut {
|
||||
v: u8,
|
||||
},
|
||||
Approve {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
#[serde(rename = "grantSeconds")]
|
||||
grant_seconds: u64,
|
||||
},
|
||||
Deny {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
},
|
||||
UnlockCancelled {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
reason: String,
|
||||
},
|
||||
/// Panel settings the companion needs to act on. Sent after the
|
||||
/// handshake and whenever they change.
|
||||
Options {
|
||||
v: u8,
|
||||
#[serde(rename = "unlockOnDemand")]
|
||||
unlock_on_demand: bool,
|
||||
},
|
||||
RevokeGrants {
|
||||
v: u8,
|
||||
},
|
||||
RevokeGrant {
|
||||
v: u8,
|
||||
#[serde(rename = "grantId")]
|
||||
grant_id: u64,
|
||||
},
|
||||
Shutdown {
|
||||
v: u8,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoadStatus {
|
||||
Ok,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ControlError {
|
||||
Empty,
|
||||
TooLong,
|
||||
Malformed,
|
||||
WrongVersion,
|
||||
}
|
||||
|
||||
impl ControlMessage {
|
||||
pub fn version(&self) -> u8 {
|
||||
match self {
|
||||
Self::Hello { v }
|
||||
| Self::VaultLoggedOut { v }
|
||||
| Self::RevokeGrants { v }
|
||||
| Self::Shutdown { v } => *v,
|
||||
Self::KeyLoadBegin { v, .. }
|
||||
| Self::Options { v, .. }
|
||||
| Self::RevokeGrant { v, .. }
|
||||
| Self::KeyLoadEnd { v, .. }
|
||||
| Self::VaultLocked { v, .. }
|
||||
| Self::Approve { v, .. }
|
||||
| Self::Deny { v, .. }
|
||||
| Self::UnlockCancelled { v, .. } => *v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_control_line(line: &[u8]) -> Result<ControlMessage, ControlError> {
|
||||
let line = line.strip_suffix(b"\n").unwrap_or(line);
|
||||
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
||||
if line.is_empty() {
|
||||
return Err(ControlError::Empty);
|
||||
}
|
||||
if line.len() > MAX_CONTROL_LINE {
|
||||
return Err(ControlError::TooLong);
|
||||
}
|
||||
let message: ControlMessage =
|
||||
serde_json::from_slice(line).map_err(|_| ControlError::Malformed)?;
|
||||
if message.version() != CONTROL_VERSION {
|
||||
return Err(ControlError::WrongVersion);
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Bounded candidate loading and epoch-authoritative private-key ownership.
|
||||
|
||||
use crate::signing;
|
||||
use crate::state::{StateTracker, VaultState};
|
||||
use ssh_key::{HashAlg, PrivateKey, PublicKey, Signature};
|
||||
use std::fmt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Maximum number of SSH items accepted in one candidate load.
|
||||
pub const MAX_KEYS: usize = 128;
|
||||
/// Maximum OpenSSH PEM bytes accepted for one item.
|
||||
pub const MAX_PEM_BYTES: usize = 64 * 1024;
|
||||
/// Public vault metadata retained in memory or emitted on the bounded control
|
||||
/// channel. Measured in UTF-8 bytes, matching the protocol ceiling. An item id
|
||||
/// past it fails the load; a name past it is truncated to it.
|
||||
pub const MAX_METADATA_BYTES: usize = 256;
|
||||
/// Maximum filtered FIFO payload accepted for one load.
|
||||
pub const MAX_FILTERED_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// One allowlisted item from the bounded FIFO decoder.
|
||||
pub struct CandidateItem {
|
||||
pub item_id: String,
|
||||
pub name: String,
|
||||
pub private_key_pem: Zeroizing<Vec<u8>>,
|
||||
pub public_key: String,
|
||||
pub fingerprint: String,
|
||||
pub requires_reprompt: bool,
|
||||
}
|
||||
|
||||
/// Stable, non-secret reason why one item was not loaded.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SkipCode {
|
||||
MalformedPrivateKey,
|
||||
InvalidPrivateKey,
|
||||
UnsupportedKeyType,
|
||||
MalformedPublicKey,
|
||||
PublicKeyMismatch,
|
||||
FingerprintMismatch,
|
||||
RequiresReprompt,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// A skipped item safe to report over the control channel.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct SkippedItem {
|
||||
pub item_id: String,
|
||||
pub code: SkipCode,
|
||||
}
|
||||
|
||||
/// Successful candidate publication summary.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct LoadReport {
|
||||
pub loaded: usize,
|
||||
pub skipped: Vec<SkippedItem>,
|
||||
}
|
||||
|
||||
/// Whole-candidate failures. These never include parser input or errors.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LoadError {
|
||||
StaleEpoch,
|
||||
FilteredPayloadTooLarge,
|
||||
TooManyKeys,
|
||||
PemTooLarge,
|
||||
MetadataTooLarge,
|
||||
FailedCandidate,
|
||||
}
|
||||
|
||||
/// Public values retained across lock.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct PublicIdentity {
|
||||
pub item_id: String,
|
||||
pub name: String,
|
||||
pub fingerprint: String,
|
||||
/// The OpenSSH one-line form, derived from the parsed private key rather
|
||||
/// than copied from vault metadata. Git signing needs this on disk as a
|
||||
/// file, and the panel writes it; deriving it here means only material
|
||||
/// this keystore actually validated can ever be exported.
|
||||
pub public_key_openssh: String,
|
||||
public_blob: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PublicIdentity {
|
||||
pub fn public_blob(&self) -> &[u8] {
|
||||
&self.public_blob
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateIdentity {
|
||||
key: PrivateKey,
|
||||
public_index: usize,
|
||||
}
|
||||
|
||||
/// A public-only authorization result. It cannot keep a private key alive.
|
||||
pub struct AuthorizationPermit {
|
||||
epoch: u64,
|
||||
public_blob: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Candidate storage, separate from the live keystore until publication.
|
||||
pub struct CandidateLoad {
|
||||
epoch: u64,
|
||||
seen_items: usize,
|
||||
failed: bool,
|
||||
public: Vec<PublicIdentity>,
|
||||
private: Vec<PrivateIdentity>,
|
||||
skipped: Vec<SkippedItem>,
|
||||
}
|
||||
|
||||
// Debug output is intentionally redacted because this value owns private keys.
|
||||
impl fmt::Debug for CandidateLoad {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CandidateLoad { private material redacted }")
|
||||
}
|
||||
}
|
||||
|
||||
impl CandidateLoad {
|
||||
/// Validate and add one item. Individual key defects are reported as skips;
|
||||
/// a hard resource limit poisons the entire candidate.
|
||||
pub fn add(&mut self, item: CandidateItem) -> Result<Option<SkipCode>, LoadError> {
|
||||
self.seen_items = self.seen_items.saturating_add(1);
|
||||
if self.seen_items > MAX_KEYS {
|
||||
self.failed = true;
|
||||
return Err(LoadError::TooManyKeys);
|
||||
}
|
||||
if item.private_key_pem.len() > MAX_PEM_BYTES {
|
||||
self.failed = true;
|
||||
return Err(LoadError::PemTooLarge);
|
||||
}
|
||||
// An item id that long is malformed rather than unusual -- Bitwarden's
|
||||
// are 36-character UUIDs -- and it identifies the key, so it cannot be
|
||||
// shortened without changing what it names.
|
||||
if item.item_id.len() > MAX_METADATA_BYTES {
|
||||
self.failed = true;
|
||||
return Err(LoadError::MetadataTooLarge);
|
||||
}
|
||||
// A long *name* is ordinary. Bitwarden allows them, and 256 bytes is
|
||||
// around 85 CJK characters, so failing the load here would take the
|
||||
// whole feature down over one item somebody named descriptively. The
|
||||
// name is display and comment text, so it is bounded by truncation
|
||||
// instead -- on a character boundary, because a String cut mid-sequence
|
||||
// is not one.
|
||||
let mut item = item;
|
||||
if item.name.len() > MAX_METADATA_BYTES {
|
||||
let mut end = MAX_METADATA_BYTES;
|
||||
while end > 0 && !item.name.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
item.name.truncate(end);
|
||||
}
|
||||
if item.requires_reprompt {
|
||||
return Ok(self.skip(item.item_id, SkipCode::RequiresReprompt));
|
||||
}
|
||||
|
||||
let key = match PrivateKey::from_openssh(item.private_key_pem.as_slice()) {
|
||||
Ok(key) => key,
|
||||
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPrivateKey)),
|
||||
};
|
||||
if !matches!(
|
||||
key.algorithm(),
|
||||
ssh_key::Algorithm::Ed25519 | ssh_key::Algorithm::Rsa { .. }
|
||||
) {
|
||||
return Ok(self.skip(item.item_id, SkipCode::UnsupportedKeyType));
|
||||
}
|
||||
if let Some(rsa) = key.key_data().rsa() {
|
||||
if crate::rsa_keys::private_key(rsa).is_err() {
|
||||
return Ok(self.skip(item.item_id, SkipCode::InvalidPrivateKey));
|
||||
}
|
||||
}
|
||||
let metadata_key = match PublicKey::from_openssh(&item.public_key) {
|
||||
Ok(key) => key,
|
||||
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPublicKey)),
|
||||
};
|
||||
let public_blob = match key.public_key().to_bytes() {
|
||||
Ok(blob) => blob,
|
||||
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPrivateKey)),
|
||||
};
|
||||
if metadata_key.to_bytes().ok().as_deref() != Some(public_blob.as_slice()) {
|
||||
return Ok(self.skip(item.item_id, SkipCode::PublicKeyMismatch));
|
||||
}
|
||||
let fingerprint = key.public_key().fingerprint(HashAlg::Sha256).to_string();
|
||||
if fingerprint != item.fingerprint {
|
||||
return Ok(self.skip(item.item_id, SkipCode::FingerprintMismatch));
|
||||
}
|
||||
if self
|
||||
.public
|
||||
.iter()
|
||||
.any(|identity| identity.public_blob == public_blob)
|
||||
{
|
||||
return Ok(self.skip(item.item_id, SkipCode::Duplicate));
|
||||
}
|
||||
|
||||
// Derived from the key that was just validated, not from the vault's
|
||||
// copy: the export on disk must be material this keystore vouched for.
|
||||
let public_key_openssh = match key.public_key().to_openssh() {
|
||||
Ok(text) => text,
|
||||
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPublicKey)),
|
||||
};
|
||||
let public_index = self.public.len();
|
||||
self.public.push(PublicIdentity {
|
||||
item_id: item.item_id,
|
||||
name: item.name,
|
||||
fingerprint,
|
||||
public_key_openssh,
|
||||
public_blob,
|
||||
});
|
||||
self.private.push(PrivateIdentity { key, public_index });
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn skip(&mut self, item_id: String, code: SkipCode) -> Option<SkipCode> {
|
||||
self.skipped.push(SkippedItem { item_id, code });
|
||||
Some(code)
|
||||
}
|
||||
}
|
||||
|
||||
/// The only owner of live private keys.
|
||||
pub struct KeyStore {
|
||||
state: StateTracker,
|
||||
public: Vec<PublicIdentity>,
|
||||
private: Vec<PrivateIdentity>,
|
||||
}
|
||||
|
||||
impl Default for KeyStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: StateTracker::new(),
|
||||
public: Vec::new(),
|
||||
private: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter loading and drop the previous private set before validation.
|
||||
pub fn begin_load(
|
||||
&mut self,
|
||||
epoch: u64,
|
||||
filtered_bytes: usize,
|
||||
) -> Result<CandidateLoad, LoadError> {
|
||||
if !self.state.begin_load(epoch) {
|
||||
return Err(LoadError::StaleEpoch);
|
||||
}
|
||||
self.private.clear();
|
||||
if filtered_bytes > MAX_FILTERED_BYTES {
|
||||
return Err(LoadError::FilteredPayloadTooLarge);
|
||||
}
|
||||
Ok(CandidateLoad {
|
||||
epoch,
|
||||
seen_items: 0,
|
||||
failed: false,
|
||||
public: Vec::new(),
|
||||
private: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically replace both public and private sets with one validated load.
|
||||
pub fn publish(&mut self, load: CandidateLoad) -> Result<LoadReport, LoadError> {
|
||||
if load.failed {
|
||||
return Err(LoadError::FailedCandidate);
|
||||
}
|
||||
if !self.state.publish(load.epoch) {
|
||||
return Err(LoadError::StaleEpoch);
|
||||
}
|
||||
self.public = load.public;
|
||||
self.private = load.private;
|
||||
Ok(LoadReport {
|
||||
loaded: self.private.len(),
|
||||
skipped: load.skipped,
|
||||
})
|
||||
}
|
||||
|
||||
/// Deny first, then erase the live private set while retaining public data.
|
||||
pub fn lock(&mut self, epoch: u64) {
|
||||
self.state.lock(epoch, !self.public.is_empty());
|
||||
self.private.clear();
|
||||
}
|
||||
|
||||
/// Clear both caches for logout or account change.
|
||||
pub fn logout(&mut self, epoch: u64) {
|
||||
self.state.logout(epoch);
|
||||
self.private.clear();
|
||||
self.public.clear();
|
||||
}
|
||||
|
||||
pub fn state(&self) -> VaultState {
|
||||
self.state.state()
|
||||
}
|
||||
|
||||
/// Current vault epoch for final authorization correlation.
|
||||
pub fn epoch(&self) -> u64 {
|
||||
self.state.epoch()
|
||||
}
|
||||
|
||||
pub fn public_identities(&self) -> &[PublicIdentity] {
|
||||
&self.public
|
||||
}
|
||||
|
||||
pub fn authorize(&self, public_blob: &[u8]) -> Option<AuthorizationPermit> {
|
||||
if !self.state.allows(self.state.epoch()) {
|
||||
return None;
|
||||
}
|
||||
self.private.iter().find_map(|identity| {
|
||||
let public = &self.public[identity.public_index];
|
||||
(public.public_blob == public_blob).then(|| AuthorizationPermit {
|
||||
epoch: self.state.epoch(),
|
||||
public_blob: public_blob.to_vec(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Final epoch/state/key check immediately before the signing primitive.
|
||||
pub fn sign(
|
||||
&self,
|
||||
permit: &AuthorizationPermit,
|
||||
message: &[u8],
|
||||
flags: u32,
|
||||
) -> Option<Signature> {
|
||||
if !self.state.allows(permit.epoch) {
|
||||
return None;
|
||||
}
|
||||
let identity = self.private.iter().find(|identity| {
|
||||
self.public[identity.public_index].public_blob == permit.public_blob
|
||||
})?;
|
||||
signing::sign(&identity.key, message, flags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Headless SSH-agent companion for the qs-bitwarden-cli Quickshell panel.
|
||||
//!
|
||||
//! The panel owns `bw` and `BW_SESSION`; this process never sees either. It
|
||||
//! receives already-decrypted private keys on a private FIFO, holds them only
|
||||
//! while the vault is unlocked, and signs only against a live approval. The
|
||||
//! full design is in `docs/ideas/ssh-agent.md`, and the dependency set below is
|
||||
//! justified in `docs/decisions/0001-ssh-agent-dependencies.md`.
|
||||
//!
|
||||
//! At this stage the crate is the dependency spike itself: it pins the crates
|
||||
//! the agent will be built from and proves, in tests that need no vault, no
|
||||
//! network, and no socket, that they can do the two things the design cannot
|
||||
//! compromise on -- sign what v1 promises to sign, and wipe private key memory
|
||||
//! when it is dropped.
|
||||
|
||||
use zeroize::ZeroizeOnDrop;
|
||||
|
||||
pub mod approvals;
|
||||
pub mod control;
|
||||
pub mod keystore;
|
||||
pub mod lifecycle;
|
||||
pub mod load;
|
||||
pub mod peer;
|
||||
pub mod protocol;
|
||||
pub mod runtime;
|
||||
pub mod selftest;
|
||||
pub mod server;
|
||||
mod signing;
|
||||
pub mod state;
|
||||
|
||||
/// Compile-time proof that a private-key representation wipes its own memory
|
||||
/// when dropped.
|
||||
///
|
||||
/// Rust drops the value either way; what this asserts is that the drop is a
|
||||
/// zeroizing one. It is a function rather than a comment because the property
|
||||
/// depends on Cargo features resolved across the whole dependency graph -- one
|
||||
/// crate anywhere in the tree can turn a wipe into a plain deallocation, and
|
||||
/// nothing in the source of this crate would look any different afterwards.
|
||||
/// If a call to this stops compiling, the keystore's lock semantics are no
|
||||
/// longer true, whatever the documentation says.
|
||||
pub fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
|
||||
|
||||
/// RSA signing keys, built here rather than through ssh-key.
|
||||
///
|
||||
/// ssh-key 0.6.7 -- the newest release; the 0.7 line has been in release
|
||||
/// candidates since 2025 -- cannot produce a usable RSA private key. Its
|
||||
/// `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey` passes `p` twice where
|
||||
/// `from_components` expects `p` and `q`, so the key fails validation and
|
||||
/// every RSA signature returns an opaque error. The fix is on the project's
|
||||
/// master branch and unreleased.
|
||||
///
|
||||
/// That leaves three options: ship a release candidate of a security
|
||||
/// dependency, drop RSA from v1, or build the private key here from the same
|
||||
/// components. This crate takes the third: it is a dozen lines against a
|
||||
/// stable API, it needs no fork or patch section in Cargo.toml, and it drops
|
||||
/// out the day a fixed 0.6.x or 0.7.0 is released. See
|
||||
/// `docs/decisions/0001-ssh-agent-dependencies.md`.
|
||||
pub mod rsa_keys {
|
||||
use rsa::pkcs1v15;
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use rsa::BigUint;
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Error, HashAlg, Result};
|
||||
|
||||
/// The RSA private key for `keypair`, with p and q the right way round.
|
||||
///
|
||||
/// The returned key zeroizes its own components on drop; the caller is
|
||||
/// responsible for not cloning it out of the keystore.
|
||||
pub fn private_key(keypair: &RsaKeypair) -> Result<rsa::RsaPrivateKey> {
|
||||
let key = rsa::RsaPrivateKey::from_components(
|
||||
BigUint::try_from(&keypair.public.n)?,
|
||||
BigUint::try_from(&keypair.public.e)?,
|
||||
BigUint::try_from(&keypair.private.d)?,
|
||||
vec![
|
||||
BigUint::try_from(&keypair.private.p)?,
|
||||
BigUint::try_from(&keypair.private.q)?,
|
||||
],
|
||||
)
|
||||
.map_err(|_| Error::Crypto)?;
|
||||
|
||||
// OpenSSH refuses RSA below 2048 bits and so does this agent; a
|
||||
// shorter key is a failed load, not a weaker signature.
|
||||
if key.size().saturating_mul(8) < MIN_RSA_KEY_BITS {
|
||||
return Err(Error::Crypto);
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Smallest RSA modulus this agent will sign with, in bits.
|
||||
pub const MIN_RSA_KEY_BITS: usize = 2048;
|
||||
|
||||
/// A PKCS#1 v1.5 signing key for one of the two RSA SHA-2 algorithms.
|
||||
///
|
||||
/// The hash is not a detail the agent gets to choose: `rsa-sha2-256` and
|
||||
/// `rsa-sha2-512` are distinct signature algorithms on the wire, selected
|
||||
/// by flags on the sign request, and answering with the other one is a
|
||||
/// failed authentication.
|
||||
pub enum Sha2SigningKey {
|
||||
Sha256(pkcs1v15::SigningKey<sha2::Sha256>),
|
||||
Sha512(pkcs1v15::SigningKey<sha2::Sha512>),
|
||||
}
|
||||
|
||||
/// Build the signing key the requested flag asks for.
|
||||
pub fn sha2_signing_key(keypair: &RsaKeypair, hash: HashAlg) -> Result<Sha2SigningKey> {
|
||||
let key = private_key(keypair)?;
|
||||
Ok(match hash {
|
||||
HashAlg::Sha256 => Sha2SigningKey::Sha256(pkcs1v15::SigningKey::new(key)),
|
||||
HashAlg::Sha512 => Sha2SigningKey::Sha512(pkcs1v15::SigningKey::new(key)),
|
||||
// ssh-key's HashAlg is non-exhaustive; anything else is not an
|
||||
// algorithm this agent advertises.
|
||||
_ => return Err(Error::Crypto),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{assert_zeroize_on_drop, rsa_keys};
|
||||
use rand_core::OsRng;
|
||||
use signature::{SignatureEncoding, Signer, Verifier};
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// The two secret representations that exist while the vault is unlocked:
|
||||
/// the transient dalek signing key ssh-key builds for each Ed25519
|
||||
/// signature, and the RSA private key it converts into for each RSA one.
|
||||
///
|
||||
/// dalek implements this only behind its `zeroize` feature, and ssh-key
|
||||
/// depends on dalek with default features off without asking for it. This
|
||||
/// crate names dalek as a direct dependency for that feature alone; drop
|
||||
/// that line from Cargo.toml and this test stops compiling rather than
|
||||
/// silently leaving 32 secret bytes in freed memory.
|
||||
#[test]
|
||||
fn every_private_key_representation_wipes_itself_on_drop() {
|
||||
assert_zeroize_on_drop::<ed25519_dalek::SigningKey>();
|
||||
assert_zeroize_on_drop::<rsa::RsaPrivateKey>();
|
||||
}
|
||||
|
||||
/// Ed25519: the algorithm nearly every Bitwarden SSH key will use.
|
||||
#[test]
|
||||
fn ed25519_keys_parse_sign_and_verify() {
|
||||
let generated = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
// Private keys reach this process as OpenSSH PEM text on the FIFO, so
|
||||
// the test takes the same route in -- and holds the text the way the
|
||||
// loader will, in a buffer that wipes itself.
|
||||
let pem = Zeroizing::new(
|
||||
generated
|
||||
.to_openssh(Default::default())
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
let key = PrivateKey::from_openssh(pem.as_bytes()).unwrap();
|
||||
|
||||
let signature = key.try_sign(b"agent sign request").unwrap();
|
||||
assert_eq!(signature.algorithm(), Algorithm::Ed25519);
|
||||
// PublicKey's inherent `verify` is the namespaced SSHSIG one; the
|
||||
// agent path is the Verifier trait, named explicitly here so the test
|
||||
// exercises what the signing gate will call.
|
||||
Verifier::verify(key.public_key(), b"agent sign request", &signature)
|
||||
.expect("a signature this agent produced must verify under the key it advertises");
|
||||
assert!(Verifier::verify(key.public_key(), b"a different payload", &signature).is_err());
|
||||
}
|
||||
|
||||
/// RSA SHA-2, both flags, through this crate's own key construction.
|
||||
///
|
||||
/// The generated key is 2048 bits rather than ssh-key's 4096-bit default
|
||||
/// because this test runs on every build and key generation dominates it.
|
||||
#[test]
|
||||
fn rsa_keys_sign_under_both_sha2_flags() {
|
||||
let keypair = RsaKeypair::random(&mut OsRng, rsa_keys::MIN_RSA_KEY_BITS).unwrap();
|
||||
let key = PrivateKey::from(keypair.clone());
|
||||
|
||||
let mut signatures = Vec::new();
|
||||
for hash in [HashAlg::Sha256, HashAlg::Sha512] {
|
||||
let signature = match rsa_keys::sha2_signing_key(&keypair, hash).unwrap() {
|
||||
rsa_keys::Sha2SigningKey::Sha256(signing) => {
|
||||
signing.try_sign(b"agent sign request").unwrap().to_vec()
|
||||
}
|
||||
rsa_keys::Sha2SigningKey::Sha512(signing) => {
|
||||
signing.try_sign(b"agent sign request").unwrap().to_vec()
|
||||
}
|
||||
};
|
||||
let signature = Signature::new(Algorithm::Rsa { hash: Some(hash) }, signature).unwrap();
|
||||
Verifier::verify(key.public_key(), b"agent sign request", &signature).unwrap_or_else(
|
||||
|_| panic!("an rsa-sha2 signature must verify under the advertised key: {hash:?}"),
|
||||
);
|
||||
assert!(
|
||||
Verifier::verify(key.public_key(), b"a different payload", &signature).is_err()
|
||||
);
|
||||
signatures.push(signature);
|
||||
}
|
||||
assert_ne!(
|
||||
signatures[0].as_bytes(),
|
||||
signatures[1].as_bytes(),
|
||||
"the two RSA SHA-2 algorithms must not produce the same signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reason `rsa_keys` exists at all. ssh-key 0.6.7 builds its RSA
|
||||
/// private key from `p` twice instead of `p` and `q`, so its own signing
|
||||
/// path cannot sign anything. This test pins that failure: when it starts
|
||||
/// passing, a fixed ssh-key has been released and `rsa_keys` can go.
|
||||
#[test]
|
||||
fn ssh_key_0_6_7_still_cannot_sign_with_rsa_itself() {
|
||||
let keypair = RsaKeypair::random(&mut OsRng, rsa_keys::MIN_RSA_KEY_BITS).unwrap();
|
||||
let key = PrivateKey::from(keypair);
|
||||
assert!(
|
||||
key.try_sign(b"agent sign request").is_err(),
|
||||
"ssh-key can sign RSA again: drop the rsa_keys module and its ADR entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// v1 signs Ed25519 and RSA SHA-2 and nothing else, and that promise is
|
||||
/// kept by what compiles in rather than by a runtime check someone can
|
||||
/// forget: with ssh-key's default features off, an ECDSA key has no
|
||||
/// signing implementation to reach.
|
||||
#[test]
|
||||
fn algorithms_outside_v1_have_no_signing_path() {
|
||||
let unsupported = PrivateKey::random(
|
||||
&mut OsRng,
|
||||
Algorithm::Ecdsa {
|
||||
curve: ssh_key::EcdsaCurve::NistP256,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
unsupported.is_err(),
|
||||
"an algorithm v1 does not support must fail closed at key construction"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Process hardening applied before runtime paths or secret-bearing inputs open.
|
||||
|
||||
use rustix::process::{self, DumpableBehavior, Resource, Rlimit};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HardenError {
|
||||
CoreLimit,
|
||||
Dumpable,
|
||||
}
|
||||
|
||||
pub fn harden_process() -> Result<(), HardenError> {
|
||||
process::setrlimit(
|
||||
Resource::Core,
|
||||
Rlimit {
|
||||
current: Some(0),
|
||||
maximum: Some(0),
|
||||
},
|
||||
)
|
||||
.map_err(|_| HardenError::CoreLimit)?;
|
||||
process::set_dumpable_behavior(DumpableBehavior::NotDumpable).map_err(|_| HardenError::Dumpable)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! One-shot nonce-framed candidate payload decoding.
|
||||
|
||||
use crate::keystore::{CandidateItem, CandidateLoad, KeyStore, LoadError};
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Sanitized whole-payload failures.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PayloadError {
|
||||
InvalidNonce,
|
||||
Closed,
|
||||
NonceMismatch,
|
||||
Malformed,
|
||||
Load(LoadError),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Envelope {
|
||||
load_id: String,
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Item {
|
||||
item_id: String,
|
||||
name: String,
|
||||
private_key: String,
|
||||
public_key: String,
|
||||
fingerprint: String,
|
||||
requires_reprompt: bool,
|
||||
}
|
||||
|
||||
/// A single armed load nonce. Every decode attempt consumes the window.
|
||||
pub struct LoadWindow {
|
||||
epoch: u64,
|
||||
nonce: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LoadWindow {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("LoadWindow { nonce redacted }")
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadWindow {
|
||||
pub fn new(epoch: u64, nonce: &str) -> Result<Self, PayloadError> {
|
||||
let nonce = parse_nonce(nonce)?;
|
||||
Ok(Self {
|
||||
epoch,
|
||||
nonce: Some(nonce),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode one complete bounded JSON payload and build an unpublished
|
||||
/// candidate. Raw JSON and each moved PEM allocation wipe on drop.
|
||||
pub fn decode(
|
||||
&mut self,
|
||||
bytes: Zeroizing<Vec<u8>>,
|
||||
store: &mut KeyStore,
|
||||
) -> Result<CandidateLoad, PayloadError> {
|
||||
let expected = self.nonce.take().ok_or(PayloadError::Closed)?;
|
||||
let mut candidate = store
|
||||
.begin_load(self.epoch, bytes.len())
|
||||
.map_err(PayloadError::Load)?;
|
||||
let envelope: Envelope =
|
||||
serde_json::from_slice(bytes.as_slice()).map_err(|_| PayloadError::Malformed)?;
|
||||
let supplied = parse_nonce(&envelope.load_id).map_err(|_| PayloadError::NonceMismatch)?;
|
||||
if !constant_time_eq(&supplied, &expected) {
|
||||
return Err(PayloadError::NonceMismatch);
|
||||
}
|
||||
for item in envelope.items {
|
||||
candidate
|
||||
.add(CandidateItem {
|
||||
item_id: item.item_id,
|
||||
name: item.name,
|
||||
private_key_pem: Zeroizing::new(item.private_key.into_bytes()),
|
||||
public_key: item.public_key,
|
||||
fingerprint: item.fingerprint,
|
||||
requires_reprompt: item.requires_reprompt,
|
||||
})
|
||||
.map_err(PayloadError::Load)?;
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |difference, (left, right)| {
|
||||
difference | (left ^ right)
|
||||
})
|
||||
== 0
|
||||
}
|
||||
|
||||
fn parse_nonce(nonce: &str) -> Result<[u8; 32], PayloadError> {
|
||||
let bytes: [u8; 32] = nonce
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| PayloadError::InvalidNonce)?;
|
||||
if bytes.iter().all(u8::is_ascii_hexdigit) {
|
||||
Ok(bytes)
|
||||
} else {
|
||||
Err(PayloadError::InvalidNonce)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
use qs_bitwarden_ssh_agent::approvals::{ApprovalManager, RequestId, Submit};
|
||||
use qs_bitwarden_ssh_agent::control::{
|
||||
parse_control_line, ControlMessage, LoadStatus, MAX_CONTROL_LINE,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::keystore::KeyStore;
|
||||
use qs_bitwarden_ssh_agent::lifecycle::harden_process;
|
||||
use qs_bitwarden_ssh_agent::load::LoadWindow;
|
||||
use qs_bitwarden_ssh_agent::protocol::{self, AgentRequest};
|
||||
use qs_bitwarden_ssh_agent::runtime::{read_payload_async, RuntimeError, ServiceRuntime};
|
||||
use qs_bitwarden_ssh_agent::server::{self, ClientEvent};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, Stdin};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum Output {
|
||||
Ready {
|
||||
v: u8,
|
||||
#[serde(rename = "socketPath")]
|
||||
socket_path: String,
|
||||
#[serde(rename = "fifoPath")]
|
||||
fifo_path: String,
|
||||
#[serde(rename = "agentVersion")]
|
||||
agent_version: String,
|
||||
},
|
||||
ApprovalRequired {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
#[serde(rename = "keyId")]
|
||||
key_id: String,
|
||||
#[serde(rename = "keyName")]
|
||||
key_name: String,
|
||||
fingerprint: String,
|
||||
pid: u32,
|
||||
#[serde(rename = "processPath")]
|
||||
process_path: String,
|
||||
operation: &'static str,
|
||||
forwarded: bool,
|
||||
#[serde(rename = "grantOffered")]
|
||||
grant_offered: bool,
|
||||
},
|
||||
Locked {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
},
|
||||
KeysLoaded {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
#[serde(rename = "keyCount")]
|
||||
key_count: usize,
|
||||
},
|
||||
/// A signature was asked for against a locked vault whose public cache
|
||||
/// still knows the key. The request is held, not failed, until the panel
|
||||
/// either unlocks or cancels.
|
||||
UnlockRequired {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
reason: &'static str,
|
||||
#[serde(rename = "keyName")]
|
||||
key_name: String,
|
||||
fingerprint: String,
|
||||
pid: u32,
|
||||
#[serde(rename = "processPath")]
|
||||
process_path: String,
|
||||
/// Whether approving this request may also open a grant. Stated by
|
||||
/// the companion so the panel never has to assume it.
|
||||
#[serde(rename = "grantOffered")]
|
||||
grant_offered: bool,
|
||||
},
|
||||
/// A request the panel may still be prompting for has gone: the client
|
||||
/// disconnected, the deadline passed, or a lock cancelled it. Without
|
||||
/// this the prompt would sit there asking about something that no longer
|
||||
/// exists, which is how people learn to click prompts away.
|
||||
RequestCancelled {
|
||||
v: u8,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: u64,
|
||||
reason: &'static str,
|
||||
},
|
||||
/// One validated public identity, in the OpenSSH one-line form. Sent per
|
||||
/// key rather than as a list: at the documented 128-key limit a single
|
||||
/// message would exceed the 64 KiB control-line ceiling. The panel
|
||||
/// accumulates them for an epoch and writes the projection when the
|
||||
/// matching `keys_loaded` arrives.
|
||||
PublicKey {
|
||||
v: u8,
|
||||
epoch: u64,
|
||||
#[serde(rename = "itemId")]
|
||||
item_id: String,
|
||||
name: String,
|
||||
fingerprint: String,
|
||||
#[serde(rename = "publicKey")]
|
||||
public_key: String,
|
||||
},
|
||||
/// The live grant set, whenever it changes. Public metadata only.
|
||||
GrantsChanged {
|
||||
v: u8,
|
||||
grants: Vec<GrantView>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GrantView {
|
||||
#[serde(rename = "grantId")]
|
||||
grant_id: u64,
|
||||
#[serde(rename = "keyName")]
|
||||
key_name: String,
|
||||
fingerprint: String,
|
||||
pid: u32,
|
||||
#[serde(rename = "processPath")]
|
||||
process_path: String,
|
||||
#[serde(rename = "expiresInSec")]
|
||||
expires_in_sec: u64,
|
||||
}
|
||||
|
||||
struct PendingSign {
|
||||
reply: oneshot::Sender<Vec<u8>>,
|
||||
message: Vec<u8>,
|
||||
flags: u32,
|
||||
}
|
||||
|
||||
/// A signature asked for while the vault was locked. It is kept whole rather
|
||||
/// than failed, so the unlock the panel is being asked for can release the
|
||||
/// very request that triggered it.
|
||||
struct HeldSign {
|
||||
reply: oneshot::Sender<Vec<u8>>,
|
||||
public_blob: Vec<u8>,
|
||||
message: Vec<u8>,
|
||||
flags: u32,
|
||||
peer: qs_bitwarden_ssh_agent::peer::PeerContext,
|
||||
deadline_ms: u64,
|
||||
/// Set when the user approved before the load finished, carrying the
|
||||
/// grant window they chose. Approving needs the key's identity and the
|
||||
/// requesting program, both of which come from the public cache -- none
|
||||
/// of it depends on the vault read, so making the user wait for that read
|
||||
/// and only then asking is pure delay. The approval still decides
|
||||
/// nothing: the load must produce the very key that was approved, and the
|
||||
/// final epoch/state/key check runs immediately before signing.
|
||||
approved: Option<u64>,
|
||||
}
|
||||
|
||||
/// Identity listings waiting on an unlock, and the one request id that was
|
||||
/// raised for all of them. A fresh companion has no public cache, so the very
|
||||
/// first `ssh` of a session lists nothing and would never produce a sign
|
||||
/// request to unlock from. Coalesced deliberately: several clients starting
|
||||
/// at once is normal, and each must not cost its own prompt.
|
||||
struct HeldIdentities {
|
||||
request_id: RequestId,
|
||||
deadline_ms: u64,
|
||||
waiting: Vec<oneshot::Sender<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// How long a held request waits for an unlock before giving up. The same
|
||||
/// bound the approval path uses, for the same reason -- and unlocking asks
|
||||
/// more of the user than approving does, so it certainly needs no less.
|
||||
const HELD_LIFETIME_MS: u64 = qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS;
|
||||
|
||||
struct ActiveLoad {
|
||||
epoch: u64,
|
||||
window: LoadWindow,
|
||||
payload: Option<Result<Zeroizing<Vec<u8>>, RuntimeError>>,
|
||||
end_received: bool,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct ControlReader {
|
||||
stdin: Stdin,
|
||||
buffered: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ControlReader {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stdin: tokio::io::stdin(),
|
||||
buffered: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_line(&mut self) -> Result<Option<Vec<u8>>, ()> {
|
||||
loop {
|
||||
if let Some(newline) = self.buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let remainder = self.buffered.split_off(newline + 1);
|
||||
let line = std::mem::replace(&mut self.buffered, remainder);
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if self.buffered.len() > MAX_CONTROL_LINE {
|
||||
return Err(());
|
||||
}
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let count = self.stdin.read(&mut chunk).await.map_err(|_| ())?;
|
||||
if count == 0 {
|
||||
if self.buffered.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(());
|
||||
}
|
||||
self.buffered.extend_from_slice(&chunk[..count]);
|
||||
if self.buffered.len() > MAX_CONTROL_LINE + 1 {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(output: &mpsc::Sender<Output>, message: Output) -> Result<(), ()> {
|
||||
output.try_send(message).map_err(|_| ())
|
||||
}
|
||||
|
||||
async fn write_output(mut messages: mpsc::Receiver<Output>) {
|
||||
let mut stdout = tokio::io::stdout();
|
||||
while let Some(message) = messages.recv().await {
|
||||
let Ok(mut bytes) = serde_json::to_vec(&message) else {
|
||||
return;
|
||||
};
|
||||
bytes.push(b'\n');
|
||||
if stdout.write_all(&bytes).await.is_err() || stdout.flush().await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the panel asks this binary before it trusts it.
|
||||
///
|
||||
/// Argument handling is deliberately exhaustive: the panel launches the helper
|
||||
/// with no arguments, so anything else is a mistake, and silently starting a
|
||||
/// key-holding daemon in response to a typo is the wrong answer.
|
||||
fn dispatch_arguments() -> Option<i32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let first = args.next()?;
|
||||
if args.next().is_some() {
|
||||
eprintln!("qs-bitwarden-ssh-agent: expected at most one argument");
|
||||
return Some(2);
|
||||
}
|
||||
match first.as_str() {
|
||||
"--version" => {
|
||||
println!(
|
||||
"qs-bitwarden-ssh-agent {} (control protocol {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
qs_bitwarden_ssh_agent::control::CONTROL_VERSION
|
||||
);
|
||||
Some(0)
|
||||
}
|
||||
"--self-test" => Some(qs_bitwarden_ssh_agent::selftest::run()),
|
||||
"--help" | "-h" => {
|
||||
println!("qs-bitwarden-ssh-agent [--version | --self-test]");
|
||||
println!();
|
||||
println!("With no arguments, serves the SSH agent protocol and speaks the");
|
||||
println!("panel's control protocol on stdin and stdout. It is launched by the");
|
||||
println!("Bitwarden Quickshell panel and is not useful on its own.");
|
||||
Some(0)
|
||||
}
|
||||
other => {
|
||||
eprintln!("qs-bitwarden-ssh-agent: unknown argument '{other}'");
|
||||
Some(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// Before the runtime does anything: these modes answer and exit, and must
|
||||
// not depend on a runtime directory, a socket, or any of the setup below.
|
||||
if let Some(code) = dispatch_arguments() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
if run().await.is_err() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), ()> {
|
||||
harden_process().map_err(|_| ())?;
|
||||
let runtime_root = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.ok_or(())?;
|
||||
let runtime = ServiceRuntime::acquire(&runtime_root).map_err(|_| ())?;
|
||||
let listener = runtime.bind_socket().map_err(|_| ())?;
|
||||
let (output_tx, output_rx) = mpsc::channel(16);
|
||||
let output_task = tokio::spawn(write_output(output_rx));
|
||||
let (events_tx, mut events_rx) = mpsc::channel::<ClientEvent>(8);
|
||||
let (load_tx, mut load_rx) = mpsc::channel(1);
|
||||
let server = tokio::spawn(server::run(listener, events_tx));
|
||||
|
||||
let socket = runtime.socket_path().to_string_lossy().into_owned();
|
||||
let fifo = runtime.runtime().fifo_path().to_string_lossy().into_owned();
|
||||
let mut control = ControlReader::new();
|
||||
let mut store = KeyStore::new();
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let mut pending = HashMap::<RequestId, PendingSign>::new();
|
||||
let mut held = HashMap::<RequestId, HeldSign>::new();
|
||||
let mut held_identities: Option<HeldIdentities> = None;
|
||||
let mut unlock_on_demand = false;
|
||||
let mut grant_snapshot = Vec::<u64>::new();
|
||||
let mut active_load: Option<ActiveLoad> = None;
|
||||
let started = Instant::now();
|
||||
let mut gate_open = false;
|
||||
let mut handshake_complete = false;
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_millis(100));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = control.next_line() => {
|
||||
let Some(line) = line? else { break };
|
||||
let message = parse_control_line(&line).map_err(|_| ())?;
|
||||
match message {
|
||||
ControlMessage::Hello { .. } if !handshake_complete => {
|
||||
handshake_complete = true;
|
||||
gate_open = true;
|
||||
emit(&output_tx, Output::Ready { v: 1, socket_path: socket.clone(), fifo_path: fifo.clone(), agent_version: env!("CARGO_PKG_VERSION").to_owned() })?;
|
||||
}
|
||||
ControlMessage::Hello { .. } => return Err(()),
|
||||
ControlMessage::VaultLocked { epoch, .. } => {
|
||||
gate_open = false;
|
||||
cancel_load(&mut active_load);
|
||||
store.lock(epoch);
|
||||
approvals.invalidate_all();
|
||||
fail_pending(&mut pending);
|
||||
cancel_held(&mut held, "locked", &output_tx)?;
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "locked")?;
|
||||
emit(&output_tx, Output::Locked { v: 1, epoch })?;
|
||||
}
|
||||
ControlMessage::VaultLoggedOut { .. } => {
|
||||
gate_open = false;
|
||||
cancel_load(&mut active_load);
|
||||
store.logout(store.epoch().saturating_add(1));
|
||||
approvals.invalidate_all();
|
||||
fail_pending(&mut pending);
|
||||
cancel_held(&mut held, "logged-out", &output_tx)?;
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "logged-out")?;
|
||||
}
|
||||
ControlMessage::Approve { request_id, grant_seconds, .. } => {
|
||||
// A held request is one still waiting on a load. The
|
||||
// approval is recorded now and applied the moment the
|
||||
// keys arrive, so the user is not made to wait out the
|
||||
// vault read before being asked.
|
||||
if let Some(request) = held.get_mut(&request_id) {
|
||||
request.approved = Some(grant_seconds);
|
||||
continue;
|
||||
}
|
||||
let Some(sign) = pending.remove(&request_id) else { continue };
|
||||
let response = approvals.approve(request_id, grant_seconds, elapsed_ms(started)).ok()
|
||||
.and_then(|authorization| authorization.finalize(&store))
|
||||
.and_then(|permit| store.sign(&permit, &sign.message, sign.flags))
|
||||
.and_then(protocol::signature_response)
|
||||
.unwrap_or_else(protocol::failure_response);
|
||||
let _ = sign.reply.send(response);
|
||||
}
|
||||
ControlMessage::Deny { request_id, .. } | ControlMessage::UnlockCancelled { request_id, .. } => {
|
||||
approvals.disconnect(request_id);
|
||||
if let Some(sign) = pending.remove(&request_id) { let _ = sign.reply.send(protocol::failure_response()); }
|
||||
// The panel asked, so it needs no request_cancelled
|
||||
// back: it already knows this one is over.
|
||||
if let Some(request) = held.remove(&request_id) { let _ = request.reply.send(protocol::failure_response()); }
|
||||
if held_identities.as_ref().is_some_and(|w| w.request_id == request_id) {
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "cancelled")?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Options { unlock_on_demand: on, .. } => unlock_on_demand = on,
|
||||
ControlMessage::RevokeGrants { .. } => approvals.revoke_all_grants(),
|
||||
ControlMessage::RevokeGrant { grant_id, .. } => approvals.revoke_grant(grant_id),
|
||||
ControlMessage::Shutdown { .. } => break,
|
||||
ControlMessage::KeyLoadBegin { epoch, load_id, .. } => {
|
||||
if active_load.is_some() { return Err(()); }
|
||||
gate_open = false;
|
||||
approvals.invalidate_all();
|
||||
fail_pending(&mut pending);
|
||||
let window = LoadWindow::new(epoch, &load_id).map_err(|_| ())?;
|
||||
let fifo = runtime.runtime().fifo_reader().map_err(|_| ())?;
|
||||
let sender = load_tx.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let result = read_payload_async(fifo, std::time::Duration::from_secs(30)).await;
|
||||
let _ = sender.send((epoch, result)).await;
|
||||
});
|
||||
active_load = Some(ActiveLoad { epoch, window, payload: None, end_received: false, task });
|
||||
}
|
||||
ControlMessage::KeyLoadEnd { epoch, status, .. } => {
|
||||
let Some(load) = active_load.as_mut() else { return Err(()) };
|
||||
if load.epoch != epoch { return Err(()); }
|
||||
if status != LoadStatus::Ok {
|
||||
cancel_load(&mut active_load);
|
||||
store.lock(epoch);
|
||||
gate_open = false;
|
||||
cancel_held(&mut held, "load-failed", &output_tx)?;
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "load-failed")?;
|
||||
} else {
|
||||
load.end_received = true;
|
||||
finish_load_if_ready(&mut active_load, &mut store, &mut gate_open, &output_tx)?;
|
||||
if gate_open {
|
||||
release_held(&mut held, &store, &mut approvals, &mut pending, started, &output_tx)?;
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "released")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(event) = events_rx.recv() => handle_client(event, gate_open, &store, &mut approvals, &mut pending, &mut held, &mut held_identities, unlock_on_demand, started, &output_tx)?,
|
||||
Some((epoch, result)) = load_rx.recv() => {
|
||||
let Some(load) = active_load.as_mut() else { continue };
|
||||
if load.epoch != epoch { continue; }
|
||||
load.payload = Some(result);
|
||||
finish_load_if_ready(&mut active_load, &mut store, &mut gate_open, &output_tx)?;
|
||||
if gate_open {
|
||||
release_held(&mut held, &store, &mut approvals, &mut pending, started, &output_tx)?;
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "released")?;
|
||||
}
|
||||
}
|
||||
_ = tick.tick() => {
|
||||
let now = elapsed_ms(started);
|
||||
approvals.expire(now);
|
||||
let expired: Vec<_> = pending.iter().filter_map(|(id, sign)| (sign.reply.is_closed() || !approvals.is_pending(*id)).then_some(*id)).collect();
|
||||
for id in expired {
|
||||
approvals.disconnect(id);
|
||||
if let Some(sign) = pending.remove(&id) { let _ = sign.reply.send(protocol::failure_response()); }
|
||||
// Whatever ended it -- a client that walked away or a
|
||||
// deadline that passed -- the panel may still be prompting.
|
||||
emit(&output_tx, Output::RequestCancelled { v: 1, request_id: id, reason: "withdrawn" })?;
|
||||
}
|
||||
let stale: Vec<_> = held.iter().filter_map(|(id, request)| (request.reply.is_closed() || request.deadline_ms <= now).then_some(*id)).collect();
|
||||
for id in stale {
|
||||
if let Some(request) = held.remove(&id) { let _ = request.reply.send(protocol::failure_response()); }
|
||||
emit(&output_tx, Output::RequestCancelled { v: 1, request_id: id, reason: "withdrawn" })?;
|
||||
}
|
||||
if held_identities.as_ref().is_some_and(|w| w.deadline_ms <= now) {
|
||||
release_held_identities(&mut held_identities, &store, &output_tx, "withdrawn")?;
|
||||
}
|
||||
emit_grants_if_changed(&mut grant_snapshot, &approvals, &store, now, &output_tx)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
approvals.invalidate_all();
|
||||
cancel_load(&mut active_load);
|
||||
fail_pending(&mut pending);
|
||||
let _ = cancel_held(&mut held, "shutdown", &output_tx);
|
||||
let _ = release_held_identities(&mut held_identities, &store, &output_tx, "shutdown");
|
||||
store.logout(store.epoch().saturating_add(1));
|
||||
server.abort();
|
||||
drop(output_tx);
|
||||
let _ = output_task.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_client(
|
||||
event: ClientEvent,
|
||||
gate_open: bool,
|
||||
store: &KeyStore,
|
||||
approvals: &mut ApprovalManager,
|
||||
pending: &mut HashMap<RequestId, PendingSign>,
|
||||
held: &mut HashMap<RequestId, HeldSign>,
|
||||
held_identities: &mut Option<HeldIdentities>,
|
||||
unlock_on_demand: bool,
|
||||
started: Instant,
|
||||
output: &mpsc::Sender<Output>,
|
||||
) -> Result<(), ()> {
|
||||
match event.request {
|
||||
// Deliberately not behind `gate_open`. Public keys are not secret, and
|
||||
// a locked vault that still lists them is what stops every `ssh` after
|
||||
// a lock from raising an unlock prompt for a connection that may have
|
||||
// nothing to do with the vault. The cache is empty when logged out or
|
||||
// locked before any load, so those answer with an empty list, and a
|
||||
// lock clears the private set that signing needs regardless.
|
||||
AgentRequest::Identities => {
|
||||
// An empty cache with unlock-on-demand on is the one case where a
|
||||
// listing may raise UI: without it the first client of a session
|
||||
// sees nothing and no sign request can ever follow to ask.
|
||||
if store.public_identities().is_empty()
|
||||
&& unlock_on_demand
|
||||
&& approvals.expects_uid(event.peer.uid)
|
||||
{
|
||||
if let Some(waiters) = held_identities.as_mut() {
|
||||
waiters.waiting.push(event.reply);
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(id) = approvals.reserve_request_id() {
|
||||
emit(
|
||||
output,
|
||||
Output::UnlockRequired {
|
||||
v: 1,
|
||||
request_id: id,
|
||||
reason: "list-identities",
|
||||
key_name: String::new(),
|
||||
fingerprint: String::new(),
|
||||
pid: event.peer.pid,
|
||||
process_path: event.peer.executable.to_string_lossy().into_owned(),
|
||||
grant_offered: false,
|
||||
},
|
||||
)?;
|
||||
*held_identities = Some(HeldIdentities {
|
||||
request_id: id,
|
||||
deadline_ms: elapsed_ms(started).saturating_add(HELD_LIFETIME_MS),
|
||||
waiting: vec![event.reply],
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let identities: Vec<_> = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.map(|key| (key.public_blob(), key.name.as_str()))
|
||||
.collect();
|
||||
let _ = event.reply.send(protocol::identities_response(&identities));
|
||||
}
|
||||
AgentRequest::Sign {
|
||||
public_blob,
|
||||
message,
|
||||
flags,
|
||||
} => {
|
||||
if !gate_open {
|
||||
// A locked vault that still knows this key asks the panel to
|
||||
// unlock and keeps the request, rather than failing a client
|
||||
// that has no way to retry. A key the cache does not know is
|
||||
// simply not ours to sign for.
|
||||
let Some(key) = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.find(|key| key.public_blob() == public_blob)
|
||||
else {
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
return Ok(());
|
||||
};
|
||||
if !approvals.expects_uid(event.peer.uid)
|
||||
|| approvals.capacity_remaining(held.len()) == 0
|
||||
{
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(id) = approvals.reserve_request_id() else {
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
return Ok(());
|
||||
};
|
||||
emit(
|
||||
output,
|
||||
Output::UnlockRequired {
|
||||
v: 1,
|
||||
request_id: id,
|
||||
reason: "sign",
|
||||
key_name: key.name.clone(),
|
||||
fingerprint: key.fingerprint.clone(),
|
||||
pid: event.peer.pid,
|
||||
process_path: event.peer.executable.to_string_lossy().into_owned(),
|
||||
grant_offered: true,
|
||||
},
|
||||
)?;
|
||||
held.insert(
|
||||
id,
|
||||
HeldSign {
|
||||
reply: event.reply,
|
||||
public_blob,
|
||||
message,
|
||||
flags,
|
||||
peer: event.peer,
|
||||
deadline_ms: elapsed_ms(started).saturating_add(HELD_LIFETIME_MS),
|
||||
approved: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if store.authorize(&public_blob).is_none() {
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
return Ok(());
|
||||
}
|
||||
match approvals.submit(
|
||||
store.epoch(),
|
||||
&public_blob,
|
||||
event.peer.clone(),
|
||||
elapsed_ms(started),
|
||||
) {
|
||||
Ok(Submit::Granted(authorization)) => {
|
||||
let response = authorization
|
||||
.finalize(store)
|
||||
.and_then(|permit| store.sign(&permit, &message, flags))
|
||||
.and_then(protocol::signature_response)
|
||||
.unwrap_or_else(protocol::failure_response);
|
||||
let _ = event.reply.send(response);
|
||||
}
|
||||
Ok(Submit::Pending(id)) => {
|
||||
let Some(key) = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.find(|key| key.public_blob() == public_blob)
|
||||
else {
|
||||
approvals.disconnect(id);
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
return Ok(());
|
||||
};
|
||||
let process_path = event.peer.executable.to_string_lossy().into_owned();
|
||||
emit(
|
||||
output,
|
||||
Output::ApprovalRequired {
|
||||
v: 1,
|
||||
request_id: id,
|
||||
key_id: key.item_id.clone(),
|
||||
key_name: key.name.clone(),
|
||||
fingerprint: key.fingerprint.clone(),
|
||||
pid: event.peer.pid,
|
||||
process_path,
|
||||
operation: "ssh-sign",
|
||||
forwarded: false,
|
||||
grant_offered: true,
|
||||
},
|
||||
)?;
|
||||
pending.insert(
|
||||
id,
|
||||
PendingSign {
|
||||
reply: event.reply,
|
||||
message,
|
||||
flags,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = event.reply.send(protocol::failure_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release every request that was waiting on an unlock, now that one has
|
||||
/// happened. Each goes through the ordinary approval path at the *new* epoch,
|
||||
/// so an unlock authorises nothing by itself -- it only gets the request back
|
||||
/// to the point where the user can be asked.
|
||||
fn release_held(
|
||||
held: &mut HashMap<RequestId, HeldSign>,
|
||||
store: &KeyStore,
|
||||
approvals: &mut ApprovalManager,
|
||||
pending: &mut HashMap<RequestId, PendingSign>,
|
||||
started: Instant,
|
||||
output: &mpsc::Sender<Output>,
|
||||
) -> Result<(), ()> {
|
||||
for (old_id, request) in held.drain().collect::<Vec<_>>() {
|
||||
// The prompt the panel is showing is about to be replaced by an
|
||||
// approval prompt with its own id, so withdraw the old one first.
|
||||
emit(
|
||||
output,
|
||||
Output::RequestCancelled {
|
||||
v: 1,
|
||||
request_id: old_id,
|
||||
reason: "released",
|
||||
},
|
||||
)?;
|
||||
if store.authorize(&request.public_blob).is_none() {
|
||||
let _ = request.reply.send(protocol::failure_response());
|
||||
continue;
|
||||
}
|
||||
// Already approved while the load was running: submit at the new
|
||||
// epoch and consume the approval straight away. Every check the
|
||||
// ordinary path makes still runs -- the key must be present, the
|
||||
// vault unlocked, and the epoch current at the signing primitive.
|
||||
if let Some(grant_seconds) = request.approved {
|
||||
let response = match approvals.submit(
|
||||
store.epoch(),
|
||||
&request.public_blob,
|
||||
request.peer.clone(),
|
||||
elapsed_ms(started),
|
||||
) {
|
||||
Ok(Submit::Granted(authorization)) => authorization
|
||||
.finalize(store)
|
||||
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
|
||||
.and_then(protocol::signature_response)
|
||||
.unwrap_or_else(protocol::failure_response),
|
||||
Ok(Submit::Pending(id)) => approvals
|
||||
.approve(id, grant_seconds, elapsed_ms(started))
|
||||
.ok()
|
||||
.and_then(|authorization| authorization.finalize(store))
|
||||
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
|
||||
.and_then(protocol::signature_response)
|
||||
.unwrap_or_else(protocol::failure_response),
|
||||
Err(_) => protocol::failure_response(),
|
||||
};
|
||||
let _ = request.reply.send(response);
|
||||
continue;
|
||||
}
|
||||
match approvals.submit(
|
||||
store.epoch(),
|
||||
&request.public_blob,
|
||||
request.peer.clone(),
|
||||
elapsed_ms(started),
|
||||
) {
|
||||
Ok(Submit::Granted(authorization)) => {
|
||||
let response = authorization
|
||||
.finalize(store)
|
||||
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
|
||||
.and_then(protocol::signature_response)
|
||||
.unwrap_or_else(protocol::failure_response);
|
||||
let _ = request.reply.send(response);
|
||||
}
|
||||
Ok(Submit::Pending(id)) => {
|
||||
let Some(key) = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.find(|key| key.public_blob() == request.public_blob)
|
||||
else {
|
||||
approvals.disconnect(id);
|
||||
let _ = request.reply.send(protocol::failure_response());
|
||||
continue;
|
||||
};
|
||||
emit(
|
||||
output,
|
||||
Output::ApprovalRequired {
|
||||
v: 1,
|
||||
request_id: id,
|
||||
key_id: key.item_id.clone(),
|
||||
key_name: key.name.clone(),
|
||||
fingerprint: key.fingerprint.clone(),
|
||||
pid: request.peer.pid,
|
||||
process_path: request.peer.executable.to_string_lossy().into_owned(),
|
||||
operation: "ssh-sign",
|
||||
forwarded: false,
|
||||
grant_offered: true,
|
||||
},
|
||||
)?;
|
||||
pending.insert(
|
||||
id,
|
||||
PendingSign {
|
||||
reply: request.reply,
|
||||
message: request.message,
|
||||
flags: request.flags,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = request.reply.send(protocol::failure_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer every identity listing that was waiting on an unlock. On success
|
||||
/// that is the real cache; otherwise it is the empty list a locked companion
|
||||
/// would have returned anyway, which is a normal answer rather than a failure.
|
||||
fn release_held_identities(
|
||||
held_identities: &mut Option<HeldIdentities>,
|
||||
store: &KeyStore,
|
||||
output: &mpsc::Sender<Output>,
|
||||
reason: &'static str,
|
||||
) -> Result<(), ()> {
|
||||
let Some(waiters) = held_identities.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let identities: Vec<_> = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.map(|key| (key.public_blob(), key.name.as_str()))
|
||||
.collect();
|
||||
let response = protocol::identities_response(&identities);
|
||||
for reply in waiters.waiting {
|
||||
let _ = reply.send(response.clone());
|
||||
}
|
||||
emit(
|
||||
output,
|
||||
Output::RequestCancelled {
|
||||
v: 1,
|
||||
request_id: waiters.request_id,
|
||||
reason,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Fail every held request and tell the panel to take its prompts down.
|
||||
fn cancel_held(
|
||||
held: &mut HashMap<RequestId, HeldSign>,
|
||||
reason: &'static str,
|
||||
output: &mpsc::Sender<Output>,
|
||||
) -> Result<(), ()> {
|
||||
for (id, request) in held.drain().collect::<Vec<_>>() {
|
||||
let _ = request.reply.send(protocol::failure_response());
|
||||
emit(
|
||||
output,
|
||||
Output::RequestCancelled {
|
||||
v: 1,
|
||||
request_id: id,
|
||||
reason,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Announce the live grant set, but only when it has actually changed --
|
||||
/// otherwise the hundred-millisecond tick would narrate it forever.
|
||||
fn emit_grants_if_changed(
|
||||
snapshot: &mut Vec<u64>,
|
||||
approvals: &ApprovalManager,
|
||||
store: &KeyStore,
|
||||
now_ms: u64,
|
||||
output: &mpsc::Sender<Output>,
|
||||
) -> Result<(), ()> {
|
||||
let current: Vec<u64> = approvals.grants().iter().map(|grant| grant.id).collect();
|
||||
if current == *snapshot {
|
||||
return Ok(());
|
||||
}
|
||||
*snapshot = current;
|
||||
let grants = approvals
|
||||
.grants()
|
||||
.iter()
|
||||
.map(|grant| {
|
||||
let key = store
|
||||
.public_identities()
|
||||
.iter()
|
||||
.find(|key| key.public_blob() == grant.public_blob);
|
||||
GrantView {
|
||||
grant_id: grant.id,
|
||||
key_name: key.map(|key| key.name.clone()).unwrap_or_default(),
|
||||
fingerprint: key.map(|key| key.fingerprint.clone()).unwrap_or_default(),
|
||||
pid: grant.peer.pid,
|
||||
process_path: grant.peer.executable.to_string_lossy().into_owned(),
|
||||
expires_in_sec: grant.expires_at_ms.saturating_sub(now_ms) / 1000,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
emit(output, Output::GrantsChanged { v: 1, grants })
|
||||
}
|
||||
|
||||
fn fail_pending(pending: &mut HashMap<RequestId, PendingSign>) {
|
||||
for (_, sign) in pending.drain() {
|
||||
let _ = sign.reply.send(protocol::failure_response());
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_load(active: &mut Option<ActiveLoad>) {
|
||||
if let Some(load) = active.take() {
|
||||
load.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_load_if_ready(
|
||||
active: &mut Option<ActiveLoad>,
|
||||
store: &mut KeyStore,
|
||||
gate_open: &mut bool,
|
||||
output: &mpsc::Sender<Output>,
|
||||
) -> Result<(), ()> {
|
||||
let ready = active
|
||||
.as_ref()
|
||||
.is_some_and(|load| load.end_received && load.payload.is_some());
|
||||
if !ready {
|
||||
return Ok(());
|
||||
}
|
||||
let mut load = active.take().ok_or(())?;
|
||||
load.task.abort();
|
||||
let result = load
|
||||
.payload
|
||||
.take()
|
||||
.ok_or(())?
|
||||
.map_err(|_| ())
|
||||
.and_then(|payload| load.window.decode(payload, store).map_err(|_| ()))
|
||||
.and_then(|candidate| store.publish(candidate).map_err(|_| ()));
|
||||
match result {
|
||||
Ok(report) => {
|
||||
*gate_open = true;
|
||||
// Ahead of keys_loaded, so the panel has the whole set by the
|
||||
// time it is told the load finished.
|
||||
for identity in store.public_identities() {
|
||||
emit(
|
||||
output,
|
||||
Output::PublicKey {
|
||||
v: 1,
|
||||
epoch: load.epoch,
|
||||
item_id: identity.item_id.clone(),
|
||||
name: identity.name.clone(),
|
||||
fingerprint: identity.fingerprint.clone(),
|
||||
public_key: identity.public_key_openssh.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
emit(
|
||||
output,
|
||||
Output::KeysLoaded {
|
||||
v: 1,
|
||||
epoch: load.epoch,
|
||||
key_count: report.loaded,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(()) => {
|
||||
store.lock(load.epoch);
|
||||
*gate_open = false;
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started: Instant) -> u64 {
|
||||
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Verified peer snapshots used to scope approvals and grants.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Sanitized proc-snapshot failures.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PeerError {
|
||||
Unavailable,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
/// Process context captured from kernel-owned peer/proc data.
|
||||
///
|
||||
/// UID is the socket admission boundary. PID, start time, and executable path
|
||||
/// are prompt context and grant-scoping inputs, not proof of user identity.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PeerContext {
|
||||
pub uid: u32,
|
||||
pub pid: u32,
|
||||
pub start_time_ticks: u64,
|
||||
pub executable: PathBuf,
|
||||
}
|
||||
|
||||
impl PeerContext {
|
||||
pub fn new(
|
||||
uid: u32,
|
||||
pid: u32,
|
||||
start_time_ticks: u64,
|
||||
executable: impl AsRef<Path>,
|
||||
) -> Option<Self> {
|
||||
let executable = executable.as_ref();
|
||||
if pid == 0 || start_time_ticks == 0 || !executable.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
uid,
|
||||
pid,
|
||||
start_time_ticks,
|
||||
executable: executable.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a grant taken for `self` covers a request from `other`.
|
||||
///
|
||||
/// A grant is scoped to one user and one program, deliberately not to one
|
||||
/// process. Git runs a fresh `ssh-keygen` for every commit it signs, so a
|
||||
/// PID-scoped grant never matches the workflow grants exist to serve --
|
||||
/// a twenty-commit rebase would prompt twenty times either way. The
|
||||
/// exposure this accepts is that any process at the same path benefits
|
||||
/// during the window; on an unlocked desktop a hostile same-UID process
|
||||
/// could simply run that program itself, which the threat model already
|
||||
/// declines to defend against. The UID check is not relaxed: that is the
|
||||
/// one property the companion actually verifies.
|
||||
pub fn shares_grant_scope(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid && self.executable == other.executable
|
||||
}
|
||||
|
||||
/// Capture grant-scoping context for a PID supplied by `SO_PEERCRED`.
|
||||
pub fn capture(uid: u32, pid: u32) -> Result<Self, PeerError> {
|
||||
if pid == 0 {
|
||||
return Err(PeerError::Malformed);
|
||||
}
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
||||
.map_err(|_| PeerError::Unavailable)?;
|
||||
let close = stat.rfind(')').ok_or(PeerError::Malformed)?;
|
||||
let fields: Vec<&str> = stat[close + 1..].split_whitespace().collect();
|
||||
// The remainder begins at field 3; starttime is field 22.
|
||||
let start_time_ticks = fields
|
||||
.get(19)
|
||||
.ok_or(PeerError::Malformed)?
|
||||
.parse()
|
||||
.map_err(|_| PeerError::Malformed)?;
|
||||
let executable =
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|_| PeerError::Unavailable)?;
|
||||
Self::new(uid, pid, start_time_ticks, executable).ok_or(PeerError::Malformed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Bounded, allowlisted SSH-agent protocol handling.
|
||||
//!
|
||||
//! Wire values follow RFC 9987. The handler answers only identity listing and
|
||||
//! signing; every malformed, mutation, forwarding, extension, or unknown
|
||||
//! request receives the same one-byte failure and no diagnostic data.
|
||||
|
||||
use crate::signing;
|
||||
use ssh_encoding::{Decode, Encode};
|
||||
use ssh_key::{Algorithm, PrivateKey, PublicKey};
|
||||
use std::fmt;
|
||||
|
||||
/// Largest accepted agent message body, excluding its four-byte prefix.
|
||||
pub const MAX_FRAME_LEN: usize = 256 * 1024;
|
||||
|
||||
const FAILURE: u8 = 5;
|
||||
const REQUEST_IDENTITIES: u8 = 11;
|
||||
const IDENTITIES_ANSWER: u8 = 12;
|
||||
const SIGN_REQUEST: u8 = 13;
|
||||
const SIGN_RESPONSE: u8 = 14;
|
||||
|
||||
/// Parsed allowlisted request. It contains public key selection and the
|
||||
/// payload to be signed, but never private material.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum AgentRequest {
|
||||
Identities,
|
||||
Sign {
|
||||
public_blob: Vec<u8>,
|
||||
message: Vec<u8>,
|
||||
flags: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A private identity and the bounded public values advertised for it.
|
||||
pub struct Identity {
|
||||
key: PrivateKey,
|
||||
public_blob: Vec<u8>,
|
||||
comment: String,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Construct an identity for one of the two v1 key algorithms.
|
||||
pub fn new(key: PrivateKey, comment: impl Into<String>) -> Result<Self, ProtocolError> {
|
||||
if !matches!(key.algorithm(), Algorithm::Ed25519 | Algorithm::Rsa { .. }) {
|
||||
return Err(ProtocolError);
|
||||
}
|
||||
let comment = comment.into();
|
||||
if comment.len() > MAX_FRAME_LEN {
|
||||
return Err(ProtocolError);
|
||||
}
|
||||
let public_blob = key.public_key().to_bytes().map_err(|_| ProtocolError)?;
|
||||
Ok(Self {
|
||||
key,
|
||||
public_blob,
|
||||
comment,
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenSSH public-key blob used to select and advertise this identity.
|
||||
pub fn public_blob(&self) -> &[u8] {
|
||||
&self.public_blob
|
||||
}
|
||||
|
||||
/// Human-readable identity comment.
|
||||
pub fn comment(&self) -> &str {
|
||||
&self.comment
|
||||
}
|
||||
|
||||
/// Public half of this identity.
|
||||
pub fn public_key(&self) -> &PublicKey {
|
||||
self.key.public_key()
|
||||
}
|
||||
}
|
||||
|
||||
/// An intentionally opaque construction error.
|
||||
pub struct ProtocolError;
|
||||
|
||||
impl fmt::Debug for ProtocolError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("invalid SSH identity")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle exactly one length-prefixed agent frame.
|
||||
///
|
||||
/// The length is checked before the body is sliced or any request field is
|
||||
/// allocated. The returned frame is always small enough for the configured
|
||||
/// cap; otherwise it is the normal agent failure frame.
|
||||
pub fn handle_frame(frame: &[u8], identities: &[Identity]) -> Vec<u8> {
|
||||
response(handle(frame, identities).unwrap_or_else(failure_payload))
|
||||
}
|
||||
|
||||
fn handle(frame: &[u8], identities: &[Identity]) -> Option<Vec<u8>> {
|
||||
match decode_request(frame)? {
|
||||
AgentRequest::Identities => identities_answer(identities),
|
||||
AgentRequest::Sign {
|
||||
public_blob,
|
||||
message,
|
||||
flags,
|
||||
} => sign_response_fields(&public_blob, &message, flags, identities),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_request(frame: &[u8]) -> Option<AgentRequest> {
|
||||
let header: [u8; 4] = frame.get(..4)?.try_into().ok()?;
|
||||
let declared = usize::try_from(u32::from_be_bytes(header)).ok()?;
|
||||
if declared == 0 || declared > MAX_FRAME_LEN || frame.len() != declared.checked_add(4)? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = &frame[4..];
|
||||
match payload.first().copied()? {
|
||||
REQUEST_IDENTITIES if payload.len() == 1 => Some(AgentRequest::Identities),
|
||||
SIGN_REQUEST => decode_sign_request(&payload[1..]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn identities_answer(identities: &[Identity]) -> Option<Vec<u8>> {
|
||||
let mut payload = vec![IDENTITIES_ANSWER];
|
||||
u32::try_from(identities.len())
|
||||
.ok()?
|
||||
.encode(&mut payload)
|
||||
.ok()?;
|
||||
for identity in identities {
|
||||
identity.public_blob.encode(&mut payload).ok()?;
|
||||
identity.comment.encode(&mut payload).ok()?;
|
||||
if payload.len() > MAX_FRAME_LEN {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn decode_sign_request(mut fields: &[u8]) -> Option<AgentRequest> {
|
||||
let key_blob = Vec::<u8>::decode(&mut fields).ok()?;
|
||||
let message = Vec::<u8>::decode(&mut fields).ok()?;
|
||||
let flags = u32::decode(&mut fields).ok()?;
|
||||
if !fields.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(AgentRequest::Sign {
|
||||
public_blob: key_blob,
|
||||
message,
|
||||
flags,
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_response_fields(
|
||||
key_blob: &[u8],
|
||||
message: &[u8],
|
||||
flags: u32,
|
||||
identities: &[Identity],
|
||||
) -> Option<Vec<u8>> {
|
||||
let identity = identities
|
||||
.iter()
|
||||
.find(|identity| identity.public_blob == key_blob)?;
|
||||
let signature = signing::sign(&identity.key, message, flags)?;
|
||||
signature_payload(signature)
|
||||
}
|
||||
|
||||
pub fn signature_response(signature: ssh_key::Signature) -> Option<Vec<u8>> {
|
||||
signature_payload(signature).map(response)
|
||||
}
|
||||
|
||||
fn signature_payload(signature: ssh_key::Signature) -> Option<Vec<u8>> {
|
||||
let signature_bytes = Vec::<u8>::try_from(signature).ok()?;
|
||||
let mut payload = vec![SIGN_RESPONSE];
|
||||
signature_bytes.encode(&mut payload).ok()?;
|
||||
(payload.len() <= MAX_FRAME_LEN).then_some(payload)
|
||||
}
|
||||
|
||||
pub fn identities_response(public: &[(&[u8], &str)]) -> Vec<u8> {
|
||||
let mut payload = vec![IDENTITIES_ANSWER];
|
||||
let Some(count) = u32::try_from(public.len()).ok() else {
|
||||
return failure_response();
|
||||
};
|
||||
if count.encode(&mut payload).is_err() {
|
||||
return failure_response();
|
||||
}
|
||||
for (blob, comment) in public {
|
||||
if blob.encode(&mut payload).is_err()
|
||||
|| comment.encode(&mut payload).is_err()
|
||||
|| payload.len() > MAX_FRAME_LEN
|
||||
{
|
||||
return failure_response();
|
||||
}
|
||||
}
|
||||
response(payload)
|
||||
}
|
||||
|
||||
pub fn failure_response() -> Vec<u8> {
|
||||
response(failure_payload())
|
||||
}
|
||||
|
||||
fn failure_payload() -> Vec<u8> {
|
||||
vec![FAILURE]
|
||||
}
|
||||
|
||||
fn response(payload: Vec<u8>) -> Vec<u8> {
|
||||
if payload.len() > MAX_FRAME_LEN {
|
||||
return vec![0, 0, 0, 1, FAILURE];
|
||||
}
|
||||
let mut frame = Vec::with_capacity(payload.len() + 4);
|
||||
let Ok(length) = u32::try_from(payload.len()) else {
|
||||
return vec![0, 0, 0, 1, FAILURE];
|
||||
};
|
||||
frame.extend_from_slice(&length.to_be_bytes());
|
||||
frame.extend_from_slice(&payload);
|
||||
frame
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Private runtime-directory and key-load FIFO creation.
|
||||
|
||||
use rustix::fs::{self, FlockOperation, Mode, OFlags, CWD};
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::keystore::MAX_FILTERED_BYTES;
|
||||
|
||||
const RUNTIME_NAME: &str = "qs-bitwarden-cli";
|
||||
const FIFO_NAME: &str = "ssh-keys.fifo";
|
||||
const LOCK_NAME: &str = "ssh-agent.lock";
|
||||
const SOCKET_NAME: &str = "ssh-agent.sock";
|
||||
|
||||
/// Sanitized runtime setup failures.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimeError {
|
||||
Io,
|
||||
UnsafeDirectory,
|
||||
UnsafeFifo,
|
||||
UnsafeLock,
|
||||
UnsafeSocket,
|
||||
AlreadyRunning,
|
||||
PayloadTooLarge,
|
||||
MultiplePayloads,
|
||||
ReadTimeout,
|
||||
}
|
||||
|
||||
/// Open private runtime paths. The FIFO descriptor remains open read/write so
|
||||
/// writers do not observe transient EOF or SIGPIPE between loads.
|
||||
pub struct Runtime {
|
||||
directory: PathBuf,
|
||||
fifo_path: PathBuf,
|
||||
fifo: File,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Runtime {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("Runtime { verified private paths }")
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulator for newline-delimited, byte-bounded FIFO payload framing.
|
||||
struct PayloadAccumulator {
|
||||
payload: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl PayloadAccumulator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
payload: Zeroizing::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> Result<Option<Zeroizing<Vec<u8>>>, RuntimeError> {
|
||||
self.payload.extend_from_slice(chunk);
|
||||
if self.payload.len() > MAX_FILTERED_BYTES + 1 {
|
||||
return Err(RuntimeError::PayloadTooLarge);
|
||||
}
|
||||
if let Some(newline) = self.payload.iter().position(|byte| *byte == b'\n') {
|
||||
if self.payload[newline + 1..]
|
||||
.iter()
|
||||
.any(|byte| !byte.is_ascii_whitespace())
|
||||
{
|
||||
return Err(RuntimeError::MultiplePayloads);
|
||||
}
|
||||
self.payload.truncate(newline);
|
||||
return Ok(Some(std::mem::take(&mut self.payload)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Create a fresh FIFO below `runtime_root`, refusing every existing FIFO
|
||||
/// path and every directory that is not a same-owner real `0700` directory.
|
||||
pub fn create(runtime_root: &Path) -> Result<Self, RuntimeError> {
|
||||
let directory = ensure_runtime_directory(runtime_root)?;
|
||||
Self::create_in(directory)
|
||||
}
|
||||
|
||||
fn create_in(directory: PathBuf) -> Result<Self, RuntimeError> {
|
||||
let fifo_path = directory.join(FIFO_NAME);
|
||||
if std::fs::symlink_metadata(&fifo_path).is_ok() {
|
||||
return Err(RuntimeError::UnsafeFifo);
|
||||
}
|
||||
fs::mkfifoat(CWD, &fifo_path, Mode::RUSR | Mode::WUSR).map_err(|_| RuntimeError::Io)?;
|
||||
let fd = fs::open(
|
||||
&fifo_path,
|
||||
OFlags::RDWR | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)
|
||||
.map_err(|_| RuntimeError::UnsafeFifo)?;
|
||||
let fifo = File::from(fd);
|
||||
let metadata = fifo.metadata().map_err(|_| RuntimeError::Io)?;
|
||||
if !metadata.file_type().is_fifo()
|
||||
|| metadata.uid() != rustix::process::geteuid().as_raw()
|
||||
|| metadata.mode() & 0o777 != 0o600
|
||||
{
|
||||
return Err(RuntimeError::UnsafeFifo);
|
||||
}
|
||||
Ok(Self {
|
||||
directory,
|
||||
fifo_path,
|
||||
fifo,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn directory(&self) -> &Path {
|
||||
&self.directory
|
||||
}
|
||||
|
||||
pub fn fifo_path(&self) -> &Path {
|
||||
&self.fifo_path
|
||||
}
|
||||
|
||||
pub fn fifo(&self) -> &File {
|
||||
&self.fifo
|
||||
}
|
||||
|
||||
pub fn fifo_reader(&self) -> Result<File, RuntimeError> {
|
||||
self.fifo.try_clone().map_err(|_| RuntimeError::Io)
|
||||
}
|
||||
|
||||
/// Drain one newline-delimited `jq -c` payload under hard byte/time bounds.
|
||||
pub fn read_payload(&mut self, timeout: Duration) -> Result<Zeroizing<Vec<u8>>, RuntimeError> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut accumulator = PayloadAccumulator::new();
|
||||
let mut chunk = [0_u8; 8192];
|
||||
loop {
|
||||
let idle = match self.fifo.read(&mut chunk) {
|
||||
Ok(0) => true,
|
||||
Ok(count) => match accumulator.push(&chunk[..count])? {
|
||||
Some(payload) => return Ok(payload),
|
||||
None => false,
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => true,
|
||||
Err(_) => return Err(RuntimeError::Io),
|
||||
};
|
||||
if Instant::now() >= deadline {
|
||||
return Err(RuntimeError::ReadTimeout);
|
||||
}
|
||||
if idle {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async FIFO drain used by the current-thread companion. `AsyncFd` waits for
|
||||
/// readiness without a blocking worker thread, so control/lock messages remain
|
||||
/// serviceable while a producer is slow.
|
||||
pub async fn read_payload_async(
|
||||
fifo: File,
|
||||
timeout: Duration,
|
||||
) -> Result<Zeroizing<Vec<u8>>, RuntimeError> {
|
||||
let fifo = tokio::io::unix::AsyncFd::new(fifo).map_err(|_| RuntimeError::Io)?;
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut accumulator = PayloadAccumulator::new();
|
||||
let mut chunk = [0_u8; 8192];
|
||||
loop {
|
||||
let mut ready = fifo.readable().await.map_err(|_| RuntimeError::Io)?;
|
||||
match ready.try_io(|inner| {
|
||||
let mut file = inner.get_ref();
|
||||
file.read(&mut chunk)
|
||||
}) {
|
||||
// EOF, not a spurious wakeup. `try_io` only clears readiness
|
||||
// on `WouldBlock`, so a producer that closed without a newline
|
||||
// leaves this readable for good: continuing straight back would
|
||||
// spin a core flat out until the timeout. Paced the same way
|
||||
// the blocking twin above paces its idle reads.
|
||||
Ok(Ok(0)) => tokio::time::sleep(Duration::from_millis(1)).await,
|
||||
Ok(Ok(count)) => {
|
||||
if let Some(payload) = accumulator.push(&chunk[..count])? {
|
||||
return Ok(payload);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Err(RuntimeError::Io),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| RuntimeError::ReadTimeout)?
|
||||
}
|
||||
|
||||
/// Singleton-owned runtime. The lock is acquired before stale paths are ever
|
||||
/// inspected or removed, closing the restart race between two companions.
|
||||
pub struct ServiceRuntime {
|
||||
runtime: Runtime,
|
||||
socket_path: PathBuf,
|
||||
lock_path: PathBuf,
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
impl ServiceRuntime {
|
||||
pub fn acquire(runtime_root: &Path) -> Result<Self, RuntimeError> {
|
||||
let directory = ensure_runtime_directory(runtime_root)?;
|
||||
let lock_path = directory.join(LOCK_NAME);
|
||||
let lock = File::from(
|
||||
fs::open(
|
||||
&lock_path,
|
||||
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
)
|
||||
.map_err(|_| RuntimeError::UnsafeLock)?,
|
||||
);
|
||||
let metadata = lock.metadata().map_err(|_| RuntimeError::Io)?;
|
||||
if !metadata.file_type().is_file()
|
||||
|| metadata.uid() != rustix::process::geteuid().as_raw()
|
||||
|| metadata.mode() & 0o777 != 0o600
|
||||
{
|
||||
return Err(RuntimeError::UnsafeLock);
|
||||
}
|
||||
fs::flock(&lock, FlockOperation::NonBlockingLockExclusive)
|
||||
.map_err(|_| RuntimeError::AlreadyRunning)?;
|
||||
|
||||
remove_stale(&directory.join(FIFO_NAME), StaleKind::Fifo)?;
|
||||
let socket_path = directory.join(SOCKET_NAME);
|
||||
remove_stale(&socket_path, StaleKind::Socket)?;
|
||||
let runtime = Runtime::create_in(directory)?;
|
||||
Ok(Self {
|
||||
runtime,
|
||||
socket_path,
|
||||
lock_path,
|
||||
_lock: lock,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn runtime(&self) -> &Runtime {
|
||||
&self.runtime
|
||||
}
|
||||
pub fn runtime_mut(&mut self) -> &mut Runtime {
|
||||
&mut self.runtime
|
||||
}
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
pub fn bind_socket(&self) -> Result<tokio::net::UnixListener, RuntimeError> {
|
||||
let listener = std::os::unix::net::UnixListener::bind(&self.socket_path)
|
||||
.map_err(|_| RuntimeError::Io)?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|_| RuntimeError::Io)?;
|
||||
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| RuntimeError::Io)?;
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&self.socket_path).map_err(|_| RuntimeError::Io)?;
|
||||
if !metadata.file_type().is_socket()
|
||||
|| metadata.uid() != rustix::process::geteuid().as_raw()
|
||||
|| metadata.mode() & 0o777 != 0o600
|
||||
{
|
||||
return Err(RuntimeError::UnsafeSocket);
|
||||
}
|
||||
tokio::net::UnixListener::from_std(listener).map_err(|_| RuntimeError::Io)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServiceRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
let _ = std::fs::remove_file(self.runtime.fifo_path());
|
||||
let _ = std::fs::remove_file(&self.lock_path);
|
||||
let _ = std::fs::remove_dir(self.runtime.directory());
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_runtime_directory(runtime_root: &Path) -> Result<PathBuf, RuntimeError> {
|
||||
let directory = runtime_root.join(RUNTIME_NAME);
|
||||
match std::fs::create_dir(&directory) {
|
||||
Ok(()) => std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| RuntimeError::Io)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(_) => return Err(RuntimeError::Io),
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(&directory).map_err(|_| RuntimeError::Io)?;
|
||||
if !metadata.file_type().is_dir()
|
||||
|| metadata.file_type().is_symlink()
|
||||
|| metadata.uid() != rustix::process::geteuid().as_raw()
|
||||
|| metadata.mode() & 0o777 != 0o700
|
||||
{
|
||||
return Err(RuntimeError::UnsafeDirectory);
|
||||
}
|
||||
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
enum StaleKind {
|
||||
Fifo,
|
||||
Socket,
|
||||
}
|
||||
|
||||
fn remove_stale(path: &Path, kind: StaleKind) -> Result<(), RuntimeError> {
|
||||
let metadata = match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(RuntimeError::Io),
|
||||
};
|
||||
let expected = match kind {
|
||||
StaleKind::Fifo => metadata.file_type().is_fifo(),
|
||||
StaleKind::Socket => metadata.file_type().is_socket(),
|
||||
};
|
||||
if !expected
|
||||
|| metadata.file_type().is_symlink()
|
||||
|| metadata.uid() != rustix::process::geteuid().as_raw()
|
||||
{
|
||||
return Err(match kind {
|
||||
StaleKind::Fifo => RuntimeError::UnsafeFifo,
|
||||
StaleKind::Socket => RuntimeError::UnsafeSocket,
|
||||
});
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|_| RuntimeError::Io)
|
||||
}
|
||||
|
||||
impl Drop for Runtime {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.fifo_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! A launch-time smoke test the panel can run before it trusts this binary.
|
||||
//!
|
||||
//! What this proves: the binary executes on this machine, its crypto library
|
||||
//! loads and computes correctly, its frame and control parsers work and reject
|
||||
//! what they should, and the kernel supports the process hardening the rest of
|
||||
//! the design depends on.
|
||||
//!
|
||||
//! What it deliberately does not prove: that signing produces a correct
|
||||
//! signature. Doing that needs a private key, and the only honest ways to get
|
||||
//! one are to generate it -- which would put a random-number generator into a
|
||||
//! key-holding binary's dependency tree for the sake of a smoke test -- or to
|
||||
//! embed one, which is exactly what this project refuses to do anywhere else.
|
||||
//! The verification path below exercises the same crypto backend; the signing
|
||||
//! path is covered by the test suite, where generating a disposable key costs
|
||||
//! nothing.
|
||||
//!
|
||||
//! It touches no filesystem, opens no socket, and needs no runtime directory,
|
||||
//! because it runs before any of those exist.
|
||||
|
||||
use crate::control::{parse_control_line, ControlError, ControlMessage, MAX_CONTROL_LINE};
|
||||
use crate::protocol::{self, AgentRequest, MAX_FRAME_LEN};
|
||||
use ssh_encoding::Encode;
|
||||
use ssh_key::{HashAlg, PublicKey};
|
||||
|
||||
/// A disposable public key, generated for this check and belonging to nobody.
|
||||
/// Public material only -- there is no private counterpart anywhere.
|
||||
const FIXTURE_PUBLIC_KEY: &str =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgSTquIEW1Ui0iRAQcZZAjS1OIA/D6Q+Arq/JfoVLkh";
|
||||
|
||||
/// One named check and whether it held.
|
||||
struct Check {
|
||||
name: &'static str,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub fn run() -> i32 {
|
||||
let checks = vec![
|
||||
Check {
|
||||
name: "process hardening (RLIMIT_CORE=0, PR_SET_DUMPABLE=0)",
|
||||
ok: hardening_available(),
|
||||
},
|
||||
Check {
|
||||
name: "public key parsing and SHA256 fingerprint",
|
||||
ok: public_key_math(),
|
||||
},
|
||||
Check {
|
||||
name: "agent frame encode and decode",
|
||||
ok: frame_round_trip(),
|
||||
},
|
||||
Check {
|
||||
name: "oversized frames rejected before allocation",
|
||||
ok: frame_bounds(),
|
||||
},
|
||||
Check {
|
||||
name: "control protocol v1 accepted, other versions refused",
|
||||
ok: control_versions(),
|
||||
},
|
||||
];
|
||||
|
||||
let failed = checks.iter().filter(|check| !check.ok).count();
|
||||
for check in &checks {
|
||||
println!("{} {}", if check.ok { "ok " } else { "FAIL" }, check.name);
|
||||
}
|
||||
if failed == 0 {
|
||||
println!("ok: {} checks passed", checks.len());
|
||||
println!("note: signing is exercised by the test suite, not here -- see selftest.rs");
|
||||
0
|
||||
} else {
|
||||
println!("FAILED: {failed} of {} checks", checks.len());
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardening is applied for real, then read back. A kernel that refuses
|
||||
/// either of these is one where the design's assumptions about core dumps and
|
||||
/// same-UID inspection do not hold, and the panel should know before it hands
|
||||
/// this process any keys.
|
||||
fn hardening_available() -> bool {
|
||||
if crate::lifecycle::harden_process().is_err() {
|
||||
return false;
|
||||
}
|
||||
let core = rustix::process::getrlimit(rustix::process::Resource::Core);
|
||||
let dumpable = rustix::process::dumpable_behavior();
|
||||
core.current == Some(0)
|
||||
&& matches!(dumpable, Ok(rustix::process::DumpableBehavior::NotDumpable))
|
||||
}
|
||||
|
||||
/// Parses a real public key and derives its fingerprint, which exercises the
|
||||
/// same ssh-key backend the signing path uses.
|
||||
fn public_key_math() -> bool {
|
||||
let Ok(key) = PublicKey::from_openssh(FIXTURE_PUBLIC_KEY) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(key.algorithm(), ssh_key::Algorithm::Ed25519) {
|
||||
return false;
|
||||
}
|
||||
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
|
||||
// A fingerprint is base64 of a SHA-256 digest, so its shape is fixed.
|
||||
fingerprint.starts_with("SHA256:") && fingerprint.len() > 20 && key.to_bytes().is_ok()
|
||||
}
|
||||
|
||||
/// A sign request built here, decoded by the real decoder, and an identities
|
||||
/// response encoded by the real encoder.
|
||||
fn frame_round_trip() -> bool {
|
||||
let Ok(key) = PublicKey::from_openssh(FIXTURE_PUBLIC_KEY) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(blob) = key.to_bytes() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut body = Vec::new();
|
||||
if 13_u8.encode(&mut body).is_err()
|
||||
|| blob.as_slice().encode(&mut body).is_err()
|
||||
|| b"self-test".as_slice().encode(&mut body).is_err()
|
||||
|| 0_u32.encode(&mut body).is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut frame = match u32::try_from(body.len()) {
|
||||
Ok(length) => length.to_be_bytes().to_vec(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
frame.extend_from_slice(&body);
|
||||
|
||||
let decoded = matches!(
|
||||
protocol::decode_request(&frame),
|
||||
Some(AgentRequest::Sign { .. })
|
||||
);
|
||||
let listed = protocol::identities_response(&[(blob.as_slice(), "self-test")]);
|
||||
decoded && listed.len() > 4
|
||||
}
|
||||
|
||||
/// The ceiling is checked before anything is allocated, so a claimed length
|
||||
/// beyond it must be refused rather than believed.
|
||||
fn frame_bounds() -> bool {
|
||||
let mut oversized = u32::try_from(MAX_FRAME_LEN + 1)
|
||||
.unwrap_or(u32::MAX)
|
||||
.to_be_bytes()
|
||||
.to_vec();
|
||||
oversized.push(11);
|
||||
let empty = [0_u8, 0, 0, 0];
|
||||
protocol::decode_request(&oversized).is_none() && protocol::decode_request(&empty).is_none()
|
||||
}
|
||||
|
||||
/// The control channel is versioned so an old bundled binary fails clearly
|
||||
/// after a plugin update rather than misreading a newer panel.
|
||||
fn control_versions() -> bool {
|
||||
let hello = parse_control_line(br#"{"v":1,"type":"hello"}"#);
|
||||
let wrong_version = parse_control_line(br#"{"v":2,"type":"hello"}"#);
|
||||
let unknown = parse_control_line(br#"{"v":1,"type":"exec"}"#);
|
||||
let mut overlong = vec![b'{'; MAX_CONTROL_LINE + 1];
|
||||
overlong.push(b'}');
|
||||
|
||||
matches!(hello, Ok(ControlMessage::Hello { .. }))
|
||||
&& matches!(wrong_version, Err(ControlError::WrongVersion))
|
||||
&& matches!(unknown, Err(ControlError::Malformed))
|
||||
&& matches!(parse_control_line(&overlong), Err(ControlError::TooLong))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Bounded Unix-socket client transport for the single-owner state loop.
|
||||
|
||||
use crate::peer::PeerContext;
|
||||
use crate::protocol::{self, AgentRequest, MAX_FRAME_LEN};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{mpsc, oneshot, Semaphore};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
pub const MAX_CLIENTS: usize = 8;
|
||||
/// Socket read and write timeouts. These are machine-speed operations, so
|
||||
/// they stay short regardless of how long a person may take to answer.
|
||||
pub const CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long a client blocks waiting for the state loop's answer. It must
|
||||
/// exceed `approvals::REQUEST_LIFETIME_MS`, or a client would give up before
|
||||
/// the request it is waiting on expires and the human deadline would be
|
||||
/// decorative -- which it was when both were thirty seconds.
|
||||
pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(150);
|
||||
const ACCEPT_ERROR_DELAY: Duration = Duration::from_millis(100);
|
||||
|
||||
pub struct ClientEvent {
|
||||
pub peer: PeerContext,
|
||||
pub request: AgentRequest,
|
||||
pub reply: oneshot::Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn run(listener: UnixListener, events: mpsc::Sender<ClientEvent>) {
|
||||
let permits = Arc::new(Semaphore::new(MAX_CLIENTS));
|
||||
loop {
|
||||
let stream = match listener.accept().await {
|
||||
Ok((stream, _)) => stream,
|
||||
Err(_) => {
|
||||
// accept(2) can surface connection and resource errors which
|
||||
// do not invalidate the listener. There is no portable error
|
||||
// taxonomy that proves this descriptor has become unusable,
|
||||
// so keep serving and pace persistent failures; shutdown
|
||||
// aborts this task with the rest of the companion.
|
||||
tokio::time::sleep(ACCEPT_ERROR_DELAY).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(permit) = permits.clone().try_acquire_owned() else {
|
||||
drop(stream);
|
||||
continue;
|
||||
};
|
||||
let events = events.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
serve_client(stream, events).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_client(mut stream: UnixStream, events: mpsc::Sender<ClientEvent>) {
|
||||
let Ok(credentials) = stream.peer_cred() else {
|
||||
return;
|
||||
};
|
||||
let Some(pid) = credentials.pid() else { return };
|
||||
let Ok(pid) = u32::try_from(pid) else { return };
|
||||
let Ok(peer) = PeerContext::capture(credentials.uid(), pid) else {
|
||||
return;
|
||||
};
|
||||
|
||||
loop {
|
||||
let Some(frame) = read_frame(&mut stream).await else {
|
||||
return;
|
||||
};
|
||||
let Some(request) = protocol::decode_request(&frame) else {
|
||||
if write_response(&mut stream, protocol::failure_response())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let (reply, response) = oneshot::channel();
|
||||
if events
|
||||
.try_send(ClientEvent {
|
||||
peer: peer.clone(),
|
||||
request,
|
||||
reply,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
if write_response(&mut stream, protocol::failure_response())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Watch the socket while the request is pending. Awaiting only the
|
||||
// reply would leave a client that walked away undetected until the
|
||||
// deadline -- and a prompt on screen for a signature nobody is
|
||||
// waiting for any more. Returning here drops the reply channel, which
|
||||
// is what tells the state loop to withdraw the request.
|
||||
//
|
||||
// Anything that actually arrives is either EOF or a pipelined frame,
|
||||
// which this protocol does not use; both end the connection.
|
||||
let mut probe = [0_u8; 1];
|
||||
let bytes = tokio::select! {
|
||||
result = timeout(RESPONSE_TIMEOUT, response) => match result {
|
||||
Ok(Ok(bytes)) => bytes,
|
||||
_ => protocol::failure_response(),
|
||||
},
|
||||
_ = stream.read(&mut probe) => return,
|
||||
};
|
||||
if write_response(&mut stream, bytes).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_frame(stream: &mut UnixStream) -> Option<Vec<u8>> {
|
||||
let mut header = [0_u8; 4];
|
||||
timeout(CLIENT_IO_TIMEOUT, stream.read_exact(&mut header))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
let length = usize::try_from(u32::from_be_bytes(header)).ok()?;
|
||||
if length == 0 || length > MAX_FRAME_LEN {
|
||||
return None;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(length + 4);
|
||||
frame.extend_from_slice(&header);
|
||||
frame.resize(length + 4, 0);
|
||||
timeout(CLIENT_IO_TIMEOUT, stream.read_exact(&mut frame[4..]))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
Some(frame)
|
||||
}
|
||||
|
||||
async fn write_response(stream: &mut UnixStream, response: Vec<u8>) -> std::io::Result<()> {
|
||||
timeout(CLIENT_IO_TIMEOUT, stream.write_all(&response))
|
||||
.await
|
||||
.map_err(|_| std::io::ErrorKind::TimedOut)??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The two signing paths allowed by the v1 agent protocol.
|
||||
|
||||
use crate::rsa_keys;
|
||||
use signature::{SignatureEncoding, Signer};
|
||||
use ssh_key::{private::KeypairData, Algorithm, HashAlg, PrivateKey, Signature};
|
||||
|
||||
/// Sign `message` with the exact algorithm selected by agent-protocol flags.
|
||||
///
|
||||
/// Callers deliberately receive no underlying crypto error: errors can carry
|
||||
/// parser or key context and the wire protocol has only a generic failure.
|
||||
pub(crate) fn sign(key: &PrivateKey, message: &[u8], flags: u32) -> Option<Signature> {
|
||||
match key.key_data() {
|
||||
KeypairData::Ed25519(_) if flags == 0 => key.try_sign(message).ok(),
|
||||
KeypairData::Rsa(keypair) => {
|
||||
let hash = match flags {
|
||||
2 => HashAlg::Sha256,
|
||||
4 => HashAlg::Sha512,
|
||||
_ => return None,
|
||||
};
|
||||
let bytes = match rsa_keys::sha2_signing_key(keypair, hash).ok()? {
|
||||
rsa_keys::Sha2SigningKey::Sha256(signing) => {
|
||||
signing.try_sign(message).ok()?.to_vec()
|
||||
}
|
||||
rsa_keys::Sha2SigningKey::Sha512(signing) => {
|
||||
signing.try_sign(message).ok()?.to_vec()
|
||||
}
|
||||
};
|
||||
Signature::new(Algorithm::Rsa { hash: Some(hash) }, bytes).ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Explicit vault state and epoch tracking for the signing worker.
|
||||
|
||||
/// State relevant to identity visibility and signing authorization.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum VaultState {
|
||||
/// No account/public cache is available.
|
||||
LoggedOut,
|
||||
/// A candidate is being validated; private signing is denied.
|
||||
Loading,
|
||||
/// A validated private set is available for the current epoch.
|
||||
Unlocked,
|
||||
/// Only the last validated public cache remains.
|
||||
LockedCached,
|
||||
/// Locked before any public cache has been loaded.
|
||||
LockedEmpty,
|
||||
}
|
||||
|
||||
/// Single-owner state tracker. Mutating methods are the authorization
|
||||
/// linearization points used by the keystore actor.
|
||||
pub(crate) struct StateTracker {
|
||||
epoch: u64,
|
||||
state: VaultState,
|
||||
}
|
||||
|
||||
impl StateTracker {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
epoch: 0,
|
||||
state: VaultState::LoggedOut,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_load(&mut self, epoch: u64) -> bool {
|
||||
if epoch <= self.epoch {
|
||||
return false;
|
||||
}
|
||||
self.epoch = epoch;
|
||||
self.state = VaultState::Loading;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&mut self, epoch: u64) -> bool {
|
||||
if self.epoch != epoch || self.state != VaultState::Loading {
|
||||
return false;
|
||||
}
|
||||
self.state = VaultState::Unlocked;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn lock(&mut self, epoch: u64, has_public_cache: bool) {
|
||||
// This assignment is the deny-signing linearization point. Private
|
||||
// values are dropped by the owner only after this returns.
|
||||
self.epoch = self.epoch.max(epoch);
|
||||
self.state = if has_public_cache {
|
||||
VaultState::LockedCached
|
||||
} else {
|
||||
VaultState::LockedEmpty
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn logout(&mut self, epoch: u64) {
|
||||
self.epoch = self.epoch.max(epoch);
|
||||
self.state = VaultState::LoggedOut;
|
||||
}
|
||||
|
||||
pub(crate) fn allows(&self, epoch: u64) -> bool {
|
||||
self.epoch == epoch && self.state == VaultState::Unlocked
|
||||
}
|
||||
|
||||
pub(crate) fn epoch(&self) -> u64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> VaultState {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use qs_bitwarden_ssh_agent::approvals::{ApprovalError, ApprovalManager, Submit};
|
||||
use qs_bitwarden_ssh_agent::keystore::{CandidateItem, KeyStore};
|
||||
use qs_bitwarden_ssh_agent::peer::PeerContext;
|
||||
use rand_core::OsRng;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn peer(pid: u32, start: u64, executable: &str) -> PeerContext {
|
||||
PeerContext::new(rustix::process::geteuid().as_raw(), pid, start, executable).unwrap()
|
||||
}
|
||||
|
||||
fn loaded_store(epoch: u64) -> (KeyStore, Vec<u8>) {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(epoch, 4096).unwrap();
|
||||
load.add(CandidateItem {
|
||||
item_id: "item".into(),
|
||||
name: "Work".into(),
|
||||
private_key_pem: Zeroizing::new(
|
||||
key.to_openssh(Default::default())
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
public_key: key.public_key().to_openssh().unwrap(),
|
||||
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
requires_reprompt: false,
|
||||
})
|
||||
.unwrap();
|
||||
store.publish(load).unwrap();
|
||||
(store, blob)
|
||||
}
|
||||
|
||||
/// Two clocks bound one wait, and they are not independent. The companion's
|
||||
/// request deadline is the human's time to answer; the server's reply wait is
|
||||
/// how long a client blocks for that answer. If the second is shorter, the
|
||||
/// first is decorative -- which it was, with both set to thirty seconds.
|
||||
#[test]
|
||||
fn a_client_waits_longer_than_the_human_is_given_to_answer() {
|
||||
assert!(
|
||||
qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT
|
||||
> std::time::Duration::from_millis(
|
||||
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS
|
||||
),
|
||||
"a client must not give up before the request it is waiting on expires"
|
||||
);
|
||||
// Reading a frame or writing a reply is machine-speed and stays short;
|
||||
// only the wait on a person is long.
|
||||
assert!(
|
||||
qs_bitwarden_ssh_agent::server::CLIENT_IO_TIMEOUT
|
||||
< qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT,
|
||||
"socket I/O should not inherit the human-scale timeout"
|
||||
);
|
||||
// The number itself, so raising it stays a deliberate act.
|
||||
assert_eq!(
|
||||
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS,
|
||||
120_000,
|
||||
"see docs/decisions/0003-request-deadline.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_is_bounded_expires_and_disconnect_cancels() {
|
||||
let (_, key) = loaded_store(1);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let client = peer(100, 10, "/usr/bin/ssh");
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..4 {
|
||||
match approvals.submit(1, &key, client.clone(), 1_000).unwrap() {
|
||||
Submit::Pending(id) => ids.push(id),
|
||||
Submit::Granted(_) => panic!("no grant exists"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
approvals.submit(1, &key, client.clone(), 1_000),
|
||||
Err(ApprovalError::QueueFull)
|
||||
);
|
||||
approvals.disconnect(ids[0]);
|
||||
assert_eq!(
|
||||
approvals.approve(ids[0], 0, 1_001),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
// Derived from the lifetime rather than hardcoded, so changing the
|
||||
// deadline cannot leave this test asserting the old one.
|
||||
let past_deadline = qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS + 1_001;
|
||||
approvals.expire(past_deadline - 1_001 - 1);
|
||||
assert_ne!(
|
||||
approvals.pending_count(),
|
||||
0,
|
||||
"a request must survive right up to its deadline"
|
||||
);
|
||||
approvals.expire(past_deadline);
|
||||
assert_eq!(approvals.pending_count(), 0);
|
||||
assert_eq!(
|
||||
approvals.approve(ids[1], 0, past_deadline),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_is_single_use_and_old_epoch_fails_at_final_check() {
|
||||
let (mut store, key) = loaded_store(7);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let id = match approvals
|
||||
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 0)
|
||||
.unwrap()
|
||||
{
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let authorization = approvals.approve(id, 0, 1).unwrap();
|
||||
assert_eq!(
|
||||
approvals.approve(id, 0, 1),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
assert!(authorization.finalize(&store).is_some());
|
||||
let second = match approvals
|
||||
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 2)
|
||||
.unwrap()
|
||||
{
|
||||
Submit::Pending(id) => approvals.approve(id, 0, 2).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
store.lock(8);
|
||||
assert!(second.finalize(&store).is_none());
|
||||
}
|
||||
|
||||
/// A grant covers one key and one program, not one process. Git spawns a
|
||||
/// fresh `ssh-keygen` for every commit it signs, so a grant tied to a PID
|
||||
/// never matches the case grants exist for -- a rebase would prompt once per
|
||||
/// commit regardless. Scoping to the executable path is what makes the
|
||||
/// feature do its job; see docs/decisions/0002-grant-scope.md for the
|
||||
/// exposure this accepts.
|
||||
#[test]
|
||||
fn grants_are_capped_and_bound_to_key_and_executable() {
|
||||
let (_, key) = loaded_store(3);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let original = peer(200, 50, "/usr/bin/git");
|
||||
let id = match approvals.submit(3, &key, original.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 10_000, 10).unwrap();
|
||||
assert_eq!(approvals.grants()[0].expires_at_ms, 900_010);
|
||||
assert!(matches!(
|
||||
approvals.submit(3, &key, original.clone(), 20).unwrap(),
|
||||
Submit::Granted(_)
|
||||
));
|
||||
|
||||
// The case that matters: a different process, same program. Every commit
|
||||
// in a rebase looks like this.
|
||||
assert!(
|
||||
matches!(
|
||||
approvals
|
||||
.submit(3, &key, peer(9001, 7777, "/usr/bin/git"), 20)
|
||||
.unwrap(),
|
||||
Submit::Granted(_)
|
||||
),
|
||||
"a fresh process running the same program must ride the grant"
|
||||
);
|
||||
|
||||
// A different program does not, even from the same process identity.
|
||||
assert!(matches!(
|
||||
approvals
|
||||
.submit(3, &key, peer(200, 50, "/usr/bin/ssh"), 20)
|
||||
.unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
// Nor does a different key.
|
||||
assert!(matches!(
|
||||
approvals.submit(3, b"different key", original, 20).unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// Widening the scope to a program must not widen it across users. The peer
|
||||
/// UID is the one thing the companion actually verifies.
|
||||
#[test]
|
||||
fn a_grant_never_crosses_to_another_user() {
|
||||
let (_, key) = loaded_store(3);
|
||||
let expected = rustix::process::geteuid().as_raw();
|
||||
let mut approvals = ApprovalManager::new(expected);
|
||||
let mine = peer(200, 50, "/usr/bin/git");
|
||||
let id = match approvals.submit(3, &key, mine, 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 10).unwrap();
|
||||
|
||||
let theirs = PeerContext::new(expected.wrapping_add(1), 201, 51, "/usr/bin/git").unwrap();
|
||||
assert!(
|
||||
approvals.submit(3, &key, theirs, 20).is_err(),
|
||||
"another user must not reach a grant, whatever program they run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_uid_and_lifecycle_revocation_fail_closed() {
|
||||
let (_, key) = loaded_store(5);
|
||||
let expected = rustix::process::geteuid().as_raw();
|
||||
let mut approvals = ApprovalManager::new(expected);
|
||||
let wrong = PeerContext::new(expected.wrapping_add(1), 1, 1, "/usr/bin/ssh").unwrap();
|
||||
assert_eq!(
|
||||
approvals.submit(5, &key, wrong, 0),
|
||||
Err(ApprovalError::WrongUid)
|
||||
);
|
||||
|
||||
let p = peer(300, 60, "/usr/bin/ssh");
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
let grant_id = approvals.grants()[0].id;
|
||||
approvals.revoke_grant(grant_id);
|
||||
assert!(approvals.grants().is_empty());
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
approvals.revoke_peer(&p);
|
||||
assert!(approvals.grants().is_empty());
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
approvals.invalidate_all();
|
||||
assert!(approvals.grants().is_empty());
|
||||
assert_eq!(approvals.pending_count(), 0);
|
||||
assert!(matches!(
|
||||
approvals.submit(5, &key, p, 1).unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_snapshot_comes_from_proc_without_trusting_display_metadata() {
|
||||
let pid = std::process::id();
|
||||
let snapshot = PeerContext::capture(rustix::process::geteuid().as_raw(), pid).unwrap();
|
||||
assert_eq!(snapshot.pid, pid);
|
||||
assert!(snapshot.start_time_ticks > 0);
|
||||
assert!(snapshot.executable.is_absolute());
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use qs_bitwarden_ssh_agent::keystore::{
|
||||
CandidateItem, KeyStore, LoadError, SkipCode, MAX_FILTERED_BYTES, MAX_KEYS, MAX_PEM_BYTES,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::state::VaultState;
|
||||
use rand_core::OsRng;
|
||||
use signature::Verifier;
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn item(id: &str, key: &PrivateKey) -> CandidateItem {
|
||||
CandidateItem {
|
||||
item_id: id.to_owned(),
|
||||
name: format!("key {id}"),
|
||||
private_key_pem: Zeroizing::new(
|
||||
key.to_openssh(Default::default())
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
public_key: key.public_key().to_openssh().unwrap(),
|
||||
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
requires_reprompt: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ed25519() -> PrivateKey {
|
||||
PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_skips_bad_mismatched_reprompt_and_duplicate_items() {
|
||||
let valid = ed25519();
|
||||
let other = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(1, 4096).unwrap();
|
||||
|
||||
assert_eq!(load.add(item("valid", &valid)).unwrap(), None);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
private_key_pem: Zeroizing::new(b"not a private key".to_vec()),
|
||||
..item("malformed", &other)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::MalformedPrivateKey)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
public_key: other.public_key().to_openssh().unwrap(),
|
||||
..item("public-mismatch", &valid)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::PublicKeyMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
|
||||
..item("fingerprint-mismatch", &valid)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::FingerprintMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
requires_reprompt: true,
|
||||
..item("reprompt", &other)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::RequiresReprompt)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(item("duplicate", &valid)).unwrap(),
|
||||
Some(SkipCode::Duplicate)
|
||||
);
|
||||
|
||||
let report = store.publish(load).unwrap();
|
||||
assert_eq!(report.loaded, 1);
|
||||
assert_eq!(report.skipped.len(), 5);
|
||||
assert_eq!(store.state(), VaultState::Unlocked);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(store.public_identities()[0].item_id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_limits_reject_the_whole_candidate_and_leave_no_private_set() {
|
||||
let key = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut initial = store.begin_load(1, 4096).unwrap();
|
||||
initial.add(item("old", &key)).unwrap();
|
||||
store.publish(initial).unwrap();
|
||||
assert!(store
|
||||
.authorize(store.public_identities()[0].public_blob())
|
||||
.is_some());
|
||||
|
||||
assert_eq!(
|
||||
store.begin_load(2, MAX_FILTERED_BYTES + 1).unwrap_err(),
|
||||
LoadError::FilteredPayloadTooLarge
|
||||
);
|
||||
assert_eq!(store.state(), VaultState::Loading);
|
||||
assert!(store
|
||||
.authorize(key.public_key().to_bytes().unwrap().as_slice())
|
||||
.is_none());
|
||||
|
||||
let mut too_many = store.begin_load(3, 4096).unwrap();
|
||||
for index in 0..MAX_KEYS {
|
||||
let unique = ed25519();
|
||||
assert_eq!(
|
||||
too_many.add(item(&index.to_string(), &unique)).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
too_many.add(item("overflow", &ed25519())).unwrap_err(),
|
||||
LoadError::TooManyKeys
|
||||
);
|
||||
|
||||
let mut oversized = store.begin_load(4, MAX_PEM_BYTES).unwrap();
|
||||
let mut huge = item("huge", &key);
|
||||
huge.private_key_pem = Zeroizing::new(vec![b'x'; MAX_PEM_BYTES + 1]);
|
||||
assert_eq!(oversized.add(huge).unwrap_err(), LoadError::PemTooLarge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_is_atomic_and_stale_or_failed_loads_cannot_mix_epochs() {
|
||||
let first = ed25519();
|
||||
let second = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(7, 4096).unwrap();
|
||||
load.add(item("first", &first)).unwrap();
|
||||
|
||||
store.lock(8);
|
||||
assert_eq!(store.publish(load).unwrap_err(), LoadError::StaleEpoch);
|
||||
assert!(store.public_identities().is_empty());
|
||||
|
||||
let mut replacement = store.begin_load(9, 4096).unwrap();
|
||||
replacement.add(item("second", &second)).unwrap();
|
||||
store.publish(replacement).unwrap();
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(store.public_identities()[0].item_id, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_invalidates_authorization_before_dropping_keys_and_keeps_public_cache() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(11, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
store.lock(12);
|
||||
|
||||
assert_eq!(store.state(), VaultState::LockedCached);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert!(store.sign(&permit, b"must not sign", 0).is_none());
|
||||
assert!(store.authorize(&public_blob).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_epoch_permit_signs_without_cloning_private_keys() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(21, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
let signature = store.sign(&permit, b"authorized payload", 0).unwrap();
|
||||
Verifier::verify(key.public_key(), b"authorized payload", &signature).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undersized_rsa_is_rejected_during_load() {
|
||||
let weak_rsa = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
|
||||
let weak = PrivateKey::from(RsaKeypair::try_from(weak_rsa).unwrap());
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(31, 4096).unwrap();
|
||||
assert_eq!(
|
||||
load.add(item("weak-rsa", &weak)).unwrap(),
|
||||
Some(SkipCode::InvalidPrivateKey)
|
||||
);
|
||||
assert_eq!(store.publish(load).unwrap().loaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_invalidates_permits_and_clears_public_and_private_sets() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(41, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
|
||||
store.logout(42);
|
||||
|
||||
assert_eq!(store.state(), VaultState::LoggedOut);
|
||||
assert!(store.public_identities().is_empty());
|
||||
assert!(store.sign(&permit, b"must not sign", 0).is_none());
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
use qs_bitwarden_ssh_agent::control::{
|
||||
parse_control_line, ControlError, ControlMessage, LoadStatus, MAX_CONTROL_LINE,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::runtime::{RuntimeError, ServiceRuntime};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use signature::Verifier;
|
||||
use ssh_encoding::{Decode, Encode};
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"qsbw-lifecycle-{}-{}",
|
||||
std::process::id(),
|
||||
OsRng.next_u64()
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_contract_accepts_every_allowlisted_message() {
|
||||
let messages = [
|
||||
r#"{"v":1,"type":"hello"}"#,
|
||||
r#"{"v":1,"type":"key_load_begin","epoch":7,"loadId":"00112233445566778899aabbccddeeff"}"#,
|
||||
r#"{"v":1,"type":"key_load_end","epoch":7,"status":"ok"}"#,
|
||||
r#"{"v":1,"type":"vault_locked","epoch":8}"#,
|
||||
r#"{"v":1,"type":"vault_logged_out"}"#,
|
||||
r#"{"v":1,"type":"approve","requestId":42,"grantSeconds":120}"#,
|
||||
r#"{"v":1,"type":"deny","requestId":42}"#,
|
||||
r#"{"v":1,"type":"unlock_cancelled","requestId":41,"reason":"user-cancelled"}"#,
|
||||
r#"{"v":1,"type":"revoke_grants"}"#,
|
||||
r#"{"v":1,"type":"shutdown"}"#,
|
||||
];
|
||||
for message in messages {
|
||||
parse_control_line(message.as_bytes()).unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
parse_control_line(messages[2].as_bytes()),
|
||||
Ok(ControlMessage::KeyLoadEnd {
|
||||
status: LoadStatus::Ok,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_contract_rejects_untrusted_shapes_and_versions() {
|
||||
assert_eq!(parse_control_line(b""), Err(ControlError::Empty));
|
||||
assert_eq!(
|
||||
parse_control_line(b"{not json}"),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":2,"type":"hello"}"#),
|
||||
Err(ControlError::WrongVersion)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":1,"type":"unknown"}"#),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":1,"type":"hello","extra":true}"#),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
let oversized = vec![b'x'; MAX_CONTROL_LINE + 1];
|
||||
assert_eq!(parse_control_line(&oversized), Err(ControlError::TooLong));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn singleton_owns_private_socket_and_cleans_runtime_paths() {
|
||||
let temp = TempDir::new();
|
||||
let owner = ServiceRuntime::acquire(&temp.0).unwrap();
|
||||
let listener = owner.bind_socket().unwrap();
|
||||
let socket_path = owner.socket_path().to_path_buf();
|
||||
let fifo_path = owner.runtime().fifo_path().to_path_buf();
|
||||
let metadata = fs::symlink_metadata(&socket_path).unwrap();
|
||||
assert!(metadata.file_type().is_socket());
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
assert!(matches!(
|
||||
ServiceRuntime::acquire(&temp.0),
|
||||
Err(RuntimeError::AlreadyRunning)
|
||||
));
|
||||
drop(listener);
|
||||
drop(owner);
|
||||
assert!(!socket_path.exists());
|
||||
assert!(!fifo_path.exists());
|
||||
|
||||
let restarted = ServiceRuntime::acquire(&temp.0).unwrap();
|
||||
assert!(restarted.runtime().fifo_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_handshake_is_private_singleton_and_eof_supervised() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"{\"v\":1,\"type\":\"hello\"}\n")
|
||||
.unwrap();
|
||||
let mut ready_line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut ready_line)
|
||||
.unwrap();
|
||||
let ready: serde_json::Value = serde_json::from_str(&ready_line).unwrap();
|
||||
assert_eq!(ready["v"], 1);
|
||||
assert_eq!(ready["type"], "ready");
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
fs::symlink_metadata(&socket).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
|
||||
let status = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(!status.success());
|
||||
|
||||
drop(child.stdin.take());
|
||||
assert!(child.wait().unwrap().success());
|
||||
assert!(!socket.exists());
|
||||
assert!(!fifo.exists());
|
||||
assert!(!temp.0.join("qs-bitwarden-cli").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_is_a_one_time_handshake_not_a_signing_gate_command() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(read_json_line(&mut output)["type"], "ready");
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(read_json_line(&mut output)["type"], "locked");
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
|
||||
assert!(!child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposable_key_load_identity_and_approved_sign_cross_the_real_socket() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let mut loaded = read_json_line(&mut output);
|
||||
while loaded["type"] == "public_key" {
|
||||
loaded = read_json_line(&mut output);
|
||||
}
|
||||
assert_eq!(loaded["type"], "keys_loaded");
|
||||
assert_eq!(loaded["keyCount"], 1);
|
||||
|
||||
let mut client = UnixStream::connect(&socket).unwrap();
|
||||
let mut slow_client = UnixStream::connect(&socket).unwrap();
|
||||
slow_client.write_all(&100_u32.to_be_bytes()).unwrap();
|
||||
client.write_all(&[0, 0, 0, 1, 11]).unwrap();
|
||||
let identities = read_agent_frame(&mut client);
|
||||
assert_eq!(identities[4], 12);
|
||||
assert!(identities
|
||||
.windows(public_blob.len())
|
||||
.any(|part| part == public_blob));
|
||||
|
||||
let message = b"task nine approved signing";
|
||||
let mut request = vec![13];
|
||||
public_blob.encode(&mut request).unwrap();
|
||||
message.as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut frame = Vec::new();
|
||||
u32::try_from(request.len())
|
||||
.unwrap()
|
||||
.encode(&mut frame)
|
||||
.unwrap();
|
||||
frame.extend_from_slice(&request);
|
||||
client.write_all(&frame).unwrap();
|
||||
let approval = read_json_line(&mut output);
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
|
||||
let response = read_agent_frame(&mut client);
|
||||
assert_eq!(response[4], 14);
|
||||
let mut fields = &response[5..];
|
||||
let encoded = Vec::<u8>::decode(&mut fields).unwrap();
|
||||
let signature = Signature::try_from(encoded.as_slice()).unwrap();
|
||||
Verifier::verify(key.public_key(), message, &signature).unwrap();
|
||||
|
||||
// Exercise the real OpenSSH signing client with only a public key file;
|
||||
// the disposable private key remains solely in the helper keystore.
|
||||
let public_path = temp.0.join("disposable.pub");
|
||||
let message_path = temp.0.join("commit.txt");
|
||||
fs::write(&public_path, key.public_key().to_openssh().unwrap()).unwrap();
|
||||
fs::write(&message_path, b"disposable commit object").unwrap();
|
||||
let mut ssh_keygen = Command::new("/usr/bin/ssh-keygen")
|
||||
.env_clear()
|
||||
.env("SSH_AUTH_SOCK", &socket)
|
||||
.args(["-Y", "sign", "-f"])
|
||||
.arg(&public_path)
|
||||
.args(["-n", "git"])
|
||||
.arg(&message_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let approval = read_json_line(&mut output);
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(ssh_keygen.wait().unwrap().success());
|
||||
assert!(message_path.with_extension("txt.sig").exists());
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
/// A lock drops the private set but keeps the public projection, and the
|
||||
/// design's state table says a locked-with-cache agent still lists identities:
|
||||
/// otherwise every `ssh` after a lock raises an unlock prompt, including the
|
||||
/// ones authenticating with an on-disk key. Signing is what the lock denies.
|
||||
#[test]
|
||||
fn a_locked_vault_still_lists_identities_but_refuses_to_sign() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let mut loaded = read_json_line(&mut output);
|
||||
while loaded["type"] == "public_key" {
|
||||
loaded = read_json_line(&mut output);
|
||||
}
|
||||
assert_eq!(loaded["type"], "keys_loaded");
|
||||
assert_eq!(loaded["keyCount"], 1);
|
||||
|
||||
// Unlocked: the identity is offered.
|
||||
assert_eq!(identity_count(&socket), 1);
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let locked = read_json_line(&mut output);
|
||||
assert_eq!(locked["type"], "locked");
|
||||
|
||||
// Locked with a cache: still listed, because public keys are not secret.
|
||||
assert_eq!(identity_count(&socket), 1);
|
||||
|
||||
// The private set is gone, so signing cannot proceed. The request is held
|
||||
// and an unlock is asked for rather than failed outright -- refusing a
|
||||
// client that has no way to retry is worse than asking. Dismissing that
|
||||
// unlock is what turns it into a refusal, and it must do so at once
|
||||
// rather than leaving the client to wait out the deadline.
|
||||
let socket_for_client = socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket_for_client).unwrap();
|
||||
let mut request = Vec::new();
|
||||
13_u8.encode(&mut request).unwrap();
|
||||
blob.as_slice().encode(&mut request).unwrap();
|
||||
b"payload".as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(&request);
|
||||
stream.write_all(&framed).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = read_json_line(&mut output);
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"unlock_cancelled\",\"requestId\":{request_id},\"reason\":\"user-cancelled\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 5,
|
||||
"a dismissed unlock must refuse the signature"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(10),
|
||||
"a dismissed unlock must refuse at once, not at the deadline"
|
||||
);
|
||||
|
||||
// Logout takes the public projection with it.
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_logged_out\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(identity_count(&socket), 0);
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
/// A sign request against a locked-but-cached vault must not simply fail: the
|
||||
/// design has it raise an unlock, hold the request across the load, and then
|
||||
/// ask for approval. The unlock and the approval carry different request ids,
|
||||
/// because they are different decisions.
|
||||
#[test]
|
||||
fn a_locked_sign_request_raises_unlock_then_approval() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(identity_count(&agent.socket), 1);
|
||||
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
// The client blocks on its request while the panel is asked to unlock.
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
assert_eq!(unlock["reason"], "sign");
|
||||
let unlock_id = unlock["requestId"].as_u64().unwrap();
|
||||
|
||||
// Unlocking is a fresh load at a new epoch, and it releases the request.
|
||||
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
|
||||
// The unlock prompt is withdrawn before the approval prompt replaces it,
|
||||
// so the panel is never left showing a question that has been answered.
|
||||
let withdrawn = agent.read();
|
||||
assert_eq!(withdrawn["type"], "request_cancelled");
|
||||
assert_eq!(withdrawn["requestId"].as_u64().unwrap(), unlock_id);
|
||||
assert_eq!(withdrawn["reason"], "released");
|
||||
let approval = agent.read();
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let approval_id = approval["requestId"].as_u64().unwrap();
|
||||
assert_ne!(
|
||||
unlock_id, approval_id,
|
||||
"unlock and approval are separate decisions"
|
||||
);
|
||||
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{approval_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 14,
|
||||
"an approved request must return a signature"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The approval decision needs the key's identity and the requesting program,
|
||||
/// both of which come from the public cache. None of it depends on the vault
|
||||
/// read finishing, so a user may approve while keys are still loading and the
|
||||
/// signature is produced the moment they arrive -- rather than being made to
|
||||
/// wait several seconds and only then be asked.
|
||||
#[test]
|
||||
fn an_approval_given_during_a_load_is_honoured_when_keys_arrive() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
|
||||
// Approved against the held request, before any load has been started.
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
|
||||
// The load lands afterwards and releases the request without asking again.
|
||||
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 14,
|
||||
"an approval given while keys were loading must produce a signature"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The same path must still refuse when the key that comes back is not the
|
||||
/// one that was approved. The approval names a key; the load decides whether
|
||||
/// that key is actually present.
|
||||
#[test]
|
||||
fn an_approval_given_during_a_load_still_requires_the_approved_key() {
|
||||
let mut agent = TestAgent::start();
|
||||
let approved = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let other = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = approved.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&approved, 1, "0123456789abcdef0123456789abcdef");
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
|
||||
// A vault that now holds a different key entirely.
|
||||
agent.load_key(&other, 2, "fedcba9876543210fedcba9876543210");
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 5,
|
||||
"the approved key is gone, so the signature must fail closed"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// A freshly started companion has no public cache, so `ssh-add -L` is empty
|
||||
/// and no client will ever offer a vault key -- which means no sign request,
|
||||
/// and no way to ask for an unlock. Unlock-on-demand exists for exactly that
|
||||
/// cliff, and it has to begin at the identity listing rather than at signing.
|
||||
#[test]
|
||||
fn unlock_on_demand_raises_an_unlock_for_an_empty_identity_listing() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
|
||||
// Off by default: an empty cache answers empty and asks for nothing.
|
||||
assert_eq!(identity_count(&agent.socket), 0);
|
||||
|
||||
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
|
||||
agent.drain_control();
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
assert_eq!(unlock["reason"], "list-identities");
|
||||
|
||||
// The load releases the waiting listing with the real identities.
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
let frame = client.join().unwrap();
|
||||
assert_eq!(frame[4], 12, "expected an identities answer");
|
||||
let mut body = &frame[5..];
|
||||
assert_eq!(u32::decode(&mut body).unwrap(), 1);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// Several clients starting at once must not produce several unlock prompts.
|
||||
#[test]
|
||||
fn concurrent_identity_listings_coalesce_into_one_unlock() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
|
||||
agent.drain_control();
|
||||
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let socket = agent.socket.clone();
|
||||
clients.push(std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
}));
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
}
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
for client in clients {
|
||||
let frame = client.join().unwrap();
|
||||
assert_eq!(frame[4], 12, "every waiting listing gets its answer");
|
||||
}
|
||||
// Exactly one unlock was asked for; the next line is the keys_loaded that
|
||||
// load_key already consumed, so nothing else is queued behind it.
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// A client that walks away leaves a prompt on screen with nothing behind it.
|
||||
/// The companion says so rather than letting it sit until its deadline.
|
||||
#[test]
|
||||
fn a_disconnected_client_withdraws_its_prompt() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
|
||||
let mut stream = UnixStream::connect(&agent.socket).unwrap();
|
||||
stream.write_all(&sign_request(&public_blob)).unwrap();
|
||||
let approval = agent.read();
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
|
||||
drop(stream);
|
||||
let cancelled = agent.read();
|
||||
assert_eq!(cancelled["type"], "request_cancelled");
|
||||
assert_eq!(cancelled["requestId"], request_id);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// Grants are only useful if the panel can see and revoke them, so every
|
||||
/// change to the set is announced with its remaining time.
|
||||
#[test]
|
||||
fn granting_and_revoking_announce_the_live_set() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
let first = read_agent_frame(&mut stream);
|
||||
// A second signature on the same connection rides the grant, with no
|
||||
// further prompt -- which is the whole point of offering one.
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
(first, read_agent_frame(&mut stream))
|
||||
});
|
||||
|
||||
let approval = agent.read();
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
assert_eq!(approval["grantOffered"], true);
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":120}}"
|
||||
));
|
||||
|
||||
let changed = agent.read();
|
||||
assert_eq!(changed["type"], "grants_changed");
|
||||
let grants = changed["grants"].as_array().unwrap();
|
||||
assert_eq!(grants.len(), 1);
|
||||
assert!(grants[0]["expiresInSec"].as_u64().unwrap() <= 120);
|
||||
assert!(grants[0]["expiresInSec"].as_u64().unwrap() > 0);
|
||||
let grant_id = grants[0]["grantId"].as_u64().unwrap();
|
||||
assert!(
|
||||
grants[0].get("privateKey").is_none(),
|
||||
"a grant must carry no key material"
|
||||
);
|
||||
|
||||
let (first, second) = client.join().unwrap();
|
||||
assert_eq!(first[4], 14);
|
||||
assert_eq!(second[4], 14, "a live grant signs without prompting again");
|
||||
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"revoke_grant\",\"grantId\":{grant_id}}}"
|
||||
));
|
||||
let revoked = agent.read();
|
||||
assert_eq!(revoked["type"], "grants_changed");
|
||||
assert_eq!(revoked["grants"].as_array().unwrap().len(), 0);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The panel validates the bundled helper before it trusts it, and needs the
|
||||
/// helper's own answers to do that: what version it is, what protocol it
|
||||
/// speaks, and whether its crypto actually works on this machine. Both must
|
||||
/// answer without touching the filesystem, opening a socket, or needing a
|
||||
/// runtime directory -- they run before any of that exists.
|
||||
#[test]
|
||||
fn version_and_self_test_answer_without_touching_the_system() {
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let temp = TempDir::new();
|
||||
|
||||
let version = Command::new(executable)
|
||||
.arg("--version")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(version.status.success(), "--version must succeed");
|
||||
let text = String::from_utf8(version.stdout).unwrap();
|
||||
assert!(
|
||||
text.contains(env!("CARGO_PKG_VERSION")),
|
||||
"--version must report the crate version, got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("protocol 1"),
|
||||
"--version must report the control protocol version, got {text:?}"
|
||||
);
|
||||
|
||||
// No XDG_RUNTIME_DIR at all: neither mode may depend on one.
|
||||
let selftest = Command::new(executable)
|
||||
.arg("--self-test")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
selftest.status.success(),
|
||||
"--self-test failed: {}",
|
||||
String::from_utf8_lossy(&selftest.stderr)
|
||||
);
|
||||
let report = String::from_utf8(selftest.stdout).unwrap();
|
||||
assert!(
|
||||
report.contains("ok"),
|
||||
"self-test should say so, got {report:?}"
|
||||
);
|
||||
|
||||
// Nothing was created anywhere it could have been.
|
||||
let runtime = std::path::Path::new(&temp.0).join("qs-bitwarden-cli");
|
||||
assert!(
|
||||
!runtime.exists(),
|
||||
"a self-test must not create a runtime directory"
|
||||
);
|
||||
|
||||
// Neither mode may leak key material to either stream.
|
||||
let combined = format!("{report}{}", String::from_utf8_lossy(&selftest.stderr));
|
||||
assert!(
|
||||
!combined.contains("PRIVATE"),
|
||||
"the self-test must not print key material"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown flag must not be mistaken for "run as the agent". The panel
|
||||
/// launches this binary with no arguments; anything else is a mistake worth
|
||||
/// reporting rather than silently starting a key-holding daemon.
|
||||
#[test]
|
||||
fn an_unknown_argument_is_refused() {
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let out = Command::new(executable)
|
||||
.arg("--not-a-real-flag")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an unknown flag must not start the agent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A running agent with its control channel, for tests that drive several
|
||||
/// messages in sequence.
|
||||
struct TestAgent {
|
||||
child: std::process::Child,
|
||||
input: std::process::ChildStdin,
|
||||
output: BufReader<std::process::ChildStdout>,
|
||||
socket: PathBuf,
|
||||
fifo: PathBuf,
|
||||
alive: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
_temp: TempDir,
|
||||
}
|
||||
|
||||
impl TestAgent {
|
||||
fn start() -> Self {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
// Every read below blocks on the agent's stdout, so a message the
|
||||
// agent never sends would hang the whole suite instead of failing it.
|
||||
// The watchdog kills the child, which closes stdout and turns that
|
||||
// hang into an EOF the assertions report.
|
||||
let alive = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
let watching = alive.clone();
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !watching.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
|
||||
});
|
||||
|
||||
Self {
|
||||
child,
|
||||
input,
|
||||
output,
|
||||
socket,
|
||||
fifo,
|
||||
alive,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, line: &str) {
|
||||
writeln!(self.input, "{line}").unwrap();
|
||||
self.input.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Wait until the control loop has processed everything sent so far.
|
||||
///
|
||||
/// Control messages are read in order on one channel, so a message whose
|
||||
/// effect is observable acts as a barrier for every message before it.
|
||||
/// `vault_locked` is that message: it answers with `locked`, and locking
|
||||
/// an empty store changes nothing a test then depends on.
|
||||
///
|
||||
/// Needed because a test that sends `options` and then connects a client
|
||||
/// is racing the control loop. That race is invisible on a fast machine
|
||||
/// and cost a CI run: the client's listing arrived first, was answered
|
||||
/// with an empty list instead of raising an unlock, and the test waited
|
||||
/// for a message that was never going to come.
|
||||
fn drain_control(&mut self) {
|
||||
self.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":0}");
|
||||
let acknowledged = self.read();
|
||||
assert_eq!(
|
||||
acknowledged["type"], "locked",
|
||||
"expected a lock acknowledgement"
|
||||
);
|
||||
}
|
||||
|
||||
fn read(&mut self) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
self.output.read_line(&mut line).unwrap();
|
||||
assert!(
|
||||
!line.is_empty(),
|
||||
"the agent closed its control channel without answering"
|
||||
);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn load_key(&mut self, key: &PrivateKey, epoch: u64, nonce: &str) {
|
||||
self.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":{epoch},\"loadId\":\"{nonce}\"}}"
|
||||
));
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&self.fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
self.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"key_load_end\",\"epoch\":{epoch},\"status\":\"ok\"}}"
|
||||
));
|
||||
// The validated public set arrives one message per key ahead of
|
||||
// keys_loaded, so the panel holds the whole projection before it is
|
||||
// told the load finished. Skip past them to the completion.
|
||||
loop {
|
||||
let message = self.read();
|
||||
if message["type"] == "keys_loaded" {
|
||||
break;
|
||||
}
|
||||
assert_eq!(
|
||||
message["type"], "public_key",
|
||||
"only public keys may precede keys_loaded"
|
||||
);
|
||||
assert!(
|
||||
!message["publicKey"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("PRIVATE"),
|
||||
"a public_key message must never carry private material"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) {
|
||||
self.send("{\"v\":1,\"type\":\"shutdown\"}");
|
||||
let status = self.child.wait().unwrap();
|
||||
self.alive
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(status.success());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestAgent {
|
||||
fn drop(&mut self) {
|
||||
self.alive
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// A framed SSH_AGENTC_SIGN_REQUEST for one public blob.
|
||||
fn sign_request(public_blob: &[u8]) -> Vec<u8> {
|
||||
let mut request = Vec::new();
|
||||
13_u8.encode(&mut request).unwrap();
|
||||
public_blob.encode(&mut request).unwrap();
|
||||
b"payload".as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(&request);
|
||||
framed
|
||||
}
|
||||
|
||||
/// Number of identities the agent offers over its real socket.
|
||||
fn identity_count(socket: &PathBuf) -> usize {
|
||||
let mut stream = UnixStream::connect(socket).unwrap();
|
||||
let request = [0_u8, 0, 0, 1, 11];
|
||||
stream.write_all(&request).unwrap();
|
||||
let frame = read_agent_frame(&mut stream);
|
||||
assert_eq!(frame[4], 12, "expected an identities answer, not a failure");
|
||||
let mut body = &frame[5..];
|
||||
usize::try_from(u32::decode(&mut body).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn read_json_line(reader: &mut BufReader<std::process::ChildStdout>) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn read_agent_frame(stream: &mut UnixStream) -> Vec<u8> {
|
||||
let mut header = [0_u8; 4];
|
||||
stream.read_exact(&mut header).unwrap();
|
||||
let length = usize::try_from(u32::from_be_bytes(header)).unwrap();
|
||||
let mut frame = header.to_vec();
|
||||
frame.resize(length + 4, 0);
|
||||
stream.read_exact(&mut frame[4..]).unwrap();
|
||||
frame
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use qs_bitwarden_ssh_agent::keystore::{
|
||||
KeyStore, LoadError, MAX_FILTERED_BYTES, MAX_METADATA_BYTES,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::load::{LoadWindow, PayloadError};
|
||||
use qs_bitwarden_ssh_agent::runtime::{read_payload_async, Runtime, RuntimeError};
|
||||
use rand_core::OsRng;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const NONCE: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"qsbw-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
rand_core::RngCore::next_u64(&mut OsRng)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn item_json(id: &str, key: &PrivateKey) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"itemId": id,
|
||||
"name": format!("key {id}"),
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
})
|
||||
}
|
||||
|
||||
fn payload(nonce: &str, items: Vec<serde_json::Value>) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({"loadId": nonce, "items": items})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_private_runtime_and_fifo_and_holds_both_fifo_ends() {
|
||||
let temp = TempDir::new("runtime");
|
||||
let runtime = Runtime::create(&temp.0).unwrap();
|
||||
let dir = fs::metadata(runtime.directory()).unwrap();
|
||||
let fifo = fs::symlink_metadata(runtime.fifo_path()).unwrap();
|
||||
|
||||
assert_eq!(dir.mode() & 0o777, 0o700);
|
||||
assert_eq!(fifo.mode() & 0o777, 0o600);
|
||||
assert!(fifo.file_type().is_fifo());
|
||||
assert_eq!(dir.uid(), rustix::process::geteuid().as_raw());
|
||||
assert_eq!(fifo.uid(), rustix::process::geteuid().as_raw());
|
||||
assert!(runtime.fifo().metadata().unwrap().file_type().is_fifo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_stale_wrong_type_symlink_and_insecure_directory() {
|
||||
let stale = TempDir::new("stale");
|
||||
let runtime_dir = stale.0.join("qs-bitwarden-cli");
|
||||
fs::create_dir(&runtime_dir).unwrap();
|
||||
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(runtime_dir.join("ssh-keys.fifo"), b"stale").unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&stale.0).unwrap_err(),
|
||||
RuntimeError::UnsafeFifo
|
||||
);
|
||||
|
||||
let insecure = TempDir::new("insecure");
|
||||
let dir = insecure.0.join("qs-bitwarden-cli");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&insecure.0).unwrap_err(),
|
||||
RuntimeError::UnsafeDirectory
|
||||
);
|
||||
|
||||
let linked = TempDir::new("linked");
|
||||
let target = linked.0.join("target");
|
||||
fs::create_dir(&target).unwrap();
|
||||
std::os::unix::fs::symlink(&target, linked.0.join("qs-bitwarden-cli")).unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&linked.0).unwrap_err(),
|
||||
RuntimeError::UnsafeDirectory
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_nonce_payload_publishes_disposable_keys_once() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let bytes = payload(NONCE, vec![item_json("one", &key)]);
|
||||
let mut window = LoadWindow::new(7, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let candidate = window.decode(Zeroizing::new(bytes), &mut store).unwrap();
|
||||
assert_eq!(store.publish(candidate).unwrap().loaded, 1);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![])), &mut store)
|
||||
.unwrap_err(),
|
||||
PayloadError::Closed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_schema_truncation_and_size_fail_the_whole_load() {
|
||||
let mut store = KeyStore::new();
|
||||
for (index, (nonce, bytes, expected)) in [
|
||||
(
|
||||
NONCE,
|
||||
payload("ffffffffffffffffffffffffffffffff", vec![]),
|
||||
PayloadError::NonceMismatch,
|
||||
),
|
||||
(
|
||||
NONCE,
|
||||
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":["#.to_vec(),
|
||||
PayloadError::Malformed,
|
||||
),
|
||||
(
|
||||
NONCE,
|
||||
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":[],"extra":1}"#.to_vec(),
|
||||
PayloadError::Malformed,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut window = LoadWindow::new(10 + index as u64, nonce).unwrap();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(bytes), &mut store)
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
let mut window = LoadWindow::new(20, NONCE).unwrap();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(
|
||||
Zeroizing::new(vec![b'x'; MAX_FILTERED_BYTES + 1]),
|
||||
&mut store
|
||||
)
|
||||
.unwrap_err(),
|
||||
PayloadError::Load(LoadError::FilteredPayloadTooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_drain_is_newline_framed_and_deadline_limited() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("drain");
|
||||
let mut runtime = Runtime::create(&temp.0).unwrap();
|
||||
let mut writer = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(runtime.fifo_path())
|
||||
.unwrap();
|
||||
writer.write_all(b"{\"loadId\":\"ok\"}\n").unwrap();
|
||||
assert_eq!(
|
||||
runtime
|
||||
.read_payload(Duration::from_secs(1))
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
b"{\"loadId\":\"ok\"}"
|
||||
);
|
||||
|
||||
writer.write_all(b"{}\n{}\n").unwrap();
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_secs(1)).unwrap_err(),
|
||||
RuntimeError::MultiplePayloads
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_millis(10)).unwrap_err(),
|
||||
RuntimeError::ReadTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_drain_rejects_a_stream_beyond_the_full_eight_mibibyte_cap() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("full-cap");
|
||||
let mut runtime = Runtime::create(&temp.0).unwrap();
|
||||
let fifo_path = runtime.fifo_path().to_owned();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let mut fifo = fs::OpenOptions::new().write(true).open(fifo_path).unwrap();
|
||||
let oversized = vec![b'x'; MAX_FILTERED_BYTES + 2];
|
||||
let _ = fifo.write_all(&oversized);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_secs(30)).unwrap_err(),
|
||||
RuntimeError::PayloadTooLarge
|
||||
);
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_nonce_is_never_armed() {
|
||||
assert_eq!(
|
||||
LoadWindow::new(1, "short").unwrap_err(),
|
||||
PayloadError::InvalidNonce
|
||||
);
|
||||
assert_eq!(
|
||||
LoadWindow::new(1, "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap_err(),
|
||||
PayloadError::InvalidNonce
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_id_past_the_metadata_cap_fails_the_candidate() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let mut item = item_json("one", &key);
|
||||
// Real ones are 36-character UUIDs, and this is what the key is known by,
|
||||
// so it cannot be shortened to fit the way a display name can.
|
||||
item["itemId"] = serde_json::Value::String("i".repeat(65 * 1024));
|
||||
let mut window = LoadWindow::new(30, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
|
||||
.unwrap_err(),
|
||||
PayloadError::Load(LoadError::MetadataTooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_item_name_is_truncated_rather_than_losing_the_whole_load() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let mut item = item_json("one", &key);
|
||||
// Multibyte on purpose. 200 of these is 400 bytes, so the cut lands in the
|
||||
// middle of a character unless the boundary is respected -- and a name is
|
||||
// a String, which cannot hold half of one.
|
||||
let name = "é".repeat(200);
|
||||
item["name"] = serde_json::Value::String(name.clone());
|
||||
let mut window = LoadWindow::new(30, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let candidate = window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
|
||||
.expect("a descriptively named key is an ordinary key");
|
||||
store.publish(candidate).unwrap();
|
||||
|
||||
let identities = store.public_identities();
|
||||
assert_eq!(identities.len(), 1, "the key still loaded");
|
||||
let stored = &identities[0].name;
|
||||
assert!(stored.len() <= MAX_METADATA_BYTES);
|
||||
assert!(name.starts_with(stored.as_str()));
|
||||
assert!(!stored.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_producer_that_closes_without_a_newline_times_out() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("eof");
|
||||
let runtime = Runtime::create(&temp.0).unwrap();
|
||||
let mut writer = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(runtime.fifo_path())
|
||||
.unwrap();
|
||||
writer.write_all(b"{\"loadId\":\"unfinished\"").unwrap();
|
||||
drop(writer);
|
||||
|
||||
// The reader keeps its own write end open, so this is a producer that gave
|
||||
// up rather than a true end-of-stream -- but the read still has to end at
|
||||
// its deadline rather than spinning on a descriptor that stays readable.
|
||||
assert_eq!(
|
||||
read_payload_async(runtime.fifo_reader().unwrap(), Duration::from_millis(150))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
RuntimeError::ReadTimeout
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use qs_bitwarden_ssh_agent::protocol::{handle_frame, Identity, MAX_FRAME_LEN};
|
||||
use rand_core::OsRng;
|
||||
use signature::Verifier;
|
||||
use ssh_encoding::{Decode, Encode};
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
|
||||
|
||||
const FAILURE: u8 = 5;
|
||||
const REQUEST_IDENTITIES: u8 = 11;
|
||||
const IDENTITIES_ANSWER: u8 = 12;
|
||||
const SIGN_REQUEST: u8 = 13;
|
||||
const SIGN_RESPONSE: u8 = 14;
|
||||
const RSA_SHA2_256: u32 = 2;
|
||||
const RSA_SHA2_512: u32 = 4;
|
||||
|
||||
fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::with_capacity(payload.len() + 4);
|
||||
u32::try_from(payload.len())
|
||||
.unwrap()
|
||||
.encode(&mut encoded)
|
||||
.unwrap();
|
||||
encoded.extend_from_slice(payload);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn string(value: &[u8], out: &mut Vec<u8>) {
|
||||
value.encode(out).unwrap();
|
||||
}
|
||||
|
||||
fn response_payload(response: &[u8]) -> &[u8] {
|
||||
let declared = u32::from_be_bytes(response[..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(declared, response.len() - 4);
|
||||
&response[4..]
|
||||
}
|
||||
|
||||
fn sign_request(key_blob: &[u8], message: &[u8], flags: u32) -> Vec<u8> {
|
||||
let mut payload = vec![SIGN_REQUEST];
|
||||
string(key_blob, &mut payload);
|
||||
string(message, &mut payload);
|
||||
flags.encode(&mut payload).unwrap();
|
||||
frame(&payload)
|
||||
}
|
||||
|
||||
fn signature(response: &[u8]) -> Signature {
|
||||
let payload = response_payload(response);
|
||||
assert_eq!(payload[0], SIGN_RESPONSE);
|
||||
let mut encoded = &payload[1..];
|
||||
let signature_bytes = Vec::<u8>::decode(&mut encoded).unwrap();
|
||||
assert!(encoded.is_empty());
|
||||
Signature::try_from(signature_bytes.as_slice()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_openssh_encoded_identities() {
|
||||
let ed25519 = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let rsa = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
|
||||
let identities = [
|
||||
Identity::new(ed25519, "vault ed25519").unwrap(),
|
||||
Identity::new(rsa, "vault rsa").unwrap(),
|
||||
];
|
||||
|
||||
let response = handle_frame(&frame(&[REQUEST_IDENTITIES]), &identities);
|
||||
let payload = response_payload(&response);
|
||||
assert_eq!(payload[0], IDENTITIES_ANSWER);
|
||||
let mut fields = &payload[1..];
|
||||
assert_eq!(u32::decode(&mut fields).unwrap(), 2);
|
||||
for identity in identities.iter() {
|
||||
assert_eq!(
|
||||
Vec::<u8>::decode(&mut fields).unwrap(),
|
||||
identity.public_blob()
|
||||
);
|
||||
assert_eq!(String::decode(&mut fields).unwrap(), identity.comment());
|
||||
}
|
||||
assert!(fields.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_ed25519_requests_and_rejects_nonzero_flags() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let identity = Identity::new(key, "ed25519").unwrap();
|
||||
let message = b"bounded agent protocol vector";
|
||||
|
||||
let signed = signature(&handle_frame(
|
||||
&sign_request(identity.public_blob(), message, 0),
|
||||
std::slice::from_ref(&identity),
|
||||
));
|
||||
assert_eq!(signed.algorithm(), Algorithm::Ed25519);
|
||||
Verifier::verify(identity.public_key(), message, &signed).unwrap();
|
||||
|
||||
let rejected = handle_frame(
|
||||
&sign_request(identity.public_blob(), message, RSA_SHA2_256),
|
||||
&[identity],
|
||||
);
|
||||
assert_eq!(response_payload(&rejected), &[FAILURE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_rsa_with_exactly_the_requested_sha2_algorithm() {
|
||||
let key = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
|
||||
let identity = Identity::new(key, "rsa").unwrap();
|
||||
let message = b"rsa protocol vector";
|
||||
|
||||
for (flags, hash) in [
|
||||
(RSA_SHA2_256, HashAlg::Sha256),
|
||||
(RSA_SHA2_512, HashAlg::Sha512),
|
||||
] {
|
||||
let signed = signature(&handle_frame(
|
||||
&sign_request(identity.public_blob(), message, flags),
|
||||
std::slice::from_ref(&identity),
|
||||
));
|
||||
assert_eq!(signed.algorithm(), Algorithm::Rsa { hash: Some(hash) });
|
||||
Verifier::verify(identity.public_key(), message, &signed).unwrap();
|
||||
}
|
||||
|
||||
for flags in [0, RSA_SHA2_256 | RSA_SHA2_512, 8] {
|
||||
let rejected = handle_frame(
|
||||
&sign_request(identity.public_blob(), message, flags),
|
||||
std::slice::from_ref(&identity),
|
||||
);
|
||||
assert_eq!(response_payload(&rejected), &[FAILURE]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_disallowed_requests_receive_only_bounded_failure() {
|
||||
let cases = [
|
||||
Vec::new(),
|
||||
vec![0, 0, 0, 2, REQUEST_IDENTITIES],
|
||||
frame(&[REQUEST_IDENTITIES, 0]),
|
||||
frame(&[17]),
|
||||
frame(&[18]),
|
||||
frame(&[19]),
|
||||
frame(&[20]),
|
||||
frame(&[21]),
|
||||
frame(&[22]),
|
||||
frame(&[23]),
|
||||
frame(&[25]),
|
||||
frame(&[26]),
|
||||
frame(&[27, 0, 0, 0, 1, 0xff]),
|
||||
frame(&[255]),
|
||||
];
|
||||
|
||||
for request in cases {
|
||||
assert_eq!(response_payload(&handle_frame(&request, &[])), &[FAILURE]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_rejected_before_body_allocation_or_parsing() {
|
||||
let oversized_header = u32::try_from(MAX_FRAME_LEN + 1).unwrap().to_be_bytes();
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&oversized_header, &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
let mut invalid_string = vec![SIGN_REQUEST];
|
||||
invalid_string.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&invalid_string), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
let mut unknown_key = vec![SIGN_REQUEST];
|
||||
string(b"not an advertised public key", &mut unknown_key);
|
||||
string(b"message", &mut unknown_key);
|
||||
0_u32.encode(&mut unknown_key).unwrap();
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&unknown_key), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
unknown_key.push(0);
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&unknown_key), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user