@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.
Files changed (78) hide show
  1. package/README.md +19 -10
  2. package/SPEC.md +301 -110
  3. package/dist/being/being.d.ts +2 -2
  4. package/dist/being/being.js +34 -12
  5. package/dist/being/digest.js +26 -8
  6. package/dist/being/index.d.ts +2 -2
  7. package/dist/being/index.js +2 -2
  8. package/dist/being/silence.d.ts +2 -0
  9. package/dist/being/silence.js +12 -0
  10. package/dist/being/types.d.ts +4 -2
  11. package/dist/being/types.js +25 -0
  12. package/dist/conformance/assert.js +40 -6
  13. package/dist/conformance/beings.d.ts +48 -5
  14. package/dist/conformance/beings.js +39 -8
  15. package/dist/conformance/estate.js +110 -21
  16. package/dist/conformance/index.d.ts +5 -2
  17. package/dist/conformance/index.js +165 -7
  18. package/dist/harbor/core.d.ts +5 -2
  19. package/dist/harbor/core.js +195 -45
  20. package/dist/harbor/dial.js +32 -15
  21. package/dist/harbor/index.d.ts +1 -0
  22. package/dist/harbor/index.js +3 -0
  23. package/dist/harbor/memory.d.ts +3 -3
  24. package/dist/harbor/memory.js +7 -12
  25. package/dist/harbor/reach.js +42 -17
  26. package/dist/ward/allowance.js +15 -4
  27. package/dist/ward/arithmetic.d.ts +1 -0
  28. package/dist/ward/arithmetic.js +22 -6
  29. package/dist/ward/cells.d.ts +3 -1
  30. package/dist/ward/cells.js +79 -21
  31. package/dist/ward/door.d.ts +3 -2
  32. package/dist/ward/door.js +38 -10
  33. package/dist/ward/ground.d.ts +5 -1
  34. package/dist/ward/ground.js +38 -1
  35. package/dist/ward/heirs.d.ts +2 -3
  36. package/dist/ward/heirs.js +19 -13
  37. package/dist/ward/index.d.ts +1 -0
  38. package/dist/ward/index.js +3 -0
  39. package/dist/ward/owner.d.ts +6 -27
  40. package/dist/ward/owner.js +59 -33
  41. package/dist/ward/partition.d.ts +3 -0
  42. package/dist/ward/partition.js +109 -4
  43. package/dist/ward/seal.d.ts +1 -0
  44. package/dist/ward/seal.js +41 -13
  45. package/dist/ward/stance.d.ts +7 -3
  46. package/dist/ward/stance.js +156 -66
  47. package/dist/ward/ward.d.ts +10 -0
  48. package/dist/ward/ward.js +123 -51
  49. package/package.json +4 -2
  50. package/src/being/being.ts +33 -11
  51. package/src/being/digest.ts +28 -13
  52. package/src/being/index.ts +2 -2
  53. package/src/being/silence.ts +14 -0
  54. package/src/being/types.ts +39 -5
  55. package/src/conformance/assert.ts +37 -4
  56. package/src/conformance/beings.ts +41 -10
  57. package/src/conformance/estate.ts +107 -20
  58. package/src/conformance/index.ts +188 -13
  59. package/src/harbor/core.ts +203 -46
  60. package/src/harbor/dial.ts +46 -17
  61. package/src/harbor/index.ts +3 -0
  62. package/src/harbor/memory.ts +8 -13
  63. package/src/harbor/reach.ts +47 -21
  64. package/src/ward/allowance.ts +15 -4
  65. package/src/ward/arithmetic.ts +25 -8
  66. package/src/ward/cells.ts +76 -25
  67. package/src/ward/door.ts +38 -12
  68. package/src/ward/ground.ts +52 -3
  69. package/src/ward/heirs.ts +19 -13
  70. package/src/ward/index.ts +3 -0
  71. package/src/ward/owner.ts +65 -46
  72. package/src/ward/partition.ts +109 -5
  73. package/src/ward/seal.ts +41 -12
  74. package/src/ward/stance.ts +163 -64
  75. package/src/ward/ward.ts +124 -52
  76. package/vectors/arithmetic.json +7 -0
  77. package/vectors/framing.json +30 -15
  78. package/vectors/wire.json +4 -4
@@ -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, as quo-harbor.md names them:
3
+ // implementations:
4
4
  //
5
- // request a URL, the quo. route of a world. POST the bytes to
6
- // `<url>/<pk>`, get bytes back. Listener to listener.
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: new Uint8Array(bytes) as unknown as BodyInit });
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
- if (res.status === 404) return undefined; // the listener holds no reach for that pk
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
- this.#open = line.readyState === OPEN ? Promise.resolve() : new Promise((ok, no) => (line.addEventListener('open', ok), line.addEventListener('error', no)));
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
- void deliver(pk, new Uint8Array(buf.subarray(69))).then((back) => {
137
- const head = new Uint8Array(5);
138
- head[0] = back === undefined ? NONE : REPLY;
139
- new DataView(head.buffer).setUint32(1, id);
140
- if (line.readyState === OPEN) line.send(back === undefined ? head : concat(head, back));
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
- }
@@ -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 silence, never unreached.
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
- const whole = (n: unknown, fallback: number) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback);
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. A door reads this on arrival.
41
- export const spent = (a: Allowance): boolean => !(a.time > 0);
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.
@@ -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 SEAL_SALT = new Uint8Array(0);
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. A public key among them verifies nothing.
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
- export const smallOrder = (pk: Uint8Array): boolean => SMALL_ORDER.some((p) => sameBytes(p, pk));
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 material = await subtle().importKey('raw', shared as BufferSource, 'HKDF', false, ['deriveBits']);
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
- // Nothing checked it. A being could write a Map, a Date, an undefined, a NaN.
7
- // In a memory harbor the object survives, because there is nowhere for it to
8
- // go and come back from. Through any harbor that keeps values it silently
9
- // becomes {}, or a string, or absent, or null and she is never told which
10
- // harbor she is standing in. That is the memory harbor lying to her, which is
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, and a key on the prototype is not a key she wrote.
22
- function fault(v: unknown, path: string, seen: Set<object>): string | null {
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
- const f = fault(v[i], `${path}[${i}]`, seen);
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
- const f = fault((v as Record<string, unknown>)[k], `${path}.${k}`, seen);
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
- function guard<T extends object>(target: T, path: string): T {
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)}`) : v;
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
- if (typeof k === 'string') {
69
- const f = cellFault(v, `${path}.${k}`);
70
- if (f) throw new TypeError(`cells hold values: ${f}`);
71
- }
72
- return Reflect.set(t, k, v, r);
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 (typeof k === 'string' && 'value' in d) {
76
- const f = cellFault(d.value, `${path}.${k}`);
77
- if (f) throw new TypeError(`cells hold values: ${f}`);
78
- }
79
- return Reflect.defineProperty(t, k, d);
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
- export const guardCells = (cells: Cells): Cells => guard(cells as unknown as Record<string, Json>, 'cells') as unknown as Cells;
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, unknown> } };
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
- async function seen(door: Door, asker: Asker): Promise<string | null> {
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, the rest of it. The allowance is read before anything is done under
64
- // it, and hops at zero is refused so that a relay chain invented later
65
- // meets doors that already stop it. Nothing sets hops.
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
- return { reply: await arrive(pub, {}, payload.method, args, false), ephemeralPk, heard: true };
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 -- a small-order point, of which the two curves
104
- // have several each -- makes a dead agreement, and the seal refuses it. The
105
- // door never throws: that reply is noise, sealed to a key nobody holds.
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(reply, (await sealingPair(random(32))).pk, key.sign, random(32)), heard };
139
+ return { bytes: await sealReply(SILENCE, (await sealingPair(random(32))).pk, key.sign, random(32)), heard };
114
140
  }
115
141
  };
116
142
  }
@@ -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. Five things,
3
- // never a sixth. Everything a runtime differs on arrives here, which is
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
+ }