@mh-gg/event 0.1.1-alpha.20260613T085325975Z
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/package.json +60 -0
- package/src/canonicalJson.cjs +63 -0
- package/src/canonicalJson.d.ts +2 -0
- package/src/canonicalJson.js +58 -0
- package/src/chat.cjs +28 -0
- package/src/constants.cjs +59 -0
- package/src/encoding.cjs +19 -0
- package/src/encryption.cjs +141 -0
- package/src/epochFilter.cjs +14 -0
- package/src/identity.cjs +16 -0
- package/src/ids.cjs +9 -0
- package/src/index.cjs +16 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +6 -0
- package/src/matterhornOperationBuilder.cjs +101 -0
- package/src/matterhornOperationBuilder.d.ts +39 -0
- package/src/matterhornOperationBuilder.js +9 -0
- package/src/ngramIndex.cjs +198 -0
- package/src/ngramIndex.d.ts +48 -0
- package/src/ngramIndex.js +17 -0
- package/src/nostrMapping.cjs +287 -0
- package/src/payload.cjs +21 -0
- package/src/relayPolicy.cjs +35 -0
- package/src/results.cjs +12 -0
- package/src/signing.cjs +97 -0
- package/src/validation.cjs +344 -0
- package/test/canonical-depth.test.cjs +37 -0
- package/test/control-plane.test.cjs +143 -0
- package/test/coverage-edges.test.cjs +186 -0
- package/test/encryption-cache.test.cjs +95 -0
- package/test/matterhorn-event.test.cjs +466 -0
- package/test/ngram-index.test.cjs +62 -0
- package/test/relay-policy-edge.test.cjs +82 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/event",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Signed, encrypted party event protocol helpers for matterhorn.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"require": "./src/index.cjs",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./canonicalJson": {
|
|
15
|
+
"types": "./src/canonicalJson.d.ts",
|
|
16
|
+
"import": "./src/canonicalJson.js",
|
|
17
|
+
"require": "./src/canonicalJson.cjs",
|
|
18
|
+
"default": "./src/canonicalJson.js"
|
|
19
|
+
},
|
|
20
|
+
"./matterhornOperationBuilder": {
|
|
21
|
+
"types": "./src/matterhornOperationBuilder.d.ts",
|
|
22
|
+
"import": "./src/matterhornOperationBuilder.js",
|
|
23
|
+
"require": "./src/matterhornOperationBuilder.cjs",
|
|
24
|
+
"default": "./src/matterhornOperationBuilder.js"
|
|
25
|
+
},
|
|
26
|
+
"./ngramIndex": {
|
|
27
|
+
"types": "./src/ngramIndex.d.ts",
|
|
28
|
+
"import": "./src/ngramIndex.js",
|
|
29
|
+
"require": "./src/ngramIndex.cjs",
|
|
30
|
+
"default": "./src/ngramIndex.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"typesVersions": {
|
|
34
|
+
"*": {
|
|
35
|
+
"canonicalJson": [
|
|
36
|
+
"src/canonicalJson.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"matterhornOperationBuilder": [
|
|
39
|
+
"src/matterhornOperationBuilder.d.ts"
|
|
40
|
+
],
|
|
41
|
+
"*": [
|
|
42
|
+
"src/index.d.ts"
|
|
43
|
+
],
|
|
44
|
+
"ngramIndex": [
|
|
45
|
+
"src/ngramIndex.d.ts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"nostr-tools": "^2.23.5"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.12"
|
|
54
|
+
},
|
|
55
|
+
"types": "src/index.d.ts",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "node --test test/*.test.cjs",
|
|
58
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/*.cjs test/*.test.cjs"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MAX_CANONICAL_DEPTH = 64;
|
|
2
|
+
|
|
3
|
+
function maxDepthFromOptions(options = {}) {
|
|
4
|
+
if (Number.isInteger(options.maxDepth) && options.maxDepth >= 0) return options.maxDepth;
|
|
5
|
+
return MAX_CANONICAL_DEPTH;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function canonicalJson(value, options = {}) {
|
|
9
|
+
return canonicalJsonInner(value, new WeakSet(), 0, maxDepthFromOptions(options));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requireDepth(depth, maxDepth) {
|
|
13
|
+
if (depth > maxDepth) throw new Error("Cannot canonicalize: max depth exceeded");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function canonicalJsonInner(value, seen, depth, maxDepth) {
|
|
17
|
+
requireDepth(depth, maxDepth);
|
|
18
|
+
|
|
19
|
+
if (value === null) return "null";
|
|
20
|
+
if (typeof value === "string") return JSON.stringify(value.normalize("NFC"));
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (!Number.isFinite(value)) throw new Error("Invalid number");
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
if (seen.has(value)) throw new Error("Cannot canonicalize cyclic values");
|
|
29
|
+
seen.add(value);
|
|
30
|
+
try {
|
|
31
|
+
return `[${value.map((item) => canonicalJsonInner(item, seen, depth + 1, maxDepth)).join(",")}]`;
|
|
32
|
+
} finally {
|
|
33
|
+
seen.delete(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value === "object") {
|
|
38
|
+
if (seen.has(value)) throw new Error("Cannot canonicalize cyclic values");
|
|
39
|
+
seen.add(value);
|
|
40
|
+
try {
|
|
41
|
+
const entries = [];
|
|
42
|
+
const normalizedKeys = new Set();
|
|
43
|
+
for (const key of Object.keys(value)) {
|
|
44
|
+
if (value[key] === undefined) continue;
|
|
45
|
+
const normalizedKey = key.normalize("NFC");
|
|
46
|
+
if (normalizedKeys.has(normalizedKey)) throw new Error("Cannot canonicalize duplicate normalized object keys");
|
|
47
|
+
normalizedKeys.add(normalizedKey);
|
|
48
|
+
entries.push([normalizedKey, value[key]]);
|
|
49
|
+
}
|
|
50
|
+
entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
51
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJsonInner(entryValue, seen, depth + 1, maxDepth)}`).join(",")}}`;
|
|
52
|
+
} finally {
|
|
53
|
+
seen.delete(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error("Unsupported value");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
MAX_CANONICAL_DEPTH,
|
|
62
|
+
canonicalJson
|
|
63
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const MAX_CANONICAL_DEPTH = 64;
|
|
2
|
+
|
|
3
|
+
function maxDepthFromOptions(options = {}) {
|
|
4
|
+
if (Number.isInteger(options.maxDepth) && options.maxDepth >= 0) return options.maxDepth;
|
|
5
|
+
return MAX_CANONICAL_DEPTH;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function canonicalJson(value, options = {}) {
|
|
9
|
+
return canonicalJsonInner(value, new WeakSet(), 0, maxDepthFromOptions(options));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requireDepth(depth, maxDepth) {
|
|
13
|
+
if (depth > maxDepth) throw new Error("Cannot canonicalize: max depth exceeded");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function canonicalJsonInner(value, seen, depth, maxDepth) {
|
|
17
|
+
requireDepth(depth, maxDepth);
|
|
18
|
+
|
|
19
|
+
if (value === null) return "null";
|
|
20
|
+
if (typeof value === "string") return JSON.stringify(value.normalize("NFC"));
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (!Number.isFinite(value)) throw new Error("Invalid number");
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
if (seen.has(value)) throw new Error("Cannot canonicalize cyclic values");
|
|
29
|
+
seen.add(value);
|
|
30
|
+
try {
|
|
31
|
+
return `[${value.map((item) => canonicalJsonInner(item, seen, depth + 1, maxDepth)).join(",")}]`;
|
|
32
|
+
} finally {
|
|
33
|
+
seen.delete(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value === "object") {
|
|
38
|
+
if (seen.has(value)) throw new Error("Cannot canonicalize cyclic values");
|
|
39
|
+
seen.add(value);
|
|
40
|
+
try {
|
|
41
|
+
const entries = [];
|
|
42
|
+
const normalizedKeys = new Set();
|
|
43
|
+
for (const key of Object.keys(value)) {
|
|
44
|
+
if (value[key] === undefined) continue;
|
|
45
|
+
const normalizedKey = key.normalize("NFC");
|
|
46
|
+
if (normalizedKeys.has(normalizedKey)) throw new Error("Cannot canonicalize duplicate normalized object keys");
|
|
47
|
+
normalizedKeys.add(normalizedKey);
|
|
48
|
+
entries.push([normalizedKey, value[key]]);
|
|
49
|
+
}
|
|
50
|
+
entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
51
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJsonInner(entryValue, seen, depth + 1, maxDepth)}`).join(",")}}`;
|
|
52
|
+
} finally {
|
|
53
|
+
seen.delete(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error("Unsupported value");
|
|
58
|
+
}
|
package/src/chat.cjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { DEFAULT_MAX_MESSAGE_CHARS } = require("./constants.cjs");
|
|
2
|
+
const { createSignedPartyEvent } = require("./signing.cjs");
|
|
3
|
+
const { validateChatMessagePayload } = require("./validation.cjs");
|
|
4
|
+
|
|
5
|
+
function createChatMessageEvent(input) {
|
|
6
|
+
const payload = {
|
|
7
|
+
text: String(input.text || "")
|
|
8
|
+
};
|
|
9
|
+
if (input.displayName !== undefined) payload.displayName = String(input.displayName);
|
|
10
|
+
if (input.replyTo !== undefined) payload.replyTo = String(input.replyTo);
|
|
11
|
+
|
|
12
|
+
const validation = validateChatMessagePayload(payload, DEFAULT_MAX_MESSAGE_CHARS);
|
|
13
|
+
if (!validation.ok) throw new Error(validation.message);
|
|
14
|
+
|
|
15
|
+
return createSignedPartyEvent({
|
|
16
|
+
kind: "chat.message",
|
|
17
|
+
partyId: input.partyId,
|
|
18
|
+
identity: input.identity,
|
|
19
|
+
payload,
|
|
20
|
+
roomSecret: input.roomSecret,
|
|
21
|
+
epochId: input.epochId,
|
|
22
|
+
createdAt: input.createdAt
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
createChatMessageEvent
|
|
28
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const kinds = require("nostr-tools/kinds");
|
|
2
|
+
|
|
3
|
+
const PROTOCOL = "matterhorn-sdk";
|
|
4
|
+
const VERSION = 1;
|
|
5
|
+
const MATTERHORN_NOSTR_KIND_BY_EVENT_KIND = Object.freeze({
|
|
6
|
+
"party.created": kinds.Time,
|
|
7
|
+
"chat.message": kinds.ChannelMessage,
|
|
8
|
+
"chat.delete": kinds.EventDeletion,
|
|
9
|
+
"relay.announce": kinds.RelayList,
|
|
10
|
+
"room.operation": 9001,
|
|
11
|
+
"file.upload": 1063,
|
|
12
|
+
"push.home-relays": 9002,
|
|
13
|
+
"room.key.epoch": 9010,
|
|
14
|
+
"room.key.epoch.grant": 9011,
|
|
15
|
+
"room.index.key.grant": 9012,
|
|
16
|
+
"room.index.ngram": 9013
|
|
17
|
+
});
|
|
18
|
+
const MATTERHORN_EVENT_KIND_BY_NOSTR_KIND = Object.freeze(Object.fromEntries(
|
|
19
|
+
Object.entries(MATTERHORN_NOSTR_KIND_BY_EVENT_KIND).map(([eventKind, nostrKind]) => [nostrKind, eventKind])
|
|
20
|
+
));
|
|
21
|
+
const MATTERHORN_NOSTR_KINDS = Object.freeze(Array.from(new Set(Object.values(MATTERHORN_NOSTR_KIND_BY_EVENT_KIND))));
|
|
22
|
+
const MATTERHORN_NOSTR_KIND = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["chat.message"];
|
|
23
|
+
const MATTERHORN_OPERATION_NOSTR_KIND = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["room.operation"];
|
|
24
|
+
const MATTERHORN_FILE_NOSTR_KIND = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["file.upload"];
|
|
25
|
+
const MATTERHORN_CALL_SIGNAL_NOSTR_KIND = 24242;
|
|
26
|
+
const MATTERHORN_PRESENCE_NOSTR_KIND = 24312;
|
|
27
|
+
const ENCRYPTION_ALG = "A256GCM";
|
|
28
|
+
const MATTERHORN_EVENT_KINDS = new Set(["party.created", "chat.message", "chat.delete", "relay.announce", "room.operation", "file.upload", "push.home-relays", "room.key.epoch", "room.key.epoch.grant", "room.index.key.grant", "room.index.ngram"]);
|
|
29
|
+
const MATTERHORN_PUSH_HOME_RELAYS_NOSTR_KIND = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["push.home-relays"];
|
|
30
|
+
const MATTERHORN_INDEX_NGRAM_NOSTR_KIND = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["room.index.ngram"];
|
|
31
|
+
const MATTERHORN_ID_PATTERN = /^[a-zA-Z0-9_-]{3,128}$/;
|
|
32
|
+
const HEX_32_PATTERN = /^[0-9a-f]{64}$/i;
|
|
33
|
+
const HEX_64_PATTERN = /^[0-9a-f]{128}$/i;
|
|
34
|
+
const DEFAULT_MAX_EVENT_BYTES = 8 * 1024;
|
|
35
|
+
const DEFAULT_MAX_MESSAGE_CHARS = 2000;
|
|
36
|
+
const DEFAULT_MAX_FUTURE_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
DEFAULT_MAX_EVENT_BYTES,
|
|
40
|
+
DEFAULT_MAX_FUTURE_MS,
|
|
41
|
+
DEFAULT_MAX_MESSAGE_CHARS,
|
|
42
|
+
ENCRYPTION_ALG,
|
|
43
|
+
HEX_32_PATTERN,
|
|
44
|
+
HEX_64_PATTERN,
|
|
45
|
+
MATTERHORN_CALL_SIGNAL_NOSTR_KIND,
|
|
46
|
+
MATTERHORN_EVENT_KIND_BY_NOSTR_KIND,
|
|
47
|
+
MATTERHORN_EVENT_KINDS,
|
|
48
|
+
MATTERHORN_FILE_NOSTR_KIND,
|
|
49
|
+
MATTERHORN_INDEX_NGRAM_NOSTR_KIND,
|
|
50
|
+
MATTERHORN_ID_PATTERN,
|
|
51
|
+
MATTERHORN_NOSTR_KIND,
|
|
52
|
+
MATTERHORN_NOSTR_KIND_BY_EVENT_KIND,
|
|
53
|
+
MATTERHORN_NOSTR_KINDS,
|
|
54
|
+
MATTERHORN_OPERATION_NOSTR_KIND,
|
|
55
|
+
MATTERHORN_PUSH_HOME_RELAYS_NOSTR_KIND,
|
|
56
|
+
MATTERHORN_PRESENCE_NOSTR_KIND,
|
|
57
|
+
PROTOCOL,
|
|
58
|
+
VERSION
|
|
59
|
+
};
|
package/src/encoding.cjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
|
|
3
|
+
function sha256Hex(value) {
|
|
4
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function base64url(bytes) {
|
|
8
|
+
return Buffer.from(bytes).toString("base64url");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fromBase64url(value) {
|
|
12
|
+
return Buffer.from(String(value), "base64url");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
base64url,
|
|
17
|
+
fromBase64url,
|
|
18
|
+
sha256Hex
|
|
19
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { canonicalJson } = require("./canonicalJson.cjs");
|
|
3
|
+
const { ENCRYPTION_ALG, MATTERHORN_EVENT_KINDS, PROTOCOL, VERSION } = require("./constants.cjs");
|
|
4
|
+
const { base64url, fromBase64url } = require("./encoding.cjs");
|
|
5
|
+
const { isEncryptedPayload } = require("./payload.cjs");
|
|
6
|
+
const { isValidPartyId } = require("./ids.cjs");
|
|
7
|
+
|
|
8
|
+
const DERIVED_ROOM_KEY_CACHE_MAX = 256;
|
|
9
|
+
const derivedRoomKeyCache = new Map();
|
|
10
|
+
|
|
11
|
+
function derivedRoomKeyCacheKey(roomSecret, partyId, epochId) {
|
|
12
|
+
const secretHash = crypto.createHash("sha256").update(String(roomSecret)).digest("hex");
|
|
13
|
+
return `${secretHash}:${partyId}:${epochId || ""}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setCachedRoomKey(cacheKey, keyBytes) {
|
|
17
|
+
if (derivedRoomKeyCache.has(cacheKey)) derivedRoomKeyCache.delete(cacheKey);
|
|
18
|
+
derivedRoomKeyCache.set(cacheKey, Buffer.from(keyBytes));
|
|
19
|
+
while (derivedRoomKeyCache.size > DERIVED_ROOM_KEY_CACHE_MAX) {
|
|
20
|
+
const oldestKey = derivedRoomKeyCache.keys().next().value;
|
|
21
|
+
derivedRoomKeyCache.delete(oldestKey);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function roomKeySalt(partyId, epochId) {
|
|
26
|
+
return epochId ? `matterhorn:${partyId}:epoch:${epochId}` : `matterhorn:${partyId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function deriveRoomKeyBytes(roomSecret, partyId, epochId) {
|
|
30
|
+
const cacheKey = derivedRoomKeyCacheKey(roomSecret, partyId, epochId);
|
|
31
|
+
const cached = derivedRoomKeyCache.get(cacheKey);
|
|
32
|
+
if (cached) {
|
|
33
|
+
derivedRoomKeyCache.delete(cacheKey);
|
|
34
|
+
derivedRoomKeyCache.set(cacheKey, cached);
|
|
35
|
+
return Buffer.from(cached);
|
|
36
|
+
}
|
|
37
|
+
const keyBytes = crypto.pbkdf2Sync(
|
|
38
|
+
String(roomSecret),
|
|
39
|
+
roomKeySalt(partyId, epochId),
|
|
40
|
+
120000,
|
|
41
|
+
32,
|
|
42
|
+
"sha256"
|
|
43
|
+
);
|
|
44
|
+
setCachedRoomKey(cacheKey, keyBytes);
|
|
45
|
+
return Buffer.from(keyBytes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clearDerivedRoomKeyCache() {
|
|
49
|
+
derivedRoomKeyCache.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function derivedRoomKeyCacheSize() {
|
|
53
|
+
return derivedRoomKeyCache.size;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function legacyEncryptionAad(partyId, kind, epochId) {
|
|
57
|
+
const suffix = epochId ? `:${epochId}` : "";
|
|
58
|
+
return Buffer.from(`${PROTOCOL}:v${VERSION}:${partyId}:${kind}${suffix}`, "utf8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function headerEncryptionAad(header) {
|
|
62
|
+
if (!header || typeof header !== "object") {
|
|
63
|
+
throw new Error("header is required for room.operation v2 AAD");
|
|
64
|
+
}
|
|
65
|
+
// AAD excludes op-id and payload-hash to avoid circularity.
|
|
66
|
+
const aadHeader = {};
|
|
67
|
+
for (const [key, value] of Object.entries(header)) {
|
|
68
|
+
if (key === "opId" || key === "payloadHash") continue;
|
|
69
|
+
aadHeader[key] = value;
|
|
70
|
+
}
|
|
71
|
+
return Buffer.from(canonicalJson(aadHeader), "utf8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function encryptionAad({ partyId, kind, epochId, header }) {
|
|
75
|
+
if (kind === "room.operation" && header) {
|
|
76
|
+
return headerEncryptionAad(header);
|
|
77
|
+
}
|
|
78
|
+
return legacyEncryptionAad(partyId, kind, epochId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function encryptPartyPayload({ roomSecret, partyId, kind, payload, epochId, header }) {
|
|
82
|
+
if (!roomSecret) throw new Error("roomSecret is required to encrypt party payloads");
|
|
83
|
+
if (!isValidPartyId(partyId)) throw new Error("Invalid partyId");
|
|
84
|
+
if (!MATTERHORN_EVENT_KINDS.has(kind)) throw new Error("Invalid party event kind");
|
|
85
|
+
|
|
86
|
+
const iv = crypto.randomBytes(12);
|
|
87
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", deriveRoomKeyBytes(roomSecret, partyId, epochId), iv);
|
|
88
|
+
cipher.setAAD(encryptionAad({ partyId, kind, epochId, header }));
|
|
89
|
+
const plaintext = Buffer.from(canonicalJson(payload), "utf8");
|
|
90
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
encrypted: true,
|
|
94
|
+
alg: ENCRYPTION_ALG,
|
|
95
|
+
epochId,
|
|
96
|
+
iv: base64url(iv),
|
|
97
|
+
data: base64url(ciphertext)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function payloadHashFromCiphertext(dataBase64) {
|
|
102
|
+
const bytes = fromBase64url(dataBase64);
|
|
103
|
+
if (bytes.length < 16) return undefined;
|
|
104
|
+
const ciphertext = bytes.subarray(0, bytes.length - 16);
|
|
105
|
+
return `sha256:${crypto.createHash("sha256").update(ciphertext).digest("hex")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function decryptPartyPayload({ roomSecret, partyId, kind, payload, header }) {
|
|
109
|
+
if (!isEncryptedPayload(payload)) throw new Error("Payload is not encrypted");
|
|
110
|
+
|
|
111
|
+
// For v2 operations, verify the ciphertext matches the header's payload-hash before decrypting.
|
|
112
|
+
if (kind === "room.operation" && header && header.scheme === "matterhorn.operation.v2" && header.payloadHash) {
|
|
113
|
+
const actualHash = payloadHashFromCiphertext(payload.data);
|
|
114
|
+
if (actualHash !== header.payloadHash) {
|
|
115
|
+
throw new Error("payload-hash mismatch: ciphertext does not match header");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bytes = fromBase64url(payload.data);
|
|
120
|
+
if (bytes.length < 17) throw new Error("Invalid encrypted payload");
|
|
121
|
+
|
|
122
|
+
const ciphertext = bytes.subarray(0, bytes.length - 16);
|
|
123
|
+
const authTag = bytes.subarray(bytes.length - 16);
|
|
124
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", deriveRoomKeyBytes(roomSecret, partyId, payload.epochId), fromBase64url(payload.iv));
|
|
125
|
+
decipher.setAAD(encryptionAad({ partyId, kind, epochId: payload.epochId, header }));
|
|
126
|
+
decipher.setAuthTag(authTag);
|
|
127
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
128
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
DERIVED_ROOM_KEY_CACHE_MAX,
|
|
133
|
+
clearDerivedRoomKeyCache,
|
|
134
|
+
decryptPartyPayload,
|
|
135
|
+
deriveRoomKeyBytes,
|
|
136
|
+
derivedRoomKeyCacheSize,
|
|
137
|
+
encryptPartyPayload,
|
|
138
|
+
encryptionAad,
|
|
139
|
+
headerEncryptionAad,
|
|
140
|
+
legacyEncryptionAad
|
|
141
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { isEncryptedPayload } = require("./payload.cjs");
|
|
2
|
+
|
|
3
|
+
function isPartyEventAllowedForKeyEpochs(event, keyEpochs = []) {
|
|
4
|
+
if (!isEncryptedPayload(event?.payload)) return true;
|
|
5
|
+
const epochId = event.payload.epochId;
|
|
6
|
+
if (!epochId) return true;
|
|
7
|
+
const epoch = keyEpochs.find((item) => item.id === epochId);
|
|
8
|
+
if (!epoch) return false;
|
|
9
|
+
return !epoch.retiredAt || event.createdAt <= epoch.retiredAt;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
isPartyEventAllowedForKeyEpochs
|
|
14
|
+
};
|
package/src/identity.cjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { generateSecretKey, getPublicKey } = require("nostr-tools/pure");
|
|
2
|
+
const { bytesToHex } = require("nostr-tools/utils");
|
|
3
|
+
|
|
4
|
+
function generatePartyIdentity(createdAt = Date.now()) {
|
|
5
|
+
const privateKeyBytes = generateSecretKey();
|
|
6
|
+
const privateKey = bytesToHex(privateKeyBytes);
|
|
7
|
+
return {
|
|
8
|
+
pubkey: getPublicKey(privateKeyBytes),
|
|
9
|
+
privateKey,
|
|
10
|
+
createdAt
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
generatePartyIdentity
|
|
16
|
+
};
|
package/src/ids.cjs
ADDED
package/src/index.cjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
...require("./canonicalJson.cjs"),
|
|
3
|
+
...require("./chat.cjs"),
|
|
4
|
+
...require("./constants.cjs"),
|
|
5
|
+
...require("./encoding.cjs"),
|
|
6
|
+
...require("./encryption.cjs"),
|
|
7
|
+
...require("./epochFilter.cjs"),
|
|
8
|
+
...require("./identity.cjs"),
|
|
9
|
+
...require("./ids.cjs"),
|
|
10
|
+
...require("./nostrMapping.cjs"),
|
|
11
|
+
...require("./ngramIndex.cjs"),
|
|
12
|
+
...require("./payload.cjs"),
|
|
13
|
+
...require("./relayPolicy.cjs"),
|
|
14
|
+
...require("./signing.cjs"),
|
|
15
|
+
...require("./validation.cjs")
|
|
16
|
+
};
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from "./canonicalJson";
|
|
2
|
+
export const MATTERHORN_CALL_SIGNAL_NOSTR_KIND: number;
|
|
3
|
+
export const MATTERHORN_FILE_NOSTR_KIND: number;
|
|
4
|
+
export const MATTERHORN_PRESENCE_NOSTR_KIND: number;
|
|
5
|
+
export const MATTERHORN_PUSH_HOME_RELAYS_NOSTR_KIND: number;
|
|
6
|
+
export const MATTERHORN_INDEX_NGRAM_NOSTR_KIND: number;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { MAX_CANONICAL_DEPTH, canonicalJson } from "./canonicalJson.js";
|
|
2
|
+
export const MATTERHORN_CALL_SIGNAL_NOSTR_KIND = 24242;
|
|
3
|
+
export const MATTERHORN_FILE_NOSTR_KIND = 1063;
|
|
4
|
+
export const MATTERHORN_PRESENCE_NOSTR_KIND = 24312;
|
|
5
|
+
export const MATTERHORN_PUSH_HOME_RELAYS_NOSTR_KIND = 9002;
|
|
6
|
+
export const MATTERHORN_INDEX_NGRAM_NOSTR_KIND = 9013;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createSignedPartyEvent,
|
|
3
|
+
partyEventToNostrEvent
|
|
4
|
+
} = require("./index.cjs");
|
|
5
|
+
const { createRoomIndexNgramEvents } = require("./ngramIndex.cjs");
|
|
6
|
+
|
|
7
|
+
function createMatterhornOperationEvent({ identity, operation, roomName, roomSecret, createdAt = Date.now() }) {
|
|
8
|
+
if (!roomSecret) throw new Error("roomSecret is required to create encrypted room operation events");
|
|
9
|
+
|
|
10
|
+
// Build the v2 plaintext header from the operation metadata.
|
|
11
|
+
const header = {
|
|
12
|
+
scheme: "matterhorn.operation.v2",
|
|
13
|
+
opId: operation.opId || operation.id,
|
|
14
|
+
hlc: operation.hlc,
|
|
15
|
+
plugin: operation.pluginId,
|
|
16
|
+
type: operation.type,
|
|
17
|
+
action: operation.schemaAction,
|
|
18
|
+
member: operation.actor?.memberId,
|
|
19
|
+
device: operation.actor?.deviceId,
|
|
20
|
+
role: operation.actor?.role,
|
|
21
|
+
grants: operation.grants,
|
|
22
|
+
epoch: operation.epoch,
|
|
23
|
+
stream: operation.stream,
|
|
24
|
+
seq: operation.seq,
|
|
25
|
+
appPackId: operation.appPackId,
|
|
26
|
+
appPackHash: operation.appPackHash
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Encrypt only the application payload.
|
|
30
|
+
const event = createSignedPartyEvent({
|
|
31
|
+
kind: "room.operation",
|
|
32
|
+
partyId: roomName,
|
|
33
|
+
identity,
|
|
34
|
+
header,
|
|
35
|
+
payload: operation.payload,
|
|
36
|
+
roomSecret,
|
|
37
|
+
epochId: operation.epoch,
|
|
38
|
+
createdAt
|
|
39
|
+
});
|
|
40
|
+
return partyEventToNostrEvent(event);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tagValue(tags, name) {
|
|
44
|
+
if (!Array.isArray(tags)) return undefined;
|
|
45
|
+
const tag = tags.find((item) => Array.isArray(item) && item[0] === name && typeof item[1] === "string");
|
|
46
|
+
return tag?.[1];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function valueAtPath(value, path) {
|
|
50
|
+
if (!path || typeof path !== "string") return undefined;
|
|
51
|
+
let current = value;
|
|
52
|
+
for (const part of path.split(".")) {
|
|
53
|
+
if (!part) continue;
|
|
54
|
+
if (!current || typeof current !== "object") return undefined;
|
|
55
|
+
current = current[part];
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringifySearchValue(value) {
|
|
61
|
+
if (value === undefined || value === null) return "";
|
|
62
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
63
|
+
if (Array.isArray(value)) return value.map(stringifySearchValue).filter(Boolean).join(" ");
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function searchTextFromOptions(operation, options) {
|
|
68
|
+
if (Array.isArray(options.indexText)) return options.indexText.map(stringifySearchValue).filter(Boolean).join(" ");
|
|
69
|
+
if (options.indexText !== undefined) return stringifySearchValue(options.indexText);
|
|
70
|
+
if (Array.isArray(options.indexPaths)) {
|
|
71
|
+
return options.indexPaths
|
|
72
|
+
.map((path) => stringifySearchValue(valueAtPath(operation.payload, path)))
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join(" ");
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createMatterhornOperationEventBundle(options = {}) {
|
|
80
|
+
const event = createMatterhornOperationEvent(options);
|
|
81
|
+
const operation = options.operation || {};
|
|
82
|
+
const text = searchTextFromOptions(operation, options);
|
|
83
|
+
const indexEvents = options.roomIndexKey && text ? createRoomIndexNgramEvents({
|
|
84
|
+
identity: options.identity,
|
|
85
|
+
roomIndexKey: options.roomIndexKey,
|
|
86
|
+
roomName: options.roomName,
|
|
87
|
+
targetEventId: event.id,
|
|
88
|
+
targetOperationId: tagValue(event.tags, "op-id") || operation.opId || operation.id,
|
|
89
|
+
targetHlc: tagValue(event.tags, "hlc") || operation.hlc,
|
|
90
|
+
stream: tagValue(event.tags, "stream") || operation.stream,
|
|
91
|
+
field: Array.isArray(options.indexPaths) ? options.indexPaths.join(",") : options.indexField,
|
|
92
|
+
text,
|
|
93
|
+
minGram: options.minGram,
|
|
94
|
+
maxGram: options.maxGram,
|
|
95
|
+
maxTokensPerEvent: options.maxTokensPerEvent,
|
|
96
|
+
createdAt: (Number.isInteger(options.createdAt) ? options.createdAt : Date.now()) + 1
|
|
97
|
+
}) : [];
|
|
98
|
+
return { event, indexEvents, events: [event, ...indexEvents] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { createMatterhornOperationEvent, createMatterhornOperationEventBundle };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface MatterhornOperationEventOptions {
|
|
2
|
+
identity: unknown;
|
|
3
|
+
operation: {
|
|
4
|
+
id?: string;
|
|
5
|
+
opId?: string;
|
|
6
|
+
hlc?: unknown;
|
|
7
|
+
pluginId?: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
schemaAction?: string;
|
|
10
|
+
actor?: {
|
|
11
|
+
memberId?: string;
|
|
12
|
+
deviceId?: string;
|
|
13
|
+
role?: string;
|
|
14
|
+
};
|
|
15
|
+
grants?: unknown;
|
|
16
|
+
epoch?: unknown;
|
|
17
|
+
stream?: unknown;
|
|
18
|
+
seq?: unknown;
|
|
19
|
+
appPackId?: string;
|
|
20
|
+
appPackHash?: string;
|
|
21
|
+
payload?: unknown;
|
|
22
|
+
};
|
|
23
|
+
roomName: string;
|
|
24
|
+
roomSecret: string;
|
|
25
|
+
createdAt?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MatterhornOperationEventBundleOptions extends MatterhornOperationEventOptions {
|
|
29
|
+
roomIndexKey?: string;
|
|
30
|
+
indexText?: unknown | unknown[];
|
|
31
|
+
indexPaths?: string[];
|
|
32
|
+
indexField?: string;
|
|
33
|
+
minGram?: number;
|
|
34
|
+
maxGram?: number;
|
|
35
|
+
maxTokensPerEvent?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMatterhornOperationEvent(options: MatterhornOperationEventOptions): unknown;
|
|
39
|
+
export function createMatterhornOperationEventBundle(options: MatterhornOperationEventBundleOptions): { event: unknown; indexEvents: unknown[]; events: unknown[] };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const {
|
|
5
|
+
createMatterhornOperationEvent,
|
|
6
|
+
createMatterhornOperationEventBundle
|
|
7
|
+
} = require("./matterhornOperationBuilder.cjs");
|
|
8
|
+
|
|
9
|
+
export { createMatterhornOperationEvent, createMatterhornOperationEventBundle };
|