Authenticated AES-128/256 CBC for Go, with backward-compatible reads and key rotation.
go get github.com/goforj/cryptcrypt provides authenticated AES-CBC encryption for Go services and graceful key rotation through APP_PREVIOUS_KEYS. Encrypt uses one current wire format, while Decrypt remains backward-compatible with ciphertext written by earlier releases.
- AES-128 / AES-256 CBC encryption
- Authenticated encryption (AES-CBC + HMAC)
- One canonical CBC write format
- Backward-compatible reads of historical CBC envelopes
- Transparent key rotation via
APP_PREVIOUS_KEYS - Zero dependencies (stdlib only)
- Instanced and global usage styles
- Stable error identities for invalid keys, malformed payloads, and authentication failures
crypt exists to solve one problem well: encrypting small application payloads with safe defaults and painless key rotation.
It is not a general-purpose cryptography library.
It is a focused, application-layer utility designed to be boring and predictable.
package main
import (
"fmt"
"github.com/goforj/crypt"
)
func main() {
appKey := "base64:..." // 16-byte (AES-128) or 32-byte (AES-256) key after decoding.
key, err := crypt.ReadAppKey(appKey)
if err != nil {
panic(err)
}
c, err := crypt.New(key)
if err != nil {
panic(err)
}
ciphertext, err := c.Encrypt("secret")
if err != nil {
panic(err)
}
plaintext, err := c.Decrypt(ciphertext)
if err != nil {
panic(err)
}
fmt.Println(plaintext) // "secret"
}c.Decrypt also reads ciphertext written in crypt's historical format.
package main
import (
"fmt"
"os"
"github.com/goforj/crypt"
)
func main() {
_ = os.Setenv("APP_KEY", "base64:...")
ciphertext, _ := crypt.Encrypt("secret")
plaintext, _ := crypt.Decrypt(ciphertext)
fmt.Println(plaintext) // "secret"
}crypt uses a base64-prefixed application-key syntax and supports graceful decryption during rotation.
APP_KEYmust be prefixed withbase64:and decode to either 16 bytes (AES-128) or 32 bytes (AES-256).APP_PREVIOUS_KEYSis optional and may contain a comma-separated list of older keys in the same format.- During decryption, the current key is tried first, followed by any previous keys.
- Encryption always uses the current
APP_KEY; previous keys are never used for encryption.
export APP_KEY="base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY="
export APP_PREVIOUS_KEYS="base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w="| Operation | crypt original format | Laravel 12 CBC encryptString |
|---|---|---|
Encrypt / (*Cipher).Encrypt writes |
No | Yes |
Decrypt / (*Cipher).Decrypt reads |
Yes | Yes |
Encrypt writes the compatible CBC envelope: outer-base64 JSON containing
iv, value, lowercase hexadecimal mac, and an explicit empty tag. It signs
the base64 IV and ciphertext strings, matching Laravel's
Encrypter.
A 16-byte key selects AES-128-CBC and a 32-byte key selects AES-256-CBC.
Compatibility is intentionally limited to Laravel's string API. crypt does not
produce or consume PHP-serialized values from Laravel's generic encrypt method,
and it does not support Laravel's GCM ciphers. See Laravel's
encryption documentation for the upstream model.
This release changes Encrypt and (*Cipher).Encrypt from crypt's original envelope
to the hex-MAC envelope described above. Their Go signatures are unchanged, and Decrypt remains
backward-compatible with existing ciphertext.
Older crypt releases and external consumers that only understand the original MAC
encoding cannot read new writes. Mixed-version deployments must either release the
dual-format reader before switching writers or pause writes during a coordinated
upgrade. Existing ciphertext does not need re-encryption unless a consumer that only
accepts the hex-MAC envelope must read it. Keep required old keys in APP_PREVIOUS_KEYS until dependent
ciphertext expires or is migrated.
EncryptedPayload retains its original three-field Go shape for source compatibility,
but ciphertext envelopes should be treated as opaque.
- A fresh cryptographic IV is generated for every write. Payloads are authenticated before CBC decryption or padding inspection.
ErrInvalidKey,ErrInvalidPayload, andErrAuthenticationsupporterrors.Is; error text never includes key, plaintext, or ciphertext values.- The library deliberately imposes no arbitrary payload-size cap. Encryption buffers the payload in memory, so callers should apply limits appropriate to their trust boundary.
- Treat
APP_KEYandAPP_PREVIOUS_KEYSas secrets. Do not log them, embed them in source, or remove a previous key while live ciphertext still depends on it.
Warning:
GenerateKeyToEnvis a destructive reset. It replacesAPP_KEYand removes every activeAPP_PREVIOUS_KEYSassignment. Existing ciphertext may become permanently unreadable. UseRotateKeyInEnvwhen retaining decryption history.
Both helpers preserve comments, unrelated formatting, quote style, CRLF/LF endings,
and an existing file's permission mode. New files are created with mode 0600.
Audit older files and restrict them to 0600 when deployment requirements allow;
the library does not silently change an existing explicit mode. Replacement files
remain 0600 while secret bytes are prepared, then receive the preserved final mode
immediately before the synced rename.
Writes use a synced same-directory temporary file and atomic rename. Final-component symlinks are rejected, and same-path mutations are serialized within one process. Unrelated processes must coordinate their read-modify-write operations separately. Atomic replacement creates an inode owned by the writing process, so run these helpers under the account that should own the resulting file. If rename commits but directory sync reports a durability error, the helper returns both the installed key and the error.
Encryption and both read formats can be benchmarked with go test -bench=. -benchmem.
Runnable examples for the documented workflows live under ./examples.
Examples are generated directly from function doc comments, and the same snippets power the README and GoDoc examples.
An automated test builds every example so the docs stay valid as the API evolves.
Global = package-level functions (env-based convenience).
Instanced = methods on *crypt.Cipher with injected keys.
| Group | Namespace | Functions |
|---|---|---|
| Encryption | Global | Decrypt · Encrypt |
| Encryption | Instanced | Cipher.Decrypt · Cipher.Encrypt |
| Key management | Global | GenerateAppKey · GenerateKeyToEnv · GetAppKey · GetPreviousAppKeys · New · NewFromEnv · ReadAppKey · RotateKeyInEnv |
Decrypt decrypts either supported payload format using APP_KEY and APP_PREVIOUS_KEYS.
Example: decrypt using current key
appKey, _ := crypt.GenerateAppKey()
_ = os.Setenv("APP_KEY", appKey)
ciphertext, _ := crypt.Encrypt("secret")
plaintext, _ := crypt.Decrypt(ciphertext)
godump.Dump(plaintext)
// #string "secret"Example: decrypt ciphertext encrypted with a previous key
oldAppKey, _ := crypt.GenerateAppKey()
newAppKey, _ := crypt.GenerateAppKey()
// Encrypt with the old key first.
_ = os.Setenv("APP_KEY", oldAppKey)
rotatedCiphertext, _ := crypt.Encrypt("rotated")
// Rotate to a new current key, but keep the old key in APP_PREVIOUS_KEYS.
_ = os.Setenv("APP_KEY", newAppKey)
_ = os.Setenv("APP_PREVIOUS_KEYS", oldAppKey)
plaintext, err := crypt.Decrypt(rotatedCiphertext)
godump.Dump(plaintext, err)
// #string "rotated"
// #error <nil>Encrypt encrypts plaintext with APP_KEY using the hex-MAC CBC envelope. The envelope signs the base64 IV and ciphertext, encodes the MAC as lowercase hex, and includes an empty tag.
appKey, _ := crypt.GenerateAppKey()
_ = os.Setenv("APP_KEY", appKey)
ciphertext, err := crypt.Encrypt("secret")
godump.Dump(err == nil, ciphertext != "")
// #bool true
// #bool trueDecrypt decrypts current and historical CBC envelopes. It authenticates with the current key first, followed by configured previous keys.
Encrypt encrypts plaintext with the Cipher's current key using the hex-MAC CBC envelope. The envelope signs the base64 IV and ciphertext, encodes the MAC as lowercase hex, and includes an empty tag.
key := make([]byte, 32)
c, _ := crypt.New(key)
ciphertext, err := c.Encrypt("secret")
godump.Dump(err == nil, ciphertext != "")
// #bool true
// #bool trueGenerateAppKey generates a random AES-256 key using the base64-prefixed APP_KEY syntax.
key, _ := crypt.GenerateAppKey()
godump.Dump(key)
// #string "base64:..."GenerateKeyToEnv creates a new APP_KEY and destructively clears APP_PREVIOUS_KEYS.
This operation is a reset, not a graceful rotation. Existing ciphertext that requires a cleared previous key becomes unreadable; use RotateKeyInEnv to retain decryption history. New files use mode 0600, while existing file permissions are preserved. Final-component symlinks are rejected. If the atomic rename commits but syncing its directory fails, the installed key is returned together with the error.
dir, _ := os.MkdirTemp("", "crypt-reset-*")
defer os.RemoveAll(dir)
envPath := filepath.Join(dir, ".env")
key, err := crypt.GenerateKeyToEnv(envPath)
godump.Dump(err, key)
// #error <nil>
// #string "base64:..."GetAppKey retrieves the APP_KEY from the environment and parses it.
appKey, _ := crypt.GenerateAppKey()
_ = os.Setenv("APP_KEY", appKey)
key, err := crypt.GetAppKey()
godump.Dump(len(key), err)
// #int 32
// #error <nil>GetPreviousAppKeys retrieves and parses APP_PREVIOUS_KEYS from the environment. Keys are expected to be comma-delimited and prefixed with "base64:".
oldKeyA, _ := crypt.GenerateAppKey()
oldKeyB, _ := crypt.GenerateAppKey()
// APP_PREVIOUS_KEYS is a comma-separated list.
_ = os.Setenv("APP_PREVIOUS_KEYS", oldKeyA+", "+oldKeyB)
keys, err := crypt.GetPreviousAppKeys()
godump.Dump(len(keys), err)
// #int 2
// #error <nil>New constructs a Cipher with an injected current key and optional previous keys. Keys must be 16 bytes (AES-128) or 32 bytes (AES-256). Inputs are copied.
NewFromEnv constructs a Cipher from APP_KEY and APP_PREVIOUS_KEYS.
ReadAppKey parses a base64-prefixed AES-128 or AES-256 application key.
// Build a 16-byte (AES-128) key string manually.
raw16 := make([]byte, 16)
_, _ = rand.Read(raw16)
key16 := "base64:" + base64.StdEncoding.EncodeToString(raw16)
// Generate a 32-byte (AES-256) key string with the helper.
key32, _ := crypt.GenerateAppKey()
parsed16, _ := crypt.ReadAppKey(key16)
parsed32, _ := crypt.ReadAppKey(key32)
godump.Dump(len(parsed16), len(parsed32))
// #int 16
// #int 32RotateKeyInEnv writes a new APP_KEY and prepends the old key to APP_PREVIOUS_KEYS.
Same-path calls are serialized within this process so concurrent rotations retain every key. Atomic replacement prevents partial files, but unrelated processes must still coordinate their read-modify-write operations with the caller. If the atomic rename commits but syncing its directory fails, the installed key is returned with the error so callers do not lose track of active key material.
dir, _ := os.MkdirTemp("", "crypt-rotate-*")
defer os.RemoveAll(dir)
envPath := filepath.Join(dir, ".env")
currentKey, _ := crypt.GenerateAppKey()
// Seed a minimal .env with an existing APP_KEY.
_ = os.WriteFile(envPath, []byte("APP_KEY="+currentKey+"\n"), 0o600)
newKey, err := crypt.RotateKeyInEnv(envPath)
godump.Dump(err == nil, newKey != "")
// #bool true
// #bool trueUse make test for the root module, make vet for static checks, and make generate to refresh generated documentation. Run make docs-watch to regenerate documentation as source files change. The examples directory is a separate Go module and can be tested from that directory when changed.
