|
| 1 | +-- SPDX-License-Identifier: MPL-2.0 |
| 2 | +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +||| LocalCoord.Identity: Cryptographic peer identity for the local-coord |
| 4 | +||| cartridge. |
| 5 | +||| |
| 6 | +||| Cartridge: local-coord-mcp |
| 7 | +||| ADR: 0016 (mTLS + ed25519 federation stop-gap), Phase 1 — identity |
| 8 | +||| foundation. No transport here; this module defines the *types* and |
| 9 | +||| *FFI signatures* for an ed25519 keypair per peer, the public key as |
| 10 | +||| federated identity, and the known_peers.toml entry shape. The Zig |
| 11 | +||| implementation (cartridges/local-coord-mcp/adapter/) realises these |
| 12 | +||| signatures with std.crypto.sign.Ed25519. |
| 13 | +||| |
| 14 | +||| Phase-1 scope (deliberate non-promises): |
| 15 | +||| * Generates / loads ed25519 keypair material on disk. |
| 16 | +||| * Exposes the public key for human export. |
| 17 | +||| * Parses known_peers.toml. |
| 18 | +||| * Does NOT sign or verify anything — Phase 2. |
| 19 | +||| * Does NOT bind to any non-loopback address — Phase 3. |
| 20 | +||| * Does NOT change the `FederationPolicy = LocalOnly` invariant from |
| 21 | +||| SafeLocalCoord.idr. Identity material is necessary-but-not- |
| 22 | +||| sufficient for federation; carrying a keypair does not enable it. |
| 23 | +module LocalCoord.Identity |
| 24 | + |
| 25 | +import Data.List |
| 26 | +import Data.Vect |
| 27 | +import Data.Nat |
| 28 | + |
| 29 | +import LocalCoord.SafeLocalCoord |
| 30 | + |
| 31 | +%default total |
| 32 | + |
| 33 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 34 | +-- Key Material — Size-Indexed Byte Vectors |
| 35 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 36 | + |
| 37 | +||| ed25519 public-key length in bytes. RFC 8032 §5.1.5. |
| 38 | +public export |
| 39 | +ed25519PubKeyBytes : Nat |
| 40 | +ed25519PubKeyBytes = 32 |
| 41 | + |
| 42 | +||| ed25519 private-key length in bytes (seed form). RFC 8032 §5.1.5. |
| 43 | +||| The expanded secret-scalar form is 64 bytes; we store the 32-byte |
| 44 | +||| seed and derive the scalar at sign time. |
| 45 | +public export |
| 46 | +ed25519PrivKeyBytes : Nat |
| 47 | +ed25519PrivKeyBytes = 32 |
| 48 | + |
| 49 | +||| ed25519 signature length in bytes (R || S). RFC 8032 §5.1.6. |
| 50 | +public export |
| 51 | +ed25519SigBytes : Nat |
| 52 | +ed25519SigBytes = 64 |
| 53 | + |
| 54 | +||| A fixed-width byte vector. Used to enforce key-material sizes at |
| 55 | +||| the type level — the only way to construct an `Ed25519PublicKey` |
| 56 | +||| is via a value of `Bytes 32`, so any code holding one *knows* it |
| 57 | +||| has 32 bytes without runtime checks. |
| 58 | +public export |
| 59 | +Bytes : Nat -> Type |
| 60 | +Bytes n = Vect n Bits8 |
| 61 | + |
| 62 | +||| An ed25519 public key. Wrapper around `Bytes 32` so the type system |
| 63 | +||| distinguishes pubkeys from arbitrary 32-byte blobs. |
| 64 | +public export |
| 65 | +record Ed25519PublicKey where |
| 66 | + constructor MkPubKey |
| 67 | + bytes : Bytes ed25519PubKeyBytes |
| 68 | + |
| 69 | +||| An ed25519 private key (seed form). NEVER crosses the FFI boundary |
| 70 | +||| as a payload — the Zig adapter holds it in process memory and |
| 71 | +||| references it via opaque handle. This type exists in the ABI only |
| 72 | +||| to document the contract. |
| 73 | +public export |
| 74 | +record Ed25519PrivateKey where |
| 75 | + constructor MkPrivKey |
| 76 | + bytes : Bytes ed25519PrivKeyBytes |
| 77 | + |
| 78 | +||| An ed25519 signature over arbitrary bytes. |
| 79 | +public export |
| 80 | +record Ed25519Signature where |
| 81 | + constructor MkSig |
| 82 | + bytes : Bytes ed25519SigBytes |
| 83 | + |
| 84 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 85 | +-- Proof Obligation P-20: Ed25519 Key Material Well-Formedness |
| 86 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 87 | +-- |
| 88 | +-- A public key value carries exactly `ed25519PubKeyBytes` bytes; a |
| 89 | +-- signature value carries exactly `ed25519SigBytes`. This is enforced |
| 90 | +-- *by construction* via `Vect n` — the only inhabitants of |
| 91 | +-- `Bytes ed25519PubKeyBytes` are length-32 byte vectors, so any code |
| 92 | +-- receiving an `Ed25519PublicKey` is statically guaranteed it has the |
| 93 | +-- right length. No runtime size check is needed; no |
| 94 | +-- malformed-key branch can exist in the protocol layer. |
| 95 | +-- |
| 96 | +-- The "proof" is the type itself: pattern-matching `MkPubKey bs` |
| 97 | +-- recovers a `bs : Vect 32 Bits8`, whose index is fixed at the |
| 98 | +-- definition site and cannot be shrunk or grown. |
| 99 | +-- |
| 100 | +-- Demonstration: a concrete zero-keyed pubkey is built-in-shape. |
| 101 | + |
| 102 | +||| Demonstration witness — a concrete pubkey value built from a |
| 103 | +||| size-32 literal compiles iff the type's size invariant holds. |
| 104 | +||| If this line fails to compile, P-20 has been broken. |
| 105 | +public export |
| 106 | +zeroPubKey : Ed25519PublicKey |
| 107 | +zeroPubKey = MkPubKey (replicate ed25519PubKeyBytes 0) |
| 108 | + |
| 109 | +||| Same demonstration for signatures. |
| 110 | +public export |
| 111 | +zeroSig : Ed25519Signature |
| 112 | +zeroSig = MkSig (replicate ed25519SigBytes 0) |
| 113 | + |
| 114 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 115 | +-- Peer Identity = PeerId + Public Key |
| 116 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 117 | + |
| 118 | +||| A peer's complete identity: the human-readable `PeerId` (from |
| 119 | +||| SafeLocalCoord) plus the ed25519 public key that vouches for it. |
| 120 | +||| The PeerId is for humans; the pubkey is for crypto. |
| 121 | +public export |
| 122 | +record PeerIdentity where |
| 123 | + constructor MkPeerIdentity |
| 124 | + peerId : PeerId |
| 125 | + pubKey : Ed25519PublicKey |
| 126 | + |
| 127 | +||| Extract the display form of a peer identity. Goes through the |
| 128 | +||| existing PeerId display — the pubkey is intentionally not rendered |
| 129 | +||| here (use `pubKeyHex` for that, in a UI context). |
| 130 | +public export |
| 131 | +identityToString : PeerIdentity -> String |
| 132 | +identityToString pi = peerIdToString (peerId pi) |
| 133 | + |
| 134 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 135 | +-- Known Peers (known_peers.toml entries) — Trust List |
| 136 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 137 | + |
| 138 | +||| An entry in `~/.config/coord-tui/known_peers.toml`. Manual trust |
| 139 | +||| list: peers we have explicitly agreed to federate with. No |
| 140 | +||| discovery, no CA hierarchy — SSH known_hosts model. |
| 141 | +||| |
| 142 | +||| The `host` and `port` fields are Phase-3 wire material; Phase 1 |
| 143 | +||| parses them but does not connect to them. |
| 144 | +public export |
| 145 | +record KnownPeer where |
| 146 | + constructor MkKnownPeer |
| 147 | + peerId : PeerId |
| 148 | + pubKey : Ed25519PublicKey |
| 149 | + host : String -- DNS name or literal IP — validated at use-site |
| 150 | + port : Nat |
| 151 | + |
| 152 | +||| The maximum number of known peers a single host may trust. |
| 153 | +||| Bound is generous; it exists to make `Vect`-based loading easy. |
| 154 | +public export |
| 155 | +maxKnownPeers : Nat |
| 156 | +maxKnownPeers = 64 |
| 157 | + |
| 158 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 159 | +-- Federation Invariant Preservation |
| 160 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 161 | +-- |
| 162 | +-- Crucially: this module adds *types*. It does not add a path from |
| 163 | +-- those types to any non-loopback bind. The `FederationPolicy` value |
| 164 | +-- in SafeLocalCoord remains `LocalOnly`, and `IsFederated LocalOnly` |
| 165 | +-- remains uninhabited. Phase 3 will widen this — Phase 1 must not. |
| 166 | + |
| 167 | +||| Proof that having a PeerIdentity does not unlock federation. The |
| 168 | +||| federation policy is still `LocalOnly`, and the negative proof |
| 169 | +||| from `SafeLocalCoord.localOnlyNotFederated` carries through. |
| 170 | +||| Discharge is structural: the LHS doesn't influence the RHS at all. |
| 171 | +export |
| 172 | +identityDoesNotEnableFederation |
| 173 | + : (_ : PeerIdentity) |
| 174 | + -> IsFederated coordFederationPolicy |
| 175 | + -> Void |
| 176 | +identityDoesNotEnableFederation _ x = localOnlyNotFederated x |
| 177 | + |
| 178 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 179 | +-- C-ABI Contract (Phase 1) — documentation, not %foreign import |
| 180 | +-- ═══════════════════════════════════════════════════════════════════════════ |
| 181 | +-- |
| 182 | +-- The Zig adapter exposes the following entry points. They are NOT |
| 183 | +-- imported via `%foreign` here because the Idris2 ABI module's job is |
| 184 | +-- to *type* the contract; the actual calls are made from Zig (intra- |
| 185 | +-- cartridge) and from the Deno/Node bridge (over HTTP). This mirrors |
| 186 | +-- the convention used in `SafeLocalCoord.idr`, which defines the type |
| 187 | +-- envelope but doesn't import the Zig functions. |
| 188 | +-- |
| 189 | +-- int boj_coord_identity_init(const char *key_path); |
| 190 | +-- Generates a fresh keypair if none exists at `key_path`, otherwise |
| 191 | +-- loads the existing seed. Persists the seed (0600) on disk. Phase |
| 192 | +-- 1 keys live at ~/.cache/coord-tui/peer.key. |
| 193 | +-- Returns: 0 on success, non-zero error code otherwise. |
| 194 | +-- |
| 195 | +-- int boj_coord_identity_get_pubkey(uint8_t *out, size_t out_len); |
| 196 | +-- Copies `ed25519PubKeyBytes` (32) bytes of the local public key |
| 197 | +-- into `out`. The corresponding `Ed25519PublicKey` value can be |
| 198 | +-- reconstructed from the bytes on the consumer side. |
| 199 | +-- Returns: bytes written (== 32) on success, -1 if not initialised |
| 200 | +-- or buffer too small. |
| 201 | +-- |
| 202 | +-- int boj_coord_identity_load_known_peers(const char *toml_path); |
| 203 | +-- Parses `~/.config/coord-tui/known_peers.toml` (or supplied path) |
| 204 | +-- into an in-process trust table of `KnownPeer` entries. Replaces |
| 205 | +-- any previously loaded set (full reload). |
| 206 | +-- Returns: number of entries loaded (>= 0) on success, -1 on error |
| 207 | +-- (missing file is treated as zero entries, not an error). |
| 208 | +-- |
| 209 | +-- int boj_coord_identity_known_peer_count(void); |
| 210 | +-- Returns the current count of loaded known peers. |
| 211 | +-- |
| 212 | +-- Phase 2 will add `boj_coord_envelope_sign` / `boj_coord_envelope_verify` |
| 213 | +-- once `LocalCoord.Federation` lands the P-22 obligation. |
0 commit comments