Crypto recipe
Password-based encryption
Encrypt data with a passphrase: stretch it into a key with Argon2id, then seal it with AES-256-GCM.
You have a passphrase, not a key — a user's password, a CLI flag, a backup phrase — and you want to encrypt something with it. You can't hand a passphrase straight to AES: it is low-entropy and the wrong size. Stretch it into a real key with a slow password KDF first, then encrypt with that key.
The recipe: pick a random salt, run Argon2id over (passphrase, salt) to derive a 32-byte key, then encrypt with AES-256-GCM under a fresh random nonce. Store the salt and nonce next to the ciphertext — both are public — and on decryption re-derive the same key from the passphrase and the stored salt. Argon2id makes guessing the passphrase expensive; AES-GCM gives you confidentiality plus tamper detection.
Get it right
- Derive the key with a slow password hash (Argon2id, scrypt, or PBKDF2) — never use the passphrase, or a plain SHA-256 of it, as the key.
- Use a fresh random salt per encryption and store it with the ciphertext; the salt is not secret.
- Use a fresh random nonce per message, and never reuse a nonce with the same derived key — that breaks GCM.
- Tune the KDF cost (memory + iterations) to the slowest you can tolerate, ~hundreds of milliseconds.
- This is for data at rest behind a human passphrase. For a high-entropy secret use HKDF; for two parties use X25519.
Implementation
Setup Standard library crypto/aes/crypto/cipher + golang.org/x/crypto/argon2 for the KDF.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"golang.org/x/crypto/argon2"
)
func main() {
password := []byte("correct horse battery staple")
// Store the salt (16 random bytes) with the ciphertext — you need it to
// re-derive the same key when decrypting. The salt is not secret.
salt := make([]byte, 16)
rand.Read(salt)
// Argon2id: 1 pass, 64 MiB, 4 threads -> a 32-byte AES-256 key. Tune the
// cost to ~hundreds of ms on your hardware.
key := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
ciphertext := gcm.Seal(nonce, nonce, []byte("attack at dawn"), nil) // nonce || sealed
// Decrypt: re-derive the key from the password + stored salt.
dk := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
db, _ := aes.NewCipher(dk)
dgcm, _ := cipher.NewGCM(db)
n := dgcm.NonceSize()
plaintext, err := dgcm.Open(nil, ciphertext[:n], ciphertext[n:], nil)
if err != nil {
panic("wrong password or tampered data")
}
fmt.Println(string(plaintext))
} Use golang.org/x/crypto/argon2 directly for raw key bytes — the PHC-string wrappers are for password storage, not key derivation.
Setup pip install argon2-cffi cryptography.
import os
from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
password = b"correct horse battery staple"
salt = os.urandom(16) # store with the ciphertext; not secret
# Argon2id -> 32-byte AES-256 key. Tune time_cost / memory_cost to ~hundreds of ms.
def derive(salt):
return hash_secret_raw(password, salt, time_cost=3, memory_cost=64 * 1024,
parallelism=4, hash_len=32, type=Type.ID)
nonce = os.urandom(12) # fresh per message
ciphertext = AESGCM(derive(salt)).encrypt(nonce, b"attack at dawn", None)
# Decrypt: re-derive the key from the password + stored salt.
plaintext = AESGCM(derive(salt)).decrypt(nonce, ciphertext, None) Setup npm install argon2 for the KDF; AES-GCM is built into node:crypto.
import argon2 from 'argon2';
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
const password = 'correct horse battery staple';
const salt = randomBytes(16); // store with the ciphertext; not secret
// Argon2id -> 32-byte AES-256 key. raw:true returns the derived bytes, not a PHC string.
const opts = { type: argon2.argon2id, raw: true, salt, hashLength: 32,
timeCost: 3, memoryCost: 64 * 1024, parallelism: 4 };
const key = await argon2.hash(password, opts);
const nonce = randomBytes(12); // fresh per message
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const ct = Buffer.concat([cipher.update('attack at dawn', 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); // store salt, nonce, tag and ct together
// Decrypt: re-derive the key from the password + stored salt.
const key2 = await argon2.hash(password, opts);
const decipher = createDecipheriv('aes-256-gcm', key2, nonce);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
console.log(plaintext.toString()); Top-level await works in an ES module. In the browser, derive with WebCrypto's PBKDF2 (crypto.subtle) — it has no Argon2.
Setup dotnet add package Konscious.Security.Cryptography.Argon2; AesGcm is built in.
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
byte[] password = Encoding.UTF8.GetBytes("correct horse battery staple");
byte[] salt = RandomNumberGenerator.GetBytes(16); // store with the ciphertext; not secret
// Argon2id -> 32-byte AES-256 key. Tune Iterations / MemorySize to ~hundreds of ms.
static byte[] Derive(byte[] pw, byte[] salt) {
using var kdf = new Argon2id(pw) {
Salt = salt, DegreeOfParallelism = 4, MemorySize = 64 * 1024, Iterations = 3,
};
return kdf.GetBytes(32);
}
byte[] nonce = RandomNumberGenerator.GetBytes(12); // fresh per message
byte[] plaintext = Encoding.UTF8.GetBytes("attack at dawn");
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(Derive(password, salt), tag.Length))
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Decrypt: re-derive the key from the password + stored salt.
byte[] decrypted = new byte[ciphertext.Length];
using (var aes = new AesGcm(Derive(password, salt), tag.Length))
aes.Decrypt(nonce, ciphertext, tag, decrypted); Isopoh (used in the password-hashing guide) can also derive raw bytes; Konscious has the simplest raw-KDF API. Store salt, nonce, tag and ciphertext together.
Setup libsodium (crypto_pwhash is Argon2id; AES-GCM needs AES-NI).
#include <sodium.h>
#include <cstring>
#include <string>
#include <vector>
// Argon2id -> 32-byte AES-256 key. INTERACTIVE limits suit a login; use
// _MODERATE / _SENSITIVE for higher-value data.
static void derive(unsigned char key[crypto_aead_aes256gcm_KEYBYTES],
const char *pw, const unsigned char *salt) {
if (crypto_pwhash(key, crypto_aead_aes256gcm_KEYBYTES, pw, std::strlen(pw), salt,
crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13) != 0) {
throw std::bad_alloc(); // out of memory
}
}
int main() {
if (sodium_init() < 0) return 1;
if (crypto_aead_aes256gcm_is_available() == 0) return 1; // needs AES-NI
const char *password = "correct horse battery staple";
std::string msg = "attack at dawn";
unsigned char salt[crypto_pwhash_SALTBYTES]; // 16; store with the ciphertext
randombytes_buf(salt, sizeof salt);
unsigned char key[crypto_aead_aes256gcm_KEYBYTES];
derive(key, password, salt);
unsigned char nonce[crypto_aead_aes256gcm_NPUBBYTES]; // 12, fresh per message
randombytes_buf(nonce, sizeof nonce);
std::vector<unsigned char> ct(msg.size() + crypto_aead_aes256gcm_ABYTES);
unsigned long long ct_len;
crypto_aead_aes256gcm_encrypt(ct.data(), &ct_len,
reinterpret_cast<const unsigned char *>(msg.data()), msg.size(),
nullptr, 0, nullptr, nonce, key);
// Decrypt: re-derive the key from the password + stored salt.
unsigned char key2[crypto_aead_aes256gcm_KEYBYTES];
derive(key2, password, salt);
std::vector<unsigned char> pt(msg.size());
unsigned long long pt_len;
return crypto_aead_aes256gcm_decrypt(pt.data(), &pt_len, nullptr,
ct.data(), ct_len, nullptr, 0, nonce, key2); // 0 on success
} For portability without AES-NI, swap in crypto_aead_xchacha20poly1305_ietf — same crypto_pwhash key.
Setup de.mkammerer:argon2-jvm for the KDF; AES-GCM is built into JCA.
import de.mkammerer.argon2.Argon2Advanced;
import de.mkammerer.argon2.Argon2Factory;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
char[] password = "correct horse battery staple".toCharArray();
Argon2Advanced argon2 = Argon2Factory.createAdvanced(Argon2Factory.Argon2Types.ARGON2id);
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt); // store with the ciphertext; not secret
// Argon2id -> 32-byte AES-256 key (the Advanced factory's default hash length).
byte[] keyBytes = argon2.rawHash(3, 64 * 1024, 4, password, salt);
SecretKeySpec key = new SecretKeySpec(keyBytes, "AES");
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce); // fresh per message
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
byte[] ciphertext = cipher.doFinal("attack at dawn".getBytes());
// Decrypt: re-derive the key from the password + stored salt.
SecretKeySpec key2 = new SecretKeySpec(argon2.rawHash(3, 64 * 1024, 4, password, salt), "AES");
Cipher dec = Cipher.getInstance("AES/GCM/NoPadding");
dec.init(Cipher.DECRYPT_MODE, key2, new GCMParameterSpec(128, nonce));
byte[] plaintext = dec.doFinal(ciphertext); Setup Cargo.toml: argon2 = "0.5", aes-gcm = "0.10", rand = "0.8".
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use argon2::Argon2;
use rand::{rngs::OsRng, RngCore};
fn main() {
let password = b"correct horse battery staple";
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt); // store with the ciphertext; not secret
// Argon2id (default params) -> 32-byte AES-256 key.
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password, &salt, &mut key)
.expect("argon2");
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes); // fresh per message
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = Aes256Gcm::new_from_slice(&key)
.unwrap()
.encrypt(nonce, b"attack at dawn".as_ref())
.expect("encrypt");
// Decrypt: re-derive the key from the password + stored salt.
let mut key2 = [0u8; 32];
Argon2::default().hash_password_into(password, &salt, &mut key2).expect("argon2");
let plaintext = Aes256Gcm::new_from_slice(&key2)
.unwrap()
.decrypt(nonce, ciphertext.as_ref())
.expect("wrong password or tampered data");
assert_eq!(&plaintext, b"attack at dawn");
} hash_password_into writes raw key bytes; the PHC hash_password API is for password storage.