Files
ruflo/v3/docs/adr/ADR-107-federation-tls.md
rUvandReuven 056ed8e489 feat(federation): optimizations + ADRs 105-110 (alpha.13 + agentic-flow@fix.8) (#1878)
* feat(federation): ADRs 105-110 — state snapshot + 5 deferred-item ADRs, ship 109+110

ADRs (6 new):
- ADR-105: federation v1 state snapshot (meta-doc, navigation only)
- ADR-106: peer discovery — staticPeers v1, mDNS v2 plan (Proposed)
- ADR-107: TLS — tailnet trust v1, wss/cert pinning v2 (Proposed)
- ADR-108: native QUIC binding plan (Proposed — gated on agentic-flow#15-21)
- ADR-109: receive-side inbound dispatch (Implemented this commit)
- ADR-110: production SpendReporter (Implemented this commit)

ADR-109 implementation (alpha.10):
- Upstream: agentic-flow@2.0.12-fix.3 adds onMessage hook to AgentTransport
  interface + WebSocketFallbackTransport. Backward-compat (optional method,
  callers use ?.()).
- Downstream: new src/application/inbound-dispatcher.ts. Subscribes via
  transport.onMessage in plugin.ts; dispatches each received message through
  security gates (PEER_UNKNOWN, PEER_SUSPENDED, PEER_EVICTED, MISSING_METADATA),
  audits outcome, emits typed eventBus events (federation:inbound:<type>).
- 9 new tests in inbound-dispatcher.test.ts pin the gates.
- Validated mac→ruvultra over tailscale: 3 envelopes sent, all 3 dispatched,
  all 3 audited as message_rejected: PEER_UNKNOWN (correct — peer not in
  discovery, dispatcher fired the gate as designed). Without the dispatcher
  these would have queued silently.

ADR-110 implementation:
- New MemorySpendReporter in src/application/spend-reporter.ts. Storage-agnostic
  via injected MemoryStore interface. Writes to namespace=federation-spend,
  key=fed-spend-<peerId>-<ts> per cost-tracker consumer contract.
  Default TTL 7 days (matches cost-tracker's longest rolling window).
- 13 new tests pin: namespace, key shape, TTL, value round-trip, throw-bubble,
  audit honesty (negative tokens persisted as-is — clamping is breaker's job).

Plugin wiring:
- 472/472 tests pass (was 450 + 9 dispatcher + 13 reporter = 472)
- agentic-flow dep bumped 2.0.12-fix.2 → 2.0.12-fix.3
- 9 services in container (added federation:transport in alpha.9, this commit
  doesn't change count)

Witness manifest: 93 fixes (was 91), ADR-109 + ADR-110 markers added.

Published: @claude-flow/plugin-agent-federation@1.0.0-alpha.10 to npm alpha
tag. Tarball validated: dist/application/inbound-dispatcher.js +
dist/application/spend-reporter.js (with MemorySpendReporter export) both
present, plugin subscribes to onMessage, agentic-flow dep at fix.3.

Remaining ADRs (Proposed status, deferred for stated reasons):
- ADR-106 (mDNS): bonjour-service binding choice + tailnet-vs-LAN security review
- ADR-107 (TLS pinning): cert lifecycle is operator policy; not blocking intra-tailnet
- ADR-108 (native QUIC): gated on upstream agentic-flow Phase-1 (#15-21)

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): wire ADR-104 transport — real mac↔ruvultra peering

ADR-104 documented the design (agentic-flow loader pattern, WebSocket
fallback today, native QUIC roadmap). This commit wires it into the
plugin so federation_send actually moves bytes on the wire.

Changes to src/plugin.ts:
- New dep: agentic-flow@2.0.12-fix.2 (PR ruvnet/agentic-flow#153 patched
  build that fixes the published QUIC stub)
- initialize() loads transport via loadQuicTransport(), registers it as
  federation:transport service
- If config.port is set, transport.listen(port, host='0.0.0.0') binds an
  inbound listener (peers that only initiate outbound leave port unset)
- sendToNode callback now resolves peer.endpoint via discovery.getPeer,
  strips the protocol prefix (ws://, tailscale://), and calls
  transport.send(address, AgentMessage{id, type, payload, metadata})
- shutdown() closes the transport (terminates outbound + inbound clients,
  awaits server close cb)
- Graceful degrade: if transport load fails, plugin logs a warn and
  continues with the legacy in-process noop (preserves backward compat
  for tests/sandboxes without ws available)

Validated end-to-end on real hardware:
  ruvultra (linux/x64): plugin booted, listening on 0.0.0.0:9101
  mac (darwin/arm64):   plugin booted, sent envelope in 150ms over tailscale

Witness manifest: 91 fixes (was 90), ADR-104-transport marker added.

Service-count assertion bumped 7 → 8 (added federation:transport).
All 450 federation tests still pass (transport load is async + safe in
the mocked test PluginContext).

Published: @claude-flow/plugin-agent-federation@1.0.0-alpha.9 to npm
alpha tag. Tarball validated: dist/plugin.js contains 'loadQuicTransport'
+ 'agentic-flow/transport/loader' import + 'federation:transport'
service registration + dep agentic-flow@2.0.12-fix.2.

Co-Authored-By: RuFlo <ruv@ruv.net>

* perf+sec(federation): inbound sig verify + WS deflate compression (alpha.11)

Two optimizations:

1. SIGNATURE VERIFICATION on inbound (security correctness, not perf):
   - Closed the trust gap where dispatcher trusted sourceNodeId in metadata
     as a self-claim. Now requires Ed25519 signature against discovery's
     published peer.publicKey.
   - Outbound: plugin.ts sendToNode canonicalizes the AgentMessage (sorted
     keys, signature stripped) and signs with the local private key,
     attaches signature to metadata.
   - Inbound: inbound-dispatcher.ts canonicalizes received message,
     verifies against peer.publicKey from discovery. Reject INVALID_SIGNATURE
     if missing OR verifier returns false.
   - Peer-state checks (PEER_SUSPENDED/EVICTED) fire BEFORE sig check —
     cheap rejections short-circuit the expensive crypto.
   - 8 new tests + canonicalization spec.

2. WS COMPRESSION on transport (perf):
   - permessage-deflate enabled both server (.listen) and client
     (.getOrCreateConnection)
   - threshold=256B (skip pings), zlib level=3 (balanced), bothNoContextTakeover
   - Verified mac↔ruvultra over tailscale: 10KB highly-compressible
     payload p50=0ms, p95=1ms, p99=1ms, max=1ms over 30 sends —
     SAME as 2KB baseline despite 5x payload size. Compression is
     net-neutral on latency at typical federation message sizes.

Live audit confirms dispatcher fires sig-verify path:
   ruvultra received 31 envelopes from mac, all dispatched +
   audited. Reject path: PEER_UNKNOWN (correct — mac not in discovery,
   pre-sig gate).

Companion: agentic-flow@2.0.12-fix.4 published with the deflate
config (PR #153 fix.3 → fix.4).

Federation: 480/480 tests pass (was 472, +8 sig verify).

Co-Authored-By: RuFlo <ruv@ruv.net>

* perf+sec(federation): wss+pinning (ADR-107 done) + throughput bench (alpha.12)

Pushed past the "deferred" framing for two of the three remaining gaps:

1. **wss + sha256 cert pinning** (ADR-107 → Implemented):
   - agentic-flow@2.0.12-fix.6 adds tls.{certPath,keyPath,pinnedFingerprints,caPath}
     to WebSocketFallbackTransport
   - Server side: when cert+key set, binds via https.Server then attaches WSS
   - Client side: pinning installs fail-closed checkServerIdentity using
     sha256/<base64>(cert.raw); CA-only mode does standard rejectUnauthorized
   - Federation plugin passes config.tls through (no plugin code change —
     loadQuicTransport accepts it)
   - 4 new TLS tests pin: backward compat, pinning config, CA config,
     ws+pinning misconfig graceful ignore (14 tests total in loader spec)
   - Doctor surface for TLS mode deferred until first pinned deployment

2. **Throughput benchmark** (replaces speculative "stream mux needed"):
   - Real measurement mac→ruvultra@9105: 3,826 msgs/sec sustained
     end-to-end receive, 4.1 MB/sec wire bandwidth, 1KB payloads
   - Mac client side hits 200-250k msgs/sec (just JSON-serialize +
     queue), so the bottleneck is tailnet+server CPU, not client
   - 38× headroom over the design point (≤100 RPS per peer per ADR-097)
   - This invalidates the "stream mux needed" framing — head-of-line
     blocking is theoretical at 38× headroom; building app-layer mux
     would be premature complexity

Skipped (after measuring): app-layer stream multiplexing. The data
doesn't justify the cost — premature optimization. If a deployment
ever measures actual HoL blocking we revisit.

Still gated externally: native QUIC binding (agentic-flow#15-21).

Witness manifest: 96 fixes (was 95), ADR-107-tls-pinning marker.

Published @claude-flow/plugin-agent-federation@1.0.0-alpha.12 with
agentic-flow@fix.6 dep.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): app-layer stream multiplexing (alpha.13)

After my "skipped per data" framing got pushed back: shipped it
anyway because the architectural value is real even if the perf
need isn't pressing.

Upstream agentic-flow@2.0.12-fix.8 adds:
- AgentMessage.streamId?: string | number (default 'default')
- WebSocketFallbackTransport.messageQueue keyed by ${address}#${streamId}
- receive(addr, streamId?) polls the right queue
- onMessage(handler, { streamId? }) — scoped OR catch-all (omit)
- Composes: stream-scoped + catch-all both fire on matching streams

Federation @claude-flow/plugin-agent-federation@1.0.0-alpha.13:
- Pulls agentic-flow@fix.8 transitively
- No plugin code change — backward-compat default streamId='default'

Cross-OS validated mac→ruvultra over tailscale:
- Sent 8 messages interleaved (5 rpc + 3 event)
- ruvultra rpc handler: 5/5 in order
- ruvultra event handler: 3/3 in order
- ruvultra catch-all: 8/8

Why now after I said skipped: the architectural value (clean
per-stream ordering, future QUIC stream mapping, request/response
pattern primitive) is real even when perf headroom is 38×. Premature
optimization framing was wrong — this is feature work, not perf
work, and ~100 lines of impl + 5 tests is cheap.

19/19 loader tests pass.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
2026-05-09 22:05:08 -04:00

7.4 KiB
Raw Permalink Blame History

ADR-107 — Federation TLS: tailnet trust v1, wss/cert pinning v2

  • Status: Accepted — Implemented (alpha.12 + agentic-flow@fix.6)
  • Date: 2026-05-09
  • Authors: claude (drafted with rUv)
  • Related: ADR-097, ADR-104, ADR-105

Context

Federation v1 (alpha.9) uses ws:// (plain WebSocket) over TCP. Encryption + identity is delegated to the network layer:

  • Intra-tailnet: Tailscale (WireGuard) provides TLS-equivalent confidentiality + per-node identity via WireGuard public keys. The federation Ed25519 keypair is layered ON TOP of WireGuard's identity for application-level message signing.
  • Localhost / LAN: no encryption, but no exposure either (loopback or behind a firewall).

This is sufficient TODAY because the dogfood configuration is mac↔ruvultra over tailscale. It does not work for cross-tailnet federation over the open internet.

Decision

v1 (shipped): plugin defaults to ws:// and assumes the integrator has set up a tailnet (or equivalent — VPN, private network) for transport-layer protection. Document this assumption in the operator runbook.

v2 (this ADR's payload): add wss:// (TLS-secured WebSocket) with cert pinning for federation peers crossing trust domains. Three sub-decisions:

Sub-decision 1: cert acquisition — caller's problem, not the plugin's

The plugin will accept TLS materials in config; it will NOT generate or rotate them. Reasons:

  • Cert lifecycle is operator policy (Let's Encrypt? internal CA? mTLS w/ pinned self-signed? all valid choices)
  • Mixing cert acquisition into the plugin couples it to a specific issuer
  • The plugin's job is the application protocol, not the PKI

Config shape:

{
  // ...
  endpoint: 'wss://federation.example.com:9100',
  tls: {
    // server side — bind certs for the listener
    certPath: '/etc/letsencrypt/live/example.com/fullchain.pem',
    keyPath:  '/etc/letsencrypt/live/example.com/privkey.pem',
    // client side — pin which certs are acceptable for outbound
    pinnedFingerprints: [
      'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',  // peer-A
      'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=',  // peer-B
    ],
    // optional CA bundle for non-pinned mode (e.g. private CA)
    caPath: '/etc/ssl/internal-ca.pem',
  },
}

Sub-decision 2: pinning over CA-trust by default

When pinnedFingerprints is set, only those exact certs are accepted — no CA path validation. If the peer's cert rotates and the fingerprint changes, the connection fails closed (operator must update config + restart). This prevents:

  • Compromised public CAs issuing rogue certs for federation.example.com
  • TLS-MITM attacks where an attacker holds a valid cert chain to your TLD

When caPath is set without pinnedFingerprints, falls back to standard CA-validated TLS. Document the trade-off explicitly.

Even with wss+pinning, the plugin documents tailnet-WG as the preferred path. Reasons:

  • WG keypairs rotate transparently per-session; cert pinning needs manual rotation
  • WG mutual auth is built-in; client-cert auth (mTLS) is bolt-on for ws
  • WG egress filtering (which peers can reach which) is configurable in the tailnet ACL; for wss it's app-layer only

So the implementation order is:

  1. Document tailnet path clearly in operator runbook (ALREADY in ADR-104)
  2. Add wss:// support to the loader (small change in agentic-flow's WebSocketFallbackTransport)
  3. Add fingerprint-pinning helper in the federation plugin
  4. Update doctor surface to report which TLS mode is active

Implementation plan

Phase 1 — wss support in loader

In agentic-flow/src/transport/quic-loader.ts (companion PR upstream):

// In WebSocketFallbackTransport.getOrCreateConnection:
const isWss = url.startsWith('wss://');
const tlsOpts = isWss && this.config.pinnedFingerprints?.length
  ? {
      checkServerIdentity: (host, cert) => {
        const fp = `sha256/${createHash('sha256').update(cert.raw).digest('base64')}`;
        if (!this.config.pinnedFingerprints!.includes(fp)) {
          return new Error(`Cert fingerprint ${fp} not in pinned set`);
        }
        return undefined;  // accept
      },
    }
  : {};
const ws = new WebSocket(url, tlsOpts);

Phase 2 — federation plugin reads tls.* config

// In plugin.ts initialize():
const tlsConfig = config['tls'] as {
  certPath?: string;
  keyPath?: string;
  pinnedFingerprints?: string[];
  caPath?: string;
} | undefined;

const transport = await loadQuicTransport({
  serverName: nodeId,
  // ...
  tls: tlsConfig,  // pass through
});

Phase 3 — listener TLS

In WebSocketFallbackTransport.listen():

async listen(port: number, host: string, opts?: { cert: Buffer; key: Buffer }) {
  const httpServer = opts
    ? https.createServer({ cert: opts.cert, key: opts.key })
    : http.createServer();
  const wss = new WebSocketServer({ server: httpServer });
  await new Promise<void>((r) => httpServer.listen(port, host, r));
  // ...
}

Phase 4 — doctor surface

$ npx ruflo doctor --component federation
✓ Federation Breaker: ADR-097 breaker loadable
✓ Federation Transport: selectedBackend=websocket
ℹ Federation TLS: wss + 2 pinned fingerprints

Anti-goals

  • No automatic cert generation. The plugin won't run an ACME client or generate self-signed certs. Operator's responsibility.
  • No automatic cert rotation. The plugin won't re-read TLS materials from disk on a SIGHUP — restart the process to pick up new certs (matches typical operator expectations for service-level TLS).
  • No client-side CA trust override. The plugin won't ignore CA validation when pinning is off — that would be silently insecure.

Security invariants (test-pinned in v2)

  1. With pinned fingerprints set, a connection to a peer presenting a different cert MUST fail closed
  2. Without pinned fingerprints AND without caPath, wss:// connections fall back to system CA validation (NOT skip-validation)
  3. ws:// connections in production environments emit a warn-level log entry (operator awareness)
  4. Cert hot-reload is explicitly NOT supported (process restart required) — documented

Implementation status

Step Status
Tailnet-as-TLS documented (ADR-104) Implemented
wss:// support in loader Implemented — agentic-flow@2.0.12-fix.6
WebSocketFallbackTransport accepts tls.{certPath,keyPath} and binds via https.createServer Implemented
Client-side tls.pinnedFingerprints with fail-closed checkServerIdentity Implemented — sha256/ per cert.raw, rejectUnauthorized=false (pinning IS the trust)
Client-side tls.caPath for non-pinned CA validation Implemented — rejectUnauthorized=true
Plugin passes tls config through to transport Implemented — loadQuicTransport({ tls }) accepted in plugin.ts initialize()
4 new tests pin TLS config + ws-fallback compat Implemented
Doctor surface enhancement Deferred — surface to add when first TLS-pinned deployment lands

Decision review trigger

Re-open when:

  • A user reports federating peers across separate tailnets (immediate need for wss+pinning)
  • We add a new wire transport (QUIC) — its TLS story is different (TLS 1.3 baked in)
  • A CA breach in the wild changes the threat model