crates/gunnar-cli/tests/common/member.rs
//! **A member of an encrypted repository**: a HOME of their own, an ed25519
//! key at `~/.ssh/id_ed25519` that both signs in over SSH and unwraps the key
//! ring, and a principal on the harness's server.
//!
//! Shared by `encrypted_repo.rs` (stock `git` on the wire, gunnar as the
//! filter) and `client_encryption.rs` (gunnar's own client on the wire), so
//! the two suites cannot drift on what "a user" is.
use std::path::{Path, PathBuf};
use std::process::Command;
use gunnar_core::{Grant, Level, Principal, PrincipalKind, RepoRef};
use super::{Harness, Output};
/// One person: a HOME, an ed25519 key in it, a principal on the server, and
/// (CRYPT-YUBI) their ONE encryption key at the server, wrapped to that SSH key.
pub struct User {
pub name: &'static str,
pub home: PathBuf,
pub key: PathBuf,
/// The person's encryption PUBLIC key (`age1pq1…` by default): what a ring is wrapped to.
pub public: String,
/// The SSH public key line (the login, and the unlock method).
pub ssh_public: String,
/// The repository this user's working clone is named after.
repo: String,
}
impl User {
/// A HOME with a fresh ed25519 key, registered on `h` as principal
/// `name`, granted `level` on `ns/repo` when given.
pub fn new(
h: &Harness,
name: &'static str,
level: Option<Level>,
ns: &str,
repo: &str,
) -> User {
let mut u = User::without_encryption_key(h, name, level, ns, repo);
u.public = give_an_encryption_key(h, name, &u.ssh_public);
u
}
/// [`User::new`], before the person has an encryption key (their first
/// `gunnar crypt init` or `gunnar crypt key new` makes it).
pub fn without_encryption_key(
h: &Harness,
name: &'static str,
level: Option<Level>,
ns: &str,
repo: &str,
) -> User {
let home = h.dir.path().join(format!("home-{name}"));
std::fs::create_dir_all(home.join(".ssh")).unwrap();
let private = gunnar_core::mint_ed25519().expect("mint a key");
let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).unwrap();
let public = parsed.public_key().to_openssh().unwrap();
let key = home.join(".ssh/id_ed25519");
std::fs::write(&key, &private).unwrap();
std::fs::write(home.join(".ssh/id_ed25519.pub"), format!("{public}\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let auth = h.stores.auth();
auth.add_principal(Principal::new(name, name, PrincipalKind::User).unwrap())
.unwrap();
auth.register_key(name, &public, None).unwrap();
if let Some(level) = level {
auth.grant(Grant::repo(name, RepoRef::new(ns, repo).unwrap(), level))
.unwrap();
}
User {
name,
home,
key,
public: String::new(),
ssh_public: public,
repo: repo.to_string(),
}
}
pub fn env(&self, c: &mut Command) {
c.env(
"GIT_SSH_COMMAND",
format!(
"ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR",
self.key.display()
),
)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", &self.home)
.env("GIT_AUTHOR_NAME", self.name)
.env("GIT_AUTHOR_EMAIL", "member@example.invalid")
.env("GIT_COMMITTER_NAME", self.name)
.env("GIT_COMMITTER_EMAIL", "member@example.invalid")
// Never the developer's agent or terminal: the key FILE is the path.
.env_remove("SSH_AUTH_SOCK")
// The quantum-safe key lives under HOME's own config dir.
.env_remove("XDG_CONFIG_HOME");
}
pub fn git(&self, cwd: &Path, args: &[&str]) -> Output {
let mut c = Command::new("git");
c.current_dir(cwd).args(args);
self.env(&mut c);
output(c, args, "git")
}
pub fn gunnar(&self, cwd: &Path, args: &[&str]) -> Output {
let mut c = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
c.current_dir(cwd).args(args);
self.env(&mut c);
output(c, args, "gunnar")
}
pub fn work(&self) -> PathBuf {
self.home.join(&self.repo)
}
}
pub fn output(mut c: Command, args: &[&str], program: &str) -> Output {
let out = c.output().unwrap_or_else(|e| panic!("{program} runs: {e}"));
Output {
argv: std::iter::once(program.to_string())
.chain(args.iter().map(|a| a.to_string()))
.collect(),
status: out.status.code().unwrap_or(-1),
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
pub fn read(path: impl AsRef<Path>) -> Vec<u8> {
std::fs::read(path.as_ref()).unwrap_or_else(|e| panic!("read {}: {e}", path.as_ref().display()))
}
impl User {
/// `gunnar crypt keygen` in this HOME; the public half it printed to a file.
pub fn keygen(&self) -> String {
self.gunnar(&self.home, &["crypt", "keygen"])
.expect_ok("gunnar crypt keygen");
let public = std::fs::read_to_string(self.pq_key().with_extension("key.pub")).unwrap();
public.trim().to_string()
}
pub fn pq_key(&self) -> PathBuf {
self.home.join(".config/gunnar/crypt-pq.key")
}
}
/// Make `who`'s encryption key at the server, wrapped to their SSH key, as
/// `gunnar crypt key new` does (quantum-safe, `Level::NEW`, CRYPT-PQ-DEFAULT);
/// its public half.
pub fn give_an_encryption_key(h: &Harness, who: &str, ssh_public: &str) -> String {
give_an_encryption_key_at(h, who, ssh_public, gunnar_crypt::members::Level::NEW)
}
/// [`give_an_encryption_key`] at a level (a quantum-safe person's key is age1pq1…).
pub fn give_an_encryption_key_at(
h: &Harness,
who: &str,
ssh_public: &str,
level: gunnar_crypt::members::Level,
) -> String {
use gunnar_core::encryption_key::{MethodKind, UnlockMethod};
let user = gunnar_crypt::userkey::UserKey::generate(level).unwrap();
let method = UnlockMethod {
id: String::new(),
kind: MethodKind::Ssh,
label: who.to_string(),
method_ref: ssh_public
.split_whitespace()
.take(2)
.collect::<Vec<_>>()
.join(" "),
rp_id: String::new(),
wrap: user.wrap_to_ssh(ssh_public).unwrap(),
added_ms: 0,
};
h.stores
.encryption_keys()
.create(who, &user.public(), method, 1)
.unwrap();
user.public()
}