diff --git a/README.md b/README.md index 3242dfd..89c857f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The second table describes each protocol's own approach in prose, for direct com | Protocol | Topology | Peer identity | Authorisation | Application content | Wire encoding | |---|---|---|---|---|---| -| **wire-mesh** | Direct P2P mesh, with relay for NAT traversal and optional cross-mesh federation | Hash of a self-signed public key | Signed, revocable, delegatable capability tokens; delegation can only narrow authority | Opaque per-application payload; only identity, handshake, tokens and relay are protocol-native | CBOR/DAG-CBOR, signed via COSE, schema in CDDL | +| **wire-mesh** | Direct P2P, with relay for NAT traversal; no discrete mesh boundary — "mesh" is just the live connected set of reachable peers, and cross-scope sharing is ordinary capability-token delegation to a bearer outside the issuer's own group | Hash of a self-signed public key | Signed, revocable, delegatable capability tokens; delegation can only narrow authority | Opaque per-application payload; only identity, handshake, tokens and relay are protocol-native | CBOR/DAG-CBOR, signed via COSE, schema in CDDL | | [libp2p](https://libp2p.io/) | Direct P2P mesh, with circuit relay and hole punching for NAT traversal | Multihash of a public key | Not built in; left to the application or its own protocols | Defined per protocol via multistream-select and application-registered protocol IDs | Protocol-specific; commonly Protobuf, which is not canonical across languages or library versions | | [Secure Scuttlebutt](https://scuttlebutt.nz/) | P2P gossip replication of append-only logs; no relay primitive for NAT traversal | Ed25519 public key | None; a feed's own author key is its sole authority | Fixed: an append-only log of an identity's own signed messages | JSON, hashed for signing; no CBOR-style canonical encoding standard | | [Syncthing](https://docs.syncthing.net/specs/bep-v1.html) (BEP) | Direct P2P mesh, with relay servers for NAT traversal | SHA-256 of a self-signed certificate | Access is device-list based, not token-scoped | Fixed: file blocks and index metadata for folder sync | Protobuf; not canonical across languages or library versions | @@ -49,8 +49,10 @@ The second table describes each protocol's own approach in prose, for direct com - **Relay and NAT traversal.** When two peers cannot connect directly, a relay carries their traffic as an opaque, unreadable byte pipe. The relay never holds the keys to decrypt what it forwards. - **An opaque, per-application data domain.** The protocol carries structured content for the domains it defines natively (identity, handshake, tokens, relay). It leaves application-specific payloads — a chat message, a file block, an operation-log entry — as opaque bytes to any peer that does not need to interpret them. Different applications can therefore share one mesh without understanding each other's content. - **A domain and capability registry**, so unrelated applications extending the protocol with their own capability domains or verbs do not collide on the same identifier. -- **Handle discovery.** A DNS-anchored resolution convention, mirroring WebFinger, for reaching an arbitrary handle you have never interacted with — resolving to a self-certifying record signed by the same key it claims, safe to serve through an untrusted intermediary. The same record's optional mailbox hint unifies discovery with offline delivery: fan-out and delivery to a currently-offline peer both fall out of any peer being able to answer for another device's data-domain log, not a separate mechanism. -- **Federation** between independent meshes, with selective, explicit cross-mesh sharing, distinct from ordinary intra-mesh relay. +- **Handle discovery.** Three bootstrap channels — a DNS-anchored resolution convention mirroring WebFinger, an out-of-band-delivered record, or local broadcast (mDNS/Bonjour) — for reaching a principal you have never interacted with, all resolving to the same self-certifying record signed by the same key it claims, safe to serve through an untrusted intermediary regardless of which channel delivered it. The same record's optional mailbox hints unify discovery with offline delivery: fan-out and delivery to a currently-offline peer both fall out of any peer being able to answer for another device's data-domain log, not a separate mechanism. +- **Coordinator election.** A lightweight, gossiped, term-based claim to the introduction/rendezvous role among whichever peers are currently reachable — any node can hold it, and any live peer can take over if it disappears, without relying on OS-level port contention to do the electing. + +**The test for whether a proposed concept belongs in this list, rather than being left to applications:** does it do genuine, orthogonal work that identity, capability tokens, and connectivity/discovery don't already cover between them — or is it a bespoke wrapper around some combination of those three that would work exactly as well expressed directly? `mesh-id` and a dedicated federation link-and-share protocol both failed this test on review — reachability was already discovery's job, authorisation was already tokens' job, and the wrapper contributed no property either didn't already provide on its own; both were removed in favour of expressing the same outcomes directly. Coordinator election and mailbox delivery both passed it — a rendezvous role and a bootstrap anchor with an explicit trust signal are work identity, tokens, and discovery genuinely cannot do by themselves. Apply the same test to the next proposed addition before assuming a new frame, domain, or field is the right shape for it. ### Encoding @@ -98,6 +100,8 @@ None yet. The schema exists (`spec/protocol.cddl`), and so does `conformance/`'s `ts/packages/cloudflare-hub` is a reference deployment of a public, always-on mesh **node** — a coordinator-of-coordinators that other peers dial into for company- or community-wide reach beyond a single local mesh. It depends on `ts/packages/core` as an ordinary consumer, exactly as agent-comms and Cascade do. It lives in this repository for now, during early co-development with the spec, but is deliberately structured as its own package rather than folded into the core library — the spec itself must stay adoptable by anyone with no interest in ExaDev's specific deployment, and that boundary is what makes moving the hub to its own repository later a packaging change, not an architectural one. +Concretely, a single always-on hub like this can serve two remote peers with no other infrastructure at all, filling two distinct roles from `spec/` that a deployment like this is meant to combine: **relay** (`transport.cddl`'s `relay-offer`/`relay-connect`/`relay-data`/`relay-inbound` frames — an opaque byte pipe when two peers can't connect directly, the hub never holding the keys to read what it forwards) and **announcer** (`discovery.cddl`'s `mailboxes` field — the hub holds a peer's `handle-record` as a `core/data` entry so a peer with no address of its own, or one that's since moved networks, can still be found and resolved through it; a peer can name more than one mailbox for redundancy). Neither role requires the other; a deployment can offer just relay, just announcing, or both on the same connection. If the hub's own operator also controls a domain, the cleanest composition is DNS-anchored handles (`alice@exadev.io`) as the primary discovery path and the hub's `core/data`/mailbox role as the fallback for a peer with no registered handle of its own — but the hub still can't tell two arbitrary strangers about each other with zero other information: whichever side is resolving still needs to already know the other's handle or device-id from somewhere (an out-of-band invite, most commonly), the same bootstrap requirement discovery.cddl describes regardless of whether a hub is involved. + The web console a human actually opens in a browser — `ts/packages/web-console` — is a different thing, kept separate for the same reason: it is a *client* of a node, not a node itself. It may be served as static assets from the same origin as `cloudflare-hub` for deployment convenience, but it is not folded into it, and it is not tied to Cloudflare at all — it can equally connect to a purely local, laptop-hosted coordinator. This is also distinct from a third scenario this naming invites confusion with: an ordinary browser tab acting as its own genuine leaf peer (its own identity, its own `core`-implemented `MeshTransport`, entirely on an end user's device). That's not a package in this repository at all — it's just another consumer of `core`, built by whoever wants a browser-embedded node, the same as agent-comms or Cascade. ### Versioning @@ -110,7 +114,7 @@ Two examples of what the design above already supports without further protocol **A Tailscale-style mesh VPN.** The transport frames already cover the coordination layer a peer-to-peer VPN needs: peer discovery (`gossip-frame`/`peer-advert`), NAT traversal (`candidates-frame`, `sync-punch-frame`, `observed-address-frame`), and relay fallback when a direct connection fails (`relay-offer-frame`/`relay-connect-frame`/`relay-data-frame`) — structurally the same job Tailscale's control plane and DERP relays do around WireGuard. This would be a coordination layer *for* WireGuard (or an equivalent), not a replacement for it: wire-mesh specifies no tunnel-encryption layer of its own, and WireGuard's own cryptography is exactly the part worth leaving alone rather than reinventing. What a wire-mesh-based coordination layer could do better than Tailscale specifically: -- No single vendor's coordination server — any node can be a coordinator, coordinator handoff is already part of the design, and federation between independently-operated meshes is a first-class capability domain rather than an enterprise-tier afterthought (Tailscale's control plane is Tailscale's own hosted service; Headscale is a reverse-engineered clone, not a first-class alternative). +- No single vendor's coordination server — any node can claim the coordinator role via `coordinator-frame`'s gossiped, term-based election (a higher term always supersedes a lower one; equal terms break by lowest device-id), giving the same crash-recovery property a port-race coordinator has without relying on OS-level port contention. Cross-scope sharing needs no dedicated federation protocol at all: a capability token handed to a bearer outside the issuer's own group already is the explicit, signed, auditable act of agreement a bilateral link-negotiation step would only redundantly restate (Tailscale's control plane is Tailscale's own hosted service; Headscale is a reverse-engineered clone, not a first-class alternative). - Capability tokens are delegable and narrowing rather than a flat, centrally-evaluated ACL file, and are self-certifying — a bearer can verify one offline with no callback to the issuer, where Tailscale's ACLs require reaching the control plane. - The same mesh session (same identity, same tokens, same peer discovery) could carry this domain alongside file transfer or messaging at once, rather than needing an unrelated coordination system and trust domain per use case the way networking (Tailscale), file sync (Syncthing), and messaging (Slack) are three separate tools today. diff --git a/conformance/frames.v1.json b/conformance/frames.v1.json index a45fd60..be09fab 100644 --- a/conformance/frames.v1.json +++ b/conformance/frames.v1.json @@ -121,6 +121,18 @@ }, "wire_hex": "a264747970656d72656c61792d696e626f756e646d736f757263652d64657669636558202222222222222222222222222222222222222222222222222222222222222222" }, + { + "name": "coordinator_v1_with_capacity_hint", + "message": { + "type": "coordinator", + "term": 3, + "coordinator": { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "capacity-hint": 64 + }, + "wire_hex": "a4647465726d0364747970656b636f6f7264696e61746f726b636f6f7264696e61746f72582011111111111111111111111111111111111111111111111111111111111111116d63617061636974792d68696e741840" + }, { "name": "manage_request_v1_pty_spawn", "message": { @@ -174,21 +186,33 @@ "message": { "type": "revocation-announce", "entries": [ - { - "token-id": { - "hex": "01010101010101010101010101010101" + [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" }, - "revoked-at": 1861833700000 - }, - { - "token-id": { - "hex": "02020202020202020202020202020202" + {}, + { + "hex": "a4666973737565725820111111111111111111111111111111111111111111111111111111111111111168746f6b656e2d696450010101010101010101010101010101016a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb6a7265766f6b65642d61741b000001b17defb2a0" }, - "revoked-at": 1861833701000 - } + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + [ + { + "hex": "a2613126613458202222222222222222222222222222222222222222222222222222222222222222" + }, + {}, + { + "hex": "a4666973737565725820222222222222222222222222222222222222222222222222222222222222222268746f6b656e2d696450020202020202020202020202020202026a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6a7265766f6b65642d61741b000001b17defb688" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] ] }, - "wire_hex": "a26474797065737265766f636174696f6e2d616e6e6f756e636567656e747269657382a268746f6b656e2d696450010101010101010101010101010101016a7265766f6b65642d61741b000001b17defb2a0a268746f6b656e2d696450020202020202020202020202020202026a7265766f6b65642d61741b000001b17defb688" + "wire_hex": "a26474797065737265766f636174696f6e2d616e6e6f756e636567656e747269657382845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a058b7a4666973737565725820111111111111111111111111111111111111111111111111111111111111111168746f6b656e2d696450010101010101010101010101010101016a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb6a7265766f6b65642d61741b000001b17defb2a05840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a058b7a4666973737565725820222222222222222222222222222222222222222222222222222222222222222268746f6b656e2d696450020202020202020202020202020202026a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6a7265766f6b65642d61741b000001b17defb6885840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }, { "name": "stream_data_v1_stdout_chunk", @@ -262,100 +286,6 @@ ] }, "wire_hex": "a464706565725820111111111111111111111111111111111111111111111111111111111111111164747970656c646174612d656e747269657367656e74726965738243aabbcc44ddeeff006866726f6d2d7365711864" - }, - { - "name": "federation_link_request_v1", - "message": { - "type": "federation-link-request", - "local-mesh": "exadev-internal", - "local-name": "exadev", - "offered-shares": [ - { - "domain": "core/data", - "resource": { - "kind": "room", - "path": "general" - }, - "direction": "outbound" - } - ] - }, - "wire_hex": "a464747970657766656465726174696f6e2d6c696e6b2d726571756573746a6c6f63616c2d6d6573686f6578616465762d696e7465726e616c6a6c6f63616c2d6e616d65666578616465766e6f6666657265642d73686172657381a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c69646972656374696f6e686f7574626f756e64" - }, - { - "name": "federation_link_accept_v1", - "message": { - "type": "federation-link-accept", - "remote-mesh": "example-partner", - "remote-name": "partner", - "accepted-shares": [ - { - "domain": "core/data", - "resource": { - "kind": "room", - "path": "general" - }, - "direction": "outbound" - } - ] - }, - "wire_hex": "a464747970657666656465726174696f6e2d6c696e6b2d6163636570746b72656d6f74652d6d6573686f6578616d706c652d706172746e65726b72656d6f74652d6e616d6567706172746e65726f61636365707465642d73686172657381a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c69646972656374696f6e686f7574626f756e64" - }, - { - "name": "federation_link_reject_v1", - "message": { - "type": "federation-link-reject", - "reason": "no shared domains accepted" - }, - "wire_hex": "a264747970657666656465726174696f6e2d6c696e6b2d72656a65637466726561736f6e781a6e6f2073686172656420646f6d61696e73206163636570746564" - }, - { - "name": "federation_share_v1", - "message": { - "type": "federation-share", - "share": { - "domain": "core/data", - "resource": { - "kind": "room", - "path": "incidents" - }, - "direction": "bidirectional" - } - }, - "wire_hex": "a264747970657066656465726174696f6e2d7368617265657368617265a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d647061746869696e636964656e747369646972656374696f6e6d6269646972656374696f6e616c" - }, - { - "name": "federation_unshare_v1", - "message": { - "type": "federation-unshare", - "share": { - "domain": "core/data", - "resource": { - "kind": "room", - "path": "incidents" - }, - "direction": "bidirectional" - } - }, - "wire_hex": "a264747970657266656465726174696f6e2d756e7368617265657368617265a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d647061746869696e636964656e747369646972656374696f6e6d6269646972656374696f6e616c" - }, - { - "name": "federation_envelope_v1_wrapping_a_ping", - "message": { - "type": "federation-envelope", - "origin-mesh": "example-partner", - "origin-device": { - "hex": "3333333333333333333333333333333333333333333333333333333333333333" - }, - "resource": { - "kind": "room", - "path": "general" - }, - "inner": { - "hex": "a164747970656470696e67" - } - }, - "wire_hex": "a564747970657366656465726174696f6e2d656e76656c6f706565696e6e65724ba164747970656470696e67687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c6b6f726967696e2d6d6573686f6578616d706c652d706172746e65726d6f726967696e2d64657669636558203333333333333333333333333333333333333333333333333333333333333333" } ] } diff --git a/conformance/generate.ts b/conformance/generate.ts index b453e79..ddf1aa0 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -54,10 +54,10 @@ const signatureFiller = hex("ff".repeat(SIGNATURE_BYTE_LENGTH)); // synthetic ES // ----------------------------------------------------------------------- const handshakeVectors: Vector[] = [ - vector("handshake_v1_management_exec_federation", { + vector("handshake_v1_management_exec_data", { type: "handshake", version: 1, - domains: ["core/management", "core/exec", "core/federation"], + domains: ["core/management", "core/exec", "core/data"], }), vector("handshake_v1_with_forward_compatible_params", { type: "handshake", @@ -119,6 +119,7 @@ const handleClaims: JsonWire = { "device-id": deviceD, "identity-key": { alg: -8, "public-key": publicKeyEd25519D }, candidates: [{ address: "203.0.113.5:4433", kind: "host", priority: 100 }], + mailboxes: [deviceA, deviceB], issued: 1861833600000, expires: 1861920000000, }; @@ -140,8 +141,6 @@ const tokenVectors: Vector[] = [ // frames.v1.json -- every $frame-variant in spec/frame.cddl except handshake-frame, which lives in handshake.v1.json above. // ----------------------------------------------------------------------- -const innerPingFrame: JsonWire = { type: "ping" }; - const frameVectors: Vector[] = [ vector("ping_v1", { type: "ping" }), vector("close_v1_with_reason", { type: "close", reason: "shutting down" }), @@ -192,6 +191,12 @@ const frameVectors: Vector[] = [ type: "relay-inbound", "source-device": deviceB, }), + vector("coordinator_v1_with_capacity_hint", { + type: "coordinator", + term: 3, + coordinator: deviceA, + "capacity-hint": 64, + }), vector("manage_request_v1_pty_spawn", { type: "manage-request", "request-id": 1, @@ -225,15 +230,34 @@ const frameVectors: Vector[] = [ }), vector("revocation_announce_v1_two_entries", { type: "revocation-announce", + // Each entry is its own cose-sign1 (same shape as capability-token), so a revocation carries the same self-certifying attribution as the token it revokes: a verifier checks revocation-claims.issuer against the token's own issuer field, not merely that some signature verifies -- only a token's own issuer may revoke it. entries: [ - { - "token-id": hex("01".repeat(TOKEN_ID_BYTE_LENGTH)), - "revoked-at": 1861833700000, - }, - { - "token-id": hex("02".repeat(TOKEN_ID_BYTE_LENGTH)), - "revoked-at": 1861833701000, - }, + [ + hex(wireHex({ 1: -7, 4: deviceA })), + {}, + hex( + wireHex({ + "token-id": hex("01".repeat(TOKEN_ID_BYTE_LENGTH)), + issuer: deviceA, + "issuer-key": { alg: -7, "public-key": publicKeyEs256A }, + "revoked-at": 1861833700000, + }), + ), + signatureFiller, + ], + [ + hex(wireHex({ 1: -7, 4: deviceB })), + {}, + hex( + wireHex({ + "token-id": hex("02".repeat(TOKEN_ID_BYTE_LENGTH)), + issuer: deviceB, + "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, + "revoked-at": 1861833701000, + }), + ), + signatureFiller, + ], ], }), vector("stream_data_v1_stdout_chunk", { @@ -266,57 +290,6 @@ const frameVectors: Vector[] = [ "from-seq": 100, entries: [hex("aabbcc"), hex("ddeeff00")], }), - vector("federation_link_request_v1", { - type: "federation-link-request", - "local-mesh": "exadev-internal", - "local-name": "exadev", - "offered-shares": [ - { - domain: "core/data", - resource: { kind: "room", path: "general" }, - direction: "outbound", - }, - ], - }), - vector("federation_link_accept_v1", { - type: "federation-link-accept", - "remote-mesh": "example-partner", - "remote-name": "partner", - "accepted-shares": [ - { - domain: "core/data", - resource: { kind: "room", path: "general" }, - direction: "outbound", - }, - ], - }), - vector("federation_link_reject_v1", { - type: "federation-link-reject", - reason: "no shared domains accepted", - }), - vector("federation_share_v1", { - type: "federation-share", - share: { - domain: "core/data", - resource: { kind: "room", path: "incidents" }, - direction: "bidirectional", - }, - }), - vector("federation_unshare_v1", { - type: "federation-unshare", - share: { - domain: "core/data", - resource: { kind: "room", path: "incidents" }, - direction: "bidirectional", - }, - }), - vector("federation_envelope_v1_wrapping_a_ping", { - type: "federation-envelope", - "origin-mesh": "example-partner", - "origin-device": deviceC, - resource: { kind: "room", path: "general" }, - inner: hex(wireHex(innerPingFrame)), - }), ]; // ----------------------------------------------------------------------- diff --git a/conformance/handshake.v1.json b/conformance/handshake.v1.json index 273045f..7dcb4c7 100644 --- a/conformance/handshake.v1.json +++ b/conformance/handshake.v1.json @@ -3,17 +3,17 @@ "description": "Handshake conformance vectors for protocol version 1. A conformant codec must decode each wire_hex to the described message and re-encode that message to exactly wire_hex, using RFC 8949 4.2 core deterministic (DAG-CBOR-compatible) encoding.", "vectors": [ { - "name": "handshake_v1_management_exec_federation", + "name": "handshake_v1_management_exec_data", "message": { "type": "handshake", "version": 1, "domains": [ "core/management", "core/exec", - "core/federation" + "core/data" ] }, - "wire_hex": "a364747970656968616e647368616b6567646f6d61696e73836f636f72652f6d616e6167656d656e7469636f72652f657865636f636f72652f66656465726174696f6e6776657273696f6e01" + "wire_hex": "a364747970656968616e647368616b6567646f6d61696e73836f636f72652f6d616e6167656d656e7469636f72652f6578656369636f72652f646174616776657273696f6e01" }, { "name": "handshake_v1_with_forward_compatible_params", diff --git a/conformance/tokens.v1.json b/conformance/tokens.v1.json index 0cf5067..2201fbb 100644 --- a/conformance/tokens.v1.json +++ b/conformance/tokens.v1.json @@ -42,13 +42,13 @@ }, {}, { - "hex": "a66668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d6964582044444444444444444444444444444444444444444444444444444444444444446a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + "hex": "a76668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d696458204444444444444444444444444444444444444444444444444444444444444444696d61696c626f7865738258201111111111111111111111111111111111111111111111111111111111111111582022222222222222222222222222222222222222222222222222222222222222226a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" }, { "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } ], - "wire_hex": "8444a1613127a058e2a66668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d6964582044444444444444444444444444444444444444444444444444444444444444446a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "wire_hex": "8444a1613127a0590131a76668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d696458204444444444444444444444444444444444444444444444444444444444444444696d61696c626f7865738258201111111111111111111111111111111111111111111111111111111111111111582022222222222222222222222222222222222222222222222222222222222222226a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } ] } diff --git a/spec/data-domain.cddl b/spec/data-domain.cddl index 1636b65..97c296c 100644 --- a/spec/data-domain.cddl +++ b/spec/data-domain.cddl @@ -8,6 +8,19 @@ ; can share a mesh without the transport layer needing to understand any of ; their content. A domain implementing core/data defines its own entry ; schema separately, in its own application-level spec, not here. +; +; Durability is opportunistic by design, not guaranteed by the protocol: a +; peer's own log lives on exactly that one device until some other peer +; independently chooses to replicate it via data-have/data-request below -- +; there is no mandated minimum replication factor, and there shouldn't be +; one baked into the wire shape, since how much redundancy is worth the +; cost is an application/deployment policy question, not a protocol one. A +; peer that wants durability for its own data doesn't need a new mechanism +; for it: proactively pushing its own log to its own discovery.cddl +; mailboxes (already redundant, already devices it trusts to stay current) +; is the same fan-out this file already defines, just initiated by the +; owner instead of waited on passively -- a recommended usage pattern of an +; existing mechanism, not a new one. ; `peer` names whose log is being asked about; it is deliberately independent ; of which connection the frame travels over. A peer that has already @@ -17,7 +30,7 @@ ; by holding a direct link to the original author. No wire change is needed ; for this; it follows from `peer` already being a plain reference rather ; than an implicit "you" per connection. A relay/mailbox device (see -; discovery.cddl's `mailbox` field) answering on behalf of a currently-offline +; discovery.cddl's `mailboxes` field) answering on behalf of a currently-offline ; peer is the same mechanism, not a separate one: it is simply a replicator ; that is reachable when the original author is not. data-have-frame = { type: "data-have", peer: device-id, head-seq: uint } diff --git a/spec/discovery.cddl b/spec/discovery.cddl index 2346c96..ce1dd57 100644 --- a/spec/discovery.cddl +++ b/spec/discovery.cddl @@ -8,9 +8,45 @@ ; Matrix all independently converged on the same DNS-anchored shape for ; exactly this category of problem. ; -; A handle is "@". It resolves out of band (this is never -; sent as a Frame over an existing mesh connection — resolution is how you -; get an address to connect to in the first place) via: +; A handle is "@". Resolving it is a bootstrap problem, +; not an authorisation one: before any wire-mesh connection exists there is +; nothing to ask over the wire, so the very first handle-record has to reach +; a new principal through a channel outside this protocol entirely. Three +; such channels are recognised, fitting three different reachability +; situations, and none substitutes for the others: +; +; - DNS (below) — unbounded, works for any two principals anywhere on the +; internet, requires the publisher to control a domain. +; - An out-of-band-delivered handle-record — explicit, requires a human or +; an existing relationship to actually hand it over, works anywhere. +; - Local broadcast discovery (mDNS/Bonjour, or an equivalent) — zero +; configuration, but only within the same physical/broadcast network +; segment. Not itself a Frame carried over an existing mesh connection any +; more than the other two are; it is the same handle-record, published via +; multicast on the local segment instead of fetched over HTTPS or handed +; over out of band. A receiver verifies it exactly the same way regardless +; of which of the three channels delivered it — physical proximity is not +; a substitute for the signature check below, only for how the bytes +; arrived. +; +; A device discovered only through local broadcast has no durable roaming +; story, and this is inherent to what that channel can do, not a gap to +; close: local broadcast tells you nothing beyond "this device is currently +; on my network segment", so once it leaves and stops advertising there, its +; last-known candidates go stale with it. Reconnecting after that depends +; entirely on what else the device published at discovery time — a DNS +; handle (its record's candidates kept current the way a dynamic-DNS entry +; is), or a handle-record replicated through a durably-reachable mailbox +; device (below) whose own address doesn't change even if the device's +; does. A device that was only ever seen over local broadcast, with neither +; of those, cannot be relocated by this protocol once it roams — the same +; limitation any purely local-discovery mechanism has (a Bonjour-discovered +; printer that leaves the network is exactly as unreachable) until the two +; devices share a network segment again or the roaming device reconnects +; first, using an address the other side already published somewhere +; durable. +; +; DNS resolution: ; ; GET https:///.well-known/wire-mesh/ ; @@ -18,17 +54,18 @@ ; self-certifying — signed by the same key it claims to belong to, the same ; pattern Cascade's own announce/DHT candidate records use — specifically so ; it stays safe to serve and cache through an untrusted intermediary (a CDN, -; a cache, an intermediate DNS resolver) rather than requiring the resolver -; to trust the HTTPS transport alone. A resolver MUST verify -; device-id == sha256(identity-key.public-key) and MUST check `expires` -; before trusting a resolved record. +; a cache, an intermediate DNS resolver, or a multicast listener on a shared +; network) rather than requiring the resolver to trust the delivery channel +; itself. A resolver MUST verify device-id == sha256(identity-key.public-key) +; and MUST check `expires` before trusting a resolved record, regardless of +; which of the three channels above delivered it. handle-claims = { handle: tstr, ; "local-part@domain", matching what was requested device-id: device-id, identity-key: identity-key, ; verifier checks sha256(identity-key.public-key) == device-id ? candidates: [* wire-candidate], ; optional connection hints, reusing transport.cddl's candidate shape - ? mailbox: device-id, ; optional: a relay/hub device holding core/data entries on this handle's behalf while it is offline — the same replication mechanism data-domain.cddl describes for fan-out, not a separate delivery system + ? mailboxes: [* device-id], ; optional: relay/hub devices holding core/data entries on this handle's behalf while it is offline — the same replication mechanism data-domain.cddl describes for fan-out, not a separate delivery system. Plural, matching `candidates` below and relay-offer-frame's own `addresses`: nothing about holding a replica is inherently single-device, and naming more than one gives a resolver redundancy (and the owner independence from any single mailbox's uptime) the same way multiple candidates or multiple relay addresses already do. issued: uint, ; Unix ms expires: uint, ; Unix ms — records are refreshed periodically, mirroring a DNS TTL; a resolver must not trust an expired record } @@ -39,24 +76,42 @@ handle-claims = { ; token one. handle-record = cose-sign1 ; payload = bstr .cbor handle-claims +; Why name mailboxes at all, given data-domain.cddl's own fan-out rule +; already lets any peer holding a replica answer on the owner's behalf +; regardless of whether it was ever named anywhere: naming and fan-out solve +; different problems, not the same one twice. Fan-out cannot bootstrap +; itself — gossip and data-have/data-request only reach peers a resolver is +; already connected to, so "who, anywhere, has a copy of this handle's +; entries" has no answer without a starting point, the identical bootstrap +; problem the three channels above exist to solve. `mailboxes` is that +; starting point: a signed, owner-chosen anchor delivered through one of +; those same channels. It also carries something fan-out alone cannot: +; naming a device is the owner deliberately vouching that this specific +; peer has been arranged to stay current and can be trusted to behave +; correctly while standing in for it, not merely "some peer, holding some +; version, happened to still have a copy." Once a resolver has reached any +; named mailbox, ordinary fan-out still takes over exactly as +; data-domain.cddl describes for discovering further, unnamed replicas — the +; two layer cleanly rather than competing, and neither makes the other +; redundant. +; ; Handle ownership without DNS control: a handle-claims record for the same ; handle MAY instead be published as an ordinary core/data entry, under the -; conventional scope { kind: "handle-registry" }, and reached by resolving -; via a federation link rather than DNS — a mesh's own root identity stands -; in for a domain, and a federated mesh can then resolve any handle the -; owning mesh has published, with no third party (no domain, no hosting -; provider) involved at all. This needs no new frame type: it is the same -; core/data replication (data-domain.cddl) and the same federation sharing -; (federation.cddl's share-descriptor) already defined, applied to handle -; records instead of application content — federation already has to resolve -; and reach a partner mesh's resources somehow, and a handle registry is -; simply one more resource a federation link can share. The tradeoff this -; accepts, deliberately: resolvability is bounded to the owning mesh plus -; whatever it is federated with, not the whole internet unconditionally — -; DNS remains the only path for genuinely unbounded, internet-wide discovery. +; conventional scope { kind: "handle-registry" }, and read by anyone holding +; a valid capability token scoped to that registry — an ordinary grant, +; delegated through however many hops trace back to the registry owner's own +; root identity (see tokens.cddl's capability-scope comment), not membership +; in any federation-specific relationship. This needs no new frame type: it +; is the same core/data replication (data-domain.cddl) already defined, +; applied to handle records instead of application content, gated the same +; way every other core/data resource already is. The tradeoff this accepts, +; deliberately: resolvability is bounded to whoever holds a valid token for +; the registry, not the whole internet unconditionally — DNS remains the +; only path for genuinely unbounded, internet-wide discovery with no prior +; relationship required at all. ; ; A fully decentralised, third-party-free alternative with unbounded (not -; federation-scoped) reach — e.g. a DHT keyed by a hash of the handle string, +; token-gated) reach — e.g. a DHT keyed by a hash of the handle string, ; mirroring Cascade's own Mainline DHT precedent — was considered and ; deliberately deferred, not built: nothing else in this protocol needs a ; DHT, so building one solely for this would be speculative generality diff --git a/spec/federation.cddl b/spec/federation.cddl index 49f389b..3d4b69d 100644 --- a/spec/federation.cddl +++ b/spec/federation.cddl @@ -1,60 +1,48 @@ -; core/federation — selective, explicit sharing between independently -; operated meshes, distinct from ordinary intra-mesh relay. No Cascade -; precedent exists for this; it is designed as its own capability domain -; rather than a separate connection type, so it reuses Handshake negotiation, -; the Frame choice, and capability-scope instead of inventing a parallel -; mechanism. Generalises agent-comms' own existing fed_handshake/fed_room_* -; messages past one specific application. +; core/federation — retired as a distinct frame domain. This file +; deliberately defines no rules; it exists so the design reasoning behind +; the removal is discoverable exactly where a reader would look for the +; mechanism that used to live here. `core-domain-name` in handshake.cddl +; still reserves the "core/federation" string (append-only, never renumbered +; or reused, per registry/core-domains.md's own convention) but no frames +; are defined for it; a peer must never advertise or negotiate it. ; -; Two designated gateway devices, one per mesh, negotiate core/federation at -; Handshake like any other domain, then speak these frames on that -; connection. - -mesh-id = tstr ; a human-chosen or hash-derived identifier for an independently-operated mesh; opaque, not required to be device-id-shaped - -federation-link-request-frame = { - type: "federation-link-request", - local-mesh: mesh-id, - local-name: tstr, - offered-shares: [* share-descriptor], -} - -federation-link-accept-frame = { - type: "federation-link-accept", - remote-mesh: mesh-id, - remote-name: tstr, - accepted-shares: [* share-descriptor], -} - -federation-link-reject-frame = { type: "federation-link-reject", reason: tstr } - -; What's shared: reuses capability-scope so "a room path" and "a folder path" -; are the same generic concept federation already needs elsewhere in this -; schema. -share-descriptor = { - domain: domain-id, - resource: capability-scope, - direction: "inbound" / "outbound" / "bidirectional", -} - -; Renegotiate shares after the initial link — add or remove a room later -; without tearing down the whole federation link. -federation-share-frame = { type: "federation-share", share: share-descriptor } -federation-unshare-frame = { type: "federation-unshare", share: share-descriptor } - -; Once a share is accepted, content belonging to that shared resource is -; forwarded across the link wrapped in this envelope — deliberately -; content-aware and resource-scoped, unlike relay-data-frame's opaque byte -; pipe. That is the actual distinction between federation and ordinary -; intra-mesh relay: relay-data-frame is payload-blind NAT-traversal plumbing; -; federation-envelope-frame is deliberate, selective, resource-scoped -; cross-mesh sharing. origin-device is carried explicitly because the link's -; own peer identity is the gateway device, not the device inside the remote -; mesh that originated the content. -federation-envelope-frame = { - type: "federation-envelope", - origin-mesh: mesh-id, - origin-device: device-id, - resource: capability-scope, - inner: bstr, ; .cbor frame — a full inner Frame value, CBOR-encoded and embedded -} +; The original design modelled cross-mesh sharing as its own bilateral link +; protocol: two designated gateway devices, one per mesh, negotiated a +; mesh-scoped `federation-link-request`/`accept`, declared coarse +; `share-descriptor`s (domain + resource + direction), and forwarded content +; in a dedicated `federation-envelope-frame`. On review this conflated two +; problems that were already solved separately, elsewhere in this schema, +; and didn't need a third mechanism bolted on top: +; +; - Reachability — how do two principals with no prior relationship even +; find and connect to each other — is discovery.cddl's job (DNS-anchored +; handle resolution, an out-of-band-delivered handle-record, or local +; broadcast discovery), not a job for a link-negotiation frame. +; - Authorisation — what a given bearer is allowed to do or see once +; connected — is tokens.cddl's job (capability tokens, signed, +; delegatable, narrowing-only, revocable). A federation-link-accept and a +; share-descriptor added no security property a signed capability token, +; scoped to the resource in question and handed to a bearer outside the +; issuer's own group, didn't already provide on its own: the token's own +; signature *is* the explicit, auditable, bilateral act of agreement that +; the link-negotiation frames were redundantly trying to represent again. +; +; What is lost by removing the dedicated frames, named rather than left +; implicit: the coarse, wire-visible "here is what's declared to cross this +; boundary" allowlist a share-descriptor gave you for free, ahead of and +; independent from per-bearer token verification. Without it, "what's +; allowed to cross" is answered purely by which tokens exist and verify — +; correct, but no longer inspectable as a standing, pre-declared fact on the +; wire. That trade was made deliberately: mesh-id and the gateway-device +; role it implied were themselves the more artificial restriction (ordinary +; capability-scope, per tokens.cddl, already expresses "a node", "a group", +; or what would colloquially be called "a mesh" as the same kind of thing; +; scoping the sharing mechanism to whole meshes only was never load-bearing +; for any property federation existed to provide). +; +; Cross-scope sharing today: a resource owner mints a capability token +; scoped to whatever `core/*` resource (or, per discovery.cddl, a +; `handle-registry` entry) it wants to expose, and hands it to a bearer +; outside its own group by whatever channel already gets a token to a +; bearer — the same mechanism used for every other capability grant in this +; schema, nothing federation-specific about it. diff --git a/spec/frame.cddl b/spec/frame.cddl index 58ec9f8..a6edc9d 100644 --- a/spec/frame.cddl +++ b/spec/frame.cddl @@ -25,6 +25,7 @@ $frame-variant /= relay-offer-frame $frame-variant /= relay-connect-frame $frame-variant /= relay-data-frame $frame-variant /= relay-inbound-frame +$frame-variant /= coordinator-frame $frame-variant /= manage-request-frame $frame-variant /= manage-response-frame $frame-variant /= revocation-announce-frame @@ -34,11 +35,5 @@ $frame-variant /= stream-end-frame $frame-variant /= data-have-frame $frame-variant /= data-request-frame $frame-variant /= data-entries-frame -$frame-variant /= federation-link-request-frame -$frame-variant /= federation-link-accept-frame -$frame-variant /= federation-link-reject-frame -$frame-variant /= federation-share-frame -$frame-variant /= federation-unshare-frame -$frame-variant /= federation-envelope-frame frame = $frame-variant diff --git a/spec/handshake.cddl b/spec/handshake.cddl index eb9b6a7..fb8cc9e 100644 --- a/spec/handshake.cddl +++ b/spec/handshake.cddl @@ -16,6 +16,9 @@ domain-id = core-domain-name / namespaced-domain-id / private-use-domain-id ; Spec-owned, append-only — see registry/core-domains.md. A retired entry is ; marked retired there, never renumbered or reused for something else. +; "core/federation" is retired: no frames are defined for it (see +; federation.cddl) and a peer must never advertise or negotiate it; the +; string itself stays reserved rather than becoming available for reuse. core-domain-name = "core/management" / "core/exec" / "core/data" / "core/federation" ; "/", where uniqueness comes from the registrant diff --git a/spec/identity.cddl b/spec/identity.cddl index d605651..f87213c 100644 --- a/spec/identity.cddl +++ b/spec/identity.cddl @@ -4,6 +4,21 @@ ; an identical key) was a bug found independently in both Cascade and ; agent-comms; this rule exists to make the correct derivation the only one ; representable. +; +; There is deliberately no key-rotation or recovery mechanism in this +; protocol: identity is bare public-key hashing with no CA, so losing a +; device's private key permanently orphans everything rooted at it -- no +; new tokens or revocations can ever be issued under that identity again, +; though already-signed tokens stay verifiable until their own expiry (see +; tokens.cddl). Adding recovery machinery to the wire protocol itself would +; be real scope and complexity this deliberately minimal identity layer +; doesn't take on. The mitigation that already exists with no protocol +; change needed: mint a scoped, long-lived capability token delegating +; recovery-relevant authority to a separate, offline-stored backup identity +; ahead of time, before it's ever needed -- ordinary delegation +; (tokens.cddl), used as an operational pattern rather than a protocol +; feature, the same way an offline root-CA backup key is operational +; practice, not something X.509 itself specifies. identity-key = { alg: int, ; COSE algorithm identifier (RFC 9053), e.g. -7 ES256, -8 EdDSA diff --git a/spec/management.cddl b/spec/management.cddl index 0ab72cc..a4f807b 100644 --- a/spec/management.cddl +++ b/spec/management.cddl @@ -23,5 +23,30 @@ manage-response-frame = { type: "manage-response", request-id: uint, outcome: ma ; Revocation — new, not present in Cascade's frozen set. Gossiped revocation ; entries let a peer check a token against a shared revocation view without ; a synchronous lookup against the issuer for every use of the token. -revocation-entry = { token-id: bstr, revoked-at: uint } +; +; Self-certifying, the same COSE_Sign1 pattern as capability-token and +; handle-record, deliberately: a bare, unsigned {token-id, revoked-at} pair +; would let any peer falsely announce any other peer's valid token as +; revoked with no attribution at all, a real denial-of-service vector this +; closes rather than merely documents. issuer-key travels inside the signed +; payload for the same reason it does in token-claims — self-certifying, no +; prior contact with the issuer needed to verify the claim, only the claim +; itself. +; +; A verifier MUST check revocation-claims.issuer against the *token's own* +; issuer field, not merely that some signature verifies — only a token's +; own issuer may revoke it. A verifier walking a token's parent chain (see +; tokens.cddl's `parent` field) MUST check every ancestor's own token-id +; against the revocation view, not only the leaf token's: revoking one +; ancestor thereby revokes every token delegated beneath it, without +; needing to individually re-revoke each descendant. Both are verifier +; obligations, not something CDDL itself can enforce, the same way +; delegation's narrowing rule is. +revocation-claims = { + token-id: bstr, + issuer: device-id, + issuer-key: identity-key, ; verifier checks sha256(issuer-key.public-key) == issuer + revoked-at: uint, +} +revocation-entry = cose-sign1 ; payload = bstr .cbor revocation-claims revocation-announce-frame = { type: "revocation-announce", entries: [* revocation-entry] } diff --git a/spec/protocol.cddl b/spec/protocol.cddl index a6e0b1f..83dc5da 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -8,6 +8,19 @@ ; can share a mesh without the transport layer needing to understand any of ; their content. A domain implementing core/data defines its own entry ; schema separately, in its own application-level spec, not here. +; +; Durability is opportunistic by design, not guaranteed by the protocol: a +; peer's own log lives on exactly that one device until some other peer +; independently chooses to replicate it via data-have/data-request below -- +; there is no mandated minimum replication factor, and there shouldn't be +; one baked into the wire shape, since how much redundancy is worth the +; cost is an application/deployment policy question, not a protocol one. A +; peer that wants durability for its own data doesn't need a new mechanism +; for it: proactively pushing its own log to its own discovery.cddl +; mailboxes (already redundant, already devices it trusts to stay current) +; is the same fan-out this file already defines, just initiated by the +; owner instead of waited on passively -- a recommended usage pattern of an +; existing mechanism, not a new one. ; `peer` names whose log is being asked about; it is deliberately independent ; of which connection the frame travels over. A peer that has already @@ -17,7 +30,7 @@ ; by holding a direct link to the original author. No wire change is needed ; for this; it follows from `peer` already being a plain reference rather ; than an implicit "you" per connection. A relay/mailbox device (see -; discovery.cddl's `mailbox` field) answering on behalf of a currently-offline +; discovery.cddl's `mailboxes` field) answering on behalf of a currently-offline ; peer is the same mechanism, not a separate one: it is simply a replicator ; that is reachable when the original author is not. data-have-frame = { type: "data-have", peer: device-id, head-seq: uint } @@ -37,9 +50,45 @@ data-entries-frame = { type: "data-entries", peer: device-id, from-seq: uint, en ; Matrix all independently converged on the same DNS-anchored shape for ; exactly this category of problem. ; -; A handle is "@". It resolves out of band (this is never -; sent as a Frame over an existing mesh connection — resolution is how you -; get an address to connect to in the first place) via: +; A handle is "@". Resolving it is a bootstrap problem, +; not an authorisation one: before any wire-mesh connection exists there is +; nothing to ask over the wire, so the very first handle-record has to reach +; a new principal through a channel outside this protocol entirely. Three +; such channels are recognised, fitting three different reachability +; situations, and none substitutes for the others: +; +; - DNS (below) — unbounded, works for any two principals anywhere on the +; internet, requires the publisher to control a domain. +; - An out-of-band-delivered handle-record — explicit, requires a human or +; an existing relationship to actually hand it over, works anywhere. +; - Local broadcast discovery (mDNS/Bonjour, or an equivalent) — zero +; configuration, but only within the same physical/broadcast network +; segment. Not itself a Frame carried over an existing mesh connection any +; more than the other two are; it is the same handle-record, published via +; multicast on the local segment instead of fetched over HTTPS or handed +; over out of band. A receiver verifies it exactly the same way regardless +; of which of the three channels delivered it — physical proximity is not +; a substitute for the signature check below, only for how the bytes +; arrived. +; +; A device discovered only through local broadcast has no durable roaming +; story, and this is inherent to what that channel can do, not a gap to +; close: local broadcast tells you nothing beyond "this device is currently +; on my network segment", so once it leaves and stops advertising there, its +; last-known candidates go stale with it. Reconnecting after that depends +; entirely on what else the device published at discovery time — a DNS +; handle (its record's candidates kept current the way a dynamic-DNS entry +; is), or a handle-record replicated through a durably-reachable mailbox +; device (below) whose own address doesn't change even if the device's +; does. A device that was only ever seen over local broadcast, with neither +; of those, cannot be relocated by this protocol once it roams — the same +; limitation any purely local-discovery mechanism has (a Bonjour-discovered +; printer that leaves the network is exactly as unreachable) until the two +; devices share a network segment again or the roaming device reconnects +; first, using an address the other side already published somewhere +; durable. +; +; DNS resolution: ; ; GET https:///.well-known/wire-mesh/ ; @@ -47,17 +96,18 @@ data-entries-frame = { type: "data-entries", peer: device-id, from-seq: uint, en ; self-certifying — signed by the same key it claims to belong to, the same ; pattern Cascade's own announce/DHT candidate records use — specifically so ; it stays safe to serve and cache through an untrusted intermediary (a CDN, -; a cache, an intermediate DNS resolver) rather than requiring the resolver -; to trust the HTTPS transport alone. A resolver MUST verify -; device-id == sha256(identity-key.public-key) and MUST check `expires` -; before trusting a resolved record. +; a cache, an intermediate DNS resolver, or a multicast listener on a shared +; network) rather than requiring the resolver to trust the delivery channel +; itself. A resolver MUST verify device-id == sha256(identity-key.public-key) +; and MUST check `expires` before trusting a resolved record, regardless of +; which of the three channels above delivered it. handle-claims = { handle: tstr, ; "local-part@domain", matching what was requested device-id: device-id, identity-key: identity-key, ; verifier checks sha256(identity-key.public-key) == device-id ? candidates: [* wire-candidate], ; optional connection hints, reusing transport.cddl's candidate shape - ? mailbox: device-id, ; optional: a relay/hub device holding core/data entries on this handle's behalf while it is offline — the same replication mechanism data-domain.cddl describes for fan-out, not a separate delivery system + ? mailboxes: [* device-id], ; optional: relay/hub devices holding core/data entries on this handle's behalf while it is offline — the same replication mechanism data-domain.cddl describes for fan-out, not a separate delivery system. Plural, matching `candidates` below and relay-offer-frame's own `addresses`: nothing about holding a replica is inherently single-device, and naming more than one gives a resolver redundancy (and the owner independence from any single mailbox's uptime) the same way multiple candidates or multiple relay addresses already do. issued: uint, ; Unix ms expires: uint, ; Unix ms — records are refreshed periodically, mirroring a DNS TTL; a resolver must not trust an expired record } @@ -68,24 +118,42 @@ handle-claims = { ; token one. handle-record = cose-sign1 ; payload = bstr .cbor handle-claims +; Why name mailboxes at all, given data-domain.cddl's own fan-out rule +; already lets any peer holding a replica answer on the owner's behalf +; regardless of whether it was ever named anywhere: naming and fan-out solve +; different problems, not the same one twice. Fan-out cannot bootstrap +; itself — gossip and data-have/data-request only reach peers a resolver is +; already connected to, so "who, anywhere, has a copy of this handle's +; entries" has no answer without a starting point, the identical bootstrap +; problem the three channels above exist to solve. `mailboxes` is that +; starting point: a signed, owner-chosen anchor delivered through one of +; those same channels. It also carries something fan-out alone cannot: +; naming a device is the owner deliberately vouching that this specific +; peer has been arranged to stay current and can be trusted to behave +; correctly while standing in for it, not merely "some peer, holding some +; version, happened to still have a copy." Once a resolver has reached any +; named mailbox, ordinary fan-out still takes over exactly as +; data-domain.cddl describes for discovering further, unnamed replicas — the +; two layer cleanly rather than competing, and neither makes the other +; redundant. +; ; Handle ownership without DNS control: a handle-claims record for the same ; handle MAY instead be published as an ordinary core/data entry, under the -; conventional scope { kind: "handle-registry" }, and reached by resolving -; via a federation link rather than DNS — a mesh's own root identity stands -; in for a domain, and a federated mesh can then resolve any handle the -; owning mesh has published, with no third party (no domain, no hosting -; provider) involved at all. This needs no new frame type: it is the same -; core/data replication (data-domain.cddl) and the same federation sharing -; (federation.cddl's share-descriptor) already defined, applied to handle -; records instead of application content — federation already has to resolve -; and reach a partner mesh's resources somehow, and a handle registry is -; simply one more resource a federation link can share. The tradeoff this -; accepts, deliberately: resolvability is bounded to the owning mesh plus -; whatever it is federated with, not the whole internet unconditionally — -; DNS remains the only path for genuinely unbounded, internet-wide discovery. +; conventional scope { kind: "handle-registry" }, and read by anyone holding +; a valid capability token scoped to that registry — an ordinary grant, +; delegated through however many hops trace back to the registry owner's own +; root identity (see tokens.cddl's capability-scope comment), not membership +; in any federation-specific relationship. This needs no new frame type: it +; is the same core/data replication (data-domain.cddl) already defined, +; applied to handle records instead of application content, gated the same +; way every other core/data resource already is. The tradeoff this accepts, +; deliberately: resolvability is bounded to whoever holds a valid token for +; the registry, not the whole internet unconditionally — DNS remains the +; only path for genuinely unbounded, internet-wide discovery with no prior +; relationship required at all. ; ; A fully decentralised, third-party-free alternative with unbounded (not -; federation-scoped) reach — e.g. a DHT keyed by a hash of the handle string, +; token-gated) reach — e.g. a DHT keyed by a hash of the handle string, ; mirroring Cascade's own Mainline DHT precedent — was considered and ; deliberately deferred, not built: nothing else in this protocol needs a ; DHT, so building one solely for this would be speculative generality @@ -123,66 +191,54 @@ exec-session-info = { session: stream-session, kind: "pty" / "proc", ? argv: [* ; A manage-response-frame answering exec.list carries manage-ok extended with ; `sessions: [* exec-session-info]`, using manage-ok's own open {* tstr => any} ; tail rather than a dedicated result type. -; core/federation — selective, explicit sharing between independently -; operated meshes, distinct from ordinary intra-mesh relay. No Cascade -; precedent exists for this; it is designed as its own capability domain -; rather than a separate connection type, so it reuses Handshake negotiation, -; the Frame choice, and capability-scope instead of inventing a parallel -; mechanism. Generalises agent-comms' own existing fed_handshake/fed_room_* -; messages past one specific application. +; core/federation — retired as a distinct frame domain. This file +; deliberately defines no rules; it exists so the design reasoning behind +; the removal is discoverable exactly where a reader would look for the +; mechanism that used to live here. `core-domain-name` in handshake.cddl +; still reserves the "core/federation" string (append-only, never renumbered +; or reused, per registry/core-domains.md's own convention) but no frames +; are defined for it; a peer must never advertise or negotiate it. ; -; Two designated gateway devices, one per mesh, negotiate core/federation at -; Handshake like any other domain, then speak these frames on that -; connection. - -mesh-id = tstr ; a human-chosen or hash-derived identifier for an independently-operated mesh; opaque, not required to be device-id-shaped - -federation-link-request-frame = { - type: "federation-link-request", - local-mesh: mesh-id, - local-name: tstr, - offered-shares: [* share-descriptor], -} - -federation-link-accept-frame = { - type: "federation-link-accept", - remote-mesh: mesh-id, - remote-name: tstr, - accepted-shares: [* share-descriptor], -} - -federation-link-reject-frame = { type: "federation-link-reject", reason: tstr } - -; What's shared: reuses capability-scope so "a room path" and "a folder path" -; are the same generic concept federation already needs elsewhere in this -; schema. -share-descriptor = { - domain: domain-id, - resource: capability-scope, - direction: "inbound" / "outbound" / "bidirectional", -} - -; Renegotiate shares after the initial link — add or remove a room later -; without tearing down the whole federation link. -federation-share-frame = { type: "federation-share", share: share-descriptor } -federation-unshare-frame = { type: "federation-unshare", share: share-descriptor } - -; Once a share is accepted, content belonging to that shared resource is -; forwarded across the link wrapped in this envelope — deliberately -; content-aware and resource-scoped, unlike relay-data-frame's opaque byte -; pipe. That is the actual distinction between federation and ordinary -; intra-mesh relay: relay-data-frame is payload-blind NAT-traversal plumbing; -; federation-envelope-frame is deliberate, selective, resource-scoped -; cross-mesh sharing. origin-device is carried explicitly because the link's -; own peer identity is the gateway device, not the device inside the remote -; mesh that originated the content. -federation-envelope-frame = { - type: "federation-envelope", - origin-mesh: mesh-id, - origin-device: device-id, - resource: capability-scope, - inner: bstr, ; .cbor frame — a full inner Frame value, CBOR-encoded and embedded -} +; The original design modelled cross-mesh sharing as its own bilateral link +; protocol: two designated gateway devices, one per mesh, negotiated a +; mesh-scoped `federation-link-request`/`accept`, declared coarse +; `share-descriptor`s (domain + resource + direction), and forwarded content +; in a dedicated `federation-envelope-frame`. On review this conflated two +; problems that were already solved separately, elsewhere in this schema, +; and didn't need a third mechanism bolted on top: +; +; - Reachability — how do two principals with no prior relationship even +; find and connect to each other — is discovery.cddl's job (DNS-anchored +; handle resolution, an out-of-band-delivered handle-record, or local +; broadcast discovery), not a job for a link-negotiation frame. +; - Authorisation — what a given bearer is allowed to do or see once +; connected — is tokens.cddl's job (capability tokens, signed, +; delegatable, narrowing-only, revocable). A federation-link-accept and a +; share-descriptor added no security property a signed capability token, +; scoped to the resource in question and handed to a bearer outside the +; issuer's own group, didn't already provide on its own: the token's own +; signature *is* the explicit, auditable, bilateral act of agreement that +; the link-negotiation frames were redundantly trying to represent again. +; +; What is lost by removing the dedicated frames, named rather than left +; implicit: the coarse, wire-visible "here is what's declared to cross this +; boundary" allowlist a share-descriptor gave you for free, ahead of and +; independent from per-bearer token verification. Without it, "what's +; allowed to cross" is answered purely by which tokens exist and verify — +; correct, but no longer inspectable as a standing, pre-declared fact on the +; wire. That trade was made deliberately: mesh-id and the gateway-device +; role it implied were themselves the more artificial restriction (ordinary +; capability-scope, per tokens.cddl, already expresses "a node", "a group", +; or what would colloquially be called "a mesh" as the same kind of thing; +; scoping the sharing mechanism to whole meshes only was never load-bearing +; for any property federation existed to provide). +; +; Cross-scope sharing today: a resource owner mints a capability token +; scoped to whatever `core/*` resource (or, per discovery.cddl, a +; `handle-registry` entry) it wants to expose, and hands it to a bearer +; outside its own group by whatever channel already gets a token to a +; bearer — the same mechanism used for every other capability grant in this +; schema, nothing federation-specific about it. ; The top-level Frame choice. This is the schema functioning as its own type ; registry: a frame's kind is expressed by CDDL's own tagged-union mechanism, ; not a separate external [type][body] byte header the way XDR-based @@ -210,6 +266,7 @@ $frame-variant /= relay-offer-frame $frame-variant /= relay-connect-frame $frame-variant /= relay-data-frame $frame-variant /= relay-inbound-frame +$frame-variant /= coordinator-frame $frame-variant /= manage-request-frame $frame-variant /= manage-response-frame $frame-variant /= revocation-announce-frame @@ -219,12 +276,6 @@ $frame-variant /= stream-end-frame $frame-variant /= data-have-frame $frame-variant /= data-request-frame $frame-variant /= data-entries-frame -$frame-variant /= federation-link-request-frame -$frame-variant /= federation-link-accept-frame -$frame-variant /= federation-link-reject-frame -$frame-variant /= federation-share-frame -$frame-variant /= federation-unshare-frame -$frame-variant /= federation-envelope-frame frame = $frame-variant ; Handshake and the domain registry. @@ -245,6 +296,9 @@ domain-id = core-domain-name / namespaced-domain-id / private-use-domain-id ; Spec-owned, append-only — see registry/core-domains.md. A retired entry is ; marked retired there, never renumbered or reused for something else. +; "core/federation" is retired: no frames are defined for it (see +; federation.cddl) and a peer must never advertise or negotiate it; the +; string itself stays reserved rather than becoming available for reuse. core-domain-name = "core/management" / "core/exec" / "core/data" / "core/federation" ; "/", where uniqueness comes from the registrant @@ -273,6 +327,21 @@ handshake-frame = { ; an identical key) was a bug found independently in both Cascade and ; agent-comms; this rule exists to make the correct derivation the only one ; representable. +; +; There is deliberately no key-rotation or recovery mechanism in this +; protocol: identity is bare public-key hashing with no CA, so losing a +; device's private key permanently orphans everything rooted at it -- no +; new tokens or revocations can ever be issued under that identity again, +; though already-signed tokens stay verifiable until their own expiry (see +; tokens.cddl). Adding recovery machinery to the wire protocol itself would +; be real scope and complexity this deliberately minimal identity layer +; doesn't take on. The mitigation that already exists with no protocol +; change needed: mint a scoped, long-lived capability token delegating +; recovery-relevant authority to a separate, offline-stored backup identity +; ahead of time, before it's ever needed -- ordinary delegation +; (tokens.cddl), used as an operational pattern rather than a protocol +; feature, the same way an offline root-CA backup key is operational +; practice, not something X.509 itself specifies. identity-key = { alg: int, ; COSE algorithm identifier (RFC 9053), e.g. -7 ES256, -8 EdDSA @@ -311,7 +380,32 @@ manage-response-frame = { type: "manage-response", request-id: uint, outcome: ma ; Revocation — new, not present in Cascade's frozen set. Gossiped revocation ; entries let a peer check a token against a shared revocation view without ; a synchronous lookup against the issuer for every use of the token. -revocation-entry = { token-id: bstr, revoked-at: uint } +; +; Self-certifying, the same COSE_Sign1 pattern as capability-token and +; handle-record, deliberately: a bare, unsigned {token-id, revoked-at} pair +; would let any peer falsely announce any other peer's valid token as +; revoked with no attribution at all, a real denial-of-service vector this +; closes rather than merely documents. issuer-key travels inside the signed +; payload for the same reason it does in token-claims — self-certifying, no +; prior contact with the issuer needed to verify the claim, only the claim +; itself. +; +; A verifier MUST check revocation-claims.issuer against the *token's own* +; issuer field, not merely that some signature verifies — only a token's +; own issuer may revoke it. A verifier walking a token's parent chain (see +; tokens.cddl's `parent` field) MUST check every ancestor's own token-id +; against the revocation view, not only the leaf token's: revoking one +; ancestor thereby revokes every token delegated beneath it, without +; needing to individually re-revoke each descendant. Both are verifier +; obligations, not something CDDL itself can enforce, the same way +; delegation's narrowing rule is. +revocation-claims = { + token-id: bstr, + issuer: device-id, + issuer-key: identity-key, ; verifier checks sha256(issuer-key.public-key) == issuer + revoked-at: uint, +} +revocation-entry = cose-sign1 ; payload = bstr .cbor revocation-claims revocation-announce-frame = { type: "revocation-announce", entries: [* revocation-entry] } ; The generic streaming/backpressure pattern — reusable by any capability ; domain that carries a live byte stream (core/exec's stdio today; a future @@ -368,10 +462,17 @@ namespaced-capability = tstr .regexp "[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-] private-use-capability = tstr .regexp "x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+" ; A hierarchical scope string. "kind" is open on purpose: "node" and "folder" -; are Cascade's own scopes; "room" and "org" are agent-comms'; "handle-registry" -; is discovery.cddl's federation-scoped handle-resolution alternative to DNS; -; a future application mints its own kind rather than needing this schema to -; change. +; are Cascade's own scopes; "room" and "org" are agent-comms'; "group" is a +; person's, team's, or organisation's own set of owned devices — ownership is +; not a separate wire concept, it is capability-token delegation targeting +; this one more scope kind, the same mechanism as every other grant. What +; would colloquially be called "a mesh" is not a distinct kind either: it is +; simply the largest, root-level "group" a set of devices happens to share — +; there is no wire-level membership list or identifier for it (see README's +; discussion of why mesh membership is an emergent property of live +; connectivity, not an asserted one). "handle-registry" is discovery.cddl's +; token-gated handle-resolution alternative to DNS. A future application +; mints its own kind rather than needing this schema to change. capability-scope = { kind: tstr, ? path: tstr, ; absent means the kind's own whole-scope root @@ -415,7 +516,7 @@ token-claims = { scope: capability-scope, expires: uint, ; Unix ms ? not-before: uint, - ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain + ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain. A verifier walking this chain MUST also check every ancestor's own token-id against the revocation view (management.cddl's revocation-entry) -- revoking one ancestor revokes everything delegated beneath it, not just a leaf's own token-id. * tstr => any, ; forward-compatible extension claims } @@ -462,8 +563,29 @@ observed-address-frame = { type: "observed-address", address: tstr } ; traffic as an opaque, unreadable byte pipe. The relay never holds the keys ; to decrypt what it forwards — relay-data-frame's payload is ciphertext ; established one layer above the transport, not something this frame -; interprets. +; interprets. Who is allowed to use a given relay as a service is +; deliberately not part of this shape: relay-connect-frame carries no +; capability-token field, so an operator that wants to gate relay access +; (rather than run an open relay) enforces it the same way any other +; behaviour is gated in this schema — an ordinary core/management verb +; checked before honouring subsequent relay frames on that connection, not a +; constraint this frame's own fields express. relay-offer-frame = { type: "relay-offer", addresses: [* tstr] } relay-connect-frame = { type: "relay-connect", target-device: device-id } relay-data-frame = { type: "relay-data", payload: bstr } relay-inbound-frame = { type: "relay-inbound", source-device: device-id } + +; Coordinator election — a lightweight, gossiped claim to the introduction/ +; rendezvous role among whichever peers are presently reachable (there is no +; membership list to scope this to: "the mesh" is the live connected +; component of the transport graph, nothing more, so this frame is simply +; gossiped the same way peer-advert already is, and reaches exactly the +; peers that are reachable). `term` is a monotonically increasing epoch a +; peer raises when claiming the role; a higher term always supersedes a +; lower one, and two simultaneous claims at equal terms are broken by lowest +; device-id — a verifier/receiver obligation, not something CDDL itself can +; enforce, the same way capability-token delegation's narrowing rule is. +; This gives the rendezvous role the same crash-recovery property a +; port-race coordinator already has (any live peer can take over) without +; relying on OS-level port contention to do the electing. +coordinator-frame = { type: "coordinator", term: uint, coordinator: device-id, ? capacity-hint: uint } diff --git a/spec/registry/core-domains.md b/spec/registry/core-domains.md index f9ef5c5..ca730c2 100644 --- a/spec/registry/core-domains.md +++ b/spec/registry/core-domains.md @@ -7,6 +7,6 @@ Append-only. A `core/*` domain name is spec-owned and defined in `handshake.cddl | `core/management` | `management.cddl` | active | | `core/exec` | `exec.cddl` | active | | `core/data` | `data-domain.cddl` | active | -| `core/federation` | `federation.cddl` | active | +| `core/federation` | `federation.cddl` | retired — no frames defined; cross-scope sharing now uses ordinary capability tokens (`tokens.cddl`) and discovery (`discovery.cddl`) instead | Third parties do not add entries here — see `namespaced-domain-id` in `handshake.cddl` for the registrant-owned namespace anyone else uses instead. diff --git a/spec/tokens.cddl b/spec/tokens.cddl index e5b8f2e..96bcfae 100644 --- a/spec/tokens.cddl +++ b/spec/tokens.cddl @@ -19,10 +19,17 @@ namespaced-capability = tstr .regexp "[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-] private-use-capability = tstr .regexp "x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+" ; A hierarchical scope string. "kind" is open on purpose: "node" and "folder" -; are Cascade's own scopes; "room" and "org" are agent-comms'; "handle-registry" -; is discovery.cddl's federation-scoped handle-resolution alternative to DNS; -; a future application mints its own kind rather than needing this schema to -; change. +; are Cascade's own scopes; "room" and "org" are agent-comms'; "group" is a +; person's, team's, or organisation's own set of owned devices — ownership is +; not a separate wire concept, it is capability-token delegation targeting +; this one more scope kind, the same mechanism as every other grant. What +; would colloquially be called "a mesh" is not a distinct kind either: it is +; simply the largest, root-level "group" a set of devices happens to share — +; there is no wire-level membership list or identifier for it (see README's +; discussion of why mesh membership is an emergent property of live +; connectivity, not an asserted one). "handle-registry" is discovery.cddl's +; token-gated handle-resolution alternative to DNS. A future application +; mints its own kind rather than needing this schema to change. capability-scope = { kind: tstr, ? path: tstr, ; absent means the kind's own whole-scope root @@ -66,7 +73,7 @@ token-claims = { scope: capability-scope, expires: uint, ; Unix ms ? not-before: uint, - ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain + ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain. A verifier walking this chain MUST also check every ancestor's own token-id against the revocation view (management.cddl's revocation-entry) -- revoking one ancestor revokes everything delegated beneath it, not just a leaf's own token-id. * tstr => any, ; forward-compatible extension claims } diff --git a/spec/transport.cddl b/spec/transport.cddl index cce35f7..c4dcc23 100644 --- a/spec/transport.cddl +++ b/spec/transport.cddl @@ -32,8 +32,29 @@ observed-address-frame = { type: "observed-address", address: tstr } ; traffic as an opaque, unreadable byte pipe. The relay never holds the keys ; to decrypt what it forwards — relay-data-frame's payload is ciphertext ; established one layer above the transport, not something this frame -; interprets. +; interprets. Who is allowed to use a given relay as a service is +; deliberately not part of this shape: relay-connect-frame carries no +; capability-token field, so an operator that wants to gate relay access +; (rather than run an open relay) enforces it the same way any other +; behaviour is gated in this schema — an ordinary core/management verb +; checked before honouring subsequent relay frames on that connection, not a +; constraint this frame's own fields express. relay-offer-frame = { type: "relay-offer", addresses: [* tstr] } relay-connect-frame = { type: "relay-connect", target-device: device-id } relay-data-frame = { type: "relay-data", payload: bstr } relay-inbound-frame = { type: "relay-inbound", source-device: device-id } + +; Coordinator election — a lightweight, gossiped claim to the introduction/ +; rendezvous role among whichever peers are presently reachable (there is no +; membership list to scope this to: "the mesh" is the live connected +; component of the transport graph, nothing more, so this frame is simply +; gossiped the same way peer-advert already is, and reaches exactly the +; peers that are reachable). `term` is a monotonically increasing epoch a +; peer raises when claiming the role; a higher term always supersedes a +; lower one, and two simultaneous claims at equal terms are broken by lowest +; device-id — a verifier/receiver obligation, not something CDDL itself can +; enforce, the same way capability-token delegation's narrowing rule is. +; This gives the rendezvous role the same crash-recovery property a +; port-race coordinator already has (any live peer can take over) without +; relying on OS-level port contention to do the electing. +coordinator-frame = { type: "coordinator", term: uint, coordinator: device-id, ? capacity-hint: uint }