wire-mesh-core 4.0.4 → 4.0.6
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/mesh-session.cjs +4 -1
- package/dist/domain/mesh-session.mjs +4 -1
- 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/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
- package/wasm-dist/wire_mesh_threshold_wasm_bg.wasm +0 -0
|
@@ -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"
|
|
@@ -494,7 +494,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
494
494
|
else if (frame.type === "gossip") {
|
|
495
495
|
const verdicts = await Promise.all(frame.peers.map(async (advert) => require_domain_peer_advert.verifyPeerAdvert(identity, advert)));
|
|
496
496
|
for (const advert of frame.peers.filter((_, i) => verdicts[i] === true)) {
|
|
497
|
-
|
|
497
|
+
const deviceHex = require_domain_device_id.deviceIdToHex(advert.device);
|
|
498
|
+
const held = directory.get(deviceHex)?.advert["snapshot-seconds"];
|
|
499
|
+
if (held !== void 0 && advert["snapshot-seconds"] < held) continue;
|
|
500
|
+
directory.set(deviceHex, {
|
|
498
501
|
device: advert.device,
|
|
499
502
|
advert
|
|
500
503
|
});
|
|
@@ -493,7 +493,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
493
493
|
else if (frame.type === "gossip") {
|
|
494
494
|
const verdicts = await Promise.all(frame.peers.map(async (advert) => verifyPeerAdvert(identity, advert)));
|
|
495
495
|
for (const advert of frame.peers.filter((_, i) => verdicts[i] === true)) {
|
|
496
|
-
|
|
496
|
+
const deviceHex = deviceIdToHex(advert.device);
|
|
497
|
+
const held = directory.get(deviceHex)?.advert["snapshot-seconds"];
|
|
498
|
+
if (held !== void 0 && advert["snapshot-seconds"] < held) continue;
|
|
499
|
+
directory.set(deviceHex, {
|
|
497
500
|
device: advert.device,
|
|
498
501
|
advert
|
|
499
502
|
});
|
|
@@ -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 };
|
|
@@ -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.6",
|
|
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"
|
|
Binary file
|