Skip to content

fix(guard/guardcore): fail closed when the policy signature cannot be trusted - #171

Merged
codeyevsky merged 2 commits into
solongate:mainfrom
tnalbant:fix/policy-signature-fail-closed-104
Oct 27, 2026
Merged

codeyevsky merged 2 commits into
solongate:mainfrom
tnalbant:fix/policy-signature-fail-closed-104

Conversation

@tnalbant

@tnalbant tnalbant commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

LoadLocalPolicyFile only rejected the home policy.json when VerifyDocument returned
signed && !valid. Every other outcome of the trust check fell through to "no verification
required" and the file was loaded and applied:

Case Before After
Signature cannot be decoded (verr != nil) loaded ErrPolicyTampered
Machine key exists but cannot be loaded (kerr != nil) check skipped, loaded ErrPolicyTampered
Signature stripped on a machine that has signed before loaded ErrPolicyTampered
File signed elsewhere, no key here to check it loaded ErrPolicyTampered
Corrupt / not JSON skipped, fell back to the project-local policy (no security block) ErrPolicyTampered

guard/main.go already turns a loader error into a deny plus an audit record, so these paths now
fail closed instead of quietly enforcing an untrusted policy with the guard's own checks disabled.

Reading a policy no longer creates the signing key

This one is subtle, and sg-parity-check.sh caught it. verifyHomePolicy called
crypto.LoadOrCreateKeyPair on every load, so merely running the guard keyed the install. A machine
that still had a pre-signature, unsigned policy.json would then be "keyed" on its second run and
reject its own policy - an upgrade that bricks the install, and an unsigned fixture that stops
loading after the first expect in the parity script.

The loader now only loads. With no key pair present, an unsigned file is a pre-signature policy and
still loads, while a signed one is refused because there is nothing here to check it against. With a
key pair present, the strict rule above applies. Writes still create the key pair through
localstore, which is where signing belongs.

Verification

  • TestLoadLocalPolicyFileDoesNotCreateASigningKey - loads an unsigned policy twice and asserts no
    key pair appeared and both loads succeeded. This is the parity-script regression.
  • TestLoadLocalPolicyFileOwnMalformedSignatureIsRejected - failed before (err=<nil>: accepted),
    passes now.
  • TestLoadLocalPolicyFileOwnUnsignedAfterTheMachineHasASigningKeyIsRejected - failed before
    (err=<nil>), passes now.
  • TestLoadLocalPolicyFileCorruptOwnFileIsRejectedNotSkipped - rewritten from
    ...SkipsACorruptOwnFileAndFallsBackToProjectLocal. Intentional behaviour change: a corrupt trust
    anchor must not silently downgrade the guard to a policy with no security block.
  • packages/guard: 8/8 packages pass; packages/crypto passes; go vet clean; go test -race
    clean on guardcore and crypto.
  • scripts/sg-parity-check.sh: passed=10 failed=0 (on the first revision of this branch:
    8 passed, 2 failed - the two "should be allowed" cases were denied).
  • scripts/sg-layers-check.sh: passed=25 wrong=0.
  • scripts/sg-box-check.sh: not runnable here (bwrap is not installed); untouched by this branch.

Refs #104

Residual risk

Deleting ~/.solongate/crypto as well as the signature still reads as "no signing identity yet", and
an unsigned file loads. Writing there is already enough to disable the guard, so this closes the
file-only attack rather than the "own the whole state directory" one.

AI assistance

Implemented with AI assistance (Hermes agent) at my direction, including the test and script runs
above against both revisions. I reviewed the change and the evidence before submitting it.

verifyHomePolicy() only rejected a home policy.json when VerifyDocument reported
signed && !valid. Every other outcome of the trust check fell through to "no
verification required" and loaded the file:

- a signature that could not be decoded (verr != nil) was ignored;
- a machine key that existed but could not be loaded (kerr != nil) skipped the
  check entirely;
- a stripped signature (signed == false) was treated as a legacy file even on a
  machine that had already signed policies;
- a corrupt file was skipped, so the loader fell back to the project-local
  policy, which carries no Security fields at all.

Each of those let someone who can write policy.json - or merely strip its
signature - have an untrusted policy enforced, with the guard's own checks
disabled through the missing Security block.

The loader now applies one accepting rule on a keyed machine: a signature that
verifies against this machine's key. Anything else returns ErrPolicyTampered,
which guard/main.go already turns into a deny plus an audit record.

Loading no longer creates the signing key. verifyHomePolicy used to call
LoadOrCreateKeyPair on every load, so a single run keyed the install; an
upgrade that still had a pre-signature, unsigned policy.json then rejected that
same policy on its second run. With no key pair present an unsigned file is
treated as pre-signature and loads, while a signed one is refused because there
is nothing here to check it against. Writes still create the key pair through
localstore, which is where signing belongs.

Signed-off-by: Turgay Nalbant <182372382+tnalbant@users.noreply.github.com>
@tnalbant
tnalbant force-pushed the fix/policy-signature-fail-closed-104 branch from 8361e41 to 656ecfb Compare October 27, 2026 18:14
@codeyevsky
codeyevsky merged commit 4042d0c into solongate:main Oct 27, 2026
34 checks passed
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.

2 participants