gunnar-crypt 1.6.0

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
//! The age wrap of the key ring to members' user encryption keys, and the SSH
//! key FILE as the CLI's unlock method of a user key.

#![cfg(not(target_arch = "wasm32"))]

use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members};
use gunnar_crypt::userkey::{self, UserKey, UserKeyError};
use gunnar_crypt::wrap::{self, NoPassphrase, Passphrase, UnwrapError};
use std::process::Command;

/// A real OpenSSH key pair from ssh-keygen: (private PEM, public line).
fn keygen(dir: &std::path::Path, name: &str, ty: &str, pass: &str) -> (Vec<u8>, String) {
    let path = dir.join(name);
    let ok = Command::new("ssh-keygen")
        .args(["-q", "-t", ty, "-N", pass, "-C", name, "-f"])
        .arg(&path)
        .status()
        .expect("ssh-keygen runs");
    assert!(ok.success());
    let pem = std::fs::read(&path).unwrap();
    let public = std::fs::read_to_string(path.with_extension("pub")).unwrap();
    (pem, public.trim().to_string())
}

fn have_ssh_keygen() -> bool {
    Command::new("ssh-keygen").arg("-?").output().is_ok()
}

#[test]
fn members_unwrap_and_a_non_member_cannot() {
    if !have_ssh_keygen() {
        eprintln!("skipped: no ssh-keygen");
        return;
    }
    let dir = tempfile::tempdir().unwrap();
    let (alice_pem, alice_pub) = keygen(dir.path(), "alice", "ed25519", "");
    let (bob_pem, bob_pub) = keygen(dir.path(), "bob", "rsa", "");
    let (carol_pem, _) = keygen(dir.path(), "carol", "ed25519", "");

    // Each member's ONE encryption key, wrapped on the server to their SSH key.
    let (alice, bob) = (
        UserKey::generate(Level::Classic).unwrap(),
        UserKey::generate(Level::Classic).unwrap(),
    );
    let alice_wrap = alice.wrap_to_ssh(&alice_pub).unwrap();
    let bob_wrap = bob.wrap_to_ssh(&bob_pub).unwrap();
    let mut members = Members::at(gunnar_crypt::members::Level::Classic);
    members.add("alice", &alice.public()).unwrap();
    members.add("bob", &bob.public()).unwrap();
    let ring = Keyring::generate();
    let file = wrap::wrap(&ring, &members).unwrap();
    assert!(file.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
    assert!(gunnar_crypt::policy::is_age_file(file.as_bytes()));

    for (pem, w, public) in [
        (&alice_pem, &alice_wrap, alice.public()),
        (&bob_pem, &bob_wrap, bob.public()),
    ] {
        let id = wrap::ssh_identity(pem, None, NoPassphrase).unwrap();
        let user = userkey::open(w.as_bytes(), id.as_ref(), &public).unwrap();
        assert_eq!(
            wrap::unwrap_checked(file.as_bytes(), &members.to_text(), &[user.identity()]).unwrap(),
            ring
        );
    }
    // carol's SSH key opens neither wrap; her own user key is no recipient.
    let carol = wrap::ssh_identity(&carol_pem, None, NoPassphrase).unwrap();
    assert_eq!(
        userkey::open(alice_wrap.as_bytes(), carol.as_ref(), &alice.public()).unwrap_err(),
        UserKeyError::WrongKey
    );
    let carol_user = UserKey::generate(Level::Classic).unwrap();
    assert!(matches!(
        wrap::unwrap(file.as_bytes(), &[carol_user.identity()]),
        Err(UnwrapError::NotARecipient)
    ));
}

#[derive(Clone)]
struct Fixed(&'static str);
impl Passphrase for Fixed {
    fn ask(&self, _: &str) -> Option<String> {
        Some(self.0.to_string())
    }
}

#[test]
fn a_passphrase_protected_key_asks_for_its_passphrase() {
    if !have_ssh_keygen() {
        return;
    }
    let dir = tempfile::tempdir().unwrap();
    let (pem, public) = keygen(dir.path(), "dana", "ed25519", "correct horse");
    let user = UserKey::generate(Level::Classic).unwrap();
    let file = user.wrap_to_ssh(&public).unwrap();

    let without = wrap::ssh_identity(&pem, None, NoPassphrase).unwrap();
    assert!(userkey::open(file.as_bytes(), without.as_ref(), &user.public()).is_err());
    let with = wrap::ssh_identity(&pem, None, Fixed("correct horse")).unwrap();
    assert_eq!(
        userkey::open(file.as_bytes(), with.as_ref(), &user.public())
            .unwrap()
            .public(),
        user.public()
    );
}

#[test]
fn an_age_file_that_is_not_a_key_ring_is_never_released() {
    // Some other secret sealed to the same user key (a sops file, a backup…).
    // The unwrap decrypts it, sees it is not a key ring, and refuses it.
    let user = age::x25519::Identity::generate();
    let recipient = user.to_public();
    let enc = age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
        .unwrap();
    let mut out = Vec::new();
    let mut w = enc.wrap_output(&mut out).unwrap();
    std::io::Write::write_all(&mut w, b"DATABASE_PASSWORD=hunter2\n").unwrap();
    w.finish().unwrap();

    let err = wrap::unwrap(&out, &[&user]).unwrap_err();
    assert!(matches!(err, UnwrapError::NotAKeyring(_)), "{err}");
    assert!(!err.to_string().contains("hunter2"));
}

#[test]
fn a_recovery_age_key_is_a_recipient_too() {
    let recovery = age::x25519::Identity::generate();
    let mut members = Members::at(gunnar_crypt::members::Level::Classic);
    members
        .add("recovery", &recovery.to_public().to_string())
        .unwrap();
    let ring = Keyring::generate();
    let file = wrap::wrap(&ring, &members).unwrap();
    assert_eq!(wrap::unwrap(file.as_bytes(), &[&recovery]).unwrap(), ring);
}