Build a password manager with Rust - Part 2

Damien Cosset - Jun 1 - - Dev Community

Introduction

After our first article, we have a simple command line application that can display the contents of a file where we store our passwords. We can also add a new password to that file through the command line.

In this article, we'll add some functionalities:

  • Possibility to generate a new password automatically
  • Add a password by getting the user's clipboard content
  • Keep the possibility to type the password in
  • Alert the user if the password is considered too weak

Let's go!

First step: Improving the command line arguments

In order to handle different ways to add a password, we need to change the command line arguments. We are going to configure some flags for our application:

  • one for the password generation
  • one to get the password from the clipboard
  • one to allow the user to write a password

We still keep the service and username arguments. The new command line arguments configuration can look like this:

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    cmd: Commands,
}

#[derive(Subcommand)]
enum Commands {
    List,
    Add {
        #[arg(short, long)]
        service: String,
        #[arg(short, long)]
        username: String,
        #[arg(short, long, default_missing_value("true"))]
        clipboard: bool, // copy from clipboard
        #[arg(short, long, default_missing_value("true"))]
        generate: bool, // generate new password
        #[arg(short, long, default_missing_value("true"))]
        write: bool, // type new password
    },
}
Enter fullscreen mode Exit fullscreen mode

Now, we can take three arguments: clipboard, generate and write. Each of them take default_missing_value as true so we do not have to add anything behind the flag.

Let's update our main function to test it out:

fn main() -> std::io::Result<()> {
    let args = Cli::parse();
    match args.cmd {
        Commands::List => display_passwords()?,
        Commands::Add {
            service,
            username,
            clipboard,
            generate,
            write,
        } => {
            println!(
                "{}, {}, clipboard: {}, generate: {}, write: {}",
                service, username, clipboard, generate, write
            )
        }
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Now let's run it. Thanks to the advice from Vladimir Ignatev, I discovered that you can run your application through the build output in target/... folder, it makes it a little bit cleaner 😉. For me, it's in the target/debug folder. So after running cargo build, we can run our application with:
./target/debug/password_manager add -u toto -s serv -c

Note: password_manager is the name of my application, yours might be different.

This command above gives us:

First clipboard run

It works! Now let's test out our other 2 flags to make sure everything is in order:

First generate run

First write run

Perfect!

Second step: Generate a password for the user

Now that we have different options to save a new password. We need to implement the logic for each. We'll start by generating a new password. Now, there are several ways to do this. You could use a existing crate that do it for you or build something yourself. I chose to use the rand crate. This is crate is a general-purpose random number generator. It will give us the foundation to randomly generate a password. I'd like to keep some freedom to custom the functionality later on, instead of relying on an existing crate. But feel free to use an existing crate 😉

First, let's add our crate with cargo add rand
Then, let's add the following code to generate a password:

// All our imports
use clap::{Parser, Subcommand};
use rand::distributions::Uniform;
use rand::{thread_rng, Rng};
use std::io::Write;
use std::{
    fs::{self, OpenOptions},
    iter,
    path::Path,
};

// More code...

fn main() -> std::io::Result<()> {
    let args = Cli::parse();
    match args.cmd {
        Commands::List => display_passwords()?,
        Commands::Add {
            service,
            username,
            clipboard,
            generate,
            write,
        } => {
            println!(
                "{}, {}, clipboard: {}, generate: {}, write: {}",
                service, username, clipboard, generate, write
            );
            if generate {
                let password = generate_password();
                println!("{}", password)
            }
        }
    }
    Ok(())
}

fn generate_password() -> String {
    let charset: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
                           abcdefghijklmnopqrstuvwxyz\
                           0123456789\
                           !@#$%^&*()_-+=[{]}\\;:'\",<.>/?";
    let mut rng = thread_rng();
    let password: String = iter::repeat_with(|| {
        let idx = rng.sample(Uniform::from(0..charset.len()));
        charset[idx] as char
    })
    .take(16)
    .collect();
    password
}

// rest of the code...
Enter fullscreen mode Exit fullscreen mode

The idea is that we create a charset variable that holds all the possible characters our password can contain. The list here is a completely arbitrary choice. You could choose to use the Alphanumerical distribution provided by the rand crate for example if you wanted. After that, we use the thread_rng function provided by the rand crate. With an iterator and repeat_with, we randomly select one character from the charset. The .take(16) indicates that we want this function to run 16 times. Again, this is an arbitrary choice I'm making for now. After that, we collect the result of the iterator into a collection ( so a collection of chars is a String ) and return our password.

Let's test it! Let's run cargo build and generate our password:

First password generation

Second password generation

Awesome! Now we can successfully generate random strong passwords of 16 characters.

Third step: Getting a password from the clipboard

Let's move on to our second use-case: getting the clipboard's content to serve as our password. It's not uncommon to fetch a password from somewhere and copying it to save it. To have access to the clipboard, we'll use the copypasta crate to make sure we have something that works accross all operating systems. Let's add the crate with cargo add copypasta and add the following code:

// Import
use copypasta::{ClipboardContext, ClipboardProvider};

fn main() -> std::io::Result<()> {
    let args = Cli::parse();
    match args.cmd {
        Commands::List => display_passwords()?,
        Commands::Add {
            service,
            username,
            clipboard,
            generate,
            write,
        } => {
            println!(
                "{}, {}, clipboard: {}, generate: {}, write: {}",
                service, username, clipboard, generate, write
            );
            if generate {
                let password = generate_password();
                println!("{}", password)
            }

            if clipboard {
                let password = get_clipboard_password();
                println!("{}", password)
            }
        }
    }
    Ok(())
}

fn get_clipboard_password() -> String {
    let mut ctx = ClipboardContext::new().unwrap();
    return ctx.get_contents().unwrap();
}
Enter fullscreen mode Exit fullscreen mode

There, in two lines of code, we're able to get the clipboard content! Let's try it out. I'll just copy that last line.

Get clipboard contents and print it

Nice!

Fourth step: Making the user write the password

Finally, let's implement the last use case. We need to handle our user's input.

fn main() -> std::io::Result<()> {
    let args = Cli::parse();
    match args.cmd {
        Commands::List => display_passwords()?,
        Commands::Add {
            service,
            username,
            clipboard,
            generate,
            write,
        } => {
            println!(
                "{}, {}, clipboard: {}, generate: {}, write: {}",
                service, username, clipboard, generate, write
            );
            if generate {
                let password = generate_password();
                println!("{}", password)
            }

            if clipboard {
                let password = get_clipboard_password();
                println!("{}", password)
            }

            if write {
                let password = get_user_input();
                println!("You typed: {}", password.unwrap())
            }
        }
    }
    Ok(())
}

fn get_user_input() -> io::Result<String> {
    println!("Write it down the press Enter!");
    let mut input = String::new();
    match io::stdin().read_line(&mut input) {
        Ok(n) => return Ok(input.trim().to_string()),
        Err(error) => Err(error),
    }
}
Enter fullscreen mode Exit fullscreen mode

We're using the read_line function from the Stdin struct. This means that the user can type what he wants until Enter is pressed. Let's run the app after cargo build:

Write the password and print it

Awesome! We now have our 3 ways to create a new password.

Fifth step: Scoring passwords

For our last functionality in this article, we are going to alert the user if a password is considered too weak ( when we get it from the clipboard or the user types it in ).

To give a password a score, we are going to use the passwords crate. Let's add it: cargo add passwords.

The process will look like this:

  • we get a password from the user's input or the clipboard
  • we use the passwords crate to check that password score
  • if the score is too low ( weak password ), we print out an alert to the user asking him to confirm that she's ok with the weak password.

I'm going to anticipate a "problem" we are going to encounter. For my use case, I would like the user to press Q or Enter to confirm or not that she wants to save a weak password. From what I understood, it's not straightforward to do this with the same io::stdin we used before because of the line-buffered nature of most terminals. Meaning, we have to wait for the user to press Enter to read the input. So, we are going to use the crossterm crate that allow us to read single chars from user's input. We can add it with: cargo add crossterm

Let's write some code:

use crossterm::event::{read, Event, KeyCode};
use crossterm::execute;
use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use passwords::analyzer;
use passwords::scorer;

#[derive(PartialEq)]
enum WeakPasswordChoice {
    ABORT,
    CONTINUE,
}


fn should_save_password(password: &str) -> bool {
    if is_password_weak(password) {
        print_alert(password);
        if let Ok(choice) = read_next_char() {
            return choice == WeakPasswordChoice::CONTINUE;
        } else {
            return false;
        }
    } else {
        return true;
    }
}

fn is_password_weak(password: &str) -> bool {
    let score = scorer::score(&analyzer::analyze(password));
    return score < 80.0;
}

fn print_alert(password: &str) {
    let alert = format!(
        "{} is a weak password. Press Enter to continue anyway. Press Q to abort and try again!",
        password
    );
    execute!(
        io::stdout(),
        SetForegroundColor(Color::Red),
        SetAttribute(Attribute::Bold),
        Print(alert),
        ResetColor,
        SetAttribute(Attribute::Reset)
    )
    .unwrap();
}


fn read_next_char() -> io::Result<WeakPasswordChoice> {
    enable_raw_mode()?;
    let result = loop {
        match read()? {
            Event::Key(event) => {
                match event.code {
                    KeyCode::Char('q') | KeyCode::Char('Q') => {
                        break Ok(WeakPasswordChoice::ABORT);
                    }
                    KeyCode::Enter => {
                        break Ok(WeakPasswordChoice::CONTINUE);
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    };

    disable_raw_mode()?;
    result
}
Enter fullscreen mode Exit fullscreen mode

We added a lot of things here:

  • The should_save_password function takes the password and returns a boolean. It tells us whether or not the password can be saved in our file.
  • To achieve this, we first create a is_password_weak function. This function uses the analyzer and scorer from the passwords crate. By getting a score for our password, we are able to determine if it is weak or not. I chose to consider 80 or less as a weak score. To get more information, you can check the crate's docs.
  • If the password is considered weak. We print an alert to the user. We use the crossterm crate to style the output in the print_alert function.
  • The read_next_char function checks the next character the user will enter. To do this, we use crossterm's functionalities to make it easier. If it's a Q or q, we will abort. If it's enter, we will continue the process of saving a password.
  • To express those two choices, I created an enum WeakPasswordChoice. Notice that nothing happens if the user presses any other keys.

Note: The #[derive(PartialEq)] on the enum allows us to do comparison like this: choice == WeakPasswordChoice::CONTINUE;

Let's test it out by adding our should_save_password function in our clipboard and write use case:

  if clipboard {
        let password = get_clipboard_password();
        println!("{}", password);
        should_save_password(&password);
    }

    if write {
        let password = get_user_input().unwrap();
        println!("You typed: {}", password);
        should_save_password(&password);
    }
Enter fullscreen mode Exit fullscreen mode

Let's run cargo build && ./target/debug/password_manager add -u myUsername -s aService -w:

Alert for weak password typed in

Or: ./target/debug/password_manager add -u myUsername -s aService -c

Alert for weak password with clipboard

Amazing! Now for the final step! We just need to connect the existing functionality of saving password to the code we just wrote.

Here's the full code for the end of our part 2:

use clap::{Parser, Subcommand};
use copypasta::{ClipboardContext, ClipboardProvider};
use rand::distributions::Uniform;
use rand::{thread_rng, Rng};
use std::io;
use std::io::Write;
use std::{
    fs::{self, OpenOptions},
    iter,
    path::Path,
};

use crossterm::event::{read, Event, KeyCode};
use crossterm::execute;
use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor};

use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use passwords::analyzer;
use passwords::scorer;

#[derive(PartialEq)]
enum WeakPasswordChoice {
    ABORT,
    CONTINUE,
}

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    cmd: Commands,
}

#[derive(Subcommand)]
enum Commands {
    List,
    Add {
        #[arg(short, long)]
        service: String,
        #[arg(short, long)]
        username: String,
        #[arg(short, long, default_missing_value("true"))]
        clipboard: bool, // copy from clipboard
        #[arg(short, long, default_missing_value("true"))]
        generate: bool, // generate new password
        #[arg(short, long, default_missing_value("true"))]
        write: bool, // type new password
    },
}

fn main() -> std::io::Result<()> {
    let args = Cli::parse();
    match args.cmd {
        Commands::List => display_passwords()?,
        Commands::Add {
            service,
            username,
            clipboard,
            generate,
            write,
        } => {
            if generate {
                let password = generate_password();
                println!("{}", password);
                let _ = add_new_password(&service, &username, &password);
            }

            if clipboard {
                let password = get_clipboard_password();
                println!("{}", password);
                if should_save_password(&password) {
                    let _ = add_new_password(&service, &username, &password);
                }
            }

            if write {
                let password = get_user_input().unwrap();
                if should_save_password(&password) {
                    let _ = add_new_password(&service, &username, &password);
                }
            }
        }
    }
    Ok(())
}

fn get_user_input() -> io::Result<String> {
    println!("Write it down the press Enter!");
    let mut input = String::new();
    match io::stdin().read_line(&mut input) {
        Ok(n) => return Ok(input.trim().to_string()),
        Err(error) => Err(error),
    }
}

fn get_clipboard_password() -> String {
    let mut ctx = ClipboardContext::new().unwrap();
    return ctx.get_contents().unwrap();
}

fn generate_password() -> String {
    let charset: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
                           abcdefghijklmnopqrstuvwxyz\
                           0123456789\
                           !@#$%^&*()_-+=[{]}\\;:'\",<.>/?";
    let mut rng = thread_rng();
    let password: String = iter::repeat_with(|| {
        let idx = rng.sample(Uniform::from(0..charset.len()));
        charset[idx] as char
    })
    .take(16)
    .collect();
    password
}

fn should_save_password(password: &str) -> bool {
    if is_password_weak(password) {
        print_alert(password);
        if let Ok(choice) = read_next_char() {
            return choice == WeakPasswordChoice::CONTINUE;
        } else {
            return false;
        }
    } else {
        return true;
    }
}

fn print_alert(password: &str) {
    let alert = format!(
        "{} is a weak password. Press Enter to continue anyway. Press Q to abort and try again!",
        password
    );
    execute!(
        io::stdout(),
        SetForegroundColor(Color::Red),
        SetAttribute(Attribute::Bold),
        Print(alert),
        ResetColor,
        SetAttribute(Attribute::Reset)
    )
    .unwrap();
}

fn is_password_weak(password: &str) -> bool {
    let score = scorer::score(&analyzer::analyze(password));
    return score < 80.0;
}

fn read_next_char() -> io::Result<WeakPasswordChoice> {
    enable_raw_mode()?;
    let result = loop {
        match read()? {
            Event::Key(event) => match event.code {
                KeyCode::Char('q') | KeyCode::Char('Q') => {
                    break Ok(WeakPasswordChoice::ABORT);
                }
                KeyCode::Enter => {
                    break Ok(WeakPasswordChoice::CONTINUE);
                }
                _ => {}
            },
            _ => {}
        }
    };

    disable_raw_mode()?;
    result
}

fn display_passwords() -> std::io::Result<()> {
    let path = Path::new("./passwords.txt");
    let contents = fs::read_to_string(path).expect("Could not read the passwords file");

    println!("{}", contents);
    Ok(())
}

fn add_new_password(service: &str, username: &str, password: &str) -> std::io::Result<()> {
    let path = Path::new("./passwords.txt");
    let password_infos = format!("{}|{}|{}\n", service, username, password);

    let mut file = OpenOptions::new().append(true).open(path)?;

    file.write_all(password_infos.as_bytes())?;

    Ok(())
}

Enter fullscreen mode Exit fullscreen mode

Let's make sure everything works as it should:

Add Password Generate

Add Password Clipboard

Add Password Write In

And check the list in our file:

Password list display

Yayyy!

Conclusion

In this article, we added quite a lot of functionality for our little application. We can now add passwords in three different ways and alert our user if their password is too weak. As always, feel free to tell me if some of this code could be improved, or if you would like to see some functionalities for this password manager. The full code can be found on github

Have fun ❤️!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .