mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-02 04:08:22 +00:00
200 lines
8.4 KiB
C++
200 lines
8.4 KiB
C++
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* MeshTimeSync - mesh clock consensus from Ed25519-signed advert timestamps.
|
|
*
|
|
* Role-agnostic estimator plus shared step policy. Each role feeds it
|
|
* signature-verified adverts (onAdvertHeard) and calls runTick() from its
|
|
* loop; runTick applies the shared policy (7-day suppression armed by any
|
|
* manual or GPS clock set, forward-only roles skip backward steps, ±1 h cap,
|
|
* 6 h rate limit) and steps the given clock. Role-specific bookkeeping (neighbor/ACL timestamp
|
|
* shifts, rate-limiter resets) happens in the role after runTick returns
|
|
* true. ZephCore-only divergence from Arduino MeshCore — design rationale
|
|
* in ARCHITECTURE.md, user-facing doc in MESHTIMESYNC.md.
|
|
*
|
|
* All policy timers anchor on uptime, never wall clock, so the very steps
|
|
* they govern cannot distort them. Main-thread-only, like the mesh classes
|
|
* that own it.
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include <stdint.h>
|
|
#include <stddef.h>
|
|
#include <mesh/RTC.h>
|
|
|
|
#ifdef CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE
|
|
#define MESHTIMESYNC_TABLE_SIZE CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE
|
|
#else
|
|
#define MESHTIMESYNC_TABLE_SIZE 32
|
|
#endif
|
|
|
|
#ifdef CONFIG_ZEPHCORE_TIMESYNC_QUORUM
|
|
#define MESHTIMESYNC_QUORUM CONFIG_ZEPHCORE_TIMESYNC_QUORUM
|
|
#else
|
|
#define MESHTIMESYNC_QUORUM 6
|
|
#endif
|
|
|
|
#ifndef FIRMWARE_BUILD_EPOCH
|
|
/* Injected by CMakeLists.txt (build-time UNIX epoch, the "provably dead
|
|
* clock" floor). 0 disables bootstrap mode entirely. */
|
|
#define FIRMWARE_BUILD_EPOCH 0u
|
|
#endif
|
|
|
|
class MeshTimeSync {
|
|
public:
|
|
static constexpr uint8_t HOP_CAP = 3;
|
|
static constexpr int32_t RADIUS_BASE_SECS = 150;
|
|
static constexpr int32_t RADIUS_PER_HOP_SECS = 15;
|
|
static constexpr uint32_t TENURE_SECS = 60 * 60;
|
|
static constexpr uint16_t TENURE_MIN_ADVERTS = 2;
|
|
static constexpr uint32_t MAX_SAMPLE_AGE_SECS = 5 * 24 * 3600;
|
|
static constexpr uint32_t MATURE_SILENT_EVICT_SECS = 24 * 3600;
|
|
static constexpr int64_t CONSISTENCY_BASE_SECS = 45;
|
|
static constexpr int64_t CONSISTENCY_PPM = 150;
|
|
static constexpr uint32_t EVAL_INTERVAL_SECS = 15 * 60;
|
|
static constexpr int64_t DEAD_BAND_SECS = 5 * 60;
|
|
static constexpr int64_t STEP_TRIGGER_SECS = 10 * 60;
|
|
static constexpr int64_t STEP_CAP_SECS = 3600;
|
|
static constexpr uint32_t STEP_INTERVAL_SECS = 6 * 3600;
|
|
static constexpr uint32_t SUPPRESS_SECS = 7 * 24 * 3600;
|
|
static constexpr int64_t PEDIGREE_PPM = 300;
|
|
static constexpr int64_t PEDIGREE_BASE_SECS = 10 * 60;
|
|
static constexpr uint8_t BOOTSTRAP_QUORUM = 3;
|
|
|
|
/* 8-byte prefix is a security floor, not a tuning knob: it is the
|
|
* sender's identity for tenure/votes while signatures verify the full
|
|
* key, so a shorter prefix lets an attacker grind keypairs to collide
|
|
* with a tenured honest voter and reset its tenure with validly-signed
|
|
* adverts. If RAM ever matters, cut slot count instead. */
|
|
struct Slot {
|
|
uint8_t prefix[8];
|
|
uint32_t advert_ts; /* latest advert timestamp = the vote */
|
|
uint32_t arrival_uptime; /* monotonic anchor: skew is recomputed at
|
|
* evaluate time, so a local clock step
|
|
* never stales stored samples */
|
|
uint32_t first_uptime; /* tenure start */
|
|
uint16_t count; /* adverts this tenure */
|
|
uint8_t hops; /* precision hint only, never a trust signal */
|
|
uint8_t used;
|
|
};
|
|
|
|
enum VerdictType : uint8_t { VERDICT_NONE = 0, VERDICT_ABSTAIN, VERDICT_STEP };
|
|
|
|
enum Reason : uint8_t {
|
|
REASON_NONE = 0,
|
|
REASON_IN_BAND, /* |skew| below the step trigger */
|
|
REASON_NO_DATA, /* no eligible voters */
|
|
REASON_NO_QUORUM,
|
|
REASON_NO_MAJORITY,
|
|
REASON_SUPPRESSED, /* manual or GPS clock set less than 7 days ago */
|
|
REASON_RATE_LIMITED, /* < 6 h since last applied step */
|
|
REASON_PEDIGREE, /* drift-envelope physics veto */
|
|
};
|
|
|
|
struct Consensus {
|
|
bool valid; /* >= 1 eligible vote, intersection computed */
|
|
uint8_t eligible;
|
|
uint8_t votes_for; /* votes inside the best intersection */
|
|
uint8_t votes_against;
|
|
int64_t mid; /* consensus skew midpoint (+ = our clock is behind) */
|
|
int32_t radius; /* intersection half-width */
|
|
};
|
|
|
|
struct Verdict {
|
|
VerdictType type;
|
|
Reason reason;
|
|
int64_t delta; /* seconds to add to the clock (STEP only) */
|
|
bool bootstrap;
|
|
Consensus consensus;
|
|
};
|
|
|
|
explicit MeshTimeSync(uint32_t build_epoch = 0, bool forward_only = false)
|
|
{
|
|
reset(build_epoch, forward_only);
|
|
}
|
|
|
|
void reset(uint32_t build_epoch, bool forward_only);
|
|
|
|
/* Feed one signature-verified advert. hops = flood path length (0 = heard
|
|
* direct). Samples beyond HOP_CAP are dropped. Callers must not feed
|
|
* share rebroadcasts (transport codes {0,0}) — those replay stale stored
|
|
* adverts and would churn the sender's tenure. */
|
|
void onAdvertHeard(const uint8_t *pubkey, uint32_t advert_ts, uint8_t hops,
|
|
uint32_t uptime_secs);
|
|
|
|
/* Cheap pre-check: would onAdvertHeard even consider this sample?
|
|
* Lets callers skip the Ed25519 verify for per-sender duplicates
|
|
* (observers hear every flood copy with no dedup of their own). */
|
|
bool wouldAccept(const uint8_t *pubkey, uint32_t advert_ts) const;
|
|
|
|
/* Paced policy run — call from the role's loop with its RTC clock.
|
|
* Evaluates at most every EVAL_INTERVAL_SECS; applies the shared step
|
|
* policy and steps the clock (incl. hardware-RTC save + UI time-source
|
|
* report). Returns true when a step was applied — the role then shifts
|
|
* its wall-clock-anchored bookkeeping by lastStepDelta(). The caller
|
|
* gates on its own enable pref. */
|
|
bool runTick(mesh::RTCClock &rtc);
|
|
|
|
/* Manual clock set (CLI time/clock sync, app time set, SNTP): arms the
|
|
* 7-day suppression window AND drift-envelope pedigree. Suppression
|
|
* gates bootstrap too. */
|
|
void noteManualSync(uint32_t uptime_secs);
|
|
/* GPS time sync: identical to a manual set — arms the same 7-day
|
|
* suppression window and drift-envelope pedigree, re-armed on every fix
|
|
* so a live GPS keeps owning the clock. */
|
|
void noteGPSSync(uint32_t uptime_secs);
|
|
|
|
/* Delta of the most recent applied step (for role bookkeeping shifts). */
|
|
int64_t lastStepDelta() const { return _last_step_delta; }
|
|
|
|
/* Compact status + evidence-table formatter shared by all role CLIs.
|
|
* Writes at most `cap` bytes (NUL-terminated), summary first, then as
|
|
* many per-sender entries as fit. Annotates a backward step a
|
|
* forward-only role would refuse ("skipped: forward-only"); a suppressed
|
|
* clock (manual or GPS) surfaces as a "hold (suppressed)" verdict. */
|
|
int formatStatus(char *out, size_t cap, uint32_t local_time,
|
|
uint32_t uptime_secs, bool enabled) const;
|
|
|
|
private:
|
|
bool isBootstrap(uint32_t local_time) const { return local_time < _build_epoch; }
|
|
|
|
Slot *findSlot(const uint8_t *prefix);
|
|
bool slotTenured(const Slot &s, uint32_t uptime_secs) const;
|
|
/* Whether this slot counts toward the current evaluation — bootstrap
|
|
* relaxes tenure, so this takes the mode explicitly rather than
|
|
* hardcoding normal-mode rules. Single source of truth shared by
|
|
* computeConsensus (who votes) and formatStatus (who gets the "E"
|
|
* marker) — they must never diverge on what "counted" means. */
|
|
bool slotEligible(const Slot &s, uint32_t uptime_secs, bool bootstrap) const;
|
|
int64_t slotSkew(const Slot &s, uint32_t local_time, uint32_t uptime_secs) const;
|
|
|
|
Consensus computeConsensus(uint32_t local_time, uint32_t uptime_secs,
|
|
bool bootstrap) const;
|
|
/* Unpaced full policy evaluation (no counter/pacing side effects). */
|
|
Verdict evaluateNow(uint32_t local_time, uint32_t uptime_secs) const;
|
|
/* Paced evaluation — VERDICT_NONE/REASON_NONE between evaluations. */
|
|
Verdict tick(uint32_t local_time, uint32_t uptime_secs);
|
|
|
|
void noteStepApplied(int64_t delta, uint32_t uptime_secs, bool bootstrap);
|
|
bool isSuppressed(uint32_t uptime_secs) const;
|
|
uint32_t suppressRemaining(uint32_t uptime_secs) const;
|
|
static const char *reasonStr(Reason r);
|
|
|
|
Slot _slots[MESHTIMESYNC_TABLE_SIZE];
|
|
uint32_t _build_epoch;
|
|
bool _forward_only;
|
|
|
|
/* Policy state — uptime-anchored (see header comment). */
|
|
uint32_t _next_eval_uptime;
|
|
uint32_t _suppress_uptime;
|
|
bool _suppressed;
|
|
uint32_t _pedigree_uptime;
|
|
bool _pedigree;
|
|
uint32_t _last_step_uptime;
|
|
bool _stepped_once;
|
|
|
|
/* Counters (shown by formatStatus). */
|
|
uint32_t _evals, _abstains, _steps, _bootstrap_steps, _backward_skips;
|
|
int64_t _last_step_delta;
|
|
};
|