- CHANGELOG: tines-core 1.0.0 and the hosts' 0.4.0 are separate, unreleased sections. Every core API break is listed (the nine pub(crate) items, the private modules and the frame_sender path, the local constants per decision K, non_exhaustive types, store_mut). The new names (WallClockStore, Node::set_wall_clock, peer_time_estimate, flush, IdentityChanged) are called out. The host section covers tines::trust, the config changes, decision J, snapshots, and the review fixes. - THREAT_MODEL: F6, F8, F9 and F13 are fixed with their commits. F18 is partly fixed: the store side and the flush on first trusted time are done, but timed and shutdown flushes are still open. F19 now records decision J and the plausibility bounds, and what remains. §9, §10 and §11 are updated to match. - README: Dest and sig in the observation table; flush and is_empty in the store table; only the public wire constants are listed; a new "Trusted time" section with the config keys and the systemd ordering; the MSRV and fuzz CI jobs; frame_sender's new path. - AGENTS: 1.0 is implemented but not yet published. The constants list shows the pub(crate) split, PEER_IDLE_MS, MIN_MTU's role and where MAX_FRAGMENTS lives. Notes the hidden conformance module. - tines-bt README: frame_sender's path, and its own MAX_PEERS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 KiB
tines — threat model and security assessment
- Assessed:
masterat3b1a644(tines-core / tines-store / tines-bt / tines-ros2 / tines 0.3.0, fleet-deployment milestone mostly landed) - Date: 2026-10-19
- Updated: 2026-10-21 at
411daa0(feat/tines-core-1-0), after the tines-core 1.0 review fixes landed. Earlier update: 2026-10-20 at89041ea, after the fleet fixes. The findings keep their original text; each fixed one ends with a Fixed or Partly fixed line. §6 adds F18, found in the fleet re-check, and F19, found with the 1.0 trusted-time work. - Status: living document. It's not a spec: update it when the code or the trust assumptions change.
- Related: the 1.0 spec (
docs/superpowers/specs/2026-10-18-tines-1-0-design.md), especially §2a (signature slot) and §2b (age and the 1.1 threat model).
Every finding below was checked against the source; file:line references
are to 3b1a644 unless a line says otherwise.
1. Summary
tines v1 has no trust layer by design. The core spec lists security as a non-goal for v1, and the fleet spec repeats "v1 has no trust layer". So most of what an attacker can do today is expected:
- Anyone in radio range can read everything.
- Anyone in radio range can inject observations under any identity.
- Anyone in radio range can take custody of addressed messages.
That's acceptable while the fleet operates in a trusted RF environment, and not otherwise. Signing arrives in 1.1 (spec §2a), and link authentication is the likely 1.1 mechanism for everything that changes per hop (spec §2b).
Separate from the by-design gaps, the assessment found defects that matter now, even with no attacker present. Several are correctness bugs that the fleet milestone needs fixed regardless of security:
| # | Severity | Finding |
|---|---|---|
| F1 | High | A receiver whose store is full still acks, so the sender releases custody of a message nobody kept. |
| F2 | High | The standalone tines service never polls its Node, so it never exchanges anything over BLE. |
| F3 | High | tines-bt doesn't bind a connection to the advertised NodeId, has no inbound listener, and loses frame boundaries on a stream socket. |
| F4 | High | No host calls LogStore::flush, and both seed Node from the config first_seq. Released flags and next_seq are never persisted, so seqs repeat after every restart. |
| F5 | Medium | LogStore recovery truncates every record after a single corrupt one, and the writer can produce records the reader rejects. |
| F6 | Medium | A peer's NodeId is taken from each frame's from, unchecked and changeable per frame, so a peer can claim any identity and be handed that identity's addressed mail. |
| F7–F17 | Medium/Low | Resource exhaustion, amplification, local-IPC and ROS2 exposure, and smaller defects (§6). |
Recommended order: fix F1–F5 in the fleet milestone (they're bugs), harden the hosts (F10–F12) before any robot leaves the lab, and settle the 1.1 threat questions (§8) before designing signing.
Fleet fixes, 2026-10-19 (through 89041ea):
| # | Status | Commit |
|---|---|---|
| F1 | Fixed | 2738de4 |
| F2 | Fixed | d98895b |
| F3 | Partly fixed: framing, roles, binding and Contact/Lost are in, but the host is still limited to one configured peer (see F3) |
7df9f41 |
| F4 | Partly fixed: seqs and releases are durable, but flushing isn't on a schedule (F18) | 848721a |
| F5 | Fixed | 4718b6f, 24b5a78 |
| F17 | Fixed | 6e2dc6f |
1.0 fixes, 2026-10-21 (through 411daa0, on feat/tines-core-1-0; not
yet merged or published):
| # | Status | Commit |
|---|---|---|
| F6 | Fixed | fb71810, 506d880 (the pin resets on a repeat Contact) |
| F8 | Fixed | fb71810, 84c79fa (silent and contact-only peers are evicted) |
| F9 | Fixed | fb71810, a180ec2, 53f592f (rate-limit slack) |
| F13 | Fixed (Reassembler displacement accepted, per spec) | fb71810, 506d880, 9362e9f |
| F18 | Partly fixed: the store side is done (flush through the Store trait, the caller's tick as the marker, clamping), and both hosts flush right after they first adopt trusted time. Hosts still don't flush on a timer or at shutdown |
c42f5de, 06a0577, 039fbf7, e4544bf |
| F19 | Partly mitigated (new, 2026-10-21): peer time is chosen by decision J and bounded to a plausible range, but it stays unauthenticated until 1.1 | 039fbf7, e4544bf, 4690bf9, 03b75f2 |
Still open:
- F10–F12: the host-hardening spec.
- F7 and F14: 1.1.
- F18 (remaining): timed and shutdown flushes, host work (§10).
- F19 (remaining): 1.1 (signatures); until then, a local clock source wherever one exists (§6).
2. System and trust boundaries
flowchart LR
subgraph Robot["Robot (one tines instance per device)"]
direction TB
App["Robot software<br/>(ROS2 nodes or IPC clients)"]
Host["Host: tines-ros2 or tines service"]
Core["tines-core<br/>Node + Exchange"]
Store[("tines-store<br/>append-only log")]
BT["tines-bt<br/>BlueZ L2CAP"]
App -- "B2: DDS domain / Unix socket" --> Host
Host --> Core
Core --> Store
Core --> BT
end
Air(("B1: radio<br/>BLE advertising + L2CAP"))
BT <--> Air
Air <--> Other["Other robots<br/>(and anyone in range)"]
Disk["B3: filesystem"] -.- Store
| Boundary | What crosses it | Who can be on the other side today |
|---|---|---|
| B1 radio | Advertisements (NodeId in clear), L2CAP frames: Summary, Obs, Frag, Ack | Any BLE device in range. There's no pairing or encryption. Since 7df9f41 a connection is bound to an advertised NodeId on an allowlist, but advertisements are unauthenticated, so anyone can advertise an allowed NodeId. |
| B2 local IPC / ROS2 | Observations in, beliefs/payloads and events out | Any local user who can reach the socket (permissions come from umask); any participant on the ROS2 DDS domain (no SROS2). |
| B3 storage | The observation log: observations, releases, the boot counter | Anyone with write access to the log file. Since 4718b6f the file is created 0600 (its directory 0700 when the store creates it) and locked against a second opener. |
| Core ↔ transport | Addr, raw frames |
Trusted code. Core relies on the transport to bind Addr to a NodeId (AGENTS.md: "core never maps Addr to NodeId"). |
3. Assets
- World state integrity: the facts robots plan and navigate on. Forged or stale facts can make a robot act wrongly.
- Directive integrity and delivery: addressed messages (tasks, telemetry home), including that they arrive, arrive once, and don't outlive their lifetime.
- Custody: "every unexpired addressed message has at least one holder".
- Availability of exchange: contacts are rare and short; wasting one is costly.
- Robot resources: RAM (MCU-class targets), disk, battery, airtime.
- Identity: which robot said what (
observer), which robot is on the other end of a link. - Confidentiality: world state and directives (not a v1 goal) and robot location/movement privacy (stable advertised NodeId).
- Durability: the log, the released set and
next_seqacross reboots.
4. Adversaries
| Adversary | Capabilities | In scope for |
|---|---|---|
| A1 radio outsider | No fleet key. In BLE range: listens, advertises any NodeId, connects, injects arbitrary frames, replays, floods. Can't easily alter frames in flight on an established link, but can impersonate a peer. | v1 assessment; 1.1 design |
| A2 compromised robot (insider) | Valid fleet member (and, from 1.1, a valid key). Can already drop, delay or withhold anything it carries; custody accepts carrier failure. | 1.1 design (what signatures do and don't buy) |
| A3 local user / process | Unprivileged account or process on the robot's computer. | Host hardening (now) |
| A4 ROS2 domain participant | Any process on the same DDS domain, local or on the network the ROS graph is exposed to. | Host hardening (now) |
| A5 physical / storage access | Can read or edit the log, swap the SD card, or power-cycle the robot at will. | Partially (integrity and crash safety); full physical attack is out of scope |
| A0 no adversary | Faults only: power loss, full disk, full store, radio loss, clock jumps. | Always. Several findings below are A0 bugs. |
Out of scope: side channels, RF jamming (physical-layer DoS can't be fixed in software), compromise of BlueZ/the kernel, and supply chain.
5. Security properties: claimed vs actual
The third column is the original assessment. The fourth is the state after the fleet fixes.
| Property | Claimed (specs/AGENTS.md) | Actual at 3b1a644 |
At 89041ea |
|---|---|---|---|
| No panic on untrusted input (core) | Hard constraint | Holds as far as reviewed: saturating age arithmetic, guarded shifts and indexing, expects unreachable from wire input (exchange.rs:437, 1092; frag.rs:147). The sim's garbage_frames_never_panic covers random bytes only (§9). |
Unchanged. |
| Malformed frames dropped | Hard constraint | Holds in core (wire.rs:64-69). The 1.0 spec adds reporting. | Unchanged; 1.0 adds reporting. |
| Bounded memory in core | Fixed-capacity buffers | Holds for the exchange (§6 table). Doesn't hold for the system: MemStore and LogStore are unbounded without trim, and no host calls trim (F10). |
Unchanged in practice: the service calls trim with an empty default policy, tines-ros2 never does (F10, F18). |
| Custody: ≥ 1 holder (no carrier failure) | Fleet spec, sim property | Breaks without an attacker when a store is full (F1). Breaks trivially under A1 (F7). | Holds without an attacker (F1 fixed, with a sim property). Still breaks under A1 (F7). |
| Frames bound to the advertised NodeId | Fleet spec ("frames whose from differs … are dropped at the transport") |
Doesn't hold (F3, F6). | Holds at the transport for tines-bt (F3), against unauthenticated adverts. Since 1.0 (fb71810, 506d880) core also pins a peer's NodeId to its first frame in a contact and drops, and counts, a frame whose from differs (F6). |
| Released messages not re-forwarded after reboot | Fleet spec | Doesn't hold: release flags are never flushed (F4). Safe direction (duplicate custodian), but wasteful. | Holds: Release records are appended at once (not fsynced until the next flush, so a power cut can still lose one). |
next_seq persisted across reboots |
Core contract | Doesn't hold in either host (F4). | Holds: a persisted boot counter seeds first_seq in both hosts. |
| Age continues across reboot | Fleet spec | Partly. Hosts open the store at tick 0, and the service's tick is wall-clock seconds (F17). | Partly. Monotonic ms (F17 fixed) and clock markers fold age across a restart, but only up to the last flush, which may be long ago or never (F18). Since 1.0 the downtime itself is folded in once the host has trusted wall time; without it, held addressed messages expire and broadcasts are floored (F18, F19). |
6. Findings
Severity reflects impact on the fleet as it's meant to be deployed (trusted RF, single-user robot computers) unless stated. "By design (v1)" marks attacker capabilities that follow from having no trust layer; they're listed so the 1.1 work can check them off.
F1 — Full store acks what it didn't keep (High, A0/A1)
Exchange::accept enqueues an ack for every receive except a released
duplicate, including when insert fails (exchange.rs ~805-813, comment:
"even an un-stored id"). Err(Full) counts as a duplicate and
released is false, so the ack goes out. The sender then releases custody
of a message the receiver didn't keep.
- Effect: an addressed message is lost with no carrier failure. This violates the custody invariant the fleet spec and sim property rely on. An attacker doesn't need to do anything clever: filling a robot's store (F10) turns it into a custody sink.
- Why it's not caught:
MemStorenever returnsFull, and the sim usesMemStore. - Fix: never ack an addressed observation unless
insertreturnedOk(_)(and, for duplicates, the copy isn't released). Add a sim/unit test with a store that returnsFull. This was the fleet spec's own rule ("B never acks a … store-full receive"). - Fixed:
2738de4.
F2 — The standalone service never runs the exchange (High, A0)
tines/src never calls Node::poll or Node::trim (grep; the plan calls
for a poll loop, docs/superpowers/plans/2026-10-18-tines-fleet-deployment.md:209).
serve() only answers IPC requests.
- Effect: a non-ROS2 robot never sends or receives anything. Inbound BLE data accumulates in an unbounded channel (F10).
- Security relevance: availability, plus unbounded memory growth while connected. Mostly a functional bug.
- Fix: a poll loop (with
trimon a schedule andflushafter releases), off the IPC thread or interleaved with it. - Fixed:
d98895b(runs inline inserve— the node isn'tSend, so no separate thread).
F3 — tines-bt: no identity binding, no listener, no framing (High, A0/A1)
read_loopstamps every inbound chunk withfrom: peer, wherepeercomes from config (tines-bt/src/transport.rs:340-363). The drop checkif from != self.peer(transport.rs:113-115) therefore never fires, whichever device connected.- The scanner connects to the first device advertising the tines service UUID and never decodes or compares the advertised NodeId (transport.rs:290-308). There's no allowlist.
- There's no
L2capListener, so nothing accepts inbound connections, andis_centralis never consulted. Every instance dials out. Two robots can only connect if something else accepts. - The L2CAP socket is in stream mode and each
read()result is treated as one frame (transport.rs:345-356). Frame boundaries aren't preserved, so frames can be split or merged. Without an attacker, that produces sporadic malformed drops. With the 1.0 drop reporting, it would also produce false "malformed" alarms. Contact/Lostevents are never emitted, so core's per-contact state (bounce records, ack validation "this contact", peer removal) never resets (see F8).- No BLE security level (pairing, encryption, LE Secure Connections) is requested.
Fix (fleet milestone):
- Use SeqPacket L2CAP, or add explicit length-prefix framing.
- Add a listener plus the smaller-NodeId-initiates rule.
- Decode the advertised NodeId and bind the connection to it.
- Drop frames whose
fromdiffers from the bound NodeId. - Emit
Contact/Lost. - Partly fixed:
7df9f41(SeqPacket framing, advertise+scan roles, UUID-checked NodeId binding,frame_senderfrom-check,Contact/Lost). Re-checked at89041ea:- The fleet-fixes spec's allowlist (empty = any tines advertiser) and
multi-peer support aren't reachable. The service config still takes one
required
peer.BluerRadio::newbuilds the allowlist as exactly{peer}, andBtTransport::polldrops every frame and event whose address isn't that peer (tines-bt/src/transport.rs:186-202, 274). The radio loop can hold several connections, but only one is ever used, so each robot exchanges with one configured partner. - Inbound and outbound channels are still unbounded (F10).
- No BLE security level is requested (unchanged; 1.1).
- No two-radio check of the SeqPacket path is recorded yet. tines-bt/README.md gives the procedure but no result.
- The fleet-fixes spec's allowlist (empty = any tines advertiser) and
multi-peer support aren't reachable. The service config still takes one
required
Later (1.1): authenticate the link (spec §2b). Advertised NodeIds are unauthenticated, so the binding only becomes meaningful once there's a handshake.
F4 — Durability promises aren't kept by the hosts (High, A0)
- No host calls
LogStore::flush(grep oftines-ros2/src,tines/src).Meta { next_seq, released }is only written byflush. So the released set never reaches disk, and after a restart every released message is forwarded again. That's the safe direction (a duplicate custodian) but contradicts the fleet spec. - Both hosts build
Nodewith the configfirst_seq(tines/src/main.rs:44-48,tines-ros2/src/node.rs:147-149) instead of a persisted value. Core requiresnext_seqto be persisted (boot_count << 32 | counter). After a restart, seqs repeat. A new observation with the same topic, payload and recipient as an earlier one then has the same id and dedupes silently: the robot reports something and it vanishes. LogStore::next_seqcounts all inserts, including received ones (log.rs:352), so it isn't the node's seq either.
Fix:
- Persist the node's
next_seq(boot counter plus counter), and seedNodefrom it. - Flush on release and on a timer.
- Add a host-level restart test.
- Partly fixed:
848721a. The boot counter seedsfirst_seqin both hosts, and releases are appended as records immediately. Flushing isn't on a schedule, though; see F18.
F5 — Log recovery can destroy good data (Medium, A0/A5)
LogStore::newstops replay at the first record that fails to parse and truncates the file there (log.rs:180-206). A single corrupt record in the middle (a bad sector, a bit flip, a hand edit) deletes every record after it. The code comment says "only the partial tail of a crashed append is lost, never a complete record", which is true only for tail damage.write_recorddoesn't enforceMAX_RECORD_BYTES(log.rs:274-281), but the reader rejects longer records. AMetarecord grows by 16 bytes per released id, so above roughly 500 released ids the store writes a record that makes it truncate itself on the next open.- There's no checksum, so silent corruption that still decodes is accepted.
The stored
idis trusted without recomputingobs.id()(log.rs:154). - There's no file locking. Two processes on one log interleave appends, and
one's recovery
set_lencan cut the other's records. - The log is read whole into memory (log.rs:127). The file is created with umask permissions, and the truncation isn't fsynced.
Fix:
- Add a per-record CRC (or a keyed MAC later).
- On a bad record in the middle, skip to the next valid one (a sync marker) instead of truncating; truncate only a torn tail.
- Enforce the size cap on write, and keep
Metasmall (store release flags per record, or compact). - Take an exclusive
flockon open. - Create the file with mode
0600. - Recompute ids on load.
- Fixed:
4718b6f+24b5a78(format v1: header, per-record CRC-32C, sync marker + skip-corrupt recovery, torn-tail truncation + dir fsync,flock, 0600, recomputed ids).
F6 — Peer identity is whatever the last frame said (Medium; High combined with F3)
on_frame sets peer.id = Some(frame.sender()) on every frame, unchecked
(exchange.rs:723). The from field inside Obs/Frag is ignored, and
observer is never compared to anything.
- Effect (A1): send one frame with
from = X, andforward_addressedhands you every message addressed to X (exchange.rs:584-593), as "delivery". Combined with F7 you can then ack them, and the honest carrier releases them. - Design note: core deliberately leaves the
Addr↔ NodeId binding to the transport, so this is F3's consequence. Still, core could cheaply refuse a NodeId change for an existing peer within a contact, and should, once the transport binds identities. - Fixed in
fb71810,506d880: the first decoded frame in a contact pinspeer.id. A later frame with anotherfromis dropped and counted asDropReason::IdentityChanged. The pin resets onLostand on a repeatContact.
F7 — Custody can be taken by anyone the carrier sends to (Medium; by design v1)
An ack is accepted if it comes from the address the message was sent to
(bounce Sent record, exchange.rs:756-784). Under the custody chain, a
carrier hands an addressed message to any peer with a known id
(exchange.rs:594-595), then releases on that peer's ack. A1 can collect
custody of every addressed message in range and drop it.
- That matches "carrier failure" (accepted for v1) but needs no key and no compromise. 1.1's link authentication limits it to fleet members (A2), where it really is carrier failure.
- Also: repeated Acks produce duplicate confidence events (
bcast_sentisn't consumed).
F8 — Peer-table lockout (Medium, A1)
MAX_PEERS = 8. Peers are added on Contact or on the first decodable
frame, and removed only on Lost (exchange.rs:674-708). A full table drops
all frames from new addresses. There's no idle eviction. With BLE never
emitting Lost (F3), entries effectively never leave on real hardware.
(Since 7df9f41 tines-bt emits Lost, so honest peers leave; spoofed
addresses on other transports still can't be evicted.)
Eight spoofed addresses lock a robot out of exchange until restart.
Fix: idle-timeout eviction in core (a local constant), and real
Lost events from tines-bt.
- Fixed in
84c79fa(first attemptfb71810evicted only the peer that had just sent): a peer with no inbound frame forPEER_IDLE_MS(30 s), counted from its contact if it never sent, is removed as ifLost. Idle peers are swept before a peer is added and at the start of everynext_frame, so eight silent or contact-only addresses can't hold the table.
F9 — Summary amplification (Medium, A1)
A Summary of about 20 bytes with an all-zero filter makes the receiver
rebuild that peer's send queue with up to 256 of its youngest broadcasts
(exchange.rs:1065-1125). Each one can be up to 64 fragments, so the worst
case is about 16,000 frames of airtime per crafted summary. That's
throttled to one frame per poll, but sustained. Re-sending the summary
re-queues what was already sent.
- Each rebuild also scans every broadcast and clones payloads, which costs CPU and allocation per summary, with no rate limit.
- Fix (as amended in spec §10, 2026-10-20 and decision L):
- Rate-limit summary processing per peer: act on at most one summary per
SUMMARY_INTERVAL_MS. To allow for poll jitter, a summary is accepted once at leastSUMMARY_INTERVAL_MS / 2has passed since the last one acted on, which still bounds replay to two per interval. - The send-queue rebuild skips ids the peer's filter says it holds
(
Bloom::might_contain). Output per received summary stays bounded bySEND_QUEUE_CAP. - It doesn't skip ids already sent this contact. "Sent" doesn't mean "received", so a peer that lost an id must get it again on its next summary.
- Rate-limit summary processing per peer: act on at most one summary per
- Fixed in
fb71810,a180ec2,53f592f.
F10 — Unbounded growth (Medium, A0/A1/A4)
MemStoreandLogStoreare bounded only byNode::trim. Neither host calls it. The log also never compacts (log.rs: append-only, and every flush appends the full released list).- At
89041ea: the service callstrimwithTrimPolicy::default(), which removes nothing; tines-ros2 never calls it. Releases are now one small record each rather than a growing list, but the log still never compacts. - At
411daa0:LogStorerewrites the log as a snapshot at every open and after the wall-clock fold (06a0577), which compacts it. Between those points it only grows, and the trim policy is still empty.
- At
- Unbounded channels: tines-bt's inbound and outbound
mpsc(transport.rs:207-208), and tines-ros2's inboundmpsc(node.rs:152). - The IPC server allocates up to 1 MiB per request frame.
- Effect: A1 can fill memory and disk by gossiping junk. A4 can do it by publishing on a bridged topic. Either drives F1 (store full) once a bounded store exists.
- Fix:
- Hosts trim on a schedule with a documented default policy.
- Use bounded channels (drop or backpressure).
- Add log compaction.
- Add per-peer and per-observer insert quotas (for example, a per-peer token bucket on new inserts).
F11 — Local IPC is open to any local user (Medium, A3)
- The socket gets umask permissions, and there's no
SO_PEERCREDcheck (tines/src/ipc.rs:194-200; grep finds noset_permissionsor peer-cred code). - Any local process that can reach it can:
- publish observations as this robot (
Observe), which are then gossiped fleet-wide under its identity; - read any belief;
- drain the shared event queue, destructively.
- publish observations as this robot (
- The configured socket path is unlinked unconditionally before binding, whatever file is there (ipc.rs:197).
- Availability: clients are served one at a time with no timeouts, so
an idle client blocks everyone. An
accepterror ends the daemon. An oversized frame (> 1 MiB) gets an error reply without consuming its body, which desynchronizes the stream (contrary to the module docs). - Fix:
- Create the socket in a
0700runtime directory with mode0600(or a group-restricted mode). - Check
SO_PEERCREDagainst an allowed uid/gid. - Only unlink a path that is a socket.
- Set per-connection read timeouts, or serve clients concurrently.
- Close the connection on an oversized frame.
- Survive
accepterrors.
- Create the socket in a
F12 — ROS2 bridge trusts the whole DDS domain (Medium, A4)
- Read: any participant on the domain can read the latest payload of every bridged topic and every receipt.
- Write: any participant can inject observations on a managed topic.
They're stored and gossiped as this robot's own (
observe(..., None, now), tines-ros2/src/node.rs:197), with no rate limit. - SROS2/DDS security isn't mentioned or configured anywhere.
- Likely self-echo loop: the node subscribes to the topics it publishes, with no filter for its own messages (node.rs:207-238). If DDS delivers its own publications back, which the default settings allow (not verified on hardware), each republish is re-observed with a new seq. That's a feedback loop that grows the log and floods the fleet.
- Fix:
- Ignore local publications (
ignore_local_publicationsor a publisher-GID filter). - Document running with SROS2 enclaves and a dedicated domain id.
- Rate-limit inbound.
- Bound the channel.
- Ignore local publications (
F13 — Smaller core defects (Low)
- Event queue drops newest, not oldest (
let _ = self.events.push, exchange.rs:777), contrary to its docs and the fleet spec. - Pending acks for a lost peer aren't purged on
remove_peer. On send errors they're re-queued, and since acks go ahead of data, retries to a dead peer can starve data frames. - Reassembler displacement: one fragment with a different id or count resets a peer's in-progress stream (frag.rs:121-133). Same-address only, so it's limited by F3/F6 binding.
- Trailing bytes:
postcard::from_bytesaccepts trailing bytes after a valid frame. That's harmless now, but the 1.0 vectors should pin rejection so the wire format is canonical. - Fixed in
fb71810,506d880,9362e9f, with Reassembler displacement accepted, per spec §10:- the event queue drops the oldest event;
- removing a peer purges that peer's pending acks, and only its own
(
506d880; the first fix purged every peer's); - a frame, and a reassembled observation (
9362e9f), must decode with no trailing bytes, or it's counted asMalformed; - displacement stays as it is: it's same-address only, and the transport binding (F3) plus the F6 pin limit who can do it.
F14 — Belief policies trust attacker-controlled fields (Medium; by design v1)
LatestWinspicks the smallestage_ms(belief.rs:36-39). An attacker sets age 0 to win any topic (see spec §2b).SelfFirsttrustsobs.observer == me(belief.rs:75-77). A forged observation claiming to be from this robot beats everything. Signing in 1.1 fixes the observer claim. Until then, a host could refuse inbound observations whoseobserveris its own NodeId (a robot never needs to receive its own observations from a peer if it still holds them).- First-copy-wins dedupe fixes
age_ms,observed_atandlifetime_msforever at a receiver (mem_store.rs:72-75). A hugelifetime_msmakes an addressed message effectively immortal.
F15 — No confidentiality, trackable robots (Low for v1; by design)
The BLE link is unencrypted, so world state and directives are readable by anyone in range. Each robot advertises a stable NodeId in cleartext and is discoverable (transport.rs:276-282), so it can be tracked across BLE address rotation. That's the design (the NodeId is the rendezvous). If tracking matters, 1.1+ needs rotating, keyed rendezvous identifiers.
F16 — The 16-byte id is unkeyed and truncated (Low, A1)
Ids are the first 128 bits of Blake2s-256 (64-bit collision resistance). Producing a collision with a specific existing id needs a second preimage, which is infeasible, so an attacker can't squat a particular id. Generic 64-bit collisions are within reach of a determined attacker, but only between two observations they produced themselves, which gains nothing. This is acceptable for dedupe. It's the reason spec §2a signs the full preimage, not the id.
F17 — Service clock is wall-clock seconds (Medium, A0)
tines/src/ipc.rs now_ms() returns as_secs() from SystemTime
(ipc.rs:276-281). Core's Tick is monotonic milliseconds.
- Ages advance 1000× too slowly, so addressed lifetimes last 1000× too long.
- An NTP step backwards freezes ages, because they saturate.
- The store is opened at tick 0 while requests use epoch values, so the first read can add the whole epoch to every age.
Fix: use a monotonic millisecond clock (Instant since process start)
everywhere in the host, and open the store with the same clock.
- Fixed:
6e2dc6f.
F18 — Hosts don't flush on a schedule (Medium, A0)
Found in the re-check at 89041ea. LogStore::flush writes the clock
marker that carries age across a restart, and it's the only fsync for
inserts and trims (log.rs module docs, "Durability contract").
- The service runs
trimandflushonly afterPOLL_CAP(64) polls that each stored something new (tines/src/ipc.rs:269-279 at411daa0). The counter advances only while observations arrive, so a robot that is mostly offline, the normal case, may never flush. - tines-ros2 never calls
flushortrim. - Even when it runs,
flushwroteLogStore's own tick as the clock marker, and that tick advanced only when something new was inserted (tines-store/src/log.rs:698). On a quiet robot the marker was stale, and the time since the last insert was lost. Fixed in 1.0 (c42f5de):Store::flush(now)takes the caller's tick, so the marker is the true current time at every flush. - Effect:
- After a restart, every stored observation's age is folded only up to the
last clock marker. Time since that marker is lost, so facts look fresher
than they are and addressed messages outlive their
lifetime_ms, by up to the whole previous uptime. That is F17's effect again, by another route. - On a power cut, observations inserted since the last flush can be lost. That includes a robot's own observations, though their seqs aren't reused.
- After a restart, every stored observation's age is folded only up to the
last clock marker. Time since that marker is lost, so facts look fresher
than they are and addressed messages outlive their
- Fix: give flush the caller's
now(proposed for 1.0:Store::flush(now)andNode::flush(now), replacingNode::store_mut), and flush and trim on a wall-time schedule (everyflush_interval_ms, from the host's monotonic clock) in both hosts, and on clean shutdown. The fleet-fixes spec asked for "a scheduled flush". The trim defaults belong to host hardening (F10). - Partly fixed in
c42f5de,06a0577,039fbf7,e4544bf.- Store side, done.
Store::flush(now)takes the caller's tick (c42f5de).LogStoreimplements it as the trait method, soNode::flushreally writes the marker and fsyncs; before06a0577it hit the trait's no-op default. An earliernowis clamped to the store's time anchor. - Flush after first trusted time, done. Both hosts adopt trusted time
through
tines::trust::adopt_wall_clock(tines/src/trust.rs:90-108), which flushes right after the fold. So the first marker with a wall time reaches disk at once. - Snapshots.
LogStorealso rewrites the log at open and after the fold (decision I), so the fold's results survive a reboot. - Still open (host work):
- The service's scheduled trim and flush are still tied to
POLL_CAPproductive polls (tines/src/ipc.rs:269-279). - tines-ros2 flushes only when it adopts trusted time, and never trims.
- Neither host flushes on a timer or at clean shutdown. A quiet robot that loses power long after its last flush still under-counts the time since that flush.
- The service's scheduled trim and flush are still tied to
- Store side, done.
F19 — Peer-derived trusted time is unauthenticated (Medium, A0/A1)
Added 2026-10-21, when the hosts gained the peer-time path (Task 10b).
When a host has no local trusted clock, it estimates wall time from peers'
observations (observed_at + age_ms, advanced by local ticks since the
observation arrived), then folds it into the store once. Nothing
authenticates that estimate.
- Effect:
- A time too far ahead makes the fold over-count downtime: addressed
messages expire early, facts look stale, and
observed_atstamps run fast. That's availability loss, not integrity loss. - A time behind the last clock marker reads as "clock went backwards", which takes the unknown-downtime path: held addressed messages expire and broadcasts are floored. Also the safe direction.
- A time behind the truth but still ahead of the marker under-counts the downtime, so held ages come out too young. That's the dangerous direction (spec §2b, §9). Honest estimates can land here, because age is a lower bound.
- Before the 1.0 review, a forged
observed_at = u64::MAXoverflowedwall + elapsedin the hosts and the store: a panic in debug, a time near 1970 in release (review B3).
- A time too far ahead makes the fold over-count downtime: addressed
messages expire early, facts look stale, and
- Mitigated since
039fbf7,e4544bf,4690bf9,03b75f2:- Decision J: the latest estimate in a window. From the first
plausible estimate, the host collects for
peer_time_window_ms(default 10 s) and then adopts the latest one, so an honest undershoot loses to a better estimate.LogStore::peer_time_estimatereturns the latest estimate among observations stored since this open. A forger gains nothing over the old smallest-age rule. - Plausibility bounds (review B3).
- The hosts adopt only wall times between
WALL_FLOOR_MS(2026-01-01Z) andWALL_CEILING_MS(floor + 100 years, about 2126). That applies to peers and to the system clock (tines::trust). - The store drops estimates outside
LogStoreBuilder::plausible_wall_msbefore choosing the latest, so a forged far-future estimate can't hide the honest ones. Both hosts set it. - The wall-time arithmetic saturates.
- The hosts adopt only wall times between
- Local sources first. The service's
trusted_clock = "system", and tines-ros2's newtrust_system_clock/trust_wall_clock, both take priority over peers. A ROS2-only fleet now has a local source.
- Decision J: the latest estimate in a window. From the first
plausible estimate, the host collects for
- Still open:
- Peer time is unauthenticated until 1.1. Within the plausible range, a forger can still choose the time, and the window takes the latest estimate, forged or not.
- A wrong-but-plausible time spreads. The robot stamps its own
observations'
observed_atfrom it, and peers without a trusted clock can adopt those stamps in turn. - The adopted time is corrected only at the next restart, or when a local trusted source arrives. Nothing corrects it while the host runs (decision J), and on the peer path it's adopted once per run.
- Mitigation for deployments: give every robot a local trusted source
(RTC, GPS, or an NTP-synced clock with the service ordered after
time-sync.target). Signing and verification in 1.1 close the forged case.
7. Threats by STRIDE, mapped to findings
| Threat | Status today | Findings | Planned mitigation | |
|---|---|---|---|---|
| Spoofing | Impersonate a robot on the link | Trivial (A1) | F3, F6 | Transport binding now; link auth (1.1) |
Forge an observation's observer |
Trivial (A1, A3, A4) | F11, F12, F14 | Host hardening now; signatures (1.1) | |
| Tampering | Alter content in transit | No integrity (A1) | — | Signatures cover content (1.1) |
| Alter age / lifetime | Unprotected | F14, §2b | 1.1 threat-model decision | |
| Alter the log on disk | No integrity (A5) | F5 | CRC now; MAC optional | |
| Repudiation | Deny having said something | Unattributable | — | Signatures (1.1) |
| Information disclosure | Read world state / directives | Cleartext (A1, A4) | F12, F15 | Out of scope v1; link encryption candidate |
| Track robots | Stable NodeId adverts | F15 | Rotating identifiers (post-1.1) | |
| Denial of service | Lose custody traffic | Easy (A0!, A1) | F1, F7 | Fix F1 now; link auth (1.1) |
| Lock out peers / waste airtime | Easy (A1) | F8, F9 | Eviction and rate limits now | |
| Exhaust memory / disk | Easy (A1, A4) | F10 | Trim, bounded channels, quotas now | |
| Block local clients | Easy (A3) | F11 | Timeouts, concurrency now | |
| Elevation of privilege | Act as the robot from a local account | Any local user (A3) | F11 | Socket permissions + peer creds now |
| Act as the robot from the ROS graph | Any domain participant (A4) | F12 | SROS2, domain isolation |
8. The age question (for 1.1)
Spec §2b asks whether age_ms must be authenticated. This assessment
supports its framing, and adds evidence:
- Today the question is moot. Everything is forgeable, and fixing F14 needs signatures first.
- After 1.1 signs content, age is the main remaining freshness lever:
stale facts winning
LatestWins, expired directives coming back to life, and fresh facts suppressed. For A1 it's fully exploitable without link authentication. For A2 it adds freshness attacks to what a bad carrier can already do by dropping. - Recommendation for 1.1: link authentication (handshake plus per-frame
tag), which removes A1 from age, acks, summaries and identity in one
mechanism. Accept A2 age forgery as equivalent to carrier misbehaviour,
and document it. Cross-check against signed
observed_atwhere robots have clocks.
9. Test gaps
The sim covers honest-node custody properties and random garbage frames
(tests/props.rs:281, tests/relay.rs). It doesn't cover:
- well-formed malicious frames: forged acks, spoofed
from/observer, NodeId changes mid-contact (since37ca04dthe fuzz harness generates all three, but in the core harness only; the sim and the hosts don't); a store returning(covered sinceFull(F1)2738de4: a unit test and the full-store custody sim property);summary floods and peer-table exhaustion (F8, F9)(covered since37ca04d,8ba1e86: the fuzz harness's adversarial mode replays summaries and floods the peer table from 3 ×MAX_PEERSaddresses, and the proptest twin checks recovery afterPEER_IDLE_MS);- reassembly abuse (now partly covered: the adversarial mode sends conflicting, repeated and out-of-order fragment streams);
- a full disk (
LogStorecorruption in the middle, oversized records, concurrent openers, and restart and seq continuity are covered since848721aand4718b6f); - a quiet host restarting without having flushed (F18).
tines-storenow tests flushing through the trait, opening three times, and clamping (06a0577), but no host-level test covers a long quiet run; - forged peer time beyond overflow and range checks: nothing tests a
wrong-but-plausible estimate spreading through
observed_at(F19); - most of the hosts: IPC abuse, ROS2 injection and self-echo (F11, F12).
The service now has a two-instance exchange test (
d98895b), and tines-bt has mock-radio tests for framing, binding and contact events (7df9f41).
The 1.0 fuzz harness (spec §7) has an adversarial peer mode since
37ca04d, as well as random bytes: structured frames with chosen ids and
identities, checked against the §7 and §10 properties. It runs nightly in CI
(.github/workflows/fuzz.yml), with a proptest twin in the PR gate. The
agreed fuzz budget hasn't been run and recorded yet.
10. Where each finding is being fixed
| Findings | Where | Spec | Status |
|---|---|---|---|
| F1, F2, F5, F17 | Fleet milestone (M3) | docs/superpowers/specs/2026-10-19-tines-fleet-fixes.md |
Fixed (§1) |
| F3, F4 | Fleet milestone (M3) | same | Partly fixed; the rest has no spec yet |
| F18 | Store side: tines-core 1.0 and tines-store; timed and shutdown flushes: host work, with or before host hardening | 1.0 spec §4, §9; none yet for the host part | Partly fixed (§1) |
| F10–F12 | Host hardening, before a robot leaves the lab | docs/superpowers/specs/2026-10-19-tines-host-hardening.md |
Open |
| F6, F8, F9, F13 | tines-core 1.0 | docs/superpowers/specs/2026-10-18-tines-1-0-design.md §10 |
Fixed (§1); F13's Reassembler displacement accepted |
| F19 | Hosts and tines-store (decision J, plausibility bounds); authentication in 1.1 | 1.0 spec §9, decision J; §2a | Partly mitigated (§1) |
| F7, F14, §8 | 1.1 (threat model, link authentication, signing) | 1.0 spec §2a, §2b | Open |
| F15, F16 | Accepted for now | — | — |
Mark each finding fixed here, with its commit, when it lands.
The remaining F3 work (one configured peer) and F18 are host-only. Neither blocks tines-core 1.0, but both block a fleet of more than two robots. F19's remainder waits for 1.1.
11. Recommendations, in order
- Fleet milestone bugs (done 2026-10-19, except the F3 and F4
remainders):
- F1 (ack only what was stored).
- F2 (poll loop).
- F3 (framing, listener, NodeId binding, Contact/Lost).
- F4 (persist seq, flush).
- F5 (CRC, recovery that skips rather than truncates, write cap, locking).
- F17 (monotonic ms clock).
- Each with a regression test.
- Host follow-ups: the F3 multi-peer allowlist and the rest of F18 (timed and shutdown flushes in both hosts, and trim in tines-ros2). Small, and needed before more than two robots share a fleet. The F18 store side and the flush after first trusted time are done.
- Host hardening before any robot leaves the lab:
- F10 (trim, bounded channels, compaction).
- F11 (socket permissions, peer creds, timeouts).
- F12 (ignore own publications, SROS2 guidance, rate limits).
- Core hardening (rides with 1.0): done on
feat/tines-core-1-0(2026-10-21), publish pending.- F8 (idle eviction, including silent and contact-only peers).
- F9 (summary rate limit with slack; the rebuild skips what the peer's filter holds).
- F13 (event drop-oldest, purge only the lost peer's acks, reject trailing bytes; displacement accepted).
- F6 (refuse NodeId change within a contact).
- 1.1:
- Write the full threat model from §4/§8.
- Choose link authentication.
- Add signing per spec §2a (F14, F7), and authenticate peer time (F19).
- Add "replace if better signed" dedupe.
- Later: confidentiality and rotating rendezvous identifiers (F15), only if the deployment needs them.