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 symmetric cipher 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 payload before sealing so its size stops identifying it.
go get github.com/pilinux/cryptRequires Go 1.25+. The only direct 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 bytes
// (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
envelope.Zero(kek)
_ = 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
defer sw.Abort() // no-op once Close has succeeded
if _, err := io.Copy(sw, src); err != nil {
return err // not every source failure reaches sw, so Close alone is not enough
}
err = sw.Close() // seals the final chunk; the stream is only complete after this
sr, err := scheme.OpenReader(masterKey, src) // io.Reader
defer sr.Abort() // wipes the held chunk if you stop early
_, 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 GiB 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) is the widest the format allows. A reader allocates whatever the header
names before anything authenticates, so a service that only writes small chunks
should say so with Config.MaxAcceptedChunkSize rather than accept 64 MiB per
concurrent open from a stranger.
A sealed stream states its chunk size in the clear, so the exact plaintext length follows from the file size. The padded pair pads the payload first, inside the encryption:
// Files: the payload size comes from a Stat.
n, err := scheme.SealPaddedFileAAD(masterKey, "doc.enc", "doc.pdf", []byte("doc"))
_, err = scheme.OpenPaddedFileAAD(masterKey, "doc.out", "doc.enc", []byte("doc"))
// Anything else: an io.Reader plus its length. Nothing is staged on disk.
_, err = scheme.SealPaddedStream(masterKey, w, r, size) // io.Writer <- io.Reader
_, err = scheme.OpenPaddedStream(masterKey, w, r)
// No length, but an empty io.WriterAt destination such as a new *os.File.
_, err = scheme.SealPaddedAt(masterKey, f, r) // io.WriterAt <- io.Reader
// Pull the payload instead: Size comes from the authenticated frame before
// any body is read, which is what a handler needs to set Content-Length.
pr, err := scheme.OpenPaddedReader(masterKey, r) // io.ReadCloser
length := pr.Size() // before a byte of body
_, err = io.Copy(w, pr)
err = pr.Close() // nil only if the payload was complete with authentic paddingAll three sealers produce the same format, so a padded blob sealed one way
opens every other way. Padding costs no memory: the frame, the payload and the
zero padding are pulled through the chunk sealer as it asks for them, so a
padded 10 GB upload is sealed on the fly exactly like an unpadded one.
SealPaddedStream needs the length in advance, since it is written ahead of the
payload and fixes the bucket: pass a Content-Length, a len(), or use the
file form. A source that then delivers a different number of bytes fails with
ErrSourceShort or ErrSourceLong (both match ErrSourceSize) at the payload
boundary, before a single byte of padding is written, which is what makes
size safe to accept from an untrusted peer. SealPaddedAt needs no length:
it holds the chunk carrying the frame, seals it last and writes it back at its
offset, at the cost of a second chunk of memory.
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 sizes from 128 KiB to 2 GiB) for 1 to 3% extra storage on
average. In the Padmé paper, 83% of Ubuntu
packages and 87% of YouTube videos are uniquely identified by their exact size;
after padding, 3%. Every opener reads the padding back, authenticates it and checks it is
all zeros before discarding it, so truncation inside the padding still fails.
When the length is not knowable up front, as with an HTML multipart upload
(no per-part Content-Length, and the file is chosen after the page loads),
write it into a file with SealPaddedAt, or, when the destination is not an
io.WriterAt, seal it unpadded and pad it afterwards:
// Request path: SealStream takes no size at all.
n, err := scheme.SealStreamAAD(masterKey, dst, part, aad)
// Background pass: open the unpadded object and re-seal it padded.
// n comes from stage 1 here, or from the sealed size (see below).
r, err := scheme.OpenReaderAAD(masterKey, src, aad)
_, err = scheme.SealPaddedStreamAAD(masterKey, dst2, r, n, aad)Nothing has to carry n between the two stages: an unpadded stream is
StreamHeaderSize + n + 16*max(1, ceil(n/ChunkSize)) bytes, and PlaintextLen inverts
that, so the sealed size gives the length back. A crash then leaves a valid
sealed object rather than a lost upload. Which objects still owe a pass is the one thing this does not tell you for
free: padded and plain blobs are deliberately indistinguishable, so an unpadded
object opened as padded fails with ErrStreamAuth, exactly like a wrong key.
Retry with OpenStream to identify it, or track the state alongside the object.
The two formats are told apart by a format tag that every chunk authenticates
and no file stores, bound to the caller's AAD as one fixed-width digest, so
neither reader can be talked into accepting the other's stream whatever AAD it
is handed.
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, SealPaddedStream, SealPaddedAt) |
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/) |
New/Default, Scheme.Seal*/Open* (+ AAD variants), DeriveKEK, GenerateMasterKey, WrapKey/UnwrapKey, Zero, Sha256Hex, RandomHex |
Envelope streaming (envelope/) |
Scheme.SealFile/OpenFile, SealStream/OpenStream, SealWriter/OpenReader/StreamWriter.Abort/StreamReader.Abort (+ AAD variants), StreamHeaderSize, PlaintextLen |
Envelope padding (envelope/) |
Scheme.SealPaddedFile/OpenPaddedFile, SealPaddedStream/OpenPaddedStream, SealPaddedAt, OpenPaddedReader (+ AAD variants), PaddedReader.Size/Close, 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>:
- AES
- ChaCha20-Poly1305 AEAD
- XChaCha20-Poly1305 AEAD
- RSA
- Hashing
- TLS 1.3 mutual authentication: a PING/PONG exchange over TLS using only the standard library, with a walkthrough of the handshake.
- Benchmark: an ad-hoc timing loop over the
ciphers, not
go testbenchmarks. - Envelope encryption at rest. Add
-serve 127.0.0.1:8080to skip the demos and start a small upload server (server.go) instead, which pushes a real file of your choosing through the streaming and padding APIs: an upload is padded on the request path withSealPaddedAtAAD, and a download setsContent-LengthfromPaddedReader.Size. It caps uploads at 1 GiB and keeps ciphertext in a temp dir;-max 0 -dir /pathlifts both, which is what a multi-gigabyte test needs;-chunksets the chunk size (default 1 MiB) and-debuglogs how each padded upload is sealed, payload length and first bytes included. Verified at 5 GB, with the server sitting at 8.9 MiB resident.
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. The floor is 32 bytes, which is whatlen(secret)measures. 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
(streams are the exception; see below). In the
envelopepackage an authentication failure is a sentinel:ErrEnvelopeAuthfor a token or a wrapped key,ErrStreamAuthfor a stream. Neither says which of "wrong key", "wrong AAD" or "altered bytes" it was, since telling those apart is what an attacker probing a datastore would want. Input too damaged to parse fails earlier, with an error such asErrBadEnvelopeorErrBadStream. - 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.
StreamWriter.Closefinalizes a stream that has not failed and refuses one that has, so a source that quit part-way cannot be closed into a valid short stream. That needs the writer to see the failure: checkio.Copy's error too, because a source with its ownWriteTomethod (aStreamReader, say) reports failures only toio.Copy. Onlyio.EOFcounts as the end of a source: anio.ErrUnexpectedEOFfrom a cut-off HTTP body fails the seal. UseStreamWriter.Abortfor the case nothing failed and you simply do not want the stream:defer sw.Abort()costs nothing onceClosehas succeeded.StreamReader.Abortis the reading half: stop before the end and it wipes the decrypted chunk the reader still holds. APaddedReaderread for exactlySize()bytes has not checked its padding yet;Closeis where that happens. - 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 andSealPaddedStreamfor everything else; when the length is not known up front, useSealPaddedAtinto a file, or seal unpadded and pad in a second pass.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.
