Recently I started learning Rust and was looking for a project to reinforce my learning and practice existing skills.
I asked ChatGPT, providing him with some info about my background in coding and what stuff I generally enjoy to code and eventually he presented me with a list of project ideas, most of them were not right for me.
Once I saw “Password Manager” on the list it instantly concluded my search for a project.
I have always adored security tools in the CLI, and for a while now I have wanted to code something involving cryptography elements.
Also this project has the potential to genuinely be practical to some users!
So lets begin.
Planning
First thing lets define our project goals.
What we need to program to do?
The rusty-password-manager
will be simple, it will live in the CLI and it will not have a curses-like interactive UI,
Only accepts parameter commands (similar to: apt install; apt update;
etc..)
The program will be storing all the password entries in a file (lets call it a “store”).
Each store will be able to house multiple entries, with each entry having an optional username and an optional password.
(We can later add all kinds of data to the structure, like secret notes, TOTP secrets, photos, etc…).
Imagine something like that:
EntryName | User | Pass |
---|---|---|
my entry | alfred | sonny |
entry-two | johnny | |
aaaaaaa | delta |
The store will obviously be encrypted :)
We will go over the cryptography part in-depth soon.
At this point I prefer to aim at implementing only minimal features, as this is my first project in rust, and I want to approach this iteratively.
The structure:
Further braking down the features that we will be implementing:
As this will be a CLI tool, we can map-out the majority of the program’s features by simply defining the arguments that the program accepts, and what each command returns.
The args will be such:
store_path
— Required, Positional argument.<command>
— One of the following:-
create
It will ask the user for a name for the store, and initialize empty store.- Optional:
store_name
— positional.
- Optional:
-
open
It will open a store and list its contents.- Optional:
entry_name
— positional.
- Optional:
-
add
It will ask the user for anentry_name
,username
andpassword
to create a new entry. -
remove
It will ask the user for anentry_name
to remove.
-
Under the hood there are two primary routines:
- Load store
- Read file && Decrypt
- Save store
- Encrypt && Write file
As those routines are the core of our program, we will be developing them early in the project.
And finally implement each command and it’s manipulation of the Store data, for example:
- Remove entry - Will prompt the user for an entry name and delete the specified entry.
- Add entry - Will prompt the user for an entry name, Username & Password and create the specified entry.
- etc…
And that covers the higher order planning of our program.
But first, let’s delve into the cryptography as promised :)
The cryptography:
I’m a bit of a fan for security related stuff, so I have decided to put some extra thought into the crypto aspects of this small project, just for practice.
For some inspiration I looked at KeepassXC, in terms of which algorithms to implement.
Here is the UI screen that lets the user choose the encryption algorithms for its password store upon creation:
The user gets to choose one of the following block cipher algorithms:
- AES
- TwoFish
- ChaCha
And one of the following key derivation algorithms:
- Argon2d
- Argon2id
- AES-KDF
For our project, I decided to implement ChaCha20Poly1305 as the stream-cipher algorithm and Argon2id as the key derivation algorithm.
The encryption or decryption process is as follows:
-> The user's passphrase - is used as:
-> Input to key derivation function (Argon2) - whose resulting hash is used as:
-> An initializing key for the cipher (ChaCha20) - which then:
-> Encrypts/Decrypts stuff.
Breakdown:
ChaCha20:
A stream-cipher (widely used inside the TLS v1.3 protocol), is designed to be fast and secure, we will provide it with a 32-bytes key and it will quickly encrypt all our data.
But its only as secure as the key that was used to initialize it, thats why we need a secure key-derivation algorithm.
In theory we could (Don’t do that) feed the user’s password directly into the ChaCha20 algorithm without a key-derivation, but as a result the whole system would become susceptible to various attacks such as a very fast bruteforce, side-channel attacks and more…
Argon2:
A key-derivation algorithm, provides decent protection from said attacks by utilizing some mechanisms like: slow hashing to mitigate GPU brute-force attempts, salting of the hash to defend against rainbow tables, side-channel protection by designed, and more…
All of those profound security algorithms will be worth very little in case the user’s password is weak (short or simple or stupid).
In case the password is weak the whole thing becomes a target practice for bruteforcing.
A Detour := How long a password needs to be?
Well, the effective upper limit is the bit-ness of the KDF - which is 256 bits.
So the user’s password entropy can get as close as possible to 256 bits.
But in reality even 128 bits is well enough to defend against all non-quantum attackers.
Password entropy is based on a variety of parameters so I can only give an estimate:
Around 22 characters is long enough.
Read more
Additional notes regarding the crypto aspects:
-
The hash result of
Argon2
is 32-bytes in length.
This is useful as we are able to passArgon2
result directly intoChaCha20
’s input — A perfect fit for the key length requirement (32-bytes).
Sometimes coding can be so elegant and satisfying 😇 -
We could additionally implement MFA to significantly improve security.
A Friendly disclaimer:
Before we get into the code;
If you are able to suggest improvements to those codes please open an Issue or a PR in the Github repository :)
Just wanted to let you know that i’m welcoming any constructive criticism, as its my very first project in Rust.
My error handling is lacking in design, some parts of the code can be written in a more “rusty” way for sure..
So please, do not frantically take for granted and absorb those codes as an exemplar of good practice.
Always try to verify what you read online through multiple different sources.
Coding:
Ah yes, the practical parts.
Lets go ahead and add all the cargo crates that our project will be depending on:
$ cargo add bincode chacha20poly1305 rust-argon2
$ cargo add serde -F derive
$ cargo add clap -F derive
# cargo.toml
...
[dependencies]
bincode = "1.3.3"
chacha20poly1305 = "0.10.1"
clap = { version = "4.4.4", features = ["derive"] }
rust-argon2 = "2.0.0"
serde = { version = "1.0.188", features = ["derive"] }
The arguments
Starting off with the arguments:
We will be using the popular clap
library to handle user’s CLI commands.
The library’s README says:
Create your command-line parser, with all of the bells and whistles, declaratively or procedurally.
Sounds straight forward to me.
Lets use Subcommand
to define an enum of mutually-exclusive commands, each having its own additional parameters:
// /src/args.rs
use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Args {
/// Store path
#[arg(value_name = "Store File")]
pub store_path: PathBuf,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Open a store (list entries or get entry)
Open { entry_name: Option<String> },
/// Create new store
Create { store_name: Option<String> },
/// Add an entry to an existing store
Add { entry_name: Option<String> },
/// Remove an entry from an existing store
Remove { entry_name: Option<String> },
}
And our main.rs
will house the switching logic:
// /src/main.rs
mod args;
use crate::args::{Args, Commands};
use clap::Parser;
fn main() {
let args = Args::parse();
match args.command {
Commands::Create { store_name } => {
}
Commands::Open { entry_name } => {
}
Commands::Add { entry_name } => {
}
Commands::Remove { entry_name } => {
}
}
}
Boilerplate is.. umm.. boring..
But now we can run our program! 😇
Great, we have the arguments in place, we can continue with writing our important pieces of code:
- The
save_store
- And
load_store
For the save_store
function we will have to implement all those things:
- Data store struct itself
- Hashing with Argon2
- Encrypting with ChaCha20
- Saving to a file
Oh well, this is exciting! Lets begin.
The store struct
Like I said in the beginning - keep it minimal.
We are deriving Serialize
and Deserialize
from serde
to make sure were able to dump and un-dump this to/from a file.
// /src/store.rs
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Store {
pub name: String,
pub entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
pub struct Entry {
pub name: String,
pub username: Option<String>,
pub password: Option<String>,
}
Also add a line to main, to include the new file:
// /src/main.rs
mod store;
...
( I dislike how in Rust I must declare each field of the struct as pub, like is there no better way to define that all the fields are pub by default, unless specified otherwise? )
Implement the data store struct — Done!
Ok so we have defined our data struct but we will have no use for it right now, what a shame; Lets hurry up so we can run it asap :)
Our next thing to implement is:
Hashing with Argon2
Lets write the hashing and salting.
We will be using the rust-argon2
crate.
At this point we will be merely calling the hashing password from a unit-test function.
Keep the salting in mind, we will need to use that same hash salt during the loading/decryption procedure as well.
// /src/crypto.rs
use std::time::Instant;
use argon2::{hash_raw, Config};
pub fn argon2_hash(key: &[u8], salt: &[u8]) -> Vec<u8> {
println!("Hashing password..");
let start = Instant::now();
let res = hash_raw(
key,
salt,
&Config {
mem_cost: 32 * 1024, // 32 MB
lanes: 6,
time_cost: 5,
variant: argon2::Variant::Argon2id,
..argon2::Config::default()
},
);
println!("Hashing took: {:?}", start.elapsed());
res.unwrap()
}
Again, add this line to main, to include the new file:
// /src/main.rs
mod crypto;
...
Notice how i’m hardcoding the configuration parameters to the Argon2
algorithm:
...
mem_cost: 32 * 1024, // 32 MB
lanes: 6,
time_cost: 5,
...
This results for me in a hashing that takes about 900ms to commence.
You may change those parameters as you wish, usually keeping it around one-second of computation time is reasonable.
See also: “Argon2: Recommended parameters”
And lets write a small (kinda slow) test, just to make sure our hashing works:
#[allow(unused_imports)]
mod tests {
// Those lines appear to me as unused even though they are in use, my misconfiguration or a bug
use super::*;
use chacha20poly1305::aead::{rand_core::RngCore, OsRng};
#[test]
fn test_hash() {
// Generate random 12-byte salt
let mut rand_salt = [0u8; 12];
OsRng.fill_bytes(&mut rand_salt);
let mega_password = "1234";
// Just make sure that two calls with the same params result in the same hash.
assert_eq!(
argon2_hash(mega_password.as_bytes(), &rand_salt),
argon2_hash(mega_password.as_bytes(), &rand_salt)
)
}
}
Awesome!
Hashing — Done!
Next we will get one step closer by writing the… 🥁🥁🥁
Encryption & Decryption with ChaCha20:
A moment before we dive into the code;
Additionally to the 32-bytes-long encryption key, the ChaCha20 algorithm uses a 12-bytes piece of data called a “nonce” (also called an “IV” (Initial Vector)).
The nonce and the hashing salt serve the same purpose, to randomize the resulting data.
But unlike the salt, which can stay static, The nonce must be random, and be rerolled and randomized for each encryption (!).
Otherwise we are exposing a vulnerability called “nonce reuse”.
So when encrypting, we will store our nonce alongside the data, to be able to read the nonce later when decrypting.
Our data structure inside the file will look like this:
+-----------------+----------+
| nonce (12 byte) | data.... |
+-----------------+----------+
So lets write the ChaCha20 encryption and decryption:
// /src/crypto.rs
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit, OsRng},
ChaCha20Poly1305, Nonce,
};
pub fn decrypt(key: &[u8], data: &[u8]) -> Result<Vec<u8>, chacha20poly1305::Error> {
let cipher = ChaCha20Poly1305::new(key.into());
// Extract 12 bytes nonce from data
let nonce_length = 12;
let nonce: &Nonce = data[..nonce_length].into();
let data_part = &data[nonce_length..];
cipher.decrypt(nonce, data_part)
}
pub fn encrypt(key: &[u8], data: &[u8]) -> Result<Vec<u8>, chacha20poly1305::Error> {
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
// Concat the nonce + encrypted data
let mut combined_data = nonce.to_vec();
let encrypted_data = cipher.encrypt(&nonce, data).to_owned()?;
combined_data.extend(encrypted_data);
Ok(combined_data)
}
Notice how we are extracting and concatenating the nonce and the data.
Also lets write an effective test for those functions:
#[allow(unused_imports)]
mod tests {
// Those lines appear to me as unused even though they are in use, my misconfiguration or a bug
use super::*;
use chacha20poly1305::aead::{rand_core::RngCore, OsRng};
#[test]
fn test_enc_dec() {
// Generate random 32-byte key
let mut rand_key = [0u8; 32];
OsRng.fill_bytes(&mut rand_key);
// Random data
let some_data = [0x42; 100];
let encrypted = encrypt(&rand_key, &some_data).unwrap();
let decrypted = decrypt(&rand_key, &encrypted).unwrap();
// Make sure we are not losing stuff on the way (it would be funny)
assert_eq!(decrypted, some_data.to_vec());
}
}
Very nice, Encryption — Done!
We have the store struct, hashing and encrypting, those are our building blocks for the next steps:
- Filesystem IO
- Actual password prompting
The save_store
and load_store
functions
At this point we will be implementing the filesystem IO.
Remember that hashing salt from before?
Well now you might wanna recall it.
The salt will have to be stored alongside our data, just like ChaCha’s nonce in the save_store
function.
And later the load_store
function will be extracting the salt and using it for hashing the password.
Just like we did with the nonce!
Remember our decryption process diagram?
In case you are worrying like I was at first:
Is storing the salt (or the nonce) in plaintext, alongside the data is secure !?
Don’t worry, its intended behavior.
The salt and nonce are public knowledge.
We will concatenate the salt to the data, so that our file structure will now look like this:
+----------------+-----------------+----------+
| salt (12 byte) | nonce (12 byte) | data.... |
+----------------+-----------------+----------+
Lets write it:
// /src/store.rs
use serde::{Deserialize, Serialize};
use bincode::{deserialize, serialize};
use chacha20poly1305::aead::{rand_core::RngCore, OsRng};
use std::{
fs::{self, File},
io::Write,
path::Path,
};
use crate::crypto;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Store {
pub name: String,
pub entries: Vec<Entry>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub name: String,
pub username: Option<String>,
pub password: Option<String>,
}
impl Store {
pub fn save_store(
&self,
store_path: &Path,
password: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Serializing
let packed_store = serialize(self)?;
// Generate 12 bytes salt
let mut salt = [0u8; 12];
OsRng.fill_bytes(&mut salt);
// Hash the password
let pwd_hash = crypto::argon2_hash(password.as_bytes(), &salt);
// Encrypt data
println!("Encrypting..");
let encrypted = crypto::encrypt(&pwd_hash, &packed_store).expect("Failed encrypting!");
// Prepend salt to data
let mut combined_data = salt.to_vec();
combined_data.extend(encrypted);
// Write to file
println!("Saving to '{}'..", store_path.to_str().unwrap());
File::create(store_path)
.unwrap()
.write_all(&combined_data)?;
Ok(())
}
pub fn load_store(store_path: &Path, password: &str) -> Self {
// Try to open file
let data = fs::read(store_path)
.unwrap_or_else(|err| panic!("Could not read file, with error: {}", err));
// Extract the 12 bytes salt
let salt: &[u8; 12] = data[..12]
.try_into()
.unwrap_or_else(|_| panic!("Invalid salt inside file"));
// Hash the password
let pwd_hash = crypto::argon2_hash(password.as_bytes(), salt);
// Decrypt data
let encrypted_data = &data[12..];
let decrypted =
crypto::decrypt(&pwd_hash, encrypted_data).expect("Failed decrypting, wrong key?");
// Parse store
deserialize(&decrypted).unwrap()
}
}
Notice how we have added new derives to the Store structs: #[derive(Debug, PartialEq, ...)]
This is for our equality check here on the unit test:
#[allow(unused_imports)]
mod tests {
use super::*;
use std::{fs::create_dir, path::PathBuf};
#[test]
fn test_save_load() {
let created_store = Store {
name: "testvault".to_string(),
entries: vec![Entry {
name: "testent".to_string(),
username: Some("sigma".to_string()),
password: None,
}],
};
// Change this on Windows..
let tmp_file = PathBuf::from("/tmp/testing-rusty-pm");
let arbitrary_pwd = "123";
created_store.save_store(&tmp_file, arbitrary_pwd).unwrap();
let loaded_store = Store::load_store(&tmp_file, arbitrary_pwd);
assert_eq!(created_store, loaded_store)
}
}
Great! Things are coming together :)
Now we are completely ready to finish writing our program and implement the last missing link in the chain:
The command functions in the main.rs
file
Let us begin by writing the Create
command.
Actually, first lets implement a generic user text prompt, we will be reusing this code in multiple places: get username, get password, get name, etc..
So when it comes to prompting the user for input we have only two cases:
- Either we want to prompt for some arbitrary parameter.
- Or we want to prompt for the store password (which is also an arbitrary parameter).
Lets write it like that:
// /src/main.rs
use std::io::{self, Write};
fn ask_param_if_none(param: Option<String>, prompt: &str, err_msg: &str) -> String {
param.unwrap_or_else(|| {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut param = String::new();
io::stdin().read_line(&mut param).expect(err_msg);
param.trim().to_string()
})
}
fn ask_password_basic() -> Option<String> {
// Ask for password
let password = ask_param_if_none(
None,
"Please enter a password:\n>>> ",
"Could not read line from stdin",
);
if password.is_empty() {
println!("Empty password. aborting.");
None
} else {
Some(password)
}
}
I have written this in my main.rs
but feel free to relocate this anywhere you want.
Nice!
Now we definitely have all that we need to write our command handlers in main
.
Argument command handlers logic
Starting off with our Create
:
This code goes inside the switching match
over the command arguments:
Commands::Create { store_name } => {
// TODO: Check if the store already exists, show message: Sure you wanna overwrite?
// If the store name is not given - Ask user for a name
let store_name = ask_param_if_none(
store_name,
"Please enter a name for your store:\n>>> ",
"Could not get store name!",
);
// Create initial store
println!("Creating store..");
let store = Store {
name: store_name.to_string(),
entries: Vec::new(),
};
// Ask user for key
let pwd = ask_password_basic().unwrap();
store.save_store(&args.store_path, &pwd).unwrap();
println!("Store created with name: {:?}!", store.name);
}
Make sure to add the missing imports:
use crate::store::{Entry, Store};
Ok now lets run it!
Looks very cool!
Lets continue writing our commands :)
The Open
command (Or list
if you wish):
Commands::Open { entry_name } => {
// Ask user for key
let pwd = ask_password_basic().unwrap();
let store = Store::load_store(&args.store_path, &pwd);
println!("Store opened: {:?}!", store.name);
if let Some(entry_name) = entry_name {
if let Some(entry) = store.entries.iter().find(|e| e.name == entry_name) {
println!(
r#"
>>> Entry:
-> Username: {:?}
-> Password: {:?}
"#,
entry.username.as_deref().unwrap_or(""),
entry.password.as_deref().unwrap_or(""),
)
} else {
println!("Entry {} not found!", entry_name);
}
} else {
// List all entries
println!("\n>>> Entries in store:");
store
.entries
.iter()
.for_each(|e| println!("-> {:?}", e.name));
println!("-----")
}
}
Note:
I used .as_deref()
on the entry.username
and entry.password
fields to pass by reference and avoid moving the username and password fields from the entry struct.
I’m still not completely comprehending why I cant move from the struct, the compiler said something like:
cannot move out of `entry.username` which is behind a shared reference
move occurs because `entry.username` has type `std::option::Option<std::string::String>`, which does not implement the `Copy` trait
I will be researching it further, to completely understand whats happening behind the scenes.
The Add
command:
Commands::Add { entry_name } => {
// Ask user for key
let pwd = ask_password_basic().unwrap();
let mut store = Store::load_store(&args.store_path, &pwd);
// If the entry name is not given - Ask user for a name
let entry_name = ask_param_if_none(
entry_name,
"Please enter a name for your entry:\n>>> ",
"Could not get entry name!",
);
// Check if exists
if store.entries.iter().any(|entry| entry.name == entry_name) {
println!("Entry named '{}' already exists!", entry_name);
return;
}
// Add a new entry
// Prompt for username and password
let username = ask_param_if_none(
None,
"Please enter a Username:\n>>> ",
"Could not read line from stdin",
);
let password = ask_param_if_none(
None,
"Please enter a Password:\n>>> ",
"Could not read line from stdin",
);
store.entries.push(Entry {
name: entry_name.to_string(),
username: if username.is_empty() {
None
} else {
Some(username.to_string())
},
password: if password.is_empty() {
None
} else {
Some(password.to_string())
},
});
store.save_store(&args.store_path, &pwd).unwrap();
println!("Added an entry to store: {:?}!", store.name);
}
And lastly, Remove
command:
Commands::Remove { entry_name } => {
// Ask user for key
let pwd = ask_password_basic().unwrap();
let mut store = Store::load_store(&args.store_path, &pwd);
// If the entry name is not given - Ask user for a name
let entry_name = ask_param_if_none(
entry_name,
"Please enter a name for your entry:\n>>> ",
"Could not get entry name!",
);
// Check if not exists
if let Some(entry_pos) = store
.entries
.iter()
.position(|entry| entry.name == entry_name)
{
store.entries.remove(entry_pos);
} else {
println!("Entry named '{}' does not exists!", entry_name);
return;
}
store.save_store(&args.store_path, &pwd).unwrap();
println!(
"Removed entry {:?} from store: {:?}!",
entry_name, store.name
);
}
And that concludes the code!
You may check it out in the GitHub repo !
Feel free to open issues and PRs if you wish.
Whats next?
I have a some ideas regarding which additional features I could add to this project, here are some:
- MFA (either a keyfile or FIDO)
- TUI (maybe use Ratatui)
- Clipboard integration to copy the password automatically
- Password generation
- Add non-encrypted file metadata to the store (maybe a magic value)
And more..
You could implement it if you’d like to practice Rust.
As for me, I will probably be starting some new project entirely…
Overall thoughts on the Rust language
I think that the Rust language is a huge success,
It’s designed with a lot of thought and expertise, you can notice how all of it’s core features are complimenting each-other.
The compiler makes life easy, and the community is enthusiastic and mindful.
Effectively making Rust a very pleasant language to work with.
Needless to say its exquisitely fast and secure, setting a very high bar for other languages, and redefining what is possible for programming language to be.
I could say that Rust’s presence today is only the beginning, In the coming years it will become a widespread, massively-used top-tier language, outperforming and surpassing by popularity most of the old top-tier system languages like: Java, C, C++, C# and possibly even Go (which is a strong competitor to rust).
Unfortunately there cannot be perfection, and when a language is so close to the low-level, it lacks higher-level abstractions, and as a result, writing high-level code becomes more time-consuming, often involving boilerplate code and consuming a plethora of crates.
I will be paying close attention to the development of both the language core itself and the surrounding community. Watching the impact it will have on the industry as a whole.
Wonder if it will ever meet my predictions of becoming a well-established highly-popular language.