@mh-gg/relay-runtime 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 +26 -0
- package/src/encryptedRoomRuntime.cjs +641 -0
- package/src/encryptedRoomRuntimeManager.cjs +131 -0
- package/src/index.cjs +152 -0
- package/src/pluginRuntimeHost.cjs +779 -0
- package/test/encryptedRoomRuntime.test.cjs +729 -0
- package/test/operation-role-keys.test.cjs +346 -0
- package/test/plugin-runtime-manager.test.cjs +651 -0
- package/test/relay-runtime.test.cjs +219 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
5
|
+
const {
|
|
6
|
+
createOperationGrantAuthority,
|
|
7
|
+
createOperationRoleKeyGrant,
|
|
8
|
+
generateOperationGrantAuthorityKeyPair,
|
|
9
|
+
generateOperationRoleKeyPair,
|
|
10
|
+
signOperationRoleKeyGrant,
|
|
11
|
+
signOperationWithRoleKey
|
|
12
|
+
} = require("@mh-gg/host-runtime");
|
|
13
|
+
const { createRelayPluginRuntimeManager } = require("../src/index.cjs");
|
|
14
|
+
const { kanbanAppPack, kanbanHostPlugins, KANBAN_PLUGIN_ID } = require("../../../examples/kanban/src/sdk-app.mjs");
|
|
15
|
+
|
|
16
|
+
const ROOM_ID = "room_relay_role_keys";
|
|
17
|
+
const APP_HASH = manifestHash(kanbanAppPack);
|
|
18
|
+
|
|
19
|
+
function actor(role = "member", memberId = "member") {
|
|
20
|
+
return { memberId, deviceId: `dev_${memberId}`, role, displayName: memberId };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function operation(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
clientOperationId: overrides.id || "op_1",
|
|
26
|
+
roomId: ROOM_ID,
|
|
27
|
+
appPackId: kanbanAppPack.id,
|
|
28
|
+
appPackHash: APP_HASH,
|
|
29
|
+
pluginId: overrides.pluginId || KANBAN_PLUGIN_ID,
|
|
30
|
+
type: overrides.type || "list.create",
|
|
31
|
+
actor: overrides.actor || actor("admin", "alice"),
|
|
32
|
+
seq: overrides.seq || 1,
|
|
33
|
+
createdAt: overrides.createdAt || 1000,
|
|
34
|
+
payload: overrides.payload || { title: "Backlog" },
|
|
35
|
+
auth: { credentialId: overrides.credentialId || "placeholder", signature: "placeholder" },
|
|
36
|
+
...overrides
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeKeys(options = {}) {
|
|
41
|
+
const appPackHash = options.appPackHash || APP_HASH;
|
|
42
|
+
const authorityKey = generateOperationGrantAuthorityKeyPair({ issuer: "room-owner" });
|
|
43
|
+
const authority = createOperationGrantAuthority({
|
|
44
|
+
issuer: authorityKey.issuer,
|
|
45
|
+
roomId: ROOM_ID,
|
|
46
|
+
appPackId: kanbanAppPack.id,
|
|
47
|
+
appPackHash,
|
|
48
|
+
publicKeyPem: authorityKey.publicKeyPem,
|
|
49
|
+
issuedAt: 50
|
|
50
|
+
});
|
|
51
|
+
const adminKey = generateOperationRoleKeyPair({ role: "admin" });
|
|
52
|
+
const userKey = generateOperationRoleKeyPair({ role: "user" });
|
|
53
|
+
const unsignedAdminGrant = createOperationRoleKeyGrant({
|
|
54
|
+
roomId: ROOM_ID,
|
|
55
|
+
appPackId: kanbanAppPack.id,
|
|
56
|
+
appPackHash,
|
|
57
|
+
credentialId: "admin_cred",
|
|
58
|
+
memberId: "alice",
|
|
59
|
+
deviceId: "dev_alice",
|
|
60
|
+
role: "admin",
|
|
61
|
+
publicKeyPem: adminKey.publicKeyPem,
|
|
62
|
+
issuedAt: 100
|
|
63
|
+
});
|
|
64
|
+
const unsignedUserGrant = createOperationRoleKeyGrant({
|
|
65
|
+
roomId: ROOM_ID,
|
|
66
|
+
appPackId: kanbanAppPack.id,
|
|
67
|
+
appPackHash,
|
|
68
|
+
credentialId: "user_cred",
|
|
69
|
+
memberId: "bob",
|
|
70
|
+
deviceId: "dev_bob",
|
|
71
|
+
role: "user",
|
|
72
|
+
publicKeyPem: userKey.publicKeyPem,
|
|
73
|
+
issuedAt: 100
|
|
74
|
+
});
|
|
75
|
+
const adminGrant = options.unsigned ? unsignedAdminGrant : signOperationRoleKeyGrant(unsignedAdminGrant, { authority, privateKeyPem: authorityKey.privateKeyPem, signedAt: 150 });
|
|
76
|
+
const userGrant = options.unsigned ? unsignedUserGrant : signOperationRoleKeyGrant(unsignedUserGrant, { authority, privateKeyPem: authorityKey.privateKeyPem, signedAt: 150 });
|
|
77
|
+
return { authorityKey, authority, adminKey, userKey, adminGrant, userGrant, unsignedAdminGrant, unsignedUserGrant, grants: [adminGrant, userGrant], unsignedGrants: [unsignedAdminGrant, unsignedUserGrant], grantAuthorities: [authority] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sign(op, key, grant) {
|
|
81
|
+
return signOperationWithRoleKey({ ...op, auth: { credentialId: grant.credentialId } }, { privateKeyPem: key.privateKeyPem, grant, now: () => op.createdAt || 1000 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function enableRelay(keys) {
|
|
85
|
+
const manager = createRelayPluginRuntimeManager({ relayAddress: "relay-role-key", now: () => 5000 });
|
|
86
|
+
await manager.enableRoomComposition({
|
|
87
|
+
roomName: ROOM_ID,
|
|
88
|
+
appPack: kanbanAppPack,
|
|
89
|
+
plugins: kanbanHostPlugins,
|
|
90
|
+
operationRoleKeyGrants: keys.grants,
|
|
91
|
+
operationGrantAuthorities: keys.grantAuthorities
|
|
92
|
+
});
|
|
93
|
+
return manager;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("relay runtime verifies role-key operation signatures without private/master keys", async () => {
|
|
97
|
+
const keys = makeKeys();
|
|
98
|
+
const manager = await enableRelay(keys);
|
|
99
|
+
const app = manager.roomApp(ROOM_ID);
|
|
100
|
+
assert.equal(JSON.stringify(app).includes("PRIVATE KEY"), false);
|
|
101
|
+
assert.equal(app.operationAuth.grants.length, 2);
|
|
102
|
+
assert.equal(app.operationAuth.authorities.length, 1);
|
|
103
|
+
assert.equal(JSON.stringify(app.operationAuth).includes("PRIVATE KEY"), false);
|
|
104
|
+
|
|
105
|
+
const forged = sign(operation({ id: "op_forged", actor: actor("admin", "bob") }), keys.userKey, keys.userGrant);
|
|
106
|
+
const rejected = await manager.handleClientOperation({
|
|
107
|
+
roomName: ROOM_ID,
|
|
108
|
+
peerId: "bob-peer",
|
|
109
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "bob", operation: forged },
|
|
110
|
+
sendToClient: () => {},
|
|
111
|
+
broadcastToRoom: () => {}
|
|
112
|
+
});
|
|
113
|
+
assert.equal(rejected.ok, false);
|
|
114
|
+
assert.match(rejected.reason, /Admins only|Forbidden|Operation is not allowed|requires admin/);
|
|
115
|
+
|
|
116
|
+
const adminList = sign(operation({ id: "op_admin", actor: actor("admin", "alice") }), keys.adminKey, keys.adminGrant);
|
|
117
|
+
assert.equal((await manager.handleClientOperation({
|
|
118
|
+
roomName: ROOM_ID,
|
|
119
|
+
peerId: "alice-peer",
|
|
120
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "alice", operation: adminList },
|
|
121
|
+
sendToClient: () => {},
|
|
122
|
+
broadcastToRoom: () => {}
|
|
123
|
+
})).ok, true);
|
|
124
|
+
|
|
125
|
+
const listId = (await manager.snapshot(ROOM_ID)).state.plugins[KANBAN_PLUGIN_ID].lists[0].id;
|
|
126
|
+
const userCard = sign(operation({
|
|
127
|
+
id: "op_user_card",
|
|
128
|
+
seq: 2,
|
|
129
|
+
type: "card.create",
|
|
130
|
+
actor: actor("admin", "bob"),
|
|
131
|
+
payload: { listId, title: "Relay accepted user-key member write" },
|
|
132
|
+
createdAt: 1001
|
|
133
|
+
}), keys.userKey, keys.userGrant);
|
|
134
|
+
assert.equal((await manager.handleClientOperation({
|
|
135
|
+
roomName: ROOM_ID,
|
|
136
|
+
peerId: "bob-peer",
|
|
137
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "bob", operation: userCard },
|
|
138
|
+
sendToClient: () => {},
|
|
139
|
+
broadcastToRoom: () => {}
|
|
140
|
+
})).ok, true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("relay-to-relay portable operations reject tampered role-key signatures", async () => {
|
|
144
|
+
const keys = makeKeys();
|
|
145
|
+
const relayA = await enableRelay(keys);
|
|
146
|
+
const relayB = await enableRelay(keys);
|
|
147
|
+
const envelopes = [];
|
|
148
|
+
const adminList = sign(operation({ id: "op_admin", actor: actor("admin", "alice") }), keys.adminKey, keys.adminGrant);
|
|
149
|
+
await relayA.handleClientOperation({
|
|
150
|
+
roomName: ROOM_ID,
|
|
151
|
+
peerId: "alice-peer",
|
|
152
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "alice", operation: adminList },
|
|
153
|
+
sendToClient: () => {},
|
|
154
|
+
broadcastToRoom: () => {},
|
|
155
|
+
broadcastToMesh: (message) => envelopes.push(message)
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const tampered = {
|
|
159
|
+
...envelopes[0],
|
|
160
|
+
operation: {
|
|
161
|
+
...envelopes[0].operation,
|
|
162
|
+
payload: { title: "Tampered by relay" }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const rejected = await relayB.handleRelayOperation(tampered);
|
|
166
|
+
assert.equal(rejected.ok, false);
|
|
167
|
+
assert.match(rejected.reason, /signature|invalid|Forbidden|Admins only|Operation is not allowed|operation\.id|content hash/);
|
|
168
|
+
assert.equal((await relayB.snapshot(ROOM_ID)).state.version, 0);
|
|
169
|
+
|
|
170
|
+
const accepted = await relayB.handleRelayOperation(envelopes[0]);
|
|
171
|
+
assert.equal(accepted.ok, true);
|
|
172
|
+
assert.equal((await relayB.snapshot(ROOM_ID)).state.version, 1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("relay-owned runtime rejects unsigned operation role-key grants unless explicitly allowed", async () => {
|
|
176
|
+
const keys = makeKeys({ unsigned: true });
|
|
177
|
+
const manager = createRelayPluginRuntimeManager();
|
|
178
|
+
await assert.rejects(() => manager.enableRoomComposition({
|
|
179
|
+
roomName: ROOM_ID,
|
|
180
|
+
appPack: kanbanAppPack,
|
|
181
|
+
plugins: kanbanHostPlugins,
|
|
182
|
+
operationRoleKeyGrants: keys.unsignedGrants
|
|
183
|
+
}), /grant authority|signed|unsigned/i);
|
|
184
|
+
|
|
185
|
+
const devManager = createRelayPluginRuntimeManager({ allowUnsignedOperationRoleKeyGrants: true });
|
|
186
|
+
await devManager.enableRoomComposition({
|
|
187
|
+
roomName: ROOM_ID,
|
|
188
|
+
appPack: kanbanAppPack,
|
|
189
|
+
plugins: kanbanHostPlugins,
|
|
190
|
+
operationRoleKeyGrants: keys.unsignedGrants,
|
|
191
|
+
operationGrantAuthorities: keys.grantAuthorities
|
|
192
|
+
});
|
|
193
|
+
assert.equal(devManager.hasRoom(ROOM_ID), true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("relay-owned runtime accepts public role-key grants from common collection shapes", async () => {
|
|
197
|
+
const keys = makeKeys();
|
|
198
|
+
const cases = [
|
|
199
|
+
{ name: "map", grants: new Map([["admin", keys.adminGrant], ["user", keys.userGrant]]), authorities: keys.grantAuthorities, expected: 2 },
|
|
200
|
+
{ name: "single", grants: keys.adminGrant, authorities: keys.grantAuthorities, expected: 1 },
|
|
201
|
+
{ name: "object", grants: { admin: keys.adminGrant, user: keys.userGrant }, authorities: { owner: keys.authority }, expected: 2 }
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
for (const collection of cases) {
|
|
205
|
+
const manager = createRelayPluginRuntimeManager({ relayAddress: `relay-${collection.name}` });
|
|
206
|
+
await manager.enableRoomComposition({
|
|
207
|
+
roomName: ROOM_ID,
|
|
208
|
+
appPack: kanbanAppPack,
|
|
209
|
+
plugins: kanbanHostPlugins,
|
|
210
|
+
operationRoleKeyGrants: collection.grants,
|
|
211
|
+
operationGrantAuthorities: collection.authorities
|
|
212
|
+
});
|
|
213
|
+
assert.equal(manager.roomApp(ROOM_ID).operationAuth.grants.length, collection.expected);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("relay-owned runtime accepts same-app historical signed operations during catch-up", async () => {
|
|
218
|
+
const oldHash = "sha256-old-kanban-schema";
|
|
219
|
+
const keys = makeKeys({ appPackHash: oldHash });
|
|
220
|
+
const manager = createRelayPluginRuntimeManager({ relayAddress: "relay-role-key-upgrade", now: () => 5000 });
|
|
221
|
+
await manager.enableRoomComposition({
|
|
222
|
+
roomName: ROOM_ID,
|
|
223
|
+
appPack: kanbanAppPack,
|
|
224
|
+
plugins: kanbanHostPlugins,
|
|
225
|
+
operationRoleKeyGrants: keys.grants,
|
|
226
|
+
operationGrantAuthorities: keys.grantAuthorities
|
|
227
|
+
});
|
|
228
|
+
const oldOperation = sign(operation({
|
|
229
|
+
id: "op_historical_signed",
|
|
230
|
+
appPackHash: oldHash,
|
|
231
|
+
actor: actor("admin", "alice")
|
|
232
|
+
}), keys.adminKey, keys.adminGrant);
|
|
233
|
+
|
|
234
|
+
const result = await manager.handleRelayOperation({
|
|
235
|
+
type: "relay.matterhorn-operation",
|
|
236
|
+
roomName: ROOM_ID,
|
|
237
|
+
operation: oldOperation
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
assert.equal(result.ok, true, result.reason);
|
|
241
|
+
assert.equal((await manager.snapshot(ROOM_ID)).state.plugins[KANBAN_PLUGIN_ID].lists[0].title, "Backlog");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("relay-owned runtime returns current state with ordinary relay operation rejections", async () => {
|
|
245
|
+
const keys = makeKeys();
|
|
246
|
+
const manager = await enableRelay(keys);
|
|
247
|
+
const forbidden = sign(operation({
|
|
248
|
+
id: "op_forbidden_relay",
|
|
249
|
+
actor: actor("member", "bob")
|
|
250
|
+
}), keys.userKey, keys.userGrant);
|
|
251
|
+
|
|
252
|
+
const result = await manager.handleRelayOperation({
|
|
253
|
+
type: "relay.matterhorn-operation",
|
|
254
|
+
id: "relay-b:forbidden",
|
|
255
|
+
roomName: ROOM_ID,
|
|
256
|
+
operation: forbidden
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
assert.equal(result.ok, false);
|
|
260
|
+
assert.equal(result.state.version, 0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("secure relay snapshot sync requires replayable signed operation proof", async () => {
|
|
264
|
+
const keys = makeKeys();
|
|
265
|
+
const relayA = await enableRelay(keys);
|
|
266
|
+
const relayB = await enableRelay(keys);
|
|
267
|
+
const envelopes = [];
|
|
268
|
+
const adminList = sign(operation({ id: "op_snapshot_proof", actor: actor("admin", "alice") }), keys.adminKey, keys.adminGrant);
|
|
269
|
+
await relayB.handleClientOperation({
|
|
270
|
+
roomName: ROOM_ID,
|
|
271
|
+
peerId: "alice-peer",
|
|
272
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "alice", operation: adminList },
|
|
273
|
+
sendToClient: () => {},
|
|
274
|
+
broadcastToRoom: () => {},
|
|
275
|
+
broadcastToMesh: (message) => envelopes.push(message)
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const provenSnapshot = await relayB.snapshot(ROOM_ID);
|
|
279
|
+
const tamperedState = {
|
|
280
|
+
...provenSnapshot.state,
|
|
281
|
+
version: provenSnapshot.state.version + 100,
|
|
282
|
+
plugins: {
|
|
283
|
+
...provenSnapshot.state.plugins,
|
|
284
|
+
[KANBAN_PLUGIN_ID]: {
|
|
285
|
+
...provenSnapshot.state.plugins[KANBAN_PLUGIN_ID],
|
|
286
|
+
lists: [{ id: "evil", title: "Forged state", cardIds: [] }]
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const rejectedBare = await relayA.syncSnapshot(ROOM_ID, tamperedState);
|
|
292
|
+
assert.equal(rejectedBare.updated, false);
|
|
293
|
+
assert.match(rejectedBare.reason, /proof|operation|verify/i);
|
|
294
|
+
assert.equal((await relayA.snapshot(ROOM_ID)).state.version, 0);
|
|
295
|
+
|
|
296
|
+
const accepted = await relayA.syncSnapshot(ROOM_ID, provenSnapshot);
|
|
297
|
+
assert.equal(accepted.updated, true);
|
|
298
|
+
assert.equal((await relayA.snapshot(ROOM_ID)).state.version, provenSnapshot.state.version);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("secure relay operation envelopes can prove missed history for catch-up", async () => {
|
|
302
|
+
const keys = makeKeys();
|
|
303
|
+
const relayA = await enableRelay(keys);
|
|
304
|
+
const relayB = await enableRelay(keys);
|
|
305
|
+
|
|
306
|
+
const listOp = sign(operation({ id: "op_catchup_list", actor: actor("admin", "alice") }), keys.adminKey, keys.adminGrant);
|
|
307
|
+
assert.equal((await relayB.handleClientOperation({
|
|
308
|
+
roomName: ROOM_ID,
|
|
309
|
+
peerId: "alice-peer",
|
|
310
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "alice", operation: listOp },
|
|
311
|
+
sendToClient: () => {},
|
|
312
|
+
broadcastToRoom: () => {},
|
|
313
|
+
broadcastToMesh: () => {}
|
|
314
|
+
})).ok, true);
|
|
315
|
+
const listId = (await relayB.snapshot(ROOM_ID)).state.plugins[KANBAN_PLUGIN_ID].lists[0].id;
|
|
316
|
+
const cardOp = sign(operation({
|
|
317
|
+
id: "op_catchup_card",
|
|
318
|
+
seq: 2,
|
|
319
|
+
type: "card.create",
|
|
320
|
+
actor: actor("member", "bob"),
|
|
321
|
+
payload: { listId, title: "Catch up through proof" },
|
|
322
|
+
createdAt: 1001
|
|
323
|
+
}), keys.userKey, keys.userGrant);
|
|
324
|
+
assert.equal((await relayB.handleClientOperation({
|
|
325
|
+
roomName: ROOM_ID,
|
|
326
|
+
peerId: "bob-peer",
|
|
327
|
+
message: { type: "client/operation", protocol: 1, roomName: ROOM_ID, clientId: "bob", operation: cardOp },
|
|
328
|
+
sendToClient: () => {},
|
|
329
|
+
broadcastToRoom: () => {},
|
|
330
|
+
broadcastToMesh: () => {}
|
|
331
|
+
})).ok, true);
|
|
332
|
+
|
|
333
|
+
const provenSnapshot = await relayB.snapshot(ROOM_ID);
|
|
334
|
+
const catchup = await relayA.handleRelayOperation({
|
|
335
|
+
type: "relay.matterhorn-operation",
|
|
336
|
+
id: "relay-b:catchup",
|
|
337
|
+
roomName: ROOM_ID,
|
|
338
|
+
operation: cardOp,
|
|
339
|
+
state: provenSnapshot.state,
|
|
340
|
+
operations: provenSnapshot.operations
|
|
341
|
+
});
|
|
342
|
+
assert.equal(catchup.ok, true);
|
|
343
|
+
const stateA = (await relayA.snapshot(ROOM_ID)).state;
|
|
344
|
+
assert.equal(stateA.version, 2);
|
|
345
|
+
assert.equal(stateA.plugins[KANBAN_PLUGIN_ID].lists[0].cardIds.length, 1);
|
|
346
|
+
});
|