eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
28import { importKey, encrypt, unwrapThreadKey } from "../web/static/crypto.js";
29
30const FLAGS = ["--host", "--token", "--priv", "--thread", "--name"];
31
32function 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
48async 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
54async 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
104main().catch((e) => {
105 process.stderr.write(`lchat-post: ${e.message}\n`);
106 process.exit(1);
107});
108