diff --git a/internal/cms/ber.go b/internal/cms/ber.go new file mode 100644 index 0000000..fd3ff7f --- /dev/null +++ b/internal/cms/ber.go @@ -0,0 +1,232 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "errors" + "fmt" +) + +// maxBERDepth bounds nesting so a hostile message cannot drive the +// normalizer's recursion arbitrarily deep. Real CMS messages, certificates +// included, stay well under twenty levels. +const maxBERDepth = 64 + +// errBER is the one error the normalizer returns, wrapped with the offset +// detail. The input is attacker-supplied, so the message carries only +// structure, never content bytes. +var errBER = errors.New("cms: malformed BER") + +// normalizeBER rewrites a BER encoding as DER where the two differ in ways +// real encoders produce: indefinite lengths become definite, non-minimal +// lengths become minimal, and a constructed OCTET STRING becomes the +// primitive one holding its concatenated chunks. +// +// RFC 5652 lets a sender use BER for everything except the signed +// attributes (section 5.4), and streaming encoders -- OpenSSL with -stream, +// and a number of SCEP clients -- do. encoding/asn1 reads only DER, so +// every parse goes through here first. A DER input comes back +// byte-for-byte unchanged, which matters: the signature over the signed +// attributes is checked against these bytes. +// +// It does not sort SET OF members or canonicalize primitive contents; that +// would change bytes a signature may cover, and nothing here needs it. +func normalizeBER(in []byte) ([]byte, error) { + out, rest, err := convertBER(in, 0) + if err != nil { + return nil, err + } + if len(rest) != 0 { + return nil, fmt.Errorf("%w: %d trailing bytes", errBER, len(rest)) + } + return out, nil +} + +// convertBER converts the single element at the front of b and returns it +// along with whatever follows. +func convertBER(b []byte, depth int) (out, rest []byte, err error) { + if depth > maxBERDepth { + return nil, nil, fmt.Errorf("%w: nested deeper than %d", errBER, maxBERDepth) + } + idLen, constructed, tagNumber, class, err := readIdentifier(b) + if err != nil { + return nil, nil, err + } + id := b[:idLen] + b = b[idLen:] + if len(b) == 0 { + return nil, nil, fmt.Errorf("%w: missing length", errBER) + } + + var children [][]byte + var body []byte + if b[0] == 0x80 { + if !constructed { + return nil, nil, fmt.Errorf("%w: indefinite length on a primitive element", errBER) + } + b = b[1:] + for { + if len(b) >= 2 && b[0] == 0x00 && b[1] == 0x00 { + b = b[2:] + break + } + if len(b) == 0 { + return nil, nil, fmt.Errorf("%w: indefinite length without end-of-contents", errBER) + } + var child []byte + child, b, err = convertBER(b, depth+1) + if err != nil { + return nil, nil, err + } + children = append(children, child) + } + rest = b + } else { + n, lenLen, err := readDefiniteLength(b) + if err != nil { + return nil, nil, err + } + b = b[lenLen:] + if n > len(b) { + return nil, nil, fmt.Errorf("%w: length %d exceeds the %d bytes remaining", errBER, n, len(b)) + } + body, rest = b[:n], b[n:] + if !constructed { + return encodeElement(id, body), rest, nil + } + for len(body) > 0 { + var child []byte + child, body, err = convertBER(body, depth+1) + if err != nil { + return nil, nil, err + } + children = append(children, child) + } + } + + if class == 0 && tagNumber == 4 { + // A constructed OCTET STRING: X.690 section 8.7.3 makes its value + // the concatenation of its primitive OCTET STRING segments, and DER + // allows only the primitive form. + var joined []byte + for _, c := range children { + if c[0] != 0x04 { + return nil, nil, fmt.Errorf("%w: constructed OCTET STRING segment is not an OCTET STRING", errBER) + } + _, hdr, err := readDefiniteLength(c[1:]) + if err != nil { + return nil, nil, err + } + joined = append(joined, c[1+hdr:]...) + } + return encodeElement([]byte{0x04}, joined), rest, nil + } + + var joined []byte + for _, c := range children { + joined = append(joined, c...) + } + return encodeElement(id, joined), rest, nil +} + +// readIdentifier parses the identifier octets (X.690 section 8.1.2), +// including the high-tag-number form. +func readIdentifier(b []byte) (n int, constructed bool, tag int, class int, err error) { + if len(b) == 0 { + return 0, false, 0, 0, fmt.Errorf("%w: missing identifier", errBER) + } + class = int(b[0] >> 6) + constructed = b[0]&0x20 != 0 + tag = int(b[0] & 0x1f) + n = 1 + if tag == 0x1f { + tag = 0 + for { + if n >= len(b) || n > 4 { + return 0, false, 0, 0, fmt.Errorf("%w: bad high tag number", errBER) + } + c := b[n] + n++ + tag = tag<<7 | int(c&0x7f) + if c&0x80 == 0 { + break + } + } + } + return n, constructed, tag, class, nil +} + +// readDefiniteLength parses a definite length. Four length octets cover any +// message this package will ever be handed; anything longer is refused +// rather than risk an int overflow. +func readDefiniteLength(b []byte) (n int, lenLen int, err error) { + if len(b) == 0 { + return 0, 0, fmt.Errorf("%w: missing length", errBER) + } + if b[0] < 0x80 { + return int(b[0]), 1, nil + } + count := int(b[0] & 0x7f) + if count == 0 || count > 4 { + return 0, 0, fmt.Errorf("%w: unsupported length of %d octets", errBER, count) + } + if len(b) < 1+count { + return 0, 0, fmt.Errorf("%w: truncated length", errBER) + } + for _, c := range b[1 : 1+count] { + n = n<<8 | int(c) + } + if n < 0 || n > 1<<30 { + return 0, 0, fmt.Errorf("%w: length out of range", errBER) + } + return n, 1 + count, nil +} + +// encodeElement writes identifier octets, a minimal definite length, and the +// contents. +func encodeElement(id, body []byte) []byte { + out := make([]byte, 0, len(id)+5+len(body)) + out = append(out, id...) + out = appendLength(out, len(body)) + return append(out, body...) +} + +func appendLength(out []byte, n int) []byte { + if n < 0x80 { + return append(out, byte(n)) + } + var tmp [4]byte + i := len(tmp) + for n > 0 { + i-- + tmp[i] = byte(n) + n >>= 8 + } + out = append(out, 0x80|byte(len(tmp)-i)) + return append(out, tmp[i:]...) +} + +// tlv builds a DER element with a single-octet identifier. +func tlv(tag byte, parts ...[]byte) []byte { + var body []byte + for _, p := range parts { + body = append(body, p...) + } + return encodeElement([]byte{tag}, body) +} diff --git a/internal/cms/ber_test.go b/internal/cms/ber_test.go new file mode 100644 index 0000000..ba67762 --- /dev/null +++ b/internal/cms/ber_test.go @@ -0,0 +1,79 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "testing" +) + +// toIndefiniteBER re-encodes a DER message the way a streaming encoder +// would: every constructed element gets an indefinite length, and the +// primitive OCTET STRING holding chunked is split into a constructed OCTET +// STRING of 7-byte pieces. It only has to handle DER input with single-byte +// tags, which is all this package produces. +func toIndefiniteBER(t *testing.T, der, chunked []byte) []byte { + t.Helper() + out, rest := rewriteBER(t, der, chunked) + if len(rest) != 0 { + t.Fatal("toIndefiniteBER: trailing bytes") + } + return out +} + +func rewriteBER(t *testing.T, b, chunked []byte) (out, rest []byte) { + t.Helper() + if len(b) < 2 { + t.Fatal("toIndefiniteBER: truncated") + } + tag := b[0] + length, hdr := 0, 2 + if b[1] < 0x80 { + length = int(b[1]) + } else { + n := int(b[1] & 0x7f) + for i := 0; i < n; i++ { + length = length<<8 | int(b[2+i]) + } + hdr = 2 + n + } + body := b[hdr : hdr+length] + rest = b[hdr+length:] + + if tag == 0x04 && chunked != nil && bytes.Equal(body, chunked) { + out = []byte{0x24, 0x80} + for len(body) > 0 { + n := min(7, len(body)) + out = append(out, 0x04, byte(n)) + out = append(out, body[:n]...) + body = body[n:] + } + return append(out, 0x00, 0x00), rest + } + if tag&0x20 == 0 { + return b[:hdr+length], rest + } + out = []byte{tag, 0x80} + for len(body) > 0 { + var child []byte + child, body = rewriteBER(t, body, chunked) + out = append(out, child...) + } + return append(out, 0x00, 0x00), rest +} diff --git a/internal/cms/doc.go b/internal/cms/doc.go new file mode 100644 index 0000000..4d6c4ba --- /dev/null +++ b/internal/cms/doc.go @@ -0,0 +1,53 @@ +// Package cms is a hand-written subset of the Cryptographic Message Syntax +// (RFC 5652): exactly what SCEP (RFC 8894), EST (RFC 7030) and RFC 3161 +// time-stamp tokens need, and nothing else. +// +// It covers: +// +// - SignedData: parse and verify (content-type and message-digest +// attribute checks, signers identified by issuer and serial number or +// by subject key identifier); build with signed attributes; and the +// degenerate form that carries only certificates and CRLs. +// - EnvelopedData: parse, decrypt and build, with RSA PKCS#1 v1.5 key +// transport and AES-128-CBC or AES-256-CBC content encryption. +// - The content types id-data, id-signedData, id-envelopedData and +// id-ct-TSTInfo. +// +// Algorithms are an allowlist: SHA-256, SHA-384 and SHA-512 digests; RSA +// PKCS#1 v1.5 and ECDSA signatures; RSA PKCS#1 v1.5 key transport; AES-CBC. +// SHA-1, MD5, DES and 3DES are refused with ErrUnsupportedAlgorithm. +// +// The package is standard library only (encoding/asn1 and crypto/...). +// Third-party CMS libraries sign and decrypt with keys they hold; here the +// caller passes a crypto.Signer or crypto.Decrypter, so a key can stay in +// the TPM and never reaches this code. +// +// Input may be BER, as streaming encoders produce: every parse first +// rewrites indefinite lengths and chunked OCTET STRINGs to DER. A DER input +// is left byte-for-byte unchanged, so signatures over it still verify. +// +// The package does not log. Its callers are protocol handlers that already +// log each request; errors here carry structure only, never content, +// plaintext or key material, so they are safe for those handlers to log. +// +// Verification proves who signed, not whether they are trusted: path +// validation, key usage, validity and revocation are the caller's policy. +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/internal/cms/enveloped.go b/internal/cms/enveloped.go new file mode 100644 index 0000000..0db6d31 --- /dev/null +++ b/internal/cms/enveloped.go @@ -0,0 +1,426 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/subtle" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" +) + +var ( + // ErrNoRecipient means the message has no key-transport recipient + // matching the certificate. During an RA key rollover the caller tries + // the next certificate on this error. + ErrNoRecipient = errors.New("cms: no matching recipient") + // ErrDecrypt is the single error for every failure after the recipient + // is found: the key transport, the key length, the ciphertext length and + // the padding. Distinguishing them would hand an attacker a padding + // oracle. + ErrDecrypt = errors.New("cms: decryption failed") +) + +// ContentEncryption selects the content-encryption algorithm. +type ContentEncryption int + +// The content-encryption algorithms behind the SCEP "AES" capability (RFC +// 8894): AES-CBC with a 128- or 256-bit key (RFC 3565). +const ( + AES128CBC ContentEncryption = iota + 1 + AES256CBC +) + +func (c ContentEncryption) params() (asn1.ObjectIdentifier, int, error) { + switch c { + case AES128CBC: + return oidAES128CBC, 16, nil + case AES256CBC: + return oidAES256CBC, 32, nil + } + return nil, 0, fmt.Errorf("%w: content encryption %d", ErrUnsupportedAlgorithm, int(c)) +} + +func contentEncryptionKeyLen(oid asn1.ObjectIdentifier) (int, error) { + switch { + case oid.Equal(oidAES128CBC): + return 16, nil + case oid.Equal(oidAES256CBC): + return 32, nil + } + return 0, fmt.Errorf("%w: content encryption %s", ErrUnsupportedAlgorithm, oid) +} + +// KeyTransRecipient is a parsed RFC 5652 section 6.2.1 KeyTransRecipientInfo. +// Exactly one of IssuerAndSerial and SubjectKeyID is set. +type KeyTransRecipient struct { + Version int + IssuerAndSerial *IssuerAndSerial + SubjectKeyID []byte + KeyEncryptionAlgorithm pkix.AlgorithmIdentifier + EncryptedKey []byte +} + +func (r *KeyTransRecipient) matches(c *x509.Certificate) bool { + if r.IssuerAndSerial != nil { + return r.IssuerAndSerial.matches(c) + } + return len(c.SubjectKeyId) > 0 && bytes.Equal(r.SubjectKeyID, c.SubjectKeyId) +} + +// EnvelopedData is a parsed RFC 5652 section 6.1 EnvelopedData. +type EnvelopedData struct { + Version int + // Recipients are the key-transport recipients. Other RecipientInfo + // kinds (key agreement, KEK, password) are skipped: nothing this + // package serves sends them. + Recipients []KeyTransRecipient + // ContentType is the type of the encrypted content. + ContentType asn1.ObjectIdentifier + ContentEncryptionAlgorithm pkix.AlgorithmIdentifier + EncryptedContent []byte +} + +type ( + rawEnvelopedData struct { + Version int + OriginatorInfo rawTagged `asn1:"optional,tag:0"` + RecipientInfos []asn1.RawValue `asn1:"set"` + EncryptedContentInfo rawEncryptedContentInfo + UnprotectedAttrs rawTagged `asn1:"optional,tag:1"` + } + rawEncryptedContentInfo struct { + ContentType asn1.ObjectIdentifier + ContentEncryptionAlgorithm pkix.AlgorithmIdentifier + EncryptedContent asn1.RawValue `asn1:"optional,tag:0"` + } + rawKeyTransRecipientInfo struct { + Version int + RID asn1.RawValue + KeyEncryptionAlgorithm pkix.AlgorithmIdentifier + EncryptedKey []byte + } +) + +// ParseEnvelopedData parses a ContentInfo carrying an EnvelopedData. BER +// input is accepted, including a chunked encryptedContent. +func ParseEnvelopedData(in []byte) (*EnvelopedData, error) { + inner, err := parseContentInfo(in, OIDEnvelopedData, "EnvelopedData") + if err != nil { + return nil, err + } + var raw rawEnvelopedData + rest, err := asn1.Unmarshal(inner, &raw) + if err != nil { + return nil, fmt.Errorf("cms: EnvelopedData: %w", err) + } + if len(rest) != 0 { + return nil, errors.New("cms: EnvelopedData: trailing bytes") + } + if len(raw.RecipientInfos) == 0 { + return nil, errors.New("cms: EnvelopedData: no recipients") + } + + ed := &EnvelopedData{ + Version: raw.Version, + ContentType: raw.EncryptedContentInfo.ContentType, + ContentEncryptionAlgorithm: raw.EncryptedContentInfo.ContentEncryptionAlgorithm, + } + for i, ri := range raw.RecipientInfos { + if ri.Class != asn1.ClassUniversal || ri.Tag != asn1.TagSequence { + continue + } + r, err := parseKeyTrans(ri.FullBytes) + if err != nil { + return nil, fmt.Errorf("cms: EnvelopedData: recipient %d: %w", i, err) + } + ed.Recipients = append(ed.Recipients, r) + } + + ec := raw.EncryptedContentInfo.EncryptedContent + switch { + case ec.FullBytes == nil: + return nil, errors.New("cms: EnvelopedData: encryptedContent is absent") + case ec.Class != asn1.ClassContextSpecific || ec.Tag != 0: + return nil, errors.New("cms: EnvelopedData: encryptedContent is not [0]") + case ec.IsCompound: + // The constructed BER form of the [0] IMPLICIT OCTET STRING: a run + // of primitive OCTET STRING segments. + segs, err := splitElements(ec.Bytes) + if err != nil { + return nil, fmt.Errorf("cms: EnvelopedData: encryptedContent: %w", err) + } + ed.EncryptedContent = []byte{} + for _, s := range segs { + if s.Class != asn1.ClassUniversal || s.Tag != asn1.TagOctetString || s.IsCompound { + return nil, errors.New("cms: EnvelopedData: encryptedContent segment is not an OCTET STRING") + } + ed.EncryptedContent = append(ed.EncryptedContent, s.Bytes...) + } + default: + ed.EncryptedContent = ec.Bytes + } + return ed, nil +} + +func parseKeyTrans(der []byte) (KeyTransRecipient, error) { + var raw rawKeyTransRecipientInfo + if rest, err := asn1.Unmarshal(der, &raw); err != nil || len(rest) != 0 { + return KeyTransRecipient{}, errors.New("malformed KeyTransRecipientInfo") + } + r := KeyTransRecipient{ + Version: raw.Version, + KeyEncryptionAlgorithm: raw.KeyEncryptionAlgorithm, + EncryptedKey: raw.EncryptedKey, + } + switch { + case raw.RID.Class == asn1.ClassUniversal && raw.RID.Tag == asn1.TagSequence: + var ias IssuerAndSerial + if rest, err := asn1.Unmarshal(raw.RID.FullBytes, &ias); err != nil || len(rest) != 0 { + return KeyTransRecipient{}, errors.New("malformed issuerAndSerialNumber") + } + r.IssuerAndSerial = &ias + case raw.RID.Class == asn1.ClassContextSpecific && raw.RID.Tag == 0 && !raw.RID.IsCompound: + if len(raw.RID.Bytes) == 0 { + return KeyTransRecipient{}, errors.New("empty subjectKeyIdentifier") + } + r.SubjectKeyID = raw.RID.Bytes + default: + return KeyTransRecipient{}, errors.New("unknown recipient identifier") + } + return r, nil +} + +// Decrypt recovers the content for the recipient identified by cert. Only +// the crypto.Decrypter interface of key is used, so the key can stay in the +// TPM; it is asked for an RSA PKCS#1 v1.5 decryption with +// rsa.PKCS1v15DecryptOptions.SessionKeyLen set, which makes a +// software RSA key answer a bad padding with a random key in constant time +// instead of an error (the RFC 3218 countermeasure). +// +// CBC is unauthenticated: a tampered ciphertext can decrypt to garbage +// without an error. SCEP and every other use here wrap the EnvelopedData in +// a SignedData, and the outer signature is what proves integrity; verify it +// before decrypting. +func (ed *EnvelopedData) Decrypt(cert *x509.Certificate, key crypto.Decrypter) ([]byte, error) { + if cert == nil || key == nil { + return nil, errors.New("cms: Decrypt: certificate and key are both required") + } + if !publicKeysEqual(key.Public(), cert.PublicKey) { + return nil, errors.New("cms: Decrypt: key does not match the certificate") + } + var r *KeyTransRecipient + for i := range ed.Recipients { + if ed.Recipients[i].matches(cert) { + r = &ed.Recipients[i] + break + } + } + if r == nil { + return nil, ErrNoRecipient + } + if !r.KeyEncryptionAlgorithm.Algorithm.Equal(oidRSAEncryption) { + return nil, fmt.Errorf("%w: key transport %s", ErrUnsupportedAlgorithm, r.KeyEncryptionAlgorithm.Algorithm) + } + if _, ok := cert.PublicKey.(*rsa.PublicKey); !ok { + return nil, fmt.Errorf("%w: key transport needs an RSA recipient", ErrUnsupportedAlgorithm) + } + keyLen, err := contentEncryptionKeyLen(ed.ContentEncryptionAlgorithm.Algorithm) + if err != nil { + return nil, err + } + var iv []byte + if rest, err := asn1.Unmarshal(ed.ContentEncryptionAlgorithm.Parameters.FullBytes, &iv); err != nil || len(rest) != 0 || len(iv) != aes.BlockSize { + return nil, errors.New("cms: Decrypt: AES-CBC parameters are not a 16-byte IV") + } + + cek, err := key.Decrypt(rand.Reader, r.EncryptedKey, &rsa.PKCS1v15DecryptOptions{SessionKeyLen: keyLen}) + if err != nil || len(cek) != keyLen { + return nil, ErrDecrypt + } + ct := ed.EncryptedContent + if len(ct) == 0 || len(ct)%aes.BlockSize != 0 { + return nil, ErrDecrypt + } + block, err := aes.NewCipher(cek) + if err != nil { + return nil, ErrDecrypt + } + plain := make([]byte, len(ct)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct) + n, ok := pkcs7Unpad(plain) + if !ok { + clear(plain) + return nil, ErrDecrypt + } + return plain[:n], nil +} + +// pkcs7Unpad checks RFC 5652 section 6.3 padding without branching on the +// padding bytes, and returns the unpadded length. +func pkcs7Unpad(b []byte) (int, bool) { + n := len(b) + pad := int(b[n-1]) + good := subtle.ConstantTimeLessOrEq(1, pad) & subtle.ConstantTimeLessOrEq(pad, aes.BlockSize) + for i := 0; i < aes.BlockSize; i++ { + inPad := subtle.ConstantTimeLessOrEq(i+1, pad) + match := subtle.ConstantTimeByteEq(b[n-1-i], byte(pad)) + good &= subtle.ConstantTimeSelect(inPad, match, 1) + } + if good != 1 { + return 0, false + } + return n - pad, true +} + +// EncryptOptions tunes Encrypt. +type EncryptOptions struct { + // IdentifyBySKI writes each recipient identifier as the certificate's + // subjectKeyIdentifier (KeyTransRecipientInfo version 2) instead of + // issuer and serial number (version 0). + IdentifyBySKI bool +} + +// Encrypt builds a ContentInfo carrying an EnvelopedData of id-data content, +// with a fresh content-encryption key transported to each recipient's RSA +// key with PKCS#1 v1.5 (RFC 3370 section 4.2.1), the key transport SCEP +// clients use. +func Encrypt(content []byte, recipients []*x509.Certificate, alg ContentEncryption, opts EncryptOptions) ([]byte, error) { + if len(recipients) == 0 { + return nil, errors.New("cms: Encrypt: at least one recipient is required") + } + oid, keyLen, err := alg.params() + if err != nil { + return nil, err + } + + cek := make([]byte, keyLen) + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(cek); err != nil { + return nil, fmt.Errorf("cms: Encrypt: %w", err) + } + defer clear(cek) + if _, err := rand.Read(iv); err != nil { + return nil, fmt.Errorf("cms: Encrypt: %w", err) + } + + recipientInfos := make([][]byte, 0, len(recipients)) + for i, c := range recipients { + ri, err := keyTransRecipient(c, cek, opts.IdentifyBySKI) + if err != nil { + return nil, fmt.Errorf("cms: Encrypt: recipient %d: %w", i, err) + } + recipientInfos = append(recipientInfos, ri) + } + + padLen := aes.BlockSize - len(content)%aes.BlockSize + padded := make([]byte, len(content)+padLen) + copy(padded, content) + for i := len(content); i < len(padded); i++ { + padded[i] = byte(padLen) + } + defer clear(padded) + block, err := aes.NewCipher(cek) + if err != nil { + return nil, fmt.Errorf("cms: Encrypt: %w", err) + } + ct := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded) + + ivOctets, err := asn1.Marshal(iv) + if err != nil { + return nil, err + } + algID, err := asn1.Marshal(pkix.AlgorithmIdentifier{Algorithm: oid, Parameters: asn1.RawValue{FullBytes: ivOctets}}) + if err != nil { + return nil, err + } + dataOID, err := asn1.Marshal(OIDData) + if err != nil { + return nil, err + } + eci := tlv(0x30, dataOID, algID, tlv(0x80, ct)) + + // RFC 5652 section 6.1: version 0 when every RecipientInfo is version 0 + // and there is no originatorInfo or unprotectedAttrs; version 2 here + // otherwise. + version := 0 + if opts.IdentifyBySKI { + version = 2 + } + v, err := asn1.Marshal(version) + if err != nil { + return nil, err + } + envOID, err := asn1.Marshal(OIDEnvelopedData) + if err != nil { + return nil, err + } + return tlv(0x30, envOID, tlv(0xa0, tlv(0x30, v, derSetOf(recipientInfos), eci))), nil +} + +func keyTransRecipient(c *x509.Certificate, cek []byte, bySKI bool) ([]byte, error) { + if c == nil { + return nil, errors.New("nil certificate") + } + pub, ok := c.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("%w: key transport needs an RSA recipient, have %T", ErrUnsupportedAlgorithm, c.PublicKey) + } + var rid []byte + version := 0 + if bySKI { + if len(c.SubjectKeyId) == 0 { + return nil, errors.New("IdentifyBySKI set but the certificate has no subjectKeyIdentifier") + } + rid = tlv(0x80, c.SubjectKeyId) + version = 2 + } else { + serial, err := asn1.Marshal(c.SerialNumber) + if err != nil { + return nil, err + } + rid = tlv(0x30, c.RawIssuer, serial) + } + encKey, err := rsa.EncryptPKCS1v15(rand.Reader, pub, cek) + if err != nil { + return nil, fmt.Errorf("key transport: %w", err) + } + v, err := asn1.Marshal(version) + if err != nil { + return nil, err + } + keyAlg, err := asn1.Marshal(pkix.AlgorithmIdentifier{Algorithm: oidRSAEncryption, Parameters: asn1.NullRawValue}) + if err != nil { + return nil, err + } + encKeyOctets, err := asn1.Marshal(encKey) + if err != nil { + return nil, err + } + return tlv(0x30, v, rid, keyAlg, encKeyOctets), nil +} diff --git a/internal/cms/enveloped_test.go b/internal/cms/enveloped_test.go new file mode 100644 index 0000000..d3eb1e3 --- /dev/null +++ b/internal/cms/enveloped_test.go @@ -0,0 +1,273 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto/x509" + "encoding/asn1" + "errors" + "strings" + "testing" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + ca := newTestCA(t) + ra := ca.issue(t, "ra", keys.rsa3072.Public(), true) + device := ca.issue(t, "device", keys.rsa2048.Public(), true) + + cases := []struct { + name string + alg ContentEncryption + ski bool + content []byte + wantVersion int + wantKeyLen int + }{ + {"aes128 issuerAndSerial", AES128CBC, false, []byte("a PKCS#10 request"), 0, 16}, + {"aes256 ski", AES256CBC, true, []byte("a PKCS#10 request"), 2, 32}, + {"block-aligned content", AES128CBC, false, bytes.Repeat([]byte{0x42}, 32), 0, 16}, + {"empty content", AES256CBC, false, []byte{}, 0, 32}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + der, err := Encrypt(tc.content, []*x509.Certificate{ra, device}, tc.alg, EncryptOptions{IdentifyBySKI: tc.ski}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + ed, err := ParseEnvelopedData(der) + if err != nil { + t.Fatalf("ParseEnvelopedData: %v", err) + } + if ed.Version != tc.wantVersion { + t.Errorf("version = %d, want %d", ed.Version, tc.wantVersion) + } + if !ed.ContentType.Equal(OIDData) { + t.Errorf("content type = %s, want id-data", ed.ContentType) + } + if len(ed.Recipients) != 2 { + t.Fatalf("got %d recipients, want 2", len(ed.Recipients)) + } + if n := len(ed.EncryptedContent); n%16 != 0 || n <= len(tc.content) { + t.Errorf("ciphertext length %d is not padded CBC over %d bytes", n, len(tc.content)) + } + if bytes.Contains(der, tc.content) && len(tc.content) > 0 { + t.Error("plaintext appears in the message") + } + // Each recipient decrypts on its own, and only through the + // crypto.Decrypter interface. + for _, r := range []struct { + cert *x509.Certificate + key decrypterOnly + }{{ra, decrypterOnly{keys.rsa3072}}, {device, decrypterOnly{keys.rsa2048}}} { + got, err := ed.Decrypt(r.cert, r.key) + if err != nil { + t.Fatalf("Decrypt as %s: %v", r.cert.Subject.CommonName, err) + } + if !bytes.Equal(got, tc.content) { + t.Fatalf("Decrypt as %s: wrong plaintext", r.cert.Subject.CommonName) + } + } + }) + } +} + +func TestDecryptRejects(t *testing.T) { + ca := newTestCA(t) + ra := ca.issue(t, "ra", keys.rsa2048.Public(), false) + stranger := ca.issue(t, "stranger", keys.rsa3072.Public(), false) + secret := []byte("sixteen byte msg plus a tail") + + der, err := Encrypt(secret, []*x509.Certificate{ra}, AES128CBC, EncryptOptions{}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + parse := func(t *testing.T, der []byte) *EnvelopedData { + t.Helper() + ed, err := ParseEnvelopedData(der) + if err != nil { + t.Fatalf("ParseEnvelopedData: %v", err) + } + return ed + } + + t.Run("wrong recipient", func(t *testing.T) { + _, err := parse(t, der).Decrypt(stranger, keys.rsa3072) + if !errors.Is(err, ErrNoRecipient) { + t.Fatalf("err = %v, want ErrNoRecipient", err) + } + }) + t.Run("right certificate, wrong key", func(t *testing.T) { + // RSA PKCS#1 v1.5 decryption answers a bad padding with a random + // key rather than an error (the Bleichenbacher countermeasure), so + // the only guarantee is that the plaintext never comes out. + got, err := parse(t, der).Decrypt(ra, keys.rsa2048b) + if err == nil && bytes.Equal(got, secret) { + t.Fatal("decrypted with the wrong key") + } + }) + t.Run("tampered ciphertext", func(t *testing.T) { + ed := parse(t, der) + // In CBC, flipping a byte of the penultimate block flips the same + // byte of the last plaintext block. XOR the last byte with the pad + // value (28 bytes of content, so 4) and the pad becomes 0x00, which + // PKCS#7 padding can never be. + n := len(ed.EncryptedContent) + tampered := flipCiphertextByte(t, der, ed.EncryptedContent, n-16-1, 0x04) + _, err := parse(t, tampered).Decrypt(ra, keys.rsa2048) + if !errors.Is(err, ErrDecrypt) { + t.Fatalf("err = %v, want ErrDecrypt", err) + } + }) + t.Run("tampered encrypted key", func(t *testing.T) { + ed := parse(t, der) + tampered := flipCiphertextByte(t, der, ed.Recipients[0].EncryptedKey, 5, 0x01) + got, err := parse(t, tampered).Decrypt(ra, keys.rsa2048) + if err == nil && bytes.Equal(got, secret) { + t.Fatal("a tampered key transport still decrypted") + } + }) + t.Run("errors do not carry the plaintext", func(t *testing.T) { + ed := parse(t, der) + n := len(ed.EncryptedContent) + tampered := flipCiphertextByte(t, der, ed.EncryptedContent, n-16-1, 0x04) + _, err := parse(t, tampered).Decrypt(ra, keys.rsa2048) + if err == nil || strings.Contains(err.Error(), "sixteen") { + t.Fatalf("err = %v", err) + } + }) + t.Run("unsupported content encryption", func(t *testing.T) { + // aes192-CBC has the same encoded length as aes128-CBC, so it + // swaps in place. + aes128, _ := asn1.Marshal(asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 2}) + aes192, _ := asn1.Marshal(asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 22}) + swapped := bytes.Replace(der, aes128, aes192, 1) + _, err := parse(t, swapped).Decrypt(ra, keys.rsa2048) + if !errors.Is(err, ErrUnsupportedAlgorithm) { + t.Fatalf("err = %v, want ErrUnsupportedAlgorithm", err) + } + }) +} + +// flipCiphertextByte XORs one byte of field (located inside der) with mask. +func flipCiphertextByte(t *testing.T, der, field []byte, at int, mask byte) []byte { + t.Helper() + i := bytes.Index(der, field) + if i < 0 { + t.Fatal("field not found in message") + } + out := bytes.Clone(der) + out[i+at] ^= mask + return out +} + +func TestEncryptRejects(t *testing.T) { + ca := newTestCA(t) + ecRecipient := ca.issue(t, "ec", keys.p256.Public(), false) + noSKI := ca.issue(t, "no-ski", keys.rsa3072.Public(), false) + ok := ca.issue(t, "ok", keys.rsa3072.Public(), false) + + if _, err := Encrypt([]byte("x"), nil, AES128CBC, EncryptOptions{}); err == nil { + t.Fatal("no recipients accepted") + } + if _, err := Encrypt([]byte("x"), []*x509.Certificate{ecRecipient}, AES128CBC, EncryptOptions{}); !errors.Is(err, ErrUnsupportedAlgorithm) { + t.Fatalf("ECDSA recipient: err = %v, want ErrUnsupportedAlgorithm", err) + } + if _, err := Encrypt([]byte("x"), []*x509.Certificate{noSKI}, AES128CBC, EncryptOptions{IdentifyBySKI: true}); err == nil { + t.Fatal("SKI identification accepted for a certificate without one") + } + if _, err := Encrypt([]byte("x"), []*x509.Certificate{ok}, ContentEncryption(99), EncryptOptions{}); !errors.Is(err, ErrUnsupportedAlgorithm) { + t.Fatalf("unknown cipher: err = %v, want ErrUnsupportedAlgorithm", err) + } +} + +func TestParseEnvelopedDataRejects(t *testing.T) { + ca := newTestCA(t) + cert := ca.issue(t, "c", keys.p256.Public(), false) + sd, err := Degenerate([][]byte{cert.Raw}, nil) + if err != nil { + t.Fatal(err) + } + ok := ca.issue(t, "ok", keys.rsa3072.Public(), false) + env, err := Encrypt([]byte("x"), []*x509.Certificate{ok}, AES128CBC, EncryptOptions{}) + if err != nil { + t.Fatal(err) + } + for name, der := range map[string][]byte{ + "empty": nil, + "garbage": []byte("nope"), + "signedData": sd, + "trailing bytes": append(bytes.Clone(env), 0x00), + "truncated": env[:len(env)-1], + } { + t.Run(name, func(t *testing.T) { + if _, err := ParseEnvelopedData(der); err == nil { + t.Fatal("parsed") + } + }) + } +} + +// TestEnvelopedInsideSigned is the SCEP pkiMessage shape (RFC 8894 section +// 3): a SignedData whose id-data content is an EnvelopedData. Integrity comes +// from the outer signature, so a tampered ciphertext is caught there even +// when CBC alone would not notice. +func TestEnvelopedInsideSigned(t *testing.T) { + ca := newTestCA(t) + ra := ca.issue(t, "ra", keys.rsa3072.Public(), false) + device := selfSigned(t, "device", keys.rsa2048) + csr := []byte("stand-in for a PKCS#10 CertificationRequest") + + env, err := Encrypt(csr, []*x509.Certificate{ra}, AES256CBC, EncryptOptions{}) + if err != nil { + t.Fatal(err) + } + msg, err := Sign(OIDData, env, []Signer{{Certificate: device, Key: keys.rsa2048}}, SignOptions{Certificates: [][]byte{device.Raw}}) + if err != nil { + t.Fatal(err) + } + + sd, err := ParseSignedData(msg) + if err != nil { + t.Fatal(err) + } + signers, err := sd.Verify(VerifyOptions{}) + if err != nil || !signers[0].Equal(device) { + t.Fatalf("Verify: %v", err) + } + ed, err := ParseEnvelopedData(sd.Content) + if err != nil { + t.Fatal(err) + } + got, err := ed.Decrypt(ra, decrypterOnly{keys.rsa3072}) + if err != nil || !bytes.Equal(got, csr) { + t.Fatalf("Decrypt: %v", err) + } + + tampered := bytes.Clone(msg) + i := bytes.Index(tampered, ed.EncryptedContent) + tampered[i] ^= 0x01 + sd2, err := ParseSignedData(tampered) + if err != nil { + t.Fatal(err) + } + if _, err := sd2.Verify(VerifyOptions{}); !errors.Is(err, ErrVerification) { + t.Fatalf("tampered ciphertext passed the outer signature: %v", err) + } +} diff --git a/internal/cms/helpers_test.go b/internal/cms/helpers_test.go new file mode 100644 index 0000000..a8edec3 --- /dev/null +++ b/internal/cms/helpers_test.go @@ -0,0 +1,226 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "sync" + "testing" + "time" +) + +// Key generation dominates the suite's run time (RSA 3072 especially), so each +// shape is generated once and shared. Every test still gets its own +// certificates. +var ( + keyOnce sync.Once + keys struct { + rsa2048 *rsa.PrivateKey + rsa2048b *rsa.PrivateKey + rsa3072 *rsa.PrivateKey + p256 *ecdsa.PrivateKey + p384 *ecdsa.PrivateKey + caKey *ecdsa.PrivateKey + keyGenErr error + } +) + +func testKeys(t *testing.T) { + t.Helper() + keyOnce.Do(func() { + var err error + gen := func(bits int) *rsa.PrivateKey { + k, e := rsa.GenerateKey(rand.Reader, bits) + if e != nil && err == nil { + err = e + } + return k + } + genEC := func(c elliptic.Curve) *ecdsa.PrivateKey { + k, e := ecdsa.GenerateKey(c, rand.Reader) + if e != nil && err == nil { + err = e + } + return k + } + keys.rsa2048 = gen(2048) + keys.rsa2048b = gen(2048) + keys.rsa3072 = gen(3072) + keys.p256 = genEC(elliptic.P256()) + keys.p384 = genEC(elliptic.P384()) + keys.caKey = genEC(elliptic.P384()) + keys.keyGenErr = err + }) + if keys.keyGenErr != nil { + t.Fatalf("generate test keys: %v", keys.keyGenErr) + } +} + +var serialCounter struct { + sync.Mutex + n int64 +} + +func nextSerial() *big.Int { + serialCounter.Lock() + defer serialCounter.Unlock() + serialCounter.n++ + return big.NewInt(1000 + serialCounter.n) +} + +// testCA is a throwaway CA that issues the signer and recipient certificates, +// so the tests exercise certificates that exist rather than hand-built blobs. +type testCA struct { + key crypto.Signer + cert *x509.Certificate +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + testKeys(t) + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: "CMS Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, keys.caKey.Public(), keys.caKey) + if err != nil { + t.Fatalf("ca cert: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse ca cert: %v", err) + } + return &testCA{key: keys.caKey, cert: cert} +} + +// issue signs a certificate for pub. withSKI controls whether the certificate +// carries a SubjectKeyIdentifier, which the SKI signer and recipient +// identifiers depend on. +func (c *testCA) issue(t *testing.T, cn string, pub crypto.PublicKey, withSKI bool) *x509.Certificate { + t.Helper() + return c.issueWith(t, cn, pub, withSKI, nextSerial()) +} + +// issueSerial issues with a chosen serial, to build a certificate that +// collides with another on issuer and serial number. +func (c *testCA) issueSerial(t *testing.T, cn string, pub crypto.PublicKey, serial *big.Int) *x509.Certificate { + t.Helper() + return c.issueWith(t, cn, pub, false, serial) +} + +func (c *testCA) issueWith(t *testing.T, cn string, pub crypto.PublicKey, withSKI bool, serial *big.Int) *x509.Certificate { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + } + if withSKI { + spki, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("marshal public key: %v", err) + } + // RFC 5280 section 4.2.1.2 method (1): an identifier, not a security digest. + sum := sha1.Sum(spki) + tmpl.SubjectKeyId = sum[:] + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, c.cert, pub, c.key) + if err != nil { + t.Fatalf("issue %s: %v", cn, err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse %s: %v", cn, err) + } + if !withSKI && len(cert.SubjectKeyId) != 0 { + t.Fatalf("%s unexpectedly carries a SubjectKeyId", cn) + } + return cert +} + +// selfSigned mints the kind of throwaway certificate a SCEP client signs its +// first request with. +func selfSigned(t *testing.T, cn string, key crypto.Signer) *x509.Certificate { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("self-signed %s: %v", cn, err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse %s: %v", cn, err) + } + return cert +} + +// testCRL issues an empty CRL from the CA. +func (c *testCA) crl(t *testing.T) []byte { + t.Helper() + der, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, c.cert, c.key) + if err != nil { + t.Fatalf("crl: %v", err) + } + return der +} + +// decrypterOnly hides the concrete key type so the tests prove Decrypt only +// needs the crypto.Decrypter interface a TPM-held key presents. +type decrypterOnly struct{ inner crypto.Decrypter } + +func (d decrypterOnly) Public() crypto.PublicKey { return d.inner.Public() } + +func (d decrypterOnly) Decrypt(r io.Reader, msg []byte, opts crypto.DecrypterOpts) ([]byte, error) { + return d.inner.Decrypt(r, msg, opts) +} + +// signerOnly does the same for signing. +type signerOnly struct{ inner crypto.Signer } + +func (s signerOnly) Public() crypto.PublicKey { return s.inner.Public() } + +func (s signerOnly) Sign(r io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + return s.inner.Sign(r, digest, opts) +} diff --git a/internal/cms/oid.go b/internal/cms/oid.go new file mode 100644 index 0000000..9530b12 --- /dev/null +++ b/internal/cms/oid.go @@ -0,0 +1,165 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + + // Registered so crypto.Hash.New works for every digest the package + // accepts. + _ "crypto/sha256" + _ "crypto/sha512" +) + +// Content types (RFC 5652 sections 4, 5 and 6; RFC 3161 section 2.4.2). +var ( + OIDData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1} + OIDSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} + OIDEnvelopedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 3} + OIDTSTInfo = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 1, 4} +) + +// Attribute types (RFC 5652 section 11). +var ( + OIDAttributeContentType = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3} + OIDAttributeMessageDigest = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4} + OIDAttributeSigningTime = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 5} +) + +// Digest algorithms (RFC 5754 section 2). +var ( + oidSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1} + oidSHA384 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 2} + oidSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3} +) + +// Signature and key-transport algorithms (RFC 3370 section 3.2 and 4.2.1, +// RFC 5754 section 3, RFC 5758 section 3.2). +var ( + oidRSAEncryption = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1} + oidSHA256WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11} + oidSHA384WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 12} + oidSHA512WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 13} + oidECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2} + oidECDSAWithSHA384 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 3} + oidECDSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 4} +) + +// Content-encryption algorithms (RFC 3565 section 4.1). +var ( + oidAES128CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 2} + oidAES256CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42} +) + +// digestOIDs is the whole digest allowlist. SHA-1 and MD5 are absent on +// purpose: SCEP (RFC 8894) and RFC 3161 clients can both use SHA-256 or +// better, and a trust anchor has no reason to accept broken digests. +var digestOIDs = []struct { + oid asn1.ObjectIdentifier + hash crypto.Hash +}{ + {oidSHA256, crypto.SHA256}, + {oidSHA384, crypto.SHA384}, + {oidSHA512, crypto.SHA512}, +} + +func hashForDigestOID(oid asn1.ObjectIdentifier) (crypto.Hash, error) { + for _, d := range digestOIDs { + if d.oid.Equal(oid) { + return d.hash, nil + } + } + return 0, fmt.Errorf("%w: digest %s", ErrUnsupportedAlgorithm, oid) +} + +func digestOIDForHash(h crypto.Hash) (asn1.ObjectIdentifier, error) { + for _, d := range digestOIDs { + if d.hash == h { + return d.oid, nil + } + } + return nil, fmt.Errorf("%w: digest %s", ErrUnsupportedAlgorithm, h) +} + +// digestAlgorithm is the AlgorithmIdentifier for h. RFC 5754 section 2 says +// the parameters SHOULD be absent. +func digestAlgorithm(h crypto.Hash) (pkix.AlgorithmIdentifier, error) { + oid, err := digestOIDForHash(h) + if err != nil { + return pkix.AlgorithmIdentifier{}, err + } + return pkix.AlgorithmIdentifier{Algorithm: oid}, nil +} + +type signatureKind int + +const ( + sigRSA signatureKind = iota + 1 + sigECDSA +) + +// signatureScheme resolves a SignerInfo signatureAlgorithm. The bare +// rsaEncryption OID is what OpenSSL and most SCEP clients write (RFC 3370 +// section 3.2 allows it); it carries no hash, so digestAlgorithm decides. +// The hash-specific OIDs must agree with digestAlgorithm, or the SignerInfo +// is inconsistent and refused. +func signatureScheme(oid asn1.ObjectIdentifier, digest crypto.Hash) (signatureKind, error) { + pinned := []struct { + oid asn1.ObjectIdentifier + kind signatureKind + hash crypto.Hash + }{ + {oidSHA256WithRSA, sigRSA, crypto.SHA256}, + {oidSHA384WithRSA, sigRSA, crypto.SHA384}, + {oidSHA512WithRSA, sigRSA, crypto.SHA512}, + {oidECDSAWithSHA256, sigECDSA, crypto.SHA256}, + {oidECDSAWithSHA384, sigECDSA, crypto.SHA384}, + {oidECDSAWithSHA512, sigECDSA, crypto.SHA512}, + } + if oid.Equal(oidRSAEncryption) { + return sigRSA, nil + } + for _, p := range pinned { + if p.oid.Equal(oid) { + if p.hash != digest { + return 0, fmt.Errorf("%w: signature algorithm %s does not match digest %s", ErrUnsupportedAlgorithm, oid, digest) + } + return p.kind, nil + } + } + return 0, fmt.Errorf("%w: signature %s", ErrUnsupportedAlgorithm, oid) +} + +// signatureAlgorithm is the AlgorithmIdentifier this package writes. RSA +// takes the hash-specific OID with NULL parameters (RFC 5754 section 3.2); +// ECDSA takes ecdsa-with-SHAx with the parameters absent (RFC 5758 section +// 3.2). +func signatureAlgorithm(kind signatureKind, h crypto.Hash) pkix.AlgorithmIdentifier { + switch kind { + case sigRSA: + oid := map[crypto.Hash]asn1.ObjectIdentifier{crypto.SHA256: oidSHA256WithRSA, crypto.SHA384: oidSHA384WithRSA, crypto.SHA512: oidSHA512WithRSA}[h] + return pkix.AlgorithmIdentifier{Algorithm: oid, Parameters: asn1.NullRawValue} + default: + oid := map[crypto.Hash]asn1.ObjectIdentifier{crypto.SHA256: oidECDSAWithSHA256, crypto.SHA384: oidECDSAWithSHA384, crypto.SHA512: oidECDSAWithSHA512}[h] + return pkix.AlgorithmIdentifier{Algorithm: oid} + } +} diff --git a/internal/cms/openssl_test.go b/internal/cms/openssl_test.go new file mode 100644 index 0000000..347cdca --- /dev/null +++ b/internal/cms/openssl_test.go @@ -0,0 +1,387 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/pem" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The tests in this file check interoperability against OpenSSL, the +// implementation SCEP clients (sscep, the IOS and IOS-XE PKI client) and +// RFC 3161 verifiers are measured against. Our own writer agreeing with our +// own reader proves little; these build messages with one side and read them +// with the other, in both directions. +// +// They need OpenSSL 3 with the cms command. CRYPTOS_TEST_OPENSSL names the +// binary when the one on PATH is something else (macOS ships LibreSSL as +// /usr/bin/openssl). Without a usable OpenSSL they skip locally, but fail +// under CI, so the interoperability check can never silently stop running. + +type openssl struct { + t *testing.T + bin string + dir string +} + +func newOpenSSL(t *testing.T) *openssl { + t.Helper() + bin := os.Getenv("CRYPTOS_TEST_OPENSSL") + if bin == "" { + bin = "openssl" + } + unavailable := func(why string) { + if os.Getenv("CI") != "" { + t.Fatalf("OpenSSL interoperability tests cannot run under CI: %s", why) + } + t.Skipf("skipping OpenSSL interoperability: %s (set CRYPTOS_TEST_OPENSSL)", why) + } + path, err := exec.LookPath(bin) + if err != nil { + unavailable(bin + " is not on PATH") + } + out, err := exec.Command(path, "version").CombinedOutput() + if err != nil || !strings.HasPrefix(string(out), "OpenSSL 3") { + unavailable("need OpenSSL 3, have " + strings.TrimSpace(string(out))) + } + return &openssl{t: t, bin: path, dir: t.TempDir()} +} + +// sub rebinds the helper to a subtest, so a failure stops that subtest rather +// than calling FailNow on its parent. +func (o *openssl) sub(t *testing.T) *openssl { + c := *o + c.t = t + return &c +} + +// run executes openssl and returns its combined output, failing the test on +// a non-zero exit. +func (o *openssl) run(args ...string) []byte { + o.t.Helper() + out, err := exec.Command(o.bin, args...).CombinedOutput() + if err != nil { + o.t.Fatalf("openssl %s: %v\n%s", strings.Join(args, " "), err, out) + } + return out +} + +// runErr is run for the cases where OpenSSL is expected to refuse. +func (o *openssl) runErr(args ...string) error { + o.t.Helper() + return exec.Command(o.bin, args...).Run() +} + +func (o *openssl) write(name string, data []byte) string { + o.t.Helper() + p := filepath.Join(o.dir, name) + if err := os.WriteFile(p, data, 0o600); err != nil { + o.t.Fatalf("write %s: %v", name, err) + } + return p +} + +func (o *openssl) read(p string) []byte { + o.t.Helper() + b, err := os.ReadFile(p) + if err != nil { + o.t.Fatalf("read %s: %v", p, err) + } + return b +} + +func (o *openssl) certPEM(name string, c *x509.Certificate) string { + return o.write(name, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})) +} + +func (o *openssl) keyPEM(name string, k crypto.PrivateKey) string { + o.t.Helper() + der, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + o.t.Fatalf("marshal key: %v", err) + } + return o.write(name, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +var interopContent = []byte("interoperability payload: a CSR, a CertRep, or a TSTInfo\n") + +func TestOpenSSLSignedDataWeVerify(t *testing.T) { + o := newOpenSSL(t) + ca := newTestCA(t) + rsaCert := ca.issue(t, "rsa signer", keys.rsa2048.Public(), true) + ecCert := ca.issue(t, "ec signer", keys.p384.Public(), true) + in := o.write("content.bin", interopContent) + rsaCrt, rsaKey := o.certPEM("rsa.crt", rsaCert), o.keyPEM("rsa.key", keys.rsa2048) + ecCrt, ecKey := o.certPEM("ec.crt", ecCert), o.keyPEM("ec.key", keys.p384) + + cases := []struct { + name string + args []string + cert *x509.Certificate + detached bool + wantSKI bool + }{ + {"cms rsa sha256", []string{"cms", "-sign", "-nodetach", "-md", "sha256", "-signer", rsaCrt, "-inkey", rsaKey}, rsaCert, false, false}, + {"cms rsa sha512 keyid", []string{"cms", "-sign", "-nodetach", "-md", "sha512", "-keyid", "-signer", rsaCrt, "-inkey", rsaKey}, rsaCert, false, true}, + {"cms ecdsa sha384", []string{"cms", "-sign", "-nodetach", "-md", "sha384", "-signer", ecCrt, "-inkey", ecKey}, ecCert, false, false}, + // -stream makes OpenSSL emit indefinite-length BER with a chunked + // eContent, the shape a streaming client sends. + {"cms stream ber", []string{"cms", "-sign", "-nodetach", "-stream", "-md", "sha256", "-signer", rsaCrt, "-inkey", rsaKey}, rsaCert, false, false}, + {"cms detached", []string{"cms", "-sign", "-md", "sha256", "-signer", ecCrt, "-inkey", ecKey}, ecCert, true, false}, + {"smime rsa sha256", []string{"smime", "-sign", "-nodetach", "-md", "sha256", "-signer", rsaCrt, "-inkey", rsaKey}, rsaCert, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := o.sub(t) + out := filepath.Join(o.dir, strings.ReplaceAll(tc.name, " ", "_")+".der") + o.run(append(tc.args, "-binary", "-in", in, "-outform", "DER", "-out", out)...) + der := o.read(out) + + sd, err := ParseSignedData(der) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if !sd.ContentType.Equal(OIDData) { + t.Fatalf("content type %s", sd.ContentType) + } + if (sd.SignerInfos[0].SubjectKeyID != nil) != tc.wantSKI { + t.Fatalf("signer identified by SKI = %v, want %v", sd.SignerInfos[0].SubjectKeyID != nil, tc.wantSKI) + } + opts := VerifyOptions{} + if tc.detached { + if sd.Content != nil { + t.Fatal("detached message carries content") + } + opts.Content = interopContent + } else if !bytes.Equal(sd.Content, interopContent) { + t.Fatal("content did not come through") + } + signers, err := sd.Verify(opts) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !signers[0].Equal(tc.cert) { + t.Fatal("wrong signer returned") + } + + // Negative: the same OpenSSL message with its content altered. + if !tc.detached { + bad := bytes.Clone(der) + i := bytes.Index(bad, interopContent[:16]) + if i < 0 { + t.Fatal("content not found in the message") + } + bad[i] ^= 0x01 + sd, err := ParseSignedData(bad) + if err != nil { + t.Fatalf("ParseSignedData(tampered): %v", err) + } + if _, err := sd.Verify(VerifyOptions{}); !errors.Is(err, ErrVerification) { + t.Fatalf("tampered OpenSSL message: err = %v, want ErrVerification", err) + } + } + }) + } + + t.Run("sha1 refused", func(t *testing.T) { + o := o.sub(t) + out := filepath.Join(o.dir, "sha1.der") + o.run("cms", "-sign", "-nodetach", "-md", "sha1", "-signer", rsaCrt, "-inkey", rsaKey, "-binary", "-in", in, "-outform", "DER", "-out", out) + sd, err := ParseSignedData(o.read(out)) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if _, err := sd.Verify(VerifyOptions{}); !errors.Is(err, ErrUnsupportedAlgorithm) { + t.Fatalf("err = %v, want ErrUnsupportedAlgorithm", err) + } + }) +} + +func TestOpenSSLVerifiesOurSignedData(t *testing.T) { + o := newOpenSSL(t) + ca := newTestCA(t) + caCrt := o.certPEM("ca.crt", ca.cert) + rsaCert := ca.issue(t, "rsa signer", keys.rsa3072.Public(), true) + ecCert := ca.issue(t, "ec signer", keys.p256.Public(), false) + device := selfSigned(t, "scep device", keys.rsa2048) + + cases := []struct { + name string + tstInfo bool + signer Signer + chain [][]byte + trustDevice bool + }{ + {"rsa sha256 issuerAndSerial", false, Signer{Certificate: rsaCert, Key: keys.rsa3072}, [][]byte{rsaCert.Raw}, false}, + {"rsa sha384 ski", false, Signer{Certificate: rsaCert, Key: keys.rsa3072, Hash: crypto.SHA384, IdentifyBySKI: true}, [][]byte{rsaCert.Raw}, false}, + {"ecdsa sha512", false, Signer{Certificate: ecCert, Key: keys.p256, Hash: crypto.SHA512}, [][]byte{ecCert.Raw}, false}, + {"self-signed scep device", false, Signer{Certificate: device, Key: keys.rsa2048}, [][]byte{device.Raw}, true}, + {"tstinfo", true, Signer{Certificate: ecCert, Key: keys.p256}, [][]byte{ecCert.Raw}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := o.sub(t) + ct := OIDData + if tc.tstInfo { + ct = OIDTSTInfo + } + der, err := Sign(ct, interopContent, []Signer{tc.signer}, SignOptions{Certificates: tc.chain}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + msg := o.write(strings.ReplaceAll(tc.name, " ", "_")+".der", der) + trust := caCrt + if tc.trustDevice { + trust = o.certPEM("device.crt", device) + } + out := filepath.Join(o.dir, "verified.bin") + for _, cmd := range []string{"cms", "smime"} { + // smime is OpenSSL's PKCS#7 path: id-data only, and signers + // by issuer and serial only. + if cmd == "smime" && (tc.tstInfo || tc.signer.IdentifyBySKI) { + continue + } + o.run(cmd, "-verify", "-binary", "-inform", "DER", "-in", msg, "-CAfile", trust, "-purpose", "any", "-out", out) + if got := o.read(out); !bytes.Equal(got, interopContent) { + t.Fatalf("openssl %s -verify returned different content", cmd) + } + } + if tc.tstInfo { + printed := string(o.run("cms", "-cmsout", "-print", "-inform", "DER", "-in", msg)) + if !strings.Contains(printed, "id-smime-ct-TSTInfo") { + t.Fatalf("eContentType is not id-ct-TSTInfo:\n%s", printed) + } + } + + // Negative: OpenSSL must reject our message once altered. + bad := bytes.Clone(der) + bad[bytes.Index(bad, interopContent[:16])] ^= 0x01 + badPath := o.write("bad.der", bad) + if err := o.runErr("cms", "-verify", "-binary", "-inform", "DER", "-in", badPath, "-CAfile", trust, "-purpose", "any", "-out", out); err == nil { + t.Fatal("openssl verified a tampered message") + } + }) + } +} + +func TestOpenSSLEnvelopedDataWeDecrypt(t *testing.T) { + o := newOpenSSL(t) + ca := newTestCA(t) + ra := ca.issue(t, "ra", keys.rsa3072.Public(), true) + stranger := ca.issue(t, "stranger", keys.rsa2048.Public(), false) + raCrt := o.certPEM("ra.crt", ra) + in := o.write("content.bin", interopContent) + + cases := []struct { + name string + args []string + }{ + {"cms aes128", []string{"cms", "-encrypt", "-aes128"}}, + {"cms aes256 keyid", []string{"cms", "-encrypt", "-aes256", "-keyid"}}, + {"cms aes256 stream ber", []string{"cms", "-encrypt", "-aes256", "-stream"}}, + {"smime aes256", []string{"smime", "-encrypt", "-aes256"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := o.sub(t) + out := filepath.Join(o.dir, strings.ReplaceAll(tc.name, " ", "_")+".der") + o.run(append(tc.args, "-binary", "-in", in, "-outform", "DER", "-out", out, raCrt)...) + ed, err := ParseEnvelopedData(o.read(out)) + if err != nil { + t.Fatalf("ParseEnvelopedData: %v", err) + } + got, err := ed.Decrypt(ra, decrypterOnly{keys.rsa3072}) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(got, interopContent) { + t.Fatal("wrong plaintext") + } + if _, err := ed.Decrypt(stranger, keys.rsa2048); !errors.Is(err, ErrNoRecipient) { + t.Fatalf("stranger: err = %v, want ErrNoRecipient", err) + } + }) + } +} + +func TestOpenSSLDecryptsOurEnvelopedData(t *testing.T) { + o := newOpenSSL(t) + ca := newTestCA(t) + device := ca.issue(t, "device", keys.rsa2048.Public(), true) + crt, key := o.certPEM("device.crt", device), o.keyPEM("device.key", keys.rsa2048) + other := ca.issue(t, "other", keys.rsa3072.Public(), false) + otherCrt, otherKey := o.certPEM("other.crt", other), o.keyPEM("other.key", keys.rsa3072) + + for _, tc := range []struct { + name string + alg ContentEncryption + ski bool + }{ + {"aes128 issuerAndSerial", AES128CBC, false}, + {"aes256 ski", AES256CBC, true}, + } { + t.Run(tc.name, func(t *testing.T) { + o := o.sub(t) + der, err := Encrypt(interopContent, []*x509.Certificate{device}, tc.alg, EncryptOptions{IdentifyBySKI: tc.ski}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + msg := o.write("env.der", der) + out := filepath.Join(o.dir, "plain.bin") + for _, cmd := range []string{"cms", "smime"} { + if cmd == "smime" && tc.ski { + continue // smime matches recipients by issuer and serial only + } + o.run(cmd, "-decrypt", "-binary", "-inform", "DER", "-in", msg, "-recip", crt, "-inkey", key, "-out", out) + if !bytes.Equal(o.read(out), interopContent) { + t.Fatalf("openssl %s -decrypt returned different content", cmd) + } + } + // Negative: the wrong recipient cannot open it. + if err := o.runErr("cms", "-decrypt", "-binary", "-inform", "DER", "-in", msg, "-recip", otherCrt, "-inkey", otherKey, "-out", out); err == nil { + t.Fatal("openssl decrypted with the wrong recipient") + } + }) + } +} + +func TestOpenSSLReadsOurDegenerate(t *testing.T) { + o := newOpenSSL(t) + ca := newTestCA(t) + leaf := ca.issue(t, "degenerate leaf", keys.p256.Public(), false) + der, err := Degenerate([][]byte{leaf.Raw, ca.cert.Raw}, [][]byte{ca.crl(t)}) + if err != nil { + t.Fatal(err) + } + msg := o.write("certs.p7b", der) + text := string(o.run("pkcs7", "-inform", "DER", "-in", msg, "-print_certs")) + for _, want := range []string{"degenerate leaf", "CMS Test CA", "Certificate Revocation List"} { + if !strings.Contains(text, want) { + t.Fatalf("openssl pkcs7 output lacks %q:\n%s", want, text) + } + } +} diff --git a/internal/cms/sign.go b/internal/cms/sign.go new file mode 100644 index 0000000..2cc0f0e --- /dev/null +++ b/internal/cms/sign.go @@ -0,0 +1,302 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "slices" +) + +// Signer is one signer of a SignedData. +type Signer struct { + // Certificate identifies the signer. It is not added to the message; + // list it in SignOptions.Certificates when the recipient needs it. + Certificate *x509.Certificate + // Key signs. Only the crypto.Signer interface is used, so the key can + // stay in the TPM. It must be RSA or ECDSA and match Certificate. + Key crypto.Signer + // Hash is the digest: SHA-256 (the default when zero), SHA-384 or + // SHA-512. + Hash crypto.Hash + // IdentifyBySKI writes the signer identifier as the certificate's + // subjectKeyIdentifier (SignerInfo version 3) instead of issuer and + // serial number (version 1). + IdentifyBySKI bool + // Attributes are extra signed attributes, such as the SCEP + // authenticated attributes or the RFC 3161 signing-certificate + // attribute. content-type and message-digest are always added and must + // not be passed here. + Attributes []Attribute +} + +// SignOptions tunes Sign. +type SignOptions struct { + // Certificates are DER certificates to carry, in order. + Certificates [][]byte + // CRLs are DER CertificateLists to carry, in order. + CRLs [][]byte + // Detached leaves eContent out; the recipient supplies the content. + Detached bool +} + +// Sign builds a ContentInfo carrying a SignedData over content, signed by +// each signer with signed attributes (RFC 5652 sections 5.3 and 5.4). +// contentType is the eContentType: OIDData for SCEP, OIDTSTInfo for an RFC +// 3161 token. +func Sign(contentType asn1.ObjectIdentifier, content []byte, signers []Signer, opts SignOptions) ([]byte, error) { + if len(signers) == 0 { + return nil, errors.New("cms: Sign: at least one signer is required") + } + if len(contentType) == 0 { + return nil, errors.New("cms: Sign: a content type is required") + } + + var signerInfos, digestAlgs [][]byte + anyV3 := false + for i, s := range signers { + si, alg, v3, err := signOne(contentType, content, s) + if err != nil { + return nil, fmt.Errorf("cms: Sign: signer %d: %w", i, err) + } + signerInfos = append(signerInfos, si) + if !slices.ContainsFunc(digestAlgs, func(d []byte) bool { return bytes.Equal(d, alg) }) { + digestAlgs = append(digestAlgs, alg) + } + anyV3 = anyV3 || v3 + } + + // RFC 5652 section 5.1: version 3 when any SignerInfo is version 3 or + // the content is not id-data; version 1 otherwise. (Versions 4 and 5 + // cover certificate and CRL choices this package never writes.) + version := 1 + if anyV3 || !contentType.Equal(OIDData) { + version = 3 + } + + var eContent []byte + if !opts.Detached { + eContent = content + } + return marshalSignedData(version, digestAlgs, contentType, eContent, !opts.Detached, opts.Certificates, opts.CRLs, signerInfos) +} + +// Degenerate builds the degenerate SignedData that carries only certificates +// and CRLs: no content and no signers (RFC 5652 section 5.2, and the +// certs-only message of RFC 7030 section 4.1.3 and RFC 8894 sections 3.4 and +// 4.2). Order is preserved, so a chain written leaf first arrives leaf +// first. +func Degenerate(certs, crls [][]byte) ([]byte, error) { + if len(certs) == 0 && len(crls) == 0 { + return nil, errors.New("cms: Degenerate: at least one certificate or CRL is required") + } + for i, c := range certs { + if len(c) == 0 { + return nil, fmt.Errorf("cms: Degenerate: certificate %d is empty", i) + } + } + for i, c := range crls { + if len(c) == 0 { + return nil, fmt.Errorf("cms: Degenerate: CRL %d is empty", i) + } + } + // Version 1: id-data with no eContent and no version 3 signers. + return marshalSignedData(1, nil, OIDData, nil, false, certs, crls, nil) +} + +func marshalSignedData(version int, digestAlgs [][]byte, contentType asn1.ObjectIdentifier, content []byte, withContent bool, certs, crls, signerInfos [][]byte) ([]byte, error) { + v, err := asn1.Marshal(version) + if err != nil { + return nil, err + } + ct, err := asn1.Marshal(contentType) + if err != nil { + return nil, err + } + encap := [][]byte{ct} + if withContent { + octets, err := asn1.Marshal(content) + if err != nil { + return nil, err + } + encap = append(encap, tlv(0xa0, octets)) + } + + parts := [][]byte{v, derSetOf(digestAlgs), tlv(0x30, encap...)} + // CertificateSet and RevocationInfoChoices are SET OFs, but their order + // is not significant (RFC 5652 section 10.2.3) and every reader takes + // them as written, which is what lets a chain arrive leaf first. They + // are outside the signature, so no DER sorting is needed for it to + // verify. + if len(certs) > 0 { + parts = append(parts, tlv(0xa0, certs...)) + } + if len(crls) > 0 { + parts = append(parts, tlv(0xa1, crls...)) + } + parts = append(parts, derSetOf(signerInfos)) + + oid, err := asn1.Marshal(OIDSignedData) + if err != nil { + return nil, err + } + return tlv(0x30, oid, tlv(0xa0, tlv(0x30, parts...))), nil +} + +// derSetOf encodes a SET OF with its members in ascending order of their +// encodings (X.690 section 11.6). +func derSetOf(members [][]byte) []byte { + sorted := slices.Clone(members) + slices.SortFunc(sorted, bytes.Compare) + return tlv(0x31, sorted...) +} + +// signOne builds one SignerInfo. It returns the SignerInfo, the encoded +// digest AlgorithmIdentifier, and whether the SignerInfo is version 3. +func signOne(contentType asn1.ObjectIdentifier, content []byte, s Signer) (si, digestAlg []byte, v3 bool, err error) { + if s.Certificate == nil || s.Key == nil { + return nil, nil, false, errors.New("certificate and key are both required") + } + h := s.Hash + if h == 0 { + h = crypto.SHA256 + } + alg, err := digestAlgorithm(h) + if err != nil { + return nil, nil, false, err + } + if !publicKeysEqual(s.Key.Public(), s.Certificate.PublicKey) { + return nil, nil, false, errors.New("key does not match the certificate") + } + var kind signatureKind + switch s.Key.Public().(type) { + case *rsa.PublicKey: + kind = sigRSA + case *ecdsa.PublicKey: + kind = sigECDSA + default: + return nil, nil, false, fmt.Errorf("%w: key type %T", ErrUnsupportedAlgorithm, s.Key.Public()) + } + + var sid []byte + version := 1 + if s.IdentifyBySKI { + if len(s.Certificate.SubjectKeyId) == 0 { + return nil, nil, false, errors.New("IdentifyBySKI set but the certificate has no subjectKeyIdentifier") + } + sid = tlv(0x80, s.Certificate.SubjectKeyId) + version = 3 + } else { + serial, err := asn1.Marshal(s.Certificate.SerialNumber) + if err != nil { + return nil, nil, false, err + } + sid = tlv(0x30, s.Certificate.RawIssuer, serial) + } + + d := h.New() + d.Write(content) + ctAttr, err := NewAttribute(OIDAttributeContentType, contentType) + if err != nil { + return nil, nil, false, err + } + mdAttr, err := NewAttribute(OIDAttributeMessageDigest, d.Sum(nil)) + if err != nil { + return nil, nil, false, err + } + attrs := []Attribute{ctAttr, mdAttr} + for _, a := range s.Attributes { + if a.Type.Equal(OIDAttributeContentType) || a.Type.Equal(OIDAttributeMessageDigest) { + return nil, nil, false, fmt.Errorf("attribute %s is set by Sign and must not be supplied", a.Type) + } + attrs = append(attrs, a) + } + encodedAttrs := make([][]byte, 0, len(attrs)) + for _, a := range attrs { + enc, err := marshalAttribute(a) + if err != nil { + return nil, nil, false, err + } + encodedAttrs = append(encodedAttrs, enc) + } + signedAttrs := derSetOf(encodedAttrs) + + sd := h.New() + sd.Write(signedAttrs) + sig, err := s.Key.Sign(rand.Reader, sd.Sum(nil), h) + if err != nil { + return nil, nil, false, fmt.Errorf("sign: %w", err) + } + + v, err := asn1.Marshal(version) + if err != nil { + return nil, nil, false, err + } + digestAlg, err = asn1.Marshal(alg) + if err != nil { + return nil, nil, false, err + } + sigAlg, err := asn1.Marshal(signatureAlgorithm(kind, h)) + if err != nil { + return nil, nil, false, err + } + sigOctets, err := asn1.Marshal(sig) + if err != nil { + return nil, nil, false, err + } + implicitAttrs := bytes.Clone(signedAttrs) + implicitAttrs[0] = 0xa0 + si = tlv(0x30, v, sid, digestAlg, implicitAttrs, sigAlg, sigOctets) + return si, digestAlg, version == 3, nil +} + +// marshalAttribute encodes an Attribute with its values DER-sorted, which +// section 5.4 requires of everything under the signature. +func marshalAttribute(a Attribute) ([]byte, error) { + oid, err := asn1.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("attribute type: %w", err) + } + if len(a.Values) == 0 { + return nil, fmt.Errorf("attribute %s has no values", a.Type) + } + vals := make([][]byte, 0, len(a.Values)) + for _, v := range a.Values { + enc := v.FullBytes + if enc == nil { + if enc, err = asn1.Marshal(v); err != nil { + return nil, fmt.Errorf("attribute %s value: %w", a.Type, err) + } + } + vals = append(vals, enc) + } + return tlv(0x30, oid, derSetOf(vals)), nil +} + +func publicKeysEqual(a, b crypto.PublicKey) bool { + eq, ok := a.(interface{ Equal(crypto.PublicKey) bool }) + return ok && eq.Equal(b) +} diff --git a/internal/cms/signeddata.go b/internal/cms/signeddata.go new file mode 100644 index 0000000..b55bee4 --- /dev/null +++ b/internal/cms/signeddata.go @@ -0,0 +1,535 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/subtle" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" +) + +// Sentinel errors. Callers match them with errors.Is; the wrapped detail is +// structural only and never carries content, plaintext or key material. +var ( + // ErrVerification means a signature, a message digest or a + // content-type attribute did not check out. + ErrVerification = errors.New("cms: verification failed") + // ErrSignerNotFound means no candidate certificate matches a + // SignerInfo's signer identifier. + ErrSignerNotFound = errors.New("cms: signer certificate not found") + // ErrUnsupportedAlgorithm means an algorithm outside the package's + // allowlist: SHA-256/384/512, RSA PKCS#1 v1.5 and ECDSA signatures, RSA + // PKCS#1 v1.5 key transport, and AES-128/256-CBC. + ErrUnsupportedAlgorithm = errors.New("cms: unsupported algorithm") + // ErrAttributeNotFound means a requested signed attribute is absent. + ErrAttributeNotFound = errors.New("cms: attribute not found") +) + +// Attribute is an RFC 5652 section 5.3 Attribute: a type and a SET OF values, +// each kept as its DER encoding. +type Attribute struct { + Type asn1.ObjectIdentifier + Values []asn1.RawValue `asn1:"set"` +} + +// NewAttribute builds a single-valued attribute. value is anything +// encoding/asn1 can marshal; an asn1.RawValue is written as given, which is +// how a caller picks a string type such as PrintableString. +func NewAttribute(t asn1.ObjectIdentifier, value any) (Attribute, error) { + der, err := asn1.Marshal(value) + if err != nil { + return Attribute{}, fmt.Errorf("cms: attribute %s: %w", t, err) + } + return Attribute{Type: t, Values: []asn1.RawValue{{FullBytes: der}}}, nil +} + +// IssuerAndSerial is the RFC 5652 section 10.2.4 IssuerAndSerialNumber. +// Issuer holds the DER Name, so it compares directly against +// x509.Certificate.RawIssuer. +type IssuerAndSerial struct { + Issuer asn1.RawValue + SerialNumber *big.Int +} + +func (ias *IssuerAndSerial) matches(c *x509.Certificate) bool { + return bytes.Equal(ias.Issuer.FullBytes, c.RawIssuer) && ias.SerialNumber.Cmp(c.SerialNumber) == 0 +} + +// SignerInfo is a parsed RFC 5652 section 5.3 SignerInfo. Exactly one of +// IssuerAndSerial and SubjectKeyID is set. +type SignerInfo struct { + Version int + IssuerAndSerial *IssuerAndSerial + SubjectKeyID []byte + DigestAlgorithm pkix.AlgorithmIdentifier + SignedAttributes []Attribute + SignatureAlgorithm pkix.AlgorithmIdentifier + Signature []byte + UnsignedAttributes []Attribute + + // rawSignedAttrs is the [0] IMPLICIT encoding exactly as received; the + // signature covers these bytes (with the SET tag), not a re-encoding. + rawSignedAttrs []byte +} + +// SignedAttribute unmarshals the single value of the signed attribute t into +// out. An attribute that appears more than once, or with other than one +// value, is refused: every attribute CMS, SCEP and RFC 3161 define here is +// single-valued, and ambiguity in an authenticated field is not something to +// resolve by picking one. +func (si *SignerInfo) SignedAttribute(t asn1.ObjectIdentifier, out any) error { + return singleAttribute(si.SignedAttributes, t, out) +} + +func singleAttribute(attrs []Attribute, t asn1.ObjectIdentifier, out any) error { + var found *Attribute + for i := range attrs { + if attrs[i].Type.Equal(t) { + if found != nil { + return fmt.Errorf("cms: attribute %s appears more than once", t) + } + found = &attrs[i] + } + } + if found == nil { + return fmt.Errorf("%w: %s", ErrAttributeNotFound, t) + } + if len(found.Values) != 1 { + return fmt.Errorf("cms: attribute %s has %d values, want 1", t, len(found.Values)) + } + rest, err := asn1.Unmarshal(found.Values[0].FullBytes, out) + if err != nil { + return fmt.Errorf("cms: attribute %s: %w", t, err) + } + if len(rest) != 0 { + return fmt.Errorf("cms: attribute %s: trailing bytes", t) + } + return nil +} + +// SignedData is a parsed RFC 5652 section 5.1 SignedData. +type SignedData struct { + Version int + DigestAlgorithms []pkix.AlgorithmIdentifier + // ContentType is the eContentType. + ContentType asn1.ObjectIdentifier + // Content is the eContent, or nil when it is absent (a detached + // signature, or a degenerate certs-only message). + Content []byte + // Certificates are the DER certificates in the order they were + // written. Other CertificateChoices (attribute certificates) are + // skipped. + Certificates [][]byte + // CRLs are the DER CertificateLists. Other RevocationInfoChoices are + // skipped. + CRLs [][]byte + SignerInfos []SignerInfo +} + +// Wire shapes for encoding/asn1. Optional implicitly tagged fields are +// structs holding RawContent rather than bare RawValues: encoding/asn1 does +// not check the tag of an optional RawValue, so one would swallow whatever +// element comes next. +type ( + rawContentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue + } + rawTagged struct { + Raw asn1.RawContent + } + rawEncapContentInfo struct { + EContentType asn1.ObjectIdentifier + EContent rawTagged `asn1:"optional,tag:0"` + } + rawSignedData struct { + Version int + DigestAlgorithms []pkix.AlgorithmIdentifier `asn1:"set"` + EncapContentInfo rawEncapContentInfo + Certificates rawTagged `asn1:"optional,tag:0"` + CRLs rawTagged `asn1:"optional,tag:1"` + SignerInfos []rawSignerInfo `asn1:"set"` + } + rawSignerInfo struct { + Version int + SID asn1.RawValue + DigestAlgorithm pkix.AlgorithmIdentifier + SignedAttrs rawTagged `asn1:"optional,tag:0"` + SignatureAlgorithm pkix.AlgorithmIdentifier + Signature []byte + UnsignedAttrs rawTagged `asn1:"optional,tag:1"` + } +) + +// parseContentInfo normalizes BER to DER, then unwraps the RFC 5652 section 3 +// ContentInfo, requiring the given content type. It returns the DER of the +// inner content. +func parseContentInfo(in []byte, want asn1.ObjectIdentifier, what string) ([]byte, error) { + der, err := normalizeBER(in) + if err != nil { + return nil, fmt.Errorf("cms: %s: %w", what, err) + } + var ci rawContentInfo + rest, err := asn1.Unmarshal(der, &ci) + if err != nil { + return nil, fmt.Errorf("cms: %s: ContentInfo: %w", what, err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("cms: %s: trailing bytes after ContentInfo", what) + } + if !ci.ContentType.Equal(want) { + return nil, fmt.Errorf("cms: %s: content type is %s, want %s", what, ci.ContentType, want) + } + if ci.Content.Class != asn1.ClassContextSpecific || ci.Content.Tag != 0 || !ci.Content.IsCompound { + return nil, fmt.Errorf("cms: %s: content is not the [0] EXPLICIT wrapper", what) + } + return ci.Content.Bytes, nil +} + +// taggedContents returns the contents of an optional context-tagged field, or +// nil when it was absent. +func taggedContents(t rawTagged) ([]byte, error) { + if t.Raw == nil { + return nil, nil + } + var v asn1.RawValue + if _, err := asn1.Unmarshal(t.Raw, &v); err != nil { + return nil, err + } + return v.Bytes, nil +} + +// splitElements splits concatenated DER elements, keeping each one's full +// encoding. +func splitElements(b []byte) ([]asn1.RawValue, error) { + var out []asn1.RawValue + for len(b) > 0 { + var v asn1.RawValue + rest, err := asn1.Unmarshal(b, &v) + if err != nil { + return nil, err + } + out = append(out, v) + b = rest + } + return out, nil +} + +func parseAttributes(contents []byte) ([]Attribute, error) { + elems, err := splitElements(contents) + if err != nil { + return nil, err + } + attrs := make([]Attribute, 0, len(elems)) + for _, e := range elems { + var a Attribute + rest, err := asn1.Unmarshal(e.FullBytes, &a) + if err != nil { + return nil, err + } + if len(rest) != 0 { + return nil, errors.New("trailing bytes in attribute") + } + attrs = append(attrs, a) + } + return attrs, nil +} + +// ParseSignedData parses a ContentInfo carrying a SignedData. BER input +// (indefinite lengths, a chunked eContent) is accepted. Parsing checks +// structure only; call Verify before trusting anything in it. +func ParseSignedData(in []byte) (*SignedData, error) { + inner, err := parseContentInfo(in, OIDSignedData, "SignedData") + if err != nil { + return nil, err + } + var raw rawSignedData + rest, err := asn1.Unmarshal(inner, &raw) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: %w", err) + } + if len(rest) != 0 { + return nil, errors.New("cms: SignedData: trailing bytes") + } + + sd := &SignedData{ + Version: raw.Version, + DigestAlgorithms: raw.DigestAlgorithms, + ContentType: raw.EncapContentInfo.EContentType, + } + if raw.EncapContentInfo.EContent.Raw != nil { + // [0] EXPLICIT: the tagged element's contents are the OCTET STRING. + inner, err := taggedContents(raw.EncapContentInfo.EContent) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: eContent: %w", err) + } + var content []byte + if rest, err := asn1.Unmarshal(inner, &content); err != nil || len(rest) != 0 { + return nil, errors.New("cms: SignedData: eContent is not an OCTET STRING") + } + if content == nil { + content = []byte{} + } + sd.Content = content + } + + certs, err := taggedContents(raw.Certificates) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: certificates: %w", err) + } + elems, err := splitElements(certs) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: certificates: %w", err) + } + for _, e := range elems { + if e.Class == asn1.ClassUniversal && e.Tag == asn1.TagSequence { + sd.Certificates = append(sd.Certificates, e.FullBytes) + } + } + + crls, err := taggedContents(raw.CRLs) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: crls: %w", err) + } + elems, err = splitElements(crls) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: crls: %w", err) + } + for _, e := range elems { + if e.Class == asn1.ClassUniversal && e.Tag == asn1.TagSequence { + sd.CRLs = append(sd.CRLs, e.FullBytes) + } + } + + for i, rsi := range raw.SignerInfos { + si, err := parseSignerInfo(rsi) + if err != nil { + return nil, fmt.Errorf("cms: SignedData: signer %d: %w", i, err) + } + sd.SignerInfos = append(sd.SignerInfos, si) + } + return sd, nil +} + +func parseSignerInfo(r rawSignerInfo) (SignerInfo, error) { + si := SignerInfo{ + Version: r.Version, + DigestAlgorithm: r.DigestAlgorithm, + SignatureAlgorithm: r.SignatureAlgorithm, + Signature: r.Signature, + } + switch { + case r.SID.Class == asn1.ClassUniversal && r.SID.Tag == asn1.TagSequence: + var ias IssuerAndSerial + if rest, err := asn1.Unmarshal(r.SID.FullBytes, &ias); err != nil || len(rest) != 0 { + return SignerInfo{}, errors.New("malformed issuerAndSerialNumber") + } + si.IssuerAndSerial = &ias + case r.SID.Class == asn1.ClassContextSpecific && r.SID.Tag == 0 && !r.SID.IsCompound: + if len(r.SID.Bytes) == 0 { + return SignerInfo{}, errors.New("empty subjectKeyIdentifier") + } + si.SubjectKeyID = r.SID.Bytes + default: + return SignerInfo{}, errors.New("unknown signer identifier") + } + + if r.SignedAttrs.Raw != nil { + contents, err := taggedContents(r.SignedAttrs) + if err != nil { + return SignerInfo{}, fmt.Errorf("signed attributes: %w", err) + } + attrs, err := parseAttributes(contents) + if err != nil { + return SignerInfo{}, fmt.Errorf("signed attributes: %w", err) + } + // RFC 5652 section 5.3: SIZE (1..MAX). + if len(attrs) == 0 { + return SignerInfo{}, errors.New("signed attributes present but empty") + } + si.SignedAttributes = attrs + si.rawSignedAttrs = r.SignedAttrs.Raw + } + if r.UnsignedAttrs.Raw != nil { + contents, err := taggedContents(r.UnsignedAttrs) + if err != nil { + return SignerInfo{}, fmt.Errorf("unsigned attributes: %w", err) + } + attrs, err := parseAttributes(contents) + if err != nil { + return SignerInfo{}, fmt.Errorf("unsigned attributes: %w", err) + } + si.UnsignedAttributes = attrs + } + return si, nil +} + +// VerifyOptions tunes Verify. +type VerifyOptions struct { + // Content supplies the content of a detached signature. It must be nil + // when the message carries its own eContent. + Content []byte + // Certificates are extra candidates for the signer certificate, beyond + // those carried in the message: a SCEP RenewalReq signer the server + // already knows, say. + Certificates []*x509.Certificate +} + +// Verify checks every SignerInfo and returns each signer's certificate, in +// SignerInfo order. For each signer it checks, per RFC 5652 section 5.6, +// that the content-type attribute equals eContentType, that the +// message-digest attribute equals the digest of the content, and that the +// signature over the DER signed attributes verifies under the identified +// certificate's key. +// +// Verify proves who signed, not whether to trust them: it does no path +// validation, validity-period, key-usage or revocation checks. Those are the +// caller's policy, applied to the returned certificates. +func (sd *SignedData) Verify(opts VerifyOptions) ([]*x509.Certificate, error) { + content := sd.Content + if opts.Content != nil { + if sd.Content != nil { + return nil, errors.New("cms: Verify: detached content supplied for a message that carries its own") + } + content = opts.Content + } + if content == nil { + return nil, errors.New("cms: Verify: no content to verify (detached signature without VerifyOptions.Content)") + } + if len(sd.SignerInfos) == 0 { + return nil, errors.New("cms: Verify: the message has no signers") + } + + candidates := make([]*x509.Certificate, 0, len(sd.Certificates)+len(opts.Certificates)) + for i, der := range sd.Certificates { + c, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("cms: Verify: certificate %d: %w", i, err) + } + candidates = append(candidates, c) + } + candidates = append(candidates, opts.Certificates...) + + signers := make([]*x509.Certificate, 0, len(sd.SignerInfos)) + for i := range sd.SignerInfos { + si := &sd.SignerInfos[i] + cert := si.findCertificate(candidates) + if cert == nil { + return nil, fmt.Errorf("%w: signer %d", ErrSignerNotFound, i) + } + if err := si.verify(cert, content, sd.ContentType); err != nil { + return nil, fmt.Errorf("cms: Verify: signer %d: %w", i, err) + } + signers = append(signers, cert) + } + return signers, nil +} + +func (si *SignerInfo) findCertificate(candidates []*x509.Certificate) *x509.Certificate { + for _, c := range candidates { + switch { + case si.IssuerAndSerial != nil: + if si.IssuerAndSerial.matches(c) { + return c + } + case len(c.SubjectKeyId) > 0 && bytes.Equal(si.SubjectKeyID, c.SubjectKeyId): + return c + } + } + return nil +} + +func (si *SignerInfo) verify(cert *x509.Certificate, content []byte, contentType asn1.ObjectIdentifier) error { + h, err := hashForDigestOID(si.DigestAlgorithm.Algorithm) + if err != nil { + return err + } + kind, err := signatureScheme(si.SignatureAlgorithm.Algorithm, h) + if err != nil { + return err + } + + contentDigest := h.New() + contentDigest.Write(content) + sum := contentDigest.Sum(nil) + + var signedBytes []byte + if si.rawSignedAttrs == nil { + // RFC 5652 section 5.3: signed attributes may be omitted only for + // id-data, and then the signature is over the content itself. + if !contentType.Equal(OIDData) { + return fmt.Errorf("%w: signed attributes are required for content type %s", ErrVerification, contentType) + } + signedBytes = content + } else { + var ct asn1.ObjectIdentifier + if err := si.SignedAttribute(OIDAttributeContentType, &ct); err != nil { + return fmt.Errorf("%w: content-type attribute: %v", ErrVerification, err) + } + if !ct.Equal(contentType) { + return fmt.Errorf("%w: content-type attribute %s does not match eContentType %s", ErrVerification, ct, contentType) + } + var md []byte + if err := si.SignedAttribute(OIDAttributeMessageDigest, &md); err != nil { + return fmt.Errorf("%w: message-digest attribute: %v", ErrVerification, err) + } + if subtle.ConstantTimeCompare(md, sum) != 1 { + return fmt.Errorf("%w: message digest does not match the content", ErrVerification) + } + // Section 5.4: the signature covers the DER of the attributes with + // an explicit SET OF tag, not the [0] IMPLICIT tag they travel + // under. + signedBytes = bytes.Clone(si.rawSignedAttrs) + signedBytes[0] = 0x31 + } + + sigDigest := h.New() + sigDigest.Write(signedBytes) + return checkSignature(cert.PublicKey, kind, h, sigDigest.Sum(nil), si.Signature) +} + +func checkSignature(pub crypto.PublicKey, kind signatureKind, h crypto.Hash, digest, sig []byte) error { + switch kind { + case sigRSA: + k, ok := pub.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w: RSA signature but the certificate key is %T", ErrVerification, pub) + } + if err := rsa.VerifyPKCS1v15(k, h, digest, sig); err != nil { + return fmt.Errorf("%w: RSA signature does not verify", ErrVerification) + } + case sigECDSA: + k, ok := pub.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("%w: ECDSA signature but the certificate key is %T", ErrVerification, pub) + } + if !ecdsa.VerifyASN1(k, digest, sig) { + return fmt.Errorf("%w: ECDSA signature does not verify", ErrVerification) + } + } + return nil +} diff --git a/internal/cms/signeddata_test.go b/internal/cms/signeddata_test.go new file mode 100644 index 0000000..ba14d1b --- /dev/null +++ b/internal/cms/signeddata_test.go @@ -0,0 +1,479 @@ +package cms + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "errors" + "testing" +) + +func TestSignVerifyRoundTrip(t *testing.T) { + ca := newTestCA(t) + content := []byte("a certificate signing request, as far as CMS cares") + + cases := []struct { + name string + key crypto.Signer + hash crypto.Hash + ski bool + contentType asn1.ObjectIdentifier + wantVersion int + wantSIVer int + }{ + {"rsa2048 sha256 issuerAndSerial", keys.rsa2048, crypto.SHA256, false, OIDData, 1, 1}, + {"rsa3072 sha384 ski", keys.rsa3072, crypto.SHA384, true, OIDData, 3, 3}, + {"rsa2048 sha512", keys.rsa2048, crypto.SHA512, false, OIDData, 1, 1}, + {"p256 sha256", keys.p256, crypto.SHA256, false, OIDData, 1, 1}, + {"p384 sha384 ski", keys.p384, crypto.SHA384, true, OIDData, 3, 3}, + // RFC 3161 section 2.4.2: a time-stamp token encapsulates TSTInfo, + // which forces SignedData version 3 (RFC 5652 section 5.1). + {"tstinfo content type", keys.p384, crypto.SHA384, false, OIDTSTInfo, 3, 1}, + {"default hash is sha256", keys.p256, 0, false, OIDData, 1, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cert := ca.issue(t, "signer", tc.key.Public(), tc.ski) + der, err := Sign(tc.contentType, content, []Signer{{ + Certificate: cert, + Key: signerOnly{tc.key}, + Hash: tc.hash, + IdentifyBySKI: tc.ski, + }}, SignOptions{Certificates: [][]byte{cert.Raw, ca.cert.Raw}}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + sd, err := ParseSignedData(der) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if sd.Version != tc.wantVersion { + t.Errorf("SignedData version = %d, want %d", sd.Version, tc.wantVersion) + } + if !sd.ContentType.Equal(tc.contentType) { + t.Errorf("content type = %s, want %s", sd.ContentType, tc.contentType) + } + if !bytes.Equal(sd.Content, content) { + t.Error("encapsulated content did not round-trip") + } + if len(sd.Certificates) != 2 || !bytes.Equal(sd.Certificates[0], cert.Raw) { + t.Error("certificates did not round-trip in order") + } + if len(sd.SignerInfos) != 1 { + t.Fatalf("got %d signers, want 1", len(sd.SignerInfos)) + } + si := sd.SignerInfos[0] + if si.Version != tc.wantSIVer { + t.Errorf("SignerInfo version = %d, want %d", si.Version, tc.wantSIVer) + } + if tc.ski { + if si.IssuerAndSerial != nil || !bytes.Equal(si.SubjectKeyID, cert.SubjectKeyId) { + t.Error("signer is not identified by its subject key identifier") + } + } else if si.IssuerAndSerial == nil || si.IssuerAndSerial.SerialNumber.Cmp(cert.SerialNumber) != 0 { + t.Error("signer is not identified by issuer and serial number") + } + + signers, err := sd.Verify(VerifyOptions{}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if len(signers) != 1 || !signers[0].Equal(cert) { + t.Fatal("Verify did not return the signing certificate") + } + }) + } +} + +func TestSignedAttributes(t *testing.T) { + ca := newTestCA(t) + cert := ca.issue(t, "signer", keys.p256.Public(), false) + + // A SCEP messageType attribute (RFC 8894 section 3.2.1.2) stands in for + // any caller-supplied authenticated attribute. + oidMessageType := asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2} + mt, err := NewAttribute(oidMessageType, asn1.RawValue{Tag: asn1.TagPrintableString, Bytes: []byte("19")}) + if err != nil { + t.Fatalf("NewAttribute: %v", err) + } + + der, err := Sign(OIDData, []byte("abc"), []Signer{{ + Certificate: cert, Key: keys.p256, Attributes: []Attribute{mt}, + }}, SignOptions{Certificates: [][]byte{cert.Raw}}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + sd, err := ParseSignedData(der) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + si := sd.SignerInfos[0] + + // DER orders a SET OF by encoding (X.690 section 11.6), so insertion + // order is irrelevant: the messageType attribute (SEQUENCE length 0x12) + // sorts before contentType (0x18), which sorts before messageDigest + // (0x2f). + var order []string + for _, a := range si.SignedAttributes { + order = append(order, a.Type.String()) + } + want := []string{oidMessageType.String(), OIDAttributeContentType.String(), OIDAttributeMessageDigest.String()} + if len(order) != len(want) { + t.Fatalf("signed attributes = %v, want %v", order, want) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("signed attributes = %v, want DER order %v", order, want) + } + } + + // Known answer: SHA-256("abc") from FIPS 180-2 appendix B.1. + var digest []byte + if err := si.SignedAttribute(OIDAttributeMessageDigest, &digest); err != nil { + t.Fatalf("messageDigest: %v", err) + } + if got := hex.EncodeToString(digest); got != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { + t.Fatalf("messageDigest = %s", got) + } + var ct asn1.ObjectIdentifier + if err := si.SignedAttribute(OIDAttributeContentType, &ct); err != nil || !ct.Equal(OIDData) { + t.Fatalf("contentType = %v, %v", ct, err) + } + var mtValue string + if err := si.SignedAttribute(oidMessageType, &mtValue); err != nil || mtValue != "19" { + t.Fatalf("messageType = %q, %v", mtValue, err) + } + if err := si.SignedAttribute(OIDAttributeSigningTime, new(asn1.RawValue)); !errors.Is(err, ErrAttributeNotFound) { + t.Fatalf("absent attribute: err = %v, want ErrAttributeNotFound", err) + } + if _, err := sd.Verify(VerifyOptions{}); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestSignRejects(t *testing.T) { + ca := newTestCA(t) + cert := ca.issue(t, "signer", keys.p256.Public(), false) + ok := Signer{Certificate: cert, Key: keys.p256} + + dupCT, err := NewAttribute(OIDAttributeContentType, OIDData) + if err != nil { + t.Fatalf("NewAttribute: %v", err) + } + certNoSKI := ca.issue(t, "no-ski", keys.p256.Public(), false) + + cases := []struct { + name string + signers []Signer + wantErr error + }{ + {"no signers", nil, nil}, + {"sha1", []Signer{{Certificate: cert, Key: keys.p256, Hash: crypto.SHA1}}, ErrUnsupportedAlgorithm}, + {"md5", []Signer{{Certificate: cert, Key: keys.p256, Hash: crypto.MD5}}, ErrUnsupportedAlgorithm}, + {"key does not match certificate", []Signer{{Certificate: cert, Key: keys.p384}}, nil}, + {"caller supplies contentType", []Signer{{Certificate: cert, Key: keys.p256, Attributes: []Attribute{dupCT}}}, nil}, + {"ski requested but absent", []Signer{{Certificate: certNoSKI, Key: keys.p256, IdentifyBySKI: true}}, nil}, + {"missing certificate", []Signer{{Key: keys.p256}}, nil}, + {"missing key", []Signer{{Certificate: cert}}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Sign(OIDData, []byte("x"), tc.signers, SignOptions{}) + if err == nil { + t.Fatal("Sign accepted it") + } + if tc.wantErr != nil && !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + }) + } + if _, err := Sign(OIDData, []byte("x"), []Signer{ok}, SignOptions{}); err != nil { + t.Fatalf("control case failed: %v", err) + } +} + +// flipAt returns a copy of b with one bit flipped at the first occurrence of +// needle (plus offset). +func flipAt(t *testing.T, b, needle []byte, offset int) []byte { + t.Helper() + i := bytes.Index(b, needle) + if i < 0 { + t.Fatal("needle not found in message") + } + out := bytes.Clone(b) + out[i+offset] ^= 0x01 + return out +} + +func TestVerifyRejects(t *testing.T) { + ca := newTestCA(t) + content := []byte("content that must not change in transit") + cert := ca.issue(t, "signer", keys.rsa2048.Public(), false) + other := ca.issue(t, "other", keys.rsa2048b.Public(), false) + + sign := func(t *testing.T, s Signer, ct asn1.ObjectIdentifier, opts SignOptions) []byte { + t.Helper() + der, err := Sign(ct, content, []Signer{s}, opts) + if err != nil { + t.Fatalf("Sign: %v", err) + } + return der + } + good := sign(t, Signer{Certificate: cert, Key: keys.rsa2048}, OIDData, SignOptions{Certificates: [][]byte{cert.Raw}}) + + verify := func(der []byte, opts VerifyOptions) error { + sd, err := ParseSignedData(der) + if err != nil { + return err + } + _, err = sd.Verify(opts) + return err + } + + t.Run("control", func(t *testing.T) { + if err := verify(good, VerifyOptions{}); err != nil { + t.Fatalf("Verify: %v", err) + } + }) + t.Run("bad digest: content altered", func(t *testing.T) { + err := verify(flipAt(t, good, content, 3), VerifyOptions{}) + if !errors.Is(err, ErrVerification) { + t.Fatalf("err = %v, want ErrVerification", err) + } + }) + t.Run("signed attribute altered", func(t *testing.T) { + sd, _ := ParseSignedData(good) + var digest []byte + if err := sd.SignerInfos[0].SignedAttribute(OIDAttributeMessageDigest, &digest); err != nil { + t.Fatal(err) + } + err := verify(flipAt(t, good, digest, 0), VerifyOptions{}) + if !errors.Is(err, ErrVerification) { + t.Fatalf("err = %v, want ErrVerification", err) + } + }) + t.Run("signature altered", func(t *testing.T) { + sd, _ := ParseSignedData(good) + err := verify(flipAt(t, good, sd.SignerInfos[0].Signature, 10), VerifyOptions{}) + if !errors.Is(err, ErrVerification) { + t.Fatalf("err = %v, want ErrVerification", err) + } + }) + t.Run("wrong signer: key does not match the identified certificate", func(t *testing.T) { + // other's key signs, but the only certificate carrying other's issuer + // and serial is an impostor over a different key. The identifier + // resolves, so only the signature check stands between the two. + impostor := ca.issueSerial(t, "impostor", keys.rsa2048.Public(), other.SerialNumber) + forged := sign(t, Signer{Certificate: other, Key: keys.rsa2048b}, OIDData, SignOptions{Certificates: [][]byte{impostor.Raw}}) + err := verify(forged, VerifyOptions{}) + if !errors.Is(err, ErrVerification) { + t.Fatalf("err = %v, want ErrVerification", err) + } + }) + t.Run("signer certificate not present", func(t *testing.T) { + bare := sign(t, Signer{Certificate: cert, Key: keys.rsa2048}, OIDData, SignOptions{}) + if err := verify(bare, VerifyOptions{}); !errors.Is(err, ErrSignerNotFound) { + t.Fatalf("err = %v, want ErrSignerNotFound", err) + } + if err := verify(bare, VerifyOptions{Certificates: []*x509.Certificate{other}}); !errors.Is(err, ErrSignerNotFound) { + t.Fatalf("with an unrelated candidate: err = %v, want ErrSignerNotFound", err) + } + if err := verify(bare, VerifyOptions{Certificates: []*x509.Certificate{cert}}); err != nil { + t.Fatalf("with the signer supplied out of band: %v", err) + } + }) + t.Run("content-type attribute does not match eContentType", func(t *testing.T) { + // id-envelopedData and id-data encode to the same length, so the + // eContentType can be swapped without disturbing any length. + der := sign(t, Signer{Certificate: cert, Key: keys.rsa2048}, OIDEnvelopedData, SignOptions{Certificates: [][]byte{cert.Raw}}) + envOID, _ := asn1.Marshal(OIDEnvelopedData) + dataOID, _ := asn1.Marshal(OIDData) + i := bytes.Index(der, envOID) + swapped := bytes.Clone(der) + copy(swapped[i:], dataOID) + err := verify(swapped, VerifyOptions{}) + if !errors.Is(err, ErrVerification) { + t.Fatalf("err = %v, want ErrVerification", err) + } + }) + t.Run("detached", func(t *testing.T) { + det := sign(t, Signer{Certificate: cert, Key: keys.rsa2048}, OIDData, SignOptions{Certificates: [][]byte{cert.Raw}, Detached: true}) + sd, err := ParseSignedData(det) + if err != nil { + t.Fatal(err) + } + if sd.Content != nil { + t.Fatal("detached message carries content") + } + if _, err := sd.Verify(VerifyOptions{}); err == nil { + t.Fatal("verified a detached message with no content supplied") + } + if _, err := sd.Verify(VerifyOptions{Content: []byte("something else")}); !errors.Is(err, ErrVerification) { + t.Fatalf("wrong detached content: err = %v, want ErrVerification", err) + } + if _, err := sd.Verify(VerifyOptions{Content: content}); err != nil { + t.Fatalf("detached content: %v", err) + } + attached, _ := ParseSignedData(good) + if _, err := attached.Verify(VerifyOptions{Content: content}); err == nil { + t.Fatal("accepted detached content for an attached message") + } + }) + t.Run("degenerate has no signers", func(t *testing.T) { + der, err := Degenerate([][]byte{cert.Raw}, nil) + if err != nil { + t.Fatal(err) + } + if err := verify(der, VerifyOptions{Content: content}); err == nil { + t.Fatal("a message with no signers verified") + } + }) +} + +func TestParseSignedDataRejects(t *testing.T) { + ca := newTestCA(t) + cert := ca.issue(t, "r", keys.rsa3072.Public(), false) + env, err := Encrypt([]byte("x"), []*x509.Certificate{cert}, AES128CBC, EncryptOptions{}) + if err != nil { + t.Fatal(err) + } + good, err := Degenerate([][]byte{cert.Raw}, nil) + if err != nil { + t.Fatal(err) + } + + cases := map[string][]byte{ + "empty": nil, + "garbage": []byte("not asn.1"), + "trailing bytes": append(bytes.Clone(good), 0x00), + "bare certificate": cert.Raw, + "envelopedData": env, + "truncated": good[:len(good)-3], + "unterminated ber": {0x30, 0x80, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}, + "deeply nested ber": append(bytes.Repeat([]byte{0x30, 0x80}, 200), bytes.Repeat([]byte{0x00, 0x00}, 200)...), + } + for name, der := range cases { + t.Run(name, func(t *testing.T) { + if _, err := ParseSignedData(der); err == nil { + t.Fatal("parsed") + } + }) + } +} + +// TestParseBER covers the encodings streaming encoders emit: indefinite +// lengths and a constructed eContent OCTET STRING split into chunks. RFC 5652 +// only requires DER for the signed attributes, so the rest may arrive as BER. +func TestParseBER(t *testing.T) { + ca := newTestCA(t) + cert := ca.issue(t, "signer", keys.p256.Public(), false) + content := bytes.Repeat([]byte("0123456789abcdef"), 20) + der, err := Sign(OIDData, content, []Signer{{Certificate: cert, Key: keys.p256}}, SignOptions{Certificates: [][]byte{cert.Raw}}) + if err != nil { + t.Fatal(err) + } + ber := toIndefiniteBER(t, der, content) + if bytes.Equal(ber, der) { + t.Fatal("the BER rewrite changed nothing") + } + sd, err := ParseSignedData(ber) + if err != nil { + t.Fatalf("ParseSignedData(BER): %v", err) + } + if !bytes.Equal(sd.Content, content) { + t.Fatal("chunked eContent was not reassembled") + } + if _, err := sd.Verify(VerifyOptions{}); err != nil { + t.Fatalf("Verify(BER): %v", err) + } +} + +func TestDegenerate(t *testing.T) { + ca := newTestCA(t) + leaf := ca.issue(t, "leaf", keys.p256.Public(), false) + crl := ca.crl(t) + + der, err := Degenerate([][]byte{leaf.Raw, ca.cert.Raw}, [][]byte{crl}) + if err != nil { + t.Fatalf("Degenerate: %v", err) + } + sd, err := ParseSignedData(der) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if sd.Version != 1 || len(sd.SignerInfos) != 0 || len(sd.DigestAlgorithms) != 0 || sd.Content != nil { + t.Fatalf("not the degenerate shape: version %d, %d signers, %d digests", sd.Version, len(sd.SignerInfos), len(sd.DigestAlgorithms)) + } + if !sd.ContentType.Equal(OIDData) { + t.Fatalf("eContentType = %s, want id-data", sd.ContentType) + } + if len(sd.Certificates) != 2 || !bytes.Equal(sd.Certificates[0], leaf.Raw) || !bytes.Equal(sd.Certificates[1], ca.cert.Raw) { + t.Fatal("certificates did not round-trip in order") + } + if len(sd.CRLs) != 1 || !bytes.Equal(sd.CRLs[0], crl) { + t.Fatal("CRL did not round-trip") + } + + crlOnly, err := Degenerate(nil, [][]byte{crl}) + if err != nil { + t.Fatalf("CRL-only: %v", err) + } + if sd, err := ParseSignedData(crlOnly); err != nil || len(sd.Certificates) != 0 || len(sd.CRLs) != 1 { + t.Fatalf("CRL-only did not round-trip: %v", err) + } + + if _, err := Degenerate(nil, nil); err == nil { + t.Fatal("an empty degenerate message was accepted") + } + if _, err := Degenerate([][]byte{{}}, nil); err == nil { + t.Fatal("an empty certificate was accepted") + } + if _, err := Degenerate(nil, [][]byte{{}}); err == nil { + t.Fatal("an empty CRL was accepted") + } +} + +// TestDegenerateKnownAnswer pins the exact bytes, worked by hand from RFC 5652 +// sections 3 and 5.1, so the encoding cannot drift unnoticed. The +// certificate and CRL slots hold an empty SEQUENCE: the structure is what is +// under test. +func TestDegenerateKnownAnswer(t *testing.T) { + der, err := Degenerate([][]byte{{0x30, 0x00}}, [][]byte{{0x30, 0x00}}) + if err != nil { + t.Fatal(err) + } + want := "302b" + // ContentInfo + "06092a864886f70d010702" + // id-signedData + "a01e" + // [0] EXPLICIT + "301c" + // SignedData + "020101" + // version 1 + "3100" + // digestAlgorithms: empty SET + "300b06092a864886f70d010701" + // encapContentInfo: id-data, no eContent + "a0023000" + // certificates [0] IMPLICIT + "a1023000" + // crls [1] IMPLICIT + "3100" // signerInfos: empty SET + if got := hex.EncodeToString(der); got != want { + t.Fatalf("Degenerate =\n%s\nwant\n%s", got, want) + } +} diff --git a/internal/est/doc.go b/internal/est/doc.go index 03b89bd..e49c71d 100644 --- a/internal/est/doc.go +++ b/internal/est/doc.go @@ -15,8 +15,9 @@ // // Dependencies are stdlib only. The one thing EST needs that the standard // library does not provide is a CMS writer, and it needs exactly one shape of -// it: the degenerate certs-only SignedData in pkcs7.go, which is a fixed -// ASN.1 envelope with a certificate set and no signers. +// it: the degenerate certs-only SignedData, a fixed ASN.1 envelope with a +// certificate set and no signers. pkcs7.go builds it with internal/cms, the +// hand-written CMS package EST shares with SCEP and RFC 3161. // // # Authentication, and what it does and does not prove // diff --git a/internal/est/pkcs7.go b/internal/est/pkcs7.go index baa2d11..1b93461 100644 --- a/internal/est/pkcs7.go +++ b/internal/est/pkcs7.go @@ -19,52 +19,17 @@ limitations under the License. */ import ( - "encoding/asn1" "errors" "fmt" -) -// PKCS#7 / CMS object identifiers (RFC 5652 section 3 and 4). -var ( - oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} - oidData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1} + "github.com/CryptOS-PKI/cryptos/internal/cms" ) -// contentInfo is the RFC 5652 section 3 outer wrapper. Content is [0] -// EXPLICIT, which is written by hand as a constructed context tag rather than -// with an `explicit` struct tag: encoding/asn1 ignores the tag options on a -// RawValue and emits it verbatim, so the wrapper would silently go missing. -type contentInfo struct { - ContentType asn1.ObjectIdentifier - Content asn1.RawValue -} - -// encapsulatedContentInfo is the RFC 5652 section 5.2 eContent wrapper. For a -// certs-only message the eContent itself is absent, so only the type remains. -type encapsulatedContentInfo struct { - EContentType asn1.ObjectIdentifier -} - -// signedData is the RFC 5652 section 5.1 SignedData, restricted to the -// degenerate certs-only shape: no digest algorithms, no encapsulated content, -// and no signers. Only the certificate set carries anything. -type signedData struct { - Version int - DigestAlgorithms []asn1.RawValue `asn1:"set"` - EncapContentInfo encapsulatedContentInfo - Certificates asn1.RawValue - SignerInfos []asn1.RawValue `asn1:"set"` -} - // CertsOnlyPKCS7 builds the degenerate "certs-only" SignedData that EST uses // to carry certificates (RFC 7030 sections 4.1.3 and 4.2.3; the structure is // RFC 5652 section 5.2, which explicitly blesses a SignedData with no signers -// as the way to transport a certificate set). -// -// The Go standard library has no CMS writer, and this is the whole of what EST -// needs from CMS: an ASN.1 envelope with a certificate set and nothing else. -// Pulling in a PKCS#7 library to emit forty bytes of fixed structure would put -// a third-party ASN.1 parser next to the CA for no benefit. +// as the way to transport a certificate set). The encoding lives in +// internal/cms, shared with SCEP and RFC 3161. // // certs are DER certificates in the order they should appear, conventionally // leaf first. At least one is required: an empty certs-only message is legal @@ -73,51 +38,9 @@ func CertsOnlyPKCS7(certs [][]byte) ([]byte, error) { if len(certs) == 0 { return nil, errors.New("est: CertsOnlyPKCS7: at least one certificate is required") } - - // CertificateSet is [0] IMPLICIT SET OF CertificateChoices. DER wants a - // SET's members sorted, but RFC 5652 section 10.2.3 defines this as a SET - // OF whose order is not significant, and every implementation reads it as - // a sequence of certificates in the order written -- which is what lets - // the chain arrive leaf-first. - var certBytes []byte - for i, der := range certs { - if len(der) == 0 { - return nil, fmt.Errorf("est: CertsOnlyPKCS7: certificate %d is empty", i) - } - certBytes = append(certBytes, der...) - } - - sd := signedData{ - // Version 1: no eContent and no version-2 attribute certificates, so - // the RFC 5652 section 5.1 rules land on 1. - Version: 1, - DigestAlgorithms: []asn1.RawValue{}, - EncapContentInfo: encapsulatedContentInfo{EContentType: oidData}, - Certificates: asn1.RawValue{ - Class: asn1.ClassContextSpecific, - Tag: 0, - IsCompound: true, - Bytes: certBytes, - }, - SignerInfos: []asn1.RawValue{}, - } - sdDER, err := asn1.Marshal(sd) + out, err := cms.Degenerate(certs, nil) if err != nil { - return nil, fmt.Errorf("est: CertsOnlyPKCS7: marshal SignedData: %w", err) - } - - ci := contentInfo{ - ContentType: oidSignedData, - Content: asn1.RawValue{ - Class: asn1.ClassContextSpecific, - Tag: 0, - IsCompound: true, - Bytes: sdDER, - }, - } - out, err := asn1.Marshal(ci) - if err != nil { - return nil, fmt.Errorf("est: CertsOnlyPKCS7: marshal ContentInfo: %w", err) + return nil, fmt.Errorf("est: CertsOnlyPKCS7: %w", err) } return out, nil } @@ -127,36 +50,9 @@ func CertsOnlyPKCS7(certs [][]byte) ([]byte, error) { // so a caller can read a message it was handed; nothing on the serving path // consumes CMS. func ParseCertsOnlyPKCS7(der []byte) ([][]byte, error) { - var ci contentInfo - rest, err := asn1.Unmarshal(der, &ci) + sd, err := cms.ParseSignedData(der) if err != nil { - return nil, fmt.Errorf("est: ParseCertsOnlyPKCS7: ContentInfo: %w", err) - } - if len(rest) != 0 { - return nil, errors.New("est: ParseCertsOnlyPKCS7: trailing bytes after ContentInfo") - } - if !ci.ContentType.Equal(oidSignedData) { - return nil, fmt.Errorf("est: ParseCertsOnlyPKCS7: content type is %s, want signedData", ci.ContentType) + return nil, fmt.Errorf("est: ParseCertsOnlyPKCS7: %w", err) } - if ci.Content.Class != asn1.ClassContextSpecific || ci.Content.Tag != 0 || !ci.Content.IsCompound { - return nil, errors.New("est: ParseCertsOnlyPKCS7: content is not the [0] EXPLICIT wrapper") - } - - var sd signedData - if _, err := asn1.Unmarshal(ci.Content.Bytes, &sd); err != nil { - return nil, fmt.Errorf("est: ParseCertsOnlyPKCS7: SignedData: %w", err) - } - - var out [][]byte - remaining := sd.Certificates.Bytes - for len(remaining) > 0 { - var one asn1.RawValue - next, err := asn1.Unmarshal(remaining, &one) - if err != nil { - return nil, fmt.Errorf("est: ParseCertsOnlyPKCS7: certificate: %w", err) - } - out = append(out, one.FullBytes) - remaining = next - } - return out, nil + return sd.Certificates, nil }