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.

Go
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.

Python
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.

Node.js
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.

.NET
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).

C++
#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.

Java
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".

Rust
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.