crypt

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 3, 2026 License: Apache-2.0 Imports: 22 Imported by: 3

README

crypt

Go Reference Go Report Card test lint coverage tag license Go

Authenticated encryption, password hashing, webhook signing, and secure random — wrapped around the Go standard library with safe defaults and byte-for-byte interop with the TypeScript counterpart crypt-ts.

import "github.com/ubgo/crypt"

key, _ := crypt.RandomBytes(crypt.AEADKeySize)
ct, _ := crypt.Seal(key, []byte("hello, world"), nil)
pt, _ := crypt.Open(key, ct, nil)
// pt == []byte("hello, world")

That's the whole API for the most common case. Need more? Read on.


Is this for you?

crypt is built for Go applications that need a curated set of cryptography primitives done well, with safe defaults, no foot-guns, and optionally a Node.js sibling that produces byte-identical output. Reach for it when you're about to write any of the following:

  • Encrypt a database column at rest (API client secrets, PII, encryption keys, webhook secrets) and decrypt it back later.
  • Sign outgoing webhooks with HMAC (or Ed25519 public-key) and verify incoming ones — Stripe-style with timestamp tolerance, etc.
  • Hash user passwords correctly with modern parameters (argon2id, or bcrypt for compatibility).
  • Generate cryptographically-random API keys, magic-link tokens, CSRF tokens, session IDs.
  • Issue stateless time-locked tokens (password reset, email verify, magic login) with embedded expiry.
  • Decrypt in Node.js what a Go service encrypted (or vice versa) — same wire format on both sides.
  • Compare an API key in constant time without leaking timing.
  • Interoperate with an existing AES-CBC system, or read ciphertext you already wrote in CBC.
  • Encrypt large files in chunks (SealStream / OpenStream) with per-chunk authentication and truncation detection.
  • Derive per-tenant or per-purpose sub-keys from a single master with HKDF.
  • Rotate keys gracefully — KeyRing with embedded kid; old data still readable, new writes use the active key.
  • Use ChaCha20-Poly1305 instead of AES-GCM (no AES-NI hardware, or defense-in-depth diversity).
  • Encrypt to a recipient's public key — X25519 + ChaCha20-Poly1305 (sealed-box), age-style.
  • Sign with Ed25519 — public-key signatures where verifiers don't share the signing key.
  • Envelope encryption with a KMS — per-row DEK wrapped under a KMS-managed KEK.

If any of those are on your plate, this is the package.

Not for you if: you need TLS, PKI, X.509, JWT/JOSE, certificate management, browser/WebCrypto, or post-quantum crypto. Use the std library or a specialized package for those.


30-second tour

Encrypt a database column at rest
sealer, _ := crypt.NewSealer(appKey) // 32 bytes from secrets manager

enc, _ := sealer.Seal([]byte(secret), nil)
db.Exec(`UPDATE partners SET client_secret = $1 WHERE id = $2`, enc, id)

plain, _ := sealer.Open(row.ClientSecret, nil)
Bind a token to a user (so it can't be replayed for another)
ct, _ := sealer.Seal(payload, []byte("user:"+userID))

pt, err := sealer.Open(ct, []byte("user:"+userID))
// err == ErrTampered if userID differs from issue time
Sign and verify a webhook
mac := crypt.Sign(secret, body)              // signer
ok := crypt.Verify(secret, body, mac)        // verifier (constant-time)
Hash and verify a password
hash, _ := crypt.HashPassword(plaintext)
ok, _   := crypt.VerifyPassword(plaintext, hash)
Generate an API token
token, _ := crypt.RandomToken(32) // 43-char URL-safe string
Cross-language: Go encrypts, Node decrypts
// Go side
ct, _ := crypt.Seal(sharedKey, payload, nil)
return ct
// Node side, using @ubgo/crypt
import { open } from "@ubgo/crypt"
const plaintext = open(sharedKey, ct)

Same wire format, byte-for-byte. Verified by shared test vectors in CI.


Why this exists

Every Go service ends up reinventing the same five wrappers around crypto/aes, crypto/hmac, crypto/rand, crypto/subtle, and golang.org/x/crypto/argon2. Each reinvention gets some part wrong:

  • AES-CBC instead of GCM (no authentication).
  • A "default" key string committed to source.
  • bytes.Equal for HMAC verification (timing leak).
  • Argon2 with hand-tuned parameters that drift from OWASP recommendations.
  • A wire format that the Node sibling decodes wrong for any plaintext > 16 bytes.

The last one is not theoretical: a hand-rolled wrapper in a Node.js codebase shipped silent corruption for any plaintext over 16 bytes before this package existed. crypt is one wrapper covering both languages, with shared test vectors enforcing wire-format parity in CI so divergence is caught at PR review rather than in production.


What's included

Authenticated encryption (AES-256-GCM) — Seal, Open, Sealer. Modern AEAD with a versioned wire format so future algorithms slot in without breaking decrypt of old data.

Password hashing (argon2id) — HashPassword, VerifyPassword. OWASP-recommended parameters; PHC string output so future re-tunes are backward-compatible.

HMAC signing — Sign, Verify. Constant-time verification.

Secure random — RandomBytes, RandomToken (URL-safe base64), RandomHex. OS CSPRNG.

Constant-time compare — ConstantTimeEqual. Wraps crypto/subtle.

AES-CBC — EncryptCBC, DecryptCBC (16/24/32-byte keys for AES-128/192/256). First-class peer of AES-GCM — use it when interop with an existing AES-CBC system is required (PHP/Java/Python), or when reading ciphertext your application already wrote in this format. CBC has no built-in authentication; layer HMAC on top (encrypt-then-MAC) or use Seal/Open if you need tamper detection. A crypt.OpenAuto helper auto-detects AEAD vs CBC for migration scripts.

Cross-language wire format — every AEAD and HMAC output is byte-identical to the TypeScript counterpart, validated by testdata/vectors.json consumed by both repos' tests.

For the full feature catalog with use cases, see FEATURES.md.


API at a glance

// AEAD
func Seal(key, plaintext, aad []byte) (string, error)
func Open(key []byte, ciphertext string, aad []byte) ([]byte, error)

type Sealer struct { /* ... */ }
func NewSealer(key []byte) (*Sealer, error)
func (s *Sealer) Seal(plaintext, aad []byte) (string, error)
func (s *Sealer) Open(ciphertext string, aad []byte) ([]byte, error)

// Random
func RandomBytes(n int) ([]byte, error)
func RandomToken(n int) (string, error)   // URL-safe base64-no-pad
func RandomHex(n int) (string, error)

// Signing
func Sign(key, data []byte) []byte
func Verify(key, data, mac []byte) bool
func ConstantTimeEqual(a, b []byte) bool

// Password
func HashPassword(plaintext string) (string, error)
func VerifyPassword(plaintext, hash string) (bool, error)

// AES-CBC (16/24/32-byte keys; no built-in auth — pair with HMAC if needed)
func EncryptCBC(key []byte, plaintext []byte) (string, error)
func DecryptCBC(key []byte, ciphertext string) ([]byte, error)
import "github.com/ubgo/crypt"
func crypt.OpenAuto(key []byte, ciphertext string, aad []byte) ([]byte, error)

// ChaCha20-Poly1305 (alternative AEAD; wire version 0x02)
func SealChaCha20(key, plaintext, aad []byte) (string, error)
func OpenChaCha20(key []byte, ciphertext string, aad []byte) ([]byte, error)

// Bcrypt password hashing (compatibility with bcrypt-using systems)
func HashPasswordBcrypt(plaintext string, cost int) (string, error)
func VerifyPasswordBcrypt(plaintext, hash string) (bool, error)

// HKDF key derivation
func DeriveKey(masterKey, salt, info []byte, length int) ([]byte, error)

// KeyRing for rotation (wire version 0x03 with embedded kid)
type KeyRing struct { /* ... */ }
func NewKeyRing(activeKid string, activeKey []byte) (*KeyRing, error)
func (r *KeyRing) Add(kid string, key []byte) error
func (r *KeyRing) Remove(kid string) error
func (r *KeyRing) SetActive(kid string) error
func (r *KeyRing) ActiveKid() string
func (r *KeyRing) Seal(plaintext, aad []byte) (string, error)
func (r *KeyRing) Open(ciphertext string, aad []byte) ([]byte, error)

// Time-locked tokens (embedded expiry; ErrExpired sentinel)
func IssueToken(key, payload []byte, ttl time.Duration, aad []byte) (string, error)
func VerifyToken(key []byte, token string, aad []byte) ([]byte, error)

// Streaming AEAD (chunked file encryption with truncation detection)
func SealStream(key []byte, r io.Reader, w io.Writer, chunkSize int) error
func OpenStream(key []byte, r io.Reader, w io.Writer) error

// Ed25519 public-key signatures
func GenerateEd25519() (publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey, err error)
func SignEd25519(priv ed25519.PrivateKey, data []byte) ([]byte, error)
func VerifyEd25519(pub ed25519.PublicKey, data, sig []byte) (bool, error)

// X25519 + ChaCha20-Poly1305 sealed-box (asymmetric encrypt; wire version 0x05)
func GenerateKeyPair() (publicKey, privateKey []byte, err error)
func SealAsymmetric(recipientPublicKey, plaintext []byte) (string, error)
func OpenAsymmetric(recipientPrivateKey []byte, ciphertext string) ([]byte, error)

// Envelope encryption (KMS-wrapped DEK; wire version 0x06)
type KMS interface { /* ... */ }
type StaticKMS struct { /* ... */ }       // in-memory adapter for tests/dev
func NewStaticKMS() *StaticKMS
type EnvelopeSealer struct { /* ... */ }
func NewEnvelopeSealer(kms KMS, keyID string) *EnvelopeSealer

Full reference at pkg.go.dev.


Binding & rotating keys — design notes

A few questions come up often enough to answer here, since the choices are deliberate.

"Can I set one global encrypt key instead of passing it everywhere?" Use a Sealer, not a global. NewSealer(key) binds the key once at startup; downstream code then calls sealer.Seal(pt, aad) / sealer.Open(ct, aad) with no key argument. That gives you the "set once, call bare" ergonomics without a package-level default. There is intentionally no SetDefaultKey / 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's set. Prefer storing the Sealer on your app/DI struct over hand-rolling encryptToken(key, ...) wrapper functions.

"Can a Sealer update its key on the fly?" No — a Sealer is immutable by design and has no Rekey method. The key is bound at NewSealer and never changes, which is exactly what makes a Sealer safe to share across goroutines with no lock. A mutable key would force a mutex or atomic load on every Seal/Open to serve a swap that happens rarely, and a hard swap would instantly make all previously-sealed ciphertext undecryptable.

"Then how do I rotate keys?" Pick by need:

  • KeyRing — restart-free rotation with a grace window. Writes use the active key; reads dispatch by the kid embedded in each ciphertext, so old data stays readable while it migrates. This is the right tool for compliance rotation and compromise response.
  • Swap the whole Sealer at a safe boundary (e.g. config reload) behind atomic.Pointer[Sealer]. Cheap and race-free, but single-key only — old ciphertext isn't readable after the swap unless you keep the old Sealer around. Note: reassigning a plain *Sealer variable while other goroutines are calling it is a data race; use atomic.Pointer.

Documentation

  • USAGE.md — long-form guide, every common pattern explained
  • RECIPES.md — short copy-pasteable snippets, organized by task
  • examples/ — 17 runnable end-to-end programs (sessions, magic links, CSRF, audit logs, encrypted files, key rotation, multi-tenant, ...)
  • SECURITY.md — threat model, what's defended, what isn't
  • WIRE_FORMAT.md — byte-by-byte ciphertext spec for cross-language interop
  • MIGRATION.md — moving from v0.x AES-CBC to v1 AES-GCM
  • BENCHMARKS.md — real numbers and what they mean
  • FAQ.md — answers to questions you'll have
  • CHANGELOG.md

TypeScript counterpart

crypt-ts — same API surface (minus password hashing — server-side only), same wire format, byte-for-byte interoperable.

import { seal, open } from "@ubgo/crypt"
const ct = seal(sharedKey, "hello")
const pt = open(sharedKey, ct).toString("utf8") // identical to Go

Install

go get github.com/ubgo/crypt

Requires Go 1.25 or later.


Status

  • v1.0.0 — frozen API. AEAD, random, sign, password, AES-CBC, migration helper.
  • v1.1+ — additive features per FEATURES.md: HKDF helper, multi-key KeyRing for rotation, ChaCha20-Poly1305 for non-AES-NI hardware. No breaking changes.
  • v2.0 — KMS adapter interface, asymmetric primitives (X25519, Ed25519). Roadmap.

Reporting vulnerabilities

Open a private security advisory: https://github.com/ubgo/crypt/security/advisories/new

We aim to acknowledge within 48 hours and patch P0 issues within 7 days.

License

Apache License 2.0

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):

  • Seal / Open — package-level form
  • Sealer — bound-key form, no globals

Random:

Signing (HMAC-SHA256):

Password hashing (argon2id):

Legacy (AES-CBC, deprecated):

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

Examples

Constants

View Source
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
)
View Source
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.

View Source
const DefaultBcryptCost = 12

DefaultBcryptCost is bcrypt's recommended cost factor as of 2026. Higher = slower = more brute-force resistant. Each +1 doubles work.

View Source
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
)
View Source
const (
	// X25519KeySize is the size of an X25519 public/private key (32 bytes).
	X25519KeySize = curve25519.ScalarSize
)

Variables

View Source
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.

View Source
var ErrExpired = errors.New("crypt: token expired")

ErrExpired is returned when a token's embedded expiry is in the past.

View Source
var ErrInvalidSignature = errors.New("crypt: invalid signature")

ErrInvalidSignature is returned by SignEd25519 / VerifyEd25519 when inputs have wrong shape.

View Source
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

func ConstantTimeEqual(a, b []byte) bool

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

func DecryptCBC(key []byte, ciphertext string) ([]byte, error)

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 DecryptWithKey(key, ciphertext string) (string, error)

DecryptWithKey is the package-level form of Cipher.Decrypt.

Deprecated: prefer Open. CBC cannot detect tampering.

func DeriveKey added in v0.2.0

func DeriveKey(masterKey, salt, info []byte, length int) ([]byte, error)

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

func EncryptCBC(key, plaintext []byte) (string, error)

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 EncryptWithKey(key, plaintext string) (string, error)

EncryptWithKey is the package-level form of Cipher.Encrypt.

Deprecated: prefer Seal for authenticated encryption.

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

func GenerateKeyPair() (publicKey, privateKey []byte, err error)

GenerateKeyPair returns a fresh X25519 keypair for use with SealAsymmetric / OpenAsymmetric.

func HashPassword added in v0.2.0

func HashPassword(plaintext string) (string, error)

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

func HashPasswordBcrypt(plaintext string, cost int) (string, error)

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

func IssueToken(key, payload []byte, ttl time.Duration, aad []byte) (string, error)

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

func Open(key []byte, ciphertext string, aad []byte) ([]byte, error)

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

func OpenAsymmetric(recipientPrivateKey []byte, ciphertext string) ([]byte, error)

OpenAsymmetric decrypts a SealAsymmetric output using the recipient's private key.

func OpenAuto added in v0.2.0

func OpenAuto(key []byte, ciphertext string, aad []byte) ([]byte, error)

OpenAuto attempts to decrypt ciphertext that may be in either AES-GCM (base64url, version byte 0x01) or AES-CBC (hex) format.

Dispatch rules:

  1. If ciphertext base64url-decodes AND first byte is 0x01 AND it's long enough to contain header + tag → call Open.
  2. Else if ciphertext hex-decodes AND length looks like CBC → call DecryptCBC. AAD is ignored (CBC has no AAD).
  3. 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

func OpenChaCha20(key []byte, ciphertext string, aad []byte) ([]byte, error)

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

func OpenStream(key []byte, r io.Reader, w io.Writer) error

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

func RandomBytes(n int) ([]byte, error)

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

func RandomHex(n int) (string, error)

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

func RandomToken(n int) (string, error)

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

func Seal(key, plaintext, aad []byte) (string, error)

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

func SealAsymmetric(recipientPublicKey, plaintext []byte) (string, error)

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

func SealChaCha20(key, plaintext, aad []byte) (string, error)

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

func SealStream(key []byte, r io.Reader, w io.Writer, chunkSize int) error

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

func Sign(key, data []byte) []byte

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

func Verify(key, data, mac []byte) bool

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

func VerifyEd25519(pub ed25519.PublicKey, data, sig []byte) (bool, error)

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

func VerifyPassword(plaintext, stored string) (bool, error)

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

func VerifyPasswordBcrypt(plaintext, hash string) (bool, error)

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

func VerifyToken(key []byte, token string, aad []byte) ([]byte, error)

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.

func New deprecated

func New(key string) (*Cipher, error)

New constructs a Cipher from a 16, 24, or 32-byte key string.

Deprecated: prefer NewSealer for AES-256-GCM authenticated encryption.

func (*Cipher) Decrypt deprecated

func (c *Cipher) Decrypt(ciphertext string) (string, error)

Decrypt reverses Encrypt.

Deprecated: prefer Sealer.Open. CBC cannot detect tampering.

func (*Cipher) Encrypt deprecated

func (c *Cipher) Encrypt(plaintext string) (string, error)

Encrypt PKCS#7-pads the plaintext, AES-CBC encrypts it with a fresh random IV, and returns hex(IV || ciphertext).

Deprecated: prefer Sealer.Seal for authenticated encryption.

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

func (*EnvelopeSealer) Open added in v0.2.0

func (e *EnvelopeSealer) Open(ctx context.Context, ciphertext string, aad []byte) ([]byte, error)

Open decrypts an EnvelopeSealer.Seal output by unwrapping the DEK via the KMS and then decrypting the inner ciphertext.

func (*EnvelopeSealer) Seal added in v0.2.0

func (e *EnvelopeSealer) Seal(ctx context.Context, plaintext, aad []byte) (string, error)

Seal encrypts plaintext using a fresh DEK from the KMS.

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

func NewKeyRing(activeKid string, activeKey []byte) (*KeyRing, error)

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) ActiveKid added in v0.2.0

func (r *KeyRing) ActiveKid() string

ActiveKid returns the kid of the currently-active key.

func (*KeyRing) Add added in v0.2.0

func (r *KeyRing) Add(kid string, key []byte) error

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

func (r *KeyRing) Open(ciphertext string, aad []byte) ([]byte, error)

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

func (r *KeyRing) Remove(kid string) error

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.

func (*KeyRing) Seal added in v0.2.0

func (r *KeyRing) Seal(plaintext, aad []byte) (string, error)

Seal encrypts under the active key, tagging the ciphertext with the active kid.

func (*KeyRing) SetActive added in v0.2.0

func (r *KeyRing) SetActive(kid string) error

SetActive marks an existing kid as active. Subsequent Seal calls use the new active key. Old ciphertexts remain readable.

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

func NewSealer(key []byte) (*Sealer, error)

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) Open added in v0.2.0

func (s *Sealer) Open(ciphertext string, aad []byte) ([]byte, error)

Open decrypts a Seal-produced ciphertext.

func (*Sealer) Seal added in v0.2.0

func (s *Sealer) Seal(plaintext, aad []byte) (string, error)

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.

func (*StaticKMS) AddKey added in v0.2.0

func (s *StaticKMS) AddKey(keyID string, key []byte) error

AddKey registers a 32-byte key under keyID.

func (*StaticKMS) Decrypt added in v0.2.0

func (s *StaticKMS) Decrypt(_ context.Context, keyID string, wrapped []byte) ([]byte, error)

Decrypt unwraps a wrapped DEK.

func (*StaticKMS) Encrypt added in v0.2.0

func (s *StaticKMS) Encrypt(_ context.Context, keyID string, plaintext []byte) ([]byte, error)

Encrypt wraps arbitrary plaintext.

func (*StaticKMS) GenerateDataKey added in v0.2.0

func (s *StaticKMS) GenerateDataKey(_ context.Context, keyID string) ([]byte, []byte, error)

GenerateDataKey for StaticKMS returns a fresh 32-byte DEK and that DEK encrypted under the KEK using crypt.Seal.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL