Skip to content

Preserve certificateMsgTLS13 original bytes for the TLS 1.3 transcript#409

Open
sam-at-ava wants to merge 1 commit into
refraction-networking:masterfrom
sam-at-ava:fix/certificate-tls13-original-bytes
Open

Preserve certificateMsgTLS13 original bytes for the TLS 1.3 transcript#409
sam-at-ava wants to merge 1 commit into
refraction-networking:masterfrom
sam-at-ava:fix/certificate-tls13-original-bytes

Conversation

@sam-at-ava

Copy link
Copy Markdown

Problem

TLS 1.3 handshakes can fail at CertificateVerify with:

tls: invalid signature by the server certificate: crypto/rsa: verification error

against real production HTTPS endpoints — even though the certificate chain validates and the same server works with crypto/tls and with a Chrome fingerprint.

Root cause

certificateMsgTLS13 does not implement handshakeMessageWithOriginalBytes. When uTLS reads the server Certificate with readHandshake(nil) (to support RFC 8879 cert compression) and then hashes it into the transcript via transcriptMsg, the fallback path re-marshals the parsed message with marshalCertificate():

func transcriptMsg(msg handshakeMessage, h transcriptHash) error {
    if msgWithOrig, ok := msg.(handshakeMessageWithOriginalBytes); ok {
        if orig := msgWithOrig.originalBytes(); orig != nil {
            h.Write(orig)              // faithful
            return nil
        }
    }
    data, err := msg.marshal()         // <- re-marshal, may diverge
    ...
}

marshalCertificate() is not guaranteed to reproduce the peer's byte encoding: it only re-emits OCSP/SCT (in a fixed order) for the leaf, and drops any other per-certificate extension; non-canonical length encodings are also lost. When the server's Certificate message doesn't round-trip exactly, the client's transcript diverges from the server's, so the CertificateVerify signature — which is correct — fails to verify against the client's (wrong) transcript hash.

This only affects the uncompressed-certificate path; compressed certificates already transcript their raw CompressedCertificate bytes. It's deterministic and reproducible against production servers whose leaf Certificate carries an extension beyond OCSP/SCT.

Fix

clientHelloMsg, serverHelloMsg and certificateRequestMsgTLS13 already cache their original wire bytes for exactly this reason (the June-2025 originalBytes work). certificateMsgTLS13 was the one TLS 1.3 transcript message left out. This PR:

  • adds original []byte to certificateMsgTLS13, captures it in unmarshal, and returns it from originalBytes();
  • nils it in TestMarshalUnmarshal alongside the sibling messages;
  • adds a regression test that builds a Certificate whose leaf carries an extension marshalCertificate drops, and asserts transcriptMsg hashes the original wire bytes rather than the divergent re-marshal.

go test ./... passes; the new test fails without the handshake_messages.go change.

certificateMsgTLS13 does not implement handshakeMessageWithOriginalBytes, so
transcriptMsg re-marshals the parsed Certificate via marshalCertificate() when
feeding the TLS 1.3 handshake transcript. That re-marshal is not guaranteed to
be byte-identical to the peer's encoding: only leaf OCSP/SCT are re-emitted (in
a fixed order), and any other per-certificate extension or non-canonical length
encoding is lost. When a server's Certificate message doesn't round-trip
exactly, the client's transcript diverges from the server's and the handshake
fails at CertificateVerify with:

    tls: invalid signature by the server certificate: crypto/rsa: verification error

This is reproducible against real production HTTPS endpoints whose leaf
certificate message carries an extension beyond OCSP/SCT. It only affects the
uncompressed-certificate path; compressed certificates already transcript their
raw CompressedCertificate bytes.

clientHelloMsg, serverHelloMsg and certificateRequestMsgTLS13 already cache
their original wire bytes for exactly this reason (added in the June 2025
originalBytes work); certificateMsgTLS13 was the one TLS 1.3 transcript message
left out. Capture `original` in unmarshal and return it from originalBytes(),
and nil it in the TestMarshalUnmarshal round-trip like the sibling messages.

Adds a regression test that builds a Certificate whose leaf carries an extension
marshalCertificate drops, and asserts transcriptMsg hashes the original wire
bytes rather than the (divergent) re-marshal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant