cojson 0.20.7 → 0.20.9
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +26 -0
- package/dist/SyncStateManager.d.ts.map +1 -1
- package/dist/SyncStateManager.js +0 -2
- package/dist/SyncStateManager.js.map +1 -1
- package/dist/base64url.d.ts +15 -0
- package/dist/base64url.d.ts.map +1 -1
- package/dist/base64url.js +101 -5
- package/dist/base64url.js.map +1 -1
- package/dist/base64url.test.js +76 -1
- package/dist/base64url.test.js.map +1 -1
- package/dist/coValue.d.ts +2 -1
- package/dist/coValue.d.ts.map +1 -1
- package/dist/coValue.js.map +1 -1
- package/dist/coValueCore/coValueCore.d.ts +9 -11
- package/dist/coValueCore/coValueCore.d.ts.map +1 -1
- package/dist/coValueCore/coValueCore.js +92 -65
- package/dist/coValueCore/coValueCore.js.map +1 -1
- package/dist/coValueCore/verifiedState.d.ts +38 -7
- package/dist/coValueCore/verifiedState.d.ts.map +1 -1
- package/dist/coValueCore/verifiedState.js +226 -30
- package/dist/coValueCore/verifiedState.js.map +1 -1
- package/dist/coValues/binaryCoStream.d.ts +63 -0
- package/dist/coValues/binaryCoStream.d.ts.map +1 -0
- package/dist/coValues/binaryCoStream.js +125 -0
- package/dist/coValues/binaryCoStream.js.map +1 -0
- package/dist/coValues/coList.d.ts +3 -1
- package/dist/coValues/coList.d.ts.map +1 -1
- package/dist/coValues/coList.js +15 -6
- package/dist/coValues/coList.js.map +1 -1
- package/dist/coValues/coMap.d.ts +1 -1
- package/dist/coValues/coMap.d.ts.map +1 -1
- package/dist/coValues/coMap.js +2 -2
- package/dist/coValues/coMap.js.map +1 -1
- package/dist/coValues/coStream.d.ts +0 -38
- package/dist/coValues/coStream.d.ts.map +1 -1
- package/dist/coValues/coStream.js +0 -86
- package/dist/coValues/coStream.js.map +1 -1
- package/dist/coValues/group.d.ts +44 -6
- package/dist/coValues/group.d.ts.map +1 -1
- package/dist/coValues/group.js +198 -17
- package/dist/coValues/group.js.map +1 -1
- package/dist/coreToCoValue.d.ts +2 -1
- package/dist/coreToCoValue.d.ts.map +1 -1
- package/dist/coreToCoValue.js +2 -1
- package/dist/coreToCoValue.js.map +1 -1
- package/dist/crypto/NapiCrypto.d.ts +18 -24
- package/dist/crypto/NapiCrypto.d.ts.map +1 -1
- package/dist/crypto/NapiCrypto.js +98 -60
- package/dist/crypto/NapiCrypto.js.map +1 -1
- package/dist/crypto/RNCrypto.d.ts +16 -3
- package/dist/crypto/RNCrypto.d.ts.map +1 -1
- package/dist/crypto/RNCrypto.js +117 -54
- package/dist/crypto/RNCrypto.js.map +1 -1
- package/dist/crypto/WasmCrypto.d.ts +18 -24
- package/dist/crypto/WasmCrypto.d.ts.map +1 -1
- package/dist/crypto/WasmCrypto.js +100 -61
- package/dist/crypto/WasmCrypto.js.map +1 -1
- package/dist/crypto/crypto.d.ts +55 -19
- package/dist/crypto/crypto.d.ts.map +1 -1
- package/dist/crypto/crypto.js +14 -3
- package/dist/crypto/crypto.js.map +1 -1
- package/dist/exports.d.ts +7 -3
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +4 -2
- package/dist/exports.js.map +1 -1
- package/dist/localNode.d.ts +3 -1
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +10 -3
- package/dist/localNode.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/permissions.d.ts +2 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +19 -3
- package/dist/permissions.js.map +1 -1
- package/dist/storage/sqliteAsync/client.d.ts +24 -12
- package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
- package/dist/storage/sqliteAsync/client.js +70 -58
- package/dist/storage/sqliteAsync/client.js.map +1 -1
- package/dist/storage/sqliteAsync/types.d.ts +1 -1
- package/dist/storage/sqliteAsync/types.d.ts.map +1 -1
- package/dist/storage/types.d.ts +1 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +7 -1
- package/dist/sync.js.map +1 -1
- package/dist/tests/CojsonMessageChannel.test.js +2 -2
- package/dist/tests/SQLiteClientAsync.test.d.ts +2 -0
- package/dist/tests/SQLiteClientAsync.test.d.ts.map +1 -0
- package/dist/tests/SQLiteClientAsync.test.js +64 -0
- package/dist/tests/SQLiteClientAsync.test.js.map +1 -0
- package/dist/tests/StorageApiAsync.test.js +2 -8
- package/dist/tests/StorageApiAsync.test.js.map +1 -1
- package/dist/tests/SyncStateManager.test.js +2 -2
- package/dist/tests/WasmCrypto.test.js +1 -15
- package/dist/tests/WasmCrypto.test.js.map +1 -1
- package/dist/tests/coList.test.js +24 -5
- package/dist/tests/coList.test.js.map +1 -1
- package/dist/tests/coStream.test.js +4 -3
- package/dist/tests/coStream.test.js.map +1 -1
- package/dist/tests/coValueCore.initTransaction.test.d.ts +2 -0
- package/dist/tests/coValueCore.initTransaction.test.d.ts.map +1 -0
- package/dist/tests/coValueCore.initTransaction.test.js +438 -0
- package/dist/tests/coValueCore.initTransaction.test.js.map +1 -0
- package/dist/tests/coValueCore.test.js +11 -19
- package/dist/tests/coValueCore.test.js.map +1 -1
- package/dist/tests/crypto.test.js +83 -0
- package/dist/tests/crypto.test.js.map +1 -1
- package/dist/tests/deleteCoValue.test.js +5 -5
- package/dist/tests/deleteCoValue.test.js.map +1 -1
- package/dist/tests/group.inheritance.test.js +11 -0
- package/dist/tests/group.inheritance.test.js.map +1 -1
- package/dist/tests/group.test.js +24 -1
- package/dist/tests/group.test.js.map +1 -1
- package/dist/tests/groupSealer.test.d.ts +2 -0
- package/dist/tests/groupSealer.test.d.ts.map +1 -0
- package/dist/tests/groupSealer.test.js +913 -0
- package/dist/tests/groupSealer.test.js.map +1 -0
- package/dist/tests/setup.js +5 -0
- package/dist/tests/setup.js.map +1 -1
- package/dist/tests/sync.auth.test.js +10 -10
- package/dist/tests/sync.concurrentLoad.test.js +12 -12
- package/dist/tests/sync.deleted.test.js +8 -8
- package/dist/tests/sync.garbageCollection.test.js +10 -10
- package/dist/tests/sync.invite.test.js +12 -12
- package/dist/tests/sync.known.test.js +2 -2
- package/dist/tests/sync.load.test.js +107 -107
- package/dist/tests/sync.mesh.test.js +164 -46
- package/dist/tests/sync.mesh.test.js.map +1 -1
- package/dist/tests/sync.multipleServers.test.js +43 -43
- package/dist/tests/sync.peerReconciliation.test.js +29 -29
- package/dist/tests/sync.sharding.test.js +3 -3
- package/dist/tests/sync.storage.test.js +104 -104
- package/dist/tests/sync.storage.test.js.map +1 -1
- package/dist/tests/sync.storageAsync.test.js +56 -56
- package/dist/tests/sync.upload.test.js +22 -22
- package/dist/tests/testStorage.d.ts +2 -0
- package/dist/tests/testStorage.d.ts.map +1 -1
- package/dist/tests/testStorage.js +30 -6
- package/dist/tests/testStorage.js.map +1 -1
- package/dist/typeUtils/isCoValue.js +1 -1
- package/dist/typeUtils/isCoValue.js.map +1 -1
- package/package.json +4 -4
- package/src/SyncStateManager.ts +0 -2
- package/src/base64url.test.ts +89 -1
- package/src/base64url.ts +134 -6
- package/src/coValue.ts +2 -1
- package/src/coValueCore/coValueCore.ts +126 -84
- package/src/coValueCore/verifiedState.ts +335 -53
- package/src/coValues/binaryCoStream.ts +217 -0
- package/src/coValues/coList.ts +21 -8
- package/src/coValues/coMap.ts +3 -0
- package/src/coValues/coStream.ts +0 -170
- package/src/coValues/group.ts +270 -21
- package/src/coreToCoValue.ts +2 -1
- package/src/crypto/NapiCrypto.ts +198 -95
- package/src/crypto/RNCrypto.ts +229 -102
- package/src/crypto/WasmCrypto.ts +201 -95
- package/src/crypto/crypto.ts +118 -45
- package/src/exports.ts +11 -5
- package/src/localNode.ts +17 -1
- package/src/media.ts +1 -1
- package/src/permissions.ts +30 -7
- package/src/storage/sqliteAsync/client.ts +136 -115
- package/src/storage/sqliteAsync/types.ts +3 -1
- package/src/storage/types.ts +4 -0
- package/src/sync.ts +10 -1
- package/src/tests/CojsonMessageChannel.test.ts +2 -2
- package/src/tests/SQLiteClientAsync.test.ts +75 -0
- package/src/tests/StorageApiAsync.test.ts +4 -9
- package/src/tests/SyncStateManager.test.ts +2 -2
- package/src/tests/WasmCrypto.test.ts +1 -25
- package/src/tests/coList.test.ts +39 -5
- package/src/tests/coStream.test.ts +4 -5
- package/src/tests/coValueCore.initTransaction.test.ts +836 -0
- package/src/tests/coValueCore.test.ts +11 -22
- package/src/tests/crypto.test.ts +107 -0
- package/src/tests/deleteCoValue.test.ts +5 -5
- package/src/tests/group.inheritance.test.ts +16 -0
- package/src/tests/group.test.ts +29 -1
- package/src/tests/groupSealer.test.ts +1473 -0
- package/src/tests/setup.ts +6 -0
- package/src/tests/sync.auth.test.ts +10 -10
- package/src/tests/sync.concurrentLoad.test.ts +12 -12
- package/src/tests/sync.deleted.test.ts +8 -8
- package/src/tests/sync.garbageCollection.test.ts +10 -10
- package/src/tests/sync.invite.test.ts +12 -12
- package/src/tests/sync.known.test.ts +2 -2
- package/src/tests/sync.load.test.ts +107 -107
- package/src/tests/sync.mesh.test.ts +189 -46
- package/src/tests/sync.multipleServers.test.ts +43 -43
- package/src/tests/sync.peerReconciliation.test.ts +29 -29
- package/src/tests/sync.sharding.test.ts +3 -3
- package/src/tests/sync.storage.test.ts +104 -104
- package/src/tests/sync.storageAsync.test.ts +56 -56
- package/src/tests/sync.upload.test.ts +22 -22
- package/src/tests/testStorage.ts +39 -9
- package/src/typeUtils/isCoValue.ts +1 -1
- package/dist/coValueCore/SessionMap.d.ts +0 -55
- package/dist/coValueCore/SessionMap.d.ts.map +0 -1
- package/dist/coValueCore/SessionMap.js +0 -206
- package/dist/coValueCore/SessionMap.js.map +0 -1
- package/dist/tests/coreWasm.test.d.ts +0 -2
- package/dist/tests/coreWasm.test.d.ts.map +0 -1
- package/dist/tests/coreWasm.test.js +0 -203
- package/dist/tests/coreWasm.test.js.map +0 -1
- package/src/coValueCore/SessionMap.ts +0 -394
- package/src/tests/coreWasm.test.ts +0 -452
|
@@ -0,0 +1,1473 @@
|
|
|
1
|
+
import { assert, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
|
3
|
+
import { SessionID } from "../ids.js";
|
|
4
|
+
import { expectGroup } from "../typeUtils/expectGroup.js";
|
|
5
|
+
import { LocalNode } from "../localNode.js";
|
|
6
|
+
import { RawGroup } from "../coValues/group.js";
|
|
7
|
+
import {
|
|
8
|
+
SyncMessagesLog,
|
|
9
|
+
createNConnectedNodes,
|
|
10
|
+
createTwoConnectedNodes,
|
|
11
|
+
createThreeConnectedNodes,
|
|
12
|
+
loadCoValueOrFail,
|
|
13
|
+
setupTestNode,
|
|
14
|
+
} from "./testUtils.js";
|
|
15
|
+
|
|
16
|
+
const crypto = await WasmCrypto.create();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a group without the groupSealer field, simulating a legacy group
|
|
20
|
+
* created before the groupSealer feature was introduced.
|
|
21
|
+
*/
|
|
22
|
+
function createLegacyGroup(node: LocalNode): RawGroup {
|
|
23
|
+
const account = node.getCurrentAgent();
|
|
24
|
+
|
|
25
|
+
const groupCoValue = node.createCoValue({
|
|
26
|
+
type: "comap",
|
|
27
|
+
ruleset: { type: "group", initialAdmin: account.id },
|
|
28
|
+
meta: null,
|
|
29
|
+
...node.crypto.createdNowUnique(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const group = expectGroup(groupCoValue.getCurrentContent());
|
|
33
|
+
|
|
34
|
+
group.set(account.id, "admin", "trusting");
|
|
35
|
+
|
|
36
|
+
const readKey = node.crypto.newRandomKeySecret();
|
|
37
|
+
|
|
38
|
+
group.set(
|
|
39
|
+
`${readKey.id}_for_${account.id}`,
|
|
40
|
+
node.crypto.seal({
|
|
41
|
+
message: readKey.secret,
|
|
42
|
+
from: account.currentSealerSecret(),
|
|
43
|
+
to: account.currentSealerID(),
|
|
44
|
+
nOnceMaterial: {
|
|
45
|
+
in: groupCoValue.id,
|
|
46
|
+
tx: groupCoValue.nextTransactionID(),
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
"trusting",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
group.set("readKey", readKey.id, "trusting");
|
|
53
|
+
|
|
54
|
+
// Intentionally NOT setting groupSealer to simulate a pre-feature group
|
|
55
|
+
|
|
56
|
+
return group;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Unit tests for sealForGroup / unsealForGroup crypto operations (Task 26)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
describe("sealForGroup / unsealForGroup crypto operations", () => {
|
|
64
|
+
test("sealForGroup round-trips with correct sealer secret", () => {
|
|
65
|
+
const data = { secret: "hello world", nested: { value: 123 } };
|
|
66
|
+
const sealer = crypto.newRandomSealer();
|
|
67
|
+
const sealerID = crypto.getSealerID(sealer);
|
|
68
|
+
|
|
69
|
+
const nOnceMaterial = {
|
|
70
|
+
in: "co_zTEST" as const,
|
|
71
|
+
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const sealed = crypto.sealForGroup({
|
|
75
|
+
message: data,
|
|
76
|
+
to: sealerID,
|
|
77
|
+
nOnceMaterial,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(sealed).toMatch(/^sealedForGroup_U/);
|
|
81
|
+
|
|
82
|
+
const unsealed = crypto.unsealForGroup(sealed, sealer, nOnceMaterial);
|
|
83
|
+
expect(unsealed).toEqual(data);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("sealForGroup fails to unseal with wrong sealer secret", () => {
|
|
87
|
+
const data = { secret: "sensitive data" };
|
|
88
|
+
const sealer = crypto.newRandomSealer();
|
|
89
|
+
const wrongSealer = crypto.newRandomSealer();
|
|
90
|
+
const sealerID = crypto.getSealerID(sealer);
|
|
91
|
+
|
|
92
|
+
const nOnceMaterial = {
|
|
93
|
+
in: "co_zTEST" as const,
|
|
94
|
+
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const sealed = crypto.sealForGroup({
|
|
98
|
+
message: data,
|
|
99
|
+
to: sealerID,
|
|
100
|
+
nOnceMaterial,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Wrong sealer should fail to unseal
|
|
104
|
+
const result = crypto.unsealForGroup(sealed, wrongSealer, nOnceMaterial);
|
|
105
|
+
expect(result).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("sealForGroup fails to unseal with wrong nonce material", () => {
|
|
109
|
+
const data = { secret: "sensitive data" };
|
|
110
|
+
const sealer = crypto.newRandomSealer();
|
|
111
|
+
const sealerID = crypto.getSealerID(sealer);
|
|
112
|
+
|
|
113
|
+
const nOnceMaterial = {
|
|
114
|
+
in: "co_zTEST" as const,
|
|
115
|
+
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const wrongNOnceMaterial = {
|
|
119
|
+
in: "co_zDIFFERENT" as const,
|
|
120
|
+
tx: { sessionID: "co_zDIFFERENT_session_zTEST" as SessionID, txIndex: 0 },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const sealed = crypto.sealForGroup({
|
|
124
|
+
message: data,
|
|
125
|
+
to: sealerID,
|
|
126
|
+
nOnceMaterial,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Wrong nonce material should fail to unseal
|
|
130
|
+
const result = crypto.unsealForGroup(sealed, sealer, wrongNOnceMaterial);
|
|
131
|
+
expect(result).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("sealForGroup uses ephemeral keys (different ciphertext each time)", () => {
|
|
135
|
+
const data = { message: "same message" };
|
|
136
|
+
const sealer = crypto.newRandomSealer();
|
|
137
|
+
const sealerID = crypto.getSealerID(sealer);
|
|
138
|
+
|
|
139
|
+
const nOnceMaterial = {
|
|
140
|
+
in: "co_zTEST" as const,
|
|
141
|
+
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const sealed1 = crypto.sealForGroup({
|
|
145
|
+
message: data,
|
|
146
|
+
to: sealerID,
|
|
147
|
+
nOnceMaterial,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const sealed2 = crypto.sealForGroup({
|
|
151
|
+
message: data,
|
|
152
|
+
to: sealerID,
|
|
153
|
+
nOnceMaterial,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Same message should produce different ciphertext due to ephemeral keys
|
|
157
|
+
expect(sealed1).not.toEqual(sealed2);
|
|
158
|
+
|
|
159
|
+
// But both should decrypt to the same value
|
|
160
|
+
expect(crypto.unsealForGroup(sealed1, sealer, nOnceMaterial)).toEqual(data);
|
|
161
|
+
expect(crypto.unsealForGroup(sealed2, sealer, nOnceMaterial)).toEqual(data);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("groupSealerFromReadKey is deterministic", () => {
|
|
165
|
+
const readKey = crypto.newRandomKeySecret();
|
|
166
|
+
|
|
167
|
+
const sealer1 = crypto.groupSealerFromReadKey(readKey.secret);
|
|
168
|
+
const sealer2 = crypto.groupSealerFromReadKey(readKey.secret);
|
|
169
|
+
|
|
170
|
+
expect(sealer1.publicKey).toEqual(sealer2.publicKey);
|
|
171
|
+
expect(sealer1.secret).toEqual(sealer2.secret);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("groupSealerFromReadKey produces different sealers for different keys", () => {
|
|
175
|
+
const readKey1 = crypto.newRandomKeySecret();
|
|
176
|
+
const readKey2 = crypto.newRandomKeySecret();
|
|
177
|
+
|
|
178
|
+
const sealer1 = crypto.groupSealerFromReadKey(readKey1.secret);
|
|
179
|
+
const sealer2 = crypto.groupSealerFromReadKey(readKey2.secret);
|
|
180
|
+
|
|
181
|
+
expect(sealer1.publicKey).not.toEqual(sealer2.publicKey);
|
|
182
|
+
expect(sealer1.secret).not.toEqual(sealer2.secret);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("derived sealer works with sealForGroup/unsealForGroup", () => {
|
|
186
|
+
const readKey = crypto.newRandomKeySecret();
|
|
187
|
+
const sealer = crypto.groupSealerFromReadKey(readKey.secret);
|
|
188
|
+
|
|
189
|
+
const data = { key: "value", num: 42 };
|
|
190
|
+
const nOnceMaterial = {
|
|
191
|
+
in: "co_zTEST" as const,
|
|
192
|
+
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const sealed = crypto.sealForGroup({
|
|
196
|
+
message: data,
|
|
197
|
+
to: sealer.publicKey,
|
|
198
|
+
nOnceMaterial,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const unsealed = crypto.unsealForGroup(
|
|
202
|
+
sealed,
|
|
203
|
+
sealer.secret,
|
|
204
|
+
nOnceMaterial,
|
|
205
|
+
);
|
|
206
|
+
expect(unsealed).toEqual(data);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Integration tests for groupSealer (Tasks 27-36)
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
let jazzCloud: ReturnType<typeof setupTestNode>;
|
|
215
|
+
|
|
216
|
+
beforeEach(async () => {
|
|
217
|
+
SyncMessagesLog.clear();
|
|
218
|
+
jazzCloud = setupTestNode({ isSyncServer: true });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("groups created with groupSealer", () => {
|
|
222
|
+
test("new groups are created with a groupSealer field (Task 27)", async () => {
|
|
223
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
224
|
+
|
|
225
|
+
const group = node1.node.createGroup();
|
|
226
|
+
|
|
227
|
+
const groupSealer = group.get("groupSealer");
|
|
228
|
+
expect(groupSealer).toBeDefined();
|
|
229
|
+
// New composite format: "key_z...@sealer_z..."
|
|
230
|
+
expect(groupSealer).toMatch(/^key_z.+@sealer_z/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("groupSealer is derived from readKey deterministically (Task 28)", async () => {
|
|
234
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
235
|
+
|
|
236
|
+
const group = node1.node.createGroup();
|
|
237
|
+
|
|
238
|
+
const groupSealer = group.get("groupSealer") as string;
|
|
239
|
+
const readKey = group.getCurrentReadKey();
|
|
240
|
+
|
|
241
|
+
expect(readKey.secret).toBeDefined();
|
|
242
|
+
if (!readKey.secret) {
|
|
243
|
+
throw new Error("Expected read key secret");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const derivedSealer = crypto.groupSealerFromReadKey(readKey.secret);
|
|
247
|
+
// Composite format: "readKeyID@sealerID"
|
|
248
|
+
expect(groupSealer).toEqual(`${readKey.id}@${derivedSealer.publicKey}`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("getGroupSealerSecret derives the correct secret", async () => {
|
|
252
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
253
|
+
|
|
254
|
+
const group = node1.node.createGroup();
|
|
255
|
+
|
|
256
|
+
const groupSealerSecret = group.getGroupSealerSecret();
|
|
257
|
+
expect(groupSealerSecret).toBeDefined();
|
|
258
|
+
|
|
259
|
+
const groupSealerValue = group.get("groupSealer") as string;
|
|
260
|
+
expect(groupSealerValue).toBeDefined();
|
|
261
|
+
|
|
262
|
+
// Verify the secret corresponds to the SealerID embedded in the composite value
|
|
263
|
+
assert(groupSealerSecret, "Expected groupSealerSecret");
|
|
264
|
+
const derivedID = crypto.getSealerID(groupSealerSecret);
|
|
265
|
+
expect(groupSealerValue).toContain(derivedID);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("non-member extending child to parent via groupSealer", () => {
|
|
270
|
+
test("parent member can read child group content via extension", async () => {
|
|
271
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
272
|
+
"server",
|
|
273
|
+
"server",
|
|
274
|
+
"server",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Node1 creates parent group and adds node2 as member
|
|
278
|
+
const parentGroup = node1.node.createGroup();
|
|
279
|
+
await parentGroup.core.waitForSync();
|
|
280
|
+
|
|
281
|
+
// Node2 creates child group and extends parent (node2 is a member of parent)
|
|
282
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
283
|
+
const childGroup = node2.node.createGroup();
|
|
284
|
+
childGroup.extend(parentOnNode2);
|
|
285
|
+
|
|
286
|
+
// Add node3 as writeOnly to child (node3 is NOT a member of parent)
|
|
287
|
+
const account3OnNode2 = await loadCoValueOrFail(
|
|
288
|
+
node2.node,
|
|
289
|
+
node3.accountID,
|
|
290
|
+
);
|
|
291
|
+
childGroup.addMember(account3OnNode2, "writeOnly");
|
|
292
|
+
|
|
293
|
+
const map = childGroup.createMap();
|
|
294
|
+
map.set("test", "Written by node2");
|
|
295
|
+
|
|
296
|
+
await map.core.waitForSync();
|
|
297
|
+
await childGroup.core.waitForSync();
|
|
298
|
+
|
|
299
|
+
// Node1 (parent member) should be able to read content from child
|
|
300
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
301
|
+
expect(mapOnNode1.get("test")).toEqual("Written by node2");
|
|
302
|
+
|
|
303
|
+
// Verify that the child group has the groupSealer set
|
|
304
|
+
const childGroupOnNode1 = await loadCoValueOrFail(
|
|
305
|
+
node1.node,
|
|
306
|
+
childGroup.id,
|
|
307
|
+
);
|
|
308
|
+
const childGroupSealer = childGroupOnNode1.get("groupSealer");
|
|
309
|
+
expect(childGroupSealer).toBeDefined();
|
|
310
|
+
// New composite format: "key_z...@sealer_z..."
|
|
311
|
+
expect(childGroupSealer).toMatch(/^key_z.+@sealer_z/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("writeOnly member uses groupSealer for key revelation", async () => {
|
|
315
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
316
|
+
"server",
|
|
317
|
+
"server",
|
|
318
|
+
"server",
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Node1 creates parent group and adds node2 as admin
|
|
322
|
+
const parentGroup = node1.node.createGroup();
|
|
323
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
324
|
+
node1.node,
|
|
325
|
+
node2.accountID,
|
|
326
|
+
);
|
|
327
|
+
parentGroup.addMember(account2OnNode1, "admin");
|
|
328
|
+
|
|
329
|
+
await parentGroup.core.waitForSync();
|
|
330
|
+
|
|
331
|
+
// Node2 creates child group and extends parent
|
|
332
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
333
|
+
const childGroup = node2.node.createGroup();
|
|
334
|
+
childGroup.extend(parentOnNode2);
|
|
335
|
+
|
|
336
|
+
// Add node3 as writeOnly to child (node3 is NOT a member of parent)
|
|
337
|
+
const account3OnNode2 = await loadCoValueOrFail(
|
|
338
|
+
node2.node,
|
|
339
|
+
node3.accountID,
|
|
340
|
+
);
|
|
341
|
+
childGroup.addMember(account3OnNode2, "writeOnly");
|
|
342
|
+
|
|
343
|
+
await childGroup.core.waitForSync();
|
|
344
|
+
|
|
345
|
+
// Node3 now has writeOnly access to child but no access to parent
|
|
346
|
+
// When node3 writes, the child needs to reveal keys to parent via groupSealer
|
|
347
|
+
const childGroupOnNode3 = await loadCoValueOrFail(
|
|
348
|
+
node3.node,
|
|
349
|
+
childGroup.id,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const map = childGroupOnNode3.createMap();
|
|
353
|
+
map.set("test", "Written by node3 (writeOnly)");
|
|
354
|
+
|
|
355
|
+
await map.core.waitForSync();
|
|
356
|
+
|
|
357
|
+
// Node1 (parent admin) should be able to read content written by node3
|
|
358
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
359
|
+
expect(mapOnNode1.get("test")).toEqual("Written by node3 (writeOnly)");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("no writeOnly key created when parent has groupSealer", async () => {
|
|
363
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
364
|
+
"server",
|
|
365
|
+
"server",
|
|
366
|
+
"server",
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const parentGroup = node1.node.createGroup();
|
|
370
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
371
|
+
node1.node,
|
|
372
|
+
node2.accountID,
|
|
373
|
+
);
|
|
374
|
+
parentGroup.addMember(account2OnNode1, "writer");
|
|
375
|
+
|
|
376
|
+
await parentGroup.core.waitForSync();
|
|
377
|
+
|
|
378
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
379
|
+
const childGroup = node2.node.createGroup();
|
|
380
|
+
childGroup.extend(parentOnNode2);
|
|
381
|
+
|
|
382
|
+
// Add node3 as writeOnly (this should use groupSealer, not writeOnly key)
|
|
383
|
+
const account3OnNode2 = await loadCoValueOrFail(
|
|
384
|
+
node2.node,
|
|
385
|
+
node3.accountID,
|
|
386
|
+
);
|
|
387
|
+
childGroup.addMember(account3OnNode2, "writeOnly");
|
|
388
|
+
|
|
389
|
+
await childGroup.core.waitForSync();
|
|
390
|
+
|
|
391
|
+
// Check that no writeKeyFor_ entry exists for the parent group
|
|
392
|
+
// This verifies we're using groupSealer instead of writeOnly keys
|
|
393
|
+
const childGroupOnNode1 = await loadCoValueOrFail(
|
|
394
|
+
node1.node,
|
|
395
|
+
childGroup.id,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// The parent group ID should not have a writeKeyFor entry in the child
|
|
399
|
+
const writeKeyForParent = childGroupOnNode1.get(
|
|
400
|
+
`writeKeyFor_${parentGroup.id}` as any,
|
|
401
|
+
);
|
|
402
|
+
expect(writeKeyForParent).toBeUndefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Role variation tests
|
|
406
|
+
test("reader in parent can read child content via groupSealer extension", async () => {
|
|
407
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
408
|
+
"server",
|
|
409
|
+
"server",
|
|
410
|
+
"server",
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Node1 creates parent group and adds node2 as reader (not admin/writer)
|
|
414
|
+
const parentGroup = node1.node.createGroup();
|
|
415
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
416
|
+
node1.node,
|
|
417
|
+
node2.accountID,
|
|
418
|
+
);
|
|
419
|
+
parentGroup.addMember(account2OnNode1, "reader");
|
|
420
|
+
|
|
421
|
+
await parentGroup.core.waitForSync();
|
|
422
|
+
|
|
423
|
+
// Node3 creates child group and extends parent (node3 is NOT a member of parent)
|
|
424
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
425
|
+
const childGroup = node3.node.createGroup();
|
|
426
|
+
childGroup.extend(parentOnNode3);
|
|
427
|
+
|
|
428
|
+
const map = childGroup.createMap();
|
|
429
|
+
map.set("test", "Written by non-member node3");
|
|
430
|
+
|
|
431
|
+
await map.core.waitForSync();
|
|
432
|
+
await childGroup.core.waitForSync();
|
|
433
|
+
|
|
434
|
+
// Node2 (reader in parent) should be able to read content from child
|
|
435
|
+
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
|
436
|
+
expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("writer in parent can read child content via groupSealer extension", async () => {
|
|
440
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
441
|
+
"server",
|
|
442
|
+
"server",
|
|
443
|
+
"server",
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// Node1 creates parent group and adds node2 as writer
|
|
447
|
+
const parentGroup = node1.node.createGroup();
|
|
448
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
449
|
+
node1.node,
|
|
450
|
+
node2.accountID,
|
|
451
|
+
);
|
|
452
|
+
parentGroup.addMember(account2OnNode1, "writer");
|
|
453
|
+
|
|
454
|
+
await parentGroup.core.waitForSync();
|
|
455
|
+
|
|
456
|
+
// Node3 creates child group and extends parent (node3 is NOT a member of parent)
|
|
457
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
458
|
+
const childGroup = node3.node.createGroup();
|
|
459
|
+
childGroup.extend(parentOnNode3);
|
|
460
|
+
|
|
461
|
+
const map = childGroup.createMap();
|
|
462
|
+
map.set("test", "Written by non-member node3");
|
|
463
|
+
|
|
464
|
+
await map.core.waitForSync();
|
|
465
|
+
await childGroup.core.waitForSync();
|
|
466
|
+
|
|
467
|
+
// Node2 (writer in parent) should be able to read content from child
|
|
468
|
+
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
|
469
|
+
expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("manager in parent can read child content via groupSealer extension", async () => {
|
|
473
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
474
|
+
"server",
|
|
475
|
+
"server",
|
|
476
|
+
"server",
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// Node1 creates parent group and adds node2 as manager
|
|
480
|
+
const parentGroup = node1.node.createGroup();
|
|
481
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
482
|
+
node1.node,
|
|
483
|
+
node2.accountID,
|
|
484
|
+
);
|
|
485
|
+
parentGroup.addMember(account2OnNode1, "manager");
|
|
486
|
+
|
|
487
|
+
await parentGroup.core.waitForSync();
|
|
488
|
+
|
|
489
|
+
// Node3 creates child group and extends parent (node3 is NOT a member of parent)
|
|
490
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
491
|
+
const childGroup = node3.node.createGroup();
|
|
492
|
+
childGroup.extend(parentOnNode3);
|
|
493
|
+
|
|
494
|
+
const map = childGroup.createMap();
|
|
495
|
+
map.set("test", "Written by non-member node3");
|
|
496
|
+
|
|
497
|
+
await map.core.waitForSync();
|
|
498
|
+
await childGroup.core.waitForSync();
|
|
499
|
+
|
|
500
|
+
// Node2 (manager in parent) should be able to read content from child
|
|
501
|
+
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
|
502
|
+
expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Multi-level hierarchy tests
|
|
506
|
+
test("three-level extension chain: grandparent can read grandchild content", async () => {
|
|
507
|
+
const nodes = await createNConnectedNodes(
|
|
508
|
+
"server",
|
|
509
|
+
"server",
|
|
510
|
+
"server",
|
|
511
|
+
"server",
|
|
512
|
+
);
|
|
513
|
+
const node1 = nodes[0]!;
|
|
514
|
+
const node2 = nodes[1]!;
|
|
515
|
+
const node3 = nodes[2]!;
|
|
516
|
+
const node4 = nodes[3]!;
|
|
517
|
+
|
|
518
|
+
// Node1 creates grandparent group and adds node2 as admin
|
|
519
|
+
const grandparentGroup = node1.node.createGroup();
|
|
520
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
521
|
+
node1.node,
|
|
522
|
+
node2.accountID,
|
|
523
|
+
);
|
|
524
|
+
grandparentGroup.addMember(account2OnNode1, "admin");
|
|
525
|
+
|
|
526
|
+
await grandparentGroup.core.waitForSync();
|
|
527
|
+
|
|
528
|
+
// Node2 creates parent group and extends grandparent
|
|
529
|
+
const grandparentOnNode2 = await loadCoValueOrFail(
|
|
530
|
+
node2.node,
|
|
531
|
+
grandparentGroup.id,
|
|
532
|
+
);
|
|
533
|
+
const parentGroup = node2.node.createGroup();
|
|
534
|
+
parentGroup.extend(grandparentOnNode2);
|
|
535
|
+
|
|
536
|
+
await parentGroup.core.waitForSync();
|
|
537
|
+
|
|
538
|
+
// Node3 (NOT a member of grandparent or parent) creates child and extends parent
|
|
539
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
540
|
+
const childGroup = node3.node.createGroup();
|
|
541
|
+
childGroup.extend(parentOnNode3);
|
|
542
|
+
|
|
543
|
+
// Node4 (NOT a member) creates grandchild and extends child
|
|
544
|
+
const childOnNode4 = await loadCoValueOrFail(node4.node, childGroup.id);
|
|
545
|
+
const grandchildGroup = node4.node.createGroup();
|
|
546
|
+
grandchildGroup.extend(childOnNode4);
|
|
547
|
+
|
|
548
|
+
const map = grandchildGroup.createMap();
|
|
549
|
+
map.set("test", "Written in grandchild by node4");
|
|
550
|
+
|
|
551
|
+
await map.core.waitForSync();
|
|
552
|
+
await grandchildGroup.core.waitForSync();
|
|
553
|
+
await childGroup.core.waitForSync();
|
|
554
|
+
await parentGroup.core.waitForSync();
|
|
555
|
+
|
|
556
|
+
// Node1 (grandparent admin) should be able to read content from grandchild
|
|
557
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
558
|
+
expect(mapOnNode1.get("test")).toEqual("Written in grandchild by node4");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("parallel extensions: two non-members extend to same parent", async () => {
|
|
562
|
+
const nodes = await createNConnectedNodes(
|
|
563
|
+
"server",
|
|
564
|
+
"server",
|
|
565
|
+
"server",
|
|
566
|
+
"server",
|
|
567
|
+
);
|
|
568
|
+
const node1 = nodes[0]!;
|
|
569
|
+
const node2 = nodes[1]!;
|
|
570
|
+
const node3 = nodes[2]!;
|
|
571
|
+
const node4 = nodes[3]!;
|
|
572
|
+
|
|
573
|
+
// Node1 creates parent group
|
|
574
|
+
const parentGroup = node1.node.createGroup();
|
|
575
|
+
|
|
576
|
+
await parentGroup.core.waitForSync();
|
|
577
|
+
|
|
578
|
+
// Node2 (NOT a member of parent) creates child1 and extends parent
|
|
579
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
580
|
+
const childGroup1 = node2.node.createGroup();
|
|
581
|
+
childGroup1.extend(parentOnNode2);
|
|
582
|
+
|
|
583
|
+
const map1 = childGroup1.createMap();
|
|
584
|
+
map1.set("test", "Written by node2 in child1");
|
|
585
|
+
|
|
586
|
+
await map1.core.waitForSync();
|
|
587
|
+
await childGroup1.core.waitForSync();
|
|
588
|
+
|
|
589
|
+
// Node3 (NOT a member of parent) creates child2 and extends parent
|
|
590
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
591
|
+
const childGroup2 = node3.node.createGroup();
|
|
592
|
+
childGroup2.extend(parentOnNode3);
|
|
593
|
+
|
|
594
|
+
const map2 = childGroup2.createMap();
|
|
595
|
+
map2.set("test", "Written by node3 in child2");
|
|
596
|
+
|
|
597
|
+
await map2.core.waitForSync();
|
|
598
|
+
await childGroup2.core.waitForSync();
|
|
599
|
+
|
|
600
|
+
// Node4 (NOT a member of parent) creates child3 and extends parent
|
|
601
|
+
const parentOnNode4 = await loadCoValueOrFail(node4.node, parentGroup.id);
|
|
602
|
+
const childGroup3 = node4.node.createGroup();
|
|
603
|
+
childGroup3.extend(parentOnNode4);
|
|
604
|
+
|
|
605
|
+
const map3 = childGroup3.createMap();
|
|
606
|
+
map3.set("test", "Written by node4 in child3");
|
|
607
|
+
|
|
608
|
+
await map3.core.waitForSync();
|
|
609
|
+
await childGroup3.core.waitForSync();
|
|
610
|
+
|
|
611
|
+
// Node1 (parent admin) should be able to read all three child contents
|
|
612
|
+
const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
|
|
613
|
+
expect(map1OnNode1.get("test")).toEqual("Written by node2 in child1");
|
|
614
|
+
|
|
615
|
+
const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
|
|
616
|
+
expect(map2OnNode1.get("test")).toEqual("Written by node3 in child2");
|
|
617
|
+
|
|
618
|
+
const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
|
|
619
|
+
expect(map3OnNode1.get("test")).toEqual("Written by node4 in child3");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Key rotation scenarios
|
|
623
|
+
test("extension after parent key rotation uses new groupSealer", async () => {
|
|
624
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
625
|
+
"server",
|
|
626
|
+
"server",
|
|
627
|
+
"server",
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// Node1 creates parent group
|
|
631
|
+
const parentGroup = node1.node.createGroup();
|
|
632
|
+
const originalGroupSealer = parentGroup.get("groupSealer");
|
|
633
|
+
|
|
634
|
+
await parentGroup.core.waitForSync();
|
|
635
|
+
|
|
636
|
+
// Node1 rotates the parent group's read key (and thus groupSealer)
|
|
637
|
+
parentGroup.rotateReadKey();
|
|
638
|
+
const newGroupSealer = parentGroup.get("groupSealer");
|
|
639
|
+
|
|
640
|
+
expect(newGroupSealer).not.toEqual(originalGroupSealer);
|
|
641
|
+
|
|
642
|
+
await parentGroup.core.waitForSync();
|
|
643
|
+
|
|
644
|
+
// Node2 (NOT a member of parent) creates child and extends parent after rotation
|
|
645
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
646
|
+
|
|
647
|
+
// Verify node2 sees the new groupSealer
|
|
648
|
+
expect(parentOnNode2.get("groupSealer")).toEqual(newGroupSealer);
|
|
649
|
+
|
|
650
|
+
const childGroup = node2.node.createGroup();
|
|
651
|
+
childGroup.extend(parentOnNode2);
|
|
652
|
+
|
|
653
|
+
const map = childGroup.createMap();
|
|
654
|
+
map.set("test", "Written after parent key rotation");
|
|
655
|
+
|
|
656
|
+
await map.core.waitForSync();
|
|
657
|
+
await childGroup.core.waitForSync();
|
|
658
|
+
|
|
659
|
+
// Node1 (parent admin) should be able to read content using the new groupSealer
|
|
660
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
661
|
+
expect(mapOnNode1.get("test")).toEqual("Written after parent key rotation");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("old content remains readable after child key rotation", async () => {
|
|
665
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
666
|
+
"server",
|
|
667
|
+
"server",
|
|
668
|
+
"server",
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
// Node1 creates parent group
|
|
672
|
+
const parentGroup = node1.node.createGroup();
|
|
673
|
+
|
|
674
|
+
await parentGroup.core.waitForSync();
|
|
675
|
+
|
|
676
|
+
// Node2 (NOT a member of parent) creates child and extends parent
|
|
677
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
678
|
+
const childGroup = node2.node.createGroup();
|
|
679
|
+
childGroup.extend(parentOnNode2);
|
|
680
|
+
|
|
681
|
+
// Create content before key rotation
|
|
682
|
+
const mapBefore = childGroup.createMap();
|
|
683
|
+
mapBefore.set("test", "Written before child key rotation");
|
|
684
|
+
|
|
685
|
+
await mapBefore.core.waitForSync();
|
|
686
|
+
await childGroup.core.waitForSync();
|
|
687
|
+
|
|
688
|
+
// Node1 verifies it can read content before rotation
|
|
689
|
+
const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
|
|
690
|
+
expect(mapBeforeOnNode1.get("test")).toEqual(
|
|
691
|
+
"Written before child key rotation",
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// Node2 rotates child group's key
|
|
695
|
+
// Note: For non-members, the new key cannot be revealed to parent via groupSealer
|
|
696
|
+
// in the current implementation (see GitHub issue #1979)
|
|
697
|
+
childGroup.rotateReadKey();
|
|
698
|
+
|
|
699
|
+
await childGroup.core.waitForSync();
|
|
700
|
+
|
|
701
|
+
// Node1 should still be able to read OLD content created before rotation
|
|
702
|
+
// This verifies the historical sealer mechanism works correctly
|
|
703
|
+
const mapBeforeOnNode1Again = await loadCoValueOrFail(
|
|
704
|
+
node1.node,
|
|
705
|
+
mapBefore.id,
|
|
706
|
+
);
|
|
707
|
+
expect(mapBeforeOnNode1Again.get("test")).toEqual(
|
|
708
|
+
"Written before child key rotation",
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("member of parent can read child content after child key rotation", async () => {
|
|
713
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
714
|
+
"server",
|
|
715
|
+
"server",
|
|
716
|
+
"server",
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// Node1 creates parent group and adds node2 as admin
|
|
720
|
+
const parentGroup = node1.node.createGroup();
|
|
721
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
722
|
+
node1.node,
|
|
723
|
+
node2.accountID,
|
|
724
|
+
);
|
|
725
|
+
parentGroup.addMember(account2OnNode1, "admin");
|
|
726
|
+
|
|
727
|
+
await parentGroup.core.waitForSync();
|
|
728
|
+
|
|
729
|
+
// Node2 (member of parent) creates child and extends parent
|
|
730
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
731
|
+
const childGroup = node2.node.createGroup();
|
|
732
|
+
childGroup.extend(parentOnNode2);
|
|
733
|
+
|
|
734
|
+
// Create content before key rotation
|
|
735
|
+
const mapBefore = childGroup.createMap();
|
|
736
|
+
mapBefore.set("test", "Written before rotation");
|
|
737
|
+
|
|
738
|
+
await mapBefore.core.waitForSync();
|
|
739
|
+
await childGroup.core.waitForSync();
|
|
740
|
+
|
|
741
|
+
// Node2 rotates child group's key (as a member, they have access to parent readKey)
|
|
742
|
+
childGroup.rotateReadKey();
|
|
743
|
+
|
|
744
|
+
// Create content after key rotation
|
|
745
|
+
const mapAfter = childGroup.createMap();
|
|
746
|
+
mapAfter.set("test", "Written after rotation");
|
|
747
|
+
|
|
748
|
+
await mapAfter.core.waitForSync();
|
|
749
|
+
await childGroup.core.waitForSync();
|
|
750
|
+
|
|
751
|
+
// Node1 should be able to read both old and new content
|
|
752
|
+
const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
|
|
753
|
+
expect(mapBeforeOnNode1.get("test")).toEqual("Written before rotation");
|
|
754
|
+
|
|
755
|
+
const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
|
|
756
|
+
expect(mapAfterOnNode1.get("test")).toEqual("Written after rotation");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("old content visible after 3 key rotations via historical groupSealer", async () => {
|
|
760
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
761
|
+
"server",
|
|
762
|
+
"server",
|
|
763
|
+
"server",
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
// Node1 creates parent group and adds node2 as admin
|
|
767
|
+
const parentGroup = node1.node.createGroup();
|
|
768
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
769
|
+
node1.node,
|
|
770
|
+
node2.accountID,
|
|
771
|
+
);
|
|
772
|
+
parentGroup.addMember(account2OnNode1, "admin");
|
|
773
|
+
|
|
774
|
+
await parentGroup.core.waitForSync();
|
|
775
|
+
|
|
776
|
+
// Node2 (member of parent) creates child and extends parent
|
|
777
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
778
|
+
const childGroup = node2.node.createGroup();
|
|
779
|
+
childGroup.extend(parentOnNode2);
|
|
780
|
+
|
|
781
|
+
// Create content with the initial key
|
|
782
|
+
const map0 = childGroup.createMap();
|
|
783
|
+
map0.set("test", "Before any rotation");
|
|
784
|
+
|
|
785
|
+
await map0.core.waitForSync();
|
|
786
|
+
await childGroup.core.waitForSync();
|
|
787
|
+
|
|
788
|
+
// Rotation 1
|
|
789
|
+
childGroup.rotateReadKey();
|
|
790
|
+
|
|
791
|
+
const map1 = childGroup.createMap();
|
|
792
|
+
map1.set("test", "After rotation 1");
|
|
793
|
+
|
|
794
|
+
await map1.core.waitForSync();
|
|
795
|
+
await childGroup.core.waitForSync();
|
|
796
|
+
|
|
797
|
+
// Rotation 2
|
|
798
|
+
childGroup.rotateReadKey();
|
|
799
|
+
|
|
800
|
+
const map2 = childGroup.createMap();
|
|
801
|
+
map2.set("test", "After rotation 2");
|
|
802
|
+
|
|
803
|
+
await map2.core.waitForSync();
|
|
804
|
+
await childGroup.core.waitForSync();
|
|
805
|
+
|
|
806
|
+
// Rotation 3
|
|
807
|
+
childGroup.rotateReadKey();
|
|
808
|
+
|
|
809
|
+
const map3 = childGroup.createMap();
|
|
810
|
+
map3.set("test", "After rotation 3");
|
|
811
|
+
|
|
812
|
+
await map3.core.waitForSync();
|
|
813
|
+
await childGroup.core.waitForSync();
|
|
814
|
+
|
|
815
|
+
// Node1 (parent admin) should be able to read content from all key generations
|
|
816
|
+
const map0OnNode1 = await loadCoValueOrFail(node1.node, map0.id);
|
|
817
|
+
expect(map0OnNode1.get("test")).toEqual("Before any rotation");
|
|
818
|
+
|
|
819
|
+
const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
|
|
820
|
+
expect(map1OnNode1.get("test")).toEqual("After rotation 1");
|
|
821
|
+
|
|
822
|
+
const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
|
|
823
|
+
expect(map2OnNode1.get("test")).toEqual("After rotation 2");
|
|
824
|
+
|
|
825
|
+
const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
|
|
826
|
+
expect(map3OnNode1.get("test")).toEqual("After rotation 3");
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Edge case tests
|
|
830
|
+
test("non-member can create multiple CoValues readable by parent", async () => {
|
|
831
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
832
|
+
"server",
|
|
833
|
+
"server",
|
|
834
|
+
"server",
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
// Node1 creates parent group
|
|
838
|
+
const parentGroup = node1.node.createGroup();
|
|
839
|
+
|
|
840
|
+
await parentGroup.core.waitForSync();
|
|
841
|
+
|
|
842
|
+
// Node2 (NOT a member of parent) creates child and extends parent
|
|
843
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
844
|
+
const childGroup = node2.node.createGroup();
|
|
845
|
+
childGroup.extend(parentOnNode2);
|
|
846
|
+
|
|
847
|
+
// Create multiple maps
|
|
848
|
+
const map1 = childGroup.createMap();
|
|
849
|
+
map1.set("name", "Map 1");
|
|
850
|
+
|
|
851
|
+
const map2 = childGroup.createMap();
|
|
852
|
+
map2.set("name", "Map 2");
|
|
853
|
+
|
|
854
|
+
const map3 = childGroup.createMap();
|
|
855
|
+
map3.set("name", "Map 3");
|
|
856
|
+
|
|
857
|
+
const map4 = childGroup.createMap();
|
|
858
|
+
map4.set("name", "Map 4");
|
|
859
|
+
|
|
860
|
+
const map5 = childGroup.createMap();
|
|
861
|
+
map5.set("name", "Map 5");
|
|
862
|
+
|
|
863
|
+
await Promise.all([
|
|
864
|
+
map1.core.waitForSync(),
|
|
865
|
+
map2.core.waitForSync(),
|
|
866
|
+
map3.core.waitForSync(),
|
|
867
|
+
map4.core.waitForSync(),
|
|
868
|
+
map5.core.waitForSync(),
|
|
869
|
+
]);
|
|
870
|
+
await childGroup.core.waitForSync();
|
|
871
|
+
|
|
872
|
+
// Node1 (parent admin) should be able to read all maps
|
|
873
|
+
const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
|
|
874
|
+
expect(map1OnNode1.get("name")).toEqual("Map 1");
|
|
875
|
+
|
|
876
|
+
const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
|
|
877
|
+
expect(map2OnNode1.get("name")).toEqual("Map 2");
|
|
878
|
+
|
|
879
|
+
const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
|
|
880
|
+
expect(map3OnNode1.get("name")).toEqual("Map 3");
|
|
881
|
+
|
|
882
|
+
const map4OnNode1 = await loadCoValueOrFail(node1.node, map4.id);
|
|
883
|
+
expect(map4OnNode1.get("name")).toEqual("Map 4");
|
|
884
|
+
|
|
885
|
+
const map5OnNode1 = await loadCoValueOrFail(node1.node, map5.id);
|
|
886
|
+
expect(map5OnNode1.get("name")).toEqual("Map 5");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("content created before extension is accessible after extension", async () => {
|
|
890
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
891
|
+
"server",
|
|
892
|
+
"server",
|
|
893
|
+
"server",
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
// Node1 creates parent group
|
|
897
|
+
const parentGroup = node1.node.createGroup();
|
|
898
|
+
|
|
899
|
+
await parentGroup.core.waitForSync();
|
|
900
|
+
|
|
901
|
+
// Node2 creates child group and content BEFORE extending
|
|
902
|
+
const childGroup = node2.node.createGroup();
|
|
903
|
+
|
|
904
|
+
const mapBefore = childGroup.createMap();
|
|
905
|
+
mapBefore.set("test", "Created before extension");
|
|
906
|
+
|
|
907
|
+
await mapBefore.core.waitForSync();
|
|
908
|
+
await childGroup.core.waitForSync();
|
|
909
|
+
|
|
910
|
+
// Now extend the child to parent
|
|
911
|
+
const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
|
|
912
|
+
childGroup.extend(parentOnNode2);
|
|
913
|
+
|
|
914
|
+
// Create content after extension
|
|
915
|
+
const mapAfter = childGroup.createMap();
|
|
916
|
+
mapAfter.set("test", "Created after extension");
|
|
917
|
+
|
|
918
|
+
await mapAfter.core.waitForSync();
|
|
919
|
+
await childGroup.core.waitForSync();
|
|
920
|
+
|
|
921
|
+
// Node1 should be able to read content created both before and after extension
|
|
922
|
+
const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
|
|
923
|
+
expect(mapBeforeOnNode1.get("test")).toEqual("Created before extension");
|
|
924
|
+
|
|
925
|
+
const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
|
|
926
|
+
expect(mapAfterOnNode1.get("test")).toEqual("Created after extension");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("nested group membership: members of member-group can read via groupSealer", async () => {
|
|
930
|
+
const nodes = await createNConnectedNodes(
|
|
931
|
+
"server",
|
|
932
|
+
"server",
|
|
933
|
+
"server",
|
|
934
|
+
"server",
|
|
935
|
+
);
|
|
936
|
+
const node1 = nodes[0]!;
|
|
937
|
+
const node2 = nodes[1]!;
|
|
938
|
+
const node3 = nodes[2]!;
|
|
939
|
+
const node4 = nodes[3]!;
|
|
940
|
+
|
|
941
|
+
// Node1 creates an inner group and adds node2 as admin
|
|
942
|
+
const innerGroup = node1.node.createGroup();
|
|
943
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
944
|
+
node1.node,
|
|
945
|
+
node2.accountID,
|
|
946
|
+
);
|
|
947
|
+
innerGroup.addMember(account2OnNode1, "admin");
|
|
948
|
+
|
|
949
|
+
await innerGroup.core.waitForSync();
|
|
950
|
+
|
|
951
|
+
// Node1 creates parent group and adds innerGroup as member (nested group membership)
|
|
952
|
+
// Using extend() to add innerGroup's members to parentGroup
|
|
953
|
+
const parentGroup = node1.node.createGroup();
|
|
954
|
+
parentGroup.extend(innerGroup, "writer");
|
|
955
|
+
|
|
956
|
+
await parentGroup.core.waitForSync();
|
|
957
|
+
|
|
958
|
+
// Node3 (NOT a member of parent or innerGroup) creates child and extends parent
|
|
959
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
960
|
+
const childGroup = node3.node.createGroup();
|
|
961
|
+
childGroup.extend(parentOnNode3);
|
|
962
|
+
|
|
963
|
+
const map = childGroup.createMap();
|
|
964
|
+
map.set("test", "Written by non-member node3");
|
|
965
|
+
|
|
966
|
+
await map.core.waitForSync();
|
|
967
|
+
await childGroup.core.waitForSync();
|
|
968
|
+
|
|
969
|
+
// Node2 (member of innerGroup, which is extended by parent) should be able to read
|
|
970
|
+
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
|
971
|
+
expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
|
|
972
|
+
|
|
973
|
+
// Node1 (admin of both groups) should also be able to read
|
|
974
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
975
|
+
expect(mapOnNode1.get("test")).toEqual("Written by non-member node3");
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe("key rotation updates groupSealer", () => {
|
|
980
|
+
test("rotating readKey also rotates groupSealer", async () => {
|
|
981
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
982
|
+
|
|
983
|
+
const group = node1.node.createGroup();
|
|
984
|
+
const originalGroupSealer = group.get("groupSealer");
|
|
985
|
+
|
|
986
|
+
// Force key rotation
|
|
987
|
+
group.rotateReadKey();
|
|
988
|
+
|
|
989
|
+
const newGroupSealer = group.get("groupSealer") as string;
|
|
990
|
+
|
|
991
|
+
expect(newGroupSealer).toBeDefined();
|
|
992
|
+
expect(newGroupSealer).not.toEqual(originalGroupSealer);
|
|
993
|
+
|
|
994
|
+
// Verify the new sealer is derived from the new read key
|
|
995
|
+
const newReadKey = group.getCurrentReadKey();
|
|
996
|
+
if (!newReadKey.secret) {
|
|
997
|
+
throw new Error("Expected read key secret after rotation");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const derivedSealer = crypto.groupSealerFromReadKey(newReadKey.secret);
|
|
1001
|
+
// Composite format includes the readKeyID
|
|
1002
|
+
expect(newGroupSealer).toEqual(
|
|
1003
|
+
`${newReadKey.id}@${derivedSealer.publicKey}`,
|
|
1004
|
+
);
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe("concurrent group sealer initialization", () => {
|
|
1009
|
+
test("concurrent group sealer initialization produces same result", () => {
|
|
1010
|
+
// Since groupSealer is derived deterministically from readKey,
|
|
1011
|
+
// multiple calls with the same readKey will always produce the same result
|
|
1012
|
+
const readKey = crypto.newRandomKeySecret();
|
|
1013
|
+
|
|
1014
|
+
// Simulate concurrent derivations
|
|
1015
|
+
const results = Array.from({ length: 10 }, () =>
|
|
1016
|
+
crypto.groupSealerFromReadKey(readKey.secret),
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
// All results should be identical
|
|
1020
|
+
const firstResult = results[0];
|
|
1021
|
+
for (const result of results) {
|
|
1022
|
+
expect(result.publicKey).toEqual(firstResult!.publicKey);
|
|
1023
|
+
expect(result.secret).toEqual(firstResult!.secret);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
describe("groupSealer composite format (readKeyID association)", () => {
|
|
1029
|
+
test("groupSealer stores readKeyID in composite format", async () => {
|
|
1030
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
1031
|
+
|
|
1032
|
+
const group = node1.node.createGroup();
|
|
1033
|
+
const groupSealerValue = group.get("groupSealer") as string;
|
|
1034
|
+
|
|
1035
|
+
// Should be in composite format: "key_z...@sealer_z..."
|
|
1036
|
+
expect(groupSealerValue).toMatch(/^key_z.+@sealer_z/);
|
|
1037
|
+
|
|
1038
|
+
// The readKeyID portion should match the current readKey
|
|
1039
|
+
const readKeyId = group.getCurrentReadKeyId();
|
|
1040
|
+
expect(groupSealerValue.startsWith(readKeyId!)).toBe(true);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test("concurrent key rotation and migration produce correct readKey association", async () => {
|
|
1044
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1045
|
+
"server",
|
|
1046
|
+
"server",
|
|
1047
|
+
"server",
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// Create a legacy group on node1, add node2 and node3 as admins
|
|
1051
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1052
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1053
|
+
node1.node,
|
|
1054
|
+
node2.accountID,
|
|
1055
|
+
);
|
|
1056
|
+
const account3OnNode1 = await loadCoValueOrFail(
|
|
1057
|
+
node1.node,
|
|
1058
|
+
node3.accountID,
|
|
1059
|
+
);
|
|
1060
|
+
legacyGroup.addMember(account2OnNode1, "admin");
|
|
1061
|
+
legacyGroup.addMember(account3OnNode1, "admin");
|
|
1062
|
+
|
|
1063
|
+
await legacyGroup.core.waitForSync();
|
|
1064
|
+
|
|
1065
|
+
// Node2 loads the group and triggers migration (sets groupSealer from key1)
|
|
1066
|
+
const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
|
|
1067
|
+
await groupOnNode2.core.waitForSync();
|
|
1068
|
+
|
|
1069
|
+
// Verify groupSealer was set with composite format
|
|
1070
|
+
const sealerOnNode2 = groupOnNode2.get("groupSealer") as string;
|
|
1071
|
+
expect(sealerOnNode2).toMatch(/^key_z.+@sealer_z/);
|
|
1072
|
+
|
|
1073
|
+
// Now node2 rotates the read key (simulating concurrent rotation)
|
|
1074
|
+
groupOnNode2.rotateReadKey();
|
|
1075
|
+
await groupOnNode2.core.waitForSync();
|
|
1076
|
+
|
|
1077
|
+
// The groupSealer should now reference the NEW readKey, not the old one
|
|
1078
|
+
const newSealerOnNode2 = groupOnNode2.get("groupSealer") as string;
|
|
1079
|
+
const newReadKeyId = groupOnNode2.getCurrentReadKeyId();
|
|
1080
|
+
expect(newSealerOnNode2.startsWith(newReadKeyId!)).toBe(true);
|
|
1081
|
+
|
|
1082
|
+
// The new sealer should be different from the old one
|
|
1083
|
+
expect(newSealerOnNode2).not.toEqual(sealerOnNode2);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("child group extended via groupSealer is readable after parent key rotation + migration race", async () => {
|
|
1087
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1088
|
+
"server",
|
|
1089
|
+
"server",
|
|
1090
|
+
"server",
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
// Node1 creates parent group with both node2 and node3 as admins
|
|
1094
|
+
const parentGroup = createLegacyGroup(node1.node);
|
|
1095
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1096
|
+
node1.node,
|
|
1097
|
+
node2.accountID,
|
|
1098
|
+
);
|
|
1099
|
+
parentGroup.addMember(account2OnNode1, "admin");
|
|
1100
|
+
|
|
1101
|
+
await parentGroup.core.waitForSync();
|
|
1102
|
+
|
|
1103
|
+
// Node2 sees the parent group
|
|
1104
|
+
parentGroup.rotateReadKey();
|
|
1105
|
+
const parentGroupOnNode2 = await loadCoValueOrFail(
|
|
1106
|
+
node2.node,
|
|
1107
|
+
parentGroup.id,
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
await Promise.all([
|
|
1111
|
+
parentGroup.core.waitForSync(),
|
|
1112
|
+
parentGroupOnNode2.core.waitForSync(),
|
|
1113
|
+
]);
|
|
1114
|
+
|
|
1115
|
+
// Node3 (non-member) creates child and extends parent using the current groupSealer
|
|
1116
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
|
|
1117
|
+
const childGroup = node3.node.createGroup();
|
|
1118
|
+
childGroup.extend(parentOnNode3);
|
|
1119
|
+
|
|
1120
|
+
const map = childGroup.createMap();
|
|
1121
|
+
map.set("test", "Written by non-member");
|
|
1122
|
+
|
|
1123
|
+
await map.core.waitForSync();
|
|
1124
|
+
await childGroup.core.waitForSync();
|
|
1125
|
+
|
|
1126
|
+
// Both node1 and node2 should be able to read the child content
|
|
1127
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
1128
|
+
expect(mapOnNode1.get("test")).toEqual("Written by non-member");
|
|
1129
|
+
|
|
1130
|
+
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
|
1131
|
+
expect(mapOnNode2.get("test")).toEqual("Written by non-member");
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
describe("permission validation for groupSealer", () => {
|
|
1136
|
+
test("non-admin cannot set groupSealer", async () => {
|
|
1137
|
+
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
|
1138
|
+
|
|
1139
|
+
const group = node1.node.createGroup();
|
|
1140
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1141
|
+
node1.node,
|
|
1142
|
+
node2.accountID,
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
// Add node2 as reader (not admin)
|
|
1146
|
+
group.addMember(account2OnNode1, "reader");
|
|
1147
|
+
|
|
1148
|
+
await group.core.waitForSync();
|
|
1149
|
+
|
|
1150
|
+
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
|
1151
|
+
|
|
1152
|
+
const originalSealer = groupOnNode2.get("groupSealer");
|
|
1153
|
+
|
|
1154
|
+
// Attempt to set groupSealer as a reader
|
|
1155
|
+
const fakeSealer = crypto.newRandomSealer();
|
|
1156
|
+
const fakeSealerID = crypto.getSealerID(fakeSealer);
|
|
1157
|
+
|
|
1158
|
+
groupOnNode2.set(
|
|
1159
|
+
"groupSealer",
|
|
1160
|
+
`${group.getCurrentReadKeyId()!}@${fakeSealerID}`,
|
|
1161
|
+
"trusting",
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
// The change should be rejected (sealer should remain original)
|
|
1165
|
+
// Wait for sync to ensure changes are processed
|
|
1166
|
+
await groupOnNode2.core.waitForSync();
|
|
1167
|
+
|
|
1168
|
+
// Re-load group on node1 to check if the invalid change was rejected
|
|
1169
|
+
// The original sealer should be preserved because readers can't set groupSealer
|
|
1170
|
+
expect(group.get("groupSealer")).toEqual(originalSealer);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("admin can set groupSealer", async () => {
|
|
1174
|
+
const { node1 } = await createTwoConnectedNodes("server", "server");
|
|
1175
|
+
|
|
1176
|
+
const group = node1.node.createGroup();
|
|
1177
|
+
const originalSealer = group.get("groupSealer");
|
|
1178
|
+
|
|
1179
|
+
// As admin, rotate the read key which will update groupSealer
|
|
1180
|
+
group.rotateReadKey();
|
|
1181
|
+
|
|
1182
|
+
const newSealer = group.get("groupSealer");
|
|
1183
|
+
expect(newSealer).toBeDefined();
|
|
1184
|
+
expect(newSealer).not.toEqual(originalSealer);
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
describe("groupSealer migration for legacy groups", () => {
|
|
1189
|
+
test("legacy group without groupSealer gets migrated when loaded by admin on another node", async () => {
|
|
1190
|
+
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
|
1191
|
+
|
|
1192
|
+
// Create a legacy group (without groupSealer) on node1, add node2 as admin
|
|
1193
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1194
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1195
|
+
node1.node,
|
|
1196
|
+
node2.accountID,
|
|
1197
|
+
);
|
|
1198
|
+
legacyGroup.addMember(account2OnNode1, "admin");
|
|
1199
|
+
|
|
1200
|
+
expect(legacyGroup.get("groupSealer")).toBeUndefined();
|
|
1201
|
+
|
|
1202
|
+
await legacyGroup.core.waitForSync();
|
|
1203
|
+
|
|
1204
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1205
|
+
|
|
1206
|
+
// Node2 (admin) loads the group - migration should add the groupSealer
|
|
1207
|
+
const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
|
|
1208
|
+
expect(groupOnNode2.get("groupSealer")).toBeDefined();
|
|
1209
|
+
// Migrated groups use the new composite format
|
|
1210
|
+
expect(groupOnNode2.get("groupSealer")).toMatch(/^key_z.+@sealer_z/);
|
|
1211
|
+
|
|
1212
|
+
// Verify it's derived from the current read key
|
|
1213
|
+
const readKey = groupOnNode2.getCurrentReadKey();
|
|
1214
|
+
expect(readKey.secret).toBeDefined();
|
|
1215
|
+
const expectedSealer = crypto.groupSealerFromReadKey(readKey.secret!);
|
|
1216
|
+
expect(groupOnNode2.get("groupSealer")).toEqual(
|
|
1217
|
+
`${readKey.id}@${expectedSealer.publicKey}`,
|
|
1218
|
+
);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("migration is idempotent - loading on two admin nodes produces same groupSealer", async () => {
|
|
1222
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1223
|
+
"server",
|
|
1224
|
+
"server",
|
|
1225
|
+
"server",
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
// Create a legacy group on node1, add node2 and node3 as admins
|
|
1229
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1230
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1231
|
+
node1.node,
|
|
1232
|
+
node2.accountID,
|
|
1233
|
+
);
|
|
1234
|
+
const account3OnNode1 = await loadCoValueOrFail(
|
|
1235
|
+
node1.node,
|
|
1236
|
+
node3.accountID,
|
|
1237
|
+
);
|
|
1238
|
+
legacyGroup.addMember(account2OnNode1, "admin");
|
|
1239
|
+
legacyGroup.addMember(account3OnNode1, "admin");
|
|
1240
|
+
|
|
1241
|
+
expect(legacyGroup.get("groupSealer")).toBeUndefined();
|
|
1242
|
+
|
|
1243
|
+
await legacyGroup.core.waitForSync();
|
|
1244
|
+
|
|
1245
|
+
// Node2 loads and migrates
|
|
1246
|
+
const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
|
|
1247
|
+
await groupOnNode2.core.waitForSync();
|
|
1248
|
+
|
|
1249
|
+
const sealerFromNode2 = groupOnNode2.get("groupSealer");
|
|
1250
|
+
expect(sealerFromNode2).toBeDefined();
|
|
1251
|
+
|
|
1252
|
+
// Record transaction count after node2's migration has synced
|
|
1253
|
+
const transactionsAfterNode2 =
|
|
1254
|
+
groupOnNode2.core.getValidSortedTransactions();
|
|
1255
|
+
|
|
1256
|
+
// Node3 loads - groupSealer is already set via sync from node2,
|
|
1257
|
+
// so no new migration should be applied
|
|
1258
|
+
const groupOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
|
|
1259
|
+
await groupOnNode3.core.waitForSync();
|
|
1260
|
+
|
|
1261
|
+
const sealerFromNode3 = groupOnNode3.get("groupSealer");
|
|
1262
|
+
expect(sealerFromNode3).toBeDefined();
|
|
1263
|
+
|
|
1264
|
+
// Both should have the same groupSealer (deterministic from readKey)
|
|
1265
|
+
expect(sealerFromNode3).toEqual(sealerFromNode2);
|
|
1266
|
+
|
|
1267
|
+
// Verify no redundant migration was applied — transaction count should be unchanged
|
|
1268
|
+
expect(groupOnNode3.core.getValidSortedTransactions()).toHaveLength(
|
|
1269
|
+
transactionsAfterNode2.length,
|
|
1270
|
+
);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("parallel migrations from different accounts produce same groupSealer", async () => {
|
|
1274
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1275
|
+
"server",
|
|
1276
|
+
"server",
|
|
1277
|
+
"server",
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
// Create a legacy group on node1, add node2 and node3 as admins
|
|
1281
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1282
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1283
|
+
node1.node,
|
|
1284
|
+
node2.accountID,
|
|
1285
|
+
);
|
|
1286
|
+
const account3OnNode1 = await loadCoValueOrFail(
|
|
1287
|
+
node1.node,
|
|
1288
|
+
node3.accountID,
|
|
1289
|
+
);
|
|
1290
|
+
legacyGroup.addMember(account2OnNode1, "admin");
|
|
1291
|
+
legacyGroup.addMember(account3OnNode1, "admin");
|
|
1292
|
+
|
|
1293
|
+
expect(legacyGroup.get("groupSealer")).toBeUndefined();
|
|
1294
|
+
|
|
1295
|
+
await legacyGroup.core.waitForSync();
|
|
1296
|
+
|
|
1297
|
+
// Both node2 and node3 load the group concurrently, triggering parallel migrations
|
|
1298
|
+
const [groupOnNode2, groupOnNode3] = await Promise.all([
|
|
1299
|
+
loadCoValueOrFail(node2.node, legacyGroup.id),
|
|
1300
|
+
loadCoValueOrFail(node3.node, legacyGroup.id),
|
|
1301
|
+
]);
|
|
1302
|
+
|
|
1303
|
+
// Both should have a groupSealer set
|
|
1304
|
+
const sealerOnNode2 = groupOnNode2.get("groupSealer");
|
|
1305
|
+
const sealerOnNode3 = groupOnNode3.get("groupSealer");
|
|
1306
|
+
|
|
1307
|
+
expect(sealerOnNode2).toBeDefined();
|
|
1308
|
+
expect(sealerOnNode3).toBeDefined();
|
|
1309
|
+
|
|
1310
|
+
// Both should derive the same groupSealer since it's deterministic from readKey
|
|
1311
|
+
expect(sealerOnNode2).toEqual(sealerOnNode3);
|
|
1312
|
+
|
|
1313
|
+
// Wait for sync and verify convergence
|
|
1314
|
+
await groupOnNode2.core.waitForSync();
|
|
1315
|
+
await groupOnNode3.core.waitForSync();
|
|
1316
|
+
|
|
1317
|
+
// After sync, both should still agree
|
|
1318
|
+
expect(groupOnNode2.get("groupSealer")).toEqual(
|
|
1319
|
+
groupOnNode3.get("groupSealer"),
|
|
1320
|
+
);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test("non-admin member does not trigger migration", async () => {
|
|
1324
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1325
|
+
"server",
|
|
1326
|
+
"server",
|
|
1327
|
+
"server",
|
|
1328
|
+
);
|
|
1329
|
+
|
|
1330
|
+
// Create a legacy group on node1, add node2 as reader only
|
|
1331
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1332
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1333
|
+
node1.node,
|
|
1334
|
+
node2.accountID,
|
|
1335
|
+
);
|
|
1336
|
+
legacyGroup.addMember(account2OnNode1, "reader");
|
|
1337
|
+
|
|
1338
|
+
expect(legacyGroup.get("groupSealer")).toBeUndefined();
|
|
1339
|
+
|
|
1340
|
+
await legacyGroup.core.waitForSync();
|
|
1341
|
+
|
|
1342
|
+
const transactions = legacyGroup.core.getValidSortedTransactions();
|
|
1343
|
+
|
|
1344
|
+
// Node2 (reader) loads the group - should NOT trigger migration
|
|
1345
|
+
const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
|
|
1346
|
+
|
|
1347
|
+
// The groupSealer should still be undefined because node2 is only a reader
|
|
1348
|
+
// and cannot set the groupSealer field
|
|
1349
|
+
expect(groupOnNode2.get("groupSealer")).toBeUndefined();
|
|
1350
|
+
expect(groupOnNode2.core.getValidSortedTransactions()).toHaveLength(
|
|
1351
|
+
transactions.length,
|
|
1352
|
+
);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
test("migrated legacy group works with non-member extension via groupSealer", async () => {
|
|
1356
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1357
|
+
"server",
|
|
1358
|
+
"server",
|
|
1359
|
+
"server",
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
// Create a legacy group on node1 (no groupSealer), add node2 as admin
|
|
1363
|
+
const legacyGroup = createLegacyGroup(node1.node);
|
|
1364
|
+
const account2OnNode1 = await loadCoValueOrFail(
|
|
1365
|
+
node1.node,
|
|
1366
|
+
node2.accountID,
|
|
1367
|
+
);
|
|
1368
|
+
legacyGroup.addMember(account2OnNode1, "admin");
|
|
1369
|
+
|
|
1370
|
+
expect(legacyGroup.get("groupSealer")).toBeUndefined();
|
|
1371
|
+
|
|
1372
|
+
await legacyGroup.core.waitForSync();
|
|
1373
|
+
|
|
1374
|
+
// Node2 (admin) loads the group, triggering migration
|
|
1375
|
+
const parentGroup = await loadCoValueOrFail(node2.node, legacyGroup.id);
|
|
1376
|
+
|
|
1377
|
+
// Wait for async migration to complete (runs via waitFor when not fully downloaded)
|
|
1378
|
+
await parentGroup.core.waitForSync();
|
|
1379
|
+
|
|
1380
|
+
// Verify migration happened
|
|
1381
|
+
expect(parentGroup.get("groupSealer")).toBeDefined();
|
|
1382
|
+
|
|
1383
|
+
await parentGroup.core.waitForSync();
|
|
1384
|
+
|
|
1385
|
+
// Node3 (NOT a member of parent) creates a child group and extends parent
|
|
1386
|
+
const parentOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
|
|
1387
|
+
const childGroup = node3.node.createGroup();
|
|
1388
|
+
childGroup.extend(parentOnNode3);
|
|
1389
|
+
|
|
1390
|
+
const map = childGroup.createMap();
|
|
1391
|
+
map.set("test", "Written by non-member after migration");
|
|
1392
|
+
|
|
1393
|
+
await map.core.waitForSync();
|
|
1394
|
+
await childGroup.core.waitForSync();
|
|
1395
|
+
|
|
1396
|
+
// Node1 (original creator/admin) should be able to read content via migrated groupSealer
|
|
1397
|
+
// First, sync to pick up the migrated groupSealer
|
|
1398
|
+
const parentOnNode1 = await loadCoValueOrFail(node1.node, legacyGroup.id);
|
|
1399
|
+
await parentOnNode1.core.waitForSync();
|
|
1400
|
+
|
|
1401
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
1402
|
+
expect(mapOnNode1.get("test")).toEqual(
|
|
1403
|
+
"Written by non-member after migration",
|
|
1404
|
+
);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("legacy fallback: parent without groupSealer uses writeOnly key for non-member extension", async () => {
|
|
1408
|
+
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
|
1409
|
+
"server",
|
|
1410
|
+
"server",
|
|
1411
|
+
"server",
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
// Create a legacy parent group (without groupSealer) on node1
|
|
1415
|
+
const legacyParent = createLegacyGroup(node1.node);
|
|
1416
|
+
expect(legacyParent.get("groupSealer")).toBeUndefined();
|
|
1417
|
+
|
|
1418
|
+
await legacyParent.core.waitForSync();
|
|
1419
|
+
|
|
1420
|
+
// Node2 (NOT a member of parent) creates child and extends legacy parent
|
|
1421
|
+
const legacyParentOnNode2 = await loadCoValueOrFail(
|
|
1422
|
+
node2.node,
|
|
1423
|
+
legacyParent.id,
|
|
1424
|
+
);
|
|
1425
|
+
const childGroup = node2.node.createGroup();
|
|
1426
|
+
childGroup.extend(legacyParentOnNode2);
|
|
1427
|
+
|
|
1428
|
+
await childGroup.core.waitForSync();
|
|
1429
|
+
|
|
1430
|
+
// Verify the legacy fallback was used: a writeKeyFor_ entry should exist
|
|
1431
|
+
// in the parent group for the extending account
|
|
1432
|
+
const legacyParentUpdated = await loadCoValueOrFail(
|
|
1433
|
+
node1.node,
|
|
1434
|
+
legacyParent.id,
|
|
1435
|
+
);
|
|
1436
|
+
|
|
1437
|
+
const writeKeyForNode2 = legacyParentUpdated.get(
|
|
1438
|
+
`writeKeyFor_${node2.node.getCurrentAgent().id}` as any,
|
|
1439
|
+
);
|
|
1440
|
+
expect(writeKeyForNode2).toBeDefined();
|
|
1441
|
+
|
|
1442
|
+
// Verify NO _sealedFor_ entries exist (groupSealer path was NOT used)
|
|
1443
|
+
const sealedForKeys = legacyParentUpdated
|
|
1444
|
+
.keys()
|
|
1445
|
+
.filter((key) => key.includes("_sealedFor_"));
|
|
1446
|
+
expect(sealedForKeys).toHaveLength(0);
|
|
1447
|
+
|
|
1448
|
+
// Node3 is added as writeOnly to child by node2
|
|
1449
|
+
const account3OnNode2 = await loadCoValueOrFail(
|
|
1450
|
+
node2.node,
|
|
1451
|
+
node3.accountID,
|
|
1452
|
+
);
|
|
1453
|
+
childGroup.addMember(account3OnNode2, "writeOnly");
|
|
1454
|
+
|
|
1455
|
+
await childGroup.core.waitForSync();
|
|
1456
|
+
|
|
1457
|
+
// Node3 writes content
|
|
1458
|
+
const childGroupOnNode3 = await loadCoValueOrFail(
|
|
1459
|
+
node3.node,
|
|
1460
|
+
childGroup.id,
|
|
1461
|
+
);
|
|
1462
|
+
const map = childGroupOnNode3.createMap();
|
|
1463
|
+
map.set("test", "Written via legacy writeOnly fallback");
|
|
1464
|
+
|
|
1465
|
+
await map.core.waitForSync();
|
|
1466
|
+
|
|
1467
|
+
// Node1 (parent admin) should be able to read via the writeOnly key
|
|
1468
|
+
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
|
1469
|
+
expect(mapOnNode1.get("test")).toEqual(
|
|
1470
|
+
"Written via legacy writeOnly fallback",
|
|
1471
|
+
);
|
|
1472
|
+
});
|
|
1473
|
+
});
|