Documentation
¶
Overview ¶
Package crypt is a cryptography toolkit for Go applications, with a byte-for-byte parity TypeScript counterpart crypt-ts so Go and Node services can encrypt, decrypt, sign, and verify each other's payloads.
Quick start — authenticated encryption ¶
Use Seal and Open for authenticated encryption with AES-256-GCM.
key, _ := crypt.RandomBytes(crypt.AEADKeySize) // 32 bytes
ct, _ := crypt.Seal(key, []byte("hello, world"), nil)
pt, _ := crypt.Open(key, ct, nil)
// pt == []byte("hello, world")
For a long-lived sealer with a bound key, use Sealer:
sealer, _ := crypt.NewSealer(appKey)
ct, _ := sealer.Seal([]byte("payload"), []byte("user:42"))
What's included ¶
Authenticated encryption (AES-256-GCM):
Random:
Signing (HMAC-SHA256):
Password hashing (argon2id):
Legacy (AES-CBC, deprecated):
- EncryptCBC / DecryptCBC
- The v0.x string-typed wrappers EncryptWithKey / DecryptWithKey and Cipher / New are kept Deprecated; use the byte-typed EncryptCBC/DecryptCBC instead.
Format auto-detect:
- OpenAuto decrypts ciphertext that may be AEAD or AES-CBC. Useful for migration scripts and rollover-window readers.
Wire format ¶
AEAD output is base64url-no-pad encoding of:
[version:1=0x01][nonce:12][ciphertext:N][tag:16]
The version byte enables forward-compatibility for future algorithms.
Legacy CBC output is hex(IV[16] || PKCS7-padded ciphertext).
Cross-language parity ¶
All AEAD and HMAC outputs are byte-for-byte interoperable with the TypeScript counterpart at github.com/ubgo/crypt-ts. Both sides consume testdata/vectors.json as a known-answer correctness check.
When to use what ¶
Encrypt-at-rest of a database column:
enc, _ := sealer.Seal(plaintext, nil) db.Save(enc)
Bind ciphertext to a context (e.g., user ID):
enc, _ := sealer.Seal(plaintext, []byte("user:"+userID))
Generate API tokens / magic links / CSRF:
token, _ := crypt.RandomToken(32)
Sign outgoing webhooks:
mac := crypt.Sign(secret, body)
req.Header.Set("X-Signature", base64.StdEncoding.EncodeToString(mac))
Hash user passwords:
hash, _ := crypt.HashPassword(plaintext) ok, _ := crypt.VerifyPassword(plaintext, hash)
Key binding and rotation — design choices ¶
The package binds keys in exactly two ways and intentionally omits a third that callers sometimes ask for.
- Call-time: Seal / Open take the key as an argument. Simplest; good when the key is already in hand at the call site.
- Construction-time: NewSealer binds the key into a Sealer once, so downstream code calls Seal/Open with no key argument. Prefer this to writing your own encryptToken(key, ...) wrappers — it is the same "set once, call bare" ergonomics without a global.
There is deliberately no package-level default key (no SetKey / SealDefault). A global would be hidden mutable state: hard to test (parallel tests share it), impossible to run two keys at once, and a silent zero-key footgun if used before it is set. A Sealer value gives the same convenience without those hazards.
A Sealer is also immutable — no Rekey method. Its key never changes after construction, which is what lets it be shared across goroutines without a lock. To rotate keys:
- Use KeyRing for restart-free rotation. Writes use the active key; reads dispatch by the kid embedded in each ciphertext, so old data stays readable during the migration window. This is the right tool for compliance rotation and compromise response.
- Or swap a whole Sealer at a safe boundary via sync/atomic.Pointer[Sealer]. Cheap and race-free, but a single key only — old ciphertext is not readable after the swap unless you keep the old Sealer around yourself.
Security notes ¶
- The default key fallback present in earlier versions of this package is removed. AEAD operations require an explicit 32-byte key supplied at call time.
- AES-CBC has no built-in message authentication. If you need tamper detection on CBC output, layer HMAC on top, or use Seal/Open which build it in.
- Password hashing uses argon2id with parameters tuned per OWASP recommendations (m=64 MiB, t=2, p=1). Stored hashes encode parameters in PHC format, allowing future re-tuning without breaking existing data.
Index ¶
- Constants
- Variables
- func ConstantTimeEqual(a, b []byte) bool
- func DecryptCBC(key []byte, ciphertext string) ([]byte, error)
- func DecryptWithKey(key, ciphertext string) (string, error)deprecated
- func DeriveKey(masterKey, salt, info []byte, length int) ([]byte, error)
- func EncryptCBC(key, plaintext []byte) (string, error)
- func EncryptWithKey(key, plaintext string) (string, error)deprecated
- func GenerateEd25519() (publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey, err error)
- func GenerateKeyPair() (publicKey, privateKey []byte, err error)
- func HashPassword(plaintext string) (string, error)
- func HashPasswordBcrypt(plaintext string, cost int) (string, error)deprecated
- func IssueToken(key, payload []byte, ttl time.Duration, aad []byte) (string, error)
- func Open(key []byte, ciphertext string, aad []byte) ([]byte, error)
- func OpenAsymmetric(recipientPrivateKey []byte, ciphertext string) ([]byte, error)
- func OpenAuto(key []byte, ciphertext string, aad []byte) ([]byte, error)
- func OpenChaCha20(key []byte, ciphertext string, aad []byte) ([]byte, error)
- func OpenStream(key []byte, r io.Reader, w io.Writer) error
- func RandomBytes(n int) ([]byte, error)
- func RandomHex(n int) (string, error)
- func RandomToken(n int) (string, error)
- func Seal(key, plaintext, aad []byte) (string, error)
- func SealAsymmetric(recipientPublicKey, plaintext []byte) (string, error)
- func SealChaCha20(key, plaintext, aad []byte) (string, error)
- func SealStream(key []byte, r io.Reader, w io.Writer, chunkSize int) error
- func Sign(key, data []byte) []byte
- func SignEd25519(priv ed25519.PrivateKey, data []byte) ([]byte, error)
- func Verify(key, data, mac []byte) bool
- func VerifyEd25519(pub ed25519.PublicKey, data, sig []byte) (bool, error)
- func VerifyPassword(plaintext, stored string) (bool, error)
- func VerifyPasswordBcrypt(plaintext, hash string) (bool, error)deprecated
- func VerifyToken(key []byte, token string, aad []byte) ([]byte, error)
- type Cipherdeprecated
- func New(key string) (*Cipher, error)deprecated
- type EnvelopeSealer
- type KMS
- type KeyRing
- func (r *KeyRing) ActiveKid() string
- func (r *KeyRing) Add(kid string, key []byte) error
- func (r *KeyRing) Open(ciphertext string, aad []byte) ([]byte, error)
- func (r *KeyRing) Remove(kid string) error
- func (r *KeyRing) Seal(plaintext, aad []byte) (string, error)
- func (r *KeyRing) SetActive(kid string) error
- type Sealer
- type StaticKMS
- func (s *StaticKMS) AddKey(keyID string, key []byte) error
- func (s *StaticKMS) Decrypt(_ context.Context, keyID string, wrapped []byte) ([]byte, error)
- func (s *StaticKMS) Encrypt(_ context.Context, keyID string, plaintext []byte) ([]byte, error)
- func (s *StaticKMS) GenerateDataKey(_ context.Context, keyID string) ([]byte, []byte, error)
Examples ¶
Constants ¶
const ( // Ed25519PublicKeySize is the size of an Ed25519 public key (32 bytes). Ed25519PublicKeySize = ed25519.PublicKeySize // Ed25519PrivateKeySize is the size of an Ed25519 private key (64 bytes). Ed25519PrivateKeySize = ed25519.PrivateKeySize // Ed25519SignatureSize is the size of an Ed25519 signature (64 bytes). Ed25519SignatureSize = ed25519.SignatureSize )
const ( // VersionAEADv1 — AES-256-GCM with 12-byte nonce and 16-byte tag. VersionAEADv1 byte = 0x01 // VersionAEADv2 — ChaCha20-Poly1305 with 12-byte nonce and 16-byte tag. VersionAEADv2 byte = 0x02 // VersionAEADv3 — keyring-tagged variant of v1: AES-256-GCM with a // 1-byte kid_len followed by a kid string between the version byte // and the nonce. Used by KeyRing for graceful key rotation. VersionAEADv3 byte = 0x03 // AEADKeySize is the required key length for AEAD operations. AEADKeySize = 32 )
Wire format constants shared between encoders and decoders.
The AEAD output is a base64url-no-pad encoding of:
[version:1][nonce:12][ciphertext:N][tag:16]
The version byte enables forward-compatibility: future algorithms get new version numbers, and decoders explicitly enumerate which versions they understand.
const DefaultBcryptCost = 12
DefaultBcryptCost is bcrypt's recommended cost factor as of 2026. Higher = slower = more brute-force resistant. Each +1 doubles work.
const ( // DefaultStreamChunkSize is the default chunk size in bytes for // the streaming AEAD writer. 64 KiB balances throughput against // per-chunk overhead. DefaultStreamChunkSize = 64 * 1024 )
const ( // X25519KeySize is the size of an X25519 public/private key (32 bytes). X25519KeySize = curve25519.ScalarSize )
Variables ¶
var ( // ErrInvalidKey is returned when a provided key has the wrong length // for the requested operation. AEAD requires exactly 32 bytes // (AES-256-GCM). Legacy CBC accepts 16, 24, or 32 bytes. ErrInvalidKey = errors.New("crypt: invalid key length") // ErrCiphertextTooShort is returned when ciphertext is smaller than // the minimum required to contain the version byte, nonce, and // authentication tag (AEAD) or IV (CBC). ErrCiphertextTooShort = errors.New("crypt: ciphertext too short") // ErrCiphertextNotBlockAligned is returned when CBC ciphertext // (excluding IV) is not a multiple of the AES block size. ErrCiphertextNotBlockAligned = errors.New("crypt: ciphertext is not a multiple of the block size") // ErrInvalidPadding is returned when PKCS#7 padding cannot be // removed because the padding length byte is out of range or // inconsistent. CBC-only. ErrInvalidPadding = errors.New("crypt: invalid PKCS#7 padding") // ErrUnsupportedVersion is returned when an AEAD ciphertext // begins with a version byte that this package version does not // know how to decrypt. ErrUnsupportedVersion = errors.New("crypt: unsupported ciphertext version") // ErrTampered is returned when AEAD authentication fails. This can // indicate a tampered ciphertext, a wrong key, or wrong AAD. ErrTampered = errors.New("crypt: ciphertext authentication failed") // ErrInvalidCiphertext is returned when a ciphertext cannot be // decoded (e.g., not valid base64url for AEAD, not valid hex for // CBC, or otherwise malformed). ErrInvalidCiphertext = errors.New("crypt: invalid ciphertext encoding") // ErrUnknownFormat is returned by OpenAuto when it cannot detect // whether the ciphertext is AES-GCM or AES-CBC. ErrUnknownFormat = errors.New("crypt: unknown ciphertext format") // ErrInvalidPasswordHash is returned when a stored password hash // string cannot be parsed (not a valid PHC string, unsupported // algorithm, or malformed parameters). ErrInvalidPasswordHash = errors.New("crypt: invalid password hash format") )
Sentinel errors returned by the package. Use errors.Is to compare.
var ErrExpired = errors.New("crypt: token expired")
ErrExpired is returned when a token's embedded expiry is in the past.
var ErrInvalidSignature = errors.New("crypt: invalid signature")
ErrInvalidSignature is returned by SignEd25519 / VerifyEd25519 when inputs have wrong shape.
var ErrTruncated = errors.New("crypt: stream truncated, no final chunk marker")
ErrTruncated is returned when the streaming opener finishes without seeing a final-chunk marker — likely an attacker truncated the file.
Functions ¶
func ConstantTimeEqual ¶ added in v0.2.0
ConstantTimeEqual reports whether a and b have equal contents, in time independent of the contents.
Use this instead of `bytes.Equal` for any comparison involving secret material: API keys, MACs, password hashes, session tokens. `bytes.Equal` short-circuits at the first mismatch and leaks timing information that an attacker can exploit to recover the secret one byte at a time.
For inputs of different lengths, returns false in constant time with respect to the equal-length comparison. The length check itself is not constant-time (it cannot be, in Go), but in nearly all practical applications the inputs already have a known fixed length (e.g., a 32-byte HMAC tag).
Example ¶
ExampleConstantTimeEqual demonstrates safe API-key comparison. Never use bytes.Equal or `==` for secrets — they leak timing information that attackers can use to recover the secret one byte at a time.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
expected := []byte("internal-api-key-from-config")
provided := []byte("internal-api-key-from-config")
if crypt.ConstantTimeEqual(expected, provided) {
fmt.Println("authorized")
} else {
fmt.Println("forbidden")
}
}
Output: authorized
func DecryptCBC ¶ added in v0.2.0
DecryptCBC reverses EncryptCBC: hex-decodes the input, takes the IV from the first AES block, AES-CBC decrypts the rest, and removes PKCS#7 padding.
See EncryptCBC for trade-offs vs the authenticated AEAD path (Open/Seal). If you control both ends of the wire and need authentication, prefer Open.
func DecryptWithKey
deprecated
func DeriveKey ¶ added in v0.2.0
DeriveKey derives a length-byte key from a master key using HKDF with SHA-256.
Use cases:
- Per-tenant sub-keys (info = "tenant:" + tenantID)
- Per-purpose sub-keys ("aead-v1", "mac-v1") from a single master
- Per-environment salting (salt = environment marker)
HKDF assumes a high-entropy input key (32+ bytes from a CSPRNG). Do NOT use it on user passwords — use HashPassword (argon2id) instead.
The salt parameter is optional (nil is fine). The info parameter is the context binding — different info values produce independent derived keys from the same master.
Example:
// Per-tenant key for AEAD encryption.
tenantKey, _ := crypt.DeriveKey(rootKey, nil, []byte("tenant:"+tid), crypt.AEADKeySize)
sealer, _ := crypt.NewSealer(tenantKey)
Example ¶
ExampleDeriveKey shows deriving a per-tenant AEAD key from a single application root key.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
root := []byte("root-application-key-32-bytes!!!")
tenantKey, err := crypt.DeriveKey(root, nil, []byte("tenant:acme"), crypt.AEADKeySize)
if err != nil {
panic(err)
}
fmt.Println(len(tenantKey))
}
Output: 32
func EncryptCBC ¶ added in v0.2.0
EncryptCBC PKCS#7-pads the plaintext, AES-CBC encrypts it with a fresh random IV from crypto/rand, and returns hex(IV || ciphertext).
Trade-offs vs Seal (AES-256-GCM):
- CBC has no message authentication. A tampered ciphertext is either rejected by PKCS#7 unpadding (~255/256 of the time) or produces silent garbage plaintext (~1/256). If you care about tamper detection, layer HMAC on top (encrypt-then-MAC) or use Seal which builds in authentication.
- CBC accepts 16/24/32-byte keys (AES-128/192/256). Seal is AES-256-only.
- Output is hex; Seal output is base64url-no-pad. CBC ciphertext is typically larger because hex doubles every byte.
Use it when:
- Interoperating with an existing system that uses AES-CBC (PHP openssl_encrypt, Java javax.crypto with CBC, older Python/Ruby code, etc.).
- Reading ciphertext your application already wrote with this format.
- A specific compliance or library-compat constraint requires AES-CBC.
Example ¶
ExampleEncryptCBC demonstrates AES-CBC encryption — useful for interop with existing AES-CBC systems (PHP, Java, Python). Pair with HMAC if you need authentication, or use Seal for built-in authenticated encryption.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
ct, err := crypt.EncryptCBC(key, []byte("payload"))
if err != nil {
panic(err)
}
pt, err := crypt.DecryptCBC(key, ct)
if err != nil {
panic(err)
}
fmt.Println(string(pt))
}
Output: payload
func EncryptWithKey
deprecated
func GenerateEd25519 ¶ added in v0.2.0
func GenerateEd25519() (publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey, err error)
GenerateEd25519 returns a new Ed25519 keypair from the OS CSPRNG.
func GenerateKeyPair ¶ added in v0.2.0
GenerateKeyPair returns a fresh X25519 keypair for use with SealAsymmetric / OpenAsymmetric.
func HashPassword ¶ added in v0.2.0
HashPassword hashes plaintext using argon2id with library-default parameters and returns a PHC-format string.
The salt is randomly generated from crypto/rand on every call; hashing the same plaintext twice produces different output strings.
Returns an error only on rare CSPRNG failure.
Example ¶
ExampleHashPassword shows the password-storage flow: hash on registration, verify on login. Hashes encode the parameters used, so future cost-tuning is backward-compatible.
package main
import (
"fmt"
"strings"
"github.com/ubgo/crypt"
)
func main() {
stored, err := crypt.HashPassword("correct horse battery staple")
if err != nil {
panic(err)
}
ok, _ := crypt.VerifyPassword("correct horse battery staple", stored)
bad, _ := crypt.VerifyPassword("Tr0ub4dor&3", stored)
fmt.Println("phc prefix:", strings.HasPrefix(stored, "$argon2id$"))
fmt.Println("right pw:", ok)
fmt.Println("wrong pw:", bad)
}
Output: phc prefix: true right pw: true wrong pw: false
func HashPasswordBcrypt
deprecated
added in
v0.2.0
HashPasswordBcrypt hashes plaintext using bcrypt at the given cost. Pass DefaultBcryptCost (12) unless you have a specific reason to deviate.
Returns an error if the plaintext exceeds 72 bytes (bcrypt's hard input limit) — bcrypt would silently truncate, which is a foot-gun.
Deprecated: Use HashPassword (argon2id) for new code. Bcrypt is retained for migration scenarios.
Example ¶
ExampleHashPasswordBcrypt is for migrating from bcrypt.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
hash, _ := crypt.HashPasswordBcrypt("password", crypt.DefaultBcryptCost)
ok, _ := crypt.VerifyPasswordBcrypt("password", hash)
fmt.Println(ok)
}
Output: true
func IssueToken ¶ added in v0.2.0
IssueToken seals payload alongside an expiry under key. Use a purpose-specific aad (e.g., "pwreset-v1", "email-verify-v1") to prevent token-type confusion across endpoints.
Example ¶
ExampleIssueToken shows the stateless time-locked token pattern.
package main
import (
"fmt"
"time"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
tok, _ := crypt.IssueToken(key, []byte("usr_42"), time.Hour, []byte("pwreset-v1"))
payload, err := crypt.VerifyToken(key, tok, []byte("pwreset-v1"))
if err != nil {
panic(err)
}
fmt.Println(string(payload))
}
Output: usr_42
func Open ¶ added in v0.2.0
Open decrypts a base64url-no-pad ciphertext produced by Seal under the same key and aad. Returns the plaintext bytes.
Errors:
- ErrInvalidKey if key length != 32.
- ErrInvalidCiphertext if the input is not valid base64url.
- ErrCiphertextTooShort if the decoded bytes are smaller than the minimum (29 bytes).
- ErrUnsupportedVersion if the version byte is not 0x01.
- ErrTampered if the GCM tag does not match (wrong key, wrong AAD, or modified ciphertext).
Example ¶
ExampleOpen shows the verify direction of an AEAD round-trip.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
ct, _ := crypt.Seal(key, []byte("payload"), []byte("ctx"))
pt, err := crypt.Open(key, ct, []byte("ctx"))
if err != nil {
panic(err)
}
fmt.Println(string(pt))
}
Output: payload
func OpenAsymmetric ¶ added in v0.2.0
OpenAsymmetric decrypts a SealAsymmetric output using the recipient's private key.
func OpenAuto ¶ added in v0.2.0
OpenAuto attempts to decrypt ciphertext that may be in either AES-GCM (base64url, version byte 0x01) or AES-CBC (hex) format.
Dispatch rules:
- If ciphertext base64url-decodes AND first byte is 0x01 AND it's long enough to contain header + tag → call Open.
- Else if ciphertext hex-decodes AND length looks like CBC → call DecryptCBC. AAD is ignored (CBC has no AAD).
- Else → return ErrUnknownFormat.
Use cases:
- One-shot migration scripts iterating a table to re-encrypt mixed-format rows.
- Read-path during a rollover window where writers emit AES-GCM but historical AES-CBC ciphertext may still be present.
For normal application code, call Open or DecryptCBC directly — you should know which format the input is in.
Example ¶
ExampleOpenAuto demonstrates dispatching transparently between AES-GCM and AES-CBC ciphertext formats — useful in a one-shot migration script that walks a table containing both.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
aead, _ := crypt.Seal(key, []byte("modern"), nil)
cbc, _ := crypt.EncryptCBC(key, []byte("legacy-format"))
for _, ct := range []string{aead, cbc} {
pt, err := crypt.OpenAuto(key, ct, nil)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println(string(pt))
}
}
Output: modern legacy-format
func OpenChaCha20 ¶ added in v0.2.0
OpenChaCha20 decrypts a v2 ciphertext (ChaCha20-Poly1305).
Errors:
- ErrInvalidKey if key length != 32.
- ErrInvalidCiphertext on bad base64url.
- ErrCiphertextTooShort if too small.
- ErrUnsupportedVersion if version byte != 0x02.
- ErrTampered on auth failure.
func OpenStream ¶ added in v0.2.0
OpenStream decrypts a stream produced by SealStream. Plaintext is written to w as chunks are decrypted.
Returns ErrTruncated if the stream ends without the last-chunk marker — strong evidence of truncation.
func RandomBytes ¶ added in v0.2.0
RandomBytes returns n cryptographically-random bytes from crypto/rand.
Errors are returned only on rare OS-level random source failure. Returns ErrInvalidKey for n <= 0.
Example ¶
ExampleRandomBytes shows generating raw random bytes for use as a key.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key, err := crypt.RandomBytes(crypt.AEADKeySize)
if err != nil {
panic(err)
}
fmt.Println(len(key))
}
Output: 32
func RandomHex ¶ added in v0.2.0
RandomHex returns n random bytes encoded as lowercase hexadecimal. Suitable for IDs, filenames, ETag-style content addressing.
Output length is exactly 2 * n characters.
RandomHex(8) -> 16 chars RandomHex(16) -> 32 chars RandomHex(32) -> 64 chars
Hex is preferred over base64 when the consumer cannot handle base64 characters (for instance, in filesystem paths on case- insensitive filesystems).
Example ¶
ExampleRandomHex shows generating a hex string suitable for log correlation IDs, filenames, or any case-insensitive identifier.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
id, err := crypt.RandomHex(8)
if err != nil {
panic(err)
}
fmt.Println(len(id))
}
Output: 16
func RandomToken ¶ added in v0.2.0
RandomToken returns n random bytes encoded as URL-safe base64 without padding. Suitable for API keys, magic-link tokens, CSRF tokens, session IDs, anywhere you need a URL-safe random string.
Output length is ceil(n * 4 / 3) characters, no '=' padding. Common sizes:
RandomToken(16) -> 22 chars (~96 bits of entropy) RandomToken(24) -> 32 chars (~144 bits) RandomToken(32) -> 43 chars (~192 bits)
For most token use cases, 16 or 24 bytes is plenty. 32 bytes is the conservative choice for high-value tokens (root API keys, password-reset links).
Example ¶
ExampleRandomToken shows generation of a URL-safe token suitable for password-reset links, magic-login URLs, API keys, etc.
package main
import (
"fmt"
"strings"
"github.com/ubgo/crypt"
)
func main() {
token, err := crypt.RandomToken(24)
if err != nil {
panic(err)
}
// 24 random bytes → 32 base64url chars (no padding).
fmt.Println("len:", len(token))
fmt.Println("url-safe:", !strings.ContainsAny(token, "+/="))
}
Output: len: 32 url-safe: true
func Seal ¶ added in v0.2.0
Seal encrypts plaintext under key with optional additional authenticated data (AAD) and returns the ciphertext as a base64url no-pad string.
The AAD is authenticated but not encrypted: it can be any context- binding data (e.g., user ID, tenant ID, message type) that callers also pass to Open. If aad differs at Open time, decryption fails with ErrTampered.
key must be exactly 32 bytes (AES-256). Nonces are generated from crypto/rand on every call; encrypting the same plaintext twice with the same key produces different ciphertexts.
Errors:
- ErrInvalidKey if key length != 32.
- Other errors only on crypto/rand failure (rare).
Example ¶
ExampleSeal demonstrates the most basic encryption flow: derive a 32-byte key, seal a plaintext, open it back out.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
// In production, load this from secure config — never hard-code.
key := []byte("01234567890123456789012345678901") // 32 bytes
ct, err := crypt.Seal(key, []byte("hello, world"), nil)
if err != nil {
panic(err)
}
pt, err := crypt.Open(key, ct, nil)
if err != nil {
panic(err)
}
fmt.Println(string(pt))
}
Output: hello, world
Example (Aad) ¶
ExampleSeal_aad shows binding ciphertext to a context using additional authenticated data (AAD). Decrypting with a different AAD fails, even with the right key.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
ct, _ := crypt.Seal(key, []byte("user payload"), []byte("user:42"))
// Right AAD → success.
pt, err := crypt.Open(key, ct, []byte("user:42"))
fmt.Println("right AAD:", err, string(pt))
// Wrong AAD → ErrTampered.
_, err = crypt.Open(key, ct, []byte("user:99"))
fmt.Println("wrong AAD:", err != nil)
}
Output: right AAD: <nil> user payload wrong AAD: true
func SealAsymmetric ¶ added in v0.2.0
SealAsymmetric encrypts plaintext to recipientPublicKey. The resulting ciphertext is base64url-no-pad with version 0x05.
Anyone with recipientPrivateKey (and the matching public key) can decrypt; nobody else can. The sender's identity is NOT authenticated by this operation — sign the plaintext with Ed25519 first if you need that.
Example ¶
ExampleSealAsymmetric shows X25519 + ChaCha20-Poly1305 sealed-box encryption.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
pub, priv, _ := crypt.GenerateKeyPair()
ct, _ := crypt.SealAsymmetric(pub, []byte("hello"))
pt, _ := crypt.OpenAsymmetric(priv, ct)
fmt.Println(string(pt))
}
Output: hello
func SealChaCha20 ¶ added in v0.2.0
SealChaCha20 encrypts plaintext under a 32-byte ChaCha20-Poly1305 key with optional AAD and returns base64url-no-pad ciphertext tagged with version byte 0x02.
Example ¶
ExampleSealChaCha20 shows the AES-NI-free AEAD alternative.
package main
import (
"bytes"
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := bytes.Repeat([]byte{0x42}, crypt.AEADKeySize)
ct, _ := crypt.SealChaCha20(key, []byte("hello"), nil)
pt, _ := crypt.OpenChaCha20(key, ct, nil)
fmt.Println(string(pt))
}
Output: hello
func SealStream ¶ added in v0.2.0
SealStream encrypts the input reader to the output writer in chunks of chunkSize bytes. Pass DefaultStreamChunkSize for chunkSize unless you have a specific reason. The output is self-describing — call OpenStream with the same key to decrypt.
Errors are returned from r.Read(), w.Write(), or AEAD operations. The output is not seekable — callers wanting random access need a different design.
Example ¶
ExampleSealStream shows chunked encryption of a large input.
package main
import (
"bytes"
"fmt"
"strings"
"github.com/ubgo/crypt"
)
func main() {
key := bytes.Repeat([]byte{0x42}, crypt.AEADKeySize)
plain := strings.Repeat("payload\n", 1000)
var enc bytes.Buffer
if err := crypt.SealStream(key, strings.NewReader(plain), &enc, crypt.DefaultStreamChunkSize); err != nil {
panic(err)
}
var dec bytes.Buffer
if err := crypt.OpenStream(key, &enc, &dec); err != nil {
panic(err)
}
fmt.Println(dec.Len() == len(plain))
}
Output: true
func Sign ¶ added in v0.2.0
Sign returns an HMAC-SHA256 tag computed over data with key. The output is always 32 bytes.
HMAC is unkeyed-collision-resistant: any key length works cryptographically, but for SHA-256 the recommendation is at least 32 bytes for full security strength.
Example ¶
ExampleSign demonstrates HMAC-SHA256 signing for webhook authenticity: signer and verifier share a secret key.
package main
import (
"encoding/base64"
"fmt"
"strings"
"github.com/ubgo/crypt"
)
func main() {
secret := []byte("shared-webhook-secret")
body := []byte(`{"event":"order.created","id":"ord_123"}`)
mac := crypt.Sign(secret, body)
signature := base64.StdEncoding.EncodeToString(mac)
// Verifier reproduces the MAC and compares constant-time.
ok := crypt.Verify(secret, body, mac)
fmt.Println("len(mac):", len(mac))
fmt.Println("verify:", ok)
fmt.Println("signature ok:", strings.HasSuffix(signature, "="))
}
Output: len(mac): 32 verify: true signature ok: true
func SignEd25519 ¶ added in v0.2.0
func SignEd25519(priv ed25519.PrivateKey, data []byte) ([]byte, error)
SignEd25519 produces a 64-byte signature over data using priv.
Errors:
- ErrInvalidKey if priv length != 64 bytes.
Example ¶
ExampleSignEd25519 shows public-key signing.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
pub, priv, _ := crypt.GenerateEd25519()
sig, _ := crypt.SignEd25519(priv, []byte("the message"))
ok, _ := crypt.VerifyEd25519(pub, []byte("the message"), sig)
fmt.Println(ok)
}
Output: true
func Verify ¶ added in v0.2.0
Verify checks that mac is a valid HMAC-SHA256 of data under key, using a constant-time comparison to prevent timing attacks.
Returns false (without error) if mac is the wrong length. This avoids forcing callers to wrap every Verify call in error handling for a malformed-input case that's rare and equivalent to a verify failure anyway.
Example ¶
ExampleVerify shows verifying an HMAC-SHA256 signature.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
secret := []byte("shared-secret")
body := []byte(`{"event":"order.created"}`)
mac := crypt.Sign(secret, body)
if crypt.Verify(secret, body, mac) {
fmt.Println("authentic")
} else {
fmt.Println("rejected")
}
}
Output: authentic
func VerifyEd25519 ¶ added in v0.2.0
VerifyEd25519 checks an Ed25519 signature in constant time.
Returns false (no error) on mismatch. Returns ErrInvalidKey or ErrInvalidSignature only on shape errors (wrong key/sig size).
func VerifyPassword ¶ added in v0.2.0
VerifyPassword constant-time compares plaintext against a stored PHC-format hash.
Returns:
- (true, nil) on match
- (false, nil) on plaintext mismatch
- (false, ErrInvalidPasswordHash) on malformed stored hash
Parameters are read from the stored string, so this works across future parameter tunings: stored hashes from older settings still verify correctly.
func VerifyPasswordBcrypt
deprecated
added in
v0.2.0
VerifyPasswordBcrypt compares plaintext against a bcrypt-format hash string (e.g., "$2a$12$..."). Constant-time internally.
Returns:
- (true, nil) on match
- (false, nil) on mismatch
- (false, ErrInvalidPasswordHash) on malformed hash
Deprecated: Use VerifyPassword for new code. Bcrypt is retained for migration scenarios.
func VerifyToken ¶ added in v0.2.0
VerifyToken opens a token, checks the embedded expiry, and returns the original payload.
Returns ErrExpired if the token has expired (different from the AEAD-level errors).
Example ¶
ExampleVerifyToken shows handling expired tokens distinctly.
package main
import (
"errors"
"fmt"
"time"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
// Hand-craft an expired token.
past := uint64(time.Now().Add(-time.Hour).Unix())
wrapped := make([]byte, 8+5)
for i := 0; i < 8; i++ {
wrapped[7-i] = byte(past >> (8 * i))
}
copy(wrapped[8:], "stale")
tok, _ := crypt.Seal(key, wrapped, []byte("pwreset-v1"))
_, err := crypt.VerifyToken(key, tok, []byte("pwreset-v1"))
fmt.Println(errors.Is(err, crypt.ErrExpired))
}
Output: true
Types ¶
type Cipher
deprecated
type Cipher struct {
// contains filtered or unexported fields
}
Cipher is a reusable AES-CBC + PKCS#7 cipher pre-keyed at construction.
Deprecated: Cipher uses AES-CBC, which is not authenticated. Use Sealer (AES-256-GCM) for new code.
type EnvelopeSealer ¶ added in v0.2.0
type EnvelopeSealer struct {
// contains filtered or unexported fields
}
EnvelopeSealer uses a KMS for envelope encryption: each call to Seal generates a fresh DEK from the KMS, encrypts the plaintext under that DEK with AES-256-GCM, and stores the wrapped DEK alongside the ciphertext.
Use cases:
- Per-row encryption keys for regulated tables (PCI, HIPAA).
- Cross-region encryption with cloud KMS (AWS / GCP).
- Defense-in-depth: even if the database is exfiltrated, the attacker still needs KMS access to decrypt.
Wire format (version 0x06):
[0x06][wrapped_len:4 BE][wrapped_dek][nonce:12][ciphertext:N][tag:16]
Each Seal makes one KMS round-trip (GenerateDataKey). Each Open makes one KMS round-trip (Decrypt). Cache wisely if your hot path requires more throughput.
func NewEnvelopeSealer ¶ added in v0.2.0
func NewEnvelopeSealer(kms KMS, keyID string) *EnvelopeSealer
NewEnvelopeSealer constructs an EnvelopeSealer bound to a KMS-managed key. The keyID is opaque; its meaning is up to the adapter (AWS KMS uses ARNs, GCP uses resource names, etc.).
Example ¶
ExampleNewEnvelopeSealer shows envelope encryption with a KMS.
package main
import (
"bytes"
"context"
"fmt"
"github.com/ubgo/crypt"
)
func main() {
ctx := context.Background()
kms := crypt.NewStaticKMS()
_ = kms.AddKey("kek-1", bytes.Repeat([]byte{0x99}, crypt.AEADKeySize))
sealer := crypt.NewEnvelopeSealer(kms, "kek-1")
ct, _ := sealer.Seal(ctx, []byte("regulated"), nil)
pt, _ := sealer.Open(ctx, ct, nil)
fmt.Println(string(pt))
}
Output: regulated
type KMS ¶ added in v0.2.0
type KMS interface {
// GenerateDataKey returns a fresh data encryption key (plaintext)
// and the same key wrapped under the KEK identified by keyID.
GenerateDataKey(ctx context.Context, keyID string) (plaintextDEK, wrappedDEK []byte, err error)
// Decrypt unwraps a previously-wrapped DEK back to plaintext.
Decrypt(ctx context.Context, keyID string, wrappedDEK []byte) (plaintextDEK []byte, err error)
// Encrypt wraps an arbitrary blob under the KEK. Optional —
// most callers use GenerateDataKey + Decrypt instead.
Encrypt(ctx context.Context, keyID string, plaintext []byte) (ciphertext []byte, err error)
}
KMS is the interface a Key Management Service adapter implements. `crypt` ships the interface and a static-key in-memory adapter for testing; production adapters for AWS KMS, GCP KMS, HashiCorp Vault, etc. are separate packages so this module doesn't pull cloud SDKs.
The interface is intentionally minimal: GenerateDataKey returns a fresh DEK along with that DEK encrypted under the KEK, so the caller can store the wrapped DEK alongside the ciphertext. Decrypt unwraps a previously-stored wrapped DEK.
type KeyRing ¶ added in v0.2.0
type KeyRing struct {
// contains filtered or unexported fields
}
KeyRing supports graceful key rotation: writes always use the active key; reads dispatch by the kid (key id) embedded in each ciphertext.
Wire format (version 0x03):
[0x03][kid_len:1][kid:kid_len][nonce:12][ciphertext:N][tag:16]
Old AEAD ciphertext (version 0x01) is also openable by KeyRing when the active key happens to match — KeyRing.Open detects v1 and falls back to plain Open with the active key. To open v1 ciphertext under a non-active key, the caller must use plain crypt.Open with that key.
Use cases:
- Annual key rotation (compliance).
- Compromise response: add new active key, drop old key after all data has rotated through.
func NewKeyRing ¶ added in v0.2.0
NewKeyRing constructs a ring with one key marked active. The active kid identifies the key used for new Seals. Other kids can be added with Add.
kid must be 1..64 ASCII bytes. Reasonable choices: "v1", "2026-q1", a UUID, etc.
Example ¶
ExampleNewKeyRing demonstrates the rotation pattern: active key for new writes, retired keys remain readable.
package main
import (
"bytes"
"fmt"
"github.com/ubgo/crypt"
)
func main() {
keyV1 := bytes.Repeat([]byte{0x01}, crypt.AEADKeySize)
keyV2 := bytes.Repeat([]byte{0x02}, crypt.AEADKeySize)
ring, _ := crypt.NewKeyRing("2025", keyV1)
old, _ := ring.Seal([]byte("old data"), nil)
_ = ring.Add("2026", keyV2)
_ = ring.SetActive("2026")
fresh, _ := ring.Seal([]byte("new data"), nil)
pt1, _ := ring.Open(old, nil)
pt2, _ := ring.Open(fresh, nil)
fmt.Printf("%s | %s\n", pt1, pt2)
}
Output: old data | new data
func (*KeyRing) Add ¶ added in v0.2.0
Add registers an additional key under kid. Used to keep retired keys readable during a rotation window.
func (*KeyRing) Open ¶ added in v0.2.0
Open decrypts a ciphertext, dispatching by the embedded kid for v3 ciphertexts. v1 ciphertexts (no kid) are opened under the active key as a fallback.
func (*KeyRing) Remove ¶ added in v0.2.0
Remove drops a kid from the ring. After this, ciphertexts tagged with that kid become unreadable — call this only after all ciphertexts have been re-encrypted.
type Sealer ¶ added in v0.2.0
type Sealer struct {
// contains filtered or unexported fields
}
Sealer holds a pre-validated AEAD key and its bound cipher state.
A Sealer is safe for concurrent use by multiple goroutines: the underlying cipher.AEAD instance is itself concurrent-safe per Go std lib documentation.
Prefer Sealer over the package-level Seal/Open functions when:
- You encrypt many times with the same key (avoids re-validating and re-constructing the cipher.AEAD on every call).
- You want to inject a sealer as a service dependency for testing.
- You want to bind the key into a long-lived component (plugin, service, etc.) rather than threading it through every call.
Design choice — a Sealer is immutable and has no Rekey method. The key is bound once at NewSealer and never changes. This is deliberate:
- Immutability is what makes a Sealer lock-free to share across goroutines; a mutable key would force a mutex or atomic.Pointer load on every Seal/Open just to serve a rare swap.
- A hard key swap would instantly make all previously-sealed ciphertext undecryptable. Real rotation needs old and new keys live at once — that is KeyRing's job, not a setter's.
To rotate keys, choose by need:
- Restart-free rotation with a decrypt-only grace window for old data: use KeyRing (writes with the active key, reads dispatch by the kid embedded in each ciphertext).
- Single-key swap at a safe boundary (e.g. config reload): build a fresh Sealer and replace the pointer, guarding the shared field with sync/atomic.Pointer[Sealer] so concurrent callers stay safe. Reassigning a plain *Sealer variable under live readers is a data race.
There is intentionally no package-level default key or global SetKey. AEAD operations require an explicit key (call-time for Seal/Open, construction-time for Sealer) so there is no hidden mutable state, no zero-key footgun, and tests never share a key.
Example ¶
ExampleSealer shows the recommended pattern for an application that encrypts repeatedly with the same key: construct one Sealer at boot, share it across goroutines.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
sealer, err := crypt.NewSealer(key)
if err != nil {
panic(err)
}
a, _ := sealer.Seal([]byte("first"), nil)
b, _ := sealer.Seal([]byte("second"), nil)
pa, _ := sealer.Open(a, nil)
pb, _ := sealer.Open(b, nil)
fmt.Println(string(pa), string(pb))
}
Output: first second
func NewSealer ¶ added in v0.2.0
NewSealer constructs a Sealer from a 32-byte AES-256 key.
The cipher.AEAD is constructed once and reused across Seal/Open calls, eliminating per-call setup overhead.
func (*Sealer) Seal ¶ added in v0.2.0
Seal encrypts plaintext with optional aad and returns a base64url no-pad ciphertext.
Example (Aad) ¶
ExampleSealer_Seal_aad shows binding ciphertext to a context, with the bound-key Sealer form.
package main
import (
"fmt"
"github.com/ubgo/crypt"
)
func main() {
key := []byte("01234567890123456789012345678901")
sealer, _ := crypt.NewSealer(key)
ct, _ := sealer.Seal([]byte("payload"), []byte("user:42"))
pt, _ := sealer.Open(ct, []byte("user:42"))
fmt.Println(string(pt))
}
Output: payload
type StaticKMS ¶ added in v0.2.0
type StaticKMS struct {
// contains filtered or unexported fields
}
StaticKMS is a trivial KMS adapter that uses a fixed local key as the KEK. Useful for tests and local development; not for production (defeats the purpose of envelope encryption — the KEK is in the process memory).
func NewStaticKMS ¶ added in v0.2.0
func NewStaticKMS() *StaticKMS
NewStaticKMS constructs a StaticKMS. Add keys with AddKey.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
gen-vectors
command
gen-vectors writes testdata/vectors.json — known-answer test vectors shared between the Go and TypeScript implementations.
|
gen-vectors writes testdata/vectors.json — known-answer test vectors shared between the Go and TypeScript implementations. |
|
examples
|
|
|
api_key_check
command
Constant-time API key authentication using only net/http (no framework).
|
Constant-time API key authentication using only net/http (no framework). |
|
asymmetric_encrypt
command
Asymmetric (sealed-box) encryption: X25519 ECDH + ChaCha20-Poly1305.
|
Asymmetric (sealed-box) encryption: X25519 ECDH + ChaCha20-Poly1305. |
|
audit_log_chain
command
Audit log with HMAC-chained integrity.
|
Audit log with HMAC-chained integrity. |
|
bcrypt_migration
command
Bcrypt password migration to argon2id.
|
Bcrypt password migration to argon2id. |
|
chacha20_aead
command
ChaCha20-Poly1305 AEAD — alternative to AES-256-GCM.
|
ChaCha20-Poly1305 AEAD — alternative to AES-256-GCM. |
|
cross_lang_go
command
Cross-language interop demo, Go side.
|
Cross-language interop demo, Go side. |
|
csrf_token
command
CSRF token issue + verify, double-submit pattern.
|
CSRF token issue + verify, double-submit pattern. |
|
ed25519_sign
command
Ed25519 public-key signatures.
|
Ed25519 public-key signatures. |
|
encrypt_field
command
Example: encrypt-at-rest of a database column.
|
Example: encrypt-at-rest of a database column. |
|
encrypted_cookie
command
Encrypted session cookie — the entire session lives inside the cookie, sealed with the server's key.
|
Encrypted session cookie — the entire session lives inside the cookie, sealed with the server's key. |
|
encrypted_file
command
Encrypt a file before writing it to disk / object storage.
|
Encrypt a file before writing it to disk / object storage. |
|
envelope_encryption
command
Envelope encryption with KMS.
|
Envelope encryption with KMS. |
|
hash_password
command
Example: register / login flow with argon2id password hashing.
|
Example: register / login flow with argon2id password hashing. |
|
hkdf_derive_key
command
HKDF key derivation: derive sub-keys from a single master.
|
HKDF key derivation: derive sub-keys from a single master. |
|
key_rotation
command
Key rotation pattern using a multi-key reader.
|
Key rotation pattern using a multi-key reader. |
|
keyring_rotation
command
Built-in KeyRing for graceful key rotation.
|
Built-in KeyRing for graceful key rotation. |
|
magic_link
command
Magic-link / password-reset link, stateless.
|
Magic-link / password-reset link, stateless. |
|
migrate_cbc_to_gcm
command
Example: one-shot migration script reading legacy AES-CBC ciphertext and rewriting as AEAD AES-GCM.
|
Example: one-shot migration script reading legacy AES-CBC ciphertext and rewriting as AEAD AES-GCM. |
|
random_token
command
Example: generating cryptographically-random tokens for API keys, magic-link URLs, CSRF tokens, and short-lived IDs.
|
Example: generating cryptographically-random tokens for API keys, magic-link URLs, CSRF tokens, and short-lived IDs. |
|
seal_with_aad
command
Example: bind ciphertext to a context using AAD (additional authenticated data).
|
Example: bind ciphertext to a context using AAD (additional authenticated data). |
|
sealer
command
Example: application-wide bound-key Sealer injected via dependency.
|
Example: application-wide bound-key Sealer injected via dependency. |
|
session_token
command
Stateless session token with embedded expiry — a JWT-like pattern implemented as a sealed payload, smaller and without algorithm confusion.
|
Stateless session token with embedded expiry — a JWT-like pattern implemented as a sealed payload, smaller and without algorithm confusion. |
|
sign_webhook
command
Example: sign and verify outgoing webhooks with HMAC-SHA256.
|
Example: sign and verify outgoing webhooks with HMAC-SHA256. |
|
streaming_file
command
Streaming AEAD for arbitrary-size files.
|
Streaming AEAD for arbitrary-size files. |
|
tenant_keys
command
Per-tenant encryption keys derived from a single root key.
|
Per-tenant encryption keys derived from a single root key. |
|
time_locked_token
command
Time-locked tokens — built-in IssueToken / VerifyToken with embedded expiry.
|
Time-locked tokens — built-in IssueToken / VerifyToken with embedded expiry. |
|
webhook_with_timestamp
command
Stripe-style webhook verification with a timestamp tolerance, to defend against replay attacks where an adversary captures a signed request and re-sends it later.
|
Stripe-style webhook verification with a timestamp tolerance, to defend against replay attacks where an adversary captures a signed request and re-sends it later. |