@pipeup/mailbox 0.6.0 → 0.7.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,3 +147,20 @@ logs, so keep those logs as private as you would any capability URL.
147
147
  ```sh
148
148
  npm test
149
149
  ```
150
+
151
+ ## A person's inbox (`pm2`)
152
+
153
+ The same server also keeps **inboxes**, for the `@pipeup/inbox` add-on: notes that say "someone mentioned you", sealed
154
+ to the person, so the server stores ciphertext it can't read, from senders it can't name, for people it knows only as
155
+ a hash. `pm1` (mailboxes) is unchanged.
156
+
157
+ | Endpoint | Purpose |
158
+ | ------------------------------------- | ------------------------------------------------------------------------- |
159
+ | `PUT <base>/i/<inbox>/key` | a person publishes their public encryption key, proved by their signature |
160
+ | `GET <base>/i/<inbox>/key` | a sender reads it |
161
+ | `POST <base>/i/<inbox>` | leave a sealed note (`{ ct, work }`: with a small proof of work) |
162
+ | `GET <base>/i/<inbox>?since=<cursor>` | the owner reads what is new |
163
+ | `DELETE <base>/i/<inbox>/<note>` | the owner takes a note away (`X-Pipeup-Key`, `X-Pipeup-Sig`) |
164
+
165
+ Options: `inboxWork` (proof-of-work bits, 14), `inboxToken` (a note then needs `Authorization: Bearer`), `inboxNotes`
166
+ (notes per inbox, 1000). Notes are kept as long as mailboxes are (`keepDays`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeup/mailbox",
3
- "version": "0.6.0",
3
+ "version": "0.7.0-beta.2",
4
4
  "description": "Reference HTTP mailbox server for Pipeup shared comments (contract pm1), and a checker for any server.",
5
5
  "homepage": "https://pipeup-ai.github.io/pipeup/",
6
6
  "author": "Pipeup contributors",
package/src/inbox.mjs ADDED
@@ -0,0 +1,213 @@
1
+ // Contract pm2: a person's inbox, for notes that say "someone mentioned you" (docs/design/mentions.md, "Inbox").
2
+ // Notes are sealed to the person, so this server stores ciphertext it can't read, from senders it can't name, for
3
+ // people it knows only as a hash. Part of the mailbox server; the same limits and the same "no accounts" rule.
4
+ import { createHash, createPublicKey, verify } from "node:crypto";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
9
+ const CT = /^pi1\.[A-Za-z0-9_-]+$/;
10
+ const ED = /^[A-Za-z0-9_-]{43}$/;
11
+ const MAX_CT = 8192;
12
+ const MAX_BODY = 16384;
13
+ /** What a person signs: the same framing the page's `sign(purpose, data)` uses, so no signature here can be an op's. */
14
+ const frame = (purpose, data) => `pipeup:${purpose}\n${data}`;
15
+ const sha = (s) => createHash("sha256").update(s).digest();
16
+ const b64u = (b) => Buffer.from(b).toString("base64url");
17
+
18
+ /** The inbox address of a person: the hash of their public key, so a sender can compute it and the server can't reverse it. */
19
+ export function inboxId(edPublicKey) {
20
+ const digest = createHash("sha256")
21
+ .update(Buffer.concat([Buffer.from("pipeup-inbox/1"), Buffer.from(edPublicKey, "base64url")]))
22
+ .digest();
23
+ let bits = 0;
24
+ let acc = 0;
25
+ let out = "";
26
+ for (const byte of digest) {
27
+ acc = (acc << 8) | byte;
28
+ bits += 8;
29
+ while (bits >= 5 && out.length < 26) {
30
+ out += ALPHABET[(acc >>> (bits - 5)) & 31];
31
+ bits -= 5;
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Whether a hash has at least `bits` leading zero bits (the small proof of work that makes flooding someone cost something). */
38
+ export function work(inbox, ct, nonce, bits) {
39
+ const d = sha(`${inbox}|${ct}|${nonce}`);
40
+ let n = 0;
41
+ for (const byte of d) {
42
+ if (byte === 0) n += 8;
43
+ else {
44
+ n += Math.clz32(byte) - 24;
45
+ break;
46
+ }
47
+ if (n >= bits) break;
48
+ }
49
+ return n >= bits;
50
+ }
51
+
52
+ const spki = (ed) =>
53
+ createPublicKey({
54
+ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(ed, "base64url")]),
55
+ format: "der",
56
+ type: "spki",
57
+ });
58
+ const signedBy = (ed, text, sig) => {
59
+ try {
60
+ return ED.test(ed) && verify(null, Buffer.from(text), spki(ed), Buffer.from(sig, "base64url"));
61
+ } catch {
62
+ return false;
63
+ }
64
+ };
65
+
66
+ /**
67
+ * @param {object} o
68
+ * @param {string | null} o.dir where inboxes are kept (null: memory only)
69
+ * @param {() => number} o.now
70
+ * @param {number} o.work required proof-of-work bits for a note
71
+ * @param {string} [o.token] when set, a note needs `Authorization: Bearer <it>`
72
+ * @param {number} o.maxNotes per inbox
73
+ * @param {number} o.keepDays 0 keeps for ever
74
+ * @param {object} h shared helpers from the server
75
+ */
76
+ export function createInbox(o, h) {
77
+ const boxes = new Map();
78
+ if (o.dir) mkdirSync(o.dir, { recursive: true });
79
+ const file = (id, ext) => join(o.dir, `i-${id}.${ext}`);
80
+ const expired = (b) => o.keepDays > 0 && o.now() > b.last + o.keepDays * 864e5;
81
+ function box(id, create = false) {
82
+ let b = boxes.get(id);
83
+ if (!b && o.dir && existsSync(file(id, "json"))) {
84
+ b = JSON.parse(readFileSync(file(id, "json"), "utf8"));
85
+ b.notes = existsSync(file(id, "jsonl"))
86
+ ? readFileSync(file(id, "jsonl"), "utf8")
87
+ .split("\n")
88
+ .filter(Boolean)
89
+ .map((l) => JSON.parse(l))
90
+ : [];
91
+ b.ids = new Set(b.notes.map((n) => n.id));
92
+ boxes.set(id, b);
93
+ }
94
+ if (b && expired(b)) {
95
+ boxes.delete(id);
96
+ if (o.dir) for (const e of ["json", "jsonl"]) rmSync(file(id, e), { force: true });
97
+ b = undefined;
98
+ }
99
+ if (!b && create) {
100
+ b = { id, key: null, seq: 0, last: o.now(), notes: [], ids: new Set() };
101
+ boxes.set(id, b);
102
+ }
103
+ return b;
104
+ }
105
+ const meta = (b) => ({ id: b.id, key: b.key, seq: b.seq, last: b.last });
106
+ const save = (b) => o.dir && writeFileSync(file(b.id, "json"), JSON.stringify(meta(b)));
107
+ const rewrite = (b) =>
108
+ o.dir && writeFileSync(file(b.id, "jsonl"), b.notes.map((n) => JSON.stringify(n) + "\n").join(""));
109
+
110
+ /** Handles a request to /i/<inbox>[/key|/<note>]; returns false when the path is not an inbox's. */
111
+ async function handle(req, res, path, method, url) {
112
+ const m = /^(.*)\/i\/([0-9A-Z]{26})(?:\/(key|[A-Za-z0-9_-]{22}))?$/.exec(path);
113
+ if (!m) return false;
114
+ const [, , id, tail] = m;
115
+ const allowed = tail === "key" ? ["GET", "PUT"] : tail ? ["DELETE"] : ["GET", "POST"];
116
+ if (!allowed.includes(method)) {
117
+ h.fail(res, 405, "method", "Method not allowed here.", {}, { Allow: allowed.join(", ") });
118
+ return true;
119
+ }
120
+ if (method !== "DELETE") {
121
+ const wait = h.limited(req, method === "GET" ? "GET" : "POST");
122
+ if (wait) {
123
+ h.fail(res, 429, "rate", "Too many requests.", { retryAfter: wait }, { "Retry-After": String(wait) });
124
+ return true;
125
+ }
126
+ }
127
+ // The person's public encryption key, which senders seal to; set by them, proved by their own signature.
128
+ if (tail === "key") {
129
+ if (method === "GET") {
130
+ const b = box(id);
131
+ if (!b?.key) h.fail(res, 404, "gone", "No key for this inbox yet.");
132
+ else h.send(res, 200, { v: 2, ...b.key });
133
+ return true;
134
+ }
135
+ const text = await h.readBody(req);
136
+ const body = text === null ? undefined : h.json(text);
137
+ if (!body) return (h.fail(res, 400, "bad-request", "Body is not a JSON object."), true);
138
+ const { ed, pub, sig } = body;
139
+ if (typeof ed !== "string" || inboxId(ed) !== id || !ED.test(String(pub)) || typeof sig !== "string")
140
+ return (h.fail(res, 400, "bad-request", "That key does not belong to this inbox."), true);
141
+ if (!signedBy(ed, frame("inbox/key", `${id}:${pub}`), sig))
142
+ return (h.fail(res, 403, "signature", "Signature wrong."), true);
143
+ const b = box(id, true);
144
+ b.key = { ed, pub, sig };
145
+ b.last = o.now();
146
+ save(b);
147
+ h.send(res, 200, { v: 2 });
148
+ return true;
149
+ }
150
+ if (method === "DELETE") {
151
+ const b = box(id);
152
+ const note = b?.notes.find((n) => n.id === tail);
153
+ if (!b || !note) return (h.fail(res, 404, "gone", "No such note."), true);
154
+ const ed = String(req.headers["x-pipeup-key"] ?? "");
155
+ const sig = String(req.headers["x-pipeup-sig"] ?? "");
156
+ if (inboxId(ed) !== id || !signedBy(ed, frame("inbox/delete", `${id}:${tail}`), sig))
157
+ return (h.fail(res, 403, "signature", "Only the inbox's owner can take a note away."), true);
158
+ b.notes = b.notes.filter((n) => n.id !== tail);
159
+ b.ids.delete(tail);
160
+ rewrite(b);
161
+ h.send(res, 204, null);
162
+ return true;
163
+ }
164
+ if (method === "GET") {
165
+ const b = box(id);
166
+ if (!b) return (h.fail(res, 404, "gone", "No such inbox."), true);
167
+ const since = url.searchParams.get("since") ?? "";
168
+ if (since !== "" && !/^\d{1,15}$/.test(since))
169
+ return (h.fail(res, 409, "cursor", "Unknown cursor."), true);
170
+ const fresh = b.notes.filter((n) => n.n > Number(since || 0));
171
+ const page = fresh.slice(0, 200);
172
+ h.send(res, 200, {
173
+ v: 2,
174
+ notes: page.map(({ id: noteId, ct }) => ({ id: noteId, ct })),
175
+ next: String(page.length ? page[page.length - 1].n : Number(since || 0)),
176
+ more: fresh.length > page.length,
177
+ });
178
+ return true;
179
+ }
180
+ // POST: leave a note.
181
+ const text = await h.readBody(req, MAX_BODY);
182
+ if (text === null) return (h.fail(res, 413, "too-large", "Note too large."), true);
183
+ const body = h.json(text);
184
+ if (!body) return (h.fail(res, 400, "bad-request", "Body is not a JSON object."), true);
185
+ if (o.token) {
186
+ const bearer = (/^Bearer (.+)$/.exec(req.headers.authorization ?? "") ?? [])[1] ?? "";
187
+ if (!h.same(bearer, o.token)) return (h.fail(res, 403, "token", "Note token wrong or missing."), true);
188
+ }
189
+ const { ct, work: nonce } = body;
190
+ if (typeof ct !== "string" || !CT.test(ct) || ct.length > MAX_CT)
191
+ return (h.fail(res, 400, "bad-request", "Bad ct."), true);
192
+ if (typeof nonce !== "string" || nonce.length > 64 || !work(id, ct, nonce, o.work))
193
+ return (
194
+ h.fail(res, 403, "work", `A note needs a proof of work of ${o.work} bits.`, { bits: o.work }),
195
+ true
196
+ );
197
+ const b = box(id, true);
198
+ const noteId = b64u(sha(ct)).slice(0, 22);
199
+ if (b.ids.has(noteId)) return (h.send(res, 200, { v: 2 }), true);
200
+ if (b.notes.length >= o.maxNotes) return (h.fail(res, 507, "full", "This inbox is full."), true);
201
+ const note = { id: noteId, ct, n: ++b.seq };
202
+ b.notes.push(note);
203
+ b.ids.add(noteId);
204
+ b.last = o.now();
205
+ if (o.dir) {
206
+ save(b);
207
+ appendFileSync(file(id, "jsonl"), JSON.stringify(note) + "\n");
208
+ }
209
+ h.send(res, 201, { v: 2 });
210
+ return true;
211
+ }
212
+ return { handle };
213
+ }
package/src/server.mjs CHANGED
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
4
4
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { createInbox } from "./inbox.mjs";
7
8
 
8
9
  const MAX_BODY = 524288;
9
10
  const PAGE_BYTES = 4 * 1024 * 1024;
@@ -27,6 +28,9 @@ const same = (a, b) => timingSafeEqual(sha(a), sha(b));
27
28
  * @property {() => number} [now] clock in ms (tests)
28
29
  * @property {number} [pageBytes] page size limits (tests)
29
30
  * @property {number} [pageBatches]
31
+ * @property {number} [inboxWork] proof-of-work bits a note in a person's inbox needs (default 14; contract pm2)
32
+ * @property {string} [inboxToken] when set, a note needs `Authorization: Bearer <it>`
33
+ * @property {number} [inboxNotes] notes one inbox holds (default 1000)
30
34
  */
31
35
 
32
36
  /**
@@ -109,14 +113,14 @@ export function createMailboxServer(options = {}) {
109
113
  }
110
114
 
111
115
  /** Reads the body as text; null when it is over the limit (the rest is drained). */
112
- function readBody(req) {
116
+ function readBody(req, max = MAX_BODY) {
113
117
  return new Promise((resolve) => {
114
118
  const parts = [];
115
119
  let size = 0;
116
- let over = Number(req.headers["content-length"] ?? 0) > MAX_BODY;
120
+ let over = Number(req.headers["content-length"] ?? 0) > max;
117
121
  req.on("data", (c) => {
118
122
  size += c.length;
119
- if (size > MAX_BODY) over = true;
123
+ if (size > max) over = true;
120
124
  if (!over) parts.push(c);
121
125
  });
122
126
  req.on("end", () => resolve(over ? null : Buffer.concat(parts).toString("utf8")));
@@ -132,18 +136,28 @@ export function createMailboxServer(options = {}) {
132
136
  }
133
137
  };
134
138
 
139
+ const inbox = createInbox(
140
+ { dir, now, work: o.inboxWork ?? 14, token: o.inboxToken, maxNotes: o.inboxNotes ?? 1000, keepDays },
141
+ { send, fail, limited, readBody, json, same },
142
+ );
143
+
135
144
  async function handle(req, res) {
136
145
  const url = new URL(req.url ?? "/", "http://x");
137
146
  const path = url.pathname.replace(/\/+$/, "");
138
147
  const method = req.method ?? "GET";
139
148
  if (method === "OPTIONS") {
149
+ // pm1 answers exactly as before; an inbox (pm2) also needs PUT and its two signature headers.
150
+ const inboxPath = /\/i\/[0-9A-Z]{26}/.test(path);
140
151
  return send(res, 204, null, {
141
- "Access-Control-Allow-Methods": "GET, POST, DELETE",
142
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
152
+ "Access-Control-Allow-Methods": inboxPath ? "GET, POST, PUT, DELETE" : "GET, POST, DELETE",
153
+ "Access-Control-Allow-Headers": inboxPath
154
+ ? "Content-Type, Authorization, X-Pipeup-Key, X-Pipeup-Sig"
155
+ : "Content-Type, Authorization",
143
156
  "Access-Control-Max-Age": "86400",
144
157
  "Access-Control-Allow-Private-Network": "true",
145
158
  });
146
159
  }
160
+ if (await inbox.handle(req, res, path, method, url)) return;
147
161
  const mm = /^(.*)\/m\/([A-Za-z0-9_-]{22,64})(\/ops)?$/.exec(path);
148
162
  const isCreate = /\/m$/.test(path) && !mm;
149
163
  if (!mm && !isCreate) return fail(res, 404, "not-found", "No such endpoint.");