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 security key's wrap v2** (CRYPT-PQ-SK, owner 2026-10-30): the text
//! form, which the server checks without a key and the client opens.
//!
//! ```text
//! gunnar-crypt user-key wrap v2 <salt> <nonce> <sealed>     (one line, base64 without padding)
//! ```
//!
//! * `salt`: 32 random bytes, new for every wrap.
//! * `key = HKDF-SHA256(salt, the security key's PRF / hmac-secret output,
//!   info "gunnar-crypt user-key wrap v2")`: 32 bytes.
//! * `sealed = XChaCha20-Poly1305(key, nonce, the user key's payload)` with a
//!   24-byte random nonce and the associated data `"gunnar-crypt user-key wrap
//!   v2" ‖ 0x00 ‖ credential id`, so a wrap opens under its own credential only.
//!
//! The PRF output is a symmetric secret the authenticator keeps; nothing in
//! the wrap is a public-key step, so a quantum computer gains only Grover's
//! square root against 256-bit keys: the wrap is quantum-safe. v1 (an age
//! X25519 stanza to a recipient derived from the same PRF output) still opens
//! and is re-wrapped to v2 on the next unlock.
//!
//! This module is format only, compiled in every build: the server checks a
//! wrap's shape with it and holds no key.

use base64::engine::general_purpose::STANDARD_NO_PAD;
use base64::Engine as _;

/// The first word group of a v2 wrap.
pub const TAG: &str = "gunnar-crypt user-key wrap v2";
/// The HKDF `info` (owner's words, CRYPT-PQ-SK).
pub const INFO: &[u8] = b"gunnar-crypt user-key wrap v2";
/// Bytes of salt.
pub const SALT_BYTES: usize = 32;
/// Bytes of XChaCha20-Poly1305 nonce.
pub const NONCE_BYTES: usize = 24;
/// The Poly1305 tag.
pub const TAG_BYTES: usize = 16;
/// The largest sealed payload anyone parses (a hybrid user key's payload is ~100 bytes).
pub const MAX_SEALED: usize = 4 << 10;

/// The three parts of a v2 wrap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parts {
    /// The HKDF salt.
    pub salt: [u8; SALT_BYTES],
    /// The XChaCha20-Poly1305 nonce.
    pub nonce: [u8; NONCE_BYTES],
    /// Ciphertext and tag.
    pub sealed: Vec<u8>,
}

/// Does `text` claim to be a v2 wrap (it may still be malformed)?
pub fn is_v2(text: &str) -> bool {
    text.trim_start().starts_with(TAG)
}

/// The parts of a well-formed v2 wrap; `None` for anything else.
pub fn parse(text: &str) -> Option<Parts> {
    let rest = text.trim().strip_prefix(TAG)?.strip_prefix(' ')?;
    let words: Vec<&str> = rest.split(' ').collect();
    let [salt, nonce, sealed] = words.as_slice() else {
        return None;
    };
    let salt: [u8; SALT_BYTES] = STANDARD_NO_PAD.decode(salt).ok()?.try_into().ok()?;
    let nonce: [u8; NONCE_BYTES] = STANDARD_NO_PAD.decode(nonce).ok()?.try_into().ok()?;
    if sealed.len() > MAX_SEALED * 2 {
        return None;
    }
    let sealed = STANDARD_NO_PAD.decode(sealed).ok()?;
    (sealed.len() > TAG_BYTES && sealed.len() <= MAX_SEALED).then_some(Parts {
        salt,
        nonce,
        sealed,
    })
}

/// The text form of `parts` (one line, with its newline).
pub fn format(parts: &Parts) -> String {
    format!(
        "{TAG} {} {} {}\n",
        STANDARD_NO_PAD.encode(parts.salt),
        STANDARD_NO_PAD.encode(parts.nonce),
        STANDARD_NO_PAD.encode(&parts.sealed)
    )
}

/// The associated data of a wrap under `credential_id`.
pub fn aad(credential_id: &[u8]) -> Vec<u8> {
    let mut a = Vec::with_capacity(INFO.len() + 1 + credential_id.len());
    a.extend_from_slice(INFO);
    a.push(0);
    a.extend_from_slice(credential_id);
    a
}