Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
use qs_bitwarden_ssh_agent::approvals::{ApprovalError, ApprovalManager, Submit};
|
||||
use qs_bitwarden_ssh_agent::keystore::{CandidateItem, KeyStore};
|
||||
use qs_bitwarden_ssh_agent::peer::PeerContext;
|
||||
use rand_core::OsRng;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn peer(pid: u32, start: u64, executable: &str) -> PeerContext {
|
||||
PeerContext::new(rustix::process::geteuid().as_raw(), pid, start, executable).unwrap()
|
||||
}
|
||||
|
||||
fn loaded_store(epoch: u64) -> (KeyStore, Vec<u8>) {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(epoch, 4096).unwrap();
|
||||
load.add(CandidateItem {
|
||||
item_id: "item".into(),
|
||||
name: "Work".into(),
|
||||
private_key_pem: Zeroizing::new(
|
||||
key.to_openssh(Default::default())
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
public_key: key.public_key().to_openssh().unwrap(),
|
||||
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
requires_reprompt: false,
|
||||
})
|
||||
.unwrap();
|
||||
store.publish(load).unwrap();
|
||||
(store, blob)
|
||||
}
|
||||
|
||||
/// Two clocks bound one wait, and they are not independent. The companion's
|
||||
/// request deadline is the human's time to answer; the server's reply wait is
|
||||
/// how long a client blocks for that answer. If the second is shorter, the
|
||||
/// first is decorative -- which it was, with both set to thirty seconds.
|
||||
#[test]
|
||||
fn a_client_waits_longer_than_the_human_is_given_to_answer() {
|
||||
assert!(
|
||||
qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT
|
||||
> std::time::Duration::from_millis(
|
||||
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS
|
||||
),
|
||||
"a client must not give up before the request it is waiting on expires"
|
||||
);
|
||||
// Reading a frame or writing a reply is machine-speed and stays short;
|
||||
// only the wait on a person is long.
|
||||
assert!(
|
||||
qs_bitwarden_ssh_agent::server::CLIENT_IO_TIMEOUT
|
||||
< qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT,
|
||||
"socket I/O should not inherit the human-scale timeout"
|
||||
);
|
||||
// The number itself, so raising it stays a deliberate act.
|
||||
assert_eq!(
|
||||
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS,
|
||||
120_000,
|
||||
"see docs/decisions/0003-request-deadline.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_is_bounded_expires_and_disconnect_cancels() {
|
||||
let (_, key) = loaded_store(1);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let client = peer(100, 10, "/usr/bin/ssh");
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..4 {
|
||||
match approvals.submit(1, &key, client.clone(), 1_000).unwrap() {
|
||||
Submit::Pending(id) => ids.push(id),
|
||||
Submit::Granted(_) => panic!("no grant exists"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
approvals.submit(1, &key, client.clone(), 1_000),
|
||||
Err(ApprovalError::QueueFull)
|
||||
);
|
||||
approvals.disconnect(ids[0]);
|
||||
assert_eq!(
|
||||
approvals.approve(ids[0], 0, 1_001),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
// Derived from the lifetime rather than hardcoded, so changing the
|
||||
// deadline cannot leave this test asserting the old one.
|
||||
let past_deadline = qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS + 1_001;
|
||||
approvals.expire(past_deadline - 1_001 - 1);
|
||||
assert_ne!(
|
||||
approvals.pending_count(),
|
||||
0,
|
||||
"a request must survive right up to its deadline"
|
||||
);
|
||||
approvals.expire(past_deadline);
|
||||
assert_eq!(approvals.pending_count(), 0);
|
||||
assert_eq!(
|
||||
approvals.approve(ids[1], 0, past_deadline),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_is_single_use_and_old_epoch_fails_at_final_check() {
|
||||
let (mut store, key) = loaded_store(7);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let id = match approvals
|
||||
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 0)
|
||||
.unwrap()
|
||||
{
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let authorization = approvals.approve(id, 0, 1).unwrap();
|
||||
assert_eq!(
|
||||
approvals.approve(id, 0, 1),
|
||||
Err(ApprovalError::UnknownRequest)
|
||||
);
|
||||
assert!(authorization.finalize(&store).is_some());
|
||||
let second = match approvals
|
||||
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 2)
|
||||
.unwrap()
|
||||
{
|
||||
Submit::Pending(id) => approvals.approve(id, 0, 2).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
store.lock(8);
|
||||
assert!(second.finalize(&store).is_none());
|
||||
}
|
||||
|
||||
/// A grant covers one key and one program, not one process. Git spawns a
|
||||
/// fresh `ssh-keygen` for every commit it signs, so a grant tied to a PID
|
||||
/// never matches the case grants exist for -- a rebase would prompt once per
|
||||
/// commit regardless. Scoping to the executable path is what makes the
|
||||
/// feature do its job; see docs/decisions/0002-grant-scope.md for the
|
||||
/// exposure this accepts.
|
||||
#[test]
|
||||
fn grants_are_capped_and_bound_to_key_and_executable() {
|
||||
let (_, key) = loaded_store(3);
|
||||
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
|
||||
let original = peer(200, 50, "/usr/bin/git");
|
||||
let id = match approvals.submit(3, &key, original.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 10_000, 10).unwrap();
|
||||
assert_eq!(approvals.grants()[0].expires_at_ms, 900_010);
|
||||
assert!(matches!(
|
||||
approvals.submit(3, &key, original.clone(), 20).unwrap(),
|
||||
Submit::Granted(_)
|
||||
));
|
||||
|
||||
// The case that matters: a different process, same program. Every commit
|
||||
// in a rebase looks like this.
|
||||
assert!(
|
||||
matches!(
|
||||
approvals
|
||||
.submit(3, &key, peer(9001, 7777, "/usr/bin/git"), 20)
|
||||
.unwrap(),
|
||||
Submit::Granted(_)
|
||||
),
|
||||
"a fresh process running the same program must ride the grant"
|
||||
);
|
||||
|
||||
// A different program does not, even from the same process identity.
|
||||
assert!(matches!(
|
||||
approvals
|
||||
.submit(3, &key, peer(200, 50, "/usr/bin/ssh"), 20)
|
||||
.unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
// Nor does a different key.
|
||||
assert!(matches!(
|
||||
approvals.submit(3, b"different key", original, 20).unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// Widening the scope to a program must not widen it across users. The peer
|
||||
/// UID is the one thing the companion actually verifies.
|
||||
#[test]
|
||||
fn a_grant_never_crosses_to_another_user() {
|
||||
let (_, key) = loaded_store(3);
|
||||
let expected = rustix::process::geteuid().as_raw();
|
||||
let mut approvals = ApprovalManager::new(expected);
|
||||
let mine = peer(200, 50, "/usr/bin/git");
|
||||
let id = match approvals.submit(3, &key, mine, 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 10).unwrap();
|
||||
|
||||
let theirs = PeerContext::new(expected.wrapping_add(1), 201, 51, "/usr/bin/git").unwrap();
|
||||
assert!(
|
||||
approvals.submit(3, &key, theirs, 20).is_err(),
|
||||
"another user must not reach a grant, whatever program they run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_uid_and_lifecycle_revocation_fail_closed() {
|
||||
let (_, key) = loaded_store(5);
|
||||
let expected = rustix::process::geteuid().as_raw();
|
||||
let mut approvals = ApprovalManager::new(expected);
|
||||
let wrong = PeerContext::new(expected.wrapping_add(1), 1, 1, "/usr/bin/ssh").unwrap();
|
||||
assert_eq!(
|
||||
approvals.submit(5, &key, wrong, 0),
|
||||
Err(ApprovalError::WrongUid)
|
||||
);
|
||||
|
||||
let p = peer(300, 60, "/usr/bin/ssh");
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
let grant_id = approvals.grants()[0].id;
|
||||
approvals.revoke_grant(grant_id);
|
||||
assert!(approvals.grants().is_empty());
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
approvals.revoke_peer(&p);
|
||||
assert!(approvals.grants().is_empty());
|
||||
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
|
||||
Submit::Pending(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
approvals.approve(id, 120, 0).unwrap();
|
||||
approvals.invalidate_all();
|
||||
assert!(approvals.grants().is_empty());
|
||||
assert_eq!(approvals.pending_count(), 0);
|
||||
assert!(matches!(
|
||||
approvals.submit(5, &key, p, 1).unwrap(),
|
||||
Submit::Pending(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_snapshot_comes_from_proc_without_trusting_display_metadata() {
|
||||
let pid = std::process::id();
|
||||
let snapshot = PeerContext::capture(rustix::process::geteuid().as_raw(), pid).unwrap();
|
||||
assert_eq!(snapshot.pid, pid);
|
||||
assert!(snapshot.start_time_ticks > 0);
|
||||
assert!(snapshot.executable.is_absolute());
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use qs_bitwarden_ssh_agent::keystore::{
|
||||
CandidateItem, KeyStore, LoadError, SkipCode, MAX_FILTERED_BYTES, MAX_KEYS, MAX_PEM_BYTES,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::state::VaultState;
|
||||
use rand_core::OsRng;
|
||||
use signature::Verifier;
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn item(id: &str, key: &PrivateKey) -> CandidateItem {
|
||||
CandidateItem {
|
||||
item_id: id.to_owned(),
|
||||
name: format!("key {id}"),
|
||||
private_key_pem: Zeroizing::new(
|
||||
key.to_openssh(Default::default())
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
public_key: key.public_key().to_openssh().unwrap(),
|
||||
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
requires_reprompt: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ed25519() -> PrivateKey {
|
||||
PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_skips_bad_mismatched_reprompt_and_duplicate_items() {
|
||||
let valid = ed25519();
|
||||
let other = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(1, 4096).unwrap();
|
||||
|
||||
assert_eq!(load.add(item("valid", &valid)).unwrap(), None);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
private_key_pem: Zeroizing::new(b"not a private key".to_vec()),
|
||||
..item("malformed", &other)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::MalformedPrivateKey)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
public_key: other.public_key().to_openssh().unwrap(),
|
||||
..item("public-mismatch", &valid)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::PublicKeyMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
|
||||
..item("fingerprint-mismatch", &valid)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::FingerprintMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(CandidateItem {
|
||||
requires_reprompt: true,
|
||||
..item("reprompt", &other)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(SkipCode::RequiresReprompt)
|
||||
);
|
||||
assert_eq!(
|
||||
load.add(item("duplicate", &valid)).unwrap(),
|
||||
Some(SkipCode::Duplicate)
|
||||
);
|
||||
|
||||
let report = store.publish(load).unwrap();
|
||||
assert_eq!(report.loaded, 1);
|
||||
assert_eq!(report.skipped.len(), 5);
|
||||
assert_eq!(store.state(), VaultState::Unlocked);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(store.public_identities()[0].item_id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_limits_reject_the_whole_candidate_and_leave_no_private_set() {
|
||||
let key = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut initial = store.begin_load(1, 4096).unwrap();
|
||||
initial.add(item("old", &key)).unwrap();
|
||||
store.publish(initial).unwrap();
|
||||
assert!(store
|
||||
.authorize(store.public_identities()[0].public_blob())
|
||||
.is_some());
|
||||
|
||||
assert_eq!(
|
||||
store.begin_load(2, MAX_FILTERED_BYTES + 1).unwrap_err(),
|
||||
LoadError::FilteredPayloadTooLarge
|
||||
);
|
||||
assert_eq!(store.state(), VaultState::Loading);
|
||||
assert!(store
|
||||
.authorize(key.public_key().to_bytes().unwrap().as_slice())
|
||||
.is_none());
|
||||
|
||||
let mut too_many = store.begin_load(3, 4096).unwrap();
|
||||
for index in 0..MAX_KEYS {
|
||||
let unique = ed25519();
|
||||
assert_eq!(
|
||||
too_many.add(item(&index.to_string(), &unique)).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
too_many.add(item("overflow", &ed25519())).unwrap_err(),
|
||||
LoadError::TooManyKeys
|
||||
);
|
||||
|
||||
let mut oversized = store.begin_load(4, MAX_PEM_BYTES).unwrap();
|
||||
let mut huge = item("huge", &key);
|
||||
huge.private_key_pem = Zeroizing::new(vec![b'x'; MAX_PEM_BYTES + 1]);
|
||||
assert_eq!(oversized.add(huge).unwrap_err(), LoadError::PemTooLarge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_is_atomic_and_stale_or_failed_loads_cannot_mix_epochs() {
|
||||
let first = ed25519();
|
||||
let second = ed25519();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(7, 4096).unwrap();
|
||||
load.add(item("first", &first)).unwrap();
|
||||
|
||||
store.lock(8);
|
||||
assert_eq!(store.publish(load).unwrap_err(), LoadError::StaleEpoch);
|
||||
assert!(store.public_identities().is_empty());
|
||||
|
||||
let mut replacement = store.begin_load(9, 4096).unwrap();
|
||||
replacement.add(item("second", &second)).unwrap();
|
||||
store.publish(replacement).unwrap();
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(store.public_identities()[0].item_id, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_invalidates_authorization_before_dropping_keys_and_keeps_public_cache() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(11, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
store.lock(12);
|
||||
|
||||
assert_eq!(store.state(), VaultState::LockedCached);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert!(store.sign(&permit, b"must not sign", 0).is_none());
|
||||
assert!(store.authorize(&public_blob).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_epoch_permit_signs_without_cloning_private_keys() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(21, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
let signature = store.sign(&permit, b"authorized payload", 0).unwrap();
|
||||
Verifier::verify(key.public_key(), b"authorized payload", &signature).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undersized_rsa_is_rejected_during_load() {
|
||||
let weak_rsa = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
|
||||
let weak = PrivateKey::from(RsaKeypair::try_from(weak_rsa).unwrap());
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(31, 4096).unwrap();
|
||||
assert_eq!(
|
||||
load.add(item("weak-rsa", &weak)).unwrap(),
|
||||
Some(SkipCode::InvalidPrivateKey)
|
||||
);
|
||||
assert_eq!(store.publish(load).unwrap().loaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_invalidates_permits_and_clears_public_and_private_sets() {
|
||||
let key = ed25519();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let mut load = store.begin_load(41, 4096).unwrap();
|
||||
load.add(item("work", &key)).unwrap();
|
||||
store.publish(load).unwrap();
|
||||
let permit = store.authorize(&public_blob).unwrap();
|
||||
|
||||
store.logout(42);
|
||||
|
||||
assert_eq!(store.state(), VaultState::LoggedOut);
|
||||
assert!(store.public_identities().is_empty());
|
||||
assert!(store.sign(&permit, b"must not sign", 0).is_none());
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
use qs_bitwarden_ssh_agent::control::{
|
||||
parse_control_line, ControlError, ControlMessage, LoadStatus, MAX_CONTROL_LINE,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::runtime::{RuntimeError, ServiceRuntime};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use signature::Verifier;
|
||||
use ssh_encoding::{Decode, Encode};
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"qsbw-lifecycle-{}-{}",
|
||||
std::process::id(),
|
||||
OsRng.next_u64()
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_contract_accepts_every_allowlisted_message() {
|
||||
let messages = [
|
||||
r#"{"v":1,"type":"hello"}"#,
|
||||
r#"{"v":1,"type":"key_load_begin","epoch":7,"loadId":"00112233445566778899aabbccddeeff"}"#,
|
||||
r#"{"v":1,"type":"key_load_end","epoch":7,"status":"ok"}"#,
|
||||
r#"{"v":1,"type":"vault_locked","epoch":8}"#,
|
||||
r#"{"v":1,"type":"vault_logged_out"}"#,
|
||||
r#"{"v":1,"type":"approve","requestId":42,"grantSeconds":120}"#,
|
||||
r#"{"v":1,"type":"deny","requestId":42}"#,
|
||||
r#"{"v":1,"type":"unlock_cancelled","requestId":41,"reason":"user-cancelled"}"#,
|
||||
r#"{"v":1,"type":"revoke_grants"}"#,
|
||||
r#"{"v":1,"type":"shutdown"}"#,
|
||||
];
|
||||
for message in messages {
|
||||
parse_control_line(message.as_bytes()).unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
parse_control_line(messages[2].as_bytes()),
|
||||
Ok(ControlMessage::KeyLoadEnd {
|
||||
status: LoadStatus::Ok,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_contract_rejects_untrusted_shapes_and_versions() {
|
||||
assert_eq!(parse_control_line(b""), Err(ControlError::Empty));
|
||||
assert_eq!(
|
||||
parse_control_line(b"{not json}"),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":2,"type":"hello"}"#),
|
||||
Err(ControlError::WrongVersion)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":1,"type":"unknown"}"#),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_control_line(br#"{"v":1,"type":"hello","extra":true}"#),
|
||||
Err(ControlError::Malformed)
|
||||
);
|
||||
let oversized = vec![b'x'; MAX_CONTROL_LINE + 1];
|
||||
assert_eq!(parse_control_line(&oversized), Err(ControlError::TooLong));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn singleton_owns_private_socket_and_cleans_runtime_paths() {
|
||||
let temp = TempDir::new();
|
||||
let owner = ServiceRuntime::acquire(&temp.0).unwrap();
|
||||
let listener = owner.bind_socket().unwrap();
|
||||
let socket_path = owner.socket_path().to_path_buf();
|
||||
let fifo_path = owner.runtime().fifo_path().to_path_buf();
|
||||
let metadata = fs::symlink_metadata(&socket_path).unwrap();
|
||||
assert!(metadata.file_type().is_socket());
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
assert!(matches!(
|
||||
ServiceRuntime::acquire(&temp.0),
|
||||
Err(RuntimeError::AlreadyRunning)
|
||||
));
|
||||
drop(listener);
|
||||
drop(owner);
|
||||
assert!(!socket_path.exists());
|
||||
assert!(!fifo_path.exists());
|
||||
|
||||
let restarted = ServiceRuntime::acquire(&temp.0).unwrap();
|
||||
assert!(restarted.runtime().fifo_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_handshake_is_private_singleton_and_eof_supervised() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"{\"v\":1,\"type\":\"hello\"}\n")
|
||||
.unwrap();
|
||||
let mut ready_line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut ready_line)
|
||||
.unwrap();
|
||||
let ready: serde_json::Value = serde_json::from_str(&ready_line).unwrap();
|
||||
assert_eq!(ready["v"], 1);
|
||||
assert_eq!(ready["type"], "ready");
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
fs::symlink_metadata(&socket).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
|
||||
let status = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(!status.success());
|
||||
|
||||
drop(child.stdin.take());
|
||||
assert!(child.wait().unwrap().success());
|
||||
assert!(!socket.exists());
|
||||
assert!(!fifo.exists());
|
||||
assert!(!temp.0.join("qs-bitwarden-cli").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_is_a_one_time_handshake_not_a_signing_gate_command() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(read_json_line(&mut output)["type"], "ready");
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(read_json_line(&mut output)["type"], "locked");
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
|
||||
assert!(!child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposable_key_load_identity_and_approved_sign_cross_the_real_socket() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let mut loaded = read_json_line(&mut output);
|
||||
while loaded["type"] == "public_key" {
|
||||
loaded = read_json_line(&mut output);
|
||||
}
|
||||
assert_eq!(loaded["type"], "keys_loaded");
|
||||
assert_eq!(loaded["keyCount"], 1);
|
||||
|
||||
let mut client = UnixStream::connect(&socket).unwrap();
|
||||
let mut slow_client = UnixStream::connect(&socket).unwrap();
|
||||
slow_client.write_all(&100_u32.to_be_bytes()).unwrap();
|
||||
client.write_all(&[0, 0, 0, 1, 11]).unwrap();
|
||||
let identities = read_agent_frame(&mut client);
|
||||
assert_eq!(identities[4], 12);
|
||||
assert!(identities
|
||||
.windows(public_blob.len())
|
||||
.any(|part| part == public_blob));
|
||||
|
||||
let message = b"task nine approved signing";
|
||||
let mut request = vec![13];
|
||||
public_blob.encode(&mut request).unwrap();
|
||||
message.as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut frame = Vec::new();
|
||||
u32::try_from(request.len())
|
||||
.unwrap()
|
||||
.encode(&mut frame)
|
||||
.unwrap();
|
||||
frame.extend_from_slice(&request);
|
||||
client.write_all(&frame).unwrap();
|
||||
let approval = read_json_line(&mut output);
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
|
||||
let response = read_agent_frame(&mut client);
|
||||
assert_eq!(response[4], 14);
|
||||
let mut fields = &response[5..];
|
||||
let encoded = Vec::<u8>::decode(&mut fields).unwrap();
|
||||
let signature = Signature::try_from(encoded.as_slice()).unwrap();
|
||||
Verifier::verify(key.public_key(), message, &signature).unwrap();
|
||||
|
||||
// Exercise the real OpenSSH signing client with only a public key file;
|
||||
// the disposable private key remains solely in the helper keystore.
|
||||
let public_path = temp.0.join("disposable.pub");
|
||||
let message_path = temp.0.join("commit.txt");
|
||||
fs::write(&public_path, key.public_key().to_openssh().unwrap()).unwrap();
|
||||
fs::write(&message_path, b"disposable commit object").unwrap();
|
||||
let mut ssh_keygen = Command::new("/usr/bin/ssh-keygen")
|
||||
.env_clear()
|
||||
.env("SSH_AUTH_SOCK", &socket)
|
||||
.args(["-Y", "sign", "-f"])
|
||||
.arg(&public_path)
|
||||
.args(["-n", "git"])
|
||||
.arg(&message_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let approval = read_json_line(&mut output);
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(ssh_keygen.wait().unwrap().success());
|
||||
assert!(message_path.with_extension("txt.sig").exists());
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
/// A lock drops the private set but keeps the public projection, and the
|
||||
/// design's state table says a locked-with-cache agent still lists identities:
|
||||
/// otherwise every `ssh` after a lock raises an unlock prompt, including the
|
||||
/// ones authenticating with an on-disk key. Signing is what the lock denies.
|
||||
#[test]
|
||||
fn a_locked_vault_still_lists_identities_but_refuses_to_sign() {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let mut loaded = read_json_line(&mut output);
|
||||
while loaded["type"] == "public_key" {
|
||||
loaded = read_json_line(&mut output);
|
||||
}
|
||||
assert_eq!(loaded["type"], "keys_loaded");
|
||||
assert_eq!(loaded["keyCount"], 1);
|
||||
|
||||
// Unlocked: the identity is offered.
|
||||
assert_eq!(identity_count(&socket), 1);
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let locked = read_json_line(&mut output);
|
||||
assert_eq!(locked["type"], "locked");
|
||||
|
||||
// Locked with a cache: still listed, because public keys are not secret.
|
||||
assert_eq!(identity_count(&socket), 1);
|
||||
|
||||
// The private set is gone, so signing cannot proceed. The request is held
|
||||
// and an unlock is asked for rather than failed outright -- refusing a
|
||||
// client that has no way to retry is worse than asking. Dismissing that
|
||||
// unlock is what turns it into a refusal, and it must do so at once
|
||||
// rather than leaving the client to wait out the deadline.
|
||||
let socket_for_client = socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket_for_client).unwrap();
|
||||
let mut request = Vec::new();
|
||||
13_u8.encode(&mut request).unwrap();
|
||||
blob.as_slice().encode(&mut request).unwrap();
|
||||
b"payload".as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(&request);
|
||||
stream.write_all(&framed).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = read_json_line(&mut output);
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
writeln!(
|
||||
input,
|
||||
"{{\"v\":1,\"type\":\"unlock_cancelled\",\"requestId\":{request_id},\"reason\":\"user-cancelled\"}}"
|
||||
)
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 5,
|
||||
"a dismissed unlock must refuse the signature"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(10),
|
||||
"a dismissed unlock must refuse at once, not at the deadline"
|
||||
);
|
||||
|
||||
// Logout takes the public projection with it.
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"vault_logged_out\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert_eq!(identity_count(&socket), 0);
|
||||
|
||||
input
|
||||
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
|
||||
.unwrap();
|
||||
input.flush().unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
/// A sign request against a locked-but-cached vault must not simply fail: the
|
||||
/// design has it raise an unlock, hold the request across the load, and then
|
||||
/// ask for approval. The unlock and the approval carry different request ids,
|
||||
/// because they are different decisions.
|
||||
#[test]
|
||||
fn a_locked_sign_request_raises_unlock_then_approval() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(identity_count(&agent.socket), 1);
|
||||
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
// The client blocks on its request while the panel is asked to unlock.
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
assert_eq!(unlock["reason"], "sign");
|
||||
let unlock_id = unlock["requestId"].as_u64().unwrap();
|
||||
|
||||
// Unlocking is a fresh load at a new epoch, and it releases the request.
|
||||
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
|
||||
// The unlock prompt is withdrawn before the approval prompt replaces it,
|
||||
// so the panel is never left showing a question that has been answered.
|
||||
let withdrawn = agent.read();
|
||||
assert_eq!(withdrawn["type"], "request_cancelled");
|
||||
assert_eq!(withdrawn["requestId"].as_u64().unwrap(), unlock_id);
|
||||
assert_eq!(withdrawn["reason"], "released");
|
||||
let approval = agent.read();
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let approval_id = approval["requestId"].as_u64().unwrap();
|
||||
assert_ne!(
|
||||
unlock_id, approval_id,
|
||||
"unlock and approval are separate decisions"
|
||||
);
|
||||
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{approval_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 14,
|
||||
"an approved request must return a signature"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The approval decision needs the key's identity and the requesting program,
|
||||
/// both of which come from the public cache. None of it depends on the vault
|
||||
/// read finishing, so a user may approve while keys are still loading and the
|
||||
/// signature is produced the moment they arrive -- rather than being made to
|
||||
/// wait several seconds and only then be asked.
|
||||
#[test]
|
||||
fn an_approval_given_during_a_load_is_honoured_when_keys_arrive() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
|
||||
// Approved against the held request, before any load has been started.
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
|
||||
// The load lands afterwards and releases the request without asking again.
|
||||
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 14,
|
||||
"an approval given while keys were loading must produce a signature"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The same path must still refuse when the key that comes back is not the
|
||||
/// one that was approved. The approval names a key; the load decides whether
|
||||
/// that key is actually present.
|
||||
#[test]
|
||||
fn an_approval_given_during_a_load_still_requires_the_approved_key() {
|
||||
let mut agent = TestAgent::start();
|
||||
let approved = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let other = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = approved.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&approved, 1, "0123456789abcdef0123456789abcdef");
|
||||
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
|
||||
assert_eq!(agent.read()["type"], "locked");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
let request_id = unlock["requestId"].as_u64().unwrap();
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
|
||||
));
|
||||
|
||||
// A vault that now holds a different key entirely.
|
||||
agent.load_key(&other, 2, "fedcba9876543210fedcba9876543210");
|
||||
let response = client.join().unwrap();
|
||||
assert_eq!(
|
||||
response[4], 5,
|
||||
"the approved key is gone, so the signature must fail closed"
|
||||
);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// A freshly started companion has no public cache, so `ssh-add -L` is empty
|
||||
/// and no client will ever offer a vault key -- which means no sign request,
|
||||
/// and no way to ask for an unlock. Unlock-on-demand exists for exactly that
|
||||
/// cliff, and it has to begin at the identity listing rather than at signing.
|
||||
#[test]
|
||||
fn unlock_on_demand_raises_an_unlock_for_an_empty_identity_listing() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
|
||||
// Off by default: an empty cache answers empty and asks for nothing.
|
||||
assert_eq!(identity_count(&agent.socket), 0);
|
||||
|
||||
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
|
||||
agent.drain_control();
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
});
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
assert_eq!(unlock["reason"], "list-identities");
|
||||
|
||||
// The load releases the waiting listing with the real identities.
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
let frame = client.join().unwrap();
|
||||
assert_eq!(frame[4], 12, "expected an identities answer");
|
||||
let mut body = &frame[5..];
|
||||
assert_eq!(u32::decode(&mut body).unwrap(), 1);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// Several clients starting at once must not produce several unlock prompts.
|
||||
#[test]
|
||||
fn concurrent_identity_listings_coalesce_into_one_unlock() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
|
||||
agent.drain_control();
|
||||
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let socket = agent.socket.clone();
|
||||
clients.push(std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
|
||||
read_agent_frame(&mut stream)
|
||||
}));
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
}
|
||||
|
||||
let unlock = agent.read();
|
||||
assert_eq!(unlock["type"], "unlock_required");
|
||||
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
for client in clients {
|
||||
let frame = client.join().unwrap();
|
||||
assert_eq!(frame[4], 12, "every waiting listing gets its answer");
|
||||
}
|
||||
// Exactly one unlock was asked for; the next line is the keys_loaded that
|
||||
// load_key already consumed, so nothing else is queued behind it.
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// A client that walks away leaves a prompt on screen with nothing behind it.
|
||||
/// The companion says so rather than letting it sit until its deadline.
|
||||
#[test]
|
||||
fn a_disconnected_client_withdraws_its_prompt() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
|
||||
let mut stream = UnixStream::connect(&agent.socket).unwrap();
|
||||
stream.write_all(&sign_request(&public_blob)).unwrap();
|
||||
let approval = agent.read();
|
||||
assert_eq!(approval["type"], "approval_required");
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
|
||||
drop(stream);
|
||||
let cancelled = agent.read();
|
||||
assert_eq!(cancelled["type"], "request_cancelled");
|
||||
assert_eq!(cancelled["requestId"], request_id);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// Grants are only useful if the panel can see and revoke them, so every
|
||||
/// change to the set is announced with its remaining time.
|
||||
#[test]
|
||||
fn granting_and_revoking_announce_the_live_set() {
|
||||
let mut agent = TestAgent::start();
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let public_blob = key.public_key().to_bytes().unwrap();
|
||||
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
|
||||
|
||||
let socket = agent.socket.clone();
|
||||
let blob = public_blob.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
let first = read_agent_frame(&mut stream);
|
||||
// A second signature on the same connection rides the grant, with no
|
||||
// further prompt -- which is the whole point of offering one.
|
||||
stream.write_all(&sign_request(&blob)).unwrap();
|
||||
(first, read_agent_frame(&mut stream))
|
||||
});
|
||||
|
||||
let approval = agent.read();
|
||||
let request_id = approval["requestId"].as_u64().unwrap();
|
||||
assert_eq!(approval["grantOffered"], true);
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":120}}"
|
||||
));
|
||||
|
||||
let changed = agent.read();
|
||||
assert_eq!(changed["type"], "grants_changed");
|
||||
let grants = changed["grants"].as_array().unwrap();
|
||||
assert_eq!(grants.len(), 1);
|
||||
assert!(grants[0]["expiresInSec"].as_u64().unwrap() <= 120);
|
||||
assert!(grants[0]["expiresInSec"].as_u64().unwrap() > 0);
|
||||
let grant_id = grants[0]["grantId"].as_u64().unwrap();
|
||||
assert!(
|
||||
grants[0].get("privateKey").is_none(),
|
||||
"a grant must carry no key material"
|
||||
);
|
||||
|
||||
let (first, second) = client.join().unwrap();
|
||||
assert_eq!(first[4], 14);
|
||||
assert_eq!(second[4], 14, "a live grant signs without prompting again");
|
||||
|
||||
agent.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"revoke_grant\",\"grantId\":{grant_id}}}"
|
||||
));
|
||||
let revoked = agent.read();
|
||||
assert_eq!(revoked["type"], "grants_changed");
|
||||
assert_eq!(revoked["grants"].as_array().unwrap().len(), 0);
|
||||
agent.shutdown();
|
||||
}
|
||||
|
||||
/// The panel validates the bundled helper before it trusts it, and needs the
|
||||
/// helper's own answers to do that: what version it is, what protocol it
|
||||
/// speaks, and whether its crypto actually works on this machine. Both must
|
||||
/// answer without touching the filesystem, opening a socket, or needing a
|
||||
/// runtime directory -- they run before any of that exists.
|
||||
#[test]
|
||||
fn version_and_self_test_answer_without_touching_the_system() {
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let temp = TempDir::new();
|
||||
|
||||
let version = Command::new(executable)
|
||||
.arg("--version")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(version.status.success(), "--version must succeed");
|
||||
let text = String::from_utf8(version.stdout).unwrap();
|
||||
assert!(
|
||||
text.contains(env!("CARGO_PKG_VERSION")),
|
||||
"--version must report the crate version, got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("protocol 1"),
|
||||
"--version must report the control protocol version, got {text:?}"
|
||||
);
|
||||
|
||||
// No XDG_RUNTIME_DIR at all: neither mode may depend on one.
|
||||
let selftest = Command::new(executable)
|
||||
.arg("--self-test")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
selftest.status.success(),
|
||||
"--self-test failed: {}",
|
||||
String::from_utf8_lossy(&selftest.stderr)
|
||||
);
|
||||
let report = String::from_utf8(selftest.stdout).unwrap();
|
||||
assert!(
|
||||
report.contains("ok"),
|
||||
"self-test should say so, got {report:?}"
|
||||
);
|
||||
|
||||
// Nothing was created anywhere it could have been.
|
||||
let runtime = std::path::Path::new(&temp.0).join("qs-bitwarden-cli");
|
||||
assert!(
|
||||
!runtime.exists(),
|
||||
"a self-test must not create a runtime directory"
|
||||
);
|
||||
|
||||
// Neither mode may leak key material to either stream.
|
||||
let combined = format!("{report}{}", String::from_utf8_lossy(&selftest.stderr));
|
||||
assert!(
|
||||
!combined.contains("PRIVATE"),
|
||||
"the self-test must not print key material"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown flag must not be mistaken for "run as the agent". The panel
|
||||
/// launches this binary with no arguments; anything else is a mistake worth
|
||||
/// reporting rather than silently starting a key-holding daemon.
|
||||
#[test]
|
||||
fn an_unknown_argument_is_refused() {
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let out = Command::new(executable)
|
||||
.arg("--not-a-real-flag")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an unknown flag must not start the agent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A running agent with its control channel, for tests that drive several
|
||||
/// messages in sequence.
|
||||
struct TestAgent {
|
||||
child: std::process::Child,
|
||||
input: std::process::ChildStdin,
|
||||
output: BufReader<std::process::ChildStdout>,
|
||||
socket: PathBuf,
|
||||
fifo: PathBuf,
|
||||
alive: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
_temp: TempDir,
|
||||
}
|
||||
|
||||
impl TestAgent {
|
||||
fn start() -> Self {
|
||||
let temp = TempDir::new();
|
||||
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
|
||||
let mut child = Command::new(executable)
|
||||
.env_clear()
|
||||
.env("XDG_RUNTIME_DIR", &temp.0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
|
||||
input.flush().unwrap();
|
||||
let ready = read_json_line(&mut output);
|
||||
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
|
||||
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
|
||||
|
||||
// Every read below blocks on the agent's stdout, so a message the
|
||||
// agent never sends would hang the whole suite instead of failing it.
|
||||
// The watchdog kills the child, which closes stdout and turns that
|
||||
// hang into an EOF the assertions report.
|
||||
let alive = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
let watching = alive.clone();
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !watching.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
|
||||
});
|
||||
|
||||
Self {
|
||||
child,
|
||||
input,
|
||||
output,
|
||||
socket,
|
||||
fifo,
|
||||
alive,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, line: &str) {
|
||||
writeln!(self.input, "{line}").unwrap();
|
||||
self.input.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Wait until the control loop has processed everything sent so far.
|
||||
///
|
||||
/// Control messages are read in order on one channel, so a message whose
|
||||
/// effect is observable acts as a barrier for every message before it.
|
||||
/// `vault_locked` is that message: it answers with `locked`, and locking
|
||||
/// an empty store changes nothing a test then depends on.
|
||||
///
|
||||
/// Needed because a test that sends `options` and then connects a client
|
||||
/// is racing the control loop. That race is invisible on a fast machine
|
||||
/// and cost a CI run: the client's listing arrived first, was answered
|
||||
/// with an empty list instead of raising an unlock, and the test waited
|
||||
/// for a message that was never going to come.
|
||||
fn drain_control(&mut self) {
|
||||
self.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":0}");
|
||||
let acknowledged = self.read();
|
||||
assert_eq!(
|
||||
acknowledged["type"], "locked",
|
||||
"expected a lock acknowledgement"
|
||||
);
|
||||
}
|
||||
|
||||
fn read(&mut self) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
self.output.read_line(&mut line).unwrap();
|
||||
assert!(
|
||||
!line.is_empty(),
|
||||
"the agent closed its control channel without answering"
|
||||
);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn load_key(&mut self, key: &PrivateKey, epoch: u64, nonce: &str) {
|
||||
self.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":{epoch},\"loadId\":\"{nonce}\"}}"
|
||||
));
|
||||
let payload = serde_json::json!({"loadId": nonce, "items": [{
|
||||
"itemId": "disposable", "name": "Disposable test key",
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
}]});
|
||||
let mut writer = fs::OpenOptions::new().write(true).open(&self.fifo).unwrap();
|
||||
writer
|
||||
.write_all(&serde_json::to_vec(&payload).unwrap())
|
||||
.unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
drop(writer);
|
||||
self.send(&format!(
|
||||
"{{\"v\":1,\"type\":\"key_load_end\",\"epoch\":{epoch},\"status\":\"ok\"}}"
|
||||
));
|
||||
// The validated public set arrives one message per key ahead of
|
||||
// keys_loaded, so the panel holds the whole projection before it is
|
||||
// told the load finished. Skip past them to the completion.
|
||||
loop {
|
||||
let message = self.read();
|
||||
if message["type"] == "keys_loaded" {
|
||||
break;
|
||||
}
|
||||
assert_eq!(
|
||||
message["type"], "public_key",
|
||||
"only public keys may precede keys_loaded"
|
||||
);
|
||||
assert!(
|
||||
!message["publicKey"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("PRIVATE"),
|
||||
"a public_key message must never carry private material"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) {
|
||||
self.send("{\"v\":1,\"type\":\"shutdown\"}");
|
||||
let status = self.child.wait().unwrap();
|
||||
self.alive
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(status.success());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestAgent {
|
||||
fn drop(&mut self) {
|
||||
self.alive
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// A framed SSH_AGENTC_SIGN_REQUEST for one public blob.
|
||||
fn sign_request(public_blob: &[u8]) -> Vec<u8> {
|
||||
let mut request = Vec::new();
|
||||
13_u8.encode(&mut request).unwrap();
|
||||
public_blob.encode(&mut request).unwrap();
|
||||
b"payload".as_slice().encode(&mut request).unwrap();
|
||||
0_u32.encode(&mut request).unwrap();
|
||||
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(&request);
|
||||
framed
|
||||
}
|
||||
|
||||
/// Number of identities the agent offers over its real socket.
|
||||
fn identity_count(socket: &PathBuf) -> usize {
|
||||
let mut stream = UnixStream::connect(socket).unwrap();
|
||||
let request = [0_u8, 0, 0, 1, 11];
|
||||
stream.write_all(&request).unwrap();
|
||||
let frame = read_agent_frame(&mut stream);
|
||||
assert_eq!(frame[4], 12, "expected an identities answer, not a failure");
|
||||
let mut body = &frame[5..];
|
||||
usize::try_from(u32::decode(&mut body).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn read_json_line(reader: &mut BufReader<std::process::ChildStdout>) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn read_agent_frame(stream: &mut UnixStream) -> Vec<u8> {
|
||||
let mut header = [0_u8; 4];
|
||||
stream.read_exact(&mut header).unwrap();
|
||||
let length = usize::try_from(u32::from_be_bytes(header)).unwrap();
|
||||
let mut frame = header.to_vec();
|
||||
frame.resize(length + 4, 0);
|
||||
stream.read_exact(&mut frame[4..]).unwrap();
|
||||
frame
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use qs_bitwarden_ssh_agent::keystore::{
|
||||
KeyStore, LoadError, MAX_FILTERED_BYTES, MAX_METADATA_BYTES,
|
||||
};
|
||||
use qs_bitwarden_ssh_agent::load::{LoadWindow, PayloadError};
|
||||
use qs_bitwarden_ssh_agent::runtime::{read_payload_async, Runtime, RuntimeError};
|
||||
use rand_core::OsRng;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const NONCE: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"qsbw-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
rand_core::RngCore::next_u64(&mut OsRng)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn item_json(id: &str, key: &PrivateKey) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"itemId": id,
|
||||
"name": format!("key {id}"),
|
||||
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
|
||||
"publicKey": key.public_key().to_openssh().unwrap(),
|
||||
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
"requiresReprompt": false
|
||||
})
|
||||
}
|
||||
|
||||
fn payload(nonce: &str, items: Vec<serde_json::Value>) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({"loadId": nonce, "items": items})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_private_runtime_and_fifo_and_holds_both_fifo_ends() {
|
||||
let temp = TempDir::new("runtime");
|
||||
let runtime = Runtime::create(&temp.0).unwrap();
|
||||
let dir = fs::metadata(runtime.directory()).unwrap();
|
||||
let fifo = fs::symlink_metadata(runtime.fifo_path()).unwrap();
|
||||
|
||||
assert_eq!(dir.mode() & 0o777, 0o700);
|
||||
assert_eq!(fifo.mode() & 0o777, 0o600);
|
||||
assert!(fifo.file_type().is_fifo());
|
||||
assert_eq!(dir.uid(), rustix::process::geteuid().as_raw());
|
||||
assert_eq!(fifo.uid(), rustix::process::geteuid().as_raw());
|
||||
assert!(runtime.fifo().metadata().unwrap().file_type().is_fifo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_stale_wrong_type_symlink_and_insecure_directory() {
|
||||
let stale = TempDir::new("stale");
|
||||
let runtime_dir = stale.0.join("qs-bitwarden-cli");
|
||||
fs::create_dir(&runtime_dir).unwrap();
|
||||
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(runtime_dir.join("ssh-keys.fifo"), b"stale").unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&stale.0).unwrap_err(),
|
||||
RuntimeError::UnsafeFifo
|
||||
);
|
||||
|
||||
let insecure = TempDir::new("insecure");
|
||||
let dir = insecure.0.join("qs-bitwarden-cli");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&insecure.0).unwrap_err(),
|
||||
RuntimeError::UnsafeDirectory
|
||||
);
|
||||
|
||||
let linked = TempDir::new("linked");
|
||||
let target = linked.0.join("target");
|
||||
fs::create_dir(&target).unwrap();
|
||||
std::os::unix::fs::symlink(&target, linked.0.join("qs-bitwarden-cli")).unwrap();
|
||||
assert_eq!(
|
||||
Runtime::create(&linked.0).unwrap_err(),
|
||||
RuntimeError::UnsafeDirectory
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_nonce_payload_publishes_disposable_keys_once() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let bytes = payload(NONCE, vec![item_json("one", &key)]);
|
||||
let mut window = LoadWindow::new(7, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let candidate = window.decode(Zeroizing::new(bytes), &mut store).unwrap();
|
||||
assert_eq!(store.publish(candidate).unwrap().loaded, 1);
|
||||
assert_eq!(store.public_identities().len(), 1);
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![])), &mut store)
|
||||
.unwrap_err(),
|
||||
PayloadError::Closed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_schema_truncation_and_size_fail_the_whole_load() {
|
||||
let mut store = KeyStore::new();
|
||||
for (index, (nonce, bytes, expected)) in [
|
||||
(
|
||||
NONCE,
|
||||
payload("ffffffffffffffffffffffffffffffff", vec![]),
|
||||
PayloadError::NonceMismatch,
|
||||
),
|
||||
(
|
||||
NONCE,
|
||||
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":["#.to_vec(),
|
||||
PayloadError::Malformed,
|
||||
),
|
||||
(
|
||||
NONCE,
|
||||
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":[],"extra":1}"#.to_vec(),
|
||||
PayloadError::Malformed,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut window = LoadWindow::new(10 + index as u64, nonce).unwrap();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(bytes), &mut store)
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
let mut window = LoadWindow::new(20, NONCE).unwrap();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(
|
||||
Zeroizing::new(vec![b'x'; MAX_FILTERED_BYTES + 1]),
|
||||
&mut store
|
||||
)
|
||||
.unwrap_err(),
|
||||
PayloadError::Load(LoadError::FilteredPayloadTooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_drain_is_newline_framed_and_deadline_limited() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("drain");
|
||||
let mut runtime = Runtime::create(&temp.0).unwrap();
|
||||
let mut writer = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(runtime.fifo_path())
|
||||
.unwrap();
|
||||
writer.write_all(b"{\"loadId\":\"ok\"}\n").unwrap();
|
||||
assert_eq!(
|
||||
runtime
|
||||
.read_payload(Duration::from_secs(1))
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
b"{\"loadId\":\"ok\"}"
|
||||
);
|
||||
|
||||
writer.write_all(b"{}\n{}\n").unwrap();
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_secs(1)).unwrap_err(),
|
||||
RuntimeError::MultiplePayloads
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_millis(10)).unwrap_err(),
|
||||
RuntimeError::ReadTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_drain_rejects_a_stream_beyond_the_full_eight_mibibyte_cap() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("full-cap");
|
||||
let mut runtime = Runtime::create(&temp.0).unwrap();
|
||||
let fifo_path = runtime.fifo_path().to_owned();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let mut fifo = fs::OpenOptions::new().write(true).open(fifo_path).unwrap();
|
||||
let oversized = vec![b'x'; MAX_FILTERED_BYTES + 2];
|
||||
let _ = fifo.write_all(&oversized);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
runtime.read_payload(Duration::from_secs(30)).unwrap_err(),
|
||||
RuntimeError::PayloadTooLarge
|
||||
);
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_nonce_is_never_armed() {
|
||||
assert_eq!(
|
||||
LoadWindow::new(1, "short").unwrap_err(),
|
||||
PayloadError::InvalidNonce
|
||||
);
|
||||
assert_eq!(
|
||||
LoadWindow::new(1, "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap_err(),
|
||||
PayloadError::InvalidNonce
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_id_past_the_metadata_cap_fails_the_candidate() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let mut item = item_json("one", &key);
|
||||
// Real ones are 36-character UUIDs, and this is what the key is known by,
|
||||
// so it cannot be shortened to fit the way a display name can.
|
||||
item["itemId"] = serde_json::Value::String("i".repeat(65 * 1024));
|
||||
let mut window = LoadWindow::new(30, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
assert_eq!(
|
||||
window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
|
||||
.unwrap_err(),
|
||||
PayloadError::Load(LoadError::MetadataTooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_item_name_is_truncated_rather_than_losing_the_whole_load() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let mut item = item_json("one", &key);
|
||||
// Multibyte on purpose. 200 of these is 400 bytes, so the cut lands in the
|
||||
// middle of a character unless the boundary is respected -- and a name is
|
||||
// a String, which cannot hold half of one.
|
||||
let name = "é".repeat(200);
|
||||
item["name"] = serde_json::Value::String(name.clone());
|
||||
let mut window = LoadWindow::new(30, NONCE).unwrap();
|
||||
let mut store = KeyStore::new();
|
||||
let candidate = window
|
||||
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
|
||||
.expect("a descriptively named key is an ordinary key");
|
||||
store.publish(candidate).unwrap();
|
||||
|
||||
let identities = store.public_identities();
|
||||
assert_eq!(identities.len(), 1, "the key still loaded");
|
||||
let stored = &identities[0].name;
|
||||
assert!(stored.len() <= MAX_METADATA_BYTES);
|
||||
assert!(name.starts_with(stored.as_str()));
|
||||
assert!(!stored.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_producer_that_closes_without_a_newline_times_out() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp = TempDir::new("eof");
|
||||
let runtime = Runtime::create(&temp.0).unwrap();
|
||||
let mut writer = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(runtime.fifo_path())
|
||||
.unwrap();
|
||||
writer.write_all(b"{\"loadId\":\"unfinished\"").unwrap();
|
||||
drop(writer);
|
||||
|
||||
// The reader keeps its own write end open, so this is a producer that gave
|
||||
// up rather than a true end-of-stream -- but the read still has to end at
|
||||
// its deadline rather than spinning on a descriptor that stays readable.
|
||||
assert_eq!(
|
||||
read_payload_async(runtime.fifo_reader().unwrap(), Duration::from_millis(150))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
RuntimeError::ReadTimeout
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use qs_bitwarden_ssh_agent::protocol::{handle_frame, Identity, MAX_FRAME_LEN};
|
||||
use rand_core::OsRng;
|
||||
use signature::Verifier;
|
||||
use ssh_encoding::{Decode, Encode};
|
||||
use ssh_key::private::RsaKeypair;
|
||||
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
|
||||
|
||||
const FAILURE: u8 = 5;
|
||||
const REQUEST_IDENTITIES: u8 = 11;
|
||||
const IDENTITIES_ANSWER: u8 = 12;
|
||||
const SIGN_REQUEST: u8 = 13;
|
||||
const SIGN_RESPONSE: u8 = 14;
|
||||
const RSA_SHA2_256: u32 = 2;
|
||||
const RSA_SHA2_512: u32 = 4;
|
||||
|
||||
fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::with_capacity(payload.len() + 4);
|
||||
u32::try_from(payload.len())
|
||||
.unwrap()
|
||||
.encode(&mut encoded)
|
||||
.unwrap();
|
||||
encoded.extend_from_slice(payload);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn string(value: &[u8], out: &mut Vec<u8>) {
|
||||
value.encode(out).unwrap();
|
||||
}
|
||||
|
||||
fn response_payload(response: &[u8]) -> &[u8] {
|
||||
let declared = u32::from_be_bytes(response[..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(declared, response.len() - 4);
|
||||
&response[4..]
|
||||
}
|
||||
|
||||
fn sign_request(key_blob: &[u8], message: &[u8], flags: u32) -> Vec<u8> {
|
||||
let mut payload = vec![SIGN_REQUEST];
|
||||
string(key_blob, &mut payload);
|
||||
string(message, &mut payload);
|
||||
flags.encode(&mut payload).unwrap();
|
||||
frame(&payload)
|
||||
}
|
||||
|
||||
fn signature(response: &[u8]) -> Signature {
|
||||
let payload = response_payload(response);
|
||||
assert_eq!(payload[0], SIGN_RESPONSE);
|
||||
let mut encoded = &payload[1..];
|
||||
let signature_bytes = Vec::<u8>::decode(&mut encoded).unwrap();
|
||||
assert!(encoded.is_empty());
|
||||
Signature::try_from(signature_bytes.as_slice()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_openssh_encoded_identities() {
|
||||
let ed25519 = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let rsa = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
|
||||
let identities = [
|
||||
Identity::new(ed25519, "vault ed25519").unwrap(),
|
||||
Identity::new(rsa, "vault rsa").unwrap(),
|
||||
];
|
||||
|
||||
let response = handle_frame(&frame(&[REQUEST_IDENTITIES]), &identities);
|
||||
let payload = response_payload(&response);
|
||||
assert_eq!(payload[0], IDENTITIES_ANSWER);
|
||||
let mut fields = &payload[1..];
|
||||
assert_eq!(u32::decode(&mut fields).unwrap(), 2);
|
||||
for identity in identities.iter() {
|
||||
assert_eq!(
|
||||
Vec::<u8>::decode(&mut fields).unwrap(),
|
||||
identity.public_blob()
|
||||
);
|
||||
assert_eq!(String::decode(&mut fields).unwrap(), identity.comment());
|
||||
}
|
||||
assert!(fields.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_ed25519_requests_and_rejects_nonzero_flags() {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
|
||||
let identity = Identity::new(key, "ed25519").unwrap();
|
||||
let message = b"bounded agent protocol vector";
|
||||
|
||||
let signed = signature(&handle_frame(
|
||||
&sign_request(identity.public_blob(), message, 0),
|
||||
std::slice::from_ref(&identity),
|
||||
));
|
||||
assert_eq!(signed.algorithm(), Algorithm::Ed25519);
|
||||
Verifier::verify(identity.public_key(), message, &signed).unwrap();
|
||||
|
||||
let rejected = handle_frame(
|
||||
&sign_request(identity.public_blob(), message, RSA_SHA2_256),
|
||||
&[identity],
|
||||
);
|
||||
assert_eq!(response_payload(&rejected), &[FAILURE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_rsa_with_exactly_the_requested_sha2_algorithm() {
|
||||
let key = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
|
||||
let identity = Identity::new(key, "rsa").unwrap();
|
||||
let message = b"rsa protocol vector";
|
||||
|
||||
for (flags, hash) in [
|
||||
(RSA_SHA2_256, HashAlg::Sha256),
|
||||
(RSA_SHA2_512, HashAlg::Sha512),
|
||||
] {
|
||||
let signed = signature(&handle_frame(
|
||||
&sign_request(identity.public_blob(), message, flags),
|
||||
std::slice::from_ref(&identity),
|
||||
));
|
||||
assert_eq!(signed.algorithm(), Algorithm::Rsa { hash: Some(hash) });
|
||||
Verifier::verify(identity.public_key(), message, &signed).unwrap();
|
||||
}
|
||||
|
||||
for flags in [0, RSA_SHA2_256 | RSA_SHA2_512, 8] {
|
||||
let rejected = handle_frame(
|
||||
&sign_request(identity.public_blob(), message, flags),
|
||||
std::slice::from_ref(&identity),
|
||||
);
|
||||
assert_eq!(response_payload(&rejected), &[FAILURE]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_disallowed_requests_receive_only_bounded_failure() {
|
||||
let cases = [
|
||||
Vec::new(),
|
||||
vec![0, 0, 0, 2, REQUEST_IDENTITIES],
|
||||
frame(&[REQUEST_IDENTITIES, 0]),
|
||||
frame(&[17]),
|
||||
frame(&[18]),
|
||||
frame(&[19]),
|
||||
frame(&[20]),
|
||||
frame(&[21]),
|
||||
frame(&[22]),
|
||||
frame(&[23]),
|
||||
frame(&[25]),
|
||||
frame(&[26]),
|
||||
frame(&[27, 0, 0, 0, 1, 0xff]),
|
||||
frame(&[255]),
|
||||
];
|
||||
|
||||
for request in cases {
|
||||
assert_eq!(response_payload(&handle_frame(&request, &[])), &[FAILURE]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_rejected_before_body_allocation_or_parsing() {
|
||||
let oversized_header = u32::try_from(MAX_FRAME_LEN + 1).unwrap().to_be_bytes();
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&oversized_header, &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
let mut invalid_string = vec![SIGN_REQUEST];
|
||||
invalid_string.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&invalid_string), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
let mut unknown_key = vec![SIGN_REQUEST];
|
||||
string(b"not an advertised public key", &mut unknown_key);
|
||||
string(b"message", &mut unknown_key);
|
||||
0_u32.encode(&mut unknown_key).unwrap();
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&unknown_key), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
|
||||
unknown_key.push(0);
|
||||
assert_eq!(
|
||||
response_payload(&handle_frame(&frame(&unknown_key), &[])),
|
||||
&[FAILURE]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user