@quo-systems/quo 0.2.10 → 0.2.12

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 (72) hide show
  1. package/SPEC.md +141 -22
  2. package/dist/being/being.js +7 -1
  3. package/dist/being/digest.js +15 -1
  4. package/dist/being/index.d.ts +2 -2
  5. package/dist/being/index.js +2 -2
  6. package/dist/being/silence.d.ts +2 -0
  7. package/dist/being/silence.js +12 -0
  8. package/dist/being/types.d.ts +2 -0
  9. package/dist/being/types.js +25 -0
  10. package/dist/conformance/assert.js +40 -6
  11. package/dist/conformance/beings.d.ts +48 -5
  12. package/dist/conformance/beings.js +39 -8
  13. package/dist/conformance/estate.js +109 -18
  14. package/dist/conformance/index.d.ts +5 -2
  15. package/dist/conformance/index.js +164 -6
  16. package/dist/harbor/core.d.ts +3 -2
  17. package/dist/harbor/core.js +86 -25
  18. package/dist/harbor/dial.js +28 -13
  19. package/dist/harbor/index.d.ts +1 -0
  20. package/dist/harbor/index.js +3 -0
  21. package/dist/harbor/memory.d.ts +3 -3
  22. package/dist/harbor/memory.js +6 -12
  23. package/dist/harbor/reach.js +10 -10
  24. package/dist/ward/allowance.js +7 -2
  25. package/dist/ward/arithmetic.d.ts +1 -0
  26. package/dist/ward/arithmetic.js +11 -3
  27. package/dist/ward/door.d.ts +2 -2
  28. package/dist/ward/door.js +14 -5
  29. package/dist/ward/ground.d.ts +4 -1
  30. package/dist/ward/ground.js +38 -1
  31. package/dist/ward/heirs.d.ts +1 -2
  32. package/dist/ward/heirs.js +13 -14
  33. package/dist/ward/index.d.ts +1 -0
  34. package/dist/ward/index.js +3 -0
  35. package/dist/ward/owner.d.ts +4 -4
  36. package/dist/ward/owner.js +36 -19
  37. package/dist/ward/partition.d.ts +3 -0
  38. package/dist/ward/partition.js +109 -4
  39. package/dist/ward/seal.d.ts +1 -0
  40. package/dist/ward/seal.js +38 -13
  41. package/dist/ward/stance.d.ts +3 -2
  42. package/dist/ward/stance.js +48 -20
  43. package/dist/ward/ward.d.ts +1 -1
  44. package/dist/ward/ward.js +40 -19
  45. package/package.json +1 -1
  46. package/src/being/being.ts +7 -1
  47. package/src/being/digest.ts +15 -2
  48. package/src/being/index.ts +2 -2
  49. package/src/being/silence.ts +14 -0
  50. package/src/being/types.ts +29 -1
  51. package/src/conformance/assert.ts +37 -4
  52. package/src/conformance/beings.ts +41 -10
  53. package/src/conformance/estate.ts +106 -17
  54. package/src/conformance/index.ts +187 -12
  55. package/src/harbor/core.ts +98 -25
  56. package/src/harbor/dial.ts +41 -14
  57. package/src/harbor/index.ts +3 -0
  58. package/src/harbor/memory.ts +8 -14
  59. package/src/harbor/reach.ts +10 -11
  60. package/src/ward/allowance.ts +7 -2
  61. package/src/ward/arithmetic.ts +14 -5
  62. package/src/ward/door.ts +16 -7
  63. package/src/ward/ground.ts +43 -1
  64. package/src/ward/heirs.ts +13 -14
  65. package/src/ward/index.ts +3 -0
  66. package/src/ward/owner.ts +45 -26
  67. package/src/ward/partition.ts +109 -5
  68. package/src/ward/seal.ts +39 -12
  69. package/src/ward/stance.ts +53 -23
  70. package/src/ward/ward.ts +46 -25
  71. package/vectors/framing.json +16 -10
  72. package/vectors/wire.json +4 -4
package/src/ward/heirs.ts CHANGED
@@ -40,9 +40,6 @@ export class Heirs {
40
40
  const g = this.#p.gone[heir];
41
41
  return !!g && (by === g.current || by === g.announced);
42
42
  }
43
- get(heir: string): Heir | undefined {
44
- return this.#p.heirs[heir];
45
- }
46
43
  // May `by` speak for this heir? Returns the record if so, null if not.
47
44
  // Does not write: the caller verifies the signature first, then settles.
48
45
  admits(heir: string, by: string): Heir | null {
@@ -82,28 +79,30 @@ export class Heirs {
82
79
  // neither: a call that binds nothing must not burn a number on its way to
83
80
  // being refused, or a stranger who cannot be heard would still leave a mark
84
81
  // behind her. Every write below this line is one that is going to hold.
82
+ // This is where a knock is judged and where `unannounced` is said. settle
83
+ // below is the writing alone, and it judges nothing: one owner for the
84
+ // question, one for the answer.
85
85
  honour(h: Heir, by: string, next: string | null, seq: number): true | DoorWord {
86
86
  if (h.fresh && (next === null || next === h.current)) return 'unannounced'; // a knock without a key of her own binds nothing, and the heir is not a key of her own
87
87
  if (!this.spend(h, seq)) return 'repeated';
88
- const settled = this.settle(h, by, next);
89
- this.#wrote(); // the number is spent whether or not the keys settled
90
- return settled ? true : 'unannounced';
88
+ this.settle(h, by, next);
89
+ this.#wrote(); // the number and the keys moved together
90
+ return true;
91
91
  }
92
92
 
93
- // The signature checked out. Settle the keys: a fresh heir rotates at once
94
- // to what it announced and must announce something that is not itself, so
95
- // that the heir dies as it speaks; a current key replaces its
96
- // announcement; an announced key becomes current.
97
- settle(h: Heir, by: string, next: string | null): boolean {
93
+ // The signature checked out and honour has judged. Settle the keys: a fresh
94
+ // heir rotates at once to what it announced, which honour has already held
95
+ // to being something other than itself, so that the heir dies as it speaks;
96
+ // a current key replaces its announcement; an announced key becomes current.
97
+ settle(h: Heir, by: string, next: string | null): void {
98
98
  if (h.fresh) {
99
- if (next === null || next === h.current) return false;
99
+ if (next === null) return; // honour judged this. the compiler has not read it, so the narrowing stays.
100
100
  h.current = next;
101
101
  h.announced = null;
102
102
  h.fresh = false;
103
- return true;
103
+ return;
104
104
  }
105
105
  if (by === h.announced) h.current = by;
106
106
  h.announced = next;
107
- return true;
108
107
  }
109
108
  }
package/src/ward/index.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  export { Ward } from './ward.ts';
4
4
  export type { Ground, WardPointers } from './ground.ts';
5
5
  export type { Partition, Heir, Bind, StandingKeys } from './partition.ts';
6
+ // The two bounds on what a partition may hold, for a kit that must keep the
7
+ // same ones and for a test that pins them.
8
+ export { GONE, MINTED } from './partition.ts';
6
9
  export type { AskPayload, ReplyPayload } from './seal.ts';
7
10
  // The seal and the arithmetic, for a kit in another language to check its
8
11
  // bytes against, and for tests that speak to a door directly.
package/src/ward/owner.ts CHANGED
@@ -13,22 +13,24 @@ import { OWNER } from '../being/types.ts';
13
13
  import type { Ask, Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
14
14
  import { put } from './partition.ts';
15
15
  import { seen } from './door.ts';
16
- import type { Booted } from './ward.ts';
16
+ import { within, DEFAULT, LATE } from './allowance.ts';
17
+ import type { Resident } from './ward.ts';
17
18
 
18
19
  export type OwnerSide = {
19
20
  pk: string;
20
- doors: Map<string, Booted>;
21
+ doors: Map<string, Resident>;
21
22
  absent(): Record<string, string | null>; // the rows no door holds this run: key to class
22
23
  publicKey(): string | null;
23
- instantiate(key: string, className: string): Booted | 'threw' | null;
24
+ // Class first, then key, as the ground and a being's own boot both say it.
25
+ instantiate(className: string, key: string): Resident | 'threw' | null;
24
26
  unboot(key: string): string[] | null;
25
- setPublic(key: string): void;
27
+ setPublic(key: string | null): void; // null takes the mark off, and the being stays
26
28
  };
27
29
 
28
30
  const str = { type: 'string' };
29
31
  export const OWNER_ASKS: Ask[] = [
30
32
  { name: 'boot', description: 'boot a being by class name, under a key the owner chooses', input: { type: 'object', properties: { key: str, class: str }, required: ['key', 'class'] } },
31
- { name: 'public', description: 'mark a booted being as the one public being of the ward, reached by anyone at the bare pk', input: { type: 'object', properties: { key: str }, required: ['key'] } },
33
+ { name: 'public', description: 'mark a booted being as the one public being of the ward, reached by anyone at the bare pk; null takes the mark off and leaves her booted', input: { type: 'object', properties: { key: str }, required: ['key'] } },
32
34
  { name: 'invite', description: 'mint an invitation on a being of the ward, under the id she will know the occupant by, with the notes she will read on it; on the ward pk it mints an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str, notes: { type: 'object' } }, required: ['being', 'id'] } },
33
35
  {
34
36
  name: 'knock',
@@ -40,17 +42,19 @@ export const OWNER_ASKS: Ask[] = [
40
42
  ];
41
43
 
42
44
  export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | undefined, args: JsonObject): Promise<Json> {
43
- // Every name the owner gives is a word. Coerced instead of checked, an
44
- // object passed where a name belongs becomes the string "[object Object]",
45
- // and two owners who each got the shape wrong would be handed one being.
46
- const word = (v: unknown): string | null => (typeof v === 'string' ? v : null);
45
+ // Every name the owner gives is a string, or it is nothing. Coerced instead
46
+ // of checked, an object passed where a name belongs becomes the string
47
+ // "[object Object]", and two owners who each got the shape wrong would be
48
+ // handed one being. Not `word`: a word in this tree is what a ward says when
49
+ // no object came back, and this file imports those.
50
+ const named = (v: unknown): string | null => (typeof v === 'string' ? v : null);
47
51
 
48
52
  if (method === undefined) return describe(w);
49
53
  if (method === 'boot') {
50
- const key = word(args.key),
51
- className = word(args.class);
54
+ const key = named(args.key),
55
+ className = named(args.class);
52
56
  if (key === null || className === null) return { error: 'no such class, or key taken' };
53
- const door = w.instantiate(key, className);
57
+ const door = w.instantiate(className, key);
54
58
  if (door === 'threw') return { error: 'threw at birth' };
55
59
  return door ? { booted: door.key } : { error: 'no such class, or key taken' };
56
60
  }
@@ -59,7 +63,14 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
59
63
  // leave the first holding every relation she had, reachable by nobody at
60
64
  // the bare pk, and told by nobody that she had been replaced. One absent
61
65
  // this run is reachable by nobody already, so the mark may move off her.
62
- const key = word(args.key);
66
+ // Null takes the mark off: the being stays and keeps every relation she
67
+ // holds, and the ward answers arrivals for no heir with silence, as one
68
+ // that never had a public being does. Retiring her is not destroying her.
69
+ if (args.key === null) {
70
+ w.setPublic(null);
71
+ return { public: null };
72
+ }
73
+ const key = named(args.key);
63
74
  if (key === null || key === w.pk || !w.doors.has(key)) return { error: 'no such being' };
64
75
  const standing = w.publicKey();
65
76
  if (standing !== null && standing !== key && w.doors.has(standing)) return { error: 'a ward has one public being' };
@@ -71,8 +82,8 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
71
82
  // is an owner, and ownership is minted by the ask pointer alone. An owner
72
83
  // at the door asking to invite on the ward is refused, so that a carried
73
84
  // key can pilot but never hand piloting on.
74
- const being = word(args.being),
75
- id = word(args.id);
85
+ const being = named(args.being),
86
+ id = named(args.id);
76
87
  if (being === null || id === null) return { error: 'no such being' };
77
88
  if (being === w.pk && asker.id !== OWNER) return { error: 'no such being' };
78
89
  // The notes are the terms the owner mints under, and the being reads them
@@ -89,14 +100,16 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
89
100
  if (!door && b && typeof b === 'object') {
90
101
  // new or existing: the owner names a being of theirs, and a key already
91
102
  // booted is a being of theirs, not a class that failed to instantiate.
92
- const key = word(b.key),
93
- className = word(b.boot);
103
+ const key = named(b.key),
104
+ className = named(b.boot);
94
105
  if (key === null || className === null) return { error: 'no such being' };
95
- const made = w.instantiate(key, className);
106
+ const made = w.instantiate(className, key);
96
107
  if (made === 'threw') return { error: 'threw at birth' };
97
- door = made ? w.doors.get(made.key) : w.doors.get(key);
108
+ // Made or already there, the door is the one under the key she named:
109
+ // instantiate boots under that key or refuses, and never another.
110
+ door = w.doors.get(key);
98
111
  }
99
- const id = word(args.id);
112
+ const id = named(args.id);
100
113
  // Never for the ward itself, for the reason invite refuses it: the ward
101
114
  // is a being to nobody outside, and holds no relation of its own. A
102
115
  // standing written into its cells would be the owner's reach dressed as
@@ -115,8 +128,8 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
115
128
  // standing, since the two share one namespace. On the ward itself the
116
129
  // id is an owner, and only the root may unseat one, for the reason only
117
130
  // the root may invite one: ownership moves by the root alone.
118
- const being = word(args.being),
119
- id = word(args.id);
131
+ const being = named(args.being),
132
+ id = named(args.id);
120
133
  if (being === null || id === null) return { error: 'no such being' };
121
134
  if (being === w.pk && asker.id !== OWNER) return { error: 'no such being' };
122
135
  const door = w.doors.get(being);
@@ -134,7 +147,7 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
134
147
  // refused, for the reason knock is: it is a being to nobody outside, and
135
148
  // unbooting it would be a ward deleting itself from inside its own map,
136
149
  // leaving its owners bound to a door that is gone.
137
- const being = word(args.being);
150
+ const being = named(args.being);
138
151
  if (being === null || being === w.pk) return { error: 'no such being' };
139
152
  const removed = w.unboot(being);
140
153
  return removed === null ? { error: 'no such being' } : { unbooted: being, removed };
@@ -148,9 +161,15 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
148
161
  // and take her out.
149
162
  async function describe(w: OwnerSide): Promise<Json> {
150
163
  const beings: Record<string, Json> = {};
151
- for (const [key, door] of w.doors) {
152
- if (key === w.pk) continue;
153
- put(beings, key, { class: door.cells.class ?? null, public: w.publicKey() === key, digest: await seen(door, { id: OWNER }) });
164
+ // Every being is asked at once and each is bounded on her own. One being
165
+ // whose describe never settles is one null digest, not a ward that never
166
+ // answers its owner: the harbor learns the pk by this very ask at boot, so
167
+ // an unbounded wait here is a ward no restart can bring back.
168
+ const asked = [...w.doors].filter(([key]) => key !== w.pk);
169
+ const digests = await Promise.all(asked.map(async ([, door]) => (await within(DEFAULT.time, seen(door, { id: OWNER }))) as string | null | typeof LATE));
170
+ for (const [i, [key, door]] of asked.entries()) {
171
+ const d = digests[i];
172
+ put(beings, key, { class: door.cells.class ?? null, public: w.publicKey() === key, digest: d === LATE ? null : d });
154
173
  }
155
174
  for (const [key, cls] of Object.entries(w.absent())) put(beings, key, { class: cls, public: w.publicKey() === key, digest: null, absent: true });
156
175
  return {
@@ -75,7 +75,7 @@ export type Bind = {
75
75
  occupants: Record<string, string>; // id -> heir pk
76
76
  knocks: Record<string, { current: string; next: string | null; spoke: boolean; sent: boolean; seq: number }>; // <ward pk>:<heir pk>, or public:<ward pk> -> her keys for a knock, before take. spoke: bytes came back once. sent: bytes went out once, answered or not
77
77
  answered: Record<string, true>; // <ward pk>:<heir pk>, or public:<ward pk>: knocked and answered
78
- minted: string[]; // the last eight pks her side minted. inner. a test reads it
78
+ minted: string[]; // the last eight pks her side minted. nothing in the ward reads it: it is there to be looked at, and a kit that keeps it empty holds the same ward
79
79
  };
80
80
 
81
81
  // A relation she removed, as the door remembers it: the keys that may still
@@ -86,6 +86,30 @@ export type Bind = {
86
86
  export type Gone = { current: string; announced: string | null };
87
87
  export const GONE = 256;
88
88
 
89
+ // The last few pks a relation minted, and no more. A relation rotates on
90
+ // every ask, so a list of all of them is a partition that grows for as long
91
+ // as she keeps talking and can never be written down. It lives here, beside
92
+ // the other bound on the durable shape, because it is a fact about what a
93
+ // partition may hold and not about how a ward mints.
94
+ export const MINTED = 8;
95
+
96
+ // A knock record is a key she minted for one invitation, kept until she takes
97
+ // it. Only take deletes one, so a being who knocks and never takes would grow
98
+ // her bind table for the life of the ward, one secret seed at a time. The
99
+ // list is bounded like `gone`: past the count the oldest go, and a relation
100
+ // that answered outlives one that never did, since an answered knock is one
101
+ // she may still take.
102
+ export const KNOCKS = 64;
103
+ export const prune = (knocks: Record<string, unknown>, answered: Record<string, true>): void => {
104
+ const names = Object.keys(knocks);
105
+ if (names.length <= KNOCKS) return;
106
+ const order = [...names.filter((n) => !answered[n]), ...names.filter((n) => answered[n])];
107
+ for (const old of order.slice(0, names.length - KNOCKS)) {
108
+ delete knocks[old];
109
+ delete answered[old];
110
+ }
111
+ };
112
+
89
113
  export type Partition = {
90
114
  version: string;
91
115
  beings: Record<string, Cells & { class?: string }>; // key -> her cells
@@ -95,11 +119,91 @@ export type Partition = {
95
119
  public: string | null; // the one public being's key
96
120
  };
97
121
 
122
+ // The shape, read before anything acts on it. A partition this ward wrote is
123
+ // this shape by construction; one it adopted was written by a hand, another
124
+ // kit, or a file that was cut short, and every field below is one the ward,
125
+ // the door or the heirs then act on without looking again. A `spent` that is
126
+ // not an array rejects the door's promise on the first bound ask, a `mark`
127
+ // that is not a number honours every number ever sent and grows without
128
+ // bound, a being row that is null refuses the whole ward at birth by
129
+ // accident and says nothing about why. So the shape is read once, here, and
130
+ // what is not it throws with the path that failed, which names the trouble
131
+ // to whoever is holding the file.
132
+ //
133
+ // The rule, so the reader does not grow into a schema: read every field the
134
+ // ward acts on, and refuse only what would make it act wrongly. So `minted`
135
+ // must be a list, because she pushes to it, and the truth in `answered` is
136
+ // not read, because anything there says answered and nothing else follows.
137
+ // `last` is reserved and nobody reads it, so nobody reads its shape either;
138
+ // the run that gives it a reader gives it a line here. A being's own cells
139
+ // are hers, of any shape JSON carries, and only the two records the ward
140
+ // keeps inside them are read.
141
+ const fault = (path: string, want: string): never => {
142
+ throw new Error(`partition ${path} is not ${want}`);
143
+ };
144
+ const table = (v: unknown, path: string): Record<string, unknown> => (typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : fault(path, 'an object'));
145
+ const list = (v: unknown, path: string): unknown[] => (Array.isArray(v) ? (v as unknown[]) : fault(path, 'an array'));
146
+ const text = (v: unknown, path: string): void => void (typeof v === 'string' || fault(path, 'a string'));
147
+ const orNull = (v: unknown, path: string): void => void (v === null || typeof v === 'string' || fault(path, 'a string or null'));
148
+ const whole = (v: unknown, path: string): void => void (Number.isSafeInteger(v) || fault(path, 'a whole number'));
149
+ const flag = (v: unknown, path: string): void => void (typeof v === 'boolean' || fault(path, 'true or false'));
150
+
151
+ function read(p: Partition): Partition {
152
+ for (const [key, cells] of Object.entries(table(p.beings, 'beings'))) {
153
+ const c = table(cells, `beings.${key}`);
154
+ table(c.standings, `beings.${key}.standings`);
155
+ table(c.occupants, `beings.${key}.occupants`);
156
+ if (c.class !== undefined) text(c.class, `beings.${key}.class`);
157
+ }
158
+ for (const [key, bind] of Object.entries(table(p.bind, 'bind'))) {
159
+ const b = table(bind, `bind.${key}`);
160
+ for (const [id, keys] of Object.entries(table(b.standings, `bind.${key}.standings`))) {
161
+ const w = `bind.${key}.standings.${id}`;
162
+ const s = table(keys, w);
163
+ text(s.ward, `${w}.ward`);
164
+ orNull(s.heir, `${w}.heir`);
165
+ text(s.current, `${w}.current`);
166
+ orNull(s.next, `${w}.next`);
167
+ whole(s.seq, `${w}.seq`);
168
+ }
169
+ for (const [id, pk] of Object.entries(table(b.occupants, `bind.${key}.occupants`))) text(pk, `bind.${key}.occupants.${id}`);
170
+ for (const [name, keys] of Object.entries(table(b.knocks, `bind.${key}.knocks`))) {
171
+ const w = `bind.${key}.knocks.${name}`;
172
+ const k = table(keys, w);
173
+ text(k.current, `${w}.current`);
174
+ orNull(k.next, `${w}.next`);
175
+ flag(k.spoke, `${w}.spoke`);
176
+ flag(k.sent, `${w}.sent`);
177
+ whole(k.seq, `${w}.seq`);
178
+ }
179
+ table(b.answered, `bind.${key}.answered`);
180
+ for (const [i, pk] of list(b.minted, `bind.${key}.minted`).entries()) text(pk, `bind.${key}.minted[${i}]`);
181
+ }
182
+ for (const [pk, heir] of Object.entries(table(p.heirs, 'heirs'))) {
183
+ const h = table(heir, `heirs.${pk}`);
184
+ text(h.being, `heirs.${pk}.being`);
185
+ text(h.id, `heirs.${pk}.id`);
186
+ text(h.current, `heirs.${pk}.current`);
187
+ orNull(h.announced, `heirs.${pk}.announced`);
188
+ flag(h.fresh, `heirs.${pk}.fresh`);
189
+ whole(h.mark, `heirs.${pk}.mark`);
190
+ for (const [i, n] of list(h.spent, `heirs.${pk}.spent`).entries()) whole(n, `heirs.${pk}.spent[${i}]`);
191
+ }
192
+ for (const [pk, gone] of Object.entries(table(p.gone, 'gone'))) {
193
+ const g = table(gone, `gone.${pk}`);
194
+ text(g.current, `gone.${pk}.current`);
195
+ orNull(g.announced, `gone.${pk}.announced`);
196
+ }
197
+ orNull(p.public, 'public');
198
+ return p;
199
+ }
200
+
98
201
  // The one place a ward reads state that another ward wrote. A fresh
99
202
  // memory becomes a partition of this version; anything already written must
100
- // say it is this version, and a ward that cannot read it does not boot. Birth is
101
- // where a ward is allowed to be loud: silence is the door's word, for asks
102
- // that were made, and no ask has been made yet.
203
+ // say it is this version and be the shape this version names, and a ward that
204
+ // cannot read it does not boot. Birth is where a ward is allowed to be loud:
205
+ // silence is the door's word, for asks that were made, and no ask has been
206
+ // made yet.
103
207
  export function open(memory: Record<string, unknown>): Partition {
104
208
  const p = memory as Partition;
105
209
  if (Object.keys(memory).length > 0 && p.version !== VERSION) {
@@ -112,7 +216,7 @@ export function open(memory: Record<string, unknown>): Partition {
112
216
  p.heirs ??= {};
113
217
  p.gone ??= {};
114
218
  p.public ??= null;
115
- return p;
219
+ return read(p);
116
220
  }
117
221
 
118
222
  // One record, reached by a key a being or an owner chose. A bare lookup finds
package/src/ward/seal.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // reply = JSON { object, seen } | { silence: true } | { quo: word } signed by the ward key
22
22
  import type { DoorWord, Json, JsonObject } from '../being/types.ts';
23
23
  import { isDoorWord } from '../being/silence.ts';
24
- import { KEY, SIGNATURE, box, concat, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.ts';
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
26
 
27
27
  const utf8 = new TextEncoder();
@@ -29,17 +29,44 @@ const text = new TextDecoder();
29
29
 
30
30
  export type WardKey = { sign: Uint8Array; padlock: Uint8Array; signPk: Uint8Array; padlockPk: Uint8Array; pk: string };
31
31
 
32
+ // The two labels a ward's keys are derived under. They are here and not in
33
+ // arithmetic.ts because a label is a decision and that file is arithmetic.
34
+ // `quo-seal` is taken: it is the label the message cipher derives its key and
35
+ // nonce under, from an agreement rather than from a seed, and two derivations
36
+ // answering to one name is how a kit is read wrong.
37
+ const WARD_SIGN = new TextEncoder().encode('quo-ward-sign');
38
+ const WARD_SEAL = new TextEncoder().encode('quo-ward-seal');
39
+
32
40
  // The ward's key from its seed. Its pk on the wire is the signing pk then the padlock, 128 hex.
41
+ //
42
+ // One seed, two curves, and each secret derived from it under its own label.
43
+ // Fed the seed straight, the two scalars differ only because Ed25519 hashes
44
+ // what it is given and X25519 clamps it raw, which is an accident of the two
45
+ // designs and no separation at all: one secret would be doing two jobs with
46
+ // nothing said about it, and a second kit would have to reproduce a
47
+ // construction nobody named. HKDF-SHA-256 under a label is the separation
48
+ // said out loud, and it is what `vectors/framing.json` pins.
49
+ //
50
+ // Bytes are key material and text is not. A seed handed in as bytes of the
51
+ // key length is taken as it stands, which is what a harbor mints; anything
52
+ // else, of any length, is hashed to that length first. So a thirty-two
53
+ // character name is a name like any other, and not a key because it happened
54
+ // to be the right size.
33
55
  export async function wardKey(seed: string | Uint8Array): Promise<WardKey> {
34
- const raw = typeof seed === 'string' ? utf8.encode(seed) : seed;
35
- const seed32 = raw.length === KEY ? raw : await sha256(raw);
36
- const s = await signingPair(seed32);
37
- const p = await sealingPair(seed32);
56
+ const seed32 = typeof seed === 'string' ? await sha256(utf8.encode(seed)) : seed.length === KEY ? seed : await sha256(seed);
57
+ const s = await signingPair(await derive(seed32, WARD_SIGN, KEY));
58
+ const p = await sealingPair(await derive(seed32, WARD_SEAL, KEY));
38
59
  return { sign: s.secret, padlock: p.secret, signPk: s.pk, padlockPk: p.pk, pk: hex(s.pk) + hex(p.pk) };
39
60
  }
40
61
  export const wardSignPk = (pk: string): Uint8Array => unhex(pk.slice(0, KEY * 2));
41
62
  export const wardPadlock = (pk: string): Uint8Array => unhex(pk.slice(KEY * 2));
42
63
  export const isWardPk = (pk: unknown): pk is string => typeof pk === 'string' && /^[0-9a-f]{128}$/.test(pk);
64
+ // Thirty-two bytes as hex, which is how this kit writes every single key: a
65
+ // heir pk, a heir secret, a being's own key, and a digest, which is the same
66
+ // thirty-two bytes of SHA-256. A ward pk is two of these and has its own
67
+ // shape above. The two are spelled here and nowhere else, so a door, a
68
+ // stance, a harbor and a conformance suite all refuse the same string.
69
+ export const isHex = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
43
70
 
44
71
  // A being's relation key: a seed, and the pk it signs as.
45
72
  export async function beingKey(seed: Uint8Array): Promise<{ seed: string; pk: string }> {
@@ -83,16 +110,16 @@ export async function openAsk(bytes: Uint8Array, padlockSecret: Uint8Array): Pro
83
110
  // The heir, or null for the public being. Absent is not null: a payload
84
111
  // that never named one is malformed, like any other missing field.
85
112
  const to = payload.to;
86
- if (to !== null && !(typeof to === 'string' && /^[0-9a-f]{64}$/.test(to))) return null;
87
- if (typeof payload.by !== 'string' || !/^[0-9a-f]{64}$/.test(payload.by)) return null;
88
- if (payload.next !== null && !(typeof payload.next === 'string' && /^[0-9a-f]{64}$/.test(payload.next))) return null;
113
+ if (to !== null && !isHex(to)) return null;
114
+ if (!isHex(payload.by)) return null;
115
+ if (payload.next !== null && !isHex(payload.next)) return null;
89
116
  if (payload.method !== undefined && typeof payload.method !== 'string') return null; // a name, or the empty ask. never a number, never an object.
90
117
  if (payload.args !== undefined && (payload.args === null || typeof payload.args !== 'object' || Array.isArray(payload.args))) return null; // args are one object, or absent. a string or a list is not an ask.
91
118
  if (payload.args !== undefined && cellFault(payload.args, 'args') !== null) return null; // and values all the way down: no key named __proto__, no nesting past the bound
92
119
  if (!Number.isSafeInteger(payload.seq) || payload.seq < 1) return null; // her count for this relation. one and up, and a whole number.
93
- // The allowance. A whole number, and one that has already run out is not an
94
- // ask this door will open: it is refused as the bytes it is, above.
95
- if (!Number.isSafeInteger(payload.time) || payload.time <= 0) return null;
120
+ // The allowance is not read here. It is the door's, D2, and the door is
121
+ // its one reader: `spent` in allowance.ts says what a budget is and what
122
+ // is left of it, in one sentence, for a payload that opened like any other.
96
123
  // The hop count, if this ask carries one: a whole number, never below
97
124
  // zero. Absent is the ordinary ask and always will be.
98
125
  if (payload.hops !== undefined && (!Number.isSafeInteger(payload.hops) || payload.hops < 0)) return null;
@@ -130,7 +157,7 @@ export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, sig
130
157
  if (r.silence === true) return { silence: true };
131
158
  if (Object.hasOwn(r, 'quo')) return isDoorWord(r.quo) ? { quo: r.quo } : null;
132
159
  if (!Object.hasOwn(r, 'object') || r.object === undefined) return null;
133
- if (r.seen !== null && !(typeof r.seen === 'string' && /^[0-9a-f]{64}$/.test(r.seen))) return null;
160
+ if (r.seen !== null && !isHex(r.seen)) return null;
134
161
  return { object: r.object as Json, seen: r.seen };
135
162
  } catch {
136
163
  return null;