mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
feat(M51): add SCEP server (RFC 8894) for MDM and network device enrollment
Implements Simple Certificate Enrollment Protocol with single-endpoint operation-based dispatch (GetCACaps, GetCACert, PKIOperation), PKCS#7 SignedData CSR extraction with fallback for raw/base64 CSR, challenge password authentication via CSR attributes, and shared internal/pkcs7 package extracted from EST handler to eliminate code duplication. 24 new tests (11 service + 13 handler) plus 5 shared pkcs7 package tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Package pkcs7 provides ASN.1 helpers for building PKCS#7 structures.
|
||||
// Used by EST (RFC 7030) and SCEP (RFC 8894) protocol handlers.
|
||||
// No external dependencies — hand-rolled ASN.1 encoding only.
|
||||
package pkcs7
|
||||
|
||||
import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BuildCertsOnlyPKCS7 creates a degenerate PKCS#7 SignedData structure containing only certificates.
|
||||
// This is the "certs-only" format specified in RFC 7030 Section 4.1.3 for /cacerts responses
|
||||
// and enrollment responses, and used by SCEP (RFC 8894) for GetCACert responses.
|
||||
//
|
||||
// ASN.1 structure (simplified):
|
||||
//
|
||||
// ContentInfo {
|
||||
// contentType: signedData (1.2.840.113549.1.7.2)
|
||||
// content: SignedData {
|
||||
// version: 1
|
||||
// digestAlgorithms: {} (empty)
|
||||
// encapContentInfo: { contentType: data (1.2.840.113549.1.7.1) }
|
||||
// certificates: [cert1, cert2, ...]
|
||||
// signerInfos: {} (empty)
|
||||
// }
|
||||
// }
|
||||
func BuildCertsOnlyPKCS7(derCerts [][]byte) ([]byte, error) {
|
||||
// OID for signedData: 1.2.840.113549.1.7.2
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
// OID for data: 1.2.840.113549.1.7.1
|
||||
oidData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
|
||||
// Build certificates [0] IMPLICIT SET OF Certificate
|
||||
var certsContent []byte
|
||||
for _, cert := range derCerts {
|
||||
certsContent = append(certsContent, cert...)
|
||||
}
|
||||
certsField := ASN1WrapImplicit(0, certsContent)
|
||||
|
||||
// Build encapContentInfo: SEQUENCE { OID data }
|
||||
encapContentInfo := ASN1WrapSequence(oidData)
|
||||
|
||||
// Build digestAlgorithms: SET {} (empty)
|
||||
digestAlgorithms := ASN1WrapSet(nil)
|
||||
|
||||
// Build signerInfos: SET {} (empty)
|
||||
signerInfos := ASN1WrapSet(nil)
|
||||
|
||||
// Version: INTEGER 1
|
||||
version := []byte{0x02, 0x01, 0x01}
|
||||
|
||||
// Build SignedData SEQUENCE
|
||||
var signedDataContent []byte
|
||||
signedDataContent = append(signedDataContent, version...)
|
||||
signedDataContent = append(signedDataContent, digestAlgorithms...)
|
||||
signedDataContent = append(signedDataContent, encapContentInfo...)
|
||||
signedDataContent = append(signedDataContent, certsField...)
|
||||
signedDataContent = append(signedDataContent, signerInfos...)
|
||||
signedData := ASN1WrapSequence(signedDataContent)
|
||||
|
||||
// Wrap in [0] EXPLICIT for ContentInfo.content
|
||||
contentField := ASN1WrapExplicit(0, signedData)
|
||||
|
||||
// Build ContentInfo SEQUENCE
|
||||
var contentInfoContent []byte
|
||||
contentInfoContent = append(contentInfoContent, oidSignedData...)
|
||||
contentInfoContent = append(contentInfoContent, contentField...)
|
||||
contentInfo := ASN1WrapSequence(contentInfoContent)
|
||||
|
||||
return contentInfo, nil
|
||||
}
|
||||
|
||||
// PEMToDERChain converts PEM-encoded certificates to a slice of DER-encoded certificates.
|
||||
func PEMToDERChain(pemData string) ([][]byte, error) {
|
||||
var derCerts [][]byte
|
||||
rest := []byte(pemData)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
derCerts = append(derCerts, block.Bytes)
|
||||
}
|
||||
}
|
||||
if len(derCerts) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||
}
|
||||
return derCerts, nil
|
||||
}
|
||||
|
||||
// ASN1WrapSequence wraps content in an ASN.1 SEQUENCE tag (0x30).
|
||||
func ASN1WrapSequence(content []byte) []byte {
|
||||
return ASN1Wrap(0x30, content)
|
||||
}
|
||||
|
||||
// ASN1WrapSet wraps content in an ASN.1 SET tag (0x31).
|
||||
func ASN1WrapSet(content []byte) []byte {
|
||||
return ASN1Wrap(0x31, content)
|
||||
}
|
||||
|
||||
// ASN1WrapExplicit wraps content in an ASN.1 context-specific EXPLICIT tag.
|
||||
func ASN1WrapExplicit(tag int, content []byte) []byte {
|
||||
return ASN1Wrap(byte(0xa0|tag), content)
|
||||
}
|
||||
|
||||
// ASN1WrapImplicit wraps content in an ASN.1 context-specific IMPLICIT CONSTRUCTED tag.
|
||||
func ASN1WrapImplicit(tag int, content []byte) []byte {
|
||||
return ASN1Wrap(byte(0xa0|tag), content)
|
||||
}
|
||||
|
||||
// ASN1Wrap wraps content with an ASN.1 tag and length.
|
||||
func ASN1Wrap(tag byte, content []byte) []byte {
|
||||
length := len(content)
|
||||
var result []byte
|
||||
result = append(result, tag)
|
||||
result = append(result, ASN1EncodeLength(length)...)
|
||||
result = append(result, content...)
|
||||
return result
|
||||
}
|
||||
|
||||
// ASN1EncodeLength encodes a length in ASN.1 DER format.
|
||||
func ASN1EncodeLength(length int) []byte {
|
||||
if length < 0x80 {
|
||||
return []byte{byte(length)}
|
||||
}
|
||||
// Long form
|
||||
var lengthBytes []byte
|
||||
l := length
|
||||
for l > 0 {
|
||||
lengthBytes = append([]byte{byte(l & 0xff)}, lengthBytes...)
|
||||
l >>= 8
|
||||
}
|
||||
return append([]byte{byte(0x80 | len(lengthBytes))}, lengthBytes...)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package pkcs7
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func generateTestCertPEM(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test CA"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
}
|
||||
|
||||
func TestBuildCertsOnlyPKCS7(t *testing.T) {
|
||||
dummyCert := []byte{0x30, 0x82, 0x01, 0x00}
|
||||
result, err := BuildCertsOnlyPKCS7([][]byte{dummyCert})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty PKCS#7 output")
|
||||
}
|
||||
if result[0] != 0x30 {
|
||||
t.Errorf("expected SEQUENCE tag (0x30), got 0x%02x", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCertsOnlyPKCS7_MultipleCerts(t *testing.T) {
|
||||
cert1 := []byte{0x30, 0x82, 0x01, 0x00}
|
||||
cert2 := []byte{0x30, 0x82, 0x02, 0x00}
|
||||
result, err := BuildCertsOnlyPKCS7([][]byte{cert1, cert2})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty PKCS#7 output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPEMToDERChain_Success(t *testing.T) {
|
||||
pemData := generateTestCertPEM(t)
|
||||
certs, err := PEMToDERChain(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("PEMToDERChain failed: %v", err)
|
||||
}
|
||||
if len(certs) != 1 {
|
||||
t.Errorf("expected 1 cert, got %d", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPEMToDERChain_NoCerts(t *testing.T) {
|
||||
_, err := PEMToDERChain("not a PEM")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASN1EncodeLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
length int
|
||||
expected []byte
|
||||
}{
|
||||
{0, []byte{0x00}},
|
||||
{1, []byte{0x01}},
|
||||
{127, []byte{0x7f}},
|
||||
{128, []byte{0x81, 0x80}},
|
||||
{256, []byte{0x82, 0x01, 0x00}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := ASN1EncodeLength(tt.length)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("ASN1EncodeLength(%d): expected %d bytes, got %d", tt.length, len(tt.expected), len(result))
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("ASN1EncodeLength(%d): byte %d: expected 0x%02x, got 0x%02x", tt.length, i, tt.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user