tsafe-cli 1.0.20

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! TOTP, QR, pin, and unpin command handlers.
//!
//! Implements `tsafe qr`, `tsafe totp`, `tsafe pin`, and `tsafe unpin` —
//! two-factor seed management, QR-code display, and key pinning within a vault.

use std::collections::HashMap;
use std::io::Write;

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_cli::cli::{TotpAction, TotpAlgorithm};
use tsafe_core::{audit::AuditEntry, profile, vault::Vault};

use crate::helpers::*;

pub(crate) fn cmd_qr(profile: &str, key: &str) -> Result<()> {
    let password = prompt_password(&format!("Password for profile '{profile}': "))?;
    let vault = Vault::open(&profile::vault_path(profile), password.as_bytes())
        .with_context(|| format!("Cannot open vault '{profile}'"))?;
    let value = vault
        .get(key)
        .with_context(|| format!("Key '{key}' not found in profile '{profile}'"))?
        .clone();
    // Release the vault lock BEFORE blocking on stdin — prevents lock file
    // persisting if the user sends Ctrl+C while waiting for Enter.
    drop(vault);

    println!();
    qr2term::print_qr(&value)
        .with_context(|| "Failed to render QR code (value may be too long)")?;
    println!();

    eprint!("Press Enter to clear...");
    let _ = std::io::stdin().read_line(&mut String::new());

    // Move cursor up and clear from top to erase the QR from the terminal buffer
    print!("\x1b[2J\x1b[H");
    std::io::stdout().flush()?;

    audit(profile)
        .append(&AuditEntry::success(profile, "qr", Some(key)))
        .ok();

    Ok(())
}

pub(crate) fn cmd_totp(profile: &str, action: TotpAction) -> Result<()> {
    match action {
        TotpAction::Add {
            key,
            secret,
            algorithm,
            digits,
            period,
        } => {
            // Validate digits (most services use 6 or 8; guard against obvious mistakes).
            if digits == 0 || digits > 10 {
                anyhow::bail!("--digits must be between 1 and 10 (most services use 6 or 8)");
            }
            // Validate period.
            if period == 0 {
                anyhow::bail!("--period must be at least 1 second");
            }
            let base32 =
                tsafe_core::totp::extract_base32(&secret).map_err(|e| anyhow::anyhow!("{e}"))?;
            let mut vault = open_vault(profile)?;
            let mut tags = HashMap::new();
            tags.insert("type".into(), "totp".into());
            let uri = format!(
                "otpauth://totp/{key}?secret={}&algorithm={}&digits={}&period={}",
                base32.as_str(),
                algorithm.as_uri_str(),
                digits,
                period,
            );
            vault
                .set(&key, &uri, tags)
                .context("failed to store TOTP secret")?;
            audit(profile)
                .append(&AuditEntry::success(profile, "totp-add", Some(&key)))
                .ok();
            let algo_note =
                if !matches!(algorithm, TotpAlgorithm::Sha1) || digits != 6 || period != 30 {
                    format!(
                        " (algorithm={}, digits={}, period={}s)",
                        algorithm.as_uri_str(),
                        digits,
                        period
                    )
                } else {
                    String::new()
                };
            println!("{} TOTP seed stored for '{key}'{}", "".green(), algo_note);
            Ok(())
        }
        TotpAction::Get { key } => {
            let vault = open_vault(profile)?;
            let stored = vault.get(&key).map_err(|_| {
                anyhow::anyhow!(
                    "key '{key}' not found\n\
                     \n  See stored TOTP keys:  tsafe list --tag type=totp\
                     \n  Add a TOTP seed:       tsafe totp add {key} <base32-secret>"
                )
            })?;
            let base32 =
                tsafe_core::totp::extract_base32(&stored).map_err(|e| anyhow::anyhow!("{e}"))?;
            let code =
                tsafe_core::totp::generate_code(&base32).map_err(|e| anyhow::anyhow!("{e}"))?;
            let secs = tsafe_core::totp::seconds_remaining();
            println!("{code}  ({secs}s remaining)");
            audit(profile)
                .append(&AuditEntry::success(profile, "totp-get", Some(&key)))
                .ok();
            Ok(())
        }
    }
}

pub(crate) fn cmd_pin(profile: &str, key: &str) -> Result<()> {
    let mut vault = open_vault(profile)?;
    let existing_val = vault.get(key).map_err(|_| {
        anyhow::anyhow!(
            "key '{key}' not found\n\
             \n  See all keys: tsafe list"
        )
    })?;
    let mut tags = if let Some(entry) = vault.file().secrets.get(key) {
        entry.tags.clone()
    } else {
        HashMap::new()
    };
    tags.insert("pinned".into(), "true".into());
    vault.set(key, &existing_val, tags)?;
    audit(profile)
        .append(&AuditEntry::success(profile, "pin", Some(key)))
        .ok();
    println!("{} Pinned '{key}' to top of list", "".green());
    Ok(())
}

pub(crate) fn cmd_unpin(profile: &str, key: &str) -> Result<()> {
    let mut vault = open_vault(profile)?;
    let existing_val = vault.get(key).map_err(|_| {
        anyhow::anyhow!(
            "key '{key}' not found\n\
             \n  See all keys: tsafe list"
        )
    })?;
    let mut tags = if let Some(entry) = vault.file().secrets.get(key) {
        entry.tags.clone()
    } else {
        HashMap::new()
    };
    tags.remove("pinned");
    vault.set(key, &existing_val, tags)?;
    audit(profile)
        .append(&AuditEntry::success(profile, "unpin", Some(key)))
        .ok();
    println!("{} Unpinned '{key}'", "".green());
    Ok(())
}