Skip to content

Commit b521867

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat(vordr): add temporal isolation engine (ADR-007)
Implement temporal isolation as a security primitive for undetectable code observation. Instead of spatial isolation (containers/VMs), the monitoring system operates on a faster timescale than the target, making observation imperceptible. Rust temporal module: - IsolationTier (Standard/Temporal/Tomograph) - TimeController with layered time manipulation (NTP/PTP/libfaketime/RDTSC) - TemporalIsolationEngine with baseline→sweep→narrow→capture pipeline Elixir temporal module: - PortSwarm: 65,535 BEAM port interceptors (~20MB, microsecond spawn) - TimeController: fake NTP/PTP servers, calendar-aware sweep strategies - Tomograph: CT-scan distributed observation across N instances eBPF updates: - TimeEvent and TimeSource types for tracking clock queries - is_time_query() for detecting gettimeofday/clock_gettime calls - Time-related syscalls added to name table Integrates with panic-attack abduct subcommand for time-bomb detection in supply chain security screening. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1725e13 commit b521867

11 files changed

Lines changed: 2278 additions & 3 deletions

File tree

container-stack/vordr/.claude/CLAUDE.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ The following files in `.machine_readable/` contain structured project metadata:
4848
### Idris2
4949
- Dependent types are documentation — name them clearly
5050
- Proofs in `src/idris2/proofs/` with literate comments
51-
- SPDX header: `-- SPDX-License-Identifier: AGPL-3.0-or-later`
51+
- SPDX header: `-- SPDX-License-Identifier: PMPL-1.0-or-later`
5252

5353
### Rust
5454
- `rustfmt` + `clippy` mandatory
5555
- No `unsafe` except in eBPF bindings (document why)
56-
- SPDX header: `// SPDX-License-Identifier: AGPL-3.0-or-later`
56+
- SPDX header: `// SPDX-License-Identifier: PMPL-1.0-or-later`
5757

5858
### Elixir
5959
- `mix format` mandatory
@@ -75,6 +75,12 @@ See `META.scm` for ADRs. Key decisions:
7575
4. **ADR-004**: eBPF for runtime monitoring
7676
5. **ADR-005**: Threshold signatures for multi-stakeholder trust
7777
6. **ADR-006**: RISC-V as primary edge target
78+
7. **ADR-007**: Temporal isolation engine (see `docs/ADR-007-temporal-isolation.md`)
79+
- Time dilation as primary security primitive (not containers/VMs)
80+
- BEAM port interceptor swarm (65,535 processes, ~20MB)
81+
- eBPF + PTP/NTP clock manipulation for undetectable observation
82+
- CT-scan tomographic mode: distributed across 1,000–100,000 instances
83+
- Integration with panic-attack abduct subcommand
7884

7985
## Key Files
8086

@@ -131,6 +137,8 @@ just prove container_lifecycle.idr # Generate proofs
131137
- **Cerro Torre**: Consumes build attestations
132138
- **Oblibeny**: Orchestration handoff
133139
- **poly-ssg-mcp**: MCP interface exposure
140+
- **panic-attack**: `abduct` subcommand consumes temporal isolation engine
141+
- **selur**: IPC bridge between Rust eBPF and BEAM orchestrator
134142

135143
## Commit Style
136144

container-stack/vordr/docs/ADR-007-temporal-isolation.md

Lines changed: 348 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
3+
defmodule Vordr.Temporal.PortSwarm do
4+
@moduledoc """
5+
BEAM Port Interceptor Swarm
6+
7+
Spawns one lightweight BEAM process per port (up to 65,535).
8+
Each process listens on a single port and logs all connection attempts.
9+
Pre-spawned before the target process starts, so the target sees
10+
services already listening — indistinguishable from normal deployment.
11+
12+
## Why BEAM?
13+
14+
BEAM processes are ~300 bytes each. 65,535 interceptors consume
15+
approximately 20MB of memory — negligible for a monitoring system.
16+
BEAM spawns processes in microseconds, so the entire swarm is ready
17+
before the target code executes its first instruction.
18+
19+
## Detection resistance
20+
21+
In any real deployment, most ports have something listening
22+
(web servers, databases, message brokers, health checks).
23+
Port interceptors look like normal services — they are
24+
environmental noise, not monitoring artefacts.
25+
26+
## Integration
27+
28+
Port swarm events feed into the eBPF event consumer via
29+
the selur IPC bridge. The Rust eBPF monitor receives
30+
`NetworkEvent` structs for correlation with syscall events.
31+
"""
32+
33+
use GenServer
34+
35+
require Logger
36+
37+
@default_port_range 1..65_535
38+
@listen_backlog 1
39+
40+
# ── Types ──────────────────────────────────────────────
41+
42+
@type interceptor_id :: pos_integer()
43+
@type connection_log :: %{
44+
port: pos_integer(),
45+
source_ip: String.t(),
46+
source_port: pos_integer(),
47+
timestamp: DateTime.t(),
48+
data_preview: binary()
49+
}
50+
51+
# ── Public API ─────────────────────────────────────────
52+
53+
@doc """
54+
Start the port swarm supervisor.
55+
56+
## Options
57+
58+
- `:port_range` — Range of ports to intercept (default: 1..65_535)
59+
- `:exclude_ports` — Ports to skip (e.g. SSH on 22, or the BEAM node port)
60+
- `:collector_pid` — PID to receive connection events
61+
- `:log_data` — Whether to capture initial bytes of data (default: false)
62+
"""
63+
@spec start_link(keyword()) :: GenServer.on_start()
64+
def start_link(opts \\ []) do
65+
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
66+
end
67+
68+
@doc """
69+
Spawn interceptors across the port range.
70+
71+
Returns the count of successfully spawned interceptors.
72+
Ports that are already in use are silently skipped (they
73+
already have a legitimate service — no need to intercept).
74+
"""
75+
@spec spawn_swarm() :: {:ok, non_neg_integer()}
76+
def spawn_swarm do
77+
GenServer.call(__MODULE__, :spawn_swarm, :infinity)
78+
end
79+
80+
@doc """
81+
Stop all interceptors and release ports.
82+
"""
83+
@spec stop_swarm() :: :ok
84+
def stop_swarm do
85+
GenServer.call(__MODULE__, :stop_swarm)
86+
end
87+
88+
@doc """
89+
Get all connection events logged so far.
90+
"""
91+
@spec get_connections() :: [connection_log()]
92+
def get_connections do
93+
GenServer.call(__MODULE__, :get_connections)
94+
end
95+
96+
@doc """
97+
Get count of active interceptors.
98+
"""
99+
@spec active_count() :: non_neg_integer()
100+
def active_count do
101+
GenServer.call(__MODULE__, :active_count)
102+
end
103+
104+
@doc """
105+
Get connection count per port (only ports that received connections).
106+
"""
107+
@spec connection_summary() :: %{pos_integer() => non_neg_integer()}
108+
def connection_summary do
109+
GenServer.call(__MODULE__, :connection_summary)
110+
end
111+
112+
# ── GenServer callbacks ────────────────────────────────
113+
114+
@impl true
115+
def init(opts) do
116+
port_range = Keyword.get(opts, :port_range, @default_port_range)
117+
exclude_ports = Keyword.get(opts, :exclude_ports, [])
118+
collector_pid = Keyword.get(opts, :collector_pid, self())
119+
log_data = Keyword.get(opts, :log_data, false)
120+
121+
state = %{
122+
port_range: port_range,
123+
exclude_ports: MapSet.new(exclude_ports),
124+
collector_pid: collector_pid,
125+
log_data: log_data,
126+
interceptors: %{},
127+
connections: [],
128+
spawned_count: 0
129+
}
130+
131+
{:ok, state}
132+
end
133+
134+
@impl true
135+
def handle_call(:spawn_swarm, _from, state) do
136+
Logger.info("Spawning port interceptor swarm across #{inspect(state.port_range)}")
137+
138+
{interceptors, count} = spawn_interceptors(state)
139+
140+
Logger.info("Swarm ready: #{count} interceptors active (#{map_size(state.exclude_ports)} ports excluded)")
141+
142+
new_state = %{state | interceptors: interceptors, spawned_count: count}
143+
{:reply, {:ok, count}, new_state}
144+
end
145+
146+
@impl true
147+
def handle_call(:stop_swarm, _from, state) do
148+
Logger.info("Stopping port interceptor swarm (#{map_size(state.interceptors)} active)")
149+
150+
Enum.each(state.interceptors, fn {_port, {socket, _pid}} ->
151+
:gen_tcp.close(socket)
152+
end)
153+
154+
{:reply, :ok, %{state | interceptors: %{}, spawned_count: 0}}
155+
end
156+
157+
@impl true
158+
def handle_call(:get_connections, _from, state) do
159+
{:reply, Enum.reverse(state.connections), state}
160+
end
161+
162+
@impl true
163+
def handle_call(:active_count, _from, state) do
164+
{:reply, map_size(state.interceptors), state}
165+
end
166+
167+
@impl true
168+
def handle_call(:connection_summary, _from, state) do
169+
summary =
170+
state.connections
171+
|> Enum.group_by(& &1.port)
172+
|> Enum.map(fn {port, conns} -> {port, length(conns)} end)
173+
|> Map.new()
174+
175+
{:reply, summary, state}
176+
end
177+
178+
@impl true
179+
def handle_info({:connection, port, source_ip, source_port, data_preview}, state) do
180+
log_entry = %{
181+
port: port,
182+
source_ip: source_ip,
183+
source_port: source_port,
184+
timestamp: DateTime.utc_now(),
185+
data_preview: data_preview
186+
}
187+
188+
Logger.debug("Connection intercepted on port #{port} from #{source_ip}:#{source_port}")
189+
190+
# Forward to collector (e.g. eBPF event consumer)
191+
if state.collector_pid != self() do
192+
send(state.collector_pid, {:intercepted_connection, log_entry})
193+
end
194+
195+
{:noreply, %{state | connections: [log_entry | state.connections]}}
196+
end
197+
198+
@impl true
199+
def handle_info(_msg, state) do
200+
{:noreply, state}
201+
end
202+
203+
# ── Private ────────────────────────────────────────────
204+
205+
defp spawn_interceptors(state) do
206+
state.port_range
207+
|> Enum.reject(&MapSet.member?(state.exclude_ports, &1))
208+
|> Enum.reduce({%{}, 0}, fn port, {interceptors, count} ->
209+
case listen_on_port(port, state) do
210+
{:ok, socket, acceptor_pid} ->
211+
{Map.put(interceptors, port, {socket, acceptor_pid}), count + 1}
212+
213+
{:error, :eaddrinuse} ->
214+
# Port already in use — legitimate service, skip silently
215+
{interceptors, count}
216+
217+
{:error, :eacces} ->
218+
# Permission denied (ports < 1024 need root), skip
219+
{interceptors, count}
220+
221+
{:error, reason} ->
222+
Logger.debug("Could not bind port #{port}: #{inspect(reason)}")
223+
{interceptors, count}
224+
end
225+
end)
226+
end
227+
228+
defp listen_on_port(port, state) do
229+
opts = [
230+
:binary,
231+
active: false,
232+
reuseaddr: true,
233+
backlog: @listen_backlog
234+
]
235+
236+
case :gen_tcp.listen(port, opts) do
237+
{:ok, socket} ->
238+
# Spawn acceptor process for this port
239+
parent = self()
240+
log_data = state.log_data
241+
242+
acceptor_pid =
243+
spawn_link(fn ->
244+
accept_loop(socket, port, parent, log_data)
245+
end)
246+
247+
{:ok, socket, acceptor_pid}
248+
249+
{:error, reason} ->
250+
{:error, reason}
251+
end
252+
end
253+
254+
defp accept_loop(listen_socket, port, parent, log_data) do
255+
case :gen_tcp.accept(listen_socket, 5000) do
256+
{:ok, client_socket} ->
257+
# Get peer info
258+
{source_ip, source_port} =
259+
case :inet.peername(client_socket) do
260+
{:ok, {ip, p}} -> {format_ip(ip), p}
261+
_ -> {"unknown", 0}
262+
end
263+
264+
# Optionally capture initial data
265+
data_preview =
266+
if log_data do
267+
case :gen_tcp.recv(client_socket, 0, 1000) do
268+
{:ok, data} -> data
269+
_ -> <<>>
270+
end
271+
else
272+
<<>>
273+
end
274+
275+
# Close the connection (we only needed to observe it)
276+
:gen_tcp.close(client_socket)
277+
278+
# Report to parent
279+
send(parent, {:connection, port, source_ip, source_port, data_preview})
280+
281+
# Continue accepting
282+
accept_loop(listen_socket, port, parent, log_data)
283+
284+
{:error, :timeout} ->
285+
# No connection within timeout, loop back
286+
accept_loop(listen_socket, port, parent, log_data)
287+
288+
{:error, :closed} ->
289+
# Socket closed, swarm is stopping
290+
:ok
291+
292+
{:error, _reason} ->
293+
# Other error, stop this acceptor
294+
:ok
295+
end
296+
end
297+
298+
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
299+
300+
defp format_ip({a, b, c, d, e, f, g, h}),
301+
do: "#{Integer.to_string(a, 16)}:#{Integer.to_string(b, 16)}:#{Integer.to_string(c, 16)}:#{Integer.to_string(d, 16)}:#{Integer.to_string(e, 16)}:#{Integer.to_string(f, 16)}:#{Integer.to_string(g, 16)}:#{Integer.to_string(h, 16)}"
302+
end

0 commit comments

Comments
 (0)