@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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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
|
|
216
|
+
var identity2 = loadOrCreateIdentity();
|
|
197
217
|
sendTo(clientId, {
|
|
198
218
|
type: "mesh:hello" as any,
|
|
199
|
-
nodeId:
|
|
219
|
+
nodeId: identity2.id,
|
|
200
220
|
name: loadConfig().name,
|
|
221
|
+
publicKey: identity2.publicKey,
|
|
201
222
|
projects: [],
|
|
202
223
|
});
|
|
203
224
|
|
package/server/src/identity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|