aamio 0.5.4 → 0.5.6
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 +54 -1
- package/package.json +3 -2
- package/src/aamio.d.ts +57 -1
- package/src/aamio.js +305 -2
package/README.md
CHANGED
|
@@ -64,6 +64,28 @@ await one.close(inboxOne);
|
|
|
64
64
|
|
|
65
65
|
Errors are `AamioError` with `status` and the server's `body`. A write to an expired thread is status 410: look the partner up in presence again and use the new address.
|
|
66
66
|
|
|
67
|
+
|
|
68
|
+
## Asking for a small answer
|
|
69
|
+
|
|
70
|
+
A thread may hold two hundred messages of 65536 bytes, so one read can be about a
|
|
71
|
+
megabyte. A count and a byte budget say how much of it to send, and the service
|
|
72
|
+
answers with whole messages only, because a signed message cut in half does not
|
|
73
|
+
verify. When something was left behind the answer says `more`, and the cursor
|
|
74
|
+
stands at the last message handed over, so reading again with it skips nothing.
|
|
75
|
+
When one message alone is larger than the whole budget it comes back named in
|
|
76
|
+
`too_large` with its size: it stays where it is, every read at that budget will
|
|
77
|
+
leave it, and you either raise the budget or step past its `seq`.
|
|
78
|
+
|
|
79
|
+
A service that does not offer `read-limits` ignores both and answers as it always
|
|
80
|
+
did, so asking costs nothing.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
const answer = await aamio.read(thread, { limit: 20, maxBytes: 8192 });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`answer.more` and `answer.too_large` come back untouched, and both are in the
|
|
87
|
+
TypeScript declarations.
|
|
88
|
+
|
|
67
89
|
## The board
|
|
68
90
|
|
|
69
91
|
`open()` keeps the requested allowlist after normalising it. A different or
|
|
@@ -157,6 +179,33 @@ import { solvePow, solveBoardPow } from "aamio-wasm";
|
|
|
157
179
|
setWorkSolver({ thread: solvePow, board: solveBoardPow });
|
|
158
180
|
```
|
|
159
181
|
|
|
182
|
+
## Domain keys
|
|
183
|
+
|
|
184
|
+
A website can publish the public keys of the agents it stands behind at
|
|
185
|
+
`https://<domain>/.well-known/aamio-keys.json`. These functions are that format
|
|
186
|
+
and nothing else: they open no connection, so reading the list is yours, and so
|
|
187
|
+
is the choice of key, which the result says as `supplied_key`. `aamio-python` and
|
|
188
|
+
`aamio-php` have the whole check, with the user's own list of websites and a read
|
|
189
|
+
that reaches only public addresses.
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
import { makeDomainKeys, matchDomainKey, normaliseDomain } from "aamio";
|
|
193
|
+
|
|
194
|
+
normaliseDomain("example.org"); // the exact host, or null
|
|
195
|
+
const found = matchDomainKey(body, theirKey); // body is a Uint8Array; a name as the third argument asks for that label
|
|
196
|
+
// found.status is listed, not_listed or invalid
|
|
197
|
+
const file = makeDomainKeys([{ key: me.public, name: "support" }]);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A browser cannot see which address a name resolves to, so it cannot hold the rule
|
|
201
|
+
that a read reaches only public addresses, and these functions do not try.
|
|
202
|
+
|
|
203
|
+
Take the domain from your own records and never from the message being checked:
|
|
204
|
+
anyone can list their own key on their own site. `listed` says this site listed
|
|
205
|
+
this key when it was read. It is not an identity and it permits nothing, and
|
|
206
|
+
`not_listed` is no evidence of fraud: a key rotated out looks the same. The bytes
|
|
207
|
+
are the exact ones the site served, since a list is checked as it was published.
|
|
208
|
+
|
|
160
209
|
## What this protects, and what it does not
|
|
161
210
|
|
|
162
211
|
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.
|
|
@@ -171,4 +220,8 @@ The hosts this client uses by default are in four constants at the top of `src/a
|
|
|
171
220
|
npm test
|
|
172
221
|
```
|
|
173
222
|
|
|
174
|
-
|
|
223
|
+
This needs no network. The two tests that run agents against aamio.at run only with `AAMIO_LIVE=1`, exactly, and leave nothing behind:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npm run test:live
|
|
227
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aamio",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"LICENSE"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test"
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"test:live": "node -e \"process.env.AAMIO_LIVE = '1'; process.exit(require('node:child_process').spawnSync(process.execPath, ['--test'], { stdio: 'inherit' }).status ?? 1)\""
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"tweetnacl": "^1.0.3"
|
package/src/aamio.d.ts
CHANGED
|
@@ -27,6 +27,45 @@ export function scopeAddress(scopeKey: string): string;
|
|
|
27
27
|
export function isKey(text: unknown): boolean;
|
|
28
28
|
export function keyHash(key: string): string;
|
|
29
29
|
export function hashPrefix(key: string, length?: number): string;
|
|
30
|
+
|
|
31
|
+
/** Domain keys: the format of a website's list of public aamio keys. It opens no connection. */
|
|
32
|
+
export const DOMAIN_KEYS_VERSION: 1;
|
|
33
|
+
export const DOMAIN_KEYS_PATH: "/.well-known/aamio-keys.json";
|
|
34
|
+
export const DOMAIN_KEYS_MAX_BYTES: 16384;
|
|
35
|
+
export const DOMAIN_KEYS_MAX_KEYS: 64;
|
|
36
|
+
/** A list that is not version 1. reason says why, without echoing the content. */
|
|
37
|
+
export class InvalidDomainKeys extends Error {
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
constructor(reason: string);
|
|
40
|
+
}
|
|
41
|
+
export interface DomainKey {
|
|
42
|
+
key: string;
|
|
43
|
+
/** A label. It authorises nothing. */
|
|
44
|
+
name?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface DomainMatch {
|
|
47
|
+
/** not_listed is no evidence of fraud: a key rotated out, the wrong name or the wrong domain look the same. */
|
|
48
|
+
status: "listed" | "not_listed" | "invalid";
|
|
49
|
+
reason: string;
|
|
50
|
+
detail?: string;
|
|
51
|
+
matched_name?: string | null;
|
|
52
|
+
/** Of the exact bytes compared, for fault finding. The domain did not sign it. */
|
|
53
|
+
document_sha256?: string;
|
|
54
|
+
/** The key was the caller's to choose and to answer for. */
|
|
55
|
+
subject_source: "supplied_key";
|
|
56
|
+
}
|
|
57
|
+
/** A public key as aamio writes it: 32 bytes, 43 characters, one spelling only. */
|
|
58
|
+
export function canonicalKey(value: unknown): boolean;
|
|
59
|
+
export function validDomainKeyName(value: unknown): boolean;
|
|
60
|
+
/** The exact host a list is read from, in lowercase, or null. */
|
|
61
|
+
export function normaliseDomain(value: unknown): string | null;
|
|
62
|
+
export function domainKeysUrl(domain: unknown): string | null;
|
|
63
|
+
/** The entries of a version 1 list, from the exact bytes. Throws InvalidDomainKeys. */
|
|
64
|
+
export function parseDomainKeys(raw: Uint8Array): DomainKey[];
|
|
65
|
+
/** Take the domain from your own records and never from the message being checked. A listed key permits nothing. */
|
|
66
|
+
export function matchDomainKey(raw: Uint8Array, key: string, name?: string | null): DomainMatch;
|
|
67
|
+
/** The file for a site to publish, in the bytes every generator writes. Throws InvalidDomainKeys. */
|
|
68
|
+
export function makeDomainKeys(entries: readonly DomainKey[]): Uint8Array;
|
|
30
69
|
export function threadSigningInput(w: string, bodyText: string): string;
|
|
31
70
|
export function boardSigningInput(key: string, bodyText: string): string;
|
|
32
71
|
export function boardDeleteSigningInput(id: string, bodyText: string): string;
|
|
@@ -116,6 +155,17 @@ export interface ReadResult {
|
|
|
116
155
|
next: number;
|
|
117
156
|
waited: number;
|
|
118
157
|
reset?: { after: number; newest: number; what: string };
|
|
158
|
+
/**
|
|
159
|
+
* Only when read() asked for a limit and the service had more after what it sent.
|
|
160
|
+
* next is the last message handed over, so passing it back as after skips nothing.
|
|
161
|
+
*/
|
|
162
|
+
more?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Only when one message alone is larger than the byte budget asked for. A signed
|
|
165
|
+
* message cannot be cut in half and still verify, so it is named rather than sent
|
|
166
|
+
* in pieces: it stays where it is and every read at this budget will leave it.
|
|
167
|
+
*/
|
|
168
|
+
too_large?: { seq: number; bytes: number; fix: string };
|
|
119
169
|
note?: string;
|
|
120
170
|
}
|
|
121
171
|
|
|
@@ -344,7 +394,13 @@ export class Aamio {
|
|
|
344
394
|
planFor(w: string): Promise<{ bits: number | null; required: boolean; notes: string[]; expectedSeconds?: number }>;
|
|
345
395
|
/** Drops the gate kept for w, and the time it said, so the next send reads them again. */
|
|
346
396
|
forgetGate(w: string): void;
|
|
347
|
-
|
|
397
|
+
/**
|
|
398
|
+
* limit and maxBytes ask the service for a small answer: X-Limit and X-Max-Bytes.
|
|
399
|
+
* Whole messages only. When something was left behind the answer carries more,
|
|
400
|
+
* and next is the last message handed over, so passing it back skips nothing.
|
|
401
|
+
* When one message alone is over budget the answer carries too_large naming it.
|
|
402
|
+
*/
|
|
403
|
+
read(thread: Thread, options?: { after?: number; wait?: number; limit?: number; maxBytes?: number }): Promise<ReadResult>;
|
|
348
404
|
/** With w, hash and signature are checked first. Without it checked is false and verified/from are the service's claim. */
|
|
349
405
|
decode(message: StoredMessage, w?: string): DecodedMessage;
|
|
350
406
|
/** decode(), with anything unexpected kept to the one message it came in on. */
|
package/src/aamio.js
CHANGED
|
@@ -198,6 +198,300 @@ export function hashPrefix(key, length = 8) {
|
|
|
198
198
|
return keyHash(key).slice(0, length);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// -------------------------------------------------------------- domain keys
|
|
202
|
+
//
|
|
203
|
+
// Which public aamio keys a website says are its own. A site can publish them
|
|
204
|
+
// at https://<domain>/.well-known/aamio-keys.json as
|
|
205
|
+
// {"aamio_keys": 1, "keys": [{"key": "<43 characters>", "name": "support"}]}.
|
|
206
|
+
// This is the format and nothing else: a strict parser, the match, a generator
|
|
207
|
+
// for the file, and the syntax of a domain. It opens no connection and reads no
|
|
208
|
+
// address book. A browser cannot see which address a name resolves to, and a
|
|
209
|
+
// library has no list of websites its user agreed to, so reading a list is for
|
|
210
|
+
// the caller, who also says which key is compared: the result says subject_source
|
|
211
|
+
// supplied_key, and aamio-python and aamio-php have the whole check.
|
|
212
|
+
//
|
|
213
|
+
// Take the domain from your own records and never from the message being
|
|
214
|
+
// checked: anyone can list their own key on their own site. A listed key says
|
|
215
|
+
// that this exact site listed this key when it was read. It is not an identity,
|
|
216
|
+
// an authority to act or proof that a message is true.
|
|
217
|
+
//
|
|
218
|
+
// JSON.parse keeps the last of two members of the same name without a word, so
|
|
219
|
+
// it cannot hold the format's rule about duplicates, and the reader here is its
|
|
220
|
+
// own. test/domain_keys_vectors.json holds what every client answers.
|
|
221
|
+
|
|
222
|
+
export const DOMAIN_KEYS_VERSION = 1;
|
|
223
|
+
export const DOMAIN_KEYS_PATH = "/.well-known/aamio-keys.json";
|
|
224
|
+
export const DOMAIN_KEYS_MAX_BYTES = 16384;
|
|
225
|
+
export const DOMAIN_KEYS_MAX_KEYS = 64;
|
|
226
|
+
const DOMAIN_MAX_DEPTH = 16;
|
|
227
|
+
const DOMAIN_MAX_INTEGER = 9007199254740991n;
|
|
228
|
+
|
|
229
|
+
/** A list that is not version 1. reason says why, without echoing the content. */
|
|
230
|
+
export class InvalidDomainKeys extends Error {
|
|
231
|
+
constructor(reason) {
|
|
232
|
+
super("invalid domain key list: " + reason);
|
|
233
|
+
this.name = "InvalidDomainKeys";
|
|
234
|
+
this.reason = reason;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A public key as aamio writes it: 32 bytes, 43 characters, one spelling only. */
|
|
239
|
+
export function canonicalKey(value) {
|
|
240
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
241
|
+
// A last character carrying bits past the 32 bytes decodes to the same bytes
|
|
242
|
+
// as another string; only the one an encoder writes is the key.
|
|
243
|
+
return b64url(unb64url(value)) === value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A name a list can carry: 1 to 64 characters of a-z, 0-9, dot, underscore and dash. */
|
|
247
|
+
export function validDomainKeyName(value) {
|
|
248
|
+
return typeof value === "string" && /^[a-z0-9._-]{1,64}$/.test(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The exact host a list is read from, in lowercase, or null. Plain ASCII names of
|
|
253
|
+
* two labels or more. No port, path, IP literal, trailing dot, underscore or
|
|
254
|
+
* space is trimmed into shape. Internationalised names, xn-- labels, wait until
|
|
255
|
+
* every client converts them the same way. www.example.org and example.org are
|
|
256
|
+
* different places, and no parent domain lends its list to a child.
|
|
257
|
+
*/
|
|
258
|
+
export function normaliseDomain(value) {
|
|
259
|
+
// ASCII first: toLowerCase makes a k of the Kelvin sign.
|
|
260
|
+
if (typeof value !== "string" || !/^[\x00-\x7f]*$/.test(value)) return null;
|
|
261
|
+
const domain = value.toLowerCase();
|
|
262
|
+
const labels = domain.split(".");
|
|
263
|
+
if (domain.length < 1 || domain.length > 253 || labels.length < 2) return null;
|
|
264
|
+
for (const label of labels) {
|
|
265
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || label.startsWith("xn--")) return null;
|
|
266
|
+
}
|
|
267
|
+
// A last label of digits or hex is an old spelling of an IPv4 address.
|
|
268
|
+
return /^(?:[0-9]+|0x[0-9a-f]+)$/.test(labels[labels.length - 1]) ? null : domain;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Where the list of this exact domain is, over HTTPS on the default port, or null. */
|
|
272
|
+
export function domainKeysUrl(domain) {
|
|
273
|
+
const host = normaliseDomain(domain);
|
|
274
|
+
return host === null ? null : "https://" + host + DOMAIN_KEYS_PATH;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
class DomainReader {
|
|
278
|
+
// JSON with the format's limits. Objects become a Map, so __proto__ is a member
|
|
279
|
+
// like any other, lists an Array, integers a BigInt and other numbers a number.
|
|
280
|
+
// A string holds UTF-16, so a lone surrogate stays what it is and a pair is the
|
|
281
|
+
// character it spells.
|
|
282
|
+
constructor(text) {
|
|
283
|
+
this.text = text;
|
|
284
|
+
this.at = 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
skip() {
|
|
288
|
+
while (this.at < this.text.length && " \t\r\n".includes(this.text[this.at])) this.at++;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
bad(reason = "invalid_json") {
|
|
292
|
+
return new InvalidDomainKeys(reason);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
value(level) {
|
|
296
|
+
this.skip();
|
|
297
|
+
const c = this.text[this.at];
|
|
298
|
+
if (c === "{") return this.members(level);
|
|
299
|
+
if (c === "[") return this.rows(level);
|
|
300
|
+
if (c === '"') return this.string();
|
|
301
|
+
for (const [word, meaning] of [["true", true], ["false", false], ["null", null]]) {
|
|
302
|
+
if (this.text.startsWith(word, this.at)) {
|
|
303
|
+
this.at += word.length;
|
|
304
|
+
return meaning;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return this.number();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
members(level) {
|
|
311
|
+
if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
|
|
312
|
+
this.at++;
|
|
313
|
+
const found = new Map();
|
|
314
|
+
this.skip();
|
|
315
|
+
if (this.text[this.at] === "}") {
|
|
316
|
+
this.at++;
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
for (;;) {
|
|
320
|
+
this.skip();
|
|
321
|
+
if (this.text[this.at] !== '"') throw this.bad();
|
|
322
|
+
const name = this.string();
|
|
323
|
+
if (found.has(name)) throw this.bad("duplicate_member");
|
|
324
|
+
this.skip();
|
|
325
|
+
if (this.text[this.at] !== ":") throw this.bad();
|
|
326
|
+
this.at++;
|
|
327
|
+
found.set(name, this.value(level + 1));
|
|
328
|
+
this.skip();
|
|
329
|
+
const next = this.text[this.at++];
|
|
330
|
+
if (next === "}") return found;
|
|
331
|
+
if (next !== ",") throw this.bad();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
rows(level) {
|
|
336
|
+
if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
|
|
337
|
+
this.at++;
|
|
338
|
+
const found = [];
|
|
339
|
+
this.skip();
|
|
340
|
+
if (this.text[this.at] === "]") {
|
|
341
|
+
this.at++;
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
for (;;) {
|
|
345
|
+
found.push(this.value(level + 1));
|
|
346
|
+
this.skip();
|
|
347
|
+
const next = this.text[this.at++];
|
|
348
|
+
if (next === "]") return found;
|
|
349
|
+
if (next !== ",") throw this.bad();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
string() {
|
|
354
|
+
const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
355
|
+
this.at++;
|
|
356
|
+
let out = "";
|
|
357
|
+
for (;;) {
|
|
358
|
+
if (this.at >= this.text.length) throw this.bad();
|
|
359
|
+
const c = this.text[this.at++];
|
|
360
|
+
if (c === '"') return out;
|
|
361
|
+
if (c < " ") throw this.bad();
|
|
362
|
+
if (c !== "\\") {
|
|
363
|
+
out += c;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const escape = this.text[this.at++];
|
|
367
|
+
if (escape === "u") {
|
|
368
|
+
const digits = this.text.slice(this.at, this.at + 4);
|
|
369
|
+
if (!/^[0-9a-fA-F]{4}$/.test(digits)) throw this.bad();
|
|
370
|
+
this.at += 4;
|
|
371
|
+
out += String.fromCharCode(parseInt(digits, 16));
|
|
372
|
+
} else if (Object.hasOwn(simple, escape ?? "")) {
|
|
373
|
+
out += simple[escape];
|
|
374
|
+
} else {
|
|
375
|
+
throw this.bad();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
number() {
|
|
381
|
+
const rest = this.text.slice(this.at);
|
|
382
|
+
const found = /^-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(rest);
|
|
383
|
+
if (found === null) throw this.bad();
|
|
384
|
+
this.at += found[0].length;
|
|
385
|
+
if (found[1] !== undefined || found[2] !== undefined) {
|
|
386
|
+
if (!Number.isFinite(Number(found[0]))) throw this.bad("non_finite_number");
|
|
387
|
+
return Number(found[0]);
|
|
388
|
+
}
|
|
389
|
+
const value = BigInt(found[0]);
|
|
390
|
+
if (value > DOMAIN_MAX_INTEGER || value < -DOMAIN_MAX_INTEGER) throw this.bad("integer_range");
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The entries of a version 1 list, as { key } or { key, name }, from the exact
|
|
397
|
+
* bytes. A duplicate key, a duplicate member name in any object or one bad entry
|
|
398
|
+
* makes the whole document invalid: a list is never valid with the bad part left
|
|
399
|
+
* out. Throws InvalidDomainKeys.
|
|
400
|
+
*/
|
|
401
|
+
export function parseDomainKeys(raw) {
|
|
402
|
+
if (!(raw instanceof Uint8Array)) throw new InvalidDomainKeys("not_bytes");
|
|
403
|
+
if (raw.length > DOMAIN_KEYS_MAX_BYTES) throw new InvalidDomainKeys("body_limit");
|
|
404
|
+
let text;
|
|
405
|
+
try {
|
|
406
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
|
|
407
|
+
} catch {
|
|
408
|
+
throw new InvalidDomainKeys("not_utf8");
|
|
409
|
+
}
|
|
410
|
+
if (text.startsWith("\ufeff")) throw new InvalidDomainKeys("byte_order_mark");
|
|
411
|
+
const reader = new DomainReader(text);
|
|
412
|
+
const value = reader.value(1);
|
|
413
|
+
reader.skip();
|
|
414
|
+
if (reader.at !== text.length) throw new InvalidDomainKeys("invalid_json");
|
|
415
|
+
if (!(value instanceof Map)) throw new InvalidDomainKeys("object_required");
|
|
416
|
+
// A BigInt and nothing looser: 1.0, 1e0 and true are not the integer 1.
|
|
417
|
+
if (value.get("aamio_keys") !== BigInt(DOMAIN_KEYS_VERSION)) throw new InvalidDomainKeys("unsupported_version");
|
|
418
|
+
const rows = value.get("keys");
|
|
419
|
+
if (!Array.isArray(rows) || rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const entries = [];
|
|
422
|
+
for (const row of rows) {
|
|
423
|
+
if (!(row instanceof Map) || !canonicalKey(row.get("key"))) throw new InvalidDomainKeys("invalid_key");
|
|
424
|
+
const key = row.get("key");
|
|
425
|
+
if (seen.has(key)) throw new InvalidDomainKeys("duplicate_key");
|
|
426
|
+
seen.add(key);
|
|
427
|
+
const entry = { key };
|
|
428
|
+
// has, since a name of null is a bad name and not a missing one.
|
|
429
|
+
if (row.has("name")) {
|
|
430
|
+
if (!validDomainKeyName(row.get("name"))) throw new InvalidDomainKeys("invalid_name");
|
|
431
|
+
entry.name = row.get("name");
|
|
432
|
+
}
|
|
433
|
+
entries.push(entry);
|
|
434
|
+
}
|
|
435
|
+
return entries;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether this exact document lists key, and under name when one is given.
|
|
440
|
+
*
|
|
441
|
+
* status is listed, not_listed or invalid. not_listed is no evidence of fraud: a
|
|
442
|
+
* key rotated out, the wrong name or the wrong domain look the same. The document
|
|
443
|
+
* hash is of the exact bytes compared, for comparison and fault finding; the
|
|
444
|
+
* domain did not sign it. The key is the caller's to choose and to answer for,
|
|
445
|
+
* which the result says as subject_source.
|
|
446
|
+
*/
|
|
447
|
+
export function matchDomainKey(raw, key, name = null) {
|
|
448
|
+
const subject_source = "supplied_key";
|
|
449
|
+
if (!canonicalKey(key)) return { status: "invalid", reason: "invalid_subject_key", subject_source };
|
|
450
|
+
if (name !== null && !validDomainKeyName(name)) return { status: "invalid", reason: "invalid_requested_name", subject_source };
|
|
451
|
+
let entries;
|
|
452
|
+
try {
|
|
453
|
+
entries = parseDomainKeys(raw);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (!(error instanceof InvalidDomainKeys)) throw error;
|
|
456
|
+
return { status: "invalid", reason: "invalid_document", detail: error.reason, subject_source };
|
|
457
|
+
}
|
|
458
|
+
const document_sha256 = sha256hex(raw);
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
if (entry.key === key && (name === null || entry.name === name)) {
|
|
461
|
+
return { status: "listed", reason: "key_listed", matched_name: entry.name ?? null, document_sha256, subject_source };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { status: "not_listed", reason: "key_or_name_not_listed", document_sha256, subject_source };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The file for a site to publish, as bytes. They are fixed, so every generator
|
|
469
|
+
* gives the same file for the same list: two spaces of indentation, aamio_keys
|
|
470
|
+
* before keys, key before name, ASCII only, and a line break at the end. An empty
|
|
471
|
+
* list is allowed, since that is how a site says it lists no key, so callers ask
|
|
472
|
+
* for it on purpose. A list this would refuse to read is refused here as well.
|
|
473
|
+
*/
|
|
474
|
+
export function makeDomainKeys(entries) {
|
|
475
|
+
const rows = [];
|
|
476
|
+
const seen = new Set();
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
if (!canonicalKey(entry?.key)) throw new InvalidDomainKeys("invalid_key");
|
|
479
|
+
if (seen.has(entry.key)) throw new InvalidDomainKeys("duplicate_key");
|
|
480
|
+
seen.add(entry.key);
|
|
481
|
+
let row = ' {\n "key": "' + entry.key + '"';
|
|
482
|
+
if (entry.name !== undefined && entry.name !== null) {
|
|
483
|
+
if (!validDomainKeyName(entry.name)) throw new InvalidDomainKeys("invalid_name");
|
|
484
|
+
row += ',\n "name": "' + entry.name + '"';
|
|
485
|
+
}
|
|
486
|
+
rows.push(row + "\n }");
|
|
487
|
+
}
|
|
488
|
+
if (rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
|
|
489
|
+
const list = rows.length === 0 ? "[]" : "[\n" + rows.join(",\n") + "\n ]";
|
|
490
|
+
const raw = utf8('{\n "aamio_keys": ' + DOMAIN_KEYS_VERSION + ',\n "keys": ' + list + "\n}\n");
|
|
491
|
+
parseDomainKeys(raw);
|
|
492
|
+
return raw;
|
|
493
|
+
}
|
|
494
|
+
|
|
201
495
|
export const threadSigningInput = (w, bodyText) => "aamio-v1\n" + w + "\n" + sha256hex(bodyText);
|
|
202
496
|
export const presenceSigningInput = (key, bodyText) => "aamio-presence-v1\n" + key + "\n" + sha256hex(bodyText);
|
|
203
497
|
export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(bodyText);
|
|
@@ -1035,11 +1329,20 @@ export class Aamio {
|
|
|
1035
1329
|
* thread with none. Messages the list does not allow are left out of
|
|
1036
1330
|
* messages and listed in keptOut, never dropped in silence.
|
|
1037
1331
|
*/
|
|
1038
|
-
async read(thread, { after = 0, wait = 0 } = {}) {
|
|
1332
|
+
async read(thread, { after = 0, wait = 0, limit = null, maxBytes = null } = {}) {
|
|
1039
1333
|
let path = "/" + thread.w;
|
|
1040
1334
|
if (after > 0 || wait > 0) path += "/after/" + after;
|
|
1041
1335
|
if (wait > 0) path += "/wait/" + wait;
|
|
1042
|
-
|
|
1336
|
+
// How much of the thread to send. A thread may hold two hundred messages of
|
|
1337
|
+
// 65536 bytes, so one read can be about a megabyte, and until now the whole
|
|
1338
|
+
// of it crossed the network before anything here looked at it. Whole messages
|
|
1339
|
+
// only: a signed message cut in half does not verify, so a budget under the
|
|
1340
|
+
// first message comes back as too_large naming it. A service that does not
|
|
1341
|
+
// offer read-limits ignores both headers and answers as it always did.
|
|
1342
|
+
const headers = { "X-Read": thread.id };
|
|
1343
|
+
if (limit !== null && limit !== undefined) headers["X-Limit"] = String(limit);
|
|
1344
|
+
if (maxBytes !== null && maxBytes !== undefined) headers["X-Max-Bytes"] = String(maxBytes);
|
|
1345
|
+
const data = await this.request("GET", path, { headers });
|
|
1043
1346
|
const allow = normalizeAllow(thread.allow);
|
|
1044
1347
|
const handed = [];
|
|
1045
1348
|
const keptOut = [];
|