Encryption for Go: AES-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305, RSA-OAEP, and an envelope-encryption package for data at rest.
go get github.com/pilinux/cryptRequires Go 1.26+. The only dependency is golang.org/x/crypto.
| Goal | Use |
|---|---|
| Encrypt with a key you already have | AES-256-GCM or XChaCha20-Poly1305 |
| Encrypt a lot of messages under one key | XChaCha20-Poly1305 |
| Let others encrypt data that only you can read | RSA-OAEP |
| Keep many records under one secret you can rotate | envelope |
| Encrypt files too big for memory, or hide their size | envelope streaming and padding |
// crypt does not derive keys. Here Argon2id turns a passphrase into a
// 32-byte key; keep the salt, you need it to derive the same key again.
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte("passphrase"), salt, 2, 64*1024, 2, 32)
ciphertext, err := crypt.EncryptAesGcmWithNonceAppended(key, "attack at dawn")
if err != nil {
return err
}
plaintext, err := crypt.DecryptAesGcmWithNonceAppended(key, ciphertext)
if err != nil {
return err // wrong key, or the ciphertext was changed
}Each cipher has the same four encrypt functions, plus the matching Decrypt
ones:
| Function | Data | Nonce |
|---|---|---|
Encrypt<Cipher> |
string |
returned separately |
EncryptByte<Cipher> |
[]byte |
returned separately |
Encrypt<Cipher>WithNonceAppended |
string |
stored at the start of the ciphertext |
EncryptByte<Cipher>WithNonceAppended |
[]byte |
stored at the start of the ciphertext |
<Cipher> is AesGcm, Chacha20poly1305 or XChacha20poly1305. The two
ChaCha ciphers also have EncryptByte...WithNonceAppendedAAD, which binds extra
data such as a record ID to the ciphertext without encrypting it.
enc := crypt.NewEncoder(publicKeyPEM)
if enc.Err != nil {
return enc.Err
}
ciphertext, err := enc.EncryptRSA("attack at dawn")
dec := crypt.NewDecoder(privateKeyPEM)
if dec.Err != nil {
return dec.Err
}
plaintext, err := dec.DecryptRSA(ciphertext)OAEP uses SHA-256 by default. For SHA-512, set enc.HashAlg = crypt.SHA512 and
the same on dec.
The public key has to be a PKIX PUBLIC KEY block and the private key a PKCS#8
PRIVATE KEY block. OpenSSL produces both public and private keys in the right
format:
openssl genpkey -algorithm RSA -out private-2048.pem -pkeyopt rsa_keygen_bits:2048
openssl pkey -in private-2048.pem -pubout -out public-2048.pem
openssl genpkey -algorithm RSA -out private-3072.pem -pkeyopt rsa_keygen_bits:3072
openssl pkey -in private-3072.pem -pubout -out public-3072.pem
openssl genpkey -algorithm RSA -out private-4096.pem -pkeyopt rsa_keygen_bits:4096
openssl pkey -in private-4096.pem -pubout -out public-4096.pemEncoder and Decoder also carry Base64 helpers (ToBase64Std,
ToBase64RawStd, ToBase64URL, ToBase64RawURL and the FromBase64*
counterparts). They don't use the key, so a zero Encoder{} or Decoder{}
works.
The envelope package is for apps that store a lot of
encrypted data. A secret from your environment wraps a random master key, and
every item is sealed under its own key derived from that master key. Rotating
the secret means re-wrapping one 32-byte key, not re-encrypting the database.
scheme := envelope.New(envelope.Config{
KEKLabel: "myapp:kek:v1", // don't change these once data exists
SubKeyLabel: "myapp:data-subkey:v1",
})
// Once: create a master key and store it wrapped.
kek, err := scheme.DeriveKEK(os.Getenv("ENCRYPTION_SECRET"))
masterKey, err := envelope.GenerateMasterKey()
wrapped, err := envelope.WrapKey(kek, masterKey) // save this, not masterKey
envelope.Zero(kek)
// Per item. The AAD ties a token to its row, so tokens can't be swapped.
aad := []byte("user:42:email")
token, err := scheme.SealStringAAD(masterKey, "alice@example.com", aad)
email, err := scheme.OpenStringAAD(masterKey, token, aad)ENCRYPTION_SECRET must be at least 32 random bytes (openssl rand -hex 32),
not a password. The KEK comes from HKDF, which does no stretching, so a weak
secret can be brute-forced from the wrapped key.
It also handles large data:
- Streaming.
SealFile,SealStreamandSealWriterencrypt in chunks (1 MiB by default) with constant memory, whatever the size. If chunks are reordered, repeated, dropped or cut off, the stream fails to open. - Length hiding.
SealPaddedFile,SealPaddedStreamandSealPaddedAtpad the data before sealing, so the ciphertext size no longer gives away the exact file size.
n, err := scheme.SealFile(masterKey, "backup.tar.enc", "backup.tar")
n, err = scheme.OpenFile(masterKey, "restored.tar", "backup.tar.enc")The envelope README covers the rest: which function to pick, the wire format, the errors, and what a ciphertext still reveals.
- AES takes a 16, 24 or 32-byte key. ChaCha20 and XChaCha20 take 32 bytes.
- AES-GCM and ChaCha20-Poly1305 use random 96-bit nonces, so keep the number of
messages per key well below 2^32. XChaCha20 and
envelopedon't have this problem. - A wrong key, changed ciphertext or malformed input (a nonce of the wrong length, say) returns an error instead of panicking or returning garbage.
- A single message is limited to about 256 GiB with ChaCha20 and 64 GiB with
AES-GCM. Use
envelopestreaming for anything bigger. - Don't use the output of a stream until the call that produced it returns
without error. A
StreamWriteris complete only afterClosesucceeds. - Ciphertext size reveals plaintext size. If that matters, use the padded
functions in
envelope.
Each folder under _example is a small program. Run one with
go run ./_example/<name>.
- aes, chacha20poly1305, xchacha20poly1305, rsa, hashing
- envelope: the envelope package end to end. With
-serve 127.0.0.1:8080it runs a small upload server instead, for trying streaming and padding on real files (-hlists the options).
go test -race -cover ./... # unit tests, race detector, coverage
go vet ./... # static analysis
golangci-lint run ./... # aggregate lintersMIT. See LICENSE.
