Plain-language "how it protects you" for non-experts, in both surfaces:
- README: new "How this protects you from unauthorized WiFi surveillance"
section — the silent device-free threat, the per-session keyed-twist defense
(own router undoes it, outside listener can't average it back → identity guess
collapses to chance; ~98% throughput; compliant/not-jamming), and honest
limits (defends vs. third-party sniffers not the AP; SYNTHETIC/L0).
- UI (ui/veil-console.html): a new always-visible "How this protects you" card
(Threat / Shield / Kept honest) plus a deeper `protect` explainer dialog.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
- docs/veil-tui.gif: an animated walkthrough of the `veil` terminal harness
(shield off/on, 32 passes → out-of-spec, 96 → pass, ward preset, optimize,
witness check), embedded at the top of the harness section in the crate README.
- ui/veil-console.html: the self-contained VEIL Console web dashboard now lives
in the repo (no build/network), linked from the README.
- veil: honor CLICOLOR_FORCE so piped captures keep ANSI color (standard flag).
- veil-console.html footer now points at the `veil` terminal harness/TUI.
Validated: 38 tests + doctest pass, clippy --all-targets -D warnings clean,
rustfmt clean. GIF is 800×520, ~113 KB, verified to decode/animate in a browser.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
A dependency-free native binary (`src/bin/veil.rs`) — the in-repo counterpart to
the npm metaharness — that drives the same crate API the tests use:
- Interactive ANSI dashboard (TUI): live status, re-ID off/on vs chance,
throughput, compliance (energy 1.000× / not jamming), a block-sparkline
collapse curve, config, and PASS/OUT-OF-SPEC verdict. Command-driven redraw
loop (std-only, no crossterm/ratatui): on/off, passes/bits/n/snr,
metric euclid|cosine, preset scif|board|ward|hotel, optimize, proof.
- Scriptable subcommands: report, sweep, optimize, adaptive <N>, proof, doctor
(exit 0 = healthy). Auto-picks TUI on a terminal, one-shot report when piped;
honors NO_COLOR.
Std-only, so it builds with no extra deps and runs in any pipe/CI. The wasm leaf
story is unchanged (validated with `--lib`; the bin is native-only). All
readouts are SYNTHETIC/L0 and never relabeled.
Validated: 38 tests + doctest pass, clippy --all-targets -D warnings clean,
rustfmt clean, wasm --lib builds; all subcommands + a scripted TUI session
exercised. Documented in the crate README and ADR-288.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
Adds a rendered screenshot of the VEIL management console (dark theme, shield
engaged: the room's WiFi identity clusters collapsed to the chance floor, re-ID
4.7%, PROTECTED) as a banner at the top of the crate README. Captured at a 16:11
landscape viewport (2400x1752, 2x).
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
Two additions on top of the hyper-optimized VEIL shield.
1) Adaptive optimization (v2/crates/wifi-densepose-privshield/src/optimize.rs):
- optimal_bits_across_snr / model_optimal_bits_for_snr: the throughput-
optimal feedback resolution shifts with SNR (unconstrained optimum 4 bits
at 5-10 dB, 3 bits at 20-40 dB); within the spec {5,7,9} set it stays 5,
which is why the shipped shield is SNR-stable.
- adaptive_shield / min_passes_for_n: derive a shield for a specific
deployment. Finding: the collapse budget is N-independent in this model
(48 passes collapses N in {8,64} alike) — it is set by the fine-subspace
dimension, not the candidate count. Defaults unchanged, so the proof
witness is untouched. 38 tests + doctest pass; clippy -D warnings clean.
2) npm metaharness harness/wifi-densepose-privshield/ (ADR-289), mirroring
wifi-densepose-sar-harness (ADR-286) with two improvements:
- @metaharness/* imported dynamically inside the commands that need them, so
`guidance` and `--help` run with ZERO dependencies installed (offline / pre
`npm install`).
- a dependency-free VEIL `guidance` command: a source-cited, evidence-
labelled, read-only capability map (topics: overview, threat,
countermeasure, compliance, optimization, experiment).
Standard router + flywheel (SYNTHETIC) + Darwin wiring, tailored to VEIL
task axes and policy levers. Tests: smoke + router + flywheel (need install)
and guidance (offline). .harness manifest generated with real per-file
hashes. Validated offline: cli syntax, --help, guidance topics, exit codes,
graceful degradation when deps are absent.
Docs: research bundle 08 gains a per-deployment adaptivity section; 07 and the
crate README point at the harness; ADR-289 added and indexed.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
Adds an `optimize` module that replaces the hand-picked shield config with a
derived, robustness-verified optimum, and hardens the experiment so the
collapse is proven to be signal-level, not classifier-level.
Model changes:
- throughput.rs: add a feedback-airtime term (cost rises with feedback bits)
alongside the falling quantization residual, giving a genuine interior
throughput optimum in feedback resolution.
- attacker.rs: add a selectable distance metric (Euclidean + Cosine) so the
optimizer can require the collapse to hold under multiple classifiers.
- experiment.rs: thread the attacker metric through; build the channel once.
optimize.rs:
- optimal_feedback_bits / spec_optimal_feedback_bits: throughput-best resolution
(3 bits unconstrained, matching DySPAN-2026; 5 bits within the 802.11 {5,7,9}
set).
- min_givens_passes: smallest mixing budget that collapses re-ID robustly across
both metrics AND N in {16,32}.
- pareto_frontier and hyper_optimize.
Findings and adopted defaults:
- Proven-minimum robust passes = 48; the hand-picked 112 was 2.3x over-
provisioned. Rotation mixing is keyed (never signaled), so extra passes are
throughput-free -> ship 96 (2x margin).
- Feedback resolution 5 bits (spec-optimal), down from 7.
- ShieldConfig::default() now equals hyper_optimize()'s output; a test guards
against drift.
Net vs. the original: strictly better on BOTH privacy and throughput.
Reference (SYNTHETIC/L0, N=16): re-ID 100% shield-off -> 4.7% shield-on
(chance 6.25%, below chance), throughput 97.6%, energy ratio 1.000000. 35 tests
+ doctest pass; clippy -D warnings clean; builds for wasm32.
Docs: new docs/research/privacy-shield/08-optimization.md; updated bundle
README/03/05/07 and ADR-288 with the derived operating point.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) is the
countermeasure counterpart to BFLD (ADR-118/121): where BFLD detects when
beamforming feedback becomes identifying, VEIL shapes a node's own outgoing
feedback so an unauthorized passive sniffer cannot re-identify people, while
a legitimate receiver that shares the per-session key sees an unchanged link.
Mechanism: identity leaks through the fine cross-subcarrier phase structure of
a compressed beamforming report; throughput rides the dominant beam direction.
These are (mostly) separable subspaces. VEIL composes extra keyed Givens
rotations (the report's native primitive) over the fine subspace only. The
rotation is orthogonal (energy-preserving -> not jamming), keyed per session
(the AP inverts it -> throughput preserved), and fresh each session (a sniffer
cannot average it back -> re-identification collapses to chance).
Contents:
- v2/crates/wifi-densepose-privshield: deterministic, dependency-free,
WASM-ready pure-compute leaf implementing the attacker-vs-protector
experiment, the four compliant controls, a throughput model, a
machine-checkable "not jamming" compliance audit, and a pinned witness.
29 tests + doctest pass; clippy -D warnings clean; builds for
wasm32-unknown-unknown.
- docs/research/privacy-shield: 8-file research bundle (SOTA, threat model,
design, compliance/regulatory, experiment protocol, market, roadmap).
- docs/adr/ADR-288: formal decision record.
Reference results (SYNTHETIC / L0, N=16 identities): passive re-ID accuracy
100% shield-off -> 7.8% shield-on (chance 6.25%); modeled throughput ratio
98.0%; emission energy ratio 1.000000 (compliant). All defense numbers are
SYNTHETIC until a two-node hardware capture with a witness exists.
Compliant waveform controls only; never jamming (47 U.S.C. 333/302a analysis
in the bundle).
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
ADR-283 was already taken by ADR-283-ruview-community-metaharness-flywheel.md,
merged to main before this branch's work started -- picked without checking
against main's actual current ADR list. Renumbered to ADR-287, the next free
slot after ADR-286 (the wifi-densepose-sar-harness ADR, no collision there).
Updated every reference across the crate (Cargo.toml description, lib.rs/
geometry.rs/measurement.rs/pointcloud.rs/reconstruct.rs/resolution.rs doc
comments, tests/physics_validation.rs), its README, the tutorial doc,
CHANGELOG.md, and the workspace Cargo.toml's member comment. 25 tests still
pass after the rename (doc-comment-only changes, no logic touched).
wifi-densepose-pointcloud's Cargo.toml had no license/authors/repository
fields at all (crates.io rejects a publish with "missing or empty
metadata fields: license") -- added the standard
authors.workspace/license.workspace/repository.workspace trio every
other crate in this workspace already uses.
nvsim-server's path dependency on nvsim had no version requirement
(crates.io rejects "all dependencies must have a version requirement
specified when publishing") -- pinned to nvsim = "0.3.1", the version
just published.
Found while publishing both crates for the first time; both now publish
cleanly (verified via cargo publish --dry-run before and after).
focus_at_point called Complex64::from_polar (a sin/cos pair) once per
(pose, frequency) term. FrequencySweep::frequencies() produces evenly
spaced frequencies by construction, so the per-term phase is an
arithmetic progression in the frequency index -- the phasor can be
evaluated once per pose and advanced by a fixed complex-multiply step
per frequency instead, turning K trig evaluations into 2.
focus_at_point's signature changes from a raw &[f64] frequency slice
to &FrequencySweep, so the evenly-spaced-frequencies precondition
this optimization depends on is a type-level invariant rather than a
caller-observed one -- an arbitrary non-uniform frequency list is no
longer constructible through this API at all.
MEASURED (criterion regression detection, p < 0.001): ~4.4-4.5x
faster across 512/4096/32768-voxel grids (300us/1.97ms/14.5ms vs the
prior 1.47ms/10.4ms/73.5ms). Proven equivalent, not just faster: a new
test independently reimplements the direct per-frequency computation
as a reference and checks the optimized path against it across four
sweep sizes (incl. the n_steps=1 degenerate case) and both on-target
and off-target points, to <1e-9 relative error.
25 tests (22 unit + 3 integration), 0 failed, clippy-clean.
New standalone leaf crate implementing the synthetic-aperture-radar
reconstruction primitive a handheld through-wall RF imaging device
would need: a stepped-frequency multi-position complex forward
measurement simulator, delay-and-sum backprojection reconstruction,
point-cloud extraction, and closed-form range/cross-range resolution
+ antenna-pose coherence-budget formulas checked against the
reconstruction's actual behavior in tests/physics_validation.rs.
Motivated by comparing this repo against Applied Electrodynamics'
"WaveSight" launch. Scoped explicitly below ADR-278's RISE/DiffRadar/
GeRaF reproduction gates: this is the bare measurement-model +
backprojection primitive, not a reproduction of any published system
or a claim about real hardware capability. Every number is
SYNTHETIC/L0 (ADR-282) -- no wideband RF hardware backs this crate.
24 tests (21 unit + 3 integration), 0 failed, clippy-clean. Adds a
tutorial walkthrough and MEASURED backprojection benchmark numbers.
--convert-model rejected the published model.safetensors because its
JSON header is padded to an 8-byte boundary with trailing NUL bytes,
which a strict serde_json::from_slice parse treats as trailing
characters. Trim the padding before parsing, with a regression test
that reproduces the exact boundary-padded shape of the real HF file.
docs/huggingface/MODEL_CARD.md had drifted from the card actually
published on the Hub: every file in its "Files in this repo" table
(pretrained-encoder.onnx, pretrained-heads.onnx, pretrained.rvf,
room-profiles.json) does not exist in ruvnet/wifi-densepose-pretrained.
Replaced it with the content live on the Hub and added a section
documenting the --convert-model / --model RVF conversion path, which
neither card mentioned.
HAP accessory signing seed no longer reachable via derived Debug. /api/history/period and /api/logbook no longer break the default (unfiltered) call shape above 32 entities. fire_event's event_type validation relaxed to match real HA's contract. homecore-migrate gained a --force flag for re-running imports. Public v2051 release notes corrected. 110 tests across the 3 touched crates, 0 failed, clippy clean.
Fixes#1422, Fixes#1423. HeartRateExtractor no longer truncates subcarrier count on empty phases; BreathingExtractor resets immediately on first out-of-band rejection instead of passively draining a stale window (recovery 28.6s -> 6.0s). 64 tests passing, clippy clean, zero regressions workspace-wide.
Fixes the calibration-path bug in #1440. Disables the variance channel when occupied-anchor variance doesn't genuinely exceed the empty-room baseline, mirroring the existing mean-shift channel's fallback. 64 tests passing, zero regressions.
Fixes#1442. Extracted classify_vitals() so presence is always derived from the label, matching the convention already used elsewhere in the codebase. 4 new regression tests, 707 tests passing, zero regressions.
Published: wifi-densepose-core 0.3.2, -vitals 0.3.2, -wifiscan 0.3.2, -hardware 0.3.2, -signal 0.3.6, -nn 0.3.2, -ruvector 0.3.3, -train 0.3.3, -mat 0.3.2, -wasm 0.3.1.
Not published (blocked, needs a decision): wifi-densepose-sensing-server and wifi-densepose-cli both path-depend on ruview-auth, which is publish = false. Version bumps reserved (0.3.5, 0.3.2) but not published.
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.
Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).
Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
Closes the two findings from the adversarial review that were verified but left
unfixed. Both now have proper tests, each proven to fail against the old code.
1. PyO3 bindings panicked / over-allocated on caller input (aether.rs).
`EmbeddingExtractor(n_heads=0)` reached `d_model % n_heads` in the transformer
and panicked (surfacing to Python as an opaque PanicException); a non-divisor
head count tripped the native assert; and `AetherConfig(d_model=100_000)`
allocated multi-gigabyte weight matrices that abort the interpreter. The
binding did no validation.
Now both constructors return `PyResult` and validate at the boundary —
positive dims, `d_model % n_heads == 0`, and a generous MAX_DIM/MAX_LAYERS cap
— raising `ValueError`. Proven: `n_heads=0` -> "n_heads must be positive",
`d_model=100_000` -> a clean ValueError, both previously a
PanicException / abort. +10 pytest cases (test_aether.py); a valid config
still constructs and embeds.
2. The single-training-job guard was a TOCTOU race (training_api.rs).
`spawn_training_job` checked `is_active()` under a `state` READ lock, released
it, then set `active` later. A tokio RwLock read lock is SHARED, so two
concurrent `POST /train/start` could both hold it, both see the slot free, and
both spawn jobs — sharing/overwriting one status+cancel and orphaning a task
handle.
Extracted `claim_training_slot`, which does the check-and-set in ONE `status`
mutex scope — the atomicity lives on the status mutex, not the coarse state
lock — so concurrent starts serialise and exactly one wins. This also makes it
unit-testable without a full AppState.
Test: 32 threads hit a barrier and race to claim; asserts EXACTLY ONE wins.
Mutation-proven — reverting to the split check-then-set makes it fail
(`left: 3, right: 1`), and it returns to 1 with the fix.
Verified on aarch64/macOS: training_api 28 pass (26 existing + 2 new), full
python/tests suite 237 pass (227 + 10). The native module keeps its internal
assert as a defence-in-depth invariant; the binding now enforces it at the edge.
Co-Authored-By: Ruflo & AQE
A cross-vendor review of the parity rework (2febbb81) found three issues; all
verified and fixed here.
1. HIGH — the native≈golden half never ran in CI, so a binding marshalling bug
could pass. The golden vectors were regenerated through the Python binding,
and only pytest (binding vs that golden) runs in CI — the native reference
tests in `python/tests/aether_parity.rs` link against the PyO3 crate and no
workflow runs them. A stable PyO3 conversion defect present at regeneration
would therefore be baked into the golden and go undetected.
Fix: a real native parity test IN the `wifi-densepose-aether` crate
(`tests/golden_parity.rs`), which is std-only and a member of the v2
workspace, so it runs under the existing `cargo test --workspace`. It
recomputes the embedding with no PyO3/marshalling and asserts it matches the
SAME committed golden within tolerance. Now native≈golden AND binding≈golden
both run in CI ⇒ binding≈native, and a binding-specific artifact in the
golden surfaces here as a native mismatch. serde_json added as a
dev-dependency only (test-only; never linked into the lib or the wheel, so
the crate stays runtime-dependency-free).
Independently proven at this commit: native output equals the committed
golden BIT-FOR-BIT (128/128 exact bits, Δ=0) for both base and loaded — the
golden is native-faithful today; this test keeps it that way.
2. MEDIUM — the Python parity helper passed non-finite output. `abs(nan - b) >
tol` is False, so an all-NaN embedding slipped through. Now every element
must be `math.isfinite` first. Proven: an all-NaN vector is rejected
("element 0 is not finite (nan)"); the Rust helpers already caught it via
`<=`.
3. LOW — a coherent shift inside the per-element tolerance could move the whole
vector undetected. Added a whole-vector cosine-similarity bound (≥ 1 - 1e-6)
alongside the per-element check.
Verified on aarch64/macOS: `test_aether` 13, full `python/tests/` suite 227
passed against a `--features sota` build; the new native test passes
(2/2) run standalone (the in-worktree `cargo test -p` only failed on an
un-checked-out submodule; ci.yml checks out submodules recursively).
Co-Authored-By: Ruflo & AQE
Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was open
is not being built. Destructive operations — training, model delete, recording
delete — keep their home in the CLI, where `--admin` is explicit and typed by a
person. Routing them through a browser would mean either asking every user to
consent to delete capability in order to watch a stream, or building a second
consent flow to avoid that.
Consequences, now settled rather than open:
- The UI's admin controls are unreachable from a Cognitum browser session, and
that is intended. The manual token-paste field is unchanged and still carries
whatever authority the pasted token has, so nothing that worked before stops
working.
- REMOVED the client-side step-up redirect from ui/services/api.service.js. It
caught an RFC 6750 challenge that can never be issued to a browser, and it
ended in `return new Promise(() => {})` — so if any other 401 had ever grown
that header, every caller awaiting it would have hung forever with no error
and no timeout. Dead code with a trap in it is worse than no code.
- KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so
if the requested scope is ever widened the freshness requirement is already
in place. Documented at its definition as a backstop specifically so nobody
reads its passing tests as evidence the control is exercised — the tests
reach it through a crate-internal seam that mints an admin cookie the real
flow does not produce.
ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B
(server-side refresh-token store) is not built, and the ADR now names the
residual instead of implying it is closed — within one hour a revoked Cognitum
grant still reads sensing data through an existing browser session. There is no
introspection endpoint, so nothing short of B closes that, and one hour is the
size of the hole we accepted.
Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR
revision asked for and nobody had written: Max-Age and the payload's `exp` are
two independent expressions of one lifetime, and drift means either the browser
presents a session we reject or we hold authority the browser discarded.
Verified: workspace 176 suites clean, UI 22, api.service.js parses.
Co-Authored-By: Ruflo & AQE
A cross-vendor pre-merge sweep found no merge blockers, but did surface an
error in ADR-271 that I wrote — and a coherence gap behind it.
ADR-271 P2 stated that capping the browser session at `sensing:read` was
"considered and rejected, because the dashboard genuinely performs admin
operations". That is wrong. `/oauth/start` (main.rs:9206) already requests
`SENSING_READ` and nothing else, deliberately, with a comment saying so. The
browser session is ALREADY read-only, so the breakage I claimed capping would
cause is simply the current behaviour.
Two consequences, now stated in the ADR instead of left to be discovered:
1. The UI's admin controls do not work from a browser OAuth session.
`model.service.js:136` issues DELETE /api/v1/models/{id}, which 401s. Admin
work needs the CLI (`login --admin`) or a pasted admin bearer. A gap in a new
feature, not a regression — the token-paste path is unchanged.
2. The ADMIN_REVERIFY_SECS step-up control added in the previous commit guards
a case that cannot currently arise. No browser session holds `sensing:admin`,
so the freshness branch never fires in production. Its tests pass because the
crate-internal seam mints an admin cookie the real flow never produces.
That second point is worth being blunt about: it is the same shape as several
defects this branch already fixed — correct code, green tests, unreachable call
site. The difference is that here the guard is deliberately ahead of the need
rather than mistakenly behind it, and saying which one it is matters.
So the constant is now named `BROWSER_SIGNIN_SCOPE` rather than inlined, with
the cost of widening it documented at the definition, and two tests:
`browser_sign_in_stays_read_only_until_someone_decides_otherwise` pins the
value, and `the_authorize_url_actually_carries_that_scope` proves it reaches the
wire — asserting on the constant alone would pass even if `begin` were called
with something else, which is exactly the isolation failure being guarded
against.
The ADR also records the coherent way to add browser-side admin if wanted:
escalate-on-demand via the RFC 6750 challenge, keeping least privilege by
default rather than asking every user to consent to delete capability to watch
a stream. Not bundled here — it needs a scope parameter and a UI affordance.
Sweep verdict: no merge blockers. Two other non-blocking risks it raised are
accurate and unchanged: concurrent JWKS refresh at the stale boundary is not
atomic (a duplicated idempotent GET, already documented as an accepted cost),
and the service worker's SHELL_ASSETS use root-relative paths while the UI
mounts under /ui, so offline shell precaching is incomplete — pre-existing,
unrelated to this branch.
Verified: workspace 176 suites clean.
Co-Authored-By: Ruflo & AQE
Found by an empirical route sweep during the pre-merge pass, not by a test.
The step-up gate fired BEFORE the scope check, so a caller holding only
`sensing:read` who attempted a privileged action with a session older than
ADMIN_REVERIFY_SECS received the RFC 6750 "reauthentication required"
challenge. `api.service.js` acts on that by redirecting through /oauth/start —
and the user returns with exactly the same scopes and is refused again. One
wasted round trip, and a reason for the refusal that was simply untrue: they
were not refused for staleness, they were refused for capability.
Now the scope check comes first, so only a caller who actually HOLDS
`sensing:admin` is ever asked to prove the session is fresh. Not a loop before
(the second refusal is a plain 401 with no challenge) and not a security issue
either way — both paths refuse. It was misleading, and it cost a redirect.
Also confirms the deny-by-default gate does not over-reach. Probed the real
binary with auth off vs on:
/, /ui/*, /health, /health/{live,ready,metrics,version},
/oauth/{status,start} unchanged
/api/field, /api/v1/* 200 -> 401 (intended)
/metrics, /favicon.ico 404 -> 401 (never existed; now uniform)
Nothing that returned 2xx before returns 401 now, which is the property that
matters for existing deployments.
Tests: +2. `a_read_only_user_is_not_sent_to_reauthenticate_pointlessly` fails
against the old ordering with the challenge header present; its counterpart
`an_admin_holder_with_a_stale_session_does_get_the_challenge` ensures the fix
does not simply disable the signal.
Verified: workspace 176 suites clean, ruview-auth 62+25+2 --all-features.
Co-Authored-By: Ruflo & AQE
All three deferred findings from the qe-court round. Each fix is guarded by a
test confirmed to FAIL against the old behaviour.
P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker.
`fetched_at` advances only on SUCCESS, and the only rate limiter sat behind
`if fresh`. So once the TTL elapsed after the last successful fetch, `fresh`
was permanently false, the limiter was never consulted, and EVERY request
performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled
itself 300s later with no attacker present; an attacker could force the same
state by flooding tokens with an unknown `kid`.
Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome,
and gates the stale path too; a stale-but-present key is served rather than
erroring, which is the offline tolerance this module always claimed.
Measured by the new test: 26 outbound fetches before, 1 after.
Kept as TWO independent limiters. Merging them looks tidy and is wrong — a
routine refetch would then suppress the unknown-`kid` path for 30s and delay
pickup of a key rotation inside the TTL. I made that mistake first; two
existing tests caught it.
The blocking call also now runs in `spawn_blocking` at the verify boundary,
matching what `main.rs` already does for the token exchange, where the comment
reads "the same mistake this codebase had to fix in jwks.rs". The hot
verification path had never been given the same treatment. A panicked task
fails closed.
P2 — session lifetime, per decision: 1 hour, plus step-up.
SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now
require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min),
tracked by a new `auth_time` claim. Reads ride the full session; only the
routes where a stale session does damage are re-verified, so a dashboard whose
main use is watching a live stream does not re-auth hourly.
`auth_time` is `#[serde(default)]`, so a cookie issued before the field existed
reads as 0 — infinitely stale. Such a session keeps working for reads and
cannot perform privileged actions. Fail-closed and self-healing on next sign-in.
The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the
client's correct response differs from a plain 401: the user IS signed in and
needs to prove it again. `api.service.js` acts on that and redirects through
`/oauth/start` — otherwise a stale-session delete surfaces as a generic
"Request failed" with no hint that signing in again fixes it.
P3 — cookie shadowing.
`read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path`
cookies first. Cookies are not isolated by port or scheme, so any other service
on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant
`ruview_session=<their own validly signed session>; Path=/ui`. The victim sent
both, the attacker's first, and it verified because it genuinely was signed:
silent session takeover, with `/oauth/status` reporting the attacker's account.
The signature was doing its job throughout, which is why "it's signed" never
answered this. `__Host-` would, but requires `Secure`, and RuView is routinely
reached over plain HTTP on a LAN.
So both credential paths now accept only when EXACTLY ONE candidate verifies.
An attacker can still cause a refusal by planting a second valid cookie — a
nuisance — but no longer a takeover. Planting junk changes nothing, so this
does not become a trivial DoS.
Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1
duplicate-name reader. Mutation-verified: reverting the stale-path guard gives
26 fetches; reverting to first-match cookie reads fails
`a_shadowing_cookie_cannot_silently_take_over_the_session`.
Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with
--all-features, UI 22.
ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains
why each fix has the shape it does.
Co-Authored-By: Ruflo & AQE
Browser sign-in was the newest security surface in this PR and had no
executable evidence behind it: browser_session.rs was 534 lines with 13 tests,
every one of which hit a private helper (sign, unsign, cookie, read_cookie,
is_live, has_scope). No test called issue, from_cookie_header, begin,
verifier_for_callback or is_configured, and no test anywhere presented a session
cookie to the gate.
+10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial
review named, each now verified dead by actually applying the mutation:
(a) delete the `state` comparison in verifier_for_callback
-> a_callback_whose_state_does_not_match_is_refused FAILED
Without it the callback accepts a code from a flow the user never
started: login CSRF, victim silently lands in the attacker's session.
(b) `session.is_live().then_some(session)` -> `Some(session)`
-> an_expired_session_cookie_does_not_authenticate FAILED
-> an_expired_browser_session_is_refused FAILED
`is_live` was already unit-tested; nothing asserted the CALLER consults
it. Same "tested in isolation, call site untested" shape as the earlier
refresh-never-invoked defect.
(c) `session.has_scope(required)` -> `true`
-> a_read_scoped_browser_session_cannot_delete_or_train FAILED
Without it any browser session could delete models and start training.
Mutant (c) initially appeared to SURVIVE. It did not — there are two
has_scope call sites and the first substitution only hit one. Mutating the
one in `session_or_unauthorized` kills the test. That accident confirmed a
separate finding: the cookie branch inside require_bearer is unreachable when
an Authorization header is present, because the OAuth step returns on both
arms. It fails closed, so it is not a hole, but "try the next credential" is
what the code reads like. Pinned by
a_bad_bearer_beats_a_good_cookie_rather_than_falling_back.
Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value).
test_cookie_value signs through the same path as `issue`, so tests presenting a
cookie exercise real verification rather than a test-only bypass.
ADR-271:
- The "browser cannot obtain an OAuth token" section asserted
`grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three
files, invalidated by commits in this same PR. Marked superseded, original
retained under a fold, replaced with what actually ships.
- Records the two deferred decisions with designs rather than patches: P1 the
blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on
exactly the stale path that matters, because fetched_at updates only on
success — so after the TTL every request fetches, and a Pi that loses WAN
stalls itself with no attacker present); P2 the 12-hour session from a
15-minute token, with three costed options. Capping the session to
sensing:read was considered and rejected: the dashboard genuinely issues
DELETE /api/v1/models/{id}.
- P3 records that dropping `__Host-` costs origin-integrity, not just Secure —
read_cookie takes the FIRST match and cookies are not port-scoped, so a
same-host writer can shadow a session. Forgery was never the threat that
prefix addresses.
- Documents redirect_uri's hardcoded default and the unconsumed CLI credential
as known-incomplete, per decision to leave both as-is.
ADR-272: corrected a claim that would mislead users into a 401. It stated the
Python client DOES send Authorization: Bearer on the handshake; ws.py passes no
headers at all (zero occurrences of extra_headers or Authorization), so every
published client 401s once auth is enabled. Server-side decision unchanged.
Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags,
ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7.
Co-Authored-By: Ruflo & AQE
Findings from a qe-court adversarial round (4 prosecutors across 2 vendors).
Each was verified against the code before being accepted; the ones below
reproduced, the rest are reported in the PR thread rather than acted on.
FATAL — `/api/field` was reachable with no credential, on both listeners.
The gate protected `/api/v1/*` by prefix. `/api/field` is the REST sibling of
`/ws/field` and serves the same signed FieldEvent stream — live presence, pose,
vitals. `/ws/field` was gated in this PR; its twin one path segment over was
not. Measured with RUVIEW_API_TOKEN set and no credential supplied:
/api/v1/models 401 (control)
/ws/field 401 (gated by this PR)
/api/field 200 on :8080 AND :8765
Fixed by inverting the gate to deny-by-default with an explicit anonymous
allowlist (`/`, `/ui`, `/health`, `/oauth/`). A route added at a new path is
now gated because nobody exposed it, rather than exposed because nobody
protected it — the same inversion already applied to the scope gate.
FATAL — the wiring test disarmed itself exactly when it mattered.
`Server::start` returned an Option that all five tests turned into `return`,
so a server that failed to boot produced "5 passed" with zero assertions run,
and cargo swallows the skip line without --nocapture. The one test that
observes real wiring — the guard against both shipped bypasses — was silent
for any change that breaks startup, including a boot panic in the auth path.
It now panics with the child's stderr.
MAJOR — a malformed client-id list silently disabled the audience check.
An empty allowlist is the opt-out sentinel in verify.rs. `RUVIEW_OAUTH_CLIENT_IDS=","`
is non-empty, passes the guard, then filters to an empty Vec — turning the
audience boundary off with no log and admitting a token minted for any other
Cognitum product. Only a literal `*` may opt out now; anything else that parses
to nothing warns and falls back to the default. Same fail-open shape as the
scope denylist this PR already had to invert.
MAJOR — credentials were world-readable for a window on every refresh.
`fs::write` creates at 0666 & !umask (0644 by default), and both writers
chmodded afterwards. The existing permissions test asserted on the FINAL file
and passed throughout. Affected the CLI refresh token (rotated with reuse
detection — a thief who presents it first takes the session family) and the
browser session secret (the HMAC key for every session; stealing it forges any
account at any scope). Both now create with mode 0600 via OpenOptions.
MAJOR — ui/sw.js cached authenticated API responses.
Closing the /oauth/ leg left the /api/ leg open. `networkFirst` cached every
successful response, keyed by URL alone, purged by nothing at sign-out: sign in
as A, load sensing data, sign out, sign in as B, lose the network, and B is
served A's data with no authorization check. API responses are now network-only
— which is also the correct behaviour for a live sensing dashboard, where
replaying a stale reading can show a room occupied after the person left — plus
a cache purge on sign-out.
CI — 40 of ruview-auth's 87 tests never ran.
The workspace runs --no-default-features, which switches off the `login` and
`pkce` features. Measured: 47 tests vs 87. The whole interactive sign-in path —
credential storage, single-flight refresh, the file lock, the loopback callback
— was green locally and never executed in CI. Added an --all-features step.
(Checked the sensing-server for the same problem and did NOT find it:
bearer_auth's 49 and browser_session's 15 do run under CI flags.)
Tests: +2 wiring tests (one fails against the old gate with
"http port served /api/field to an anonymous caller", passes after), +3 UI
service-worker tests, +3 CLI scope tests, +1 temp-file permission test, +1
client-id parsing test. wifi-densepose-cli/src/auth.rs had zero tests and
builds its own scope string, so the library's least-privilege test said nothing
about what the CLI requests.
Verified: ruview-auth 61+25+2 pass (--all-features), sensing-server bearer_auth
50, browser_session 15, auth_wiring 7, workspace 25 suites clean, UI 22.
Co-Authored-By: Ruflo & AQE
Browser sign-in worked, but the settings panel kept offering "Sign in with
Cognitum" afterwards; only a hard reload showed the true state. The server was
correct throughout.
Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path
outside `/api/` and `/health/`. `/oauth/status` therefore had its first
(signed-out) response stored in the Cache API and replayed to the page forever.
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the
`no-store, no-cache, must-revalidate` the server already sends could not prevent
it. A hard reload bypasses the service worker, which is why that alone appeared
to fix it, and why signing out re-poisoned the entry.
Fixes, in order of blast radius:
- `/oauth/` is never handled by the worker. Not network-first — that still
writes a copy, which would be replayed the moment the server is briefly
unreachable, silently reinstating a stale sign-in state.
- Cache-first is now an ALLOWLIST (navigations and static asset extensions)
rather than the catch-all. This is the underlying defect: any endpoint added
outside `/api/` was frozen on its first response. Unrecognised paths now go
to the network untouched.
- `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned
`ruview-v1` from browsers that already ran the old worker. Verified: the
cache list went from `[ruview-v1]` to `[ruview-v2]` on update.
- Shell lookups use `ignoreSearch` and store a search-less key, so the
`?signed_in=<ms>` the callback redirects to does not mint a fresh, never-hit
cache entry per sign-in.
Also: re-check sign-in state on `pageshow` (bfcache restore, where no script
re-runs) and on `visibilitychange` (signed in or out in another tab). Opening
the panel alone is not sufficient — it may already be open.
Verification, in a real browser driven end-to-end:
- Reproduced with a valid session cookie: server returned `signed_in: true` to
curl while the page's own fetch got a cached `signed_in: false`, and the
request never appeared in the server log at all.
- Confirmed the cached entry existed: `caches.open('ruview-v1').match(
'/oauth/status')` returned the signed-out body.
- After the fix, on a NORMAL (not hard) reload the panel reads
"Signed in as <account> - sensing:read" with Sign out shown.
Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and
asserts the routing decision per path. 4 of its 10 tests fail against the
pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET,
websocket upgrade, cross-origin, API paths, static assets, navigation) and pass
in both, so they track behaviour rather than the rewrite.
CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this
suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is
the same blind spot that let this defect ship.
Removes the temporary `/oauth/status` cookie logging and the panel console
diagnostic added while tracking this down.
Co-Authored-By: Ruflo & AQE
Two bugs, one of which I introduced while fixing the other and caught only by
checking the actual response.
1. THE SPENT TRANSACTION COOKIE WAS NEVER CLEARED ON SUCCESS.
`clear_transaction` was only used on error paths, so after a successful
sign-in `ruview_oauth_txn` lingered for its full 10-minute TTL and every
subsequent request carried a dead cookie. Visible in the logs as
`names=ruview_oauth_txn,ruview_session`. Now cleared alongside issuing the
session, and on logout too.
2. THE FIX FOR (1) BROKE SIGN-IN, AND THE TEST CAUGHT IT.
Axum's array-of-tuples response form REPLACES same-name headers rather than
appending. Adding a second `Set-Cookie` silently overwrote the first, so the
logout response emitted only the transaction clear and — had this shipped —
the CALLBACK would have emitted only the transaction clear too, dropping the
session cookie and making a successful OAuth round-trip a no-op.
Caught by looking at the real response headers rather than trusting the
change: `curl -D-` showed one Set-Cookie where there should have been two.
Now uses `axum::response::AppendHeaders`. Both cookies verified present.
Two tests pin this: one DOCUMENTS the footgun (the array form collapses two
Set-Cookie headers into one) and one asserts AppendHeaders emits both. The
first exists so the next person to reach for the tidier-looking array form
finds out here instead of in production.
Also adds a cache-busting query to both redirect targets
(`/ui/?signed_in=<ms>` and `?signed_out=<ms>`). Landing on the same URL let the
browser restore the page from the back/forward cache with a stale panel, which
is why signing in appeared not to work until a hard refresh — the session was
established correctly every time, the page simply was not re-fetched.
Tests: 549 sensing-server lib.
Co-Authored-By: Ruflo & AQE