Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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()
}