Skip to content

fix(provision): WiFi password persisted world-readable (0644) in per-port state file #1754

Description

@proffesor-for-testing

Summary

provision.py persists the WiFi password in cleartext to a per-port state file created with
default permissions, so it lands world-readable (0644) under the default umask on macOS and
mainstream Linux.

  • The password is deliberately kept in the merged state dict (provision.py:103) and
    round-tripping it is covered by tests (tests/test_provision_state.py:37), so persistence
    is intentional — the issue is only how it is protected on disk.
  • save_state() (provision.py:147-157) calls os.makedirs(state_dir, exist_ok=True) and
    open(tmp, "w") with no mode argument. Both take whatever the umask allows.
  • Observed on a real host: state directory 0755, files 0644.

Why now

Found while provisioning several nodes for hardware testing. Any local process or user on a
shared machine can read the WiFi credentials of every network the operator has provisioned.

Reproduction

$ python3 provision.py --port <PORT> --ssid <SSID> --password <PW> --target-ip <IP>
$ ls -l ~/.config/wifi-densepose/esp32-provision-state/
-rw-r--r--  ...  _dev_cu.usbmodemXXXX.json
$ python3 -c "import json;print('password' in json.load(open('<that file>')))"
True

Done when

The state directory is 0700 and state files are 0600, on both a fresh install and an
existing directory created by an earlier version.

Work items

  • os.makedirs(state_dir, mode=0o700, exist_ok=True) and os.chmod the directory on the
    existing-directory path, since makedirs does not alter an existing one.
  • Create the temp file restrictively (e.g. os.open(..., 0o600) or os.chmod before
    os.replace) so the secret is never briefly world-readable.
  • Tighten permissions on directories/files left behind by earlier versions.
  • Consider whether the password needs to be persisted at all, or could be prompted per
    invocation — that would remove the exposure rather than narrow it.

Activity

  1. added
    bugSomething isn't working
    securitySecurity-related issues and hardening
    on Sep 1, 2026
  2. ruvnet commented on Sep 30, 2026

    @ruvnet
    Owner

    This is a real security issue on multi-user systems. The minimal fix in provision.py:

    # Replace: open(tmp, "w") 
    # With:
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(state, f, indent=2)

    Also ensure the state directory is created with restricted permissions:

    os.makedirs(state_dir, mode=0o700, exist_ok=True)

    As a follow-up: the WiFi password only needs to be stored if re-provisioning without re-entering credentials is a use case. If callers always pass credentials explicitly, consider dropping the password from the persisted state entirely to eliminate the at-rest exposure.


    Generated by Claude Code

  3. aepod commented on Oct 2, 2026

    @aepod
    Contributor

    There's a second place the Wi-Fi password leaks: provision.py --state prints the stored record as JSON with the password in clear. Checked on current main with a dry-run and placeholder credentials. MEASURED. The state file's 0644 mode is CODE-DERIVED (provision.py save_state, no mode=/chmod; unchanged at 03a89793).

    Users paste --state output into issues when they ask for help, so this ends up in public threads.

    cd firmware/esp32-csi-node
    python provision.py --port /dev/null-test --ssid TESTSSID --password TESTPASS \
      --target-ip <test-ip> --node-id 1 --dry-run
    python provision.py --port /dev/null-test --state     # prints "password": "TESTPASS"

    Masking secrets in --state, or not storing them at all as #1832 proposes, would stop them ending up in pasted support logs. Whichever PR lands should cover this path, and 0600 on the state file would be a sensible default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity-related issues and hardening

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions