crates/gunnar-crypt/src/userkey.rs
//! **The user's encryption key**: one key pair per user, the same for every
//! repository, opened by any one of the user's unlock methods.
//!
//! ```text
//! unlock methods (each is one age file on the server, wrapping the SAME user key)
//! SSH key file ──────────────┐ age ssh-ed25519 / ssh-rsa stanza (CLI)
//! security key (PRF secret) ─┤ v2: HKDF-SHA256 → XChaCha20-Poly1305 (browser + CLI)
//! │ (v1, still opened: HKDF → X25519 → age X25519)
//! age1pq1… key file ─────────┤ age mlkem768x25519 stanza (CLI)
//! printed recovery code ─────┘ age scrypt stanza (CLI + browser)
//! │
//! ▼
//! user key (AGE-SECRET-KEY-PQ-1…, quantum-safe: the default; or AGE-SECRET-KEY-1…, classic)
//! │ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
//! ▼
//! repository key ring (id key + epochs)
//! ```
//!
//! * **A repository names only user public keys** (`alice age1…` in
//! `.gunnar-crypt/members`). Adding or removing an unlock method changes one
//! wrap of the user key and no repository at all.
//! * **The server keeps the user's public key and the wraps**, which are
//! ciphertext to it. It never sees a method's secret: an SSH private key, the
//! security key's PRF output or the recovery code.
//! * **A security key's recipient is fixed per credential**: the PRF output
//! (WebAuthn `prf`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
//! fixed [`security_key::PRF_INPUT`], and HKDF-SHA256 with a fixed label and
//! the credential id as context makes the X25519 secret. The same key and
//! credential always give the same `age1…` recipient, in the browser and in
//! the CLI.
//! * **No primitive is ours**: age's X25519, SSH, scrypt and (feature `pq`)
//! hybrid stanzas do every wrap; RustCrypto `hkdf` and `sha2` do the
//! derivation.
//! * **A new key is quantum-safe** ([`Level::NEW`], CRYPT-PQ-DEFAULT
//! 2026-10-30). Every method opens it. Each method's OWN wrap is as strong as
//! the method ([`wrap_is_quantum_safe`]): the `age1pq1…` key file (hybrid
//! stanza), the recovery code (scrypt, symmetric) and the security key's v2
//! wrap (symmetric, CRYPT-PQ-SK) are quantum-safe; the SSH key's wrap
//! (X25519 or RSA-OAEP) is not, and the CLI and the console say so
//! ([`SSH_WRAP_IS_CLASSICAL`]). A security key's v1 wrap (X25519) still
//! opens and is re-wrapped to v2 on the next unlock.
use std::io::{Read, Write};
use age::armor::{ArmoredReader, ArmoredWriter, Format};
use age::secrecy::{ExposeSecret as _, SecretString};
use zeroize::Zeroizing;
use crate::members::Level;
/// The first line of a wrapped user key's payload: a type tag, so a wrap that
/// opens to anything else is refused.
pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
pub const MAX_WRAP: usize = 64 << 10;
/// **Is an unlock method's wrap of the key quantum-safe?** By the method's
/// kind on the wire and, for a security key, the wrap itself: `age-pq` (age's
/// hybrid ML-KEM-768 + X25519 stanza), `recovery-code` (age's scrypt) and a
/// security key's v2 wrap ([`crate::skwrap`]: symmetric, HKDF over the PRF
/// output, XChaCha20-Poly1305) are, since a quantum computer only halves a
/// symmetric key; `ssh` (X25519 or RSA-OAEP) and a security key's v1 wrap (an
/// X25519 stanza) are not, and neither is any kind this version does not know.
pub fn wrap_is_quantum_safe(method_kind: &str, wrap: &str) -> bool {
match method_kind {
"age-pq" | "recovery-code" => true,
"security-key" => crate::skwrap::is_v2(wrap),
_ => false,
}
}
/// Which of a security key's wraps opened ([`open_with_security_key`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityKeyWrap {
/// v1: an age X25519 stanza to the credential's derived recipient. Not
/// quantum-safe: re-wrap it to v2 ([`UserKey::wrap_to_security_key_v2`]).
V1,
/// v2: symmetric under HKDF of the PRF output ([`crate::skwrap`]).
V2,
}
/// **Open a security key's wrap** (v2, or a v1 left from before CRYPT-PQ-SK)
/// with the credential's PRF / hmac-secret output, and check the key inside
/// against the account's published public half. Says which wrap it was: a v1
/// one is re-wrapped to v2 by the caller.
pub fn open_with_security_key(
wrap: &[u8],
prf_output: &[u8; 32],
credential_id: &[u8],
expected_public: &str,
) -> Result<(UserKey, SecurityKeyWrap), UserKeyError> {
use chacha20poly1305::aead::{Aead as _, Payload};
use chacha20poly1305::KeyInit as _;
match std::str::from_utf8(wrap)
.ok()
.filter(|t| crate::skwrap::is_v2(t))
{
Some(text) => {
let parts = crate::skwrap::parse(text).ok_or_else(|| {
UserKeyError::Malformed("not a well-formed security-key wrap v2".into())
})?;
let key = security_key::wrap_key(prf_output, &parts.salt);
let cipher =
chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
let aad = crate::skwrap::aad(credential_id);
let plain = Zeroizing::new(
cipher
.decrypt(
&chacha20poly1305::XNonce::from(parts.nonce),
Payload {
msg: &parts.sealed,
aad: &aad,
},
)
.map_err(|_| UserKeyError::WrongKey)?,
);
let text = std::str::from_utf8(&plain).map_err(|_| UserKeyError::NotAUserKey)?;
let key = UserKey::from_payload(text)?;
if key.public() != expected_public.trim() {
return Err(UserKeyError::NotThisAccountsKey);
}
Ok((key, SecurityKeyWrap::V2))
}
None => open(
wrap,
&security_key::identity(prf_output, credential_id),
expected_public,
)
.map(|k| (k, SecurityKeyWrap::V1)),
}
}
/// **What the CLI and the console say when an SSH key opens a quantum-safe
/// key.** The key stays quantum-safe and so do the repositories; this one wrap
/// does not, and nothing falls back to a classic key.
pub const SSH_WRAP_IS_CLASSICAL: &str =
"Your encryption key is quantum-safe, but the SSH key's wrap of \
it is not quantum-safe: age wraps it to an ed25519 key with X25519 and to an RSA key with \
RSA-OAEP, which a quantum computer breaks, and Gunnar keeps that wrap. Your repositories stay \
quantum-safe; this one way to open your key does not. To keep the key quantum-safe at the \
server too, add a quantum-safe way to open it (`gunnar crypt keygen`, then `gunnar crypt key \
add-pq`; a security key, `gunnar crypt key add-security-key`; or a printed recovery code, \
`gunnar crypt key add-recovery-code`), then remove the SSH \
one (`gunnar crypt key remove <id>`).";
/// One user's encryption key (the private half).
// One key per person, held once per command: a box would buy nothing (OX-GUNBR 2026-10-28,
// clippy -p gunnar-crypt).
#[allow(clippy::large_enum_variant)]
pub enum UserKey {
/// An age X25519 key: classic, for classic repositories (the opt-out).
X25519(age::x25519::Identity),
/// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories:
/// what a new key is unless asked otherwise ([`Level::NEW`]).
#[cfg(feature = "pq")]
Pq(crate::pq::Identity),
}
impl std::fmt::Debug for UserKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "UserKey({})", self.public())
}
}
impl UserKey {
/// A new user key for repositories of `level` ([`Level::NEW`] unless the
/// person asked for a classic one).
pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
match level {
Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
#[cfg(feature = "pq")]
Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
#[cfg(not(feature = "pq"))]
Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
}
}
/// The public half: what a members line and the server hold
/// (`age1…`, or `age1pq1…`).
pub fn public(&self) -> String {
match self {
UserKey::X25519(id) => id.to_public().to_string(),
#[cfg(feature = "pq")]
UserKey::Pq(id) => id.to_public().to_string(),
}
}
/// The level of repositories this key can be a member of.
pub fn level(&self) -> Level {
match self {
UserKey::X25519(_) => Level::Classic,
#[cfg(feature = "pq")]
UserKey::Pq(_) => Level::QuantumSafe,
}
}
/// As an age identity: what opens a repository's key ring.
pub fn identity(&self) -> &dyn age::Identity {
match self {
UserKey::X25519(id) => id,
#[cfg(feature = "pq")]
UserKey::Pq(id) => id,
}
}
fn payload(&self) -> Zeroizing<String> {
let secret = match self {
UserKey::X25519(id) => id.to_string(),
#[cfg(feature = "pq")]
UserKey::Pq(id) => id.to_secret_string(),
};
Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
}
fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
let mut lines = text.lines();
if lines.next() != Some(PAYLOAD_TAG) {
return Err(UserKeyError::NotAUserKey);
}
let secret = lines.next().map(str::trim).unwrap_or_default();
if lines.any(|l| !l.trim().is_empty()) {
return Err(UserKeyError::NotAUserKey);
}
if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
#[cfg(feature = "pq")]
{
return crate::pq::parse_identity_file(secret)
.map(UserKey::Pq)
.map_err(|_| UserKeyError::NotAUserKey);
}
#[cfg(not(feature = "pq"))]
return Err(UserKeyError::QuantumSafeNeedsTheCli);
}
if secret.starts_with("AGE-SECRET-KEY-1") {
return secret
.parse::<age::x25519::Identity>()
.map(UserKey::X25519)
.map_err(|_| UserKeyError::NotAUserKey);
}
Err(UserKeyError::NotAUserKey)
}
/// Wrap this key to one unlock method's age recipient: one armored age
/// file, to be stored on the server as the method's wrap.
pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
.map_err(|e| UserKeyError::Age(e.to_string()))?;
seal(enc, &self.payload())
}
/// Wrap this key to a security key's recipient (the `age1…` string
/// [`security_key::recipient`] gave at enrolment).
pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
let r = recipient
.trim()
.parse::<age::x25519::Recipient>()
.map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
self.wrap_to(&r)
}
/// **Wrap this key to a security key, v2** (CRYPT-PQ-SK): a fresh 32-byte
/// salt, `HKDF-SHA256(salt, prf_output, "gunnar-crypt user-key wrap v2")`
/// as the key, XChaCha20-Poly1305 with a fresh nonce over the key's
/// payload, the credential id bound in the associated data. Symmetric, so
/// quantum-safe; no X25519 step.
pub fn wrap_to_security_key_v2(
&self,
prf_output: &[u8; 32],
credential_id: &[u8],
) -> Result<String, UserKeyError> {
use chacha20poly1305::aead::{Aead as _, Payload};
use chacha20poly1305::KeyInit as _;
let salt = crate::random_bytes::<{ crate::skwrap::SALT_BYTES }>();
let nonce = crate::random_bytes::<{ crate::skwrap::NONCE_BYTES }>();
let key = security_key::wrap_key(prf_output, &salt);
let cipher = chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
let aad = crate::skwrap::aad(credential_id);
let payload = self.payload();
let sealed = cipher
.encrypt(
&chacha20poly1305::XNonce::from(nonce),
Payload {
msg: payload.as_bytes(),
aad: &aad,
},
)
.map_err(|_| UserKeyError::Age("XChaCha20-Poly1305 refused the payload".into()))?;
Ok(crate::skwrap::format(&crate::skwrap::Parts {
salt,
nonce,
sealed,
}))
}
/// Wrap this key to a printed recovery code: age's scrypt stanza at the
/// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
/// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
r.set_work_factor(recovery_code::LOG_N);
let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
.map_err(|e| UserKeyError::Age(e.to_string()))?;
seal(enc, &self.payload())
}
/// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
/// The wrap is age's SSH stanza (X25519 for ed25519, RSA-OAEP for RSA):
/// CLASSICAL, also for a quantum-safe key ([`SSH_WRAP_IS_CLASSICAL`]).
#[cfg(feature = "wrap")]
pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
let r = public_key
.trim()
.parse::<age::ssh::Recipient>()
.map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
self.wrap_to(&r)
}
/// Wrap this key to an `age1pq1…` key (a key file made by
/// `gunnar crypt keygen`).
#[cfg(feature = "pq")]
pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
let r = public_key
.trim()
.parse::<crate::pq::Recipient>()
.map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
self.wrap_to(&r)
}
}
fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
let mut out = Vec::new();
let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
let mut w = enc.wrap_output(armor).map_err(age_err)?;
w.write_all(payload.as_bytes()).map_err(age_err)?;
w.finish().and_then(|a| a.finish()).map_err(age_err)?;
String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
}
/// Why a user key could not be wrapped or opened. Every refusal has a name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserKeyError {
/// The method's key is not the one this wrap is for: the wrong SSH key,
/// another security key, a mistyped recovery code.
WrongKey,
/// Not a readable age file.
Malformed(String),
/// It opened, but what is inside is not a user key.
NotAUserKey,
/// It opened to a user key, but not the one the account publishes: a
/// wrap substituted ALONE is refused. A server that replaces both the
/// public half and the wrap is caught only where the public half is
/// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
NotThisAccountsKey,
/// A recipient string that is not one.
BadRecipient(String),
/// A recovery code that is not one (wrong length or characters).
BadRecoveryCode,
/// A quantum-safe user key in a build without feature `pq`.
QuantumSafeNeedsTheCli,
/// age itself failed.
Age(String),
}
impl std::fmt::Display for UserKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserKeyError::WrongKey => write!(
f,
"this key does not open your encryption key: it is not one of your unlock methods \
(the wrong SSH key, another security key, or a mistyped recovery code)"
),
UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
UserKeyError::NotThisAccountsKey => write!(
f,
"the stored wrap holds an encryption key that is not your account's: it is refused \
(someone replaced it on the server)"
),
UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
UserKeyError::BadRecoveryCode => write!(
f,
"that is not a recovery code: it is {} characters from 0-9 and A-Z (no I, L, O or U), in groups of four",
recovery_code::CHARS
),
UserKeyError::QuantumSafeNeedsTheCli => write!(
f,
"this build cannot open a quantum-safe encryption key (gunnar-crypt without its `pq` \
feature); the Gunnar CLI and the console can"
),
UserKeyError::Age(e) => write!(f, "age: {e}"),
}
}
}
impl std::error::Error for UserKeyError {}
/// Open a stored wrap with one method's age identity, and check the key
/// inside against the account's published public key (`expected_public`).
pub fn open(
wrap: &[u8],
identity: &dyn age::Identity,
expected_public: &str,
) -> Result<UserKey, UserKeyError> {
let key = open_unchecked(wrap, std::iter::once(identity))?;
if key.public() != expected_public.trim() {
return Err(UserKeyError::NotThisAccountsKey);
}
Ok(key)
}
/// Open a stored wrap with a printed recovery code.
pub fn open_with_recovery_code(
wrap: &[u8],
code: &str,
expected_public: &str,
) -> Result<UserKey, UserKeyError> {
let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
id.set_max_work_factor(recovery_code::LOG_N);
open(wrap, &id, expected_public)
}
fn open_unchecked<'a>(
wrap: &[u8],
identities: impl Iterator<Item = &'a dyn age::Identity>,
) -> Result<UserKey, UserKeyError> {
if wrap.len() > MAX_WRAP {
return Err(UserKeyError::Malformed(format!(
"larger than {} KiB",
MAX_WRAP >> 10
)));
}
let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
.map_err(|e| UserKeyError::Malformed(e.to_string()))?;
let mut reader = match decryptor.decrypt(identities) {
Ok(r) => r,
Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
return Err(UserKeyError::WrongKey)
}
Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
};
let mut buf = Zeroizing::new(Vec::new());
reader
.by_ref()
.take(MAX_WRAP as u64)
.read_to_end(&mut buf)
.map_err(|e| UserKeyError::Malformed(e.to_string()))?;
let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
UserKey::from_payload(text)
}
/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
/// FIDO2 `hmac-secret` in the CLI, one secret.
pub mod security_key {
use sha2::{Digest as _, Sha256};
use zeroize::Zeroizing;
/// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
/// so a credential always gives the same secret.
pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
/// The HKDF-SHA256 salt and label of the derivation.
pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
/// The `hmac-secret` salt a CTAP2 client sends so the authenticator
/// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
/// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
pub fn hmac_secret_salt() -> [u8; 32] {
let mut h = Sha256::new();
h.update(b"WebAuthn PRF");
h.update([0u8]);
h.update(PRF_INPUT);
h.finalize().into()
}
/// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
/// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
/// context), as an age identity.
pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
use bech32::ToBase32 as _;
let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
info.extend_from_slice(LABEL);
info.push(0);
info.extend_from_slice(credential_id);
let mut secret = Zeroizing::new([0u8; 32]);
hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
.expand(&info, secret.as_mut())
.expect("32 bytes is a valid HKDF-SHA256 length");
// age's own key text is the one constructor its API offers.
let text = Zeroizing::new(
bech32::encode(
"age-secret-key-",
secret.to_base32(),
bech32::Variant::Bech32,
)
.expect("the HRP is valid")
.to_uppercase(),
);
text.parse().expect("a 32-byte age secret key parses")
}
/// **The v2 wrap key** (CRYPT-PQ-SK): HKDF-SHA256 with the wrap's own
/// salt over the PRF output, info `gunnar-crypt user-key wrap v2`.
pub fn wrap_key(prf_output: &[u8; 32], salt: &[u8]) -> Zeroizing<[u8; 32]> {
let mut key = Zeroizing::new([0u8; 32]);
hkdf::Hkdf::<Sha256>::new(Some(salt), prf_output)
.expand(crate::skwrap::INFO, key.as_mut())
.expect("32 bytes is a valid HKDF-SHA256 length");
key
}
/// The credential's v1 recipient (`age1…`): what a v1 enrolment uploaded.
pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
identity(prf_output, credential_id).to_public().to_string()
}
}
/// **A printed recovery code** as one more unlock method: 32 characters of
/// Crockford's base32 (160 random bits), in groups of four.
pub mod recovery_code {
/// The alphabet: no I, L, O or U to misread.
pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// Characters in a code (without the dashes).
pub const CHARS: usize = 32;
/// The scrypt work factor every recovery wrap uses, and the most an opener
/// accepts: N = 2^18 (age's own default class; about 256 MiB).
pub const LOG_N: u8 = 18;
/// A new code from 20 random bytes.
pub fn generate() -> zeroize::Zeroizing<String> {
from_bytes(&crate::random_bytes::<20>())
}
/// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
let (mut acc, mut bits) = (0u32, 0u32);
let mut n = 0;
for b in bytes {
acc = (acc << 8) | u32::from(*b);
bits += 8;
while bits >= 5 {
bits -= 5;
if n > 0 && n % 4 == 0 {
out.push('-');
}
out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
n += 1;
}
}
out
}
/// The code as typed, normalised: upper case, no spaces or dashes, the
/// look-alikes read as Crockford reads them (O → 0, I and L → 1).
/// `None` unless it is [`CHARS`] characters of the alphabet.
pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
for c in typed.chars() {
let c = match c.to_ascii_uppercase() {
' ' | '-' => continue,
'O' => '0',
'I' | 'L' => '1',
c => c,
};
if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
return None;
}
out.push(c);
}
(out.len() == CHARS).then_some(out)
}
}