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,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]
);
}