openfused 0.2.0 → 0.3.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.
- package/README.md +200 -17
- package/dist/cli.js +225 -42
- package/dist/crypto.d.ts +15 -1
- package/dist/crypto.js +81 -20
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +127 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +78 -0
- package/dist/store.d.ts +4 -0
- package/dist/store.js +64 -19
- package/dist/sync.d.ts +9 -0
- package/dist/sync.js +184 -0
- package/package.json +11 -5
- package/wearethecompute.md +22 -1
package/dist/crypto.js
CHANGED
|
@@ -1,55 +1,116 @@
|
|
|
1
|
-
import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey } from "node:crypto";
|
|
1
|
+
import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey, createHash } from "node:crypto";
|
|
2
2
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
+
import { Encrypter, Decrypter, generateIdentity, identityToRecipient } from "age-encryption";
|
|
5
6
|
const KEY_DIR = ".keys";
|
|
7
|
+
// --- Key generation ---
|
|
6
8
|
export async function generateKeys(storeRoot) {
|
|
7
9
|
const keyDir = join(storeRoot, KEY_DIR);
|
|
8
10
|
await mkdir(keyDir, { recursive: true });
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// Ed25519 signing keypair
|
|
12
|
+
const { publicKey: pubObj, privateKey: privObj } = generateKeyPairSync("ed25519");
|
|
13
|
+
const pubJwk = pubObj.export({ format: "jwk" });
|
|
14
|
+
const privJwk = privObj.export({ format: "jwk" });
|
|
15
|
+
const publicHex = Buffer.from(pubJwk.x, "base64url").toString("hex");
|
|
16
|
+
const privateHex = Buffer.from(privJwk.d, "base64url").toString("hex");
|
|
17
|
+
await writeFile(join(keyDir, "public.key"), publicHex, { mode: 0o644 });
|
|
18
|
+
await writeFile(join(keyDir, "private.key"), privateHex, { mode: 0o600 });
|
|
19
|
+
// age encryption keypair
|
|
20
|
+
const ageIdentity = await generateIdentity();
|
|
21
|
+
const ageRecipient = await identityToRecipient(ageIdentity);
|
|
22
|
+
await writeFile(join(keyDir, "age.key"), ageIdentity, { mode: 0o600 });
|
|
23
|
+
await writeFile(join(keyDir, "age.pub"), ageRecipient, { mode: 0o644 });
|
|
24
|
+
return { publicKey: publicHex, encryptionKey: ageRecipient };
|
|
16
25
|
}
|
|
17
26
|
export async function hasKeys(storeRoot) {
|
|
18
|
-
return existsSync(join(storeRoot, KEY_DIR, "private.
|
|
27
|
+
return existsSync(join(storeRoot, KEY_DIR, "private.key"));
|
|
19
28
|
}
|
|
29
|
+
// --- Fingerprint ---
|
|
30
|
+
export function fingerprint(publicKey) {
|
|
31
|
+
const hash = createHash("sha256").update(publicKey).digest();
|
|
32
|
+
const pairs = [];
|
|
33
|
+
for (let i = 0; i < 16; i++) {
|
|
34
|
+
pairs.push(hash[i].toString(16).toUpperCase().padStart(2, "0"));
|
|
35
|
+
}
|
|
36
|
+
const groups = [];
|
|
37
|
+
for (let i = 0; i < pairs.length; i += 2) {
|
|
38
|
+
groups.push(pairs[i] + pairs[i + 1]);
|
|
39
|
+
}
|
|
40
|
+
return groups.join(":");
|
|
41
|
+
}
|
|
42
|
+
// --- Signing ---
|
|
20
43
|
async function loadPrivateKey(storeRoot) {
|
|
21
|
-
const
|
|
22
|
-
|
|
44
|
+
const privHex = (await readFile(join(storeRoot, KEY_DIR, "private.key"), "utf-8")).trim();
|
|
45
|
+
const pubHex = (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
|
|
46
|
+
const d = Buffer.from(privHex, "hex").toString("base64url");
|
|
47
|
+
const x = Buffer.from(pubHex, "hex").toString("base64url");
|
|
48
|
+
return createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d, x }, format: "jwk" });
|
|
23
49
|
}
|
|
24
|
-
async function
|
|
25
|
-
return readFile(join(storeRoot, KEY_DIR, "public.
|
|
50
|
+
async function loadPublicKeyHex(storeRoot) {
|
|
51
|
+
return (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
|
|
52
|
+
}
|
|
53
|
+
export async function loadAgeRecipient(storeRoot) {
|
|
54
|
+
return (await readFile(join(storeRoot, KEY_DIR, "age.pub"), "utf-8")).trim();
|
|
55
|
+
}
|
|
56
|
+
async function loadAgeIdentity(storeRoot) {
|
|
57
|
+
return (await readFile(join(storeRoot, KEY_DIR, "age.key"), "utf-8")).trim();
|
|
26
58
|
}
|
|
27
59
|
export async function signMessage(storeRoot, from, message) {
|
|
28
60
|
const privateKey = await loadPrivateKey(storeRoot);
|
|
29
|
-
const publicKey = await
|
|
61
|
+
const publicKey = await loadPublicKeyHex(storeRoot);
|
|
30
62
|
const timestamp = new Date().toISOString();
|
|
31
63
|
const payload = Buffer.from(`${from}\n${timestamp}\n${message}`);
|
|
32
64
|
const signature = sign(null, payload, privateKey).toString("base64");
|
|
33
|
-
return { from, timestamp, message, signature, publicKey };
|
|
65
|
+
return { from, timestamp, message, signature, publicKey, encrypted: false };
|
|
66
|
+
}
|
|
67
|
+
export async function signAndEncrypt(storeRoot, from, plaintext, recipientAgeKey) {
|
|
68
|
+
const ciphertext = await ageEncrypt(plaintext, recipientAgeKey);
|
|
69
|
+
const encoded = Buffer.from(ciphertext).toString("base64");
|
|
70
|
+
const privateKey = await loadPrivateKey(storeRoot);
|
|
71
|
+
const publicKey = await loadPublicKeyHex(storeRoot);
|
|
72
|
+
const timestamp = new Date().toISOString();
|
|
73
|
+
const payload = Buffer.from(`${from}\n${timestamp}\n${encoded}`);
|
|
74
|
+
const signature = sign(null, payload, privateKey).toString("base64");
|
|
75
|
+
return { from, timestamp, message: encoded, signature, publicKey, encrypted: true };
|
|
34
76
|
}
|
|
35
77
|
export function verifyMessage(signed) {
|
|
36
78
|
try {
|
|
37
79
|
const payload = Buffer.from(`${signed.from}\n${signed.timestamp}\n${signed.message}`);
|
|
38
|
-
const
|
|
80
|
+
const x = Buffer.from(signed.publicKey.trim(), "hex").toString("base64url");
|
|
81
|
+
const pubKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
|
|
39
82
|
return verify(null, payload, pubKey, Buffer.from(signed.signature, "base64"));
|
|
40
83
|
}
|
|
41
84
|
catch {
|
|
42
85
|
return false;
|
|
43
86
|
}
|
|
44
87
|
}
|
|
45
|
-
|
|
88
|
+
export async function decryptMessage(storeRoot, signed) {
|
|
89
|
+
if (!signed.encrypted)
|
|
90
|
+
return signed.message;
|
|
91
|
+
const ciphertext = Buffer.from(signed.message, "base64");
|
|
92
|
+
return await ageDecrypt(ciphertext, storeRoot);
|
|
93
|
+
}
|
|
94
|
+
// --- age encryption ---
|
|
95
|
+
async function ageEncrypt(plaintext, recipientKey) {
|
|
96
|
+
const e = new Encrypter();
|
|
97
|
+
e.addRecipient(recipientKey);
|
|
98
|
+
return await e.encrypt(plaintext);
|
|
99
|
+
}
|
|
100
|
+
async function ageDecrypt(ciphertext, storeRoot) {
|
|
101
|
+
const identity = await loadAgeIdentity(storeRoot);
|
|
102
|
+
const d = new Decrypter();
|
|
103
|
+
d.addIdentity(identity);
|
|
104
|
+
return await d.decrypt(ciphertext, "text");
|
|
105
|
+
}
|
|
106
|
+
// --- Helpers ---
|
|
46
107
|
export function wrapExternalMessage(signed, verified) {
|
|
47
108
|
const status = verified ? "verified" : "UNVERIFIED";
|
|
48
|
-
|
|
49
|
-
${signed.
|
|
109
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
110
|
+
return `<external_message from="${esc(signed.from)}" verified="${verified}" time="${esc(signed.timestamp)}" status="${status}">
|
|
111
|
+
${esc(signed.message)}
|
|
50
112
|
</external_message>`;
|
|
51
113
|
}
|
|
52
|
-
// Format for writing to inbox files
|
|
53
114
|
export function serializeSignedMessage(signed) {
|
|
54
115
|
return JSON.stringify(signed, null, 2);
|
|
55
116
|
}
|
package/dist/mcp.d.ts
ADDED
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ContextStore } from "./store.js";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
/** Reject path traversal in filenames — extract basename, block dangerous patterns */
|
|
8
|
+
function sanitizeFilename(name) {
|
|
9
|
+
// Extract basename (strip any directory components)
|
|
10
|
+
const base = name.split("/").pop().split("\\").pop();
|
|
11
|
+
if (!base || base === "." || base === ".." || base.includes("..")) {
|
|
12
|
+
throw new Error(`Invalid filename: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
return base;
|
|
15
|
+
}
|
|
16
|
+
const storeDir = process.env.OPENFUSE_DIR || process.argv[3] || ".";
|
|
17
|
+
const store = new ContextStore(resolve(storeDir));
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: "openfuse",
|
|
20
|
+
version: "0.3.0",
|
|
21
|
+
});
|
|
22
|
+
// --- Context ---
|
|
23
|
+
server.tool("context_read", "Read the agent's CONTEXT.md (working memory)", async () => {
|
|
24
|
+
const content = await store.readContext();
|
|
25
|
+
return { content: [{ type: "text", text: content }] };
|
|
26
|
+
});
|
|
27
|
+
server.tool("context_write", "Replace CONTEXT.md contents", { text: z.string().describe("New content for CONTEXT.md") }, async ({ text }) => {
|
|
28
|
+
await store.writeContext(text);
|
|
29
|
+
return { content: [{ type: "text", text: "Context updated." }] };
|
|
30
|
+
});
|
|
31
|
+
server.tool("context_append", "Append text to CONTEXT.md", { text: z.string().describe("Text to append") }, async ({ text }) => {
|
|
32
|
+
const existing = await store.readContext();
|
|
33
|
+
await store.writeContext(existing + "\n" + text);
|
|
34
|
+
return { content: [{ type: "text", text: "Context appended." }] };
|
|
35
|
+
});
|
|
36
|
+
// --- Soul ---
|
|
37
|
+
server.tool("soul_read", "Read the agent's SOUL.md (identity & rules)", async () => {
|
|
38
|
+
const content = await store.readSoul();
|
|
39
|
+
return { content: [{ type: "text", text: content }] };
|
|
40
|
+
});
|
|
41
|
+
server.tool("soul_write", "Replace SOUL.md contents", { text: z.string().describe("New content for SOUL.md") }, async ({ text }) => {
|
|
42
|
+
await store.writeSoul(text);
|
|
43
|
+
return { content: [{ type: "text", text: "Soul updated." }] };
|
|
44
|
+
});
|
|
45
|
+
// --- Inbox ---
|
|
46
|
+
server.tool("inbox_list", "List all inbox messages with verification status", async () => {
|
|
47
|
+
const messages = await store.readInbox();
|
|
48
|
+
if (messages.length === 0) {
|
|
49
|
+
return { content: [{ type: "text", text: "Inbox is empty." }] };
|
|
50
|
+
}
|
|
51
|
+
const lines = messages.map((m) => {
|
|
52
|
+
const badge = m.verified ? "[VERIFIED]" : "[UNVERIFIED]";
|
|
53
|
+
return `${badge} From: ${m.from} | ${m.time}\n${m.wrappedContent}`;
|
|
54
|
+
});
|
|
55
|
+
return { content: [{ type: "text", text: lines.join("\n\n---\n\n") }] };
|
|
56
|
+
});
|
|
57
|
+
server.tool("inbox_send", "Send a signed message to a peer's inbox (auto-encrypts if age key on file)", {
|
|
58
|
+
peer_id: z.string().describe("Peer name or ID"),
|
|
59
|
+
message: z.string().describe("Message content"),
|
|
60
|
+
}, async ({ peer_id, message }) => {
|
|
61
|
+
await store.sendInbox(peer_id, message);
|
|
62
|
+
return { content: [{ type: "text", text: `Message sent to ${peer_id}'s inbox.` }] };
|
|
63
|
+
});
|
|
64
|
+
// --- Shared ---
|
|
65
|
+
server.tool("shared_list", "List files in the shared/ directory", async () => {
|
|
66
|
+
const files = await store.listShared();
|
|
67
|
+
if (files.length === 0) {
|
|
68
|
+
return { content: [{ type: "text", text: "No shared files." }] };
|
|
69
|
+
}
|
|
70
|
+
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
71
|
+
});
|
|
72
|
+
server.tool("shared_read", "Read a file from shared/", { filename: z.string().describe("Filename in shared/") }, async ({ filename }) => {
|
|
73
|
+
const safeName = sanitizeFilename(filename);
|
|
74
|
+
const { readFile } = await import("node:fs/promises");
|
|
75
|
+
const { join } = await import("node:path");
|
|
76
|
+
const content = await readFile(join(store.root, "shared", safeName), "utf-8");
|
|
77
|
+
return { content: [{ type: "text", text: content }] };
|
|
78
|
+
});
|
|
79
|
+
server.tool("shared_write", "Write a file to shared/", {
|
|
80
|
+
filename: z.string().describe("Filename to create in shared/"),
|
|
81
|
+
content: z.string().describe("File content"),
|
|
82
|
+
}, async ({ filename, content }) => {
|
|
83
|
+
const safeName = sanitizeFilename(filename);
|
|
84
|
+
await store.share(safeName, content);
|
|
85
|
+
return { content: [{ type: "text", text: `Shared: ${safeName}` }] };
|
|
86
|
+
});
|
|
87
|
+
// --- Status ---
|
|
88
|
+
server.tool("status", "Get context store status (agent, peers, inbox count)", async () => {
|
|
89
|
+
const s = await store.status();
|
|
90
|
+
const text = [
|
|
91
|
+
`Agent: ${s.name} (${s.id})`,
|
|
92
|
+
`Peers: ${s.peers}`,
|
|
93
|
+
`Inbox: ${s.inboxCount} messages`,
|
|
94
|
+
`Shared: ${s.sharedCount} files`,
|
|
95
|
+
].join("\n");
|
|
96
|
+
return { content: [{ type: "text", text }] };
|
|
97
|
+
});
|
|
98
|
+
// --- Peers ---
|
|
99
|
+
server.tool("peer_list", "List configured peers", async () => {
|
|
100
|
+
const config = await store.readConfig();
|
|
101
|
+
if (config.peers.length === 0) {
|
|
102
|
+
return { content: [{ type: "text", text: "No peers connected." }] };
|
|
103
|
+
}
|
|
104
|
+
const lines = config.peers.map((p) => `${p.name} (${p.id}) — ${p.url} [${p.access}]`);
|
|
105
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
106
|
+
});
|
|
107
|
+
server.tool("peer_add", "Add a peer by URL (http:// for WAN, ssh://host:/path for LAN)", {
|
|
108
|
+
url: z.string().describe("Peer URL"),
|
|
109
|
+
name: z.string().describe("Peer name"),
|
|
110
|
+
access: z.enum(["read", "readwrite"]).default("read").describe("Access mode"),
|
|
111
|
+
}, async ({ url, name, access }) => {
|
|
112
|
+
const { nanoid } = await import("nanoid");
|
|
113
|
+
const config = await store.readConfig();
|
|
114
|
+
const peerId = nanoid(12);
|
|
115
|
+
config.peers.push({ id: peerId, name, url, access });
|
|
116
|
+
await store.writeConfig(config);
|
|
117
|
+
return { content: [{ type: "text", text: `Added peer: ${name} (${url}) [${access}]` }] };
|
|
118
|
+
});
|
|
119
|
+
// --- Start ---
|
|
120
|
+
async function main() {
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await server.connect(transport);
|
|
123
|
+
}
|
|
124
|
+
main().catch((e) => {
|
|
125
|
+
console.error(e);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ContextStore } from "./store.js";
|
|
2
|
+
export declare const DEFAULT_REGISTRY = "https://openfuse-registry.wzmcghee.workers.dev";
|
|
3
|
+
export interface Manifest {
|
|
4
|
+
name: string;
|
|
5
|
+
endpoint: string;
|
|
6
|
+
publicKey: string;
|
|
7
|
+
encryptionKey?: string;
|
|
8
|
+
fingerprint: string;
|
|
9
|
+
created: string;
|
|
10
|
+
capabilities: string[];
|
|
11
|
+
description?: string;
|
|
12
|
+
signature?: string;
|
|
13
|
+
signedAt?: string;
|
|
14
|
+
revoked?: boolean;
|
|
15
|
+
revokedAt?: string;
|
|
16
|
+
rotatedFrom?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function resolveRegistry(flag?: string): string;
|
|
19
|
+
export declare function register(store: ContextStore, endpoint: string, registry: string): Promise<Manifest>;
|
|
20
|
+
export declare function discover(name: string, registry: string): Promise<Manifest>;
|
|
21
|
+
export declare function revoke(store: ContextStore, registry: string): Promise<void>;
|
|
22
|
+
export declare function checkUpdate(currentVersion: string): Promise<string | null>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { signMessage, fingerprint } from "./crypto.js";
|
|
2
|
+
export const DEFAULT_REGISTRY = "https://openfuse-registry.wzmcghee.workers.dev";
|
|
3
|
+
export function resolveRegistry(flag) {
|
|
4
|
+
return flag || process.env.OPENFUSE_REGISTRY || DEFAULT_REGISTRY;
|
|
5
|
+
}
|
|
6
|
+
export async function register(store, endpoint, registry) {
|
|
7
|
+
const config = await store.readConfig();
|
|
8
|
+
if (!config.publicKey)
|
|
9
|
+
throw new Error("No signing key — run `openfuse init` first");
|
|
10
|
+
const manifest = {
|
|
11
|
+
name: config.name,
|
|
12
|
+
endpoint,
|
|
13
|
+
publicKey: config.publicKey,
|
|
14
|
+
encryptionKey: config.encryptionKey,
|
|
15
|
+
fingerprint: fingerprint(config.publicKey),
|
|
16
|
+
created: new Date().toISOString(),
|
|
17
|
+
capabilities: ["inbox", "shared", "knowledge"],
|
|
18
|
+
};
|
|
19
|
+
const canonical = `${manifest.name}|${manifest.endpoint}|${manifest.publicKey}|${manifest.encryptionKey || ""}`;
|
|
20
|
+
const signed = await signMessage(store.root, manifest.name, canonical);
|
|
21
|
+
manifest.signature = signed.signature;
|
|
22
|
+
manifest.signedAt = signed.timestamp;
|
|
23
|
+
const resp = await fetch(`${registry.replace(/\/$/, "")}/register`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify(manifest),
|
|
27
|
+
});
|
|
28
|
+
if (!resp.ok) {
|
|
29
|
+
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
|
30
|
+
throw new Error(body.error || `Registry returned ${resp.status}`);
|
|
31
|
+
}
|
|
32
|
+
return manifest;
|
|
33
|
+
}
|
|
34
|
+
export async function discover(name, registry) {
|
|
35
|
+
const resp = await fetch(`${registry.replace(/\/$/, "")}/discover/${name}`);
|
|
36
|
+
if (!resp.ok) {
|
|
37
|
+
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
|
38
|
+
throw new Error(body.error || `Agent '${name}' not found`);
|
|
39
|
+
}
|
|
40
|
+
return (await resp.json());
|
|
41
|
+
}
|
|
42
|
+
export async function revoke(store, registry) {
|
|
43
|
+
const config = await store.readConfig();
|
|
44
|
+
if (!config.publicKey)
|
|
45
|
+
throw new Error("No signing key");
|
|
46
|
+
const revokeMsg = `REVOKE:${config.publicKey}`;
|
|
47
|
+
const signed = await signMessage(store.root, config.name, revokeMsg);
|
|
48
|
+
const resp = await fetch(`${registry.replace(/\/$/, "")}/revoke`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
name: config.name,
|
|
53
|
+
signature: signed.signature,
|
|
54
|
+
signedAt: signed.timestamp,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (!resp.ok) {
|
|
58
|
+
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
|
59
|
+
throw new Error(body.error || `Revocation failed`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function checkUpdate(currentVersion) {
|
|
63
|
+
try {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
66
|
+
const resp = await fetch(DEFAULT_REGISTRY, { signal: controller.signal });
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
if (!resp.ok)
|
|
69
|
+
return null;
|
|
70
|
+
const body = (await resp.json());
|
|
71
|
+
if (body.latest && body.latest !== currentVersion)
|
|
72
|
+
return body.latest;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { type KeyringEntry } from "./crypto.js";
|
|
1
2
|
export interface MeshConfig {
|
|
2
3
|
id: string;
|
|
3
4
|
name: string;
|
|
4
5
|
created: string;
|
|
5
6
|
publicKey?: string;
|
|
7
|
+
encryptionKey?: string;
|
|
6
8
|
peers: PeerConfig[];
|
|
9
|
+
keyring: KeyringEntry[];
|
|
7
10
|
trustedKeys?: string[];
|
|
8
11
|
}
|
|
9
12
|
export interface PeerConfig {
|
|
@@ -33,6 +36,7 @@ export declare class ContextStore {
|
|
|
33
36
|
from: string;
|
|
34
37
|
time: string;
|
|
35
38
|
verified: boolean;
|
|
39
|
+
encrypted: boolean;
|
|
36
40
|
}>>;
|
|
37
41
|
listShared(): Promise<string[]>;
|
|
38
42
|
share(filename: string, content: string): Promise<void>;
|
package/dist/store.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { generateKeys, signMessage, verifyMessage, deserializeSignedMessage, serializeSignedMessage, wrapExternalMessage } from "./crypto.js";
|
|
5
|
-
const STORE_DIRS = ["history", "knowledge", "inbox", "outbox", "shared"];
|
|
4
|
+
import { generateKeys, signMessage, signAndEncrypt, verifyMessage, decryptMessage, deserializeSignedMessage, serializeSignedMessage, wrapExternalMessage, fingerprint, } from "./crypto.js";
|
|
5
|
+
const STORE_DIRS = ["history", "knowledge", "inbox", "outbox", "shared", ".peers"];
|
|
6
6
|
export class ContextStore {
|
|
7
7
|
root;
|
|
8
8
|
constructor(root) {
|
|
@@ -15,7 +15,6 @@ export class ContextStore {
|
|
|
15
15
|
return existsSync(this.configPath);
|
|
16
16
|
}
|
|
17
17
|
async init(name, id) {
|
|
18
|
-
// Create directory structure
|
|
19
18
|
await mkdir(this.root, { recursive: true });
|
|
20
19
|
for (const dir of STORE_DIRS) {
|
|
21
20
|
await mkdir(join(this.root, dir), { recursive: true });
|
|
@@ -30,22 +29,44 @@ export class ContextStore {
|
|
|
30
29
|
await writeFile(destPath, content);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
|
-
// Generate signing keypair
|
|
34
32
|
const keys = await generateKeys(this.root);
|
|
35
|
-
// Write mesh config
|
|
36
33
|
const config = {
|
|
37
34
|
id,
|
|
38
35
|
name,
|
|
39
36
|
created: new Date().toISOString(),
|
|
40
37
|
publicKey: keys.publicKey,
|
|
38
|
+
encryptionKey: keys.encryptionKey,
|
|
41
39
|
peers: [],
|
|
42
|
-
|
|
40
|
+
keyring: [],
|
|
43
41
|
};
|
|
44
42
|
await this.writeConfig(config);
|
|
45
43
|
}
|
|
46
44
|
async readConfig() {
|
|
47
45
|
const raw = await readFile(this.configPath, "utf-8");
|
|
48
|
-
|
|
46
|
+
const config = JSON.parse(raw);
|
|
47
|
+
// Migrate legacy trustedKeys → keyring
|
|
48
|
+
if (config.trustedKeys && config.trustedKeys.length > 0) {
|
|
49
|
+
if (!config.keyring)
|
|
50
|
+
config.keyring = [];
|
|
51
|
+
for (const key of config.trustedKeys) {
|
|
52
|
+
const k = key.trim();
|
|
53
|
+
if (!k || config.keyring.some((e) => e.signingKey === k))
|
|
54
|
+
continue;
|
|
55
|
+
config.keyring.push({
|
|
56
|
+
name: `migrated-${k.slice(0, 8)}`,
|
|
57
|
+
address: "",
|
|
58
|
+
signingKey: k,
|
|
59
|
+
fingerprint: fingerprint(k),
|
|
60
|
+
trusted: true,
|
|
61
|
+
added: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
delete config.trustedKeys;
|
|
65
|
+
await this.writeConfig(config);
|
|
66
|
+
}
|
|
67
|
+
if (!config.keyring)
|
|
68
|
+
config.keyring = [];
|
|
69
|
+
return config;
|
|
49
70
|
}
|
|
50
71
|
async writeConfig(config) {
|
|
51
72
|
await writeFile(this.configPath, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -62,14 +83,20 @@ export class ContextStore {
|
|
|
62
83
|
async writeSoul(content) {
|
|
63
84
|
await writeFile(join(this.root, "SOUL.md"), content);
|
|
64
85
|
}
|
|
65
|
-
// --- Inbox
|
|
86
|
+
// --- Inbox ---
|
|
66
87
|
async sendInbox(peerId, message) {
|
|
67
88
|
const config = await this.readConfig();
|
|
68
|
-
|
|
89
|
+
// Look up peer's encryption key in keyring
|
|
90
|
+
const entry = config.keyring.find((e) => e.name === peerId || e.address.startsWith(`${peerId}@`));
|
|
91
|
+
let signed;
|
|
92
|
+
if (entry?.encryptionKey) {
|
|
93
|
+
signed = await signAndEncrypt(this.root, config.id, message, entry.encryptionKey);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
signed = await signMessage(this.root, config.id, message);
|
|
97
|
+
}
|
|
69
98
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
70
99
|
const filename = `${timestamp}_${peerId}.json`;
|
|
71
|
-
// Write to inbox (for the recipient) and outbox (our copy)
|
|
72
|
-
await writeFile(join(this.root, "inbox", filename), serializeSignedMessage(signed));
|
|
73
100
|
await writeFile(join(this.root, "outbox", filename), serializeSignedMessage(signed));
|
|
74
101
|
}
|
|
75
102
|
async readInbox() {
|
|
@@ -81,22 +108,34 @@ export class ContextStore {
|
|
|
81
108
|
const messages = [];
|
|
82
109
|
for (const file of files.filter((f) => f.endsWith(".json") || f.endsWith(".md"))) {
|
|
83
110
|
const raw = await readFile(join(inboxDir, file), "utf-8");
|
|
84
|
-
// Try parsing as signed message
|
|
85
111
|
const signed = deserializeSignedMessage(raw);
|
|
86
112
|
if (signed) {
|
|
87
|
-
const
|
|
88
|
-
const trusted = config.
|
|
113
|
+
const sigValid = verifyMessage(signed);
|
|
114
|
+
const trusted = config.keyring.some((k) => k.trusted && k.signingKey.trim() === signed.publicKey.trim());
|
|
115
|
+
const verified = sigValid && trusted;
|
|
116
|
+
let content;
|
|
117
|
+
if (signed.encrypted) {
|
|
118
|
+
try {
|
|
119
|
+
content = await decryptMessage(this.root, signed);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
content = "[encrypted — cannot decrypt]";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
content = signed.message;
|
|
127
|
+
}
|
|
89
128
|
messages.push({
|
|
90
129
|
file,
|
|
91
|
-
content
|
|
92
|
-
wrappedContent: wrapExternalMessage(signed, verified
|
|
130
|
+
content,
|
|
131
|
+
wrappedContent: wrapExternalMessage(signed, verified),
|
|
93
132
|
from: signed.from,
|
|
94
133
|
time: signed.timestamp,
|
|
95
|
-
verified
|
|
134
|
+
verified,
|
|
135
|
+
encrypted: !!signed.encrypted,
|
|
96
136
|
});
|
|
97
137
|
}
|
|
98
138
|
else {
|
|
99
|
-
// Unsigned message — mark as unverified
|
|
100
139
|
const parts = file.replace(/\.(md|json)$/, "").split("_");
|
|
101
140
|
const from = parts.slice(1).join("_");
|
|
102
141
|
messages.push({
|
|
@@ -106,6 +145,7 @@ export class ContextStore {
|
|
|
106
145
|
from,
|
|
107
146
|
time: parts[0],
|
|
108
147
|
verified: false,
|
|
148
|
+
encrypted: false,
|
|
109
149
|
});
|
|
110
150
|
}
|
|
111
151
|
}
|
|
@@ -119,9 +159,14 @@ export class ContextStore {
|
|
|
119
159
|
return readdir(sharedDir);
|
|
120
160
|
}
|
|
121
161
|
async share(filename, content) {
|
|
162
|
+
// Sanitize: extract basename, reject traversal
|
|
163
|
+
const base = filename.split("/").pop().split("\\").pop();
|
|
164
|
+
if (!base || base === "." || base === ".." || base.includes("..")) {
|
|
165
|
+
throw new Error(`Invalid filename: ${filename}`);
|
|
166
|
+
}
|
|
122
167
|
const sharedDir = join(this.root, "shared");
|
|
123
168
|
await mkdir(sharedDir, { recursive: true });
|
|
124
|
-
await writeFile(join(sharedDir,
|
|
169
|
+
await writeFile(join(sharedDir, base), content);
|
|
125
170
|
}
|
|
126
171
|
// --- Status ---
|
|
127
172
|
async status() {
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ContextStore } from "./store.js";
|
|
2
|
+
export interface SyncResult {
|
|
3
|
+
peerName: string;
|
|
4
|
+
pulled: string[];
|
|
5
|
+
pushed: string[];
|
|
6
|
+
errors: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function syncAll(store: ContextStore): Promise<SyncResult[]>;
|
|
9
|
+
export declare function syncOne(store: ContextStore, peerName: string): Promise<SyncResult>;
|