mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 08:08:56 +00:00
4b0b90192d
CI on the Bundle L merge (7b79234) failed at golangci-lint:
internal/connector/issuer/stepca/jwe_failure_test.go:105:16:
QF1008: could remove embedded field 'PublicKey' from selector
internal/connector/issuer/stepca/jwe_failure_test.go:106:16: same
internal/connector/issuer/stepca/jwe_failure_test.go:241:9: same
ecdsa.PrivateKey embeds PublicKey, so 'key.PublicKey.X' is
redundantly traversing the embedded field. The shorter 'key.X'
compiles to the same access via the embedded promotion.
Verified clean via 'staticcheck -checks all' (only pre-existing
ST1000 'no package comment' hits remain, predating this bundle).
Tests still PASS at 90.4% coverage; semantics unchanged.
679 lines
21 KiB
Go
679 lines
21 KiB
Go
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.X.Bytes()
|
|
yBytes := key.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.X.Cmp(key.X) != 0 {
|
|
t.Errorf("public X mismatch")
|
|
}
|
|
if got.Y.Cmp(key.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)
|
|
}
|
|
}
|