use std::io::{Read, Write};
use age::armor::{ArmoredReader, ArmoredWriter, Format};
use age::secrecy::{ExposeSecret as _, SecretString};
use zeroize::Zeroizing;
use crate::members::Level;
pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
pub const MAX_WRAP: usize = 64 << 10;
pub enum UserKey {
X25519(age::x25519::Identity),
#[cfg(feature = "wrap")]
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 {
pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
match level {
Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
#[cfg(feature = "wrap")]
Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
#[cfg(not(feature = "wrap"))]
Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
}
}
pub fn public(&self) -> String {
match self {
UserKey::X25519(id) => id.to_public().to_string(),
#[cfg(feature = "wrap")]
UserKey::Pq(id) => id.to_public().to_string(),
}
}
pub fn level(&self) -> Level {
match self {
UserKey::X25519(_) => Level::Classic,
#[cfg(feature = "wrap")]
UserKey::Pq(_) => Level::QuantumSafe,
}
}
pub fn identity(&self) -> &dyn age::Identity {
match self {
UserKey::X25519(id) => id,
#[cfg(feature = "wrap")]
UserKey::Pq(id) => id,
}
}
fn payload(&self) -> Zeroizing<String> {
let secret = match self {
UserKey::X25519(id) => id.to_string(),
#[cfg(feature = "wrap")]
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 = "wrap")]
{
return crate::pq::parse_identity_file(secret)
.map(UserKey::Pq)
.map_err(|_| UserKeyError::NotAUserKey);
}
#[cfg(not(feature = "wrap"))]
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)
}
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())
}
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)
}
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())
}
#[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)
}
#[cfg(feature = "wrap")]
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()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserKeyError {
WrongKey,
Malformed(String),
NotAUserKey,
NotThisAccountsKey,
BadRecipient(String),
BadRecoveryCode,
QuantumSafeNeedsTheCli,
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,
"a quantum-safe encryption key opens in the Gunnar CLI only (`gunnar crypt unlock`)"
),
UserKeyError::Age(e) => write!(f, "age: {e}"),
}
}
}
impl std::error::Error for UserKeyError {}
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)
}
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)
}
pub mod security_key {
use sha2::{Digest as _, Sha256};
use zeroize::Zeroizing;
pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
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()
}
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");
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")
}
pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
identity(prf_output, credential_id).to_public().to_string()
}
}
pub mod recovery_code {
pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
pub const CHARS: usize = 32;
pub const LOG_N: u8 = 18;
pub fn generate() -> zeroize::Zeroizing<String> {
from_bytes(&crate::random_bytes::<20>())
}
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
}
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)
}
}