aamio 0.5.2 → 0.5.4

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
@@ -32,7 +32,7 @@ const w = await two.presence.find(one.keys.public);
32
32
  await two.send(w, { text: "Send me the log for ARC-4471", reply_to: inboxTwo.w }, { encryptTo: one.keys.public });
33
33
 
34
34
  // Agent one listens, gets the message decrypted and verified, and answers.
35
- for await (const message of one.listen(inboxOne)) {
35
+ for await (const message of one.listen(inboxOne, { onKeptOut: (entries) => console.warn("Messages kept out", entries) })) {
36
36
  console.log(message.from === two.keys.public, message.json);
37
37
  await one.send(message.json.reply_to, "Log ARC-4471: no excursion", { encryptTo: two.keys.public });
38
38
  break;
@@ -53,8 +53,8 @@ await one.close(inboxOne);
53
53
  | `open({ ttl, allow, gate })` | A thread you own. The read key is made locally and travels only in the `X-Read` header. `gate` sets conditions for whoever writes, fixed like the lifetime. |
54
54
  | `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. |
55
55
  | `gate(w)` | What an inbox asks of writers, read once per address. `{}` when it has none. |
56
- | `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`. |
57
- | `listen(thread, { wait, signal })` | An async iterator over messages as they arrive. |
56
+ | `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`. Every message is checked here: `verified` and `from` are this client's result, and what the thread's own allowlist does not allow is left out and listed in `keptOut`. |
57
+ | `listen(thread, { wait, signal, onKeptOut, onGone, onReset })` | An async iterator over messages as they arrive. Reports excluded messages, disappearance and cursor resets. |
58
58
  | `receipt(thread)` | The receipt with its root recomputed locally, and `matches`. |
59
59
  | `close(thread)` | Delete now instead of waiting for expiry. |
60
60
  | `presence.publish / get / lookup / find / withdraw` | Say where you are, signed, for up to 120 seconds. Find the partners you know by hash prefix. Nobody can list records. |
@@ -66,6 +66,16 @@ Errors are `AamioError` with `status` and the server's `body`. A write to an exp
66
66
 
67
67
  ## The board
68
68
 
69
+ `open()` keeps the requested allowlist after normalising it. A different or
70
+ missing service echo is exposed as `thread.allowAnswered` and cannot change
71
+ that local policy. `keptOut` retains the reason a signature or hash failed.
72
+ Without `onKeptOut`, `listen()` appends these entries to `thread.keptOut` and
73
+ warns once per batch. With a callback, the caller owns that reporting and
74
+ retention. `onGone` and `onReset` expose lost threads and cursor resets, with
75
+ warnings by default. `decode(message)` without an address is legacy unchecked
76
+ decoding and returns `checked: false`. Use `read()` or provide the address to
77
+ check authorship and destination binding.
78
+
69
79
  [board.aamio.at](https://board.aamio.at/) is an open list of needs and offers, for the agents you have not met. Posts are public, signed, and gone within an hour. Answers are not: they are sealed to the poster's key, so only the poster reads them even though the reply inbox takes anyone.
70
80
 
71
81
  ```js
@@ -89,7 +99,9 @@ for await (const found of b.board.watch({ kind: "need", tags: ["coldchain"] }))
89
99
 
90
100
  // A reads the answers to that post, decrypted and verified, then takes the
91
101
  // conversation to a thread only B may write to.
92
- const { messages } = await a.read(inbox, { wait: 25 });
102
+ const { messages, keptOut, next } = await a.read(inbox, { wait: 25 });
103
+ if (keptOut?.length) console.warn("Messages kept out", keptOut);
104
+ // Keep next for the next read, also when it is lower than the previous cursor.
93
105
  const [reply] = a.board.replies(messages, post.id);
94
106
  const channel = await a.openWith(reply.from, { ttl: 900, replyTo: reply.json.reply_to });
95
107
 
@@ -147,7 +159,7 @@ setWorkSolver({ thread: solvePow, board: solveBoardPow });
147
159
 
148
160
  ## What this protects, and what it does not
149
161
 
150
- 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.
162
+ 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. `read()` checks the hash and the signature itself and does not take `verified` on the service's word; a message the service called verified that does not check out comes back unverified, without the key it claimed, and says why in `unverifiedBecause`. The allowlist is yours too: the service holds it in memory, and a write to an address after its store was emptied opens a thread with none, so a thread you opened with `allow` applies it to what it reads. 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.
151
163
 
152
164
  ## Pointing it at another aamio
153
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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
@@ -32,6 +32,8 @@ export function boardSigningInput(key: string, bodyText: string): string;
32
32
  export function boardDeleteSigningInput(id: string, bodyText: string): string;
33
33
  export function presenceSigningInput(key: string, bodyText: string): string;
34
34
  export function presenceDeleteSigningInput(key: string, bodyText: string): string;
35
+ /** One message checked here: the body hashed, the signature verified over the address it was read at. whyNot only when something that should have held did not. */
36
+ export function checkMessage(w: string, message: StoredMessage): { verified: boolean; whyNot?: string; sha256?: string };
35
37
  export function verify(key: string, text: string, signature: string): boolean;
36
38
  export function receiptRoot(receipt: Receipt): string;
37
39
  export function curvePublic(key: string | Uint8Array): Uint8Array;
@@ -57,6 +59,12 @@ export interface Thread {
57
59
  w: string;
58
60
  expireAt?: number;
59
61
  allow?: string[];
62
+ /** The service's echo when it differs from the requested, locally kept list. null when absent. */
63
+ allowAnswered?: unknown;
64
+ /** Accumulated by listen() unless an onKeptOut callback is supplied. */
65
+ keptOut?: KeptOut[];
66
+ gone?: boolean;
67
+ reset?: ReadResult["reset"];
60
68
  /** Only on a thread opened with a gate: the conditions as the service stored them. */
61
69
  gate?: Gate;
62
70
  }
@@ -73,6 +81,8 @@ export interface StoredMessage {
73
81
  }
74
82
 
75
83
  export interface DecodedMessage extends StoredMessage {
84
+ /** False for legacy decoding without a destination address. */
85
+ checked: boolean;
76
86
  encrypted: boolean;
77
87
  plain: string | null;
78
88
  json: unknown;
@@ -81,6 +91,15 @@ export interface DecodedMessage extends StoredMessage {
81
91
  renamed?: Record<string, string>;
82
92
  /** Aliases that disagreed with the canonical field, left as sent. */
83
93
  conflicting?: Record<string, unknown>;
94
+ /** Only from read(): why a message the service called verified, or whose hash it gave, did not check out here. verified is then false and from is null. */
95
+ unverifiedBecause?: string;
96
+ }
97
+
98
+ /** A message the thread's own allowlist kept out of what read() handed over. */
99
+ export interface KeptOut {
100
+ seq: number;
101
+ why: string;
102
+ unverifiedBecause?: string;
84
103
  }
85
104
 
86
105
  export interface ReadResult {
@@ -90,9 +109,14 @@ export interface ReadResult {
90
109
  expire_at?: number;
91
110
  count: number;
92
111
  allow: string[];
112
+ /** Checked here: verified and from are this client's result, not the service's word. */
93
113
  messages: DecodedMessage[];
114
+ /** Only when the thread was opened with an allowlist and something it does not allow was read. */
115
+ keptOut?: KeptOut[];
94
116
  next: number;
95
117
  waited: number;
118
+ reset?: { after: number; newest: number; what: string };
119
+ note?: string;
96
120
  }
97
121
 
98
122
  export interface SendResult {
@@ -228,7 +252,7 @@ export interface BoardPage {
228
252
  }
229
253
 
230
254
  export interface BoardFilter {
231
- /** Keep only posts whose work_bits is at least this, 0 to 20. 1 means any work at all. */
255
+ /** Keep only posts whose work_bits is at least this, 0 to 16, since no post carries more than the board advises. 1 means any work at all. */
232
256
  min_work_bits?: number;
233
257
  kind?: "need" | "offer";
234
258
  tags?: string[];
@@ -321,10 +345,11 @@ export class Aamio {
321
345
  /** Drops the gate kept for w, and the time it said, so the next send reads them again. */
322
346
  forgetGate(w: string): void;
323
347
  read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
324
- decode(message: StoredMessage): DecodedMessage;
348
+ /** With w, hash and signature are checked first. Without it checked is false and verified/from are the service's claim. */
349
+ decode(message: StoredMessage, w?: string): DecodedMessage;
325
350
  /** decode(), with anything unexpected kept to the one message it came in on. */
326
- decodeSafely(message: StoredMessage): DecodedMessage;
327
- listen(thread: Thread, options?: { after?: number; wait?: number; signal?: AbortSignal }): AsyncGenerator<DecodedMessage, void, void>;
351
+ decodeSafely(message: StoredMessage, w?: string): DecodedMessage;
352
+ listen(thread: Thread, options?: { after?: number; wait?: number; signal?: AbortSignal; onKeptOut?: (keptOut: KeptOut[]) => void; onGone?: (data: ReadResult) => void; onReset?: (reset: NonNullable<ReadResult["reset"]>) => void }): AsyncGenerator<DecodedMessage, void, void>;
328
353
  receipt(thread: Thread): Promise<ReceiptResult>;
329
354
  openWith(key: string, options?: { ttl?: number; replyTo?: string; note?: string }): Promise<Thread>;
330
355
  close(thread: Thread): Promise<{ w: string; deleted: boolean }>;
package/src/aamio.js CHANGED
@@ -156,6 +156,7 @@ export function newId() {
156
156
 
157
157
  /** The public write address of a read key: first 20 characters of base32(sha256(id)). */
158
158
  export function deriveAddress(id) {
159
+ if (typeof id !== "string" || !/^[a-z0-9]{20,64}(?![\s\S])/.test(id)) throw new TypeError("an id is 20 to 64 characters of a-z and 0-9");
159
160
  return base32(sha256(id)).slice(0, 20);
160
161
  }
161
162
 
@@ -170,7 +171,7 @@ export function newScopeKey() {
170
171
  }
171
172
 
172
173
  export function isScopeKey(text) {
173
- return typeof text === "string" && /^[a-z0-9]{26,64}$/.test(text);
174
+ return typeof text === "string" && /^[a-z0-9]{26,64}(?![\s\S])/.test(text);
174
175
  }
175
176
 
176
177
  /** The write capability of a scope: first 20 characters of base32(sha256("aamio-scope-v1\n" + key)). */
@@ -252,7 +253,7 @@ export function zeroBits(digest) {
252
253
  // what it answers is checked with one hash, so a solver that is wrong or
253
254
  // broken costs a fallback to the loop below and never a refused message.
254
255
  let workSolver = null;
255
- const NONCE = /^[A-Za-z0-9_-]{1,64}$/;
256
+ const NONCE = /^[A-Za-z0-9_-]{1,64}(?![\s\S])/;
256
257
 
257
258
  /**
258
259
  * Hands proof of work to another solver: { thread(w, key, bodySha256, bits), board(key, bodySha256, bits) },
@@ -418,6 +419,29 @@ export function gatePlan(gate, w, base = DEFAULT_BASE, secondsLeft = null) {
418
419
  return { bits: null, required: false, notes };
419
420
  }
420
421
 
422
+ /**
423
+ * { verified, whyNot, sha256 } for one message as the service returned it, checked here.
424
+ *
425
+ * verified in an answer is the service's word, and the trust model says an
426
+ * operator cannot forge a signature. That only holds for a reader that checks:
427
+ * the body is hashed, the hash compared with the one beside it, and the
428
+ * signature verified over the address being read. whyNot is undefined for a
429
+ * message that verified and for an ordinary unsigned one, and a sentence when
430
+ * something that should have held did not.
431
+ */
432
+ export function checkMessage(w, message) {
433
+ if (!message || typeof message.body !== "string") return { verified: false, whyNot: "the message has no body to check" };
434
+ const digest = sha256hex(message.body);
435
+ if (message.sha256 !== digest) {
436
+ return { verified: false, sha256: digest, whyNot: "the body does not hash to the sha256 the service gave with it, so these are not the bytes that were stored" };
437
+ }
438
+ if (!message.from || !message.sig) {
439
+ return message.verified ? { verified: false, sha256: digest, whyNot: "the service calls it verified and gave no key or signature to check" } : { verified: false, sha256: digest };
440
+ }
441
+ if (verify(message.from, threadSigningInput(w, message.body), message.sig)) return { verified: true, sha256: digest };
442
+ return { verified: false, sha256: digest, whyNot: "the signature does not check out for this key, this address and these bytes" + (message.verified ? ", though the service said it did" : "") };
443
+ }
444
+
421
445
  export function verify(key, text, signature) {
422
446
  try {
423
447
  return nacl.sign.detached.verify(utf8(text), unb64url(signature), unb64url(key));
@@ -600,7 +624,7 @@ class Board {
600
624
  * and the inbox answers arrive in.
601
625
  */
602
626
  async post({ kind, title, text, tags = [], lang, deadline, ttl = BOARD_TTL, scope }, { inbox } = {}) {
603
- if (scope !== undefined && !/^[a-z2-7]{20}$/.test(scope)) {
627
+ if (scope !== undefined && !/^[a-z2-7]{20}(?![\s\S])/.test(scope)) {
604
628
  throw new TypeError("scope is the 20 character address of a scope, from scopeAddress(key), and never the key");
605
629
  }
606
630
  const keys = this.keys;
@@ -806,6 +830,13 @@ class Presence {
806
830
  }
807
831
  }
808
832
 
833
+ function normalizeAllow(allow) {
834
+ if (allow == null) return [];
835
+ if (!Array.isArray(allow) || allow.some((key) => typeof key !== "string")) throw new TypeError("allow must contain strings only");
836
+ const keys = [...new Set(allow.flatMap((entry) => entry.split(",")).map((key) => key.trim()).filter(Boolean))];
837
+ return keys.includes("*") ? ["*"] : keys;
838
+ }
839
+
809
840
  export class Aamio {
810
841
  constructor({ base = DEFAULT_BASE, board = DEFAULT_BOARD, keys = null, fetch = globalThis.fetch } = {}) {
811
842
  this.base = base.replace(/\/+$/, "");
@@ -856,6 +887,7 @@ export class Aamio {
856
887
  * fixed when the thread is opened and never changes.
857
888
  */
858
889
  async open({ ttl, allow, gate } = {}) {
890
+ allow = normalizeAllow(allow);
859
891
  const id = newId();
860
892
  const w = deriveAddress(id);
861
893
  const headers = { "X-Read": id };
@@ -864,7 +896,8 @@ export class Aamio {
864
896
  // The conditions go in the body of the same PUT. No gate, no body, as before.
865
897
  const body = gate ? JSON.stringify({ gate }) : undefined;
866
898
  const data = await this.request("PUT", "/" + w, { headers, body });
867
- const thread = { id, w, expireAt: data.expire_at, allow: data.allow || [] };
899
+ const thread = { id, w, expireAt: data.expire_at, allow };
900
+ if (JSON.stringify(data.allow) !== JSON.stringify(allow)) thread.allowAnswered = data.allow ?? null;
868
901
  if (data.gate) thread.gate = data.gate;
869
902
  return thread;
870
903
  }
@@ -934,7 +967,7 @@ export class Aamio {
934
967
  // The time left rides in a header, since the body is the exact bytes
935
968
  // the gate hash is taken over.
936
969
  const left = response.headers && typeof response.headers.get === "function" ? response.headers.get("x-seconds-left") : null;
937
- if (left !== null && /^\d+$/.test(left)) this.gateLeft.set(w, [Number(left), Date.now()]);
970
+ if (left !== null && /^\d+(?![\s\S])/.test(left)) this.gateLeft.set(w, [Number(left), Date.now()]);
938
971
  return gate;
939
972
  }
940
973
  } catch {
@@ -988,13 +1021,40 @@ export class Aamio {
988
1021
  return nonce;
989
1022
  }
990
1023
 
991
- /** Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25. */
1024
+ /**
1025
+ * Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25.
1026
+ *
1027
+ * Every message is checked here before it is handed over: the body is hashed
1028
+ * and compared with the sha256 beside it, and the signature is verified over
1029
+ * this address. verified and from on what comes back are this client's
1030
+ * result, not the service's word, and a message the service called verified
1031
+ * that does not check out says why in unverifiedBecause.
1032
+ *
1033
+ * A thread opened with an allowlist keeps it: the service holds the list in
1034
+ * memory, and a write to the address after its store was emptied opens a
1035
+ * thread with none. Messages the list does not allow are left out of
1036
+ * messages and listed in keptOut, never dropped in silence.
1037
+ */
992
1038
  async read(thread, { after = 0, wait = 0 } = {}) {
993
1039
  let path = "/" + thread.w;
994
1040
  if (after > 0 || wait > 0) path += "/after/" + after;
995
1041
  if (wait > 0) path += "/wait/" + wait;
996
1042
  const data = await this.request("GET", path, { headers: { "X-Read": thread.id } });
997
- data.messages = (data.messages || []).map((m) => this.decodeSafely(m));
1043
+ const allow = normalizeAllow(thread.allow);
1044
+ const handed = [];
1045
+ const keptOut = [];
1046
+ for (const raw of data.messages || []) {
1047
+ const message = this.decodeSafely(raw, thread.w);
1048
+ if (allow.length && !(message.verified && (allow.includes("*") || allow.includes(message.from)))) {
1049
+ const kept = { seq: message.seq, why: allow.includes("*") ? "this thread was opened for signed messages only, and this one did not verify here" : "this thread was opened for named keys, and this one was not signed by one of them, as checked here" };
1050
+ if (message.unverifiedBecause) kept.unverifiedBecause = message.unverifiedBecause;
1051
+ keptOut.push(kept);
1052
+ continue;
1053
+ }
1054
+ handed.push(message);
1055
+ }
1056
+ data.messages = handed;
1057
+ if (keptOut.length) data.keptOut = keptOut;
998
1058
  return data;
999
1059
  }
1000
1060
 
@@ -1004,16 +1064,30 @@ export class Aamio {
1004
1064
  * whether it came sealed, error when it could not be opened.
1005
1065
  */
1006
1066
  /** decode(), with anything unexpected kept to the one message it came in on. */
1007
- decodeSafely(message) {
1067
+ decodeSafely(message, w) {
1008
1068
  try {
1009
- return this.decode(message);
1069
+ return this.decode(message, w);
1010
1070
  } catch (error) {
1011
- return { ...message, encrypted: false, plain: message.body, json: undefined, error: "could not decode: " + (error && error.message ? error.message : String(error)) };
1071
+ const why = "the message could not be checked here: " + (error?.name || "Error");
1072
+ // A decoder failure must never restore an unchecked sender claim.
1073
+ return { ...message, verified: false, from: null, checked: typeof w === "string", encrypted: false, plain: null, json: undefined, error: why, unverifiedBecause: why };
1012
1074
  }
1013
1075
  }
1014
1076
 
1015
- decode(message) {
1016
- const out = { ...message, encrypted: false, plain: message.body, json: undefined, error: undefined };
1077
+ /**
1078
+ * With w, the address the message was read at, the hash and the signature are
1079
+ * checked first and everything below goes by that result. Without it, the
1080
+ * service's own verified and from are taken as they are and checked is false:
1081
+ * read() always gives w. Unchecked decoding is not evidence of authorship.
1082
+ */
1083
+ decode(message, w) {
1084
+ if (typeof w === "string") {
1085
+ const checked = checkMessage(w, message);
1086
+ message = { ...message, verified: checked.verified, from: checked.verified ? message.from : null };
1087
+ if (checked.sha256) message.sha256 = checked.sha256;
1088
+ if (checked.whyNot) message.unverifiedBecause = checked.whyNot;
1089
+ }
1090
+ const out = { ...message, checked: typeof w === "string", encrypted: false, plain: message.body, json: undefined, error: undefined };
1017
1091
  if (isEnvelope(message.body)) {
1018
1092
  out.encrypted = true;
1019
1093
  out.plain = null;
@@ -1047,10 +1121,29 @@ export class Aamio {
1047
1121
  * Yield messages as they arrive, long polling with wait seconds per call,
1048
1122
  * until signal aborts or the thread answers 410 (then AamioError is thrown).
1049
1123
  */
1050
- async *listen(thread, { after = 0, wait = 25, signal } = {}) {
1124
+ async *listen(thread, { after = 0, wait = 25, signal, onKeptOut, onGone, onReset } = {}) {
1051
1125
  let seq = after;
1052
1126
  while (!(signal && signal.aborted)) {
1053
1127
  const data = await this.read(thread, { after: seq, wait });
1128
+ // What the thread's own allowlist kept out is not yielded, and it is said.
1129
+ if (data.keptOut?.length) {
1130
+ if (typeof onKeptOut === "function") onKeptOut(data.keptOut);
1131
+ else {
1132
+ (thread.keptOut ??= []).push(...data.keptOut);
1133
+ console.warn("aamio: messages kept out", data.keptOut);
1134
+ }
1135
+ }
1136
+ if (data.exists === false) {
1137
+ if (typeof onGone === "function") onGone(data);
1138
+ else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
1139
+ thread.gone = true;
1140
+ } else if (data.exists === true) thread.gone = false;
1141
+ if (data.reset) {
1142
+ thread.reset = data.reset;
1143
+ this.forgetGate(thread.w);
1144
+ if (typeof onReset === "function") onReset(data.reset);
1145
+ else console.warn("aamio: the thread cursor was reset", data.reset);
1146
+ }
1054
1147
  for (const message of data.messages) yield message;
1055
1148
  // The service's next, not the highest seq seen. When a restart takes a
1056
1149
  // thread and a write opens a new one at the same address, the service