Proyecto práctico: Gestor de contraseñas CLI en Rust
Construiremos un gestor de contraseñas en línea de comandos que almacena entradas en SQLite y cifra los secretos con XChaCha20-Poly1305. El master password nunca se guarda: se deriva una clave con PBKDF2 y un salt único. Tendrás comandos para inicializar la base, añadir, listar, obtener y borrar entradas.
Requisitos previos
- Rust toolchain (rustc + cargo), >= 1.60
- sqlite3 (opcional, para inspeccionar la DB)
- Conocimientos básicos de Rust y conceptos de criptografía: KDF, nonce, AEAD
Estructura de carpetas
password-manager-rs/
├─ Cargo.toml
└─ src/
├─ main.rs # CLI + orquestación
├─ db.rs # funciones para SQLite
└─ crypto.rs # derivación de clave y cifrado/descifrado
Cargo.toml
[package]
name = "password_manager"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.29", features = ["bundled"] }
rand = "0.8"
base64 = "0.21"
chacha20poly1305 = { version = "0.10", features = ["xchacha20"] }
pbkdf2 = "0.10"
hmac = "0.12"
sha2 = "0.10"
rpassword = "7"
anyhow = "1"
src/crypto.rs
use anyhow::Result;
use base64::{engine::general_purpose, Engine as _};
use chacha20poly1305::{aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, Key};
use pbkdf2::pbkdf2;
use hmac::Hmac;
use sha2::Sha256;
use rand::rngs::OsRng;
use rand::RngCore;
pub const PBKDF2_ITERS: u32 = 100_000;
pub fn gen_salt() -> Vec {
let mut s = vec![0u8; 16];
OsRng.fill_bytes(&mut s);
s
}
pub fn salt_to_b64(s: &[u8]) -> String {
general_purpose::STANDARD.encode(s)
}
pub fn salt_from_b64(s: &str) -> Result> {
Ok(general_purpose::STANDARD.decode(s)?)
}
pub fn derive_key(master: &str, salt: &[u8]) -> [u8; 32] {
let mut key = [0u8; 32];
// PBKDF2-HMAC-SHA256
pbkdf2::>(master.as_bytes(), salt, PBKDF2_ITERS, &mut key);
key
}
pub fn encrypt(key_bytes: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
let key = Key::from_slice(key_bytes);
let cipher = XChaCha20Poly1305::new(key);
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut nonce);
let ct = cipher.encrypt(XNonce::from_slice(&nonce), plaintext)?;
Ok((general_purpose::STANDARD.encode(&ct), general_purpose::STANDARD.encode(&nonce)))
}
pub fn decrypt(key_bytes: &[u8; 32], ct_b64: &str, nonce_b64: &str) -> Result> {
let key = Key::from_slice(key_bytes);
let cipher = XChaCha20Poly1305::new(key);
let ct = general_purpose::STANDARD.decode(ct_b64)?;
let nonce = general_purpose::STANDARD.decode(nonce_b64)?;
let pt = cipher.decrypt(XNonce::from_slice(&nonce), ct.as_ref())?;
Ok(pt)
}
src/db.rs
use anyhow::Result;
use rusqlite::{params, Connection};
pub fn open_db(path: &str) -> Result {
let conn = Connection::open(path)?;
Ok(conn)
}
pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
username TEXT,
cipher TEXT NOT NULL,
nonce TEXT NOT NULL,
notes TEXT,
created_at INTEGER NOT NULL
);",
)?;
Ok(())
}
pub fn get_meta(conn: &Connection, key: &str) -> Result
src/main.rs
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
use std::time::{SystemTime, UNIX_EPOCH};
mod db;
mod crypto;
use crypto::*;
use db::*;
#[derive(Parser)]
#[command(name = "pm")]
#[command(about = "Password manager minimal en Rust", long_about = None)]
struct Cli {
/// Ruta al archivo sqlite
#[arg(short, long, default_value = "pm.db")]
db: String,
#[command(subcommand)]
cmd: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Inicializa la base y crea salt
Init,
/// Añade una entrada: nombre y username. Se pedirá la contraseña
Add { name: String, username: String, #[arg(short, long, default_value = "")] notes: String },
/// Obtiene y descifra una entrada
Get { name: String },
/// Lista entradas
List,
/// Borra una entrada
Del { name: String },
}
fn read_master(prompt: &str) -> Result {
let pw = rpassword::prompt_password_stdout(prompt)?;
if pw.is_empty() { Err(anyhow!("Master password no puede estar vacío")) } else { Ok(pw) }
}
fn now_ts() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}
fn main() -> Result<()> {
let cli = Cli::parse();
let conn = open_db(&cli.db)?;
init_schema(&conn)?;
match cli.cmd {
Commands::Init => {
if get_meta(&conn, "salt")?.is_some() {
println!("La base ya está inicializada.");
return Ok(());
}
let master = read_master("Define master password: ")?;
let salt = gen_salt();
// derive and discard key just to ensure user typed something usable
let _k = derive_key(&master, &salt);
set_meta(&conn, "salt", &salt_to_b64(&salt))?;
println!("Inicializado. Salt guardado en meta.");
}
Commands::Add { name, username, notes } => {
let salt_b64 = get_meta(&conn, "salt")?.ok_or_else(|| anyhow!("DB no inicializada. Ejecuta 'pm --db=db Init'"))?;
let salt = salt_from_b64(&salt_b64)?;
let master = read_master("Master password: ")?;
let key = derive_key(&master, &salt);
let secret = rpassword::prompt_password_stdout("Password para la entrada: ")?;
let (ct_b64, nonce_b64) = encrypt(&key, secret.as_bytes())?;
insert_entry(&conn, &name, &username, &ct_b64, &nonce_b64, ¬es, now_ts())?;
println!("Entrada '{}' añadida.", name);
}
Commands::Get { name } => {
let salt_b64 = get_meta(&conn, "salt")?.ok_or_else(|| anyhow!("DB no inicializada."))?;
let salt = salt_from_b64(&salt_b64)?;
let master = read_master("Master password: ")?;
let key = derive_key(&master, &salt);
if let Some((username, ct, nonce)) = get_entry(&conn, &name)? {
let pt = decrypt(&key, &ct, &nonce)?;
let pwd = String::from_utf8(pt)?;
println!("Name: {}\nUsername: {}\nPassword: {}", name, username, pwd);
} else {
println!("Entrada no encontrada: {}", name);
}
}
Commands::List => {
let rows = list_entries(&conn)?;
for (id, name, username) in rows {
println!("{}: {} ({})", id, name, username);
}
}
Commands::Del { name } => {
let n = delete_entry(&conn, &name)?;
println!("Filas borradas: {}", n);
}
}
Ok(())
}
Por qué estas decisiones
- SQLite: sencillo, portable y suficiente para este ejemplo CLI.
- PBKDF2 (HMAC-SHA256): KDF robusto y ampliamente disponible; para mayor seguridad en producción considera Argon2 (memoria-cost).
- XChaCha20-Poly1305: AEAD moderno con nonce largo (24 bytes) que evita reutilización accidental e incluye autenticación.
- Salt aleatorio por base (meta table): permite derivar la misma clave del master password en cada ejecución; guardarlo es seguro porque no revela la contraseña.
- Base64: facilita almacenar binarios en SQLite como texto. Puedes cambiar a BLOB si prefieres.
Uso rápido
# Compilar
cargo build --release
# Inicializar (crea salt)
./target/release/password_manager --db pm.db Init
# Añadir
./target/release/password_manager --db pm.db Add github alice --notes "cuenta personal"
# Listar
./target/release/password_manager --db pm.db List
# Obtener
./target/release/password_manager --db pm.db Get github
# Borrar
./target/release/password_manager --db pm.db Del github
Pruebas manuales y validación
- Inspecciona la DB con sqlite3 para confirmar que las contraseñas están cifradas (no texto plano).
- Prueba con master password distinto: la derivación fallará y la desencriptación regresará error (integridad protegida por AEAD).
Advertencias de seguridad y mejoras
- PBKDF2 con suficientes iteraciones está bien, pero Argon2 (memoria-cost) es preferible para resistencia a ASIC/GPUs.
- Almacenar salt en la DB está bien; nunca almacenes la clave derivada ni el master password.
- Evita imprimir contraseñas en logs o dejar la aplicación en un entorno sin control. Considera integrarlo con el portapapeles por un tiempo limitado en lugar de stdout.
- Para multi-dispositivo, sincroniza la BD cifrada y gestiona versiones/merge de forma segura.
Siguiente paso recomendado: reemplaza PBKDF2 por Argon2 (crate argonautica o argon2) y añade tests unitarios para crypto+DB (usa vectores conocidos para el cifrado y mocks de sqlite). También considera añadir autenticación por hardware (YubiKey) o integración con secret stores para empresas.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación