mod common;
use std::sync::Arc;
use common::*;
use gunnar_crypt::door::{Answer, MethodLine, Request};
use gunnar_crypt::members::Level;
use gunnar_crypt::userkey::{recovery_code, security_key, UserKey};
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, GunnarSsh, SshConfig, RECIPIENT_KEYS_VERB};
async fn serve(plane: Plane) -> gunnar_ssh::BoundServer {
let transport = Arc::new(BannerTransport::new());
GunnarSsh::new(SshConfig::ephemeral_loopback(), plane.control, transport)
.bind(mint_key())
.await
.expect("bind")
}
fn sk(user: &UserKey, fill: u8, cred: &str) -> MethodLine {
MethodLine {
kind: "security-key".into(),
label: format!("key {fill}"),
method_ref: cred.into(),
rp_id: "console.mock.invalid".into(),
wrap: user
.wrap_to_security_key(&security_key::recipient(&[fill; 32], cred.as_bytes()))
.unwrap(),
..MethodLine::default()
}
}
async fn said(
session: &russh::client::Handle<TrustingClient>,
line: &str,
) -> (Option<u32>, String) {
match exec(session, line).await.unwrap() {
ExecOutcome::Accepted {
stdout,
exit_status,
} => (exit_status, stdout),
other => panic!("{line:.60}: {other:?}"),
}
}
#[tokio::test]
async fn a_person_keeps_one_key_and_its_methods_and_others_see_only_the_public_half() {
let (alice, bob) = (mint_key(), mint_key());
let plane = Plane::empty()
.with_the_repo()
.with_principal("alice")
.with_key("alice", &alice)
.with_grant("alice", Access::Write)
.with_principal("bob")
.with_key("bob", &bob)
.with_grant("bob", Access::Read);
let server = serve(plane).await;
let addr = server.local_addr();
let b = authenticate(addr, &bob, "git")
.await
.unwrap()
.expect("bob's key");
assert_eq!(
said(&b, &Request::Get.line()).await,
(Some(0), String::new())
);
let user = UserKey::generate(Level::Classic).unwrap();
let create = Request::Create {
public_key: user.public(),
method: sk(&user, 1, "cred-one"),
};
let (exit, out) = said(&b, &create.line()).await;
assert_eq!(exit, Some(0), "{out}");
let a = Answer::parse(&out).expect("an answer");
assert_eq!(a.public_key.as_deref(), Some(user.public().as_str()));
assert_eq!((a.methods.len(), a.methods[0].id.len()), (1, 16));
let (exit, out) = said(&b, &create.line()).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("orphan"), "{out}");
let (exit, out) = said(&b, &Request::Remove(a.methods[0].id.clone()).line()).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("last unlock method"), "{out}");
let code = MethodLine {
kind: "recovery-code".into(),
label: "printed".into(),
wrap: user
.wrap_to_recovery_code(&recovery_code::from_bytes(&[4; 20]))
.unwrap(),
..MethodLine::default()
};
let (exit, out) = said(&b, &Request::Add(code).line()).await;
assert_eq!(exit, Some(0), "{out}");
assert_eq!(Answer::parse(&out).unwrap().methods.len(), 2);
let a_session = authenticate(addr, &alice, "git")
.await
.unwrap()
.expect("alice's key");
let repo = the_repo();
let path = format!("'{}/{}.git'", repo.namespace(), repo.name());
let (exit, out) = said(&a_session, &format!("{RECIPIENT_KEYS_VERB} {path} bob")).await;
assert_eq!((exit, out.trim()), (Some(0), user.public().as_str()));
assert!(!out.contains("AGE ENCRYPTED FILE"), "never a wrap: {out}");
assert_eq!(
said(&a_session, &Request::Get.line()).await,
(Some(0), String::new())
);
let (exit, out) = said(&b, &format!("{RECIPIENT_KEYS_VERB} {path} alice")).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("alice has no encryption key yet"), "{out}");
let (exit, out) = said(&b, "gunnar-user-key add 1 2 3").await;
assert_eq!(exit, Some(1));
assert!(out.contains("gunnar-user-key"), "{out}");
}
#[tokio::test]
async fn method_changes_over_the_door_are_in_the_account_audit() {
use gunnar_core::account_audit::{AccountAuditLog, AuditQuery};
let bob = mint_key();
let mut plane = Plane::empty()
.with_the_repo()
.with_principal("bob")
.with_key("bob", &bob)
.with_grant("bob", Access::Read);
let audit = Arc::new(AccountAuditLog::new(Arc::clone(&plane.store)).unwrap());
let names = gunnar_core::name_filter::NameFilterStore::new(Arc::clone(&plane.store)).unwrap();
plane.control = plane
.control
.clone()
.with_enterprise(names, Arc::clone(&audit));
let server = serve(plane).await;
let b = authenticate(server.local_addr(), &bob, "git")
.await
.unwrap()
.expect("bob's key");
let user = UserKey::generate(Level::Classic).unwrap();
let (exit, out) = said(
&b,
&Request::Create {
public_key: user.public(),
method: sk(&user, 1, "cred-one"),
}
.line(),
)
.await;
assert_eq!(exit, Some(0), "{out}");
let code = MethodLine {
kind: "recovery-code".into(),
label: "printed".into(),
wrap: user
.wrap_to_recovery_code(&recovery_code::from_bytes(&[4; 20]))
.unwrap(),
..MethodLine::default()
};
let (exit, out) = said(&b, &Request::Add(code).line()).await;
assert_eq!(exit, Some(0), "{out}");
let id = Answer::parse(&out).unwrap().methods[1].id.clone();
let (exit, _) = said(&b, &Request::Remove(id).line()).await;
assert_eq!(exit, Some(0));
let kinds: Vec<String> = audit
.all("bob", &AuditQuery::default())
.unwrap()
.into_iter()
.map(|e| e.kind)
.collect();
for want in [
"encryption-key.created",
"encryption-key.method-added",
"encryption-key.method-removed",
] {
assert!(
kinds.iter().any(|k| k == want),
"{want} is not in bob's audit: {kinds:?}"
);
}
}