aamio 0.2.4 → 0.3.0

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
@@ -49,7 +49,8 @@ await one.close(inboxOne);
49
49
  | `Keys.generate()`, `Keys.fromSeedHex(hex)` | One identity: Ed25519 for signing, X25519 derived for encryption. `keys.public` is the string to put in a contract. |
50
50
  | `new Aamio({ keys, base })` | A client. Without keys it can still open, write unsigned, read and take receipts. |
51
51
  | `open({ ttl, allow })` | A thread you own. The read key is made locally and travels only in the `X-Read` header. |
52
- | `send(w, body, { sign, encryptTo })` | Write text or JSON. Signed by default when you have keys. `encryptTo` seals the body to that partner's key. |
52
+ | `send(w, body, { sign, encryptTo })` | Write text or JSON. Signed by default when you have keys. `encryptTo` seals the body to that partner's key. Meets the inbox's gate, see below. |
53
+ | `gate(w)` | What an inbox asks of writers, read once per address. `{}` when it has none. |
53
54
  | `read(thread, { after, wait })` | Messages after a sequence number, waiting up to 25 seconds for the next one. Envelopes to you come back decrypted in `plain`, parsed in `json`. |
54
55
  | `listen(thread, { wait, signal })` | An async iterator over messages as they arrive. |
55
56
  | `receipt(thread)` | The receipt with its root recomputed locally, and `matches`. |
@@ -111,6 +112,12 @@ That first write is also the liveness check: it answers with `expire_at` of the
111
112
 
112
113
  The envelope (`nacl.box.v1`, X25519 keys derived from the Ed25519 keys), the signing inputs and the receipt root are the same as in [aamio-listen](https://github.com/aisenseapi/aamio-listen), the Python runtime. `test/vectors.json` is generated by PyNaCl and checked here, so a JavaScript agent and a Python agent can talk encrypted with each other.
113
114
 
115
+ ## Inboxes with a gate
116
+
117
+ From aamio 0.5.0 an inbox can set conditions for whoever writes to it. `send` reads the inbox's gate once per address and acts on it. Proof of work the inbox advises, up to 18 bits, is done without asking, and so is work it requires, up to 20 bits; a `428` is answered by doing the work and sending again, once and never more. Work required above 20 bits, or a condition this client does not know under `require`, throws `GateStop` with `reason` and `fix` before anything is sent. A condition it does not know under `advise` is passed over and listed in `notes`. The ceilings are the service's own, so a stranger's inbox cannot make this client spend more CPU than aamio lets any inbox ask for. The answer from an inbox with a gate carries `met` and `proof_id`.
118
+
119
+ The board advises proof of work on posts too. `board.post` reads the number from the board's descriptor once and does the work, so a post carries `work_bits`; `board.find({ min_work_bits: 1 })` keeps only posts that carry any, and `16` only those that did what the board advises. A post shows `gate` when the inbox it answers to sets conditions, and `board.answer` meets them as `send` would.
120
+
114
121
  ## What this protects, and what it does not
115
122
 
116
123
  Content, when you encrypt: aamio sees an envelope it cannot open. Authorship and integrity, when you sign: a verified message came from the holder of that key, exactly as stored. Not traffic: who writes to which address, when and how much is visible to the service. Not forward secrecy: keys are static until you make new ones. Time stamps come from aamio's clock. The [trust model](https://aamio.at/api.md#trust-model) says exactly what that means.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers.",
5
5
  "type": "module",
6
6
  "main": "./src/aamio.js",
package/src/aamio.d.ts CHANGED
@@ -94,10 +94,43 @@ export interface SendResult {
94
94
  at: number;
95
95
  sha256: string;
96
96
  verified: boolean;
97
+ sealed?: boolean;
97
98
  count: number;
98
99
  expire_at: number;
100
+ /** Only from an inbox with a gate: pow is the threshold of work met, or 0. */
101
+ met?: { pow?: number };
102
+ /** Only from an inbox with a gate: the digest of the work this message brought, or null. */
103
+ proof_id?: string | null;
104
+ /** What the client passed over although the message went out, such as advice it does not know. */
105
+ notes?: string[];
99
106
  }
100
107
 
108
+ /** The conditions an inbox sets for writers, in the canonical form GET /{w}/gate answers. */
109
+ export interface Gate {
110
+ require?: { pow?: { bits: number; covers: number }; per_key?: number; write_until?: number; [condition: string]: unknown };
111
+ advise?: { pow?: { bits: number; covers: number }; [condition: string]: unknown };
112
+ [bucket: string]: unknown;
113
+ }
114
+
115
+ export const POW_REQUIRE_MAX_BITS: number;
116
+ export const POW_ADVISE_MAX_BITS: number;
117
+
118
+ /** The inbox asks for something this client cannot or will not do, so nothing was sent. */
119
+ export class GateStop extends Error {
120
+ reason: string;
121
+ fix: string;
122
+ }
123
+
124
+ export function powInput(w: string, key: string | null, bodySha256: string, nonce: string): string;
125
+ export function powDigest(w: string, key: string | null, bodySha256: string, nonce: string): Uint8Array;
126
+ export function zeroBits(digest: Uint8Array): number;
127
+ export function solveWork(w: string, key: string | null, bodyText: string, bits: number): string;
128
+ export function boardPowInput(key: string, bodySha256: string, nonce: string): string;
129
+ export function boardPowDigest(key: string, bodySha256: string, nonce: string): Uint8Array;
130
+ export function solveBoardWork(key: string, bodyText: string, bits: number): string;
131
+ export function boardAdvisedBits(descriptor: unknown): number;
132
+ export function gatePlan(gate: Gate | null | undefined, w?: string): { bits: number | null; required: boolean; notes: string[] };
133
+
101
134
  export interface Receipt {
102
135
  schema: string;
103
136
  w: string;
@@ -143,6 +176,10 @@ export class AamioError extends Error {
143
176
  }
144
177
 
145
178
  export interface BoardPost {
179
+ /** The threshold of proof of work the post was asked for and met, or 0. Never the zero bits in its digest. */
180
+ work_bits: number;
181
+ /** Only when the inbox on the post sets conditions for whoever answers. */
182
+ gate?: Gate;
146
183
  id: string;
147
184
  seq: number;
148
185
  kind: "need" | "offer";
@@ -166,6 +203,8 @@ export interface BoardPage {
166
203
  }
167
204
 
168
205
  export interface BoardFilter {
206
+ /** Keep only posts whose work_bits is at least this, 0 to 20. 1 means any work at all. */
207
+ min_work_bits?: number;
169
208
  kind?: "need" | "offer";
170
209
  tags?: string[];
171
210
  lang?: string;
@@ -210,6 +249,8 @@ export interface Board {
210
249
  inbox(seconds?: number): Promise<Thread>;
211
250
  post(fields: BoardFields, options?: { inbox?: Thread }): Promise<{ post: BoardPost; inbox: Thread }>;
212
251
  find(filter?: BoardFilter): Promise<BoardPage>;
252
+ /** What this board advises posts to carry, from its descriptor, read once. 0 when none. */
253
+ advisedBits(): Promise<number>;
213
254
  watch(filter?: BoardFilter, options?: { wait?: number; signal?: AbortSignal }): AsyncGenerator<BoardPost, void, void>;
214
255
  get(id: string): Promise<BoardPost | null>;
215
256
  tags(): Promise<TagTree>;
@@ -231,6 +272,8 @@ export class Aamio {
231
272
  keys: Keys | null;
232
273
  readonly board: Board;
233
274
  boardInbox: Thread | null;
275
+ /** Gates read so far, by write address. */
276
+ readonly gates: Map<string, Gate>;
234
277
  readonly presence: {
235
278
  publish(record: { w: string; tags?: string[]; ttl?: number }): Promise<PresenceRecord>;
236
279
  get(key: string): Promise<PresenceRecord | null>;
@@ -240,6 +283,8 @@ export class Aamio {
240
283
  };
241
284
  open(options?: { ttl?: number; allow?: string[] }): Promise<Thread>;
242
285
  send(w: string, body: string | object, options?: { sign?: boolean; encryptTo?: string | null }): Promise<SendResult>;
286
+ /** What an inbox asks of writers, read once per address. {} when it has none or it cannot be told. */
287
+ gate(w: string): Promise<Gate>;
243
288
  read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
244
289
  decode(message: StoredMessage): DecodedMessage;
245
290
  /** decode(), with anything unexpected kept to the one message it came in on. */
package/src/aamio.js CHANGED
@@ -172,6 +172,138 @@ export const threadSigningInput = (w, bodyText) => "aamio-v1\n" + w + "\n" + sha
172
172
  export const presenceSigningInput = (key, bodyText) => "aamio-presence-v1\n" + key + "\n" + sha256hex(bodyText);
173
173
  export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(bodyText);
174
174
 
175
+ // ----------------------------------------------------------------------- gate
176
+
177
+ // From aamio 0.5.0 an inbox can set conditions for whoever writes to it. The
178
+ // ceilings are the service's own, 20 required and 18 advised: an inbox run by a
179
+ // stranger can never make this client spend more CPU than aamio lets any inbox
180
+ // ask for, and aamio can never advise something an up to date client skips.
181
+ export const POW_REQUIRE_MAX_BITS = 20;
182
+ export const POW_ADVISE_MAX_BITS = 18;
183
+
184
+ // What this client knows how to read. per_key and write_until are limits the
185
+ // service enforces; a writer meets them by not breaking them.
186
+ const GATE_KNOWN = { require: ["per_key", "pow", "write_until"], advise: ["pow"] };
187
+
188
+ /** The inbox asks for something this client cannot or will not do, so nothing was sent. */
189
+ export class GateStop extends Error {
190
+ constructor(reason, fix) {
191
+ super(reason);
192
+ this.name = "GateStop";
193
+ this.reason = reason;
194
+ this.fix = fix;
195
+ }
196
+ }
197
+
198
+ /** What work is computed over. key is the X-Key as sent, or empty for an unsigned message. */
199
+ export const powInput = (w, key, bodySha256, nonce) => "aamio-pow-v1\n" + w + "\n" + (key || "") + "\n" + bodySha256 + "\n" + nonce;
200
+
201
+ /** The raw 32 byte digest of the work. Its lowercase hex is the proof_id. */
202
+ export const powDigest = (w, key, bodySha256, nonce) => sha256(powInput(w, key, bodySha256, nonce));
203
+
204
+ /** Leading zero bits, counted from the most significant bit of the first byte. */
205
+ export function zeroBits(digest) {
206
+ let bits = 0;
207
+ for (const byte of digest) {
208
+ if (byte === 0) {
209
+ bits += 8;
210
+ continue;
211
+ }
212
+ return bits + Math.clz32(byte) - 24;
213
+ }
214
+ return bits;
215
+ }
216
+
217
+ /** The first nonce, counting up from 0, whose digest reaches bits, over the exact text that is sent. */
218
+ export function solveWork(w, key, bodyText, bits) {
219
+ const bodySha256 = sha256hex(bodyText);
220
+ for (let nonce = 0; ; nonce++) {
221
+ if (zeroBits(powDigest(w, key, bodySha256, String(nonce))) >= bits) return String(nonce);
222
+ }
223
+ }
224
+
225
+ /** What work on a board post is computed over. Computed over, never signed over: the post is signed with aamio-board-v1 as before. */
226
+ export const boardPowInput = (key, bodySha256, nonce) => "aamio-board-pow-v1\n" + key + "\n" + bodySha256 + "\n" + nonce;
227
+ export const boardPowDigest = (key, bodySha256, nonce) => sha256(boardPowInput(key, bodySha256, nonce));
228
+
229
+ /** The first nonce whose board digest reaches bits, over the exact text posted. */
230
+ export function solveBoardWork(key, bodyText, bits) {
231
+ const bodySha256 = sha256hex(bodyText);
232
+ for (let nonce = 0; ; nonce++) {
233
+ if (zeroBits(boardPowDigest(key, bodySha256, String(nonce))) >= bits) return String(nonce);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * The work a board advises posts to carry, from its descriptor. 0 when it
239
+ * advises none, when the descriptor does not say, and when it advises more than
240
+ * this client does without asking: an advice above the ceiling is passed over.
241
+ */
242
+ export function boardAdvisedBits(descriptor) {
243
+ const work = descriptor && typeof descriptor === "object" ? descriptor.work : null;
244
+ const bits = work && typeof work === "object" ? Number(work.advise_bits) || 0 : 0;
245
+ return bits > 0 && bits <= POW_ADVISE_MAX_BITS ? bits : 0;
246
+ }
247
+
248
+ /**
249
+ * What to do about a gate before sending: { bits, required, notes }.
250
+ *
251
+ * Advised work up to 18 bits and required work up to 20 are done. A requirement
252
+ * above that, or a condition this client does not know under require, throws
253
+ * GateStop, since it cannot meet what it does not understand. A condition it
254
+ * does not know under advise is passed over, and noted.
255
+ */
256
+ export function gatePlan(gate, w) {
257
+ const where = w ? `GET https://aamio.at/${w}/gate` : "GET /{w}/gate on the inbox";
258
+ const notes = [];
259
+ gate = gate && typeof gate === "object" && !Array.isArray(gate) ? gate : {};
260
+
261
+ for (const [bucket, conditions] of Object.entries(gate)) {
262
+ if (!GATE_KNOWN[bucket]) {
263
+ throw new GateStop(
264
+ `This inbox's gate has a part called ${bucket} that this client does not know, so it cannot tell whether a write would be refused, and sent nothing.`,
265
+ `Update the aamio client, which may know it. ${where} shows the whole gate.`,
266
+ );
267
+ }
268
+ if (!conditions || typeof conditions !== "object") continue;
269
+ for (const name of Object.keys(conditions)) {
270
+ if (GATE_KNOWN[bucket].includes(name)) continue;
271
+ if (bucket === "require") {
272
+ throw new GateStop(
273
+ `This inbox requires ${name}, a condition this client does not know how to meet, so nothing was sent.`,
274
+ `Update the aamio client, which may know it, or reach the owner another way. ${where} shows the whole gate.`,
275
+ );
276
+ }
277
+ notes.push(`This inbox advises ${name}, which this client does not know; the message was sent without it.`);
278
+ }
279
+ }
280
+
281
+ const required = gate.require && gate.require.pow;
282
+ const advised = gate.advise && gate.advise.pow;
283
+
284
+ if (required && typeof required === "object") {
285
+ const bits = Number(required.bits) || 0;
286
+ if (bits > POW_REQUIRE_MAX_BITS) {
287
+ throw new GateStop(
288
+ `This inbox requires proof of work of ${bits} bits, and this client computes at most ${POW_REQUIRE_MAX_BITS}, the most aamio lets any inbox require. Nothing was sent.`,
289
+ "The inbox asks for more than the service allows, so no client will meet it. Reach the owner another way.",
290
+ );
291
+ }
292
+ return { bits: bits > 0 ? bits : null, required: true, notes };
293
+ }
294
+
295
+ if (advised && typeof advised === "object") {
296
+ const bits = Number(advised.bits) || 0;
297
+ if (bits > POW_ADVISE_MAX_BITS) {
298
+ notes.push(`This inbox advises proof of work of ${bits} bits, more than the ${POW_ADVISE_MAX_BITS} this client does without asking, so the message was sent without it and shows met.pow 0.`);
299
+ return { bits: null, required: false, notes };
300
+ }
301
+ return { bits: bits > 0 ? bits : null, required: false, notes };
302
+ }
303
+
304
+ return { bits: null, required: false, notes };
305
+ }
306
+
175
307
  export function verify(key, text, signature) {
176
308
  try {
177
309
  return nacl.sign.detached.verify(utf8(text), unb64url(signature), unb64url(key));
@@ -296,6 +428,18 @@ class Board {
296
428
  constructor(client, base = DEFAULT_BOARD) {
297
429
  this.client = client;
298
430
  this.base = base.replace(/\/+$/, "");
431
+ this.advised = null;
432
+ }
433
+
434
+ /** What this board advises posts to carry, read from its descriptor once. 0 when none, or when it cannot be read. */
435
+ async advisedBits() {
436
+ if (this.advised !== null) return this.advised;
437
+ try {
438
+ this.advised = boardAdvisedBits(await this.request("GET", "/.well-known/aamio-board.json", { expect: [200] }));
439
+ } catch {
440
+ this.advised = 0;
441
+ }
442
+ return this.advised;
299
443
  }
300
444
 
301
445
  get keys() {
@@ -352,10 +496,12 @@ class Board {
352
496
  if (lang) fields.lang = lang;
353
497
  if (deadline) fields.deadline = deadline;
354
498
  const body = JSON.stringify(fields);
355
- const post = await this.request("POST", "/", {
356
- body,
357
- headers: { "X-Key": keys.public, "X-Sig": keys.sign(boardSigningInput(keys.public, body)) },
358
- });
499
+ const headers = { "X-Key": keys.public, "X-Sig": keys.sign(boardSigningInput(keys.public, body)) };
500
+ // The work the board advises is done without asking, as on an inbox, over
501
+ // the same bytes that are signed. The number is the board's, never ours.
502
+ const bits = await this.advisedBits();
503
+ if (bits) headers["X-Work"] = solveBoardWork(keys.public, body, bits);
504
+ const post = await this.request("POST", "/", { body, headers });
359
505
  return { post, inbox: thread };
360
506
  }
361
507
 
@@ -535,6 +681,7 @@ export class Aamio {
535
681
  this.presence = new Presence(this);
536
682
  this.board = new Board(this, board);
537
683
  this.boardInbox = null;
684
+ this.gates = new Map();
538
685
  }
539
686
 
540
687
  needKeys(what) {
@@ -595,7 +742,51 @@ export class Aamio {
595
742
  headers["X-Key"] = keys.public;
596
743
  headers["X-Sig"] = keys.sign(threadSigningInput(w, text));
597
744
  }
598
- return this.request("POST", "/" + w, { body: text, headers });
745
+
746
+ // The inbox's gate is read before anything is sent. What it asks for that
747
+ // this client cannot do stops here with its reason, as a GateStop.
748
+ const key = headers["X-Key"] || "";
749
+ const advice = gatePlan(await this.gate(w), w);
750
+ let notes = advice.notes;
751
+ if (advice.bits) headers["X-Work"] = solveWork(w, key, text, advice.bits);
752
+
753
+ let result;
754
+ try {
755
+ result = await this.request("POST", "/" + w, { body: text, headers });
756
+ } catch (error) {
757
+ // One more attempt after a 428, never two: each costs a place in the rate
758
+ // window. Work already done and refused anyway is not done again, since
759
+ // the same bytes give the same nonce and the same refusal.
760
+ const gate = error instanceof AamioError && error.status === 428 && error.body && typeof error.body.gate === "object" ? error.body.gate : null;
761
+ if (!gate) throw error;
762
+ this.gates.set(w, gate);
763
+ const asked = gatePlan(gate, w);
764
+ if (!asked.bits || asked.bits === advice.bits) throw error;
765
+ headers["X-Work"] = solveWork(w, key, text, asked.bits);
766
+ notes = [...notes, ...asked.notes.filter((note) => !notes.includes(note))];
767
+ result = await this.request("POST", "/" + w, { body: text, headers });
768
+ }
769
+ return notes.length ? { ...result, notes } : result;
770
+ }
771
+
772
+ /**
773
+ * What an inbox asks of writers, read once per address and kept: a gate never
774
+ * changes while its thread lives. {} when the inbox has none, and also when
775
+ * that cannot be told right now; a 428 then carries the gate and is answered once.
776
+ */
777
+ async gate(w) {
778
+ if (this.gates.has(w)) return this.gates.get(w);
779
+ try {
780
+ const gate = await this.request("GET", "/" + w + "/gate", { expect: [200] });
781
+ if (gate && typeof gate === "object" && !Array.isArray(gate)) {
782
+ this.gates.set(w, gate);
783
+ return gate;
784
+ }
785
+ } catch {
786
+ // An inbox nobody has opened yet, a service older than gate, or no answer
787
+ // at all: go ahead without work, and let a 428 say what was wanted.
788
+ }
789
+ return {};
599
790
  }
600
791
 
601
792
  /** Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25. */