mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-07-03 16:20:58 +02:00
message decryption
This commit is contained in:
Generated
+13
@@ -12,6 +12,7 @@
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/leaflet": "^1.9.19",
|
||||
"aes-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clickhouse": "^2.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/aes-js": "^3.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1422,6 +1424,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aes-js": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aes-js/-/aes-js-3.1.4.tgz",
|
||||
"integrity": "sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2023,6 +2031,11 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
|
||||
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/leaflet": "^1.9.19",
|
||||
"aes-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clickhouse": "^2.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -30,6 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/aes-js": "^3.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getLatestChatMessages } from "@/lib/clickhouse/actions";
|
||||
import { getChannelIdFromKey } from "@/lib/meshcore";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limit = parseInt(searchParams.get("limit") || "20", 10);
|
||||
const before = searchParams.get("before") || undefined;
|
||||
const messages = await getLatestChatMessages({ limit, before });
|
||||
const channelId = searchParams.get("channel_id") || undefined;
|
||||
const messages = await getLatestChatMessages({ limit, before, channelId } as { limit?: number, before?: string, channelId?: string });
|
||||
return NextResponse.json(messages);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch chat messages" }, { status: 500 });
|
||||
|
||||
+110
-25
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import { decryptMeshcoreGroupMessage } from "../lib/meshcore_decrypt";
|
||||
import { getChannelIdFromKey } from "../lib/meshcore";
|
||||
|
||||
interface ChatMessage {
|
||||
ingest_timestamp: string;
|
||||
@@ -28,26 +31,89 @@ function formatLocalTime(utcString: string): string {
|
||||
}
|
||||
|
||||
function ChatMessageItem({ msg }: { msg: ChatMessage }) {
|
||||
// Placeholder for decryption logic
|
||||
// const decryptedMessage = decryptMessage(msg.encrypted_message);
|
||||
const { config } = useConfig();
|
||||
const knownKeys = [
|
||||
...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []),
|
||||
"izOH6cXN6mrJ5e26oRXNcg==", // Always include public key
|
||||
];
|
||||
const [parsed, setParsed] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await decryptMeshcoreGroupMessage({
|
||||
encrypted_message: msg.encrypted_message,
|
||||
mac: msg.mac,
|
||||
channel_hash: msg.channel_hash,
|
||||
knownKeys,
|
||||
parse: true,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setParsed(result);
|
||||
if (result === null) {
|
||||
console.warn("Meshcore message could not be parsed", { msg });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setParsed(null);
|
||||
console.error("Error during Meshcore decryption/parsing", { msg, err });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeys.join(",")]);
|
||||
|
||||
if (parsed) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
|
||||
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
|
||||
</div>
|
||||
<div className="break-all whitespace-pre-wrap">
|
||||
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
|
||||
{parsed.sender && ": "}
|
||||
<span>{parsed.text}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(msg.ingest_timestamp)} <span className="text-xs text-blue-600 dark:text-blue-400 font-mono">{msg.channel_hash}</span>
|
||||
{formatLocalTime(msg.ingest_timestamp)}
|
||||
</div>
|
||||
<div className="font-mono break-all whitespace-pre-wrap">{formatHex(msg.encrypted_message)}</div>
|
||||
<div className="text-xs text-gray-300">from: {msg.origin}</div>
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
{formatHex(msg.encrypted_message)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatBox() {
|
||||
const { config } = useConfig();
|
||||
const meshcoreKeys = [
|
||||
{ channelName: "Public", privateKey: "izOH6cXN6mrJ5e26oRXNcg==" },
|
||||
...(config?.meshcoreKeys || [])
|
||||
];
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [lastBefore, setLastBefore] = useState<string | undefined>(undefined);
|
||||
|
||||
const selectedKey = meshcoreKeys[selectedTab];
|
||||
const channelId = getChannelIdFromKey(selectedKey.privateKey).toUpperCase();
|
||||
|
||||
// Only show tabs if more than one channel (public + at least one custom key)
|
||||
const showTabs = meshcoreKeys.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
setMessages([]);
|
||||
@@ -56,12 +122,12 @@ export default function ChatBox() {
|
||||
fetchMessages(undefined, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minimized]);
|
||||
}, [minimized, selectedTab]);
|
||||
|
||||
const fetchMessages = async (before?: string, replace = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}`;
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}&channel_id=${channelId}`;
|
||||
if (before) url += `&before=${encodeURIComponent(before)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
@@ -108,25 +174,44 @@ export default function ChatBox() {
|
||||
</button>
|
||||
</div>
|
||||
{!minimized && (
|
||||
<div className="flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 flex flex-col-reverse">
|
||||
<div className="flex flex-col-reverse gap-2">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="text-gray-400 text-center mt-8">No chat messages found.</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessageItem key={msg.ingest_timestamp + msg.origin + i} msg={msg} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
className="w-full py-2 bg-gray-100 dark:bg-neutral-800 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-neutral-700 mt-2"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
<>
|
||||
{showTabs && (
|
||||
<div className="flex gap-1 mb-2 border-b border-gray-200 dark:border-neutral-800">
|
||||
{meshcoreKeys.map((key, idx) => (
|
||||
<button
|
||||
key={key.privateKey + idx}
|
||||
className={`px-2 py-1 text-xs rounded-t font-mono ${
|
||||
idx === selectedTab
|
||||
? "bg-gray-100 dark:bg-neutral-800 text-blue-700 dark:text-blue-400 border-b-2 border-blue-500"
|
||||
: "bg-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
onClick={() => setSelectedTab(idx)}
|
||||
>
|
||||
{key.channelName || getChannelIdFromKey(key.privateKey).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 flex flex-col-reverse">
|
||||
<div className="flex flex-col-reverse gap-2">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="text-gray-400 text-center mt-8">No chat messages found.</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessageItem key={msg.ingest_timestamp + msg.origin + i} msg={msg} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
className="w-full py-2 bg-gray-100 dark:bg-neutral-800 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-neutral-700 mt-2"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,13 +45,17 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getLatestChatMessages({ limit = 20, before }: { limit?: number, before?: string } = {}) {
|
||||
export async function getLatestChatMessages({ limit = 20, before, channelId }: { limit?: number, before?: string, channelId?: string } = {}) {
|
||||
let where = [];
|
||||
const params: Record<string, any> = { limit };
|
||||
if (before) {
|
||||
where.push('ingest_timestamp < {before:DateTime64}');
|
||||
params.before = before;
|
||||
}
|
||||
if (channelId) {
|
||||
where.push('channel_hash = {channelId:String}');
|
||||
params.channelId = channelId;
|
||||
}
|
||||
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const query = `SELECT ingest_timestamp, origin, mesh_timestamp, packet, path_len, path, channel_hash, mac, hex(encrypted_message) AS encrypted_message FROM meshcore_public_channel_messages ${whereClause} ORDER BY ingest_timestamp DESC LIMIT {limit:UInt32}`;
|
||||
const resultSet = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' });
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getChannelIdFromKey } from "./meshcore";
|
||||
import aesjs from "aes-js";
|
||||
|
||||
// Helper: Convert hex string to Uint8Array
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.startsWith("0x")) hex = hex.slice(2);
|
||||
if (hex.length % 2 !== 0) hex = "0" + hex;
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Helper: Convert base64 to Uint8Array
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
// Helper: HMAC-SHA256, returns Uint8Array
|
||||
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||
if (window.crypto?.subtle) {
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
"raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
|
||||
);
|
||||
const sig = await window.crypto.subtle.sign("HMAC", cryptoKey, data);
|
||||
return new Uint8Array(sig);
|
||||
} else {
|
||||
// Fallback: use a JS polyfill if needed (not implemented here)
|
||||
throw new Error("No WebCrypto support for HMAC-SHA256");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse decrypted MeshCore group message
|
||||
export function parseMeshcoreGroupMessage(decrypted: Uint8Array | string): {
|
||||
timestamp: number;
|
||||
msgType: number;
|
||||
sender: string;
|
||||
text: string;
|
||||
rawText: string;
|
||||
} | null {
|
||||
let buf: Uint8Array;
|
||||
if (typeof decrypted === "string") {
|
||||
buf = new TextEncoder().encode(decrypted);
|
||||
} else {
|
||||
buf = decrypted;
|
||||
}
|
||||
if (buf.length < 6) return null;
|
||||
// 1. Timestamp (4 bytes, little-endian)
|
||||
const timestamp = buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24);
|
||||
// 2. MsgType (1 byte)
|
||||
const msgType = buf[4];
|
||||
// 3. Message text (null-terminated)
|
||||
let end = 5;
|
||||
while (end < buf.length && buf[end] !== 0) end++;
|
||||
const rawText = new TextDecoder().decode(buf.slice(5, end));
|
||||
// Try to split sender and text
|
||||
let sender = "";
|
||||
let text = rawText;
|
||||
const sepIdx = rawText.indexOf(": ");
|
||||
if (sepIdx !== -1) {
|
||||
sender = rawText.slice(0, sepIdx);
|
||||
text = rawText.slice(sepIdx + 2);
|
||||
}
|
||||
return { timestamp, msgType, sender, text, rawText };
|
||||
}
|
||||
|
||||
// Main decryption function
|
||||
export async function decryptMeshcoreGroupMessage({
|
||||
encrypted_message, // hex string or Uint8Array
|
||||
mac, // hex string or Uint8Array (2 bytes)
|
||||
channel_hash, // hex string (1 byte)
|
||||
knownKeys, // array of base64 strings
|
||||
parse = false,
|
||||
}: {
|
||||
encrypted_message: string | Uint8Array,
|
||||
mac: string | Uint8Array,
|
||||
channel_hash: string,
|
||||
knownKeys: string[],
|
||||
parse?: boolean,
|
||||
}): Promise<string | ReturnType<typeof parseMeshcoreGroupMessage> | null> {
|
||||
// Normalize inputs
|
||||
const ciphertext = typeof encrypted_message === "string" ? hexToBytes(encrypted_message) : encrypted_message;
|
||||
const macBytes = typeof mac === "string" ? hexToBytes(mac) : mac;
|
||||
const chash = channel_hash.toLowerCase();
|
||||
|
||||
const failures: { key: string, reason: string }[] = [];
|
||||
|
||||
for (const base64Key of knownKeys) {
|
||||
let keyBytes: Uint8Array;
|
||||
try {
|
||||
keyBytes = base64ToBytes(base64Key);
|
||||
} catch (e) {
|
||||
console.warn("Skipping invalid base64 meshcore key:", base64Key, e);
|
||||
failures.push({ key: base64Key, reason: `base64 decode error: ${e}` });
|
||||
continue;
|
||||
}
|
||||
const candidateHash = getChannelIdFromKey(base64Key);
|
||||
if (candidateHash !== chash) {
|
||||
failures.push({ key: base64Key, reason: `channel hash mismatch (expected ${chash}, got ${candidateHash})` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// MAC check
|
||||
let hmac;
|
||||
try {
|
||||
hmac = await hmacSha256(keyBytes, ciphertext);
|
||||
} catch (e) {
|
||||
failures.push({ key: base64Key, reason: `HMAC error: ${e}` });
|
||||
continue;
|
||||
}
|
||||
if (macBytes.length !== 2 || hmac[0] !== macBytes[0] || hmac[1] !== macBytes[1]) {
|
||||
failures.push({ key: base64Key, reason: `MAC mismatch (expected ${Array.from(macBytes).map(b=>b.toString(16).padStart(2,'0')).join('')}, got ${Array.from(hmac.slice(0,2)).map(b=>b.toString(16).padStart(2,'0')).join('')})` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt
|
||||
try {
|
||||
const aesEcb = new aesjs.ModeOfOperation.ecb(keyBytes);
|
||||
const decrypted = aesEcb.decrypt(ciphertext);
|
||||
// Remove trailing nulls/zeros
|
||||
let end = decrypted.length;
|
||||
while (end > 0 && decrypted[end - 1] === 0) end--;
|
||||
const plainBytes = decrypted.slice(0, end);
|
||||
if (parse) {
|
||||
return parseMeshcoreGroupMessage(plainBytes);
|
||||
} else {
|
||||
return new TextDecoder().decode(plainBytes);
|
||||
}
|
||||
} catch (e) {
|
||||
failures.push({ key: base64Key, reason: `AES decryption error: ${e}` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.info("Meshcore decryption failed for message", {
|
||||
channel_hash: chash,
|
||||
mac: Array.from(macBytes).map(b=>b.toString(16).padStart(2,'0')).join(''),
|
||||
knownKeysTried: knownKeys,
|
||||
failures,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user