message decryption

This commit is contained in:
ajvpot
2025-07-04 00:00:00 +00:00
parent 7181bfc89d
commit e30000b2d3
6 changed files with 277 additions and 27 deletions
+13
View File
@@ -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",
+2
View File
@@ -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",
+3 -1
View File
@@ -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
View File
@@ -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>
);
+5 -1
View File
@@ -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' });
+144
View File
@@ -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;
}