#!/usr/bin/env node // lchat-post — post a message to an lchat thread from outside the browser // (bots, cron, CI, webhooks) while keeping the server blind. // // All encryption happens HERE, in the caller's trust domain. lchat is end-to-end // encrypted with per-thread AES-GCM keys, each wrapped to a user's keypair. So to // post, a script needs that user's credentials: a device token (authorises the // API and identifies the user) and the user's private key (to unwrap the thread // key). We fetch the thread's wrapped key, unwrap it with the private key, // AES-GCM-encrypt the payload with the same crypto.js the browser uses, and POST // only the ciphertext. Neither the private key nor the plaintext ever reaches the // server, so the end-to-end guarantee — "compromise the server, get nothing" — // is preserved. // // Usage: // lchat-post --host https://chat.example.com --token T --priv P --thread TID "hello" // echo "deploy finished" | lchat-post --thread TID --name CI # rest via env // // Config (flags win over env): // --host / LCHAT_HOST instance origin // --token / LCHAT_TOKEN device token (from a joined session / add-device link) // --priv / LCHAT_PRIV the user's private key, pkcs8 base64url // --thread / LCHAT_THREAD id of the thread to post to // --name / LCHAT_NAME screen name shown in chat (default: "bot") // // Text comes from the positional args, or from stdin if none are given. import { importKey, encrypt, unwrapThreadKey } from "../web/static/crypto.js"; const FLAGS = ["--host", "--token", "--priv", "--thread", "--name"]; function parseArgs(argv) { const opts = {}; const rest = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (FLAGS.includes(a)) { opts[a.slice(2)] = argv[++i]; } else if (a === "-h" || a === "--help") { opts.help = true; } else { rest.push(a); } } return { opts, rest }; } async function readStdin() { const chunks = []; for await (const c of process.stdin) chunks.push(c); return Buffer.concat(chunks).toString("utf8"); } async function main() { const { opts, rest } = parseArgs(process.argv.slice(2)); if (opts.help) { process.stdout.write( "lchat-post — post an encrypted message to an lchat thread\n" + " lchat-post --host H --token T --priv P --thread TID [--name NAME] [text]\n" + " echo text | lchat-post --thread TID # other config via LCHAT_* env\n" ); return; } const host = (opts.host || process.env.LCHAT_HOST || "").replace(/\/$/, ""); const token = opts.token || process.env.LCHAT_TOKEN; const priv = opts.priv || process.env.LCHAT_PRIV; const thread = opts.thread || process.env.LCHAT_THREAD; const name = opts.name || process.env.LCHAT_NAME || "bot"; if (!host || !token || !priv || !thread) { throw new Error("need --host, --token, --priv and --thread (or the LCHAT_* env vars)"); } const text = (rest.length ? rest.join(" ") : await readStdin()).trim(); if (!text) throw new Error("nothing to post (empty text)"); // Fetch the thread's wrapped key and unwrap it with the user's private key — // exactly what the browser does on load. The server only ever handed us a blob // it can't open. const tr = await fetch(`${host}/api/threads?token=${encodeURIComponent(token)}`); if (!tr.ok) throw new Error(`fetch threads: server returned ${tr.status}`); const threads = (await tr.json()).threads || []; const t = threads.find((x) => x.id === thread); if (!t) throw new Error(`thread ${thread} not found (is this user a member of it?)`); if (!t.wrapped) throw new Error(`no wrapped key for thread ${thread}`); const key = await importKey(await unwrapThreadKey(t.wrapped, priv)); // Identical shape and crypto to the browser composer; ts is the local clock (ms). const blob = await encrypt(key, { name, text, ts: Date.now() }); const res = await fetch(`${host}/api/messages?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thread_id: thread, blob }), }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`server returned ${res.status}: ${detail.trim() || res.statusText}`); } const { id } = await res.json(); process.stderr.write(`posted message #${id}\n`); } main().catch((e) => { process.stderr.write(`lchat-post: ${e.message}\n`); process.exit(1); });