- 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
984 lines
37 KiB
Rust
984 lines
37 KiB
Rust
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
|
|
}
|