docs: security assessment specs (fleet-fixes, host-hardening, 1.0 §10, threat model)

This commit is contained in:
2026-10-19 12:02:33 -07:00
parent 3b1a644b24
commit 55cc7952bc
4 changed files with 1260 additions and 36 deletions
@@ -1,9 +1,10 @@
# tines-core 1.0 — design
Date: 2026-10-18
Status: draft v4. Kyle decided D1–D5 and the follow-ups Q1–Q3
(2026-10-18). §2b records the 1.1 direction on age and authenticity. Not yet
reviewed.
Status: draft v6. Kyle decided D1–D5 and Q1–Q3 (2026-10-18) and the
review decisions R1–R3 and the `observed_at` definition (2026-10-19);
review fixes applied. v6 adds §10 (core hardening from `THREAT_MODEL.md`)
and the adversarial fuzz mode; these additions aren't reviewed yet.
Builds on: `2026-10-16-tines-design.md`, `2026-10-18-tines-addressed-delivery.md`,
`2026-10-18-tines-fleet-deployment-design.md` (assumed fully implemented)
@@ -114,26 +115,52 @@ installed base to migrate (D5).
milestone). Any value ≥ 4 is an unknown variant and decodes as
`Malformed`. Conversely, a 0.x frame starts with 0–3, which a 1.0 node
rejects as a version mismatch. No robot runs 0.x, but test rigs and
simulators might, so this costs nothing to get right. Pick a value well
clear of small tags (e.g. `0xA1`).
simulators might, so this costs nothing to get right. Avoid 0–3 (valid
0.x tags). Prefer a value in 4–0x7F: postcard reads it as a single-byte
unknown tag. A value ≥ 0x80 also ends up `Malformed`, but only because
postcard treats it as a varint continuation and reads a second byte,
which is a needless subtlety.
- **Every drop is reported (D3).** A frame dropped at the envelope or in
decoding raises a `NodeEvent::Dropped { reason }`, where `reason` is a
decoding is reported against the transport `Addr` it arrived from, with a
`#[non_exhaustive]` `DropReason`:
- `VersionMismatch { version: u8 }`: an un-updated or foreign peer.
- `IdentityChanged`: a frame's `from` differs from the NodeId already
pinned for this contact (§10).
- `Malformed`: the version was right but the body didn't decode, or
decoded to something core must reject (e.g. a `Signature` whose length
doesn't fit its algorithm, §2a).
An operator then sees "met an un-updated robot" or "a peer is sending
garbage" instead of a silent non-exchange. With the version byte,
"Malformed" from a v1 peer means a bug or an attack, not drift. The
report carries no `NodeId`, since the sender can't be trusted before
decode. Reports are rate-limited per transport address so one peer can't
flood the event queue (builder's call how; the limit is a local
constant).
garbage" instead of a silent non-exchange. `Malformed` from a v1 peer
means one of three things:
- a bug,
- an attack,
- a *newer* 1.x peer sending a `Frame` variant this node predates. That
case is expected and safe (see "What a version bump means" below), so
tooling shouldn't treat every `Malformed` as an alarm.
- **Reports carry the `Addr`, not a `NodeId` (R1).** The sender's NodeId is
inside the frame and can't be trusted before decode. The transport `Addr`
is local truth that doesn't depend on the frame, so it's what makes a
drop attributable. The report is only as good as the transport's
binding: on BLE, `Addr` must be the NodeId bound to the connection (see
`THREAT_MODEL.md` F3).
- **Drops are counted, not queued (R2).** Core keeps a small fixed table of
counters keyed by `(Addr, DropReason)`, so that `VersionMismatch` with
different versions counts separately. Each new drop increments its
counter instead of pushing an event. `drain_events` reports each nonzero
counter once as `NodeEvent::Dropped { addr, reason, count }` and resets
it.
- A flood of bad frames therefore costs one table slot, not the event
queue. `Delivered` receipts can never be evicted by drops.
- The counting is itself the rate limit; no separate limiter is needed.
- When the table is full, a new `(Addr, reason)` pair goes into one
overflow counter, reported with no address. Losing attribution under a
flood is acceptable; losing the count isn't.
- The table size is a local constant (§5).
- Expired addressed observations are *not* drops. They're well-formed and
are discarded by policy; reporting them would be noise.
- `SUMMARY_OVERHEAD` and `FRAG_OVERHEAD` absorb the extra byte. Every frame
- `SUMMARY_OVERHEAD` (private, `exchange.rs`) and `FRAG_OVERHEAD` absorb
the extra byte. Every frame
still fits `mtu()` (Review Focus #6).
- **What a version bump means:** any change to the encoding of an existing
variant, the meaning of an existing field, or a wire constant (§5) is a new
@@ -174,8 +201,24 @@ pub struct Dest { pub to: NodeId, pub lifetime_ms: u64 }
plus `with_observed_at`. Applications normally never build one —
`Node::observe`/`observe_to` do. 1.0 has no public way to set `sig`; 1.1
adds it.
- The id hash reads `dest.map(|d| d.to)` and is byte-for-byte the same as
0.x; the pinned vector proves it. `sig` is excluded from the id.
- **The observation's wire encoding changes in v1; only its identity is
preserved.** `dest` replaces `to` + `lifetime_ms`, and `sig` is appended,
so postcard encodes an `Observation` differently from 0.x. That's fine
under the version byte (§1). The id hash reads `dest.map(|d| d.to)` and
is byte-for-byte the same as 0.x; the pinned vector proves it. `sig` is
excluded from the id.
- **`observed_at` gets a defined meaning (Kyle confirmed, 2026-10-19).**
Today the field has no docs and no pinned units, but §2a signs it and
§2b cross-checks against it. Proposed definition:
- **Units:** the observer's wall-clock time when it made the
observation, in milliseconds since the Unix epoch (UTC).
- **`None`:** the observer had no clock it trusts. A robot without an
RTC or time sync must leave it `None` rather than guess.
- **Never changed:** set once by the observer; relays never change it.
- **Not a local tick:** it's unrelated to `Tick`, which stays local
monotonic milliseconds and never crosses the wire.
The rustdoc on the field says all of this.
- `reject()` keeps only the expiry check; the malformed-pair branch and its
tests go away (the pairing is now enforced by the type, including on
decode).
@@ -319,17 +362,18 @@ Public in 1.0 (everything else becomes `pub(crate)`):
| Item | Why it's public |
|---|---|
| `Node`, `ObserveError` | the facade applications use |
| `NodeEvent` (non-exhaustive enum; replaces `ConfidenceEvent`), `DropReason` | `Delivered { peer, id }`, `Dropped { reason }`; room for more |
| `NodeEvent<A>` (non-exhaustive enum, generic over the transport `Addr`; replaces `ConfidenceEvent`), `DropReason` | `Delivered { peer, id }`, `Dropped { addr: Option<A>, reason, count }` (`addr` is `None` only for the overflow counter, §1); room for more |
| `Observation`, `Dest`, `Signature`, `SigAlgo`, `NodeId`, `ObservationId`, `Tick`, `Topic`, `Payload` | the data model |
| `Store`, `StoreError`, `TrimPolicy`, `MemStore` (`alloc`) | backends and their reference impl |
| `Transport`, `Event`, `SendError`, `FrameBuf` | transport adapters |
| `BeliefPolicy`, `LatestWins`, `SelfFirst` | the memory layer |
| `consts` (wire constants + local caps, §5), `WIRE_VERSION` | sizing |
| `frame_sender(bytes) -> Option<NodeId>` | lets a transport check a frame's claimed sender against the identity it bound to the connection without core's private `Frame` layout (fleet fixes spec §3; `THREAT_MODEL.md` F3) |
Made private: `Exchange`, `Peer`, `InFlight`, `Bloom` (including the
public `salt` field), `Reassembler`, `fragment`, `Frame`, `WireError`,
`FRAG_OVERHEAD`. Nothing outside `tines-core` uses them except one
`tines-sim` test that touches `Exchange` directly; it moves to `Node`.
`FRAG_OVERHEAD`. Nothing outside `tines-core` uses them; the only mention
is a doc-comment link in `tines-sim/src/world.rs`, which is updated.
- **All public enums are `#[non_exhaustive]`**: `ObserveError`,
`StoreError`, `SendError`, `Event`, `NodeEvent`, `DropReason`,
@@ -341,7 +385,8 @@ public `salt` field), `Reassembler`, `fragment`, `Frame`, `WireError`,
(`TrimPolicy::default()` + field assignment still works).
- `Node` keeps its current methods: `new`, `id`, `next_seq`, `observe`,
`observe_to`, `poll`, `drain_events`, `belief`, `trim`, `store`,
`transport_mut`. `drain_events` returns `NodeEvent`s.
`transport_mut`. `drain_events` returns `NodeEvent<T::Addr>`s: queued
`Delivered` events, then one `Dropped` per nonzero drop counter (§1).
- Every remaining public item is documented; `#![deny(missing_docs)]` goes in
`tines-core/src/lib.rs` (26 items are missing docs as of the fleet
milestone; most of them are the fields of the now-private types).
@@ -387,18 +432,30 @@ changes:
### 5. Constants: wire vs local
`consts.rs` splits into two documented groups:
`consts.rs` splits into two documented groups. The rule: **a constant is
a wire constant if changing it changes how a v1 peer decodes or interprets
a frame.** That covers capacity bounds a decoder enforces, and constants
both sides must agree on to read a field. Everything else is local.
- **Wire constants**, frozen for wire v1: changing any is a `WIRE_VERSION`
bump. These are `MAX_TOPIC`, `MAX_PAYLOAD`, `MAX_FRAME`, `SUMMARY_BYTES`,
`BLOOM_K`, `ACK_IDS_CAP` and `MAX_SIG` (= 64, Ed25519; Q1), plus the
private `MAX_FRAGMENTS`. Post-quantum signatures don't fit in 64 bytes, so
adding one is a wire v2 / tines-core 2.0 change.
bump. The capacity bounds are `MAX_TOPIC`, `MAX_PAYLOAD`, `MAX_FRAME`,
`SUMMARY_BYTES`, `ACK_IDS_CAP` and `MAX_SIG` (= 64, Ed25519; Q1), plus the private
`MAX_FRAGMENTS`. `BLOOM_K` is a wire constant because both sides must
agree on it to read a summary.
- Post-quantum signatures don't fit in 64 bytes, so adding one is a wire
v2 / tines-core 2.0 change.
- **`MAX_FRAME = 1500` is a known 2.0 trigger (R3).** `Frag.data` is an
inline `Vec<u8, MAX_FRAME>`, so a peer can't decode a bigger frame. A
future radio that needs frames over 1500 bytes means wire v2. BLE (L2CAP
CoC) and LoRa are comfortably under it, and a bigger radio can still run
v1 with 1500-byte frames.
- **Local capacities and timings**, which can change in a minor release
because they never cross the wire: `MAX_PEERS`, `SEND_QUEUE_CAP`,
`ACK_BUFFER_CAP`, `BCAST_SENT_CAP`, `EVENT_QUEUE_CAP`,
`SUMMARY_INTERVAL_MS`, `REASSEMBLY_TIMEOUT_MS`, and the drop-report rate
limit.
`SUMMARY_INTERVAL_MS`, `REASSEMBLY_TIMEOUT_MS`, and the size of the
drop-counter table (§1). `EVENT_QUEUE_CAP` is local, even though
`ACK_IDS_CAP` is wire: the event queue never leaves the node, while an
`Ack` frame's id list is decoded by the peer.
AGENTS.md's constant list is updated to show the split.
@@ -435,9 +492,27 @@ can overflow during a large sync. The 1.0 spec pins what overflow costs:
- no panic,
- every sent frame is no larger than `mtu()` (randomised per run),
- no addressed observation is stored past its lifetime,
- every rejected frame produced a `Dropped` report (up to the rate limit),
- every rejected frame is counted: the drained `Dropped` counts for an
address add up to the frames rejected from it (the overflow counter
included),
- stored signatures round-trip unchanged.
**Adversarial-peer mode.** Random bytes rarely get past the decoder, so
a second input mode builds *well-formed* frames from the fuzz input:
- Summaries with chosen bits, including all-zero, all-one and repeated.
- Obs and Frag frames with chosen ids, observers (including the node's
own NodeId), seqs, ages, lifetimes and addressing.
- Acks for ids that were or weren't sent to that address.
- Fragments that conflict, repeat or arrive out of order.
- `from` values that change within a contact.
These are driven from many addresses, beyond `MAX_PEERS`. On top of the
checks above, this mode checks the §10 properties: peer-table recovery,
bounded output per received summary, no NodeId change within a contact,
and drop-oldest event behaviour. It also checks the custody invariant
under the fleet fixes' F1 rule: an ack is sent only for a stored
observation.
Fuzzing through `Node` keeps every internal private. It runs as a
scheduled CI job (not a PR gate, since the toolchain is pinned stable),
with the corpus checked in.
@@ -464,7 +539,8 @@ can overflow during a large sync. The 1.0 spec pins what overflow costs:
- **Wire:** every 1.x release speaks wire v1. Only new optional `Frame`
variants may be added, plus new `SigAlgo` ids and signature versions
within the 64-byte `MAX_SIG` budget. A wire-v2 change, including any
post-quantum signature, ships as tines-core 2.0.
post-quantum signature or a `MAX_FRAME` above 1500, ships as
tines-core 2.0.
- **Identity:** `Observation::id` never changes within a major version, and
should not change across majors unless the hash itself is broken.
- **API:** standard semver, enforced by `cargo-semver-checks`; the
@@ -483,14 +559,73 @@ can overflow during a large sync. The 1.0 spec pins what overflow costs:
`tines-store` (0.x) moves to `tines-core = "1"` and its log format follows
the new `Observation` encoding. There is no migration:
- The log gains a format header. Opening a log with a missing or older
header is an error that names the fix (delete the log). Silently
misparsing an old log is not allowed.
- The log already has a versioned header and per-record CRC by then: the
fleet fixes (`2026-10-19-tines-fleet-fixes.md` §5) introduce both for
integrity reasons. 1.0 bumps that header's `format_version` for the new
`Observation` encoding. Opening a log with an older version is an error
that names the fix (delete the log). Silently misparsing an old log is
not allowed.
- Hosts wipe pre-1.0 logs on upgrade. That's acceptable because no robot
runs tines yet, and none will be deployed with a pre-1.0 `tines-core`.
- The header is what makes a later migration possible if one is ever
needed. Adding it is cheap now and expensive later.
### 10. Core hardening (from `THREAT_MODEL.md`)
The security assessment at `3b1a644` found core behaviours that a peer can
abuse without any key. All of them are local behaviour (§8), so 1.x could
change them. They're fixed before 1.0 anyway: they're cheap, and 1.0 is
when core's behaviour is written down as a promise. None of them adds
authentication. They bound what an unauthenticated peer can do; 1.1
decides who is allowed to be a peer.
1. **F6 — a peer's NodeId is fixed for the contact.**
- Today `on_frame` overwrites `peer.id` with each frame's `from`
(exchange.rs:723), so a peer can claim another identity mid-contact
and be handed that identity's addressed mail.
- New rule: the first decoded frame on a contact sets `peer.id`. A
later frame with a different `from` is dropped and counted as
`DropReason::IdentityChanged` (§1).
- The pin resets on `Contact`/`Lost`. On transports that bind identities
(tines-bt after the fleet fixes), the two always agree.
2. **F8 — idle peers are evicted.** A peer with no inbound frame for
`PEER_IDLE_MS` (local constant, default 30 s) is removed as if `Lost`.
Today peers leave only on `Lost`, so eight spoofed addresses can fill
the table until restart. With the fleet fixes' `Lost` events this
rarely triggers for honest peers, but it bounds the lockout.
3. **F9 — a summary can't be replayed for amplification.**
- At most one received Summary per peer is acted on per
`SUMMARY_INTERVAL_MS`. Extra ones are ignored; that's a policy, not a
drop.
- The send-queue rebuild skips ids already sent to that peer this
contact, using the existing per-peer sent record. That record is
best-effort under its cap, and a miss only costs a resend.
- The worst-case output per received summary stays bounded by
`SEND_QUEUE_CAP`, but repeating the summary no longer re-sends.
4. **F13 — smaller defects:**
- **Event queue drops the oldest** when full, as its docs and the fleet
spec say (today `push` silently drops the newest, exchange.rs:777).
This applies to `Delivered` events; `Dropped` uses counters (§1).
- **Pending acks for a removed peer are purged** on `Lost` or eviction,
so retries to a dead peer can't starve data frames.
- **Frames must be canonical.** Decode rejects trailing bytes after a
complete `Frame` (`postcard::take_from_bytes` and an empty remainder),
counted as `Malformed`. This is wire format, so it can't wait for 1.x,
and §7's vectors pin it with a valid frame plus one trailing byte.
5. **Considered and left out:**
- **A per-peer quota on new inserts** (threat model F10). A big honest
sync is indistinguishable from a flood without authentication, and a
quota would slow convergence for everyone. Storage bounds belong to
the host's trim policy and store cap (host hardening spec §1).
Revisit after 1.1.
- **Refusing inbound observations that claim this node as observer**
(threat model F14). It would stop forged "self" observations beating
`SelfFirst`, but also stop a robot recovering its own history from
peers after losing its log. Signatures in 1.1 fix the real problem.
New local constants, `PEER_IDLE_MS` and the drop-counter table size, join
§5's local group. `DropReason` gains `IdentityChanged`.
## Decisions (Kyle, 2026-10-18)
- **D1. Reserve the signature slot now: yes.** `Signature { version: u8,
@@ -519,10 +654,31 @@ the new `Observation` encoding. There is no migration:
- **Q3. Malformed frames are reported the same way as version
mismatches**, through `NodeEvent::Dropped { reason }` (§1).
## Review decisions (Kyle, 2026-10-19)
- **R1. Drop reports carry the transport `Addr`**, not a NodeId (§1).
- **R2. No second queue: drops are counted by `(Addr, reason)`** and
reported as `Dropped { addr, reason, count }` when drained. `Delivered`
receipts can't be evicted by drops, and the counting doubles as the rate
limit (§1).
- **R3. `MAX_FRAME` stays a wire constant at 1500**, and the spec names it
as a known 2.0 trigger (§5, §8).
The review's other fixes are applied too:
- `Malformed` can also mean a newer peer's optional variant (§1).
- The rule for wire vs local constants is written down (§5).
- The `Observation` encoding change is stated outright (§2).
- The `tines-sim` claim is corrected (§3).
- The version-byte guidance is corrected (§1).
- **R4. `observed_at` is defined** as the observer's wall-clock time in
milliseconds since the Unix epoch (UTC), or `None` if the observer has no
trusted clock. Relays never change it, and it's unrelated to `Tick` (§2).
## Left to the builder
- The exact `WIRE_VERSION` value, within the rules in §1.
- How drop reports are rate-limited per peer, and the limit's value.
- The size of the drop-counter table (§1).
- The `SigAlgo` catch-all representation and the `version` sanity rule
(§2a).
- Fair-share vs shared FIFO for the bounce table (§6).
@@ -532,15 +688,17 @@ the new `Observation` encoding. There is no migration:
2. the envelope and drop reports,
3. `Dest`, then `Signature`,
4. the API narrowing,
5. docs, fuzzing and MSRV,
6. the 1.0.0 release, with `cargo-semver-checks` switched on.
5. core hardening (§10),
6. docs, fuzzing (including adversarial mode) and MSRV,
7. the 1.0.0 release, with `cargo-semver-checks` switched on.
## Done means
- `sh scripts/ci.sh` is green with `#![deny(missing_docs)]`, an MSRV job and
the vector tests.
- The fuzz target has run for an agreed budget (e.g. 24 CPU-hours) with no
findings, and the corpus is committed.
- The fuzz target has run for an agreed budget (e.g. 24 CPU-hours) in both
input modes with no findings, and the corpus is committed.
- `THREAT_MODEL.md` marks F6, F8, F9 and F13 fixed.
- README has a "Stability" section with the §8 policy, including the 1.1
signature note; CHANGELOG has a 1.0.0 entry listing every break from 0.3.
- AGENTS.md lists this spec, the wire/local constant split and the trait
@@ -0,0 +1,320 @@
# tines fleet deployment v1 — fixes from the security assessment
Date: 2026-10-19
Status: draft v1. Not yet reviewed.
Fixes: findings F1–F5 and F17 in `THREAT_MODEL.md` (assessed at `3b1a644`)
Amends: `2026-10-18-tines-fleet-deployment-design.md` (milestone M3)
## Intent
The fleet milestone's code has landed, but the security assessment found
that several of its promises don't hold even with no attacker present:
- custody is lost when a store is full;
- the standalone service never exchanges anything;
- BLE can't accept connections and doesn't keep frames intact;
- restarts repeat sequence numbers and forget released messages;
- one corrupt log record deletes the rest of the log;
- the service's clock runs in the wrong units.
This spec fixes those six findings so M3 does what its spec says. It adds
no features.
## Why now, and why not in 1.0
These are M3 bugs. M3 can't ship with them, so they can't wait for 1.0.
All but F1 live in host crates (`tines-store`, `tines-bt`, `tines`,
`tines-ros2`), which stay 0.x and aren't covered by the 1.0 promise (1.0
spec, D4). The core-side hardening that does belong with 1.0 (F6, F8, F9,
F13) is in the 1.0 spec, §10. Host hardening (F10–F12) has its own spec,
`2026-10-19-tines-host-hardening.md`.
## Goals
1. Custody is never released for a message the receiver didn't keep (F1).
2. The standalone `tines` service exchanges observations over its
transport (F2).
3. `tines-bt` has the following properties (F3):
- it accepts as well as initiates connections;
- it preserves frame boundaries;
- it binds each connection to the advertised NodeId;
- it reports `Contact`/`Lost`.
4. A robot's `next_seq` never repeats across restarts, and released flags
survive restarts (F4).
5. Log recovery loses only a torn tail. A corrupt record elsewhere costs
that record, not the rest of the log (F5).
6. Every host uses one monotonic millisecond clock for `Tick` (F17).
## Non-goals
- **Authentication or encryption of any kind.** The NodeId binding in
goal 3 is only as trustworthy as an unauthenticated advertisement. It
stops accidents and makes `from` checks meaningful; it doesn't stop an
attacker (1.1, 1.0 spec §2b).
- Growth bounds, trimming defaults, bounded channels, IPC permissions, ROS2
isolation: host hardening spec.
- Peer eviction, summary rate limits, identity pinning in core: 1.0 spec
§10.
- Multi-peer configuration of the service beyond what F3 needs (see §3).
## Constraints
- tines-core invariants are unchanged (no_std, no I/O, no panics on
untrusted input, `Addr` ↔ NodeId mapping stays in the transport).
- **The F1 fix is a core behaviour change** and must keep the fleet sim
properties green, plus add the one they missed.
- No robots run tines yet, so log formats may change without migration:
existing logs are wiped (1.0 spec, D5).
- TDD per AGENTS.md: each finding gets a failing regression test first.
## The how
### 1. F1 — ack only what was kept (tines-core)
`Exchange::accept` (exchange.rs ~795-820) currently enqueues an ack for
every receive except a released addressed duplicate, including when
`insert` fails.
New rule, matching the fleet spec's own eligibility rule ("B never acks a
… store-full receive"):
| `insert` result | Addressed | Broadcast |
|---|---|---|
| `Ok(true)` (new) | ack | ack |
| `Ok(false)`, local copy not released | ack | ack |
| `Ok(false)`, local copy released | **no ack** (existing rule) | n/a |
| `Err(_)` (full, backend error) | **no ack** | **no ack** |
- Broadcasts follow the same rule. Acking an unstored broadcast would fire
a false confidence event at the sender.
- No bounce record on `Err` (already true).
- **Tests:**
- A unit test with a store that returns `Full`: no Ack frame is
emitted, and the sender's copy stays unreleased.
- A sim property: the "≥ 1 holder" invariant holds when some nodes use a
store with a small capacity that returns `Full`. Add a capped wrapper
store to tines-sim for this; `MemStore` never fills.
- Fix the `accept` comment ("even an un-stored id").
### 2. F2 — the service runs the exchange (tines)
The service gets a poll loop. Today `serve()` only answers IPC.
- **Structure:**
- `Node` lives behind a `Mutex`, shared by the IPC thread and a new
exchange thread.
- The exchange thread loops:
1. `poll(now)` until it returns 0 or a per-iteration cap is reached;
2. sleep for `poll_interval_ms` (config, default 20 ms);
3. on schedule, `trim(policy, now)` and `store.flush()`. The trim
policy and its defaults are in the host hardening spec; this spec
only requires that the hook exists and is called.
- **Lock poisoning:** a panic in either thread exits the process with a
non-zero code. Systemd restarts it (host hardening spec). Continuing with
a node in an unknown state isn't allowed.
- **Generic over `Transport`:** the service's core loop is generic over
`Transport`, so tests can run it on an in-memory link.
- **Test:** two service instances over an in-memory transport. An
observation made through one's IPC becomes a belief readable through the
other's IPC.
### 3. F3 — tines-bt: framing, roles, binding, contact events
1. **Preserve frame boundaries.** Switch from `l2cap::Stream` to
`l2cap::SeqPacket` (`SeqPacket::connect` and `SeqPacketListener`, both
in bluer 0.17). One L2CAP SDU is one frame.
- The negotiated MTU is still reported through `mtu()`, clamped to
`MAX_FRAME`.
- If SeqPacket proves unusable on the Jetson or Pi BlueZ versions, the
fallback is an explicit `u16` length prefix on a stream. The builder
records which was chosen and why.
2. **Roles.** Every node both advertises and scans.
- When a node discovers an eligible peer (see binding), the one with the
numerically smaller NodeId dials, per the fleet spec rule
(`is_central`).
- The larger one only accepts, on a `SeqPacketListener` bound to the
tines PSM (`0x008D`).
- So every node runs a listener for the whole session, not only after it
loses a race.
3. **Binding.** The scanner decodes the advertised NodeId from service data
(UUID `0xFE7E`, checking the UUID, which `decode_node_id` doesn't do
today) and records `bt address → NodeId` for each eligible advertiser.
- **Eligible** means either a NodeId in the configured allowlist
(`peers`, a list; empty means any tines advertiser), or any tines
advertiser if no allowlist is set.
- **Outbound connection:** bound to the NodeId that was advertised by the
address it dials.
- **Inbound connection:** bound via `peer_addr()` looked up in the scan
map. An inbound connection from an address with no recent eligible
advertisement is closed immediately.
- **Use the discovered address type.** Today it's hard-coded to
`LePublic` (transport.rs:307).
4. **Addr and the `from` check.**
- `Addr` is the bound NodeId of the connection a frame arrived on.
`read_loop` stamps frames with that bound NodeId, not the configured
`peer`.
- `BtTransport::poll` drops any frame whose decoded `from` differs from
that bound `Addr`. The check now means something; it remains
unauthenticated.
- The transport doesn't decode frames; it peeks at the `from` field.
Because core's `Frame` layout is private after 1.0, core exposes a
`pub fn frame_sender(bytes) -> Option<NodeId>` for this. That's new
public API, called out per AGENTS.md, and the only one this spec adds.
It's added to the 1.0 public surface (1.0 spec §3).
5. **Contact and Lost.**
- A connection that is up emits `Event::Contact(addr)`.
- Its end (EOF, error, adapter loss) emits `Event::Lost(addr)`. Today
neither is emitted, so core's per-contact state never resets.
- After a loss, scanning and listening continue, so the node reconnects.
6. **Several peers.** The radio supports up to `MAX_PEERS` concurrent
connections, one per bound NodeId; a second connection to the same
NodeId replaces the first. The single `peer` config field becomes the
optional `peers` allowlist (see 3). This is the minimum needed for a
fleet of more than two robots to work at all.
7. **Errors aren't swallowed.** Failures of the radio thread, adapter
power-on and advertising are surfaced, either through `RadioEvent` or as
a returned error the host logs. The radio thread's exit is reported.
Today all of these are discarded or only logged.
**Tests** (the `Radio` trait already makes the transport testable without
hardware):
- frame boundaries are preserved for back-to-back frames;
- a frame with a mismatched `from` is dropped;
- `Contact`/`Lost` are emitted;
- an inbound connection from an unknown address is rejected;
- the role is decided by NodeId order.
The SeqPacket and BlueZ path gets a manual two-device check, written up in
`tines-bt/README.md`.
### 4. F4 — seq continuity and durable releases (tines-store, both hosts)
1. **Boot counter.** `LogStore` keeps a persisted boot counter.
- `LogStore::open` increments it and durably writes it (`fsync`) before
returning.
- It then exposes `first_seq() -> u64 = boot_count << 32`, which is
core's documented contract.
- Both hosts seed `Node::new` from `store.first_seq()`, never from
config. The `first_seq` config field is removed.
- A boot can make up to 2³² observations, which is enough for any
robot.
2. **Remove the insert-counting `next_seq`.** `LogStore::next_seq`
(log.rs:352) counts received observations too, so it isn't the node's
seq. It's removed along with the `Meta` record's `next_seq` field.
3. **Releases are written when they happen.**
- `Store::release` appends a `Release { id }` record immediately, rather
than relying on a later `flush` to write the whole released set in
`Meta`. `Meta`'s `released` list goes away, which also removes the
oversized-`Meta` self-truncation in F5.
- `release` stays infallible (1.0 spec §4): an append error is logged,
and the worst case is a duplicate custodian after a reboot.
4. **Flush on a schedule.** Both hosts call `store.flush()` at least every
`flush_interval_ms` (config, default 1000 ms) and on clean shutdown.
`flush` is `fsync`.
5. **Clock marker for ages across reboot.** The fleet spec promised that
pre-reboot elapsed time is folded into ages, but there's no clock-marker
record today.
- `flush` appends a `Clock { tick }` record. On load,
`age += last_clock_tick − stored_at` for each live observation,
saturating, before rebasing.
- Downtime stays invisible (no RTC), as the fleet spec accepted.
- The marker is only meaningful within a boot, so the hosts must use
the F17 clock.
6. **Tests:**
- Reopen twice: `first_seq` strictly increases, and observations made
after each reopen don't collide.
- Release, kill without flush, reopen: the release survives.
- Ages after reopen include the time since `stored_at` up to the last
clock marker.
### 5. F5 — log integrity and recovery (tines-store)
New log format, version 1. Old logs are refused, and logs are wiped
(D5).
```
file = header record*
header = magic "TINESLOG" ‖ format_version: u16 ‖ reserved: u16
record = sync: u32 (constant) ‖ len: u32 ‖ crc32c: u32 ‖ body[len]
```
- **CRC32C** covers `len ‖ body`. A per-record checksum protects against
accidental corruption only; a keyed MAC is out of scope (threat model
F5, A5).
- **Write cap.** `write_record` refuses a record over `MAX_RECORD_BYTES`
(8 KiB) with `StoreError::Backend`, so the writer can no longer produce
a record the reader rejects.
- **Recovery rule:**
- A record whose CRC, length or decode is bad is **skipped**: scan
forward for the next `sync` value, and check that candidate's CRC
before accepting it.
- Only bad bytes at the end, with no valid record after them, count as a
torn tail.
- A torn tail is truncated, then the file and its directory are fsynced.
- Skipped corrupt records are counted and reported once on open (stderr,
and a counter the host can read).
- **Ids are recomputed** from the decoded observation on load. The stored
`id` field is dropped from the format.
- **Exclusive lock.** `open` takes an exclusive lock (`File::try_lock`,
stable since Rust 1.89; the toolchain is 1.99). A second opener gets a
clear error instead of interleaving.
- **Permissions.** Create the file with mode `0600`, and create its parent
directory (mode `0700`) if it's missing.
- **Header errors.** A missing or unknown header is an error that names
the fix (delete the log), per 1.0 spec §9. The 1.0 work then bumps
`format_version` for the new `Observation` encoding, and doesn't
introduce the header itself.
- **Tests:**
- corrupt middle record: the records after it survive;
- torn tail: truncated;
- an oversized record is refused at write;
- a second `open` fails while the first holds the lock;
- permissions are `0600`.
### 6. F17 — one monotonic millisecond clock (tines, tines-ros2)
- Each host has one `HostClock`: an `Instant` captured at process start.
`now() -> Tick` is the milliseconds elapsed since then.
- It replaces `now_ms()` in `tines/src/ipc.rs`, which returns
wall-clock *seconds*.
- It's the clock the ROS2 host documents for callers of
`TinesNode::poll(now)`.
- `LogStore::open` takes `now` from the same clock. That's `0` at
startup, which is now correct by construction: the clock starts at 0.
- **Wall-clock time is used only for `observed_at`** (1.0 spec §2,
milliseconds since the Unix epoch), and only when the host is configured
to trust its clock.
- **Test:** the service's tick advances in milliseconds, and an addressed
observation with `lifetime_ms = 1000` expires after about 1 s, not
about 1000 s.
## Acceptance
- `sh scripts/ci.sh` is green, with every regression test above added
first and seen failing.
- The fleet sim properties still pass, plus the new full-store custody
property.
- A manual two-robot BLE check is done: both directions connect, frames
round-trip, the connection is lost and re-established, and an
observation propagates.
- `THREAT_MODEL.md` marks F1–F5 and F17 fixed, with the fixing commit.
- The fleet spec's claims that were false (F3 binding, released flags
across reboot, ages across reboot) are now true, so no errata are
needed.
## Left to the builder
- SeqPacket vs length-prefixed stream, if SeqPacket misbehaves on the
target BlueZ (record the outcome).
- The `sync` constant value, and the CRC crate (`crc32c` or equivalent,
`std` only).
- The poll loop's per-iteration cap and lock granularity.
- The scan-map retention time for "recent eligible advertisement".
## Sequencing
1. F1 (core, small).
2. F17 (both hosts; the other fixes depend on the clock).
3. F4 and F5 together (one log format change).
4. F2.
5. F3 (largest; needs `frame_sender` from core).
@@ -0,0 +1,231 @@
# tines host hardening — before a robot leaves the lab
Date: 2026-10-19
Status: draft v1. Not yet reviewed.
Fixes: findings F10–F12 in `THREAT_MODEL.md` (assessed at `3b1a644`)
Depends on: `2026-10-19-tines-fleet-fixes.md`, which adds the poll loop,
the trim/flush hooks and the log format this spec builds on
## Intent
The hosts that run tines on a robot (`tines`, `tines-ros2`, and the
`tines-bt` and `tines-store` crates under them) currently trust three
things they shouldn't:
- that storage never fills,
- that every local process is friendly,
- that every participant on the ROS2 domain is friendly.
This spec bounds every resource a peer, a local process or a ROS2
publisher can grow, and limits who can act as the robot. It's the bar a
robot must meet before it operates anywhere other than a trusted lab.
## Why separately from 1.0
Everything here is in host crates, which stay 0.x with their own versions
(1.0 spec, D4). None of it changes the wire or core's API. Tying it to the
core 1.0 release would make one wait on the other for no benefit. The bar
is deployment readiness, not a version number.
## Goals
1. **Nothing grows without bound** (F10): store size, log size, channels,
and per-request allocations.
2. **Only authorised local users can act as the robot** through the
service's IPC (F11), and no client can block the others.
3. **The ROS2 bridge doesn't feed on itself** (F12): it can be restricted
to a trusted DDS scope, can run read-only, and rate-limits what it
accepts.
4. **Hosts run with least privilege**, under a provided systemd unit.
## Non-goals
- Authenticating peers or signing observations (1.1). These limits make
floods *bounded*; they don't make them *impossible*.
- Confidentiality of the radio link (threat model F15).
- Core changes. Per-peer behaviour in the exchange (eviction, summary rate
limits) is in the 1.0 spec §10.
## Constraints
- No changes to tines-core's API or wire format.
- Defaults must be safe without configuration: a robot that gets an
untuned config must still be bounded.
- Every limit is configurable, has a documented default, and is reported
(counter or log line) when it bites. A silent limit looks like a bug.
## The how
### 1. F10 — bounded growth
1. **Default trim policy.** Both hosts call `trim` on the poll loop's
schedule (fleet fixes §2), every `trim_interval_ms` (default 10 s), with
a config `TrimPolicy` whose defaults are always on:
- `max_count`: default 50,000 observations (about 50 MB worst case at
the 1 KiB payload cap; size it per robot).
- `max_age_ms`: default 7 days.
- `keep_per_topic`: unset by default.
The fleet spec's sizing guidance applies: custody traffic must outlive
its lifetime. The host docs say how to size `max_count` for that.
2. **Store full stops being silent.** `LogStore` gains an optional hard cap
(`max_records`, defaulting to 2 × `max_count`). Above it, `insert`
returns `StoreError::Full`. With the fleet fix for F1, a full store no
longer acks, so custody stays with the sender. That's the safe
direction, and it's the point of having a cap.
3. **Log compaction.** When dead records (trimmed or superseded) exceed
`compact_ratio` of the file (default 0.5), and at most once per
`compact_interval_ms`, `LogStore` writes the live records and current
release flags to a new file. It fsyncs the file, renames it over the
old one, and fsyncs the directory. A crash during compaction leaves the
old log intact. Releases written as records (fleet fixes §4) are
carried over.
4. **Bounded channels.**
- tines-bt inbound: a bounded `mpsc` of `BT_INBOUND_CAP` frames (default
256). When full, the oldest is dropped and counted. A frame is
droppable; the protocol tolerates loss.
- tines-bt outbound: a bounded channel. When full, `send` returns
`SendError::Busy`, which core already retries via `on_send_error`.
- tines-ros2 inbound: bounded (default 256), dropping the oldest with a
counter.
5. **Per-request allocation.** The IPC frame cap drops from 1 MiB to
16 KiB. The largest valid request is a hex-encoded 64-byte topic plus a
1 KiB payload in JSON, about 2.3 KiB.
6. **Load.** `LogStore::open` reads the log in a stream instead of
`fs::read` of the whole file, so memory use at open is bounded by the
index, not the file size.
### 2. F11 — local IPC
1. **Socket location and mode.**
- Default path `$RUNTIME_DIRECTORY/tines.sock`, provided by systemd's
`RuntimeDirectory=tines` with mode `0750`.
- The socket is created `0660`, owned by the service user and a
configurable `ipc_group` (default `tines`).
- Only members of that group can connect.
2. **Peer credentials.** On accept, read `SO_PEERCRED`. Allow if the uid is
the service uid, or the gid or any supplementary group is `ipc_group`,
or the uid is in the config `ipc_allow_uids`. Anything else is closed
with a logged refusal. This is defence in depth on top of the file
mode.
3. **Read-only clients.** Config `ipc_readonly_uids` and
`ipc_readonly_gid` get `Belief`, `Status` and `Events` only. `Observe`
requires the full grant. Most consumers only read, and shouldn't be
able to speak as the robot.
4. **Don't unlink arbitrary paths.** Remove the configured path only if
it's a socket (`symlink_metadata` → file type socket). Otherwise fail
with an error.
5. **Concurrency and timeouts.**
- Serve each client on its own thread, up to `ipc_max_clients`
(default 8); beyond that, accept and close.
- Each connection has read and write timeouts (`ipc_timeout_ms`,
default 5 s).
- An idle client is closed. No client can block another.
6. **Framing errors close the connection.** An oversized length is
rejected and the connection is closed, so the stream can't be left
desynchronised. Today it returns an error and keeps reading.
7. **`accept` errors** (for example `EMFILE`) are logged and retried with
backoff; they no longer end the daemon.
8. **Events.** `Events` stays a destructive drain (one consumer), and the
docs say so. Fan-out to several consumers is a later feature if it's
needed.
### 3. F12 — the ROS2 bridge
1. **No self-echo.** The bridge must not re-observe its own publications.
- Prefer the middleware option that ignores local publications, if
rclrs exposes it.
- Otherwise, filter in the callback by publisher GID if available, or
else drop an inbound message whose payload equals the last payload
the bridge published on that topic within `echo_window_ms` (default
1 s).
- **Test:** publish once, poll several times; the store gains exactly
one observation.
2. **Read-only mode.** Config `bridge_inbound` (default `false`). When
false, the bridge creates publishers only. ROS2 consumers can read
tines state, but nothing on the domain can make the robot say anything.
Enabling inbound is an explicit decision per deployment.
3. **Rate limits.** When inbound is enabled, a per-topic token bucket
applies (`inbound_rate_per_s`, default 2; burst 4). Excess messages are
dropped and counted. A bridged topic should carry facts, not telemetry
streams.
4. **DDS scope.**
- The docs and the sample unit set a dedicated `ROS_DOMAIN_ID` and
`ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST` unless the deployment needs
the ROS graph off-box.
- For off-box graphs, the docs describe enabling SROS2: an enclave for
the `tines` node, with governance that allows only listed
participants to publish on `/tines/**`.
- This spec documents SROS2 setup; it doesn't automate it.
5. **Receipts topic.** `/tines/receipts` reveals peer NodeIds and
observation ids. It stays on, but can be disabled
(`publish_receipts`, default `true`) for deployments that bridge
off-box without SROS2.
### 4. Least privilege and the systemd unit
Provide `packaging/systemd/tines.service` (standalone) and a ROS2 launch
note. The unit:
```ini
[Service]
User=tines
Group=tines
SupplementaryGroups=bluetooth
StateDirectory=tines # log lives here, 0700
RuntimeDirectory=tines # socket lives here
RuntimeDirectoryMode=0750
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_UNIX AF_BLUETOOTH
CapabilityBoundingSet=
```
- The service never runs as root.
- BlueZ access goes through D-Bus policy for the `bluetooth` group. The
docs list the bluetoothd configuration the target images need.
- A panic exits non-zero, and `Restart=on-failure` brings the service back
(fleet fixes §2).
## Acceptance
- Each limit has a test that drives it past its cap: the counter or log
fires, and memory or disk stays bounded:
- trim,
- the store cap,
- compaction (including a crash mid-compaction),
- both BT channels,
- the ROS2 inbound channel and rate limit,
- the IPC frame cap.
- **IPC tests:**
- wrong uid refused;
- read-only uid can't `Observe`;
- an idle client doesn't block a second client;
- an oversized frame closes the connection;
- a non-socket path isn't unlinked.
- **ROS2 tests:** the self-echo test, and that read-only mode creates no
subscriptions. These run in the existing ROS2 Docker job (`Dockerfile.ros2`).
- The systemd unit passes `systemd-analyze verify` and runs the service on
the Pi image.
- `THREAT_MODEL.md` marks F10–F12 mitigated and updates §5's
"Bounded memory" row.
## Left to the builder
- Exact defaults where marked; they get tuned once real robots are
measured.
- Whether rclrs exposes local-publication filtering (and so which echo
filter is used).
- Compaction triggers beyond the ratio, for example on open.
## Sequencing
Follows the fleet fixes. Within this spec, the order is:
1. channels and the IPC cap (small, independent);
2. trim defaults and the store cap;
3. compaction;
4. IPC permissions, credentials and concurrency;
5. ROS2;
6. the systemd unit.