mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 16:08:52 +00:00
5dc698307b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit bc6039a (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
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/certctl-io/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)
|
|
}
|
|
}
|