nrfcloud-utils 3.2.1__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/PKG-INFO +3 -3
  2. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/pyproject.toml +3 -3
  3. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/cli_helpers.py +14 -2
  4. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/device_credentials_installer.py +57 -11
  5. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/nrf93_onboard.py +3 -1
  6. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/nrf_cloud_device_mgmt.py +4 -3
  7. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/nrf_cloud_diap.py +2 -1
  8. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/nrf_cloud_onboard.py +9 -7
  9. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/LICENSE.txt +0 -0
  10. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/README.md +0 -0
  11. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/__init__.py +0 -0
  12. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/ca_certs.py +0 -0
  13. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/claim_and_provision_device.py +0 -0
  14. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/claim_devices.py +0 -0
  15. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/cli.py +0 -0
  16. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/create_ca_cert.py +0 -0
  17. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/create_device_credentials.py +0 -0
  18. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/create_proxy_jwt.py +0 -0
  19. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/gather_attestation_tokens.py +0 -0
  20. {nrfcloud_utils-3.2.1 → nrfcloud_utils-3.3.0}/src/nrfcloud_utils/modem_credentials_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nrfcloud-utils
3
- Version: 3.2.1
3
+ Version: 3.3.0
4
4
  Summary: Scripts and utilities for working with the nRF Cloud
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.txt
@@ -12,10 +12,10 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
- Requires-Dist: cbor2 (>=5.6.5)
15
+ Requires-Dist: cbor2 (>=5.9.0)
16
16
  Requires-Dist: cffi (>=2.0.0)
17
17
  Requires-Dist: cryptography (>=49.0.0)
18
- Requires-Dist: idna (>=3.10)
18
+ Requires-Dist: idna (>=3.15)
19
19
  Requires-Dist: inquirer (>=3.4.0)
20
20
  Requires-Dist: nrfcredstore (>=2.1.0)
21
21
  Requires-Dist: pyjwt (>=2.13.0)
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "nrfcloud-utils"
7
- version = "3.2.1"
7
+ version = "3.3.0"
8
8
  description = "Scripts and utilities for working with the nRF Cloud"
9
9
  license = "BSD-3-Clause"
10
10
  readme = "README.md"
@@ -13,11 +13,11 @@ authors = [
13
13
  { name = "Nordic Semiconductor ASA" }
14
14
  ]
15
15
  dependencies = [
16
- "cbor2>=5.6.5",
16
+ "cbor2>=5.9.0",
17
17
  "cryptography>=49.0.0",
18
18
  "cffi>=2.0.0",
19
19
  "requests>=2.34.2",
20
- "idna>=3.10",
20
+ "idna>=3.15",
21
21
  "urllib3>=2.7.0",
22
22
  "semver>=3.0.0",
23
23
  "pyjwt>=2.13.0",
@@ -6,6 +6,7 @@
6
6
 
7
7
  from os import path
8
8
  from os import makedirs
9
+ from importlib.metadata import version, PackageNotFoundError
9
10
  import platform
10
11
  import os
11
12
  import csv
@@ -21,6 +22,16 @@ full_encoding = 'mbcs' if is_windows else 'ascii'
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
25
+ try:
26
+ _package_version = version('nrfcloud-utils')
27
+ except PackageNotFoundError:
28
+ _package_version = 'unknown'
29
+
30
+ USER_AGENT = (
31
+ f'nrfcloud-utils/{_package_version} '
32
+ f'({platform.system()} {platform.release()}; Python {platform.python_version()})'
33
+ )
34
+
24
35
  def setup_logging(level='info', fmt='%(levelname)-8s %(message)s', use_color=True):
25
36
  """
26
37
  Configure colored logging with dark-terminal-friendly colors.
@@ -217,6 +228,7 @@ CMD_TERM_DICT = {'NULL': '\0',
217
228
  CMD_TYPE_AT = "at"
218
229
  CMD_TYPE_AT_SHELL = "at_shell"
219
230
  CMD_TYPE_TLS_SHELL = "tls_cred_shell"
231
+ CMD_TYPE_NRF_CLOUD_CRED_SHELL = "nrf_cloud_cred_shell"
220
232
  CMD_TYPE_AUTO = "auto"
221
233
 
222
234
  def parser_add_comms_args(parser):
@@ -244,8 +256,8 @@ def parser_add_comms_args(parser):
244
256
  parser.add_argument("--rtt",
245
257
  help="Use RTT instead of serial. Requires device run Modem Shell sample application configured with RTT overlay",
246
258
  action='store_true', default=False)
247
- parser.add_argument("--cmd-type", default=CMD_TYPE_AUTO, choices=[CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL], type=str.lower,
248
- help=f"Specify the device command line type. '{CMD_TYPE_AT}' will use AT commands, '{CMD_TYPE_AT_SHELL}' will prefix AT commands with 'at ', and '{CMD_TYPE_TLS_SHELL}' will use TLS Credentials Shell commands.")
259
+ parser.add_argument("--cmd-type", default=CMD_TYPE_AUTO, choices=[CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, CMD_TYPE_NRF_CLOUD_CRED_SHELL], type=str.lower,
260
+ help=f"Specify the device command line type. '{CMD_TYPE_AT}' will use AT commands, '{CMD_TYPE_AT_SHELL}' will prefix AT commands with 'at ', '{CMD_TYPE_TLS_SHELL}' will use TLS Credentials Shell commands, and '{CMD_TYPE_NRF_CLOUD_CRED_SHELL}' will use the nrf_cloud_cred shell commands to generate the device key and CSR on-device.")
249
261
  parser.add_argument("--term", type=str,
250
262
  help="AT command termination",choices=list(CMD_TERM_DICT.keys()),
251
263
  default='CRLF')
@@ -9,12 +9,13 @@ import re
9
9
  import os
10
10
  import sys
11
11
  import uuid
12
+ import base64
12
13
  import semver
13
14
  import logging
14
15
  from nrfcloud_utils import create_device_credentials, ca_certs, modem_credentials_parser
15
16
  from nrfcloud_utils.cli_helpers import write_file, save_devinfo_csv, save_onboarding_csv, full_encoding, setup_logging
16
- from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, parser_add_comms_args
17
- from nrfcredstore.command_interface import ATCommandInterface, TLSCredShellInterface
17
+ from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, CMD_TYPE_NRF_CLOUD_CRED_SHELL, parser_add_comms_args
18
+ from nrfcredstore.command_interface import ATCommandInterface, TLSCredShellInterface, NrfCloudCredShellInterface
18
19
  from nrfcredstore.comms import Comms
19
20
 
20
21
  from cryptography import x509
@@ -148,7 +149,9 @@ def check_mfw_version(cred_if):
148
149
  # check for required version
149
150
  parsed_ver = parse_mfw_ver(ver)
150
151
 
151
- if parsed_ver is None:
152
+ if "ntn" in ver:
153
+ pass
154
+ elif parsed_ver is None:
152
155
  logger.error('Unexpected modem FW version format... continuing')
153
156
  elif semver.Version.parse(parsed_ver).compare(MIN_REQD_MFW_VER) < 0:
154
157
  logger.error(f'Modem FW version must be >= {MIN_REQD_MFW_VER}')
@@ -172,8 +175,13 @@ def get_csr(cred_if, custom_dev_id = "", sectag = 0, local = False):
172
175
  logger.error('Failed to obtain CSR from device')
173
176
  sys.exit(9)
174
177
 
175
- csr_bytes, _, _, _ = modem_credentials_parser.parse_keygen_output(csr_blob)
176
- csr = x509.load_pem_x509_csr(csr_bytes)
178
+ if isinstance(cred_if, NrfCloudCredShellInterface):
179
+ # The nrf_cloud_cred shell returns a plain Base64-encoded DER CSR,
180
+ # not the modem's "body.cose" format.
181
+ csr = x509.load_der_x509_csr(base64.b64decode(csr_blob))
182
+ else:
183
+ csr_bytes, _, _, _ = modem_credentials_parser.parse_keygen_output(csr_blob)
184
+ csr = x509.load_pem_x509_csr(csr_bytes)
177
185
 
178
186
  return csr, local_priv_key
179
187
 
@@ -217,7 +225,14 @@ def main(in_args):
217
225
  logger.error(f"cmd_type '{CMD_TYPE_TLS_SHELL}' currently requires --local_cert or --local_cert_file")
218
226
  sys.exit(1)
219
227
 
220
- if args.cmd_type == CMD_TYPE_TLS_SHELL and id_len == 0:
228
+ if args.cmd_type == CMD_TYPE_NRF_CLOUD_CRED_SHELL and (args.local_cert or args.local_cert_file):
229
+ # The key and CSR are generated on-device; generating a key on the host
230
+ # would defeat the purpose and is almost certainly a mistake.
231
+ logger.error(f"cmd_type '{CMD_TYPE_NRF_CLOUD_CRED_SHELL}' generates the key on-device; "
232
+ "do not use --local-cert or --local-cert-file")
233
+ sys.exit(1)
234
+
235
+ if args.cmd_type in (CMD_TYPE_TLS_SHELL, CMD_TYPE_NRF_CLOUD_CRED_SHELL) and id_len == 0:
221
236
  args.id_str = str(uuid.uuid4())
222
237
 
223
238
  cmd_type_has_at = args.cmd_type in (CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_AUTO)
@@ -250,6 +265,10 @@ def main(in_args):
250
265
  cred_if = TLSCredShellInterface(serial_interface)
251
266
  has_shell = True
252
267
 
268
+ if args.cmd_type == CMD_TYPE_NRF_CLOUD_CRED_SHELL:
269
+ cred_if = NrfCloudCredShellInterface(serial_interface)
270
+ has_shell = True
271
+
253
272
  # prepare modem so we can interact with security keys
254
273
  if (cmd_type_has_at):
255
274
  logger.info('Disabling LTE and GNSS...')
@@ -416,10 +435,11 @@ def main(in_args):
416
435
 
417
436
  # AT-command-based SHA check has a modem firmware version requirement
418
437
  if (cmd_type_has_at):
419
- parsed_ver = parse_mfw_ver(mfw_ver)
420
- if parsed_ver and semver.Version.parse(parsed_ver).compare(MIN_REQD_MFW_VER_FOR_VERIFY) < 0:
421
- logger.error(f'Skipping SHA verification, modem FW version must be >= {MIN_REQD_MFW_VER_FOR_VERIFY}')
422
- check_sha = False
438
+ if "ntn" not in mfw_ver:
439
+ parsed_ver = parse_mfw_ver(mfw_ver)
440
+ if parsed_ver and semver.Version.parse(parsed_ver).compare(MIN_REQD_MFW_VER_FOR_VERIFY) < 0:
441
+ logger.error(f'Skipping SHA verification, modem FW version must be >= {MIN_REQD_MFW_VER_FOR_VERIFY}')
442
+ check_sha = False
423
443
 
424
444
  verify_res = verify_credentials(cred_if, args.sectag, nrf_ca_cert_text, dev_text, prv_text,
425
445
  check_sha=check_sha)
@@ -443,6 +463,27 @@ def main(in_args):
443
463
  if args.devinfo:
444
464
  save_devinfo_csv(args.devinfo, args.devinfo_append, args.replace, dev_id, mfw_ver, imei)
445
465
 
466
+ def verify_on_device_key(cred_if, sec_tag, client_cert):
467
+ # The on-device private key is non-exportable, so it cannot be verified by
468
+ # hash. Instead, confirm that the device public key matches the public key
469
+ # in the installed device certificate.
470
+ logger.info('Verifying Private Key')
471
+ dev_pub = cred_if.get_pubkey(sec_tag)
472
+ if not dev_pub:
473
+ logger.error('...Failed to read device public key')
474
+ return False
475
+
476
+ cert = x509.load_pem_x509_certificate(client_cert.encode())
477
+ cert_pub = cert.public_key().public_bytes(
478
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
479
+
480
+ if dev_pub != cert_pub:
481
+ logger.error('...Device public key does not match the device certificate')
482
+ return False
483
+
484
+ logger.info('...Private Key matches device certificate')
485
+ return True
486
+
446
487
  def verify_credentials(cred_if, sec_tag, ca_cert, client_cert, client_prv=None, check_sha=False):
447
488
  # verify the CA cert
448
489
  if not verify_credential(cred_if, sec_tag, 0, ca_cert, verify_hash = check_sha):
@@ -452,6 +493,11 @@ def verify_credentials(cred_if, sec_tag, ca_cert, client_cert, client_prv=None,
452
493
  if not verify_credential(cred_if, sec_tag, 1, client_cert, verify_hash = check_sha):
453
494
  return False
454
495
 
496
+ # The on-device key is opaque (held in PSA); verify it by matching the
497
+ # device public key to the certificate instead of by hash.
498
+ if isinstance(cred_if, NrfCloudCredShellInterface):
499
+ return verify_on_device_key(cred_if, sec_tag, client_cert)
500
+
455
501
  if not verify_credential(cred_if, sec_tag, 2, client_prv, get_hash = check_sha,
456
502
  verify_hash = (client_prv is not None) and check_sha):
457
503
  return False
@@ -493,7 +539,7 @@ def verify_credential(cred_if, sec_tag, cred_type, cred = None, get_hash = False
493
539
  logger.error(f'...Failed to calculate hash for {cred_type_name}')
494
540
  logger.debug(f'Error details: {e}')
495
541
  return False
496
-
542
+
497
543
  if hash != expected_hash:
498
544
  logger.error(f'{cred_type_name} - SHA mismatch:')
499
545
  logger.error(f'\tDevice : {hash}')
@@ -12,6 +12,7 @@ import requests
12
12
  import coloredlogs
13
13
  from nrfcredstore.comms import Comms
14
14
  from nrfcredstore.command_interface import ATCommandInterface
15
+ from nrfcloud_utils.cli_helpers import USER_AGENT
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -144,7 +145,7 @@ def set_dev_stage(stage = ''):
144
145
  return api_url
145
146
 
146
147
  def fetch_tenant_id(api_key):
147
- hdr = {'Authorization': 'Bearer ' + api_key}
148
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
148
149
  req = api_url + "account"
149
150
  response = requests.get(req, headers=hdr)
150
151
  if not response.ok:
@@ -192,6 +193,7 @@ def onboard_device(api_key, dev_id, sub_type, tags, fw_types, onboarding_token):
192
193
  hdr = {
193
194
  'Authorization': 'Bearer ' + api_key,
194
195
  'Accept': 'application/json',
196
+ 'User-Agent': USER_AGENT,
195
197
  }
196
198
 
197
199
  req = api_url + "devices/" + dev_id
@@ -12,6 +12,7 @@ import urllib
12
12
  from urllib.parse import quote_plus as url_encode
13
13
  from datetime import datetime
14
14
  from enum import Enum
15
+ from nrfcloud_utils.cli_helpers import USER_AGENT
15
16
 
16
17
  API_URL = "https://api.nrfcloud.com/v1/"
17
18
 
@@ -229,7 +230,7 @@ def parse_args(in_args):
229
230
 
230
231
  def get_bundle_list(api_key, modem_only):
231
232
 
232
- hdr = {'Authorization': 'Bearer ' + api_key}
233
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
233
234
  req_base = f'{GET_BUNDLES_BASE}?pageLimit={GET_BUNDLES_PAGE_LIMIT}'
234
235
 
235
236
  if modem_only:
@@ -273,7 +274,7 @@ def get_requested_bundles(api_key, fota_type):
273
274
  return [i for i in bundles if i.type == fota_type.name]
274
275
 
275
276
  def get_device_list(api_key, fota_types_list, device_id):
276
- hdr = {'Authorization': 'Bearer ' + api_key}
277
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
277
278
  req_base = GET_DEVICES_BASE + f'&pageLimit={GET_DEVICES_PAGE_LIMIT}'
278
279
 
279
280
  if fota_types_list:
@@ -321,7 +322,7 @@ def print_api_result(custom_text, api_result, print_response_txt):
321
322
 
322
323
  def create_fota_job(api_key, json_payload_obj):
323
324
  jobId = None
324
- hdr = {'Authorization': f'Bearer {api_key}'}
325
+ hdr = {'Authorization': f'Bearer {api_key}', 'User-Agent': USER_AGENT}
325
326
  req = FOTA_JOBS_BASE
326
327
 
327
328
  api_res = requests.post(req, json=json_payload_obj, headers=hdr)
@@ -7,6 +7,7 @@
7
7
  import requests
8
8
  import logging
9
9
  from nrfcloud_utils import ca_certs
10
+ from nrfcloud_utils.cli_helpers import USER_AGENT
10
11
 
11
12
  DEV_STAGE_DICT = {'dev': '.dev.',
12
13
  'beta': '.beta.',
@@ -44,7 +45,7 @@ def set_dev_stage(stage = ''):
44
45
  def get_auth_header(api_key):
45
46
  if not api_key:
46
47
  return None
47
- return { AUTH : BEARER + api_key}
48
+ return { AUTH : BEARER + api_key, 'User-Agent': USER_AGENT }
48
49
 
49
50
  def claim_device(api_key, claim_token, tags = None):
50
51
  global api_url
@@ -15,7 +15,7 @@ import io
15
15
  import logging
16
16
  from os import makedirs
17
17
  from enum import Enum
18
- from nrfcloud_utils.cli_helpers import write_file, setup_logging
18
+ from nrfcloud_utils.cli_helpers import write_file, setup_logging, USER_AGENT
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -105,22 +105,22 @@ def set_dev_stage(stage = ''):
105
105
  return api_url
106
106
 
107
107
  def get_bulk_ops_result(api_key, bulk_ops_req_id):
108
- hdr = {'Authorization': 'Bearer ' + api_key}
108
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
109
109
  req = api_url + "bulk-ops-requests/" + bulk_ops_req_id
110
110
  return requests.get(req, headers=hdr)
111
111
 
112
112
  def update_device_shadow(api_key, device_id, json_payload):
113
- hdr = {'Authorization': 'Bearer ' + api_key}
113
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
114
114
  req = api_url + "devices/" + device_id + "/state"
115
115
  return requests.patch(req, json=json_payload, headers=hdr)
116
116
 
117
117
  def fetch_device(api_key, device_id):
118
- hdr = {'Authorization': 'Bearer ' + api_key}
118
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
119
119
  req = api_url + "devices/" + device_id
120
120
  return requests.get(req, headers=hdr)
121
121
 
122
122
  def update_device_name(api_key, device_id, name):
123
- hdr = {'Authorization': 'Bearer ' + api_key}
123
+ hdr = {'Authorization': 'Bearer ' + api_key, 'User-Agent': USER_AGENT}
124
124
  req = api_url + "devices/" + device_id + "/name"
125
125
  json_payload = [name]
126
126
  return requests.put(req, json=json_payload, headers=hdr)
@@ -128,7 +128,8 @@ def update_device_name(api_key, device_id, name):
128
128
  def onboard_device(api_key, dev_id, sub_type, tags, fw_types, cert_pem_str):
129
129
  hdr = {'Authorization': 'Bearer ' + api_key,
130
130
  'content-type' : 'text/plain',
131
- 'Accept-Encoding' : '*'}
131
+ 'Accept-Encoding' : '*',
132
+ 'User-Agent': USER_AGENT}
132
133
 
133
134
  req = api_url + "devices"
134
135
 
@@ -139,7 +140,8 @@ def onboard_device(api_key, dev_id, sub_type, tags, fw_types, cert_pem_str):
139
140
  def onboard_devices(api_key, csv_filepath):
140
141
  hdr = {'Authorization': 'Bearer ' + api_key,
141
142
  'content-type' : 'text/csv',
142
- 'Accept-Encoding' : '*'}
143
+ 'Accept-Encoding' : '*',
144
+ 'User-Agent': USER_AGENT}
143
145
 
144
146
  req = api_url + "devices"
145
147
 
File without changes