Skip to content

Commit 4432f84

Browse files
feat: Add self-signed certificate generation for nRF91x1 devices
1 parent 7f67455 commit 4432f84

4 files changed

Lines changed: 684 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ create_proxy_jwt = "nrfcloud_utils.create_proxy_jwt:run"
3737
device_credentials_installer = "nrfcloud_utils.device_credentials_installer:run"
3838
gather_attestation_tokens = "nrfcloud_utils.gather_attestation_tokens:run"
3939
modem_credentials_parser = "nrfcloud_utils.modem_credentials_parser:run"
40+
nrf91_gather_self_signed_certs = "nrfcloud_utils.nrf91_gather_self_signed_certs:run"
4041
nrf_cloud_device_mgmt = "nrfcloud_utils.nrf_cloud_device_mgmt:run"
4142
nrf_cloud_onboard = "nrfcloud_utils.nrf_cloud_onboard:run"
4243
nrf93_onboard = "nrfcloud_utils.nrf93_onboard:run"

src/nrfcloud_utils/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
device_credentials_installer,
1616
gather_attestation_tokens,
1717
modem_credentials_parser,
18+
nrf91_gather_self_signed_certs,
1819
nrf_cloud_device_mgmt,
1920
nrf_cloud_onboard,
2021
nrf93_onboard,
@@ -29,6 +30,7 @@
2930
"device_credentials_installer": device_credentials_installer,
3031
"gather_attestation_tokens": gather_attestation_tokens,
3132
"modem_credentials_parser": modem_credentials_parser,
33+
"nrf91_gather_self_signed_certs": nrf91_gather_self_signed_certs,
3234
"nrf_cloud_device_mgmt": nrf_cloud_device_mgmt,
3335
"nrf_cloud_onboard": nrf_cloud_onboard,
3436
"nrf93_onboard": nrf93_onboard,
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2026 Nordic Semiconductor ASA
4+
#
5+
# SPDX-License-Identifier: BSD-3-Clause
6+
7+
import argparse
8+
import csv
9+
import logging
10+
import os
11+
import sys
12+
import semver
13+
14+
from nrfcloud_utils.cli_helpers import (
15+
setup_logging,
16+
parser_add_comms_args,
17+
CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL,
18+
)
19+
from nrfcloud_utils.device_credentials_installer import parse_mfw_ver
20+
from nrfcredstore.command_interface import ATCommandInterface
21+
from nrfcredstore.comms import Comms
22+
23+
logger = logging.getLogger(__name__)
24+
25+
MIN_REQD_MFW_VER = "2.0.2"
26+
DEFAULT_SECTAG = 16842753
27+
CSV_HEADERS = ["deviceId", "selfSignedCertificateAttestation"]
28+
KEYGEN_TIMEOUT_S = 30
29+
30+
31+
def get_parser():
32+
parser = argparse.ArgumentParser(
33+
description="Generate a self-signed certificate on an nRF91x1 device "
34+
"and emit (deviceId, attestation) for nRF Cloud onboarding.",
35+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
36+
add_help=False,
37+
)
38+
parser_add_comms_args(parser)
39+
parser.add_argument("--csv", type=str, default="",
40+
help="Filepath to onboarding CSV file. "
41+
"If empty (default), only print to stdout.")
42+
parser.add_argument("-o", "--overwrite", action="store_true", default=False,
43+
help="When saving CSV, overwrite the file instead of appending")
44+
parser.add_argument("--keep", action="store_true", default=False,
45+
help="When appending: if device already exists in CSV, "
46+
"keep old data instead of replacing")
47+
parser.add_argument("--sectag", type=int, default=DEFAULT_SECTAG,
48+
help="Security tag to use for the self-signed certificate")
49+
parser.add_argument("-c", "--clear-sectag", action="store_true", default=False,
50+
help="Clear the existing certificate and key in the "
51+
"sectag before generating a new one. Required if "
52+
"the slot is already populated.")
53+
parser.add_argument("-P", "--plain", action="store_true", default=False,
54+
help="Plain output (no colors)")
55+
parser.add_argument("--log-level", default="info",
56+
choices=["debug", "info", "warning", "error", "critical"],
57+
help="Set the logging level")
58+
return parser
59+
60+
61+
def parse_args(in_args):
62+
_p = get_parser()
63+
parser = argparse.ArgumentParser(parents=[_p], description=_p.description,
64+
formatter_class=_p.formatter_class)
65+
args = parser.parse_args(in_args)
66+
setup_logging(level=args.log_level, use_color=not args.plain)
67+
return args
68+
69+
70+
def error_exit(msg, code=1):
71+
logger.error(msg)
72+
sys.exit(code)
73+
74+
75+
def check_mfw_version(cred_if):
76+
ver = cred_if.get_mfw_version()
77+
if not ver:
78+
error_exit("Failed to obtain modem firmware version")
79+
logger.info(f"Modem FW version: {ver}")
80+
81+
parsed = parse_mfw_ver(ver)
82+
if parsed is None:
83+
error_exit(f"Could not parse modem FW version from '{ver}'")
84+
if semver.Version.parse(parsed).compare(MIN_REQD_MFW_VER) < 0:
85+
error_exit(f"Modem FW version must be >= {MIN_REQD_MFW_VER}, got {parsed}")
86+
return ver
87+
88+
89+
def get_device_uuid(cred_if):
90+
if not cred_if.at_command("AT%DEVICEUUID", wait_for_result=False):
91+
return None
92+
ok, output = cred_if.comms.expect_response("OK", "ERROR", "%DEVICEUUID:")
93+
if not ok:
94+
return None
95+
for line in output.split("\n"):
96+
line = line.strip()
97+
if line.startswith("%DEVICEUUID:"):
98+
uuid_str = line.split(":", 1)[1].strip()
99+
if uuid_str:
100+
return uuid_str
101+
return None
102+
103+
104+
def gen_self_signed_cert(cred_if, sectag):
105+
cmd = f"AT%KEYGEN={sectag},14,2"
106+
if not cred_if.at_command(cmd, wait_for_result=False):
107+
return None
108+
ok, output = cred_if.comms.expect_response(
109+
"OK", "ERROR", "%KEYGEN:", timeout=KEYGEN_TIMEOUT_S
110+
)
111+
if not ok:
112+
return None
113+
for line in output.split("\n"):
114+
line = line.strip()
115+
if line.startswith("%KEYGEN:"):
116+
value = line.split(":", 1)[1].strip()
117+
return value.strip('"')
118+
return None
119+
120+
121+
def check_if_device_exists_in_csv(csv_filename, dev_id, delete_duplicates):
122+
row_count = 0
123+
duplicate_rows = []
124+
keep_rows = [] if delete_duplicates else None
125+
try:
126+
with open(csv_filename) as f:
127+
for row in csv.reader(f):
128+
if not row:
129+
continue
130+
if row[0] == CSV_HEADERS[0]:
131+
if delete_duplicates:
132+
keep_rows.append(row)
133+
continue
134+
row_count += 1
135+
if row[0] == dev_id:
136+
duplicate_rows.append(row)
137+
elif delete_duplicates:
138+
keep_rows.append(row)
139+
except OSError:
140+
logger.error(f"Error opening (read) file {csv_filename}")
141+
return duplicate_rows, row_count
142+
143+
if delete_duplicates and duplicate_rows:
144+
try:
145+
with open(csv_filename, "w", newline="\n") as f:
146+
w = csv.writer(f, delimiter=",", lineterminator="\n",
147+
quoting=csv.QUOTE_MINIMAL)
148+
w.writerows(keep_rows)
149+
except OSError:
150+
logger.error(f"Error opening (write) file {csv_filename}")
151+
152+
return duplicate_rows, row_count
153+
154+
155+
def user_request_open_mode(filename, append):
156+
mode = "a" if append else "w"
157+
exists = os.path.isfile(filename)
158+
if not append and exists:
159+
answer = " "
160+
while answer not in "yan":
161+
answer = input(
162+
f"--- File {filename} exists; overwrite, append, or quit (y,a,n)? "
163+
)
164+
if answer == "n":
165+
logger.info("File will not be overwritten")
166+
return None
167+
mode = "w" if answer == "y" else "a"
168+
elif not exists and append:
169+
mode = "w"
170+
logger.warning("Append specified but file does not exist...")
171+
return mode
172+
173+
174+
def save_csv(csv_filename, append, replace, dev_id, attestation):
175+
mode = user_request_open_mode(csv_filename, append)
176+
if mode is None:
177+
return
178+
179+
write_header = mode == "w" or not os.path.isfile(csv_filename)
180+
181+
if mode == "a" and not write_header:
182+
duplicate_rows, _ = check_if_device_exists_in_csv(csv_filename, dev_id, replace)
183+
if duplicate_rows:
184+
if replace:
185+
logger.warning(f"Removed existing row(s):\n\t{duplicate_rows}")
186+
else:
187+
logger.error(
188+
f"Device {dev_id} already exists in {csv_filename}; row NOT added"
189+
)
190+
return
191+
192+
try:
193+
with open(csv_filename, mode, newline="\n") as f:
194+
w = csv.writer(f, delimiter=",", lineterminator="\n",
195+
quoting=csv.QUOTE_MINIMAL)
196+
if write_header:
197+
w.writerow(CSV_HEADERS)
198+
w.writerow([dev_id, attestation])
199+
logger.info(f"CSV file {csv_filename} saved")
200+
except OSError:
201+
logger.error(f"Error opening file {csv_filename}")
202+
203+
204+
def main(in_args):
205+
args = parse_args(in_args)
206+
207+
if args.cmd_type not in (CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_AUTO):
208+
error_exit("Self-signed certificate generation requires AT command support")
209+
210+
serial_interface = Comms(
211+
port=args.port,
212+
serial=args.serial_number,
213+
baudrate=args.baud,
214+
xonxoff=args.xonxoff,
215+
rtscts=not args.rtscts_off,
216+
dsrdtr=args.dsrdtr,
217+
line_ending=CMD_TERM_DICT[args.term],
218+
list_all=args.all,
219+
rtt=args.rtt,
220+
)
221+
222+
cred_if = ATCommandInterface(serial_interface)
223+
if args.cmd_type == CMD_TYPE_AUTO:
224+
cred_if.detect_shell_mode()
225+
elif args.cmd_type == CMD_TYPE_AT_SHELL:
226+
cred_if.set_shell_mode(True)
227+
elif args.rtt:
228+
cred_if.write_raw("at at_cmd_mode start")
229+
230+
check_mfw_version(cred_if)
231+
232+
logger.info("Reading device UUID...")
233+
dev_id = get_device_uuid(cred_if)
234+
if not dev_id:
235+
error_exit("Failed to read device UUID")
236+
logger.info(f"Device UUID: {dev_id}")
237+
238+
logger.info("Switching modem to offline mode...")
239+
if not cred_if.go_offline():
240+
error_exit("Failed to switch modem to offline mode")
241+
242+
if args.clear_sectag:
243+
logger.info(f"Clearing existing credentials in sectag {args.sectag}...")
244+
cred_if.delete_credential(args.sectag, 1)
245+
cred_if.delete_credential(args.sectag, 2)
246+
247+
logger.info(f"Generating self-signed certificate (sectag {args.sectag})...")
248+
attestation = gen_self_signed_cert(cred_if, args.sectag)
249+
if not attestation:
250+
error_exit("Failed to generate self-signed certificate, use --clear-sectag if the slot is already occupied")
251+
252+
logger.info("Returning modem to online mode...")
253+
if not cred_if.at_command("AT+CFUN=1", wait_for_result=True):
254+
logger.warning("Failed to return modem to online mode")
255+
256+
print(f"{dev_id},{attestation}")
257+
258+
if args.csv:
259+
save_csv(args.csv, append=not args.overwrite, replace=not args.keep,
260+
dev_id=dev_id, attestation=attestation)
261+
262+
263+
def run():
264+
main(sys.argv[1:])
265+
266+
267+
if __name__ == "__main__":
268+
run()

0 commit comments

Comments
 (0)