@dxos/credentials 0.5.0 → 0.5.1-main.0ec204c

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.
Files changed (34) hide show
  1. package/dist/lib/browser/index.mjs +755 -167
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +729 -144
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/types/src/credentials/credential-factory.d.ts +3 -1
  8. package/dist/types/src/credentials/credential-factory.d.ts.map +1 -1
  9. package/dist/types/src/credentials/credential-generator.d.ts +18 -2
  10. package/dist/types/src/credentials/credential-generator.d.ts.map +1 -1
  11. package/dist/types/src/credentials/signing.d.ts.map +1 -1
  12. package/dist/types/src/credentials/verifier.d.ts +1 -1
  13. package/dist/types/src/credentials/verifier.d.ts.map +1 -1
  14. package/dist/types/src/graph/credential-graph.d.ts +152 -0
  15. package/dist/types/src/graph/credential-graph.d.ts.map +1 -0
  16. package/dist/types/src/state-machine/member-state-machine.d.ts +17 -12
  17. package/dist/types/src/state-machine/member-state-machine.d.ts.map +1 -1
  18. package/dist/types/src/state-machine/member-state-machine.test.d.ts +2 -0
  19. package/dist/types/src/state-machine/member-state-machine.test.d.ts.map +1 -0
  20. package/dist/types/src/state-machine/space-state-machine.d.ts +3 -3
  21. package/dist/types/src/state-machine/space-state-machine.d.ts.map +1 -1
  22. package/package.json +16 -16
  23. package/project.json +3 -0
  24. package/src/credentials/credential-factory.ts +9 -1
  25. package/src/credentials/credential-generator.ts +35 -2
  26. package/src/credentials/signing.ts +1 -0
  27. package/src/credentials/verifier.test.ts +25 -0
  28. package/src/credentials/verifier.ts +5 -1
  29. package/src/graph/credential-graph.ts +557 -0
  30. package/src/state-machine/invitation-state-machine.test.ts +1 -1
  31. package/src/state-machine/member-state-machine.test.ts +580 -0
  32. package/src/state-machine/member-state-machine.ts +111 -53
  33. package/src/state-machine/space-state-machine.test.ts +20 -16
  34. package/src/state-machine/space-state-machine.ts +12 -33
@@ -88,7 +88,7 @@ export class CredentialGenerator {
88
88
  assertion: {
89
89
  '@type': 'dxos.halo.credentials.SpaceMember',
90
90
  spaceKey,
91
- role: SpaceMember.Role.MEMBER,
91
+ role: SpaceMember.Role.EDITOR,
92
92
  genesisFeedKey,
93
93
  },
94
94
  }),
@@ -202,21 +202,34 @@ export const createDeviceAuthorization = async (
202
202
  };
203
203
 
204
204
  // TODO(burdon): Reconcile with above (esp. Signer).
205
+ /**
206
+ * @param signer - invitation signer.
207
+ * @param identityKey - identity key of the admitted member.
208
+ * @param spaceKey - subject space key.
209
+ * @param genesisFeedKey - genesis feed key of the space.
210
+ * @param role - role of the newly added member.
211
+ * @param membershipChainHeads - ids of the last known SpaceMember credentials (branching possible).
212
+ * @param profile - profile of the newly added member.
213
+ * @param invitationCredentialId - id of the delegated invitation credential in case one was used to add the member.
214
+ */
205
215
  export const createAdmissionCredentials = async (
206
216
  signer: CredentialSigner,
207
217
  identityKey: PublicKey,
208
218
  spaceKey: PublicKey,
209
219
  genesisFeedKey: PublicKey,
220
+ role: SpaceMember.Role = SpaceMember.Role.ADMIN,
221
+ membershipChainHeads: PublicKey[] = [],
210
222
  profile?: ProfileDocument,
211
223
  invitationCredentialId?: PublicKey,
212
224
  ): Promise<FeedMessage.Payload[]> => {
213
225
  const credentials = await Promise.all([
214
226
  await signer.createCredential({
215
227
  subject: identityKey,
228
+ parentCredentialIds: membershipChainHeads,
216
229
  assertion: {
217
230
  '@type': 'dxos.halo.credentials.SpaceMember',
218
231
  spaceKey,
219
- role: SpaceMember.Role.ADMIN, // TODO(burdon): Configure.
232
+ role,
220
233
  profile,
221
234
  genesisFeedKey,
222
235
  invitationCredentialId,
@@ -249,3 +262,23 @@ export const createDelegatedSpaceInvitationCredential = async (
249
262
  });
250
263
  return { credential: { credential } };
251
264
  };
265
+
266
+ /**
267
+ * @param signer - credential issuer.
268
+ * @param subject - key of the space the invitation was for.
269
+ * @param invitationCredentialId id of a dxos.halo.invitations.DelegateSpaceInvitation credential.
270
+ */
271
+ export const createCancelDelegatedSpaceInvitationCredential = async (
272
+ signer: CredentialSigner,
273
+ subject: PublicKey,
274
+ invitationCredentialId: PublicKey,
275
+ ): Promise<FeedMessage.Payload> => {
276
+ const credential = await signer.createCredential({
277
+ subject,
278
+ assertion: {
279
+ '@type': 'dxos.halo.invitations.CancelDelegatedInvitation',
280
+ credentialId: invitationCredentialId,
281
+ },
282
+ });
283
+ return { credential: { credential } };
284
+ };
@@ -12,6 +12,7 @@ import { arrayToBuffer } from '@dxos/util';
12
12
  /**
13
13
  * @returns The input message to be signed for a given credential.
14
14
  */
15
+ // TODO(nf): rename, this returns not the proof itself, but the payload for verifying against the proof.
15
16
  export const getCredentialProofPayload = (credential: Credential): Uint8Array => {
16
17
  const copy = {
17
18
  ...credential,
@@ -142,6 +142,31 @@ describe('verifier', () => {
142
142
  });
143
143
  });
144
144
 
145
+ test('parent references are part of the proof', async () => {
146
+ const keyring = new Keyring();
147
+ const issuer = await keyring.createKey();
148
+ const credential = await createCredential({
149
+ parentCredentialIds: [PublicKey.random()],
150
+ assertion: {
151
+ '@type': 'dxos.halo.credentials.SpaceMember',
152
+ spaceKey: PublicKey.random(),
153
+ role: SpaceMember.Role.ADMIN,
154
+ genesisFeedKey: PublicKey.random(),
155
+ },
156
+ issuer,
157
+ signer: keyring,
158
+ subject: PublicKey.random(),
159
+ });
160
+ expect(credential.parentCredentialIds?.length).toEqual(1);
161
+ expect(await verifyCredential(credential)).toMatchObject({ kind: 'pass' });
162
+
163
+ // Tamper with the credential.
164
+ credential.parentCredentialIds?.push(PublicKey.random());
165
+ expect(await verifyCredential(credential)).toMatchObject({
166
+ kind: 'fail',
167
+ });
168
+ });
169
+
145
170
  describe('chain', () => {
146
171
  test('pass - delegated authority with 1 device', async () => {
147
172
  const keyring = new Keyring();
@@ -14,6 +14,10 @@ export const SIGNATURE_TYPE_ED25519 = 'ED25519Signature';
14
14
  export type VerificationResult = { kind: 'pass' } | { kind: 'fail'; errors: string[] };
15
15
 
16
16
  export const verifyCredential = async (credential: Credential): Promise<VerificationResult> => {
17
+ if (credential.parentCredentialIds?.length === 0) {
18
+ delete credential.parentCredentialIds;
19
+ }
20
+
17
21
  if (!credential.issuer.equals(credential.proof!.signer)) {
18
22
  if (!credential.proof!.chain) {
19
23
  return {
@@ -57,7 +61,7 @@ export const verifyCredentialSignature = async (credential: Credential): Promise
57
61
  };
58
62
 
59
63
  /**
60
- * Verifies the the signer has the delegated authority to create credentials on the half of the issuer.
64
+ * Verifies that the signer has the delegated authority to create credentials on behalf of the issuer.
61
65
  */
62
66
  export const verifyChain = async (
63
67
  chain: Chain,
@@ -0,0 +1,557 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { invariant } from '@dxos/invariant';
6
+ import { PublicKey } from '@dxos/keys';
7
+ import { log } from '@dxos/log';
8
+ import { type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
9
+ import { type AsyncCallback, Callback, ComplexMap, ComplexSet } from '@dxos/util';
10
+
11
+ export class CredentialGraph<A, State> {
12
+ /**
13
+ * Local ids are used during traversals.
14
+ */
15
+ private _vertexIdGenerator = 1;
16
+ /**
17
+ * All credentials without parent references are connected to the root.
18
+ */
19
+ private _root = { id: -1, parents: [], children: [] } as any as ChainVertex<A>;
20
+ /**
21
+ * A credential which is not a parent of any other credential has the sentinel as a child.
22
+ * Sentinel is a virtual merge-point of all credentials.
23
+ */
24
+ private _sentinel = { id: -2, parents: [], children: [] } as any as ChainVertex<A>;
25
+ /**
26
+ * Vertex references are used for fast credential inserts into the graph.
27
+ */
28
+ private _vertexByCredentialId = new ComplexMap<PublicKey, ChainVertex<A>>(PublicKey.hash);
29
+ /**
30
+ * The current state of the graph.
31
+ */
32
+ private _subjectToVertex = new ComplexMap<PublicKey, ChainVertex<A>>(PublicKey.hash);
33
+ private _subjectToState = new ComplexMap<PublicKey, State>(PublicKey.hash);
34
+
35
+ public onSubjectStateChanged = new Callback<AsyncCallback<State[]>>();
36
+
37
+ constructor(private readonly _stateHandler: CredentialGraphStateHandler<A, State>) {}
38
+
39
+ public getSubjectState(subjectId: PublicKey): State | undefined {
40
+ return this._subjectToState.get(subjectId);
41
+ }
42
+
43
+ public getState(): ReadonlyMap<PublicKey, State> {
44
+ return this._subjectToState;
45
+ }
46
+
47
+ public getLeafIds(): PublicKey[] {
48
+ return this._sentinel.parents.map((v) => v.credential!.id!);
49
+ }
50
+
51
+ public getGlobalStateScope(): StateScope<A> {
52
+ return { state: this._subjectToVertex };
53
+ }
54
+
55
+ public addVertex(credential: Credential, assertion: A): Promise<void> {
56
+ const newVertex: ChainVertex<A> = {
57
+ id: this._vertexIdGenerator++,
58
+ credential,
59
+ assertion,
60
+ parents: [],
61
+ children: [],
62
+ };
63
+ this._vertexByCredentialId.set(credential.id!, newVertex);
64
+ const parentIds = credential.parentCredentialIds ?? [];
65
+ if (parentIds.length === 0) {
66
+ this._root.children.push(newVertex);
67
+ newVertex.parents.push(this._root);
68
+ } else {
69
+ for (const parentId of parentIds) {
70
+ const parentVertex = this._vertexByCredentialId.get(parentId);
71
+ if (parentVertex == null) {
72
+ log.error('credential skipped because of the unknown parent', { credential, parentId });
73
+ continue;
74
+ }
75
+ parentVertex.children.push(newVertex);
76
+ newVertex.parents.push(parentVertex);
77
+ this._removeSentinelConnection(parentVertex);
78
+ }
79
+ }
80
+ newVertex.children.push(this._sentinel);
81
+ this._sentinel.parents.push(newVertex);
82
+ return this._onVertexInserted(newVertex);
83
+ }
84
+
85
+ private _removeSentinelConnection(vertex: ChainVertex<A>) {
86
+ const sentinelIdx = vertex.children.indexOf(this._sentinel);
87
+ if (sentinelIdx >= 0) {
88
+ vertex.children.splice(sentinelIdx, 1);
89
+ const vertexInSentinelIdx = this._sentinel.parents.indexOf(vertex);
90
+ invariant(vertexInSentinelIdx >= 0);
91
+ this._sentinel.parents.splice(vertexInSentinelIdx, 1);
92
+ }
93
+ }
94
+
95
+ private async _onVertexInserted(newVertex: ChainVertex<A>) {
96
+ const { credential, assertion } = newVertex;
97
+ invariant(credential);
98
+ let changedSubjects: State[] = [];
99
+ const isUpdateAppliedOnTopOfThePreviousState = this._sentinel.parents.length === 1;
100
+ if (isUpdateAppliedOnTopOfThePreviousState) {
101
+ const subjectId = credential.subject.id;
102
+ if (this._stateHandler.isUpdateAllowed(this.getGlobalStateScope(), credential, assertion)) {
103
+ const newSubjectState = this._stateHandler.createState(credential, newVertex.assertion);
104
+ const prevSubjectState = this._subjectToState.get(subjectId);
105
+ this._subjectToState.set(subjectId, newSubjectState);
106
+ this._subjectToVertex.set(subjectId, newVertex);
107
+ if (this._stateHandler.hasStateChanged(newSubjectState, prevSubjectState)) {
108
+ changedSubjects.push(newSubjectState);
109
+ }
110
+ }
111
+ } else {
112
+ changedSubjects = this._recomputeState();
113
+ }
114
+ if (changedSubjects.length > 0) {
115
+ await this.onSubjectStateChanged.callIfSet(changedSubjects);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * DFS the graph from root to sentinel pausing on merge points (nodes with multiple parents).
121
+ * Continue after all paths leading to a merge point converge by merging their states.
122
+ * In case of a concurrent update paths are replayed taking into account the state set
123
+ * by the winning branch.
124
+ */
125
+ private _recomputeState(): State[] {
126
+ // ID of a merge point to the list of paths that reached the point.
127
+ const pendingPaths = new Map<number, PathState<A>[]>();
128
+ const paths: PathState<A>[] = [this._createRootPath()];
129
+ let lastPath: PathState<A> | null = null;
130
+ while (lastPath == null) {
131
+ const path = paths.pop()!;
132
+ log('visit vertex', { id: path.head.id });
133
+ this._updatePathState(path);
134
+ const convergedPaths = this._handleMergePoint(paths, pendingPaths, path);
135
+ if (convergedPaths == null) {
136
+ log('waiting for other paths');
137
+ continue;
138
+ }
139
+ const mergeResult = this._mergePaths(convergedPaths);
140
+ if (mergeResult.type === 'replay_required') {
141
+ this._replayFailedPaths(paths, pendingPaths, mergeResult, convergedPaths);
142
+ continue;
143
+ }
144
+ const merged = mergeResult.path;
145
+ if (merged.head.children.length === 0) {
146
+ lastPath = merged;
147
+ } else if (merged.head.children.length === 1) {
148
+ merged.head = merged.head.children[0];
149
+ paths.push(merged);
150
+ } else {
151
+ this._forkTraversal(paths, merged);
152
+ }
153
+ }
154
+ if (paths.length > 0) {
155
+ log.error('traversal finished while there were active paths', {
156
+ paths: paths.map((p) => ({ path: toChosenPath(p), head: p.head.id })),
157
+ });
158
+ }
159
+ return this._setCurrentState(lastPath);
160
+ }
161
+
162
+ private _replayFailedPaths(
163
+ paths: PathState<A>[],
164
+ pendingPaths: Map<number, PathState<A>[]>,
165
+ mergeResult: ReplayRequiredMergeResult<A>,
166
+ convergedPaths: PathState<A>[],
167
+ ) {
168
+ paths.push(
169
+ ...mergeResult.replay.map((path) => {
170
+ const stateOverrides = path.stateOverrides ?? new ComplexMap<PublicKey, ChainVertex<A>>(PublicKey.hash);
171
+ mergeResult.stateOverrides.forEach((value, key) => stateOverrides.set(key, value));
172
+ return { ...mergeResult.from, chosenPath: path.chosenPath, stateOverrides };
173
+ }),
174
+ );
175
+ log('replay paths', () => ({
176
+ count: paths.length,
177
+ paths: paths.map((path) => ({
178
+ from: mergeResult.from.head.id,
179
+ path: toChosenPath(path),
180
+ overrides: path?.stateOverrides?.mapValues((v) => this._stateHandler.toLogString(v.assertion)),
181
+ })),
182
+ }));
183
+ const clearedPending = convergedPaths.filter((l) => !mergeResult.replay.includes(l));
184
+ pendingPaths.set(convergedPaths[0].head.id, clearedPending);
185
+ }
186
+
187
+ private _handleMergePoint(
188
+ paths: PathState<A>[],
189
+ pendingPaths: Map<number, PathState<A>[]>,
190
+ path: PathState<A>,
191
+ ): PathState<A>[] | null {
192
+ const pendingList = pendingPaths.get(path.head.id) ?? [];
193
+ pendingPaths.set(path.head.id, pendingList);
194
+ pendingList.push(path);
195
+ if (pendingList.length < path.head.parents.length) {
196
+ return null;
197
+ }
198
+ if (path.head.id === this._sentinel.id && paths.length > 0) {
199
+ log('waiting for all the active paths to converge on the sentinel');
200
+ return null;
201
+ }
202
+ pendingPaths.delete(path.head.id);
203
+ return pendingList;
204
+ }
205
+
206
+ private _updatePathState(path: PathState<A>) {
207
+ const headCredential = path.head.credential;
208
+ if (headCredential == null) {
209
+ return;
210
+ }
211
+ const updatedSubject = headCredential.subject.id;
212
+ path.credentials.add(headCredential.id!);
213
+ let isUpdateAllowed = this._stateHandler.isUpdateAllowed(path, headCredential, path.head.assertion);
214
+ // Compatibility with old credentials where parent references were not specified.
215
+ if (!isUpdateAllowed && path.head.parents[0]?.id === this._root.id) {
216
+ const globalState = this.getGlobalStateScope();
217
+ isUpdateAllowed = this._stateHandler.isUpdateAllowed(globalState, headCredential, path.head.assertion);
218
+ }
219
+ if (isUpdateAllowed) {
220
+ path.forkChangedSubjects.add(updatedSubject);
221
+ path.forkIssuers.add(headCredential.issuer);
222
+ path.state.set(updatedSubject, path.head);
223
+ log('path state updated', () => ({
224
+ subject: updatedSubject,
225
+ newState: this._stateHandler.toLogString(path.head.assertion),
226
+ }));
227
+ }
228
+ }
229
+
230
+ private _forkTraversal(paths: PathState<A>[], path: PathState<A>) {
231
+ const replayChoice = path.chosenPath?.[path.head.id];
232
+ const choices = replayChoice ?? path.head.children;
233
+ for (const choice of choices) {
234
+ log('edge traversal', { from: path.head.id, to: choice.id });
235
+ const fork: PathState<A> = {
236
+ forkPoint: path,
237
+ chosenPath: { ...path.chosenPath, [path.head.id]: [choice] },
238
+ head: choice,
239
+ credentials: new ComplexSet(PublicKey.hash, path.credentials),
240
+ state: new ComplexMap(PublicKey.hash, [...path.state.entries()]),
241
+ forkIssuers: new ComplexSet(PublicKey.hash),
242
+ forkChangedSubjects: new ComplexSet(PublicKey.hash),
243
+ stateOverrides: path.stateOverrides,
244
+ };
245
+ paths.push(fork);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Updates the current graph state.
251
+ * @returns changed states.
252
+ */
253
+ private _setCurrentState(path: PathState<A>): State[] {
254
+ const changedSubjects: State[] = [];
255
+ const newStateMap = new ComplexMap<PublicKey, State>(PublicKey.hash);
256
+ const newVertexMap = new ComplexMap<PublicKey, ChainVertex<A>>(PublicKey.hash);
257
+ for (const [subjectKey, subjectVertex] of path.state.entries()) {
258
+ const newState = this._stateHandler.createState(subjectVertex.credential!, subjectVertex.assertion);
259
+ const prevState = this._subjectToState.get(subjectKey);
260
+ newStateMap.set(subjectKey, newState);
261
+ newVertexMap.set(subjectKey, subjectVertex);
262
+ if (this._stateHandler.hasStateChanged(newState, prevState)) {
263
+ changedSubjects.push(newState);
264
+ }
265
+ }
266
+ this._subjectToState = newStateMap;
267
+ this._subjectToVertex = newVertexMap;
268
+ return changedSubjects;
269
+ }
270
+
271
+ /*
272
+ * Walk up all the fork points and return the first one present in all the paths.
273
+ * We use local id to determine vertex position in the graph, because nodes can't
274
+ * be inserted in the middle (between a parent and a child) and ids are monotonically increasing.
275
+ */
276
+ private _leastCommonAncestor(paths: PathState<A>[]): PathState<A> {
277
+ const uniqueForkPoints = paths.reduce((acc, path) => {
278
+ let it = path.forkPoint;
279
+ while (it) {
280
+ acc.set(it.head.id, it);
281
+ it = it.forkPoint;
282
+ }
283
+ return acc;
284
+ }, new Map<number, PathState<A>>());
285
+ let maxId = this._root.id;
286
+ let maxState: PathState<A> | null = null;
287
+ for (const [id, state] of uniqueForkPoints.entries()) {
288
+ const headCredential = state.head.credential;
289
+ if (headCredential != null) {
290
+ const isPointInEveryPath = paths.every((p) => p.credentials.has(headCredential.id!));
291
+ if (isPointInEveryPath && id > maxId) {
292
+ maxId = id;
293
+ maxState = state;
294
+ }
295
+ }
296
+ }
297
+ return maxState ?? this._createRootPath();
298
+ }
299
+
300
+ /**
301
+ * We might be merging paths where some of them had fork points after the initial forking.
302
+ * We need all the paths to point to the least common fork point and contain all the changes
303
+ * that happened after it.
304
+ */
305
+ private _moveUpToForkPoint(forkPoint: PathState<A>, path: PathState<A>): PathState<A> {
306
+ const isForkPointInPath = path.chosenPath[forkPoint.head.id] == null || path.forkPoint == null;
307
+ if (isForkPointInPath) {
308
+ return path;
309
+ }
310
+ if (forkPoint.head.id === path.forkPoint?.head.id) {
311
+ return path;
312
+ }
313
+ let it = path.forkPoint!;
314
+ while (it.head.id !== forkPoint.head.id) {
315
+ it.forkIssuers.forEach((iss) => path.forkIssuers.add(iss));
316
+ it.forkChangedSubjects.forEach((m) => path.forkChangedSubjects.add(m));
317
+ it = it!.forkPoint!;
318
+ path.forkPoint = it;
319
+ }
320
+ return path;
321
+ }
322
+
323
+ private _mergePaths(convergedPaths: PathState<A>[]): PathMergeResult<A> {
324
+ invariant(convergedPaths.length >= 1);
325
+ if (convergedPaths.length === 1) {
326
+ return { type: 'merged', path: convergedPaths[0] };
327
+ }
328
+ const forkPoint = this._leastCommonAncestor(convergedPaths);
329
+ log('merging paths', () => ({
330
+ forkPointId: forkPoint.head.id,
331
+ pathCount: convergedPaths.length,
332
+ forkPoints: convergedPaths.map((fp) => fp.forkPoint?.head.id),
333
+ }));
334
+ const paths = convergedPaths.map((p) => this._moveUpToForkPoint(forkPoint, p));
335
+ invariant(forkPoint);
336
+ const result: PathState<A> = {
337
+ forkPoint: forkPoint.forkPoint,
338
+ chosenPath: { ...forkPoint.chosenPath, [forkPoint.head.id]: [] },
339
+ stateOverrides: forkPoint.stateOverrides,
340
+ credentials: new ComplexSet(PublicKey.hash, forkPoint.credentials),
341
+ forkIssuers: new ComplexSet(PublicKey.hash, forkPoint.forkIssuers),
342
+ forkChangedSubjects: new ComplexSet(PublicKey.hash, forkPoint.forkChangedSubjects),
343
+ state: forkPoint.state.mapValues((v) => v),
344
+ head: paths[0].head,
345
+ };
346
+ const subjectToBranch = new ComplexMap<PublicKey, PathState<A>>(PublicKey.hash);
347
+ for (const path of paths) {
348
+ log('processing a path', () => ({
349
+ choices: toChosenPath(path),
350
+ modified: path.forkChangedSubjects,
351
+ forkIssuers: path.forkIssuers,
352
+ state: path.state.mapValues((v) => this._stateHandler.toLogString(v.assertion)),
353
+ }));
354
+ path.forkIssuers.forEach((iss) => result.forkIssuers.add(iss));
355
+ path.credentials.forEach((cred) => result.credentials.add(cred));
356
+ result.chosenPath![forkPoint.head.id].push(...(path.chosenPath![forkPoint.head.id] ?? []));
357
+ for (const modifiedSubject of path.forkChangedSubjects) {
358
+ const existingBranch = subjectToBranch.get(modifiedSubject);
359
+ if (existingBranch == null || this._shouldOverrideCredential(existingBranch, path, modifiedSubject)) {
360
+ subjectToBranch.set(modifiedSubject, path);
361
+ }
362
+ }
363
+ }
364
+ const replayPaths = new Set<PathState<A>>();
365
+ const addReplayPath = replayPaths.add.bind(replayPaths);
366
+ for (const [subject, branch] of subjectToBranch.entries()) {
367
+ result.forkChangedSubjects.add(subject);
368
+ const vertex = branch.state.get(subject)!;
369
+ result.state.set(subject, vertex);
370
+ log('set subject state', () => ({ subject, state: this._stateHandler.toLogString(vertex.assertion) }));
371
+ const otherPaths = paths.filter((p) => p !== branch);
372
+ this._stateHandler.getConflictingPaths(otherPaths, vertex).forEach(addReplayPath);
373
+ }
374
+ if (replayPaths.size > 0) {
375
+ return {
376
+ type: 'replay_required',
377
+ replay: [...replayPaths.values()],
378
+ from: forkPoint,
379
+ stateOverrides: subjectToBranch.mapValues((v, key) => v.state.get(key)!),
380
+ };
381
+ }
382
+ return { type: 'merged', path: result };
383
+ }
384
+
385
+ /**
386
+ * A candidate credential is preferred over the existing credential if:
387
+ * 1. It is the merge-point, because it's the last credential that was issued in awareness of all
388
+ * the previously existing ones.
389
+ * 2. A path where candidate was set contains existing credential in it, which means that the candidate
390
+ * was issued after the existing credential by a legitimate issuer.
391
+ * 3. A state-specific logic (_stateHandler) is able to justify using the candidate credential.
392
+ * 4. The path where candidate was set has more issuers than the existing path (longer branch).
393
+ * 5. The issuance time of the candidate is after the issuance time of the existing credential (LWW).
394
+ */
395
+ private _shouldOverrideCredential(
396
+ existing: PathState<A>,
397
+ candidate: PathState<A>,
398
+ modifiedSubject: PublicKey,
399
+ ): boolean {
400
+ const candidateVertex = candidate.state.get(modifiedSubject)!;
401
+ const currentVertex = existing.state.get(modifiedSubject)!;
402
+ if (candidateVertex.id === currentVertex.id) {
403
+ return false;
404
+ }
405
+ // During merge all paths are pointing to the same head, which is the merge point.
406
+ const mergePointId = existing.head.id;
407
+ if (candidateVertex.id === mergePointId || currentVertex.id === mergePointId) {
408
+ log('merge point chosen to break the tie', { mergePointId: existing.head.id });
409
+ return mergePointId === candidateVertex.id;
410
+ }
411
+ const candidateCredential = candidateVertex.credential!;
412
+ const currentCredential = currentVertex.credential!;
413
+ // A credential is contained in a branch where another credential for this subject was issued.
414
+ if (existing.credentials.has(candidateCredential.id!) !== candidate.credentials.has(currentCredential.id!)) {
415
+ log('one of the credentials was overridden in another branch', {
416
+ current: currentVertex.id,
417
+ candidate: candidateVertex.id,
418
+ });
419
+ return candidate.credentials.has(currentCredential.id!);
420
+ }
421
+ // Give a chance to state-specific conflict resolution logic.
422
+ const winningCredential = this._stateHandler.tryPickWinningUpdate(
423
+ existing,
424
+ currentCredential,
425
+ candidate,
426
+ candidateCredential,
427
+ );
428
+ if (winningCredential != null) {
429
+ return winningCredential === candidateCredential;
430
+ }
431
+ if (candidate.forkIssuers.size !== existing.forkIssuers.size) {
432
+ log('longer issuers branch used to break the tie', {
433
+ issuerCount: [existing.forkIssuers.size, candidate.forkIssuers.size],
434
+ });
435
+ return candidate.forkIssuers.size > existing.forkIssuers.size;
436
+ }
437
+ log('issuance date used to break the tie');
438
+ return candidateCredential.issuanceDate.getTime() > currentCredential.issuanceDate.getTime();
439
+ }
440
+
441
+ private _createRootPath(): PathState<A> {
442
+ return {
443
+ head: this._root,
444
+ chosenPath: {},
445
+ forkIssuers: new ComplexSet(PublicKey.hash),
446
+ forkChangedSubjects: new ComplexSet(PublicKey.hash),
447
+ state: new ComplexMap<PublicKey, ChainVertex<A>>(PublicKey.hash),
448
+ credentials: new ComplexSet(PublicKey.hash),
449
+ };
450
+ }
451
+ }
452
+
453
+ export interface StateScope<A> {
454
+ head?: { id: number };
455
+ state: ReadonlyMap<PublicKey, ChainVertex<A>>;
456
+ stateOverrides?: ReadonlyMap<PublicKey, ChainVertex<A>>;
457
+ }
458
+
459
+ export interface CredentialGraphStateHandler<Assertion, State> {
460
+ hasStateChanged(s1?: State, s2?: State): boolean;
461
+
462
+ createState(credential: Credential, assertion: Assertion): State;
463
+
464
+ isUpdateAllowed: (scope: StateScope<Assertion>, update: Credential, assertion: Assertion) => boolean;
465
+
466
+ getConflictingPaths(paths: PathState<Assertion>[], update: ChainVertex<Assertion>): PathState<Assertion>[];
467
+
468
+ tryPickWinningUpdate(
469
+ scope1: StateScope<Assertion>,
470
+ update1: Credential,
471
+ scope2: StateScope<Assertion>,
472
+ update2: Credential,
473
+ ): Credential | null;
474
+
475
+ toLogString(assertion: Assertion): string;
476
+ }
477
+
478
+ export interface PathState<A> {
479
+ /**
480
+ * The current vertex position in path, always advances.
481
+ */
482
+ head: ChainVertex<A>;
483
+ /**
484
+ * Subject info local to the current path.
485
+ */
486
+ state: ComplexMap<PublicKey, ChainVertex<A>>;
487
+ /**
488
+ * Used during path replay to throw away cascading concurrent modifications.
489
+ * Overrides pathState.
490
+ */
491
+ stateOverrides?: ComplexMap<PublicKey, ChainVertex<A>>;
492
+ /**
493
+ * Used to faster search of conflicting branches. Is different from pathState.keys()
494
+ * because pathState is not reset on forks.
495
+ */
496
+ forkChangedSubjects: ComplexSet<PublicKey>;
497
+ /**
498
+ * Used to find winning branches. A branch wins if it had more participants.
499
+ * Ties are broken using credential issuance date.
500
+ */
501
+ forkIssuers: ComplexSet<PublicKey>;
502
+ /**
503
+ * All the credentials processed during this path traversal.
504
+ */
505
+ credentials: ComplexSet<PublicKey>;
506
+ /**
507
+ * PathState where we had multiple children in the current vertex.
508
+ * Will be merged with child branches when they converge.
509
+ */
510
+ forkPoint?: PathState<A>;
511
+ /**
512
+ * Used for a particular path replay with stateOverrides for conflict resolution
513
+ * forkVertexId is mapped to child vertex selection.
514
+ * Contains choices that lead to the current state. Value is an array when
515
+ * some branches converged before converging with the remaining branches.
516
+ */
517
+ chosenPath: { [forkVertexId: number]: ChainVertex<A>[] };
518
+ }
519
+
520
+ export interface ChainVertex<Assertion> {
521
+ /**
522
+ * The field is missing on root and sentinel vertices. Assertion is not undefined to avoid
523
+ * always asserting two fields.
524
+ */
525
+ credential?: Credential;
526
+ assertion: Assertion;
527
+ /**
528
+ * Local incrementing counter used to form paths. Is used only for causality resolution.
529
+ */
530
+ id: number;
531
+ /**
532
+ * Parents references are used to handle divergent branch merge-points.
533
+ */
534
+ parents: ChainVertex<Assertion>[];
535
+ /**
536
+ * Child references are traversed when computing the current state.
537
+ */
538
+ children: ChainVertex<Assertion>[];
539
+ }
540
+
541
+ type PathMergeResult<A> = SuccessfulMergeResult<A> | ReplayRequiredMergeResult<A>;
542
+
543
+ interface SuccessfulMergeResult<A> {
544
+ type: 'merged';
545
+ path: PathState<A>;
546
+ }
547
+
548
+ interface ReplayRequiredMergeResult<A> {
549
+ type: 'replay_required';
550
+ from: PathState<A>;
551
+ replay: PathState<A>[];
552
+ stateOverrides: ComplexMap<PublicKey, ChainVertex<A>>;
553
+ }
554
+
555
+ const toChosenPath = <A>(path: PathState<A>) => {
556
+ return Object.fromEntries(Object.entries(path.chosenPath!).map(([k, vs]) => [k, vs.map((v) => v.id)]));
557
+ };
@@ -43,7 +43,7 @@ describe('InvitationStateMachine', () => {
43
43
 
44
44
  test('multiple invitations', async () => {
45
45
  const stateMachine = createStateMachine();
46
- const invitations = range(3).map(() => ({ ...baseInvitation, invitationId: PublicKey.random().toHex() }));
46
+ const invitations = range(3, () => ({ ...baseInvitation, invitationId: PublicKey.random().toHex() }));
47
47
  await Promise.all(
48
48
  invitations.map(async (invitation) => {
49
49
  return stateMachine.process(await delegateInvitation(invitation));