Skip to content

feat(cms): add a hand-written CMS package for SCEP and RFC 3161 - #285

Merged
Bugs5382 merged 2 commits into
mainfrom
feat/185-cms-primitives
Sep 30, 2026
Merged

Bugs5382 merged 2 commits into
mainfrom
feat/185-cms-primitives

Conversation

@Bugs5382

Copy link
Copy Markdown
Contributor

What and why

SCEP needs far more CMS than the certs-only envelope EST writes, and RFC 3161 time-stamp tokens need the same SignedData. This adds internal/cms, a hand-written CMS subset (RFC 5652) built on encoding/asn1 and the stdlib crypto packages. There is no third-party CMS or PKCS#7 library, and the package never holds a key: callers pass a crypto.Signer or crypto.Decrypter, so a key can stay in the TPM.

What it covers:

  • SignedData, parse and verify: signed attributes, the content-type and message-digest checks (the digest compared in constant time), and signers identified by issuer and serial number or by subject key identifier. Verify returns each signer's certificate. Path validation, key usage and revocation stay the caller's policy.
  • SignedData, build: Sign writes signed attributes (content-type, message-digest, plus caller attributes such as the SCEP authenticated attributes), DER-sorted as RFC 5652 section 5.4 requires. Degenerate writes the certs-only and CRL-carrying message (SCEP GetCACert and GetCRL, EST /cacerts).
  • EnvelopedData: Encrypt and ParseEnvelopedData/Decrypt, with RSA PKCS#1 v1.5 key transport and AES-128-CBC or AES-256-CBC. Decrypt asks the key for a PKCS#1 v1.5 decryption with SessionKeyLen set, so a software key answers a bad padding with a random key instead of an error. Every failure after the recipient is found returns the same ErrDecrypt, and the CBC padding check is constant-time. ErrNoRecipient is separate so an RA rollover can try the next certificate.
  • Content types: id-data, id-signedData, id-envelopedData and id-ct-TSTInfo (a TSTInfo SignedData is version 3).
  • Algorithms: an allowlist of SHA-256/384/512, RSA PKCS#1 v1.5 and ECDSA signatures, RSA PKCS#1 v1.5 key transport, and AES-CBC. SHA-1, MD5, DES and 3DES are refused with ErrUnsupportedAlgorithm, on both the build and the verify side.
  • BER input: streaming encoders (openssl cms -stream, and several SCEP clients) send indefinite lengths and chunked OCTET STRINGs. Every parse normalizes them to DER first. DER input passes through byte-for-byte, so the signature over the signed attributes still verifies.

EST's certs-only writer moves into the package. est.CertsOnlyPKCS7 and est.ParseCertsOnlyPKCS7 keep their signatures and now call internal/cms. The output is byte-for-byte what it was (checked against the old encoder), and EST's tests are unchanged and pass.

This slice has only the primitives. It adds no SCEP or TSA handler and no config.

Design calls:

  • No logging in internal/cms. It is a set of pure functions under protocol handlers that already log each request. Its errors carry structure only, never content, plaintext or key material, so the handlers can log them safely.
  • OpenSSL interop runs live, not from checked-in fixtures. Fixtures would need private keys in the repo, which gitleaks would flag and which would need an allowlist. The tests build messages with OpenSSL 3 at test time instead. They skip locally when OpenSSL 3 is missing (CRYPTOS_TEST_OPENSSL names the binary) and fail under CI, so they can't silently stop running.
  • Key transport is RSA PKCS#1 v1.5 only. That is what SCEP clients use. OAEP and the other RecipientInfo kinds are refused or skipped until a protocol needs them.
  • The RFC 3161 ESS signing-certificate attribute is not built here. The TSA handler will pass it through Signer.Attributes, next to the code that picks the TSA certificate.

Refs #185

No docs change: an internal package with no user-facing behaviour, config or API.

Verification

  • Lint clean
  • Tests pass
  • Build succeeds
  • Documentation updated (if behavior or API changed): package doc comment for internal/cms, and the internal/est doc comment now points to it

How this was verified

Tests came first and failed until the package existed. New tests in internal/cms:

  • Round trips: SignedData with RSA 2048/3072 and ECDSA P-256/P-384, SHA-256/384/512, identified by issuer and serial and by SKI, attached and detached, id-data and id-ct-TSTInfo. EnvelopedData with AES-128/256, issuer and serial and SKI recipients, several recipients each decrypting, block-aligned and empty content. The decrypt and sign paths are driven through wrappers that expose only the crypto.Decrypter or crypto.Signer interface.
  • Known answers: exact DER of a degenerate message worked by hand from RFC 5652, DER ordering of the signed attributes, and the message-digest of "abc" against the FIPS 180-2 SHA-256 vector.
  • Negative cases: a bad digest (altered content), an altered signed attribute, an altered signature, a wrong signer (an impostor certificate with the signer's issuer and serial), a missing signer certificate, a content-type attribute that doesn't match eContentType, detached-content misuse, and a degenerate message with no signers. Also a wrong recipient, the right certificate with the wrong key, tampered ciphertext (deterministic bad padding), a tampered encrypted key, an unsupported cipher, SHA-1 and MD5 refused, errors that never contain the plaintext, and malformed, truncated, trailing-byte, unterminated-BER and deeply nested input.
  • SCEP pkiMessage shape: EnvelopedData inside SignedData. Tampered ciphertext is caught by the outer signature.
  • OpenSSL 3 interop, both directions:
    • OpenSSL-signed messages (cms -sign with RSA, ECDSA, -keyid, -stream BER and detached, plus smime -sign) verify here, and fail once tampered. An OpenSSL SHA-1 message is refused.
    • Our SignedData verifies with openssl cms -verify and smime -verify, including a self-signed SCEP-style signer and a TSTInfo message that cms -cmsout -print shows as id-smime-ct-TSTInfo. OpenSSL rejects our messages once tampered.
    • OpenSSL-encrypted messages (cms -encrypt with AES-128/256, -keyid, -stream, and smime -encrypt) decrypt here, and a stranger gets ErrNoRecipient.
    • Our EnvelopedData decrypts with openssl cms -decrypt and smime -decrypt, and OpenSSL refuses the wrong recipient.
    • openssl pkcs7 -print_certs reads our degenerate message, certificates and CRL.
  • gofmt, go vet ./..., golangci-lint run ./... (0 issues), go test ./... (the full suite), task build and the golic license check pass locally. The internal/cms tests also passed -count=20 and -race. The pre-push hook passed.

Open PRs #271, #277, #279, #280, #282 and #283 touch none of these files. #271 changes go.mod, and this PR does not.


Before merging: add a closing comment summarizing what was actually done in this PR
(not just the checked boxes).

SCEP and RFC 3161 both need more CMS than the certs-only envelope EST
writes: signed and verified SignedData with signed attributes, and
EnvelopedData with RSA key transport and AES-CBC. internal/cms provides
exactly that on encoding/asn1 and the stdlib crypto packages, taking a
crypto.Signer or crypto.Decrypter so keys can stay in the TPM.

- SignedData: parse and verify (content-type and message-digest checks,
  signers by issuer and serial or by SKI), build with signed attributes,
  and the degenerate certs-and-CRLs form.
- EnvelopedData: parse, decrypt and build; RSA PKCS#1 v1.5 key transport,
  AES-128/256-CBC, one uniform decryption error and a constant-time
  padding check.
- Content types id-data, id-signedData, id-envelopedData, id-ct-TSTInfo.
- BER input (indefinite lengths, chunked OCTET STRINGs) is normalized to
  DER before parsing; DER passes through unchanged.
- SHA-1, MD5, DES and 3DES are refused.

Tests cover RFC 5652 structure, known answers, negative cases and round
trips in both directions against OpenSSL 3 (cms and smime). The OpenSSL
tests skip locally without OpenSSL 3 and fail under CI.

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
The degenerate SignedData encoder moves into internal/cms, shared with
SCEP and RFC 3161. CertsOnlyPKCS7 and ParseCertsOnlyPKCS7 keep their
signatures and behaviour, and the output is byte-for-byte what it was.

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the feat/185-cms-primitives branch from 2327802 to dda9138 Compare September 30, 2026 10:05
@Bugs5382

Copy link
Copy Markdown
Contributor Author

Closing summary: adds internal/cms, a hand-written RFC 5652 subset (SignedData build and verify, degenerate certs-only, EnvelopedData with RSA key transport and AES-CBC) on the stdlib crypto packages, and moves EST's certs-only writer onto it with byte-identical output. Rebased onto main; CI green on dda9138. Squash-merging with the owner's approval. #185 stays open: the SCEP handler (slices 3 and 4) continues in #288, now retargeted to main.

@Bugs5382
Bugs5382 merged commit 13b649e into main Sep 30, 2026
@Bugs5382
Bugs5382 deleted the feat/185-cms-primitives branch September 30, 2026 10:07
@Bugs5382 Bugs5382 mentioned this pull request Sep 30, 2026
4 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(scep): serve SCEP so network devices can enrol a trustpoint

1 participant