mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
feat(scep): add RFC 8894 message-type constants + RA cert/key config
SCEP RFC 8894 + Intune master bundle — Phase 0 + Phase 1 of 14.
Phase 0 (recon, no code changes):
Baseline tests green at HEAD 2519da8 (handler 79.0% / service 73.2% /
pkcs7 100%). SCEPConfig actual line is 666, prompt cited 639 — used
actual per the 'repo wins' operating rule.
Phase 1 (this commit):
internal/domain/scep.go
* Added SCEPMessageTypeCertRep (3) — RFC 8894 §3.3.2 server response
messageType. Clients pivot on this to extract a cert (Status=Success),
surface a failInfo (Status=Failure), or poll (Status=Pending).
* Added SCEPMessageTypeRenewalReq (17) — RFC 8894 §3.3.1.2
re-enrollment with an existing valid cert; signerInfo signed by the
existing cert (proving possession).
* Added SCEPRequestEnvelope struct — parsed authenticated attributes
from the inbound signerInfo (messageType / transactionID /
senderNonce / signerCert).
* Added SCEPResponseEnvelope struct — what the service hands back to
the handler so the handler can build the CertRep PKIMessage with
the correct status / failInfo / nonce echoes.
* Existing constants preserved unchanged.
internal/config/config.go
* SCEPConfig.RACertPath + RAKeyPath fields with the doc-comment density
matching the existing ChallengePassword field.
* Env-var loading: CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH.
* Validate() refuse: SCEP enabled with empty RA pair fails loud at
startup (defense-in-depth with the new preflight gate below).
cmd/server/main.go
* preflightSCEPRACertKey: file existence, mode 0600 gate (refuses
world-/group-readable RA key), tls.X509KeyPair-based parse + match
+ algorithm check (one stdlib call covers parse + cert-key match +
pubkey alg in one shot), expiry check, RSA-or-ECDSA gate (RFC 8894
§3.5.2 CMS signing requirement). Mirrors preflightSCEPChallenge-
Password's no-op-when-disabled pattern; each failure returns a
wrapped error so the caller (main) translates to a structured
slog.Error + os.Exit(1).
* Wired into the SCEP startup block immediately after the existing
challenge-password preflight; if it errors, the server refuses to
boot with a specific log line + the pointer to docs/legacy-est-scep.md
for the openssl recipe.
* Added crypto/tls + crypto/x509 imports.
cmd/server/preflight_scep_ra_test.go (new)
* Seven hermetic table-driven test cases covering each failure mode
spelled out in the helper's docblock plus the no-op-when-disabled
path. Each case materialises a real ECDSA P-256 cert/key pair on
disk so the tls.X509KeyPair path is exercised end-to-end (catches
drift in stdlib cert-parsing semantics that a mock would hide):
- disabled SCEP no-op
- missing paths (3 sub-cases: both empty, cert only, key only)
- world-readable key (chmod 0644)
- valid pair (happy path)
- expired cert (NotAfter in past)
- mismatched pair (cert from one ECDSA pair, key from another)
- missing files (paths set but files don't exist)
- ed25519 RA key (unsupported alg per RFC 8894 §3.5.2)
* writeECDSARAPair helper materialises a fresh ECDSA pair under the
test temp dir with the cert at 0644 and the key at 0600 (production
deploy mode).
internal/config/config_test.go
* TestValidate_SCEPEnabled_MissingRAPair_Refuses — 3 sub-cases pin
the new Validate() refuse path (both empty, cert only, key only).
* TestValidate_SCEPEnabled_CompleteRAPair_Accepts — pins the boundary
that file-existence is the preflight's job, NOT Validate's.
* TestValidate_SCEPDisabled_EmptyRAPair_Accepts — pins that the gate
only fires when SCEP is enabled (mirrors the CHALLENGE_PASSWORD
disabled-passes precedent).
docs/features.md
* SCEP env-vars table extended with CERTCTL_SCEP_RA_CERT_PATH and
CERTCTL_SCEP_RA_KEY_PATH (with the prod 'MUST set' callout +
file-mode 0600 requirement). Closes the G-3 'env var defined in Go
but never documented' CI guard for the new vars.
Verification:
* gofmt clean for the files I touched (preflight_scep_ra_test.go +
config.go + scep.go); pre-existing gofmt drift in unrelated files
not in scope.
* go vet ./internal/domain/... ./internal/config/... ./cmd/server/...
clean.
* go test -short -count=1 ./internal/domain/... ./internal/config/...
./cmd/server/... green.
* Coverage held at handler 79.0% / service 73.2% / pkcs7 100% /
config 96.1% / domain 88.6%.
* Local G-3 set difference (Go-defined env vars ∖ docs-mentioned env
vars) empty.
No behavior change for operators who don't enable SCEP. New behavior
gated by CERTCTL_SCEP_ENABLED=true + the new RA env vars. The MVP
raw-CSR fall-through path stays unchanged — Phase 2 will add the
RFC 8894 EnvelopedData decryption that consumes the RA pair.
Phase 1 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
This commit is contained in:
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -743,6 +745,25 @@ func main() {
|
|||||||
)
|
)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// SCEP RFC 8894 Phase 1: validate the RA cert/key pair before booting.
|
||||||
|
// Without a valid pair the new RFC 8894 PKIMessage path (EnvelopedData
|
||||||
|
// decryption + CertRep signing) cannot run; fail loud at startup rather
|
||||||
|
// than silently falling through to the MVP raw-CSR path on every
|
||||||
|
// request. preflightSCEPRACertKey checks: file existence, key file mode
|
||||||
|
// 0600 (defense-in-depth against world-readable RA key), cert/key
|
||||||
|
// algorithm match, RA cert not expired, RA cert public-key algorithm is
|
||||||
|
// CMS-compatible (RSA or ECDSA per RFC 8894 §3.5.2). Mirrors
|
||||||
|
// preflightSCEPChallengePassword's fail-loud-then-os.Exit(1) pattern.
|
||||||
|
if err := preflightSCEPRACertKey(cfg.SCEP.Enabled, cfg.SCEP.RACertPath, cfg.SCEP.RAKeyPath); err != nil {
|
||||||
|
logger.Error(
|
||||||
|
"startup refused: SCEP RA cert/key preflight failed "+
|
||||||
|
"(RFC 8894 §3.2.2 EnvelopedData + §3.3.2 CertRep require an RA pair). "+
|
||||||
|
"Generate the RA pair per docs/legacy-est-scep.md, set "+
|
||||||
|
"CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH, then restart.",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
|
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
||||||
@@ -1105,6 +1126,106 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
||||||
|
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
||||||
|
// pattern; otherwise the checks are:
|
||||||
|
//
|
||||||
|
// 1. Both paths are non-empty (the Validate() refuse covers this too,
|
||||||
|
// but preflight reports the specific failure mode + os.Exit(1) so the
|
||||||
|
// operator sees a clear log line in addition to the config error).
|
||||||
|
// 2. The key file mode is 0600 (refuse world-/group-readable RA key —
|
||||||
|
// defense-in-depth against credential leak via a misconfigured
|
||||||
|
// deploy that leaves /etc/certctl/scep/*.key as 0644).
|
||||||
|
// 3. Cert PEM parses to exactly one x509.Certificate.
|
||||||
|
// 4. Key PEM parses to a Go crypto.Signer (RSA or ECDSA — RFC 8894
|
||||||
|
// §3.5.2 advertises those as the CMS-compatible algorithms).
|
||||||
|
// 5. The cert's PublicKey matches the key's Public() — refuses pairs
|
||||||
|
// accidentally swapped between profiles in a multi-profile config.
|
||||||
|
// 6. The cert's NotAfter is in the future — an expired RA cert would
|
||||||
|
// fail TLS handshake on EnvelopedData decryption per RFC 5652.
|
||||||
|
//
|
||||||
|
// Each check returns a wrapped error; the caller (main) is responsible for
|
||||||
|
// translating to a structured slog.Error + os.Exit(1) so the helper stays
|
||||||
|
// unit-testable without booting the full server.
|
||||||
|
func preflightSCEPRACertKey(enabled bool, raCertPath, raKeyPath string) error {
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if raCertPath == "" || raKeyPath == "" {
|
||||||
|
return fmt.Errorf("SCEP enabled but RA pair missing: " +
|
||||||
|
"set CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH " +
|
||||||
|
"(RFC 8894 §3.2.2 requires an RA pair so clients can encrypt the " +
|
||||||
|
"CSR to the RA cert and the server can sign the CertRep response)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// File mode check FIRST so a world-readable key never gets read into the
|
||||||
|
// process address space. Ignored on Windows (Stat().Mode() doesn't carry
|
||||||
|
// POSIX bits there); the production deploy is Linux per the Dockerfile.
|
||||||
|
keyInfo, err := os.Stat(raKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH stat failed: %w (path=%s)", err, raKeyPath)
|
||||||
|
}
|
||||||
|
mode := keyInfo.Mode().Perm()
|
||||||
|
if mode&0o077 != 0 {
|
||||||
|
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH has insecure permissions %#o; "+
|
||||||
|
"RA private key must be mode 0600 (owner read/write only) — "+
|
||||||
|
"chmod 0600 %s and restart", mode, raKeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM, err := os.ReadFile(raCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CERTCTL_SCEP_RA_CERT_PATH read failed: %w (path=%s)", err, raCertPath)
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(raKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH read failed: %w (path=%s)", err, raKeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tls.X509KeyPair validates that the cert + key parse, share an algorithm,
|
||||||
|
// and the cert's PublicKey matches the key's Public() — three of our six
|
||||||
|
// checks in a single stdlib call, so we use it rather than re-implementing.
|
||||||
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RA cert/key pair invalid: %w "+
|
||||||
|
"(cert=%s key=%s) — verify the cert and key are matching halves of "+
|
||||||
|
"the same RA pair, both PEM-encoded, with the cert containing exactly "+
|
||||||
|
"one CERTIFICATE block and the key containing one PRIVATE KEY block",
|
||||||
|
err, raCertPath, raKeyPath)
|
||||||
|
}
|
||||||
|
if len(pair.Certificate) == 0 {
|
||||||
|
// Defensive — tls.X509KeyPair already errors on this, but the contract
|
||||||
|
// for the next x509.ParseCertificate call needs the slice non-empty.
|
||||||
|
return fmt.Errorf("RA cert PEM at %s contains no certificate blocks", raCertPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-parse the leaf so we can read NotAfter + the public-key alg.
|
||||||
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RA cert at %s does not parse as x509: %w", raCertPath, err)
|
||||||
|
}
|
||||||
|
if time.Now().After(leaf.NotAfter) {
|
||||||
|
return fmt.Errorf("RA cert at %s expired at %s — "+
|
||||||
|
"generate a fresh RA pair (the SCEP CertRep signature would be "+
|
||||||
|
"rejected by every conformant client)", raCertPath, leaf.NotAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CMS-compatible public-key algorithm gate. RFC 8894 §3.5.2 advertises RSA
|
||||||
|
// and AES; the responder cert algorithm pertains to the signature scheme
|
||||||
|
// used on the CertRep, which means the cert's PublicKey must be RSA or
|
||||||
|
// ECDSA. Catches pre-shared Ed25519 dev keys that micromdm/scep clients
|
||||||
|
// reject.
|
||||||
|
switch leaf.PublicKeyAlgorithm {
|
||||||
|
case x509.RSA, x509.ECDSA:
|
||||||
|
// ok — supported by golang.org/x/crypto/ocsp + every SCEP client
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("RA cert at %s uses unsupported public-key algorithm %s — "+
|
||||||
|
"RFC 8894 §3.5.2 CMS signing requires RSA or ECDSA",
|
||||||
|
raCertPath, leaf.PublicKeyAlgorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
||||||
// can actually serve a CA certificate. This closes audit finding L-005:
|
// can actually serve a CA certificate. This closes audit finding L-005:
|
||||||
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 1: preflightSCEPRACertKey covers the six failure
|
||||||
|
// modes spelled out in the helper's docblock plus the no-op-when-disabled
|
||||||
|
// path. Mirrors TestPreflightEnrollmentIssuer's table-driven shape so the
|
||||||
|
// suite stays uniform for the next reviewer.
|
||||||
|
//
|
||||||
|
// Each test materialises a real ECDSA P-256 cert/key pair on disk (rather
|
||||||
|
// than mocking) so the tls.X509KeyPair path is exercised end-to-end —
|
||||||
|
// catches drift in stdlib cert-parsing semantics that a mock would hide.
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_Disabled_NoOp(t *testing.T) {
|
||||||
|
// Enabled=false short-circuits before any path validation; should pass
|
||||||
|
// even with empty paths (mirrors preflightSCEPChallengePassword).
|
||||||
|
if err := preflightSCEPRACertKey(false, "", ""); err != nil {
|
||||||
|
t.Fatalf("disabled SCEP returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_EnabledMissingPaths_Refuses(t *testing.T) {
|
||||||
|
// Validate() also catches this; preflight reports the specific failure
|
||||||
|
// with a more actionable error string + os.Exit(1) at the call site.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
certPath string
|
||||||
|
keyPath string
|
||||||
|
}{
|
||||||
|
{"both_empty", "", ""},
|
||||||
|
{"cert_only", "/tmp/ra.crt", ""},
|
||||||
|
{"key_only", "", "/tmp/ra.key"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := preflightSCEPRACertKey(true, tc.certPath, tc.keyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for missing paths, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "RA pair missing") {
|
||||||
|
t.Errorf("error should mention RA pair missing, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_KeyWorldReadable_Refuses(t *testing.T) {
|
||||||
|
// Defense-in-depth: even a perfectly-valid RA pair must be rejected if
|
||||||
|
// the key file is mode 0644 (world-readable). The deploy convention is
|
||||||
|
// 0600 — owner read/write only.
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||||
|
// Re-chmod the key to 0644 to trigger the gate.
|
||||||
|
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||||
|
t.Fatalf("chmod failed: %v", err)
|
||||||
|
}
|
||||||
|
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for world-readable key, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "insecure permissions") {
|
||||||
|
t.Errorf("error should mention insecure permissions, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_ValidPair_Accepts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||||
|
if err := preflightSCEPRACertKey(true, certPath, keyPath); err != nil {
|
||||||
|
t.Fatalf("valid RA pair rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_ExpiredCert_Refuses(t *testing.T) {
|
||||||
|
// An RA cert past NotAfter would cause every conformant SCEP client to
|
||||||
|
// reject the CertRep signature. Catch it at startup.
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(-1*time.Hour))
|
||||||
|
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for expired cert, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "expired") {
|
||||||
|
t.Errorf("error should mention expired, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_MismatchedPair_Refuses(t *testing.T) {
|
||||||
|
// tls.X509KeyPair detects the cert/key mismatch; preflight should
|
||||||
|
// surface it with an actionable error (cert + key are halves of
|
||||||
|
// different RA pairs — common multi-profile typo).
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, _ := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||||
|
_, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||||
|
// Re-write the key path under a unique name to avoid collision with
|
||||||
|
// the first pair's file (writeECDSARAPair would have overwritten).
|
||||||
|
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for mismatched pair, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Errorf("error should mention invalid pair, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_MissingFiles_Refuses(t *testing.T) {
|
||||||
|
// Both files referenced but neither exists — a typo or a fresh deploy
|
||||||
|
// where the operator forgot to mount the secret. Cert-path failure mode
|
||||||
|
// is checked first because key-path stat is the first os call after
|
||||||
|
// the empty-string check.
|
||||||
|
dir := t.TempDir()
|
||||||
|
missingCert := filepath.Join(dir, "ra.crt")
|
||||||
|
missingKey := filepath.Join(dir, "ra.key")
|
||||||
|
err := preflightSCEPRACertKey(true, missingCert, missingKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for missing files, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "stat failed") && !strings.Contains(err.Error(), "read failed") {
|
||||||
|
t.Errorf("error should mention stat/read failure, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightSCEPRACertKey_UnsupportedAlg_Refuses(t *testing.T) {
|
||||||
|
// Ed25519 isn't supported by the CMS signature path RFC 8894 §3.5.2
|
||||||
|
// advertises. Catch this at startup to avoid runtime failures the
|
||||||
|
// first time a client sends a real PKIMessage.
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath := filepath.Join(dir, "ra.crt")
|
||||||
|
keyPath := filepath.Join(dir, "ra.key")
|
||||||
|
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "ra-ed25519"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||||
|
t.Fatalf("write cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||||
|
t.Fatalf("write key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = preflightSCEPRACertKey(true, certPath, keyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for ed25519 RA cert, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported public-key algorithm") &&
|
||||||
|
!strings.Contains(err.Error(), "invalid") {
|
||||||
|
// tls.X509KeyPair may reject ed25519 SCEP-signing keys earlier
|
||||||
|
// than our explicit alg gate; accept either failure path so the
|
||||||
|
// test is robust against stdlib changes.
|
||||||
|
t.Errorf("error should mention algorithm/invalid, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeECDSARAPair generates a fresh ECDSA P-256 self-signed cert + key,
|
||||||
|
// writes them to dir/ra-<rand>.crt + ra-<rand>.key with the cert at 0644
|
||||||
|
// and the key at 0600 (the production deploy mode). Returns the two paths.
|
||||||
|
func writeECDSARAPair(t *testing.T, dir string, notAfter time.Time) (certPath, keyPath string) {
|
||||||
|
t.Helper()
|
||||||
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: "ra-test"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: notAfter,
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
// Use a unique suffix so successive calls within the same test don't
|
||||||
|
// overwrite each other (the mismatched-pair test relies on this).
|
||||||
|
suffix := tmpl.SerialNumber.String()
|
||||||
|
certPath = filepath.Join(dir, "ra-"+suffix+".crt")
|
||||||
|
keyPath = filepath.Join(dir, "ra-"+suffix+".key")
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||||
|
t.Fatalf("write cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||||
|
t.Fatalf("write key: %v", err)
|
||||||
|
}
|
||||||
|
return certPath, keyPath
|
||||||
|
}
|
||||||
@@ -646,6 +646,8 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
|
|||||||
| `CERTCTL_SCEP_ISSUER_ID` | `iss-local` | Issuer for SCEP enrollments |
|
| `CERTCTL_SCEP_ISSUER_ID` | `iss-local` | Issuer for SCEP enrollments |
|
||||||
| `CERTCTL_SCEP_PROFILE_ID` | (none) | Optional profile constraint |
|
| `CERTCTL_SCEP_PROFILE_ID` | (none) | Optional profile constraint |
|
||||||
| `CERTCTL_SCEP_CHALLENGE_PASSWORD` | (none) | Shared secret for enrollment authentication |
|
| `CERTCTL_SCEP_CHALLENGE_PASSWORD` | (none) | Shared secret for enrollment authentication |
|
||||||
|
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA (Registration Authority) certificate. **Required when `CERTCTL_SCEP_ENABLED=true`** for the RFC 8894 PKIMessage path: SCEP clients encrypt their PKCS#10 CSR to this cert's public key (EnvelopedData wrapper, RFC 8894 §3.2.2) and the server signs the outbound CertRep PKIMessage signerInfo with the matching key (RFC 8894 §3.3.2). Generation: a self-signed cert with `CN=<your-ca-id>-RA` and the `id-kp-emailProtection` / `id-kp-cmcRA` EKU is sufficient — see [`legacy-est-scep.md`](legacy-est-scep.md) for the openssl recipe. The preflight gate at startup also enforces a cert/key match, non-expired NotAfter, and an RSA-or-ECDSA public-key algorithm. |
|
||||||
|
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded private key matching `CERTCTL_SCEP_RA_CERT_PATH`. **Required when `CERTCTL_SCEP_ENABLED=true`.** File MUST be mode `0600` (owner read/write only); preflight refuses to load a world- or group-readable RA key as defense-in-depth against credential leak. The server reads this file once at startup; rotation requires a restart. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,32 @@ type SCEPConfig struct {
|
|||||||
// issuer. The service-layer PKCSReq path also rejects this configuration
|
// issuer. The service-layer PKCSReq path also rejects this configuration
|
||||||
// defense-in-depth.
|
// defense-in-depth.
|
||||||
ChallengePassword string
|
ChallengePassword string
|
||||||
|
|
||||||
|
// RACertPath is the path to a PEM-encoded RA (Registration Authority)
|
||||||
|
// certificate used by the RFC 8894 SCEP path. SCEP clients encrypt their
|
||||||
|
// PKCS#10 CSR to this cert's public key (via the EnvelopedData wrapper, RFC
|
||||||
|
// 8894 §3.2.2). The certctl server uses RAKeyPath to decrypt inbound
|
||||||
|
// EnvelopedData and to sign outbound CertRep PKIMessage signerInfo (RFC
|
||||||
|
// 8894 §3.3.2).
|
||||||
|
//
|
||||||
|
// Required when Enabled is true; Config.Validate() refuses to start without
|
||||||
|
// it. Without an RA pair the new RFC 8894 path silently falls through to
|
||||||
|
// the MVP raw-CSR path on every request and the operator's intent is
|
||||||
|
// unclear — fail loud at startup instead.
|
||||||
|
//
|
||||||
|
// Generation: a self-signed RA cert with subject "CN=<your-ca-id>-RA" and
|
||||||
|
// the id-kp-emailProtection / id-kp-cmcRA EKU is sufficient. The RA cert
|
||||||
|
// SHOULD be the same cert returned by GetCACert (RFC 8894 §3.5.1) so
|
||||||
|
// clients encrypt to a key the server can decrypt with. See
|
||||||
|
// docs/legacy-est-scep.md for the openssl recipe.
|
||||||
|
RACertPath string
|
||||||
|
|
||||||
|
// RAKeyPath is the path to the PEM-encoded private key matching RACertPath.
|
||||||
|
// File MUST be mode 0600 (owner read/write only); preflight refuses to load
|
||||||
|
// a world-readable RA key as defense-in-depth against credential leak. The
|
||||||
|
// server only ever reads this file at startup; rotation requires a restart
|
||||||
|
// (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go).
|
||||||
|
RAKeyPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||||
@@ -1118,6 +1144,13 @@ func Load() (*Config, error) {
|
|||||||
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
|
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
|
||||||
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
|
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
|
||||||
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
|
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
|
||||||
|
// SCEP RFC 8894 Phase 1: RA cert + key for the EnvelopedData /
|
||||||
|
// signerInfo path. Required when Enabled is true (Validate() refuse
|
||||||
|
// + cmd/server/main.go::preflightSCEPRACertKey). Loaded from
|
||||||
|
// CERTCTL_SCEP_RA_CERT_PATH / CERTCTL_SCEP_RA_KEY_PATH per the
|
||||||
|
// existing CERTCTL_SCEP_* prefix convention.
|
||||||
|
RACertPath: getEnv("CERTCTL_SCEP_RA_CERT_PATH", ""),
|
||||||
|
RAKeyPath: getEnv("CERTCTL_SCEP_RA_KEY_PATH", ""),
|
||||||
},
|
},
|
||||||
Verification: VerificationConfig{
|
Verification: VerificationConfig{
|
||||||
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
||||||
@@ -1403,6 +1436,17 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
|
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 1: RA cert + key are mandatory when SCEP is enabled.
|
||||||
|
// Without them the new RFC 8894 PKIMessage path (EnvelopedData decryption,
|
||||||
|
// CertRep signing) cannot run and every SCEP request silently falls through
|
||||||
|
// to the MVP raw-CSR path — fail loud at startup so the operator's intent
|
||||||
|
// is unambiguous. Mirrors the ChallengePassword gate above; defense in
|
||||||
|
// depth with cmd/server/main.go::preflightSCEPRACertKey which additionally
|
||||||
|
// validates file mode + cert/key match + expiry + algorithm.
|
||||||
|
if c.SCEP.Enabled && (c.SCEP.RACertPath == "" || c.SCEP.RAKeyPath == "") {
|
||||||
|
return fmt.Errorf("SCEP is enabled but RA cert/key path missing — refuse to start (RFC 8894 §3.2.2 requires an RA cert clients can encrypt their CSR to and an RA key the server uses to decrypt + sign CertRep): set both CERTCTL_SCEP_RA_CERT_PATH and CERTCTL_SCEP_RA_KEY_PATH or disable SCEP with CERTCTL_SCEP_ENABLED=false. See docs/legacy-est-scep.md for the openssl recipe to generate the RA pair. This gate duplicates cmd/server/main.go:preflightSCEPRACertKey for defense in depth")
|
||||||
|
}
|
||||||
|
|
||||||
// Validate scheduler intervals
|
// Validate scheduler intervals
|
||||||
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
|
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
|
||||||
return fmt.Errorf("renewal check interval must be at least 1 minute")
|
return fmt.Errorf("renewal check interval must be at least 1 minute")
|
||||||
|
|||||||
@@ -1290,3 +1290,116 @@ func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
|
|||||||
t.Errorf("Validate() returned error for 44-byte key: %v", err)
|
t.Errorf("Validate() returned error for 44-byte key: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 1: Validate() must refuse to start when SCEP is enabled
|
||||||
|
// without an RA cert + key pair, mirroring the existing CHALLENGE_PASSWORD
|
||||||
|
// gate. Defense-in-depth with cmd/server/main.go::preflightSCEPRACertKey
|
||||||
|
// which additionally validates file mode + cert/key match + expiry + alg.
|
||||||
|
func TestValidate_SCEPEnabled_MissingRAPair_Refuses(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raCertPath string
|
||||||
|
raKeyPath string
|
||||||
|
}{
|
||||||
|
{"both_empty", "", ""},
|
||||||
|
{"cert_only", "/etc/certctl/scep/ra.crt", ""},
|
||||||
|
{"key_only", "", "/etc/certctl/scep/ra.key"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Server: validServerConfig(t),
|
||||||
|
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||||
|
Log: LogConfig{Level: "info", Format: "json"},
|
||||||
|
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
|
||||||
|
Keygen: KeygenConfig{Mode: "agent"},
|
||||||
|
Scheduler: SchedulerConfig{
|
||||||
|
RenewalCheckInterval: 1 * time.Hour,
|
||||||
|
JobProcessorInterval: 30 * time.Second,
|
||||||
|
AgentHealthCheckInterval: 2 * time.Minute,
|
||||||
|
NotificationProcessInterval: 1 * time.Minute,
|
||||||
|
NotificationRetryInterval: 2 * time.Minute,
|
||||||
|
RetryInterval: 5 * time.Minute,
|
||||||
|
JobTimeoutInterval: 10 * time.Minute,
|
||||||
|
AwaitingCSRTimeout: 24 * time.Hour,
|
||||||
|
AwaitingApprovalTimeout: 168 * time.Hour,
|
||||||
|
},
|
||||||
|
SCEP: SCEPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ChallengePassword: "shared-secret-not-empty",
|
||||||
|
RACertPath: tc.raCertPath,
|
||||||
|
RAKeyPath: tc.raKeyPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Validate() = nil, want error for SCEP enabled with missing RA pair")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "RA cert/key path missing") {
|
||||||
|
t.Errorf("Validate() error = %q, want 'RA cert/key path missing'", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEP enabled with a complete RA pair (and a non-empty challenge password)
|
||||||
|
// should pass Validate — the file-existence + mode + match checks live in
|
||||||
|
// preflightSCEPRACertKey, not in Validate. This pins the boundary so a
|
||||||
|
// future "validate the file too" refactor doesn't accidentally double up.
|
||||||
|
func TestValidate_SCEPEnabled_CompleteRAPair_Accepts(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Server: validServerConfig(t),
|
||||||
|
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||||
|
Log: LogConfig{Level: "info", Format: "json"},
|
||||||
|
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
|
||||||
|
Keygen: KeygenConfig{Mode: "agent"},
|
||||||
|
Scheduler: SchedulerConfig{
|
||||||
|
RenewalCheckInterval: 1 * time.Hour,
|
||||||
|
JobProcessorInterval: 30 * time.Second,
|
||||||
|
AgentHealthCheckInterval: 2 * time.Minute,
|
||||||
|
NotificationProcessInterval: 1 * time.Minute,
|
||||||
|
NotificationRetryInterval: 2 * time.Minute,
|
||||||
|
RetryInterval: 5 * time.Minute,
|
||||||
|
JobTimeoutInterval: 10 * time.Minute,
|
||||||
|
AwaitingCSRTimeout: 24 * time.Hour,
|
||||||
|
AwaitingApprovalTimeout: 168 * time.Hour,
|
||||||
|
},
|
||||||
|
SCEP: SCEPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ChallengePassword: "shared-secret-not-empty",
|
||||||
|
RACertPath: "/etc/certctl/scep/ra.crt",
|
||||||
|
RAKeyPath: "/etc/certctl/scep/ra.key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() = %v, want nil for complete RA pair (file-existence checked in preflightSCEPRACertKey)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEP disabled with empty RA pair fields must NOT trip the gate — the
|
||||||
|
// fields only matter when SCEP is enabled. Mirrors the CHALLENGE_PASSWORD
|
||||||
|
// disabled-passes precedent in TestValidate_ValidConfig.
|
||||||
|
func TestValidate_SCEPDisabled_EmptyRAPair_Accepts(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Server: validServerConfig(t),
|
||||||
|
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||||
|
Log: LogConfig{Level: "info", Format: "json"},
|
||||||
|
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
|
||||||
|
Keygen: KeygenConfig{Mode: "agent"},
|
||||||
|
Scheduler: SchedulerConfig{
|
||||||
|
RenewalCheckInterval: 1 * time.Hour,
|
||||||
|
JobProcessorInterval: 30 * time.Second,
|
||||||
|
AgentHealthCheckInterval: 2 * time.Minute,
|
||||||
|
NotificationProcessInterval: 1 * time.Minute,
|
||||||
|
NotificationRetryInterval: 2 * time.Minute,
|
||||||
|
RetryInterval: 5 * time.Minute,
|
||||||
|
JobTimeoutInterval: 10 * time.Minute,
|
||||||
|
AwaitingCSRTimeout: 24 * time.Hour,
|
||||||
|
AwaitingApprovalTimeout: 168 * time.Hour,
|
||||||
|
},
|
||||||
|
SCEP: SCEPConfig{Enabled: false}, // RACertPath / RAKeyPath stay empty
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() = %v, want nil for SCEP disabled with empty RA pair", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+62
-4
@@ -10,9 +10,25 @@ type SCEPEnrollResult struct {
|
|||||||
type SCEPMessageType int
|
type SCEPMessageType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// SCEPMessageTypeCertRep is the server's response to PKCSReq / RenewalReq /
|
||||||
|
// GetCertInitial. RFC 8894 §3.3.2. Wire-encoded as the messageType
|
||||||
|
// authenticated attribute on the outbound CertRep PKIMessage; clients pivot
|
||||||
|
// on this value to decide whether to extract a cert from the EnvelopedData
|
||||||
|
// (Status=Success), surface a failInfo (Status=Failure), or poll
|
||||||
|
// (Status=Pending).
|
||||||
|
SCEPMessageTypeCertRep SCEPMessageType = 3
|
||||||
|
// SCEPMessageTypeRenewalReq is re-enrollment with an existing valid cert.
|
||||||
|
// RFC 8894 §3.3.1.2. Distinct from PKCSReq because the signerInfo is signed
|
||||||
|
// by the existing cert (proving possession), not by a transient self-signed
|
||||||
|
// device key. The service-side handler must verify the signing cert chains
|
||||||
|
// to a trusted CA and is not yet revoked or expired.
|
||||||
|
SCEPMessageTypeRenewalReq SCEPMessageType = 17
|
||||||
// SCEPMessageTypePKCSReq is a PKCS#10 certificate request (initial enrollment).
|
// SCEPMessageTypePKCSReq is a PKCS#10 certificate request (initial enrollment).
|
||||||
|
// RFC 8894 §3.3.1.
|
||||||
SCEPMessageTypePKCSReq SCEPMessageType = 19
|
SCEPMessageTypePKCSReq SCEPMessageType = 19
|
||||||
// SCEPMessageTypeGetCertInitial is a polling request for a pending certificate.
|
// SCEPMessageTypeGetCertInitial is a polling request for a pending certificate.
|
||||||
|
// RFC 8894 §3.3.3. Used when the prior PKCSReq returned Status=Pending and
|
||||||
|
// the client is checking whether the request has been approved.
|
||||||
SCEPMessageTypeGetCertInitial SCEPMessageType = 20
|
SCEPMessageTypeGetCertInitial SCEPMessageType = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,9 +48,51 @@ const (
|
|||||||
type SCEPFailInfo string
|
type SCEPFailInfo string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
|
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
|
||||||
SCEPFailBadMessageCheck SCEPFailInfo = "1" // Integrity check failed
|
SCEPFailBadMessageCheck SCEPFailInfo = "1" // Integrity check failed
|
||||||
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
|
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
|
||||||
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
|
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
|
||||||
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
|
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SCEPRequestEnvelope carries the parsed RFC 8894 PKIMessage authenticated
|
||||||
|
// attributes from the inbound signerInfo (RFC 8894 §3.2.1.2). Populated by
|
||||||
|
// the handler when a request comes in over the new RFC-8894 path; consumed
|
||||||
|
// by the service to thread transactionID + nonces through to the CertRep
|
||||||
|
// response and the audit trail.
|
||||||
|
//
|
||||||
|
// Fields mirror the SCEP attributes RFC 8894 §3.2.1.2 enumerates:
|
||||||
|
// - messageType: which SCEP operation (PKCSReq / RenewalReq / GetCertInitial)
|
||||||
|
// - transactionID: client-chosen identifier; server MUST echo verbatim in CertRep
|
||||||
|
// - senderNonce: 16-byte client nonce; server MUST echo as recipientNonce
|
||||||
|
// - signerCert: the device's transient self-signed cert (PKCSReq) or its
|
||||||
|
// existing valid cert (RenewalReq) — the public key in this cert is what
|
||||||
|
// the server encrypts the CertRep EnvelopedData to.
|
||||||
|
//
|
||||||
|
// The MVP fall-through path (handler::extractCSRFromPKCS7) does not populate
|
||||||
|
// this struct; it stays nil and the service layer routes to the legacy
|
||||||
|
// PKCSReq method that synthesizes a transactionID from the CSR's CommonName.
|
||||||
|
type SCEPRequestEnvelope struct {
|
||||||
|
MessageType SCEPMessageType // PKCSReq (19), RenewalReq (17), GetCertInitial (20)
|
||||||
|
TransactionID string // client-chosen ID; echoed verbatim in CertRep response
|
||||||
|
SenderNonce []byte // 16-byte client nonce; echoed as recipientNonce
|
||||||
|
SignerCert []byte // DER of the device's signing cert (for CertRep encryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEPResponseEnvelope is what the service hands back to the handler so the
|
||||||
|
// handler can build the CertRep PKIMessage. The handler is responsible for
|
||||||
|
// computing the new senderNonce and signing the response with the RA cert/key
|
||||||
|
// loaded at startup (see SCEPConfig.RACertPath / RAKeyPath).
|
||||||
|
//
|
||||||
|
// Status semantics (RFC 8894 §3.3.2.1):
|
||||||
|
// - SCEPStatusSuccess: Result is non-nil and contains the issued cert + chain
|
||||||
|
// - SCEPStatusFailure: FailInfo identifies the rejection reason; Result is nil
|
||||||
|
// - SCEPStatusPending: request is queued for manual approval; Result is nil
|
||||||
|
// (client polls via GetCertInitial)
|
||||||
|
type SCEPResponseEnvelope struct {
|
||||||
|
Status SCEPPKIStatus
|
||||||
|
FailInfo SCEPFailInfo // populated only when Status == SCEPStatusFailure
|
||||||
|
TransactionID string // echo of request.TransactionID
|
||||||
|
RecipientNonce []byte // echo of request.SenderNonce
|
||||||
|
Result *SCEPEnrollResult // populated only when Status == SCEPStatusSuccess
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user