mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:12:04 +00:00
aa139ee0d9
route + RFC 9266 channel binding + HTTP Basic enrollment-password +
per-source-IP failed-auth limit + per-(CN, sourceIP) sliding-window cap.
Two new shared packages so EST + Intune share infrastructure:
- internal/cms/ — RFC 9266 tls-exporter extractor (ExtractTLSExporter
with stdlib-panic recovery for synthetic ConnectionStates) +
CSR-side channel-binding parser via raw TBSCertificationRequestInfo
walk (the stdlib's csr.Attributes can't represent the OCTET STRING
binding value), VerifyChannelBinding composite, EmbedChannel-
BindingAttribute fixture helper, typed sentinel errors for missing
/ mismatch / not-TLS-1.3 mapped to HTTP 400 / 409 / 426 in handler.
- internal/trustanchor/ — extracted from scep/intune/trust_anchor*.go
so the EST mTLS sibling route + Intune dispatcher share the same
SIGHUP-reloadable PEM bundle primitive. intune.TrustAnchorHolder
is now `= trustanchor.Holder` (type alias) + NewTrustAnchorHolder =
trustanchor.New (function alias) — every existing call site compiles
unchanged. Intune's LoadTrustAnchor is a thin wrapper over
trustanchor.LoadBundle. White-box tests moved to the new package.
- internal/ratelimit/ — extracted from scep/intune/rate_limit.go (this
was Phase 4.1, in the same bundle). intune.PerDeviceRateLimiter
is now a thin wrapper preserving the (subject, issuer)→key
composition; EST handler reaches for SlidingWindowLimiter directly.
ESTHandler grew six optional fields wired by per-profile setters
(SetMTLSTrust / SetChannelBindingRequired / SetEnrollmentPassword /
SetSourceIPRateLimiter / SetPerPrincipalRateLimiter / SetLabelForLog)
plus four new mTLS-route methods (CACertsMTLS / SimpleEnrollMTLS /
SimpleReEnrollMTLS / CSRAttrsMTLS); shared internal pipeline
handleEnrollOrReEnroll(reEnroll, viaMTLS) keeps the auth/binding/
rate-limit gates DRY. New router method RegisterESTMTLSHandlers
registers /.well-known/est-mtls/<PathID>/{cacerts,simpleenroll,
simplereenroll,csrattrs}; AuthExemptDispatchPrefixes extends the
no-auth chain to /.well-known/est-mtls.
cmd/server/main.go's EST loop wires per-profile mTLS holder +
channel-binding policy + per-principal limiter + (when EnrollmentPassword
non-empty) Basic + source-IP limiter; new preflightESTMTLSClientCATrust-
Bundle returns *trustanchor.Holder so SIGHUP rotates the EST mTLS
bundle live without restart. SCEP + EST mTLS profiles now share a
single union mtlsUnionPoolForTLS passed to buildServerTLSConfigWithMTLS
(replaces the protocol-specific scepMTLSUnionPoolForTLS); per-handler
re-verify enforces "cert must chain to THIS profile's bundle" so
cross-protocol bleed is blocked at the application layer even though
the TLS layer trusts certs from either pool's union.
Phase 3.3 source-IP failed-Basic limiter defaults: 10 attempts / 1h
/ 50k tracked IPs (no env var; tunable in a follow-up). Phase 4.2
per-principal limiter cap from CERTCTL_EST_PROFILE_<NAME>_RATE_
LIMIT_PER_PRINCIPAL_24H (existing field, Phase 1 shipped).
New tests:
- internal/cms/channelbinding_test.go: extractor + CSR-side parser +
composite + TLS-1.3 round-trip end-to-end + EmbedChannelBinding-
Attribute round-trip
- internal/trustanchor/holder_test.go: parseBundlePEM white-box +
LoadBundle + Holder Get/Pool/SetLabelForLog/Reload-happy/
Reload-keeps-old-on-failure/Reload-keeps-old-on-expired/
WatchSIGHUP-reloads-pool/WatchSIGHUP-stop-clean
- internal/api/handler/est_hardening_test.go: 16 named cases covering
mTLS no-trust-pool 500 + no-cert 401 + cross-profile cert 401 +
happy-path 200 + CACertsMTLS auth gate + CSRAttrsMTLS auth gate +
channel-binding required-absent-rejected + not-required-absent-
allowed + writeChannelBindingError mapping + Basic no-header 401
+ Basic wrong-password 401 + Basic correct-200 + Basic-no-password
no-gate + per-IP failed-attempt lockout 429 + per-principal
blocks-after-cap + different-principals-independent + no-limiter-
unbounded.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean for
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
cmd/server, go test -short -count=1 green for cms/trustanchor/
api/handler/api/router/scep/intune/ratelimit/service. G-3
docs-drift guard reproduced locally clean (Phase 1 already
documented every new env var; Phases 2-4 added zero new env vars).
370 lines
15 KiB
Go
370 lines
15 KiB
Go
// Package cms implements the small subset of CMS / RFC 7030 / RFC 9266
|
|
// helpers that the EST handler needs at request-time: extracting the
|
|
// RFC 9266 tls-exporter from a *tls.ConnectionState, and pulling the
|
|
// matching value back out of an EST CSR's CMC unsignedAttribute when the
|
|
// device proved channel binding.
|
|
//
|
|
// Why a separate package (vs adding to internal/api/handler/est.go):
|
|
//
|
|
// 1. internal/api/handler depends on internal/pkcs7 already; if the EST
|
|
// mTLS hardening also pulled CMC parsing into handler we'd grow the
|
|
// handler-side dep graph by another asn1 surface that has nothing
|
|
// specific to HTTP.
|
|
//
|
|
// 2. Channel-binding extraction is testable in isolation — the unit
|
|
// tests construct a *tls.ConnectionState with raw exporter bytes and
|
|
// a *x509.CertificateRequest with the CMC unsignedAttribute already
|
|
// filled in. No HTTP plumbing required to verify the contract.
|
|
//
|
|
// 3. Future EST extensions (RFC 7030 §3.5 fullCMC, RFC 9148 EST-coaps)
|
|
// are likely to land here too — keep them out of net/http land.
|
|
//
|
|
// EST RFC 7030 hardening master bundle Phase 2.4.
|
|
package cms
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// ----- RFC 9266 §3 — TLS exporter extraction -----
|
|
|
|
// TLSExporterLabel is the EXPORTER label registered by RFC 9266 §3.1
|
|
// for use as a TLS-1.3 channel binding. Constant rather than string-typed
|
|
// so a typo here is a compile error rather than a silent failure mode.
|
|
const TLSExporterLabel = "EXPORTER-Channel-Binding"
|
|
|
|
// TLSExporterLength is the 32-byte exporter length pinned by RFC 9266 §3.1
|
|
// (matches the SHA-256 output size; clients and servers MUST agree on the
|
|
// length to make the comparison meaningful).
|
|
const TLSExporterLength = 32
|
|
|
|
// ErrChannelBindingMissing is returned when the EST mTLS handler requires
|
|
// channel binding (per-profile ChannelBindingRequired=true) but the device's
|
|
// CSR has no id-aa-est-tls-exporter unsignedAttribute or the attribute is
|
|
// the wrong shape.
|
|
var ErrChannelBindingMissing = errors.New("cms: channel binding required but absent or malformed in CSR")
|
|
|
|
// ErrChannelBindingMismatch is returned when the device's CSR carried a
|
|
// channel-binding attribute but its bytes do not match the TLS-1.3 exporter
|
|
// extracted from the live connection. This is the signal of an MITM that
|
|
// terminates TLS in front of certctl: the device computed exporter X
|
|
// against the attacker, certctl sees exporter Y against itself, X≠Y.
|
|
var ErrChannelBindingMismatch = errors.New("cms: channel binding in CSR does not match TLS exporter")
|
|
|
|
// ErrChannelBindingNotTLS13 is returned when the connection is older than
|
|
// TLS 1.3 and the per-profile config still requires channel binding.
|
|
// RFC 9266's tls-exporter is a TLS-1.3 binding; pre-1.3 connections would
|
|
// need RFC 5929 tls-unique, which we deliberately don't support
|
|
// (certctl pins TLS-1.3 server-side).
|
|
var ErrChannelBindingNotTLS13 = errors.New("cms: tls-exporter channel binding requires TLS 1.3")
|
|
|
|
// ExtractTLSExporter pulls the 32-byte RFC 9266 channel-binding value from
|
|
// the TLS connection state. The connection must be TLS 1.3 + handshake-
|
|
// complete; anything else returns a typed error so the caller can map to
|
|
// HTTP 400 / 412 cleanly.
|
|
//
|
|
// Stateless on purpose: callers handle storage + comparison.
|
|
//
|
|
// Robustness note: stdlib's ConnectionState.ExportKeyingMaterial nil-derefs
|
|
// when the underlying secret-derivation closure is unset (i.e. the state
|
|
// was hand-constructed by a test fixture rather than produced by a real
|
|
// TLS handshake). The recover() below converts that panic into the same
|
|
// typed error a missing-binding state would surface, so synthetic test
|
|
// states + production TLS-1.3 connections share a single failure mode.
|
|
func ExtractTLSExporter(state *tls.ConnectionState) (out []byte, err error) {
|
|
if state == nil {
|
|
return nil, fmt.Errorf("%w: nil ConnectionState", ErrChannelBindingMissing)
|
|
}
|
|
if !state.HandshakeComplete {
|
|
return nil, fmt.Errorf("%w: handshake incomplete", ErrChannelBindingMissing)
|
|
}
|
|
// tls.VersionTLS13 == 0x0304. We use the literal so this package doesn't
|
|
// have to import "crypto/tls" twice (once for tls.VersionTLS13, once for
|
|
// the *tls.ConnectionState type — Go allows it but it's noisy).
|
|
if state.Version < 0x0304 {
|
|
return nil, fmt.Errorf("%w: negotiated 0x%04x", ErrChannelBindingNotTLS13, state.Version)
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
out = nil
|
|
err = fmt.Errorf("%w: ExportKeyingMaterial unavailable on this connection state (panic=%v)", ErrChannelBindingMissing, r)
|
|
}
|
|
}()
|
|
out, err = state.ExportKeyingMaterial(TLSExporterLabel, nil, TLSExporterLength)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cms: ExportKeyingMaterial: %w", err)
|
|
}
|
|
if len(out) != TLSExporterLength {
|
|
return nil, fmt.Errorf("cms: exporter returned %d bytes, want %d", len(out), TLSExporterLength)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ----- RFC 7030 §3.5 / RFC 9266 §4.1 — CSR-side channel binding -----
|
|
|
|
// OIDChannelBindingTLSExporter is the id-aa-est-tls-exporter OID from
|
|
// RFC 9266 §4.1 (registered under id-aa = 1.2.840.113549.1.9.16.2 with
|
|
// arc 56 by RFC 9266). Devices that signed channel binding into their
|
|
// CSR add a CMC unsignedAttribute with this OID + an OCTET STRING value.
|
|
//
|
|
// Note: the EST RFC 7030 §3.5 historical OID for tls-unique is
|
|
// id-aa-cmc-binding (1.2.840.113549.1.9.16.2.43). RFC 9266 §4.1 added
|
|
// arc 56 for tls-exporter. We accept BOTH OIDs on the read path so a
|
|
// device using a slightly older library that still emits the §3.5 OID
|
|
// continues to work — the value bytes are still the 32-byte exporter
|
|
// (the OID identifies the binding scheme, not the underlying wire
|
|
// format).
|
|
var (
|
|
OIDChannelBindingTLSExporter = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 56}
|
|
OIDCMCEnrollmentBinding = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 43}
|
|
)
|
|
|
|
// ExtractCSRChannelBinding looks for the RFC 9266 channel-binding
|
|
// attribute (or the legacy RFC 7030 §3.5 binding attribute) in the CSR's
|
|
// raw attributes block. Returns the raw 32-byte exporter value if
|
|
// present.
|
|
//
|
|
// Why we walk csr.RawTBSCertificateRequest manually instead of using
|
|
// csr.Attributes:
|
|
//
|
|
// - csr.Attributes is typed as []pkix.AttributeTypeAndValueSET, where
|
|
// the inner Value is [][]pkix.AttributeTypeAndValue. That shape only
|
|
// fits attributes whose AttributeValue is itself a SEQUENCE { OID,
|
|
// ANY } (e.g. the requestedExtensions attribute). RFC 9266's
|
|
// TLSExporterValue is `OCTET STRING` — a primitive, not a SEQUENCE
|
|
// — so the stdlib parse path either drops the attribute silently or
|
|
// fails the whole CSR parse depending on encoding.
|
|
//
|
|
// - The PKCS#10 challengePassword path in scep.go works by accident
|
|
// because PrintableString happens to round-trip through the
|
|
// stdlib's interface{}-typed AttributeTypeAndValue.Value. OCTET
|
|
// STRING does not — it's not in the small list of primitive types
|
|
// the stdlib's reflect-based unmarshaller handles for `any`.
|
|
//
|
|
// - Walking the raw TBS is ~30 lines of asn1.Unmarshal calls and
|
|
// gives us a stable contract independent of stdlib quirks.
|
|
//
|
|
// Returns (value, true, nil) on success; (nil, false, nil) when the
|
|
// attribute is absent (caller decides whether absence is acceptable per
|
|
// the per-profile ChannelBindingRequired flag); (nil, false, err) on
|
|
// malformed attribute (always fatal — a present-but-wrong attribute
|
|
// signals an attacker rewriting the binding into garbage).
|
|
func ExtractCSRChannelBinding(csr *x509.CertificateRequest) ([]byte, bool, error) {
|
|
if csr == nil {
|
|
return nil, false, fmt.Errorf("cms: nil CSR")
|
|
}
|
|
if len(csr.RawTBSCertificateRequest) == 0 {
|
|
// Stdlib fills RawTBSCertificateRequest on every parse path, so an
|
|
// empty value here means the caller hand-crafted the struct. Tests
|
|
// can do that — but real handler-side calls always have raw bytes.
|
|
return nil, false, nil
|
|
}
|
|
return walkCSRAttributesForBinding(csr.RawTBSCertificateRequest)
|
|
}
|
|
|
|
// walkCSRAttributesForBinding parses just enough of TBSCertificationRequestInfo
|
|
// to reach the [0] IMPLICIT Attributes field, then iterates each Attribute
|
|
// looking for the channel-binding OID. The body is intentionally low-level
|
|
// so we can keep the asn1 footprint contained to this one helper.
|
|
//
|
|
// TBSCertificationRequestInfo per RFC 2986 §4.1:
|
|
//
|
|
// TBSCertificationRequestInfo ::= SEQUENCE {
|
|
// version INTEGER (0),
|
|
// subject Name,
|
|
// subjectPKInfo SubjectPublicKeyInfo,
|
|
// attributes [0] IMPLICIT Attributes (SET OF Attribute)
|
|
// }
|
|
func walkCSRAttributesForBinding(tbs []byte) ([]byte, bool, error) {
|
|
// 1. Crack the outer SEQUENCE wrapper.
|
|
var inner asn1.RawValue
|
|
if rest, err := asn1.Unmarshal(tbs, &inner); err != nil {
|
|
return nil, false, fmt.Errorf("cms: TBS outer parse: %w", err)
|
|
} else if len(rest) > 0 {
|
|
return nil, false, fmt.Errorf("cms: TBS trailing bytes: %d", len(rest))
|
|
}
|
|
if inner.Tag != asn1.TagSequence {
|
|
return nil, false, fmt.Errorf("cms: TBS outer tag %d not SEQUENCE", inner.Tag)
|
|
}
|
|
rest := inner.Bytes
|
|
|
|
// 2. Skip version (INTEGER), subject (SEQUENCE = Name), subjectPKInfo
|
|
// (SEQUENCE). asn1.Unmarshal into asn1.RawValue advances the cursor
|
|
// without parsing the body — perfect for skipping fields we don't care
|
|
// about.
|
|
for i, label := range []string{"version", "subject", "subjectPKInfo"} {
|
|
var rv asn1.RawValue
|
|
next, err := asn1.Unmarshal(rest, &rv)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("cms: skip TBS field %d (%s): %w", i, label, err)
|
|
}
|
|
rest = next
|
|
}
|
|
|
|
// 3. Attributes is [0] IMPLICIT — the on-wire tag is 0xA0 with class
|
|
// CONTEXT-SPECIFIC. asn1.Unmarshal into a RawValue accepts arbitrary
|
|
// tags; we then walk its Bytes as a SET OF Attribute.
|
|
var attrsField asn1.RawValue
|
|
if _, err := asn1.Unmarshal(rest, &attrsField); err != nil {
|
|
// No attributes block at all — RFC 2986 says [0] is OPTIONAL when
|
|
// empty (encoders typically omit the field rather than emit an
|
|
// empty SET). Treat as "no binding present", not as an error.
|
|
return nil, false, nil
|
|
}
|
|
if attrsField.Class != asn1.ClassContextSpecific || attrsField.Tag != 0 {
|
|
// Some non-attribute-shaped trailing field: not what we expected
|
|
// but not strictly a corruption signal — skip silently.
|
|
return nil, false, nil
|
|
}
|
|
|
|
// 4. Walk each Attribute in the SET. Each Attribute is
|
|
// SEQUENCE { OID, SET OF ANY }.
|
|
attrBytes := attrsField.Bytes
|
|
for len(attrBytes) > 0 {
|
|
var oneAttr asn1.RawValue
|
|
next, err := asn1.Unmarshal(attrBytes, &oneAttr)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("cms: walk attributes: %w", err)
|
|
}
|
|
attrBytes = next
|
|
if oneAttr.Tag != asn1.TagSequence {
|
|
continue
|
|
}
|
|
// Inner: OID, then SET.
|
|
var oid asn1.ObjectIdentifier
|
|
afterOID, err := asn1.Unmarshal(oneAttr.Bytes, &oid)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !oid.Equal(OIDChannelBindingTLSExporter) && !oid.Equal(OIDCMCEnrollmentBinding) {
|
|
continue
|
|
}
|
|
// Now afterOID is the SET wrapper. Crack it and pull the OCTET
|
|
// STRING out of the SET's first element.
|
|
var setWrap asn1.RawValue
|
|
if _, err := asn1.Unmarshal(afterOID, &setWrap); err != nil {
|
|
return nil, false, fmt.Errorf("cms: binding SET parse: %w (%w)", err, ErrChannelBindingMissing)
|
|
}
|
|
if setWrap.Tag != asn1.TagSet {
|
|
return nil, false, fmt.Errorf("cms: binding outer tag %d not SET (%w)", setWrap.Tag, ErrChannelBindingMissing)
|
|
}
|
|
var octet asn1.RawValue
|
|
if _, err := asn1.Unmarshal(setWrap.Bytes, &octet); err != nil {
|
|
return nil, false, fmt.Errorf("cms: binding inner parse: %w (%w)", err, ErrChannelBindingMissing)
|
|
}
|
|
if octet.Tag != asn1.TagOctetString {
|
|
return nil, false, fmt.Errorf("cms: binding inner tag %d not OCTET STRING (%w)", octet.Tag, ErrChannelBindingMissing)
|
|
}
|
|
if len(octet.Bytes) != TLSExporterLength {
|
|
return nil, false, fmt.Errorf("cms: binding length %d, want %d (%w)",
|
|
len(octet.Bytes), TLSExporterLength, ErrChannelBindingMissing)
|
|
}
|
|
return octet.Bytes, true, nil
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
// VerifyChannelBinding is the convenience composite the EST mTLS handler
|
|
// calls per request: extract the exporter from the live TLS connection,
|
|
// pull the matching value from the CSR, compare in constant time.
|
|
//
|
|
// Returns:
|
|
// - nil when the binding is present + matches.
|
|
// - ErrChannelBindingMissing when the CSR has no binding attribute.
|
|
// - ErrChannelBindingMismatch when both sides have a value but they
|
|
// differ (the MITM signal).
|
|
// - Any error from the exporter extraction (TLS state is wrong, etc).
|
|
//
|
|
// The required flag controls absence-handling: when required=false a
|
|
// missing attribute returns nil (channel binding is optional for this
|
|
// profile); when required=true a missing attribute returns
|
|
// ErrChannelBindingMissing.
|
|
func VerifyChannelBinding(state *tls.ConnectionState, csr *x509.CertificateRequest, required bool) error {
|
|
live, err := ExtractTLSExporter(state)
|
|
if err != nil {
|
|
// If the profile doesn't require channel binding AND the only
|
|
// problem is "no TLS 1.3 / no handshake", we still let the request
|
|
// through — the binding is opt-in per profile. But if the CSR
|
|
// itself carries a binding attribute, the device clearly INTENDED
|
|
// to bind, so a TLS state mismatch is a genuine error.
|
|
if !required {
|
|
if _, present, _ := ExtractCSRChannelBinding(csr); !present {
|
|
return nil
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
csrBinding, present, err := ExtractCSRChannelBinding(csr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !present {
|
|
if required {
|
|
return ErrChannelBindingMissing
|
|
}
|
|
return nil
|
|
}
|
|
if subtle.ConstantTimeCompare(live, csrBinding) != 1 {
|
|
return ErrChannelBindingMismatch
|
|
}
|
|
// Sanity: the comparison should be identical bytes for matching cases.
|
|
// The bytes.Equal call is dead code under correct subtle.Compare result;
|
|
// it's here only to make the contract obvious to readers and to pin the
|
|
// symmetry test that asserts ExtractCSRChannelBinding is byte-equivalent
|
|
// to ExtractTLSExporter when the device behaved correctly.
|
|
if !bytes.Equal(live, csrBinding) {
|
|
return ErrChannelBindingMismatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EmbedChannelBindingAttribute is the test helper inverse of
|
|
// ExtractCSRChannelBinding: given an exporter value, returns the DER
|
|
// bytes of the Attribute (SEQUENCE { OID, SET { OCTET STRING } }) that
|
|
// the caller can splice into the [0] IMPLICIT Attributes field of
|
|
// TBSCertificationRequestInfo. Used by the EST channel-binding tests
|
|
// AND by any external caller that wants to forge a CSR with a known
|
|
// binding for fixture generation.
|
|
func EmbedChannelBindingAttribute(exporter []byte) ([]byte, error) {
|
|
if len(exporter) != TLSExporterLength {
|
|
return nil, fmt.Errorf("cms: exporter length %d, want %d", len(exporter), TLSExporterLength)
|
|
}
|
|
octet, err := asn1.Marshal(exporter) // marshal []byte as OCTET STRING
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cms: marshal exporter octet: %w", err)
|
|
}
|
|
// Wrap in SET OF.
|
|
setBody := octet
|
|
setEnvelope, err := asn1.Marshal(asn1.RawValue{
|
|
Class: asn1.ClassUniversal,
|
|
Tag: asn1.TagSet,
|
|
IsCompound: true,
|
|
Bytes: setBody,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cms: marshal SET: %w", err)
|
|
}
|
|
oid, err := asn1.Marshal(OIDChannelBindingTLSExporter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cms: marshal OID: %w", err)
|
|
}
|
|
// Wrap as SEQUENCE { OID, SET }.
|
|
seqBody := append(append([]byte{}, oid...), setEnvelope...)
|
|
seqEnvelope, err := asn1.Marshal(asn1.RawValue{
|
|
Class: asn1.ClassUniversal,
|
|
Tag: asn1.TagSequence,
|
|
IsCompound: true,
|
|
Bytes: seqBody,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cms: marshal SEQUENCE: %w", err)
|
|
}
|
|
return seqEnvelope, nil
|
|
}
|