|
6 | 6 | from nrfcloud_utils import device_credentials_installer |
7 | 7 | from tempfile import TemporaryDirectory |
8 | 8 | 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 |
10 | 17 |
|
11 | 18 | TEST_KEYGEN_UUID = [b"OK\r\n", b"%KEYGEN: \"MIIBCzCBrwIBADAvMS0wKwYDVQQDDCQ1MDM2Mzk1My0zMjM0LTQ3MjMtODBiOS0xNTAzZDg4MjcxYmYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ37lDghqs2kF2iiH8lYRDDxNMiziQRPPdw9Meb1iHfTEZNdlB1xZzMV-oK6i52p1GHYQszjoDzUAZF2zU2MTGGoB4wHAYJKoZIhvcNAQkOMQ8wDTALBgNVHQ8EBAMCA-gwDAYIKoZIzj0EAwIFAANJADBGAiEAzaMPi5NcWFYZBJGBMk0tU-TBoNDVlQUzhHWJzXKRTWsCIQCWYpYqjccA281F5Geb8SwOP3tnjS_ZbAXUgVWhTVNuvg.0oRDoQEmoQRBIVhM2dn3hQlQUDY5UzI0RyOAuRUD2IJxv0IYNFggTaa7Z9K-8bQPz3YG5o_h32quNr0FHEtnX5VpEZ-8gflQY8D67v4xx32mF0L3-mbuuVhAfY3TgibaimIVPaN1C3Sz_oWj6JPf8sEOV2XNBDUNCV3sD3WdNOjgv32-rLXAx_vBIvpk1DTCb3Y97zqFhhdKlw\"\r\n"] |
12 | 19 | 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): |
86 | 93 | assert fw_types == "APP|MODEM" |
87 | 94 | assert cert_pem.startswith("-----BEGIN CERTIFICATE-----") |
88 | 95 | 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