Peering ADR: transport, handshake, threat model (gates the v0.5 track) #100

Closed
opened 2026-08-04 11:02:58 +00:00 by Cordy · 3 comments
Owner

Foundation issue for the peering milestone (Manuel, 2026-07-26 + Peering-Idea.drawio). Deliverable: PEERING.md decision record + ARCHITECTURE.md section (and §10 milestone realignment: v0.5 = peering, old v0.5 renumbered to v0.6). Nothing else in the milestone starts before the transport and handshake decisions here are settled with Manuel.

v1 scope (from the conversation): one file, one direction, A→B. Every instance stays standalone; peering is an explicit admin-established relationship. "Weitere Funktionen" ride the same channel later — the protocol must leave room, not implement them.

Decision 1 — transport: gRPC vs HTTP/2 streaming. Manuel proposes gRPC. Honest inventory: Cairn has ZERO gRPC/protobuf today — the crypto role (#33) does streaming over plain HTTP and it's proven. Adding google.golang.org/grpc + protobuf is the single largest dependency the codebase would take, against the tiny-trust-surface goal (§1.4).

  • For gRPC: typed schema evolution for the "further functions" Manuel wants on the same basis; native GRPCRoute in the k8s Gateway API; industry-standard federation story ("nobody does that shit" marketing works better with a spec-able protocol).
  • For HTTP/2: one less giant dependency, reuses the cryptosvc streaming pattern, Caddy/Traefik proxying is trivial, curl-debuggable.
  • Q for Manuel: is gRPC load-bearing for the vision (schema/interop), or is the streaming semantics what matters? Would a versioned HTTP/2 protocol with protobuf-schema'd JSON/CBOR messages satisfy the same goal?

Decision 2 — handshake strength. The diagram sends hash(pKEY-target) + hash(pKEY-source) per message. A static hash is bearer-equivalent: anyone who observes it once can replay it forever. Options:

  • (a) Key hashes over mandatory TLS — matches the diagram, simplest, but the hash is still a long-lived bearer credential.
  • (b) HMAC challenge–response (or HMAC over transfer-id + timestamp) — no static credential ever on the wire, stateless to verify, cheap. Recommended default going into review.
  • (c) mTLS with pinned peer certs — strongest, heaviest ops burden for exactly the admins who are supposed to exchange one string via email.
  • Q for Manuel: is (b) acceptable as "hash check" in spirit? TLS mandatory in all cases, or allow plain HTTP for LAN-only peerings (like our Basic-auth-is-LAN-only stance)?

Decision 3 — pairing model. Diagram shows each side generating one key that the OTHER side presents (two directed keys per peering). Confirm this asymmetric-pair model vs a single shared pairing secret. The per-peer key already bounds compromise blast radius nicely — keep that property.

Threat model to write down: user enumeration via the recipient check (see wire-protocol issue), replay, oversized-stream DoS (concurrency caps — remember the #5 OOM lesson), hostile peer after key theft (revocation = delete from registry, instant), filename-based path traversal on receive, and what a compromised peer can and cannot learn (it must learn nothing beyond the addressed recipient's existence).

Open questions (beyond the decisions above):

  • Recipients: users only in v1, or groups too (the diagram note says recipient: ID (group, user))? Groups pull in space-delivery semantics — recommend deferring to v1.1.
  • Instance identity: is the peer NAME purely local (each admin names their peers freely, like SSH host aliases) or does the wire carry a self-declared instance id? Recommend: purely local, wire carries only key material.
  • Licensing interplay: does peering require a paid tier, or is it free-tier too? (Product call for Nikola, affects nothing technical.)
  • Air-gapped stance (#91 parked): peering is inherently online — document that these features are disjoint.
Foundation issue for the peering milestone (Manuel, 2026-07-26 + Peering-Idea.drawio). Deliverable: `PEERING.md` decision record + ARCHITECTURE.md section (and §10 milestone realignment: v0.5 = peering, old v0.5 renumbered to v0.6). Nothing else in the milestone starts before the transport and handshake decisions here are settled with Manuel. **v1 scope (from the conversation):** one file, one direction, A→B. Every instance stays standalone; peering is an explicit admin-established relationship. "Weitere Funktionen" ride the same channel later — the protocol must leave room, not implement them. **Decision 1 — transport: gRPC vs HTTP/2 streaming.** Manuel proposes gRPC. Honest inventory: Cairn has ZERO gRPC/protobuf today — the crypto role (#33) does streaming over plain HTTP and it's proven. Adding `google.golang.org/grpc` + protobuf is the single largest dependency the codebase would take, against the tiny-trust-surface goal (§1.4). - For gRPC: typed schema evolution for the "further functions" Manuel wants on the same basis; native GRPCRoute in the k8s Gateway API; industry-standard federation story ("nobody does that shit" marketing works better with a spec-able protocol). - For HTTP/2: one less giant dependency, reuses the cryptosvc streaming pattern, Caddy/Traefik proxying is trivial, curl-debuggable. - ➤ **Q for Manuel:** is gRPC load-bearing for the vision (schema/interop), or is the streaming semantics what matters? Would a versioned HTTP/2 protocol with protobuf-*schema'd* JSON/CBOR messages satisfy the same goal? **Decision 2 — handshake strength.** The diagram sends `hash(pKEY-target)` + `hash(pKEY-source)` per message. A static hash is bearer-equivalent: anyone who observes it once can replay it forever. Options: - (a) Key hashes over **mandatory TLS** — matches the diagram, simplest, but the hash is still a long-lived bearer credential. - (b) **HMAC challenge–response** (or HMAC over transfer-id + timestamp) — no static credential ever on the wire, stateless to verify, cheap. *Recommended default going into review.* - (c) **mTLS** with pinned peer certs — strongest, heaviest ops burden for exactly the admins who are supposed to exchange one string via email. - ➤ **Q for Manuel:** is (b) acceptable as "hash check" in spirit? TLS mandatory in all cases, or allow plain HTTP for LAN-only peerings (like our Basic-auth-is-LAN-only stance)? **Decision 3 — pairing model.** Diagram shows each side generating one key that the OTHER side presents (two directed keys per peering). Confirm this asymmetric-pair model vs a single shared pairing secret. The per-peer key already bounds compromise blast radius nicely — keep that property. **Threat model to write down:** user enumeration via the recipient check (see wire-protocol issue), replay, oversized-stream DoS (concurrency caps — remember the #5 OOM lesson), hostile peer after key theft (revocation = delete from registry, instant), filename-based path traversal on receive, and what a compromised peer can and cannot learn (it must learn nothing beyond the addressed recipient's existence). **Open questions (beyond the decisions above):** - Recipients: users only in v1, or groups too (the diagram note says `recipient: ID (group, user)`)? Groups pull in space-delivery semantics — recommend deferring to v1.1. - Instance identity: is the peer NAME purely local (each admin names their peers freely, like SSH host aliases) or does the wire carry a self-declared instance id? Recommend: purely local, wire carries only key material. - Licensing interplay: does peering require a paid tier, or is it free-tier too? (Product call for Nikola, affects nothing technical.) - Air-gapped stance (#91 parked): peering is inherently online — document that these features are disjoint.
Author
Owner

Draft ADR is on main: PEERING.md. Six recommendations, seven open questions, ready for Manuel's review. Summary of what it argues and where it pushes back:

Positioning — one correction that matters. "Nobody does that shit right now" doesn't survive a fact check: Open Cloud Mesh has existed since ~2015, is implemented by Nextcloud, ownCloud, OpenCloud, CERNBox and Seafile, and reached the IETF standards track in March 2026 (draft-ietf-ocm-open-cloud-mesh-04). We should not make a falsifiable novelty claim publicly. What IS defensible and genuinely differentiated is the trust model: OCM federates identities and discovers peers; ours is two admins exchanging a key out of band, no discovery, no identity federation, no central broker, and actual bytes moving rather than share notifications. The doc has a comparison table that doubles as the marketing copy. An OCM adapter later would be a strong migration story and nothing here forecloses it (Q7).

Transport — I recommend plain HTTP over TLS, not gRPC, for v1. The reasoning in full is in §4; the short version: (1) gRPC-Go + protobuf + codegen would be by far the largest dependency ever added, landing in the network-facing path, against a marketed four-module discipline; (2) for bulk streaming gRPC's advantages invert — length-prefixed protobuf messages, 4 MiB default chunking, double flow control, extra copies, all on 500m-CPU ARM nodes; the industry pattern is control-plane RPC + data-plane HTTP for exactly this reason; (3) gRPC needs end-to-end HTTP/2 through every customer's proxy chain, ours included (Caddy + WireGuard + CGNAT), and fails opaquely, whereas plain HTTP degrades to 1.1 chunked and reproduces with curl. Manuel is right that GRPCRoute is GA and mature in 2026 — the point is it becomes a requirement we impose on every customer rather than a capability we need. Where gRPC genuinely wins (schema evolution, typed multi-method surface) is acknowledged, and the escape hatch is explicit: if a real control plane emerges, add gRPC for control and keep bytes on HTTP. Q1 asks Manuel whether gRPC is load-bearing for the vision or was shorthand for "streaming".

Handshake — the diagram's literal design needs one change. A static hash(pKEY) sent every request is bearer-equivalent: observed once, replayable forever, and TLS is terminated by proxies in exactly our customers' environments. Recommendation: HMAC-SHA256 request signing — key never on the wire, timestamp + nonce for replay protection, signature binds the metadata so a captured signature can't be re-pointed at another recipient, stateless verification, no extra round trip. Same idea as the diagram, done so it survives observation.

Pairing — keep the diagram exactly. Directed key pairs have a property worth not "simplifying" away: compromising the issuing side yields only verifiers, so a stolen key can impersonate a peer only to the instance that trusted it. A shared secret would make every compromise symmetric.

Payload — reuse the strong-share pipeline (#19). Encrypt end to end inside TLS with a per-transfer ephemeral age recipient, so a TLS-terminating proxy at either end never sees content, and the receiver re-encrypts to the recipient's own key on delivery. Same code path we just shipped and tested.

Net effect: if these hold, v0.5 adds zero dependencies and is a genuinely small build — registry + admin UI, one signed streaming endpoint, a send pipeline, delivery through the existing stack, a dropdown, and the second dogfood instance.

Manuel: Q1 (transport) and Q4 (username enumeration) are the two I'd most like your call on. Nikola: Q5 (paid tier?), Q6 (who may send, and may read-only space members send off-instance?) and Q7 (OCM adapter on the roadmap?).

**Draft ADR is on main: [`PEERING.md`](http://192.168.10.245/Cordy/Cairn/src/branch/main/PEERING.md).** Six recommendations, seven open questions, ready for Manuel's review. Summary of what it argues and where it pushes back: **Positioning — one correction that matters.** "Nobody does that shit right now" doesn't survive a fact check: **Open Cloud Mesh** has existed since ~2015, is implemented by Nextcloud, ownCloud, OpenCloud, CERNBox and Seafile, and reached the **IETF standards track** in March 2026 (`draft-ietf-ocm-open-cloud-mesh-04`). We should not make a falsifiable novelty claim publicly. What IS defensible and genuinely differentiated is the *trust model*: OCM federates identities and discovers peers; ours is two admins exchanging a key out of band, no discovery, no identity federation, no central broker, and actual bytes moving rather than share notifications. The doc has a comparison table that doubles as the marketing copy. An OCM adapter later would be a strong migration story and nothing here forecloses it (Q7). **Transport — I recommend plain HTTP over TLS, not gRPC, for v1.** The reasoning in full is in §4; the short version: (1) gRPC-Go + protobuf + codegen would be by far the largest dependency ever added, landing in the network-facing path, against a marketed four-module discipline; (2) for *bulk streaming* gRPC's advantages invert — length-prefixed protobuf messages, 4 MiB default chunking, double flow control, extra copies, all on 500m-CPU ARM nodes; the industry pattern is control-plane RPC + data-plane HTTP for exactly this reason; (3) gRPC needs end-to-end HTTP/2 through every customer's proxy chain, ours included (Caddy + WireGuard + CGNAT), and fails opaquely, whereas plain HTTP degrades to 1.1 chunked and reproduces with `curl`. Manuel is right that `GRPCRoute` is GA and mature in 2026 — the point is it becomes a *requirement we impose on every customer* rather than a capability we need. Where gRPC genuinely wins (schema evolution, typed multi-method surface) is acknowledged, and the escape hatch is explicit: if a real control plane emerges, add gRPC **for control** and keep bytes on HTTP. Q1 asks Manuel whether gRPC is load-bearing for the vision or was shorthand for "streaming". **Handshake — the diagram's literal design needs one change.** A static `hash(pKEY)` sent every request is bearer-equivalent: observed once, replayable forever, and TLS is terminated by proxies in exactly our customers' environments. Recommendation: **HMAC-SHA256 request signing** — key never on the wire, timestamp + nonce for replay protection, signature binds the metadata so a captured signature can't be re-pointed at another recipient, stateless verification, no extra round trip. Same idea as the diagram, done so it survives observation. **Pairing — keep the diagram exactly.** Directed key pairs have a property worth not "simplifying" away: compromising the issuing side yields only verifiers, so a stolen key can impersonate a peer *only to the instance that trusted it*. A shared secret would make every compromise symmetric. **Payload — reuse the strong-share pipeline (#19).** Encrypt end to end inside TLS with a per-transfer ephemeral age recipient, so a TLS-terminating proxy at either end never sees content, and the receiver re-encrypts to the recipient's own key on delivery. Same code path we just shipped and tested. **Net effect:** if these hold, v0.5 adds **zero dependencies** and is a genuinely small build — registry + admin UI, one signed streaming endpoint, a send pipeline, delivery through the existing stack, a dropdown, and the second dogfood instance. Manuel: Q1 (transport) and Q4 (username enumeration) are the two I'd most like your call on. Nikola: Q5 (paid tier?), Q6 (who may send, and may read-only space members send off-instance?) and Q7 (OCM adapter on the roadmap?).
Author
Owner

DECIDED 2026-08-05 (Nikola). Full design: IMPLEMENTED-PEERING.md. PEERING.md is kept, marked superseded, so the proposal and the decision can be compared later.

Decisions

  • Transport → option C (not the draft's recommendation): gRPC control plane + HTTP/2 data plane. Manuel's instinct wins on the control surface; bytes stay out of protobuf framing.
  • Handshake → (b)+(c) combined: challenge–response on the control plane (the key is never transmitted, the challenge is single-use anti-replay), then a short-lived single-use capability token for the data-plane PUT. Better than the draft's plain request-signing, because a capability authorizes before the first byte instead of requiring an HMAC over a multi-gigabyte body.
  • Pairing → unchanged: directed key pairs.
  • Payload → unchanged: end-to-end age via the strong-share pipeline (#19), inside TLS.
  • Q4 enumeration → accepted, narrowed: only allow-listed senders on an authenticated peering can probe; rate-limited and audited.
  • Q5 → free tier included.
  • Q6 → per-peering sender allow-lists of users AND IdP groups, chosen by the admin at peering setup. This improves on the draft's global toggle and evaluates against live groups (#96), so revoking a group membership revokes send rights within the cache TTL. Read-only space members still may not send space content off-instance.
  • Q7 → OCM adapter scheduled for v0.6 (#117).
  • Q3 (sender identity on the wire) remains open — field is reserved in PrepareRequest; decide before #102 ships.

Two draft arguments were contested and are recorded as partly conceded (§0.1 of the implemented design):

  1. The performance case leaned on the Pi cluster, which is the dev/test playground, not the deployment target. On customer hardware the framing CPU is noise against a network-bound transfer, so performance is not a deciding factor between transports — the draft implied more than it should have.
  2. "Plain HTTP" was read as HTTP/1.1. Go negotiates HTTP/2 over TLS automatically; 1.1 is only a middlebox fallback. The draft invited the misreading.

Consequences to track: this is the project's first gRPC dependency and the largest ever added. Mitigations in the design: control messages only (four RPCs), generated code committed so customers need no protoc, data plane independent and replaceable. Documentation debt to settle when it ships — the README's dependency line and ARCHITECTURE §1.4 must record the exception honestly rather than drift, same discipline as the typage bundle.

#101 gains the sender allow-list; #102 gains the proto + both planes; #106 now genuinely needs GRPCRoute. Build order unchanged: #101#102 → #103/#104 → #105#106.

Unblocked — ready to start #101 on your word.

**DECIDED 2026-08-05 (Nikola).** Full design: [`IMPLEMENTED-PEERING.md`](http://192.168.10.245/Cordy/Cairn/src/branch/main/IMPLEMENTED-PEERING.md). [`PEERING.md`](http://192.168.10.245/Cordy/Cairn/src/branch/main/PEERING.md) is kept, marked superseded, so the proposal and the decision can be compared later. **Decisions** - **Transport → option C** (not the draft's recommendation): **gRPC control plane + HTTP/2 data plane**. Manuel's instinct wins on the control surface; bytes stay out of protobuf framing. - **Handshake → (b)+(c) combined**: challenge–response on the control plane (the key is never transmitted, the challenge is single-use anti-replay), then a short-lived single-use **capability token** for the data-plane PUT. Better than the draft's plain request-signing, because a capability authorizes *before* the first byte instead of requiring an HMAC over a multi-gigabyte body. - **Pairing → unchanged**: directed key pairs. - **Payload → unchanged**: end-to-end age via the strong-share pipeline (#19), inside TLS. - **Q4 enumeration → accepted**, narrowed: only allow-listed senders on an authenticated peering can probe; rate-limited and audited. - **Q5 → free tier included.** - **Q6 → per-peering sender allow-lists of users AND IdP groups**, chosen by the admin at peering setup. This improves on the draft's global toggle and evaluates against live groups (#96), so revoking a group membership revokes send rights within the cache TTL. Read-only space members still may not send space content off-instance. - **Q7 → OCM adapter scheduled for v0.6** (#117). - **Q3 (sender identity on the wire) remains open** — field is reserved in `PrepareRequest`; decide before #102 ships. **Two draft arguments were contested and are recorded as partly conceded** (§0.1 of the implemented design): 1. The performance case leaned on the Pi cluster, which is the dev/test playground, not the deployment target. On customer hardware the framing CPU is noise against a network-bound transfer, so **performance is not a deciding factor between transports** — the draft implied more than it should have. 2. "Plain HTTP" was read as HTTP/1.1. Go negotiates HTTP/2 over TLS automatically; 1.1 is only a middlebox fallback. The draft invited the misreading. **Consequences to track:** this is the project's **first gRPC dependency** and the largest ever added. Mitigations in the design: control messages only (four RPCs), generated code committed so customers need no `protoc`, data plane independent and replaceable. **Documentation debt to settle when it ships** — the README's dependency line and ARCHITECTURE §1.4 must record the exception honestly rather than drift, same discipline as the typage bundle. #101 gains the sender allow-list; #102 gains the proto + both planes; #106 now genuinely needs `GRPCRoute`. Build order unchanged: #101 → #102 → #103/#104 → #105 → #106. Unblocked — ready to start #101 on your word.
Cordy closed this issue 2026-08-05 19:32:47 +00:00
Author
Owner

Closed — what was actually built against these decisions

IMPLEMENTED-PEERING.md is the decision record; PEERING.md stays as the superseded proposal so the two can be compared. Shipped in v0.4.1 and v0.4.2.

Decision Built as
A — own protocol, no novelty claim proto/cairn/peer/v1/peering.proto; OCM adapter deferred to #117
B — transport, option C gRPC control plane (internal/peering/service.go) beside an HTTP/2 data plane (blob.go), sharing one port via mux.go
C — handshake Ed25519 challenge–response (auth.go) then capability tokens
D — directed key pairs registry.go: we store the peer's public half plus a lookup id, never a shared secret
E — end-to-end age inside TLS client.go encrypts to the recipient the receiver minted; deliver.go decrypts and re-encrypts through the storage stack
Q2 — users only in v1 PrepareRequest.recipient is a username
Q3 — user@instance SendRequest.Sender, treated as a display hint and never an authorization input
Q4 — enumeration accepted, narrowed #123: allow-list AND residency, both audited
Q5 — free tier no licence gate anywhere in the peering path
Q6 — per-peering allow-lists CanSend / CanReceive, both fail closed
Q7 — OCM #117, v0.6

Two decisions in this ADR were wrong, and implementing them is what proved it

Both are recorded in §0.1 rather than quietly patched:

  1. §3.1 specified the proof as HMAC-SHA256(peerKey, …) while §4.1 required the receiver to store only a verifier. Those cannot both hold — verifying an HMAC needs the shared secret, so the receiver would have had to keep peer keys in the clear, destroying the exact property directed key pairs exist for. Corrected to Ed25519 signatures.
  2. §5 required the receiver to mint the per-transfer age recipient, but the proto put age_recipient in PrepareRequest, travelling sender → receiver. The sender cannot know a recipient the receiver has not minted yet, and a receiver holding only a public key could never have decrypted — #104 would have been unbuildable. Moved to PrepareResponse; field 8 is reserved.

Worth noting the pattern for future ADRs: both errors were internally consistent as prose and only surfaced when something tried to execute them. Writing the test first caught each one before any code depended on it.

One decision added after the fact

Peering is post-quantum unconditionally (§5, summary row E) — hybrid ML-KEM768 + X25519 per-transfer recipients, not following the storage postQuantum setting. The recipient is minted per transfer and discarded, so there is no data at rest to migrate, and every sender is a Cairn instance where hybrid support predates peering. Wire protocol = 1 now means hybrid.

## Closed — what was actually built against these decisions `IMPLEMENTED-PEERING.md` is the decision record; `PEERING.md` stays as the superseded proposal so the two can be compared. Shipped in v0.4.1 and v0.4.2. | Decision | Built as | |---|---| | A — own protocol, no novelty claim | `proto/cairn/peer/v1/peering.proto`; OCM adapter deferred to #117 | | B — transport, **option C** | gRPC control plane (`internal/peering/service.go`) beside an HTTP/2 data plane (`blob.go`), sharing one port via `mux.go` | | C — handshake | Ed25519 challenge–response (`auth.go`) then capability tokens | | D — directed key pairs | `registry.go`: we store the peer's **public** half plus a lookup id, never a shared secret | | E — end-to-end age inside TLS | `client.go` encrypts to the recipient the receiver minted; `deliver.go` decrypts and re-encrypts through the storage stack | | Q2 — users only in v1 | `PrepareRequest.recipient` is a username | | Q3 — `user@instance` | `SendRequest.Sender`, treated as a display hint and never an authorization input | | Q4 — enumeration accepted, narrowed | #123: allow-list AND residency, both audited | | Q5 — free tier | no licence gate anywhere in the peering path | | Q6 — per-peering allow-lists | `CanSend` / `CanReceive`, both fail closed | | Q7 — OCM | #117, v0.6 | ## Two decisions in this ADR were wrong, and implementing them is what proved it Both are recorded in §0.1 rather than quietly patched: 1. **§3.1 specified the proof as `HMAC-SHA256(peerKey, …)` while §4.1 required the receiver to store only a verifier.** Those cannot both hold — verifying an HMAC needs the shared secret, so the receiver would have had to keep peer keys in the clear, destroying the exact property directed key pairs exist for. Corrected to Ed25519 signatures. 2. **§5 required the receiver to mint the per-transfer age recipient, but the proto put `age_recipient` in `PrepareRequest`**, travelling sender → receiver. The sender cannot know a recipient the receiver has not minted yet, and a receiver holding only a public key could never have decrypted — #104 would have been unbuildable. Moved to `PrepareResponse`; field 8 is `reserved`. Worth noting the pattern for future ADRs: both errors were internally consistent *as prose* and only surfaced when something tried to execute them. Writing the test first caught each one before any code depended on it. ## One decision added after the fact **Peering is post-quantum unconditionally** (§5, summary row E) — hybrid ML-KEM768 + X25519 per-transfer recipients, not following the storage `postQuantum` setting. The recipient is minted per transfer and discarded, so there is no data at rest to migrate, and every sender is a Cairn instance where hybrid support predates peering. Wire `protocol = 1` now *means* hybrid.
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#100
No description provided.