Skip to content

Commit d1aa75c

Browse files
krish2718kartben
authored andcommitted
scripts: utils: Add a script to install TLS credentials
This helps install certificates to the TLS credentials store using TLS credentials shell. Signed-off-by: Chaitanya Tata <Chaitanya.Tata@nordicsemi.no>
1 parent a71cadf commit d1aa75c

File tree

1 file changed

+326
-0
lines changed

1 file changed

+326
-0
lines changed

scripts/utils/tls_creds_installer.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2025 Nordic Semiconductor ASA
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
"""
8+
This script is used to install TLS credentials on a device via a serial connection.
9+
It supports both deleting and writing credentials, as well as checking for their existence.
10+
It also verifies the hash of the installed credentials against the expected hash.
11+
12+
This script is based on https://github.com/nRFCloud/utils/, specifically
13+
"command_interface.py" and "device_credentials_installer.py".
14+
"""
15+
16+
import argparse
17+
import base64
18+
import hashlib
19+
import logging
20+
import math
21+
import os
22+
import sys
23+
import time
24+
25+
import serial
26+
27+
# Configure logging
28+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
29+
logger = logging.getLogger(__name__)
30+
31+
CMD_TERM_DICT = {'NULL': '\0', 'CR': '\r', 'LF': '\n', 'CRLF': '\r\n'}
32+
# 'CR' is the default termination value for the at_host library in the nRF Connect SDK
33+
cmd_term_key = 'CR'
34+
35+
TLS_CRED_TYPES = ["CA", "SERV", "PK"]
36+
TLS_CRED_CHUNK_SIZE = 48
37+
serial_timeout = 1
38+
ser = None
39+
40+
41+
class TLSCredShellInterface:
42+
def __init__(self, serial_write_line, serial_wait_for_response, verbose):
43+
self.serial_write_line = serial_write_line
44+
self.serial_wait_for_response = serial_wait_for_response
45+
self.verbose = verbose
46+
47+
def write_raw(self, command):
48+
if self.verbose:
49+
logger.debug(f'-> {command}')
50+
self.serial_write_line(command)
51+
52+
def write_credential(self, sectag, cred_type, cred_text):
53+
# Because the Zephyr shell does not support multi-line commands,
54+
# we must base-64 encode our PEM strings and install them as if they were binary.
55+
# Yes, this does mean we are base-64 encoding a string which is already mostly base-64.
56+
# We could alternatively strip the ===== BEGIN/END XXXX ===== header/footer, and then pass
57+
# everything else directly as a binary payload (using BIN mode instead of BINT, since
58+
# MBedTLS uses the NULL terminator to determine if the credential is raw DER, or is a
59+
# PEM string). But this will fail for multi-CA installs, such as CoAP.
60+
61+
# text -> bytes -> base64 bytes -> base64 text
62+
encoded = base64.b64encode(cred_text.encode()).decode()
63+
self.write_raw("cred buf clear")
64+
chunks = math.ceil(len(encoded) / TLS_CRED_CHUNK_SIZE)
65+
for c in range(chunks):
66+
chunk = encoded[c * TLS_CRED_CHUNK_SIZE : (c + 1) * TLS_CRED_CHUNK_SIZE]
67+
self.write_raw(f"cred buf {chunk}")
68+
self.serial_wait_for_response("Stored")
69+
self.write_raw(f"cred add {sectag} {TLS_CRED_TYPES[cred_type]} DEFAULT bint")
70+
result, _ = self.serial_wait_for_response("Added TLS credential")
71+
time.sleep(1)
72+
return result
73+
74+
def delete_credential(self, sectag, cred_type):
75+
self.write_raw(f'cred del {sectag} {TLS_CRED_TYPES[cred_type]}')
76+
result, _ = self.serial_wait_for_response(
77+
"Deleted TLS credential", "There is no TLS credential"
78+
)
79+
time.sleep(2)
80+
return result
81+
82+
def check_credential_exists(self, sectag, cred_type, get_hash=True):
83+
self.write_raw(f'cred list {sectag} {TLS_CRED_TYPES[cred_type]}')
84+
_, output = self.serial_wait_for_response(
85+
"1 credentials found.",
86+
"0 credentials found.",
87+
store=f"{sectag},{TLS_CRED_TYPES[cred_type]}",
88+
)
89+
90+
if not output:
91+
return False, None
92+
93+
if not get_hash:
94+
return True, None
95+
96+
data = output.decode().split(",")
97+
hash = data[2].strip()
98+
status_code = data[3].strip()
99+
100+
if status_code != "0":
101+
logger.warning(f"Error retrieving credential hash: {output.decode().strip()}.")
102+
logger.warning("Device might not support credential digests.")
103+
return True, None
104+
105+
return True, hash
106+
107+
def calculate_expected_hash(self, cred_text):
108+
hash = hashlib.sha256(cred_text.encode('utf-8') + b'\x00')
109+
return base64.b64encode(hash.digest()).decode()
110+
111+
def check_cred_command(self):
112+
logger.info("Checking for 'cred' command existence...")
113+
self.serial_write_line("cred")
114+
result, output = self.serial_wait_for_response(timeout=5)
115+
if not result or (output and b"command not found" in output):
116+
logger.error("Device does not support 'cred' command.")
117+
logger.error("Hint: Add 'CONFIG_TLS_CREDENTIALS_SHELL=y' to your prj.conf file.")
118+
return False
119+
logger.info("'cred' command found.")
120+
return True
121+
122+
123+
def write_line(line, hidden=False):
124+
if not hidden:
125+
logger.debug(f'-> {line}')
126+
ser.write(bytes((line + CMD_TERM_DICT[cmd_term_key]).encode('utf-8')))
127+
128+
129+
def wait_for_prompt(val1='uart:~$ ', val2=None, timeout=15, store=None):
130+
found = False
131+
retval = False
132+
output = None
133+
134+
if not ser:
135+
logger.error('Serial interface not initialized')
136+
return False, None
137+
138+
if isinstance(val1, str):
139+
val1 = val1.encode()
140+
141+
if isinstance(val2, str):
142+
val2 = val2.encode()
143+
144+
if isinstance(store, str):
145+
store = store.encode()
146+
147+
ser.flush()
148+
149+
while not found and timeout != 0:
150+
try:
151+
line = ser.readline()
152+
except serial.SerialException as e:
153+
logger.error(f"Error reading from serial interface: {e}")
154+
return False, None
155+
except Exception as e:
156+
logger.error(f"Unexpected error: {e}")
157+
return False, None
158+
159+
if line == b'\r\n':
160+
continue
161+
162+
if line is None or len(line) == 0:
163+
if timeout > 0:
164+
timeout -= serial_timeout
165+
continue
166+
167+
logger.debug(f'<- {line.decode("utf-8", errors="replace")}')
168+
169+
if val1 in line:
170+
found = True
171+
retval = True
172+
elif val2 is not None and val2 in line:
173+
found = True
174+
retval = False
175+
elif store is not None and (store in line or str(store) in str(line)):
176+
output = line
177+
178+
if b'\n' not in line:
179+
logger.debug('')
180+
181+
ser.flush()
182+
if store is not None and output is None:
183+
logger.error(f'String {store} not detected in line {line}')
184+
185+
if timeout == 0:
186+
logger.error('Serial timeout waiting for prompt')
187+
188+
return retval, output
189+
190+
191+
def parse_args(in_args):
192+
parser = argparse.ArgumentParser(
193+
description="Device Credentials Installer",
194+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
195+
allow_abbrev=False,
196+
)
197+
parser.add_argument(
198+
"-p", "--port", type=str, help="Specify which serial port to open", default="/dev/ttyACM1"
199+
)
200+
parser.add_argument(
201+
"-x",
202+
"--xonxoff",
203+
help="Enable software flow control for serial connection",
204+
action='store_true',
205+
default=False,
206+
)
207+
parser.add_argument(
208+
"-r",
209+
"--rtscts-off",
210+
help="Disable hardware (RTS/CTS) flow control for serial connection",
211+
action='store_true',
212+
default=False,
213+
)
214+
parser.add_argument(
215+
"-f",
216+
"--dsrdtr",
217+
help="Enable hardware (DSR/DTR) flow control for serial connection",
218+
action='store_true',
219+
default=False,
220+
)
221+
parser.add_argument(
222+
"-d", "--delete", help="Delete sectag from device first", action='store_true', default=False
223+
)
224+
parser.add_argument(
225+
"-l",
226+
"--local-cert-file",
227+
type=str,
228+
help="Filepath to a local certificate (PEM) to use for the device",
229+
required=True,
230+
)
231+
parser.add_argument(
232+
"-t", "--cert-type", type=int, help="Certificate type to use for the device", default=1
233+
)
234+
parser.add_argument(
235+
"-S", "--sectag", type=int, help="integer: Security tag to use", default=16842753
236+
)
237+
parser.add_argument(
238+
"-H",
239+
"--check-hash",
240+
help="Check hash of the credential after writing",
241+
action='store_true',
242+
default=False,
243+
)
244+
245+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
246+
args = parser.parse_args(in_args)
247+
return args
248+
249+
250+
def main(in_args):
251+
global ser
252+
253+
args = parse_args(in_args)
254+
255+
if args.verbose:
256+
logger.setLevel(logging.DEBUG)
257+
258+
if not os.path.isfile(args.local_cert_file):
259+
logger.error(f'Local certificate file {args.local_cert_file} does not exist')
260+
sys.exit(3)
261+
262+
logger.info(f'Opening port {args.port}')
263+
try:
264+
try:
265+
ser = serial.Serial(
266+
args.port,
267+
115200,
268+
xonxoff=args.xonxoff,
269+
rtscts=(not args.rtscts_off),
270+
dsrdtr=args.dsrdtr,
271+
timeout=serial_timeout,
272+
)
273+
ser.reset_input_buffer()
274+
ser.reset_output_buffer()
275+
except FileNotFoundError:
276+
logger.error(f'Specified port {args.port} does not exist or cannot be accessed')
277+
sys.exit(2)
278+
except serial.SerialException as e:
279+
logger.error(f'Failed to open serial port {args.port}: {e}')
280+
sys.exit(2)
281+
except serial.serialutil.SerialException:
282+
logger.error('Port could not be opened; not a device, or open already')
283+
sys.exit(2)
284+
285+
cred_if = TLSCredShellInterface(write_line, wait_for_prompt, args.verbose)
286+
cmd_exits = cred_if.check_cred_command()
287+
if not cmd_exits:
288+
sys.exit(1)
289+
290+
with open(args.local_cert_file) as f:
291+
dev_bytes = f.read()
292+
293+
if args.delete:
294+
logger.info(f'Deleting sectag {args.sectag}...')
295+
cred_if.delete_credential(args.sectag, args.cert_type)
296+
297+
cred_if.write_credential(args.sectag, args.cert_type, dev_bytes)
298+
logger.info(f'Writing sectag {args.sectag}...')
299+
result, hash = cred_if.check_credential_exists(args.sectag, args.cert_type, args.check_hash)
300+
if args.check_hash:
301+
logger.debug(f'Checking hash for sectag {args.sectag}...')
302+
if not result:
303+
logger.error(f'Failed to check credential existence for sectag {args.sectag}')
304+
sys.exit(4)
305+
if hash:
306+
logger.debug(f'Credential hash: {hash}')
307+
expected_hash = cred_if.calculate_expected_hash(dev_bytes)
308+
if hash != expected_hash:
309+
logger.error(
310+
f'Hash mismatch for sectag {args.sectag}. Expected: {expected_hash}, got: {hash}'
311+
)
312+
sys.exit(6)
313+
logger.info(f'Credential for sectag {args.sectag} written successfully')
314+
sys.exit(0)
315+
316+
317+
def run():
318+
try:
319+
main(sys.argv[1:])
320+
except KeyboardInterrupt:
321+
logger.info("Execution interrupted by user (Ctrl-C). Exiting...")
322+
sys.exit(1)
323+
324+
325+
if __name__ == '__main__':
326+
run()

0 commit comments

Comments
 (0)