@mh-gg/protocol 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 ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@mh-gg/protocol",
3
+ "version": "0.1.1-alpha.20260613T085325975Z",
4
+ "description": "Shared Matterhorn wire protocol validators and operation schemas.",
5
+ "type": "commonjs",
6
+ "main": "src/index.cjs",
7
+ "exports": {
8
+ ".": "./src/index.cjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.12"
12
+ },
13
+ "dependencies": {
14
+ "nostr-tools": "^2.23.5",
15
+ "@mh-gg/event": "^0.1.1-alpha.20260613T085325975Z"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.cjs",
19
+ "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"
20
+ }
21
+ }
@@ -0,0 +1,52 @@
1
+ const { MAX_ID_LENGTH, PROTOCOL_VERSION } = require("./constants.cjs");
2
+ const { invalid } = require("./errors.cjs");
3
+
4
+ function isRecord(value) {
5
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ function assertRecord(value, path) {
9
+ if (!isRecord(value)) throw invalid(path, "must be an object");
10
+ return value;
11
+ }
12
+
13
+ function assertString(value, path, maxLength = MAX_ID_LENGTH) {
14
+ if (typeof value !== "string" || value.length === 0) throw invalid(path, "must be a non-empty string");
15
+ if (value.length > maxLength) throw invalid(path, "is too large");
16
+ return value;
17
+ }
18
+
19
+ function assertOptionalString(value, path, maxLength = MAX_ID_LENGTH) {
20
+ if (value === undefined) return undefined;
21
+ return assertString(value, path, maxLength);
22
+ }
23
+
24
+ function assertNumber(value, path) {
25
+ if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(path, "must be a finite number");
26
+ return value;
27
+ }
28
+
29
+ function assertInteger(value, path, min = 0) {
30
+ if (!Number.isInteger(value) || value < min) throw invalid(path, `must be an integer >= ${min}`);
31
+ return value;
32
+ }
33
+
34
+ function assertArray(value, path) {
35
+ if (!Array.isArray(value)) throw invalid(path, "must be an array");
36
+ return value;
37
+ }
38
+
39
+ function assertProtocol(value, path) {
40
+ if (value.protocol !== PROTOCOL_VERSION) throw invalid(path, "protocol is invalid");
41
+ }
42
+
43
+ module.exports = {
44
+ assertArray,
45
+ assertInteger,
46
+ assertNumber,
47
+ assertOptionalString,
48
+ assertProtocol,
49
+ assertRecord,
50
+ assertString,
51
+ isRecord
52
+ };
@@ -0,0 +1,35 @@
1
+ const { MAX_CANONICAL_DEPTH } = require("@mh-gg/event/canonicalJson");
2
+
3
+ const PROTOCOL_VERSION = 1;
4
+ const MAX_ID_LENGTH = 240;
5
+ const MAX_RELAY_ADDRESS_LENGTH = 2048;
6
+ const MATTERHORN_OPERATION_KIND = "matterhorn.operation";
7
+ const MATTERHORN_OPERATION_VERSION = 1;
8
+ const LEGACY_ROOM_OPERATION_KIND = "matterhorn.room-operation";
9
+ const LEGACY_ROOM_OPERATION_VERSION = 1;
10
+ const HOST_OPERATION_BATCH_KIND = "matterhorn.host-operation-batch";
11
+ const HOST_SNAPSHOT_KIND = "matterhorn.host-snapshot";
12
+ const MAX_OPERATION_BYTES = 64 * 1024;
13
+ const MAX_OPERATION_DEPTH = MAX_CANONICAL_DEPTH;
14
+ const MAX_PUSH_PAYLOAD_BYTES = 3 * 1024;
15
+ const CLIENT_PUSH_GRANT = "client/push-grant";
16
+ const CLIENT_PUSH_REGISTER = "client/push-register";
17
+ const RELAY_PUSH = "relay.push";
18
+
19
+ module.exports = {
20
+ HOST_OPERATION_BATCH_KIND,
21
+ HOST_SNAPSHOT_KIND,
22
+ CLIENT_PUSH_GRANT,
23
+ CLIENT_PUSH_REGISTER,
24
+ LEGACY_ROOM_OPERATION_KIND,
25
+ LEGACY_ROOM_OPERATION_VERSION,
26
+ MAX_ID_LENGTH,
27
+ MAX_OPERATION_BYTES,
28
+ MAX_OPERATION_DEPTH,
29
+ MAX_PUSH_PAYLOAD_BYTES,
30
+ MAX_RELAY_ADDRESS_LENGTH,
31
+ PROTOCOL_VERSION,
32
+ MATTERHORN_OPERATION_KIND,
33
+ RELAY_PUSH,
34
+ MATTERHORN_OPERATION_VERSION
35
+ };
package/src/errors.cjs ADDED
@@ -0,0 +1,23 @@
1
+ class MatterhornProtocolError extends Error {
2
+ constructor(message, details = {}) {
3
+ super(message);
4
+ this.name = "MatterhornProtocolError";
5
+ this.code = details.code || "invalid-message";
6
+ this.path = details.path;
7
+ }
8
+ }
9
+
10
+ function invalid(path, message) {
11
+ return new MatterhornProtocolError(`${path}: ${message}`, { path });
12
+ }
13
+
14
+ function safeValidate(fn, value, ...args) {
15
+ try {
16
+ return { ok: true, value: fn(value, ...args) };
17
+ } catch (error) {
18
+ if (error instanceof MatterhornProtocolError) return { ok: false, code: error.code, message: error.message, path: error.path };
19
+ return { ok: false, code: "invalid-message", message: error?.message || String(error) };
20
+ }
21
+ }
22
+
23
+ module.exports = { MatterhornProtocolError, invalid, safeValidate };
package/src/index.cjs ADDED
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ ...require("./constants.cjs"),
3
+ ...require("./errors.cjs"),
4
+ ...require("./validators/shared.cjs"),
5
+ ...require("./validators/bounds.cjs"),
6
+ ...require("./validators/operations.cjs"),
7
+ ...require("./validators/client.cjs"),
8
+ ...require("./validators/host.cjs"),
9
+ ...require("./validators/relay.cjs"),
10
+ ...require("./operations/identity.cjs"),
11
+ ...require("./operations/snowflake.cjs"),
12
+ ...require("./operations/roomDeviceSigning.cjs"),
13
+ ...require("./parsers/hostOperationBatch.cjs")
14
+ };
@@ -0,0 +1,141 @@
1
+ const crypto = require("node:crypto");
2
+ const { canonicalJson } = require("@mh-gg/event/canonicalJson");
3
+
4
+ const HLC_PHYSICAL_WIDTH = 15;
5
+ const HLC_LOGICAL_WIDTH = 6;
6
+ const DEFAULT_MAX_HLC_FUTURE_SKEW_MS = 5 * 60 * 1000;
7
+ const HLC_RE = /^([0-9]{15}):([0-9]{6}):([A-Za-z0-9_.:-]{1,128})$/;
8
+
9
+ function codePointCompare(left = "", right = "") {
10
+ const a = String(left);
11
+ const b = String(right);
12
+ return a < b ? -1 : a > b ? 1 : 0;
13
+ }
14
+
15
+ function padNumber(value, width, name) {
16
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative safe integer`);
17
+ const text = String(value);
18
+ if (text.length > width) throw new Error(`${name} exceeds HLC width`);
19
+ return text.padStart(width, "0");
20
+ }
21
+
22
+ function parseHlc(hlc) {
23
+ if (typeof hlc !== "string") return undefined;
24
+ const match = HLC_RE.exec(hlc);
25
+ if (!match) return undefined;
26
+ return { physical: Number(match[1]), logical: Number(match[2]), nodeId: match[3], value: hlc };
27
+ }
28
+
29
+ function formatHlc({ physical, logical = 0, nodeId } = {}) {
30
+ if (typeof nodeId !== "string" || nodeId.length === 0 || !/^[A-Za-z0-9_.:-]{1,128}$/.test(nodeId)) throw new Error("nodeId is invalid");
31
+ return `${padNumber(physical, HLC_PHYSICAL_WIDTH, "physical")}:${padNumber(logical, HLC_LOGICAL_WIDTH, "logical")}:${nodeId}`;
32
+ }
33
+
34
+ function validateHlc(hlc, options = {}) {
35
+ const parsed = parseHlc(hlc);
36
+ if (!parsed) return { ok: false, error: "operation.hlc is invalid" };
37
+ const now = Number.isFinite(options.now) ? options.now : (typeof options.now === "function" ? options.now() : Date.now());
38
+ const skew = Number.isFinite(options.maxFutureSkewMs) ? options.maxFutureSkewMs : DEFAULT_MAX_HLC_FUTURE_SKEW_MS;
39
+ if (Number.isFinite(now) && parsed.physical > now + skew) return { ok: false, error: "operation.hlc exceeds allowed future skew" };
40
+ return { ok: true, parsed };
41
+ }
42
+
43
+ function compareHlc(left, right) { return codePointCompare(left, right); }
44
+ function maxHlc(left, right) {
45
+ if (!left) return right || "";
46
+ if (!right) return left || "";
47
+ return compareHlc(left, right) >= 0 ? left : right;
48
+ }
49
+ function advanceHlc(lastSeen, { now = Date.now(), nodeId = "node" } = {}) {
50
+ const observed = parseHlc(lastSeen);
51
+ const wall = Number.isFinite(Number(now)) ? Math.max(0, Math.floor(Number(now))) : 0;
52
+ const physical = Math.max(wall, observed?.physical || 0);
53
+ const logical = observed && physical === observed.physical ? observed.logical + 1 : 0;
54
+ return formatHlc({ physical, logical, nodeId });
55
+ }
56
+ function createHlcClock({ nodeId = "node", now = () => Date.now(), initialHlc } = {}) {
57
+ let lastSeen = parseHlc(initialHlc)?.value || "";
58
+ return {
59
+ observe(hlc) { const parsed = parseHlc(hlc); if (parsed) lastSeen = maxHlc(lastSeen, parsed.value); return lastSeen; },
60
+ next() { lastSeen = advanceHlc(lastSeen, { now: typeof now === "function" ? now() : now, nodeId }); return lastSeen; },
61
+ lastSeen() { return lastSeen; }
62
+ };
63
+ }
64
+
65
+ function clone(value) {
66
+ if (value === undefined) return undefined;
67
+ const encoded = JSON.stringify(value);
68
+ return encoded === undefined ? undefined : JSON.parse(encoded);
69
+ }
70
+ function unsignedOperationBodyForId(operation) {
71
+ const next = clone(operation || {});
72
+ delete next.id;
73
+ delete next.committedRoomVersion;
74
+ delete next.committedAt;
75
+ delete next.receivedAt;
76
+ delete next.relayId;
77
+ delete next.ledgerId;
78
+ delete next.snowflakeId;
79
+ if (next.auth && typeof next.auth === "object") {
80
+ delete next.auth.signature;
81
+ delete next.auth.signatureEventId;
82
+ delete next.auth.eventId;
83
+ delete next.auth.signedAt;
84
+ }
85
+ return next;
86
+ }
87
+ function operationContentHash(operation) {
88
+ return `sha256:${crypto.createHash("sha256").update(canonicalJson(unsignedOperationBodyForId(operation))).digest("hex")}`;
89
+ }
90
+ function unsignedHeaderForId(header) {
91
+ const next = {};
92
+ for (const [key, value] of Object.entries(header || {})) {
93
+ if (key === "opId") continue;
94
+ if (value !== undefined) next[key] = value;
95
+ }
96
+ return next;
97
+ }
98
+ function operationHeaderHash(header) {
99
+ return `sha256:${crypto.createHash("sha256").update(canonicalJson(unsignedHeaderForId(header))).digest("hex")}`;
100
+ }
101
+ function operationHash(operation, header) {
102
+ if (header && (header.scheme === "matterhorn.operation.v2" || operation?.scheme === "matterhorn.operation.v2")) {
103
+ return operationHeaderHash(header);
104
+ }
105
+ return operationContentHash(operation);
106
+ }
107
+ function assignOperationId(operation, header) { const next = clone(operation || {}); next.id = operationHash(next, header); return next; }
108
+ function operationIdMatchesContent(operation, header) { return Boolean(operation && typeof operation === "object" && operation.id === operationHash(operation, header)); }
109
+ function ensureOperationIdentity(operation, options = {}) {
110
+ const next = clone(operation || {});
111
+ if (!parseHlc(next.hlc)) {
112
+ next.hlc = advanceHlc(options.lastSeenHlc, {
113
+ now: typeof options.now === "function" ? options.now() : (Number.isFinite(options.now) ? options.now : Date.now()),
114
+ nodeId: options.nodeId || next.actor?.deviceId || next.actor?.memberId || "node"
115
+ });
116
+ }
117
+ next.id = operationHash(next, options.header);
118
+ return next;
119
+ }
120
+
121
+ module.exports = {
122
+ DEFAULT_MAX_HLC_FUTURE_SKEW_MS,
123
+ HLC_LOGICAL_WIDTH,
124
+ HLC_PHYSICAL_WIDTH,
125
+ advanceHlc,
126
+ assignOperationId,
127
+ codePointCompare,
128
+ compareHlc,
129
+ createHlcClock,
130
+ ensureOperationIdentity,
131
+ formatHlc,
132
+ maxHlc,
133
+ operationContentHash,
134
+ operationHeaderHash,
135
+ operationHash,
136
+ operationIdMatchesContent,
137
+ parseHlc,
138
+ unsignedHeaderForId,
139
+ unsignedOperationBodyForId,
140
+ validateHlc
141
+ };
@@ -0,0 +1,159 @@
1
+ const { canonicalJson } = require("@mh-gg/event/canonicalJson");
2
+ const {
3
+ MATTERHORN_DEVICE_SIGNATURE_NOSTR_KIND,
4
+ MATTERHORN_ROOM_DEVICE_SIGNING_ALG,
5
+ MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME
6
+ } = require("./roomDeviceSigningConstants.cjs");
7
+ const {
8
+ validateRoomDeviceSignedOperationAuth,
9
+ validateRoomMemberKeyClaim
10
+ } = require("./roomDeviceSigningValidation.cjs");
11
+
12
+ function clone(value) {
13
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
14
+ }
15
+
16
+ function eventForSignature({ purpose, roomName, memberId, deviceId, keyId, publicKey, content, eventId, sig, createdAt }) {
17
+ return {
18
+ kind: MATTERHORN_DEVICE_SIGNATURE_NOSTR_KIND,
19
+ created_at: createdAt || Math.floor(Date.now() / 1000),
20
+ tags: [
21
+ ["protocol", "matterhorn-sdk"],
22
+ ["scheme", MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME],
23
+ ["purpose", purpose],
24
+ ["room", roomName],
25
+ ["member", memberId],
26
+ ["device", deviceId],
27
+ ["key", keyId]
28
+ ],
29
+ content,
30
+ pubkey: publicKey,
31
+ id: eventId || "",
32
+ sig: sig || ""
33
+ };
34
+ }
35
+
36
+ function verifyEvent(event) {
37
+ const nostr = require("nostr-tools");
38
+ if (typeof nostr.verifyEvent === "function") return nostr.verifyEvent(event) === true;
39
+ if (typeof nostr.validateEvent === "function" && typeof nostr.verifySignature === "function") return nostr.validateEvent(event) && nostr.verifySignature(event);
40
+ throw new Error("nostr-tools does not expose a supported event verifier");
41
+ }
42
+
43
+ function unsignedRoomOperationForDeviceSignature(operation) {
44
+ const next = clone(operation || {});
45
+ if (next.auth && typeof next.auth === "object") {
46
+ delete next.auth.signature;
47
+ delete next.auth.signatureEventId;
48
+ delete next.auth.eventId;
49
+ delete next.auth.signedAt;
50
+ }
51
+ delete next.ledgerId;
52
+ delete next.snowflakeId;
53
+ delete next.committedRoomVersion;
54
+ delete next.committedAt;
55
+ delete next.receivedAt;
56
+ delete next.relayId;
57
+ // For matterhorn.operation.v2, the device signature binds the plaintext header so that
58
+ // tampering with any header tag invalidates the device proof.
59
+ if (next.header && next.header.scheme === "matterhorn.operation.v2") {
60
+ return { header: next.header, payloadHash: next.header.payloadHash };
61
+ }
62
+ return next;
63
+ }
64
+
65
+ function unsignedMemberKeyClaim(claim) {
66
+ const next = clone(claim || {});
67
+ delete next.proof;
68
+ delete next.rootProof;
69
+ return next;
70
+ }
71
+
72
+ function verifyRoomMemberKeyClaim(claim) {
73
+ const validation = validateRoomMemberKeyClaim(claim);
74
+ if (!validation.ok) return validation;
75
+ const content = canonicalJson(unsignedMemberKeyClaim(claim));
76
+ try {
77
+ const rootOk = verifyEvent(eventForSignature({
78
+ purpose: "member-key",
79
+ roomName: validation.roomName,
80
+ memberId: claim.memberId,
81
+ deviceId: claim.deviceId,
82
+ keyId: claim.keyId,
83
+ publicKey: claim.rootPublicKey,
84
+ content,
85
+ eventId: claim.rootProof.eventId,
86
+ sig: claim.rootProof.sig,
87
+ createdAt: claim.rootProof.createdAt
88
+ }));
89
+ if (!rootOk) return { ok: false, error: "member key root proof is invalid" };
90
+ const deviceOk = verifyEvent(eventForSignature({
91
+ purpose: "member-key",
92
+ roomName: validation.roomName,
93
+ memberId: claim.memberId,
94
+ deviceId: claim.deviceId,
95
+ keyId: claim.keyId,
96
+ publicKey: claim.publicKey,
97
+ content,
98
+ eventId: claim.proof.eventId,
99
+ sig: claim.proof.sig,
100
+ createdAt: claim.proof.createdAt
101
+ }));
102
+ return deviceOk ? { ok: true, claim } : { ok: false, error: "member key device proof is invalid" };
103
+ } catch (error) {
104
+ return { ok: false, error: error && error.message ? error.message : String(error) };
105
+ }
106
+ }
107
+
108
+ function verifyRoomDeviceKeyClaim(claim) {
109
+ return verifyRoomMemberKeyClaim(claim).ok;
110
+ }
111
+
112
+ function verifyRoomDeviceOperationSignature(operation, claim) {
113
+ return verifyRoomDeviceSignedOperation(operation, claim);
114
+ }
115
+
116
+ function verifyRoomDeviceSignedOperation(operation, claim = operation && operation.auth && operation.auth.claim) {
117
+ const authValidation = validateRoomDeviceSignedOperationAuth(operation);
118
+ if (!authValidation.ok) return authValidation;
119
+ const auth = authValidation.auth;
120
+ const memberClaim = claim || auth.claim;
121
+ const claimVerification = verifyRoomMemberKeyClaim(memberClaim);
122
+ if (!claimVerification.ok) return claimVerification;
123
+ const roomName = String(operation.roomId || memberClaim.roomName || memberClaim.roomId || "");
124
+ if (roomName.length === 0) return { ok: false, error: "operation room is required" };
125
+ if (memberClaim.keyId !== auth.keyId) return { ok: false, error: "operation key does not match member key claim" };
126
+ if (memberClaim.memberId !== auth.memberId || memberClaim.deviceId !== auth.deviceId) return { ok: false, error: "operation actor does not match member key claim" };
127
+ if (memberClaim.rootPublicKey !== auth.rootPublicKey) return { ok: false, error: "operation root public key does not match member key claim" };
128
+ if (memberClaim.publicKey !== auth.publicKey || memberClaim.publicKeyFingerprint !== auth.publicKeyFingerprint) return { ok: false, error: "operation public key does not match member key claim" };
129
+ try {
130
+ const ok = verifyEvent(eventForSignature({
131
+ purpose: "operation",
132
+ roomName,
133
+ memberId: auth.memberId,
134
+ deviceId: auth.deviceId,
135
+ keyId: auth.keyId,
136
+ publicKey: auth.publicKey,
137
+ content: canonicalJson(unsignedRoomOperationForDeviceSignature(operation)),
138
+ eventId: authValidation.eventId,
139
+ sig: auth.signature,
140
+ createdAt: auth.signedAt
141
+ }));
142
+ return ok ? { ok: true, auth, claim: memberClaim } : { ok: false, error: "operation room-device signature is invalid" };
143
+ } catch (error) {
144
+ return { ok: false, error: error && error.message ? error.message : String(error) };
145
+ }
146
+ }
147
+
148
+ module.exports = {
149
+ MATTERHORN_DEVICE_SIGNATURE_NOSTR_KIND,
150
+ MATTERHORN_ROOM_DEVICE_SIGNING_ALG,
151
+ MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME,
152
+ unsignedRoomOperationForDeviceSignature,
153
+ validateRoomDeviceSignedOperationAuth,
154
+ validateRoomMemberKeyClaim,
155
+ verifyRoomDeviceKeyClaim,
156
+ verifyRoomDeviceOperationSignature,
157
+ verifyRoomDeviceSignedOperation,
158
+ verifyRoomMemberKeyClaim
159
+ };
@@ -0,0 +1,9 @@
1
+ const MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME = "matterhorn.device-signing.v1";
2
+ const MATTERHORN_ROOM_DEVICE_SIGNING_ALG = "nostr-secp256k1";
3
+ const MATTERHORN_DEVICE_SIGNATURE_NOSTR_KIND = 24313;
4
+
5
+ module.exports = {
6
+ MATTERHORN_DEVICE_SIGNATURE_NOSTR_KIND,
7
+ MATTERHORN_ROOM_DEVICE_SIGNING_ALG,
8
+ MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME
9
+ };
@@ -0,0 +1,63 @@
1
+ const {
2
+ MATTERHORN_ROOM_DEVICE_SIGNING_ALG,
3
+ MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME
4
+ } = require("./roomDeviceSigningConstants.cjs");
5
+
6
+ function isRecord(value) {
7
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+
10
+ function isHex(value, length) {
11
+ return typeof value === "string" && value.length === length && new RegExp(`^[0-9a-f]{${length}}$`, "i").test(value);
12
+ }
13
+
14
+ function validateRoomMemberKeyClaim(claim) {
15
+ if (!isRecord(claim)) return { ok: false, error: "member key claim is required" };
16
+ if (claim.kind !== "matterhorn.member-key-claim") return { ok: false, error: "member key claim kind is invalid" };
17
+ if (claim.type !== undefined && claim.type !== "matterhorn/member-key/v1") return { ok: false, error: "member key claim type is invalid" };
18
+ if (claim.version !== 1) return { ok: false, error: "member key claim version is invalid" };
19
+ if (claim.scheme !== MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) return { ok: false, error: "member key claim scheme is invalid" };
20
+ if (claim.alg !== MATTERHORN_ROOM_DEVICE_SIGNING_ALG) return { ok: false, error: "member key claim alg is invalid" };
21
+ for (const field of ["memberId", "deviceId", "keyId", "rootPublicKey", "publicKey", "publicKeyFingerprint", "encryptionKeyId", "encryptionPublicKey"]) {
22
+ if (typeof claim[field] !== "string" || claim[field].length === 0) return { ok: false, error: `member key claim ${field} is required` };
23
+ }
24
+ if (claim.encryptionAlg !== "x25519") return { ok: false, error: "member key claim encryptionAlg is invalid" };
25
+ const roomName = claim.roomName || claim.roomId;
26
+ if (typeof roomName !== "string" || roomName.length === 0) return { ok: false, error: "member key claim room is required" };
27
+ if (!isHex(claim.rootPublicKey, 64)) return { ok: false, error: "member key claim rootPublicKey is invalid" };
28
+ if (!isHex(claim.publicKey, 64)) return { ok: false, error: "member key claim publicKey is invalid" };
29
+ if (!Number.isFinite(Number(claim.createdAt))) return { ok: false, error: "member key claim createdAt is invalid" };
30
+ if (!isRecord(claim.rootProof)) return { ok: false, error: "member key root proof is required" };
31
+ if (claim.rootProof.alg !== MATTERHORN_ROOM_DEVICE_SIGNING_ALG) return { ok: false, error: "member key root proof alg is invalid" };
32
+ if (!isHex(claim.rootProof.eventId, 64) || !isHex(claim.rootProof.sig, 128)) return { ok: false, error: "member key root proof shape is invalid" };
33
+ if (!isRecord(claim.proof)) return { ok: false, error: "member key device proof is required" };
34
+ if (claim.proof.alg !== MATTERHORN_ROOM_DEVICE_SIGNING_ALG) return { ok: false, error: "member key device proof alg is invalid" };
35
+ if (!isHex(claim.proof.eventId, 64) || !isHex(claim.proof.sig, 128)) return { ok: false, error: "member key device proof shape is invalid" };
36
+ return { ok: true, claim, roomName };
37
+ }
38
+
39
+ function validateRoomDeviceSignedOperationAuth(operation) {
40
+ if (!isRecord(operation)) return { ok: false, error: "operation is required" };
41
+ const auth = operation.auth;
42
+ if (!isRecord(auth)) return { ok: false, error: "operation auth is required" };
43
+ if (auth.scheme !== MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) return { ok: false, error: "operation auth scheme is invalid" };
44
+ if (auth.alg !== MATTERHORN_ROOM_DEVICE_SIGNING_ALG) return { ok: false, error: "operation auth alg is invalid" };
45
+ if (auth.kind !== undefined && auth.kind !== "matterhorn.operation-device-signature") return { ok: false, error: "operation auth kind is invalid" };
46
+ for (const field of ["memberId", "deviceId", "keyId", "credentialId", "rootPublicKey", "publicKey", "publicKeyFingerprint", "signature"]) {
47
+ if (typeof auth[field] !== "string" || auth[field].length === 0) return { ok: false, error: `operation auth ${field} is required` };
48
+ }
49
+ const eventId = auth.signatureEventId || auth.eventId;
50
+ if (!isHex(auth.rootPublicKey, 64) || !isHex(auth.publicKey, 64) || !isHex(auth.signature, 128) || !isHex(eventId, 64)) return { ok: false, error: "operation device signature shape is invalid" };
51
+ if (!Number.isFinite(Number(auth.issuedAt))) return { ok: false, error: "operation auth issuedAt is invalid" };
52
+ if (!Number.isFinite(Number(auth.signedAt))) return { ok: false, error: "operation auth signedAt is invalid" };
53
+ if (auth.credentialId !== auth.keyId) return { ok: false, error: "operation auth credentialId must match keyId" };
54
+ const actor = operation.actor || {};
55
+ if (actor.memberId !== auth.memberId) return { ok: false, error: "operation auth member mismatch" };
56
+ if (actor.deviceId !== auth.deviceId) return { ok: false, error: "operation auth device mismatch" };
57
+ return { ok: true, auth, eventId };
58
+ }
59
+
60
+ module.exports = {
61
+ validateRoomDeviceSignedOperationAuth,
62
+ validateRoomMemberKeyClaim
63
+ };