Skip to content

Q_Utils::sign() throws without Q/internal/secret; signature() keeps the local fallback; install.php checks the secret - #55

Open
zattak1 wants to merge 2 commits into
Qbix:mainfrom
zattak1:upstream/sign-throws-install-check
Open

Q_Utils::sign() throws without Q/internal/secret; signature() keeps the local fallback; install.php checks the secret#55
zattak1 wants to merge 2 commits into
Qbix:mainfrom
zattak1:upstream/sign-throws-install-check

Conversation

@zattak1

@zattak1 zattak1 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The producing half of #40, reshaped along the split proposed there: accepting an unsigned request fails closed always (that's #54, which this PR is based on — it will show that PR's commit until it merges); producing a signature, or verifying an artifact we issued ourselves, may degrade to a machine-local key without letting anyone in.

What changes

  • Q_Utils::sign() throws Q_Exception_MissingConfig when no secret is passed and Q/internal/secret is not configured (or is the shipped placeholder). It hands out an outbound credential; signing that with a guessable key is worse than not signing.
  • Q_Utils::signature() keeps generateLocalSecret() as its fallback. It runs from Q_Session::isValidId() on every request, from Q_Bootstrap::validateEarly(), before any sensible error path — a throw there is a 500 at the door discovered by the first visitor after a deploy. The comment at the fallback says why it is allowed to degrade and where the missing secret is meant to be caught instead.
  • generateLocalSecret() memoizes in a static. Inputs are fixed for the life of the process; it now sits on the per-request path several times on an unconfigured install and previously re-read /etc/machine-id (or spawned reg query) each call. Output unchanged.
  • Q_Plugin::installApp() checks the secret up front and fails with the config path in the message (<APP_LOCAL_DIR>/app.jsonQ/internal/secret), so a missing or placeholder secret is found where an operator can act on it.
  • Same split in Q/Utils.js: Utils.sign() throws, Utils.signature() keeps the fallback, generateLocalSecret() memoizes.

One thing #40's description raised that this shape leaves in place, stated so it isn't lost: generateLocalSecret() differs between the PHP host and the Node host, so on an install without a configured secret, PHP↔Node internal signing never verifies. With this PR that is caught at install time rather than at runtime, which is the intended place for it.

Behaviour with a secret configured is unchanged.

🤖 Generated with Claude Code

zattak1 and others added 2 commits September 7, 2026 17:01
…ecret is not configured

Q_Valid::signature() returned TRUE for every payload when no secret was
available:

    if (!isset($secret)) {
        return true;
    }

It is a validator, and it read "no key" as "valid". Everything built on it is
an internal gate -- handlers/Q/config/validate.php, which is the check on
Q_Config_post() before it dispatches to Q_Config::setOnServer() and
clearOnServer() with a client-supplied filename and data; Streams::invites();
Media/callCenter -- so an install with no Q/internal/secret accepted an
unsigned POST that writes config files onto the machine, while looking
healthy.

This is only the ACCEPTING side. Producing a signature (Q_Utils::signature())
is untouched here and still falls back to a machine-local key; that policy is
a separate change. Accepting an unsigned request must fail closed regardless
of what the producing side does.

Q_Utils::requireInternalSecret() is new and additive: it returns the
configured secret, and treats the empty string and the "TODO: CHANGE TO SOME
RANDOM STRING, ..." literal that local.sample/app.json ships as unconfigured.
That literal is a fixed value published in every copy of this repository, so
an install that keeps it is not weakly configured -- it holds a secret every
attacker already has, and can therefore sign.

All three in-tree call sites pass $throwIfInvalid = true, so the rejection
surfaces as the existing Q_Response::code(403), with Q_Exception_MissingConfig
naming the key at fault rather than blaming the caller for a local
misconfiguration.

platform/classes/Q/Utils.js carried the same fallback in Utils.validate(),
whose docblock advertised "Returns true if secret is empty". It now returns
false with a console warning. Utils.signature() and Utils.sign() are untouched.

Behaviour with a secret configured is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he local fallback; install.php checks the secret

Splits the signing side of the fail-closed change along the line between
producing a signature and accepting one.

- Q_Utils::sign() throws Q_Exception_MissingConfig when no secret is passed
  and Q/internal/secret is not configured (or is the shipped "TODO:"
  placeholder). It hands out an outbound credential, and signing that with a
  guessable machine-derived key is worse than not signing at all.

- Q_Utils::signature() keeps generateLocalSecret() as its fallback. It is
  called from Q_Session::isValidId() on every request, from
  Q_Bootstrap::validateEarly(), before there is any sensible error path; a
  throw there would turn an install missing the secret into a 500 at the
  door, discovered by the first visitor after a deploy. Degrading to a
  machine-local key there lets nobody in: accepting a signature from outside
  never degrades (Q_Valid::signature(), previous commit).

- generateLocalSecret() memoizes in a static. Its inputs are fixed for the
  life of the process, and it is now on the per-request path several times
  on an install without a configured secret; previously it re-read
  /etc/machine-id (or spawned `reg query` on Windows) on every call. Output
  is unchanged.

- Q_Plugin::installApp() checks the secret up front and fails with the
  config path in the message. That is where an operator can act on it.

- Same split on the Node side: Utils.sign() throws, Utils.signature() keeps
  the fallback, generateLocalSecret() memoizes.

Behaviour with a secret configured is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant