nervur 0.19.0 → 0.19.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/dist/being/lent.js +9 -0
- package/dist/conformance/store.js +0 -10
- package/dist/harbor/core.d.ts +3 -1
- package/dist/harbor/core.js +51 -11
- package/dist/harbor/reach.js +9 -1
- package/dist/harbor/store.d.ts +0 -5
- package/dist/harbor/store.js +1 -9
- package/dist/vector/stand.js +2 -1
- package/dist/ward/arithmetic.js +6 -2
- package/dist/ward/json.d.ts +2 -0
- package/dist/ward/json.js +163 -0
- package/dist/ward/seal.js +13 -8
- package/package.json +1 -1
- package/src/being/lent.ts +6 -0
- package/src/conformance/store.ts +0 -11
- package/src/harbor/core.ts +48 -10
- package/src/harbor/reach.ts +7 -1
- package/src/harbor/store.ts +1 -11
- package/src/vector/stand.ts +2 -1
- package/src/ward/arithmetic.ts +6 -2
- package/src/ward/json.ts +144 -0
- package/src/ward/seal.ts +11 -7
package/dist/being/lent.js
CHANGED
|
@@ -53,7 +53,16 @@ export class Lent extends Being {
|
|
|
53
53
|
return bp;
|
|
54
54
|
return { ...bp, asks: [...bp.asks, LENT_ASKS.offer, LENT_ASKS.retract] };
|
|
55
55
|
}
|
|
56
|
+
// An offer is open until it is taken or retracted. The taker's knock is the
|
|
57
|
+
// first word she hears from the occupant she minted for it, so hearing
|
|
58
|
+
// from that id closes the offer, and `open` holds only offers still
|
|
59
|
+
// waiting and not every lend she ever made.
|
|
56
60
|
async answer(asker, method, args = {}) {
|
|
61
|
+
const open = this.cells.open;
|
|
62
|
+
if (open && asker.id !== undefined && asker.id !== BOX)
|
|
63
|
+
for (const [heir, id] of Object.entries(open))
|
|
64
|
+
if (id === asker.id)
|
|
65
|
+
delete open[heir];
|
|
57
66
|
if (asker.id === BOX && method === 'offer')
|
|
58
67
|
return this.offer();
|
|
59
68
|
if (asker.id === BOX && method === 'retract')
|
|
@@ -22,7 +22,6 @@ export function conformStore(label, make, { test }) {
|
|
|
22
22
|
assert.deepEqual(await s.list(), []);
|
|
23
23
|
assert.equal(await s.load('main'), undefined);
|
|
24
24
|
assert.equal(await s.take('main'), undefined);
|
|
25
|
-
assert.deepEqual(await s.hints(), {});
|
|
26
25
|
});
|
|
27
26
|
t('put, then load: seed, partition and record come back as they went in', async () => {
|
|
28
27
|
const s = await make();
|
|
@@ -111,13 +110,4 @@ export function conformStore(label, make, { test }) {
|
|
|
111
110
|
await s.put('main', kept(9));
|
|
112
111
|
assert.deepEqual((await s.load('main')).seed, seed(9));
|
|
113
112
|
});
|
|
114
|
-
t('hints are kept by pk and the last one wins', async () => {
|
|
115
|
-
const s = await make();
|
|
116
|
-
const pk = 'cd'.repeat(64);
|
|
117
|
-
await s.hint(pk, 'https://quo.acme.com/quo');
|
|
118
|
-
await s.hint('ef'.repeat(64), 'http://127.0.0.1:1/quo');
|
|
119
|
-
assert.deepEqual(await s.hints(), { [pk]: 'https://quo.acme.com/quo', ['ef'.repeat(64)]: 'http://127.0.0.1:1/quo' });
|
|
120
|
-
await s.hint(pk, 'https://quo.other.com/quo');
|
|
121
|
-
assert.equal((await s.hints())[pk], 'https://quo.other.com/quo');
|
|
122
|
-
});
|
|
123
113
|
}
|
package/dist/harbor/core.d.ts
CHANGED
|
@@ -39,10 +39,12 @@ export declare class Harbor {
|
|
|
39
39
|
protected boxFor(name: string, _record: WardRecord): Promise<Invitation | undefined>;
|
|
40
40
|
boot(): Promise<void>;
|
|
41
41
|
partitionOf(name: string): Record<string, unknown> | undefined;
|
|
42
|
+
quiet(): Promise<void>;
|
|
42
43
|
carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
43
44
|
deliver(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
44
45
|
hint(pk: string, url: string): void;
|
|
45
|
-
|
|
46
|
+
learn(pk: string, url: string): Promise<boolean>;
|
|
47
|
+
forget(pk: string): void;
|
|
46
48
|
prove(pk: string, reach: Reach): Promise<boolean>;
|
|
47
49
|
bind(pks: string[], reach: Reach, held: boolean): Promise<void>;
|
|
48
50
|
unbind(reach: Reach): void;
|
package/dist/harbor/core.js
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// doors; a socket a dialer holds to it, bound once the door behind the
|
|
13
13
|
// dialer's claim has proved it holds the key, and unbound at close; the
|
|
14
14
|
// claims of a listener this harbor dialed, proved the same way and reached
|
|
15
|
-
// through that line; and a hint, a pk at a URL
|
|
16
|
-
//
|
|
15
|
+
// through that line; and a hint, a pk at a URL. A hint never displaces a
|
|
16
|
+
// reach proved at a door. The directory lives in memory and is kept by no
|
|
17
|
+
// store: whoever learns a hint keeps it where it keeps everything, in a
|
|
18
|
+
// being's cells, and hands it back at every boot. And one fallback: a dialer with nothing in its
|
|
17
19
|
// directory for a pk sends down the socket it holds, because the listener
|
|
18
20
|
// it dialed is the rendezvous and may hold that pk on another socket. Bytes
|
|
19
21
|
// that arrive from the wire go to an own door or a held socket and never
|
|
@@ -40,6 +42,9 @@ const PROVING = 8;
|
|
|
40
42
|
// How many times each reach has announced: a proof from an older announce
|
|
41
43
|
// binds nothing when it lands.
|
|
42
44
|
const announces = new WeakMap();
|
|
45
|
+
// The reaches that are hints, so a hint can be replaced and put down without
|
|
46
|
+
// touching a reach a door proved.
|
|
47
|
+
const hinted = new WeakSet();
|
|
43
48
|
export class Harbor {
|
|
44
49
|
store;
|
|
45
50
|
loader;
|
|
@@ -92,9 +97,9 @@ export class Harbor {
|
|
|
92
97
|
boxFor(name, _record) {
|
|
93
98
|
return this.offering?.(name) ?? Promise.resolve(undefined);
|
|
94
99
|
}
|
|
95
|
-
// Boot every ward the store keeps
|
|
96
|
-
//
|
|
97
|
-
//
|
|
100
|
+
// Boot every ward the store keeps. One ward that will not host, a
|
|
101
|
+
// partition this kit cannot read or a loader that throws, is named in
|
|
102
|
+
// `unbooted` and takes nobody else down with it.
|
|
98
103
|
async boot() {
|
|
99
104
|
for (const name of await this.store.list()) {
|
|
100
105
|
// A ward this harbor already hosts is not booted twice. A terrain that
|
|
@@ -111,8 +116,6 @@ export class Harbor {
|
|
|
111
116
|
this.unbooted.push(name);
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
|
-
for (const [pk, url] of Object.entries(await this.store.hints()))
|
|
115
|
-
this.hint(pk, url);
|
|
116
119
|
}
|
|
117
120
|
// The object a ward writes into, by the name it is kept under: the
|
|
118
121
|
// partition the harbor was handed at boot and keeps so it can save it.
|
|
@@ -126,6 +129,19 @@ export class Harbor {
|
|
|
126
129
|
return this.#saving.get(name)?.memory;
|
|
127
130
|
}
|
|
128
131
|
// ---- saving
|
|
132
|
+
// Every ward's writes kept and none waiting: no row named, no save armed
|
|
133
|
+
// and none in flight. A write can name a row while another ward is being
|
|
134
|
+
// saved, so this goes round until a pass finds nothing. A process about to
|
|
135
|
+
// exit waits here, since its exit is not a turn the timer is given.
|
|
136
|
+
async quiet() {
|
|
137
|
+
for (;;) {
|
|
138
|
+
const busy = [...this.#saving].filter(([, s]) => !s.gone && (s.dirty.size > 0 || s.timer !== undefined || s.running !== null));
|
|
139
|
+
if (busy.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
for (const [name, s] of busy)
|
|
142
|
+
await (s.running ?? this.#flush(name));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
129
145
|
// The ward wrote one row. Its partition is saved once the line is free, and
|
|
130
146
|
// once for every burst of writes, with every row named in that burst.
|
|
131
147
|
#wrote(name, row) {
|
|
@@ -362,14 +378,38 @@ export class Harbor {
|
|
|
362
378
|
const bound = this.reaches.get(pk);
|
|
363
379
|
return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
|
|
364
380
|
}
|
|
381
|
+
// A hint, taken on the word of whoever gives it. A hint already standing
|
|
382
|
+
// for the pk is replaced, since a URL is the one part of a ward that moves;
|
|
383
|
+
// a reach proven at a door is never displaced by one.
|
|
365
384
|
hint(pk, url) {
|
|
366
|
-
|
|
385
|
+
const standing = this.reaches.get(pk);
|
|
386
|
+
if (standing && !hinted.has(standing.reach))
|
|
367
387
|
return; // a reach proven at a door beats a hint, held or reached through a dialed line
|
|
368
|
-
|
|
388
|
+
const reach = request(url);
|
|
389
|
+
hinted.add(reach);
|
|
390
|
+
this.reaches.set(pk, { reach, held: false });
|
|
369
391
|
}
|
|
370
|
-
|
|
392
|
+
// A hint kept only once the door behind it has proven it holds the pk's
|
|
393
|
+
// key, the same probe an announce is proven by. A URL that answers for
|
|
394
|
+
// another ward, or for nobody, is refused and nothing is kept: whoever
|
|
395
|
+
// hands a harbor a hint could otherwise point one ward's asks at a place
|
|
396
|
+
// that drops them. True is kept.
|
|
397
|
+
async learn(pk, url) {
|
|
398
|
+
const standing = this.reaches.get(pk);
|
|
399
|
+
if (standing && !hinted.has(standing.reach))
|
|
400
|
+
return true; // already reached, and proven at a door
|
|
401
|
+
const reach = request(url);
|
|
402
|
+
if (!(await this.prove(pk, reach)))
|
|
403
|
+
return false;
|
|
371
404
|
this.hint(pk, url);
|
|
372
|
-
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
// A hint put down. A reach proven at a door is the line's, and goes when
|
|
408
|
+
// the line does, never by a word from here.
|
|
409
|
+
forget(pk) {
|
|
410
|
+
const standing = this.reaches.get(pk);
|
|
411
|
+
if (standing && hinted.has(standing.reach))
|
|
412
|
+
this.reaches.delete(pk);
|
|
373
413
|
}
|
|
374
414
|
// An announce is a claim, and a claim binds nothing until proven: anyone
|
|
375
415
|
// who can reach a listener could otherwise name a pk that is not theirs and
|
package/dist/harbor/reach.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// A reach that sent the bytes and lost the line afterwards answers nothing
|
|
21
21
|
// at all, and the ward's own bound ends the ask.
|
|
22
22
|
import { concat, hex, unhex } from '../ward/arithmetic.js';
|
|
23
|
+
import { readJson } from '../ward/json.js';
|
|
23
24
|
const never = () => new Promise(() => { }); // sent, and no word since: the ward's bound decides
|
|
24
25
|
// A request reach to one listener.
|
|
25
26
|
export function request(url) {
|
|
@@ -111,7 +112,9 @@ export class Socket {
|
|
|
111
112
|
line.addEventListener('message', (e) => {
|
|
112
113
|
if (typeof e.data === 'string') {
|
|
113
114
|
try {
|
|
114
|
-
const m =
|
|
115
|
+
const m = readJson(e.data);
|
|
116
|
+
if (m === null || typeof m !== 'object')
|
|
117
|
+
return;
|
|
115
118
|
// Absent is this suite: a side that says nothing says one.
|
|
116
119
|
if (m.suite !== undefined && m.suite !== SUITE) {
|
|
117
120
|
this.far = [];
|
|
@@ -129,9 +132,14 @@ export class Socket {
|
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
134
|
const buf = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : ArrayBuffer.isView(e.data) ? new Uint8Array(e.data.buffer, e.data.byteOffset, e.data.byteLength) : undefined;
|
|
135
|
+
// A frame that is none of the three is not a frame: an unknown kind, an
|
|
136
|
+
// ask too short to hold a pk, a nothing with bytes after its id. It is
|
|
137
|
+
// dropped, and tells no ask waiting on its id that nothing was delivered.
|
|
132
138
|
if (!buf || buf.length < 5)
|
|
133
139
|
return;
|
|
134
140
|
const kind = buf[0], id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
|
|
141
|
+
if (kind > NONE || (kind === ASK && buf.length < 69) || (kind === NONE && buf.length !== 5))
|
|
142
|
+
return;
|
|
135
143
|
if (kind === ASK) {
|
|
136
144
|
const pk = hex(buf.subarray(5, 69));
|
|
137
145
|
// A deliver that rejects has delivered nothing this side can vouch
|
package/dist/harbor/store.d.ts
CHANGED
|
@@ -15,8 +15,6 @@ export type Store = {
|
|
|
15
15
|
save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void>;
|
|
16
16
|
record(name: string, record: WardRecord): Promise<void>;
|
|
17
17
|
take(name: string): Promise<Kept | undefined>;
|
|
18
|
-
hints(): Promise<Record<string, string>>;
|
|
19
|
-
hint(pk: string, url: string): Promise<void>;
|
|
20
18
|
};
|
|
21
19
|
export declare const values: (p: Record<string, unknown>) => Record<string, unknown>;
|
|
22
20
|
export declare const rowsOf: (partition: Record<string, unknown>, rows: readonly string[]) => [string, Record<string, unknown> | undefined][];
|
|
@@ -27,14 +25,11 @@ type Row = {
|
|
|
27
25
|
};
|
|
28
26
|
export declare class MemoryStore implements Store {
|
|
29
27
|
readonly rows: Map<string, Row>;
|
|
30
|
-
readonly reach: Map<string, string>;
|
|
31
28
|
list(): Promise<string[]>;
|
|
32
29
|
load(name: string): Promise<Kept | undefined>;
|
|
33
30
|
put(name: string, kept: Kept): Promise<void>;
|
|
34
31
|
save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void>;
|
|
35
32
|
record(name: string, record: WardRecord): Promise<void>;
|
|
36
33
|
take(name: string): Promise<Kept | undefined>;
|
|
37
|
-
hints(): Promise<Record<string, string>>;
|
|
38
|
-
hint(pk: string, url: string): Promise<void>;
|
|
39
34
|
}
|
|
40
35
|
export {};
|
package/dist/harbor/store.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// The store: keeping wards by name. A ward is three things and the store
|
|
3
3
|
// keeps all three under one name: the seed, the partition and the ward
|
|
4
4
|
// record, which says where the class bodies come from and which being is
|
|
5
|
-
// the user's.
|
|
6
|
-
// survive a restart. The store reads nothing it keeps; the partition is
|
|
5
|
+
// the user's. The store reads nothing it keeps; the partition is
|
|
7
6
|
// values the ward wrote, and the seed is bytes only the ward derives from.
|
|
8
7
|
// Every terrain has one: files on a disk, IndexedDB in a tab, an object's
|
|
9
8
|
// storage on the edge; the memory store below is the library's own, for a
|
|
@@ -31,7 +30,6 @@ const keep = (row, partition, rows) => {
|
|
|
31
30
|
};
|
|
32
31
|
export class MemoryStore {
|
|
33
32
|
rows = new Map();
|
|
34
|
-
reach = new Map();
|
|
35
33
|
async list() {
|
|
36
34
|
return [...this.rows.keys()];
|
|
37
35
|
}
|
|
@@ -61,10 +59,4 @@ export class MemoryStore {
|
|
|
61
59
|
this.rows.delete(name);
|
|
62
60
|
return kept;
|
|
63
61
|
}
|
|
64
|
-
async hints() {
|
|
65
|
-
return Object.fromEntries(this.reach);
|
|
66
|
-
}
|
|
67
|
-
async hint(pk, url) {
|
|
68
|
-
this.reach.set(pk, url);
|
|
69
|
-
}
|
|
70
62
|
}
|
package/dist/vector/stand.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// world runs on.
|
|
19
19
|
import { SUITE } from '../harbor/reach.js';
|
|
20
20
|
import { hex } from '../ward/arithmetic.js';
|
|
21
|
+
import { readJson } from '../ward/json.js';
|
|
21
22
|
import { caseNamed, stand } from './cases.js';
|
|
22
23
|
import { partitionDigest } from './world.js';
|
|
23
24
|
const CORS = {
|
|
@@ -39,7 +40,7 @@ export class Stand {
|
|
|
39
40
|
if (method === 'POST' && p === '/stand') {
|
|
40
41
|
let named;
|
|
41
42
|
try {
|
|
42
|
-
named =
|
|
43
|
+
named = readJson(body);
|
|
43
44
|
}
|
|
44
45
|
catch {
|
|
45
46
|
return NONE;
|
package/dist/ward/arithmetic.js
CHANGED
|
@@ -59,8 +59,10 @@ export function sameBytes(a, b) {
|
|
|
59
59
|
diff |= a[at] ^ b[at];
|
|
60
60
|
return diff === 0;
|
|
61
61
|
}
|
|
62
|
-
// The eight small-order points of Ed25519, in their canonical encoding
|
|
63
|
-
//
|
|
62
|
+
// The eight small-order points of Ed25519, in their canonical encoding, and
|
|
63
|
+
// the two points with x = 0 spelled with the sign bit set, which a decoder
|
|
64
|
+
// that ignores the bit reads as the same point. A public key among them
|
|
65
|
+
// verifies nothing. An encoding whose y coordinate is
|
|
64
66
|
// not reduced, y at or above the field's prime p, names one of the same
|
|
65
67
|
// points under another spelling, and a terrain's verify may accept a zero
|
|
66
68
|
// signature under it; the field has room for the spelling, so it is refused
|
|
@@ -74,6 +76,8 @@ const SMALL_ORDER = [
|
|
|
74
76
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
75
77
|
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
76
78
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
79
|
+
'0100000000000000000000000000000000000000000000000000000000000080',
|
|
80
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
|
77
81
|
].map(unhex);
|
|
78
82
|
// y >= p, read little-endian with the sign bit masked off: the top byte is
|
|
79
83
|
// 0x7f under the mask, every middle byte is 0xff, and the low byte is at
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The one reader of JSON that crossed an edge. A runtime's own parser is
|
|
3
|
+
// where two kits stop agreeing: it keeps the last of two keys of one name,
|
|
4
|
+
// rounds a whole number no double holds, reads a number past the doubles as
|
|
5
|
+
// infinity, and mends bytes that are not UTF-8 into a replacement character.
|
|
6
|
+
// Each of those is text two kits read two ways, so each is refused here, where
|
|
7
|
+
// it is read, with a throw the caller already turns into what bytes that said
|
|
8
|
+
// nothing are.
|
|
9
|
+
//
|
|
10
|
+
// What a value is beyond its text, a lone surrogate, a key named `__proto__`,
|
|
11
|
+
// a nesting past the bound, is the value rule's, and `cellFault` in cells.ts
|
|
12
|
+
// is its one reader. This file holds the rules that live only in the text.
|
|
13
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
14
|
+
const fail = (at, why) => {
|
|
15
|
+
throw new Error(`JSON at ${at}: ${why}`);
|
|
16
|
+
};
|
|
17
|
+
// A number text, held to the Values chapter. A text naming a whole number
|
|
18
|
+
// stands only if that number is exactly a double, or if the text is how
|
|
19
|
+
// ECMAScript writes the double it reads as. A text past the largest double,
|
|
20
|
+
// or naming a value other than zero that rounds to zero, is refused, and so
|
|
21
|
+
// is minus zero in any spelling.
|
|
22
|
+
export function numberFault(literal) {
|
|
23
|
+
const m = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
|
|
24
|
+
if (!m)
|
|
25
|
+
return 'not a number';
|
|
26
|
+
const [, sign, whole, frac = '', exp = '0'] = m;
|
|
27
|
+
const v = Number(literal);
|
|
28
|
+
if (!Number.isFinite(v))
|
|
29
|
+
return 'past the largest double';
|
|
30
|
+
let digits = (whole + frac).replace(/^0+/, '');
|
|
31
|
+
if (v === 0) {
|
|
32
|
+
if (digits !== '')
|
|
33
|
+
return 'a value other than zero that rounds to zero';
|
|
34
|
+
return sign ? 'minus zero' : null;
|
|
35
|
+
}
|
|
36
|
+
// The exact value is digits times ten to e. A finite double bounds e from
|
|
37
|
+
// above, so the power below is never larger than the double's own width.
|
|
38
|
+
let e = BigInt(exp) - BigInt(frac.length);
|
|
39
|
+
const trimmed = digits.replace(/0+$/, '');
|
|
40
|
+
e += BigInt(digits.length - trimmed.length);
|
|
41
|
+
digits = trimmed;
|
|
42
|
+
if (e < 0n)
|
|
43
|
+
return null; // a fraction: the double nearest it, as every reader takes it
|
|
44
|
+
const exact = BigInt(digits) * 10n ** e;
|
|
45
|
+
if (exact === (v < 0 ? -BigInt(v) : BigInt(v)))
|
|
46
|
+
return null;
|
|
47
|
+
return literal === String(v) ? null : 'a whole number that is not exactly a double';
|
|
48
|
+
}
|
|
49
|
+
// Bytes to a value, or a throw.
|
|
50
|
+
export function readJson(input) {
|
|
51
|
+
const s = typeof input === 'string' ? input : utf8.decode(input);
|
|
52
|
+
let at = 0;
|
|
53
|
+
const space = () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const c = s.charCodeAt(at);
|
|
56
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d)
|
|
57
|
+
at += 1;
|
|
58
|
+
else
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const string = () => {
|
|
63
|
+
const start = at;
|
|
64
|
+
at += 1;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const c = s.charCodeAt(at);
|
|
67
|
+
if (Number.isNaN(c))
|
|
68
|
+
fail(start, 'an unterminated string');
|
|
69
|
+
if (c === 0x22)
|
|
70
|
+
break;
|
|
71
|
+
if (c < 0x20)
|
|
72
|
+
fail(at, 'a control character in a string');
|
|
73
|
+
at += c === 0x5c ? 2 : 1;
|
|
74
|
+
}
|
|
75
|
+
at += 1;
|
|
76
|
+
// The escapes are JSON's own, and the runtime reads them the one way.
|
|
77
|
+
return JSON.parse(s.slice(start, at));
|
|
78
|
+
};
|
|
79
|
+
const value = () => {
|
|
80
|
+
space();
|
|
81
|
+
const c = s[at];
|
|
82
|
+
if (c === '{') {
|
|
83
|
+
at += 1;
|
|
84
|
+
const out = {};
|
|
85
|
+
const keys = new Set();
|
|
86
|
+
space();
|
|
87
|
+
if (s[at] === '}') {
|
|
88
|
+
at += 1;
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
for (;;) {
|
|
92
|
+
space();
|
|
93
|
+
if (s[at] !== '"')
|
|
94
|
+
fail(at, 'a key that is not a string');
|
|
95
|
+
const where = at;
|
|
96
|
+
const k = string();
|
|
97
|
+
if (keys.has(k))
|
|
98
|
+
fail(where, `two keys named ${JSON.stringify(k)}`);
|
|
99
|
+
keys.add(k);
|
|
100
|
+
space();
|
|
101
|
+
if (s[at] !== ':')
|
|
102
|
+
fail(at, 'a key with no colon');
|
|
103
|
+
at += 1;
|
|
104
|
+
// Defined, never assigned: a key named `__proto__` is an own key here
|
|
105
|
+
// as it is in JSON.parse, and the value rule refuses it after.
|
|
106
|
+
Object.defineProperty(out, k, { value: value(), enumerable: true, writable: true, configurable: true });
|
|
107
|
+
space();
|
|
108
|
+
if (s[at] === ',')
|
|
109
|
+
at += 1;
|
|
110
|
+
else if (s[at] === '}') {
|
|
111
|
+
at += 1;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
fail(at, 'an object not closed');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (c === '[') {
|
|
119
|
+
at += 1;
|
|
120
|
+
const out = [];
|
|
121
|
+
space();
|
|
122
|
+
if (s[at] === ']') {
|
|
123
|
+
at += 1;
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
for (;;) {
|
|
127
|
+
out.push(value());
|
|
128
|
+
space();
|
|
129
|
+
if (s[at] === ',')
|
|
130
|
+
at += 1;
|
|
131
|
+
else if (s[at] === ']') {
|
|
132
|
+
at += 1;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
fail(at, 'a list not closed');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (c === '"')
|
|
140
|
+
return string();
|
|
141
|
+
for (const [word, v] of [['true', true], ['false', false], ['null', null]]) {
|
|
142
|
+
if (s.startsWith(word, at)) {
|
|
143
|
+
at += word.length;
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const m = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
148
|
+
m.lastIndex = at;
|
|
149
|
+
const hit = m.exec(s);
|
|
150
|
+
if (!hit)
|
|
151
|
+
return fail(at, 'no value');
|
|
152
|
+
const why = numberFault(hit[0]);
|
|
153
|
+
if (why !== null)
|
|
154
|
+
fail(at, `${hit[0]} is ${why}`);
|
|
155
|
+
at += hit[0].length;
|
|
156
|
+
return Number(hit[0]);
|
|
157
|
+
};
|
|
158
|
+
const out = value();
|
|
159
|
+
space();
|
|
160
|
+
if (at !== s.length)
|
|
161
|
+
fail(at, 'text after the value');
|
|
162
|
+
return out;
|
|
163
|
+
}
|
package/dist/ward/seal.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isDoorWord } from '../being/silence.js';
|
|
2
2
|
import { KEY, SIGNATURE, box, concat, derive, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
|
|
3
3
|
import { cellFault } from './cells.js';
|
|
4
|
+
import { readJson } from './json.js';
|
|
4
5
|
const utf8 = new TextEncoder();
|
|
5
|
-
const text = new TextDecoder();
|
|
6
6
|
// The two labels a ward's keys are derived under. They are here and not in
|
|
7
7
|
// arithmetic.ts because a label is a decision and that file is arithmetic.
|
|
8
8
|
// `quo-seal` is taken: it is the label the message cipher derives its key and
|
|
@@ -70,7 +70,7 @@ export async function openAsk(bytes, padlockSecret) {
|
|
|
70
70
|
return null;
|
|
71
71
|
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
72
72
|
const signature = inside.subarray(inside.length - SIGNATURE);
|
|
73
|
-
const payload =
|
|
73
|
+
const payload = readJson(body);
|
|
74
74
|
// The heir, or null for the public being. Absent is not null: a payload
|
|
75
75
|
// that never named one is malformed, like any other missing field.
|
|
76
76
|
const to = payload.to;
|
|
@@ -112,7 +112,9 @@ export async function sealReply(reply, ephemeralPk, wardSign, seed) {
|
|
|
112
112
|
// lied, or it is not a reply. Every ward on the wire is somebody else's code,
|
|
113
113
|
// so the shape is checked here and nowhere later: a reply is `{ silence }`,
|
|
114
114
|
// `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
|
|
115
|
-
// object
|
|
115
|
+
// object that is a value and seen a digest or null. A field beside a shape's
|
|
116
|
+
// own makes it none of the three, since a kit that dropped the field would
|
|
117
|
+
// hand her an answer where this one hands her silence.
|
|
116
118
|
// Anything else is bytes that said nothing, and her ward never hands her a
|
|
117
119
|
// throw or an absent value for what a stranger wrote.
|
|
118
120
|
export async function openReply(bytes, ephemeralSecret, signPk) {
|
|
@@ -125,18 +127,21 @@ export async function openReply(bytes, ephemeralSecret, signPk) {
|
|
|
125
127
|
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
126
128
|
if (!(await verify(body, inside.subarray(inside.length - SIGNATURE), signPk)))
|
|
127
129
|
return null;
|
|
128
|
-
const reply =
|
|
130
|
+
const reply = readJson(body);
|
|
129
131
|
if (reply === null || typeof reply !== 'object' || Array.isArray(reply))
|
|
130
132
|
return null;
|
|
131
133
|
const r = reply;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
const keys = Object.keys(r).sort().join(',');
|
|
135
|
+
if (keys === 'silence')
|
|
136
|
+
return r.silence === true ? { silence: true } : null;
|
|
137
|
+
if (keys === 'quo')
|
|
135
138
|
return isDoorWord(r.quo) ? { quo: r.quo } : null;
|
|
136
|
-
if (
|
|
139
|
+
if (keys !== 'object,seen')
|
|
137
140
|
return null;
|
|
138
141
|
if (r.seen !== null && !isHex(r.seen))
|
|
139
142
|
return null;
|
|
143
|
+
if (cellFault(r.object, 'object') !== null)
|
|
144
|
+
return null; // values in both directions: nested past the bound is none of the shapes
|
|
140
145
|
return { object: r.object, seen: r.seen };
|
|
141
146
|
}
|
|
142
147
|
catch {
|
package/package.json
CHANGED
package/src/being/lent.ts
CHANGED
|
@@ -58,7 +58,13 @@ export class Lent extends Being {
|
|
|
58
58
|
return { ...bp, asks: [...bp.asks, LENT_ASKS.offer, LENT_ASKS.retract] };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// An offer is open until it is taken or retracted. The taker's knock is the
|
|
62
|
+
// first word she hears from the occupant she minted for it, so hearing
|
|
63
|
+
// from that id closes the offer, and `open` holds only offers still
|
|
64
|
+
// waiting and not every lend she ever made.
|
|
61
65
|
override async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
|
|
66
|
+
const open = this.cells.open as Record<string, string> | undefined;
|
|
67
|
+
if (open && asker.id !== undefined && asker.id !== BOX) for (const [heir, id] of Object.entries(open)) if (id === asker.id) delete open[heir];
|
|
62
68
|
if (asker.id === BOX && method === 'offer') return this.offer();
|
|
63
69
|
if (asker.id === BOX && method === 'retract') return this.retract(args);
|
|
64
70
|
return super.answer(asker, method, args);
|
package/src/conformance/store.ts
CHANGED
|
@@ -27,7 +27,6 @@ export function conformStore(label: string, make: () => Promise<Store>, { test }
|
|
|
27
27
|
assert.deepEqual(await s.list(), []);
|
|
28
28
|
assert.equal(await s.load('main'), undefined);
|
|
29
29
|
assert.equal(await s.take('main'), undefined);
|
|
30
|
-
assert.deepEqual(await s.hints(), {});
|
|
31
30
|
});
|
|
32
31
|
|
|
33
32
|
t('put, then load: seed, partition and record come back as they went in', async () => {
|
|
@@ -123,14 +122,4 @@ export function conformStore(label: string, make: () => Promise<Store>, { test }
|
|
|
123
122
|
await s.put('main', kept(9));
|
|
124
123
|
assert.deepEqual((await s.load('main'))!.seed, seed(9));
|
|
125
124
|
});
|
|
126
|
-
|
|
127
|
-
t('hints are kept by pk and the last one wins', async () => {
|
|
128
|
-
const s = await make();
|
|
129
|
-
const pk = 'cd'.repeat(64);
|
|
130
|
-
await s.hint(pk, 'https://quo.acme.com/quo');
|
|
131
|
-
await s.hint('ef'.repeat(64), 'http://127.0.0.1:1/quo');
|
|
132
|
-
assert.deepEqual(await s.hints(), { [pk]: 'https://quo.acme.com/quo', ['ef'.repeat(64)]: 'http://127.0.0.1:1/quo' });
|
|
133
|
-
await s.hint(pk, 'https://quo.other.com/quo');
|
|
134
|
-
assert.equal((await s.hints())[pk], 'https://quo.other.com/quo');
|
|
135
|
-
});
|
|
136
125
|
}
|
package/src/harbor/core.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// doors; a socket a dialer holds to it, bound once the door behind the
|
|
13
13
|
// dialer's claim has proved it holds the key, and unbound at close; the
|
|
14
14
|
// claims of a listener this harbor dialed, proved the same way and reached
|
|
15
|
-
// through that line; and a hint, a pk at a URL
|
|
16
|
-
//
|
|
15
|
+
// through that line; and a hint, a pk at a URL. A hint never displaces a
|
|
16
|
+
// reach proved at a door. The directory lives in memory and is kept by no
|
|
17
|
+
// store: whoever learns a hint keeps it where it keeps everything, in a
|
|
18
|
+
// being's cells, and hands it back at every boot. And one fallback: a dialer with nothing in its
|
|
17
19
|
// directory for a pk sends down the socket it holds, because the listener
|
|
18
20
|
// it dialed is the rendezvous and may hold that pk on another socket. Bytes
|
|
19
21
|
// that arrive from the wire go to an own door or a held socket and never
|
|
@@ -110,6 +112,9 @@ const PROVING = 8;
|
|
|
110
112
|
// How many times each reach has announced: a proof from an older announce
|
|
111
113
|
// binds nothing when it lands.
|
|
112
114
|
const announces = new WeakMap<Reach, number>();
|
|
115
|
+
// The reaches that are hints, so a hint can be replaced and put down without
|
|
116
|
+
// touching a reach a door proved.
|
|
117
|
+
const hinted = new WeakSet<Reach>();
|
|
113
118
|
|
|
114
119
|
export class Harbor {
|
|
115
120
|
readonly store: Store;
|
|
@@ -166,9 +171,9 @@ export class Harbor {
|
|
|
166
171
|
return this.offering?.(name) ?? Promise.resolve(undefined);
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
// Boot every ward the store keeps
|
|
170
|
-
//
|
|
171
|
-
//
|
|
174
|
+
// Boot every ward the store keeps. One ward that will not host, a
|
|
175
|
+
// partition this kit cannot read or a loader that throws, is named in
|
|
176
|
+
// `unbooted` and takes nobody else down with it.
|
|
172
177
|
async boot(): Promise<void> {
|
|
173
178
|
for (const name of await this.store.list()) {
|
|
174
179
|
// A ward this harbor already hosts is not booted twice. A terrain that
|
|
@@ -182,7 +187,6 @@ export class Harbor {
|
|
|
182
187
|
this.unbooted.push(name);
|
|
183
188
|
}
|
|
184
189
|
}
|
|
185
|
-
for (const [pk, url] of Object.entries(await this.store.hints())) this.hint(pk, url);
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
// The object a ward writes into, by the name it is kept under: the
|
|
@@ -199,6 +203,18 @@ export class Harbor {
|
|
|
199
203
|
|
|
200
204
|
// ---- saving
|
|
201
205
|
|
|
206
|
+
// Every ward's writes kept and none waiting: no row named, no save armed
|
|
207
|
+
// and none in flight. A write can name a row while another ward is being
|
|
208
|
+
// saved, so this goes round until a pass finds nothing. A process about to
|
|
209
|
+
// exit waits here, since its exit is not a turn the timer is given.
|
|
210
|
+
async quiet(): Promise<void> {
|
|
211
|
+
for (;;) {
|
|
212
|
+
const busy = [...this.#saving].filter(([, s]) => !s.gone && (s.dirty.size > 0 || s.timer !== undefined || s.running !== null));
|
|
213
|
+
if (busy.length === 0) return;
|
|
214
|
+
for (const [name, s] of busy) await (s.running ?? this.#flush(name));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
// The ward wrote one row. Its partition is saved once the line is free, and
|
|
203
219
|
// once for every burst of writes, with every row named in that burst.
|
|
204
220
|
#wrote(name: string, row: string): void {
|
|
@@ -418,14 +434,36 @@ export class Harbor {
|
|
|
418
434
|
return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
|
|
419
435
|
}
|
|
420
436
|
|
|
437
|
+
// A hint, taken on the word of whoever gives it. A hint already standing
|
|
438
|
+
// for the pk is replaced, since a URL is the one part of a ward that moves;
|
|
439
|
+
// a reach proven at a door is never displaced by one.
|
|
421
440
|
hint(pk: string, url: string): void {
|
|
422
|
-
|
|
423
|
-
|
|
441
|
+
const standing = this.reaches.get(pk);
|
|
442
|
+
if (standing && !hinted.has(standing.reach)) return; // a reach proven at a door beats a hint, held or reached through a dialed line
|
|
443
|
+
const reach = request(url);
|
|
444
|
+
hinted.add(reach);
|
|
445
|
+
this.reaches.set(pk, { reach, held: false });
|
|
424
446
|
}
|
|
425
447
|
|
|
426
|
-
|
|
448
|
+
// A hint kept only once the door behind it has proven it holds the pk's
|
|
449
|
+
// key, the same probe an announce is proven by. A URL that answers for
|
|
450
|
+
// another ward, or for nobody, is refused and nothing is kept: whoever
|
|
451
|
+
// hands a harbor a hint could otherwise point one ward's asks at a place
|
|
452
|
+
// that drops them. True is kept.
|
|
453
|
+
async learn(pk: string, url: string): Promise<boolean> {
|
|
454
|
+
const standing = this.reaches.get(pk);
|
|
455
|
+
if (standing && !hinted.has(standing.reach)) return true; // already reached, and proven at a door
|
|
456
|
+
const reach = request(url);
|
|
457
|
+
if (!(await this.prove(pk, reach))) return false;
|
|
427
458
|
this.hint(pk, url);
|
|
428
|
-
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A hint put down. A reach proven at a door is the line's, and goes when
|
|
463
|
+
// the line does, never by a word from here.
|
|
464
|
+
forget(pk: string): void {
|
|
465
|
+
const standing = this.reaches.get(pk);
|
|
466
|
+
if (standing && hinted.has(standing.reach)) this.reaches.delete(pk);
|
|
429
467
|
}
|
|
430
468
|
|
|
431
469
|
// An announce is a claim, and a claim binds nothing until proven: anyone
|
package/src/harbor/reach.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// A reach that sent the bytes and lost the line afterwards answers nothing
|
|
21
21
|
// at all, and the ward's own bound ends the ask.
|
|
22
22
|
import { concat, hex, unhex } from '../ward/arithmetic.ts';
|
|
23
|
+
import { readJson } from '../ward/json.ts';
|
|
23
24
|
|
|
24
25
|
export type Carry = (pk: string, bytes: Uint8Array) => Promise<Uint8Array | undefined>;
|
|
25
26
|
export type Reach = { carry: Carry; close(): void };
|
|
@@ -136,7 +137,8 @@ export class Socket implements Reach {
|
|
|
136
137
|
line.addEventListener('message', (e) => {
|
|
137
138
|
if (typeof e.data === 'string') {
|
|
138
139
|
try {
|
|
139
|
-
const m =
|
|
140
|
+
const m = readJson(e.data) as { announce?: unknown; suite?: unknown };
|
|
141
|
+
if (m === null || typeof m !== 'object') return;
|
|
140
142
|
// Absent is this suite: a side that says nothing says one.
|
|
141
143
|
if (m.suite !== undefined && m.suite !== SUITE) {
|
|
142
144
|
this.far = [];
|
|
@@ -153,9 +155,13 @@ export class Socket implements Reach {
|
|
|
153
155
|
return;
|
|
154
156
|
}
|
|
155
157
|
const buf = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : ArrayBuffer.isView(e.data) ? new Uint8Array(e.data.buffer, e.data.byteOffset, e.data.byteLength) : undefined;
|
|
158
|
+
// A frame that is none of the three is not a frame: an unknown kind, an
|
|
159
|
+
// ask too short to hold a pk, a nothing with bytes after its id. It is
|
|
160
|
+
// dropped, and tells no ask waiting on its id that nothing was delivered.
|
|
156
161
|
if (!buf || buf.length < 5) return;
|
|
157
162
|
const kind = buf[0],
|
|
158
163
|
id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
|
|
164
|
+
if (kind > NONE || (kind === ASK && buf.length < 69) || (kind === NONE && buf.length !== 5)) return;
|
|
159
165
|
if (kind === ASK) {
|
|
160
166
|
const pk = hex(buf.subarray(5, 69));
|
|
161
167
|
// A deliver that rejects has delivered nothing this side can vouch
|
package/src/harbor/store.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// The store: keeping wards by name. A ward is three things and the store
|
|
3
3
|
// keeps all three under one name: the seed, the partition and the ward
|
|
4
4
|
// record, which says where the class bodies come from and which being is
|
|
5
|
-
// the user's.
|
|
6
|
-
// survive a restart. The store reads nothing it keeps; the partition is
|
|
5
|
+
// the user's. The store reads nothing it keeps; the partition is
|
|
7
6
|
// values the ward wrote, and the seed is bytes only the ward derives from.
|
|
8
7
|
// Every terrain has one: files on a disk, IndexedDB in a tab, an object's
|
|
9
8
|
// storage on the edge; the memory store below is the library's own, for a
|
|
@@ -32,8 +31,6 @@ export type Store = {
|
|
|
32
31
|
record(name: string, record: WardRecord): Promise<void>;
|
|
33
32
|
// Take a ward out: the first move of a migration. What was kept comes back.
|
|
34
33
|
take(name: string): Promise<Kept | undefined>;
|
|
35
|
-
hints(): Promise<Record<string, string>>;
|
|
36
|
-
hint(pk: string, url: string): Promise<void>;
|
|
37
34
|
};
|
|
38
35
|
|
|
39
36
|
// The partition is values only, and the ward hands it out through a guard,
|
|
@@ -64,7 +61,6 @@ type Row = { seed: Uint8Array; parts: Map<string, Record<string, unknown>>; reco
|
|
|
64
61
|
|
|
65
62
|
export class MemoryStore implements Store {
|
|
66
63
|
readonly rows = new Map<string, Row>();
|
|
67
|
-
readonly reach = new Map<string, string>();
|
|
68
64
|
|
|
69
65
|
async list(): Promise<string[]> {
|
|
70
66
|
return [...this.rows.keys()];
|
|
@@ -92,10 +88,4 @@ export class MemoryStore implements Store {
|
|
|
92
88
|
this.rows.delete(name);
|
|
93
89
|
return kept;
|
|
94
90
|
}
|
|
95
|
-
async hints(): Promise<Record<string, string>> {
|
|
96
|
-
return Object.fromEntries(this.reach);
|
|
97
|
-
}
|
|
98
|
-
async hint(pk: string, url: string): Promise<void> {
|
|
99
|
-
this.reach.set(pk, url);
|
|
100
|
-
}
|
|
101
91
|
}
|
package/src/vector/stand.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// world runs on.
|
|
19
19
|
import { SUITE } from '../harbor/reach.ts';
|
|
20
20
|
import { hex } from '../ward/arithmetic.ts';
|
|
21
|
+
import { readJson } from '../ward/json.ts';
|
|
21
22
|
import { caseNamed, stand } from './cases.ts';
|
|
22
23
|
import type { World } from './world.ts';
|
|
23
24
|
import { partitionDigest } from './world.ts';
|
|
@@ -44,7 +45,7 @@ export class Stand {
|
|
|
44
45
|
if (method === 'POST' && p === '/stand') {
|
|
45
46
|
let named: { case?: unknown; name?: unknown };
|
|
46
47
|
try {
|
|
47
|
-
named =
|
|
48
|
+
named = readJson(body) as typeof named;
|
|
48
49
|
} catch {
|
|
49
50
|
return NONE;
|
|
50
51
|
}
|
package/src/ward/arithmetic.ts
CHANGED
|
@@ -58,8 +58,10 @@ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
58
58
|
return diff === 0;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// The eight small-order points of Ed25519, in their canonical encoding
|
|
62
|
-
//
|
|
61
|
+
// The eight small-order points of Ed25519, in their canonical encoding, and
|
|
62
|
+
// the two points with x = 0 spelled with the sign bit set, which a decoder
|
|
63
|
+
// that ignores the bit reads as the same point. A public key among them
|
|
64
|
+
// verifies nothing. An encoding whose y coordinate is
|
|
63
65
|
// not reduced, y at or above the field's prime p, names one of the same
|
|
64
66
|
// points under another spelling, and a terrain's verify may accept a zero
|
|
65
67
|
// signature under it; the field has room for the spelling, so it is refused
|
|
@@ -73,6 +75,8 @@ const SMALL_ORDER = [
|
|
|
73
75
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
74
76
|
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
75
77
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
78
|
+
'0100000000000000000000000000000000000000000000000000000000000080',
|
|
79
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
|
76
80
|
].map(unhex);
|
|
77
81
|
// y >= p, read little-endian with the sign bit masked off: the top byte is
|
|
78
82
|
// 0x7f under the mask, every middle byte is 0xff, and the low byte is at
|
package/src/ward/json.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The one reader of JSON that crossed an edge. A runtime's own parser is
|
|
3
|
+
// where two kits stop agreeing: it keeps the last of two keys of one name,
|
|
4
|
+
// rounds a whole number no double holds, reads a number past the doubles as
|
|
5
|
+
// infinity, and mends bytes that are not UTF-8 into a replacement character.
|
|
6
|
+
// Each of those is text two kits read two ways, so each is refused here, where
|
|
7
|
+
// it is read, with a throw the caller already turns into what bytes that said
|
|
8
|
+
// nothing are.
|
|
9
|
+
//
|
|
10
|
+
// What a value is beyond its text, a lone surrogate, a key named `__proto__`,
|
|
11
|
+
// a nesting past the bound, is the value rule's, and `cellFault` in cells.ts
|
|
12
|
+
// is its one reader. This file holds the rules that live only in the text.
|
|
13
|
+
|
|
14
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
15
|
+
|
|
16
|
+
const fail = (at: number, why: string): never => {
|
|
17
|
+
throw new Error(`JSON at ${at}: ${why}`);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A number text, held to the Values chapter. A text naming a whole number
|
|
21
|
+
// stands only if that number is exactly a double, or if the text is how
|
|
22
|
+
// ECMAScript writes the double it reads as. A text past the largest double,
|
|
23
|
+
// or naming a value other than zero that rounds to zero, is refused, and so
|
|
24
|
+
// is minus zero in any spelling.
|
|
25
|
+
export function numberFault(literal: string): string | null {
|
|
26
|
+
const m = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
|
|
27
|
+
if (!m) return 'not a number';
|
|
28
|
+
const [, sign, whole, frac = '', exp = '0'] = m;
|
|
29
|
+
const v = Number(literal);
|
|
30
|
+
if (!Number.isFinite(v)) return 'past the largest double';
|
|
31
|
+
let digits = (whole + frac).replace(/^0+/, '');
|
|
32
|
+
if (v === 0) {
|
|
33
|
+
if (digits !== '') return 'a value other than zero that rounds to zero';
|
|
34
|
+
return sign ? 'minus zero' : null;
|
|
35
|
+
}
|
|
36
|
+
// The exact value is digits times ten to e. A finite double bounds e from
|
|
37
|
+
// above, so the power below is never larger than the double's own width.
|
|
38
|
+
let e = BigInt(exp) - BigInt(frac.length);
|
|
39
|
+
const trimmed = digits.replace(/0+$/, '');
|
|
40
|
+
e += BigInt(digits.length - trimmed.length);
|
|
41
|
+
digits = trimmed;
|
|
42
|
+
if (e < 0n) return null; // a fraction: the double nearest it, as every reader takes it
|
|
43
|
+
const exact = BigInt(digits) * 10n ** e;
|
|
44
|
+
if (exact === (v < 0 ? -BigInt(v) : BigInt(v))) return null;
|
|
45
|
+
return literal === String(v) ? null : 'a whole number that is not exactly a double';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Bytes to a value, or a throw.
|
|
49
|
+
export function readJson(input: Uint8Array | string): unknown {
|
|
50
|
+
const s = typeof input === 'string' ? input : utf8.decode(input);
|
|
51
|
+
let at = 0;
|
|
52
|
+
const space = () => {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const c = s.charCodeAt(at);
|
|
55
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) at += 1;
|
|
56
|
+
else return;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const string = (): string => {
|
|
60
|
+
const start = at;
|
|
61
|
+
at += 1;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const c = s.charCodeAt(at);
|
|
64
|
+
if (Number.isNaN(c)) fail(start, 'an unterminated string');
|
|
65
|
+
if (c === 0x22) break;
|
|
66
|
+
if (c < 0x20) fail(at, 'a control character in a string');
|
|
67
|
+
at += c === 0x5c ? 2 : 1;
|
|
68
|
+
}
|
|
69
|
+
at += 1;
|
|
70
|
+
// The escapes are JSON's own, and the runtime reads them the one way.
|
|
71
|
+
return JSON.parse(s.slice(start, at)) as string;
|
|
72
|
+
};
|
|
73
|
+
const value = (): unknown => {
|
|
74
|
+
space();
|
|
75
|
+
const c = s[at];
|
|
76
|
+
if (c === '{') {
|
|
77
|
+
at += 1;
|
|
78
|
+
const out: Record<string, unknown> = {};
|
|
79
|
+
const keys = new Set<string>();
|
|
80
|
+
space();
|
|
81
|
+
if (s[at] === '}') {
|
|
82
|
+
at += 1;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (;;) {
|
|
86
|
+
space();
|
|
87
|
+
if (s[at] !== '"') fail(at, 'a key that is not a string');
|
|
88
|
+
const where = at;
|
|
89
|
+
const k = string();
|
|
90
|
+
if (keys.has(k)) fail(where, `two keys named ${JSON.stringify(k)}`);
|
|
91
|
+
keys.add(k);
|
|
92
|
+
space();
|
|
93
|
+
if (s[at] !== ':') fail(at, 'a key with no colon');
|
|
94
|
+
at += 1;
|
|
95
|
+
// Defined, never assigned: a key named `__proto__` is an own key here
|
|
96
|
+
// as it is in JSON.parse, and the value rule refuses it after.
|
|
97
|
+
Object.defineProperty(out, k, { value: value(), enumerable: true, writable: true, configurable: true });
|
|
98
|
+
space();
|
|
99
|
+
if (s[at] === ',') at += 1;
|
|
100
|
+
else if (s[at] === '}') {
|
|
101
|
+
at += 1;
|
|
102
|
+
return out;
|
|
103
|
+
} else fail(at, 'an object not closed');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (c === '[') {
|
|
107
|
+
at += 1;
|
|
108
|
+
const out: unknown[] = [];
|
|
109
|
+
space();
|
|
110
|
+
if (s[at] === ']') {
|
|
111
|
+
at += 1;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
for (;;) {
|
|
115
|
+
out.push(value());
|
|
116
|
+
space();
|
|
117
|
+
if (s[at] === ',') at += 1;
|
|
118
|
+
else if (s[at] === ']') {
|
|
119
|
+
at += 1;
|
|
120
|
+
return out;
|
|
121
|
+
} else fail(at, 'a list not closed');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (c === '"') return string();
|
|
125
|
+
for (const [word, v] of [['true', true], ['false', false], ['null', null]] as const) {
|
|
126
|
+
if (s.startsWith(word, at)) {
|
|
127
|
+
at += word.length;
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const m = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
132
|
+
m.lastIndex = at;
|
|
133
|
+
const hit = m.exec(s);
|
|
134
|
+
if (!hit) return fail(at, 'no value');
|
|
135
|
+
const why = numberFault(hit[0]);
|
|
136
|
+
if (why !== null) fail(at, `${hit[0]} is ${why}`);
|
|
137
|
+
at += hit[0].length;
|
|
138
|
+
return Number(hit[0]);
|
|
139
|
+
};
|
|
140
|
+
const out = value();
|
|
141
|
+
space();
|
|
142
|
+
if (at !== s.length) fail(at, 'text after the value');
|
|
143
|
+
return out;
|
|
144
|
+
}
|
package/src/ward/seal.ts
CHANGED
|
@@ -23,9 +23,9 @@ import type { DoorWord, Json, JsonObject } from '../being/types.ts';
|
|
|
23
23
|
import { isDoorWord } from '../being/silence.ts';
|
|
24
24
|
import { KEY, SIGNATURE, box, concat, derive, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.ts';
|
|
25
25
|
import { cellFault } from './cells.ts';
|
|
26
|
+
import { readJson } from './json.ts';
|
|
26
27
|
|
|
27
28
|
const utf8 = new TextEncoder();
|
|
28
|
-
const text = new TextDecoder();
|
|
29
29
|
|
|
30
30
|
export type WardKey = { sign: Uint8Array; padlock: Uint8Array; signPk: Uint8Array; padlockPk: Uint8Array; pk: string };
|
|
31
31
|
|
|
@@ -115,7 +115,7 @@ export async function openAsk(bytes: Uint8Array, padlockSecret: Uint8Array): Pro
|
|
|
115
115
|
if (inside.length <= SIGNATURE) return null;
|
|
116
116
|
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
117
117
|
const signature = inside.subarray(inside.length - SIGNATURE);
|
|
118
|
-
const payload =
|
|
118
|
+
const payload = readJson(body) as Sealed;
|
|
119
119
|
// The heir, or null for the public being. Absent is not null: a payload
|
|
120
120
|
// that never named one is malformed, like any other missing field.
|
|
121
121
|
const to = payload.to;
|
|
@@ -150,7 +150,9 @@ export async function sealReply(reply: ReplyPayload, ephemeralPk: Uint8Array, wa
|
|
|
150
150
|
// lied, or it is not a reply. Every ward on the wire is somebody else's code,
|
|
151
151
|
// so the shape is checked here and nowhere later: a reply is `{ silence }`,
|
|
152
152
|
// `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
|
|
153
|
-
// object
|
|
153
|
+
// object that is a value and seen a digest or null. A field beside a shape's
|
|
154
|
+
// own makes it none of the three, since a kit that dropped the field would
|
|
155
|
+
// hand her an answer where this one hands her silence.
|
|
154
156
|
// Anything else is bytes that said nothing, and her ward never hands her a
|
|
155
157
|
// throw or an absent value for what a stranger wrote.
|
|
156
158
|
export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, signPk: Uint8Array): Promise<ReplyPayload | null> {
|
|
@@ -160,13 +162,15 @@ export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, sig
|
|
|
160
162
|
if (inside.length <= SIGNATURE) return null;
|
|
161
163
|
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
162
164
|
if (!(await verify(body, inside.subarray(inside.length - SIGNATURE), signPk))) return null;
|
|
163
|
-
const reply =
|
|
165
|
+
const reply = readJson(body);
|
|
164
166
|
if (reply === null || typeof reply !== 'object' || Array.isArray(reply)) return null;
|
|
165
167
|
const r = reply as Record<string, unknown>;
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
if (
|
|
168
|
+
const keys = Object.keys(r).sort().join(',');
|
|
169
|
+
if (keys === 'silence') return r.silence === true ? { silence: true } : null;
|
|
170
|
+
if (keys === 'quo') return isDoorWord(r.quo) ? { quo: r.quo } : null;
|
|
171
|
+
if (keys !== 'object,seen') return null;
|
|
169
172
|
if (r.seen !== null && !isHex(r.seen)) return null;
|
|
173
|
+
if (cellFault(r.object, 'object') !== null) return null; // values in both directions: nested past the bound is none of the shapes
|
|
170
174
|
return { object: r.object as Json, seen: r.seen };
|
|
171
175
|
} catch {
|
|
172
176
|
return null;
|