mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
Bundle L (Coverage Audit Closure): StepCA failure-mode + JWE coverage + CI threshold raise #1
L.B closes C-005; L.A defers C-003 (refactor required); L.C operator-required (testcontainers); L.CI raises CI thresholds for ACME / StepCA / MCP.
L.B — StepCA (~580 LoC stepca/jwe_failure_test.go):
Strategy: hermetic test-side RFC 3394 AES Key Wrap implementation
constructs a valid step-ca PBES2-HS256+A128KW + A128GCM provisioner-
key JWE in-test, exercises the full decrypt pipeline end-to-end.
Coverage: 52.1% -> 90.4% (+38.3pp; +5.4 above 85% target)
decryptProvisionerKey: 0% -> 89.7%
aesKeyUnwrap: 0% -> 100.0%
jwkToECDSA: 0% -> 100.0%
loadProvisionerKey: 0% -> 76.9%
Tests (24 functions):
JWE round-trip pinning all 4 0%-covered helpers
decryptProvisionerKey: 10 negative-path cases (malformed JSON,
bad protected b64, malformed header JSON, unsupported alg,
unsupported enc, bad p2s/encrypted_key/IV/ciphertext/tag b64)
Wrong-password path: AES key unwrap integrity check fail
aesKeyUnwrap: too-short, not-mult-of-8, bad-KEK-size, bad-IV
jwkToECDSA: unsupported curve + bad x/y/d b64 + all-curves
loadProvisionerKey: round-trip + file-not-found
IssueCertificate failure modes (network/5xx/401/403)
RevokeCertificate failure modes (network/5xx/403)
L.A — cmd/server (DEFERRED):
cmd/server's 16.1% baseline is dominated by main()'s 1041-LoC
startup body which is 0%-covered. The other named functions
(preflight* + buildFinalHandler + tls.go) are at 85-100% already.
Lifting overall to >=75% requires a production-code refactor
(extract main() into testable Run(*Config)) that exceeds Bundle
L.A's test-only scope. Tracked as 'Bundle L.A-extended'.
L.C — Repository (OPERATOR-REQUIRED):
testcontainers + Docker not available in sandbox. Operator runs
go test -tags integration ./internal/repository/postgres/...
on a workstation with Docker.
L.CI — CI threshold raise #1 (.github/workflows/ci.yml):
ACME issuer: >=50% (Bundle J floor; bumps to 85 with Pebble-mock)
StepCA issuer: >=80% (Bundle L.B floor with 10pp margin from 90.4)
MCP: >=85% (Bundle K floor with 8pp margin from 93.1)
cmd/server raise deferred until Bundle L.A-extended lands.
YAML validated; each gate fails CI with 'add tests, do not lower
the gate' message matching L-010's pattern.
Verification:
go vet ./internal/connector/issuer/stepca/... clean
gofmt -l clean
staticcheck -checks all clean
go test -short ./internal/connector/issuer/stepca/ PASS, 90.4%
go test -race -count=1 PASS, 0 races
python3 -c 'yaml.safe_load(...)' YAML OK
Audit deliverables:
findings.yaml: C-005 status open -> closed; C-003 open -> deferred
gap-backlog.md: closure log + C-005 strikethrough + C-003/C-004 notes
coverage-matrix.md: stepca row at 90.4%
closure-plan.md: Bundle L [~] with per-sub-bundle status
CHANGELOG.md: [unreleased] Bundle L entry
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user