gunnar-crypt 1.5.0

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, and the key wrap to members' SSH public keys
Documentation
//! The key ring, wrapped to every member key with age, and unwrapped with an
//! SSH private key FILE or a post-quantum identity file.
//!
//! **Two schemes, one trait** ([`WrapScheme`]), chosen per repository by the
//! level in its members file ([`Level`]):
//!
//! * [`SshClassic`] — the members' SSH keys (below). A ring harvested today
//!   opens the day a quantum computer breaks X25519 or RSA.
//! * [`StandalonePq`] — age's hybrid ML-KEM-768 + X25519 recipient
//!   (`age1pq1…`, [`crate::pq`]), a separate key per member device made by
//!   `gunnar crypt keygen` and kept on it. The ring is wrapped to those keys
//!   ONLY: one classical stanza beside them would be the weak link. An SSH key
//!   cannot be such a recipient. (Deriving the hybrid key from an ed25519 SSH
//!   key's seed would spare members the second key; it is a new construction
//!   and is not built — it could be a third scheme after an external review.)
//!
//! age's SSH recipients do the work: an `ssh-ed25519` key is converted to an
//! X25519 key (the "tweaked" scheme of age-encryption.org/v1), an `ssh-rsa`
//! key uses RSA-OAEP, and an `age1…` key is plain X25519. One age file holds
//! one stanza per key; adding a member is one more stanza.
//!
//! **Why age and not our own wrap**: `age` 0.12 (str4d/rage, MIT OR
//! Apache-2.0) is maintained (0.12.1, 2026-07), has a published format spec,
//! test vectors and fuzzing, and already speaks SSH keys — the one thing the
//! owner asked for ("the ssh key decrypts"). A home-made X25519 + HKDF +
//! ChaCha20-Poly1305 wrap would be shorter, and unreviewed.
//!
//! **ssh-agent cannot unwrap.** The agent protocol only signs; decryption
//! needs the private scalar, so the CLI reads the key FILE and asks for its
//! passphrase if it has one.

use std::io::{Read, Write};

/// age itself, for callers that hold identities.
pub use age;

use age::armor::{ArmoredReader, ArmoredWriter, Format};
use zeroize::Zeroizing;

use crate::keyring::{Keyring, KeyringError};
pub use crate::members::Level;
use crate::members::{KeyKind, MemberKey, Members};
use crate::policy::{self, Refusal};

/// Why the wrap failed.
#[derive(Debug)]
pub enum WrapError {
    /// No member key to wrap to.
    NoMembers,
    /// A member key age would not take.
    BadKey(String, String),
    /// age itself failed.
    Age(String),
}

impl std::fmt::Display for WrapError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            WrapError::NoMembers => write!(f, "the key ring needs at least one member key"),
            WrapError::BadKey(name, why) => write!(f, "{name}'s key cannot be used: {why}"),
            WrapError::Age(e) => write!(f, "age: {e}"),
        }
    }
}
impl std::error::Error for WrapError {}

/// Why the unwrap failed.
#[derive(Debug)]
pub enum UnwrapError {
    /// None of the given identities is a recipient of this key file: the
    /// holder is not (or no longer) a member.
    NotARecipient,
    /// The key file is not a readable age file.
    Malformed(String),
    /// The payload decrypted, but it is not a key ring. `gunnar unlock`
    /// releases nothing in that case.
    NotAKeyring(KeyringError),
    /// The ring's stanzas do not match the members file (a stanza for a key
    /// nobody listed, a missing one, or a classical one in a quantum-safe
    /// repository): refused before anything is unwrapped.
    Mismatch(Refusal),
}

impl std::fmt::Display for UnwrapError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            UnwrapError::NotARecipient => write!(
                f,
                "this SSH key is not one the key ring is wrapped to — you are not a member \
                 of this encrypted repository (or were removed before this key ring)"
            ),
            UnwrapError::Malformed(e) => write!(f, "the key file is not a readable age file: {e}"),
            UnwrapError::NotAKeyring(e) => write!(f, "the unwrapped payload is refused: {e}"),
            UnwrapError::Mismatch(r) => write!(f, "the key ring is refused: {r}"),
        }
    }
}
impl std::error::Error for UnwrapError {}

/// **How a repository's key ring is wrapped.** One implementation per
/// [`Level`]; [`scheme`] picks it from the members file.
pub trait WrapScheme {
    /// The level this scheme gives a repository.
    fn level(&self) -> Level;
    /// The age recipient for one member key, or why this scheme cannot take
    /// it (the words the member is shown).
    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String>;
}

/// The members' SSH keys (`ssh-ed25519`, `ssh-rsa`) and an `age1…` recovery
/// key: age's SSH and X25519 recipients.
#[derive(Clone, Copy, Debug)]
pub struct SshClassic;

impl WrapScheme for SshClassic {
    fn level(&self) -> Level {
        Level::Classic
    }
    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String> {
        match key.kind {
            KeyKind::SshEd25519 | KeyKind::SshRsa => key
                .key
                .parse::<age::ssh::Recipient>()
                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
                .map_err(|e| format!("{e:?}")),
            KeyKind::Age => key
                .key
                .trim()
                .parse::<age::x25519::Recipient>()
                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
                .map_err(|e| e.to_string()),
            KeyKind::AgePq => Err(
                "an age1pq1… key is quantum-safe and this repository is classic; run \
                 `gunnar crypt upgrade-pq` to make it quantum-safe"
                    .to_string(),
            ),
        }
    }
}

/// age's hybrid ML-KEM-768 + X25519 recipient (`age1pq1…`), one separate key
/// per member device, and nothing else.
#[derive(Clone, Copy, Debug)]
pub struct StandalonePq;

/// What a member is told when an SSH key is offered to a quantum-safe ring.
pub const SSH_IS_NOT_PQ: &str = "SSH keys cannot be quantum-safe recipients: an ed25519 \
    key's wrap is X25519 and an RSA key's is RSA-OAEP, and a quantum computer breaks both. \
    Each member makes a separate quantum-safe key with `gunnar crypt keygen` (an age1pq1… \
    key, ML-KEM-768 + X25519; its private half stays on their machine) and registers the \
    public half with gunnar";

impl WrapScheme for StandalonePq {
    fn level(&self) -> Level {
        Level::QuantumSafe
    }
    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String> {
        match key.kind {
            KeyKind::AgePq => key
                .key
                .parse::<crate::pq::Recipient>()
                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
                .map_err(str::to_string),
            KeyKind::SshEd25519 | KeyKind::SshRsa | KeyKind::Age => Err(SSH_IS_NOT_PQ.to_string()),
        }
    }
}

/// The scheme of a level.
pub fn scheme(level: Level) -> &'static dyn WrapScheme {
    match level {
        Level::Classic => &SshClassic,
        Level::QuantumSafe => &StandalonePq,
    }
}

/// Wrap `ring` to every key in `members`, with the scheme of its level;
/// returns the armored age file (the content of `.gunnar-crypt/keys`).
pub fn wrap(ring: &Keyring, members: &Members) -> Result<String, WrapError> {
    if members.is_empty() {
        return Err(WrapError::NoMembers);
    }
    let scheme = scheme(members.level());
    let mut recipients = Vec::new();
    for m in members.entries() {
        recipients.push(
            scheme
                .recipient(m)
                .map_err(|e| WrapError::BadKey(m.name.clone(), e))?,
        );
    }
    let encryptor = age::Encryptor::with_recipients(
        recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient),
    )
    .map_err(|e| WrapError::Age(e.to_string()))?;
    let mut out = Vec::new();
    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor)
        .map_err(|e| WrapError::Age(e.to_string()))?;
    let mut w = encryptor
        .wrap_output(armor)
        .map_err(|e| WrapError::Age(e.to_string()))?;
    w.write_all(ring.to_text().as_bytes())
        .map_err(|e| WrapError::Age(e.to_string()))?;
    w.finish()
        .and_then(|a| a.finish())
        .map_err(|e| WrapError::Age(e.to_string()))?;
    String::from_utf8(out).map_err(|e| WrapError::Age(e.to_string()))
}

/// Unwrap a key file with the given identities. The payload must be a key
/// ring; anything else is refused and dropped (zeroed).
pub fn unwrap(keys_file: &[u8], identities: &[&dyn age::Identity]) -> Result<Keyring, UnwrapError> {
    // `new_buffered`, not `new`: age's unbuffered header reader asks for the
    // bytes it still needs a few at a time and re-parses the header from its
    // start after each read, which is quadratic in the header's size — a
    // 500-member quantum-safe ring (a ~1 MB header) never finished
    // (MEASURED 2026-10-06, `tests/pq_stress.rs` and `examples/pq_bench.rs`).
    // The buffered reader parses what the buffer holds, once.
    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(keys_file))
        .map_err(|e| UnwrapError::Malformed(e.to_string()))?;
    let mut reader = match decryptor.decrypt(identities.iter().copied()) {
        Ok(r) => r,
        Err(age::DecryptError::NoMatchingKeys) => return Err(UnwrapError::NotARecipient),
        Err(e) => return Err(UnwrapError::Malformed(e.to_string())),
    };
    let mut buf = Zeroizing::new(Vec::new());
    reader
        .read_to_end(&mut buf)
        .map_err(|e| UnwrapError::Malformed(e.to_string()))?;
    let text = std::str::from_utf8(&buf)
        .map_err(|_| UnwrapError::NotAKeyring(KeyringError::NotAKeyring))?;
    Keyring::parse(text).map_err(UnwrapError::NotAKeyring)
}

/// [`unwrap`], after the ring's stanzas are checked against the members file
/// ([`policy::check_ring`]): one stanza per listed key, the SSH ones by their
/// tags, no type the repository's level does not allow. A ring with a stanza
/// for someone the members file does not name is refused before anything is
/// unwrapped.
pub fn unwrap_checked(
    keys_file: &[u8],
    members: &str,
    identities: &[&dyn age::Identity],
) -> Result<Keyring, UnwrapError> {
    policy::check_ring(members, keys_file).map_err(UnwrapError::Mismatch)?;
    unwrap(keys_file, identities)
}

/// A post-quantum identity file (`gunnar crypt keygen`, or `age-keygen -pq`)
/// as an age identity.
pub fn pq_identity(text: &str) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
    crate::pq::parse_identity_file(text)
        .map(|id| Box::new(id) as Box<dyn age::Identity + Send + Sync>)
        .map_err(|e| IdentityError::Read(e.to_string()))
}

/// A passphrase source for an encrypted SSH key file.
pub trait Passphrase: Clone + Send + Sync + 'static {
    /// Ask for the passphrase of the key `description` names; `None` gives up.
    fn ask(&self, description: &str) -> Option<String>;
}

#[derive(Clone)]
struct Callbacks<P: Passphrase>(P);

impl<P: Passphrase> age::Callbacks for Callbacks<P> {
    fn display_message(&self, message: &str) {
        eprintln!("{message}");
    }
    fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
        None
    }
    fn request_public_string(&self, _: &str) -> Option<String> {
        None
    }
    fn request_passphrase(&self, description: &str) -> Option<age::secrecy::SecretString> {
        self.0
            .ask(description)
            .map(age::secrecy::SecretString::from)
    }
}

/// Why an SSH key file could not be used as an identity.
#[derive(Debug)]
pub enum IdentityError {
    /// Could not read or parse the file.
    Read(String),
    /// A key type age cannot decrypt with (for example a security-key `sk-`
    /// key, which only signs).
    Unsupported(String),
}

impl std::fmt::Display for IdentityError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IdentityError::Read(e) => write!(f, "{e}"),
            IdentityError::Unsupported(e) => write!(
                f,
                "{e}. A security-key (sk-) SSH key can only sign and cannot decrypt; \
                 use an ordinary ed25519 key"
            ),
        }
    }
}
impl std::error::Error for IdentityError {}

/// An SSH private key file as an age identity. An encrypted key asks
/// `passphrase` when it is first needed.
pub fn ssh_identity<P: Passphrase>(
    pem: &[u8],
    filename: Option<String>,
    passphrase: P,
) -> Result<Box<dyn age::Identity>, IdentityError> {
    let id = age::ssh::Identity::from_buffer(pem, filename)
        .map_err(|e| IdentityError::Read(e.to_string()))?;
    if let age::ssh::Identity::Unsupported(u) = &id {
        return Err(IdentityError::Unsupported(format!("{u:?}")));
    }
    Ok(Box::new(id.with_callbacks(Callbacks(passphrase))))
}

/// An SSH private key file as an age identity, decrypted NOW: an encrypted
/// key asks `passphrase` once, here, rather than later in the middle of an
/// unwrap. What `gunnar unlock` uses, so a passphrase is typed at start and
/// never while a browser request is waiting.
pub fn ssh_identity_unlocked(
    pem: &[u8],
    passphrase: impl Fn(&str) -> Option<String>,
) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
    let id = age::ssh::Identity::from_buffer(pem, None)
        .map_err(|e| IdentityError::Read(e.to_string()))?;
    match id {
        age::ssh::Identity::Unencrypted(_) => Ok(Box::new(id)),
        age::ssh::Identity::Encrypted(enc) => {
            let pass = passphrase("Passphrase for the SSH key")
                .ok_or_else(|| IdentityError::Read("no passphrase given".into()))?;
            let key = enc
                .decrypt(age::secrecy::SecretString::from(pass))
                .map_err(|_| IdentityError::Read("wrong passphrase".into()))?;
            Ok(Box::new(age::ssh::Identity::from(key)))
        }
        age::ssh::Identity::Unsupported(u) => Err(IdentityError::Unsupported(format!("{u:?}"))),
    }
}

/// A passphrase source that never answers: for key files without one.
#[derive(Clone, Copy, Debug)]
pub struct NoPassphrase;

impl Passphrase for NoPassphrase {
    fn ask(&self, _: &str) -> Option<String> {
        None
    }
}