wire-mesh-core 4.0.5 → 4.0.7
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/dist/adapters/node-identity.cjs +38 -0
- package/dist/adapters/node-identity.mjs +38 -0
- package/dist/domain/peer-advert.cjs +34 -2
- package/dist/domain/peer-advert.d.cts +8 -0
- package/dist/domain/peer-advert.d.mts +8 -0
- package/dist/domain/peer-advert.mjs +34 -3
- package/dist/domain/room-rekey.cjs +17 -6
- package/dist/domain/room-rekey.d.cts +4 -2
- package/dist/domain/room-rekey.d.mts +4 -2
- package/dist/domain/room-rekey.mjs +17 -7
- package/dist/domain/verdict-cache.cjs +80 -0
- package/dist/domain/verdict-cache.d.cts +25 -0
- package/dist/domain/verdict-cache.d.mts +25 -0
- package/dist/domain/verdict-cache.mjs +77 -0
- package/package.json +5 -1
|
@@ -3,6 +3,16 @@ let node_crypto = require("node:crypto");
|
|
|
3
3
|
//#region src/adapters/node-identity.ts
|
|
4
4
|
const ES256 = -7;
|
|
5
5
|
const EDDSA = -8;
|
|
6
|
+
/** An ES256 public key is an uncompressed SEC1 P-256 point: a 0x04 prefix, then the 32-byte X and Y coordinates. */
|
|
7
|
+
const ES256_PUBLIC_KEY_BYTES = 65;
|
|
8
|
+
/** An Ed25519 public key is its 32-byte compressed point. */
|
|
9
|
+
const EDDSA_PUBLIC_KEY_BYTES = 32;
|
|
10
|
+
/** The exact size of a public key for `alg`, throwing for an algorithm no verifier here implements. */
|
|
11
|
+
function publicKeySize(alg) {
|
|
12
|
+
if (alg === ES256) return ES256_PUBLIC_KEY_BYTES;
|
|
13
|
+
if (alg === EDDSA) return EDDSA_PUBLIC_KEY_BYTES;
|
|
14
|
+
throw new Error(`unsupported identity-key alg ${String(alg)}`);
|
|
15
|
+
}
|
|
6
16
|
function algParams(alg) {
|
|
7
17
|
if (alg === ES256) return {
|
|
8
18
|
name: "ECDSA",
|
|
@@ -15,7 +25,35 @@ function algParams(alg) {
|
|
|
15
25
|
function toBufferSource(bytes) {
|
|
16
26
|
return Uint8Array.from(bytes);
|
|
17
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* How many imported verification keys are kept. An imported key is a handle to a few dozen bytes of key material (only a key that imported successfully is kept, and a valid ES256 or Ed25519 public key is fixed-size), so the bound is on count: well above the devices of any plausible local mesh, so a mesh gossiping every few seconds finds its keys already imported rather than re-importing one per advert.
|
|
30
|
+
*/
|
|
31
|
+
const IMPORTED_KEY_CAPACITY = 1024;
|
|
32
|
+
/** Least recently used first, by Map insertion order. Holds the pending promise so concurrent verifications of one device import its key once. */
|
|
33
|
+
const importedKeys = /* @__PURE__ */ new Map();
|
|
34
|
+
/** Equal for two identity-keys exactly when algorithm and key bytes are equal. */
|
|
35
|
+
function importedKeyId(key) {
|
|
36
|
+
return `${String(key.alg)}:${Buffer.from(key["public-key"]).toString("base64")}`;
|
|
37
|
+
}
|
|
18
38
|
async function importPublicKey(key) {
|
|
39
|
+
if (key["public-key"].length !== publicKeySize(key.alg)) throw new Error(`identity-key public-key is ${String(key["public-key"].length)} bytes, not the ${String(publicKeySize(key.alg))} an alg ${String(key.alg)} key has`);
|
|
40
|
+
const id = importedKeyId(key);
|
|
41
|
+
const held = importedKeys.get(id);
|
|
42
|
+
if (held !== void 0) {
|
|
43
|
+
importedKeys.delete(id);
|
|
44
|
+
importedKeys.set(id, held);
|
|
45
|
+
return held;
|
|
46
|
+
}
|
|
47
|
+
const pending = importPublicKeyUncached(key);
|
|
48
|
+
importedKeys.set(id, pending);
|
|
49
|
+
pending.catch(() => importedKeys.delete(id));
|
|
50
|
+
for (const oldest of importedKeys.keys()) {
|
|
51
|
+
if (importedKeys.size <= IMPORTED_KEY_CAPACITY) break;
|
|
52
|
+
importedKeys.delete(oldest);
|
|
53
|
+
}
|
|
54
|
+
return pending;
|
|
55
|
+
}
|
|
56
|
+
async function importPublicKeyUncached(key) {
|
|
19
57
|
if (key.alg === ES256) return node_crypto.webcrypto.subtle.importKey("raw", toBufferSource(key["public-key"]), {
|
|
20
58
|
name: "ECDSA",
|
|
21
59
|
namedCurve: "P-256"
|
|
@@ -2,6 +2,16 @@ import { webcrypto } from "node:crypto";
|
|
|
2
2
|
//#region src/adapters/node-identity.ts
|
|
3
3
|
const ES256 = -7;
|
|
4
4
|
const EDDSA = -8;
|
|
5
|
+
/** An ES256 public key is an uncompressed SEC1 P-256 point: a 0x04 prefix, then the 32-byte X and Y coordinates. */
|
|
6
|
+
const ES256_PUBLIC_KEY_BYTES = 65;
|
|
7
|
+
/** An Ed25519 public key is its 32-byte compressed point. */
|
|
8
|
+
const EDDSA_PUBLIC_KEY_BYTES = 32;
|
|
9
|
+
/** The exact size of a public key for `alg`, throwing for an algorithm no verifier here implements. */
|
|
10
|
+
function publicKeySize(alg) {
|
|
11
|
+
if (alg === ES256) return ES256_PUBLIC_KEY_BYTES;
|
|
12
|
+
if (alg === EDDSA) return EDDSA_PUBLIC_KEY_BYTES;
|
|
13
|
+
throw new Error(`unsupported identity-key alg ${String(alg)}`);
|
|
14
|
+
}
|
|
5
15
|
function algParams(alg) {
|
|
6
16
|
if (alg === ES256) return {
|
|
7
17
|
name: "ECDSA",
|
|
@@ -14,7 +24,35 @@ function algParams(alg) {
|
|
|
14
24
|
function toBufferSource(bytes) {
|
|
15
25
|
return Uint8Array.from(bytes);
|
|
16
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* How many imported verification keys are kept. An imported key is a handle to a few dozen bytes of key material (only a key that imported successfully is kept, and a valid ES256 or Ed25519 public key is fixed-size), so the bound is on count: well above the devices of any plausible local mesh, so a mesh gossiping every few seconds finds its keys already imported rather than re-importing one per advert.
|
|
29
|
+
*/
|
|
30
|
+
const IMPORTED_KEY_CAPACITY = 1024;
|
|
31
|
+
/** Least recently used first, by Map insertion order. Holds the pending promise so concurrent verifications of one device import its key once. */
|
|
32
|
+
const importedKeys = /* @__PURE__ */ new Map();
|
|
33
|
+
/** Equal for two identity-keys exactly when algorithm and key bytes are equal. */
|
|
34
|
+
function importedKeyId(key) {
|
|
35
|
+
return `${String(key.alg)}:${Buffer.from(key["public-key"]).toString("base64")}`;
|
|
36
|
+
}
|
|
17
37
|
async function importPublicKey(key) {
|
|
38
|
+
if (key["public-key"].length !== publicKeySize(key.alg)) throw new Error(`identity-key public-key is ${String(key["public-key"].length)} bytes, not the ${String(publicKeySize(key.alg))} an alg ${String(key.alg)} key has`);
|
|
39
|
+
const id = importedKeyId(key);
|
|
40
|
+
const held = importedKeys.get(id);
|
|
41
|
+
if (held !== void 0) {
|
|
42
|
+
importedKeys.delete(id);
|
|
43
|
+
importedKeys.set(id, held);
|
|
44
|
+
return held;
|
|
45
|
+
}
|
|
46
|
+
const pending = importPublicKeyUncached(key);
|
|
47
|
+
importedKeys.set(id, pending);
|
|
48
|
+
pending.catch(() => importedKeys.delete(id));
|
|
49
|
+
for (const oldest of importedKeys.keys()) {
|
|
50
|
+
if (importedKeys.size <= IMPORTED_KEY_CAPACITY) break;
|
|
51
|
+
importedKeys.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
async function importPublicKeyUncached(key) {
|
|
18
56
|
if (key.alg === ES256) return webcrypto.subtle.importKey("raw", toBufferSource(key["public-key"]), {
|
|
19
57
|
name: "ECDSA",
|
|
20
58
|
namedCurve: "P-256"
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_domain_token_scope = require("./token-scope.cjs");
|
|
3
|
+
const require_domain_verdict_cache = require("./verdict-cache.cjs");
|
|
3
4
|
let cbor2 = require("cbor2");
|
|
4
5
|
//#region src/domain/peer-advert.ts
|
|
6
|
+
const BYTES_PER_KIBIBYTE = 1024;
|
|
7
|
+
/**
|
|
8
|
+
* The most a verifier's verdict memo may charge across all its entries, in bytes of key.
|
|
9
|
+
*
|
|
10
|
+
* A key is a signature plus the signed bytes, so an honest advert (a device id, a few addresses, a handful of gossip extensions) costs on the order of a kilobyte and this budget holds the current advert of every device a large local mesh gossips, plus the several generations a hub's catch-up frames can still replay. It is a budget in bytes rather than a count of entries because the signed bytes are attacker-sized up to the frame limit: a count would let a hostile peer pin that many frames in memory, where a byte budget makes the worst case this figure and refuses to keep any single advert larger than it.
|
|
11
|
+
*/
|
|
12
|
+
const PEER_ADVERT_VERDICT_BUDGET_BYTES = 8 * (BYTES_PER_KIBIBYTE * BYTES_PER_KIBIBYTE);
|
|
13
|
+
/**
|
|
14
|
+
* One memo per verifier object, held weakly so it lives exactly as long as the identity it serves. A verdict is a pure function of the advert's own bytes (the key and the signature both travel inside it), so every session and hub sharing one verifier shares the answers, and a fresh verifier (a test's fake, a second node identity) starts cold rather than inheriting another's.
|
|
15
|
+
*/
|
|
16
|
+
const verdictCaches = /* @__PURE__ */ new WeakMap();
|
|
17
|
+
function verdictCacheFor(identity) {
|
|
18
|
+
const existing = verdictCaches.get(identity);
|
|
19
|
+
if (existing !== void 0) return existing;
|
|
20
|
+
const created = require_domain_verdict_cache.createVerdictCache(PEER_ADVERT_VERDICT_BUDGET_BYTES);
|
|
21
|
+
verdictCaches.set(identity, created);
|
|
22
|
+
return created;
|
|
23
|
+
}
|
|
5
24
|
/** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
|
|
6
25
|
const PEER_ADVERT_SIGNATURE_KEY = "signature";
|
|
7
26
|
/** peer-advert's own self-certifying key field. Covered by the signature like every other entry, so an advert cannot be re-keyed after signing. */
|
|
@@ -41,21 +60,34 @@ async function signPeerAdvert(identity, advert) {
|
|
|
41
60
|
* Both validity checks spec/transport.cddl states for a gossiped advert, in the order a verifier must apply them: the embedded identity-key self-certifies (sha256 of its public-key equals the advert's own `device`), and the signature verifies under that same key over peerAdvertSigningInput's bytes.
|
|
42
61
|
*
|
|
43
62
|
* Returns a verdict for every input, including a hostile one, and never throws. An advert's `alg` and `public-key` are attacker-chosen (identity-key constrains them to an int and a bstr, nothing more), so importing the key can legitimately fail for an algorithm no verifier implements or for key bytes of the wrong length. An unverifiable advert is a refused advert, exactly as if its signature had simply been wrong, which is why the import failure is turned into `false` here rather than propagated to a caller iterating a frame's other adverts.
|
|
63
|
+
*
|
|
64
|
+
* A hub re-broadcasts every advert it accepts to every other client and replays the whole directory to each sender, so one advert reaches a receiver several times and a mesh of N devices checks each advert on the order of N times per receiver. The two checks are pure functions of the advert's bytes, so the verdict for a given signature over given signed bytes is remembered (see PEER_ADVERT_VERDICT_BUDGET_BYTES) and a repeat costs one encoding rather than two digests and a public-key import and verification. Only a verdict the checks reached is remembered, a refusal included; a check that threw is not, because the same bytes might verify on a later attempt.
|
|
44
65
|
*/
|
|
45
66
|
async function verifyPeerAdvert(identity, advert) {
|
|
67
|
+
let signedBytes;
|
|
68
|
+
try {
|
|
69
|
+
signedBytes = peerAdvertSigningInput(advert);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return verdictCacheFor(identity).verdict([advert[PEER_ADVERT_SIGNATURE_KEY], signedBytes], async () => checkPeerAdvert(identity, advert, signedBytes));
|
|
74
|
+
}
|
|
75
|
+
/** The uncached checks: true or false when they reached a verdict, undefined when verifying threw. */
|
|
76
|
+
async function checkPeerAdvert(identity, advert, signedBytes) {
|
|
46
77
|
const key = advert[PEER_ADVERT_IDENTITY_KEY_KEY];
|
|
47
78
|
try {
|
|
48
79
|
const derived = await identity.deriveDeviceId(key["public-key"]);
|
|
49
80
|
if (!require_domain_token_scope.bytesEqual(derived, advert.device)) return false;
|
|
50
|
-
return await identity.verify(key,
|
|
81
|
+
return await identity.verify(key, signedBytes, advert[PEER_ADVERT_SIGNATURE_KEY]);
|
|
51
82
|
} catch {
|
|
52
|
-
return
|
|
83
|
+
return;
|
|
53
84
|
}
|
|
54
85
|
}
|
|
55
86
|
//#endregion
|
|
56
87
|
exports.PEER_ADVERT_IDENTITY_KEY_KEY = PEER_ADVERT_IDENTITY_KEY_KEY;
|
|
57
88
|
exports.PEER_ADVERT_SIGNATURE_KEY = PEER_ADVERT_SIGNATURE_KEY;
|
|
58
89
|
exports.PEER_ADVERT_SIGNING_CONTEXT = PEER_ADVERT_SIGNING_CONTEXT;
|
|
90
|
+
exports.PEER_ADVERT_VERDICT_BUDGET_BYTES = PEER_ADVERT_VERDICT_BUDGET_BYTES;
|
|
59
91
|
exports.peerAdvertSigningInput = peerAdvertSigningInput;
|
|
60
92
|
exports.signPeerAdvert = signPeerAdvert;
|
|
61
93
|
exports.verifyPeerAdvert = verifyPeerAdvert;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-DzmpxxUr.cjs";
|
|
2
2
|
import { t as IdentityPort } from "../identity-B6nhlSOT.cjs";
|
|
3
3
|
//#region src/domain/peer-advert.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The most a verifier's verdict memo may charge across all its entries, in bytes of key.
|
|
6
|
+
*
|
|
7
|
+
* A key is a signature plus the signed bytes, so an honest advert (a device id, a few addresses, a handful of gossip extensions) costs on the order of a kilobyte and this budget holds the current advert of every device a large local mesh gossips, plus the several generations a hub's catch-up frames can still replay. It is a budget in bytes rather than a count of entries because the signed bytes are attacker-sized up to the frame limit: a count would let a hostile peer pin that many frames in memory, where a byte budget makes the worst case this figure and refuses to keep any single advert larger than it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PEER_ADVERT_VERDICT_BUDGET_BYTES: number;
|
|
4
10
|
/** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
|
|
5
11
|
export declare const PEER_ADVERT_SIGNATURE_KEY = "signature";
|
|
6
12
|
/** peer-advert's own self-certifying key field. Covered by the signature like every other entry, so an advert cannot be re-keyed after signing. */
|
|
@@ -40,6 +46,8 @@ export declare function signPeerAdvert(identity: Readonly<PeerAdvertSigner>, adv
|
|
|
40
46
|
* Both validity checks spec/transport.cddl states for a gossiped advert, in the order a verifier must apply them: the embedded identity-key self-certifies (sha256 of its public-key equals the advert's own `device`), and the signature verifies under that same key over peerAdvertSigningInput's bytes.
|
|
41
47
|
*
|
|
42
48
|
* Returns a verdict for every input, including a hostile one, and never throws. An advert's `alg` and `public-key` are attacker-chosen (identity-key constrains them to an int and a bstr, nothing more), so importing the key can legitimately fail for an algorithm no verifier implements or for key bytes of the wrong length. An unverifiable advert is a refused advert, exactly as if its signature had simply been wrong, which is why the import failure is turned into `false` here rather than propagated to a caller iterating a frame's other adverts.
|
|
49
|
+
*
|
|
50
|
+
* A hub re-broadcasts every advert it accepts to every other client and replays the whole directory to each sender, so one advert reaches a receiver several times and a mesh of N devices checks each advert on the order of N times per receiver. The two checks are pure functions of the advert's bytes, so the verdict for a given signature over given signed bytes is remembered (see PEER_ADVERT_VERDICT_BUDGET_BYTES) and a repeat costs one encoding rather than two digests and a public-key import and verification. Only a verdict the checks reached is remembered, a refusal included; a check that threw is not, because the same bytes might verify on a later attempt.
|
|
43
51
|
*/
|
|
44
52
|
export declare function verifyPeerAdvert(identity: Readonly<PeerAdvertVerifier>, advert: Readonly<PeerAdvert>): Promise<boolean>;
|
|
45
53
|
//#endregion
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-DzmpxxUr.mjs";
|
|
2
2
|
import { t as IdentityPort } from "../identity-EV-HHv_D.mjs";
|
|
3
3
|
//#region src/domain/peer-advert.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The most a verifier's verdict memo may charge across all its entries, in bytes of key.
|
|
6
|
+
*
|
|
7
|
+
* A key is a signature plus the signed bytes, so an honest advert (a device id, a few addresses, a handful of gossip extensions) costs on the order of a kilobyte and this budget holds the current advert of every device a large local mesh gossips, plus the several generations a hub's catch-up frames can still replay. It is a budget in bytes rather than a count of entries because the signed bytes are attacker-sized up to the frame limit: a count would let a hostile peer pin that many frames in memory, where a byte budget makes the worst case this figure and refuses to keep any single advert larger than it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PEER_ADVERT_VERDICT_BUDGET_BYTES: number;
|
|
4
10
|
/** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
|
|
5
11
|
export declare const PEER_ADVERT_SIGNATURE_KEY = "signature";
|
|
6
12
|
/** peer-advert's own self-certifying key field. Covered by the signature like every other entry, so an advert cannot be re-keyed after signing. */
|
|
@@ -40,6 +46,8 @@ export declare function signPeerAdvert(identity: Readonly<PeerAdvertSigner>, adv
|
|
|
40
46
|
* Both validity checks spec/transport.cddl states for a gossiped advert, in the order a verifier must apply them: the embedded identity-key self-certifies (sha256 of its public-key equals the advert's own `device`), and the signature verifies under that same key over peerAdvertSigningInput's bytes.
|
|
41
47
|
*
|
|
42
48
|
* Returns a verdict for every input, including a hostile one, and never throws. An advert's `alg` and `public-key` are attacker-chosen (identity-key constrains them to an int and a bstr, nothing more), so importing the key can legitimately fail for an algorithm no verifier implements or for key bytes of the wrong length. An unverifiable advert is a refused advert, exactly as if its signature had simply been wrong, which is why the import failure is turned into `false` here rather than propagated to a caller iterating a frame's other adverts.
|
|
49
|
+
*
|
|
50
|
+
* A hub re-broadcasts every advert it accepts to every other client and replays the whole directory to each sender, so one advert reaches a receiver several times and a mesh of N devices checks each advert on the order of N times per receiver. The two checks are pure functions of the advert's bytes, so the verdict for a given signature over given signed bytes is remembered (see PEER_ADVERT_VERDICT_BUDGET_BYTES) and a repeat costs one encoding rather than two digests and a public-key import and verification. Only a verdict the checks reached is remembered, a refusal included; a check that threw is not, because the same bytes might verify on a later attempt.
|
|
43
51
|
*/
|
|
44
52
|
export declare function verifyPeerAdvert(identity: Readonly<PeerAdvertVerifier>, advert: Readonly<PeerAdvert>): Promise<boolean>;
|
|
45
53
|
//#endregion
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { bytesEqual } from "./token-scope.mjs";
|
|
2
|
+
import { createVerdictCache } from "./verdict-cache.mjs";
|
|
2
3
|
import { cdeEncodeOptions, encode } from "cbor2";
|
|
3
4
|
//#region src/domain/peer-advert.ts
|
|
5
|
+
const BYTES_PER_KIBIBYTE = 1024;
|
|
6
|
+
/**
|
|
7
|
+
* The most a verifier's verdict memo may charge across all its entries, in bytes of key.
|
|
8
|
+
*
|
|
9
|
+
* A key is a signature plus the signed bytes, so an honest advert (a device id, a few addresses, a handful of gossip extensions) costs on the order of a kilobyte and this budget holds the current advert of every device a large local mesh gossips, plus the several generations a hub's catch-up frames can still replay. It is a budget in bytes rather than a count of entries because the signed bytes are attacker-sized up to the frame limit: a count would let a hostile peer pin that many frames in memory, where a byte budget makes the worst case this figure and refuses to keep any single advert larger than it.
|
|
10
|
+
*/
|
|
11
|
+
const PEER_ADVERT_VERDICT_BUDGET_BYTES = 8 * (BYTES_PER_KIBIBYTE * BYTES_PER_KIBIBYTE);
|
|
12
|
+
/**
|
|
13
|
+
* One memo per verifier object, held weakly so it lives exactly as long as the identity it serves. A verdict is a pure function of the advert's own bytes (the key and the signature both travel inside it), so every session and hub sharing one verifier shares the answers, and a fresh verifier (a test's fake, a second node identity) starts cold rather than inheriting another's.
|
|
14
|
+
*/
|
|
15
|
+
const verdictCaches = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
function verdictCacheFor(identity) {
|
|
17
|
+
const existing = verdictCaches.get(identity);
|
|
18
|
+
if (existing !== void 0) return existing;
|
|
19
|
+
const created = createVerdictCache(PEER_ADVERT_VERDICT_BUDGET_BYTES);
|
|
20
|
+
verdictCaches.set(identity, created);
|
|
21
|
+
return created;
|
|
22
|
+
}
|
|
4
23
|
/** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
|
|
5
24
|
const PEER_ADVERT_SIGNATURE_KEY = "signature";
|
|
6
25
|
/** peer-advert's own self-certifying key field. Covered by the signature like every other entry, so an advert cannot be re-keyed after signing. */
|
|
@@ -40,16 +59,28 @@ async function signPeerAdvert(identity, advert) {
|
|
|
40
59
|
* Both validity checks spec/transport.cddl states for a gossiped advert, in the order a verifier must apply them: the embedded identity-key self-certifies (sha256 of its public-key equals the advert's own `device`), and the signature verifies under that same key over peerAdvertSigningInput's bytes.
|
|
41
60
|
*
|
|
42
61
|
* Returns a verdict for every input, including a hostile one, and never throws. An advert's `alg` and `public-key` are attacker-chosen (identity-key constrains them to an int and a bstr, nothing more), so importing the key can legitimately fail for an algorithm no verifier implements or for key bytes of the wrong length. An unverifiable advert is a refused advert, exactly as if its signature had simply been wrong, which is why the import failure is turned into `false` here rather than propagated to a caller iterating a frame's other adverts.
|
|
62
|
+
*
|
|
63
|
+
* A hub re-broadcasts every advert it accepts to every other client and replays the whole directory to each sender, so one advert reaches a receiver several times and a mesh of N devices checks each advert on the order of N times per receiver. The two checks are pure functions of the advert's bytes, so the verdict for a given signature over given signed bytes is remembered (see PEER_ADVERT_VERDICT_BUDGET_BYTES) and a repeat costs one encoding rather than two digests and a public-key import and verification. Only a verdict the checks reached is remembered, a refusal included; a check that threw is not, because the same bytes might verify on a later attempt.
|
|
43
64
|
*/
|
|
44
65
|
async function verifyPeerAdvert(identity, advert) {
|
|
66
|
+
let signedBytes;
|
|
67
|
+
try {
|
|
68
|
+
signedBytes = peerAdvertSigningInput(advert);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return verdictCacheFor(identity).verdict([advert[PEER_ADVERT_SIGNATURE_KEY], signedBytes], async () => checkPeerAdvert(identity, advert, signedBytes));
|
|
73
|
+
}
|
|
74
|
+
/** The uncached checks: true or false when they reached a verdict, undefined when verifying threw. */
|
|
75
|
+
async function checkPeerAdvert(identity, advert, signedBytes) {
|
|
45
76
|
const key = advert[PEER_ADVERT_IDENTITY_KEY_KEY];
|
|
46
77
|
try {
|
|
47
78
|
const derived = await identity.deriveDeviceId(key["public-key"]);
|
|
48
79
|
if (!bytesEqual(derived, advert.device)) return false;
|
|
49
|
-
return await identity.verify(key,
|
|
80
|
+
return await identity.verify(key, signedBytes, advert[PEER_ADVERT_SIGNATURE_KEY]);
|
|
50
81
|
} catch {
|
|
51
|
-
return
|
|
82
|
+
return;
|
|
52
83
|
}
|
|
53
84
|
}
|
|
54
85
|
//#endregion
|
|
55
|
-
export { PEER_ADVERT_IDENTITY_KEY_KEY, PEER_ADVERT_SIGNATURE_KEY, PEER_ADVERT_SIGNING_CONTEXT, peerAdvertSigningInput, signPeerAdvert, verifyPeerAdvert };
|
|
86
|
+
export { PEER_ADVERT_IDENTITY_KEY_KEY, PEER_ADVERT_SIGNATURE_KEY, PEER_ADVERT_SIGNING_CONTEXT, PEER_ADVERT_VERDICT_BUDGET_BYTES, peerAdvertSigningInput, signPeerAdvert, verifyPeerAdvert };
|
|
@@ -8,6 +8,8 @@ const require_domain_room_token_verification = require("./room-token-verificatio
|
|
|
8
8
|
*
|
|
9
9
|
* The receiving side's own security property is worth stating plainly, since it's what makes this handler simpler than it might look: it needs no separate check of who actually sent the request. verifyRoomToken (obligation 1) already certifies that the recipient's OWN held room:member token's delegation chain terminates at the room's real, rightful owner -- deriving the ECDH shared secret against that certified owner's own rootIssuerKey means a wrapped-key only ever unwraps correctly when it was actually produced by someone holding the real owner's private key. A forged room.rekey from anyone else -- an attacker, a not-yet-admitted stranger, even another legitimate member with no admission authority -- cannot produce ciphertext that unwraps, so it fails closed on the cryptography itself rather than needing a redundant, separately-maintained live-sender identity check.
|
|
10
10
|
*/
|
|
11
|
+
/** The error code a room.rekey is answered with when it unwrapped but the recipient could not store the keys, so the sender knows the key is not held and sends it again. */
|
|
12
|
+
const REKEY_STORE_FAILED = "store_failed";
|
|
11
13
|
/**
|
|
12
14
|
* Builds a room.rekey command per room.cddl. The outer verb is room:member, the same "one resource, several inner verbs" pattern room.send/room.leave/room.members already establish -- not a new capability of its own.
|
|
13
15
|
*/
|
|
@@ -29,7 +31,7 @@ async function sendRoomRekey(session, roomPath, keyEpoch, wrappedKey, targetDevi
|
|
|
29
31
|
}, targetDevice, token);
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result.
|
|
34
|
+
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result. The unwrapped keys are then handed to onRekey, and the sender is answered "ok" only once onRekey has stored them, or "store_failed" when it could not.
|
|
33
35
|
*/
|
|
34
36
|
function createRoomRekeyHandler(options) {
|
|
35
37
|
return async function handleRoomRekey(incoming) {
|
|
@@ -99,15 +101,24 @@ function createRoomRekeyHandler(options) {
|
|
|
99
101
|
});
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
104
|
+
try {
|
|
105
|
+
await options.onRekey({
|
|
106
|
+
room: roomPath,
|
|
107
|
+
keyEpoch,
|
|
108
|
+
contentKeys
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await incoming.respond({
|
|
112
|
+
result: "error",
|
|
113
|
+
code: REKEY_STORE_FAILED
|
|
114
|
+
});
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
102
117
|
await incoming.respond({ result: "ok" });
|
|
103
|
-
await options.onRekey({
|
|
104
|
-
room: roomPath,
|
|
105
|
-
keyEpoch,
|
|
106
|
-
contentKeys
|
|
107
|
-
});
|
|
108
118
|
};
|
|
109
119
|
}
|
|
110
120
|
//#endregion
|
|
121
|
+
exports.REKEY_STORE_FAILED = REKEY_STORE_FAILED;
|
|
111
122
|
exports.buildRoomRekeyCommand = buildRoomRekeyCommand;
|
|
112
123
|
exports.createRoomRekeyHandler = createRoomRekeyHandler;
|
|
113
124
|
exports.sendRoomRekey = sendRoomRekey;
|
|
@@ -4,6 +4,8 @@ import { t as Clock } from "../clock-DhUPRfLh.cjs";
|
|
|
4
4
|
import { IncomingManageRequest, ManageOutcome, MeshSession } from "./mesh-session.cjs";
|
|
5
5
|
import { RevocationCheck } from "./tokens.cjs";
|
|
6
6
|
//#region src/domain/room-rekey.d.ts
|
|
7
|
+
/** The error code a room.rekey is answered with when it unwrapped but the recipient could not store the keys, so the sender knows the key is not held and sends it again. */
|
|
8
|
+
export declare const REKEY_STORE_FAILED = "store_failed";
|
|
7
9
|
/**
|
|
8
10
|
* Builds a room.rekey command per room.cddl. The outer verb is room:member, the same "one resource, several inner verbs" pattern room.send/room.leave/room.members already establish -- not a new capability of its own.
|
|
9
11
|
*/
|
|
@@ -25,11 +27,11 @@ export interface CreateRoomRekeyHandlerOptions {
|
|
|
25
27
|
revocation: RevocationCheck;
|
|
26
28
|
/** This recipient's own currently-held room:member token for the room being rekeyed, verified fresh on every incoming room.rekey (not cached) -- see this module's own doc comment for why its certified root issuer-key is what the ECDH derivation uses, rather than a separately-tracked live-sender identity. */
|
|
27
29
|
ownRoomMemberToken: CapabilityToken;
|
|
28
|
-
/** Called once per successfully unwrapped room.rekey,
|
|
30
|
+
/** Called once per successfully unwrapped room.rekey, before the sender is told it succeeded. May be asynchronous, as storing a key durably is; the handler waits for it and answers "ok" only once it has settled, and when it rejects the handler answers `store_failed` and rejects with the same error, so the failure reaches both the sender and whoever awaits the handler. */
|
|
29
31
|
onRekey: (event: Readonly<RoomRekeyEvent>) => Promise<void> | void;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result.
|
|
34
|
+
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result. The unwrapped keys are then handed to onRekey, and the sender is answered "ok" only once onRekey has stored them, or "store_failed" when it could not.
|
|
33
35
|
*/
|
|
34
36
|
export declare function createRoomRekeyHandler(options: Readonly<CreateRoomRekeyHandlerOptions>): (incoming: Readonly<IncomingManageRequest>) => Promise<void>;
|
|
35
37
|
//#endregion
|
|
@@ -4,6 +4,8 @@ import { t as Clock } from "../clock-DhUPRfLh.mjs";
|
|
|
4
4
|
import { IncomingManageRequest, ManageOutcome, MeshSession } from "./mesh-session.mjs";
|
|
5
5
|
import { RevocationCheck } from "./tokens.mjs";
|
|
6
6
|
//#region src/domain/room-rekey.d.ts
|
|
7
|
+
/** The error code a room.rekey is answered with when it unwrapped but the recipient could not store the keys, so the sender knows the key is not held and sends it again. */
|
|
8
|
+
export declare const REKEY_STORE_FAILED = "store_failed";
|
|
7
9
|
/**
|
|
8
10
|
* Builds a room.rekey command per room.cddl. The outer verb is room:member, the same "one resource, several inner verbs" pattern room.send/room.leave/room.members already establish -- not a new capability of its own.
|
|
9
11
|
*/
|
|
@@ -25,11 +27,11 @@ export interface CreateRoomRekeyHandlerOptions {
|
|
|
25
27
|
revocation: RevocationCheck;
|
|
26
28
|
/** This recipient's own currently-held room:member token for the room being rekeyed, verified fresh on every incoming room.rekey (not cached) -- see this module's own doc comment for why its certified root issuer-key is what the ECDH derivation uses, rather than a separately-tracked live-sender identity. */
|
|
27
29
|
ownRoomMemberToken: CapabilityToken;
|
|
28
|
-
/** Called once per successfully unwrapped room.rekey,
|
|
30
|
+
/** Called once per successfully unwrapped room.rekey, before the sender is told it succeeded. May be asynchronous, as storing a key durably is; the handler waits for it and answers "ok" only once it has settled, and when it rejects the handler answers `store_failed` and rejects with the same error, so the failure reaches both the sender and whoever awaits the handler. */
|
|
29
31
|
onRekey: (event: Readonly<RoomRekeyEvent>) => Promise<void> | void;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result.
|
|
34
|
+
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result. The unwrapped keys are then handed to onRekey, and the sender is answered "ok" only once onRekey has stored them, or "store_failed" when it could not.
|
|
33
35
|
*/
|
|
34
36
|
export declare function createRoomRekeyHandler(options: Readonly<CreateRoomRekeyHandlerOptions>): (incoming: Readonly<IncomingManageRequest>) => Promise<void>;
|
|
35
37
|
//#endregion
|
|
@@ -7,6 +7,8 @@ import { ROOM_MEMBER_CAPABILITY, verifyRoomToken } from "./room-token-verificati
|
|
|
7
7
|
*
|
|
8
8
|
* The receiving side's own security property is worth stating plainly, since it's what makes this handler simpler than it might look: it needs no separate check of who actually sent the request. verifyRoomToken (obligation 1) already certifies that the recipient's OWN held room:member token's delegation chain terminates at the room's real, rightful owner -- deriving the ECDH shared secret against that certified owner's own rootIssuerKey means a wrapped-key only ever unwraps correctly when it was actually produced by someone holding the real owner's private key. A forged room.rekey from anyone else -- an attacker, a not-yet-admitted stranger, even another legitimate member with no admission authority -- cannot produce ciphertext that unwraps, so it fails closed on the cryptography itself rather than needing a redundant, separately-maintained live-sender identity check.
|
|
9
9
|
*/
|
|
10
|
+
/** The error code a room.rekey is answered with when it unwrapped but the recipient could not store the keys, so the sender knows the key is not held and sends it again. */
|
|
11
|
+
const REKEY_STORE_FAILED = "store_failed";
|
|
10
12
|
/**
|
|
11
13
|
* Builds a room.rekey command per room.cddl. The outer verb is room:member, the same "one resource, several inner verbs" pattern room.send/room.leave/room.members already establish -- not a new capability of its own.
|
|
12
14
|
*/
|
|
@@ -28,7 +30,7 @@ async function sendRoomRekey(session, roomPath, keyEpoch, wrappedKey, targetDevi
|
|
|
28
30
|
}, targetDevice, token);
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
|
-
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result.
|
|
33
|
+
* Builds a reusable handler for one room's incoming room.rekey messages. Checks, in order: the outer verb is room:member (otherwise "malformed"); the params payload parses against room.rekey's own CDDL shape (otherwise "malformed"); the request's own scope is a room scope (otherwise "scope_mismatch"); ownRoomMemberToken independently passes every one of core/room's six verifier obligations, scoped to the incoming request's own room path (a failure responds with verifyRoomToken's own specific reason, e.g. "wrong_chain_root"/"wrong_scope_path"/"expired"); this identity actually supports deriveSharedSecret (otherwise "ecdh_unsupported" -- an Ed25519-only identity genuinely cannot participate). Only then does it derive the shared secret against the verified chain's own rootIssuerKey, derive one wrapping key per granted epoch (per the array-ordering convention: entry i is epoch i+1, counting back from key-epoch), and unwrap each -- a failure at that final cryptographic step (a forged sender, or genuine corruption) responds "unwrap_failed" rather than surfacing a partially-decoded result. The unwrapped keys are then handed to onRekey, and the sender is answered "ok" only once onRekey has stored them, or "store_failed" when it could not.
|
|
32
34
|
*/
|
|
33
35
|
function createRoomRekeyHandler(options) {
|
|
34
36
|
return async function handleRoomRekey(incoming) {
|
|
@@ -98,13 +100,21 @@ function createRoomRekeyHandler(options) {
|
|
|
98
100
|
});
|
|
99
101
|
return;
|
|
100
102
|
}
|
|
103
|
+
try {
|
|
104
|
+
await options.onRekey({
|
|
105
|
+
room: roomPath,
|
|
106
|
+
keyEpoch,
|
|
107
|
+
contentKeys
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await incoming.respond({
|
|
111
|
+
result: "error",
|
|
112
|
+
code: REKEY_STORE_FAILED
|
|
113
|
+
});
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
101
116
|
await incoming.respond({ result: "ok" });
|
|
102
|
-
await options.onRekey({
|
|
103
|
-
room: roomPath,
|
|
104
|
-
keyEpoch,
|
|
105
|
-
contentKeys
|
|
106
|
-
});
|
|
107
117
|
};
|
|
108
118
|
}
|
|
109
119
|
//#endregion
|
|
110
|
-
export { buildRoomRekeyCommand, createRoomRekeyHandler, sendRoomRekey };
|
|
120
|
+
export { REKEY_STORE_FAILED, buildRoomRekeyCommand, createRoomRekeyHandler, sendRoomRekey };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/domain/verdict-cache.ts
|
|
3
|
+
/** Characters appended to a key's byte length when charging an entry to the budget: the cached promise and the map slot are not free, so an entry of zero key bytes still costs something. Sized as one pointer pair on a 64-bit runtime. */
|
|
4
|
+
const ENTRY_OVERHEAD_BYTES = 16;
|
|
5
|
+
/** The most characters String.fromCharCode may be given as arguments in one call without overflowing an engine's argument limit. */
|
|
6
|
+
const FROM_CHAR_CODE_CHUNK = 4096;
|
|
7
|
+
/**
|
|
8
|
+
* How many characters bytesKey produces for these parts: each part's length in decimal, a colon, then one character per byte. Computed from the lengths alone, so a caller can refuse an oversized input before any character of its key exists.
|
|
9
|
+
*/
|
|
10
|
+
function bytesKeyLength(parts) {
|
|
11
|
+
let length = 0;
|
|
12
|
+
for (const part of parts) length += String(part.length).length + 1 + part.length;
|
|
13
|
+
return length;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A string that is equal for two byte sequences exactly when the sequences are equal: one character per byte, so a Map can key on it without hashing or comparing byte arrays itself.
|
|
17
|
+
*/
|
|
18
|
+
function bytesKey(...parts) {
|
|
19
|
+
let key = "";
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
key += `${String(part.length)}:`;
|
|
22
|
+
for (let start = 0; start < part.length; start += FROM_CHAR_CODE_CHUNK) key += String.fromCharCode(...part.subarray(start, start + FROM_CHAR_CODE_CHUNK));
|
|
23
|
+
}
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
/** An outcome that was not kept: the check's verdict, or false when it did not reach one. */
|
|
27
|
+
async function unsettledAsRefusal(compute) {
|
|
28
|
+
return await compute() ?? false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Builds a memo whose entries together never charge more than `budgetBytes`. The least recently used entry goes first when a new one would overflow; an input whose key is larger than the whole budget is computed but never kept, so one hostile oversized input cannot flush everything else.
|
|
32
|
+
*/
|
|
33
|
+
function createVerdictCache(budgetBytes) {
|
|
34
|
+
const entries = /* @__PURE__ */ new Map();
|
|
35
|
+
let used = 0;
|
|
36
|
+
/** Key bytes charged to verdicts still being computed. An entry evicted while its verdict is pending is gone from `entries`, but the computation still holds its key until it settles, so `used` alone would let many concurrent large inputs each keep a key the budget no longer sees. */
|
|
37
|
+
let pending = 0;
|
|
38
|
+
const forget = (key) => {
|
|
39
|
+
const entry = entries.get(key);
|
|
40
|
+
if (entry === void 0) return;
|
|
41
|
+
entries.delete(key);
|
|
42
|
+
used -= entry.cost;
|
|
43
|
+
};
|
|
44
|
+
return { async verdict(parts, compute) {
|
|
45
|
+
const cost = bytesKeyLength(parts) + ENTRY_OVERHEAD_BYTES;
|
|
46
|
+
if (cost > budgetBytes) return unsettledAsRefusal(compute);
|
|
47
|
+
if (pending + cost > budgetBytes) return unsettledAsRefusal(compute);
|
|
48
|
+
const key = bytesKey(...parts);
|
|
49
|
+
const held = entries.get(key);
|
|
50
|
+
if (held !== void 0) {
|
|
51
|
+
entries.delete(key);
|
|
52
|
+
entries.set(key, held);
|
|
53
|
+
return held.settled;
|
|
54
|
+
}
|
|
55
|
+
pending += cost;
|
|
56
|
+
const settled = compute().then((outcome) => {
|
|
57
|
+
if (outcome === void 0) {
|
|
58
|
+
forget(key);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return outcome;
|
|
62
|
+
}).finally(() => {
|
|
63
|
+
pending -= cost;
|
|
64
|
+
});
|
|
65
|
+
entries.set(key, {
|
|
66
|
+
settled,
|
|
67
|
+
cost
|
|
68
|
+
});
|
|
69
|
+
used += cost;
|
|
70
|
+
for (const oldest of entries.keys()) {
|
|
71
|
+
if (used <= budgetBytes) break;
|
|
72
|
+
forget(oldest);
|
|
73
|
+
}
|
|
74
|
+
return settled;
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
exports.bytesKey = bytesKey;
|
|
79
|
+
exports.bytesKeyLength = bytesKeyLength;
|
|
80
|
+
exports.createVerdictCache = createVerdictCache;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/domain/verdict-cache.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How many characters bytesKey produces for these parts: each part's length in decimal, a colon, then one character per byte. Computed from the lengths alone, so a caller can refuse an oversized input before any character of its key exists.
|
|
4
|
+
*/
|
|
5
|
+
export declare function bytesKeyLength(parts: readonly Uint8Array[]): number;
|
|
6
|
+
/**
|
|
7
|
+
* A string that is equal for two byte sequences exactly when the sequences are equal: one character per byte, so a Map can key on it without hashing or comparing byte arrays itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bytesKey(...parts: readonly Uint8Array[]): string;
|
|
10
|
+
/** A memo of in-flight and settled verdicts keyed by an opaque string. */
|
|
11
|
+
export interface VerdictCache {
|
|
12
|
+
/**
|
|
13
|
+
* The verdict for the bytes in `parts`, computing it with `compute` only when no live entry holds one. A concurrent caller for the same bytes shares the first caller's computation rather than starting another.
|
|
14
|
+
*
|
|
15
|
+
* The key is built from `parts` only when the entry would fit the budget, counting the keys of verdicts still being computed: an input that would not fit is computed and never keyed, so neither one attacker-sized input nor many concurrent ones cost more than computing their verdicts directly, with no copy of them as strings.
|
|
16
|
+
*
|
|
17
|
+
* `compute` resolves to a verdict to remember, or to `undefined` when the outcome was an error that a later attempt might not repeat: that outcome is returned to every waiting caller as `false` (the check did not succeed) but is not kept, so a transient failure is retried instead of being remembered as a refusal.
|
|
18
|
+
*/
|
|
19
|
+
verdict: (parts: readonly Uint8Array[], compute: () => Promise<boolean | undefined>) => Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Builds a memo whose entries together never charge more than `budgetBytes`. The least recently used entry goes first when a new one would overflow; an input whose key is larger than the whole budget is computed but never kept, so one hostile oversized input cannot flush everything else.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createVerdictCache(budgetBytes: number): VerdictCache;
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/domain/verdict-cache.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How many characters bytesKey produces for these parts: each part's length in decimal, a colon, then one character per byte. Computed from the lengths alone, so a caller can refuse an oversized input before any character of its key exists.
|
|
4
|
+
*/
|
|
5
|
+
export declare function bytesKeyLength(parts: readonly Uint8Array[]): number;
|
|
6
|
+
/**
|
|
7
|
+
* A string that is equal for two byte sequences exactly when the sequences are equal: one character per byte, so a Map can key on it without hashing or comparing byte arrays itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bytesKey(...parts: readonly Uint8Array[]): string;
|
|
10
|
+
/** A memo of in-flight and settled verdicts keyed by an opaque string. */
|
|
11
|
+
export interface VerdictCache {
|
|
12
|
+
/**
|
|
13
|
+
* The verdict for the bytes in `parts`, computing it with `compute` only when no live entry holds one. A concurrent caller for the same bytes shares the first caller's computation rather than starting another.
|
|
14
|
+
*
|
|
15
|
+
* The key is built from `parts` only when the entry would fit the budget, counting the keys of verdicts still being computed: an input that would not fit is computed and never keyed, so neither one attacker-sized input nor many concurrent ones cost more than computing their verdicts directly, with no copy of them as strings.
|
|
16
|
+
*
|
|
17
|
+
* `compute` resolves to a verdict to remember, or to `undefined` when the outcome was an error that a later attempt might not repeat: that outcome is returned to every waiting caller as `false` (the check did not succeed) but is not kept, so a transient failure is retried instead of being remembered as a refusal.
|
|
18
|
+
*/
|
|
19
|
+
verdict: (parts: readonly Uint8Array[], compute: () => Promise<boolean | undefined>) => Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Builds a memo whose entries together never charge more than `budgetBytes`. The least recently used entry goes first when a new one would overflow; an input whose key is larger than the whole budget is computed but never kept, so one hostile oversized input cannot flush everything else.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createVerdictCache(budgetBytes: number): VerdictCache;
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region src/domain/verdict-cache.ts
|
|
2
|
+
/** Characters appended to a key's byte length when charging an entry to the budget: the cached promise and the map slot are not free, so an entry of zero key bytes still costs something. Sized as one pointer pair on a 64-bit runtime. */
|
|
3
|
+
const ENTRY_OVERHEAD_BYTES = 16;
|
|
4
|
+
/** The most characters String.fromCharCode may be given as arguments in one call without overflowing an engine's argument limit. */
|
|
5
|
+
const FROM_CHAR_CODE_CHUNK = 4096;
|
|
6
|
+
/**
|
|
7
|
+
* How many characters bytesKey produces for these parts: each part's length in decimal, a colon, then one character per byte. Computed from the lengths alone, so a caller can refuse an oversized input before any character of its key exists.
|
|
8
|
+
*/
|
|
9
|
+
function bytesKeyLength(parts) {
|
|
10
|
+
let length = 0;
|
|
11
|
+
for (const part of parts) length += String(part.length).length + 1 + part.length;
|
|
12
|
+
return length;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A string that is equal for two byte sequences exactly when the sequences are equal: one character per byte, so a Map can key on it without hashing or comparing byte arrays itself.
|
|
16
|
+
*/
|
|
17
|
+
function bytesKey(...parts) {
|
|
18
|
+
let key = "";
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
key += `${String(part.length)}:`;
|
|
21
|
+
for (let start = 0; start < part.length; start += FROM_CHAR_CODE_CHUNK) key += String.fromCharCode(...part.subarray(start, start + FROM_CHAR_CODE_CHUNK));
|
|
22
|
+
}
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
/** An outcome that was not kept: the check's verdict, or false when it did not reach one. */
|
|
26
|
+
async function unsettledAsRefusal(compute) {
|
|
27
|
+
return await compute() ?? false;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds a memo whose entries together never charge more than `budgetBytes`. The least recently used entry goes first when a new one would overflow; an input whose key is larger than the whole budget is computed but never kept, so one hostile oversized input cannot flush everything else.
|
|
31
|
+
*/
|
|
32
|
+
function createVerdictCache(budgetBytes) {
|
|
33
|
+
const entries = /* @__PURE__ */ new Map();
|
|
34
|
+
let used = 0;
|
|
35
|
+
/** Key bytes charged to verdicts still being computed. An entry evicted while its verdict is pending is gone from `entries`, but the computation still holds its key until it settles, so `used` alone would let many concurrent large inputs each keep a key the budget no longer sees. */
|
|
36
|
+
let pending = 0;
|
|
37
|
+
const forget = (key) => {
|
|
38
|
+
const entry = entries.get(key);
|
|
39
|
+
if (entry === void 0) return;
|
|
40
|
+
entries.delete(key);
|
|
41
|
+
used -= entry.cost;
|
|
42
|
+
};
|
|
43
|
+
return { async verdict(parts, compute) {
|
|
44
|
+
const cost = bytesKeyLength(parts) + ENTRY_OVERHEAD_BYTES;
|
|
45
|
+
if (cost > budgetBytes) return unsettledAsRefusal(compute);
|
|
46
|
+
if (pending + cost > budgetBytes) return unsettledAsRefusal(compute);
|
|
47
|
+
const key = bytesKey(...parts);
|
|
48
|
+
const held = entries.get(key);
|
|
49
|
+
if (held !== void 0) {
|
|
50
|
+
entries.delete(key);
|
|
51
|
+
entries.set(key, held);
|
|
52
|
+
return held.settled;
|
|
53
|
+
}
|
|
54
|
+
pending += cost;
|
|
55
|
+
const settled = compute().then((outcome) => {
|
|
56
|
+
if (outcome === void 0) {
|
|
57
|
+
forget(key);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return outcome;
|
|
61
|
+
}).finally(() => {
|
|
62
|
+
pending -= cost;
|
|
63
|
+
});
|
|
64
|
+
entries.set(key, {
|
|
65
|
+
settled,
|
|
66
|
+
cost
|
|
67
|
+
});
|
|
68
|
+
used += cost;
|
|
69
|
+
for (const oldest of entries.keys()) {
|
|
70
|
+
if (used <= budgetBytes) break;
|
|
71
|
+
forget(oldest);
|
|
72
|
+
}
|
|
73
|
+
return settled;
|
|
74
|
+
} };
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { bytesKey, bytesKeyLength, createVerdictCache };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wire-mesh-core",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.7",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"cbor2": "2.3.0",
|
|
6
6
|
"cddl.js": "1.0.1",
|
|
@@ -214,6 +214,10 @@
|
|
|
214
214
|
"import": "./dist/domain/topology-snapshot.mjs",
|
|
215
215
|
"require": "./dist/domain/topology-snapshot.cjs"
|
|
216
216
|
},
|
|
217
|
+
"./domain/verdict-cache": {
|
|
218
|
+
"import": "./dist/domain/verdict-cache.mjs",
|
|
219
|
+
"require": "./dist/domain/verdict-cache.cjs"
|
|
220
|
+
},
|
|
217
221
|
"./domain/webrtc-signaling": {
|
|
218
222
|
"import": "./dist/domain/webrtc-signaling.mjs",
|
|
219
223
|
"require": "./dist/domain/webrtc-signaling.cjs"
|