- 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
247 lines
8.9 KiB
Rust
247 lines
8.9 KiB
Rust
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());
|
|
}
|