- 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
284 lines
9.3 KiB
Rust
284 lines
9.3 KiB
Rust
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
|
|
);
|
|
}
|