Skip to content

Proposal: [control_security] psk_file — an owner-only PSK file, the ~20-line alternative the deferred keystore wiring was reaching for #1408

Description

@dc0sk

Comes out of #1234's deferral (2026-09-19) and docs/dev/reviews/review-1234-keystore-writer.md. A proposal, not a plan — it needs its own design review before anything is written, for the same reason the keystore wiring did: the last secret-storage design I proposed was forbidden by a sentence one line below the text I had quoted.

Why this exists

#1234's wiring was deferred because a file keystore is indirection without security for a single PSK on a headless daemon: the Argon2id master must itself come from an env var or a prompt, so env→env buys nothing. But the underlying operator problem is real — the only way to give the daemon a PSK today is OPENPULSE_CONTROL_PSK, and psk_key_id is an inert knob that merely warns.

REQ-CTL-05 already anticipates the answer. Its text lists the file types that must be owner-only:

Any file holding key or secret material (identity key, trust store, keystore, PSK file) must be owner-only: 0600 for files, 0700 for the containing directory.

A PSK file is already a named, governed concept in the requirements. Nothing implements it.

The shape

[control_security]
psk_file = "/etc/openpulse/control-psk"   # 64 hex chars, 0600

load_control_psk (daemon/src/server.rs:2083) currently reads one source and parses 64 hex chars into [u8; PSK_LEN]. The change is to read the file through openpulse_config::secret_file::validate_owner_only (secret_file.rs:15) and hand the same string to the same parser.

  • no new crate, no KDF, no AEAD, no writer, no prompt dependency
  • the permission check is machinery that already exists, is already enforced, and is REQ-CTL-05's subject
  • same security tier as the deferred design, with the moving parts removed
  • an operator can populate it with printf %s "$hex" > file && chmod 600 file

Why it is better than what was deferred

  • It cannot be half-written into an unreadable state. openpulse-keystore has no consumer: wire the keystore-backed PSK loading that server.rs promises (blocks REQ-CTL-03) #1234's un-deflation of audit finding B4 was decisive: FileKeystore::save truncates without temp+rename or fsync, so an interrupted keystore set on a Pi's SD card leaves a file open rejects and the daemon refuses to start. A hex file is either 64 chars or it is not, and the failure is legible.
  • It composes with the real headless upgrade. systemd LoadCredential= / LoadCredentialEncrypted= places a decrypted secret at $CREDENTIALS_DIRECTORY/control-psk on a non-persistent tmpfs, encrypted at rest to the host key or TPM2. That is a path, so it needs exactly this config field and nothing else. This is the part I would most want reviewed — it may be the whole answer, with psk_file as the mechanism.

Open questions for the review

  1. Precedence. Same trap openpulse-keystore has no consumer: wire the keystore-backed PSK loading that server.rs promises (blocks REQ-CTL-03) #1234 hit: with OPENPULSE_CONTROL_PSK and psk_file both potentially set, what wins? The openpulse-keystore has no consumer: wire the keystore-backed PSK loading that server.rs promises (blocks REQ-CTL-03) #1234 review's conclusion was that a present non-selected source should be a refusal to start even when the values are equal, because the equal case is the pre-image of the dangerous one (rotate one, forget a stale export, and the old secret still works while the operator believes they rotated). Does that hold here, where there is no psk_source selector, or does psk_file set ⇒ file wins ⇒ warn-on-env suffice?
  2. The panel. apps/openpulse-panel/src/transport.rs:7 is the second reader. control-channel-security.md:93 already settles its path as OS-keychain-first with an in-UI prompt fallback — so probably daemon-only, but that should be stated rather than assumed, and note transport.rs:64 is cfg(not(wasm32)).
  3. Is it worth building at all? openpulse-keystore has no consumer: wire the keystore-backed PSK loading that server.rs promises (blocks REQ-CTL-03) #1234 established that no deployment has ever set the PSK — every on-air rig binds loopback, where it is discarded by policy. That argues the honest answer may again be "not yet". If so, this issue is the record of the option rather than a task.
  4. Does it need a requirement id? It implements a clause of REQ-CTL-05 that nothing currently implements. Whether that is a new binding on CTL-05 or something else is a traceability question, and CTL-05's own mutation scope is currently 8/303 reachable (A requirement's mutation scope can contain mutants its bound test cannot link, so a PASS is bounded by scope rather than by test quality #1405).

What this is not

Not a keystore, not a revival of #1234's wiring, and not a security improvement over the environment on its own — it is the same tier with fewer moving parts, whose value is that it makes systemd credentials usable. If review concludes that value is not worth 20 lines plus a config field, closing this is a fine outcome.

Related: #1234 (deferred), #1405 (CTL-05 reachability), #1380 (feature-gated code never type-checked).

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions