Merge fix/stepca-coverage-LB: Bundle L — StepCA coverage 52.1% -> 90.4%; C-005 closed; CI threshold raise #1 shipped

This commit is contained in:
shankar0123
2026-04-27 17:02:49 +00:00
3 changed files with 782 additions and 0 deletions
+40
View File
@@ -745,6 +745,30 @@ jobs:
LOCAL_ISSUER_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/local' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
echo "Local-issuer coverage: ${LOCAL_ISSUER_COV}%"
# Bundle-J / Coverage-Audit C-001 (partial-closed) — ACME failure-mode
# batch lifts internal/connector/issuer/acme from 41.8% to ~55.6%
# (per-package package-scoped run). The global per-file average can
# come in lower because this awk pattern divides by file count
# rather than weighting by line count, but with the failure-mode
# tests landed every file in the package has at least 50% coverage.
# Floor set at 50 to accommodate the global-run arithmetic; bumps
# to 85 when Bundle J-extended (Pebble-style mock) lands and the
# IssueCertificate / solveAuthorizations* flows are exercisable.
ACME_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/acme' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
echo "ACME issuer coverage: ${ACME_COV}%"
# Bundle-L.B / Coverage-Audit C-005 — StepCA failure-mode + JWE
# round-trip tests lift internal/connector/issuer/stepca from
# 52.1% to 90.4% (per-package run). Floor at 80 with margin.
STEPCA_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/stepca' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
echo "StepCA issuer coverage: ${STEPCA_COV}%"
# Bundle-K / Coverage-Audit C-002 — MCP per-tool dispatch via
# in-memory transport lifts internal/mcp from 28.0% to 93.1%
# (per-package run). Floor at 85.
MCP_COV=$(go tool cover -func=coverage.out | grep 'internal/mcp/' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
echo "MCP coverage: ${MCP_COV}%"
# Fail if thresholds not met
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
@@ -791,6 +815,22 @@ jobs:
echo "::error::Local-issuer coverage ${LOCAL_ISSUER_COV}% is below 85% (H-010 closure floor — add tests, do not lower the gate)"
exit 1
fi
# Bundle L.CI threshold raise #1 — post-Bundles J / L.B / K floors.
# Each gate is set with margin below the verified package-scoped
# coverage so the global per-file-average arithmetic doesn't false-
# positive on a single low-coverage file dragging the mean.
if [ "$(echo "$ACME_COV < 50" | bc -l)" -eq 1 ]; then
echo "::error::ACME issuer coverage ${ACME_COV}% is below 50% (Bundle J partial-closure floor — add Pebble-mock tests, do not lower the gate)"
exit 1
fi
if [ "$(echo "$STEPCA_COV < 80" | bc -l)" -eq 1 ]; then
echo "::error::StepCA issuer coverage ${STEPCA_COV}% is below 80% (Bundle L.B closure floor — add tests, do not lower the gate)"
exit 1
fi
if [ "$(echo "$MCP_COV < 85" | bc -l)" -eq 1 ]; then
echo "::error::MCP coverage ${MCP_COV}% is below 85% (Bundle K closure floor — add tests, do not lower the gate)"
exit 1
fi
echo "Coverage thresholds passed!"
- name: Upload Coverage Report
+64
View File
@@ -4,6 +4,70 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
## [unreleased] — 2026-04-27
### Bundle L (Coverage Audit Closure — cmd/server + StepCA + Repo + CI raise #1)
> Three sub-bundles + CI threshold raise. **L.B closes C-005** (StepCA 52.1% → 90.4%); **L.A defers C-003** (cmd/server needs production-code refactor before tests can move it); **L.C is operator-required** (testcontainers blocked in sandbox); **L.CI raises CI thresholds** for ACME, StepCA, and MCP based on Bundles J/L.B/K.
#### L.B — StepCA failure-mode + JWE coverage (C-005 closed)
`internal/connector/issuer/stepca/jwe_failure_test.go` (~580 LoC). The novel piece: a **test-side RFC 3394 AES Key Wrap implementation** that constructs a valid step-ca-shaped PBES2-HS256+A128KW + A128GCM provisioner-key JWE in-test. This unlocks hermetic round-trip testing of the four previously-0%-covered JWE/AES helpers.
Coverage delta:
| | Pre-Bundle-L.B | Post-Bundle-L.B |
|---|---|---|
| `internal/connector/issuer/stepca` overall | 52.1% | **90.4%** (+38.3pp; +5.4 above 85% target) |
| `decryptProvisionerKey` | 0.0% | **89.7%** |
| `aesKeyUnwrap` | 0.0% | **100.0%** |
| `jwkToECDSA` | 0.0% | **100.0%** |
| `loadProvisionerKey` | 0.0% | **76.9%** |
Tests added (24 functions):
- **JWE round-trip:** `TestDecryptProvisionerKey_RoundTrip` constructs a valid JWE for a known EC key + password, decrypts, and asserts every byte of the recovered private scalar D + public X/Y matches the original. Hits all four 0%-coverage functions in one test.
- **decryptProvisionerKey negative paths (10 cases):** malformed JSON, bad protected b64, malformed header JSON, unsupported alg ("RSA-OAEP"), unsupported enc ("A256CBC"), bad p2s b64, bad encrypted_key b64, bad IV b64, bad ciphertext b64, bad tag b64.
- **Wrong-password path:** confirms AES key unwrap integrity-check failure surfaces with `AES key unwrap failed` wrap.
- **aesKeyUnwrap negative paths (4 cases):** too short (<24 bytes), not multiple of 8, bad KEK size (17 bytes — invalid for AES), bad integrity check IV (all-zero ciphertext).
- **jwkToECDSA negative paths (3 cases):** unsupported curve ("secp192r1"), bad x/y/d base64.
- **jwkToECDSA all-supported curves:** P-256, P-384, P-521 round-trip.
- **loadProvisionerKey:** round-trip via `t.TempDir()` JWE fixture file + file-not-found path.
- **IssueCertificate failure modes (4 cases):** network-error (closed server), 5xx, 401 Unauthorized, 403 Forbidden.
- **RevokeCertificate failure modes (3 cases):** network-error, 5xx, 403.
Verification: `go vet` clean; `go test -short -count=1` PASS at 90.4% coverage; `go test -race -count=1` PASS, 0 races.
#### L.A — cmd/server startup coverage (C-003 deferred)
cmd/server's 16.1% baseline is dominated by `main()`'s 1041-LoC startup body which is 0%-covered. The other named functions in cmd/server (`preflightSCEPChallengePassword`, `preflightEnrollmentIssuer`, `buildFinalHandler`, plus all of `tls.go`) are already at 85100% coverage. A "test-only" bundle cannot move the headline meaningfully — it requires extracting `main()` into a testable `Run(*Config)` helper with injected dependencies, which is a production-code refactor.
`findings.yaml::CRTCTL-COVAUDIT-2026-04-27-0003::status` flips from `open` to `deferred` with the rationale + tracked as a follow-on "Bundle L.A-extended" that combines a refactor commit with the test commit.
#### L.C — Repository round-out (C-004 operator-required)
Repository tests use testcontainers-go against PostgreSQL 16 Alpine; the sandbox cannot run Docker. Operator-runnable command:
```
go test -tags integration ./internal/repository/postgres/...
```
If any per-file coverage <75%, add CRUD + FK-violation + unique-constraint tests per the existing finding sketch.
#### L.CI — CI threshold raise #1
`.github/workflows/ci.yml` adds three new package-coverage floors based on Bundles J / L.B / K:
| Package | Floor | Rationale |
|---|---|---|
| `internal/connector/issuer/acme` | ≥50% | Bundle J partial-closure floor; bumps to 85 when Pebble-mock lands |
| `internal/connector/issuer/stepca` | ≥80% | Bundle L.B closure floor with 10pp margin from 90.4% |
| `internal/mcp` | ≥85% | Bundle K closure floor with 8pp margin from 93.1% |
Each gate fails CI with a "do not lower the gate, add tests" message, matching the L-010 (`internal/connector/issuer/local`) pattern. cmd/server raise is deferred until Bundle L.A-extended lands.
YAML validated via `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"`.
Audit deliverable updates: `findings.yaml` flips C-005 closed + C-003 deferred (+ retains C-004 as operator-pending); `gap-backlog.md` adds full Bundle L closure-log entry covering all four sub-bundles + updates the C-003/C-004/C-005 rows; `coverage-matrix.md` adds the post-Bundle-L.B StepCA row at 90.4%; `closure-plan.md` ticks Bundle L `[~]` with per-sub-bundle status breakdown.
### Bundle K (Coverage Audit Closure — MCP Per-Tool Coverage): C-002 closed
> Lifts `internal/mcp` line coverage from **28.0% → 93.1%** (+65.1pp; +8.1pp above the 85% acquisition target). Closes finding C-002 — the highest-leverage High-tier coverage gap in the audit.
@@ -0,0 +1,678 @@
package stepca
// Bundle L.B (Coverage Audit Closure) — StepCA failure-mode + JWE coverage.
//
// Pre-Bundle-L coverage on this package was 52.1%, with the following 0%
// hotspots dragging the headline number down:
//
// - decryptProvisionerKey 0% (~110 LoC) — JWE PBES2-HS256+A128KW + A128GCM
// - jwkToECDSA 0% (~40 LoC) — JWK -> *ecdsa.PrivateKey
// - aesKeyUnwrap 0% (~40 LoC) — RFC 3394 AES Key Unwrap
// - loadProvisionerKey 0% (~30 LoC) — file read + delegate to decrypt
//
// This file pins all four functions via a hermetic test-side AES Key Wrap
// implementation that constructs a valid step-ca-shaped JWE in-test, then
// asserts decryptProvisionerKey round-trips back to the original key.
// Plus the negative-path matrix (malformed JSON, unsupported alg, wrong
// password, bad base64, bad curve, etc.).
//
// Mirrors Bundle J's hermetic-via-stdlib pattern: no external JOSE library,
// no live step-ca call.
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/pbkdf2"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// quietLogger returns a slog.Logger writing to io.Discard at error level.
// Avoids polluting test output during failure-mode tests.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// ---------------------------------------------------------------------------
// JWE construction helpers (test-side implementation of AES Key Wrap +
// PBES2-HS256+A128KW + A128GCM, mirroring step-ca's provisioner key format)
// ---------------------------------------------------------------------------
// aesKeyWrap is the inverse of aesKeyUnwrap (decrypt-side function in jwe.go).
// RFC 3394 AES Key Wrap. Used only by test fixtures to build a valid JWE.
func aesKeyWrap(t *testing.T, kek, plaintext []byte) []byte {
t.Helper()
if len(plaintext)%8 != 0 {
t.Fatalf("aesKeyWrap: plaintext len %d not multiple of 8", len(plaintext))
}
block, err := aes.NewCipher(kek)
if err != nil {
t.Fatalf("aesKeyWrap: NewCipher: %v", err)
}
n := len(plaintext) / 8
// A = 0xA6A6A6A6A6A6A6A6
a := []byte{0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6}
r := make([][]byte, n)
for i := 0; i < n; i++ {
r[i] = make([]byte, 8)
copy(r[i], plaintext[i*8:(i+1)*8])
}
buf := make([]byte, 16)
for j := 0; j < 6; j++ {
for i := 1; i <= n; i++ {
copy(buf[:8], a)
copy(buf[8:], r[i-1])
block.Encrypt(buf, buf)
t := uint64(n*j + i)
tBytes := make([]byte, 8)
binary.BigEndian.PutUint64(tBytes, t)
for k := 0; k < 8; k++ {
a[k] = buf[k] ^ tBytes[k]
}
copy(r[i-1], buf[8:])
}
}
out := make([]byte, 0, (n+1)*8)
out = append(out, a...)
for _, ri := range r {
out = append(out, ri...)
}
return out
}
// buildJWE constructs a valid step-ca-shaped JWE for the given password +
// EC key. Mirrors decryptProvisionerKey's exact format expectations.
func buildJWE(t *testing.T, password string, key *ecdsa.PrivateKey, kid string) []byte {
t.Helper()
// 1. Build the JWK and serialize to JSON (this is the "plaintext" of the JWE)
xBytes := key.PublicKey.X.Bytes()
yBytes := key.PublicKey.Y.Bytes()
dBytes := key.D.Bytes()
// Pad to fixed-size for P-256 (32 bytes)
pad := func(b []byte, size int) []byte {
if len(b) >= size {
return b
}
out := make([]byte, size)
copy(out[size-len(b):], b)
return out
}
xBytes = pad(xBytes, 32)
yBytes = pad(yBytes, 32)
dBytes = pad(dBytes, 32)
jwk := jwkEC{
Kty: "EC",
Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(xBytes),
Y: base64.RawURLEncoding.EncodeToString(yBytes),
D: base64.RawURLEncoding.EncodeToString(dBytes),
Kid: kid,
}
plaintext, err := json.Marshal(&jwk)
if err != nil {
t.Fatalf("marshal jwk: %v", err)
}
// 2. Generate PBKDF2 salt + iteration count
p2s := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, p2s); err != nil {
t.Fatalf("salt: %v", err)
}
const p2c = 100000
const alg = "PBES2-HS256+A128KW"
const enc = "A128GCM"
// 3. Derive KEK via PBKDF2(password, alg || 0x00 || p2s, p2c)
algBytes := []byte(alg)
salt := make([]byte, len(algBytes)+1+len(p2s))
copy(salt, algBytes)
salt[len(algBytes)] = 0x00
copy(salt[len(algBytes)+1:], p2s)
kek := pbkdf2.Key([]byte(password), salt, p2c, 16, sha256.New)
// 4. Generate CEK (16 bytes for A128GCM)
cek := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, cek); err != nil {
t.Fatalf("cek: %v", err)
}
// 5. Wrap CEK with KEK (AES-128 Key Wrap)
encryptedKey := aesKeyWrap(t, kek, cek)
// 6. Build protected header + AAD
header := jweHeader{
Alg: alg,
Enc: enc,
Cty: "jwk+json",
P2s: base64.RawURLEncoding.EncodeToString(p2s),
P2c: p2c,
}
headerJSON, err := json.Marshal(&header)
if err != nil {
t.Fatalf("marshal header: %v", err)
}
protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
aad := []byte(protectedB64)
// 7. AES-GCM encrypt the JWK plaintext
block, err := aes.NewCipher(cek)
if err != nil {
t.Fatalf("aes.NewCipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("cipher.NewGCM: %v", err)
}
iv := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
t.Fatalf("iv: %v", err)
}
sealed := gcm.Seal(nil, iv, plaintext, aad)
// sealed = ciphertext || tag
tagOffset := len(sealed) - gcm.Overhead()
ciphertext := sealed[:tagOffset]
tag := sealed[tagOffset:]
// 8. Assemble JWE JSON
jwe := jweJSON{
Protected: protectedB64,
EncryptedKey: base64.RawURLEncoding.EncodeToString(encryptedKey),
IV: base64.RawURLEncoding.EncodeToString(iv),
Ciphertext: base64.RawURLEncoding.EncodeToString(ciphertext),
Tag: base64.RawURLEncoding.EncodeToString(tag),
}
out, err := json.Marshal(&jwe)
if err != nil {
t.Fatalf("marshal jwe: %v", err)
}
return out
}
// ---------------------------------------------------------------------------
// decryptProvisionerKey — happy path (round-trip) + negative paths
// ---------------------------------------------------------------------------
// TestDecryptProvisionerKey_RoundTrip pins the full JWE pipeline.
// Constructs a valid JWE for a known EC key + password, then decrypts and
// asserts every field of the recovered key matches the original. Hits all
// four 0%-coverage functions in one shot:
// - decryptProvisionerKey
// - aesKeyUnwrap
// - jwkToECDSA
// - (loadProvisionerKey via TestLoadProvisionerKey_RoundTrip below)
func TestDecryptProvisionerKey_RoundTrip(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
password := "correct-horse-battery-staple"
kid := "test-kid-12345"
jweBlob := buildJWE(t, password, key, kid)
got, gotKid, err := decryptProvisionerKey(jweBlob, password)
if err != nil {
t.Fatalf("decryptProvisionerKey: %v", err)
}
if gotKid != kid {
t.Errorf("kid = %q; want %q", gotKid, kid)
}
if got.D.Cmp(key.D) != 0 {
t.Errorf("private scalar D mismatch")
}
if got.PublicKey.X.Cmp(key.PublicKey.X) != 0 {
t.Errorf("public X mismatch")
}
if got.PublicKey.Y.Cmp(key.PublicKey.Y) != 0 {
t.Errorf("public Y mismatch")
}
}
func TestDecryptProvisionerKey_MalformedJSON(t *testing.T) {
_, _, err := decryptProvisionerKey([]byte(`{not json`), "anything")
if err == nil || !strings.Contains(err.Error(), "parse JWE JSON") {
t.Fatalf("expected JWE JSON parse error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadProtectedB64(t *testing.T) {
jwe := jweJSON{
Protected: "!!!not-base64!!!",
EncryptedKey: "AA",
IV: "AA",
Ciphertext: "AA",
Tag: "AA",
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode JWE protected header") {
t.Fatalf("expected protected header decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_MalformedHeaderJSON(t *testing.T) {
jwe := jweJSON{
Protected: base64.RawURLEncoding.EncodeToString([]byte("{not-json")),
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "parse JWE header") {
t.Fatalf("expected header parse error, got: %v", err)
}
}
func TestDecryptProvisionerKey_UnsupportedAlg(t *testing.T) {
header := jweHeader{Alg: "RSA-OAEP", Enc: "A128GCM"}
hb, _ := json.Marshal(&header)
jwe := jweJSON{Protected: base64.RawURLEncoding.EncodeToString(hb)}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "unsupported JWE algorithm") {
t.Fatalf("expected unsupported alg error, got: %v", err)
}
}
func TestDecryptProvisionerKey_UnsupportedEnc(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A256CBC"}
hb, _ := json.Marshal(&header)
jwe := jweJSON{Protected: base64.RawURLEncoding.EncodeToString(hb)}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "unsupported JWE encryption") {
t.Fatalf("expected unsupported enc error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadP2sB64(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A128GCM", P2s: "!!!", P2c: 1000}
hb, _ := json.Marshal(&header)
jwe := jweJSON{Protected: base64.RawURLEncoding.EncodeToString(hb)}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode PBKDF2 salt") {
t.Fatalf("expected p2s decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadEncryptedKeyB64(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A128GCM", P2s: "AAAA", P2c: 1000}
hb, _ := json.Marshal(&header)
jwe := jweJSON{
Protected: base64.RawURLEncoding.EncodeToString(hb),
EncryptedKey: "!!!",
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode encrypted key") {
t.Fatalf("expected encrypted key decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadIVB64(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A128GCM", P2s: "AAAA", P2c: 1000}
hb, _ := json.Marshal(&header)
jwe := jweJSON{
Protected: base64.RawURLEncoding.EncodeToString(hb),
EncryptedKey: "AAAA",
IV: "!!!",
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode IV") {
t.Fatalf("expected IV decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadCiphertextB64(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A128GCM", P2s: "AAAA", P2c: 1000}
hb, _ := json.Marshal(&header)
jwe := jweJSON{
Protected: base64.RawURLEncoding.EncodeToString(hb),
EncryptedKey: "AAAA",
IV: "AAAA",
Ciphertext: "!!!",
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode ciphertext") {
t.Fatalf("expected ciphertext decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_BadTagB64(t *testing.T) {
header := jweHeader{Alg: "PBES2-HS256+A128KW", Enc: "A128GCM", P2s: "AAAA", P2c: 1000}
hb, _ := json.Marshal(&header)
jwe := jweJSON{
Protected: base64.RawURLEncoding.EncodeToString(hb),
EncryptedKey: "AAAA",
IV: "AAAA",
Ciphertext: "AAAA",
Tag: "!!!",
}
body, _ := json.Marshal(&jwe)
_, _, err := decryptProvisionerKey(body, "anything")
if err == nil || !strings.Contains(err.Error(), "decode tag") {
t.Fatalf("expected tag decode error, got: %v", err)
}
}
func TestDecryptProvisionerKey_WrongPassword(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
jweBlob := buildJWE(t, "right-password", key, "kid")
_, _, err = decryptProvisionerKey(jweBlob, "wrong-password")
if err == nil {
t.Fatal("expected error on wrong password")
}
// Wrong password causes integrity check failure during AES Key Unwrap.
if !strings.Contains(err.Error(), "AES key unwrap failed") &&
!strings.Contains(err.Error(), "GCM decryption failed") {
t.Errorf("error %q should mention AES key unwrap or GCM failure", err)
}
}
// ---------------------------------------------------------------------------
// aesKeyUnwrap — negative paths
// ---------------------------------------------------------------------------
func TestAESKeyUnwrap_TooShort(t *testing.T) {
_, err := aesKeyUnwrap(make([]byte, 16), make([]byte, 16))
if err == nil || !strings.Contains(err.Error(), "invalid ciphertext length") {
t.Fatalf("expected length error, got: %v", err)
}
}
func TestAESKeyUnwrap_NotMultipleOf8(t *testing.T) {
_, err := aesKeyUnwrap(make([]byte, 16), make([]byte, 25))
if err == nil || !strings.Contains(err.Error(), "invalid ciphertext length") {
t.Fatalf("expected length error, got: %v", err)
}
}
func TestAESKeyUnwrap_BadKEKSize(t *testing.T) {
// AES requires 16/24/32-byte keys. 17 bytes = invalid.
_, err := aesKeyUnwrap(make([]byte, 17), make([]byte, 24))
if err == nil || !strings.Contains(err.Error(), "AES cipher") {
t.Fatalf("expected AES cipher error, got: %v", err)
}
}
func TestAESKeyUnwrap_BadIntegrityCheck(t *testing.T) {
// Provide all-zero ciphertext; the unwrapped IV will not be 0xA6...A6.
_, err := aesKeyUnwrap(make([]byte, 16), make([]byte, 24))
if err == nil || !strings.Contains(err.Error(), "integrity check failed") {
t.Fatalf("expected integrity check error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// jwkToECDSA — negative paths
// ---------------------------------------------------------------------------
func TestJwkToECDSA_UnsupportedCurve(t *testing.T) {
jwk := &jwkEC{Crv: "secp192r1"}
_, err := jwkToECDSA(jwk)
if err == nil || !strings.Contains(err.Error(), "unsupported curve") {
t.Fatalf("expected unsupported curve error, got: %v", err)
}
}
func TestJwkToECDSA_BadXB64(t *testing.T) {
jwk := &jwkEC{Crv: "P-256", X: "!!!", Y: "AA", D: "AA"}
_, err := jwkToECDSA(jwk)
if err == nil || !strings.Contains(err.Error(), "decode JWK x") {
t.Fatalf("expected x decode error, got: %v", err)
}
}
func TestJwkToECDSA_BadYB64(t *testing.T) {
jwk := &jwkEC{Crv: "P-384", X: "AA", Y: "!!!", D: "AA"}
_, err := jwkToECDSA(jwk)
if err == nil || !strings.Contains(err.Error(), "decode JWK y") {
t.Fatalf("expected y decode error, got: %v", err)
}
}
func TestJwkToECDSA_BadDB64(t *testing.T) {
jwk := &jwkEC{Crv: "P-521", X: "AA", Y: "AA", D: "!!!"}
_, err := jwkToECDSA(jwk)
if err == nil || !strings.Contains(err.Error(), "decode JWK d") {
t.Fatalf("expected d decode error, got: %v", err)
}
}
func TestJwkToECDSA_AllSupportedCurves(t *testing.T) {
for _, crv := range []string{"P-256", "P-384", "P-521"} {
jwk := &jwkEC{Crv: crv, X: "AA", Y: "AA", D: "AA"}
key, err := jwkToECDSA(jwk)
if err != nil {
t.Errorf("crv=%s: %v", crv, err)
continue
}
if key == nil {
t.Errorf("crv=%s: returned nil key", crv)
}
}
}
// ---------------------------------------------------------------------------
// loadProvisionerKey — happy + missing-file
// ---------------------------------------------------------------------------
func TestLoadProvisionerKey_RoundTrip(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
password := "test-password"
kid := "stepca-test-kid"
jweBlob := buildJWE(t, password, key, kid)
dir := t.TempDir()
path := filepath.Join(dir, "provisioner.json")
if err := os.WriteFile(path, jweBlob, 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
c := &Connector{
config: &Config{
ProvisionerKeyPath: path,
ProvisionerPassword: password,
},
logger: quietLogger(),
}
gotKey, gotKid, err := c.loadProvisionerKey()
if err != nil {
t.Fatalf("loadProvisionerKey: %v", err)
}
if gotKid != kid {
t.Errorf("kid = %q; want %q", gotKid, kid)
}
if gotKey.D.Cmp(key.D) == 0 == false {
t.Errorf("private scalar mismatch")
}
}
func TestLoadProvisionerKey_FileNotFound(t *testing.T) {
c := &Connector{
config: &Config{
ProvisionerKeyPath: "/nonexistent/path/provisioner.json",
ProvisionerPassword: "x",
},
logger: quietLogger(),
}
_, _, err := c.loadProvisionerKey()
if err == nil {
t.Fatal("expected file-not-found error")
}
}
// ---------------------------------------------------------------------------
// IssueCertificate / RevokeCertificate failure modes via httptest.Server
// ---------------------------------------------------------------------------
// preWiredStepCAConnector returns a step-ca connector with the given URL,
// using an ephemeral provisioner key so IssueCertificate / RevokeCertificate
// can produce a valid token without needing a real key file.
func preWiredStepCAConnector(t *testing.T, url string) *Connector {
t.Helper()
return New(&Config{
CAURL: url,
ProvisionerName: "test-provisioner",
// ProvisionerKeyPath intentionally empty -> ephemeral key
}, quietLogger())
}
// minimalCSRPEM returns a syntactically valid CSR PEM. Used as test input
// for IssueCertificate failure modes that should NOT depend on CSR
// validation (we want the failure to come from the upstream HTTP response,
// not from CSR parsing).
const minimalCSRPEM = `-----BEGIN CERTIFICATE REQUEST-----
MIH4MIGgAgEAMBoxGDAWBgNVBAMMD3Rlc3QuZXhhbXBsZS5jb20wWTATBgcqhkjO
PQIBBggqhkjOPQMBBwNCAATctzj78qjxwoTYDjBzZ7iC1cnaSPjEr/m3rT4xPCA0
QqL5bfjRoIN6sH9HX8AKqL7cNWxbdQepZx7TAR1eb6DjoCgwJgYJKoZIhvcNAQkO
MRkwFzAVBgNVHREEDjAMggp0LmV4YW1wbGUwCgYIKoZIzj0EAwIDSAAwRQIhAOMW
KcW6Z3MzKQT7YCePO1l9oZSDqXqJYJV6BEmjcpAJAiBNqcPDt0qRR1aUH9qFZQzP
GuQvbz9HKkPxmXcnkBOjIw==
-----END CERTIFICATE REQUEST-----`
func TestIssueCertificate_NetworkError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := ts.URL
ts.Close()
c := preWiredStepCAConnector(t, url)
_, err := c.IssueCertificate(t.Context(), issuer.IssuanceRequest{
CommonName: "test",
CSRPEM: minimalCSRPEM,
})
if err == nil {
t.Fatal("expected network error")
}
if !strings.Contains(err.Error(), "sign request failed") {
t.Errorf("error %q should mention 'sign request failed'", err)
}
}
func TestIssueCertificate_5xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"upstream boom"}`)
}))
defer ts.Close()
c := preWiredStepCAConnector(t, ts.URL)
_, err := c.IssueCertificate(t.Context(), issuer.IssuanceRequest{
CommonName: "test",
CSRPEM: minimalCSRPEM,
})
if err == nil {
t.Fatal("expected error on 5xx")
}
if !strings.Contains(err.Error(), "status 500") {
t.Errorf("error %q should mention 'status 500'", err)
}
}
func TestIssueCertificate_401Unauthorized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"invalid token"}`)
}))
defer ts.Close()
c := preWiredStepCAConnector(t, ts.URL)
_, err := c.IssueCertificate(t.Context(), issuer.IssuanceRequest{
CommonName: "test",
CSRPEM: minimalCSRPEM,
})
if err == nil {
t.Fatal("expected 401 to error")
}
if !strings.Contains(err.Error(), "status 401") {
t.Errorf("error %q should mention 'status 401'", err)
}
}
func TestIssueCertificate_403Forbidden(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer ts.Close()
c := preWiredStepCAConnector(t, ts.URL)
_, err := c.IssueCertificate(t.Context(), issuer.IssuanceRequest{
CommonName: "test",
CSRPEM: minimalCSRPEM,
})
if err == nil || !strings.Contains(err.Error(), "status 403") {
t.Fatalf("expected 403 error, got: %v", err)
}
}
func TestRevokeCertificate_NetworkError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := ts.URL
ts.Close()
c := preWiredStepCAConnector(t, url)
err := c.RevokeCertificate(t.Context(), issuer.RevocationRequest{
Serial: "ABCD1234",
})
if err == nil {
t.Fatal("expected network error")
}
if !strings.Contains(err.Error(), "revoke request failed") {
t.Errorf("error %q should mention 'revoke request failed'", err)
}
}
func TestRevokeCertificate_5xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"boom"}`)
}))
defer ts.Close()
c := preWiredStepCAConnector(t, ts.URL)
err := c.RevokeCertificate(t.Context(), issuer.RevocationRequest{
Serial: "ABCD",
})
if err == nil || !strings.Contains(err.Error(), "status 500") {
t.Fatalf("expected 500 error, got: %v", err)
}
}
func TestRevokeCertificate_403(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer ts.Close()
c := preWiredStepCAConnector(t, ts.URL)
err := c.RevokeCertificate(t.Context(), issuer.RevocationRequest{Serial: "ABCD"})
if err == nil || !strings.Contains(err.Error(), "status 403") {
t.Fatalf("expected 403 error, got: %v", err)
}
}