diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..93833f2 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,5 @@ +name: CodeQL config + +paths-ignore: + - mocks + - testdata diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..56c1614 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,59 @@ +# Development Guidelines + +This document contains the critical information about working with the project codebase. +Follows these guidelines precisely to ensure consistency and maintainability of the code. + +## Stack + +- Language: Go (Go 1.26.3) +- Framework: Go standard library +- Testing: Go's built-in testing package +- Dependency Management: Go modules +- Version Control: Git +- Documentation: go doc +- Code Review: Pull requests on GitHub +- CI/CD: GitHub Actions + +## Project Structure + +Since this is a library build in native go, the files are mostly organized following the standard Go project layout with some additional folders for specific functionalities. + +- Library files are located in the root directory. +- examples/ contains example code demonstrating how to use the library. +- .github/ contains GitHub-specific files such as workflows for CI/CD. +- .gitignore specifies files and directories to be ignored by Git. +- .vscode/ contains Visual Studio Code configuration files. +- LICENSE is the license file for the project. +- README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. +- go.mod declares the module and Go toolchain version. +- go.sum should not exist unless external dependencies are intentionally introduced. +- \*.go files contain the main source code of the library. +- \*\_test.go files contain the test cases for the library. + +## Code Style + +- Follow Go's idiomatic style defined in + - + - + - + - +- Use meaningful names for variables, functions, and packages. +- Keep functions small and focused on a single task. +- Use comments to explain complex logic or decisions. +- Use dependency injection for services and repositories to facilitate testing and maintainability. +- don't use `interface{}` instead use `any` for better readability. + +## Post-Change Checklist + +Use standard Go commands after making changes: + +```bash +go fix ./... +go fmt ./... +go vet ./... +betteralign -apply ./... +go test -race -coverprofile=/tmp/e5t-coverage.txt -covermode=atomic ./... +go build ./... +``` + +Do not add external module dependencies without explicit approval; this project is intentionally standard-library-only. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7fbaf5f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a032ef7 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,16 @@ +changelog: + categories: + - title: Breaking Changes + labels: + - Semver-Major + - breaking-change + - title: New Features + labels: + - Semver-Minor + - enhancement + - title: Security + labels: + - security + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d664519 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL Advanced + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "10 12 * * 3" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..39c3293 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,81 @@ +name: Main + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Push Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Push:** ${{ github.event.head_commit.message }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.head_commit.author.name }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Tools and versions + run: | + echo "## Tools and versions" >> "$GITHUB_STEP_SUMMARY" + echo "**Ubuntu Version:** $(lsb_release -ds)" >> "$GITHUB_STEP_SUMMARY" + echo "**Bash Version:** $(bash --version | head -n 1 | awk '{print $4}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Git Version:** $(git --version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Go Version:** $(go version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + echo "## Format check" >> "$GITHUB_STEP_SUMMARY" + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + echo "The files above need gofmt." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "All Go files are gofmt-formatted." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Vet + run: | + echo "## Vet" >> "$GITHUB_STEP_SUMMARY" + go vet ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test + run: | + echo "## Test report" >> "$GITHUB_STEP_SUMMARY" + go test -race -coverprofile=coverage.txt -covermode=atomic ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> "$GITHUB_STEP_SUMMARY" + + - name: Build + run: | + echo "## Build" >> "$GITHUB_STEP_SUMMARY" + go build ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "Build completed successfully." >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..2e7ab90 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,58 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Pull Request Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.pull_request.user.login }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commits:** ${{ github.event.pull_request.commits }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Additions:** ${{ github.event.pull_request.additions }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: go tool cover -func=coverage.txt + + - name: Build + run: go build ./... \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..04878c1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + actions: write + contents: write + id-token: write + packages: write + pull-requests: read + security-events: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Release Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Actor:** ${{ github.triggering_actor }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commit ID:** ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Tag:** ${{ github.ref_name }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + make_latest: true diff --git a/.gitignore b/.gitignore index aaadf73..e4ab336 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # Code coverage profiles and other test artifacts *.out +coverage.txt coverage.* *.coverprofile profile.cov diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..e4af670 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,21 @@ +version: "2" +linters: + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + settings: + errcheck: + check-type-assertions: false + check-blank: false + disable-default-exclusions: true + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - fmt.Print + - fmt.Printf + - fmt.Println + - fmt.Fprint + - fmt.Fprintf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..67d2346 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "cSpell.words": [ + "betteralign", + "Ciphertext", + "Errorf", + "ncryp", + "nolint", + "slashdevops" + ], + "chat.tools.terminal.autoApprove": { + "gofmt": true, + "test": true, + "/^ruby -e 'require \"yaml\"; ARGV\\.each \\{ \\|f\\| begin; YAML\\.load_file\\(f\\); puts \"yaml ok: #\\{f\\}\"; rescue => e; puts \"yaml error: #\\{f\\}: #\\{e\\.message\\}\"; exit 1; end \\}' \\.github/dependabot\\.yml \\.github/release\\.yml \\.github/codeql/codeql-config\\.yml \\.github/workflows/main\\.yml \\.github/workflows/pr\\.yml \\.github/workflows/codeql\\.yml \\.github/workflows/release\\.yml \\.golangci\\.yaml$/": { + "approve": true, + "matchCommandLine": true + }, + "actionlint": true + } +} \ No newline at end of file diff --git a/README.md b/README.md index 94ba21e..dccb1bd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,279 @@ # e5t -Provides simple and secure AES-256-GCM encryption and decryption. + +[![main branch](https://github.com/slashdevops/e5t/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/e5t/actions/workflows/main.yml) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/e5t?style=plastic) +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/e5t.svg)](https://pkg.go.dev/github.com/slashdevops/e5t) +[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/e5t)](https://goreportcard.com/report/github.com/slashdevops/e5t) +[![license](https://img.shields.io/github/license/slashdevops/e5t.svg)](https://github.com/slashdevops/e5t/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/e5t/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/e5t/actions/workflows/release.yml) + +`e5t` is a small Go package for AES-256-GCM encryption and decryption. It is designed for applications that need a compact, easy-to-review encryption helper with no external dependencies: only the Go standard library is used. + +The package encrypts byte slices, generates a fresh random nonce for every encryption operation, prefixes that nonce to the ciphertext, and can return either raw encrypted bytes or a hex-encoded string for storage and transport. + +## Features + +- AES-256-GCM authenticated encryption using `crypto/aes` and `crypto/cipher` +- Fresh random nonce generation with `crypto/rand` +- Raw byte and hex-encoded ciphertext APIs +- Deterministic 32-byte key helper based on SHA-256 +- Sentinel errors for invalid key size and short ciphertext handling +- Zero third-party dependencies +- Apache-2.0 licensed + +## Installation + +```sh +go get github.com/slashdevops/e5t +``` + +Update to the latest available version: + +```sh +go get -u github.com/slashdevops/e5t +``` + +## Requirements + +- Go 1.26.3 or newer +- No external Go modules + +## Quick Start + +```go +package main + +import ( + "fmt" + "log" + + "github.com/slashdevops/e5t" +) + +func main() { + key := e5t.GenerateHashKey("my-secret-password", "unique-salt") + + plaintext := []byte("sensitive data") + encrypted, err := e5t.EncryptAsString(plaintext, key) + if err != nil { + log.Fatal(err) + } + + decrypted, err := e5t.DecryptFromText(encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Println(string(decrypted)) +} +``` + +## API + +### GenerateHashKey + +```go +func GenerateHashKey(secret string, salt ...string) []byte +``` + +`GenerateHashKey` returns a 32-byte key by hashing `secret + salt` with SHA-256. The optional salt is useful when deriving separate keys for different environments, tenants, users, or data classes. + +```go +key := e5t.GenerateHashKey("application-secret", "production-config-v1") +``` + +This helper is intentionally simple and dependency-free. For user-entered passwords or high-risk secrets, prefer passing `Encrypt` and `Decrypt` a 32-byte key produced by your platform's key management system or by a dedicated password-based key derivation strategy. + +### Encrypt + +```go +func Encrypt(plaintext []byte, key []byte) ([]byte, error) +``` + +`Encrypt` encrypts `plaintext` with AES-256-GCM and returns raw bytes. The returned value is formatted as: + +```text +nonce || ciphertext || authentication-tag +``` + +Use this API when you want to store or transmit binary data directly. + +```go +encrypted, err := e5t.Encrypt([]byte("secret message"), key) +if err != nil { + return err +} +``` + +### EncryptAsString + +```go +func EncryptAsString(plaintext []byte, key []byte) (string, error) +``` + +`EncryptAsString` encrypts `plaintext` and returns the encrypted bytes as a hex string. Use it for text-only storage locations such as environment variables, JSON fields, fixtures, or database columns that expect text. + +```go +encrypted, err := e5t.EncryptAsString([]byte("secret message"), key) +if err != nil { + return err +} +``` + +### Decrypt + +```go +func Decrypt(ciphertext []byte, key []byte) ([]byte, error) +``` + +`Decrypt` reverses `Encrypt`. It expects raw encrypted bytes containing the nonce prefix generated by `Encrypt`. + +```go +decrypted, err := e5t.Decrypt(encrypted, key) +if err != nil { + return err +} +``` + +### DecryptFromText + +```go +func DecryptFromText(hexCiphertext string, key []byte) ([]byte, error) +``` + +`DecryptFromText` reverses `EncryptAsString`. It decodes the hex string and then decrypts the underlying bytes. + +```go +decrypted, err := e5t.DecryptFromText(encryptedText, key) +if err != nil { + return err +} +``` + +### VerifyEncryption + +```go +func VerifyEncryption(original []byte, encrypted string, key []byte) (bool, error) +``` + +`VerifyEncryption` decrypts a hex-encoded ciphertext and compares the result with `original`. + +```go +encrypted, err := e5t.EncryptAsString(original, key) +if err != nil { + return err +} + +match, err := e5t.VerifyEncryption(original, encrypted, key) +if err != nil { + return err +} +if !match { + return errors.New("decrypted data does not match original") +} +``` + +AES-GCM already authenticates ciphertext during decryption. Use `VerifyEncryption` when your application specifically needs to compare decrypted data with an expected plaintext value. + +## Error Handling + +The package exposes sentinel errors for common validation failures: + +```go +var ( + ErrInvalidKeySize = errors.New("key must be exactly 32 bytes for AES-256") + ErrCiphertextTooShort = errors.New("ciphertext shorter than nonce prefix") +) +``` + +Use `errors.Is` when branching on these errors: + +```go +decrypted, err := e5t.DecryptFromText(encrypted, key) +if err != nil { + switch { + case errors.Is(err, e5t.ErrInvalidKeySize): + return fmt.Errorf("invalid encryption key: %w", err) + case errors.Is(err, e5t.ErrCiphertextTooShort): + return fmt.Errorf("invalid ciphertext: %w", err) + default: + return fmt.Errorf("decrypt data: %w", err) + } +} +_ = decrypted +``` + +Other errors may come from the Go standard library, such as hex decoding errors or AES-GCM authentication failures when ciphertext is corrupted or the wrong key is used. + +## Security Notes + +- Keep encryption keys out of source control and application logs. +- Use a unique salt or context string when deriving separate keys with `GenerateHashKey`. +- Store or transmit the full encrypted output. The nonce prefix is required for decryption and is safe to store with the ciphertext. +- Reusing a key is expected; reusing a key and nonce pair is not. `Encrypt` and `EncryptAsString` generate a fresh nonce automatically. +- Losing the key means losing the ability to decrypt the data. +- A wrong key, modified ciphertext, or truncated ciphertext causes decryption to fail. +- `GenerateHashKey` is a convenience helper, not a memory-hard password hashing function. + +## Quality Automation + +The repository follows the same GitHub quality practices used by `slashdevops/httpx`: + +- `main.yml` validates pushes to `main` with format checks, `go vet`, race-enabled tests, coverage, and `go build`. +- `pr.yml` runs the same quality gate for pull requests targeting `main`. +- `codeql.yml` runs CodeQL for Go and GitHub Actions on pushes, pull requests, and a weekly schedule. +- `release.yml` validates tagged releases and publishes GitHub releases with generated notes. +- `dependabot.yml` checks Go module and GitHub Actions updates weekly. + +## Project Structure + +```text +. +|-- .github/ GitHub Actions, CodeQL, Dependabot, and release metadata +|-- .golangci.yaml Optional local golangci-lint configuration +|-- doc.go Package documentation rendered by pkg.go.dev +|-- e5t.go Public encryption API +|-- e5t_test.go Unit tests and benchmarks +|-- example_test.go Executable Go examples for documentation +|-- go.mod Module definition with no external requirements +|-- LICENSE Apache License 2.0 +|-- README.md Project overview and usage guide +`-- SECURITY.md Vulnerability reporting policy +``` + +## Testing + +Run the test suite: + +```sh +go test ./... +``` + +Run benchmarks: + +```sh +go test -bench=. ./... +``` + +Check test coverage: + +```sh +go test -cover ./... +``` + +Run the same local quality checks used by CI: + +```sh +go fmt ./... +go vet ./... +go test -race -coverprofile=/tmp/e5t-coverage.txt -covermode=atomic ./... +go build ./... +``` + +## License + +`e5t` is licensed under the [Apache License 2.0](LICENSE). + +## Contributing + +Issues and pull requests are welcome at [github.com/slashdevops/e5t](https://github.com/slashdevops/e5t). Please keep changes small, idiomatic, tested, documented, and dependency-free unless there is a clear reason to expand the project scope. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b575a4a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +This project uses GitHub CodeQL to scan for security vulnerabilities. + +[![CodeQL Advanced](https://github.com/slashdevops/e5t/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/e5t/actions/workflows/codeql.yml) + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| 0.0.x | Yes | + +## Reporting a Vulnerability + +Please report vulnerabilities through GitHub issues or the repository security advisory flow when available. Avoid posting sensitive exploit details publicly before maintainers have had time to respond. diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..46d083a --- /dev/null +++ b/doc.go @@ -0,0 +1,54 @@ +// Package e5t provides small AES-256-GCM encryption helpers built only on the +// Go standard library. +// +// The package encrypts and decrypts byte slices with AES in Galois/Counter Mode +// (GCM). GCM is an authenticated encryption mode: successful decryption proves +// that the ciphertext, authentication tag, and nonce match the supplied key. +// +// # Encryption Format +// +// [Encrypt] returns raw bytes in this format: +// +// nonce || ciphertext || authentication-tag +// +// [EncryptAsString] returns the same bytes encoded as hexadecimal text. +// [Decrypt] expects the raw byte format, and [DecryptFromText] expects the +// hex-encoded format. +// +// # Keys +// +// AES-256 requires a 32-byte key. [GenerateHashKey] is a convenience helper that +// returns 32 bytes by hashing a key string plus an optional salt with SHA-256: +// +// key := e5t.GenerateHashKey("application-secret", "config-v1") +// +// For higher-risk secrets or user-entered passwords, pass a 32-byte key produced +// by a dedicated key management or password-based key derivation strategy. +// +// # Basic Usage +// +// key := e5t.GenerateHashKey("my-secret-password", "unique-salt") +// +// encrypted, err := e5t.EncryptAsString([]byte("sensitive data"), key) +// if err != nil { +// log.Fatal(err) +// } +// +// decrypted, err := e5t.DecryptFromText(encrypted, key) +// if err != nil { +// log.Fatal(err) +// } +// +// fmt.Println(string(decrypted)) +// +// # Error Handling +// +// The package returns [ErrInvalidKeySize] when a key is not exactly 32 bytes and +// [ErrCiphertextTooShort] when encrypted input is shorter than the nonce prefix. +// Use errors.Is to branch on these sentinel errors. +// +// # Dependencies +// +// e5t has zero third-party dependencies. It uses crypto/aes, crypto/cipher, +// crypto/rand, crypto/sha256, encoding/hex, and other standard library packages. +package e5t diff --git a/e5t.go b/e5t.go new file mode 100644 index 0000000..cf68486 --- /dev/null +++ b/e5t.go @@ -0,0 +1,147 @@ +package e5t + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" +) + +// Sentinel errors for common error conditions. +var ( + // ErrInvalidKeySize is returned when a key is not exactly 32 bytes for AES-256. + ErrInvalidKeySize = errors.New("key must be exactly 32 bytes for AES-256") + + // ErrCiphertextTooShort is returned when ciphertext is shorter than the nonce prefix. + ErrCiphertextTooShort = errors.New("ciphertext shorter than nonce prefix") +) + +const aes256KeySize = 32 + +// GenerateHashKey creates a deterministic 32-byte key from secret and an optional salt. +// +// It hashes secret plus the first salt value with SHA-256. This helper is useful +// for deriving stable AES-256 keys from application secrets or context strings. +// For user-entered passwords or high-risk secrets, prefer a dedicated key +// management or password-based key derivation strategy. +func GenerateHashKey(secret string, salt ...string) []byte { + var saltValue string + if len(salt) > 0 { + saltValue = salt[0] + } + + input := secret + saltValue + hasher := sha256.Sum256([]byte(input)) + + return hasher[:] +} + +// EncryptAsString encrypts plaintext with AES-256-GCM and returns hex-encoded ciphertext. +// +// The decoded ciphertext is formatted as nonce followed by encrypted data and +// the GCM authentication tag. key must be exactly 32 bytes. +func EncryptAsString(plaintext []byte, key []byte) (string, error) { + ciphertext, err := Encrypt(plaintext, key) + if err != nil { + return "", err + } + + return hex.EncodeToString(ciphertext), nil +} + +// Encrypt encrypts plaintext with AES-256-GCM and returns raw encrypted bytes. +// +// The returned bytes are formatted as nonce followed by encrypted data and the +// GCM authentication tag. key must be exactly 32 bytes. +func Encrypt(plaintext []byte, key []byte) ([]byte, error) { + if len(key) != aes256KeySize { + return nil, ErrInvalidKeySize + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := randomNonce(gcm.NonceSize()) + + // Keep the nonce with the ciphertext so Decrypt can recover it. + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + + return ciphertext, nil +} + +// DecryptFromText decodes hexCiphertext and decrypts it with AES-256-GCM. +// +// hexCiphertext must be a value returned by EncryptAsString. key must be +// the same 32-byte key used for encryption. +func DecryptFromText(hexCiphertext string, key []byte) ([]byte, error) { + if len(key) != aes256KeySize { + return nil, ErrInvalidKeySize + } + + data, err := hex.DecodeString(hexCiphertext) + if err != nil { + return nil, err + } + + return Decrypt(data, key) +} + +// Decrypt decrypts raw bytes produced by Encrypt. +// +// ciphertext must include the nonce prefix generated during encryption. key +// must be the same 32-byte key used for encryption. +func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { + if len(key) != aes256KeySize { + return nil, ErrInvalidKeySize + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, ErrCiphertextTooShort + } + + // Split the nonce from the actual encrypted message. + nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:] + return gcm.Open(nil, nonce, encrypted, nil) +} + +// VerifyEncryption decrypts encrypted and compares the result with original. +// +// encrypted must be a hex-encoded ciphertext returned by EncryptAsString. The +// function returns false with a nil error when decryption succeeds but the +// decrypted data does not match original. +func VerifyEncryption(original []byte, encrypted string, key []byte) (bool, error) { + decrypted, err := DecryptFromText(encrypted, key) + if err != nil { + return false, err + } + + return bytes.Equal(original, decrypted), nil +} + +func randomNonce(size int) []byte { + nonce := make([]byte, size) + _, _ = rand.Read(nonce) // rand.Read always fills nonce and returns nil in Go 1.26. + + return nonce +} diff --git a/e5t_test.go b/e5t_test.go new file mode 100644 index 0000000..dfeff70 --- /dev/null +++ b/e5t_test.go @@ -0,0 +1,746 @@ +package e5t + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "testing" + "testing/cryptotest" +) + +func mustEncryptAsString(t *testing.T, plaintext []byte, key []byte) string { + t.Helper() + + encrypted, err := EncryptAsString(plaintext, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + return encrypted +} + +// TestGenerateHashKey verifies that key generation produces correct length and consistency. +func TestGenerateHashKey(t *testing.T) { + tests := []struct { + name string + key string + salt []string + expected int + }{ + { + name: "without salt", + key: "test-password", + salt: nil, + expected: 32, + }, + { + name: "with salt", + key: "test-password", + salt: []string{"random-salt"}, + expected: 32, + }, + { + name: "empty key without salt", + key: "", + salt: nil, + expected: 32, + }, + { + name: "empty key with salt", + key: "", + salt: []string{"salt"}, + expected: 32, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateHashKey(tt.key, tt.salt...) + if len(result) != tt.expected { + t.Errorf("GenerateHashKey() length = %d, want %d", len(result), tt.expected) + } + }) + } +} + +// TestGenerateHashKeyConsistency ensures the same input produces the same key. +func TestGenerateHashKeyConsistency(t *testing.T) { + key1 := GenerateHashKey("password", "salt") + key2 := GenerateHashKey("password", "salt") + + if !bytes.Equal(key1, key2) { + t.Errorf("GenerateHashKey(%q, %q) = %x and %x, want equal keys", "password", "salt", key1, key2) + } +} + +// TestGenerateHashKeyDifferentInputs ensures different inputs produce different keys. +func TestGenerateHashKeyDifferentInputs(t *testing.T) { + key1 := GenerateHashKey("password1", "salt") + key2 := GenerateHashKey("password2", "salt") + key3 := GenerateHashKey("password1", "salt2") + key4 := GenerateHashKey("password1") + + if bytes.Equal(key1, key2) { + t.Errorf("GenerateHashKey(%q, %q) = %x, want different from GenerateHashKey(%q, %q)", "password1", "salt", key1, "password2", "salt") + } + + if bytes.Equal(key1, key3) { + t.Errorf("GenerateHashKey(%q, %q) = %x, want different from GenerateHashKey(%q, %q)", "password1", "salt", key1, "password1", "salt2") + } + + if bytes.Equal(key1, key4) { + t.Errorf("GenerateHashKey(%q, %q) = %x, want different from GenerateHashKey(%q)", "password1", "salt", key1, "password1") + } +} + +// TestEncryptDecryptRoundTrip tests basic encryption and decryption. +func TestEncryptDecryptRoundTrip(t *testing.T) { + tests := []struct { + name string + plaintext []byte + }{ + { + name: "simple text", + plaintext: []byte("Hello, World!"), + }, + { + name: "empty string", + plaintext: []byte(""), + }, + { + name: "unicode text", + plaintext: []byte("Hello δΈ–η•Œ 🌍"), + }, + { + name: "large text", + plaintext: bytes.Repeat([]byte("a"), 10000), + }, + { + name: "binary data", + plaintext: []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}, + }, + } + + key := GenerateHashKey("test-password", "test-salt") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypted, err := EncryptAsString(tt.plaintext, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + if encrypted == "" { + t.Error("EncryptAsString() returned empty string") + } + + // Verify that the ciphertext is valid hex. + _, err = hex.DecodeString(encrypted) + if err != nil { + t.Errorf("hex.DecodeString(EncryptAsString()) error = %v", err) + } + + decrypted, err := DecryptFromText(encrypted, key) + if err != nil { + t.Fatalf("DecryptFromText() error = %v", err) + } + + if !bytes.Equal(tt.plaintext, decrypted) { + t.Errorf("DecryptFromText() = %v, want %v", decrypted, tt.plaintext) + } + }) + } +} + +// TestEncryptUniqueCiphertext verifies that encrypting the same plaintext twice produces different ciphertext. +func TestEncryptUniqueCiphertext(t *testing.T) { + plaintext := []byte("test message") + key := GenerateHashKey("password", "salt") + + encrypted1, err := EncryptAsString(plaintext, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + encrypted2, err := EncryptAsString(plaintext, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + if encrypted1 == encrypted2 { + t.Errorf("EncryptAsString(%q, key) produced duplicate ciphertext %q, want unique ciphertext", plaintext, encrypted1) + } + + decrypted1, err := DecryptFromText(encrypted1, key) + if err != nil { + t.Fatalf("DecryptFromText(encrypted1, key) error = %v", err) + } + + decrypted2, err := DecryptFromText(encrypted2, key) + if err != nil { + t.Fatalf("DecryptFromText(encrypted2, key) error = %v", err) + } + + if !bytes.Equal(decrypted1, plaintext) || !bytes.Equal(decrypted2, plaintext) { + t.Errorf("DecryptFromText() = %q and %q, want %q", decrypted1, decrypted2, plaintext) + } +} + +// TestEncryptWithDeterministicRandom verifies nonce generation through Go's test crypto source. +func TestEncryptWithDeterministicRandom(t *testing.T) { + plaintext := []byte("test message") + key := GenerateHashKey("password", "salt") + + var first []byte + t.Run("first", func(t *testing.T) { + cryptotest.SetGlobalRandom(t, 42) + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + first = encrypted + }) + + var second []byte + t.Run("second", func(t *testing.T) { + cryptotest.SetGlobalRandom(t, 42) + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + second = encrypted + }) + + if !bytes.Equal(first, second) { + t.Errorf("Encrypt() = %x and %x, want equal ciphertexts with fixed cryptotest random", first, second) + } + + decrypted, err := Decrypt(first, key) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + + if !bytes.Equal(plaintext, decrypted) { + t.Errorf("Decrypt() = %v, want %v", decrypted, plaintext) + } +} + +// TestEncryptInvalidKeyLength tests encryption with invalid key lengths. +func TestEncryptInvalidKeyLength(t *testing.T) { + tests := []struct { + name string + keyLen int + wantErr bool + }{ + { + name: "16-byte key (AES-128)", + keyLen: 16, + wantErr: true, + }, + { + name: "24-byte key (AES-192)", + keyLen: 24, + wantErr: true, + }, + { + name: "32-byte key (AES-256)", + keyLen: 32, + wantErr: false, + }, + { + name: "invalid short key", + keyLen: 8, + wantErr: true, + }, + { + name: "invalid long key", + keyLen: 64, + wantErr: true, + }, + { + name: "empty key", + keyLen: 0, + wantErr: true, + }, + } + + plaintext := []byte("test") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := make([]byte, tt.keyLen) + if _, err := rand.Read(key); err != nil { + t.Fatalf("rand.Read() error = %v", err) + } + + _, err := EncryptAsString(plaintext, key) + if (err != nil) != tt.wantErr { + t.Errorf("EncryptAsString() error = %v, want error presence %v", err, tt.wantErr) + } + + if tt.wantErr && !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("EncryptAsString() error = %v, want ErrInvalidKeySize", err) + } + }) + } +} + +// TestDecryptInvalidKeyLength tests decryption with invalid key lengths. +func TestDecryptInvalidKeyLength(t *testing.T) { + validKey := GenerateHashKey("password", "salt") + encrypted := mustEncryptAsString(t, []byte("test"), validKey) + + tests := []struct { + name string + keyLen int + }{ + {"16-byte key", 16}, + {"24-byte key", 24}, + {"8-byte key", 8}, + {"64-byte key", 64}, + {"empty key", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := make([]byte, tt.keyLen) + if _, err := rand.Read(key); err != nil { + t.Fatalf("rand.Read() error = %v", err) + } + + _, err := DecryptFromText(encrypted, key) + if err == nil { + t.Error("DecryptFromText() error = nil, want ErrInvalidKeySize") + } + + if !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("DecryptFromText() error = %v, want ErrInvalidKeySize", err) + } + }) + } +} + +// TestDecryptWithWrongKey verifies that decryption fails with wrong key. +func TestDecryptWithWrongKey(t *testing.T) { + plaintext := []byte("secret message") + key1 := GenerateHashKey("password1", "salt") + key2 := GenerateHashKey("password2", "salt") + + encrypted, err := EncryptAsString(plaintext, key1) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + _, err = DecryptFromText(encrypted, key2) + if err == nil { + t.Error("DecryptFromText() error = nil, want non-nil error for wrong key") + } +} + +// TestDecryptInvalidCiphertext tests decryption of invalid ciphertext. +func TestDecryptInvalidCiphertext(t *testing.T) { + key := GenerateHashKey("password", "salt") + + tests := []struct { + name string + ciphertext string + }{ + { + name: "invalid hex", + ciphertext: "not-hex-data", + }, + { + name: "empty string", + ciphertext: "", + }, + { + name: "too short", + ciphertext: "abcd", + }, + { + name: "corrupted data", + ciphertext: hex.EncodeToString([]byte("corrupted")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecryptFromText(tt.ciphertext, key) + if err == nil { + t.Error("DecryptFromText() error = nil, want non-nil error for invalid ciphertext") + } + }) + } +} + +// TestDecryptTamperedCiphertext verifies that tampering is detected. +func TestDecryptTamperedCiphertext(t *testing.T) { + plaintext := []byte("important message") + key := GenerateHashKey("password", "salt") + + encrypted, err := EncryptAsString(plaintext, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + // Tamper with the encrypted data + data, err := hex.DecodeString(encrypted) + if err != nil { + t.Fatalf("hex.DecodeString(encrypted) error = %v", err) + } + + if len(data) > 10 { + data[len(data)-1] ^= 0x01 // Flip a bit in the last byte + } + tamperedHex := hex.EncodeToString(data) + + _, err = DecryptFromText(tamperedHex, key) + if err == nil { + t.Error("DecryptFromText() error = nil, want non-nil error for tampered ciphertext") + } +} + +// TestSentinelErrors verifies that sentinel errors can be used with errors.Is. +func TestSentinelErrors(t *testing.T) { + t.Run("ErrInvalidKeySize in EncryptAsString", func(t *testing.T) { + shortKey := make([]byte, 16) + _, err := EncryptAsString([]byte("test"), shortKey) + if !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("EncryptAsString() error = %v, want ErrInvalidKeySize", err) + } + }) + + t.Run("ErrInvalidKeySize in DecryptFromText", func(t *testing.T) { + shortKey := make([]byte, 16) + _, err := DecryptFromText("abcd", shortKey) + if !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("DecryptFromText() error = %v, want ErrInvalidKeySize", err) + } + }) + + t.Run("ErrCiphertextTooShort", func(t *testing.T) { + key := GenerateHashKey("password", "salt") + _, err := DecryptFromText("abcd", key) + if !errors.Is(err, ErrCiphertextTooShort) { + t.Errorf("DecryptFromText() error = %v, want ErrCiphertextTooShort", err) + } + }) +} + +// BenchmarkGenerateHashKey benchmarks key generation. +func BenchmarkGenerateHashKey(b *testing.B) { + for i := 0; i < b.N; i++ { + GenerateHashKey("test-password", "test-salt") + } +} + +// BenchmarkGenerateHashKeyNoSalt benchmarks key generation without salt. +func BenchmarkGenerateHashKeyNoSalt(b *testing.B) { + for i := 0; i < b.N; i++ { + GenerateHashKey("test-password") + } +} + +// BenchmarkEncrypt benchmarks encryption performance. +func BenchmarkEncrypt(b *testing.B) { + key := GenerateHashKey("password", "salt") + plaintext := []byte("This is a test message for benchmarking encryption performance.") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := EncryptAsString(plaintext, key); err != nil { + b.Fatalf("EncryptAsString() error = %v", err) + } + } +} + +// BenchmarkDecrypt benchmarks decryption performance. +func BenchmarkDecrypt(b *testing.B) { + key := GenerateHashKey("password", "salt") + plaintext := []byte("This is a test message for benchmarking decryption performance.") + encrypted, err := EncryptAsString(plaintext, key) + if err != nil { + b.Fatalf("EncryptAsString() error = %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := DecryptFromText(encrypted, key); err != nil { + b.Fatalf("DecryptFromText() error = %v", err) + } + } +} + +// BenchmarkEncryptDecrypt benchmarks the full round-trip. +func BenchmarkEncryptDecrypt(b *testing.B) { + key := GenerateHashKey("password", "salt") + plaintext := []byte("This is a test message for benchmarking full round-trip performance.") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + encrypted, err := EncryptAsString(plaintext, key) + if err != nil { + b.Fatalf("EncryptAsString() error = %v", err) + } + + if _, err := DecryptFromText(encrypted, key); err != nil { + b.Fatalf("DecryptFromText() error = %v", err) + } + } +} + +// BenchmarkEncryptLargeData benchmarks encryption of larger data. +func BenchmarkEncryptLargeData(b *testing.B) { + key := GenerateHashKey("password", "salt") + plaintext := bytes.Repeat([]byte("a"), 1024*100) // 100KB + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := EncryptAsString(plaintext, key); err != nil { + b.Fatalf("EncryptAsString() error = %v", err) + } + } +} + +// TestVerifyEncryption tests the VerifyEncryption function. +func TestVerifyEncryption(t *testing.T) { + key := GenerateHashKey("password", "salt") + + tests := []struct { + name string + original []byte + setupFunc func(t *testing.T) string + wantMatch bool + wantErr bool + }{ + { + name: "matching data", + original: []byte("test message"), + setupFunc: func(t *testing.T) string { + t.Helper() + + encrypted, err := EncryptAsString([]byte("test message"), key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + return encrypted + }, + wantMatch: true, + wantErr: false, + }, + { + name: "non-matching data", + original: []byte("original message"), + setupFunc: func(t *testing.T) string { + t.Helper() + + encrypted, err := EncryptAsString([]byte("different message"), key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + return encrypted + }, + wantMatch: false, + wantErr: false, + }, + { + name: "empty data matches", + original: []byte(""), + setupFunc: func(t *testing.T) string { + t.Helper() + + encrypted, err := EncryptAsString([]byte(""), key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + return encrypted + }, + wantMatch: true, + wantErr: false, + }, + { + name: "unicode data", + original: []byte("Hello δΈ–η•Œ 🌍"), + setupFunc: func(t *testing.T) string { + t.Helper() + + encrypted, err := EncryptAsString([]byte("Hello δΈ–η•Œ 🌍"), key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + return encrypted + }, + wantMatch: true, + wantErr: false, + }, + { + name: "invalid encrypted data", + original: []byte("test"), + setupFunc: func(t *testing.T) string { + return "invalid-hex-data" + }, + wantMatch: false, + wantErr: true, + }, + { + name: "corrupted encrypted data", + original: []byte("test"), + setupFunc: func(t *testing.T) string { + return "abcd1234" + }, + wantMatch: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypted := tt.setupFunc(t) + match, err := VerifyEncryption(tt.original, encrypted, key) + + if (err != nil) != tt.wantErr { + t.Errorf("VerifyEncryption() error = %v, want error presence %v", err, tt.wantErr) + return + } + + if match != tt.wantMatch { + t.Errorf("VerifyEncryption() match = %v, want %v", match, tt.wantMatch) + } + }) + } +} + +// TestVerifyEncryptionWithWrongKey tests verification with wrong key. +func TestVerifyEncryptionWithWrongKey(t *testing.T) { + key1 := GenerateHashKey("password1", "salt") + key2 := GenerateHashKey("password2", "salt") + + original := []byte("secret message") + encrypted, err := EncryptAsString(original, key1) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + match, err := VerifyEncryption(original, encrypted, key2) + if err == nil { + t.Error("VerifyEncryption() error = nil, want non-nil error for wrong key") + } + + if match { + t.Error("VerifyEncryption() match = true, want false for wrong key") + } +} + +// TestVerifyEncryptionInvalidKeySize tests verification with invalid key size. +func TestVerifyEncryptionInvalidKeySize(t *testing.T) { + validKey := GenerateHashKey("password", "salt") + invalidKey := make([]byte, 16) + + original := []byte("test message") + encrypted, err := EncryptAsString(original, validKey) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + match, err := VerifyEncryption(original, encrypted, invalidKey) + if !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("VerifyEncryption() error = %v, want ErrInvalidKeySize", err) + } + + if match { + t.Error("VerifyEncryption() match = true, want false for invalid key") + } +} + +// TestVerifyEncryptionLargeData tests verification with large data. +func TestVerifyEncryptionLargeData(t *testing.T) { + key := GenerateHashKey("password", "salt") + original := bytes.Repeat([]byte("a"), 10000) + + encrypted, err := EncryptAsString(original, key) + if err != nil { + t.Fatalf("EncryptAsString() error = %v", err) + } + + match, err := VerifyEncryption(original, encrypted, key) + if err != nil { + t.Fatalf("VerifyEncryption() error = %v", err) + } + + if !match { + t.Error("VerifyEncryption() match = false, want true for matching large data") + } +} + +// BenchmarkVerifyEncryption benchmarks the verification process. +func BenchmarkVerifyEncryption(b *testing.B) { + key := GenerateHashKey("password", "salt") + original := []byte("This is a test message for benchmarking verification.") + encrypted, err := EncryptAsString(original, key) + if err != nil { + b.Fatalf("EncryptAsString() error = %v", err) + } + + for b.Loop() { + if _, err := VerifyEncryption(original, encrypted, key); err != nil { + b.Fatalf("VerifyEncryption() error = %v", err) + } + } +} + +// TestEncryptBytes tests the Encrypt function that returns []byte. +func TestEncryptBytes(t *testing.T) { + key := GenerateHashKey("password", "salt") + plaintext := []byte("test message") + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + if len(encrypted) == 0 { + t.Error("len(Encrypt()) = 0, want > 0") + } + + // Decrypt using hex-encoded string + hexEncrypted := hex.EncodeToString(encrypted) + decrypted, err := DecryptFromText(hexEncrypted, key) + if err != nil { + t.Fatalf("DecryptFromText() error = %v", err) + } + + if !bytes.Equal(plaintext, decrypted) { + t.Errorf("DecryptFromText() = %v, want %v", decrypted, plaintext) + } +} + +// TestEncryptBytesInvalidKey tests Encrypt with invalid key. +func TestEncryptBytesInvalidKey(t *testing.T) { + shortKey := make([]byte, 16) + _, err := Encrypt([]byte("test"), shortKey) + if !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("Encrypt() error = %v, want ErrInvalidKeySize", err) + } +} + +// BenchmarkEncryptBytes benchmarks the Encrypt function that returns []byte. +func BenchmarkEncryptBytes(b *testing.B) { + key := GenerateHashKey("password", "salt") + plaintext := []byte("This is a test message for benchmarking encryption performance.") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Encrypt(plaintext, key); err != nil { + b.Fatalf("Encrypt() error = %v", err) + } + } +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..8bc0b29 --- /dev/null +++ b/example_test.go @@ -0,0 +1,362 @@ +package e5t_test + +import ( + "fmt" + "log" + + "github.com/slashdevops/e5t" +) + +// ExampleGenerateHashKey demonstrates basic key generation. +func ExampleGenerateHashKey() { + // Generate a key without salt + key1 := e5t.GenerateHashKey("my-password") + fmt.Printf("Key length: %d bytes\n", len(key1)) + + // Generate a key with salt (recommended) + key2 := e5t.GenerateHashKey("my-password", "unique-salt") + fmt.Printf("Key with salt length: %d bytes\n", len(key2)) + + // Output: + // Key length: 32 bytes + // Key with salt length: 32 bytes +} + +// ExampleGenerateHashKey_withUserID demonstrates using user ID as salt. +func ExampleGenerateHashKey_withUserID() { + userID := "user-12345" + masterPassword := "app-master-secret" + + // Generate a unique key for this user + userKey := e5t.GenerateHashKey(masterPassword, userID) + fmt.Printf("Generated user-specific key: %d bytes\n", len(userKey)) + + // Output: + // Generated user-specific key: 32 bytes +} + +// ExampleEncryptAsString demonstrates basic encryption. +func ExampleEncryptAsString() { + // Generate an encryption key + key := e5t.GenerateHashKey("my-secret-password", "random-salt") + + // Encrypt some data + plaintext := []byte("Hello, World!") + encrypted, err := e5t.EncryptAsString(plaintext, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Encrypted data length: %d characters\n", len(encrypted)) + fmt.Println("Encrypted data is hex-encoded") + + // Output: + // Encrypted data length: 82 characters + // Encrypted data is hex-encoded +} + +// ExampleEncrypt demonstrates basic encryption returning raw bytes. +func ExampleEncrypt() { + // Generate an encryption key + key := e5t.GenerateHashKey("my-secret-password", "random-salt") + + // Encrypt some data + plaintext := []byte("Hello, World!") + encrypted, err := e5t.Encrypt(plaintext, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Encrypted data length: %d bytes\n", len(encrypted)) + fmt.Println("Encrypted data is raw bytes") + + // Output: + // Encrypted data length: 41 bytes + // Encrypted data is raw bytes +} + +// ExampleDecryptFromText demonstrates basic decryption from hex-encoded string. +func ExampleDecryptFromText() { + key := e5t.GenerateHashKey("my-secret-password", "random-salt") + + // Encrypt + plaintext := []byte("Secret Message") + encrypted, err := e5t.EncryptAsString(plaintext, key) + if err != nil { + log.Fatal(err) + } + + // Decrypt + decrypted, err := e5t.DecryptFromText(encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Println(string(decrypted)) + + // Output: + // Secret Message +} + +// ExampleEncryptAsString_roundTrip demonstrates a complete encryption and decryption cycle. +func ExampleEncryptAsString_roundTrip() { + // Create a key + key := e5t.GenerateHashKey("secure-password", "app-salt-v1") + + // Original message + message := []byte("This is a confidential message.") + + // Encrypt + encrypted, err := e5t.EncryptAsString(message, key) + if err != nil { + log.Fatal(err) + } + fmt.Println("Message encrypted successfully") + + // Decrypt + decrypted, err := e5t.DecryptFromText(encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Decrypted: %s\n", string(decrypted)) + + // Output: + // Message encrypted successfully + // Decrypted: This is a confidential message. +} + +// ExampleEncryptAsString_apiKey demonstrates encrypting an API key. +func ExampleEncryptAsString_apiKey() { + // Encrypt a sensitive API key + apiKey := []byte("sk_live_1234567890abcdef") + encryptionKey := e5t.GenerateHashKey("master-key", "api-keys-v1") + + encrypted, err := e5t.EncryptAsString(apiKey, encryptionKey) + if err != nil { + log.Fatal(err) + } + + fmt.Println("API key encrypted") + + // Later, decrypt it + decrypted, err := e5t.DecryptFromText(encrypted, encryptionKey) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Decrypted API key: %s\n", string(decrypted)) + + // Output: + // API key encrypted + // Decrypted API key: sk_live_1234567890abcdef +} + +// ExampleEncryptAsString_multipleEncryptions demonstrates that the same plaintext encrypts differently each time. +func ExampleEncryptAsString_multipleEncryptions() { + key := e5t.GenerateHashKey("password", "salt") + message := []byte("same message") + + // Encrypt the same message twice + encrypted1, err := e5t.EncryptAsString(message, key) + if err != nil { + log.Fatal(err) + } + + encrypted2, err := e5t.EncryptAsString(message, key) + if err != nil { + log.Fatal(err) + } + + // The encrypted values will be different + fmt.Printf("First encryption length: %d\n", len(encrypted1)) + fmt.Printf("Second encryption length: %d\n", len(encrypted2)) + fmt.Println("Each encryption produces unique ciphertext") + + // But both decrypt to the same message + decrypted1, err := e5t.DecryptFromText(encrypted1, key) + if err != nil { + log.Fatal(err) + } + + decrypted2, err := e5t.DecryptFromText(encrypted2, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Both decrypt correctly: %t\n", string(decrypted1) == string(decrypted2)) + + // Output: + // First encryption length: 80 + // Second encryption length: 80 + // Each encryption produces unique ciphertext + // Both decrypt correctly: true +} + +// ExampleEncryptAsString_config demonstrates encrypting configuration data. +func ExampleEncryptAsString_config() { + // Simulate encrypting sensitive configuration + config := []byte(`{"db_password":"secret123","api_key":"abc-xyz"}`) + + // Use application-specific key and salt + key := e5t.GenerateHashKey("app-master-key", "config-v1") + + encrypted, err := e5t.EncryptAsString(config, key) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Configuration encrypted") + + // Decrypt when needed + decrypted, err := e5t.DecryptFromText(encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Decrypted config length: %d bytes\n", len(decrypted)) + + // Output: + // Configuration encrypted + // Decrypted config length: 47 bytes +} + +// ExampleDecrypt_wrongKey demonstrates error handling with wrong key. +func ExampleDecrypt_wrongKey() { + // Encrypt with one key + key1 := e5t.GenerateHashKey("password1", "salt") + encrypted, err := e5t.EncryptAsString([]byte("secret"), key1) + if err != nil { + log.Fatal(err) + } + + // Try to decrypt with wrong key + key2 := e5t.GenerateHashKey("password2", "salt") + _, err = e5t.DecryptFromText(encrypted, key2) + if err != nil { + fmt.Println("Decryption failed with wrong key") + } + + // Output: + // Decryption failed with wrong key +} + +// ExampleEncryptAsString_userSpecific demonstrates per-user encryption. +func ExampleEncryptAsString_userSpecific() { + masterKey := "application-master-key" + + // Encrypt data for user 1 + user1ID := "user-001" + user1Key := e5t.GenerateHashKey(masterKey, user1ID) + user1Data := []byte("User 1 private data") + encrypted1, err := e5t.EncryptAsString(user1Data, user1Key) + if err != nil { + log.Fatal(err) + } + + // Encrypt data for user 2 + user2ID := "user-002" + user2Key := e5t.GenerateHashKey(masterKey, user2ID) + user2Data := []byte("User 2 private data") + encrypted2, err := e5t.EncryptAsString(user2Data, user2Key) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Each user has their own encrypted data") + + // Each user can only decrypt their own data + decrypted1, err := e5t.DecryptFromText(encrypted1, user1Key) + if err != nil { + log.Fatal(err) + } + fmt.Printf("User 1 data: %s\n", string(decrypted1)) + + decrypted2, err := e5t.DecryptFromText(encrypted2, user2Key) + if err != nil { + log.Fatal(err) + } + fmt.Printf("User 2 data: %s\n", string(decrypted2)) + + // Output: + // Each user has their own encrypted data + // User 1 data: User 1 private data + // User 2 data: User 2 private data +} + +// ExampleVerifyEncryption demonstrates verifying encrypted data matches original data. +func ExampleVerifyEncryption() { + key := e5t.GenerateHashKey("my-password", "my-salt") + + original := []byte("Important data to verify") + encrypted, err := e5t.EncryptAsString(original, key) + if err != nil { + log.Fatal(err) + } + + // Verify the encrypted data matches the original + match, err := e5t.VerifyEncryption(original, encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Data matches: %t\n", match) + + // Output: + // Data matches: true +} + +// ExampleVerifyEncryption_mismatch demonstrates verification with mismatched data. +func ExampleVerifyEncryption_mismatch() { + key := e5t.GenerateHashKey("password", "salt") + + original := []byte("Original message") + different := []byte("Different message") + + // Encrypt different data + encrypted, err := e5t.EncryptAsString(different, key) + if err != nil { + log.Fatal(err) + } + + // Verify against original (should not match) + match, err := e5t.VerifyEncryption(original, encrypted, key) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Data matches: %t\n", match) + + // Output: + // Data matches: false +} + +// ExampleVerifyEncryption_integrityCheck demonstrates using VerifyEncryption for integrity checking. +func ExampleVerifyEncryption_integrityCheck() { + key := e5t.GenerateHashKey("secure-key", "app-v1") + + // Simulate storing data + userData := []byte("User profile data") + encryptedData, err := e5t.EncryptAsString(userData, key) + if err != nil { + log.Fatal(err) + } + + // Store encryptedData in database/file... + // Later, retrieve and verify integrity + + verified, err := e5t.VerifyEncryption(userData, encryptedData, key) + if err != nil { + fmt.Println("Verification failed:", err) + return + } + + if verified { + fmt.Println("Data integrity verified successfully") + } else { + fmt.Println("Data integrity check failed - data was modified") + } + + // Output: + // Data integrity verified successfully +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5491933 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/slashdevops/e5t + +go 1.26.3