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:
asepharyana
2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 changed files with 78143 additions and 0 deletions
@@ -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]
);
}