@mh-gg/host-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 +23 -0
- package/src/constants.cjs +9 -0
- package/src/errors.cjs +39 -0
- package/src/host/installAppPack.cjs +35 -0
- package/src/host/migrations.cjs +21 -0
- package/src/host/runtimeFromPack.cjs +25 -0
- package/src/host/startRoomHost.cjs +95 -0
- package/src/index.cjs +23 -0
- package/src/memory.cjs +81 -0
- package/src/plugins/definition.cjs +19 -0
- package/src/plugins/install.cjs +90 -0
- package/src/plugins/migrations.cjs +27 -0
- package/src/plugins/operationDescriptors.cjs +138 -0
- package/src/runtime/HostPluginRuntime.cjs +85 -0
- package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
- package/src/runtime/authorityReplay/applyContent.cjs +49 -0
- package/src/runtime/authorityReplay/index.cjs +70 -0
- package/src/runtime/authorityReplay/state.cjs +56 -0
- package/src/runtime/context.cjs +65 -0
- package/src/runtime/coreOperations.cjs +169 -0
- package/src/runtime/corePayloads.cjs +66 -0
- package/src/runtime/directMessages/commit.cjs +50 -0
- package/src/runtime/directMessages/constants.cjs +20 -0
- package/src/runtime/directMessages/helpers.cjs +59 -0
- package/src/runtime/directMessages/payloads.cjs +74 -0
- package/src/runtime/directMessages/state.cjs +168 -0
- package/src/runtime/directMessages.cjs +6 -0
- package/src/runtime/lifecycle.cjs +166 -0
- package/src/runtime/memberProfiles.cjs +74 -0
- package/src/runtime/methods.cjs +10 -0
- package/src/runtime/operations.cjs +166 -0
- package/src/runtime/queries.cjs +146 -0
- package/src/runtime/readTags.cjs +171 -0
- package/src/runtime/scopedRoleOperations.cjs +97 -0
- package/src/runtime/snowflake.cjs +43 -0
- package/src/security/authority/constants.cjs +10 -0
- package/src/security/authority/resolve/operations.cjs +7 -0
- package/src/security/authority/resolve/policy.cjs +7 -0
- package/src/security/authority/resolve/voids.cjs +8 -0
- package/src/security/authorization/coreGate.cjs +63 -0
- package/src/security/authorization/schemaActions.cjs +75 -0
- package/src/security/roleKeys/authenticator.cjs +36 -0
- package/src/security/roleKeys/authorities/index.cjs +4 -0
- package/src/security/roleKeys/authorities/shapes.cjs +98 -0
- package/src/security/roleKeys/authorities/signing.cjs +121 -0
- package/src/security/roleKeys/constants.cjs +15 -0
- package/src/security/roleKeys/fingerprints.cjs +24 -0
- package/src/security/roleKeys/grants.cjs +93 -0
- package/src/security/roleKeys/index.cjs +10 -0
- package/src/security/roleKeys/roles.cjs +21 -0
- package/src/security/roleKeys/signatures.cjs +126 -0
- package/src/security/roles.cjs +10 -0
- package/src/security/roomDeviceKeys.cjs +41 -0
- package/src/security/scopedRoles/access.cjs +123 -0
- package/src/security/scopedRoles/constants.cjs +23 -0
- package/src/security/scopedRoles/metadata.cjs +39 -0
- package/src/security/scopedRoles/normalize.cjs +179 -0
- package/src/security/scopedRoles/publicView.cjs +31 -0
- package/src/security/scopedRoles/stateOps.cjs +167 -0
- package/src/security/scopedRoles.cjs +7 -0
- package/src/security/standingAuthority.cjs +76 -0
- package/src/shared.cjs +14 -0
- package/src/state.cjs +54 -0
- package/test/authority-ordering-hardening.test.cjs +101 -0
- package/test/authorization-gate.test.cjs +610 -0
- package/test/cascading-authority.test.cjs +390 -0
- package/test/grant-authority-security.test.cjs +305 -0
- package/test/matterhorn-host-runtime.test.cjs +1629 -0
- package/test/operation-descriptor-policy.test.cjs +140 -0
- package/test/role-key-auth.test.cjs +289 -0
- package/test/security-isolation.test.cjs +112 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
HostPluginRuntime,
|
|
6
|
+
allow,
|
|
7
|
+
createMemoryOperationLog,
|
|
8
|
+
createMemoryRoomStore,
|
|
9
|
+
createOperationSchemaDescriptor,
|
|
10
|
+
defineHostPlugin,
|
|
11
|
+
declaredRolesForOperation,
|
|
12
|
+
operationDescriptor
|
|
13
|
+
} = require("..");
|
|
14
|
+
const { ensureOperationIdentity } = require("@mh-gg/protocol");
|
|
15
|
+
|
|
16
|
+
const ROOM = {
|
|
17
|
+
id: "room_policy",
|
|
18
|
+
appPack: { id: "app.policy", hash: "sha256-policy" }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function stateSchema() {
|
|
22
|
+
return { parse(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : { items: [] }; } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function payloadSchema() {
|
|
26
|
+
return { parse(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validPlugin(overrides = {}) {
|
|
30
|
+
return defineHostPlugin({
|
|
31
|
+
id: overrides.id || "policy.plugin",
|
|
32
|
+
version: "1.0.0",
|
|
33
|
+
schemas: {
|
|
34
|
+
state: stateSchema(),
|
|
35
|
+
operations: overrides.operations || { "thing.create": payloadSchema() }
|
|
36
|
+
},
|
|
37
|
+
operationSchemaDescriptor: overrides.operationSchemaDescriptor || createOperationSchemaDescriptor({
|
|
38
|
+
plugin: overrides.id || "policy.plugin",
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
operations: {
|
|
41
|
+
"thing.create": { authorize: { roles: ["member"] } }
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
createInitialState() { return { items: [] }; },
|
|
45
|
+
authorize() { return allow(); },
|
|
46
|
+
reduce(_ctx, state) { return { ...state, items: [...(state.items || []), "ok"] }; }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runtimeFor(plugin) {
|
|
51
|
+
return new HostPluginRuntime({
|
|
52
|
+
room: ROOM,
|
|
53
|
+
plugins: [plugin],
|
|
54
|
+
store: createMemoryRoomStore(),
|
|
55
|
+
operationLog: createMemoryOperationLog(),
|
|
56
|
+
authenticateActor: async (_auth, actor) => actor
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function operation(type = "thing.create", overrides = {}) {
|
|
61
|
+
const op = {
|
|
62
|
+
id: `op_${type.replace(/[^a-z0-9]+/gi, "_")}_${overrides.seq || 1}`,
|
|
63
|
+
roomId: ROOM.id,
|
|
64
|
+
appPackId: ROOM.appPack.id,
|
|
65
|
+
appPackHash: ROOM.appPack.hash,
|
|
66
|
+
pluginId: "policy.plugin",
|
|
67
|
+
type,
|
|
68
|
+
actor: { memberId: "member", deviceId: "dev", role: "member" },
|
|
69
|
+
seq: overrides.seq || 1,
|
|
70
|
+
createdAt: 1000 + (overrides.seq || 1),
|
|
71
|
+
payload: {},
|
|
72
|
+
auth: { credentialId: "cred", signature: "sig" },
|
|
73
|
+
...overrides
|
|
74
|
+
};
|
|
75
|
+
return ensureOperationIdentity(op, { nodeId: op.actor.deviceId, now: op.createdAt });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("valid plugins expose one canonical operation descriptor shape", () => {
|
|
79
|
+
const plugin = validPlugin();
|
|
80
|
+
assert.deepEqual(operationDescriptor(plugin, "thing.create"), { authorize: { roles: ["member"] } });
|
|
81
|
+
assert.deepEqual(declaredRolesForOperation(plugin, "thing.create"), ["member"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("plugin registration rejects operations without canonical authorize.roles", () => {
|
|
85
|
+
assert.throws(() => validPlugin({
|
|
86
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({
|
|
87
|
+
plugin: "policy.plugin",
|
|
88
|
+
version: "1.0.0",
|
|
89
|
+
operations: { "thing.create": { required: ["title"] } }
|
|
90
|
+
})
|
|
91
|
+
}), /authorize\.roles|OPERATION_ROLE_POLICY_MISSING/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("plugin registration rejects legacy flat operation descriptors", () => {
|
|
95
|
+
assert.throws(() => validPlugin({
|
|
96
|
+
operationSchemaDescriptor: { "thing.create": { authorize: { roles: ["member"] } } }
|
|
97
|
+
}), /operationSchemaDescriptor\.operations|canonical/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("plugin registration rejects alternate descriptor shapes instead of falling back", () => {
|
|
101
|
+
assert.throws(() => defineHostPlugin({
|
|
102
|
+
id: "policy.plugin",
|
|
103
|
+
version: "1.0.0",
|
|
104
|
+
operationPolicyDescriptor: { "thing.create": { authorize: { roles: ["member"] } } },
|
|
105
|
+
schemas: { state: stateSchema(), operations: { "thing.create": payloadSchema() } },
|
|
106
|
+
createInitialState() { return { items: [] }; },
|
|
107
|
+
authorize() { return allow(); },
|
|
108
|
+
reduce(_ctx, state) { return state; }
|
|
109
|
+
}), /operationSchemaDescriptor|canonical/i);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("canonical descriptor entries must match schemas.operations exactly", () => {
|
|
113
|
+
assert.throws(() => validPlugin({
|
|
114
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({
|
|
115
|
+
plugin: "policy.plugin",
|
|
116
|
+
version: "1.0.0",
|
|
117
|
+
operations: {
|
|
118
|
+
"thing.create": { authorize: { roles: ["member"] } },
|
|
119
|
+
"thing.extra": { authorize: { roles: ["member"] } }
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}), /thing\.extra|schemas\.operations/);
|
|
123
|
+
|
|
124
|
+
assert.throws(() => validPlugin({
|
|
125
|
+
operations: { "thing.create": payloadSchema(), "thing.delete": payloadSchema() },
|
|
126
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({
|
|
127
|
+
plugin: "policy.plugin",
|
|
128
|
+
version: "1.0.0",
|
|
129
|
+
operations: { "thing.create": { authorize: { roles: ["member"] } } }
|
|
130
|
+
})
|
|
131
|
+
}), /thing\.delete|authorize\.roles/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("unknown operation attacks produce routine unknown-operation denial, not missing policy", async () => {
|
|
135
|
+
const runtime = runtimeFor(validPlugin());
|
|
136
|
+
const result = await runtime.handleOperation(operation("thing.unknown"));
|
|
137
|
+
assert.equal(result.ok, false);
|
|
138
|
+
assert.equal(result.code, "UNKNOWN_OPERATION_TYPE");
|
|
139
|
+
assert.doesNotMatch(result.reason, /authorize\.roles|role policy/i);
|
|
140
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
5
|
+
const {
|
|
6
|
+
HostPluginRuntime,
|
|
7
|
+
createMemoryOperationLog,
|
|
8
|
+
createMemoryRoomStore,
|
|
9
|
+
createOperationRoleKeyGrant,
|
|
10
|
+
createRoleKeyAuthenticator,
|
|
11
|
+
generateOperationRoleKeyPair,
|
|
12
|
+
publicOperationRoleKeyGrant,
|
|
13
|
+
signOperationWithRoleKey,
|
|
14
|
+
verifyOperationRoleKey,
|
|
15
|
+
operationKeyRoleToActorRole
|
|
16
|
+
} = require("../src/index.cjs");
|
|
17
|
+
const { kanbanAppPack, kanbanHostPlugin, KANBAN_PLUGIN_ID } = require("../../../examples/kanban/src/sdk-app.mjs");
|
|
18
|
+
|
|
19
|
+
const ROOM_ID = "room_role_keys";
|
|
20
|
+
const APP_HASH = manifestHash(kanbanAppPack);
|
|
21
|
+
|
|
22
|
+
function actor(role = "member", memberId = "member") {
|
|
23
|
+
return { memberId, deviceId: `dev_${memberId}`, role, displayName: memberId };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function operation(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
id: overrides.id || "op_1",
|
|
29
|
+
roomId: ROOM_ID,
|
|
30
|
+
appPackId: kanbanAppPack.id,
|
|
31
|
+
appPackHash: APP_HASH,
|
|
32
|
+
pluginId: overrides.pluginId || KANBAN_PLUGIN_ID,
|
|
33
|
+
type: overrides.type || "list.create",
|
|
34
|
+
actor: overrides.actor || actor("admin", "admin"),
|
|
35
|
+
seq: overrides.seq || 1,
|
|
36
|
+
createdAt: overrides.createdAt || 1000,
|
|
37
|
+
payload: overrides.payload || { title: "Backlog" },
|
|
38
|
+
auth: { credentialId: overrides.credentialId || "cred_placeholder", signature: "placeholder" },
|
|
39
|
+
...overrides
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function signedOperation(op, key, grant, overrides = {}) {
|
|
44
|
+
return signOperationWithRoleKey({ ...op, auth: { credentialId: grant.credentialId } }, {
|
|
45
|
+
privateKeyPem: key.privateKeyPem,
|
|
46
|
+
grant,
|
|
47
|
+
now: () => op.createdAt || 1000,
|
|
48
|
+
...overrides
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function initialMembersFromGrants(grants) {
|
|
53
|
+
return Object.fromEntries(grants.filter((grant) => grant.memberId).map((grant) => [grant.memberId, { id: grant.memberId, role: operationKeyRoleToActorRole(grant.role), credentialId: grant.credentialId, deviceId: grant.deviceId }]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function startRuntime(grants, now = () => 5000) {
|
|
57
|
+
const runtime = new HostPluginRuntime({
|
|
58
|
+
room: {
|
|
59
|
+
id: ROOM_ID,
|
|
60
|
+
appPack: {
|
|
61
|
+
id: kanbanAppPack.id,
|
|
62
|
+
version: kanbanAppPack.version,
|
|
63
|
+
hash: APP_HASH,
|
|
64
|
+
protocolHash: kanbanAppPack.compatibility.appProtocolHash
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
plugins: [kanbanHostPlugin],
|
|
68
|
+
store: createMemoryRoomStore(),
|
|
69
|
+
operationLog: createMemoryOperationLog(),
|
|
70
|
+
initialMembers: initialMembersFromGrants(grants),
|
|
71
|
+
authenticateActor: createRoleKeyAuthenticator({
|
|
72
|
+
roomId: ROOM_ID,
|
|
73
|
+
appPackId: kanbanAppPack.id,
|
|
74
|
+
appPackHash: APP_HASH,
|
|
75
|
+
grants,
|
|
76
|
+
now
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
await runtime.start();
|
|
80
|
+
return runtime;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("role-key authenticator prevents a user key from claiming admin operations", async () => {
|
|
84
|
+
const adminKey = generateOperationRoleKeyPair({ role: "admin" });
|
|
85
|
+
const userKey = generateOperationRoleKeyPair({ role: "user" });
|
|
86
|
+
const adminGrant = createOperationRoleKeyGrant({
|
|
87
|
+
roomId: ROOM_ID,
|
|
88
|
+
appPackId: kanbanAppPack.id,
|
|
89
|
+
appPackHash: APP_HASH,
|
|
90
|
+
credentialId: "admin_cred",
|
|
91
|
+
memberId: "alice",
|
|
92
|
+
deviceId: "dev_alice",
|
|
93
|
+
role: "admin",
|
|
94
|
+
publicKeyPem: adminKey.publicKeyPem,
|
|
95
|
+
issuedAt: 100
|
|
96
|
+
});
|
|
97
|
+
const userGrant = createOperationRoleKeyGrant({
|
|
98
|
+
roomId: ROOM_ID,
|
|
99
|
+
appPackId: kanbanAppPack.id,
|
|
100
|
+
appPackHash: APP_HASH,
|
|
101
|
+
credentialId: "user_cred",
|
|
102
|
+
memberId: "bob",
|
|
103
|
+
deviceId: "dev_bob",
|
|
104
|
+
role: "user",
|
|
105
|
+
publicKeyPem: userKey.publicKeyPem,
|
|
106
|
+
issuedAt: 100
|
|
107
|
+
});
|
|
108
|
+
const runtime = await startRuntime([adminGrant, userGrant]);
|
|
109
|
+
|
|
110
|
+
const forgedAdmin = signedOperation(operation({
|
|
111
|
+
id: "op_forged_admin",
|
|
112
|
+
actor: actor("admin", "bob")
|
|
113
|
+
}), userKey, userGrant);
|
|
114
|
+
const rejected = await runtime.handleOperation(forgedAdmin);
|
|
115
|
+
assert.equal(rejected.ok, false);
|
|
116
|
+
assert.match(rejected.reason, /Admins only|Forbidden|Operation is not allowed|requires admin/);
|
|
117
|
+
|
|
118
|
+
const adminList = signedOperation(operation({ id: "op_admin_list", actor: actor("admin", "alice") }), adminKey, adminGrant);
|
|
119
|
+
assert.equal((await runtime.handleOperation(adminList)).ok, true);
|
|
120
|
+
|
|
121
|
+
const state = await runtime.getState();
|
|
122
|
+
const listId = state.plugins[KANBAN_PLUGIN_ID].lists[0].id;
|
|
123
|
+
const userCard = signedOperation(operation({
|
|
124
|
+
id: "op_user_card",
|
|
125
|
+
seq: 2,
|
|
126
|
+
type: "card.create",
|
|
127
|
+
actor: actor("admin", "bob"),
|
|
128
|
+
payload: { listId, title: "User can write member operation" },
|
|
129
|
+
createdAt: 1001
|
|
130
|
+
}), userKey, userGrant);
|
|
131
|
+
assert.equal((await runtime.handleOperation(userCard)).ok, true);
|
|
132
|
+
|
|
133
|
+
const finalState = await runtime.getState();
|
|
134
|
+
const cardId = finalState.plugins[KANBAN_PLUGIN_ID].lists[0].cardIds[0];
|
|
135
|
+
assert.equal(finalState.plugins[KANBAN_PLUGIN_ID].cards[cardId].title, "User can write member operation");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("role-key signatures reject tampering, wrong room, and expired grants", async () => {
|
|
139
|
+
const key = generateOperationRoleKeyPair({ role: "admin" });
|
|
140
|
+
const grant = createOperationRoleKeyGrant({
|
|
141
|
+
roomId: ROOM_ID,
|
|
142
|
+
appPackId: kanbanAppPack.id,
|
|
143
|
+
appPackHash: APP_HASH,
|
|
144
|
+
credentialId: "admin_cred",
|
|
145
|
+
memberId: "alice",
|
|
146
|
+
role: "admin",
|
|
147
|
+
publicKeyPem: key.publicKeyPem,
|
|
148
|
+
issuedAt: 100,
|
|
149
|
+
expiresAt: 10000
|
|
150
|
+
});
|
|
151
|
+
const signed = signedOperation(operation({ id: "op_valid", actor: actor("admin", "alice") }), key, grant);
|
|
152
|
+
assert.equal(verifyOperationRoleKey(signed, [grant], { now: 5000 }).ok, true);
|
|
153
|
+
|
|
154
|
+
const tampered = { ...signed, payload: { title: "Changed after signing" } };
|
|
155
|
+
assert.equal(verifyOperationRoleKey(tampered, [grant], { now: 5000 }).ok, false);
|
|
156
|
+
|
|
157
|
+
const wrongRoom = signedOperation(operation({ id: "op_wrong_room", roomId: "other", actor: actor("admin", "alice") }), key, grant);
|
|
158
|
+
assert.equal(verifyOperationRoleKey(wrongRoom, [grant], { now: 5000 }).ok, false);
|
|
159
|
+
|
|
160
|
+
assert.equal(verifyOperationRoleKey(signed, [grant], { now: 11000 }).ok, false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("public role-key grants never expose private key material", () => {
|
|
164
|
+
const key = generateOperationRoleKeyPair({ role: "admin" });
|
|
165
|
+
const grant = createOperationRoleKeyGrant({
|
|
166
|
+
roomId: ROOM_ID,
|
|
167
|
+
appPackId: kanbanAppPack.id,
|
|
168
|
+
appPackHash: APP_HASH,
|
|
169
|
+
credentialId: "admin_cred",
|
|
170
|
+
memberId: "alice",
|
|
171
|
+
role: "admin",
|
|
172
|
+
publicKeyPem: key.publicKeyPem
|
|
173
|
+
});
|
|
174
|
+
const publicGrant = publicOperationRoleKeyGrant({ ...grant, privateKeyPem: key.privateKeyPem });
|
|
175
|
+
assert.equal(publicGrant.privateKeyPem, undefined);
|
|
176
|
+
assert.equal(publicGrant.publicKeyPem.includes("PUBLIC KEY"), true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("role-key helpers reject malformed grants and scoped permissions", () => {
|
|
180
|
+
const key = generateOperationRoleKeyPair({ role: "member" });
|
|
181
|
+
assert.equal(key.role, "user");
|
|
182
|
+
assert.throws(() => createOperationRoleKeyGrant({}), /publicKeyPem/);
|
|
183
|
+
assert.throws(() => createOperationRoleKeyGrant({ publicKeyPem: key.publicKeyPem, role: "guest", credentialId: "c", roomId: ROOM_ID, appPackId: kanbanAppPack.id }), /role/);
|
|
184
|
+
assert.throws(() => createOperationRoleKeyGrant({ publicKeyPem: key.publicKeyPem, role: "user", credentialId: "c", roomId: ROOM_ID, appPackId: kanbanAppPack.id, allowedPluginIds: [""] }), /allowedPluginIds/);
|
|
185
|
+
|
|
186
|
+
const grant = createOperationRoleKeyGrant({
|
|
187
|
+
roomId: ROOM_ID,
|
|
188
|
+
appPackId: kanbanAppPack.id,
|
|
189
|
+
appPackHash: APP_HASH,
|
|
190
|
+
credentialId: "user_scoped",
|
|
191
|
+
memberId: "bob",
|
|
192
|
+
deviceId: "dev_bob",
|
|
193
|
+
role: "user",
|
|
194
|
+
publicKeyPem: key.publicKeyPem,
|
|
195
|
+
allowedPluginIds: [KANBAN_PLUGIN_ID],
|
|
196
|
+
allowedOperations: ["card.create"],
|
|
197
|
+
issuedAt: 100
|
|
198
|
+
});
|
|
199
|
+
const base = operation({
|
|
200
|
+
id: "op_scoped",
|
|
201
|
+
type: "card.create",
|
|
202
|
+
actor: actor("member", "bob"),
|
|
203
|
+
payload: { listId: "list_1", title: "Card" },
|
|
204
|
+
createdAt: 1000
|
|
205
|
+
});
|
|
206
|
+
const signed = signedOperation(base, key, grant);
|
|
207
|
+
assert.equal(verifyOperationRoleKey(signed, [grant], { now: 5000 }).ok, true);
|
|
208
|
+
assert.match(verifyOperationRoleKey({ ...signed, pluginId: "other.plugin" }, [grant], { now: 5000 }).error, /plugin/);
|
|
209
|
+
assert.match(verifyOperationRoleKey({ ...signed, type: "list.create" }, [grant], { now: 5000 }).error, /operation/);
|
|
210
|
+
assert.match(verifyOperationRoleKey({ ...signed, actor: { ...signed.actor, deviceId: "other" } }, [grant], { now: 5000 }).error, /device/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("role-key verifier covers auth metadata and grant collection forms", () => {
|
|
214
|
+
const key = generateOperationRoleKeyPair({ role: "admin" });
|
|
215
|
+
const grant = createOperationRoleKeyGrant({
|
|
216
|
+
roomId: ROOM_ID,
|
|
217
|
+
appPackId: kanbanAppPack.id,
|
|
218
|
+
appPackHash: APP_HASH,
|
|
219
|
+
credentialId: "admin_map",
|
|
220
|
+
memberId: "alice",
|
|
221
|
+
role: "admin",
|
|
222
|
+
publicKeyPem: key.publicKeyPem,
|
|
223
|
+
issuedAt: 100
|
|
224
|
+
});
|
|
225
|
+
const signed = signedOperation(operation({ id: "op_map", actor: actor("admin", "alice") }), key, grant);
|
|
226
|
+
assert.equal(verifyOperationRoleKey(signed, new Map([[grant.credentialId, grant]]), { now: 5000 }).ok, true);
|
|
227
|
+
assert.equal(verifyOperationRoleKey(signed, { [grant.credentialId]: grant }, { now: 5000 }).ok, true);
|
|
228
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, kind: "wrong" } }, [grant], { now: 5000 }).error, /kind/);
|
|
229
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, version: 2 } }, [grant], { now: 5000 }).error, /version/);
|
|
230
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, alg: "rsa" } }, [grant], { now: 5000 }).error, /algorithm/);
|
|
231
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, keyRole: "user" } }, [grant], { now: 5000 }).error, /role/);
|
|
232
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, publicKeyFingerprint: "sha256-nope" } }, [grant], { now: 5000 }).error, /fingerprint/);
|
|
233
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, issuedAt: 1 } }, [grant], { now: 5000 }).error, /predates/);
|
|
234
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: { credentialId: grant.credentialId } }, [grant], { now: 5000 }).error, /signature/);
|
|
235
|
+
assert.match(verifyOperationRoleKey(null, [grant]).error, /Operation is required/);
|
|
236
|
+
assert.match(verifyOperationRoleKey({ ...signed, auth: null }, [grant]).error, /auth/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
test("role-key helpers cover canonicalization and public grant collection edge cases", () => {
|
|
241
|
+
const {
|
|
242
|
+
canonicalJson,
|
|
243
|
+
isOperationRoleKeyGrant,
|
|
244
|
+
operationKeyRoleToActorRole,
|
|
245
|
+
publicOperationRoleKeyGrants,
|
|
246
|
+
roleKeyFingerprint
|
|
247
|
+
} = require("../src/index.cjs");
|
|
248
|
+
assert.throws(() => canonicalJson(Symbol("bad")), /Unsupported value/);
|
|
249
|
+
assert.equal(operationKeyRoleToActorRole("guest"), "guest");
|
|
250
|
+
assert.deepEqual(publicOperationRoleKeyGrants(undefined), []);
|
|
251
|
+
|
|
252
|
+
const key = generateOperationRoleKeyPair({ role: "admin" });
|
|
253
|
+
const grant = createOperationRoleKeyGrant({
|
|
254
|
+
roomId: ROOM_ID,
|
|
255
|
+
appPackId: kanbanAppPack.id,
|
|
256
|
+
credentialId: "admin_publics",
|
|
257
|
+
role: "admin",
|
|
258
|
+
publicKeyPem: key.publicKeyPem
|
|
259
|
+
});
|
|
260
|
+
assert.equal(publicOperationRoleKeyGrants(grant)[0].credentialId, "admin_publics");
|
|
261
|
+
assert.equal(publicOperationRoleKeyGrants(new Map([[grant.credentialId, grant]])).length, 1);
|
|
262
|
+
|
|
263
|
+
const invalidKeyGrant = {
|
|
264
|
+
...grant,
|
|
265
|
+
credentialId: "invalid_key",
|
|
266
|
+
publicKeyPem: "not a public key",
|
|
267
|
+
publicKeyFingerprint: roleKeyFingerprint("not a public key")
|
|
268
|
+
};
|
|
269
|
+
assert.equal(isOperationRoleKeyGrant(invalidKeyGrant), true);
|
|
270
|
+
const signed = signedOperation(operation({ id: "op_invalid_key", actor: actor("admin", "admin") }), key, invalidKeyGrant);
|
|
271
|
+
assert.match(verifyOperationRoleKey(signed, [invalidKeyGrant], { now: 5000 }).error, /decoder|PEM|key|unsupported|asn1|Failed/i);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("signing role-key operations accepts operations without existing auth", () => {
|
|
275
|
+
const key = generateOperationRoleKeyPair({ role: "admin" });
|
|
276
|
+
const grant = createOperationRoleKeyGrant({
|
|
277
|
+
roomId: ROOM_ID,
|
|
278
|
+
appPackId: kanbanAppPack.id,
|
|
279
|
+
appPackHash: APP_HASH,
|
|
280
|
+
credentialId: "admin_no_auth",
|
|
281
|
+
role: "admin",
|
|
282
|
+
publicKeyPem: key.publicKeyPem,
|
|
283
|
+
issuedAt: 1
|
|
284
|
+
});
|
|
285
|
+
const signed = signOperationWithRoleKey({ ...operation({ id: "op_no_auth" }), auth: undefined }, { privateKeyPem: key.privateKeyPem, grant, issuedAt: 10 });
|
|
286
|
+
assert.equal(signed.auth.credentialId, "admin_no_auth");
|
|
287
|
+
assert.equal(verifyOperationRoleKey(signed, [grant], { now: 20 }).ok, true);
|
|
288
|
+
assert.throws(() => signOperationWithRoleKey(operation(), { privateKeyPem: key.privateKeyPem, grant: { bad: true } }), /valid operation role-key grant/);
|
|
289
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
HostPluginRuntime,
|
|
6
|
+
allow,
|
|
7
|
+
createOperationSchemaDescriptor,
|
|
8
|
+
defineHostPlugin,
|
|
9
|
+
createMemoryOperationLog,
|
|
10
|
+
createMemoryRoomStore
|
|
11
|
+
} = require("../src/index.cjs");
|
|
12
|
+
const { ensureOperationIdentity } = require("@mh-gg/protocol");
|
|
13
|
+
|
|
14
|
+
function stateSchema() {
|
|
15
|
+
return { parse(value) { return JSON.parse(JSON.stringify(value)); } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeOp(type, pluginId, id = type) {
|
|
19
|
+
const op = {
|
|
20
|
+
id,
|
|
21
|
+
roomId: "room_security",
|
|
22
|
+
appPackId: "app.security",
|
|
23
|
+
appPackHash: "sha256-app-security",
|
|
24
|
+
pluginId,
|
|
25
|
+
type,
|
|
26
|
+
actor: { memberId: "alice", deviceId: "dev_alice", role: "admin" },
|
|
27
|
+
seq: 1,
|
|
28
|
+
createdAt: 1000,
|
|
29
|
+
payload: {},
|
|
30
|
+
auth: { credentialId: "cred", signature: "sig" }
|
|
31
|
+
};
|
|
32
|
+
return ensureOperationIdentity(op, { nodeId: op.actor.deviceId, now: op.createdAt });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function victimPlugin() {
|
|
36
|
+
return defineHostPlugin({
|
|
37
|
+
id: "com.matterhorn.security.victim",
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "com.matterhorn.security.victim", version: "1.0.0", operations: { noop: { authorize: { roles: ["member"] } } } }),
|
|
40
|
+
schemas: {
|
|
41
|
+
state: stateSchema(),
|
|
42
|
+
operations: { noop: { parse: (value) => value || {} } }
|
|
43
|
+
},
|
|
44
|
+
async createInitialState() { return { secret: "safe", methodTouched: false }; },
|
|
45
|
+
authorize() { return allow(); },
|
|
46
|
+
async reduce(_ctx, state) { return state; },
|
|
47
|
+
methods: {
|
|
48
|
+
mutateState(ctx) {
|
|
49
|
+
ctx.state.secret = "method-pwned";
|
|
50
|
+
ctx.state.methodTouched = true;
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attackerPlugin({ viaMethod = false } = {}) {
|
|
58
|
+
return defineHostPlugin({
|
|
59
|
+
id: "com.matterhorn.security.attacker",
|
|
60
|
+
version: "1.0.0",
|
|
61
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "com.matterhorn.security.attacker", version: "1.0.0", operations: { attack: { authorize: { roles: ["member"] } } } }),
|
|
62
|
+
schemas: {
|
|
63
|
+
state: stateSchema(),
|
|
64
|
+
operations: { attack: { parse: (value) => value || {} } }
|
|
65
|
+
},
|
|
66
|
+
async createInitialState() { return { attacks: 0 }; },
|
|
67
|
+
authorize() { return allow(); },
|
|
68
|
+
async reduce(ctx, state) {
|
|
69
|
+
if (viaMethod) {
|
|
70
|
+
await ctx.plugins.call("com.matterhorn.security.victim", "mutateState", {});
|
|
71
|
+
} else {
|
|
72
|
+
ctx.roomState.plugins["com.matterhorn.security.victim"].secret = "context-pwned";
|
|
73
|
+
}
|
|
74
|
+
return { ...state, attacks: state.attacks + 1 };
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function startRuntime(plugins) {
|
|
80
|
+
const runtime = new HostPluginRuntime({
|
|
81
|
+
room: { id: "room_security", appPack: { id: "app.security", hash: "sha256-app-security" } },
|
|
82
|
+
plugins,
|
|
83
|
+
store: createMemoryRoomStore(),
|
|
84
|
+
operationLog: createMemoryOperationLog(),
|
|
85
|
+
authenticateActor: async (_auth, actor) => actor,
|
|
86
|
+
now: () => 1000
|
|
87
|
+
});
|
|
88
|
+
await runtime.start();
|
|
89
|
+
return runtime;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test("plugin reducers cannot mutate sibling plugin state through their context", async () => {
|
|
93
|
+
const runtime = await startRuntime([victimPlugin(), attackerPlugin()]);
|
|
94
|
+
|
|
95
|
+
const result = await runtime.handleOperation(makeOp("attack", "com.matterhorn.security.attacker"));
|
|
96
|
+
const state = await runtime.getState();
|
|
97
|
+
|
|
98
|
+
assert.equal(result.ok, true);
|
|
99
|
+
assert.equal(state.plugins["com.matterhorn.security.attacker"].attacks, 1);
|
|
100
|
+
assert.equal(state.plugins["com.matterhorn.security.victim"].secret, "safe");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("plugin method calls receive isolated target state and cannot mutate persisted plugin state", async () => {
|
|
104
|
+
const runtime = await startRuntime([victimPlugin(), attackerPlugin({ viaMethod: true })]);
|
|
105
|
+
|
|
106
|
+
const result = await runtime.handleOperation(makeOp("attack", "com.matterhorn.security.attacker", "op_method_attack"));
|
|
107
|
+
const state = await runtime.getState();
|
|
108
|
+
|
|
109
|
+
assert.equal(result.ok, true);
|
|
110
|
+
assert.equal(state.plugins["com.matterhorn.security.attacker"].attacks, 1);
|
|
111
|
+
assert.deepEqual(state.plugins["com.matterhorn.security.victim"], { secret: "safe", methodTouched: false });
|
|
112
|
+
});
|