Ok, so this is going to be a longer article. Today I'll cover how to think about hashing, signing, encryption, salting, and as a sort of side note, why you shouldn't get NordVPN.
So chances are you've heard stuff like "This service is end to end encrypted", "Military grade encryption", "We never share your data", etc, and maybe if you're into privacy and security you've heard some of the terms that I mentioned a bit ago, like 'hashing', 'encryption', etc. Anyways, I'll discuss what those are now.
Hashing takes any string or data and converts it into a uniform output. There are several different algorithms for hashing, maybe you've heard of SHA-256, PBKDF2, etc, anyways this is a basic example of how hashing works:
Let's look at some examples of what hashes into what:
"This is a string" ⇾ '745b3710658912b0a2c7505547f517558ca95b790c8fb260491885e4b17a559d'
[Insert markdown of article currently] ⇾ 'a4ff8e63fd0ed435f72c18a3ea65afd25d05ea3f0c16ac7a4895d660565aad17'
"a" ⇾ 'da3811154d59c4267077ddd8bb768fa9b06399c486e1fc00485116b57c9872f5'
As you can see, the length of the string as an input doesn't matter. This is very important, why? Because it means you can't undo a hash. Let's make a really simple hash function in JavaScript:
function badHashFunction(string) {
return (
string
//Split the string into a list
.split("")
//Get character code for each letter ("a" = 97, "b" = 98, etc)
.map((letter) => letter.charCodeAt(0))
//Add all the character codes up
.reduce((acc, current) => acc + current)
);
}
Now we get this type of output:
hash("test") ⇾ 448
hash("askjldlaksdjlkaskjdkjlaskjlflkjlkjdslkj") ⇾ 4140
ok, but two things can equal the same output. That's the important behavior of a hash:
hash("test") ⇾ 448
hash("etst") ⇾ 448
hash("tfss") ⇾ 448
When two strings equal the same thing hashed, this is called a collision, in most secure hash algorithms collisions are highly unlikely, but the possibility of a collision makes hashes unreverseable.
When you're storing a password in a database, like when you make a great site where users can sign up and login, you always need to hash the passwords. This still allows you to compare them though. Let's see how that works:
//Still unsafe, we'll cover why in a sec
function userSignedUp(username, password) {
storeInDB(username, hash(password));
}
Then when that user logs into your app, you check their password like this:
function seeIfUserIsAuthenticated(username, password) {
let hashedPasswordFromDB = getDBItem(username, "password");
//Using === is also unsafe, we'll cover that in a bit too
return hash(password) === hashedPasswordFromDB;
}
Now we'll see why these examples I saw are unsafe. Say an attacker finds your database,they can easily simply search for common passwords. Let's say your database looks like this:
[
{
"username": "explosion",
"password": "b88280a8f8ef0ac50042315463a1358292a78e0dfdea0606055d19301bb95284"
},
{
"username": "alice",
"password": "b88280a8f8ef0ac50042315463a1358292a78e0dfdea0606055d19301bb95284"
},
{
"username": "bob",
"password": "0dbf385d0b592de47ae3fb65f2f842262750a200d183ade1f2384a6613f4fc7e"
}
]
There are a couple of things we can notice right off the bat, #1: explosion
, and alice
, have the same password hash, this means they used the same password! OH NO! #2, an attacker could easily figure out many many of these passwords, like this:
//Hashes of "abc", and "12345"
let listOfCommonPasswordHashes = ["b88280a8f8ef0ac50042315463a1358292a78e0dfdea0606055d19301bb95284", "0dbf385d0b592de47ae3fb65f2f842262750a200d183ade1f2384a6613f4fc7e"];
for (let commonPasswordHash of listOfCommonPasswords){
if (db.find(i => i.password === commonPasswordHash){
console.log(`Someone has a common password!!!`);
}
}
So this means no users can use a common password, right? Otherwise hacker will be able to crack it. Nope! The solution to this is salt.
Hashes take computational energy on purpose, this is why older hashing algorithms like SHA-1 are insecure. They can be generated super quickly. This is bad because it means a hacker can generate a list of the hashes of the top 200,000 passwords and just see who on your site has those passwords.
The solution to this is to add salt to those hashes, that means just adding some random characters before storing it in the database, so now our user sign up looks like this:
function userSignedUp(username, password) {
let salt = generateRandomSalt(); //This should return something random, like "ljk138flj2389!&*#%(@^LH"
//Notice how we also store the salt
storeInDB(username, hash(password + salt), salt);
}
Now our database looks like this:
[
{
"username": "explosion",
"salt": "ljk138flj2389!&*#%(@^LH",
"password": "f43957427c3216bd4ddf287c439c058521fd6ab65bb00be5b099aa7ed2a2358c"
}
]
Yay! Now that hashed password does not match the hash of a common password, and instead, the attacker has to spend lots of time computing the hash of every common password for each user. This is also why choosing a good hashing algorithm is important.
Characteristics of a good hashing algorithm:
Ending note for hashes:
You may have noticed my comment further back:
// Using === is also unsafe, we'll cover that in a bit too
This type of attack is called a timing attack, let's say that we do some sort of thing like this:
function randomRequestToServer(){
let startTime = performance.now();
await fetchServerForRandomData();
let timeSpent = performance.now() - startTime;
store(timeSpent);
}
function attemptToLogin(username, password){
//Send stuff to server, you know
}
//Now we can attempt to figure out the password like this:
attemptToLogin(username, "random password we're testing");
randomRequestToServer();
Now if we do this a whole lot and keep track of the time that randomRequestToServer took you could figure out how close a tested password is to the real one. Because it takes longer to compare similar strings than non-similar strings.
This means that it's best to use something that provides a timing safe equals function, like Node.js's crypto.timingSafeEqual(a, b)
.
Now let's move on to signing data. This takes someone's private key, signs some data with it, then you send the data, and the signature along with that data. To verify if that data has been signed, you use the public key of the user who signed it and the signature. A nice diagram is as follows:
What do you mean by public key and private key? Public keys and private keys are generated using an algorithm called RSA. It's super cool because it allows you to encrypt something to someone else specifically without anyone along the way being able to decrypt it. This is how my file sharing service OnDrop works.
Basically here's an example:
function decryptData(data, privateKey) {
//More real code
return decrypted;
}
function encryptData(data, publicKey) {
//Some real code here
return encryptedData;
}
function generateKeyPair() {
//Actual code here
return {
publicKey,
privateKey,
};
}
You can do all of this with the Web Crypto API! Anyways, as you can see from my horrible example with no real code whatsoever, you can only decrypt the data with a private key, but you can encrypt data to anyone using their public key. That means you can share public keys whereever, and keep private keys safe.
Back to signing Signing also uses a public key and private key. The private key is used to sign the data. The public key is used to verify that the data was signed by it. This means that nobody else can sign data as somebody else, and other people can check if data was signed by a specific person.
Encryption takes some data, and a key, and encrypts it. This can then be decrypted using that same key, or in the case of RSA, data is encrypted with the public key, and decrypted with the private key.
The major algorithm used for encryption is called AES, and it's also avalible in the web crypto API!!
I've got to be honest, I still find this a bit confusing =| Anyways, here are some basic functions that I like to use:
const buff_to_base64 = (buff) => btoa(String.fromCharCode.apply(null, buff));
const base64_to_buf = (b64) =>
Uint8Array.from(atob(b64), (c) => c.charCodeAt(null));
const enc = new TextEncoder();
const dec = new TextDecoder();
const getPasswordKey = (password) =>
window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, [
"deriveKey",
]);
const deriveKey = (passwordKey, salt, keyUsage) =>
window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 250000,
hash: "SHA-256",
},
passwordKey,
{
name: "AES-GCM",
length: 256,
},
false,
keyUsage
);
export async function encrypt(secretData, password) {
try {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const passwordKey = await getPasswordKey(password);
const aesKey = await deriveKey(passwordKey, salt, ["encrypt"]);
const encryptedContent = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
enc.encode(secretData)
);
const encryptedContentArr = new Uint8Array(encryptedContent);
let buff = new Uint8Array(
salt.byteLength + iv.byteLength + encryptedContentArr.byteLength
);
buff.set(salt, 0);
buff.set(iv, salt.byteLength);
buff.set(encryptedContentArr, salt.byteLength + iv.byteLength);
const base64Buff = buff_to_base64(buff);
return base64Buff;
} catch (e) {
console.log(`Error - ${e}`);
return "";
}
}
export async function decrypt(encryptedData, password) {
const encryptedDataBuff = base64_to_buf(encryptedData);
const salt = encryptedDataBuff.slice(0, 16);
const iv = encryptedDataBuff.slice(16, 16 + 12);
const data = encryptedDataBuff.slice(16 + 12);
const passwordKey = await getPasswordKey(password);
const aesKey = await deriveKey(passwordKey, salt, ["decrypt"]);
const decryptedContent = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
data
);
return dec.decode(decryptedContent);
}
export async function hash(str, iterations = 1000) {
const buf = await crypto.subtle.digest(
"SHA-256",
new TextEncoder("utf-8").encode(str)
);
let result = Array.prototype.map
.call(new Uint8Array(buf), (x) => ("00" + x.toString(16)).slice(-2))
.join("");
if (iterations === 0) {
return result;
} else {
return await hash(result, iterations - 1);
}
}
This nifty little file lets you do stuff like this:
let { hash, encrypt, decrypt } = await import("./crypto.js");
//you might notice the 2000, that's 2000 iterations, that means that it'll take longer to compute, this means that it would be much harder to brute force.
hash("random string", 2000).then(console.log);
//Or you can encrypt:
encrypt("string", "super secret password").then((encrypted) => {
console.log(`Encrypted to %o`, encrypted);
decrypt(encrypted, "super secret password").then((decrypted) => {
console.log(`Decrypted to %o`, decrypted);
console.log(`Does decrypted equal original: %o`, decrypted === "string");
});
});
Also you may have noticed new TextEncoder()
and new TextDecoder()
. That's because the web crypto API takes in a buffer, Uint8Array, and other stuff instead of strings. This also allows you to encrypt files!
Anyways, the web crypto API also has support for RSA, OAEP, and many more algorithms, it's totally worth checking out some real documentation on that!
You've probably heard "NordVPN has military grade encryption" and stuff like that, sure, but so does our web browser. Let's explain some of that.
Military-grade encryption:
What can your ISP or network actually view?
What VPNS are useful for
The cons of VPNS:
The solution OpenVPN to the rescue! Host your own VPN! Don't worry about data stealing, etc!
This was a fun article to write, I hope you learned something! If you did make sure to follow me on Github!