@cryptiklemur/lattice 1.32.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, memo } from "react";
2
- import { Plus, CircleDot, Circle, RefreshCw } from "lucide-react";
2
+ import { Plus, CircleDot, Circle, RefreshCw, Loader2 } from "lucide-react";
3
3
  import { useWebSocket } from "../../hooks/useWebSocket";
4
4
  import { useMesh } from "../../hooks/useMesh";
5
5
  import { PairingDialog } from "../mesh/PairingDialog";
@@ -13,6 +13,7 @@ interface NodeRowProps {
13
13
 
14
14
  function NodeRow(props: NodeRowProps) {
15
15
  var [confirming, setConfirming] = useState(false);
16
+ var [reconnecting, setReconnecting] = useState(false);
16
17
 
17
18
  function handleUnpair() {
18
19
  if (!confirming) {
@@ -58,14 +59,25 @@ function NodeRow(props: NodeRowProps) {
58
59
  {!props.node.isLocal && (
59
60
  <div className="flex gap-1.5 flex-shrink-0">
60
61
  {!props.node.online && (
61
- <button
62
- onClick={function () { props.onReconnect(props.node.id); }}
63
- className="btn btn-ghost btn-xs border border-base-content/20 hover:btn-info hover:border-info gap-1"
64
- title="Attempt to reconnect"
65
- >
66
- <RefreshCw size={10} />
67
- Reconnect
68
- </button>
62
+ reconnecting ? (
63
+ <span className="flex items-center gap-1 text-[11px] text-base-content/40">
64
+ <Loader2 size={10} className="animate-spin" />
65
+ Connecting...
66
+ </span>
67
+ ) : (
68
+ <button
69
+ onClick={function () {
70
+ setReconnecting(true);
71
+ props.onReconnect(props.node.id);
72
+ setTimeout(function () { setReconnecting(false); }, 5000);
73
+ }}
74
+ className="btn btn-ghost btn-xs border border-base-content/20 hover:btn-info hover:border-info gap-1"
75
+ title="Attempt to reconnect"
76
+ >
77
+ <RefreshCw size={10} />
78
+ Reconnect
79
+ </button>
80
+ )
69
81
  )}
70
82
  {confirming ? (
71
83
  <>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -4,7 +4,7 @@ import { sendTo, broadcast } from "../ws/broadcast";
4
4
  import { loadConfig } from "../config";
5
5
  import { loadOrCreateIdentity } from "../identity";
6
6
  import { generateInviteCode, parseInviteCode, validatePairingToken, consumePairingToken } from "../mesh/pairing";
7
- import { addPeer, removePeer, loadPeers } from "../mesh/peers";
7
+ import { addPeer, removePeer, loadPeers, getPeer } from "../mesh/peers";
8
8
  import { getConnectedPeerIds, connectToPeer, reconnectPeer } from "../mesh/connector";
9
9
  import type { PeerInfo } from "@lattice/shared";
10
10
  import { networkInterfaces } from "node:os";
@@ -111,6 +111,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
111
111
  type: "mesh:hello",
112
112
  nodeId: identity.id,
113
113
  name: pairConfig.name,
114
+ publicKey: identity.publicKey,
114
115
  token: parsed!.token,
115
116
  port: pairConfig.port,
116
117
  addresses: getAllAddresses().map(function (a) { return a.address + ":" + pairConfig.port; }),
@@ -120,7 +121,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
120
121
 
121
122
  pairWs.addEventListener("message", function (event: MessageEvent) {
122
123
  try {
123
- var data = JSON.parse(event.data as string) as { type: string; nodeId?: string; name?: string; error?: string };
124
+ var data = JSON.parse(event.data as string) as { type: string; nodeId?: string; name?: string; publicKey?: string; error?: string };
124
125
 
125
126
  if (data.type === "mesh:hello_rejected") {
126
127
  clearTimeout(pairTimeout);
@@ -136,7 +137,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
136
137
  id: data.nodeId,
137
138
  name: data.name,
138
139
  addresses: [peerAddr],
139
- publicKey: "",
140
+ publicKey: data.publicKey ?? "",
140
141
  pairedAt: Date.now(),
141
142
  };
142
143
  addPeer(peer);
@@ -170,7 +171,26 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
170
171
  }
171
172
 
172
173
  if ((message as any).type === "mesh:hello") {
173
- var hello = message as any as { type: "mesh:hello"; nodeId: string; name: string; token?: string; port?: number; addresses?: string[]; projects: Array<{ slug: string; title: string }> };
174
+ var hello = message as any as { type: "mesh:hello"; nodeId: string; name: string; publicKey?: string; token?: string; port?: number; addresses?: string[]; projects: Array<{ slug: string; title: string }> };
175
+
176
+ var knownPeer = hello.nodeId ? getPeer(hello.nodeId) : undefined;
177
+
178
+ if (knownPeer) {
179
+ if (knownPeer.publicKey && hello.publicKey && knownPeer.publicKey !== hello.publicKey) {
180
+ sendTo(clientId, { type: "mesh:hello_rejected" as any, error: "Public key mismatch — possible impersonation" });
181
+ return;
182
+ }
183
+ var identity = loadOrCreateIdentity();
184
+ sendTo(clientId, {
185
+ type: "mesh:hello" as any,
186
+ nodeId: identity.id,
187
+ name: loadConfig().name,
188
+ publicKey: identity.publicKey,
189
+ projects: [],
190
+ });
191
+ broadcast({ type: "mesh:nodes", nodes: buildNodesMessage() });
192
+ return;
193
+ }
174
194
 
175
195
  if (!hello.token || !validatePairingToken(hello.token)) {
176
196
  sendTo(clientId, { type: "mesh:hello_rejected" as any, error: "Invalid or expired invite code" });
@@ -184,7 +204,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
184
204
  id: hello.nodeId,
185
205
  name: hello.name,
186
206
  addresses: peerAddresses,
187
- publicKey: "",
207
+ publicKey: hello.publicKey ?? "",
188
208
  pairedAt: Date.now(),
189
209
  };
190
210
  addPeer(peer);
@@ -193,11 +213,12 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
193
213
  connectToPeer(peer.id, peerAddresses[0]);
194
214
  }
195
215
 
196
- var identity = loadOrCreateIdentity();
216
+ var identity2 = loadOrCreateIdentity();
197
217
  sendTo(clientId, {
198
218
  type: "mesh:hello" as any,
199
- nodeId: identity.id,
219
+ nodeId: identity2.id,
200
220
  name: loadConfig().name,
221
+ publicKey: identity2.publicKey,
201
222
  projects: [],
202
223
  });
203
224
 
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { randomUUID } from "node:crypto";
3
+ import { randomUUID, generateKeyPairSync } from "node:crypto";
4
4
  import { getLatticeHome } from "./config";
5
5
 
6
6
  interface NodeIdentity {
7
7
  id: string;
8
+ publicKey: string;
9
+ privateKey: string;
8
10
  createdAt: number;
9
11
  }
10
12
 
@@ -15,12 +17,38 @@ export function getIdentityPath(): string {
15
17
  export function loadOrCreateIdentity(): NodeIdentity {
16
18
  var path = getIdentityPath();
17
19
  if (existsSync(path)) {
18
- return JSON.parse(readFileSync(path, "utf-8")) as NodeIdentity;
20
+ var stored = JSON.parse(readFileSync(path, "utf-8")) as NodeIdentity;
21
+ if (stored.publicKey && stored.privateKey) {
22
+ return stored;
23
+ }
24
+ var keys = generateEd25519Keypair();
25
+ stored.publicKey = keys.publicKey;
26
+ stored.privateKey = keys.privateKey;
27
+ writeFileSync(path, JSON.stringify(stored, null, 2), "utf-8");
28
+ return stored;
19
29
  }
30
+ var keys = generateEd25519Keypair();
20
31
  var identity: NodeIdentity = {
21
32
  id: randomUUID(),
33
+ publicKey: keys.publicKey,
34
+ privateKey: keys.privateKey,
22
35
  createdAt: Date.now(),
23
36
  };
24
37
  writeFileSync(path, JSON.stringify(identity, null, 2), "utf-8");
25
38
  return identity;
26
39
  }
40
+
41
+ function generateEd25519Keypair(): { publicKey: string; privateKey: string } {
42
+ var pair = generateKeyPairSync("ed25519", {
43
+ publicKeyEncoding: { type: "spki", format: "der" },
44
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
45
+ });
46
+ return {
47
+ publicKey: Buffer.from(pair.publicKey).toString("base64"),
48
+ privateKey: Buffer.from(pair.privateKey).toString("base64"),
49
+ };
50
+ }
51
+
52
+ export function getPublicKey(): string {
53
+ return loadOrCreateIdentity().publicKey;
54
+ }
@@ -138,10 +138,11 @@ function openConnection(conn: PeerConnection, url: string): void {
138
138
  var config = loadConfig();
139
139
  var projects = listProjects(identity.id);
140
140
 
141
- var hello: MeshHelloMessage = {
141
+ var hello: MeshHelloMessage & { publicKey?: string } = {
142
142
  type: "mesh:hello",
143
143
  nodeId: identity.id,
144
144
  name: config.name,
145
+ publicKey: identity.publicKey,
145
146
  projects: projects.map(function (p) {
146
147
  return { slug: p.slug, title: p.title };
147
148
  }),
@@ -0,0 +1,106 @@
1
+ import { createSign, createVerify, randomBytes, createCipheriv, createDecipheriv, createHash, diffieHellman, generateKeyPairSync, createPublicKey, createPrivateKey } from "node:crypto";
2
+
3
+ export function sign(privateKeyBase64: string, data: Buffer): string {
4
+ var privateKeyDer = Buffer.from(privateKeyBase64, "base64");
5
+ var key = createPrivateKey({ key: privateKeyDer, format: "der", type: "pkcs8" });
6
+ var signer = createSign("ed25519");
7
+ signer.update(data);
8
+ return signer.sign(key).toString("base64");
9
+ }
10
+
11
+ export function verify(publicKeyBase64: string, data: Buffer, signatureBase64: string): boolean {
12
+ try {
13
+ var publicKeyDer = Buffer.from(publicKeyBase64, "base64");
14
+ var key = createPublicKey({ key: publicKeyDer, format: "der", type: "spki" });
15
+ var verifier = createVerify("ed25519");
16
+ verifier.update(data);
17
+ return verifier.verify(key, Buffer.from(signatureBase64, "base64"));
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export function generateChallenge(): string {
24
+ return randomBytes(32).toString("base64");
25
+ }
26
+
27
+ export interface EphemeralKeys {
28
+ publicKey: string;
29
+ privateKey: Buffer;
30
+ }
31
+
32
+ export function generateEphemeralKeys(): EphemeralKeys {
33
+ var pair = generateKeyPairSync("x25519", {
34
+ publicKeyEncoding: { type: "spki", format: "der" },
35
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
36
+ });
37
+ return {
38
+ publicKey: Buffer.from(pair.publicKey).toString("base64"),
39
+ privateKey: Buffer.from(pair.privateKey),
40
+ };
41
+ }
42
+
43
+ export function deriveSharedSecret(myPrivateKeyDer: Buffer, theirPublicKeyBase64: string, nodeIdA: string, nodeIdB: string): Buffer {
44
+ var myKey = createPrivateKey({ key: myPrivateKeyDer, format: "der", type: "pkcs8" });
45
+ var theirKey = createPublicKey({ key: Buffer.from(theirPublicKeyBase64, "base64"), format: "der", type: "spki" });
46
+
47
+ var shared = diffieHellman({ privateKey: myKey, publicKey: theirKey });
48
+
49
+ var sortedIds = [nodeIdA, nodeIdB].sort().join(":");
50
+ var hash = createHash("sha256");
51
+ hash.update(shared);
52
+ hash.update(Buffer.from("lattice-mesh-v1:" + sortedIds));
53
+ return hash.digest();
54
+ }
55
+
56
+ export function encrypt(key: Buffer, nonce: number, plaintext: string): { ciphertext: string; tag: string } {
57
+ var iv = Buffer.alloc(12);
58
+ iv.writeBigUInt64BE(BigInt(nonce), 4);
59
+ var cipher = createCipheriv("aes-256-gcm", key, iv);
60
+ var encrypted = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
61
+ return {
62
+ ciphertext: encrypted.toString("base64"),
63
+ tag: cipher.getAuthTag().toString("base64"),
64
+ };
65
+ }
66
+
67
+ export function decrypt(key: Buffer, nonce: number, ciphertext: string, tag: string): string | null {
68
+ try {
69
+ var iv = Buffer.alloc(12);
70
+ iv.writeBigUInt64BE(BigInt(nonce), 4);
71
+ var decipher = createDecipheriv("aes-256-gcm", key, iv);
72
+ decipher.setAuthTag(Buffer.from(tag, "base64"));
73
+ var decrypted = Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]);
74
+ return decrypted.toString("utf-8");
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export interface SecureSession {
81
+ sharedKey: Buffer;
82
+ sendNonce: number;
83
+ recvNonce: number;
84
+ }
85
+
86
+ export function createSecureSession(sharedKey: Buffer): SecureSession {
87
+ return { sharedKey, sendNonce: 0, recvNonce: 0 };
88
+ }
89
+
90
+ export function encryptMessage(session: SecureSession, message: object): { type: "mesh:encrypted"; nonce: number; ciphertext: string; tag: string } {
91
+ session.sendNonce++;
92
+ var result = encrypt(session.sharedKey, session.sendNonce, JSON.stringify(message));
93
+ return { type: "mesh:encrypted", nonce: session.sendNonce, ...result };
94
+ }
95
+
96
+ export function decryptMessage(session: SecureSession, nonce: number, ciphertext: string, tag: string): object | null {
97
+ if (nonce <= session.recvNonce) return null;
98
+ var plaintext = decrypt(session.sharedKey, nonce, ciphertext, tag);
99
+ if (!plaintext) return null;
100
+ session.recvNonce = nonce;
101
+ try {
102
+ return JSON.parse(plaintext);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }