docs: security assessment specs (fleet-fixes, host-hardening, 1.0 §10, threat model)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user