@quo-systems/quo 0.2.9 → 0.2.11
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 +19 -10
- package/SPEC.md +301 -110
- package/dist/being/being.d.ts +2 -2
- package/dist/being/being.js +34 -12
- package/dist/being/digest.js +26 -8
- package/dist/being/index.d.ts +2 -2
- package/dist/being/index.js +2 -2
- package/dist/being/silence.d.ts +2 -0
- package/dist/being/silence.js +12 -0
- package/dist/being/types.d.ts +4 -2
- package/dist/being/types.js +25 -0
- package/dist/conformance/assert.js +40 -6
- package/dist/conformance/beings.d.ts +48 -5
- package/dist/conformance/beings.js +39 -8
- package/dist/conformance/estate.js +110 -21
- package/dist/conformance/index.d.ts +5 -2
- package/dist/conformance/index.js +165 -7
- package/dist/harbor/core.d.ts +5 -2
- package/dist/harbor/core.js +195 -45
- package/dist/harbor/dial.js +32 -15
- package/dist/harbor/index.d.ts +1 -0
- package/dist/harbor/index.js +3 -0
- package/dist/harbor/memory.d.ts +3 -3
- package/dist/harbor/memory.js +7 -12
- package/dist/harbor/reach.js +42 -17
- package/dist/ward/allowance.js +15 -4
- package/dist/ward/arithmetic.d.ts +1 -0
- package/dist/ward/arithmetic.js +22 -6
- package/dist/ward/cells.d.ts +3 -1
- package/dist/ward/cells.js +79 -21
- package/dist/ward/door.d.ts +3 -2
- package/dist/ward/door.js +38 -10
- package/dist/ward/ground.d.ts +5 -1
- package/dist/ward/ground.js +38 -1
- package/dist/ward/heirs.d.ts +2 -3
- package/dist/ward/heirs.js +19 -13
- package/dist/ward/index.d.ts +1 -0
- package/dist/ward/index.js +3 -0
- package/dist/ward/owner.d.ts +6 -27
- package/dist/ward/owner.js +59 -33
- package/dist/ward/partition.d.ts +3 -0
- package/dist/ward/partition.js +109 -4
- package/dist/ward/seal.d.ts +1 -0
- package/dist/ward/seal.js +41 -13
- package/dist/ward/stance.d.ts +7 -3
- package/dist/ward/stance.js +156 -66
- package/dist/ward/ward.d.ts +10 -0
- package/dist/ward/ward.js +123 -51
- package/package.json +4 -2
- package/src/being/being.ts +33 -11
- package/src/being/digest.ts +28 -13
- package/src/being/index.ts +2 -2
- package/src/being/silence.ts +14 -0
- package/src/being/types.ts +39 -5
- package/src/conformance/assert.ts +37 -4
- package/src/conformance/beings.ts +41 -10
- package/src/conformance/estate.ts +107 -20
- package/src/conformance/index.ts +188 -13
- package/src/harbor/core.ts +203 -46
- package/src/harbor/dial.ts +46 -17
- package/src/harbor/index.ts +3 -0
- package/src/harbor/memory.ts +8 -13
- package/src/harbor/reach.ts +47 -21
- package/src/ward/allowance.ts +15 -4
- package/src/ward/arithmetic.ts +25 -8
- package/src/ward/cells.ts +76 -25
- package/src/ward/door.ts +38 -12
- package/src/ward/ground.ts +52 -3
- package/src/ward/heirs.ts +19 -13
- package/src/ward/index.ts +3 -0
- package/src/ward/owner.ts +65 -46
- package/src/ward/partition.ts +109 -5
- package/src/ward/seal.ts +41 -12
- package/src/ward/stance.ts +163 -64
- package/src/ward/ward.ts +124 -52
- package/vectors/arithmetic.json +7 -0
- package/vectors/framing.json +30 -15
- package/vectors/wire.json +4 -4
package/src/harbor/reach.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Reach: carrying bytes to a pk off the device. One interface, two
|
|
3
|
-
// implementations
|
|
3
|
+
// implementations:
|
|
4
4
|
//
|
|
5
|
-
// request a URL
|
|
6
|
-
//
|
|
5
|
+
// request a URL. POST the bytes to `<url>/<pk>`, get bytes back.
|
|
6
|
+
// Listener to listener.
|
|
7
7
|
// socket a held WebSocket at `<url>`, opened by whichever side can
|
|
8
8
|
// dial, used in both directions with a frame id. Each side
|
|
9
9
|
// announces the ward pks it holds when the socket opens, and the
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// at the far end, a connection that would not open, a socket that is gone.
|
|
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
|
-
import { hex, unhex } from '../ward/arithmetic.ts';
|
|
22
|
+
import { concat, hex, unhex } from '../ward/arithmetic.ts';
|
|
23
23
|
|
|
24
24
|
export type Carry = (pk: string, bytes: Uint8Array) => Promise<Uint8Array | undefined>;
|
|
25
25
|
export type Reach = { carry: Carry; close(): void };
|
|
@@ -33,12 +33,22 @@ export function request(url: string): Reach {
|
|
|
33
33
|
close: () => {},
|
|
34
34
|
carry: async (pk, bytes) => {
|
|
35
35
|
let res: Response;
|
|
36
|
+
// A body of its own, not the caller's view: fetch reads it after this
|
|
37
|
+
// line returns, and a buffer somebody else may write is not a body. The
|
|
38
|
+
// copy is its own ArrayBuffer, which is what a body is; the bytes handed
|
|
39
|
+
// in are typed as backed by anything, shared memory included.
|
|
40
|
+
const body = new Uint8Array(bytes.length);
|
|
41
|
+
body.set(bytes);
|
|
36
42
|
try {
|
|
37
|
-
res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body
|
|
43
|
+
res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body });
|
|
38
44
|
} catch {
|
|
39
45
|
return undefined; // the connection never opened: nothing was delivered
|
|
40
46
|
}
|
|
41
|
-
|
|
47
|
+
// A status that says the listener did not take the bytes is nothing
|
|
48
|
+
// delivered: no reach for that pk, a request it refused on its face, a
|
|
49
|
+
// gateway with nobody behind it. Any other failure is a listener that
|
|
50
|
+
// took them and failed after, which is sent and no word since.
|
|
51
|
+
if (res.status === 404 || (res.status >= 400 && res.status < 500) || res.status === 502 || res.status === 503 || res.status === 504) return undefined;
|
|
42
52
|
if (!res.ok) return never();
|
|
43
53
|
return new Uint8Array(await res.arrayBuffer());
|
|
44
54
|
},
|
|
@@ -75,6 +85,11 @@ export const SUITE = 1;
|
|
|
75
85
|
// the refusal names itself on the way out, and the far side may read it.
|
|
76
86
|
export const REFUSED = 4001;
|
|
77
87
|
const OPEN = 1; // WebSocket.OPEN, on every terrain
|
|
88
|
+
// How many asks a socket keeps a resolver for. An ask the far side never
|
|
89
|
+
// answers is ended by the ward's bound, and its resolver stays until the
|
|
90
|
+
// line closes; a far side that answers nothing for long enough would grow
|
|
91
|
+
// the map for as long as this side asks, so the oldest goes when it is full.
|
|
92
|
+
const PENDING = 4096;
|
|
78
93
|
|
|
79
94
|
// The standard surface, and no more of it: what a browser WebSocket, Node's
|
|
80
95
|
// WebSocket and a `ws` server socket all have.
|
|
@@ -106,7 +121,17 @@ export class Socket implements Reach {
|
|
|
106
121
|
constructor(line: Line, deliver: Carry, onAnnounce: Announce, onClose: (why: { code?: number; reason?: string }) => void) {
|
|
107
122
|
this.line = line;
|
|
108
123
|
line.binaryType = 'arraybuffer';
|
|
109
|
-
|
|
124
|
+
// A line that opens is a reach; one that errors, or closes before it
|
|
125
|
+
// opened, is a reach that failed, and an ask waiting on the handshake is
|
|
126
|
+
// nothing delivered.
|
|
127
|
+
this.#open =
|
|
128
|
+
line.readyState === OPEN
|
|
129
|
+
? Promise.resolve()
|
|
130
|
+
: new Promise((ok, no) => {
|
|
131
|
+
line.addEventListener('open', ok);
|
|
132
|
+
line.addEventListener('error', no);
|
|
133
|
+
line.addEventListener('close', () => no(new Error('closed before it opened')));
|
|
134
|
+
});
|
|
110
135
|
this.#open.catch(() => {}); // a handshake refused is a reach that failed, not a process that dies
|
|
111
136
|
line.addEventListener('message', (e) => {
|
|
112
137
|
if (typeof e.data === 'string') {
|
|
@@ -133,12 +158,19 @@ export class Socket implements Reach {
|
|
|
133
158
|
id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
|
|
134
159
|
if (kind === ASK) {
|
|
135
160
|
const pk = hex(buf.subarray(5, 69));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
161
|
+
// A deliver that rejects has delivered nothing this side can vouch
|
|
162
|
+
// for, and a listener does not die of it: the far side hears none.
|
|
163
|
+
void deliver(pk, new Uint8Array(buf.subarray(69)))
|
|
164
|
+
.then(
|
|
165
|
+
(back) => back,
|
|
166
|
+
() => undefined,
|
|
167
|
+
)
|
|
168
|
+
.then((back) => {
|
|
169
|
+
const head = new Uint8Array(5);
|
|
170
|
+
head[0] = back === undefined ? NONE : REPLY;
|
|
171
|
+
new DataView(head.buffer).setUint32(1, id);
|
|
172
|
+
if (line.readyState === OPEN) line.send(back === undefined ? head : concat([head, back]));
|
|
173
|
+
});
|
|
142
174
|
return;
|
|
143
175
|
}
|
|
144
176
|
const waiting = this.pending.get(id);
|
|
@@ -171,8 +203,9 @@ export class Socket implements Reach {
|
|
|
171
203
|
new DataView(head.buffer).setUint32(1, id);
|
|
172
204
|
head.set(unhex(pk), 5);
|
|
173
205
|
return new Promise((ok) => {
|
|
206
|
+
if (this.pending.size >= PENDING) this.pending.delete(this.pending.keys().next().value!); // the oldest, which the ward's bound ended long ago
|
|
174
207
|
this.pending.set(id, ok);
|
|
175
|
-
this.line.send(concat(head, bytes));
|
|
208
|
+
this.line.send(concat([head, bytes]));
|
|
176
209
|
});
|
|
177
210
|
}
|
|
178
211
|
|
|
@@ -180,10 +213,3 @@ export class Socket implements Reach {
|
|
|
180
213
|
this.line.close();
|
|
181
214
|
}
|
|
182
215
|
}
|
|
183
|
-
|
|
184
|
-
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
185
|
-
const out = new Uint8Array(a.length + b.length);
|
|
186
|
-
out.set(a, 0);
|
|
187
|
-
out.set(b, a.length);
|
|
188
|
-
return out;
|
|
189
|
-
}
|
package/src/ward/allowance.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// is held to the ward's ceiling. The door reads it before anything is done
|
|
6
6
|
// under it and refuses a budget already gone, as one silence like every other
|
|
7
7
|
// refusal there. The sender bounds the whole of her ask to the same number,
|
|
8
|
-
// and a wait that ran out is
|
|
8
|
+
// and a wait that ran out is the word late, never silence and never unreached.
|
|
9
9
|
//
|
|
10
10
|
// Each ask is bounded on its own. The time an arriving call has left does not
|
|
11
11
|
// bound the asks a being makes while answering it: attributing her onward ask
|
|
@@ -33,12 +33,23 @@ export const CEILING: Allowance = { time: 300_000 };
|
|
|
33
33
|
// is thirty seconds, silently, because the ceiling is not hers to know.
|
|
34
34
|
// Asking for nothing at all is the default, which is the whole point.
|
|
35
35
|
export function allow(wanted: Wanted | undefined, ceiling: Allowance = CEILING, base: Allowance = DEFAULT): Allowance {
|
|
36
|
-
|
|
36
|
+
// Floored first, then tested: a fraction of a millisecond is not a budget,
|
|
37
|
+
// and falls to the default like every other number that is not a positive
|
|
38
|
+
// whole one.
|
|
39
|
+
const whole = (n: unknown, fallback: number) => {
|
|
40
|
+
const w = typeof n === 'number' ? Math.floor(n) : NaN;
|
|
41
|
+
return Number.isFinite(w) && w > 0 ? w : fallback;
|
|
42
|
+
};
|
|
37
43
|
return { time: Math.min(whole(wanted?.time, base.time), ceiling.time) };
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
// Whether a budget has anything left to spend
|
|
41
|
-
|
|
46
|
+
// Whether a budget has anything left to spend, and the one reader of it: the
|
|
47
|
+
// door calls this on arrival and nothing else asks the question anywhere. A
|
|
48
|
+
// time that is not a whole number above zero is not a budget that ran out, it
|
|
49
|
+
// is no budget at all, and the door refuses both the same way, since a peer
|
|
50
|
+
// learns nothing from the difference. `allow` never makes one, so only bytes
|
|
51
|
+
// off the road ever carry one.
|
|
52
|
+
export const spent = (a: Allowance): boolean => !(Number.isSafeInteger(a.time) && a.time > 0);
|
|
42
53
|
|
|
43
54
|
// What a wait that ran out comes back as. Its own value, held by nobody
|
|
44
55
|
// outside the ward, so no answer from any door can be mistaken for it.
|
package/src/ward/arithmetic.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// runs on. `test/floor.test.ts` names the floor and probes for it. Subtle is
|
|
9
9
|
// asynchronous, so everything here is.
|
|
10
10
|
//
|
|
11
|
-
// Ported from an earlier kit's arithmetic. Same bytes, same vectors.
|
|
12
11
|
// `crypto.subtle` is read at every use and never captured at load. A browser
|
|
13
12
|
// on a plain http:// origin has `crypto` without `subtle`, and a terrain may
|
|
14
13
|
// install one after this module is first imported; a reference taken here
|
|
@@ -24,7 +23,7 @@ export const SIGNATURE = 64;
|
|
|
24
23
|
export const NONCE = 12;
|
|
25
24
|
export const TAG = 16;
|
|
26
25
|
const SEAL_INFO = new TextEncoder().encode('quo-seal');
|
|
27
|
-
const
|
|
26
|
+
const SALT = new Uint8Array(0);
|
|
28
27
|
|
|
29
28
|
// A 32-byte secret plus a fixed prefix is the whole PKCS#8 wrapping for both curves.
|
|
30
29
|
const ED_SECRET = unhex('302e020100300506032b657004220420');
|
|
@@ -59,7 +58,12 @@ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
59
58
|
return diff === 0;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
// The eight small-order points
|
|
61
|
+
// The eight small-order points of Ed25519, in their canonical encoding. A
|
|
62
|
+
// public key among them verifies nothing. An encoding whose y coordinate is
|
|
63
|
+
// not reduced, y at or above the field's prime p, names one of the same
|
|
64
|
+
// points under another spelling, and a terrain's verify may accept a zero
|
|
65
|
+
// signature under it; the field has room for the spelling, so it is refused
|
|
66
|
+
// before the list is read.
|
|
63
67
|
const SMALL_ORDER = [
|
|
64
68
|
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
65
69
|
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
@@ -70,7 +74,11 @@ const SMALL_ORDER = [
|
|
|
70
74
|
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
71
75
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
72
76
|
].map(unhex);
|
|
73
|
-
|
|
77
|
+
// y >= p, read little-endian with the sign bit masked off: the top byte is
|
|
78
|
+
// 0x7f under the mask, every middle byte is 0xff, and the low byte is at
|
|
79
|
+
// least 0xed, which is p's.
|
|
80
|
+
const unreduced = (pk: Uint8Array): boolean => (pk[31] & 0x7f) === 0x7f && pk[0] >= 0xed && pk.subarray(1, 31).every((b) => b === 0xff);
|
|
81
|
+
export const smallOrder = (pk: Uint8Array): boolean => unreduced(pk) || SMALL_ORDER.some((p) => sameBytes(p, pk));
|
|
74
82
|
|
|
75
83
|
const key32 = (value: Uint8Array, what: string): Uint8Array => {
|
|
76
84
|
if (!(value instanceof Uint8Array) || value.length !== KEY) throw new Error(`${what} is not a 32-byte key`);
|
|
@@ -127,22 +135,31 @@ export async function agree(secret: Uint8Array, peerPk: Uint8Array): Promise<Uin
|
|
|
127
135
|
return shared;
|
|
128
136
|
}
|
|
129
137
|
|
|
138
|
+
// HKDF-SHA-256: bytes from a secret, under a label that says what they are
|
|
139
|
+
// for. No salt, because the secret handed in is already a secret of full
|
|
140
|
+
// strength and the label is what keeps one use apart from another. Every
|
|
141
|
+
// label in this kit is spelled where it is used, never here: this is
|
|
142
|
+
// arithmetic and a label is a decision.
|
|
143
|
+
export async function derive(secret: Uint8Array, label: Uint8Array, bytes: number): Promise<Uint8Array> {
|
|
144
|
+
const material = await subtle().importKey('raw', secret as BufferSource, 'HKDF', false, ['deriveBits']);
|
|
145
|
+
return new Uint8Array(await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SALT, info: label as BufferSource }, material, bytes * 8));
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
// One HKDF-SHA-256 yields the AES key and the nonce together. The nonce needs
|
|
131
149
|
// no randomness of its own: the key it pairs with is fresh on every message.
|
|
132
150
|
async function cipherKey(shared: Uint8Array, use: KeyUsage) {
|
|
133
|
-
const
|
|
134
|
-
const out = new Uint8Array(await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SEAL_SALT, info: SEAL_INFO }, material, (KEY + NONCE) * 8));
|
|
151
|
+
const out = await derive(shared, SEAL_INFO, KEY + NONCE);
|
|
135
152
|
return { key: await subtle().importKey('raw', out.subarray(0, KEY) as BufferSource, 'AES-GCM', false, [use]), nonce: out.subarray(KEY) };
|
|
136
153
|
}
|
|
137
154
|
// The additional authenticated data is the ephemeral public key: the one thing outside the seal, bound to it.
|
|
138
155
|
export async function encrypt(shared: Uint8Array, plaintext: Uint8Array, aad: Uint8Array): Promise<Uint8Array> {
|
|
139
156
|
const { key, nonce } = await cipherKey(shared, 'encrypt');
|
|
140
|
-
return new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, plaintext as BufferSource));
|
|
157
|
+
return new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce as BufferSource, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, plaintext as BufferSource));
|
|
141
158
|
}
|
|
142
159
|
export async function decrypt(shared: Uint8Array, ciphertext: Uint8Array, aad: Uint8Array): Promise<Uint8Array> {
|
|
143
160
|
if (ciphertext.length < TAG) throw new Error('short input');
|
|
144
161
|
const { key, nonce } = await cipherKey(shared, 'decrypt');
|
|
145
|
-
return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, ciphertext as BufferSource));
|
|
162
|
+
return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce as BufferSource, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, ciphertext as BufferSource));
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
// A box: an ephemeral X25519 pk outside, one ciphertext sealed to the
|
package/src/ward/cells.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// persist a being's cells as it likes: as this process's objects, as a row, as
|
|
4
4
|
// a line of JSON on a disk. That promise is only worth what the cells honour.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// go and come back from. Through any harbor that keeps values it
|
|
9
|
-
//
|
|
10
|
-
// harbor she is standing in. That
|
|
11
|
-
// the one thing it must never do.
|
|
6
|
+
// Unchecked, a being could write a Map, a Date, an undefined, a NaN. In a
|
|
7
|
+
// memory harbor the object would survive, because there is nowhere for it to
|
|
8
|
+
// go and come back from. Through any harbor that keeps values it would
|
|
9
|
+
// silently become {}, or a string, or absent, or null, and she is never told
|
|
10
|
+
// which harbor she is standing in. That would be the memory harbor lying to
|
|
11
|
+
// her, which is the one thing it must never do.
|
|
12
12
|
//
|
|
13
13
|
// So the cells refuse the write. Loud, where she wrote it, in her own frame:
|
|
14
14
|
// a being holds three answers and a throw is not one of them, so this throw
|
|
@@ -17,18 +17,27 @@
|
|
|
17
17
|
// that a harbor would have to lie about later.
|
|
18
18
|
import type { Cells, Json } from '../being/types.ts';
|
|
19
19
|
|
|
20
|
+
// How deep a value may nest. Every walk over a value in the kit recurses,
|
|
21
|
+
// and a peer chooses the depth of what she answers; a bound keeps a hostile
|
|
22
|
+
// reply from ending a walk in a stack overflow instead of a refusal.
|
|
23
|
+
export const DEPTH = 64;
|
|
24
|
+
|
|
20
25
|
// I-JSON, all the way down. A number that JSON cannot write is not a number
|
|
21
|
-
// a harbor can keep,
|
|
22
|
-
|
|
26
|
+
// a harbor can keep, a key on the prototype is not a key she wrote, and a
|
|
27
|
+
// hole in a list, or a key named `__proto__`, is a thing JSON writes one way
|
|
28
|
+
// and a runtime reads another.
|
|
29
|
+
function fault(v: unknown, path: string, seen: Set<object>, depth: number): string | null {
|
|
23
30
|
if (v === null || typeof v === 'boolean' || typeof v === 'string') return null;
|
|
24
31
|
if (typeof v === 'number') return Number.isFinite(v) ? null : `${path} is ${String(v)}, which no harbor can write down`;
|
|
25
32
|
if (typeof v !== 'object') return `${path} is a ${typeof v}, which is not a value`;
|
|
26
33
|
if (seen.has(v)) return `${path} refers back to itself`;
|
|
34
|
+
if (depth >= DEPTH) return `${path} is nested past ${DEPTH} levels, which no harbor can keep`;
|
|
27
35
|
seen.add(v);
|
|
28
36
|
try {
|
|
29
37
|
if (Array.isArray(v)) {
|
|
30
38
|
for (let i = 0; i < v.length; i += 1) {
|
|
31
|
-
|
|
39
|
+
if (!(i in v)) return `${path}[${i}] is a hole, which no harbor can write down`;
|
|
40
|
+
const f = fault(v[i], `${path}[${i}]`, seen, depth + 1);
|
|
32
41
|
if (f) return f;
|
|
33
42
|
}
|
|
34
43
|
return null;
|
|
@@ -36,7 +45,8 @@ function fault(v: unknown, path: string, seen: Set<object>): string | null {
|
|
|
36
45
|
const proto = Object.getPrototypeOf(v);
|
|
37
46
|
if (proto !== Object.prototype && proto !== null) return `${path} is a ${(v).constructor?.name ?? 'object'}, which is not a value`;
|
|
38
47
|
for (const k of Object.keys(v)) {
|
|
39
|
-
|
|
48
|
+
if (k === '__proto__') return `${path}.__proto__ is a key no harbor can keep`;
|
|
49
|
+
const f = fault((v as Record<string, unknown>)[k], `${path}.${k}`, seen, depth + 1);
|
|
40
50
|
if (f) return f;
|
|
41
51
|
}
|
|
42
52
|
return null;
|
|
@@ -45,16 +55,43 @@ function fault(v: unknown, path: string, seen: Set<object>): string | null {
|
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
export const cellFault = (v: unknown, path: string): string | null => fault(v, path, new Set());
|
|
58
|
+
export const cellFault = (v: unknown, path: string): string | null => fault(v, path, new Set(), 0);
|
|
59
|
+
|
|
60
|
+
// The three keys at the root of her cells that are the ward's: it writes
|
|
61
|
+
// them, she reads them, and a write of hers there is refused like a non-value.
|
|
62
|
+
const WARDS = new Set(['standings', 'occupants', 'class']);
|
|
49
63
|
|
|
50
64
|
// The guard is one proxy at the root and one for every container read through
|
|
51
65
|
// it, so a write nested three deep is refused the same way a write at the top
|
|
52
66
|
// is. Wrappers are remembered, so reading the same array twice is the same
|
|
53
67
|
// object twice and a being may still compare what she holds.
|
|
54
68
|
const wrapped = new WeakMap<object, object>();
|
|
69
|
+
const targets = new WeakMap<object, object>();
|
|
55
70
|
const guards = new WeakSet();
|
|
56
71
|
|
|
57
|
-
|
|
72
|
+
// A key a write may land on: a string that is not `__proto__`, and not one
|
|
73
|
+
// of the ward's at the root. A symbol key is a thing JSON never writes.
|
|
74
|
+
const refuse: (why: string) => never = (why) => {
|
|
75
|
+
throw new TypeError(`cells hold values: ${why}`);
|
|
76
|
+
};
|
|
77
|
+
function keyFault(t: object, k: string | symbol, v: unknown, path: string, root: boolean): void {
|
|
78
|
+
if (typeof k !== 'string') refuse(`${path} takes no symbol key`);
|
|
79
|
+
if (k === '__proto__') refuse(`${path}.__proto__ is a key no harbor can keep`);
|
|
80
|
+
if (root && WARDS.has(k)) refuse(`${path}.${k} is the ward's to write`);
|
|
81
|
+
// A list grows by one at its end, or it has holes JSON cannot write. A
|
|
82
|
+
// push sets the slot at its length and then the length: both pass. A
|
|
83
|
+
// length set past what she wrote, or a slot beyond it, would leave holes.
|
|
84
|
+
if (Array.isArray(t)) {
|
|
85
|
+
if (k === 'length') {
|
|
86
|
+
if (typeof v !== 'number' || v > t.length) refuse(`${path}.length set past what she wrote would leave holes`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const i = Number(k);
|
|
90
|
+
if (Number.isInteger(i) && i > t.length) refuse(`${path}[${i}] would leave a hole`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function guard<T extends object>(target: T, path: string, wrote: () => void, root = false): T {
|
|
58
95
|
if (guards.has(target)) return target;
|
|
59
96
|
const had = wrapped.get(target);
|
|
60
97
|
if (had) return had as T;
|
|
@@ -62,29 +99,43 @@ function guard<T extends object>(target: T, path: string): T {
|
|
|
62
99
|
get(t, k, r) {
|
|
63
100
|
const v = Reflect.get(t, k, r);
|
|
64
101
|
// A container reached through her cells is part of her cells.
|
|
65
|
-
return v !== null && typeof v === 'object' && !ArrayBuffer.isView(v) ? guard(v as object, `${path}.${String(k)}
|
|
102
|
+
return v !== null && typeof v === 'object' && !ArrayBuffer.isView(v) ? guard(v as object, `${path}.${String(k)}`, wrote) : v;
|
|
66
103
|
},
|
|
67
104
|
set(t, k, v, r) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
105
|
+
keyFault(t, k, v, path, root);
|
|
106
|
+
const f = cellFault(v, `${path}.${String(k)}`);
|
|
107
|
+
if (f) refuse(f);
|
|
108
|
+
const ok = Reflect.set(t, k, v, r);
|
|
109
|
+
wrote();
|
|
110
|
+
return ok;
|
|
73
111
|
},
|
|
74
112
|
defineProperty(t, k, d) {
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
if (!('value' in d)) refuse(`${path}.${String(k)} is an accessor, which no harbor can keep`);
|
|
114
|
+
keyFault(t, k, d.value, path, root);
|
|
115
|
+
const f = cellFault(d.value, `${path}.${String(k)}`);
|
|
116
|
+
if (f) refuse(f);
|
|
117
|
+
const ok = Reflect.defineProperty(t, k, d);
|
|
118
|
+
wrote();
|
|
119
|
+
return ok;
|
|
120
|
+
},
|
|
121
|
+
deleteProperty(t, k) {
|
|
122
|
+
if (root && typeof k === 'string' && WARDS.has(k)) refuse(`${path}.${k} is the ward's to keep`);
|
|
123
|
+
const ok = Reflect.deleteProperty(t, k);
|
|
124
|
+
wrote();
|
|
125
|
+
return ok;
|
|
80
126
|
},
|
|
81
127
|
});
|
|
82
128
|
wrapped.set(target, p);
|
|
129
|
+
targets.set(p, target);
|
|
83
130
|
guards.add(p);
|
|
84
131
|
return p;
|
|
85
132
|
}
|
|
86
133
|
|
|
87
134
|
// Her cells, guarded. Called once per being at boot; the guarded object is
|
|
88
135
|
// what goes into the partition and what the stance hands her, so there is no
|
|
89
|
-
// second door onto the same cells.
|
|
90
|
-
|
|
136
|
+
// second door onto the same cells. Every write that passes says so to the
|
|
137
|
+
// ward, which says so to its harbor.
|
|
138
|
+
export const guardCells = (cells: Cells, wrote: () => void = () => {}): Cells => guard(cells as unknown as Record<string, Json>, 'cells', wrote, true) as unknown as Cells;
|
|
139
|
+
// The cells behind the guard, for the ward alone: the one writer of the
|
|
140
|
+
// three keys that are its own.
|
|
141
|
+
export const unguarded = (cells: Cells): Cells => (targets.get(cells as unknown as object) ?? cells) as Cells;
|
package/src/ward/door.ts
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
// asker rides back with the object.
|
|
10
10
|
import { isSilence, isWord } from '../being/silence.ts';
|
|
11
11
|
import { digest } from '../being/digest.ts';
|
|
12
|
-
import type { Asker, BeingLike, DoorWord, JsonObject } from '../being/types.ts';
|
|
12
|
+
import type { Asker, BeingLike, DoorWord, JsonObject, OccupantRecord } from '../being/types.ts';
|
|
13
13
|
import { at } from './partition.ts';
|
|
14
14
|
import type { Heirs } from './heirs.ts';
|
|
15
15
|
import { openAsk, sealReply, verifyAsk, type ReplyPayload, type WardKey } from './seal.ts';
|
|
16
16
|
import { spent } from './allowance.ts';
|
|
17
17
|
import { KEY, sealingPair } from './arithmetic.ts';
|
|
18
|
+
import { cellFault } from './cells.ts';
|
|
18
19
|
|
|
19
|
-
export type Door = { key: string; being: BeingLike; cells: { occupants: Record<string,
|
|
20
|
+
export type Door = { key: string; being: BeingLike; cells: { occupants: Record<string, OccupantRecord> } };
|
|
20
21
|
export type Judged = { bytes: Uint8Array; heard: boolean };
|
|
21
22
|
const SILENCE: ReplyPayload = { silence: true };
|
|
22
23
|
const said = (quo: DoorWord): ReplyPayload => ({ quo });
|
|
@@ -37,13 +38,21 @@ export async function arrive(door: Door, asker: Asker, method: string | undefine
|
|
|
37
38
|
// on the way out and the far side reads back as a fourth word.
|
|
38
39
|
if (out === undefined || isSilence(out)) return SILENCE;
|
|
39
40
|
if (isWord(out)) return threw; // a word is the ward's to say, never hers
|
|
41
|
+
// Her answer is held to the rule her args and her cells are held to. A
|
|
42
|
+
// shape JSON would drop or rewrite on the way out, a Date, a Map, a NaN, a
|
|
43
|
+
// cycle, is not hers to make: the far side would read something she never
|
|
44
|
+
// said, or the door itself would fail to write her reply after the number
|
|
45
|
+
// was spent. It is threw, like a word out of her.
|
|
46
|
+
if (cellFault(out, 'answer') !== null) return threw;
|
|
40
47
|
if (method === undefined) return { object: out, seen: null };
|
|
41
48
|
// The digest rides along, it is not the answer. She has already answered:
|
|
42
49
|
// a describe that will not run costs the digest, and nothing else.
|
|
43
50
|
return { object: out, seen: await seen(door, asker) };
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
// Her digest for this asker, or null when her describe threw or fell silent.
|
|
54
|
+
// The owner's describe reads it the same way for every being of the ward.
|
|
55
|
+
export async function seen(door: Door, asker: Asker): Promise<string | null> {
|
|
47
56
|
try {
|
|
48
57
|
const bp = await door.being.answer(asker);
|
|
49
58
|
return isSilence(bp) || isWord(bp) ? null : await digest(bp);
|
|
@@ -60,9 +69,10 @@ export function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, p
|
|
|
60
69
|
if (!a) return null; // D1. it did not open. there is nobody to answer.
|
|
61
70
|
const { to, payload, ephemeralPk } = a;
|
|
62
71
|
const refuse = (): Verdict => ({ reply: SILENCE, ephemeralPk, heard: false });
|
|
63
|
-
// D2
|
|
64
|
-
//
|
|
65
|
-
//
|
|
72
|
+
// D2. The allowance is read here, before anything is done under it, and
|
|
73
|
+
// here only: a time that is not a whole number above zero is malformed and
|
|
74
|
+
// is refused as one. Hops at zero is refused beside it, so that a relay
|
|
75
|
+
// chain invented later meets doors that already stop it. Nothing sets hops.
|
|
66
76
|
if (spent({ time: payload.time }) || payload.hops === 0) return refuse();
|
|
67
77
|
const args = payload.args ?? {};
|
|
68
78
|
if (to === null) {
|
|
@@ -72,7 +82,10 @@ export function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, p
|
|
|
72
82
|
const pk = publicKey();
|
|
73
83
|
const pub = pk !== null ? doors.get(pk) : undefined;
|
|
74
84
|
if (!pub || !(await verifyAsk(a, payload.by))) return refuse();
|
|
75
|
-
|
|
85
|
+
// She answers, and the bit stays false: no key this door holds spoke.
|
|
86
|
+
// The public being is the one place a stranger is answered by design,
|
|
87
|
+
// so it is the one place a harbor must still be able to rate her.
|
|
88
|
+
return { reply: await arrive(pub, {}, payload.method, args, false), ephemeralPk, heard: false };
|
|
76
89
|
}
|
|
77
90
|
const h = heirs.admits(to, payload.by);
|
|
78
91
|
if (!h) {
|
|
@@ -83,6 +96,12 @@ export function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, p
|
|
|
83
96
|
return { reply: said('removed'), ephemeralPk, heard: true };
|
|
84
97
|
}
|
|
85
98
|
if (!(await verifyAsk(a, payload.by))) return refuse(); // D7, under an admitted key
|
|
99
|
+
// The signature took time, and another arrival on this heir may have been
|
|
100
|
+
// honoured meanwhile: a knock that raced this one and won spent the heir,
|
|
101
|
+
// and the key this ask speaks under may not be admitted any more. The
|
|
102
|
+
// door judges concurrently, so admission is read again now that writing
|
|
103
|
+
// is next, and what changed under the await is judged as it stands.
|
|
104
|
+
if (!heirs.admits(to, payload.by)) return heirs.gone(to, payload.by) ? { reply: said('removed'), ephemeralPk, heard: true } : refuse();
|
|
86
105
|
// From here the door has heard a key it holds. Every answer below is
|
|
87
106
|
// sealed to that key's lid; nothing below is a stranger's.
|
|
88
107
|
const door = doors.get(h.being);
|
|
@@ -100,17 +119,24 @@ export function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, p
|
|
|
100
119
|
// reply is sealed to the ephemeral pk on its lid, which is all a stranger
|
|
101
120
|
// holds, and says silence.
|
|
102
121
|
//
|
|
103
|
-
// A lid that is not a key
|
|
104
|
-
// have several each
|
|
105
|
-
// door never throws: that reply is noise, sealed to a key
|
|
122
|
+
// A lid that is not a key, a small-order point, of which the two curves
|
|
123
|
+
// have several each, makes a dead agreement, and the seal refuses it. The
|
|
124
|
+
// door never throws: that reply is noise, a plain silence sealed to a key
|
|
125
|
+
// nobody holds, and the same is written for anything else the seal will
|
|
126
|
+
// not take.
|
|
127
|
+
// `judge` reads rows it does not check: open() reads the partition's shape
|
|
128
|
+
// at birth, so a row it acts on is the shape it expects. That is the first
|
|
129
|
+
// line and this is the second, because "the door never throws" is a promise
|
|
130
|
+
// to the harbor, which has nobody to hand a rejection to and would count it
|
|
131
|
+
// as no answer at all. Anything unforeseen is the silence a stranger hears.
|
|
106
132
|
return async function door(bytes: Uint8Array): Promise<Judged> {
|
|
107
|
-
const out = await judge(bytes);
|
|
133
|
+
const out = await judge(bytes).catch(() => null);
|
|
108
134
|
const reply = out?.reply ?? SILENCE;
|
|
109
135
|
const heard = out?.heard ?? false;
|
|
110
136
|
try {
|
|
111
137
|
return { bytes: await sealReply(reply, out?.ephemeralPk ?? lid(bytes, random), key.sign, random(32)), heard };
|
|
112
138
|
} catch {
|
|
113
|
-
return { bytes: await sealReply(
|
|
139
|
+
return { bytes: await sealReply(SILENCE, (await sealingPair(random(32))).pk, key.sign, random(32)), heard };
|
|
114
140
|
}
|
|
115
141
|
};
|
|
116
142
|
}
|
package/src/ward/ground.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The ground. The one object a harbor passes a ward at birth.
|
|
3
|
-
// never a
|
|
2
|
+
// The ground. The one object a harbor passes a ward at birth. Six things,
|
|
3
|
+
// never a seventh. Everything a runtime differs on arrives here, which is
|
|
4
4
|
// why the ward itself knows no runtime.
|
|
5
|
-
import type { Stance, BeingLike } from '../being/types.ts';
|
|
5
|
+
import type { Stance, BeingLike, BeingClass } from '../being/types.ts';
|
|
6
6
|
|
|
7
7
|
export type Ground = {
|
|
8
8
|
seed: string | Uint8Array; // the ward derives its pk from it and nothing else
|
|
9
9
|
memory: Record<string, unknown>; // the partition. the ward's files. opaque to the harbor
|
|
10
10
|
instantiate(className: string, stance: Stance): BeingLike | null; // the code half
|
|
11
|
+
// The ward wrote its partition. It says so after every write, a key
|
|
12
|
+
// rotated, a relation taken, a cell she set, and says nothing else: it
|
|
13
|
+
// never learns whether anything was kept. A harbor that keeps the
|
|
14
|
+
// partition saves after this, when it likes and in the order it was told,
|
|
15
|
+
// so a being driven in process is kept the way one reached through a door
|
|
16
|
+
// is. A harbor that keeps nothing leaves it out.
|
|
17
|
+
wrote?: () => void;
|
|
11
18
|
// Sealed bytes to a ward pk. What comes back, or undefined.
|
|
12
19
|
//
|
|
13
20
|
// undefined is a promise, not a shrug: no door was reached, and nothing was
|
|
@@ -37,3 +44,45 @@ export type WardPointers = {
|
|
|
37
44
|
door(bytes: Uint8Array): Promise<{ bytes: Uint8Array; heard: boolean }>;
|
|
38
45
|
ask(method?: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
39
46
|
};
|
|
47
|
+
|
|
48
|
+
// ---- what every harbor builds a ground out of. Three pieces, because every
|
|
49
|
+
// harbor in this tree writes the same three and the spec keeps the harbors
|
|
50
|
+
// themselves apart: what a memory harbor and a real one differ on is the
|
|
51
|
+
// route and the store, and nothing here. A second kit writes its own harbor
|
|
52
|
+
// and may write these again; they are convenience, never contract.
|
|
53
|
+
|
|
54
|
+
// The code half of a ground, and the map back to what it made. A class is
|
|
55
|
+
// found by own key only, since `constructor` is a name Object lends every
|
|
56
|
+
// registry, and the first registry holding the name wins, so a ward's own
|
|
57
|
+
// classes stand in front of the harbor's. The object is remembered by the
|
|
58
|
+
// cells the ward handed in, which is how a side reaches a being it made and
|
|
59
|
+
// how that hand follows her out when she is unbooted.
|
|
60
|
+
export const maker =
|
|
61
|
+
(objects: WeakMap<object, BeingLike>, ...registries: (Record<string, BeingClass> | undefined)[]): Ground['instantiate'] =>
|
|
62
|
+
(className: string, stance: Stance) => {
|
|
63
|
+
const r = registries.find((reg) => reg !== undefined && Object.hasOwn(reg, className));
|
|
64
|
+
const C = r?.[className];
|
|
65
|
+
if (!C) return null;
|
|
66
|
+
const obj = new C(stance);
|
|
67
|
+
objects.set(stance.cells, obj);
|
|
68
|
+
return obj;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Entropy, from the one place every terrain that runs Quo has it. Every key a
|
|
72
|
+
// ward mints is drawn from this, so a harbor that wants another source hands
|
|
73
|
+
// its own and nothing here has to know.
|
|
74
|
+
export const entropy = (n: number): Uint8Array => globalThis.crypto.getRandomValues(new Uint8Array(n));
|
|
75
|
+
|
|
76
|
+
// A ward's pk, learned the way anyone learns anything: by asking. The empty
|
|
77
|
+
// ask on the ask pointer is the ward's own describe and its notes carry the
|
|
78
|
+
// pk. A harbor has no other way to it and wants none: the ward mints it from
|
|
79
|
+
// the seed, and a harbor that read it off the seed itself would be a second
|
|
80
|
+
// derivation to keep in step with the first. A ward that answers anything
|
|
81
|
+
// else is not one this harbor can route to, and says so here rather than
|
|
82
|
+
// leaving an undefined pk in a directory.
|
|
83
|
+
export async function learnPk(w: WardPointers): Promise<string> {
|
|
84
|
+
const notes = (await w.ask()) as { notes?: { pk?: unknown } } | null;
|
|
85
|
+
const pk = notes?.notes?.pk;
|
|
86
|
+
if (typeof pk !== 'string') throw new Error('the ward did not say its pk');
|
|
87
|
+
return pk;
|
|
88
|
+
}
|