docs/adr/ADR-161-homecore-server-layer-security.md
Beyond-SOTA sweep Milestone 7, over the HOMECORE server/network layer
crates only — homecore-api, homecore-server, homecore-automation,
homecore-hap, homecore-plugins — executed under the project's
prove-everything / anti-"AI-slop" directive.
The same audit pattern as ADR-160 held for the library logic: the automation trigger/condition/template/action evaluators, the REST handlers, the HAP mapping, and the plugin manifest parser are real, tested code — not stubs. That is the anti-slop positive and it is cited here as such.
What the audit found was not fake business logic but an unsound trust boundary plus documented-but-no-op features:
result/pong/event ever reached the client.RunMode, Choose branches, and template
conditions were each documented as working but were no-ops in the live
path.This is a worse class than ADR-160's over-naming: here the doc claimed a capability the code did not deliver (auth enforcement, reply transport, running automations). The fix is implement where feasible, honestly relabel where not — never leave a false doc. Every fix is pinned by a test that fails on the old code.
Grading vocabulary (ADR-152 / ADR-158 / ADR-160):
homecore-api/src/ws.rs handshake checked only token.trim().is_empty() and
sent auth_ok for any non-empty token. It never called
state.tokens().is_valid() — the check the REST path uses via
auth::BearerAuth. With a provisioned HOMECORE_TOKENS whitelist, any
attacker-chosen non-empty token got full WS access (read all states, call any
service, subscribe to all events).
Real fix: the handshake now calls
state.tokens().is_valid(&token).await (the same store + method as REST).
A wrong token receives auth_invalid and the socket closes. DEV (allow_any)
mode still accepts any non-empty bearer with a warn, so smoke tests keep
working; the empty token is rejected inside is_valid.
Failing-on-old test (tests/ws_handshake.rs):
wrong_token_is_rejected — provisions a real (non-dev) store with one good
token, sends a DIFFERENT non-empty token over the WS handshake, asserts
auth_invalid. On the old source the client received
{"type":"auth_ok",…} (verified: the test panics on old ws.rs with
left: "auth_ok", right: "auth_invalid"). Companion: correct_token_is_accepted.
Grade: MEASURED. This is the milestone headline.
ws.rs::Connection::run moved the socket into a recv-only task; the only
consumer of the response mpsc just did debug!("ws emit: {msg}") and dropped
every message. No command reply ever reached the wire.
Real fix: the socket is split with futures_util::StreamExt::split. A
dedicated writer task drains the response channel onto sink.send(...)
(text frames; a __pong:<n> sentinel maps to a Pong control frame); the reader
task parses commands concurrently. On reader exit the senders drop and the
writer task ends cleanly.
Failing-on-old tests: result_reply_is_received (connect → auth →
get_states → assert a result reply is RECEIVED within 5s) and
ping_pong_reply_is_received. Both time out on the old source (verified:
Elapsed panic). Grade: MEASURED.
homecore-api bin: no env-token path, network-exposed (HIGH, security) — MEASUREDhomecore-api/src/bin/server.rs bound 0.0.0.0:8123 with
SharedState::new() → allow_any_non_empty() and no HOMECORE_TOKENS
path (unlike homecore-server), so a provisioned operator had no way to lock
it down.
Real fix: the bin now mirrors homecore-server's provisioning — prefer the
HOMECORE_TOKENS whitelist (LongLivedTokenStore::from_env()), fall back to an
explicitly warn-logged DEV mode only when unset. It also defaults the bind
address to 127.0.0.1 (loopback) so a bare cargo run is not
network-exposed, with HOMECORE_BIND to opt into LAN.
Failing-on-old test (tests/server_bin_auth.rs):
provisioned_bin_rejects_wrong_bearer reproduces the bin's exact provisioning
path (a populated, non-dev store) and asserts a wrong bearer → 401;
from_env_path_enforces_whitelist proves from_env() is not dev mode and
enforces the list. The old bin's allow_any_non_empty() accepted the wrong
bearer. Grade: MEASURED.
homecore-server/src/main.rs did let _automation_engine = AutomationEngine::new(...)
then dropped it immediately, while the header doc claimed "Automation engine
subscribed to the state machine."
Real fix: the engine is now built into a long-lived binding and .start()
is called, spawning the event loop + timer task; the header/log lines state it
is started with N automations and which trigger classes are active. (With A4–A7
the running engine is genuinely functional, not theater.)
Evidence: the engine-behavior tests below run against the same
AutomationEngine::start() path now wired into the bin. Grade: MEASURED.
Trigger::Time hard-coded false, no timer (HIGH) — MEASUREDtrigger.rs::matches_sync returned false for Time and there was no timer
task anywhere, so time automations could never fire.
Real fix: AutomationEngine::start_timer — a 1 Hz tokio interval that
compares each time: automation's at (HH:MM or HH:MM:SS) against the
local wall-clock second and fires it once per match (conditions still gate it).
matches_sync returning false for Time is now correct and documented
(it is a wall-clock trigger with no state-change context); a public
fire_time_for_test exposes the same path deterministically.
Failing-on-old test (tests/engine_behaviors.rs):
time_trigger_fires_via_timer_path (+ unit time_at_matches_handles_hh_mm_and_hh_mm_ss).
The method does not exist on the old engine. Grade: MEASURED.
RunMode documented as AtomicBool-enforced but unbounded-parallel (HIGH) — MEASUREDengine.rs doc claimed "RunMode::Single is enforced via a per-automation
AtomicBool" — but no such code existed and every trigger spawned an
unbounded parallel task regardless of mode.
Real fix: each registered automation carries a running: Arc<AtomicBool>.
Single/IgnoreFirst modes compare_exchange the flag before spawning and
skip the trigger if a run is already in flight, clearing it on completion;
Parallel (and, for now, Restart/Queued) spawn on every trigger.
Failing-on-old tests (tests/engine_behaviors.rs):
single_mode_does_not_double_fire_on_rapid_triggers (two rapid triggers while
the first run sleeps → exactly 1 run; old code fired 2, verified) and
parallel_mode_does_fire_concurrently (→ 2). Grade: MEASURED (Single/Parallel
honored; bounded Queued/Restart/max ordering → ACCEPTED-FUTURE, see below).
Action::Choose ignored branches (HIGH) — MEASUREDaction.rs discarded choices and always ran default.
Real fix: ChoiceBranch::matches deserialises each branch's
serde_yaml::Value conditions into Condition and evaluates them (AND
semantics, against an EvalContext now carried on ExecutionContext). Choose
runs the first matching branch's sequence and falls to default only if
none match.
Failing-on-old tests (action.rs inline):
choose_runs_matching_branch_not_default (matching branch runs, default does
NOT — old code ran default, verified) and
choose_falls_to_default_when_no_branch_matches. Grade: MEASURED.
condition.rs returned false for Template whenever template_env was
None, and the engine built every EvalContext with template_env: None
(EvalContext::new), so template: conditions could never be true in
production — only in unit tests that hand-built a template env.
Real fix: the engine constructs one TemplateEnvironment over the state
machine and threads it into every EvalContext via
EvalContext::with_templates (event loop, timer task, and
ExecutionContext for Choose branches).
Failing-on-old tests (tests/engine_behaviors.rs):
template_condition_evaluates_true_in_engine (a {{ is_state(...) }} condition
gates an action true) and template_condition_evaluates_false_blocks_action.
On the old engine the action never ran (template always false, verified).
Grade: MEASURED.
homecore-plugins/src/manifest.rs documented wasm_module_hash as "verified
before execution" and carried wasm_module_sig / publisher_key, but these
fields are never read for verification (only ever set to None in tests).
Fix (honest labeling — no false capability claimed): the three fields are re-doc'd "(P4 — not yet enforced, ADR-161/B5)" — parsed and round-tripped, but no integrity/signature check happens before a plugin runs. No verification code was added (that is P4); the doc now matches the code. Grade: doc-honesty (no behavior change). (Superseded by ADR-162 §P4: the hash/signature gate is now implemented and enforced.)
These were checked and are genuinely sound/honest; cited as positives, not touched:
uuid::v4; the rng/randn suspicion was
REFUTED. No weak-randomness issue exists.app.rs) — already hardened (explicit AllowOrigin::list,
no permissive(), allow_credentials(false), env override). NO-ACTION.homecore-migrate — audited, clean.InProcessRuntime "no sandbox" disclaimer — honest; left as-is.homecore_permissions claims are parsed
but not enforced at the host-call boundary.hc_state_set now consults a PermissionSet distilled from the manifest;
an undeclared write returns a typed -3 to the guest.wasm_module_hash/wasm_module_sig/publisher_key gate that B5 now honestly
says is absent.WasmtimeRuntime::load_plugin now
SHA-256-checks the module, Ed25519-verifies the signature against
publisher_key, and enforces a PluginPolicy trust allowlist
(secure-default rejects unsigned/untrusted/tampered modules).RunMode::Queued/Restart/max ordering — Single/Parallel are
honored; bounded queueing, restart-kill, and max concurrency are not yet
wired (every non-Single mode is parallel).max: N caps concurrency via a per-automation semaphore.cd v2
cargo test -p homecore-api -p homecore-server -p homecore-automation -p homecore-hap --no-default-features
cargo test -p homecore-plugins --features wasmtime
cargo build --workspace --no-default-features
Result at time of writing (all 0 failed):
server_bin_auth 3; ws_handshake 4)engine_behaviors 5)cargo build --workspace --no-default-features succeeds.LongLivedTokenStore whitelist as REST (A1).result/pong/event frames (A2).homecore-api dev bin defaults to loopback and honors HOMECORE_TOKENS
(A8); it is no longer an open 0.0.0.0 accept-any endpoint by default.Single
run-mode, Choose branches, and template: conditions all function — no doc
claims a capability the code lacks (A3–A7).engine.rs 462; behavioral tests
moved to tests/engine_behaviors.rs).homecore-api follow-up security review (beyond-SOTA pass)A later network-facing review of homecore-api (the remote REST + WS attack
surface) — independent of the ADR-154–159 sweep — found and fixed two real
issues the original M7 pass (which focused on the WS auth bypass HC-WS-01, the
reply-theater HC-WS-02, and the bin token provisioning HC-WS-08) did not catch.
Both are LOW severity and reported at true severity.
GET /api/ was unauthenticated (FIXED)rest::api_root took no headers and unconditionally returned
200 {"message":"API running."}, while every sibling route gates on
BearerAuth::from_headers. HA's APIStatusView inherits requires_auth = True,
so /api/ must return 401 for a missing/wrong bearer. HA clients use the
status route as a token-validation probe; a 200 told a bad-token client its
token was valid and let an unauthenticated party confirm a live endpoint.
LOW severity (the body is a static string; no entity/state data leaks).
Fix: api_root(headers, State) now validates the bearer like get_config.
Pinned by (fail-on-old, tests/server_bin_auth.rs):
api_root_rejects_missing_bearer, api_root_rejects_wrong_bearer (both 200→401),
guarded by api_root_accepts_correct_bearer (still 200 with a valid token).
subscribe_events killed the stream on a broadcast lag (FIXED)The per-subscription task matched Err(_) => break on both broadcast
recv() arms. RecvError::Lagged(n) (a slow consumer falling
EVENT_CHANNEL_CAPACITY= 4,096 events behind) is recoverable — the bus doc says "Lagged receivers must re-sync" and HA keeps the subscription alive across a lag. The old code treated the first lag as fatal, so after an event burst the client's stream went permanently silent with no error frame — a self-inflicted event-delivery DoS under load.
Fix: Lagged(_) => continue (skip the dropped window, re-sync),
Closed => break, on both the system and domain arms of the select!.
Pinned by subscription_survives_broadcast_lag (tests/ws_handshake.rs):
subscribes to a filtered event type, floods 6,000 unrelated events past the
4,096 capacity to force a Lagged, then asserts a subsequent subscribed event
is still delivered (old code: 5s-timeout panic).
BearerAuth::from_headers
→ LongLivedTokenStore::is_valid before any work; the WS handshake validates
the auth token against the same store before the command loop, and
privileged commands are unreachable pre-auth_ok. Token compare is
HashSet::contains (content-independent timing — not the byte-== oracle of
ADR-157 §B4), so no timing-oracle finding. No route skips the gate; no
result-ignored check; no default/empty token accepted.DashMap); :entity_id passes through EntityId::parse, a strict
[a-z0-9_]+\.[a-z0-9_]+ ASCII allowlist that rejects .., /, \, and
absolute paths. No traversal surface.format!-into-response;
service/state bodies are typed serde_json::Value handed to the in-process
registry (HA-equivalent).ApiError maps to fixed status + a typed {message};
ServiceError::HandlerFailed(String) is integration-controlled (HA surfaces
the handler error too), never framework internals/paths/stack-traces — no
ADR-080-class leak.allow_credentials(false) (HC-05),
not permissive().EVENT_CHANNEL_CAPACITY is already named in homecore; CORS
dev-default ports are documented).Tests: homecore-api --no-default-features 25 → 29 (+2 api-root auth,
+1 api-root accept-guard, +1 WS lag-survival), 0 failed. Workspace green.
Python deterministic proof unchanged (homecore-api is off the signal proof
path).
The P2 HAP deferral recorded above is closed as a single security boundary in
homecore-hap; it was not replaced with a success-shaped partial protocol.
Evidence includes a deterministic HAP SRP vector, complete in-process
Pair-Setup and Pair-Verify ceremonies, malformed/proof/transcript tests, record
tamper/replay/oversize tests, persistence lifecycle tests, and a real TCP test
that verifies Pair-Verify, accesses /accessories over encrypted records, then
proves replay closes the connection.
This closes the cryptographic implementation item, not the entire Apple Home product surface. Current-Apple/MFi interoperability has not been certified; transient/split Pair-Setup, writable/timed characteristics, resource endpoints, and persisted AID/IID allocation remain explicitly unsupported.