Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,70 @@ jobs:
- name: Stop vault
if: always()
run: docker stop vault || true

pgp:
name: pgp
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
check-latest: true

- name: Verify gpg
run: gpg --version

- name: Run pgp integration tests
run: go test -tags integration -v ./pgp/...

kms:
name: kms
runs-on: ubuntu-latest
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ENDPOINT_URL_KMS: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_REGION: us-east-1
AWS_DEFAULT_REGION: us-east-1
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
check-latest: true

- name: Start localstack
run: |
docker run -d --name localstack \
-p 4566:4566 \
-e SERVICES=kms \
-e DEFAULT_REGION=us-east-1 \
localstack/localstack:3

- name: Wait for localstack
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:4566/_localstack/health > /dev/null; then
exit 0
fi
sleep 1
done
echo "localstack did not become ready"
docker logs localstack
exit 1

- name: Create kms key
run: |
ARN=$(aws kms create-key --query 'KeyMetadata.Arn' --output text)
echo "CIPHER_TEST_KMS_ARN=$ARN" >> "$GITHUB_ENV"

- name: Run kms integration tests
run: go test -tags integration -v ./kms/...

- name: Stop localstack
if: always()
run: docker stop localstack || true
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to cipher are recorded here. The format follows [Keep a Chan

## Unreleased

### Added

- Round trip integration tests for the pgp backend using a throwaway gpg keyring and the aws kms backend using a localstack container. CI runs both on every pull request so real api drift is caught early.

## v0.2.0 (2026-06-24)

### Breaking

- `EncoderOptions.MACOnlyEncrypted bool` is now `EncoderOptions.MAC MACMode`. Library callers using `MACOnlyEncrypted: true` should switch to `MAC: cipher.MACOnEncrypted`. The tri-state `MACInherit / MACOnAll / MACOnEncrypted` lets router rules flip the mode in either direction instead of only enabling it.
Expand Down
52 changes: 52 additions & 0 deletions kms/kms_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build integration

// Integration tests for the AWS KMS backend. Skipped unless
// CIPHER_TEST_KMS_ARN points at a usable KMS key. The integration CI
// job runs a LocalStack container, creates a KMS key inside it, and
// passes the ARN through this env var. Local runs can target real
// AWS or a LocalStack instance the same way.
package kms_test

import (
"bytes"
"context"
"os"
"testing"

"github.com/dcadolph/cipher"
"github.com/dcadolph/cipher/kms"
)

// TestKMSIntegrationRoundTrip encrypts a plaintext through cipher
// using the kms provider, then decrypts the bytes and verifies the
// round trip. Reads the target key ARN from CIPHER_TEST_KMS_ARN.
func TestKMSIntegrationRoundTrip(t *testing.T) {
arn := os.Getenv("CIPHER_TEST_KMS_ARN")
if arn == "" {
t.Skip("set CIPHER_TEST_KMS_ARN to run aws kms integration tests")
}

kp, err := kms.NewProvider(arn)
if err != nil {
t.Fatalf("kms.NewProvider: %v", err)
}

plain := []byte("api_key: sk-kms-integration-2026\n")
enc := cipher.NewEncoder(kp)
ciphertext, err := enc.Encode(context.Background(), "secrets.yaml", plain)
if err != nil {
t.Fatalf("encode: %v", err)
}
if bytes.Contains(ciphertext, []byte("sk-kms-integration-2026")) {
t.Fatal("plaintext leaked into ciphertext")
}

dec := cipher.NewDecoder()
round, err := dec.Decode(context.Background(), "secrets.yaml", ciphertext)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(plain, round) {
t.Errorf("round trip mismatch\n want: %q\n got: %q", plain, round)
}
}
96 changes: 96 additions & 0 deletions pgp/pgp_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//go:build integration

// Integration tests for the PGP backend. Skipped when gpg is not on
// PATH. Uses a fresh GNUPGHOME so generated keys never touch the
// caller's keyring.
package pgp_test

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/dcadolph/cipher"
"github.com/dcadolph/cipher/pgp"
)

// TestPGPIntegrationRoundTrip generates a throwaway RSA key in an
// isolated GNUPGHOME, encrypts a plaintext through cipher and the
// pgp provider, then decrypts the bytes and checks they match.
func TestPGPIntegrationRoundTrip(t *testing.T) {
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("gpg not on PATH")
}
gnupgHome := t.TempDir()
t.Setenv("GNUPGHOME", gnupgHome)

fp := generateKey(t, gnupgHome)

kp, err := pgp.NewProvider(fp)
if err != nil {
t.Fatalf("pgp.NewProvider: %v", err)
}

plain := []byte("api_key: sk-pgp-integration-2026\n")
enc := cipher.NewEncoder(kp)
ciphertext, err := enc.Encode(context.Background(), "secrets.yaml", plain)
if err != nil {
t.Fatalf("encode: %v", err)
}
if bytes.Contains(ciphertext, []byte("sk-pgp-integration-2026")) {
t.Fatal("plaintext leaked into ciphertext")
}

dec := cipher.NewDecoder()
round, err := dec.Decode(context.Background(), "secrets.yaml", ciphertext)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(plain, round) {
t.Errorf("round trip mismatch\n want: %q\n got: %q", plain, round)
}
}

// generateKey writes a gpg batch script then asks gpg to create a
// 2048 bit RSA key with no passphrase. Returns the primary key
// fingerprint. 2048 is enough for tests and finishes in a few
// seconds even on slow CI runners.
func generateKey(t *testing.T, gnupgHome string) string {
t.Helper()
batch := filepath.Join(gnupgHome, "batch")
script := []byte(`%no-protection
Key-Type: RSA
Key-Length: 2048
Name-Real: Cipher Test
Name-Email: cipher-test@example.com
Expire-Date: 0
%commit
`)
if err := os.WriteFile(batch, script, 0o600); err != nil {
t.Fatalf("write batch: %v", err)
}
if out, err := exec.Command("gpg", "--batch", "--gen-key", batch).CombinedOutput(); err != nil {
t.Fatalf("gen-key: %v\n%s", err, out)
}
out, err := exec.Command(
"gpg", "--list-keys", "--with-colons", "cipher-test@example.com",
).Output()
if err != nil {
t.Fatalf("list-keys: %v", err)
}
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "fpr:") {
continue
}
parts := strings.Split(line, ":")
if len(parts) >= 10 && parts[9] != "" {
return parts[9]
}
}
t.Fatal("no fingerprint in gpg --list-keys output")
return ""
}
Loading