Proyecto práctico: Gestor de contraseñas CLI en Rust (SQLite + cifrado XChaCha20-Poly1305)

rust Proyecto práctico: Gestor de contraseñas CLI en Rust (SQLite + cifrado XChaCha20-Poly1305)

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> {
    let mut stmt = conn.prepare("SELECT v FROM meta WHERE k = ?1")?;
    let mut rows = stmt.query(params![key])?;
    if let Some(row) = rows.next()? {
        let v: String = row.get(0)?;
        Ok(Some(v))
    } else {
        Ok(None)
    }
}

pub fn set_meta(conn: &Connection, key: &str, val: &str) -> Result<()> {
    conn.execute("INSERT OR REPLACE INTO meta (k, v) VALUES (?1, ?2)", params![key, val])?;
    Ok(())
}

pub fn insert_entry(conn: &Connection, name: &str, username: &str, cipher: &str, nonce: &str, notes: &str, ts: i64) -> Result<()> {
    conn.execute(
        "INSERT INTO entries (name, username, cipher, nonce, notes, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        params![name, username, cipher, nonce, notes, ts],
    )?;
    Ok(())
}

pub fn get_entry(conn: &Connection, name: &str) -> Result> {
    let mut stmt = conn.prepare("SELECT username, cipher, nonce FROM entries WHERE name = ?1")?;
    let mut rows = stmt.query(params![name])?;
    if let Some(row) = rows.next()? {
        let username: String = row.get(0)?;
        let cipher: String = row.get(1)?;
        let nonce: String = row.get(2)?;
        Ok(Some((username, cipher, nonce)))
    } else {
        Ok(None)
    }
}

pub fn list_entries(conn: &Connection) -> Result> {
    let mut stmt = conn.prepare("SELECT id, name, username FROM entries ORDER BY name")?;
    let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
    let mut out = Vec::new();
    for r in rows { out.push(r?); }
    Ok(out)
}

pub fn delete_entry(conn: &Connection, name: &str) -> Result {
    let n = conn.execute("DELETE FROM entries WHERE name = ?1", params![name])?;
    Ok(n)
}

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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión