Recreating a Secret Santa App in Rust with WASM (Part 1)
I'm trying to learn Rust and WASM together and this seemed like an interesting project to pursue. This part covers the Rust portion of the project.
I've always been annoyed by how SecretSanta apps make you sign up and give them your email and other details. They do make it simple in some ways but more complex in others. Then I found this very cool example. Which uses an encrypted hash to hide details from a human observer but which can then be decrypted to give a person their SecretSanta. Obviously this isn't how encryption was intended to be used because the private key needs to be available to online application to decrypt the hash, but it serves its purpose and means the App doesn't need to know someone's email address or even store their name.
Perfect for a static website like this!
The full code for the Rust implementation is available in this repository.
Scoping
The JS implementation I wanted to emulate for this project had taken an approach of serving JS in HTML alongside styling. The codebase was compact but required a little deciphering to pull out the secret santa bits. The core of JS included three main parts; the instruction parser, the secret santa algorithm and the encryption/decryption of matches.
An important step in the scoping was choosing where to put the boundary between the Rust/WASM and the DOM rendering/styling. Given the objectives of learning Rust, I wanted to try and put as much of the secret santa logic into Rust as possible but decided to avoid DOM manipulations. As a side effect, the Rust implementation can also be used as a standalone library by other code.
Instruction Parser
Instructions for the Secret Santa are provided by the user as ASCII text. Each participant is labelled and then given a
set of restrictions or an enforced pairing. For example, if Ben
has no restrictions then the line is
Ben
Labels can also be complex
Ben (the older one)
If Tom
cannot get Ben
for secret santa then the blocked pairing label is prefixed by a !
and the instruction is
Tom !Ben
It is possible to give multiple blocked pairings as well
Tom !Ben !Ben (the older one)
And we can force a match for one person to another using a =
prefix.
Tom =Jerry
Finally all text after a #
is ignored as a comment.
struct Participant
Each instruction about a participant represents the minimal amount of information needed by the secret santa algorithm. To capture each instruction I implemented a struct capturing the Participant name or label, the pairing as an option and the blocklist as an optional HashSet.
#[derive(Default, Debug, Eq, Clone)]
pub struct Participant {
pub name: String,
pub paired_with: Option<String>,
pub blocklist: Option<HashSet<String>>,
}
If an enforced pairing (the =
prefix) or a blocked pairing (the !
prefix) are not supplied with an instruction then
we cannot set these values in the struct, hence they are kept as options. The HastSet
is useful, because if a blocked
name is supplied twice it will only be added to the list once. HashSets enforce uniqueness of values in the set.
There will be some traits required for Participant
to support the secret santa algorithm but these will be introduced
in that section. For now, Participant
just needs a constructor.
impl Participant {
pub fn new(name: String) -> Self {
Participant {
name,
blocklist: None, // Default to None
paired_with: None, // Default to None
}
}
}
Instruction parsing
Instruction parsing can be broken down into a number of sequential steps.
- Remote comments from and white space around each line.
- Get the participant label.
- Get any blocked labels for 2.
- Get and enforced match for 2.
- Return a
Participant
with parsed values.
I approached this problem in a functional way using a mixture of Options and Results in Rust to handle errors. Line
components are extracted or removed using the regex crate. The primary method I used to extract text from each line
utilised the capture
method for a compiled regex. I found the regex crate somewhat more difficult to
use then similar libraries in other languages. A Regex
instance has many methods that might be used for extraction and
they all return different structs. The key seemed to be to understand what traits the structs implemented so that the
captured text could be extracted.
The function get_instruction
which trims comments and whitespace is an example of this. re.captures
returns an iterable
struct over the groups identified by the Regex. The iterable, which can only be of length one still needs to be post-processed
through the map
and extract
operations to get the necessary data.
fn get_instruction(instruction: &str) -> Option<&str> {
let re = Regex::new(r"(^[^#]+)").unwrap();
let Some((_, [inst])) = re.captures(&instruction).map(|cap| cap.extract()) else {
return None;
};
Some(inst.trim())
}
The same process is repeated in subsequent regex extraction functions to get the participant name and rules.
Secret Santa algorithm
Algorithm
There are a few considerations when building the secret santa algorithm.
- We want people to be assigned randomly to other people.
- We need to observe the rules for all participants.
- Sometimes the rules might be too restrictive in that there aren't enough participants who a person can be matched with.
My approach to this can be broken down into the following steps.
- Create two sets of all names.
set_a
for givers andset_b
for receivers. - Check for any forced pairings and remove the giver from
set_a
and the receiver fromset_b
. - Recursively generate one giver and receiver pair by:
- Sort
set_a
by the number of possible matches remaining inset_b
accounting for rules. - If
set_a
is empty everyone has been assigned, return OK. - If the first participant has no possible matches, return error.
- If the first participant in
set_a
only has 1 possible match, make them a pair and updateset_a
andset_b
then recurse. - If the first participant has two or more possible matches, choose one at random, update
set_a
andset_b
then recurse.
- Sort
The sorting step is important because it gives the algorithm the best chance of finding a match for all people. We want to ensure that participants with the fewest match options are prioritised when assigning the matches.
Participant Traits
The development of the algorithm in Rust got slightly tricky when trying to cover different functionality. There are strong
limitations around certain structs (like HashSet
) which needed some trial and error to understand. The HashSet
type is
important in the algorithm because it allows the easy adding and removal of names from set_a
and set_b
. I also use a
HashSet<Participant>
in a SecretSanta
struct to hold the participants created during the parsing of the Instructions
and while generating pairings.
Firstly, there are a few traits that can be implemented on Participant
to make referencing them in a HashSet
easier.
This requires creating methods that facilitate comparisons between String
types and Participants. The trait needed is
PartialEq
for String
. This directly compares a String
to the name
value of the Participant struct.
impl PartialEq<Participant> for String {
fn eq(&self, other: &Participant) -> bool {
self == &other.name
}
}
The reciprocal can also be implemented allowing bidirectional comparisons.
impl PartialEq<String> for Participant {
fn eq(&self, other: &String) -> bool {
&self.name == other
}
}
To make Participant hashable, the Hash
trait should also be implemented. Here, the name is the important
value in the struct and will form the sole basis of the hash - ignoring the other values.
impl Hash for Participant {
// Only need name in hash as it is the unique bit
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
The String
comparisons and the Hash
trait facilitate methods in SecretSanta
like get_name
and contains
where
Participant
instances in a HashSet
can be referenced with just a Participant
instance that has the name only.
pub struct SecretSanta {
participants: HashSet<Participant>,
}
impl SecretSanta {
/// SecretSanta contains recipient with name
pub fn contains(&self, name: &str) -> bool {
self.participants
.contains(&Participant::new(name.to_string()))
}
/// SecretSanta get participant by name
pub fn get_name(&self, name: &str) -> Option<&Participant> {
let p = Participant::new(name.to_string());
let part = self.participants.get(&p)?;
Some(part)
}
}
HashSet
limitations
After some experimentation in the algorithm it became clear that HashSet
have issues around ordering.
Specifically, the Rust implementation does not guarantee the set order under iteration which means
sorting and indexing into the set are not possible.
The sorting issue was overcome by using a BTreeMap
, an alternative to HashMap
. The BTreeMap
does
support ordering, so it was used when calculating the number of possible matches for givers in set_a
.
find_matches
is a method of Participant
that looks for valid matches in the argument given the blocklist
rules. The insertion into the tree_a
instance automatically sorts based on the key.
let mut tree_a = BTreeMap::new();
for p in set_a.iter() {
let Some(part) = self.get_name(p) else {
return Err(SecretSantaError::new("Instructions issue".to_string()));
};
let n_matches = part.find_matches(&set_b).len();
tree_a.insert(n_matches, part);
}
Indexing into the HashSet
for selecting a random name in cases where more than one recipient are possible choices also
requires an ordered collection. The simplest solution was to transform the matches
HashSet
into a Vec<&String>
which
contains ordered pointers to the names in matches
. Then a random index can be chosen.
let matches = part.find_matches(&set_b);
let vec_matches: Vec<&String> = matches.iter().collect();
let pairing = vec_matches
.choose(&mut thread_rng())
.cloned()
.expect("Something went wrong!");
The full algorithm is available in secretsanta.rs
.
Encryption / Decryption
I used the RustCrypto crate collection for encryption in Rust. The documentation is a little patchy and the broad use of traits can make understanding how the libraries fit together a little difficult. There are some limited examples for some of the crates but they aren't always direct copy and paste.
Similar to the original implementation I went for AES (aes-gcm-siv
) and 256 byte key. AES is a symmetric encryption
algorithm which means the same key is used on decryption. Although other security measures are needed like verification of
inputs in typical encryption processes, the objective here is only to obfuscate the paired name for use in a URL query string.
Encryption
The encryption needs three arguments, the message, a suitable key and a nonce or initialisation vector. Usually the key would be kept secret but we will provide all the information necessary to decrypt the message to the user.
The key and nonce are generated with the generate_nonce
and generate_key
functions. Note that Key
, OsRng
and others
come from a common aead
crate while Aes256GcmSiv
is implementations of traits associated with these base structs.
use aes_gcm_siv::{
aead::{generic_array::GenericArray, Aead, Key, KeyInit, OsRng},
Aes256GcmSiv, // Or `Aes128GcmSiv`
Nonce,
};
use rand::RngCore;
/// Generate a random nonce with 96bit fixed size.
pub fn generate_nonce() -> Nonce {
let mut rand = [0u8; 12];
OsRng.fill_bytes(&mut rand);
let nonce = Nonce::from_iter(rand);
nonce
}
// Generate a 256bit key
pub fn generate_key() -> Key<Aes256GcmSiv> {
let key = Aes256GcmSiv::generate_key(&mut OsRng);
key
}
A message can then be encrypted using the key and nonce.
pub fn encrypt(msg: &str, key: &Key<Aes256GcmSiv>, nonce: &Nonce) -> Vec<u8> {
let cipher = Aes256GcmSiv::new(key);
let ciphertext = cipher.encrypt(nonce, msg.as_bytes()).unwrap();
ciphertext
}
The returned key, nonce and ciphertext are bit representations that are not compatible with ASCII or UTF-8 characters.
This means it isn't directly usable in a URL query which we will need for the frontend. Instead, the bits must be
encoded into text. This can be done using the base64ct
crate. Other bases are also available. The URL safe encoding
used here is helpful because it does not require an alphabet to be supplied.
fn encrypt_secret_santa(paired_with: &str) -> EncryptedSecretSanta {
let key = crypto::generate_key();
let nonce = crypto::generate_nonce();
let ciphertext = crypto::encrypt(paired_with, &key, &nonce);
let enc_ss = EncryptedSecretSanta {
key: Base64Url::encode_string(&key),
nonce: Base64Url::encode_string(&nonce),
pairing: Base64Url::encode_string(&ciphertext),
};
enc_ss
}
Decryption
Decryption occurs in the reverse order. First the encoded strings provided by the user are decoded. the #[wasm_bindgen(catch)]
is needed because this function will be exposed to the frontend.
fn decode_vec(input: &str) -> Result<Vec<u8>, SecretSantaError> {
match Base64Url::decode_vec(input) {
Ok(v) => Ok(v),
Err(_) => Err(SecretSantaError::new(
"Could not decode secrets".to_string(),
)),
}
}
#[wasm_bindgen(catch)]
pub fn decrypt_secret_santa(
key: &str,
nonce: &str,
ciphertext: &str,
) -> Result<String, SecretSantaError> {
let dc_key = decode_vec(key)?;
let dc_nonce = decode_vec(nonce)?;
let dc_ct = decode_vec(ciphertext)?;
let name = crypto::decrypt(&dc_ct, &dc_key, &dc_nonce);
Ok(name)
}
Examples of decryption from previously created keys were difficult to find, but the bit representations of the key and nonce supplied by the user can be used to decrypt the ciphertext.
pub fn decrypt(ciphertext: &Vec<u8>, key: &Vec<u8>, nonce: &Vec<u8>) -> String {
let cipher = Aes256GcmSiv::new_from_slice(&key).expect("Key was incorrect");
let nonce_ga: GenericArray<u8, _> = GenericArray::clone_from_slice(nonce);
// let cipher = Aes256GcmSiv::new(key);
let msg = cipher.decrypt(&nonce_ga, ciphertext.as_ref()).unwrap();
String::from_utf8(msg).unwrap()
}
Generate secrets
The final step was to combine the parsing of the instructions, the pairing and the encryption into an accessible interface
for the frontend. A little trickery was needed with serde
to serialise the HashMap
used for the secrets into a JS
compatible type - an Object of nested Objects in this case.
The catch
property for wasm_bindgen
allows for gracious handling of the Result
with errors catchable within JS.
#[wasm_bindgen(catch)]
pub fn get_secret_santas(instructions: String) -> Result<JsValue, SecretSantaError> {
let mut secret_santa = SecretSanta::new();
// loop all lines
for i in instructions.trim().split('\n') {
secret_santa.add_instruction(&i)?;
}
secret_santa.generate_pairings()?;
let pairings = secret_santa.get_pairings();
let enc_pairings: HashMap<String, EncryptedSecretSanta> = pairings
.iter()
.map(|(k, v)| (k.clone(), encrypt_secret_santa(v)))
.collect();
match serde_wasm_bindgen::to_value(&enc_pairings) {
Ok(v) => return Ok(v),
Err(_) => return Err(SecretSantaError::new("Serialisation error".to_string())),
}
}
Notes & Issues
Rust crates
Rust has a pretty small standard library which means there has been a lot of crate proliferation over the years. When searching for a crate or set of crates to use it can be difficult to know what exactly to use. Often you'll be guided by examples or when some crates have good documentation. Some crates or sets of crates seem to becoming the standard like RustCrypto but there is no firm endorsements either way from core Rust.
Rust and WASM
Initially I had worked from the Rust WASM tutorial which was structured in a way that it exported most of its functions to JS. I found that complex Rust structs/types didn't play nicely with WASM. Introducing non-standard structs and types to JS requires a lot of overhead to manage serialisation and deserialisation using serde.
Instead I opted to limit the interface between Rust and WASM/JS by keeping much of the internals Rust only. I then offered a limited interface to JS through a couple of key functions. This meant moving everything into Rust (apart from frontend) into Rust and it worked out in the end.
rstest
crate
I discovered the rstest
crate which was a good attempt to create Pytest like functionality in Rust testing. I wouldn't
say its as convenient but it makes things a bit easier. Especially repetitive fixture set up.