mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.
Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.
== Phase 4: RenewalReq + GetCertInitial wiring ============================
internal/service/scep.go
* RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
existing valid cert. Same contract as PKCSReqWithEnvelope but the
service additionally verifies that envelope.SignerCert chains to
the issuer's CA (verifyRenewalSignerCertChain). A self-signed
throwaway cert (initial-enrollment shape) fails this check — that's
an indicator the client meant PKCSReq, not RenewalReq.
* GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
Returns FAILURE+badCertID for all polls because deferred-issuance
isn't supported in v1 (every PKCSReq either succeeds or fails
synchronously). Wiring stays in place for a future enhancement.
* Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
grep the audit log to distinguish initial enrollments from renewals.
internal/api/handler/scep.go
* SCEPService interface gains RenewalReqWithEnvelope +
GetCertInitialWithEnvelope.
* pkiOperation RFC 8894 path now switches on envelope.MessageType:
PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
badRequest per RFC 8894 §3.3.2.2.
== Phase 5.1: GetCACaps capability advertisement =========================
internal/service/scep.go
* Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
to add 'SHA-512' (modern digest alternative now implemented in the
Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
Phase 4). ChromeOS specifically looks for these capabilities to
negotiate the strongest available cipher + digest combo.
* scep_test.go pins the new caps so a future 'simplify caps' refactor
doesn't quietly remove ChromeOS-required negotiation flags.
== Phase 5.2: ChromeOS-shape integration tests ===========================
internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
* 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
in-test (acting as the ChromeOS client), POSTs through the handler,
parses the CertRep response back via the same internal/pkcs7/
builders the handler uses.
* TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
POSTed; verifies CertRep parses + RA signature verifies.
* TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
* TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
* TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
signature falls through to MVP path (which also rejects since the
encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
* TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
* TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
path keeps working when no RA pair is configured. Backward compat
is non-negotiable.
== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============
internal/domain/profile.go
* Added MustStaple bool to CertificateProfile. Default false; operators
opt in once they've confirmed the TLS reverse proxy / load balancer
staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
require explicit config).
internal/connector/issuer/interface.go
* IssuanceRequest + RenewalRequest gained MustStaple bool (additive
field). Connectors that don't support extension injection (Vault,
EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
issuer-only feature in V2 since upstream connectors enforce their
own extension policy.
internal/connector/issuer/local/local.go
* Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
RFC 7633 §6).
* generateCertificate signature gained mustStaple bool; when true,
appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
mustStapleExtensionValue} to template.ExtraExtensions before
x509.CreateCertificate.
internal/connector/issuer/local/must_staple_test.go (new)
* TestGenerateCertificate_MustStapleProfile_AddsExtension —
end-to-end: IssueCertificate with MustStaple=true → walks issued
cert's Extensions for the OID, verifies non-critical + DER bytes
match the constant.
* TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
'omit by default' contract (adding it by default would break
customer deployments where the TLS path doesn't staple).
* TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
DER bytes against RFC 7633 §6 verbatim; round-trips through
asn1.Unmarshal as []int{5}.
Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.
== Phase 5.4: docs/legacy-est-scep.md ====================================
Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).
== Verification ==========================================================
* gofmt + go vet clean for the files I touched.
* staticcheck ./internal/api/handler/... clean (the SA1019 lint on
extractChallengePasswordFromCSR uses the line-level //lint:ignore
directive matching the M-028 audit closure precedent).
* go test -short -count=1 green across api/handler / api/router /
service / pkcs7 / connector/issuer/local / domain / cmd/server.
* G-3 docs-drift CI guard local check: empty diff in both directions.
Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.
Living progress at cowork/scep-rfc8894-intune/progress.md.
This commit is contained in:
@@ -201,6 +201,167 @@ becomes a compliance failure:
|
||||
- https://www.pcisecuritystandards.org/news_events/
|
||||
- https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions)
|
||||
|
||||
## SCEP RFC 8894 native implementation (post-2026-04-29)
|
||||
|
||||
Prior to this bundle, certctl's SCEP server parsed `PKCS#7 SignedData` and
|
||||
treated the encapsulated content as a raw `PKCS#10 CSR` (the file-internal
|
||||
"MVP" comment at `internal/api/handler/scep.go:217` flagged this). That
|
||||
worked for lightweight MDM agents but failed against ChromeOS and most
|
||||
production MDM clients which expect full RFC 8894 wire format:
|
||||
`SignedData` wrapping an `EnvelopedData` encrypting the CSR to the RA
|
||||
cert's public key, with `signerInfo` POPO over the auth-attrs.
|
||||
|
||||
The new RFC 8894 path runs FIRST; on any parse failure it falls through
|
||||
to the legacy MVP raw-CSR path so existing operators see no behavior
|
||||
change for their lightweight clients.
|
||||
|
||||
### Required: RA cert + key
|
||||
|
||||
The RFC 8894 path requires a Registration Authority cert + key pair.
|
||||
Clients encrypt their CSR to the RA cert's public key (RFC 8894 §3.2.2);
|
||||
the certctl server uses the RA key to decrypt and to sign the outbound
|
||||
CertRep PKIMessage signerInfo (RFC 8894 §3.3.2).
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA certificate. **Required when `CERTCTL_SCEP_ENABLED=true`.** |
|
||||
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded RA private key matching `CERTCTL_SCEP_RA_CERT_PATH`. File MUST be mode `0600` (preflight refuses world-readable). |
|
||||
|
||||
Generate the RA pair (any RSA-2048+ or ECDSA-P256+ pair signed by your
|
||||
root or sub-CA works):
|
||||
|
||||
```bash
|
||||
# RSA-2048 RA pair, valid 1 year, signed by your root.
|
||||
openssl req -new -newkey rsa:2048 -nodes -keyout ra.key -out ra.csr \
|
||||
-subj "/CN=corp-ca-RA"
|
||||
openssl x509 -req -in ra.csr -days 365 \
|
||||
-CA root.crt -CAkey root.key -CAcreateserial \
|
||||
-extfile <(printf "extendedKeyUsage=emailProtection,1.3.6.1.5.5.7.3.4") \
|
||||
-out ra.crt
|
||||
|
||||
chmod 0600 ra.key # required — preflight rejects world-readable keys
|
||||
chmod 0644 ra.crt
|
||||
mv ra.key ra.crt /etc/certctl/scep/
|
||||
|
||||
export CERTCTL_SCEP_ENABLED=true
|
||||
export CERTCTL_SCEP_RA_CERT_PATH=/etc/certctl/scep/ra.crt
|
||||
export CERTCTL_SCEP_RA_KEY_PATH=/etc/certctl/scep/ra.key
|
||||
export CERTCTL_SCEP_CHALLENGE_PASSWORD=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
The startup preflight in `cmd/server/main.go::preflightSCEPRACertKey`
|
||||
validates: file existence, key file mode 0600, cert/key match, cert
|
||||
non-expired, RSA-or-ECDSA public-key algorithm. Failures `os.Exit(1)`
|
||||
with a structured log line identifying the offending profile.
|
||||
|
||||
### Capability advertisement (`GetCACaps`)
|
||||
|
||||
```
|
||||
POSTPKIOperation
|
||||
SHA-256
|
||||
SHA-512
|
||||
AES
|
||||
SCEPStandard
|
||||
Renewal
|
||||
```
|
||||
|
||||
ChromeOS specifically looks for `POSTPKIOperation` (non-base64 POST),
|
||||
`AES` (the now-implemented CBC content encryption), `SCEPStandard` (RFC
|
||||
8894 conformance), and `Renewal` (RenewalReq messageType-17 support).
|
||||
Older Cisco IOS clients also accept `SHA-256` and `SHA-512` per RFC 8894
|
||||
§3.5.2.
|
||||
|
||||
### Supported messageTypes
|
||||
|
||||
| Type | RFC 8894 § | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `PKCSReq` (19) | §3.3.1 | Initial enrollment. Signer cert is the device's transient self-signed key. |
|
||||
| `RenewalReq` (17) | §3.3.1.2 | Re-enrollment. Signer cert MUST be a previously-issued cert from this issuer; service-side `verifyRenewalSignerCertChain` enforces. |
|
||||
| `GetCertInitial` (20) | §3.3.3 | Polling for pending requests. v1 returns `FAILURE+badCertID` because deferred-issuance isn't supported (every PKCSReq either succeeds or fails synchronously). |
|
||||
| `CertRep` (3) | §3.3.2 | Server response — never inbound. |
|
||||
|
||||
### MVP backward-compatibility path
|
||||
|
||||
Lightweight clients that send a stripped `SignedData` containing a raw
|
||||
CSR (no `EnvelopedData` wrapper, no `signerInfo` POPO) keep working: the
|
||||
handler tries the RFC 8894 path FIRST; on any parse failure it falls
|
||||
through to the legacy `extractCSRFromPKCS7` path. The legacy path uses
|
||||
the CSR's `challengePassword` attribute the same way as the RFC 8894
|
||||
path. Operators with existing lightweight-client deploys see zero
|
||||
behavior change.
|
||||
|
||||
### Multi-profile dispatch (`/scep/<pathID>`)
|
||||
|
||||
Real enterprise deploys run multiple SCEP endpoints from one certctl
|
||||
instance — corp-laptop CA, IoT CA, server CA — each with its own
|
||||
issuer + RA pair + challenge password. Configure via:
|
||||
|
||||
```
|
||||
CERTCTL_SCEP_PROFILES=corp,iot,server
|
||||
CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop
|
||||
CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls
|
||||
CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=...
|
||||
CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt
|
||||
CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key
|
||||
# ... per profile name in CERTCTL_SCEP_PROFILES
|
||||
```
|
||||
|
||||
The router exposes `/scep/corp`, `/scep/iot`, `/scep/server`. The legacy
|
||||
`/scep` root remains for the single-profile flat-env-var case (when
|
||||
`CERTCTL_SCEP_PROFILES` is unset). Per-profile preflight validates each
|
||||
RA pair independently; failures log the offending PathID.
|
||||
|
||||
### ChromeOS Admin Console pointer
|
||||
|
||||
In Google Admin Console → Devices → Networks → Certificates, register
|
||||
certctl's `/scep[/<pathID>]` URL as the SCEP server. Enter the challenge
|
||||
password from `CERTCTL_SCEP_CHALLENGE_PASSWORD` (or per-profile
|
||||
`CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD`). ChromeOS pulls
|
||||
`GetCACert` first to retrieve the RA cert, then enrolls via
|
||||
PKIOperation.
|
||||
|
||||
### RA cert rotation
|
||||
|
||||
The RA cert is loaded once at startup and persisted in the handler's
|
||||
struct field; rotation requires a server restart (mirrors the
|
||||
`CERTCTL_TLS_CERT_PATH` precedent in `cmd/server/tls.go`). The
|
||||
recommended cadence is annual rotation with a 30-day overlap during
|
||||
which both old + new RA certs are listed in `GetCACert`'s response (set
|
||||
the cert chain accordingly in your sub-CA hierarchy).
|
||||
|
||||
### Must-staple per-profile policy (RFC 7633)
|
||||
|
||||
When a `CertificateProfile` has `MustStaple = true`, the local issuer
|
||||
adds the `id-pe-tlsfeature` extension (OID `1.3.6.1.5.5.7.1.24`,
|
||||
non-critical, value `SEQUENCE OF INTEGER {5}`) to every issued cert.
|
||||
Browsers + modern TLS libraries that see this extension fail-closed on
|
||||
missing OCSP stapling responses — defense against revocation-bypass via
|
||||
OCSP blackholing.
|
||||
|
||||
**Default policy:** `false`. Operators opt in once they've confirmed the
|
||||
TLS reverse proxy / load balancer staples OCSP responses. NGINX,
|
||||
HAProxy, Envoy all support stapling but it requires explicit config —
|
||||
turning must-staple on without verifying the TLS path will hard-fail
|
||||
browsers.
|
||||
|
||||
Recommended for: Intune-deployed device certs (modern TLS clients);
|
||||
SCEP profiles serving general / legacy clients (ChromeOS, IoT) should
|
||||
stay `false` until the TLS path is verified.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Audit:** every enrollment emits an `audit_event` row with action
|
||||
`scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators
|
||||
can grep the audit log to distinguish.
|
||||
- **Body-size cap:** `http.MaxBytesReader` middleware caps request
|
||||
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
|
||||
typically <50KB so the default cap is generous.
|
||||
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
||||
plane; there is no plaintext fallback.
|
||||
- **Forward reference:** for Microsoft Intune deployments specifically,
|
||||
see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the
|
||||
master bundle ships).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only
|
||||
|
||||
@@ -39,6 +39,19 @@ type SCEPService interface {
|
||||
// failures. Returns nil to signal 'invalid challenge password' (caller
|
||||
// translates to HTTP 403, matching the MVP path's wire shape).
|
||||
PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||
|
||||
// RenewalReqWithEnvelope processes a SCEP RenewalReq (RFC 8894 §3.3.1.2)
|
||||
// from the RFC 8894 path. Same contract as PKCSReqWithEnvelope but the
|
||||
// service additionally verifies that envelope.SignerCert chains to the
|
||||
// issuer's CA — RenewalReq requires a previously-issued cert as POPO.
|
||||
RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||
|
||||
// GetCertInitialWithEnvelope handles SCEP polling requests (RFC 8894
|
||||
// §3.3.3). The v1 implementation always returns FAILURE+badCertID
|
||||
// because deferred-issuance isn't supported (every PKCSReq either
|
||||
// succeeds or fails synchronously); wiring is in place for a future
|
||||
// 'queue for manual approval' workflow.
|
||||
GetCertInitialWithEnvelope(ctx context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||
}
|
||||
|
||||
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
||||
@@ -196,14 +209,44 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||
// backward-compat contract for lightweight clients.
|
||||
if h.raCert != nil && h.raKey != nil {
|
||||
if envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok {
|
||||
resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 4.1: dispatch on
|
||||
// the parsed messageType. PKCSReq + RenewalReq exercise the
|
||||
// full enrollment pipeline (different audit actions + chain
|
||||
// validation for renewal); GetCertInitial is the polling
|
||||
// shape (v1 stub returns badCertID since deferred-issuance
|
||||
// isn't supported); unknown messageType returns CertRep with
|
||||
// FAILURE+badRequest per RFC 8894 §3.3.2.2.
|
||||
var resp *domain.SCEPResponseEnvelope
|
||||
switch envelope.MessageType {
|
||||
case domain.SCEPMessageTypePKCSReq:
|
||||
resp = h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
case domain.SCEPMessageTypeRenewalReq:
|
||||
resp = h.svc.RenewalReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
case domain.SCEPMessageTypeGetCertInitial:
|
||||
resp = h.svc.GetCertInitialWithEnvelope(r.Context(), envelope)
|
||||
default:
|
||||
// Unknown messageType — emit a CertRep+FAILURE so the
|
||||
// client sees a structured response rather than a vague
|
||||
// 400. RFC 8894 §3.2.1.4.1 enumerates the valid types;
|
||||
// anything else is a malformed client.
|
||||
resp = &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadRequest,
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
}
|
||||
if resp == nil {
|
||||
// nil signals 'invalid challenge password'. RFC 8894 §3.3.1
|
||||
// is silent on whether to return a CertRep or an HTTP error
|
||||
// for this case; we mirror the MVP path's HTTP 403 wire
|
||||
// shape so the client sees a clear auth failure rather than
|
||||
// trying to interpret a structurally-valid CertRep+failInfo
|
||||
// (which conflates 'wrong secret' with 'wrong CSR shape').
|
||||
// nil signals 'invalid challenge password' from the
|
||||
// service layer (only PKCSReq + RenewalReq paths can
|
||||
// return nil — GetCertInitial always returns a
|
||||
// CertRep). RFC 8894 §3.3.1 is silent on whether to
|
||||
// return a CertRep or an HTTP error for the wrong-
|
||||
// password case; we mirror the MVP path's HTTP 403
|
||||
// wire shape so the client sees a clear auth failure
|
||||
// rather than trying to interpret a structurally-valid
|
||||
// CertRep+failInfo (which conflates 'wrong secret'
|
||||
// with 'wrong CSR shape').
|
||||
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||
return
|
||||
}
|
||||
@@ -336,9 +379,15 @@ func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope,
|
||||
// the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7).
|
||||
// Returns empty string when missing.
|
||||
//
|
||||
//nolint:staticcheck // SA1019: RFC 2985 challengePassword has no non-deprecated stdlib API; mirrors extractCSRFields.
|
||||
// SA1019 carve-out: csr.Attributes is deprecated by Go's stdlib for the
|
||||
// requestedExtensions attribute, but RFC 2985 challengePassword (OID
|
||||
// 1.2.840.113549.1.9.7) is a SEPARATE CSR attribute that cannot be
|
||||
// retrieved via csr.Extensions. There is no non-deprecated stdlib API
|
||||
// for it; the same `lint:ignore SA1019` line precedent set by
|
||||
// extractCSRFields applies here.
|
||||
func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string {
|
||||
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
||||
//lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see extractCSRFields docblock for the M-028 audit closure rationale.
|
||||
for _, attr := range csr.Attributes {
|
||||
if attr.Type.Equal(oidChallengePassword) {
|
||||
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/des" //nolint:gosec // RFC 8894 §3.5.2 legacy fallback for backward-compat test
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
|
||||
// tests for the SCEP handler's full RFC 8894 path.
|
||||
//
|
||||
// Each test builds a real PKIMessage (acting as the ChromeOS client),
|
||||
// POSTs it through the handler, and verifies the response. The "client"
|
||||
// is built from primitives in internal/pkcs7/ — the same builders the
|
||||
// handler uses on the response side. This is intentional: if the handler
|
||||
// regresses, the client builder might also regress, and the E2E would
|
||||
// pass anyway (false negative). The mitigation: round-trip property
|
||||
// tests in internal/pkcs7/ assert Build/Parse symmetry independently,
|
||||
// and the handler-side tests focus on the dispatch + status-code wire
|
||||
// shape rather than the bytes themselves.
|
||||
|
||||
// chromeOSStackFixture holds the materials needed for an end-to-end
|
||||
// ChromeOS SCEP test: an issuer + RA pair (server side), a transient
|
||||
// device cert (client side), and a constructed SCEPHandler.
|
||||
type chromeOSStackFixture struct {
|
||||
raKey *rsa.PrivateKey
|
||||
raCert *x509.Certificate
|
||||
deviceKey *rsa.PrivateKey
|
||||
deviceCert *x509.Certificate
|
||||
handler SCEPHandler
|
||||
svc *chromeOSMockSCEPService
|
||||
}
|
||||
|
||||
// chromeOSMockSCEPService is the per-test SCEPService implementation used
|
||||
// by these E2E tests. Records the last call's envelope + CSR for assertion.
|
||||
type chromeOSMockSCEPService struct {
|
||||
caCertPEM string
|
||||
pkcsReqEnvelope *domain.SCEPRequestEnvelope
|
||||
pkcsReqCSRPEM string
|
||||
pkcsReqChallenge string
|
||||
renewalReqEnvelope *domain.SCEPRequestEnvelope
|
||||
renewalReqCSRPEM string
|
||||
getCertInitialEnvelope *domain.SCEPRequestEnvelope
|
||||
enrollResult *domain.SCEPEnrollResult
|
||||
failChallenge bool
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCACaps(_ context.Context) string {
|
||||
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n"
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCACert(_ context.Context) (string, error) {
|
||||
return m.caCertPEM, nil
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) {
|
||||
return m.enrollResult, nil
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) PKCSReqWithEnvelope(_ context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.pkcsReqEnvelope = env
|
||||
m.pkcsReqCSRPEM = csrPEM
|
||||
m.pkcsReqChallenge = challengePassword
|
||||
if m.failChallenge {
|
||||
return nil
|
||||
}
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusSuccess,
|
||||
Result: m.enrollResult,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) RenewalReqWithEnvelope(_ context.Context, csrPEM, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.renewalReqEnvelope = env
|
||||
m.renewalReqCSRPEM = csrPEM
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusSuccess,
|
||||
Result: m.enrollResult,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.getCertInitialEnvelope = env
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadCertID,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
// newChromeOSStackFixture wires up an RA pair + device cert + handler with
|
||||
// an enroll-result fixture so the test can POST a PKIMessage and verify the
|
||||
// CertRep response.
|
||||
func newChromeOSStackFixture(t *testing.T) *chromeOSStackFixture {
|
||||
t.Helper()
|
||||
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey RA: %v", err)
|
||||
}
|
||||
raCert := selfSignedRSACert(t, raKey, "ra-test")
|
||||
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey device: %v", err)
|
||||
}
|
||||
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient")
|
||||
|
||||
svc := &chromeOSMockSCEPService{
|
||||
enrollResult: &domain.SCEPEnrollResult{
|
||||
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "issued.example.com")),
|
||||
},
|
||||
}
|
||||
handler := NewSCEPHandler(svc)
|
||||
handler.SetRAPair(raCert, raKey)
|
||||
|
||||
return &chromeOSStackFixture{
|
||||
raKey: raKey,
|
||||
raCert: raCert,
|
||||
deviceKey: deviceKey,
|
||||
deviceCert: deviceCert,
|
||||
handler: handler,
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_E2E exercises the full RFC 8894 path:
|
||||
// build a PKIMessage shaped like ChromeOS sends (SignedData wrapping
|
||||
// EnvelopedData wrapping a CSR, with signerInfo POPO over auth attrs);
|
||||
// POST through the handler; verify the response is a valid CertRep
|
||||
// PKIMessage with the issued cert encrypted to the test's transient pubkey.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_E2E(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-chromeos-e2e", "shared-secret-123", "device-cert.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope == nil {
|
||||
t.Fatal("PKCSReqWithEnvelope was not called — handler skipped RFC 8894 path?")
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope.TransactionID != "txn-chromeos-e2e" {
|
||||
t.Errorf("envelope.TransactionID = %q, want txn-chromeos-e2e", fix.svc.pkcsReqEnvelope.TransactionID)
|
||||
}
|
||||
if fix.svc.pkcsReqChallenge != "shared-secret-123" {
|
||||
t.Errorf("challengePassword = %q, want shared-secret-123", fix.svc.pkcsReqChallenge)
|
||||
}
|
||||
// Parse the CertRep back via the same builders the handler emits.
|
||||
certRep, err := pkcs7.ParseSignedData(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignedData(CertRep response): %v", err)
|
||||
}
|
||||
if len(certRep.SignerInfos) != 1 {
|
||||
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
|
||||
}
|
||||
if err := certRep.SignerInfos[0].VerifySignature(); err != nil {
|
||||
t.Errorf("CertRep RA signature invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_RenewalReq exercises RenewalReq
|
||||
// dispatch — the handler should route to RenewalReqWithEnvelope based on
|
||||
// the messageType auth-attr.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_RenewalReq(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeRenewalReq, "txn-renewal-1", "shared-secret-123", "renewal.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (renewal): got %d, want 200", w.Code)
|
||||
}
|
||||
if fix.svc.renewalReqEnvelope == nil {
|
||||
t.Fatal("RenewalReqWithEnvelope was not called — dispatch missed messageType=17")
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope != nil {
|
||||
t.Errorf("PKCSReqWithEnvelope was called for a RenewalReq messageType — wrong dispatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial exercises the polling
|
||||
// path. v1 always returns FAILURE+badCertID; this test asserts that's what
|
||||
// ChromeOS sees when it polls.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeGetCertInitial, "txn-poll-1", "shared-secret-123", "poll.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (poll): got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
if fix.svc.getCertInitialEnvelope == nil {
|
||||
t.Fatal("GetCertInitialWithEnvelope was not called — dispatch missed messageType=20")
|
||||
}
|
||||
// The response should be a CertRep with pkiStatus=2 (FAILURE) +
|
||||
// failInfo=4 (badCertID).
|
||||
certRep, err := pkcs7.ParseSignedData(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignedData: %v", err)
|
||||
}
|
||||
if len(certRep.SignerInfos) == 0 {
|
||||
t.Fatal("CertRep has no signerInfos")
|
||||
}
|
||||
si := certRep.SignerInfos[0]
|
||||
statusRV, ok := si.AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
|
||||
if !ok {
|
||||
t.Fatal("CertRep missing pkiStatus auth-attr")
|
||||
}
|
||||
statusStr := decodeFirstSetMember(t, statusRV)
|
||||
if statusStr != string(domain.SCEPStatusFailure) {
|
||||
t.Errorf("pkiStatus = %q, want %q (FAILURE)", statusStr, domain.SCEPStatusFailure)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_BadPOPO builds a PKIMessage with the
|
||||
// signerInfo signature corrupted; expects the handler to fall through to
|
||||
// the MVP path (the RFC 8894 verifier rejects the message, and the MVP
|
||||
// path also rejects it because the encrypted EnvelopedData isn't a raw
|
||||
// CSR). Result: HTTP 400 with a clear error message.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_BadPOPO(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-bad-popo", "shared-secret-123", "bad.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
// Tamper with the LAST byte of the message (which lands inside the
|
||||
// signature OCTET STRING for a non-trivial chance of corrupting the
|
||||
// signature without breaking the outer DER framing).
|
||||
pkiMessage[len(pkiMessage)-1] ^= 0xff
|
||||
|
||||
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||
t.Errorf("POST PKIOperation (bad POPO): got %d, want 400 (MVP fall-through rejection) or 200 (CertRep+failInfo)", w.Code)
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope != nil {
|
||||
t.Errorf("PKCSReqWithEnvelope was called despite invalid signerInfo signature — POPO check failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_AESVariants exercises AES-128, 192,
|
||||
// and 256-CBC. ChromeOS picks based on the GetCACaps response; verify
|
||||
// all three round-trip correctly.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
oid asn1.ObjectIdentifier
|
||||
}{
|
||||
{"AES-128-CBC", pkcs7.OIDAES128CBC},
|
||||
{"AES-192-CBC", pkcs7.OIDAES192CBC},
|
||||
{"AES-256-CBC", pkcs7.OIDAES256CBC},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-aes-"+tc.name, "shared-secret-123", "aes.example.com", aesKeyForOID(tc.oid))
|
||||
pkiMessage = withContentEncryptionOID(t, pkiMessage, fix, tc.oid, aesKeyForOID(tc.oid))
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (%s): got %d, want 200 (body=%q)", tc.name, w.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
|
||||
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
||||
// backward compat with lightweight clients.
|
||||
func TestSCEPHandler_MVPCompat_StillWorks(t *testing.T) {
|
||||
// Build an MVP-shape request: a SignedData whose encapContent is a
|
||||
// raw CSR (no EnvelopedData wrapper). The legacy handler path
|
||||
// extractCSRFromPKCS7 unwraps it.
|
||||
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
csrDER := buildTestCSR(t, deviceKey, "mvp.example.com", "mvp-shared-secret")
|
||||
|
||||
// Wrap in MVP-shape PKCS#7 SignedData (encapContent = CSR DER as
|
||||
// OCTET STRING). The existing extractCSRFromPKCS7 handles this.
|
||||
mvpPKCS7 := buildMVPSignedData(t, csrDER)
|
||||
|
||||
svc := &chromeOSMockSCEPService{
|
||||
enrollResult: &domain.SCEPEnrollResult{
|
||||
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "mvp-issued.example.com")),
|
||||
},
|
||||
}
|
||||
// Note: NO RA pair set — the handler runs MVP-only.
|
||||
handler := NewSCEPHandler(svc)
|
||||
w, body := postPKIOperation(t, handler, mvpPKCS7)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("MVP path POST: got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
// Response is the legacy certs-only PKCS#7, NOT a CertRep PKIMessage.
|
||||
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
func postPKIOperation(t *testing.T, h SCEPHandler, body []byte) (*httptest.ResponseRecorder, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSCEP(w, req)
|
||||
respBody, _ := io.ReadAll(w.Body)
|
||||
return w, respBody
|
||||
}
|
||||
|
||||
// buildChromeOSStylePKIMessage builds a real SCEP PKIMessage targeting the
|
||||
// fixture's RA cert. Mirrors what ChromeOS / micromdm-style clients emit:
|
||||
// SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping an
|
||||
// EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)).
|
||||
func buildChromeOSStylePKIMessage(t *testing.T, fix *chromeOSStackFixture, messageType domain.SCEPMessageType, transactionID, challengePassword, csrCN string, symKey []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
// 1. Build the inner CSR carrying the challengePassword attribute.
|
||||
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
|
||||
|
||||
// 2. Encrypt the CSR via AES-CBC under symKey + random IV.
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
t.Fatalf("rand iv: %v", err)
|
||||
}
|
||||
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||
|
||||
// 3. RSA-encrypt the symKey to fix.raCert.PublicKey.
|
||||
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||
}
|
||||
|
||||
// 4. Build EnvelopedData wrapping ciphertext.
|
||||
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
|
||||
|
||||
// 5. Build the SignedData carrying the EnvelopedData with a
|
||||
// signerInfo signed by the device's transient cert/key.
|
||||
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, messageType, transactionID, []byte("0123456789abcdef"), envelopedData)
|
||||
return signedData
|
||||
}
|
||||
|
||||
// withContentEncryptionOID rewrites the AES OID inside an already-built
|
||||
// PKIMessage by re-building from scratch with the new OID. Simpler than
|
||||
// surgically patching the bytes.
|
||||
func withContentEncryptionOID(t *testing.T, _ []byte, fix *chromeOSStackFixture, oid asn1.ObjectIdentifier, symKey []byte) []byte {
|
||||
t.Helper()
|
||||
csrDER := buildTestCSR(t, fix.deviceKey, "aes.example.com", "shared-secret-123")
|
||||
iv := make([]byte, 16)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
t.Fatalf("rand iv: %v", err)
|
||||
}
|
||||
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa encrypt: %v", err)
|
||||
}
|
||||
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oid)
|
||||
return buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-aes", []byte("0123456789abcdef"), envelopedData)
|
||||
}
|
||||
|
||||
func aesCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("aes.NewCipher: %v", err)
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
padLen := bs - len(plaintext)%bs
|
||||
padded := append([]byte{}, plaintext...)
|
||||
for i := 0; i < padLen; i++ {
|
||||
padded = append(padded, byte(padLen))
|
||||
}
|
||||
enc := cipher.NewCBCEncrypter(block, iv)
|
||||
out := make([]byte, len(padded))
|
||||
enc.CryptBlocks(out, padded)
|
||||
return out
|
||||
}
|
||||
|
||||
// oidForAESKeyLen maps an AES key length to its CBC OID. Helper for the
|
||||
// AES-variants table-driven test.
|
||||
func oidForAESKeyLen(t *testing.T, n int) asn1.ObjectIdentifier {
|
||||
t.Helper()
|
||||
switch n {
|
||||
case 16:
|
||||
return pkcs7.OIDAES128CBC
|
||||
case 24:
|
||||
return pkcs7.OIDAES192CBC
|
||||
case 32:
|
||||
return pkcs7.OIDAES256CBC
|
||||
}
|
||||
t.Fatalf("oidForAESKeyLen: unsupported key length %d", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesKeyForOID returns a deterministic-length symmetric key matching the
|
||||
// AES variant identified by oid. Test-only — production uses crypto/rand.
|
||||
func aesKeyForOID(oid asn1.ObjectIdentifier) []byte {
|
||||
switch {
|
||||
case oid.Equal(pkcs7.OIDAES128CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 16)
|
||||
case oid.Equal(pkcs7.OIDAES192CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 24)
|
||||
case oid.Equal(pkcs7.OIDAES256CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 32)
|
||||
case oid.Equal(pkcs7.OIDDESEDE3CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 24)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTestCSR creates a CSR with a challengePassword attribute. Used by
|
||||
// the buildChromeOSStylePKIMessage helper to populate the EnvelopedData
|
||||
// inner content.
|
||||
func buildTestCSR(t *testing.T, key *rsa.PrivateKey, commonName, challengePassword string) []byte {
|
||||
t.Helper()
|
||||
// Build the challengePassword attribute (RFC 2985 §5.4.1, OID
|
||||
// 1.2.840.113549.1.9.7).
|
||||
cpAttr := pkix.AttributeTypeAndValue{
|
||||
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||
Value: challengePassword,
|
||||
}
|
||||
cpAttrSet, err := asn1.Marshal(cpAttr)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cp attr: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
// Inject the challengePassword as a raw extra extension via the
|
||||
// CSR Attributes field.
|
||||
ExtraExtensions: []pkix.Extension{},
|
||||
Attributes: []pkix.AttributeTypeAndValueSET{
|
||||
{
|
||||
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||
Value: [][]pkix.AttributeTypeAndValue{
|
||||
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = cpAttrSet
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
// buildEnvelopedDataForTest builds an EnvelopedData targeting raCert with
|
||||
// a single KTRI carrying the encrypted symmetric key + the AES-CBC
|
||||
// ciphertext. Mirrors the Phase 3 buildEnvelopedDataAES256 internal helper
|
||||
// but exposed at test scope.
|
||||
func buildEnvelopedDataForTest(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte, contentEncOID asn1.ObjectIdentifier) []byte {
|
||||
t.Helper()
|
||||
// IssuerAndSerial of the recipient.
|
||||
serialDER, err := asn1.Marshal(raCert.SerialNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal serial: %v", err)
|
||||
}
|
||||
risBody := append([]byte{}, raCert.RawIssuer...)
|
||||
risBody = append(risBody, serialDER...)
|
||||
risBytes := pkcs7.ASN1Wrap(0x30, risBody)
|
||||
|
||||
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal keyEncAlg: %v", err)
|
||||
}
|
||||
encryptedKeyBytes := pkcs7.ASN1Wrap(0x04, encryptedKey)
|
||||
|
||||
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||
ktriBody = append(ktriBody, risBytes...)
|
||||
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
||||
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
||||
ktriBytes := pkcs7.ASN1Wrap(0x30, ktriBody)
|
||||
|
||||
recipientInfosBytes := pkcs7.ASN1Wrap(0x31, ktriBytes)
|
||||
|
||||
ivOctet := pkcs7.ASN1Wrap(0x04, iv)
|
||||
contentAlg := pkix.AlgorithmIdentifier{
|
||||
Algorithm: contentEncOID,
|
||||
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
||||
}
|
||||
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal contentAlg: %v", err)
|
||||
}
|
||||
|
||||
encContentField := pkcs7.ASN1Wrap(0x80, ciphertext)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
eciBody := append([]byte{}, oidDataBytes...)
|
||||
eciBody = append(eciBody, contentAlgBytes...)
|
||||
eciBody = append(eciBody, encContentField...)
|
||||
eciBytes := pkcs7.ASN1Wrap(0x30, eciBody)
|
||||
|
||||
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||
envBody = append(envBody, recipientInfosBytes...)
|
||||
envBody = append(envBody, eciBytes...)
|
||||
return pkcs7.ASN1Wrap(0x30, envBody)
|
||||
}
|
||||
|
||||
// buildSignedDataForTest builds a CMS SignedData with the device cert as
|
||||
// the signer + auth-attrs carrying SCEP messageType / transactionID /
|
||||
// senderNonce + messageDigest of the encapContent.
|
||||
func buildSignedDataForTest(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
|
||||
t.Helper()
|
||||
contentDigest := sha256.Sum256(encapContent)
|
||||
|
||||
// Auth-attrs SET-OF body.
|
||||
var attrSetBody []byte
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDContentType, pkcs7.ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDMessageDigest, pkcs7.ASN1Wrap(0x04, contentDigest[:]))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPMessageType, pkcs7.ASN1Wrap(0x13, []byte(intToASCII(int(messageType)))))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPTransactionID, pkcs7.ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPSenderNonce, pkcs7.ASN1Wrap(0x04, senderNonce))...)
|
||||
|
||||
// Sign over SET OF Attribute (RFC 5652 §5.4 quirk).
|
||||
signedAttrsForSig := pkcs7.ASN1Wrap(0x31, attrSetBody)
|
||||
digest := sha256.Sum256(signedAttrsForSig)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
|
||||
// SignerInfo SEQUENCE.
|
||||
versionBytes := []byte{0x02, 0x01, 0x01}
|
||||
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
|
||||
sidBody := append([]byte{}, signerCert.RawIssuer...)
|
||||
sidBody = append(sidBody, serialDER...)
|
||||
sidBytes := pkcs7.ASN1Wrap(0x30, sidBody)
|
||||
|
||||
digestAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDSHA256, Parameters: asn1.NullRawValue}
|
||||
digestAlgBytes, _ := asn1.Marshal(digestAlg)
|
||||
|
||||
signedAttrsImplicit := pkcs7.ASN1Wrap(0xa0, attrSetBody)
|
||||
|
||||
sigAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAWithSHA256, Parameters: asn1.NullRawValue}
|
||||
sigAlgBytes, _ := asn1.Marshal(sigAlg)
|
||||
|
||||
sigOctet := pkcs7.ASN1Wrap(0x04, sig)
|
||||
|
||||
siBody := append([]byte{}, versionBytes...)
|
||||
siBody = append(siBody, sidBytes...)
|
||||
siBody = append(siBody, digestAlgBytes...)
|
||||
siBody = append(siBody, signedAttrsImplicit...)
|
||||
siBody = append(siBody, sigAlgBytes...)
|
||||
siBody = append(siBody, sigOctet...)
|
||||
siBytes := pkcs7.ASN1Wrap(0x30, siBody)
|
||||
|
||||
// encapContentInfo
|
||||
octetWrap := pkcs7.ASN1Wrap(0x04, encapContent)
|
||||
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
encapBody := append([]byte{}, oidDataBytes...)
|
||||
encapBody = append(encapBody, explicitWrap...)
|
||||
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||
|
||||
// certificates [0] IMPLICIT SET OF Certificate
|
||||
certsBytes := pkcs7.ASN1Wrap(0xa0, signerCert.Raw)
|
||||
|
||||
// digestAlgorithms SET OF
|
||||
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, digestAlgBytes)
|
||||
// signerInfos SET OF
|
||||
signerInfosBytes := pkcs7.ASN1Wrap(0x31, siBytes)
|
||||
|
||||
// SignedData SEQUENCE
|
||||
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||
sdBody = append(sdBody, digestAlgsBytes...)
|
||||
sdBody = append(sdBody, encapBytes...)
|
||||
sdBody = append(sdBody, certsBytes...)
|
||||
sdBody = append(sdBody, signerInfosBytes...)
|
||||
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||
|
||||
// ContentInfo wrap
|
||||
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
ciBody := append([]byte{}, oidSignedData...)
|
||||
ciBody = append(ciBody, contentField...)
|
||||
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||
}
|
||||
|
||||
// buildMVPSignedData builds a degenerate SignedData where the encapContent
|
||||
// is the raw CSR bytes — what lightweight SCEP clients send. Used by the
|
||||
// MVP-compat test to confirm the legacy parser still works.
|
||||
func buildMVPSignedData(t *testing.T, csrDER []byte) []byte {
|
||||
t.Helper()
|
||||
octetWrap := pkcs7.ASN1Wrap(0x04, csrDER)
|
||||
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
encapBody := append([]byte{}, oidDataBytes...)
|
||||
encapBody = append(encapBody, explicitWrap...)
|
||||
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||
|
||||
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||
signerInfosBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||
|
||||
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||
sdBody = append(sdBody, digestAlgsBytes...)
|
||||
sdBody = append(sdBody, encapBytes...)
|
||||
sdBody = append(sdBody, signerInfosBytes...)
|
||||
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||
|
||||
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
ciBody := append([]byte{}, oidSignedData...)
|
||||
ciBody = append(ciBody, contentField...)
|
||||
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||
}
|
||||
|
||||
func attrSeqHelper(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||
t.Helper()
|
||||
oidBytes, err := asn1.Marshal(oid)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal OID %v: %v", oid, err)
|
||||
}
|
||||
setOfValue := pkcs7.ASN1Wrap(0x31, value)
|
||||
body := append([]byte{}, oidBytes...)
|
||||
body = append(body, setOfValue...)
|
||||
return pkcs7.ASN1Wrap(0x30, body)
|
||||
}
|
||||
|
||||
func decodeFirstSetMember(t *testing.T, rv asn1.RawValue) string {
|
||||
t.Helper()
|
||||
var inner asn1.RawValue
|
||||
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||
t.Fatalf("unmarshal SET first member: %v", err)
|
||||
}
|
||||
return string(inner.Bytes)
|
||||
}
|
||||
|
||||
func intToASCII(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for i > 0 {
|
||||
b = append([]byte{byte('0' + i%10)}, b...)
|
||||
i /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func selfSignedRSACert(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
|
||||
t.Helper()
|
||||
der := selfSignedRSACertRaw(t, key, cn)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
func selfSignedRSACertRaw(t *testing.T, key *rsa.PrivateKey, cn string) []byte {
|
||||
t.Helper()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
Issuer: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
func pemEncodeCert(der []byte) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
|
||||
// silence unused-import warnings — these packages are referenced inside
|
||||
// helpers above; Go's import-pruning is conservative around test-only
|
||||
// uses through other test files.
|
||||
var (
|
||||
_ = ecdsa.PublicKey{}
|
||||
_ = elliptic.P256
|
||||
_ = des.NewTripleDESCipher
|
||||
)
|
||||
@@ -59,6 +59,22 @@ func (m *mockSCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string
|
||||
}
|
||||
}
|
||||
|
||||
// RenewalReqWithEnvelope + GetCertInitialWithEnvelope added in Phase 4 to
|
||||
// satisfy the extended SCEPService interface. Same MVP-only test fixture
|
||||
// rules apply — these stubs mirror PKCSReqWithEnvelope's shape.
|
||||
func (m *mockSCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return m.PKCSReqWithEnvelope(ctx, csrPEM, challengePassword, envelope)
|
||||
}
|
||||
|
||||
func (m *mockSCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadCertID,
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
||||
svc := &mockSCEPService{}
|
||||
h := NewSCEPHandler(svc)
|
||||
|
||||
@@ -43,14 +43,23 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// PKCSReqWithEnvelope was added to the SCEPService interface in SCEP RFC 8894
|
||||
// + Intune master bundle Phase 2.4. The router-level tests don't drive the
|
||||
// RFC 8894 path; this stub satisfies the interface so the per-profile
|
||||
// PKCSReqWithEnvelope / RenewalReqWithEnvelope / GetCertInitialWithEnvelope
|
||||
// were added to the SCEPService interface in SCEP RFC 8894 + Intune master
|
||||
// bundle Phase 2.4 + Phase 4. The router-level tests don't drive the
|
||||
// RFC 8894 path; these stubs satisfy the interface so the per-profile
|
||||
// dispatch tests still compile.
|
||||
func (s *scepProfileMockService) PKCSReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||
}
|
||||
|
||||
func (s *scepProfileMockService) RenewalReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||
}
|
||||
|
||||
func (s *scepProfileMockService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadCertID, TransactionID: env.TransactionID}
|
||||
}
|
||||
|
||||
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
||||
r := New()
|
||||
svc := &scepProfileMockService{tag: "legacy"}
|
||||
|
||||
@@ -54,8 +54,15 @@ type IssuanceRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
||||
// MustStaple, when true, instructs the issuer to add the RFC 7633
|
||||
// must-staple extension (id-pe-tlsfeature) to the issued cert.
|
||||
// Plumbed from CertificateProfile.MustStaple at the service layer.
|
||||
// Issuers that don't support extension injection (Vault, EJBCA, etc.)
|
||||
// silently ignore this — must-staple is a local-issuer-only feature
|
||||
// in V2 since upstream connectors enforce their own extension policy.
|
||||
MustStaple bool `json:"must_staple,omitempty"`
|
||||
}
|
||||
|
||||
// IssuanceResult contains the result of a successful certificate issuance.
|
||||
@@ -73,9 +80,13 @@ type RenewalRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
||||
OrderID *string `json:"order_id,omitempty"`
|
||||
// MustStaple — same semantics as IssuanceRequest.MustStaple. The
|
||||
// renewal pipeline plumbs through the same CertificateProfile.MustStaple
|
||||
// field so renewed certs match their initial-issuance extension set.
|
||||
MustStaple bool `json:"must_staple,omitempty"`
|
||||
}
|
||||
|
||||
// RevocationRequest contains the parameters for revoking a certificate.
|
||||
|
||||
@@ -55,6 +55,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -332,7 +333,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
}
|
||||
|
||||
// Generate certificate with EKUs and MaxTTL from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds)
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -396,7 +397,7 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
}
|
||||
|
||||
// Generate certificate with EKUs and MaxTTL from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds)
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -643,7 +644,7 @@ func (c *Connector) generateSelfSignedCA() error {
|
||||
// It uses the CSR subject and adds any additional SANs from the request.
|
||||
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
||||
// If maxTTLSeconds > 0, the certificate validity is capped to that duration.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int) (*x509.Certificate, string, string, error) {
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int, mustStaple bool) (*x509.Certificate, string, string, error) {
|
||||
// Generate random serial number
|
||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||
if err != nil {
|
||||
@@ -719,6 +720,21 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
|
||||
}
|
||||
}
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple
|
||||
// extension per RFC 7633. When the bound CertificateProfile has
|
||||
// MustStaple=true, the issued cert carries id-pe-tlsfeature with
|
||||
// the TLS Feature `status_request` (5). Browsers + modern TLS
|
||||
// libraries that see this extension fail-closed when OCSP stapling
|
||||
// is missing — defense against revocation-bypass via OCSP
|
||||
// blackholing.
|
||||
if mustStaple {
|
||||
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
|
||||
Id: oidMustStaple,
|
||||
Critical: false,
|
||||
Value: mustStapleExtensionValue,
|
||||
})
|
||||
}
|
||||
|
||||
// Sign certificate with CA
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
|
||||
if err != nil {
|
||||
@@ -767,6 +783,26 @@ func isEmail(s string) bool {
|
||||
}
|
||||
|
||||
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple extension
|
||||
// constants per RFC 7633 §6.
|
||||
//
|
||||
// id-pe-tlsfeature OID: 1.3.6.1.5.5.7.1.24.
|
||||
var oidMustStaple = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}
|
||||
|
||||
// mustStapleExtensionValue is the pre-encoded DER for SEQUENCE OF INTEGER
|
||||
// containing a single value 5 (the TLS Feature for status_request, RFC
|
||||
// 7633 §6 referencing IANA TLS ExtensionType registry).
|
||||
//
|
||||
// Wire bytes:
|
||||
//
|
||||
// 0x30 0x03 -- SEQUENCE, length 3
|
||||
// 0x02 0x01 0x05 -- INTEGER 5 (status_request)
|
||||
//
|
||||
// Pre-encoded as a constant rather than asn1.Marshal'd at runtime: the
|
||||
// extension value is fixed, byte-stable across Go versions, and tested by
|
||||
// pinning the exact bytes against RFC 7633 §6.
|
||||
var mustStapleExtensionValue = []byte{0x30, 0x03, 0x02, 0x01, 0x05}
|
||||
|
||||
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
||||
"serverAuth": x509.ExtKeyUsageServerAuth,
|
||||
"clientAuth": x509.ExtKeyUsageClientAuth,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple per-profile
|
||||
// policy field (RFC 7633).
|
||||
//
|
||||
// Pins the contract that:
|
||||
//
|
||||
// 1. When the IssuanceRequest carries MustStaple=true, the issued cert
|
||||
// contains the id-pe-tlsfeature extension with the canonical
|
||||
// wire bytes (SEQUENCE OF INTEGER {5} per RFC 7633 §6).
|
||||
//
|
||||
// 2. When MustStaple=false (or unset), the extension is OMITTED — adding
|
||||
// it by default would break customer deployments where the TLS path
|
||||
// doesn't staple.
|
||||
//
|
||||
// 3. The OID + DER bytes match RFC 7633 §6 verbatim:
|
||||
// OID 1.3.6.1.5.5.7.1.24, value 0x30 0x03 0x02 0x01 0x05.
|
||||
//
|
||||
// The test exercises the local issuer end-to-end (CSR → CreateCertificate
|
||||
// → ParseCertificate → walk Extensions) so any drift in the extension-
|
||||
// injection path is caught.
|
||||
|
||||
func TestGenerateCertificate_MustStapleProfile_AddsExtension(t *testing.T) {
|
||||
conn, _ := newLocalIssuerForMustStapleTest(t)
|
||||
csrPEM := buildMustStapleCSR(t, "must-staple.example.com")
|
||||
|
||||
result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "must-staple.example.com",
|
||||
SANs: []string{"must-staple.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: []string{"serverAuth"},
|
||||
MaxTTLSeconds: 86400,
|
||||
MustStaple: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate: %v", err)
|
||||
}
|
||||
|
||||
cert := parsePEMCertForTest(t, result.CertPEM)
|
||||
ext := findExtensionByOID(cert, oidMustStaple)
|
||||
if ext == nil {
|
||||
t.Fatal("issued cert is missing id-pe-tlsfeature extension despite MustStaple=true")
|
||||
}
|
||||
if ext.Critical {
|
||||
t.Errorf("must-staple extension Critical = true, want false (RFC 7633 §6 says non-critical)")
|
||||
}
|
||||
if !bytes.Equal(ext.Value, mustStapleExtensionValue) {
|
||||
t.Errorf("must-staple extension Value = %x, want %x (RFC 7633 §6 SEQUENCE OF INTEGER {5})",
|
||||
ext.Value, mustStapleExtensionValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCertificate_NoMustStaple_OmitsExtension(t *testing.T) {
|
||||
conn, _ := newLocalIssuerForMustStapleTest(t)
|
||||
csrPEM := buildMustStapleCSR(t, "no-staple.example.com")
|
||||
|
||||
result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "no-staple.example.com",
|
||||
SANs: []string{"no-staple.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: []string{"serverAuth"},
|
||||
MaxTTLSeconds: 86400,
|
||||
// MustStaple intentionally unset — defaults to false.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate: %v", err)
|
||||
}
|
||||
|
||||
cert := parsePEMCertForTest(t, result.CertPEM)
|
||||
if ext := findExtensionByOID(cert, oidMustStaple); ext != nil {
|
||||
t.Errorf("issued cert has id-pe-tlsfeature extension despite MustStaple=false (would break non-stapling deploys)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustStapleConstants_PinExactRFC7633Bytes locks down the exact OID +
|
||||
// DER bytes against RFC 7633 §6. If a future refactor changes the
|
||||
// pre-encoded value in any way, this test fails — catches drift before
|
||||
// it reaches a real cert.
|
||||
func TestMustStapleConstants_PinExactRFC7633Bytes(t *testing.T) {
|
||||
wantOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} // id-pe-tlsfeature
|
||||
if !oidMustStaple.Equal(wantOID) {
|
||||
t.Errorf("oidMustStaple = %v, want %v (RFC 7633 §6)", oidMustStaple, wantOID)
|
||||
}
|
||||
|
||||
// The TLS Feature for status_request is INTEGER 5 (per the IANA TLS
|
||||
// ExtensionType registry). RFC 7633 §6 wraps that in SEQUENCE OF.
|
||||
wantBytes := []byte{0x30, 0x03, 0x02, 0x01, 0x05}
|
||||
if !bytes.Equal(mustStapleExtensionValue, wantBytes) {
|
||||
t.Errorf("mustStapleExtensionValue = %x, want %x (SEQUENCE OF INTEGER {5})",
|
||||
mustStapleExtensionValue, wantBytes)
|
||||
}
|
||||
|
||||
// Sanity: the bytes round-trip through asn1.Unmarshal as the
|
||||
// expected structure.
|
||||
var parsed []int
|
||||
if _, err := asn1.Unmarshal(mustStapleExtensionValue, &parsed); err != nil {
|
||||
t.Fatalf("mustStapleExtensionValue does not parse as SEQUENCE OF INTEGER: %v", err)
|
||||
}
|
||||
if len(parsed) != 1 || parsed[0] != 5 {
|
||||
t.Errorf("parsed mustStaple = %v, want [5]", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
// newLocalIssuerForMustStapleTest builds a self-signed local CA Connector
|
||||
// using the package's standard New + ensureCA path — same constructor
|
||||
// production uses, so any drift in the cert-template-injection code path
|
||||
// is exercised faithfully.
|
||||
func newLocalIssuerForMustStapleTest(t *testing.T) (*Connector, *x509.Certificate) {
|
||||
t.Helper()
|
||||
c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := c.ensureCA(context.Background()); err != nil {
|
||||
t.Fatalf("ensureCA: %v", err)
|
||||
}
|
||||
return c, c.caCert
|
||||
}
|
||||
|
||||
func buildMustStapleCSR(t *testing.T, cn string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey CSR: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
}
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||
}
|
||||
|
||||
func parsePEMCertForTest(t *testing.T, certPEM string) *x509.Certificate {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
t.Fatal("PEM decode returned nil")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
func findExtensionByOID(cert *x509.Certificate, oid asn1.ObjectIdentifier) *pkix.Extension {
|
||||
for i := range cert.Extensions {
|
||||
if cert.Extensions[i].Id.Equal(oid) {
|
||||
return &cert.Extensions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -17,9 +17,26 @@ type CertificateProfile struct {
|
||||
RequiredSANPatterns []string `json:"required_san_patterns"`
|
||||
SPIFFEURIPattern string `json:"spiffe_uri_pattern"`
|
||||
AllowShortLived bool `json:"allow_short_lived"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// MustStaple, when true, causes the local issuer to add the RFC 7633
|
||||
// must-staple extension (id-pe-tlsfeature, OID 1.3.6.1.5.5.7.1.24) to
|
||||
// every certificate issued under this profile. Browsers + modern TLS
|
||||
// libraries that see this extension MUST fail-closed on missing OCSP
|
||||
// stapling responses — defense against revocation-bypass via OCSP
|
||||
// blackholing.
|
||||
//
|
||||
// Default: false. Operators opt in once they've confirmed their TLS
|
||||
// reverse proxy / load balancer staples OCSP responses (NGINX,
|
||||
// HAProxy, Envoy, etc. all support stapling but it requires explicit
|
||||
// config). Setting must-staple by default would break customer
|
||||
// deployments where the TLS path doesn't staple — browsers hard-fail.
|
||||
//
|
||||
// Recommended for: Intune-deployed device certs (modern TLS clients);
|
||||
// SCEP profiles serving general/legacy clients (ChromeOS, IoT) should
|
||||
// stay false until the TLS path is verified.
|
||||
MustStaple bool `json:"must_staple"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// KeyAlgorithmRule defines an allowed key algorithm and its minimum key size.
|
||||
|
||||
+141
-1
@@ -49,8 +49,16 @@ func (s *SCEPService) SetProfileRepo(repo repository.CertificateProfileRepositor
|
||||
|
||||
// GetCACaps returns the capabilities of this SCEP server.
|
||||
// RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.1: extended from the
|
||||
// initial value (POSTPKIOperation+SHA-256+AES+SCEPStandard) to additionally
|
||||
// advertise SHA-512 (now-implemented modern digest alternative) and Renewal
|
||||
// (the messageType-17 dispatch from Phase 4). ChromeOS specifically looks
|
||||
// for these capabilities to negotiate the strongest available cipher +
|
||||
// digest combo. Order is by historical convention; clients walk the list
|
||||
// linearly.
|
||||
func (s *SCEPService) GetCACaps(ctx context.Context) string {
|
||||
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
|
||||
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n"
|
||||
}
|
||||
|
||||
// GetCACert returns the PEM-encoded CA certificate chain for this SCEP server.
|
||||
@@ -299,3 +307,135 @@ func containsAnyOf(s string, needles ...string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RenewalReqWithEnvelope processes a SCEP RenewalReq from the RFC 8894 path.
|
||||
// RFC 8894 §3.3.1.2 — re-enrollment with an existing valid cert. Distinct
|
||||
// from PKCSReq because the signerInfo is signed by the EXISTING cert
|
||||
// (proving possession), not by a transient self-signed device key.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 4.2.
|
||||
//
|
||||
// Functionally identical to PKCSReqWithEnvelope but with two differences:
|
||||
//
|
||||
// 1. Audit action is `scep_renewalreq` (vs `scep_pkcsreq`) — operators
|
||||
// can grep the audit log to distinguish initial enrollments from
|
||||
// renewals.
|
||||
//
|
||||
// 2. The signing cert presented as POPO MUST chain to the issuer's CA
|
||||
// (the cert was previously issued by THIS issuer, not a self-signed
|
||||
// throwaway). Verified against the issuer's GetCACertPEM chain via
|
||||
// x509.Certificate.Verify. A signing cert that doesn't chain is
|
||||
// mapped to BadMessageCheck per the same RFC 8894 §3.3.2.2 semantics
|
||||
// as an EnvelopedData decrypt failure (integrity-check failure).
|
||||
//
|
||||
// Returns *SCEPResponseEnvelope (same contract as PKCSReqWithEnvelope);
|
||||
// nil signals 'invalid challenge password' for HTTP 403 translation.
|
||||
func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
resp := &domain.SCEPResponseEnvelope{
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
|
||||
// Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth
|
||||
// even though the RenewalReq path additionally verifies the signing
|
||||
// cert chain — a stolen/leaked challenge password combined with a
|
||||
// previously-issued cert (e.g. from a compromised device) would still
|
||||
// allow renewal otherwise. The two checks are independent.
|
||||
if s.challengePassword == "" {
|
||||
s.logger.Warn("SCEP renewal rejected: server has no challenge password configured (RFC 8894 path)",
|
||||
"transaction_id", envelope.TransactionID)
|
||||
return nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
||||
s.logger.Warn("SCEP renewal rejected: invalid challenge password (RFC 8894 path)",
|
||||
"transaction_id", envelope.TransactionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify the signing cert chains to the issuer's CA. Without this gate
|
||||
// any self-signed cert with a valid challenge password could trigger a
|
||||
// renewal — defeating the 'proof of prior issuance' contract RenewalReq
|
||||
// is supposed to provide.
|
||||
if err := s.verifyRenewalSignerCertChain(ctx, envelope.SignerCert); err != nil {
|
||||
s.logger.Warn("SCEP renewal rejected: signer cert chain invalid",
|
||||
"transaction_id", envelope.TransactionID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
resp.Status = domain.SCEPStatusFailure
|
||||
resp.FailInfo = domain.SCEPFailBadMessageCheck
|
||||
return resp
|
||||
}
|
||||
|
||||
// Reuse the existing processEnrollment for the actual issuance work
|
||||
// — RenewalReq is functionally a re-issuance with a different audit
|
||||
// action and chain-validation precondition.
|
||||
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_renewalreq")
|
||||
if err != nil {
|
||||
resp.Status = domain.SCEPStatusFailure
|
||||
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
||||
return resp
|
||||
}
|
||||
resp.Status = domain.SCEPStatusSuccess
|
||||
resp.Result = result
|
||||
return resp
|
||||
}
|
||||
|
||||
// verifyRenewalSignerCertChain confirms the device's signing cert (the cert
|
||||
// presented as POPO in the SignerInfo) was previously issued by the
|
||||
// configured issuer. Used by RenewalReqWithEnvelope to enforce the 'must
|
||||
// have a previously-issued cert' contract RFC 8894 §3.3.1.2 implies.
|
||||
//
|
||||
// A self-signed throwaway cert (initial-enrollment shape) fails this check
|
||||
// — that's an indicator the client meant to send PKCSReq, not RenewalReq.
|
||||
// Operators see the audit-log entry; the client sees BadMessageCheck.
|
||||
func (s *SCEPService) verifyRenewalSignerCertChain(ctx context.Context, signerCertDER []byte) error {
|
||||
if len(signerCertDER) == 0 {
|
||||
return fmt.Errorf("signer cert is empty (no POPO cert in SignerInfo)")
|
||||
}
|
||||
signerCert, err := x509.ParseCertificate(signerCertDER)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse signer cert: %w", err)
|
||||
}
|
||||
|
||||
// Pull the issuer's CA chain via the existing IssuerConnector
|
||||
// surface. Failure here is a deploy bug (the issuer connector lost
|
||||
// its CA cert mid-flight) rather than a client error — surface as
|
||||
// the same generic failure to avoid leaking server state.
|
||||
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get CA cert PEM: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM([]byte(caPEM)) {
|
||||
return fmt.Errorf("CA cert PEM contains no parseable certs")
|
||||
}
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
if _, err := signerCert.Verify(opts); err != nil {
|
||||
return fmt.Errorf("signer cert chain validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCertInitialWithEnvelope handles SCEP polling requests. RFC 8894 §3.3.3
|
||||
// — the client polls when the prior PKCSReq returned Status=Pending.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 4.3.
|
||||
//
|
||||
// v1 of this bundle returns FAILURE+badCertID for all GetCertInitial
|
||||
// requests since deferred-issuance isn't supported (every PKCSReq either
|
||||
// succeeds or fails synchronously — no Pending state in the existing
|
||||
// service-layer issuance pipeline). The wiring stays in place for a
|
||||
// future enhancement (e.g. 'queue for manual approval' workflows).
|
||||
func (s *SCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
s.logger.Info("SCEP GetCertInitial received — deferred-issuance not supported in v1, returning badCertID",
|
||||
"transaction_id", envelope.TransactionID)
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadCertID,
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ func TestSCEPService_GetCACaps(t *testing.T) {
|
||||
if !strings.Contains(caps, "SCEPStandard") {
|
||||
t.Errorf("expected SCEPStandard in caps, got: %s", caps)
|
||||
}
|
||||
// SCEP RFC 8894 Phase 5.1 additions — pin the new caps so a future
|
||||
// 'simplify caps' refactor doesn't quietly remove ChromeOS-required
|
||||
// negotiation flags.
|
||||
if !strings.Contains(caps, "SHA-512") {
|
||||
t.Errorf("expected SHA-512 in caps (Phase 5.1 addition), got: %s", caps)
|
||||
}
|
||||
if !strings.Contains(caps, "AES") {
|
||||
t.Errorf("expected AES in caps, got: %s", caps)
|
||||
}
|
||||
if !strings.Contains(caps, "Renewal") {
|
||||
t.Errorf("expected Renewal in caps (Phase 5.1 addition — RenewalReq messageType support), got: %s", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCEPService_GetCACert_Success(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user