From: Rogerio Guerra Borin <[email protected]> The three TI K3 certificate helpers invoke 'openssl req -new -x509' without setting a serial number or validity period. OpenSSL chooses a random 159-bit serial and a validity period based on the current time, so otherwise identical builds produce different tiboot3.bin, tispl.bin and u-boot.img files.
This prevents a re-signed boot chain from being checked with a byte-for-byte comparison. It also leaves these images outside the reproducible-build support described in doc/build/reproducible.rst, even though their FIT wrappers already honour SOURCE_DATE_EPOCH. Here we derive the serial from the hash of the signed data, keeping it at 159 bits so the encoded certificate does not change size. When SOURCE_DATE_EPOCH is present, use it for notBefore and set notAfter to 20491231235959Z. The K3 boot chain does not enforce the validity values, but the ROM cannot parse the GeneralizedTime encoding required for dates in 2050 or later. An AM62 HS-FS device does not boot with a 9999 or 2050 expiry but boots with a 2048 or 2049 expiry. TI reports the same limitation on AM64x [1]. The validity arguments require OpenSSL 3.4 or later. Older versions keep the wall-clock validity and emit a warning, while still using the deterministic serial supported by all relevant OpenSSL versions. Keep the generic x509_cert helper unchanged. Its validity policy is outside this K3-specific constraint, and it has a separate reproducibility issue because its Common Name contains the output path. The full binman suite passes with OpenSSL 3.5.7 and reports 100% coverage. The OpenSSL bintool also reaches 100% under OpenSSL 3.0.20; the reproducibility test skips there because the validity arguments are not available. [1] https://e2e.ti.com/support/processors-group/processors/f/processors-forum/1491915/am6412-ti-supports-for-tiboot3-certificates-with-validity-beyond-2049 Signed-off-by: Rogerio Guerra Borin <[email protected]> --- tools/binman/btool/openssl.py | 71 +++++++++++++++++++++++----- tools/binman/ftest.py | 87 +++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/tools/binman/btool/openssl.py b/tools/binman/btool/openssl.py index b26f087c447..f360864da6d 100644 --- a/tools/binman/btool/openssl.py +++ b/tools/binman/btool/openssl.py @@ -11,9 +11,12 @@ Source code is at https://www.openssl.org/ """ import hashlib +import os +import time from binman import bintool from u_boot_pylib import tools +from u_boot_pylib import tout VALID_SHAS = [256, 384, 512, 224] @@ -22,6 +25,12 @@ SHA_OIDS = {256:'2.16.840.1.101.3.4.2.1', 512:'2.16.840.1.101.3.4.2.3', 224:'2.16.840.1.101.3.4.2.4'} +# TI K3 ROMs reject GeneralizedTime, which OpenSSL uses from 2050 onwards. +TI_K3_MAX_NOT_AFTER = '20491231235959Z' + +# 'openssl req' learned -not_before and -not_after in OpenSSL 3.4 +MIN_VALIDITY_VERSION = (3, 4) + class Bintoolopenssl(bintool.Bintool): """openssl tool @@ -35,6 +44,52 @@ class Bintoolopenssl(bintool.Bintool): super().__init__( name, 'openssl cryptography toolkit', version_regex=r'OpenSSL (.*) \(', version_args='version') + self._version_tuple = None + + def version_tuple(self): + """Get the openssl version as a tuple of ints, for comparison + + Returns: + tuple of int: (major, minor), or () if the version cannot be parsed + """ + if self._version_tuple is None: + # version() returns something like '3.4.1 11 Feb 2025' + text = self.version().split()[0] + try: + self._version_tuple = tuple( + int(part) for part in text.split('.')[:2]) + except ValueError: + self._version_tuple = () + return self._version_tuple + + def _req_args(self, cert_fname, key_fname, config_fname): + """Get the common 'openssl req' certificate arguments""" + return ['req', '-new', '-x509', '-key', key_fname, '-nodes', + '-outform', 'DER', '-out', cert_fname, '-config', config_fname, + '-sha512'] + + def _ti_req_args(self, cert_fname, key_fname, config_fname, hashval): + """Get reproducible arguments for TI K3 certificates""" + # Keep the serial number to 159 bits, matching the width and the sign + # of the one OpenSSL generates, so the certificate size does not shift. + serial = int(hashval[:40], 16) & ((1 << 159) - 1) + args = self._req_args(cert_fname, key_fname, config_fname) + args += ['-set_serial', str(serial)] + + epoch = os.environ.get('SOURCE_DATE_EPOCH') + if epoch is not None: + if self.version_tuple() >= MIN_VALIDITY_VERSION: + not_before = time.strftime('%Y%m%d%H%M%SZ', + time.gmtime(int(epoch))) + args += ['-not_before', not_before, '-not_after', + TI_K3_MAX_NOT_AFTER] + else: + tout.warning( + f"openssl '{self.version()}' cannot set a certificate " + f'validity period, which needs ' + f"{'.'.join(str(v) for v in MIN_VALIDITY_VERSION)} or " + f'later; TI certificates will not be reproducible') + return args def x509_cert(self, cert_fname, input_fname, key_fname, cn, revision, config_fname): @@ -76,9 +131,7 @@ shaType = OID:2.16.840.1.101.3.4.2.3 shaValue = FORMAT:HEX,OCT:{hashval} imageSize = INTEGER:{len(indata)} ''', file=outf) - args = ['req', '-new', '-x509', '-key', key_fname, '-nodes', - '-outform', 'DER', '-out', cert_fname, '-config', config_fname, - '-sha512'] + args = self._req_args(cert_fname, key_fname, config_fname) return self.run_cmd(*args) def x509_cert_sysfw(self, cert_fname, input_fname, key_fname, sw_rev, @@ -146,9 +199,7 @@ authInPlace = INTEGER:{hex(firewall_cert_data['auth_in_place'])} numFirewallRegions = INTEGER:{firewall_cert_data['num_firewalls']} {firewall_cert_data['certificate']} ''', file=outf) - args = ['req', '-new', '-x509', '-key', key_fname, '-nodes', - '-outform', 'DER', '-out', cert_fname, '-config', config_fname, - '-sha512'] + args = self._ti_req_args(cert_fname, key_fname, config_fname, hashval) return self.run_cmd(*args) def x509_cert_rom(self, cert_fname, input_fname, key_fname, sw_rev, @@ -231,9 +282,7 @@ emailAddress = {req_dist_name_dict['emailAddress']} coreDbgEn = INTEGER:0 coreDbgSecEn = INTEGER:0 ''', file=outf) - args = ['req', '-new', '-x509', '-key', key_fname, '-nodes', - '-outform', 'DER', '-out', cert_fname, '-config', config_fname, - '-sha512'] + args = self._ti_req_args(cert_fname, key_fname, config_fname, hashval) return self.run_cmd(*args) def x509_cert_rom_combined(self, cert_fname, input_fname, key_fname, sw_rev, @@ -342,9 +391,7 @@ coreDbgSecEn = INTEGER:0 {dm_data_ext_boot_block} ''', file=outf) - args = ['req', '-new', '-x509', '-key', key_fname, '-nodes', - '-outform', 'DER', '-out', cert_fname, '-config', config_fname, - '-sha512'] + args = self._ti_req_args(cert_fname, key_fname, config_fname, hashval) return self.run_cmd(*args) def fetch(self, method): diff --git a/tools/binman/ftest.py b/tools/binman/ftest.py index a0e8bde7901..7e4c84e2976 100644 --- a/tools/binman/ftest.py +++ b/tools/binman/ftest.py @@ -33,6 +33,7 @@ from binman import fmap_util from binman import state from dtoc import fdt from dtoc import fdt_util +from binman.btool import openssl as openssl_btool from binman.etype import fdtmap from binman.etype import image_header from binman.image import Image @@ -7296,6 +7297,92 @@ fdt fdtmap Extract the devicetree blob from the fdtmap entry_args=entry_args)[0] self.assertGreater(len(data), len(TI_UNSECURE_DATA)) + def testPackTiSecureReproducible(self): + """Test that TI secured binaries are reproducible + + With SOURCE_DATE_EPOCH set, building the same image twice must produce + the same certificate, and therefore the same image. This covers all + three of the certificate types which sign a payload. + """ + openssl = bintool.Bintool.create('openssl') + if not openssl.is_present(): + self.skipTest('openssl not available') + if openssl.version_tuple() < openssl_btool.MIN_VALIDITY_VERSION: + self.skipTest('openssl cannot set a certificate validity period') + + keyfile = self.TestFile('security/key.key') + entry_args = { + 'keyfile': keyfile, + } + old_epoch = os.environ.get('SOURCE_DATE_EPOCH') + os.environ['SOURCE_DATE_EPOCH'] = '1234567890' + try: + for dts in ['vendor/ti_secure.dts', 'vendor/ti_secure_rom.dts', + 'vendor/ti_secure_rom_combined.dts']: + first = self._DoReadFileDtb(dts, entry_args=entry_args)[0] + second = self._DoReadFileDtb(dts, entry_args=entry_args)[0] + self.assertEqual(first, second, f'{dts} is not reproducible') + finally: + if old_epoch is None: + del os.environ['SOURCE_DATE_EPOCH'] + else: + os.environ['SOURCE_DATE_EPOCH'] = old_epoch + + def testOpenSSLVersionTuple(self): + """Test parsing and caching of the openssl version""" + openssl = bintool.Bintool.create('openssl') + with unittest.mock.patch.object( + openssl, 'version', return_value='3.5.7 9 Jun 2026') as version: + self.assertEqual((3, 5), openssl.version_tuple()) + self.assertEqual((3, 5), openssl.version_tuple()) + version.assert_called_once_with() + + openssl = bintool.Bintool.create('openssl') + with unittest.mock.patch.object(openssl, 'version', + return_value='unknown'): + self.assertEqual((), openssl.version_tuple()) + + def testOpenSSLReproducibleArgs(self): + """Test openssl arguments for reproducible certificates""" + openssl = bintool.Bintool.create('openssl') + hashval = 'f' * 128 + env = {'SOURCE_DATE_EPOCH': '1234567890'} + + self.assertEqual( + ['req', '-new', '-x509', '-key', 'key', '-nodes', '-outform', + 'DER', '-out', 'cert', '-config', 'config', '-sha512'], + openssl._req_args('cert', 'key', 'config')) + + with unittest.mock.patch.dict(os.environ, {}, clear=True): + with unittest.mock.patch.object(openssl, + 'version_tuple') as version: + args = openssl._ti_req_args('cert', 'key', 'config', hashval) + version.assert_not_called() + self.assertNotIn('-not_before', args) + + with unittest.mock.patch.dict(os.environ, env): + with unittest.mock.patch.object(openssl, 'version_tuple', + return_value=(3, 4)): + args = openssl._ti_req_args('cert', 'key', 'config', hashval) + self.assertEqual(str((1 << 159) - 1), + args[args.index('-set_serial') + 1]) + self.assertEqual(['-not_before', '20090213233130Z', '-not_after', + openssl_btool.TI_K3_MAX_NOT_AFTER], args[-4:]) + + with unittest.mock.patch.dict(os.environ, env): + with unittest.mock.patch.object(openssl, 'version_tuple', + return_value=(3, 3)): + with unittest.mock.patch.object(openssl, 'version', + return_value='3.3.0'): + with unittest.mock.patch.object( + openssl_btool.tout, 'warning') as warning: + args = openssl._ti_req_args('cert', 'key', 'config', + hashval) + self.assertNotIn('-not_before', args) + warning.assert_called_once() + self.assertIn('certificates will not be reproducible', + warning.call_args.args[0]) + def testEncryptedNoAlgo(self): """Test encrypted node with missing required properties""" with self.assertRaises(ValueError) as e: -- 2.43.0
