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.
- package/README.md +156 -104
- package/dist/being/being.d.ts +8 -15
- package/dist/being/being.js +20 -67
- package/dist/being/digest.d.ts +1 -1
- package/dist/being/digest.js +7 -27
- package/dist/being/faculty.d.ts +10 -0
- package/dist/being/faculty.js +74 -0
- package/dist/being/index.d.ts +5 -5
- package/dist/being/index.js +6 -5
- package/dist/being/kind.d.ts +4 -0
- package/dist/being/kind.js +28 -0
- package/dist/being/types.d.ts +35 -63
- package/dist/being/types.js +3 -57
- package/dist/being/words.d.ts +13 -0
- package/dist/being/words.js +14 -0
- package/dist/cli/command.d.ts +13 -0
- package/dist/cli/command.js +194 -0
- package/dist/cli/harbor.d.ts +21 -0
- package/dist/cli/harbor.js +141 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +14 -0
- package/dist/contract/index.d.ts +39 -0
- package/dist/contract/index.js +33 -0
- package/dist/crypto/aes.d.ts +3 -0
- package/dist/crypto/aes.js +24 -0
- package/dist/crypto/bytes.d.ts +6 -0
- package/dist/crypto/bytes.js +33 -0
- package/dist/crypto/ed25519.d.ts +3 -0
- package/dist/crypto/ed25519.js +85 -0
- package/dist/crypto/hash.d.ts +2 -0
- package/dist/crypto/hash.js +11 -0
- package/dist/crypto/index.d.ts +7 -0
- package/dist/crypto/index.js +10 -0
- package/dist/crypto/json.d.ts +8 -0
- package/dist/crypto/json.js +250 -0
- package/dist/crypto/mlkem.d.ts +12 -0
- package/dist/crypto/mlkem.js +36 -0
- package/dist/crypto/subtle.d.ts +10 -0
- package/dist/crypto/subtle.js +29 -0
- package/dist/crypto/x25519.d.ts +2 -0
- package/dist/crypto/x25519.js +21 -0
- package/dist/folder/index.d.ts +14 -0
- package/dist/folder/index.js +87 -0
- package/dist/harbor/catalogue.d.ts +25 -0
- package/dist/harbor/catalogue.js +93 -0
- package/dist/harbor/dock.d.ts +29 -0
- package/dist/harbor/dock.js +122 -0
- package/dist/harbor/harbor.d.ts +31 -0
- package/dist/harbor/harbor.js +146 -0
- package/dist/harbor/index.d.ts +7 -10
- package/dist/harbor/index.js +9 -18
- package/dist/harbor/package.d.ts +10 -0
- package/dist/harbor/package.js +115 -0
- package/dist/harbor/registry.d.ts +9 -0
- package/dist/harbor/registry.js +78 -0
- package/dist/harbor/root-line.d.ts +9 -0
- package/dist/harbor/root-line.js +36 -0
- package/dist/harbor/terrain.d.ts +9 -0
- package/dist/harbor/terrain.js +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/pointer/bodies.d.ts +26 -0
- package/dist/pointer/bodies.js +64 -0
- package/dist/pointer/index.d.ts +2 -0
- package/dist/pointer/index.js +4 -0
- package/dist/pointer/world.d.ts +35 -0
- package/dist/pointer/world.js +85 -0
- package/dist/quo/door.d.ts +34 -0
- package/dist/quo/door.js +172 -0
- package/dist/quo/index.d.ts +8 -0
- package/dist/quo/index.js +11 -0
- package/dist/quo/invitation.d.ts +7 -0
- package/dist/quo/invitation.js +15 -0
- package/dist/quo/keys.d.ts +40 -0
- package/dist/quo/keys.js +79 -0
- package/dist/quo/payload.d.ts +15 -0
- package/dist/quo/payload.js +55 -0
- package/dist/quo/relations.d.ts +40 -0
- package/dist/quo/relations.js +33 -0
- package/dist/quo/reply.d.ts +13 -0
- package/dist/quo/reply.js +35 -0
- package/dist/quo/seal.d.ts +42 -0
- package/dist/quo/seal.js +78 -0
- package/dist/quo/standing.d.ts +39 -0
- package/dist/quo/standing.js +90 -0
- package/dist/tcp/frame.d.ts +29 -0
- package/dist/tcp/frame.js +74 -0
- package/dist/tcp/index.d.ts +18 -0
- package/dist/tcp/index.js +164 -0
- package/dist/ward/allowance.d.ts +13 -9
- package/dist/ward/allowance.js +28 -67
- package/dist/ward/cells.d.ts +6 -6
- package/dist/ward/cells.js +67 -179
- package/dist/ward/index.d.ts +6 -10
- package/dist/ward/index.js +7 -13
- package/dist/ward/partition.d.ts +44 -60
- package/dist/ward/partition.js +56 -285
- package/dist/ward/pilot.d.ts +8 -0
- package/dist/ward/pilot.js +50 -0
- package/dist/ward/stance.d.ts +27 -20
- package/dist/ward/stance.js +174 -406
- package/dist/ward/ward-being.d.ts +35 -0
- package/dist/ward/ward-being.js +82 -0
- package/dist/ward/ward.d.ts +45 -11
- package/dist/ward/ward.js +239 -357
- package/package.json +24 -31
- package/src/being/being.ts +35 -78
- package/src/being/digest.ts +7 -35
- package/src/being/faculty.ts +73 -0
- package/src/being/index.ts +6 -6
- package/src/being/kind.ts +30 -0
- package/src/being/types.ts +50 -146
- package/src/being/words.ts +31 -0
- package/src/cli/command.ts +188 -0
- package/src/cli/harbor.ts +137 -0
- package/src/cli/main.ts +13 -0
- package/src/contract/index.ts +73 -0
- package/src/crypto/aes.ts +26 -0
- package/src/crypto/bytes.ts +37 -0
- package/src/crypto/ed25519.ts +84 -0
- package/src/crypto/hash.ts +14 -0
- package/src/crypto/index.ts +10 -0
- package/src/crypto/json.ts +241 -0
- package/src/crypto/mlkem.ts +38 -0
- package/src/crypto/subtle.ts +33 -0
- package/src/crypto/x25519.ts +21 -0
- package/src/folder/index.ts +92 -0
- package/src/harbor/catalogue.ts +110 -0
- package/src/harbor/dock.ts +137 -0
- package/src/harbor/harbor.ts +180 -0
- package/src/harbor/index.ts +9 -19
- package/src/harbor/package.ts +131 -0
- package/src/harbor/registry.ts +73 -0
- package/src/harbor/root-line.ts +36 -0
- package/src/harbor/terrain.ts +15 -0
- package/src/index.ts +20 -0
- package/src/pointer/bodies.ts +65 -0
- package/src/pointer/index.ts +4 -0
- package/src/pointer/world.ts +94 -0
- package/src/quo/door.ts +178 -0
- package/src/quo/index.ts +11 -0
- package/src/quo/invitation.ts +15 -0
- package/src/quo/keys.ts +91 -0
- package/src/quo/payload.ts +61 -0
- package/src/quo/relations.ts +62 -0
- package/src/quo/reply.ts +38 -0
- package/src/quo/seal.ts +101 -0
- package/src/quo/standing.ts +111 -0
- package/src/stand/main.ts +20 -0
- package/src/stand/stand.ts +220 -0
- package/src/tcp/frame.ts +78 -0
- package/src/tcp/index.ts +173 -0
- package/src/ward/allowance.ts +37 -75
- package/src/ward/cells.ts +63 -176
- package/src/ward/index.ts +7 -17
- package/src/ward/partition.ts +86 -326
- package/src/ward/pilot.ts +50 -0
- package/src/ward/stance.ts +186 -420
- package/src/ward/ward-being.ts +103 -0
- package/src/ward/ward.ts +256 -359
- package/dist/being/lent.d.ts +0 -32
- package/dist/being/lent.js +0 -72
- package/dist/being/silence.d.ts +0 -12
- package/dist/being/silence.js +0 -41
- package/dist/conformance/assert.d.ts +0 -11
- package/dist/conformance/assert.js +0 -106
- package/dist/conformance/beings.d.ts +0 -199
- package/dist/conformance/beings.js +0 -188
- package/dist/conformance/estate.d.ts +0 -5
- package/dist/conformance/estate.js +0 -388
- package/dist/conformance/index.d.ts +0 -80
- package/dist/conformance/index.js +0 -819
- package/dist/conformance/reach.d.ts +0 -10
- package/dist/conformance/reach.js +0 -72
- package/dist/conformance/store.d.ts +0 -5
- package/dist/conformance/store.js +0 -113
- package/dist/harbor/box.d.ts +0 -121
- package/dist/harbor/box.js +0 -121
- package/dist/harbor/core.d.ts +0 -55
- package/dist/harbor/core.js +0 -662
- package/dist/harbor/dial.d.ts +0 -9
- package/dist/harbor/dial.js +0 -81
- package/dist/harbor/memory.d.ts +0 -29
- package/dist/harbor/memory.js +0 -104
- package/dist/harbor/reach.d.ts +0 -36
- package/dist/harbor/reach.js +0 -199
- package/dist/harbor/store.d.ts +0 -35
- package/dist/harbor/store.js +0 -62
- package/dist/vector/cases.d.ts +0 -42
- package/dist/vector/cases.js +0 -223
- package/dist/vector/index.d.ts +0 -6
- package/dist/vector/index.js +0 -8
- package/dist/vector/stand.d.ts +0 -9
- package/dist/vector/stand.js +0 -77
- package/dist/vector/world.d.ts +0 -144
- package/dist/vector/world.js +0 -209
- package/dist/ward/arithmetic.d.ts +0 -31
- package/dist/ward/arithmetic.js +0 -249
- package/dist/ward/door.d.ts +0 -18
- package/dist/ward/door.js +0 -186
- package/dist/ward/ground.d.ts +0 -29
- package/dist/ward/ground.js +0 -56
- package/dist/ward/heirs.d.ts +0 -13
- package/dist/ward/heirs.js +0 -117
- package/dist/ward/json.d.ts +0 -2
- package/dist/ward/json.js +0 -163
- package/dist/ward/owner.d.ts +0 -13
- package/dist/ward/owner.js +0 -220
- package/dist/ward/seal.d.ts +0 -52
- package/dist/ward/seal.js +0 -150
- package/src/being/lent.ts +0 -72
- package/src/being/silence.ts +0 -46
- package/src/conformance/assert.ts +0 -100
- package/src/conformance/beings.ts +0 -188
- package/src/conformance/estate.ts +0 -412
- package/src/conformance/index.ts +0 -965
- package/src/conformance/reach.ts +0 -83
- package/src/conformance/store.ts +0 -125
- package/src/harbor/box.ts +0 -131
- package/src/harbor/core.ts +0 -699
- package/src/harbor/dial.ts +0 -112
- package/src/harbor/memory.ts +0 -123
- package/src/harbor/reach.ts +0 -221
- package/src/harbor/store.ts +0 -91
- package/src/vector/cases.ts +0 -257
- package/src/vector/index.ts +0 -11
- package/src/vector/stand.ts +0 -76
- package/src/vector/world.ts +0 -232
- package/src/ward/arithmetic.ts +0 -251
- package/src/ward/door.ts +0 -186
- package/src/ward/ground.ts +0 -142
- package/src/ward/heirs.ts +0 -116
- package/src/ward/json.ts +0 -144
- package/src/ward/owner.ts +0 -214
- package/src/ward/seal.ts +0 -178
package/src/quo/reply.ts
ADDED
|
@@ -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
|
+
};
|
package/src/quo/seal.ts
ADDED
|
@@ -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);
|
package/src/tcp/frame.ts
ADDED
|
@@ -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
|
+
};
|