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

nordisk/gunnar

crates/gunnar-crypt/src/lib.rs

//! **Encrypted repositories**: the operator of a gunnar server cannot read a
//! repository's content; only its members can, each with their own
//! encryption key, opened by their SSH key or their security key.
//!
//! The design, the threat model and what still leaks are in
//! `.nornir/encrypted-repos.md`. This crate is the part every door shares:
//!
//! * [`envelope`] — the format of one encrypted file (blob): a 13-byte header
//!   naming the key epoch, then AES-256-SIV over the content. Deterministic on
//!   purpose: the same content under the same key gives the same blob, so git's
//!   clean filter is stable and `git status` stays quiet, and the blob id is a
//!   KEYED id (it depends on the secret key), so nobody without the key can
//!   confirm that a known file is in the repository.
//! * [`keyring`] — the repository's data keys, one per epoch. A rotation adds
//!   an epoch; old epochs stay so members can still read old content.
//! * [`members`] — the plaintext list of members' PUBLIC keys
//!   (`.gunnar-crypt/members`). The key ring is wrapped to every key in it.
//! * [`policy`] — what a server checks on a push to an encrypted repository:
//!   every blob is an envelope, except the three files the scheme itself needs.
//!   It reads formats, never keys.
//! * `userkey` (feature `user-key`) — the USER's encryption key: one key
//!   pair per user (the hybrid `age1pq1…`, quantum-safe, unless the person
//!   asked for a classic age X25519 key), its private half stored on the server only as age files,
//!   one per unlock method: an SSH key file (CLI), a security key's WebAuthn
//!   PRF / FIDO2 hmac-secret output through HKDF-SHA256 (browser and CLI), an
//!   `age1pq1…` key file, or a printed recovery code (age's scrypt).
//! * [`skwrap`] — the text form of a security key's wrap v2 (CRYPT-PQ-SK):
//!   symmetric under HKDF of the PRF output, XChaCha20-Poly1305, quantum-safe.
//!   Format only; the server checks it.
//! * `wrap` (feature `user-key`) — the age wrap of the key ring to every
//!   member's user public key. A repository never names an SSH key or a
//!   security key; adding or removing an unlock method touches no repository.
//!   Feature `pq` adds the quantum-safe user key and ring, `wrap` the SSH
//!   unlock method.
//! * `pq` (feature `pq`) — that hybrid recipient and its identity, until an
//!   age release carries its own.
//!
//! **Quantum-safe is the default** (CRYPT-PQ-DEFAULT, owner 2026-10-30): a new
//! repository ([`members::Members::new`]) and a new key are at
//! [`members::Level::NEW`]; classic is the visible opt-out. A members file
//! without a `level` line stays classic, so an existing repository keeps its
//! level.
//!
//! * [`diff`] (feature `diff`) — line diffs of decrypted content. The server
//!   cannot diff ciphertext and keeps no pre-made diffs; the client does it.
//!
//! **One crate, two targets.** The CLI and the browser console run this same
//! code, native and wasm32: fetch ciphertext → open the user key with an
//! unlock method → unwrap the key ring → open and diff locally → seal before
//! push. The CLI opens the user key with the SSH key file in `~/.ssh` (or a
//! security key over FIDO2 hmac-secret); the browser opens it with a security
//! key (WebAuthn PRF), in the tab, with no helper. `tests/vectors.rs` and
//! `tests/userkey.rs` run the same vectors on both targets.
//!
//! Who links what: gunnar-server takes `default-features = false` (policy and
//! format only; no cipher, no key), the browser console takes `cipher`,
//! `diff`, `user-key` and `pq`, and the CLI takes everything.

#![forbid(unsafe_code)]
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

/// zeroize, for callers holding key material.
#[cfg(feature = "cipher")]
pub use zeroize;

#[cfg(feature = "diff")]
pub mod diff;
pub mod door;
pub mod envelope;
#[cfg(feature = "cipher")]
pub mod keyring;
pub mod members;
pub mod policy;
#[cfg(feature = "pq")]
pub mod pq;
pub mod skwrap;
#[cfg(feature = "user-key")]
pub mod userkey;
#[cfg(feature = "user-key")]
pub mod wrap;

/// `N` bytes from the OS random source (keys, recovery codes).
#[cfg(feature = "keygen")]
pub fn random_bytes<const N: usize>() -> [u8; N] {
    let mut b = [0u8; N];
    getrandom::getrandom(&mut b).expect("the OS random source is available");
    b
}

/// Open a blob as text: an envelope is opened with `ring`; anything else is
/// returned as it is (history from before `gunnar crypt init` is plaintext).
#[cfg(feature = "cipher")]
pub fn open_blob(ring: &keyring::Keyring, blob: &[u8]) -> Result<Vec<u8>, envelope::OpenError> {
    if envelope::has_magic(blob) {
        envelope::open(ring, blob)
    } else {
        Ok(blob.to_vec())
    }
}

/// The diff between two stored blobs of one path (`None` = the path does not
/// exist on that side), opened with `ring`. What `gunnar crypt diff` prints
/// and what the console shows for an encrypted repository.
#[cfg(all(feature = "cipher", feature = "diff"))]
pub fn diff_blobs(
    ring: &keyring::Keyring,
    old: Option<&[u8]>,
    new: Option<&[u8]>,
) -> Result<diff::FileDiff, envelope::OpenError> {
    let old = old
        .map(|b| open_blob(ring, b))
        .transpose()?
        .unwrap_or_default();
    let new = new
        .map(|b| open_blob(ring, b))
        .transpose()?
        .unwrap_or_default();
    Ok(diff::hunks(
        &String::from_utf8_lossy(&old),
        &String::from_utf8_lossy(&new),
    ))
}

/// Where the scheme's own files live in a repository.
pub mod paths {
    /// The directory of the scheme's files.
    pub const DIR: &str = ".gunnar-crypt";
    /// The key ring, age-encrypted to every member key (armored).
    pub const KEYS: &str = ".gunnar-crypt/keys";
    /// The members and their PUBLIC keys, plaintext.
    pub const MEMBERS: &str = ".gunnar-crypt/members";
    /// git's attributes file: it names the filter, so it must stay plaintext.
    pub const GITATTRIBUTES: &str = ".gitattributes";
}

/// The name of the git filter, diff and merge drivers.
pub const DRIVER: &str = "gunnar-crypt";

/// The `.gitattributes` lines `gunnar crypt init` writes.
///
/// Everything goes through the filter except the scheme's own files, which
/// must stay readable before a clone is unlocked.
pub const GITATTRIBUTES_LINES: &[&str] = &[
    "* filter=gunnar-crypt diff=gunnar-crypt merge=gunnar-crypt",
    ".gitattributes !filter !diff !merge",
    ".gunnar-crypt/** !filter !diff !merge",
];