fix(security): gate radio-affecting threat action on calibration and authorization - #921
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_threatcompared a caller-supplied score to a caller-supplied threshold and, above it, drove the firewall intoPanicand recorded a modem power-cut request. TwoFIXME(#874)markers sat on those exact lines.sema_core::Calibration's own doc states the canon:The threat bands are provisional score ranges (#555) —
Highis 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 returnsAdvisory(Uncalibrated)and touches neither the firewall nor the power manager, even atu32::MAXand 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
RadioCutAuthorizationis where it enters.There is deliberately no
Defaultand no constructor deriving it from a score — the failure being prevented is a detector authorizing itself.OperatorAcceptedexists 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
Advisory(Uncalibrated)Advisory(NotAuthorized)ModemCutRequestedThey 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
NotAuthorizedthere 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
kardiabadge: the missing fixture was the defect, not the expressionThe 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.rshas no test module at all, andscripts/witness/boot.shassertsmodem_path_availablebut neverthreat_high. So reverting to the old derivation would have passed the entire suite and the boot witness.VERIFICATION.mdsays a claim without a failing fixture cannot be passed. This extractsscreen_threat::threat_indicatorand 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.shclean,cargo fmt --checkclean. Full kernel suite on the host target: 2466 tests, 43 failures — exactly the unmodified-mainbaseline, compared name-by-name against a captured baseline set. Ledger rows updated (security_mode36 → 39,screen_threat23 → 24).