diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 43f07c4..406ea14 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3240ae..65c98a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/kms/kms_integration_test.go b/kms/kms_integration_test.go new file mode 100644 index 0000000..178e10d --- /dev/null +++ b/kms/kms_integration_test.go @@ -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) + } +} diff --git a/pgp/pgp_integration_test.go b/pgp/pgp_integration_test.go new file mode 100644 index 0000000..f7d966e --- /dev/null +++ b/pgp/pgp_integration_test.go @@ -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 "" +}