| 1 | #!/usr/bin/env node |
| 2 | // lchat-post — post a message to an lchat thread from outside the browser |
| 3 | // (bots, cron, CI, webhooks) while keeping the server blind. |
| 4 | // |
| 5 | // All encryption happens HERE, in the caller's trust domain. lchat is end-to-end |
| 6 | // encrypted with per-thread AES-GCM keys, each wrapped to a user's keypair. So to |
| 7 | // post, a script needs that user's credentials: a device token (authorises the |
| 8 | // API and identifies the user) and the user's private key (to unwrap the thread |
| 9 | // key). We fetch the thread's wrapped key, unwrap it with the private key, |
| 10 | // AES-GCM-encrypt the payload with the same crypto.js the browser uses, and POST |
| 11 | // only the ciphertext. Neither the private key nor the plaintext ever reaches the |
| 12 | // server, so the end-to-end guarantee — "compromise the server, get nothing" — |
| 13 | // is preserved. |
| 14 | // |
| 15 | // Usage: |
| 16 | // lchat-post --host https://chat.example.com --token T --priv P --thread TID "hello" |
| 17 | // echo "deploy finished" | lchat-post --thread TID --name CI # rest via env |
| 18 | // |
| 19 | // Config (flags win over env): |
| 20 | // --host / LCHAT_HOST instance origin |
| 21 | // --token / LCHAT_TOKEN device token (from a joined session / add-device link) |
| 22 | // --priv / LCHAT_PRIV the user's private key, pkcs8 base64url |
| 23 | // --thread / LCHAT_THREAD id of the thread to post to |
| 24 | // --name / LCHAT_NAME screen name shown in chat (default: "bot") |
| 25 | // |
| 26 | // Text comes from the positional args, or from stdin if none are given. |
| 27 | |
| 28 | import { importKey, encrypt, unwrapThreadKey } from "../web/static/crypto.js"; |
| 29 | |
| 30 | const FLAGS = ["--host", "--token", "--priv", "--thread", "--name"]; |
| 31 | |
| 32 | function parseArgs(argv) { |
| 33 | const opts = {}; |
| 34 | const rest = []; |
| 35 | for (let i = 0; i < argv.length; i++) { |
| 36 | const a = argv[i]; |
| 37 | if (FLAGS.includes(a)) { |
| 38 | opts[a.slice(2)] = argv[++i]; |
| 39 | } else if (a === "-h" || a === "--help") { |
| 40 | opts.help = true; |
| 41 | } else { |
| 42 | rest.push(a); |
| 43 | } |
| 44 | } |
| 45 | return { opts, rest }; |
| 46 | } |
| 47 | |
| 48 | async function readStdin() { |
| 49 | const chunks = []; |
| 50 | for await (const c of process.stdin) chunks.push(c); |
| 51 | return Buffer.concat(chunks).toString("utf8"); |
| 52 | } |
| 53 | |
| 54 | async function main() { |
| 55 | const { opts, rest } = parseArgs(process.argv.slice(2)); |
| 56 | if (opts.help) { |
| 57 | process.stdout.write( |
| 58 | "lchat-post — post an encrypted message to an lchat thread\n" + |
| 59 | " lchat-post --host H --token T --priv P --thread TID [--name NAME] [text]\n" + |
| 60 | " echo text | lchat-post --thread TID # other config via LCHAT_* env\n" |
| 61 | ); |
| 62 | return; |
| 63 | } |
| 64 | |
| 65 | const host = (opts.host || process.env.LCHAT_HOST || "").replace(/\/$/, ""); |
| 66 | const token = opts.token || process.env.LCHAT_TOKEN; |
| 67 | const priv = opts.priv || process.env.LCHAT_PRIV; |
| 68 | const thread = opts.thread || process.env.LCHAT_THREAD; |
| 69 | const name = opts.name || process.env.LCHAT_NAME || "bot"; |
| 70 | if (!host || !token || !priv || !thread) { |
| 71 | throw new Error("need --host, --token, --priv and --thread (or the LCHAT_* env vars)"); |
| 72 | } |
| 73 | |
| 74 | const text = (rest.length ? rest.join(" ") : await readStdin()).trim(); |
| 75 | if (!text) throw new Error("nothing to post (empty text)"); |
| 76 | |
| 77 | // Fetch the thread's wrapped key and unwrap it with the user's private key — |
| 78 | // exactly what the browser does on load. The server only ever handed us a blob |
| 79 | // it can't open. |
| 80 | const tr = await fetch(`${host}/api/threads?token=${encodeURIComponent(token)}`); |
| 81 | if (!tr.ok) throw new Error(`fetch threads: server returned ${tr.status}`); |
| 82 | const threads = (await tr.json()).threads || []; |
| 83 | const t = threads.find((x) => x.id === thread); |
| 84 | if (!t) throw new Error(`thread ${thread} not found (is this user a member of it?)`); |
| 85 | if (!t.wrapped) throw new Error(`no wrapped key for thread ${thread}`); |
| 86 | const key = await importKey(await unwrapThreadKey(t.wrapped, priv)); |
| 87 | |
| 88 | // Identical shape and crypto to the browser composer; ts is the local clock (ms). |
| 89 | const blob = await encrypt(key, { name, text, ts: Date.now() }); |
| 90 | |
| 91 | const res = await fetch(`${host}/api/messages?token=${encodeURIComponent(token)}`, { |
| 92 | method: "POST", |
| 93 | headers: { "Content-Type": "application/json" }, |
| 94 | body: JSON.stringify({ thread_id: thread, blob }), |
| 95 | }); |
| 96 | if (!res.ok) { |
| 97 | const detail = await res.text().catch(() => ""); |
| 98 | throw new Error(`server returned ${res.status}: ${detail.trim() || res.statusText}`); |
| 99 | } |
| 100 | const { id } = await res.json(); |
| 101 | process.stderr.write(`posted message #${id}\n`); |
| 102 | } |
| 103 | |
| 104 | main().catch((e) => { |
| 105 | process.stderr.write(`lchat-post: ${e.message}\n`); |
| 106 | process.exit(1); |
| 107 | }); |
| 108 | |