// Copyright (c) certctl // SPDX-License-Identifier: BSL-1.1 package acme import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/json" "errors" "strings" "testing" jose "github.com/go-jose/go-jose/v4" "github.com/certctl-io/certctl/internal/domain" ) // --- test fixtures + helpers -------------------------------------------- // stubAccounts implements AccountLookup with a static map. type stubAccounts struct { byID map[string]*domain.ACMEAccount } func (s *stubAccounts) LookupAccount(accountID string) (*domain.ACMEAccount, error) { acct, ok := s.byID[accountID] if !ok { return nil, ErrJWSAccountNotFound } return acct, nil } // stubNonces implements NonceConsumer with a one-shot map. Used == true // after first Consume. type stubNonces struct { known map[string]bool // nonce → consumed? } func newStubNonces(nonces ...string) *stubNonces { s := &stubNonces{known: make(map[string]bool, len(nonces))} for _, n := range nonces { s.known[n] = false } return s } func (s *stubNonces) ConsumeNonce(nonce string) error { used, ok := s.known[nonce] if !ok { return errors.New("not found") } if used { return errors.New("already used") } s.known[nonce] = true return nil } const testKID = "https://server/acme/profile/prof-corp/account/acme-acc-test123" const testURL = "https://server/acme/profile/prof-corp/new-account" func testAccountKID(accountID string) string { return "https://server/acme/profile/prof-corp/account/" + accountID } // genRSAKey, genECKey, genEdKey return a freshly-generated keypair // suitable for signing JWS objects. Tests share the same key per-case // to keep failures localized to the verifier rather than cross-test // state. func genRSAKey(t *testing.T) *rsa.PrivateKey { t.Helper() k, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("rsa keygen: %v", err) } return k } func genECKey(t *testing.T) *ecdsa.PrivateKey { t.Helper() k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("ecdsa keygen: %v", err) } return k } func genEdKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519 keygen: %v", err) } return pub, priv } // signWithKID builds a flattened JWS using kid (registered-account flow). func signWithKID(t *testing.T, key interface{}, alg jose.SignatureAlgorithm, kid, url, nonce string, payload interface{}) string { t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } signer, err := jose.NewSigner( jose.SigningKey{Algorithm: alg, Key: key}, (&jose.SignerOptions{}). WithHeader(jose.HeaderKey("url"), url). WithHeader("kid", kid). WithHeader("nonce", nonce), ) if err != nil { t.Fatalf("new signer: %v", err) } jws, err := signer.Sign(body) if err != nil { t.Fatalf("sign: %v", err) } out := jws.FullSerialize() return out } // signWithJWK builds a flattened JWS embedding the public JWK // (new-account flow). The Signer with EmbedJWK=true attaches the // JSONWebKey to the protected header. func signWithJWK(t *testing.T, key interface{}, alg jose.SignatureAlgorithm, url, nonce string, payload interface{}) string { t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } signer, err := jose.NewSigner( jose.SigningKey{Algorithm: alg, Key: key}, (&jose.SignerOptions{EmbedJWK: true}). WithHeader(jose.HeaderKey("url"), url). WithHeader("nonce", nonce), ) if err != nil { t.Fatalf("new signer (embed jwk): %v", err) } jws, err := signer.Sign(body) if err != nil { t.Fatalf("sign: %v", err) } return jws.FullSerialize() } // --- JWK round-trip helpers -------------------------------------------- func TestJWKRoundTrip_RSA(t *testing.T) { k := genRSAKey(t) jwk := &jose.JSONWebKey{Key: &k.PublicKey} pem, err := JWKToPEM(jwk) if err != nil { t.Fatalf("JWKToPEM: %v", err) } if !strings.Contains(pem, "BEGIN ACME ACCOUNT JWK") { t.Fatalf("PEM missing header: %s", pem) } parsed, err := ParseJWKFromPEM(pem) if err != nil { t.Fatalf("ParseJWKFromPEM: %v", err) } if !parsed.Valid() { t.Fatal("parsed jwk is not valid") } } func TestJWKThumbprint_StableAcrossKeyTypes(t *testing.T) { rsaJWK := &jose.JSONWebKey{Key: &genRSAKey(t).PublicKey} rsaThumb1, err := JWKThumbprint(rsaJWK) if err != nil { t.Fatalf("rsa thumb: %v", err) } rsaThumb2, err := JWKThumbprint(rsaJWK) if err != nil { t.Fatalf("rsa thumb 2: %v", err) } if rsaThumb1 != rsaThumb2 { t.Errorf("thumbprint not stable: %q vs %q", rsaThumb1, rsaThumb2) } // Different keys produce different thumbprints. otherJWK := &jose.JSONWebKey{Key: &genRSAKey(t).PublicKey} otherThumb, err := JWKThumbprint(otherJWK) if err != nil { t.Fatalf("other thumb: %v", err) } if rsaThumb1 == otherThumb { t.Error("two distinct keys collided on thumbprint") } } // --- VerifyJWS happy paths --------------------------------------------- func TestVerifyJWS_Happy_RS256_KID(t *testing.T) { key := genRSAKey(t) jwk := &jose.JSONWebKey{Key: &key.PublicKey} pem, _ := JWKToPEM(jwk) thumb, _ := JWKThumbprint(jwk) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-test123": { AccountID: "acme-acc-test123", JWKPEM: pem, JWKThumbprint: thumb, Status: domain.ACMEAccountStatusValid, ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("nonce-001"), AccountKID: testAccountKID, } body := signWithKID(t, key, jose.RS256, testKID, testURL, "nonce-001", map[string]any{"hello": "world"}) v, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: false}) if err != nil { t.Fatalf("VerifyJWS: %v", err) } if v.Account == nil || v.Account.AccountID != "acme-acc-test123" { t.Errorf("account = %+v, want acme-acc-test123", v.Account) } if v.JWK != nil { t.Errorf("JWK should be nil on kid path; got %+v", v.JWK) } if v.Nonce != "nonce-001" { t.Errorf("nonce = %q", v.Nonce) } if v.URL != testURL { t.Errorf("url = %q", v.URL) } if v.Algorithm != "RS256" { t.Errorf("algorithm = %q", v.Algorithm) } var payload map[string]any if err := json.Unmarshal(v.Payload, &payload); err != nil { t.Fatalf("payload not json: %v", err) } if payload["hello"] != "world" { t.Errorf("payload = %+v", payload) } } func TestVerifyJWS_Happy_ES256_JWK(t *testing.T) { key := genECKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("nonce-002"), AccountKID: testAccountKID, } body := signWithJWK(t, key, jose.ES256, testURL, "nonce-002", map[string]any{"new": "account"}) v, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}) if err != nil { t.Fatalf("VerifyJWS: %v", err) } if v.JWK == nil { t.Fatal("JWK should be populated on jwk path") } if v.Account != nil { t.Errorf("Account should be nil on jwk path; got %+v", v.Account) } if v.Algorithm != "ES256" { t.Errorf("algorithm = %q", v.Algorithm) } } func TestVerifyJWS_Happy_EdDSA_KID(t *testing.T) { pub, priv := genEdKey(t) jwk := &jose.JSONWebKey{Key: pub} pem, _ := JWKToPEM(jwk) thumb, _ := JWKThumbprint(jwk) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-ed1": { AccountID: "acme-acc-ed1", JWKPEM: pem, JWKThumbprint: thumb, Status: domain.ACMEAccountStatusValid, ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("nonce-003"), AccountKID: testAccountKID, } kid := testAccountKID("acme-acc-ed1") body := signWithKID(t, priv, jose.EdDSA, kid, testURL, "nonce-003", struct{}{}) v, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: false}) if err != nil { t.Fatalf("VerifyJWS: %v", err) } if v.Algorithm != "EdDSA" { t.Errorf("algorithm = %q, want EdDSA", v.Algorithm) } } // --- VerifyJWS rejection paths ----------------------------------------- func TestVerifyJWS_Reject_AlgNotInAllowList(t *testing.T) { // HS256 (HMAC-SHA256, symmetric) is forbidden by RFC 8555 §6.2. key := []byte("supersecretkey32byteslongforhmac") signer, err := jose.NewSigner( jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}). WithHeader(jose.HeaderKey("url"), testURL). WithHeader("kid", testKID). WithHeader("nonce", "n"), ) if err != nil { t.Fatalf("hs256 signer: %v", err) } jws, _ := signer.Sign([]byte("{}")) body := jws.FullSerialize() cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("n"), AccountKID: testAccountKID, } _, err = VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{}) if err == nil { t.Fatal("expected algorithm-rejected error; got nil") } // ParseSigned filters the alg before we ever see the JWS, so the // error wraps ErrJWSMalformed (the verifier can't distinguish // "wrong format" from "bad alg" at this layer — both manifest as // malformed). if !errors.Is(err, ErrJWSMalformed) && !errors.Is(err, ErrJWSAlgorithmRejected) { t.Errorf("err = %v; want ErrJWSMalformed or ErrJWSAlgorithmRejected", err) } } func TestVerifyJWS_Reject_BadSignature(t *testing.T) { signingKey := genRSAKey(t) // The verifier resolves the account row's stored JWK and uses its // public component as the verify key. Register an account whose // stored JWK is a DIFFERENT key — same shape, different material. // The JWS parses cleanly but Verify returns "verification failed". storedKey := genRSAKey(t) storedJWK := &jose.JSONWebKey{Key: &storedKey.PublicKey} storedPEM, _ := JWKToPEM(storedJWK) storedThumb, _ := JWKThumbprint(storedJWK) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-test123": { AccountID: "acme-acc-test123", JWKPEM: storedPEM, JWKThumbprint: storedThumb, Status: domain.ACMEAccountStatusValid, ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("n1"), AccountKID: testAccountKID, } body := signWithKID(t, signingKey, jose.RS256, testKID, testURL, "n1", map[string]any{"x": 1}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{}) if err == nil { t.Fatal("expected signature-invalid error; got nil") } if !errors.Is(err, ErrJWSSignatureInvalid) { t.Errorf("err = %v; want ErrJWSSignatureInvalid wrapper", err) } } func TestVerifyJWS_Reject_NonceMissingFromHeader(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces(), AccountKID: testAccountKID, } signer, _ := jose.NewSigner( jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{EmbedJWK: true}). WithHeader(jose.HeaderKey("url"), testURL), // nonce omitted intentionally ) jws, _ := signer.Sign([]byte("{}")) body := jws.FullSerialize() _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}) if !errors.Is(err, ErrJWSMissingNonce) { t.Errorf("err = %v; want ErrJWSMissingNonce", err) } } func TestVerifyJWS_Reject_NonceUnknown(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces(), // no nonces issued AccountKID: testAccountKID, } body := signWithJWK(t, key, jose.RS256, testURL, "ghost-nonce", map[string]any{}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}) if !errors.Is(err, ErrJWSBadNonce) { t.Errorf("err = %v; want ErrJWSBadNonce", err) } } func TestVerifyJWS_Reject_NonceReplay(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("n-replay"), AccountKID: testAccountKID, } body := signWithJWK(t, key, jose.RS256, testURL, "n-replay", map[string]any{}) if _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}); err != nil { t.Fatalf("first verify: %v", err) } // Replay — same JWS, second time. _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}) if !errors.Is(err, ErrJWSBadNonce) { t.Errorf("err = %v; want ErrJWSBadNonce on replay", err) } } func TestVerifyJWS_Reject_URLMismatch(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("n-url"), AccountKID: testAccountKID, } body := signWithJWK(t, key, jose.RS256, testURL, "n-url", map[string]any{}) // Hand the verifier a different URL than the one signed. _, err := VerifyJWS(cfg, []byte(body), "https://server/acme/different", VerifyOptions{ExpectNewAccount: true}) if !errors.Is(err, ErrJWSURLMismatch) { t.Errorf("err = %v; want ErrJWSURLMismatch", err) } } func TestVerifyJWS_Reject_ExpectKidGotJWK(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("n-mix1"), AccountKID: testAccountKID, } body := signWithJWK(t, key, jose.RS256, testURL, "n-mix1", map[string]any{}) // New-account expects jwk; we set ExpectNewAccount=false so this // flow demands kid. _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: false}) if !errors.Is(err, ErrJWSExpectKidGotJWK) { t.Errorf("err = %v; want ErrJWSExpectKidGotJWK", err) } } func TestVerifyJWS_Reject_ExpectJWKGotKid(t *testing.T) { key := genRSAKey(t) jwk := &jose.JSONWebKey{Key: &key.PublicKey} pem, _ := JWKToPEM(jwk) thumb, _ := JWKThumbprint(jwk) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-test123": { AccountID: "acme-acc-test123", JWKPEM: pem, JWKThumbprint: thumb, Status: domain.ACMEAccountStatusValid, ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("n-mix2"), AccountKID: testAccountKID, } body := signWithKID(t, key, jose.RS256, testKID, testURL, "n-mix2", map[string]any{}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{ExpectNewAccount: true}) if !errors.Is(err, ErrJWSExpectJWKGotKid) { t.Errorf("err = %v; want ErrJWSExpectJWKGotKid", err) } } func TestVerifyJWS_Reject_AccountUnknown(t *testing.T) { key := genRSAKey(t) cfg := VerifierConfig{ Accounts: &stubAccounts{}, Nonces: newStubNonces("n-acct"), AccountKID: testAccountKID, } body := signWithKID(t, key, jose.RS256, testKID, testURL, "n-acct", map[string]any{}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{}) if !errors.Is(err, ErrJWSAccountNotFound) { t.Errorf("err = %v; want ErrJWSAccountNotFound", err) } } func TestVerifyJWS_Reject_AccountDeactivated(t *testing.T) { key := genRSAKey(t) jwk := &jose.JSONWebKey{Key: &key.PublicKey} pem, _ := JWKToPEM(jwk) thumb, _ := JWKThumbprint(jwk) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-test123": { AccountID: "acme-acc-test123", JWKPEM: pem, JWKThumbprint: thumb, Status: domain.ACMEAccountStatusDeactivated, // ← deactivated ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("n-deact"), AccountKID: testAccountKID, } body := signWithKID(t, key, jose.RS256, testKID, testURL, "n-deact", map[string]any{}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{}) if !errors.Is(err, ErrJWSAccountInactive) { t.Errorf("err = %v; want ErrJWSAccountInactive", err) } } func TestVerifyJWS_Reject_KIDMismatchesProfile(t *testing.T) { key := genRSAKey(t) jwk := &jose.JSONWebKey{Key: &key.PublicKey} pem, _ := JWKToPEM(jwk) thumb, _ := JWKThumbprint(jwk) cfg := VerifierConfig{ Accounts: &stubAccounts{byID: map[string]*domain.ACMEAccount{ "acme-acc-test123": { AccountID: "acme-acc-test123", JWKPEM: pem, JWKThumbprint: thumb, Status: domain.ACMEAccountStatusValid, ProfileID: "prof-corp", }, }}, Nonces: newStubNonces("n-cross"), // AccountKID expects prof-corp; the test JWS uses a kid that // claims prof-corp BUT we're going to feed an off-canonical // kid that doesn't match. AccountKID: testAccountKID, } // Sign with a kid that points at a different host. The verifier's // AccountKID round-trip-check should reject it. wrongKID := "https://different-host/acme/profile/prof-corp/account/acme-acc-test123" body := signWithKID(t, key, jose.RS256, wrongKID, testURL, "n-cross", map[string]any{}) _, err := VerifyJWS(cfg, []byte(body), testURL, VerifyOptions{}) if err == nil { t.Fatal("expected error from kid round-trip mismatch") } if !errors.Is(err, ErrJWSMalformed) { t.Errorf("err = %v; want ErrJWSMalformed (round-trip mismatch)", err) } } // MapJWSErrorToProblem coverage check: every exported sentinel maps // to a typed Problem (not the default malformed catch-all). func TestMapJWSErrorToProblem_KnownSentinels(t *testing.T) { cases := []struct { err error wantTyp string }{ {ErrJWSBadNonce, "urn:ietf:params:acme:error:badNonce"}, {ErrJWSMissingNonce, "urn:ietf:params:acme:error:badNonce"}, {ErrJWSAccountNotFound, "urn:ietf:params:acme:error:accountDoesNotExist"}, {ErrJWSAccountInactive, "urn:ietf:params:acme:error:unauthorized"}, {ErrJWSURLMismatch, "urn:ietf:params:acme:error:unauthorized"}, {ErrJWSSignatureInvalid, "urn:ietf:params:acme:error:unauthorized"}, {ErrJWSAlgorithmRejected, "urn:ietf:params:acme:error:malformed"}, {ErrJWSExpectJWKGotKid, "urn:ietf:params:acme:error:malformed"}, {ErrJWSExpectKidGotJWK, "urn:ietf:params:acme:error:malformed"}, {ErrJWSBothKidAndJWK, "urn:ietf:params:acme:error:malformed"}, {ErrJWSNeitherKidNorJWK, "urn:ietf:params:acme:error:malformed"}, {ErrJWSInvalidJWK, "urn:ietf:params:acme:error:malformed"}, {ErrJWSWrongType, "urn:ietf:params:acme:error:malformed"}, {ErrJWSPayloadMismatch, "urn:ietf:params:acme:error:serverInternal"}, {ErrJWSMalformed, "urn:ietf:params:acme:error:malformed"}, } for _, tc := range cases { p := MapJWSErrorToProblem(tc.err) if p.Type != tc.wantTyp { t.Errorf("err=%v: type = %q, want %q", tc.err, p.Type, tc.wantTyp) } if p.Status == 0 { t.Errorf("err=%v: status was 0", tc.err) } } }