nervur 0.19.2 → 0.20.1

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 (235) hide show
  1. package/README.md +156 -104
  2. package/dist/being/being.d.ts +8 -15
  3. package/dist/being/being.js +20 -67
  4. package/dist/being/digest.d.ts +1 -1
  5. package/dist/being/digest.js +7 -27
  6. package/dist/being/faculty.d.ts +10 -0
  7. package/dist/being/faculty.js +74 -0
  8. package/dist/being/index.d.ts +5 -5
  9. package/dist/being/index.js +6 -5
  10. package/dist/being/kind.d.ts +4 -0
  11. package/dist/being/kind.js +28 -0
  12. package/dist/being/types.d.ts +35 -63
  13. package/dist/being/types.js +3 -57
  14. package/dist/being/words.d.ts +13 -0
  15. package/dist/being/words.js +14 -0
  16. package/dist/cli/command.d.ts +13 -0
  17. package/dist/cli/command.js +194 -0
  18. package/dist/cli/harbor.d.ts +21 -0
  19. package/dist/cli/harbor.js +141 -0
  20. package/dist/cli/main.d.ts +2 -0
  21. package/dist/cli/main.js +14 -0
  22. package/dist/contract/index.d.ts +39 -0
  23. package/dist/contract/index.js +33 -0
  24. package/dist/crypto/aes.d.ts +3 -0
  25. package/dist/crypto/aes.js +24 -0
  26. package/dist/crypto/bytes.d.ts +6 -0
  27. package/dist/crypto/bytes.js +33 -0
  28. package/dist/crypto/ed25519.d.ts +3 -0
  29. package/dist/crypto/ed25519.js +85 -0
  30. package/dist/crypto/hash.d.ts +2 -0
  31. package/dist/crypto/hash.js +11 -0
  32. package/dist/crypto/index.d.ts +7 -0
  33. package/dist/crypto/index.js +10 -0
  34. package/dist/crypto/json.d.ts +8 -0
  35. package/dist/crypto/json.js +250 -0
  36. package/dist/crypto/mlkem.d.ts +12 -0
  37. package/dist/crypto/mlkem.js +36 -0
  38. package/dist/crypto/subtle.d.ts +10 -0
  39. package/dist/crypto/subtle.js +29 -0
  40. package/dist/crypto/x25519.d.ts +2 -0
  41. package/dist/crypto/x25519.js +21 -0
  42. package/dist/folder/index.d.ts +14 -0
  43. package/dist/folder/index.js +87 -0
  44. package/dist/harbor/catalogue.d.ts +25 -0
  45. package/dist/harbor/catalogue.js +93 -0
  46. package/dist/harbor/dock.d.ts +29 -0
  47. package/dist/harbor/dock.js +122 -0
  48. package/dist/harbor/harbor.d.ts +31 -0
  49. package/dist/harbor/harbor.js +146 -0
  50. package/dist/harbor/index.d.ts +7 -10
  51. package/dist/harbor/index.js +9 -18
  52. package/dist/harbor/package.d.ts +10 -0
  53. package/dist/harbor/package.js +115 -0
  54. package/dist/harbor/registry.d.ts +9 -0
  55. package/dist/harbor/registry.js +78 -0
  56. package/dist/harbor/root-line.d.ts +9 -0
  57. package/dist/harbor/root-line.js +36 -0
  58. package/dist/harbor/terrain.d.ts +9 -0
  59. package/dist/harbor/terrain.js +2 -0
  60. package/dist/index.d.ts +7 -0
  61. package/dist/index.js +14 -0
  62. package/dist/pointer/bodies.d.ts +26 -0
  63. package/dist/pointer/bodies.js +64 -0
  64. package/dist/pointer/index.d.ts +2 -0
  65. package/dist/pointer/index.js +4 -0
  66. package/dist/pointer/world.d.ts +35 -0
  67. package/dist/pointer/world.js +85 -0
  68. package/dist/quo/door.d.ts +34 -0
  69. package/dist/quo/door.js +172 -0
  70. package/dist/quo/index.d.ts +8 -0
  71. package/dist/quo/index.js +11 -0
  72. package/dist/quo/invitation.d.ts +7 -0
  73. package/dist/quo/invitation.js +15 -0
  74. package/dist/quo/keys.d.ts +40 -0
  75. package/dist/quo/keys.js +79 -0
  76. package/dist/quo/payload.d.ts +15 -0
  77. package/dist/quo/payload.js +55 -0
  78. package/dist/quo/relations.d.ts +40 -0
  79. package/dist/quo/relations.js +33 -0
  80. package/dist/quo/reply.d.ts +13 -0
  81. package/dist/quo/reply.js +35 -0
  82. package/dist/quo/seal.d.ts +42 -0
  83. package/dist/quo/seal.js +78 -0
  84. package/dist/quo/standing.d.ts +39 -0
  85. package/dist/quo/standing.js +90 -0
  86. package/dist/tcp/frame.d.ts +29 -0
  87. package/dist/tcp/frame.js +74 -0
  88. package/dist/tcp/index.d.ts +18 -0
  89. package/dist/tcp/index.js +164 -0
  90. package/dist/ward/allowance.d.ts +13 -9
  91. package/dist/ward/allowance.js +28 -67
  92. package/dist/ward/cells.d.ts +6 -6
  93. package/dist/ward/cells.js +67 -179
  94. package/dist/ward/index.d.ts +6 -10
  95. package/dist/ward/index.js +7 -13
  96. package/dist/ward/partition.d.ts +44 -60
  97. package/dist/ward/partition.js +56 -285
  98. package/dist/ward/pilot.d.ts +8 -0
  99. package/dist/ward/pilot.js +50 -0
  100. package/dist/ward/stance.d.ts +27 -20
  101. package/dist/ward/stance.js +174 -406
  102. package/dist/ward/ward-being.d.ts +35 -0
  103. package/dist/ward/ward-being.js +82 -0
  104. package/dist/ward/ward.d.ts +45 -11
  105. package/dist/ward/ward.js +239 -357
  106. package/package.json +24 -31
  107. package/src/being/being.ts +35 -78
  108. package/src/being/digest.ts +7 -35
  109. package/src/being/faculty.ts +73 -0
  110. package/src/being/index.ts +6 -6
  111. package/src/being/kind.ts +30 -0
  112. package/src/being/types.ts +50 -146
  113. package/src/being/words.ts +31 -0
  114. package/src/cli/command.ts +188 -0
  115. package/src/cli/harbor.ts +137 -0
  116. package/src/cli/main.ts +13 -0
  117. package/src/contract/index.ts +73 -0
  118. package/src/crypto/aes.ts +26 -0
  119. package/src/crypto/bytes.ts +37 -0
  120. package/src/crypto/ed25519.ts +84 -0
  121. package/src/crypto/hash.ts +14 -0
  122. package/src/crypto/index.ts +10 -0
  123. package/src/crypto/json.ts +241 -0
  124. package/src/crypto/mlkem.ts +38 -0
  125. package/src/crypto/subtle.ts +33 -0
  126. package/src/crypto/x25519.ts +21 -0
  127. package/src/folder/index.ts +92 -0
  128. package/src/harbor/catalogue.ts +110 -0
  129. package/src/harbor/dock.ts +137 -0
  130. package/src/harbor/harbor.ts +180 -0
  131. package/src/harbor/index.ts +9 -19
  132. package/src/harbor/package.ts +131 -0
  133. package/src/harbor/registry.ts +73 -0
  134. package/src/harbor/root-line.ts +36 -0
  135. package/src/harbor/terrain.ts +15 -0
  136. package/src/index.ts +20 -0
  137. package/src/pointer/bodies.ts +65 -0
  138. package/src/pointer/index.ts +4 -0
  139. package/src/pointer/world.ts +94 -0
  140. package/src/quo/door.ts +178 -0
  141. package/src/quo/index.ts +11 -0
  142. package/src/quo/invitation.ts +15 -0
  143. package/src/quo/keys.ts +91 -0
  144. package/src/quo/payload.ts +61 -0
  145. package/src/quo/relations.ts +62 -0
  146. package/src/quo/reply.ts +38 -0
  147. package/src/quo/seal.ts +101 -0
  148. package/src/quo/standing.ts +111 -0
  149. package/src/stand/main.ts +20 -0
  150. package/src/stand/stand.ts +220 -0
  151. package/src/tcp/frame.ts +78 -0
  152. package/src/tcp/index.ts +173 -0
  153. package/src/ward/allowance.ts +37 -75
  154. package/src/ward/cells.ts +63 -176
  155. package/src/ward/index.ts +7 -17
  156. package/src/ward/partition.ts +86 -326
  157. package/src/ward/pilot.ts +50 -0
  158. package/src/ward/stance.ts +186 -420
  159. package/src/ward/ward-being.ts +103 -0
  160. package/src/ward/ward.ts +256 -359
  161. package/dist/being/lent.d.ts +0 -32
  162. package/dist/being/lent.js +0 -72
  163. package/dist/being/silence.d.ts +0 -12
  164. package/dist/being/silence.js +0 -41
  165. package/dist/conformance/assert.d.ts +0 -11
  166. package/dist/conformance/assert.js +0 -106
  167. package/dist/conformance/beings.d.ts +0 -199
  168. package/dist/conformance/beings.js +0 -188
  169. package/dist/conformance/estate.d.ts +0 -5
  170. package/dist/conformance/estate.js +0 -388
  171. package/dist/conformance/index.d.ts +0 -80
  172. package/dist/conformance/index.js +0 -819
  173. package/dist/conformance/reach.d.ts +0 -10
  174. package/dist/conformance/reach.js +0 -72
  175. package/dist/conformance/store.d.ts +0 -5
  176. package/dist/conformance/store.js +0 -113
  177. package/dist/harbor/box.d.ts +0 -121
  178. package/dist/harbor/box.js +0 -121
  179. package/dist/harbor/core.d.ts +0 -55
  180. package/dist/harbor/core.js +0 -662
  181. package/dist/harbor/dial.d.ts +0 -9
  182. package/dist/harbor/dial.js +0 -81
  183. package/dist/harbor/memory.d.ts +0 -29
  184. package/dist/harbor/memory.js +0 -104
  185. package/dist/harbor/reach.d.ts +0 -36
  186. package/dist/harbor/reach.js +0 -199
  187. package/dist/harbor/store.d.ts +0 -35
  188. package/dist/harbor/store.js +0 -62
  189. package/dist/vector/cases.d.ts +0 -42
  190. package/dist/vector/cases.js +0 -223
  191. package/dist/vector/index.d.ts +0 -6
  192. package/dist/vector/index.js +0 -8
  193. package/dist/vector/stand.d.ts +0 -9
  194. package/dist/vector/stand.js +0 -77
  195. package/dist/vector/world.d.ts +0 -144
  196. package/dist/vector/world.js +0 -209
  197. package/dist/ward/arithmetic.d.ts +0 -31
  198. package/dist/ward/arithmetic.js +0 -249
  199. package/dist/ward/door.d.ts +0 -18
  200. package/dist/ward/door.js +0 -186
  201. package/dist/ward/ground.d.ts +0 -29
  202. package/dist/ward/ground.js +0 -56
  203. package/dist/ward/heirs.d.ts +0 -13
  204. package/dist/ward/heirs.js +0 -117
  205. package/dist/ward/json.d.ts +0 -2
  206. package/dist/ward/json.js +0 -163
  207. package/dist/ward/owner.d.ts +0 -13
  208. package/dist/ward/owner.js +0 -220
  209. package/dist/ward/seal.d.ts +0 -52
  210. package/dist/ward/seal.js +0 -150
  211. package/src/being/lent.ts +0 -72
  212. package/src/being/silence.ts +0 -46
  213. package/src/conformance/assert.ts +0 -100
  214. package/src/conformance/beings.ts +0 -188
  215. package/src/conformance/estate.ts +0 -412
  216. package/src/conformance/index.ts +0 -965
  217. package/src/conformance/reach.ts +0 -83
  218. package/src/conformance/store.ts +0 -125
  219. package/src/harbor/box.ts +0 -131
  220. package/src/harbor/core.ts +0 -699
  221. package/src/harbor/dial.ts +0 -112
  222. package/src/harbor/memory.ts +0 -123
  223. package/src/harbor/reach.ts +0 -221
  224. package/src/harbor/store.ts +0 -91
  225. package/src/vector/cases.ts +0 -257
  226. package/src/vector/index.ts +0 -11
  227. package/src/vector/stand.ts +0 -76
  228. package/src/vector/world.ts +0 -232
  229. package/src/ward/arithmetic.ts +0 -251
  230. package/src/ward/door.ts +0 -186
  231. package/src/ward/ground.ts +0 -142
  232. package/src/ward/heirs.ts +0 -116
  233. package/src/ward/json.ts +0 -144
  234. package/src/ward/owner.ts +0 -214
  235. package/src/ward/seal.ts +0 -178
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The reply text: `{ object, seen }`, `{ silence: true }` or `{ quo: word }`.
3
+ // `object` is carried as the text it was written in.
4
+ import { readObject } from '../crypto/index.ts';
5
+
6
+ export const WORDS = ['removed', 'unannounced', 'repeated'] as const;
7
+ export type Word = (typeof WORDS)[number];
8
+
9
+ export type Reply = { readonly object: string; readonly seen: string | null } | { readonly silence: true } | { readonly quo: Word };
10
+
11
+ export const SILENCE = '{"silence":true}';
12
+
13
+ export const writeReply = (reply: Reply): string => {
14
+ if ('silence' in reply) return SILENCE;
15
+ if ('quo' in reply) return `{"quo":"${reply.quo}"}`;
16
+ return `{"object":${reply.object},"seen":${JSON.stringify(reply.seen)}}`;
17
+ };
18
+
19
+ const exactly = (fields: ReadonlyMap<string, string>, ...keys: string[]): boolean => fields.size === keys.length && keys.every((k) => fields.has(k));
20
+
21
+ // A reply text of one of the three shapes, or null for anything else, which
22
+ // the reader takes as silence.
23
+ export const readReply = (bytes: Uint8Array): Reply | null => {
24
+ const fields = readObject(bytes);
25
+ if (fields === null) return null;
26
+ if (exactly(fields, 'silence')) return fields.get('silence') === 'true' ? { silence: true } : null;
27
+ if (exactly(fields, 'quo')) {
28
+ const text = fields.get('quo')!;
29
+ const word = text.startsWith('"') ? (JSON.parse(text) as string) : null;
30
+ return WORDS.find((w) => w === word) ? { quo: word as Word } : null;
31
+ }
32
+ if (exactly(fields, 'object', 'seen')) {
33
+ const seen = fields.get('seen')!;
34
+ if (seen === 'null') return { object: fields.get('object')!, seen: null };
35
+ return seen.startsWith('"') ? { object: fields.get('object')!, seen: JSON.parse(seen) as string } : null;
36
+ }
37
+ return null;
38
+ };
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The three boxes and the keys between them.
3
+ //
4
+ // ask box lid (32) || AES( head ) || AES( body )
5
+ // knock box lid (32) || AES( head ) || ML-KEM ciphertext (1088) || AES( body )
6
+ // reply box ephemeral pk (32) || AES( body )
7
+ // body JSON text || signature (64)
8
+ //
9
+ // The head of an ask and the body of a reply are sealed under `quo-seal`
10
+ // over the agreement. The body of an ask is sealed under `quo-edge-seal`
11
+ // over the agreement and then the edge key. Every ciphertext's additional
12
+ // data is its own box's ephemeral pk.
13
+ import { CIPHERTEXT, concat, decrypt, encrypt, hkdf, sign, TAG, verify } from '../crypto/index.ts';
14
+ import { Ephemeral, LABEL, type Draw, type SigningKey } from './keys.ts';
15
+
16
+ export const SIZE = 1_048_576;
17
+ export const HEAD = 32;
18
+ export const SIGNATURE = 64;
19
+ export const SEALED_HEAD = HEAD + TAG;
20
+ export const ZERO_EDGE = new Uint8Array(32);
21
+ export const ZERO_HEAD = new Uint8Array(HEAD);
22
+
23
+ const cipher = async (label: Uint8Array, ...material: Uint8Array[]) => {
24
+ const out = await hkdf(concat(...material), label, 44);
25
+ return { key: out.subarray(0, 32), nonce: out.subarray(32) };
26
+ };
27
+ const seal = async (label: Uint8Array, material: Uint8Array[], lid: Uint8Array, plaintext: Uint8Array) => {
28
+ const { key, nonce } = await cipher(label, ...material);
29
+ return encrypt(key, nonce, lid, plaintext);
30
+ };
31
+ const open = async (label: Uint8Array, material: Uint8Array[], lid: Uint8Array, ciphertext: Uint8Array) => {
32
+ const { key, nonce } = await cipher(label, ...material);
33
+ return decrypt(key, nonce, lid, ciphertext);
34
+ };
35
+
36
+ // A knock's edge key, from the shared secret the lock decapsulates.
37
+ export const knockEdge = (shared: Uint8Array): Promise<Uint8Array> => hkdf(shared, LABEL.lock, 32);
38
+
39
+ // The edge key that follows `edge` after a reply of this agreement.
40
+ export const follow = (edge: Uint8Array, agreement: Uint8Array): Promise<Uint8Array> => hkdf(concat(edge, agreement), LABEL.edge, 32);
41
+
42
+ export const signBody = async (text: Uint8Array, key: SigningKey): Promise<Uint8Array> => concat(text, await sign(key.secret, text));
43
+
44
+ // The text and signature of a body, or null for a body of sixty-four bytes
45
+ // or fewer.
46
+ export const splitBody = (body: Uint8Array): { text: Uint8Array; signature: Uint8Array } | null =>
47
+ body.length <= SIGNATURE ? null : { text: body.subarray(0, -SIGNATURE), signature: body.subarray(-SIGNATURE) };
48
+
49
+ export const checkBody = (pk: Uint8Array, body: { text: Uint8Array; signature: Uint8Array }): Promise<boolean> => verify(pk, body.text, body.signature);
50
+
51
+ // An ask box sealed under `lid` to a padlock, or null for a padlock that
52
+ // takes no seal. `ciphertext` makes it a knock box.
53
+ export const sealAsk = async (lid: Ephemeral, padlock: Uint8Array, head: Uint8Array, edge: Uint8Array, body: Uint8Array, ciphertext?: Uint8Array): Promise<Uint8Array | null> => {
54
+ const agreement = await lid.agree(padlock);
55
+ if (agreement === null) return null;
56
+ const sealedHead = await seal(LABEL.seal, [agreement], lid.pk, head);
57
+ const sealedBody = await seal(LABEL.edgeSeal, [agreement, edge], lid.pk, body);
58
+ return concat(lid.pk, sealedHead, ciphertext ?? new Uint8Array(0), sealedBody);
59
+ };
60
+
61
+ // What a door reads before it knows which edge key to try.
62
+ export type OpenedHead = { lid: Uint8Array; agreement: Uint8Array; head: Uint8Array; rest: Uint8Array };
63
+
64
+ export const openHead = async (bytes: Uint8Array, agreement: Uint8Array): Promise<OpenedHead | null> => {
65
+ if (bytes.length < HEAD + SEALED_HEAD) return null;
66
+ const lid = bytes.subarray(0, HEAD);
67
+ const head = await open(LABEL.seal, [agreement], lid, bytes.subarray(HEAD, HEAD + SEALED_HEAD));
68
+ return head === null ? null : { lid, agreement, head, rest: bytes.subarray(HEAD + SEALED_HEAD) };
69
+ };
70
+
71
+ // A knock's rest split into its ciphertext and sealed body.
72
+ export const splitKnock = (rest: Uint8Array): { ciphertext: Uint8Array; sealedBody: Uint8Array } | null =>
73
+ rest.length < CIPHERTEXT ? null : { ciphertext: rest.subarray(0, CIPHERTEXT), sealedBody: rest.subarray(CIPHERTEXT) };
74
+
75
+ export const openBody = (opened: OpenedHead, edge: Uint8Array, sealedBody: Uint8Array): Promise<Uint8Array | null> =>
76
+ open(LABEL.edgeSeal, [opened.agreement, edge], opened.lid, sealedBody);
77
+
78
+ export type SealedReply = { bytes: Uint8Array; agreement: Uint8Array };
79
+
80
+ // The reply's ephemeral key and its agreement with the lid, drawn before the
81
+ // reply is written, since the edge keys move by that agreement.
82
+ export type ReplyKey = { ephemeral: Ephemeral; agreement: Uint8Array };
83
+
84
+ export const replyKey = async (draw: Draw, lid: Uint8Array): Promise<ReplyKey | null> => {
85
+ const ephemeral = await Ephemeral.draw(draw);
86
+ const agreement = await ephemeral.agree(lid);
87
+ return agreement === null ? null : { ephemeral, agreement };
88
+ };
89
+
90
+ export const sealReply = async (key: ReplyKey, body: Uint8Array): Promise<Uint8Array> =>
91
+ concat(key.ephemeral.pk, await seal(LABEL.seal, [key.agreement], key.ephemeral.pk, body));
92
+
93
+ // A reply's body and agreement, opened with the lid's secret, or null.
94
+ export const openReply = async (bytes: Uint8Array, lid: Ephemeral): Promise<SealedReply | null> => {
95
+ if (bytes.length > SIZE || bytes.length < HEAD + TAG) return null;
96
+ const pk = bytes.subarray(0, HEAD);
97
+ const agreement = await lid.agree(pk);
98
+ if (agreement === null) return null;
99
+ const body = await open(LABEL.seal, [agreement], pk, bytes.subarray(HEAD));
100
+ return body === null ? null : { bytes: body, agreement };
101
+ };
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The standing's side of a relation: it seals asks and reads replies, and
3
+ // moves to its announced key and the following edge key when an object
4
+ // comes back to an ask above every ask it has moved on, and on nothing else.
5
+ //
6
+ // Its state is a value, so a ward keeps it in its partition.
7
+ import { encapsulate, hex, unhex } from '../crypto/index.ts';
8
+ import type { Invitation } from './invitation.ts';
9
+ import { Ephemeral, SigningKey, wardHalves, type Draw } from './keys.ts';
10
+ import { writePayload } from './payload.ts';
11
+ import { readReply, type Reply } from './reply.ts';
12
+ import { checkBody, follow, knockEdge, openReply, sealAsk, signBody, splitBody } from './seal.ts';
13
+
14
+ export type Read = Reply | { readonly nothing: true };
15
+
16
+ // A knock that brought no object back: its bytes, sent again as they are,
17
+ // and what the standing moves to if it bound.
18
+ type Knock = { readonly bytes: string; readonly lid: string; readonly next: string; readonly edge: string; readonly seq: number };
19
+
20
+ export type StandingState = {
21
+ readonly key: string | null;
22
+ readonly edge: string | null;
23
+ readonly sent: number;
24
+ readonly moved: number;
25
+ readonly knock: Knock | null;
26
+ // Whether the last ask after an unanswered knock asked under its own key,
27
+ // so the next sends the knock again.
28
+ readonly probed: boolean;
29
+ };
30
+
31
+ export const freshStanding = (): StandingState => ({ key: null, edge: null, sent: 0, moved: 0, knock: null, probed: false });
32
+
33
+ // One ask out: what `read` needs to read its reply and to move.
34
+ export type Sent = { readonly bytes: Uint8Array; readonly lid: Ephemeral; readonly next: string; readonly edge: string; readonly seq: number };
35
+
36
+ export class Standing {
37
+ readonly invitation: Invitation;
38
+ state: StandingState;
39
+ readonly draw: Draw;
40
+
41
+ constructor(invitation: Invitation, state: StandingState, draw: Draw) {
42
+ this.invitation = invitation;
43
+ this.state = state;
44
+ this.draw = draw;
45
+ }
46
+
47
+ async ask(method?: string, args?: string): Promise<Sent | null> {
48
+ const s = this.state;
49
+ if (s.key !== null) return this.#ask(unhex(s.key), unhex(s.edge!), method, args);
50
+ if (s.knock === null) return this.#knock(method, args);
51
+ if (!s.probed) {
52
+ this.state = { ...s, probed: true };
53
+ return this.#ask(unhex(s.knock.next), unhex(s.knock.edge), method, args);
54
+ }
55
+ this.state = { ...s, probed: false };
56
+ // The same bytes again: a new lid would be a new box.
57
+ return { bytes: unhex(s.knock.bytes), lid: await Ephemeral.from(unhex(s.knock.lid)), next: s.knock.next, edge: s.knock.edge, seq: s.knock.seq };
58
+ }
59
+
60
+ async read(sent: Sent, bytes: Uint8Array | null): Promise<Read> {
61
+ if (bytes === null) return { nothing: true };
62
+ const reply = await this.#open(sent, bytes);
63
+ if (reply === null) return { silence: true };
64
+ if ('object' in reply.text && sent.seq > this.state.moved) {
65
+ const edge = await follow(unhex(sent.edge), reply.agreement);
66
+ this.state = { ...this.state, key: sent.next, edge: hex(edge), moved: sent.seq, knock: null, probed: false };
67
+ }
68
+ return reply.text;
69
+ }
70
+
71
+ async #knock(method?: string, args?: string): Promise<Sent | null> {
72
+ const heir = await SigningKey.from(unhex(this.invitation.secret));
73
+ const next = await SigningKey.draw(this.draw);
74
+ const lid = await Ephemeral.draw(this.draw);
75
+ const sent = encapsulate(unhex(this.invitation.lock), this.draw(32));
76
+ if (sent === null) return null;
77
+ const edge = await knockEdge(sent.shared);
78
+ const seq = this.state.sent + 1;
79
+ const body = await this.#body(heir, next, seq, method, args);
80
+ const bytes = await sealAsk(lid, unhex(wardHalves(this.invitation.ward).padlock), unhex(this.invitation.heir), edge, body, sent.ciphertext);
81
+ if (bytes === null) return null;
82
+ this.state = { ...this.state, sent: seq, knock: { bytes: hex(bytes), lid: hex(lid.secret), next: hex(next.secret), edge: hex(edge), seq } };
83
+ return { bytes, lid, next: hex(next.secret), edge: hex(edge), seq };
84
+ }
85
+
86
+ async #ask(secret: Uint8Array, edge: Uint8Array, method?: string, args?: string): Promise<Sent | null> {
87
+ const by = await SigningKey.from(secret);
88
+ const next = await SigningKey.draw(this.draw);
89
+ const lid = await Ephemeral.draw(this.draw);
90
+ const seq = this.state.sent + 1;
91
+ const body = await this.#body(by, next, seq, method, args);
92
+ const bytes = await sealAsk(lid, unhex(wardHalves(this.invitation.ward).padlock), unhex(this.invitation.heir), edge, body);
93
+ if (bytes === null) return null;
94
+ this.state = { ...this.state, sent: seq };
95
+ return { bytes, lid, next: hex(next.secret), edge: hex(edge), seq };
96
+ }
97
+
98
+ #body(by: SigningKey, next: SigningKey, seq: number, method?: string, args?: string): Promise<Uint8Array> {
99
+ const text = writePayload({ to: this.invitation.heir, by: by.id, next: next.id, seq, ...(method === undefined ? {} : { method }), ...(args === undefined ? {} : { args }) });
100
+ return signBody(new TextEncoder().encode(text), by);
101
+ }
102
+
103
+ async #open(sent: Sent, bytes: Uint8Array): Promise<{ text: Reply; agreement: Uint8Array } | null> {
104
+ const opened = await openReply(bytes, sent.lid);
105
+ const body = opened && splitBody(opened.bytes);
106
+ if (!opened || !body) return null;
107
+ if (!(await checkBody(unhex(wardHalves(this.invitation.ward).signing), body))) return null;
108
+ const text = readReply(body.text);
109
+ return text && { text, agreement: opened.agreement };
110
+ }
111
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The stand program: one JSON request a line on stdin, one answer a line on
3
+ // stdout, until stdin closes.
4
+ import { createInterface } from 'node:readline';
5
+ import { Stand } from './stand.ts';
6
+
7
+ const stand = new Stand((n) => globalThis.crypto.getRandomValues(new Uint8Array(n)));
8
+ const pending: Promise<void>[] = [];
9
+
10
+ for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) {
11
+ if (line === '') continue;
12
+ pending.push(
13
+ stand.handle(line).then(
14
+ (answer) => void process.stdout.write(`${JSON.stringify(answer)}\n`),
15
+ (error: unknown) => void process.stderr.write(`${String(error)}\n`),
16
+ ),
17
+ );
18
+ }
19
+ await Promise.all(pending);
20
+ await stand.close();
@@ -0,0 +1,220 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The stand of `quo/vectors/HARNESS.md`, over `quo/` and `nervur/tcp`:
3
+ // part one judged, and part two carried, its wards answering on one
4
+ // listener and asking through one dialer.
5
+ import { hex, isHex, unhex } from '../crypto/index.ts';
6
+ import { Door, freshStanding, MemoryRelations, readInvitation, Standing, WardKey, type Behind, type Choice, type Draw, type Invitation, type Payload, type Read, type Sent } from '../quo/index.ts';
7
+ import { TcpCarrier, TcpListener } from '../tcp/index.ts';
8
+
9
+ type Answer = Record<string, unknown>;
10
+ const ERRORS = ['bad request', 'no such op', 'no such ward', 'not reached', 'ward stood', 'name held'] as const;
11
+ type Error = (typeof ERRORS)[number];
12
+ class Refused {
13
+ readonly error: Error;
14
+ constructor(error: Error) {
15
+ this.error = error;
16
+ }
17
+ }
18
+ const refuse = (error: Error): never => {
19
+ throw new Refused(error);
20
+ };
21
+
22
+ // What the verifier names behind a door.
23
+ const REACHES: Record<string, (p: Payload) => Choice> = {
24
+ echo: (p) => ({ object: p.method === undefined ? '{}' : (p.args ?? '{}'), seen: null }),
25
+ marked: (p) => ({ object: p.method === undefined ? '{}' : (p.args ?? '{}'), seen: p.method === undefined ? null : '1' }),
26
+ null: () => ({ object: 'null', seen: null }),
27
+ silent: () => ({ silence: true }),
28
+ };
29
+
30
+ class StandWard implements Behind {
31
+ readonly door: Door;
32
+ readonly zero: boolean;
33
+ readonly #zero: string | undefined;
34
+ readonly names = new Map<string, string>();
35
+ readonly reaches = new Map<string, string>();
36
+ readonly standings = new Map<string, { standing: Standing; last?: Sent }>();
37
+
38
+ constructor(key: WardKey, zero: string | undefined, draw: Draw) {
39
+ this.#zero = zero;
40
+ this.zero = zero !== undefined;
41
+ this.door = new Door(key, new MemoryRelations(), this, draw);
42
+ }
43
+
44
+ answer(heir: string | null, p: Payload): Promise<Choice> {
45
+ const reach = heir === null ? this.#zero! : this.reaches.get(heir);
46
+ return Promise.resolve(reach === undefined ? { silence: true } : REACHES[reach]!(p));
47
+ }
48
+ }
49
+
50
+ const OPS = new Set(['ward', 'invite', 'release', 'arrive', 'ask', 'read', 'listen', 'route', 'send']);
51
+
52
+ export class Stand {
53
+ readonly #wards = new Map<string, StandWard>();
54
+ readonly #draw: Draw;
55
+ readonly #carrier = new TcpCarrier();
56
+ #listener: Promise<TcpListener> | undefined;
57
+
58
+ constructor(draw: Draw) {
59
+ this.#draw = draw;
60
+ }
61
+
62
+ async close(): Promise<void> {
63
+ await this.#carrier.close();
64
+ await (await this.#listener)?.close();
65
+ }
66
+
67
+ async handle(line: string): Promise<Answer> {
68
+ let request: unknown;
69
+ try {
70
+ request = JSON.parse(line);
71
+ } catch {
72
+ return { id: null, error: 'bad request' };
73
+ }
74
+ if (typeof request !== 'object' || request === null || Array.isArray(request)) return { id: null, error: 'bad request' };
75
+ const r = request as Record<string, unknown>;
76
+ if (typeof r.id !== 'string') return { id: null, error: 'bad request' };
77
+ try {
78
+ return { id: r.id, ...(await this.#op(r)) };
79
+ } catch (e) {
80
+ if (e instanceof Refused) return { id: r.id, error: e.error };
81
+ throw e;
82
+ }
83
+ }
84
+
85
+ async #op(r: Record<string, unknown>): Promise<Answer> {
86
+ if (typeof r.op !== 'string') return refuse('bad request');
87
+ if (!OPS.has(r.op)) return refuse('no such op');
88
+ switch (r.op) {
89
+ case 'ward':
90
+ return this.#ward(text(r.seed), optional(r.reach));
91
+ case 'invite':
92
+ return this.#invite(r);
93
+ case 'release':
94
+ return this.#release(r);
95
+ case 'arrive':
96
+ return this.#arrive(r);
97
+ case 'ask':
98
+ return this.#ask(r);
99
+ case 'read':
100
+ return this.#read(r);
101
+ case 'listen':
102
+ return this.#listen();
103
+ case 'route':
104
+ return this.#route(r);
105
+ default:
106
+ return this.#send(r);
107
+ }
108
+ }
109
+
110
+ // One listener, answering for every ward this program stands.
111
+ async #listen(): Promise<Answer> {
112
+ this.#listener ??= TcpListener.listen({
113
+ arrive: async (pk, bytes) => {
114
+ const ward = this.#wards.get(pk);
115
+ return ward ? (await ward.door.arrive(bytes)).bytes : null;
116
+ },
117
+ });
118
+ return { at: (await this.#listener).at };
119
+ }
120
+
121
+ #route(r: Record<string, unknown>): Answer {
122
+ const far = r.far;
123
+ if (!isHex(far, 64) || !this.#carrier.route(far, text(r.at))) return refuse('bad request');
124
+ return { routed: far };
125
+ }
126
+
127
+ async #send(r: Record<string, unknown>): Promise<Answer> {
128
+ const invitation = inviteOf(r.invitation);
129
+ const method = r.method === undefined ? undefined : text(r.method);
130
+ const args = r.args === undefined ? undefined : objectText(r.args);
131
+ const ward = this.#ward_(r.ward);
132
+ const held = this.#standing(ward, invitation);
133
+ const sent = await held.standing.ask(method, args);
134
+ if (sent === null) return refuse('bad request');
135
+ held.last = sent;
136
+ const reply = await this.#carrier.carry(invitation.ward, sent.bytes);
137
+ return { read: readOf(await held.standing.read(sent, reply)) };
138
+ }
139
+
140
+ async #ward(seed: string, reach: string | undefined): Promise<Answer> {
141
+ reached(reach);
142
+ const key = await WardKey.from(seed);
143
+ if (this.#wards.has(key.pk)) refuse('ward stood');
144
+ this.#wards.set(key.pk, new StandWard(key, reach, this.#draw));
145
+ return { ward: key.pk };
146
+ }
147
+
148
+ async #invite(r: Record<string, unknown>): Promise<Answer> {
149
+ const name = text(r.heir);
150
+ const reach = optional(r.reach) ?? 'echo';
151
+ const ward = this.#ward_(r.ward);
152
+ reached(reach);
153
+ if (ward.names.has(name)) refuse('name held');
154
+ const { heir, invitation } = await ward.door.invite();
155
+ ward.names.set(name, heir);
156
+ ward.reaches.set(heir, reach);
157
+ return { invitation };
158
+ }
159
+
160
+ #release(r: Record<string, unknown>): Answer {
161
+ const name = text(r.heir);
162
+ const ward = this.#ward_(r.ward);
163
+ const heir = ward.names.get(name);
164
+ if (heir === undefined) return { released: null };
165
+ ward.names.delete(name);
166
+ ward.door.remove(heir);
167
+ return { released: name };
168
+ }
169
+
170
+ async #arrive(r: Record<string, unknown>): Promise<Answer> {
171
+ const box = r.box;
172
+ if (!isHex(box)) return refuse('bad request');
173
+ const ward = this.#ward_(r.ward);
174
+ const judged = await ward.door.arrive(unhex(box));
175
+ return { reply: hex(judged.bytes) };
176
+ }
177
+
178
+ async #ask(r: Record<string, unknown>): Promise<Answer> {
179
+ const invitation = inviteOf(r.invitation);
180
+ const method = r.method === undefined ? undefined : text(r.method);
181
+ const args = r.args === undefined ? undefined : objectText(r.args);
182
+ const ward = this.#ward_(r.ward);
183
+ const held = this.#standing(ward, invitation);
184
+ const sent = await held.standing.ask(method, args);
185
+ if (sent === null) return refuse('bad request');
186
+ held.last = sent;
187
+ return { box: hex(sent.bytes) };
188
+ }
189
+
190
+ async #read(r: Record<string, unknown>): Promise<Answer> {
191
+ const invitation = inviteOf(r.invitation);
192
+ if (r.reply !== null && !isHex(r.reply)) return refuse('bad request');
193
+ const ward = this.#ward_(r.ward);
194
+ const held = this.#standing(ward, invitation);
195
+ if (held.last === undefined) return refuse('bad request');
196
+ const read = await held.standing.read(held.last, r.reply === null ? null : unhex(r.reply as string));
197
+ return { read: readOf(read) };
198
+ }
199
+
200
+ #standing(ward: StandWard, invitation: Invitation): { standing: Standing; last?: Sent } {
201
+ const key = `${invitation.ward}.${invitation.heir}`;
202
+ let held = ward.standings.get(key);
203
+ if (!held) ward.standings.set(key, (held = { standing: new Standing(invitation, freshStanding(), this.#draw) }));
204
+ return held;
205
+ }
206
+
207
+ #ward_(pk: unknown): StandWard {
208
+ if (!isHex(pk, 64)) return refuse('bad request');
209
+ return this.#wards.get(pk) ?? refuse('no such ward');
210
+ }
211
+ }
212
+
213
+ const text = (value: unknown): string => (typeof value === 'string' ? value : refuse('bad request'));
214
+ const optional = (value: unknown): string | undefined => (value === undefined ? undefined : text(value));
215
+ const reached = (reach: string | undefined): void => {
216
+ if (reach !== undefined && !Object.hasOwn(REACHES, reach)) refuse('not reached');
217
+ };
218
+ const inviteOf = (value: unknown): Invitation => readInvitation(value) ?? refuse('bad request');
219
+ const objectText = (value: unknown): string => (typeof value === 'object' && value !== null && !Array.isArray(value) ? JSON.stringify(value) : refuse('bad request'));
220
+ const readOf = (read: Read): unknown => ('object' in read ? { object: JSON.parse(read.object) as unknown, seen: read.seen } : read);
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The frames of Quo over TCP, `quo/CARRIER-TCP.md`:
3
+ //
4
+ // frame = length (4, big-endian) || kind (1) || id (4, big-endian) || rest
5
+ // 00 ask rest = ward pk (64) || box
6
+ // 01 reply rest = box
7
+ // 02 nothing rest = empty
8
+ //
9
+ // A reader says a stream is not a frame as soon as its bytes say so, and
10
+ // reads nothing after it.
11
+ import { concat } from '../crypto/index.ts';
12
+
13
+ export const ASK = 0;
14
+ export const REPLY = 1;
15
+ export const NOTHING = 2;
16
+ export const MAX_BODY = 1_048_645;
17
+ const PK = 64;
18
+
19
+ export type Frame = { kind: 'ask'; id: number; pk: Uint8Array; box: Uint8Array } | { kind: 'reply'; id: number; box: Uint8Array } | { kind: 'nothing'; id: number };
20
+
21
+ const frame = (kind: number, id: number, ...rest: Uint8Array[]): Uint8Array => {
22
+ const head = new Uint8Array(9);
23
+ const view = new DataView(head.buffer);
24
+ view.setUint32(0, 5 + rest.reduce((n, r) => n + r.length, 0));
25
+ head[4] = kind;
26
+ view.setUint32(5, id >>> 0);
27
+ return concat(head, ...rest);
28
+ };
29
+
30
+ export const askFrame = (id: number, pk: Uint8Array, box: Uint8Array): Uint8Array => frame(ASK, id, pk, box);
31
+ export const replyFrame = (id: number, box: Uint8Array): Uint8Array => frame(REPLY, id, box);
32
+ export const nothingFrame = (id: number): Uint8Array => frame(NOTHING, id);
33
+
34
+ // Whether a body of this length, and this first byte when known, is no frame.
35
+ const notAFrame = (length: number, kind: number | undefined): boolean =>
36
+ length > MAX_BODY || length < 5 || (kind !== undefined && (kind > NOTHING || (kind === ASK && length < 5 + PK) || (kind === NOTHING && length !== 5)));
37
+
38
+ export class FrameReader {
39
+ #buffer: Uint8Array = new Uint8Array(0);
40
+ #bad = false;
41
+
42
+ // True once bytes that are not a frame arrived; nothing is read after.
43
+ get bad(): boolean {
44
+ return this.#bad;
45
+ }
46
+
47
+ push(chunk: Uint8Array): Frame[] {
48
+ if (this.#bad) return [];
49
+ this.#buffer = this.#buffer.length === 0 ? chunk.slice() : concat(this.#buffer, chunk);
50
+ const out: Frame[] = [];
51
+ while (this.#buffer.length >= 4) {
52
+ const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset);
53
+ const length = view.getUint32(0);
54
+ if (notAFrame(length, this.#buffer.length > 4 ? this.#buffer[4] : undefined)) {
55
+ this.#bad = true;
56
+ this.#buffer = new Uint8Array(0);
57
+ return out;
58
+ }
59
+ if (this.#buffer.length < 4 + length) break;
60
+ const body = this.#buffer.subarray(4, 4 + length);
61
+ this.#buffer = this.#buffer.slice(4 + length);
62
+ const id = new DataView(body.buffer, body.byteOffset).getUint32(1);
63
+ if (body[0] === ASK) out.push({ kind: 'ask', id, pk: body.slice(5, 5 + PK), box: body.slice(5 + PK) });
64
+ else if (body[0] === REPLY) out.push({ kind: 'reply', id, box: body.slice(5) });
65
+ else out.push({ kind: 'nothing', id });
66
+ }
67
+ return out;
68
+ }
69
+ }
70
+
71
+ // "host:port" to its parts, or null. A host in brackets is an IPv6 address.
72
+ export const parseAt = (at: unknown): { host: string; port: number } | null => {
73
+ if (typeof at !== 'string') return null;
74
+ const m = /^(?:\[([^\]]+)\]|([^:[\]]+)):(\d{1,5})$/.exec(at);
75
+ if (!m) return null;
76
+ const port = Number(m[3]);
77
+ return port > 0 && port < 65_536 ? { host: m[1] ?? m[2]!, port } : null;
78
+ };