Batteries-included encryption for Go: AEAD ciphers, RSA, and a ready-made envelope-encryption scheme, all behind a small, hard-to-misuse API.
crypt wraps Go's standard crypto packages and golang.org/x/crypto so you
can encrypt and decrypt data with well-established primitives without writing
the fiddly plumbing yourself. Every function authenticates its output, generates
nonces for you, and returns plain []byte / string values.
The whole library on one page: pick a path at the top, find the package and file it lives in, then the one naming pattern the ciphers share.
- AES-GCM: AES-128/192/256 authenticated encryption.
- ChaCha20-Poly1305: fast AEAD with a 96-bit nonce.
- XChaCha20-Poly1305: AEAD with a 192-bit nonce, safe for huge numbers of messages under one key.
- RSA-OAEP: public-key encryption with SHA-256 (default) or SHA-512.
- Base64 helpers: Std, RawStd, URL and RawURL encoders/decoders.
envelopesubpackage: a complete KEK/DEK envelope-encryption scheme for protecting many records under a single rotatable secret.- Streaming: chunked XChaCha20-Poly1305 for files that do not fit in memory, with constant memory use and no size ceiling.
- Length hiding: pad a file before sealing so its size stops identifying it.
go get github.com/pilinux/cryptRequires Go 1.25+. The only external dependency is golang.org/x/crypto.
package main
import (
"crypto/rand"
"fmt"
"golang.org/x/crypto/argon2"
"github.com/pilinux/crypt"
)
func main() {
// 1. Derive a 32-byte key. crypt never derives keys for you;
// bring your own KDF (here: Argon2id over a passphrase).
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
panic(err)
}
key := argon2.IDKey([]byte("s3cr3t-passphrase"), salt, 2, 64*1024, 2, 32)
// 2. Encrypt. A random nonce is generated and prepended to the
// ciphertext, so you only ever store a single blob.
ciphertext, err := crypt.EncryptAesGcmWithNonceAppended(key, "attack at dawn")
if err != nil {
panic(err)
}
// 3. Decrypt. This also verifies authenticity: any tampering
// (or a wrong key) returns an error instead of garbage.
plaintext, err := crypt.DecryptAesGcmWithNonceAppended(key, ciphertext)
if err != nil {
panic(err)
}
fmt.Println(plaintext) // attack at dawn
}Every symmetric cipher follows the same four-way naming pattern, so once you know one you know them all:
| Variant | Input / output | Nonce |
|---|---|---|
Encrypt<Cipher> |
string |
returned separately |
EncryptByte<Cipher> |
[]byte |
returned separately |
Encrypt<Cipher>WithNonceAppended |
string |
prepended to ciphertext |
EncryptByte<Cipher>WithNonceAppended |
[]byte |
prepended to ciphertext |
Swap EncryptAesGcm for EncryptXChacha20poly1305 (or the ChaCha20 variant) to
change algorithms; the shape is identical.
// publicKeyPEM / privateKeyPEM are strings loaded from .pem files
// (PKIX "PUBLIC KEY" and PKCS#8 "PRIVATE KEY" blocks; see below).
enc := crypt.NewEncoder(publicKeyPEM)
if enc.Err != nil {
panic(enc.Err) // the constructor reports PEM problems via .Err
}
ciphertext, err := enc.EncryptRSA("attack at dawn")
if err != nil {
panic(err)
}
dec := crypt.NewDecoder(privateKeyPEM)
if dec.Err != nil {
panic(dec.Err)
}
plaintext, err := dec.DecryptRSA(ciphertext)
if err != nil {
panic(err)
}
// Want SHA-512 instead of the SHA-256 default? Set it on both sides:
// enc.HashAlg = crypt.SHA512
// dec.HashAlg = crypt.SHA512Use the envelope
subpackage when you need to protect lots of items (rows, files, fields) and be
able to rotate the top-level secret without re-encrypting everything.
package main
import (
"fmt"
"os"
"github.com/pilinux/crypt/envelope"
)
func main() {
// Configure once with your app's domain-separation labels.
scheme := envelope.New(envelope.Config{
KEKLabel: "myapp:kek:v1",
SubKeyLabel: "myapp:data-subkey:v1",
})
// Bootstrap: derive a key-encryption key (KEK) from a rotatable secret,
// then generate a master key and store it *wrapped*. (Errors omitted
// for brevity; handle them in real code.)
// The secret must be machine-generated randomness, >= 32 chars
// (e.g. `openssl rand -hex 32`), never a human-chosen passphrase.
kek, _ := scheme.DeriveKEK(os.Getenv("ENCRYPTION_SECRET"))
masterKey, _ := envelope.GenerateMasterKey()
wrapped, _ := envelope.WrapKey(kek, masterKey) // persist `wrapped`, not masterKey
_ = wrapped
// Per item: seal to a base64 token, then open it back.
token, _ := scheme.SealString(masterKey, "top secret")
plain, _ := scheme.OpenString(masterKey, token)
fmt.Println(plain) // top secret
// Optional context binding: authenticate the record/field the token
// belongs to, so valid tokens cannot be swapped between rows.
bound, _ := scheme.SealStringAAD(masterKey, "top secret", []byte("user:42:note"))
_, err := scheme.OpenStringAAD(masterKey, bound, []byte("user:7:note"))
fmt.Println(err != nil) // wrong context fails to decrypt
}Under the hood every item gets a fresh per-item sub-key (HKDF) and its own
random nonce, so a nonce can never repeat under the same key.
The envelope header is authenticated, and every Seal*/Open* function
has an AAD variant that additionally authenticates caller-supplied context.
Seal*/Open* hold the whole item in memory. For data that does not fit,
such as a 10 GB backup, a 100 GB disk image or an upload of unknown length,
the same scheme also streams, sealing one chunk at a time:
// Whole files, in constant memory. The destination must not exist yet.
n, err := scheme.SealFileAAD(masterKey, "backup.tar.enc", "backup.tar", []byte("backup.tar"))
_, err = scheme.OpenFileAAD(masterKey, "restored.tar", "backup.tar.enc", []byte("backup.tar"))
// Or plug into any io.Reader / io.Writer: HTTP bodies, S3 objects, pipes.
_, err = scheme.SealStream(masterKey, w, r) // io.Writer <- io.Reader
_, err = scheme.OpenStream(masterKey, w, r)
// Or take the writer/reader themselves and compose freely.
sw, err := scheme.SealWriter(masterKey, dst) // io.WriteCloser
_, err = io.Copy(sw, src)
err = sw.Close() // seals the final chunk; the stream is only complete after this
sr, err := scheme.OpenReader(masterKey, src) // io.Reader
_, err = io.Copy(dst, sr)Each chunk (1 MiB by default, Config.ChunkSize) is sealed under the same
per-stream sub-key with the nonce noncePrefix || counter || finalFlag, so
chunks cannot be reordered, duplicated, dropped, or the stream cut short: a
truncated file fails to open instead of decrypting to truncated plaintext.
Every stream records its own chunk size, so changing ChunkSize later never
orphans sealed data.
What it costs. A stream holds exactly one chunk in memory whatever the input size, and that buffer is allocated once per stream and reused, so nothing is allocated per chunk: sealing a 100 GB file costs the same handful of allocations as sealing 1 KB. On the wire:
sealed = 37 + plaintext + 16 * chunks chunks = ceil(plaintext / ChunkSize), min 1
That is a 37-byte header plus one 16-byte tag per chunk, so a 10 GB file at
the default 1 MiB chunk size grows by 160 KiB, about 0.0015%. Larger chunks
mean less overhead and more memory per stream; smaller chunks the reverse.
MinChunkSize (1 KiB) keeps the worst case under 2%, and MaxChunkSize
(64 MiB) caps what a reader will allocate for a chunk size it read from an
untrusted header.
A sealed stream states its chunk size in the clear, so the exact plaintext
length follows from the file size. SealPaddedFile pads the payload first,
inside the encryption:
n, err := scheme.SealPaddedFileAAD(masterKey, "doc.enc", "doc.pdf", []byte("doc"))
_, err = scheme.OpenPaddedFileAAD(masterKey, "doc.out", "doc.enc", []byte("doc"))The payload is framed as version(1) || realLen(8) || payload || zero padding
and rounded up to a Padmé bucket (PaddedSize), destroying 12 to 25 bits of
the length for about 1.4% extra storage. Measured over 162,524 real files: of
those above 1 MB, 61% are uniquely identified by their exact size, 3.9% after
padding. OpenPaddedFile reads the padding back and authenticates it before
discarding it, so truncation inside the padding still fails.
Only the length is hidden. File names, timestamps and access patterns leak
independently; use RandomHex names if that matters.
| If you want to… | Reach for | Key |
|---|---|---|
| Encrypt data with a key you already hold or derive | AES-256-GCM or XChaCha20-Poly1305 | 32 bytes |
| Encrypt many messages under one key without nonce worries | XChaCha20-Poly1305 | 32 bytes |
| Let someone encrypt to you using your public key | RSA-OAEP | PEM key pair |
| Protect many records under one rotatable secret | envelope subpackage |
derived |
| Encrypt a file too big to hold in memory | envelope streaming (SealFile, SealWriter) |
derived |
| Stop a file's size from identifying it | envelope padding (SealPaddedFile) |
derived |
| Area | Key functions |
|---|---|
AES-GCM (aes.go) |
EncryptAesGcm / DecryptAesGcm (+ Byte and WithNonceAppended variants) |
ChaCha20-Poly1305 (chaCha20.go) |
EncryptChacha20poly1305 / DecryptChacha20poly1305 (96-bit nonce) |
XChaCha20-Poly1305 (chaCha20.go) |
EncryptXChacha20poly1305 / DecryptXChacha20poly1305 (192-bit nonce) |
RSA-OAEP (rsa.go) |
Encoder.EncryptRSA / Decoder.DecryptRSA (+ Byte variants) |
Base64 (base64.go) |
Encoder.ToBase64* / Decoder.FromBase64* (Std, RawStd, URL, RawURL) |
Envelope (envelope/) |
Scheme.Seal*/Open* (+ AAD variants), DeriveKEK, WrapKey/UnwrapKey, Zero, Sha256Hex, RandomHex |
Envelope streaming (envelope/) |
Scheme.SealFile/OpenFile, SealStream/OpenStream, SealWriter/OpenReader (+ AAD variants) |
Envelope padding (envelope/) |
Scheme.SealPaddedFile/OpenPaddedFile (+ AAD variants), PaddedSize |
The ChaCha20/XChaCha20 Byte...WithNonceAppended functions also come in
...AAD forms that bind caller-supplied associated data (authenticated, not
encrypted) into the ciphertext.
Full, always-current reference lives on pkg.go.dev.
Each folder under _example is a standalone program you can run
with go run ./_example/<name>:
RSA works with a PKIX public key (PUBLIC KEY) and a PKCS#8 private key
(PRIVATE KEY): exactly what these OpenSSL commands produce.
openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private-key.pem -pubout -out public-key.pemopenssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:3072
openssl rsa -in private-key.pem -pubout -out public-key.pemopenssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -in private-key.pem -pubout -out public-key.pem- Bring your own key derivation.
cryptencrypts with the key you give it; it never derives one. Use Argon2id for passwords and HKDF for high-entropy secrets (theenvelopesubpackage does the latter for you). - The envelope secret must be machine-generated.
DeriveKEKuses HKDF, which does no password stretching: generateENCRYPTION_SECRETwithopenssl rand -hex 32(or similar) and never use a human-chosen passphrase. A guessable secret can be brute-forced offline from the wrapped master key. - Key sizes. AES accepts 16/24/32-byte keys; ChaCha20 and XChaCha20 require exactly 32 bytes.
- Never reuse a (key, nonce) pair. Nonces come from
crypto/rand. When encrypting many items under one key, prefer XChaCha20-Poly1305 or theenvelopescheme, which give each item its own key or a large random nonce. - Everything is authenticated. All AEAD modes and RSA-OAEP fail closed: tampered ciphertext or a wrong key returns an error, never partial plaintext.
- Fail closed on bad input, never panic. The
Decrypt…functions that take a nonce directly validate its length (12 bytes for AES-GCM and ChaCha20-Poly1305, 24 for XChaCha20-Poly1305) and return an error on a mismatch instead of letting the underlying cipher panic. - Per-message size limit. A single message is capped by the underlying
AEAD: roughly 256 GiB for ChaCha20/XChaCha20-Poly1305 and 64 GiB for
AES-GCM. Anything larger returns an error rather than panicking. These bounds
sit far above any realistic payload; for data that big use the
envelopestreaming API, which chunks it and lifts the ceiling. - A stream is only trustworthy once it ends. The streaming API authenticates
every chunk before releasing it, but a consumer that acts on partial output
has acted on data whose stream may still fail. Treat the destination as
unusable until the call returns without error, and note that
StreamWriter.Closefinalizes whatever was written. Call it only after the whole input went in. - Ciphertext reveals its plaintext length. Both formats store enough in the
clear to recover it exactly:
blob - 58for a token,size - 37 - 16*chunksfor a stream. Content, key and context stay hidden, but size alone can identify a known file. UseSealPaddedFilefor files;SealInt64is already fixed-width, and other tokens need padding before you seal them. - RSA key formats. The public key must be a PKIX
PUBLIC KEYblock and the private key a PKCS#8PRIVATE KEYblock. Always check.Errright afterNewEncoder/NewDecoder.
go test -race -cover ./... # unit tests, race detector, coverage
go vet ./... # static analysis
golangci-lint run ./... # aggregate lintersMIT. See LICENSE.
