use aes_gcm::aead::Aead;
use aes_gcm::aead::OsRng;
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::secret::SecretString;
const LONGITUD_DEL_NONCE: usize = 12;
pub const PREFIJO: &str = "v1";
pub const ETIQUETA: &[u8] = b"kesto/secrets/v1";
#[derive(Debug, thiserror::Error)]
pub enum ErrorDeCifrado {
#[error("el valor almacenado no es un sobre cifrado: no tiene las tres partes esperadas")]
SobreMalFormado,
#[error("el sobre cifrado no lleva un prefijo de versión conocido")]
PrefijoDesconocido,
#[error("el nonce del sobre no tiene la longitud esperada: {longitud} bytes")]
NonceDeLongitudEquivocada {
longitud: usize,
},
#[error("una de las partes del sobre no es base64 válido: {causa}")]
Base64Ilegible {
#[source]
causa: base64::DecodeError,
},
#[error(
"el texto cifrado no autentica: la clave no es la que se usó, el dato se manipuló o está \
truncado"
)]
NoAutentica,
#[error("no se pudo cifrar el secreto")]
CifradoFallido,
#[error("el valor descifrado no es texto válido")]
ClaroNoEsTexto,
}
pub struct Clave(Zeroizing<[u8; 32]>);
impl Clave {
pub fn desde_maestra(maestra: &SecretString) -> Self {
Clave(Zeroizing::new(derivar(maestra, ETIQUETA)))
}
pub fn cifrar(&self, claro: &SecretString) -> Result<String, ErrorDeCifrado> {
let mut nonce = [0u8; LONGITUD_DEL_NONCE];
OsRng
.try_fill_bytes(&mut nonce)
.map_err(|_| ErrorDeCifrado::CifradoFallido)?;
let cifrado = self
.cifrador()
.encrypt(Nonce::from_slice(&nonce), claro.expose().as_bytes())
.map_err(|_| ErrorDeCifrado::CifradoFallido)?;
Ok(format!(
"{PREFIJO}:{}:{}",
STANDARD.encode(nonce),
STANDARD.encode(cifrado)
))
}
pub fn descifrar(&self, almacenado: &str) -> Result<SecretString, ErrorDeCifrado> {
let mut partes = almacenado.split(':');
let (Some(prefijo), Some(nonce_en_base64), Some(cifrado_en_base64), None) =
(partes.next(), partes.next(), partes.next(), partes.next())
else {
return Err(ErrorDeCifrado::SobreMalFormado);
};
if prefijo != PREFIJO {
return Err(ErrorDeCifrado::PrefijoDesconocido);
}
let nonce = STANDARD
.decode(nonce_en_base64)
.map_err(|causa| ErrorDeCifrado::Base64Ilegible { causa })?;
let nonce: [u8; LONGITUD_DEL_NONCE] =
nonce
.as_slice()
.try_into()
.map_err(|_| ErrorDeCifrado::NonceDeLongitudEquivocada {
longitud: nonce.len(),
})?;
let cifrado = STANDARD
.decode(cifrado_en_base64)
.map_err(|causa| ErrorDeCifrado::Base64Ilegible { causa })?;
let claro = self
.cifrador()
.decrypt(Nonce::from_slice(&nonce), cifrado.as_ref())
.map_err(|_| ErrorDeCifrado::NoAutentica)?;
String::from_utf8(claro)
.map(SecretString::new)
.map_err(|_| ErrorDeCifrado::ClaroNoEsTexto)
}
fn cifrador(&self) -> Aes256Gcm {
Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.0[..]))
}
}
impl std::fmt::Debug for Clave {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Clave(<redactada>)")
}
}
fn derivar(maestra: &SecretString, etiqueta: &[u8]) -> [u8; 32] {
let hkdf = Hkdf::<Sha256>::new(None, maestra.expose().as_bytes());
let mut material = [0u8; 32];
let _ = hkdf.expand(etiqueta, &mut material);
material
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::{Clave, ETIQUETA, ErrorDeCifrado, PREFIJO};
use crate::secret::SecretString;
use crate::test_support::capturar_log;
const MAESTRA: &str = "clave-maestra-crypto-distinguible-000000000000000000000000";
const OTRA_MAESTRA: &str = "otra-clave-maestra-crypto-distinguible-11111111111111111111";
fn secreto(valor: &str) -> SecretString {
SecretString::new(valor.to_owned())
}
fn clave_de(maestra: &str) -> Clave {
Clave::desde_maestra(&secreto(maestra))
}
fn cambiar_un_byte_del_cifrado(sobre: &str) -> String {
let partes: Vec<&str> = sobre.split(':').collect();
let mut cifrado: Vec<char> = partes[2].chars().collect();
cifrado[0] = if cifrado[0] == 'A' { 'B' } else { 'A' };
format!(
"{}:{}:{}",
partes[0],
partes[1],
cifrado.into_iter().collect::<String>()
)
}
#[test]
fn el_valor_guardado_lleva_prefijo_y_tres_partes() {
let clave = clave_de(MAESTRA);
let almacenado = clave
.cifrar(&secreto("AKIAIOSFODNN7EXAMPLE-1-37"))
.expect("cifrar un secreto no puede fallar");
assert!(
almacenado.starts_with(&format!("{PREFIJO}:")),
"el sobre no empieza por `{PREFIJO}:`"
);
let partes: Vec<&str> = almacenado.split(':').collect();
assert_eq!(
partes.len(),
3,
"el sobre debe tener tres partes separadas por ':'"
);
}
#[test]
fn dos_cifrados_del_mismo_secreto_son_distintos() {
let clave = clave_de(MAESTRA);
let valor = secreto("el-mismo-secreto-para-los-dos-cifrados");
let primero = clave
.cifrar(&valor)
.expect("el primer cifrado no puede fallar");
let segundo = clave
.cifrar(&valor)
.expect("el segundo cifrado no puede fallar");
assert_ne!(
primero, segundo,
"los dos sobres del mismo secreto son iguales: el nonce no es aleatorio"
);
let parte_de = |sobre: &str, indice: usize| -> String {
sobre
.split(':')
.nth(indice)
.expect("el sobre tiene el prefijo, el nonce y el cifrado")
.to_owned()
};
assert_ne!(
parte_de(&primero, 1),
parte_de(&segundo, 1),
"los dos nonces son iguales: repetir un nonce con la misma clave rompe el AEAD"
);
assert_ne!(
parte_de(&primero, 2),
parte_de(&segundo, 2),
"los dos textos cifrados son iguales"
);
let nonces: HashSet<String> = (0..64)
.map(|_| {
let sobre = clave
.cifrar(&valor)
.expect("cifrar no puede fallar con una clave derivada en pie");
parte_de(&sobre, 1)
})
.collect();
assert_eq!(
nonces.len(),
64,
"64 cifrados del mismo secreto no dieron 64 nonces distintos: \
el nonce no es aleatorio por valor"
);
}
#[test]
fn la_ida_y_vuelta_devuelve_el_valor_original_sin_prefijo() {
let clave = clave_de(MAESTRA);
let original = "AKIAIOSFODNN7EXAMPLE-ida-y-vuelta";
let almacenado = clave
.cifrar(&secreto(original))
.expect("cifrar no puede fallar");
let recuperado = clave
.descifrar(&almacenado)
.expect("descifrar lo que se acaba de cifrar no puede fallar");
assert_eq!(
recuperado.expose(),
original,
"el valor devuelto no es el original"
);
assert!(
!recuperado.expose().contains(&format!("{PREFIJO}:")),
"el prefijo de versión no debe aparecer en el valor devuelto"
);
}
#[test]
fn un_prefijo_desconocido_no_se_descifra() {
let clave = clave_de(MAESTRA);
for sobre in [
"v2:AAAAAAAAAAAAAAAAAAAAAA==:BBBBBBBBBBBBBBBBBBBBBB",
"x:AAAAAAAAAAAAAAAAAAAAAA==:BBBBBBBBBBBBBBBBBBBBBB",
"v10:AAAAAAAAAAAAAAAAAAAAAA==:BBBBBBBBBBBBBBBBBBBBBB",
] {
let error = clave
.descifrar(sobre)
.expect_err("un prefijo desconocido no puede dar un secreto");
assert!(
matches!(error, ErrorDeCifrado::PrefijoDesconocido),
"el fallo debe ser el prefijo desconocido, no otro: {error}"
);
}
}
#[test]
fn un_nonce_de_longitud_equivocada_no_se_descifra() {
let clave = clave_de(MAESTRA);
for (nonce, esperada) in [("AAAAAAAAAAA=", 8), ("AAAAAAAAAAAAAAAAAAAAAA==", 16)] {
let sobre = format!("{PREFIJO}:{nonce}:{nonce}");
let error = clave
.descifrar(&sobre)
.expect_err("un nonce de longitud equivocada no puede dar un secreto");
assert!(
matches!(
error,
ErrorDeCifrado::NonceDeLongitudEquivocada { longitud }
if longitud == esperada
),
"el fallo debe ser el nonce de longitud equivocada y decir cuál ({esperada}): {error}"
);
}
}
#[test]
fn un_texto_cifrado_manipulado_no_se_descifra() {
let clave = clave_de(MAESTRA);
let almacenado = clave
.cifrar(&secreto("secreto-manipulable-1-42"))
.expect("cifrar no puede fallar");
let manipulado = cambiar_un_byte_del_cifrado(&almacenado);
assert_ne!(
manipulado, almacenado,
"el aparato de la prueba no cambió nada del sobre"
);
let error = clave
.descifrar(&manipulado)
.expect_err("un texto cifrado manipulado no puede dar un secreto");
assert!(
matches!(error, ErrorDeCifrado::NoAutentica),
"el fallo debe ser el AEAD que no autentica, no otro: {error}"
);
}
#[test]
fn un_valor_en_claro_no_se_descifra() {
let clave = clave_de(MAESTRA);
let en_claro = "AKIAIOSFODNN7EXAMPLE-en-claro-sin-sobre";
let error = clave
.descifrar(en_claro)
.expect_err("un valor en claro no puede darse por descifrado");
assert!(
matches!(
error,
ErrorDeCifrado::SobreMalFormado | ErrorDeCifrado::PrefijoDesconocido
),
"el fallo debe ser un sobre que no lo es, no otro: {error}"
);
}
#[test]
fn el_mensaje_del_error_no_publica_el_cifrado_ni_el_nonce_ni_el_claro() {
let clave = clave_de(MAESTRA);
let en_claro = "AKIAIOSFODNN7EXAMPLE-no-debe-salir-1-44";
let almacenado = clave
.cifrar(&secreto(en_claro))
.expect("cifrar no puede fallar");
let partes: Vec<&str> = almacenado.split(':').collect();
let nonce = partes[1].to_owned();
let manipulado = cambiar_un_byte_del_cifrado(&almacenado);
let cifrado_de_la_entrada = manipulado.split(':').nth(2).unwrap_or_default().to_owned();
let error = clave
.descifrar(&manipulado)
.expect_err("el cifrado manipulado no puede descifrarse");
let mensaje = error.to_string();
assert!(
!mensaje.contains(&cifrado_de_la_entrada),
"el mensaje publica el texto cifrado: {mensaje}"
);
assert!(
!mensaje.contains(partes[2]),
"el mensaje publica el texto cifrado original: {mensaje}"
);
assert!(
!mensaje.contains(&nonce),
"el mensaje publica el nonce: {mensaje}"
);
assert!(
!mensaje.contains(en_claro),
"el mensaje publica el valor en claro: {mensaje}"
);
}
#[test]
fn la_parte_que_no_es_base64_no_se_descifra_ni_publica_la_parte() {
let clave = clave_de(MAESTRA);
let almacenado = format!("{PREFIJO}:no-es-base64:cualquiera");
let error = clave
.descifrar(&almacenado)
.expect_err("una parte que no es base64 no puede dar un secreto");
assert!(
matches!(error, ErrorDeCifrado::Base64Ilegible { .. }),
"el fallo debe ser la parte ilegible, no otro: {error}"
);
let mensaje = error.to_string();
assert!(
!mensaje.contains("no-es-base64"),
"el mensaje publica la parte ilegible: {mensaje}"
);
}
#[test]
fn la_derivacion_es_determinista_y_la_etiqueta_la_cambia() {
let maestra = secreto(MAESTRA);
let primera = super::derivar(&maestra, ETIQUETA);
let segunda = super::derivar(&maestra, ETIQUETA);
assert_eq!(
primera, segunda,
"dos derivaciones con la misma maestra y la misma etiqueta difieren"
);
let con_otra_etiqueta = super::derivar(&maestra, b"kesto/secrets/otra");
assert_ne!(
primera, con_otra_etiqueta,
"cambiar la etiqueta no cambió la clave derivada"
);
assert_eq!(
ETIQUETA, b"kesto/secrets/v1",
"la etiqueta de derivación cambió: los secretos ya guardados quedarían ilegibles"
);
}
#[test]
fn el_material_derivado_es_el_de_hkdf_sha256_con_esta_sal() {
let material = super::derivar(&secreto(MAESTRA), ETIQUETA);
assert_eq!(
material,
[
0x74, 0xf0, 0x86, 0x11, 0x88, 0xf1, 0xb4, 0x23, 0xdd, 0x12, 0x7e, 0x34, 0x5d, 0xde,
0x32, 0xac, 0xca, 0x70, 0x4f, 0x9d, 0xb0, 0x1e, 0x77, 0x10, 0x6e, 0x47, 0xa3, 0x56,
0xf8, 0x1b, 0x47, 0x38,
],
"el material derivado no es el de HKDF-SHA256 con sal por defecto y esta etiqueta"
);
}
#[test]
fn otra_clave_maestra_deja_el_secreto_ilegible() {
let en_claro = "secreto-que-no-sobrevive-a-la-rotacion-1-46";
let almacenado = clave_de(MAESTRA)
.cifrar(&secreto(en_claro))
.expect("cifrar con la maestra original no puede fallar");
let error = clave_de(OTRA_MAESTRA)
.descifrar(&almacenado)
.expect_err("con otra maestra el secreto no puede leerse");
assert!(
matches!(error, ErrorDeCifrado::NoAutentica),
"el fallo debe ser el AEAD que no autentica con otra clave: {error}"
);
}
#[test]
fn el_registro_no_publica_la_maestra_ni_la_clave_derivada() {
let (buffer, _guard) = capturar_log();
let maestra = secreto(MAESTRA);
let clave = Clave::desde_maestra(&maestra);
let derivada = super::derivar(&maestra, ETIQUETA);
let en_claro = "AKIAIOSFODNN7EXAMPLE-no-debe-salir-1-50";
let sobre = clave
.cifrar(&secreto(en_claro))
.expect("cifrar no puede fallar");
let leido = clave
.descifrar(&sobre)
.expect("descifrar lo que se acaba de cifrar no puede fallar");
tracing::trace!(?clave, "clave de cifrado derivada de la maestra");
tracing::trace!(?maestra, "clave maestra cargada");
let log = buffer.texto();
assert_eq!(
leido.expose(),
en_claro,
"la ida y vuelta debe devolver el mismo valor"
);
assert!(
log.contains("clave de cifrado derivada de la maestra"),
"la captura por hilo no recibió el registro, así que no prueba nada: {log}"
);
assert!(
log.contains("Clave(<redactada>)"),
"el `Debug` de la clave no aparece redactado en el registro: {log}"
);
assert!(
!log.contains(MAESTRA),
"el registro publica el valor de la clave maestra: {log}"
);
assert!(
!log.contains(en_claro),
"el registro publica el valor en claro del secreto: {log}"
);
assert!(
!log.contains(&sobre),
"el registro publica el sobre cifrado: {log}"
);
let huella = format!("{derivada:?}");
assert!(
!log.contains(&huella),
"el registro publica el material de la clave derivada: {log}"
);
}
#[test]
fn la_clave_es_compartible_entre_hilos() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Clave>();
}
}