Files
gman/src/lib.rs

317 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Gman core library
//!
//! This crate provides two layers:
//! - A small crypto helper API for envelope encrypting/decrypting strings.
//! - Public modules for configuration and secret providers used by the CLI.
//!
//! Quick start for the crypto helpers:
//!
//! ```
//! use gman::{encrypt_string, decrypt_string};
//! use secrecy::SecretString;
//!
//! let password = SecretString::new("correct horse battery staple".into());
//! let ciphertext = encrypt_string(password.clone(), "swordfish").unwrap();
//! let plaintext = decrypt_string(password, &ciphertext).unwrap();
//!
//! assert_eq!(plaintext, "swordfish");
//! ```
//!
//! The `config` and `providers` modules power the CLI. They can be embedded
//! in other programs, but many functions interact with the user or the
//! filesystem. Prefer `no_run` doctests for those.
use anyhow::{Context, Result, anyhow, bail};
use argon2::{Algorithm, Argon2, Params, Version, password_hash::rand_core::RngCore};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng},
};
use secrecy::{ExposeSecret, SecretString};
use std::path::PathBuf;
use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library.
pub mod config;
/// Secret provider trait and implementations.
pub mod providers;
pub(crate) const HEADER: &str = "$VAULT";
pub(crate) const VERSION: &str = "v1";
pub(crate) const KDF: &str = "argon2id";
pub(crate) const ARGON_M_COST_KIB: u32 = 65_536;
pub(crate) const ARGON_T_COST: u32 = 3;
pub(crate) const ARGON_P: u32 = 1;
pub(crate) const SALT_LEN: usize = 16;
pub(crate) const NONCE_LEN: usize = 24;
pub(crate) const KEY_LEN: usize = 32;
fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
let params = Params::new(ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P, Some(KEY_LEN))
.map_err(|e| anyhow!("argon2 params error: {:?}", e))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key_bytes = [0u8; KEY_LEN];
argon
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
let key: Key = key_bytes.into();
key_bytes.zeroize();
Ok(key)
}
/// Encrypt a UTF8 string using a password and return a portable envelope.
///
/// The returned value is a semicolonseparated envelope containing metadata
/// (header, version, KDF params) and base64 encoded salt, nonce and
/// ciphertext. It is safe to store in configuration files.
///
/// Example
/// ```
/// use gman::encrypt_string;
/// use secrecy::SecretString;
///
/// let pw = SecretString::new("password".into());
/// let env = encrypt_string(pw, "hello").unwrap();
/// assert!(env.starts_with("$VAULT;v1;argon2id;"));
/// ```
pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Result<String> {
let password = password.into();
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let mut key = derive_key(&password, &salt)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad = format!(
"{};{};{};m={},t={},p={}",
HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P
);
let nonce: XNonce = nonce_bytes.into();
let mut pt = plaintext.as_bytes().to_vec();
let ct = cipher
.encrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &pt,
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow!("encryption failed"))?;
pt.zeroize();
let env = format!(
"{};{};{};m={m},t={t},p={p};salt={salt};nonce={nonce};ct={ct}",
HEADER,
VERSION,
KDF,
m = ARGON_M_COST_KIB,
t = ARGON_T_COST,
p = ARGON_P,
salt = B64.encode(salt),
nonce = B64.encode(nonce_bytes),
ct = B64.encode(&ct),
);
drop(cipher);
key.zeroize();
salt.zeroize();
nonce_bytes.zeroize();
Ok(env)
}
/// Decrypt an envelope produced by [`encrypt_string`].
///
/// Returns the original plaintext on success or an error if the password is
/// wrong, the envelope was tampered with, or the input is malformed.
///
/// This function supports both the current format (with KDF params in AAD) and
/// the legacy format (without KDF params in AAD) for backwards compatibility.
///
/// Example
/// ```
/// use gman::{encrypt_string, decrypt_string};
/// use secrecy::SecretString;
///
/// let pw = SecretString::new("pw".into());
/// let env = encrypt_string(pw.clone(), "top secret").unwrap();
/// let pt = decrypt_string(pw, &env).unwrap();
/// assert_eq!(pt, "top secret");
/// ```
pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
let password = password.into();
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let parts: Vec<&str> = envelope.split(';').collect();
if parts.len() < 7 {
bail!("invalid envelope format");
}
if parts[0] != HEADER {
bail!("unexpected header");
}
if parts[1] != VERSION {
bail!("unsupported version {}", parts[1]);
}
if parts[2] != KDF {
bail!("unsupported kdf {}", parts[2]);
}
let params_str = parts[3];
let mut m = ARGON_M_COST_KIB;
let mut t = ARGON_T_COST;
let mut p = ARGON_P;
for kv in params_str.split(',') {
if let Some((k, v)) = kv.split_once('=') {
match k {
"m" => m = v.parse().unwrap_or(m),
"t" => t = v.parse().unwrap_or(t),
"p" => p = v.parse().unwrap_or(p),
_ => {}
}
}
}
let salt_b64 = parts[4].strip_prefix("salt=").context("missing salt")?;
let nonce_b64 = parts[5].strip_prefix("nonce=").context("missing nonce")?;
let ct_b64 = parts[6].strip_prefix("ct=").context("missing ct")?;
let mut salt_bytes = B64.decode(salt_b64).context("bad salt b64")?;
let nonce_bytes = B64.decode(nonce_b64).context("bad nonce b64")?;
let mut ct = B64.decode(ct_b64).context("bad ct b64")?;
if nonce_bytes.len() != NONCE_LEN {
bail!("nonce length mismatch");
}
let mut key = derive_key(&password, &salt_bytes)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad_new = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let aad_legacy = format!("{};{}", HEADER, VERSION);
let mut nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| anyhow!("invalid nonce length"))?;
let nonce: XNonce = nonce_arr.into();
let decrypt_result = cipher.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_new.as_bytes(),
},
);
let mut pt = match decrypt_result {
Ok(pt) => pt,
Err(_) => cipher
.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_legacy.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?,
};
let s = String::from_utf8(pt.clone()).context("plaintext not valid UTF-8")?;
key.zeroize();
salt_bytes.zeroize();
nonce_arr.zeroize();
ct.zeroize();
pt.zeroize();
Ok(s)
}
pub(crate) fn calling_app_name() -> String {
let exe: PathBuf = std::env::current_exe().expect("unable to get current exe path");
exe.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
.expect("executable name not valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn round_trip() {
let pw = SecretString::new("correct horse battery staple".into());
let msg = "swordfish";
let env = encrypt_string(pw.clone(), msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
assert_eq!(msg, out);
}
#[test]
fn wrong_password_fails() {
let env = encrypt_string(SecretString::new("pw1".into()), "hello").unwrap();
assert!(decrypt_string(SecretString::new("pw2".into()), &env).is_err());
}
#[test]
fn empty_plaintext() {
let pw = SecretString::new("password".into());
let msg = "";
let env = encrypt_string(pw.clone(), msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
assert_eq!(msg, out);
}
#[test]
fn empty_password_rejected() {
let pw = SecretString::new("".into());
let msg = "hello";
assert!(encrypt_string(pw.clone(), msg).is_err());
}
#[test]
fn long_plaintext() {
let pw = SecretString::new("password".into());
let msg = "a".repeat(1000);
let env = encrypt_string(pw.clone(), msg.as_str()).unwrap();
let out = decrypt_string(pw, &env).unwrap();
assert_eq!(msg, out);
}
#[test]
fn tampered_ciphertext() {
let pw = SecretString::new("password".into());
let msg = "hello";
let env = encrypt_string(pw.clone(), msg).unwrap();
let mut parts: Vec<&str> = env.split(';').collect();
let ct_b64 = parts[6].strip_prefix("ct=").unwrap();
let mut ct = base64::engine::general_purpose::STANDARD
.decode(ct_b64)
.unwrap();
ct[0] ^= 0x01;
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_ct_part = format!("ct={}", new_ct_b64);
parts[6] = &new_ct_part;
let tampered_env = parts.join(";");
assert!(decrypt_string(pw, &tampered_env).is_err());
}
}