From 98c12749b21417ef6a21ce6cfb703bce0a9f4f76 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Tue, 20 Jan 2026 14:09:30 +0100 Subject: [PATCH 1/7] New script to cross-validate SSL client and server logs Signed-off-by: Gilles Peskine --- scripts/mbedtls_framework/ssl_log_parser.py | 80 ++++++++++++++++++ scripts/validate_ssl_logs.py | 93 +++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 scripts/mbedtls_framework/ssl_log_parser.py create mode 100755 scripts/validate_ssl_logs.py diff --git a/scripts/mbedtls_framework/ssl_log_parser.py b/scripts/mbedtls_framework/ssl_log_parser.py new file mode 100644 index 000000000..d62e0a756 --- /dev/null +++ b/scripts/mbedtls_framework/ssl_log_parser.py @@ -0,0 +1,80 @@ +"""Parse logs from ssl_client2 and ssl_server2.""" + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later + +import re +from typing import Dict, Iterator, List, Tuple + + +class Info: + """Information gathered from a log of ssl_client2 or ssl_server2.""" + + DUMPING_RE = re.compile(' +'.join([ + r'(?P\S+):(?P[0-9]+):', + r'\|(?P[0-9]+)\|(?: (?P
\w+):)?', + r'dumping \'(?P.*?)\'', + r'\((?P[0-9]+) bytes\)', + ])) + DUMP_CHUNK_RE = re.compile(' +'.join([ + r'(?P\S+):(?P[0-9]+):', + r'\|(?P[0-9]+)\|(?: (?P
\w+):)?', + r'[0-9a-f]+:', + r'(?P(?:[0-9a-f]{2} *){1,16})', + ])) + + def __init__(self) -> None: + """Create an empty log info object.""" + self.dumps = {} #type: Dict[str, List[str]] + + def add_dump(self, name: str, hex_data: str) -> None: + """Add a hex dump.""" + self.dumps.setdefault(name, []).append(hex_data) + + def read_dump(self, + filename: str, + lines: Iterator[Tuple[int, str]], + length: int) -> str: + """Read a hex dump. Return the hex data. + + This method consumes the data dump lines from lines. + """ + acc = '' + remaining = length + while remaining > 0: + lineno, line = next(lines) + m = self.DUMP_CHUNK_RE.match(line) + if not m: + raise Exception(f'{filename}:{lineno}: not a dump chunk as expected') + acc += m.group('data') + remaining -= 16 + plain = acc.replace(' ', '') + assert len(plain) == length * 2 + return plain + + def read_file_contents(self, + filename: str, + lines: Iterator[Tuple[int, str]]) -> None: + """Read lines from a log file and store the information we find. + + The iterator lines delivers a stream of lines with their line numbers. + """ + for _lineno, line in lines: + m = self.DUMPING_RE.match(line) + if m: + what = m.group('what') + hex_data = self.read_dump(filename, lines, + int(m.group('length'))) + self.add_dump(what, hex_data) + + def read_file(self, filename: str) -> None: + """Read a log file and store the information we find.""" + with open(filename) as input_: + self.read_file_contents(filename, enumerate(input_, 1)) + + +def parse_log_file(filename: str) -> Info: + """Parse a log of ssl_client2 or ssl_server2.""" + info = Info() + info.read_file(filename) + return info diff --git a/scripts/validate_ssl_logs.py b/scripts/validate_ssl_logs.py new file mode 100755 index 000000000..5f689c14d --- /dev/null +++ b/scripts/validate_ssl_logs.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Validate logs from ssl_client2 and ssl_server2. + +On success, print nothing and return 0. +On a validation failure, print a short error message and return 1. +On a command line or parse error, die with an exception and return 1. +""" + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later + +import argparse +import sys +from typing import Callable, Dict, List, Optional + +from mbedtls_framework import ssl_log_parser + + + +def match_random(client_log: ssl_log_parser.Info, + server_log: ssl_log_parser.Info) -> Optional[str]: + """Check that both sides have the same idea of client_random and server_random.""" + client_client_randoms = client_log.dumps['client hello, random bytes'] + client_server_randoms = client_log.dumps['server hello, random bytes'] + server_client_randoms = server_log.dumps['client hello, random bytes'] + server_server_randoms = server_log.dumps['server hello, random bytes'] + if len(client_client_randoms) != len(server_client_randoms): + return ('Client and server disagree on the number of client_random ' + + f'({len(client_client_randoms)} != {len(server_client_randoms)})') + if len(client_server_randoms) != len(server_server_randoms): + return ('Client and server disagree on the number of server_random ' + + f'({len(client_server_randoms)} != {len(server_server_randoms)})') + for n, (c, s) in enumerate(zip(client_client_randoms, server_client_randoms)): + if c != s: + return f'Client and server disagree on client random #{n}' + for n, (c, s) in enumerate(zip(client_server_randoms, server_server_randoms)): + if c != s: + return f'Client and server disagree on server random #{n}' + return None + + +Task = Callable[[ssl_log_parser.Info, ssl_log_parser.Info], Optional[str]] + +TASKS = { + 'match_random': match_random, +} #type: Dict[str, Task] + +def validate(client_log: ssl_log_parser.Info, + server_log: ssl_log_parser.Info, + tasks: List[str]) -> Optional[str]: + """Perform validation tasks on a pair of matching logs. + + Return None if the validation succeeds, a human-oriented error message + otherwise. + """ + for task_name in tasks: + task = TASKS[task_name] + outcome = task(client_log, server_log) + if outcome is not None: + return outcome + return None + + +def main() -> int: + """Command line entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--list-tasks', + help='List available tasks and exit') + parser.add_argument('client_log', metavar='FILE', + help='Client log file ($CLI_OUT or ?-cli-*.log)') + parser.add_argument('server_log', metavar='FILE', + help='Server log file ($SRV_OUT or ?-srv-*.log)') + parser.add_argument('tasks', metavar='TASK', + nargs='+', #action='append', + help='Tasks to perform (use --list-tasks to see supported task names)') + args = parser.parse_args() + if args.list_tasks: + for task_name in sorted(TASKS.keys()): + print(task_name) + return 0 + client_log = ssl_log_parser.parse_log_file(args.client_log) + server_log = ssl_log_parser.parse_log_file(args.server_log) + outcome = validate(client_log, server_log, args.tasks) + if outcome is None: + return 0 + else: + if outcome and outcome[-1] != '\n': + outcome += '\n' + sys.stderr.write(outcome) + return 1 + +if __name__ == '__main__': + sys.exit(main()) From b0c3668772d3bdcddec116f52224374d32af1cd1 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Tue, 20 Jan 2026 16:07:50 +0100 Subject: [PATCH 2/7] Also parse bignum value dumps Signed-off-by: Gilles Peskine --- scripts/mbedtls_framework/ssl_log_parser.py | 47 +++++++++++++-------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/scripts/mbedtls_framework/ssl_log_parser.py b/scripts/mbedtls_framework/ssl_log_parser.py index d62e0a756..4e1302b89 100644 --- a/scripts/mbedtls_framework/ssl_log_parser.py +++ b/scripts/mbedtls_framework/ssl_log_parser.py @@ -4,24 +4,26 @@ # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later import re -from typing import Dict, Iterator, List, Tuple +from typing import Dict, Iterator, List, Pattern, Tuple class Info: """Information gathered from a log of ssl_client2 or ssl_server2.""" - DUMPING_RE = re.compile(' +'.join([ - r'(?P\S+):(?P[0-9]+):', - r'\|(?P[0-9]+)\|(?: (?P
\w+):)?', - r'dumping \'(?P.*?)\'', - r'\((?P[0-9]+) bytes\)', - ])) - DUMP_CHUNK_RE = re.compile(' +'.join([ - r'(?P\S+):(?P[0-9]+):', - r'\|(?P[0-9]+)\|(?: (?P
\w+):)?', - r'[0-9a-f]+:', - r'(?P(?:[0-9a-f]{2} *){1,16})', - ])) + PREFIX_RE_S = (r'(?P\S+):(?P[0-9]+): +' + + r'\|(?P[0-9]+)\|(?: (?P
\w+):)? +') + DUMPING_RE = re.compile( + PREFIX_RE_S + + r'dumping \'(?P.*?)\' \((?P[0-9]+) bytes\)') + DUMP_CHUNK_RE = re.compile( + PREFIX_RE_S + + r'[0-9a-f]+: +(?P(?:[0-9a-f]{2} *){1,16})') + VALUE_OF_RE = re.compile( + PREFIX_RE_S + + r'value of \'(?P.*?)\' \((?P[0-9]+) bits\)') + VALUE_CHUNK_RE = re.compile( + PREFIX_RE_S + + r'(?P(?:[0-9a-f]{2} *){1,16})') def __init__(self) -> None: """Create an empty log info object.""" @@ -31,10 +33,10 @@ def add_dump(self, name: str, hex_data: str) -> None: """Add a hex dump.""" self.dumps.setdefault(name, []).append(hex_data) - def read_dump(self, - filename: str, + @staticmethod + def read_dump(filename: str, lines: Iterator[Tuple[int, str]], - length: int) -> str: + length: int, chunk_re: Pattern) -> str: """Read a hex dump. Return the hex data. This method consumes the data dump lines from lines. @@ -43,7 +45,7 @@ def read_dump(self, remaining = length while remaining > 0: lineno, line = next(lines) - m = self.DUMP_CHUNK_RE.match(line) + m = chunk_re.match(line) if not m: raise Exception(f'{filename}:{lineno}: not a dump chunk as expected') acc += m.group('data') @@ -64,7 +66,16 @@ def read_file_contents(self, if m: what = m.group('what') hex_data = self.read_dump(filename, lines, - int(m.group('length'))) + int(m.group('length')), + self.DUMP_CHUNK_RE) + self.add_dump(what, hex_data) + m = self.VALUE_OF_RE.match(line) + if m: + what = m.group('what') + n_bits = int(m.group('length')) + n_bytes = (n_bits + 7) // 8 + hex_data = self.read_dump(filename, lines, + n_bytes, self.VALUE_CHUNK_RE) self.add_dump(what, hex_data) def read_file(self, filename: str) -> None: From f911af9fc190965e145b9e6a4704c67ca6b01742 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Tue, 20 Jan 2026 16:08:37 +0100 Subject: [PATCH 3/7] New validation: distinct_server_ephemeral, distinct_server_random When two clients connect to the same server, validate that they get distinct server_random values and distinct ephemeral public keys. Signed-off-by: Gilles Peskine --- scripts/validate_ssl_logs.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/scripts/validate_ssl_logs.py b/scripts/validate_ssl_logs.py index 5f689c14d..96ea080ac 100755 --- a/scripts/validate_ssl_logs.py +++ b/scripts/validate_ssl_logs.py @@ -39,9 +39,53 @@ def match_random(client_log: ssl_log_parser.Info, return None +def distinct_server_ephemeral(client_log: ssl_log_parser.Info, + _server_log: ssl_log_parser.Info) -> Optional[str]: + """Check that server ephemeral keys as seen from the client are not repeated.""" + # The current implementation does not handle cases where the client + # receives and discards a legitimate resend of the ServerKeyExchange + # message in DTLS. + if 'DHM: GY' in client_log.dumps: + values = client_log.dumps['DHM: GY'] + else: + values = client_log.dumps['server ephemeral public key'] + if len(values) < 2: + return 'Fewer than two server ephemeral public keys found' + seen = {} #type: Dict[str, int] + for n, v in enumerate(values): + if v in seen: + return f'server ephemeral public key #{n} repeats #{seen[v]}' + seen[v] = n + return None + +def distinct_server_random(client_log: ssl_log_parser.Info, + _server_log: ssl_log_parser.Info) -> Optional[str]: + """Check that server randoms as seen from the client are not repeated.""" + # The current implementation does not handle cases where the client + # receives and discards a legitimate resend of the server hello in DTLS. + values = client_log.dumps['server hello, random bytes'] + if len(values) < 2: + return 'Fewer than two server_random found' + def random_part(hex_data: str) -> str: + # In TLS <=1.2, the first 4 bytes (8 hex digits) are the time, + # and may differ even if the actually random part is repeated. + # The last 8 bytes (16 hex digits) are not random in TLS 1.2 when + # the server also supports 1.3 (they are forced to b'DOWNGR\001'). + return hex_data[8:48] + seen = {} #type: Dict[str, int] + for n, v in enumerate(values): + r = random_part(v) + if r in seen: + return f'server_random #{n} repeats #{seen[r]}' + seen[r] = n + return None + + Task = Callable[[ssl_log_parser.Info, ssl_log_parser.Info], Optional[str]] TASKS = { + 'distinct_server_ephemeral': distinct_server_ephemeral, + 'distinct_server_random': distinct_server_random, 'match_random': match_random, } #type: Dict[str, Task] From dd683324d8ab82b7aeb4a74d37e4f80b7ccde2fd Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Tue, 20 Jan 2026 16:29:41 +0100 Subject: [PATCH 4/7] Fix spurious match of data in DUMP_CHUNK_RE If the data from `MBEDTLS_SSL_DEBUG_BUF` that's listed in text form after the hex digits starts with two bytes that happen to be hex digits, this could be interpreted as an extra pair of hex digits. Fix this. Signed-off-by: Gilles Peskine --- scripts/mbedtls_framework/ssl_log_parser.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/mbedtls_framework/ssl_log_parser.py b/scripts/mbedtls_framework/ssl_log_parser.py index 4e1302b89..64a778d0c 100644 --- a/scripts/mbedtls_framework/ssl_log_parser.py +++ b/scripts/mbedtls_framework/ssl_log_parser.py @@ -17,13 +17,13 @@ class Info: r'dumping \'(?P.*?)\' \((?P[0-9]+) bytes\)') DUMP_CHUNK_RE = re.compile( PREFIX_RE_S + - r'[0-9a-f]+: +(?P(?:[0-9a-f]{2} *){1,16})') + r'[0-9a-f]+: *(?P(?: [0-9a-f]{2}){1,16})') VALUE_OF_RE = re.compile( PREFIX_RE_S + r'value of \'(?P.*?)\' \((?P[0-9]+) bits\)') VALUE_CHUNK_RE = re.compile( PREFIX_RE_S + - r'(?P(?:[0-9a-f]{2} *){1,16})') + r'(?P(?:[0-9a-f]{2}(?:$| )){1,16})') def __init__(self) -> None: """Create an empty log info object.""" @@ -51,7 +51,9 @@ def read_dump(filename: str, acc += m.group('data') remaining -= 16 plain = acc.replace(' ', '') - assert len(plain) == length * 2 + if len(plain) != length * 2: + raise Exception(f'{filename}:{lineno}: ' + f'found {len(plain)} hex digits but expected {length * 2}') return plain def read_file_contents(self, From e42959c2afe2e584a7bbf8c0a9dbc83ddfd2cfa0 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 8 Apr 2026 16:50:34 +0200 Subject: [PATCH 5/7] Document validation task return values Signed-off-by: Gilles Peskine --- scripts/validate_ssl_logs.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/validate_ssl_logs.py b/scripts/validate_ssl_logs.py index 96ea080ac..1bb0b9ba6 100755 --- a/scripts/validate_ssl_logs.py +++ b/scripts/validate_ssl_logs.py @@ -17,8 +17,12 @@ +# None: validation succeeded. +# str: validation failed; the string is a human-readable explanation of the failure. +Validation = Optional[str] + def match_random(client_log: ssl_log_parser.Info, - server_log: ssl_log_parser.Info) -> Optional[str]: + server_log: ssl_log_parser.Info) -> Validation: """Check that both sides have the same idea of client_random and server_random.""" client_client_randoms = client_log.dumps['client hello, random bytes'] client_server_randoms = client_log.dumps['server hello, random bytes'] @@ -40,7 +44,7 @@ def match_random(client_log: ssl_log_parser.Info, def distinct_server_ephemeral(client_log: ssl_log_parser.Info, - _server_log: ssl_log_parser.Info) -> Optional[str]: + _server_log: ssl_log_parser.Info) -> Validation: """Check that server ephemeral keys as seen from the client are not repeated.""" # The current implementation does not handle cases where the client # receives and discards a legitimate resend of the ServerKeyExchange @@ -59,7 +63,7 @@ def distinct_server_ephemeral(client_log: ssl_log_parser.Info, return None def distinct_server_random(client_log: ssl_log_parser.Info, - _server_log: ssl_log_parser.Info) -> Optional[str]: + _server_log: ssl_log_parser.Info) -> Validation: """Check that server randoms as seen from the client are not repeated.""" # The current implementation does not handle cases where the client # receives and discards a legitimate resend of the server hello in DTLS. @@ -81,7 +85,7 @@ def random_part(hex_data: str) -> str: return None -Task = Callable[[ssl_log_parser.Info, ssl_log_parser.Info], Optional[str]] +Task = Callable[[ssl_log_parser.Info, ssl_log_parser.Info], Validation] TASKS = { 'distinct_server_ephemeral': distinct_server_ephemeral, @@ -91,7 +95,7 @@ def random_part(hex_data: str) -> str: def validate(client_log: ssl_log_parser.Info, server_log: ssl_log_parser.Info, - tasks: List[str]) -> Optional[str]: + tasks: List[str]) -> Validation: """Perform validation tasks on a pair of matching logs. Return None if the validation succeeds, a human-oriented error message From ebd9ff1bdde9b3b4369616a0705d5427d2a654de Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 8 Apr 2026 16:50:57 +0200 Subject: [PATCH 6/7] distinct_server_ephemeral: validation failure rather than exception if the key is not found Signed-off-by: Gilles Peskine --- scripts/validate_ssl_logs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/validate_ssl_logs.py b/scripts/validate_ssl_logs.py index 1bb0b9ba6..007ee3269 100755 --- a/scripts/validate_ssl_logs.py +++ b/scripts/validate_ssl_logs.py @@ -51,8 +51,10 @@ def distinct_server_ephemeral(client_log: ssl_log_parser.Info, # message in DTLS. if 'DHM: GY' in client_log.dumps: values = client_log.dumps['DHM: GY'] - else: + elif 'server ephemeral public key' in client_log.dumps: values = client_log.dumps['server ephemeral public key'] + else: + return 'Ephemeral public keys not found in client log' if len(values) < 2: return 'Fewer than two server ephemeral public keys found' seen = {} #type: Dict[str, int] From 37e13da3c2909e8333eb5a6d4665234d2c97ec7c Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 8 Apr 2026 16:51:22 +0200 Subject: [PATCH 7/7] Fix --list-tasks parsing Don't require the file and task arguments when `--list-tasks` is passed. Signed-off-by: Gilles Peskine --- scripts/validate_ssl_logs.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/validate_ssl_logs.py b/scripts/validate_ssl_logs.py index 007ee3269..06365b9b1 100755 --- a/scripts/validate_ssl_logs.py +++ b/scripts/validate_ssl_logs.py @@ -115,19 +115,29 @@ def main() -> int: """Command line entry point.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--list-tasks', + action='store_true', help='List available tasks and exit') - parser.add_argument('client_log', metavar='FILE', + parser.add_argument('client_log', metavar='CLIENT_LOG_FILE', + nargs='?', help='Client log file ($CLI_OUT or ?-cli-*.log)') - parser.add_argument('server_log', metavar='FILE', + parser.add_argument('server_log', metavar='SERVER_LOG_FILE', + nargs='?', help='Server log file ($SRV_OUT or ?-srv-*.log)') parser.add_argument('tasks', metavar='TASK', - nargs='+', #action='append', + nargs='*', help='Tasks to perform (use --list-tasks to see supported task names)') args = parser.parse_args() + if args.list_tasks: for task_name in sorted(TASKS.keys()): print(task_name) return 0 + + if args.client_log is None or args.server_log is None: + parser.error('the following arguments are required: CLIENT_LOG_FILE SERVER_LOG_FILE') + if not args.tasks: + parser.error('at least one TASK is required') + client_log = ssl_log_parser.parse_log_file(args.client_log) server_log = ssl_log_parser.parse_log_file(args.server_log) outcome = validate(client_log, server_log, args.tasks)