Skip to content

fix(security): gate radio-affecting threat action on calibration and authorization - #921

Merged
forkwright merged 2 commits into
mainfrom
fix/874-threat-authorization
Aug 21, 2026
Merged

fix(security): gate radio-affecting threat action on calibration and authorization#921
forkwright merged 2 commits into
mainfrom
fix/874-threat-authorization

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Addresses the authorization gap on #874 — the part that gates #862's callers. Does not close the issue; detector-output provenance and the spoofed/stale/replayed evidence coverage remain its open work.

The rule this broke is already written down

evaluate_threat compared a caller-supplied score to a caller-supplied threshold and, above it, drove the firewall into Panic and recorded a modem power-cut request. Two FIXME(#874) markers sat on those exact lines.

sema_core::Calibration's own doc states the canon:

Any future automatic response must match on Calibrated before it may act.

The threat bands are provisional score ranges (#555) — High is documented as "labeled 'likely IMSI catcher' only after calibration establishes what the band actually separates". So a threshold crossing on an uncalibrated detector is a statement about arithmetic, not about the world. It now returns Advisory(Uncalibrated) and touches neither the firewall nor the power manager, even at u32::MAX and even with an authorization standing.

A second gate, because calibration is not consent

A detector that is right most of the time still does not get to disconnect someone's phone on its own. That is an operator's decision, and RadioCutAuthorization is where it enters.

There is deliberately no Default and no constructor deriving it from a score — the failure being prevented is a detector authorizing itself. OperatorAccepted exists so the plumbing is written and tested; no production path can build one today because no operator-acceptance mechanism exists yet. When one lands it constructs this value, and nothing else has to change to let the action through.

Why the two refusals are separate variants, and why the order matters

State Response
uncalibrated Advisory(Uncalibrated)
calibrated, unauthorized Advisory(NotAuthorized)
calibrated + authorized, over threshold ModemCutRequested

They resolve at different times and from different sources — calibration from an evaluation harness, authorization from an operator. Collapsing them would make the eventual arrival of calibration look like nothing had changed.

The calibration check runs first, and that is deliberate: an operator who authorized action on a calibrated detector did not thereby authorize it on an uncalibrated one, so reporting NotAuthorized there would name the wrong missing thing.

Sentinel firewall restriction stays ungated. It follows the operating mode the operator selected rather than a detector score, restricts rather than severs, and is reversible by leaving the mode. A test passes an uncalibrated detector through that path on purpose, to pin that the gates cover radio-affecting action and not every effect.

The kardia badge: the missing fixture was the defect, not the expression

The threat indicator had already been corrected from a CCCI boot-path artefact to detector_online && High|Critical. That correction was right — and nothing guarded it. kardia.rs has no test module at all, and scripts/witness/boot.sh asserts modem_path_available but never threat_high. So reverting to the old derivation would have passed the entire suite and the boot witness.

VERIFICATION.md says a claim without a failing fixture cannot be passed. This extracts screen_threat::threat_indicator and covers the full cross product.

The offline row is the one that matters: a detector that has stopped reporting must not leave the badge asserting whatever it last saw. All four levels are checked against detector_online = false, not just the convenient one.

Verified

Nine-configuration scripts/kernel-clippy.sh clean, cargo fmt --check clean. Full kernel suite on the host target: 2466 tests, 43 failures — exactly the unmodified-main baseline, compared name-by-name against a captured baseline set. Ledger rows updated (security_mode 36 → 39, screen_threat 23 → 24).

forkwright and others added 2 commits August 21, 2026 05:59
…authorization

evaluate_threat compared a caller-supplied score to a caller-supplied
threshold and, above it, drove the firewall into Panic and recorded a modem
power-cut request. Two FIXME(#874) markers sat on those exact lines.

sema_core::Calibration's own doc already states the rule this violated:
"Any future automatic response must match on Calibrated before it may act."
The threat bands are provisional score ranges (#555), so a threshold
crossing on an uncalibrated detector is a statement about arithmetic, not
about the world. It now returns Advisory(Uncalibrated) and touches neither
the firewall nor the power manager.

A second gate outlives calibration. A detector that is right most of the
time still does not get to disconnect someone's phone on its own; that is an
operator's decision, and RadioCutAuthorization is where it enters. There is
deliberately no Default and no constructor deriving it from a score -- the
failure being prevented is a detector authorizing itself. No production path
can build OperatorAccepted today because no operator-acceptance mechanism
exists; when one lands it constructs this and nothing else changes.

The two refusals are separate variants because they resolve at different
times and from different sources. Collapsing them would make the eventual
arrival of calibration look like nothing had changed. The calibration check
runs FIRST: an operator who authorized action on a calibrated detector did
not thereby authorize it on an uncalibrated one, so reporting NotAuthorized
there would name the wrong missing thing.

Sentinel firewall restriction stays ungated. It follows the operating mode
the operator selected rather than a detector score, restricts rather than
severs, and is reversible by leaving the mode. A test passes an uncalibrated
detector through that path on purpose, to pin that the gates cover
radio-affecting action and not every effect.

Separately, the kardia threat-indicator derivation had no regression
coverage, and that was the actual defect rather than the expression. It had
been corrected from a CCCI boot-path artefact to detector_online x
threat_level, but nothing guarded the correction: reverting it would have
passed the whole suite AND the boot witness, since neither asserts on
threat_high. Per VERIFICATION.md a claim without a failing fixture cannot be
passed, so this extracts screen_threat::threat_indicator and covers the full
cross product. The offline row is the one that matters -- a detector that has
stopped reporting must not leave the badge asserting whatever it last saw.

This does not close #874: source, uncertainty, freshness and evidence
provenance on detector output, and the spoofed/stale/replayed evidence
coverage, remain its open work. What lands here is the authorization gap
that gates #862's callers.
@forkwright
forkwright merged commit c9562fd into main Aug 21, 2026
12 checks passed
@forkwright
forkwright deleted the fix/874-threat-authorization branch August 21, 2026 11:32
forkwright pushed a commit that referenced this pull request Aug 21, 2026
🤖 I have created a release *beep* *boop*
---


## [0.9.0](v0.8.7...v0.9.0)
(2026-08-21)


### Features

* **security:** derive secret verifiers with Argon2id from page-backed
memory ([#915](#915))
([4667bb8](4667bb8)),
closes [#272](#272)
* **security:** enforce the ~77-bit boot-secret floor at first-boot
setup ([#912](#912))
([f071f42](f071f42))
* **watchdog:** pet only while every liveness owner is still advancing
([#920](#920))
([33791a8](33791a8)),
closes [#875](#875)


### Bug Fixes

* **ci:** count the ledger's runnable tests after combining profiles,
not before ([#916](#916))
([653e0df](653e0df)),
closes [#855](#855)
* **clock:** separate authentication from precedence and refuse
implausible time
([#906](#906))
([b7f9205](b7f9205))
* **crypto:** enable zeroize on every keyed cipher crate and prove it at
compile time ([#900](#900))
([e2218fc](e2218fc))
* **csprng:** credit cadence departure, not raw sample-to-sample bit
flips ([#901](#901))
([7b01d75](7b01d75))
* **kernel:** map the whole reserved window, and derive its bound from
one place ([#918](#918))
([25ccf87](25ccf87)),
closes [#917](#917)
* **klesis-core:** reject an out-of-domain CSQ reading and cover the six
untested guards
([#909](#909))
([1f174d6](1f174d6))
* **krypta:** hold ratchet key material in a type whose drop scrubs it
([#904](#904))
([0c7f6df](0c7f6df))
* **krypta:** make the ratchet commit atomic and stop it being
duplicable ([#908](#908))
([dd3ba99](dd3ba99))
* **matrix_crypto:** drop the Olm ratchet fields nothing can advance,
scrub what the handshake used
([#903](#903))
([d733857](d733857))
* **matrix_crypto:** name the Olm pre-key derivation for what it is
([#913](#913))
([8c217c2](8c217c2))
* **pteron:** close the four remaining SMP findings
([#910](#910))
([e96b7c7](e96b7c7))
* **security_mode:** store a versioned Sentinel-exit PIN verifier with a
per-device salt
([#896](#896))
([bbf9c86](bbf9c86))
* **security:** delete the plaintext LFS mount rather than gate it
([#919](#919))
([cff4e35](cff4e35)),
closes [#866](#866)
* **security:** gate radio-affecting threat action on calibration and
authorization ([#921](#921))
([c9562fd](c9562fd))
* **security:** require a real provisioning anchor, or carry none at all
([#922](#922))
([a5016e8](a5016e8))
* **security:** verify every stored secret through one salted, iterated
record ([#898](#898))
([b5cb2c2](b5cb2c2))
* **sms:** apply the SCTS timezone octet so decoded timestamps are UTC
([#905](#905))
([ad048fe](ad048fe))
* **stegnos:** reject a persisted PBKDF2 iteration count above the
maximum ([#899](#899))
([87fdfad](87fdfad))
* **thumos:** close all eight findings in the low-severity kernel batch
([#911](#911))
([8160589](8160589))
* **thumos:** close the three live findings in the wave-1 batch
([#907](#907))
([891da7e](891da7e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.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