Compare commits

..

6 Commits

Author SHA1 Message Date
8342904488 fix(firmware/lib): wrap-safe millis() comparison in net_guard reconnect timer
net_guard_tick() compared absolute uint32_t millis() values:
  if (millis() < s_next_retry_ms) return;
This is broken across the ~49.7-day millis() wrap: depending on which
side of the wrap each value lands, retries either tight-loop or stall
indefinitely. The device is designed for multi-month uptime, so this
is a real production case, not a theoretical one.

Replace with the standard wrap-safe pattern using a signed difference.

Found via adversarial review (run 2026-05-01-202910, gpt-5.5 reviewer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:36:06 -07:00
ef00afb14e fix(firmware/lib): validate HMAC secret length and hex format before signing
hmac_sign() previously trusted whatever secret_hex came out of NVS:
- Lengths >128 chars overflowed the fixed 64-byte stack buffer in
  hex_to_bytes (out_len was unbounded).
- Non-hex characters were silently decoded to 0 via strtol with no
  end-pointer check, producing signatures under a corrupted key.
- Empty secrets fell through to mbedtls_md_hmac_starts with len=0.

flash_device.py now rejects malformed --hmac-secret at provision time,
but hmac_sign should also refuse to sign under a malformed key regardless
of how it ended up in NVS (legacy provisioning, partial flash, etc.).

Add length, hex-charset, and even-length validation; make hex_to_bytes
return bool and have hmac_sign return empty HString on any failure
(callers already treat empty as failure via post_json_once).

Found via adversarial review (run 2026-05-01-202910, both reviewers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:36:06 -07:00
96ede7c999 chore: gitignore secrets, pycache, and adversarial-review artifacts
Add patterns for *secret* files (e.g. operator-saved HMAC secrets at
repo root), __pycache__/ directories, and .adversarial-review/ run
artifacts so they don't get accidentally committed via 'git add -A'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:21:15 -07:00
e2dbe6a2d5 fix(server): COALESCE diagnostic columns so v1.0 heartbeats don't clear v1.1 data
store_heartbeat_diagnostics() unconditionally SET each diagnostic column
to its parameter, so a v1.0.0 heartbeat (which omits the five v1.1.0
fields and leaves them as None after Pydantic parsing) erased previously
stored diagnostics for that device. Wrap each parameter in
COALESCE(?, column_name) so omitted fields preserve the existing value.

Found via adversarial review (gpt-5.5 reviewer, run 2026-05-01-191359).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:19:23 -07:00
2226c1b4ca fix(tools): validate flash_device.py HMAC secret format before flashing
--hmac-secret accepted any string and passed it through to NVS, silently
producing a device that cannot authenticate to the server. Reject anything
that isn't exactly 64 hex characters (32 bytes) before generating the NVS
image. Auto-generated secrets are validated too as a defensive check.

Found via adversarial review (both reviewers, run 2026-05-01-192928).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:19:16 -07:00
a0eee0e6d4 fix(firmware): preserve buffered records appended during flush POST
reporter_flush() snapshotted the buffers under lock, released the lock
to POST, then unconditionally cleared the entire buffer on success.
Records appended by reporter_submit_*() during the in-flight POST were
silently erased. Replace clear() with erase() of just the snapshotted
prefix so concurrent appends survive.

Found via adversarial review (gpt-5.5 reviewer, run 2026-05-01-190903).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:19:11 -07:00
6 changed files with 54 additions and 14 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
.worktrees/
.agent/
.claude/
.adversarial-review/
graphify-out/
firmware/.pio/
*.log
*secret*
__pycache__/

View File

@@ -14,12 +14,21 @@ static HString bytes_to_hex(const uint8_t* bytes, size_t len) {
return out;
}
static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) {
if (hex.length() % 2 != 0) return; // malformed — odd-length hex
for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.length(); i++) {
char byte_str[3] = {hex[i*2], hex[i*2+1], 0};
static bool is_hex_char(char c) {
return (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
}
static bool hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) {
if (hex.length() != out_len * 2) return false;
for (size_t i = 0; i < out_len; i++) {
char a = hex[i*2], b = hex[i*2+1];
if (!is_hex_char(a) || !is_hex_char(b)) return false;
char byte_str[3] = {a, b, 0};
out[i] = (uint8_t)strtol(byte_str, nullptr, 16);
}
return true;
}
static bool sha256(const uint8_t* data, size_t len, uint8_t out[32]) {
@@ -52,10 +61,20 @@ HString hmac_sign(const HString& secret_hex,
snprintf(ts_buf, sizeof(ts_buf), "%u", (unsigned)timestamp);
HString message = method + "\n" + path + "\n" + ts_buf + "\n" + body_hash_hex;
// 3. Decode secret from hex
// 3. Decode secret from hex. Reject empty / odd-length / oversized /
// non-hex inputs — flash_device.py validates at provision time, but
// hmac_sign refuses to sign under a malformed key regardless of how it
// ended up in NVS (legacy provisioning, NVS corruption, etc.).
if (secret_hex.length() == 0 ||
secret_hex.length() > 128 ||
secret_hex.length() % 2 != 0) {
return HString{};
}
size_t secret_len = secret_hex.length() / 2;
uint8_t secret[64] = {};
hex_to_bytes(secret_hex, secret, secret_len);
if (!hex_to_bytes(secret_hex, secret, secret_len)) {
return HString{};
}
// 4. HMAC-SHA256(secret, message)
uint8_t hmac_result[32];

View File

@@ -126,7 +126,11 @@ extern "C" void net_guard_tick() {
}
if (s_up || s_cfg == nullptr) return;
if (millis() < s_next_retry_ms) return;
// Wrap-safe: signed difference handles the ~49.7-day millis() wrap. The
// device is meant to run for months between reboots, so absolute compare
// (millis() < s_next_retry_ms) would either tight-loop retries across the
// wrap or stall them until millis() climbed back past an old high mark.
if ((int32_t)(millis() - s_next_retry_ms) < 0) return;
if (s_up) return; // re-check after the timing gate — closes GOT_IP-vs-tick race
s_attempts++;
// WiFi.begin() alone re-associates cleanly; a prior WiFi.disconnect() call

View File

@@ -6,6 +6,7 @@
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <algorithm>
#include <vector>
#include <time.h>
#include <freertos/semphr.h>
@@ -296,7 +297,10 @@ void reporter_flush(const DeviceConfig& cfg) {
String body = build_camera_batch(cfg, cam_snap);
if (post_json(cfg, "/api/v1/camera/events/batch", body)) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_cam_buf.clear();
// Erase only the prefix we snapshotted; FIFO append from
// submit_camera during the in-flight POST stays buffered.
size_t n = std::min(cam_snap.size(), s_cam_buf.size());
s_cam_buf.erase(s_cam_buf.begin(), s_cam_buf.begin() + n);
xSemaphoreGive(s_buf_mutex);
}
}
@@ -304,7 +308,8 @@ void reporter_flush(const DeviceConfig& cfg) {
String body = build_ble_batch(cfg, ble_snap);
if (post_json(cfg, "/api/v1/events/batch", body)) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_ble_buf.clear();
size_t n = std::min(ble_snap.size(), s_ble_buf.size());
s_ble_buf.erase(s_ble_buf.begin(), s_ble_buf.begin() + n);
xSemaphoreGive(s_buf_mutex);
}
}

View File

@@ -70,13 +70,15 @@ def store_heartbeat_diagnostics(
else None
)
cursor = db.cursor()
# COALESCE preserves existing column values when the v1.0.0 payload omits
# diagnostic fields (Pydantic resolves them to None).
cursor.execute(
"""UPDATE heartbeats
SET reset_reason = ?,
heap_free = ?,
heap_min_free = ?,
last_disconnect_code = ?,
recent_events = ?
SET reset_reason = COALESCE(?, reset_reason),
heap_free = COALESCE(?, heap_free),
heap_min_free = COALESCE(?, heap_min_free),
last_disconnect_code = COALESCE(?, last_disconnect_code),
recent_events = COALESCE(?, recent_events)
WHERE device_id = ?""",
(
hb.reset_reason,

View File

@@ -15,11 +15,14 @@ Usage:
"""
import argparse
import os
import re
import secrets
import subprocess
import sys
import tempfile
HMAC_SECRET_RE = re.compile(r"^[0-9a-fA-F]{64}$")
NVS_NAMESPACE = "doorcounter"
NVS_PARTITION_OFFSET = "0x9000"
@@ -63,6 +66,10 @@ def main():
args = parser.parse_args()
hmac_secret = args.hmac_secret or secrets.token_hex(32)
if not HMAC_SECRET_RE.match(hmac_secret):
print("Error: --hmac-secret must be exactly 64 hex characters (32 bytes)",
file=sys.stderr)
sys.exit(1)
if args.hmac_secret is None:
print(f"Generated HMAC secret: {hmac_secret}")
print(" *** SAVE THIS — you need it to register the device on the server ***")