#![forbid(unsafe_code)]
use super::{PrimaryKey, ENC_PREFIX, ENC_PREFIX_V2};
use crate::constants::{AEAD_NONCE_LEN_BYTES, AEAD_TAG_LEN_BYTES};
use crate::errors::{SshCliError, SshCliResult};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use zeroize::Zeroize;
const AAD_DOMAIN: &[u8] = b"ssh-cli:secret-aad:v2";
const UNBOUND_NAME: &str = "";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecretContext<'a> {
host: &'a str,
field: &'a str,
}
impl<'a> SecretContext<'a> {
#[must_use]
pub const fn new(host: &'a str, field: &'a str) -> Self {
Self { host, field }
}
#[must_use]
pub const fn unbound() -> Self {
Self {
host: UNBOUND_NAME,
field: UNBOUND_NAME,
}
}
#[must_use]
pub const fn is_unbound(&self) -> bool {
self.host.is_empty() && self.field.is_empty()
}
pub(super) fn aad(&self) -> Vec<u8> {
let host = self.host.as_bytes();
let field = self.field.as_bytes();
let mut out = Vec::with_capacity(AAD_DOMAIN.len() + 8 + host.len() + field.len());
out.extend_from_slice(AAD_DOMAIN);
out.extend_from_slice(&u32::try_from(host.len()).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(host);
out.extend_from_slice(&u32::try_from(field.len()).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(field);
out
}
}
pub fn encrypt_secret(
key: &PrimaryKey,
plaintext: &str,
ctx: SecretContext<'_>,
) -> SshCliResult<String> {
let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice())
.map_err(|_| SshCliError::crypto("aead_key"))?;
let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
getrandom::fill(&mut nonce_bytes).map_err(|_| SshCliError::software("rng"))?;
let nonce = Nonce::from(nonce_bytes);
let aad = ctx.aad();
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext.as_bytes(),
aad: &aad,
},
)
.map_err(|_| SshCliError::crypto("encrypt"))?;
let mut packed = Vec::with_capacity(AEAD_NONCE_LEN_BYTES + ciphertext.len());
packed.extend_from_slice(&nonce_bytes);
packed.extend_from_slice(&ciphertext);
Ok(format!(
"{ENC_PREFIX_V2}{}",
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
))
}
pub fn decrypt_secret(
key: &PrimaryKey,
blob: &str,
ctx: SecretContext<'_>,
) -> SshCliResult<String> {
let (b64, versioned) = match blob.strip_prefix(ENC_PREFIX_V2) {
Some(rest) => (rest, true),
None => (
blob.strip_prefix(ENC_PREFIX)
.ok_or_else(|| SshCliError::crypto("blob_parse"))?,
false,
),
};
let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(|_| SshCliError::crypto("blob_b64"))?;
if packed.len() < AEAD_NONCE_LEN_BYTES + AEAD_TAG_LEN_BYTES {
return Err(SshCliError::Config("encrypted blob too short".to_string()));
}
let (nonce_bytes, ct) = packed.split_at(AEAD_NONCE_LEN_BYTES);
let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice())
.map_err(|_| SshCliError::crypto("aead_key"))?;
let nonce = Nonce::try_from(nonce_bytes).map_err(|_| SshCliError::crypto("blob_nonce"))?;
let plain = if versioned {
let aad = ctx.aad();
match cipher.decrypt(&nonce, Payload { msg: ct, aad: &aad }) {
Ok(p) => p,
Err(_) if !ctx.is_unbound() => {
let legacy = SecretContext::unbound().aad();
cipher
.decrypt(
&nonce,
Payload {
msg: ct,
aad: &legacy,
},
)
.map_err(|_| SshCliError::crypto("decrypt"))?
}
Err(_) => return Err(SshCliError::crypto("decrypt")),
}
} else {
cipher
.decrypt(&nonce, ct)
.map_err(|_| SshCliError::crypto("decrypt"))?
};
match String::from_utf8(plain) {
Ok(s) => Ok(s),
Err(e) => {
let mut bad = e.into_bytes();
bad.zeroize();
Err(SshCliError::Config(
"decrypted secret is not valid UTF-8".to_string(),
))
}
}
}