Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions internal/cms/ber.go
Original file line number Diff line number Diff line change
@@ -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)
}
79 changes: 79 additions & 0 deletions internal/cms/ber_test.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions internal/cms/doc.go
Original file line number Diff line number Diff line change
@@ -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.
*/
Loading