crates/gunnar-crypt/src/skwrap.rs
//! **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
}