ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
wifi_provision.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Emit one runtime Wi-Fi provisioning packet to a non-terminal stdout.
5
6The packet is consumed by the bench-only firmware provisioner after flashing;
7credentials never enter CMake, compiler arguments, build metadata, or firmware
8artifacts. Values come from explicitly exported variables, the gitignored
90600 ``coprocessor/esp32c6/wifi.env`` file, or the existing OpenBao service.
10"""
11
12from __future__ import annotations
13
14import argparse
15import contextlib
16import io
17import os
18import signal
19import stat
20import string
21import sys
22import tempfile
23from collections.abc import Callable, Iterator
24from pathlib import Path
25from unittest import mock
26
27SCRIPT_DIR = Path(__file__).resolve().parent
28REPO_ROOT = SCRIPT_DIR.parents[1]
29DEFAULT_ENV = REPO_ROOT / "coprocessor" / "esp32c6" / "wifi.env"
30WIFI_KEYS = frozenset(("RA8_C6_WIFI_SSID", "RA8_C6_WIFI_PSK"))
31URL_KEY = "RA8_MEDIA_DOWNLOAD_URL"
32MIN_PSK_BYTES = 8
33MAX_PSK_BYTES = 63
34HEX_PSK_CHARS = 64
35MAX_SSID_BYTES = 32
36MAX_URL_BYTES = 511
37MIN_QUOTED_VALUE_LENGTH = 2
38ASCII_CONTROL_MAX = 0x20
39ASCII_DELETE = 0x7F
40ERROR_EXIT = 2
41DEFAULT_EMIT_TIMEOUT_S = 30
42MAX_EMIT_TIMEOUT_S = 600
43
44sys.path.insert(0, str(SCRIPT_DIR))
45
46from openbao_client import ( # noqa: E402 -- sibling path added above
47 OpenBaoClient,
48 OpenBaoError,
49 creds_path,
50)
51
52
53class ProvisionError(ValueError):
54 """A credential source or effective runtime value is invalid."""
55
56
57def _timeout_seconds(raw: str) -> int:
58 """Parse one bounded whole-second total emit deadline for argparse."""
59 try:
60 timeout_s = int(raw, 10)
61 except ValueError as exc:
62 message = "timeout must be a whole number of seconds"
63 raise argparse.ArgumentTypeError(message) from exc
64 if not 1 <= timeout_s <= MAX_EMIT_TIMEOUT_S:
65 message = f"timeout must be in 1..{MAX_EMIT_TIMEOUT_S} seconds"
66 raise argparse.ArgumentTypeError(message)
67 return timeout_s
68
69
70@contextlib.contextmanager
71def _emit_deadline(timeout_s: int) -> Iterator[None]:
72 """Bound the complete credential resolution, encoding, and stdout write."""
73
74 def timed_out(_signum: int, _frame: object) -> None:
75 message = f"credential emission exceeded its {timeout_s}-second deadline"
76 raise ProvisionError(message)
77
78 previous_handler = signal.signal(signal.SIGALRM, timed_out)
79 previous_timer = signal.setitimer(signal.ITIMER_REAL, float(timeout_s))
80 try:
81 yield
82 finally:
83 signal.setitimer(signal.ITIMER_REAL, 0.0)
84 signal.signal(signal.SIGALRM, previous_handler)
85 if previous_timer[0] > 0.0:
86 signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1])
87
88
89def _read_private_file(path: Path) -> str:
90 """Open a regular, non-symlink credential file once and return its text."""
91 try:
92 before = path.lstat()
93 except FileNotFoundError:
94 raise
95 except OSError as exc:
96 message = f"cannot inspect {path}: {exc.strerror}"
97 raise ProvisionError(message) from exc
98 if stat.S_ISLNK(before.st_mode):
99 message = f"credential file must not be a symlink: {path}"
100 raise ProvisionError(message)
101
102 try:
103 flags = os.O_RDONLY
104 flags |= getattr(os, "O_CLOEXEC", 0)
105 flags |= getattr(os, "O_NOFOLLOW", 0)
106 descriptor = os.open(path, flags)
107 except FileNotFoundError:
108 raise
109 except OSError as exc:
110 message = f"cannot open {path}: {exc.strerror}"
111 raise ProvisionError(message) from exc
112
113 try:
114 opened = os.fstat(descriptor)
115 if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
116 message = f"credential file changed while opening: {path}"
117 raise ProvisionError(message)
118 if not stat.S_ISREG(opened.st_mode):
119 message = f"credential file must be a regular file: {path}"
120 raise ProvisionError(message)
121 mode = stat.S_IMODE(opened.st_mode)
122 if mode & 0o077:
123 message = f"credential file must grant no group/other permissions: {path}"
124 raise ProvisionError(message)
125 with os.fdopen(descriptor, encoding="utf-8") as stream:
126 descriptor = -1
127 return stream.read()
128 except UnicodeDecodeError as exc:
129 message = f"credential file is not valid UTF-8: {path}"
130 raise ProvisionError(message) from exc
131 except OSError as exc:
132 message = f"cannot read {path}: {exc.strerror}"
133 raise ProvisionError(message) from exc
134 finally:
135 if descriptor >= 0:
136 os.close(descriptor)
137
138
139def _openbao_config(path: Path) -> dict[str, str]:
140 """Read the operator's OpenBao configuration through the private-file gate."""
141 config: dict[str, str] = {}
142 for raw_line in _read_private_file(path).splitlines():
143 line = raw_line.strip()
144 if not line or line.startswith("#") or "=" not in line:
145 continue
146 key, value = line.split("=", 1)
147 config[key.strip()] = value.strip()
148 return config
149
150
151def _literal_value(raw: str, path: Path, line_no: int) -> str:
152 """Parse one dotenv value as data without evaluating shell syntax."""
153 value = raw.strip()
154 if not value:
155 return ""
156 if value[0] in ("'", '"'):
157 quote = value[0]
158 if len(value) < MIN_QUOTED_VALUE_LENGTH or value[-1] != quote:
159 message = f"{path}:{line_no}: unmatched credential quote"
160 raise ProvisionError(message)
161 value = value[1:-1]
162 if quote in value:
163 message = f"{path}:{line_no}: embedded credential quote is unsupported"
164 raise ProvisionError(message)
165 elif any(char.isspace() for char in value):
166 message = f"{path}:{line_no}: quote values containing whitespace"
167 raise ProvisionError(message)
168 return value
169
170
171def parse_env_file(path: Path) -> dict[str, str]:
172 """Parse the optional private Wi-Fi dotenv file without shell evaluation."""
173 try:
174 lines = _read_private_file(path).splitlines()
175 except FileNotFoundError:
176 return {}
177
178 parsed: dict[str, str] = {}
179 for line_no, raw_line in enumerate(lines, start=1):
180 line = raw_line.strip()
181 if not line or line.startswith("#"):
182 continue
183 key, separator, raw_value = line.partition("=")
184 if not separator or key.strip() != key or key not in WIFI_KEYS:
185 message = f"{path}:{line_no}: expected a supported KEY=value assignment"
186 raise ProvisionError(message)
187 if key in parsed:
188 message = f"{path}:{line_no}: duplicate assignment for {key}"
189 raise ProvisionError(message)
190 parsed[key] = _literal_value(raw_value, path, line_no)
191 return parsed
192
193
194def validate_values(ssid: str, psk: str, url: str) -> None:
195 """Validate protocol and storage bounds before emitting any bytes."""
196 ssid_bytes = ssid.encode("utf-8")
197 psk_bytes = psk.encode("utf-8")
198 url_bytes = url.encode("utf-8")
199 if not ssid or len(ssid_bytes) > MAX_SSID_BYTES:
200 message = "Wi-Fi SSID must contain 1-32 UTF-8 bytes"
201 raise ProvisionError(message)
202 psk_is_passphrase = MIN_PSK_BYTES <= len(psk_bytes) <= MAX_PSK_BYTES
203 psk_is_hex = len(psk) == HEX_PSK_CHARS and all(char in string.hexdigits for char in psk)
204 if not (psk_is_passphrase or psk_is_hex):
205 message = "Wi-Fi PSK must contain 8-63 UTF-8 bytes or 64 hexadecimal digits"
206 raise ProvisionError(message)
207 if len(url_bytes) > MAX_URL_BYTES:
208 message = "media URL exceeds the 511-byte runtime protocol bound"
209 raise ProvisionError(message)
210 if any(ord(char) < ASCII_CONTROL_MAX or ord(char) == ASCII_DELETE for char in ssid + psk + url):
211 message = "runtime provisioning values may not contain control characters"
212 raise ProvisionError(message)
213
214
215def resolve_values(
216 env_path: Path = DEFAULT_ENV,
217 environment: dict[str, str] | None = None,
218 vault_data: dict[str, str] | None = None,
219) -> tuple[str, str, str]:
220 """Resolve explicit/file values first, then the bench network in OpenBao."""
221 effective_env = os.environ if environment is None else environment
222 local = parse_env_file(env_path)
223 ssid = effective_env.get("RA8_C6_WIFI_SSID", local.get("RA8_C6_WIFI_SSID", ""))
224 psk = effective_env.get("RA8_C6_WIFI_PSK", local.get("RA8_C6_WIFI_PSK", ""))
225 url = effective_env.get(URL_KEY, "")
226 if not psk:
227 if vault_data is None:
228 openbao_env = creds_path()
229 try:
230 client = OpenBaoClient(_openbao_config(openbao_env))
231 except FileNotFoundError:
232 client = OpenBaoClient()
233 if not client.configured:
234 message = "Wi-Fi credentials are absent and OpenBao is not configured"
235 raise ProvisionError(message)
236 try:
237 vault_data = client.kv_get("ra8d2/bench-network")
238 except OpenBaoError as exc:
239 message = "could not fetch the bench network from OpenBao"
240 raise ProvisionError(message) from exc
241 psk = vault_data.get("bench_psk", "")
242 ssid = ssid or vault_data.get("bench_ssid", "") or "ra8-bench"
243 validate_values(ssid, psk, url)
244 return ssid, psk, url
245
246
247def make_packet(ssid: str, psk: str, url: str = "") -> bytes:
248 """Encode one newline-terminated RA8NET1 packet without raw delimiters."""
249 validate_values(ssid, psk, url)
250 fields = (ssid.encode("utf-8").hex(), psk.encode("utf-8").hex(), url.encode("utf-8").hex())
251 return f"RA8NET1:{fields[0]}:{fields[1]}:{fields[2]}\n".encode("ascii")
252
253
254def _values_are_valid(values: tuple[str, str, str]) -> bool:
255 """Return whether runtime values can be encoded."""
256 try:
257 make_packet(*values)
258 except ProvisionError:
259 return False
260 return True
261
262
263class _SelftestState:
264 """Accumulate selftest checks and failures without nested closures."""
265
266 def __init__(self) -> None:
267 self.failures: list[str] = []
268 self.checks = 0
269
270 def expect(self, condition: bool, message: str) -> None:
271 """Record one Boolean selftest expectation."""
272 self.checks += 1
273 if not condition:
274 self.failures.append(message)
275
276 def expect_rejected(self, operation: Callable[[], object], message: str) -> None:
277 """Record that one operation raises the provisioning error."""
278 self.checks += 1
279 try:
280 operation()
281 except ProvisionError:
282 return
283 self.failures.append(message)
284
285
286class _TerminalCapture(io.StringIO):
287 """Text stream that reports an interactive terminal."""
288
289 @staticmethod
290 def isatty() -> bool:
291 """Report that writes would expose bytes to a terminal."""
292 return True
293
294
295class _BinaryCapture(io.StringIO):
296 """Redirected text stream with the binary buffer used by main."""
297
298 def __init__(self) -> None:
299 super().__init__()
300 self.buffer = io.BytesIO()
301
302 @staticmethod
303 def isatty() -> bool:
304 """Report that output is safely redirected."""
305 return False
306
307
308def _selftest_write_env(root: Path, name: str, text: str, mode: int = 0o600) -> Path:
309 """Write one private temporary dotenv fixture."""
310 path = root / name
311 path.write_text(text, encoding="utf-8")
312 path.chmod(mode)
313 return path
314
315
316def _selftest_source_precedence(state: _SelftestState, valid: Path) -> None:
317 """Exercise environment, file, and vault source precedence."""
318 got = resolve_values(valid, {}, {})
319 state.expect(got == ("bench wifi", "correct-horse", ""), "private dotenv did not resolve")
320 precedence = resolve_values(
321 valid,
322 {
323 "RA8_C6_WIFI_SSID": "environment-network",
324 "RA8_C6_WIFI_PSK": "environment-password",
325 URL_KEY: "https://media.example.invalid/library",
326 },
327 {"bench_ssid": "vault-network", "bench_psk": "vault-password"},
328 )
329 state.expect(
330 precedence
331 == (
332 "environment-network",
333 "environment-password",
334 "https://media.example.invalid/library",
335 ),
336 "environment did not take precedence over file and vault",
337 )
338 mixed_ssid = resolve_values(
339 valid, {"RA8_C6_WIFI_SSID": "environment-network"}, {"bench_psk": "vault-password"}
340 )
341 state.expect(
342 mixed_ssid == ("environment-network", "correct-horse", ""),
343 "environment SSID did not combine with file PSK",
344 )
345 mixed_psk = resolve_values(
346 valid,
347 {"RA8_C6_WIFI_PSK": "environment-password"},
348 {"bench_ssid": "vault-network", "bench_psk": "vault-password"},
349 )
350 state.expect(
351 mixed_psk == ("bench wifi", "environment-password", ""),
352 "environment PSK did not combine with file SSID",
353 )
354
355
356def _selftest_vault_fallback(state: _SelftestState, missing: Path) -> None:
357 """Exercise vault defaults and preservation of an explicit SSID."""
358 fallback = resolve_values(missing, {}, {"bench_psk": "vault-password"})
359 state.expect(
360 fallback == ("ra8-bench", "vault-password", ""),
361 "OpenBao fallback did not supply the default SSID",
362 )
363 preserved_ssid = resolve_values(
364 missing,
365 {"RA8_C6_WIFI_SSID": "operator-network"},
366 {"bench_ssid": "vault-network", "bench_psk": "vault-password"},
367 )
368 state.expect(
369 preserved_ssid == ("operator-network", "vault-password", ""),
370 "OpenBao fallback replaced an explicit SSID",
371 )
372
373
374def _selftest_private_files(state: _SelftestState, root: Path, valid: Path) -> None:
375 """Exercise private-file modes, symlinks, and dotenv syntax."""
376 contents = "RA8_C6_WIFI_SSID=bench\nRA8_C6_WIFI_PSK=correct-horse\n"
377 owner_read_only = _selftest_write_env(root, "owner-read-only.env", contents, 0o400)
378 state.expect(
379 parse_env_file(owner_read_only)["RA8_C6_WIFI_PSK"] == "correct-horse",
380 "owner-only read permission was rejected",
381 )
382 public = _selftest_write_env(root, "public.env", contents, 0o640)
383 state.expect_rejected(
384 lambda: parse_env_file(public), "group-readable credential file was accepted"
385 )
386 symlink = root / "symlink.env"
387 symlink.symlink_to(valid)
388 state.expect_rejected(lambda: parse_env_file(symlink), "credential symlink was accepted")
389 invalid_env_cases = (
390 ("duplicate.env", "RA8_C6_WIFI_SSID=one\nRA8_C6_WIFI_SSID=two\n", "duplicate"),
391 ("unknown.env", "UNSUPPORTED_KEY=value\n", "unknown key"),
392 ("unmatched.env", 'RA8_C6_WIFI_SSID="bench\n', "unmatched quote"),
393 ("embedded.env", 'RA8_C6_WIFI_SSID="ben"ch"\n', "embedded quote"),
394 )
395 for file_name, text, label in invalid_env_cases:
396 path = _selftest_write_env(root, file_name, text)
397 state.expect_rejected(
398 lambda path=path: parse_env_file(path), f"dotenv {label} was accepted"
399 )
400
401
402def _selftest_values(state: _SelftestState) -> bytes:
403 """Exercise protocol value bounds and exact packet framing."""
404 valid_values = (
405 ("bench", "a" * HEX_PSK_CHARS, ""),
406 ("\u00e9" * 16, "\u00e9" * 4, ""),
407 ("bench", "correct-horse", "https://media.example.invalid/"),
408 )
409 for values in valid_values:
410 state.expect(_values_are_valid(values), f"valid runtime values were rejected: {values!r}")
411 invalid_values = (
412 ("", "correct-horse", ""),
413 ("bench", "short", ""),
414 ("bench", "g" * HEX_PSK_CHARS, ""),
415 ("\u00e9" * 17, "correct-horse", ""),
416 ("bench", "correct\nhorse", ""),
417 ("bench", "correct-horse", "https://media.invalid/\x7f"),
418 ("bench", "correct-horse", "x" * (MAX_URL_BYTES + 1)),
419 )
420 for values in invalid_values:
421 state.expect(
422 not _values_are_valid(values), f"invalid runtime values were accepted: {values!r}"
423 )
424 packet = make_packet("bench", "correct-horse")
425 state.expect(
426 packet == b"RA8NET1:62656e6368:636f72726563742d686f727365:\n",
427 "packet framing changed",
428 )
429 return packet
430
431
432def _selftest_openbao_errors(state: _SelftestState, missing: Path) -> None:
433 """Exercise unavailable and failing OpenBao clients."""
434 unconfigured = mock.Mock(configured=False)
435 failing = mock.Mock(configured=True)
436 failing.kv_get.side_effect = OpenBaoError("selftest transport failure")
437 with mock.patch.object(sys.modules[__name__], "creds_path", return_value=missing):
438 with mock.patch.object(sys.modules[__name__], "OpenBaoClient", return_value=unconfigured):
439 state.expect_rejected(
440 lambda: resolve_values(missing, {}, None), "unconfigured OpenBao was accepted"
441 )
442 with mock.patch.object(sys.modules[__name__], "OpenBaoClient", return_value=failing):
443 state.expect_rejected(
444 lambda: resolve_values(missing, {}, None),
445 "OpenBao transport failure was accepted",
446 )
447
448
449def _selftest_emission(state: _SelftestState, packet: bytes) -> None:
450 """Exercise terminal refusal and redirected binary emission."""
451 terminal = _TerminalCapture()
452 with contextlib.redirect_stdout(terminal), contextlib.redirect_stderr(io.StringIO()):
453 terminal_rc = main(["emit"])
454 state.expect(
455 terminal_rc == ERROR_EXIT and terminal.getvalue() == "",
456 "terminal emission was not refused",
457 )
458 redirected = _BinaryCapture()
459 with (
460 mock.patch.object(
461 sys.modules[__name__],
462 "resolve_values",
463 return_value=("bench", "correct-horse", ""),
464 ),
465 contextlib.redirect_stdout(redirected),
466 contextlib.redirect_stderr(io.StringIO()),
467 ):
468 emit_rc = main(["emit"])
469 state.expect(
470 emit_rc == 0 and redirected.buffer.getvalue() == packet,
471 "redirected end-to-end emission failed",
472 )
473
474
475def _selftest_force_timeout(_path: Path) -> tuple[str, str, str]:
476 """Raise the process alarm while credential resolution is active."""
477 signal.raise_signal(signal.SIGALRM)
478 return ("must-not", "reach-this-secret", "")
479
480
481def _selftest_timeout(state: _SelftestState) -> None:
482 """Exercise total emission deadlines and timeout argument bounds."""
483 timeout_output = _BinaryCapture()
484 with (
485 mock.patch.object(
486 sys.modules[__name__], "resolve_values", side_effect=_selftest_force_timeout
487 ),
488 contextlib.redirect_stdout(timeout_output),
489 contextlib.redirect_stderr(io.StringIO()) as timeout_error,
490 ):
491 timeout_rc = main(["--timeout", "1", "emit"])
492 state.expect(
493 timeout_rc == ERROR_EXIT
494 and timeout_output.buffer.getvalue() == b""
495 and "deadline" in timeout_error.getvalue()
496 and "secret" not in timeout_error.getvalue(),
497 "total emit deadline failed or exposed credential material",
498 )
499 state.expect(
500 _timeout_seconds("1") == 1
501 and _timeout_seconds(str(MAX_EMIT_TIMEOUT_S)) == MAX_EMIT_TIMEOUT_S,
502 "valid timeout boundaries were rejected",
503 )
504 for invalid_timeout in ("0", "601", "1.5", "invalid"):
505 state.checks += 1
506 try:
507 _timeout_seconds(invalid_timeout)
508 except argparse.ArgumentTypeError:
509 continue
510 state.failures.append(f"invalid timeout was accepted: {invalid_timeout}")
511
512
513def run_selftest() -> int:
514 """Exercise source precedence, secure file I/O, validation, and emission."""
515 state = _SelftestState()
516 with tempfile.TemporaryDirectory() as name:
517 root = Path(name)
518 valid = _selftest_write_env(
519 root,
520 "wifi.env",
521 'RA8_C6_WIFI_SSID="bench wifi"\nRA8_C6_WIFI_PSK=correct-horse\n',
522 )
523 missing = root / "missing.env"
524 _selftest_source_precedence(state, valid)
525 _selftest_vault_fallback(state, missing)
526 _selftest_private_files(state, root, valid)
527 packet = _selftest_values(state)
528 _selftest_openbao_errors(state, missing)
529 _selftest_emission(state, packet)
530 _selftest_timeout(state)
531 if state.failures:
532 for failure in state.failures:
533 print(f"wifi_provision.py --selftest: FAIL: {failure}", file=sys.stderr)
534 return 1
535 print(f"wifi_provision.py --selftest: PASS ({state.checks} checks)")
536 return 0
537
538
539def main(argv: list[str] | None = None) -> int:
540 """Parse the mode and emit only to a pipe or redirected file descriptor."""
541 parser = argparse.ArgumentParser(description=__doc__)
542 parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV)
543 parser.add_argument("--timeout", type=_timeout_seconds, default=DEFAULT_EMIT_TIMEOUT_S)
544 parser.add_argument("--selftest", action="store_true")
545 parser.add_argument("command", nargs="?", choices=("emit",))
546 args = parser.parse_args(argv)
547 if args.selftest:
548 if args.command is not None:
549 parser.error("--selftest accepts no command")
550 return run_selftest()
551 if args.command != "emit":
552 parser.error("the emit command is required")
553 if sys.stdout.isatty():
554 print(
555 "wifi_provision.py: refusing to print credential material to a terminal",
556 file=sys.stderr,
557 )
558 return ERROR_EXIT
559 try:
560 with _emit_deadline(args.timeout):
561 ssid, psk, url = resolve_values(args.env_file)
562 packet = make_packet(ssid, psk, url)
563 sys.stdout.buffer.write(packet)
564 sys.stdout.buffer.flush()
565 except (OSError, ProvisionError) as exc:
566 print(f"wifi_provision.py: {exc}", file=sys.stderr)
567 return ERROR_EXIT
568 return 0
569
570
571if __name__ == "__main__":
572 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298