nervur 0.19.0 → 0.19.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.
@@ -20,6 +20,7 @@
20
20
  // A reach that sent the bytes and lost the line afterwards answers nothing
21
21
  // at all, and the ward's own bound ends the ask.
22
22
  import { concat, hex, unhex } from '../ward/arithmetic.js';
23
+ import { readJson } from '../ward/json.js';
23
24
  const never = () => new Promise(() => { }); // sent, and no word since: the ward's bound decides
24
25
  // A request reach to one listener.
25
26
  export function request(url) {
@@ -111,7 +112,9 @@ export class Socket {
111
112
  line.addEventListener('message', (e) => {
112
113
  if (typeof e.data === 'string') {
113
114
  try {
114
- const m = JSON.parse(e.data);
115
+ const m = readJson(e.data);
116
+ if (m === null || typeof m !== 'object')
117
+ return;
115
118
  // Absent is this suite: a side that says nothing says one.
116
119
  if (m.suite !== undefined && m.suite !== SUITE) {
117
120
  this.far = [];
@@ -129,9 +132,14 @@ export class Socket {
129
132
  return;
130
133
  }
131
134
  const buf = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : ArrayBuffer.isView(e.data) ? new Uint8Array(e.data.buffer, e.data.byteOffset, e.data.byteLength) : undefined;
135
+ // A frame that is none of the three is not a frame: an unknown kind, an
136
+ // ask too short to hold a pk, a nothing with bytes after its id. It is
137
+ // dropped, and tells no ask waiting on its id that nothing was delivered.
132
138
  if (!buf || buf.length < 5)
133
139
  return;
134
140
  const kind = buf[0], id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
141
+ if (kind > NONE || (kind === ASK && buf.length < 69) || (kind === NONE && buf.length !== 5))
142
+ return;
135
143
  if (kind === ASK) {
136
144
  const pk = hex(buf.subarray(5, 69));
137
145
  // A deliver that rejects has delivered nothing this side can vouch
@@ -18,6 +18,7 @@
18
18
  // world runs on.
19
19
  import { SUITE } from '../harbor/reach.js';
20
20
  import { hex } from '../ward/arithmetic.js';
21
+ import { readJson } from '../ward/json.js';
21
22
  import { caseNamed, stand } from './cases.js';
22
23
  import { partitionDigest } from './world.js';
23
24
  const CORS = {
@@ -39,7 +40,7 @@ export class Stand {
39
40
  if (method === 'POST' && p === '/stand') {
40
41
  let named;
41
42
  try {
42
- named = JSON.parse(new TextDecoder().decode(body));
43
+ named = readJson(body);
43
44
  }
44
45
  catch {
45
46
  return NONE;
@@ -59,8 +59,10 @@ export function sameBytes(a, b) {
59
59
  diff |= a[at] ^ b[at];
60
60
  return diff === 0;
61
61
  }
62
- // The eight small-order points of Ed25519, in their canonical encoding. A
63
- // public key among them verifies nothing. An encoding whose y coordinate is
62
+ // The eight small-order points of Ed25519, in their canonical encoding, and
63
+ // the two points with x = 0 spelled with the sign bit set, which a decoder
64
+ // that ignores the bit reads as the same point. A public key among them
65
+ // verifies nothing. An encoding whose y coordinate is
64
66
  // not reduced, y at or above the field's prime p, names one of the same
65
67
  // points under another spelling, and a terrain's verify may accept a zero
66
68
  // signature under it; the field has room for the spelling, so it is refused
@@ -74,6 +76,8 @@ const SMALL_ORDER = [
74
76
  'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
75
77
  '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
76
78
  'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
79
+ '0100000000000000000000000000000000000000000000000000000000000080',
80
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
77
81
  ].map(unhex);
78
82
  // y >= p, read little-endian with the sign bit masked off: the top byte is
79
83
  // 0x7f under the mask, every middle byte is 0xff, and the low byte is at
@@ -0,0 +1,2 @@
1
+ export declare function numberFault(literal: string): string | null;
2
+ export declare function readJson(input: Uint8Array | string): unknown;
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The one reader of JSON that crossed an edge. A runtime's own parser is
3
+ // where two kits stop agreeing: it keeps the last of two keys of one name,
4
+ // rounds a whole number no double holds, reads a number past the doubles as
5
+ // infinity, and mends bytes that are not UTF-8 into a replacement character.
6
+ // Each of those is text two kits read two ways, so each is refused here, where
7
+ // it is read, with a throw the caller already turns into what bytes that said
8
+ // nothing are.
9
+ //
10
+ // What a value is beyond its text, a lone surrogate, a key named `__proto__`,
11
+ // a nesting past the bound, is the value rule's, and `cellFault` in cells.ts
12
+ // is its one reader. This file holds the rules that live only in the text.
13
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
14
+ const fail = (at, why) => {
15
+ throw new Error(`JSON at ${at}: ${why}`);
16
+ };
17
+ // A number text, held to the Values chapter. A text naming a whole number
18
+ // stands only if that number is exactly a double, or if the text is how
19
+ // ECMAScript writes the double it reads as. A text past the largest double,
20
+ // or naming a value other than zero that rounds to zero, is refused, and so
21
+ // is minus zero in any spelling.
22
+ export function numberFault(literal) {
23
+ const m = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
24
+ if (!m)
25
+ return 'not a number';
26
+ const [, sign, whole, frac = '', exp = '0'] = m;
27
+ const v = Number(literal);
28
+ if (!Number.isFinite(v))
29
+ return 'past the largest double';
30
+ let digits = (whole + frac).replace(/^0+/, '');
31
+ if (v === 0) {
32
+ if (digits !== '')
33
+ return 'a value other than zero that rounds to zero';
34
+ return sign ? 'minus zero' : null;
35
+ }
36
+ // The exact value is digits times ten to e. A finite double bounds e from
37
+ // above, so the power below is never larger than the double's own width.
38
+ let e = BigInt(exp) - BigInt(frac.length);
39
+ const trimmed = digits.replace(/0+$/, '');
40
+ e += BigInt(digits.length - trimmed.length);
41
+ digits = trimmed;
42
+ if (e < 0n)
43
+ return null; // a fraction: the double nearest it, as every reader takes it
44
+ const exact = BigInt(digits) * 10n ** e;
45
+ if (exact === (v < 0 ? -BigInt(v) : BigInt(v)))
46
+ return null;
47
+ return literal === String(v) ? null : 'a whole number that is not exactly a double';
48
+ }
49
+ // Bytes to a value, or a throw.
50
+ export function readJson(input) {
51
+ const s = typeof input === 'string' ? input : utf8.decode(input);
52
+ let at = 0;
53
+ const space = () => {
54
+ for (;;) {
55
+ const c = s.charCodeAt(at);
56
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d)
57
+ at += 1;
58
+ else
59
+ return;
60
+ }
61
+ };
62
+ const string = () => {
63
+ const start = at;
64
+ at += 1;
65
+ for (;;) {
66
+ const c = s.charCodeAt(at);
67
+ if (Number.isNaN(c))
68
+ fail(start, 'an unterminated string');
69
+ if (c === 0x22)
70
+ break;
71
+ if (c < 0x20)
72
+ fail(at, 'a control character in a string');
73
+ at += c === 0x5c ? 2 : 1;
74
+ }
75
+ at += 1;
76
+ // The escapes are JSON's own, and the runtime reads them the one way.
77
+ return JSON.parse(s.slice(start, at));
78
+ };
79
+ const value = () => {
80
+ space();
81
+ const c = s[at];
82
+ if (c === '{') {
83
+ at += 1;
84
+ const out = {};
85
+ const keys = new Set();
86
+ space();
87
+ if (s[at] === '}') {
88
+ at += 1;
89
+ return out;
90
+ }
91
+ for (;;) {
92
+ space();
93
+ if (s[at] !== '"')
94
+ fail(at, 'a key that is not a string');
95
+ const where = at;
96
+ const k = string();
97
+ if (keys.has(k))
98
+ fail(where, `two keys named ${JSON.stringify(k)}`);
99
+ keys.add(k);
100
+ space();
101
+ if (s[at] !== ':')
102
+ fail(at, 'a key with no colon');
103
+ at += 1;
104
+ // Defined, never assigned: a key named `__proto__` is an own key here
105
+ // as it is in JSON.parse, and the value rule refuses it after.
106
+ Object.defineProperty(out, k, { value: value(), enumerable: true, writable: true, configurable: true });
107
+ space();
108
+ if (s[at] === ',')
109
+ at += 1;
110
+ else if (s[at] === '}') {
111
+ at += 1;
112
+ return out;
113
+ }
114
+ else
115
+ fail(at, 'an object not closed');
116
+ }
117
+ }
118
+ if (c === '[') {
119
+ at += 1;
120
+ const out = [];
121
+ space();
122
+ if (s[at] === ']') {
123
+ at += 1;
124
+ return out;
125
+ }
126
+ for (;;) {
127
+ out.push(value());
128
+ space();
129
+ if (s[at] === ',')
130
+ at += 1;
131
+ else if (s[at] === ']') {
132
+ at += 1;
133
+ return out;
134
+ }
135
+ else
136
+ fail(at, 'a list not closed');
137
+ }
138
+ }
139
+ if (c === '"')
140
+ return string();
141
+ for (const [word, v] of [['true', true], ['false', false], ['null', null]]) {
142
+ if (s.startsWith(word, at)) {
143
+ at += word.length;
144
+ return v;
145
+ }
146
+ }
147
+ const m = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
148
+ m.lastIndex = at;
149
+ const hit = m.exec(s);
150
+ if (!hit)
151
+ return fail(at, 'no value');
152
+ const why = numberFault(hit[0]);
153
+ if (why !== null)
154
+ fail(at, `${hit[0]} is ${why}`);
155
+ at += hit[0].length;
156
+ return Number(hit[0]);
157
+ };
158
+ const out = value();
159
+ space();
160
+ if (at !== s.length)
161
+ fail(at, 'text after the value');
162
+ return out;
163
+ }
package/dist/ward/seal.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { isDoorWord } from '../being/silence.js';
2
2
  import { KEY, SIGNATURE, box, concat, derive, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
3
3
  import { cellFault } from './cells.js';
4
+ import { readJson } from './json.js';
4
5
  const utf8 = new TextEncoder();
5
- const text = new TextDecoder();
6
6
  // The two labels a ward's keys are derived under. They are here and not in
7
7
  // arithmetic.ts because a label is a decision and that file is arithmetic.
8
8
  // `quo-seal` is taken: it is the label the message cipher derives its key and
@@ -70,7 +70,7 @@ export async function openAsk(bytes, padlockSecret) {
70
70
  return null;
71
71
  const body = inside.subarray(0, inside.length - SIGNATURE);
72
72
  const signature = inside.subarray(inside.length - SIGNATURE);
73
- const payload = JSON.parse(text.decode(body));
73
+ const payload = readJson(body);
74
74
  // The heir, or null for the public being. Absent is not null: a payload
75
75
  // that never named one is malformed, like any other missing field.
76
76
  const to = payload.to;
@@ -112,7 +112,9 @@ export async function sealReply(reply, ephemeralPk, wardSign, seed) {
112
112
  // lied, or it is not a reply. Every ward on the wire is somebody else's code,
113
113
  // so the shape is checked here and nowhere later: a reply is `{ silence }`,
114
114
  // `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
115
- // object present and seen a digest or null.
115
+ // object that is a value and seen a digest or null. A field beside a shape's
116
+ // own makes it none of the three, since a kit that dropped the field would
117
+ // hand her an answer where this one hands her silence.
116
118
  // Anything else is bytes that said nothing, and her ward never hands her a
117
119
  // throw or an absent value for what a stranger wrote.
118
120
  export async function openReply(bytes, ephemeralSecret, signPk) {
@@ -125,18 +127,21 @@ export async function openReply(bytes, ephemeralSecret, signPk) {
125
127
  const body = inside.subarray(0, inside.length - SIGNATURE);
126
128
  if (!(await verify(body, inside.subarray(inside.length - SIGNATURE), signPk)))
127
129
  return null;
128
- const reply = JSON.parse(text.decode(body));
130
+ const reply = readJson(body);
129
131
  if (reply === null || typeof reply !== 'object' || Array.isArray(reply))
130
132
  return null;
131
133
  const r = reply;
132
- if (r.silence === true)
133
- return { silence: true };
134
- if (Object.hasOwn(r, 'quo'))
134
+ const keys = Object.keys(r).sort().join(',');
135
+ if (keys === 'silence')
136
+ return r.silence === true ? { silence: true } : null;
137
+ if (keys === 'quo')
135
138
  return isDoorWord(r.quo) ? { quo: r.quo } : null;
136
- if (!Object.hasOwn(r, 'object') || r.object === undefined)
139
+ if (keys !== 'object,seen')
137
140
  return null;
138
141
  if (r.seen !== null && !isHex(r.seen))
139
142
  return null;
143
+ if (cellFault(r.object, 'object') !== null)
144
+ return null; // values in both directions: nested past the bound is none of the shapes
140
145
  return { object: r.object, seen: r.seen };
141
146
  }
142
147
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is. Being, Ward, Harbor.",
5
5
  "keywords": [
6
6
  "quo",
@@ -20,6 +20,7 @@
20
20
  // A reach that sent the bytes and lost the line afterwards answers nothing
21
21
  // at all, and the ward's own bound ends the ask.
22
22
  import { concat, hex, unhex } from '../ward/arithmetic.ts';
23
+ import { readJson } from '../ward/json.ts';
23
24
 
24
25
  export type Carry = (pk: string, bytes: Uint8Array) => Promise<Uint8Array | undefined>;
25
26
  export type Reach = { carry: Carry; close(): void };
@@ -136,7 +137,8 @@ export class Socket implements Reach {
136
137
  line.addEventListener('message', (e) => {
137
138
  if (typeof e.data === 'string') {
138
139
  try {
139
- const m = JSON.parse(e.data) as { announce?: unknown; suite?: unknown };
140
+ const m = readJson(e.data) as { announce?: unknown; suite?: unknown };
141
+ if (m === null || typeof m !== 'object') return;
140
142
  // Absent is this suite: a side that says nothing says one.
141
143
  if (m.suite !== undefined && m.suite !== SUITE) {
142
144
  this.far = [];
@@ -153,9 +155,13 @@ export class Socket implements Reach {
153
155
  return;
154
156
  }
155
157
  const buf = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : ArrayBuffer.isView(e.data) ? new Uint8Array(e.data.buffer, e.data.byteOffset, e.data.byteLength) : undefined;
158
+ // A frame that is none of the three is not a frame: an unknown kind, an
159
+ // ask too short to hold a pk, a nothing with bytes after its id. It is
160
+ // dropped, and tells no ask waiting on its id that nothing was delivered.
156
161
  if (!buf || buf.length < 5) return;
157
162
  const kind = buf[0],
158
163
  id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
164
+ if (kind > NONE || (kind === ASK && buf.length < 69) || (kind === NONE && buf.length !== 5)) return;
159
165
  if (kind === ASK) {
160
166
  const pk = hex(buf.subarray(5, 69));
161
167
  // A deliver that rejects has delivered nothing this side can vouch
@@ -18,6 +18,7 @@
18
18
  // world runs on.
19
19
  import { SUITE } from '../harbor/reach.ts';
20
20
  import { hex } from '../ward/arithmetic.ts';
21
+ import { readJson } from '../ward/json.ts';
21
22
  import { caseNamed, stand } from './cases.ts';
22
23
  import type { World } from './world.ts';
23
24
  import { partitionDigest } from './world.ts';
@@ -44,7 +45,7 @@ export class Stand {
44
45
  if (method === 'POST' && p === '/stand') {
45
46
  let named: { case?: unknown; name?: unknown };
46
47
  try {
47
- named = JSON.parse(new TextDecoder().decode(body)) as typeof named;
48
+ named = readJson(body) as typeof named;
48
49
  } catch {
49
50
  return NONE;
50
51
  }
@@ -58,8 +58,10 @@ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
58
58
  return diff === 0;
59
59
  }
60
60
 
61
- // The eight small-order points of Ed25519, in their canonical encoding. A
62
- // public key among them verifies nothing. An encoding whose y coordinate is
61
+ // The eight small-order points of Ed25519, in their canonical encoding, and
62
+ // the two points with x = 0 spelled with the sign bit set, which a decoder
63
+ // that ignores the bit reads as the same point. A public key among them
64
+ // verifies nothing. An encoding whose y coordinate is
63
65
  // not reduced, y at or above the field's prime p, names one of the same
64
66
  // points under another spelling, and a terrain's verify may accept a zero
65
67
  // signature under it; the field has room for the spelling, so it is refused
@@ -73,6 +75,8 @@ const SMALL_ORDER = [
73
75
  'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
74
76
  '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
75
77
  'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
78
+ '0100000000000000000000000000000000000000000000000000000000000080',
79
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
76
80
  ].map(unhex);
77
81
  // y >= p, read little-endian with the sign bit masked off: the top byte is
78
82
  // 0x7f under the mask, every middle byte is 0xff, and the low byte is at
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The one reader of JSON that crossed an edge. A runtime's own parser is
3
+ // where two kits stop agreeing: it keeps the last of two keys of one name,
4
+ // rounds a whole number no double holds, reads a number past the doubles as
5
+ // infinity, and mends bytes that are not UTF-8 into a replacement character.
6
+ // Each of those is text two kits read two ways, so each is refused here, where
7
+ // it is read, with a throw the caller already turns into what bytes that said
8
+ // nothing are.
9
+ //
10
+ // What a value is beyond its text, a lone surrogate, a key named `__proto__`,
11
+ // a nesting past the bound, is the value rule's, and `cellFault` in cells.ts
12
+ // is its one reader. This file holds the rules that live only in the text.
13
+
14
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
15
+
16
+ const fail = (at: number, why: string): never => {
17
+ throw new Error(`JSON at ${at}: ${why}`);
18
+ };
19
+
20
+ // A number text, held to the Values chapter. A text naming a whole number
21
+ // stands only if that number is exactly a double, or if the text is how
22
+ // ECMAScript writes the double it reads as. A text past the largest double,
23
+ // or naming a value other than zero that rounds to zero, is refused, and so
24
+ // is minus zero in any spelling.
25
+ export function numberFault(literal: string): string | null {
26
+ const m = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
27
+ if (!m) return 'not a number';
28
+ const [, sign, whole, frac = '', exp = '0'] = m;
29
+ const v = Number(literal);
30
+ if (!Number.isFinite(v)) return 'past the largest double';
31
+ let digits = (whole + frac).replace(/^0+/, '');
32
+ if (v === 0) {
33
+ if (digits !== '') return 'a value other than zero that rounds to zero';
34
+ return sign ? 'minus zero' : null;
35
+ }
36
+ // The exact value is digits times ten to e. A finite double bounds e from
37
+ // above, so the power below is never larger than the double's own width.
38
+ let e = BigInt(exp) - BigInt(frac.length);
39
+ const trimmed = digits.replace(/0+$/, '');
40
+ e += BigInt(digits.length - trimmed.length);
41
+ digits = trimmed;
42
+ if (e < 0n) return null; // a fraction: the double nearest it, as every reader takes it
43
+ const exact = BigInt(digits) * 10n ** e;
44
+ if (exact === (v < 0 ? -BigInt(v) : BigInt(v))) return null;
45
+ return literal === String(v) ? null : 'a whole number that is not exactly a double';
46
+ }
47
+
48
+ // Bytes to a value, or a throw.
49
+ export function readJson(input: Uint8Array | string): unknown {
50
+ const s = typeof input === 'string' ? input : utf8.decode(input);
51
+ let at = 0;
52
+ const space = () => {
53
+ for (;;) {
54
+ const c = s.charCodeAt(at);
55
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) at += 1;
56
+ else return;
57
+ }
58
+ };
59
+ const string = (): string => {
60
+ const start = at;
61
+ at += 1;
62
+ for (;;) {
63
+ const c = s.charCodeAt(at);
64
+ if (Number.isNaN(c)) fail(start, 'an unterminated string');
65
+ if (c === 0x22) break;
66
+ if (c < 0x20) fail(at, 'a control character in a string');
67
+ at += c === 0x5c ? 2 : 1;
68
+ }
69
+ at += 1;
70
+ // The escapes are JSON's own, and the runtime reads them the one way.
71
+ return JSON.parse(s.slice(start, at)) as string;
72
+ };
73
+ const value = (): unknown => {
74
+ space();
75
+ const c = s[at];
76
+ if (c === '{') {
77
+ at += 1;
78
+ const out: Record<string, unknown> = {};
79
+ const keys = new Set<string>();
80
+ space();
81
+ if (s[at] === '}') {
82
+ at += 1;
83
+ return out;
84
+ }
85
+ for (;;) {
86
+ space();
87
+ if (s[at] !== '"') fail(at, 'a key that is not a string');
88
+ const where = at;
89
+ const k = string();
90
+ if (keys.has(k)) fail(where, `two keys named ${JSON.stringify(k)}`);
91
+ keys.add(k);
92
+ space();
93
+ if (s[at] !== ':') fail(at, 'a key with no colon');
94
+ at += 1;
95
+ // Defined, never assigned: a key named `__proto__` is an own key here
96
+ // as it is in JSON.parse, and the value rule refuses it after.
97
+ Object.defineProperty(out, k, { value: value(), enumerable: true, writable: true, configurable: true });
98
+ space();
99
+ if (s[at] === ',') at += 1;
100
+ else if (s[at] === '}') {
101
+ at += 1;
102
+ return out;
103
+ } else fail(at, 'an object not closed');
104
+ }
105
+ }
106
+ if (c === '[') {
107
+ at += 1;
108
+ const out: unknown[] = [];
109
+ space();
110
+ if (s[at] === ']') {
111
+ at += 1;
112
+ return out;
113
+ }
114
+ for (;;) {
115
+ out.push(value());
116
+ space();
117
+ if (s[at] === ',') at += 1;
118
+ else if (s[at] === ']') {
119
+ at += 1;
120
+ return out;
121
+ } else fail(at, 'a list not closed');
122
+ }
123
+ }
124
+ if (c === '"') return string();
125
+ for (const [word, v] of [['true', true], ['false', false], ['null', null]] as const) {
126
+ if (s.startsWith(word, at)) {
127
+ at += word.length;
128
+ return v;
129
+ }
130
+ }
131
+ const m = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
132
+ m.lastIndex = at;
133
+ const hit = m.exec(s);
134
+ if (!hit) return fail(at, 'no value');
135
+ const why = numberFault(hit[0]);
136
+ if (why !== null) fail(at, `${hit[0]} is ${why}`);
137
+ at += hit[0].length;
138
+ return Number(hit[0]);
139
+ };
140
+ const out = value();
141
+ space();
142
+ if (at !== s.length) fail(at, 'text after the value');
143
+ return out;
144
+ }
package/src/ward/seal.ts CHANGED
@@ -23,9 +23,9 @@ import type { DoorWord, Json, JsonObject } from '../being/types.ts';
23
23
  import { isDoorWord } from '../being/silence.ts';
24
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
+ import { readJson } from './json.ts';
26
27
 
27
28
  const utf8 = new TextEncoder();
28
- const text = new TextDecoder();
29
29
 
30
30
  export type WardKey = { sign: Uint8Array; padlock: Uint8Array; signPk: Uint8Array; padlockPk: Uint8Array; pk: string };
31
31
 
@@ -115,7 +115,7 @@ export async function openAsk(bytes: Uint8Array, padlockSecret: Uint8Array): Pro
115
115
  if (inside.length <= SIGNATURE) return null;
116
116
  const body = inside.subarray(0, inside.length - SIGNATURE);
117
117
  const signature = inside.subarray(inside.length - SIGNATURE);
118
- const payload = JSON.parse(text.decode(body)) as Sealed;
118
+ const payload = readJson(body) as Sealed;
119
119
  // The heir, or null for the public being. Absent is not null: a payload
120
120
  // that never named one is malformed, like any other missing field.
121
121
  const to = payload.to;
@@ -150,7 +150,9 @@ export async function sealReply(reply: ReplyPayload, ephemeralPk: Uint8Array, wa
150
150
  // lied, or it is not a reply. Every ward on the wire is somebody else's code,
151
151
  // so the shape is checked here and nowhere later: a reply is `{ silence }`,
152
152
  // `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
153
- // object present and seen a digest or null.
153
+ // object that is a value and seen a digest or null. A field beside a shape's
154
+ // own makes it none of the three, since a kit that dropped the field would
155
+ // hand her an answer where this one hands her silence.
154
156
  // Anything else is bytes that said nothing, and her ward never hands her a
155
157
  // throw or an absent value for what a stranger wrote.
156
158
  export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, signPk: Uint8Array): Promise<ReplyPayload | null> {
@@ -160,13 +162,15 @@ export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, sig
160
162
  if (inside.length <= SIGNATURE) return null;
161
163
  const body = inside.subarray(0, inside.length - SIGNATURE);
162
164
  if (!(await verify(body, inside.subarray(inside.length - SIGNATURE), signPk))) return null;
163
- const reply = JSON.parse(text.decode(body)) as unknown;
165
+ const reply = readJson(body);
164
166
  if (reply === null || typeof reply !== 'object' || Array.isArray(reply)) return null;
165
167
  const r = reply as Record<string, unknown>;
166
- if (r.silence === true) return { silence: true };
167
- if (Object.hasOwn(r, 'quo')) return isDoorWord(r.quo) ? { quo: r.quo } : null;
168
- if (!Object.hasOwn(r, 'object') || r.object === undefined) return null;
168
+ const keys = Object.keys(r).sort().join(',');
169
+ if (keys === 'silence') return r.silence === true ? { silence: true } : null;
170
+ if (keys === 'quo') return isDoorWord(r.quo) ? { quo: r.quo } : null;
171
+ if (keys !== 'object,seen') return null;
169
172
  if (r.seen !== null && !isHex(r.seen)) return null;
173
+ if (cellFault(r.object, 'object') !== null) return null; // values in both directions: nested past the bound is none of the shapes
170
174
  return { object: r.object as Json, seen: r.seen };
171
175
  } catch {
172
176
  return null;