Skip to content

Commit e947931

Browse files
PavelVPVclaude
authored andcommitted
tests: device_credentials_installer: cover nrf_cloud_cred_shell
Add unit tests for the nrf_cloud_cred_shell command type, modeled on the existing AT-based tests. A stateful fake shell device answers the nrf_cloud_cred keygen/csr/pubkey commands and the TLS Credentials Shell cred commands (reassembling the base64 chunks and returning the SHA-256 the installer expects), so the flow runs end-to-end against real crypto with no device. Covered: - on-device key/CSR generation: the Base64 DER CSR is decoded and the device certificate and onboarding CSV are produced, with no private key written to the device. - --verify: certificates checked by hash and the on-device key verified by matching the device public key to the device certificate. - verify failure when the device public key does not match the certificate (exit code 12). - rejection of --local-cert with this command type (exit code 1). Signed-off-by: Pavel Vasilyev <pavel.vasilyev@nordicsemi.no> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbf94db commit e947931

1 file changed

Lines changed: 189 additions & 1 deletion

File tree

tests/test_device_credentials_installer.py

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
from nrfcloud_utils import device_credentials_installer
77
from tempfile import TemporaryDirectory
88
import os
9-
from collections import namedtuple
9+
import base64
10+
import hashlib
11+
import pytest
12+
from collections import namedtuple, deque
13+
from cryptography import x509
14+
from cryptography.x509.oid import NameOID
15+
from cryptography.hazmat.primitives import hashes, serialization
16+
from cryptography.hazmat.primitives.asymmetric import ec
1017

1118
TEST_KEYGEN_UUID = [b"OK\r\n", b"%KEYGEN: \"MIIBCzCBrwIBADAvMS0wKwYDVQQDDCQ1MDM2Mzk1My0zMjM0LTQ3MjMtODBiOS0xNTAzZDg4MjcxYmYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ37lDghqs2kF2iiH8lYRDDxNMiziQRPPdw9Meb1iHfTEZNdlB1xZzMV-oK6i52p1GHYQszjoDzUAZF2zU2MTGGoB4wHAYJKoZIhvcNAQkOMQ8wDTALBgNVHQ8EBAMCA-gwDAYIKoZIzj0EAwIFAANJADBGAiEAzaMPi5NcWFYZBJGBMk0tU-TBoNDVlQUzhHWJzXKRTWsCIQCWYpYqjccA281F5Geb8SwOP3tnjS_ZbAXUgVWhTVNuvg.0oRDoQEmoQRBIVhM2dn3hQlQUDY5UzI0RyOAuRUD2IJxv0IYNFggTaa7Z9K-8bQPz3YG5o_h32quNr0FHEtnX5VpEZ-8gflQY8D67v4xx32mF0L3-mbuuVhAfY3TgibaimIVPaN1C3Sz_oWj6JPf8sEOV2XNBDUNCV3sD3WdNOjgv32-rLXAx_vBIvpk1DTCb3Y97zqFhhdKlw\"\r\n"]
1219
TEST_KEYGEN_IMEI = [b"OK\r\n", b"%KEYGEN: \"MIH4MIGeAgEAMB4xHDAaBgNVBAMME25yZi0zNTUwMjU5MzAwMDAwMDAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATYRrWI0v6LJWciuTlscw8E1TcOBJoygkALKecquhe-fr_QT_tcjC2DgmDyX1cjTubp7n_mUaf5ZJ1s-Ke-ABBuoB4wHAYJKoZIhvcNAQkOMQ8wDTALBgNVHQ8EBAMCA-gwDAYIKoZIzj0EAwIFAANHADBEAiBDTS-I_ye8nJpB7vtO6FQWvnUhJnG6QsjRPo56nBFh2gIgPB5NW16dANAMmn0VLMBXGeRPTtoxOs5Ld1Z7JW46_8s.0oRDoQEmoQRBIVhM2dn3hQlQUDQ5VjA3RziADR8p4KD3v0IYNFggl3ygk__l-pZ9jtlsf0AyuFJlRaaEouhzYgau2zOcHwBQp2eEbVMiHVcHrE9R2670dlhA1a7jaKq3ALRx2h-h2TIkxxh82oyr4c4LqzraUecL8SFek-IbvEBfv30695FKOt3FXEk7y5G_JW3yaRQGczL3TQ\"\r\n"]
@@ -86,3 +93,184 @@ def test_nrf_prefix(self, ser, select_device):
8693
assert fw_types == "APP|MODEM"
8794
assert cert_pem.startswith("-----BEGIN CERTIFICATE-----")
8895
assert cert_pem.endswith("-----END CERTIFICATE-----")
96+
97+
98+
# --- nrf_cloud_cred_shell command type ---------------------------------------
99+
#
100+
# This command type generates the device private key and CSR on-device via the
101+
# 'nrf_cloud_cred' shell commands; certificates are still written with the
102+
# inherited TLS Credentials Shell commands. The private key stays in PSA, so it
103+
# is verified by matching the device public key against the device certificate
104+
# instead of by hash.
105+
106+
NRF_CLOUD_CRED_CN = "test-device-id"
107+
NRF_CLOUD_CRED_SECTAG = 52
108+
109+
110+
def _make_device_key_and_csr(cn):
111+
"""Build an on-device-style EC P-256 key + CSR the way the device would.
112+
113+
Returns (base64 DER CSR, base64 SEC1-uncompressed public key). The CSR is
114+
what 'nrf_cloud_cred csr' prints; the public key is what
115+
'nrf_cloud_cred pubkey' prints. Because the device certificate is created
116+
from this CSR, its public key equals this key's public key, so the
117+
public-key verification matches.
118+
"""
119+
key = ec.generate_private_key(ec.SECP256R1())
120+
csr = (
121+
x509.CertificateSigningRequestBuilder()
122+
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]))
123+
.sign(key, hashes.SHA256())
124+
)
125+
csr_der_b64 = base64.b64encode(csr.public_bytes(serialization.Encoding.DER)).decode()
126+
pub_sec1 = key.public_key().public_bytes(
127+
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
128+
return csr_der_b64, base64.b64encode(pub_sec1).decode()
129+
130+
131+
DEV_CSR_DER_B64, DEV_PUBKEY_B64 = _make_device_key_and_csr(NRF_CLOUD_CRED_CN)
132+
133+
134+
class FakeShellSerial(Mock):
135+
"""A stateful fake device speaking the Zephyr shell protocol.
136+
137+
It answers the 'nrf_cloud_cred' keygen/csr/pubkey commands and the TLS
138+
Credentials Shell 'cred' commands. For 'cred add' it reassembles the
139+
base64 chunks, and for 'cred list' it returns the SHA-256 hash the
140+
installer expects, so --verify passes end-to-end against real crypto.
141+
"""
142+
def __init__(self, *args, **kwargs):
143+
super().__init__(*args, **kwargs)
144+
self._lines = deque()
145+
self._buf = "" # accumulated base64 chunks for the current credential
146+
self._hashes = {} # cred type string -> base64(sha256(cred + NUL))
147+
148+
def _queue(self, *lines):
149+
for line in lines:
150+
self._lines.append((line + "\r\n").encode())
151+
152+
def write(self, data):
153+
cmd = data.decode("utf-8", errors="replace").strip()
154+
parts = cmd.split()
155+
156+
if cmd.startswith("nrf_cloud_cred keygen"):
157+
self._queue(f"Generated device key in sec tag {parts[-1]}")
158+
elif cmd.startswith("nrf_cloud_cred csr"):
159+
self._queue(f"CSR: {DEV_CSR_DER_B64}", "CSR generation complete")
160+
elif cmd.startswith("nrf_cloud_cred pubkey"):
161+
self._queue(f"PUBKEY: {DEV_PUBKEY_B64}", "Public key export complete")
162+
elif cmd == "cred buf clear":
163+
self._buf = "" # no response expected after clear
164+
elif cmd.startswith("cred buf "):
165+
self._buf += cmd[len("cred buf "):]
166+
self._queue("Stored")
167+
elif cmd.startswith("cred add "):
168+
# cred add <sectag> <TYPE> DEFAULT bint
169+
cred_type = parts[3]
170+
raw = base64.b64decode(self._buf)
171+
self._hashes[cred_type] = base64.b64encode(
172+
hashlib.sha256(raw + b"\x00").digest()).decode()
173+
self._buf = ""
174+
self._queue("Added TLS credential")
175+
elif cmd.startswith("cred list "):
176+
# cred list <sectag> <TYPE>
177+
sectag, cred_type = parts[2], parts[3]
178+
self._queue(f"{sectag},{cred_type},{self._hashes.get(cred_type, '')},0",
179+
"1 credentials found.")
180+
# Any other command (including the empty line-ending probe) gets no
181+
# response, which is what the installer expects for those.
182+
183+
def readline(self):
184+
if self._lines:
185+
return self._lines.popleft()
186+
return b""
187+
188+
189+
FakeSerialPortShell = namedtuple("FakeSerialPortShell", ["device"])
190+
191+
192+
class TestNrfCloudCredShell:
193+
def _base_args(self, csv_file, extra=""):
194+
return (
195+
f"--port /not/a/real/device --log-level debug "
196+
f"--cmd-type nrf_cloud_cred_shell "
197+
f"--ca {TEST_CA_FILE} --ca-key {TEST_CA_KEY_FILE} "
198+
f"--csv {csv_file} --term CRLF --sectag {NRF_CLOUD_CRED_SECTAG} "
199+
f"--id-str {NRF_CLOUD_CRED_CN} {extra}"
200+
).split()
201+
202+
def _assert_onboard_csv(self, csv_file):
203+
assert os.path.exists(csv_file)
204+
with open(csv_file, "r") as f:
205+
content = f.read().strip()
206+
device_id, _sub_type, _tags, _fw_types, cert_pem = content.split(",")
207+
cert_pem = cert_pem.replace("\"", "").strip()
208+
# The device ID comes from the CN of the on-device CSR.
209+
assert device_id == NRF_CLOUD_CRED_CN
210+
assert cert_pem.startswith("-----BEGIN CERTIFICATE-----")
211+
assert cert_pem.endswith("-----END CERTIFICATE-----")
212+
213+
@patch("nrfcredstore.comms.select_device",
214+
return_value=(FakeSerialPortShell("/not/a/real/device"), "TEST_DEVICE"))
215+
@patch("nrfcredstore.comms.serial.Serial", return_value=FakeShellSerial())
216+
def test_on_device_keygen_and_csr(self, ser, select_device):
217+
# Generates the key/CSR on-device, decodes the Base64 DER CSR, creates
218+
# and writes the device cert, and writes the onboarding CSV. No private
219+
# key is written to the device.
220+
with TemporaryDirectory() as tmp_dir:
221+
csv_file = os.path.join(tmp_dir, "onboard.csv")
222+
device_credentials_installer.main(self._base_args(csv_file))
223+
self._assert_onboard_csv(csv_file)
224+
225+
@patch("nrfcredstore.comms.select_device",
226+
return_value=(FakeSerialPortShell("/not/a/real/device"), "TEST_DEVICE"))
227+
@patch("nrfcredstore.comms.serial.Serial", return_value=FakeShellSerial())
228+
def test_verify_by_public_key_match(self, ser, select_device):
229+
# With --verify, the certs are checked by hash and the on-device key is
230+
# verified by matching the device public key to the device certificate.
231+
# Reaching the CSV write means verification passed (a mismatch would
232+
# exit with code 12).
233+
with TemporaryDirectory() as tmp_dir:
234+
csv_file = os.path.join(tmp_dir, "onboard.csv")
235+
device_credentials_installer.main(self._base_args(csv_file, "--verify"))
236+
self._assert_onboard_csv(csv_file)
237+
238+
@patch("nrfcredstore.comms.select_device",
239+
return_value=(FakeSerialPortShell("/not/a/real/device"), "TEST_DEVICE"))
240+
@patch("nrfcredstore.comms.serial.Serial", return_value=FakeShellSerial())
241+
def test_verify_fails_on_public_key_mismatch(self, ser, select_device):
242+
# If the device reports a public key that does not match the device
243+
# certificate, verification must fail (exit code 12).
244+
other_key = ec.generate_private_key(ec.SECP256R1())
245+
wrong_pub = base64.b64encode(other_key.public_key().public_bytes(
246+
serialization.Encoding.X962,
247+
serialization.PublicFormat.UncompressedPoint)).decode()
248+
249+
with TemporaryDirectory() as tmp_dir:
250+
csv_file = os.path.join(tmp_dir, "onboard.csv")
251+
fake = ser.return_value
252+
orig_write = fake.write
253+
254+
def write_wrong_pubkey(data):
255+
cmd = data.decode("utf-8", errors="replace").strip()
256+
if cmd.startswith("nrf_cloud_cred pubkey"):
257+
fake._queue(f"PUBKEY: {wrong_pub}", "Public key export complete")
258+
return
259+
orig_write(data)
260+
261+
fake.write = write_wrong_pubkey
262+
with pytest.raises(SystemExit) as e:
263+
device_credentials_installer.main(self._base_args(csv_file, "--verify"))
264+
assert e.value.code == 12
265+
266+
def test_rejects_local_cert(self):
267+
# The key is generated on-device, so --local-cert is a mistake and must
268+
# be rejected before any device interaction.
269+
args = (
270+
f"--port /not/a/real/device --cmd-type nrf_cloud_cred_shell "
271+
f"--local-cert --ca {TEST_CA_FILE} --ca-key {TEST_CA_KEY_FILE} "
272+
f"--sectag {NRF_CLOUD_CRED_SECTAG}"
273+
).split()
274+
with pytest.raises(SystemExit) as e:
275+
device_credentials_installer.main(args)
276+
assert e.value.code == 1

0 commit comments

Comments
 (0)