4"""Emit one runtime Wi-Fi provisioning packet to a non-terminal stdout.
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.
12from __future__
import annotations
23from collections.abc
import Callable, Iterator
24from pathlib
import Path
25from unittest
import mock
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"
37MIN_QUOTED_VALUE_LENGTH = 2
38ASCII_CONTROL_MAX = 0x20
41DEFAULT_EMIT_TIMEOUT_S = 30
42MAX_EMIT_TIMEOUT_S = 600
44sys.path.insert(0, str(SCRIPT_DIR))
46from openbao_client
import (
53class ProvisionError(ValueError):
54 """A credential source or effective runtime value is invalid."""
57def _timeout_seconds(raw: str) -> int:
58 """Parse one bounded whole-second total emit deadline for argparse."""
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)
70@contextlib.contextmanager
71def _emit_deadline(timeout_s: int) -> Iterator[
None]:
72 """Bound the complete credential resolution, encoding, and stdout write."""
74 def timed_out(_signum: int, _frame: object) ->
None:
75 message = f
"credential emission exceeded its {timeout_s}-second deadline"
76 raise ProvisionError(message)
78 previous_handler = signal.signal(signal.SIGALRM, timed_out)
79 previous_timer = signal.setitimer(signal.ITIMER_REAL, float(timeout_s))
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])
89def _read_private_file(path: Path) -> str:
90 """Open a regular, non-symlink credential file once and return its text."""
93 except FileNotFoundError:
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)
104 flags |= getattr(os,
"O_CLOEXEC", 0)
105 flags |= getattr(os,
"O_NOFOLLOW", 0)
106 descriptor = os.open(path, flags)
107 except FileNotFoundError:
109 except OSError
as exc:
110 message = f
"cannot open {path}: {exc.strerror}"
111 raise ProvisionError(message)
from exc
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)
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:
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
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:
146 key, value = line.split(
"=", 1)
147 config[key.strip()] = value.strip()
151def _literal_value(raw: str, path: Path, line_no: int) -> str:
152 """Parse one dotenv value as data without evaluating shell syntax."""
156 if value[0]
in (
"'",
'"'):
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)
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)
171def parse_env_file(path: Path) -> dict[str, str]:
172 """Parse the optional private Wi-Fi dotenv file without shell evaluation."""
174 lines = _read_private_file(path).splitlines()
175 except FileNotFoundError:
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(
"#"):
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)
188 message = f
"{path}:{line_no}: duplicate assignment for {key}"
189 raise ProvisionError(message)
190 parsed[key] = _literal_value(raw_value, path, line_no)
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)
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,
"")
227 if vault_data
is None:
228 openbao_env = creds_path()
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)
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
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")
254def _values_are_valid(values: tuple[str, str, str]) -> bool:
255 """Return whether runtime values can be encoded."""
258 except ProvisionError:
264 """Accumulate selftest checks and failures without nested closures."""
266 def __init__(self) -> None:
267 self.failures: list[str] = []
270 def expect(self, condition: bool, message: str) ->
None:
271 """Record one Boolean selftest expectation."""
274 self.failures.append(message)
276 def expect_rejected(self, operation: Callable[[], object], message: str) ->
None:
277 """Record that one operation raises the provisioning error."""
281 except ProvisionError:
283 self.failures.append(message)
286class _TerminalCapture(io.StringIO):
287 """Text stream that reports an interactive terminal."""
290 def isatty() -> bool:
291 """Report that writes would expose bytes to a terminal."""
295class _BinaryCapture(io.StringIO):
296 """Redirected text stream with the binary buffer used by main."""
298 def __init__(self) -> None:
300 self.buffer = io.BytesIO()
303 def isatty() -> bool:
304 """Report that output is safely redirected."""
308def _selftest_write_env(root: Path, name: str, text: str, mode: int = 0o600) -> Path:
309 """Write one private temporary dotenv fixture."""
311 path.write_text(text, encoding=
"utf-8")
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(
323 "RA8_C6_WIFI_SSID":
"environment-network",
324 "RA8_C6_WIFI_PSK":
"environment-password",
325 URL_KEY:
"https://media.example.invalid/library",
327 {
"bench_ssid":
"vault-network",
"bench_psk":
"vault-password"},
332 "environment-network",
333 "environment-password",
334 "https://media.example.invalid/library",
336 "environment did not take precedence over file and vault",
338 mixed_ssid = resolve_values(
339 valid, {
"RA8_C6_WIFI_SSID":
"environment-network"}, {
"bench_psk":
"vault-password"}
342 mixed_ssid == (
"environment-network",
"correct-horse",
""),
343 "environment SSID did not combine with file PSK",
345 mixed_psk = resolve_values(
347 {
"RA8_C6_WIFI_PSK":
"environment-password"},
348 {
"bench_ssid":
"vault-network",
"bench_psk":
"vault-password"},
351 mixed_psk == (
"bench wifi",
"environment-password",
""),
352 "environment PSK did not combine with file SSID",
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"})
360 fallback == (
"ra8-bench",
"vault-password",
""),
361 "OpenBao fallback did not supply the default SSID",
363 preserved_ssid = resolve_values(
365 {
"RA8_C6_WIFI_SSID":
"operator-network"},
366 {
"bench_ssid":
"vault-network",
"bench_psk":
"vault-password"},
369 preserved_ssid == (
"operator-network",
"vault-password",
""),
370 "OpenBao fallback replaced an explicit SSID",
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)
379 parse_env_file(owner_read_only)[
"RA8_C6_WIFI_PSK"] ==
"correct-horse",
380 "owner-only read permission was rejected",
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"
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"),
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"
402def _selftest_values(state: _SelftestState) -> bytes:
403 """Exercise protocol value bounds and exact packet framing."""
405 (
"bench",
"a" * HEX_PSK_CHARS,
""),
406 (
"\u00e9" * 16,
"\u00e9" * 4,
""),
407 (
"bench",
"correct-horse",
"https://media.example.invalid/"),
409 for values
in valid_values:
410 state.expect(_values_are_valid(values), f
"valid runtime values were rejected: {values!r}")
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)),
420 for values
in invalid_values:
422 not _values_are_valid(values), f
"invalid runtime values were accepted: {values!r}"
424 packet = make_packet(
"bench",
"correct-horse")
426 packet == b
"RA8NET1:62656e6368:636f72726563742d686f727365:\n",
427 "packet framing changed",
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"
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",
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"])
455 terminal_rc == ERROR_EXIT
and terminal.getvalue() ==
"",
456 "terminal emission was not refused",
458 redirected = _BinaryCapture()
461 sys.modules[__name__],
463 return_value=(
"bench",
"correct-horse",
""),
465 contextlib.redirect_stdout(redirected),
466 contextlib.redirect_stderr(io.StringIO()),
468 emit_rc =
main([
"emit"])
470 emit_rc == 0
and redirected.buffer.getvalue() == packet,
471 "redirected end-to-end emission failed",
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",
"")
481def _selftest_timeout(state: _SelftestState) ->
None:
482 """Exercise total emission deadlines and timeout argument bounds."""
483 timeout_output = _BinaryCapture()
486 sys.modules[__name__],
"resolve_values", side_effect=_selftest_force_timeout
488 contextlib.redirect_stdout(timeout_output),
489 contextlib.redirect_stderr(io.StringIO())
as timeout_error,
491 timeout_rc =
main([
"--timeout",
"1",
"emit"])
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",
500 _timeout_seconds(
"1") == 1
501 and _timeout_seconds(str(MAX_EMIT_TIMEOUT_S)) == MAX_EMIT_TIMEOUT_S,
502 "valid timeout boundaries were rejected",
504 for invalid_timeout
in (
"0",
"601",
"1.5",
"invalid"):
507 _timeout_seconds(invalid_timeout)
508 except argparse.ArgumentTypeError:
510 state.failures.append(f
"invalid timeout was accepted: {invalid_timeout}")
513def run_selftest() -> int:
514 """Exercise source precedence, secure file I/O, validation, and emission."""
515 state = _SelftestState()
516 with tempfile.TemporaryDirectory()
as name:
518 valid = _selftest_write_env(
521 'RA8_C6_WIFI_SSID="bench wifi"\nRA8_C6_WIFI_PSK=correct-horse\n',
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)
532 for failure
in state.failures:
533 print(f
"wifi_provision.py --selftest: FAIL: {failure}", file=sys.stderr)
535 print(f
"wifi_provision.py --selftest: PASS ({state.checks} checks)")
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)
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():
555 "wifi_provision.py: refusing to print credential material to a terminal",
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)
571if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.