@dxos/client-services 0.4.10-main.3e35a2f → 0.4.10-main.3f5e2d2
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/dist/lib/browser/{chunk-S3G2RM7S.mjs → chunk-HIQTBJPW.mjs} +837 -715
- package/dist/lib/browser/chunk-HIQTBJPW.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +10 -4
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/packlets/testing/index.mjs +132 -116
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-3T6D6GIB.cjs → chunk-JGUWA36I.cjs} +859 -733
- package/dist/lib/node/chunk-JGUWA36I.cjs.map +7 -0
- package/dist/lib/node/index.cjs +50 -44
- package/dist/lib/node/index.cjs.map +3 -3
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/packlets/testing/index.cjs +131 -118
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +3 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/index.d.ts +1 -0
- package/dist/types/src/packlets/invitations/index.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts +1 -0
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +6 -1
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -3
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts +44 -0
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-service.d.ts +7 -23
- package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +2 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-context.d.ts +6 -6
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts +2 -2
- package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts +8 -3
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space.d.ts +4 -3
- package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts +9 -6
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/packlets/vault/worker-runtime.d.ts.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/package.json +34 -34
- package/src/packlets/identity/identity-manager.ts +1 -0
- package/src/packlets/identity/identity.test.ts +3 -0
- package/src/packlets/invitations/device-invitation-protocol.ts +6 -1
- package/src/packlets/invitations/index.ts +1 -0
- package/src/packlets/invitations/invitation-extension.ts +28 -1
- package/src/packlets/invitations/invitation-protocol.ts +7 -1
- package/src/packlets/invitations/invitations-handler.ts +77 -91
- package/src/packlets/invitations/invitations-manager.ts +271 -0
- package/src/packlets/invitations/invitations-service.ts +23 -168
- package/src/packlets/invitations/space-invitation-protocol.ts +45 -3
- package/src/packlets/services/automerge-host.test.ts +4 -4
- package/src/packlets/services/service-context.test.ts +3 -3
- package/src/packlets/services/service-context.ts +23 -25
- package/src/packlets/services/service-host.test.ts +6 -0
- package/src/packlets/services/service-host.ts +9 -29
- package/src/packlets/spaces/data-space-manager.test.ts +4 -4
- package/src/packlets/spaces/data-space-manager.ts +56 -13
- package/src/packlets/spaces/data-space.ts +14 -19
- package/src/packlets/testing/invitation-utils.ts +100 -97
- package/src/packlets/testing/test-builder.ts +29 -16
- package/src/packlets/vault/worker-runtime.ts +3 -1
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-S3G2RM7S.mjs.map +0 -7
- package/dist/lib/node/chunk-3T6D6GIB.cjs.map +0 -7
- package/dist/types/src/packlets/indexing/index.d.ts +0 -2
- package/dist/types/src/packlets/indexing/index.d.ts.map +0 -1
- package/dist/types/src/packlets/indexing/util.d.ts +0 -15
- package/dist/types/src/packlets/indexing/util.d.ts.map +0 -1
- package/src/packlets/indexing/index.ts +0 -5
- package/src/packlets/indexing/util.ts +0 -89
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Trigger } from '@dxos/async';
|
|
6
6
|
import { cancelWithContext, Context } from '@dxos/context';
|
|
7
|
+
import { randomBytes, verify } from '@dxos/crypto';
|
|
7
8
|
import { invariant } from '@dxos/invariant';
|
|
8
9
|
import { PublicKey } from '@dxos/keys';
|
|
9
10
|
import { log } from '@dxos/log';
|
|
@@ -51,6 +52,8 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
51
52
|
private _remoteOptions?: Options;
|
|
52
53
|
private _remoteOptionsTrigger = new Trigger();
|
|
53
54
|
|
|
55
|
+
private _challenge?: Buffer = undefined;
|
|
56
|
+
|
|
54
57
|
public invitation?: Invitation = undefined;
|
|
55
58
|
|
|
56
59
|
public guestProfile?: ProfileDocument = undefined;
|
|
@@ -113,13 +116,17 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
113
116
|
|
|
114
117
|
this._callbacks.onStateUpdate({ ...this.invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
115
118
|
|
|
119
|
+
this._challenge =
|
|
120
|
+
this.invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
|
|
121
|
+
|
|
116
122
|
log.trace('dxos.sdk.invitation-handler.host.introduce', trace.end({ id: traceId }));
|
|
117
123
|
return {
|
|
118
124
|
authMethod: this.invitation.authMethod,
|
|
125
|
+
challenge: this._challenge,
|
|
119
126
|
};
|
|
120
127
|
},
|
|
121
128
|
|
|
122
|
-
authenticate: async ({ authCode: code }) => {
|
|
129
|
+
authenticate: async ({ authCode: code, signedChallenge }) => {
|
|
123
130
|
const traceId = PublicKey.random().toHex();
|
|
124
131
|
log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.begin({ id: traceId }));
|
|
125
132
|
log('received authentication request', { authCode: code });
|
|
@@ -145,6 +152,26 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
145
152
|
break;
|
|
146
153
|
}
|
|
147
154
|
|
|
155
|
+
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY: {
|
|
156
|
+
if (!this.invitation.guestKeypair) {
|
|
157
|
+
status = AuthenticationResponse.Status.INTERNAL_ERROR;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const isSignatureValid =
|
|
161
|
+
this._challenge &&
|
|
162
|
+
verify(
|
|
163
|
+
this._challenge,
|
|
164
|
+
Buffer.from(signedChallenge ?? []),
|
|
165
|
+
this.invitation.guestKeypair.publicKey.asBuffer(),
|
|
166
|
+
);
|
|
167
|
+
if (isSignatureValid) {
|
|
168
|
+
this.authenticationPassed = true;
|
|
169
|
+
} else {
|
|
170
|
+
status = AuthenticationResponse.Status.INVALID_SIGNATURE;
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
default: {
|
|
149
176
|
log.error('invalid authentication method', { authMethod: this.invitation.authMethod });
|
|
150
177
|
status = AuthenticationResponse.Status.INTERNAL_ERROR;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { type PublicKey } from '@dxos/keys';
|
|
5
6
|
import type { ApiError } from '@dxos/protocols';
|
|
6
7
|
import type { Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
7
8
|
import type { ProfileDocument, DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
@@ -33,7 +34,12 @@ export interface InvitationProtocol {
|
|
|
33
34
|
/**
|
|
34
35
|
* Once authentication is successful, the host can admit the guest to the requested resource.
|
|
35
36
|
*/
|
|
36
|
-
|
|
37
|
+
delegate(invitation: Invitation): Promise<PublicKey>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Once authentication is successful, the host can admit the guest to the requested resource.
|
|
41
|
+
*/
|
|
42
|
+
admit(invitation: Invitation, request: AdmissionRequest, guestProfile?: ProfileDocument): Promise<AdmissionResponse>;
|
|
37
43
|
|
|
38
44
|
//
|
|
39
45
|
// Guest
|
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
|
|
6
|
-
import {
|
|
7
|
-
AuthenticatingInvitation,
|
|
8
|
-
AUTHENTICATION_CODE_LENGTH,
|
|
9
|
-
CancellableInvitation,
|
|
10
|
-
INVITATION_TIMEOUT,
|
|
11
|
-
} from '@dxos/client-protocol';
|
|
6
|
+
import { AuthenticatingInvitation, INVITATION_TIMEOUT } from '@dxos/client-protocol';
|
|
12
7
|
import { Context } from '@dxos/context';
|
|
13
|
-
import {
|
|
8
|
+
import { createKeyPair, sign } from '@dxos/crypto';
|
|
14
9
|
import { invariant } from '@dxos/invariant';
|
|
15
10
|
import { PublicKey } from '@dxos/keys';
|
|
16
11
|
import { log } from '@dxos/log';
|
|
@@ -21,9 +16,9 @@ import {
|
|
|
21
16
|
type SwarmConnection,
|
|
22
17
|
} from '@dxos/network-manager';
|
|
23
18
|
import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
|
|
24
|
-
import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
19
|
+
import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
25
20
|
import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
26
|
-
import { AuthenticationResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
21
|
+
import { AuthenticationResponse, type IntroductionResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
27
22
|
|
|
28
23
|
import {
|
|
29
24
|
InvitationGuestExtension,
|
|
@@ -66,52 +61,12 @@ export class InvitationsHandler {
|
|
|
66
61
|
*/
|
|
67
62
|
constructor(private readonly _networkManager: NetworkManager) {}
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
timeout = INVITATION_TIMEOUT,
|
|
76
|
-
swarmKey = PublicKey.random(),
|
|
77
|
-
persistent = true,
|
|
78
|
-
created = new Date(),
|
|
79
|
-
lifetime = 86400, // 1 day,
|
|
80
|
-
multiUse = false,
|
|
81
|
-
} = options ?? {};
|
|
82
|
-
const authCode =
|
|
83
|
-
options?.authCode ??
|
|
84
|
-
(authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
|
|
85
|
-
invariant(protocol);
|
|
86
|
-
|
|
87
|
-
const invitation: Invitation = {
|
|
88
|
-
invitationId,
|
|
89
|
-
type,
|
|
90
|
-
authMethod,
|
|
91
|
-
state,
|
|
92
|
-
swarmKey,
|
|
93
|
-
authCode,
|
|
94
|
-
timeout,
|
|
95
|
-
persistent: persistent && type !== Invitation.Type.OFFLINE, // offline invitations are persisted in control feed
|
|
96
|
-
created,
|
|
97
|
-
lifetime,
|
|
98
|
-
multiUse,
|
|
99
|
-
...protocol.getInvitationContext(),
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const stream = new PushStream<Invitation>();
|
|
103
|
-
const ctx = new Context({
|
|
104
|
-
onError: (err) => {
|
|
105
|
-
stream.error(err);
|
|
106
|
-
void ctx.dispose();
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
ctx.onDispose(() => {
|
|
111
|
-
log('complete', { ...protocol.toJSON() });
|
|
112
|
-
stream.complete();
|
|
113
|
-
});
|
|
114
|
-
|
|
64
|
+
handleInvitationFlow(
|
|
65
|
+
ctx: Context,
|
|
66
|
+
stream: PushStream<Invitation>,
|
|
67
|
+
protocol: InvitationProtocol,
|
|
68
|
+
invitation: Invitation,
|
|
69
|
+
): void {
|
|
115
70
|
// Called for every connecting peer.
|
|
116
71
|
const createExtension = (): InvitationHostExtension => {
|
|
117
72
|
const extension = new InvitationHostExtension({
|
|
@@ -130,7 +85,7 @@ export class InvitationsHandler {
|
|
|
130
85
|
try {
|
|
131
86
|
const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
|
|
132
87
|
invariant(deviceKey);
|
|
133
|
-
const admissionResponse = await protocol.admit(admissionRequest, extension.guestProfile);
|
|
88
|
+
const admissionResponse = await protocol.admit(invitation, admissionRequest, extension.guestProfile);
|
|
134
89
|
|
|
135
90
|
// Updating credentials complete.
|
|
136
91
|
extension.completedTrigger.wake(deviceKey);
|
|
@@ -150,7 +105,7 @@ export class InvitationsHandler {
|
|
|
150
105
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
|
|
151
106
|
log('connected', { ...protocol.toJSON() });
|
|
152
107
|
stream.next({ ...invitation, state: Invitation.State.CONNECTED });
|
|
153
|
-
const deviceKey = await extension.completedTrigger.wait({ timeout });
|
|
108
|
+
const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
|
|
154
109
|
log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
|
|
155
110
|
stream.next({ ...invitation, state: Invitation.State.SUCCESS });
|
|
156
111
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
|
|
@@ -164,7 +119,7 @@ export class InvitationsHandler {
|
|
|
164
119
|
}
|
|
165
120
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
|
|
166
121
|
} finally {
|
|
167
|
-
if (!multiUse) {
|
|
122
|
+
if (!invitation.multiUse) {
|
|
168
123
|
// Wait for graceful close before disposing.
|
|
169
124
|
await swarmConnection.close();
|
|
170
125
|
await ctx.dispose();
|
|
@@ -189,7 +144,7 @@ export class InvitationsHandler {
|
|
|
189
144
|
return extension;
|
|
190
145
|
};
|
|
191
146
|
|
|
192
|
-
if (invitation.lifetime && invitation.created
|
|
147
|
+
if (invitation.lifetime && invitation.created) {
|
|
193
148
|
if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
|
|
194
149
|
log.warn('invitation has already expired');
|
|
195
150
|
} else {
|
|
@@ -225,18 +180,6 @@ export class InvitationsHandler {
|
|
|
225
180
|
|
|
226
181
|
stream.next({ ...invitation, state: Invitation.State.CONNECTING });
|
|
227
182
|
});
|
|
228
|
-
|
|
229
|
-
// TODO(burdon): Stop anything pending.
|
|
230
|
-
const observable = new CancellableInvitation({
|
|
231
|
-
initialInvitation: invitation,
|
|
232
|
-
subscriber: stream.observable,
|
|
233
|
-
onCancel: async () => {
|
|
234
|
-
stream.next({ ...invitation, state: Invitation.State.CANCELLED });
|
|
235
|
-
await ctx.dispose();
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
return observable;
|
|
240
183
|
}
|
|
241
184
|
|
|
242
185
|
acceptInvitation(
|
|
@@ -247,7 +190,6 @@ export class InvitationsHandler {
|
|
|
247
190
|
const { timeout = INVITATION_TIMEOUT } = invitation;
|
|
248
191
|
invariant(protocol);
|
|
249
192
|
|
|
250
|
-
// TODO(nf): duplicate check in InvitationsService
|
|
251
193
|
if (deviceProfile) {
|
|
252
194
|
invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
253
195
|
}
|
|
@@ -319,26 +261,13 @@ export class InvitationsHandler {
|
|
|
319
261
|
|
|
320
262
|
// 2. Get authentication code.
|
|
321
263
|
if (isAuthenticationRequired(invitation)) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
setState({ state: Invitation.State.AUTHENTICATING });
|
|
329
|
-
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
330
|
-
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
264
|
+
switch (invitation.authMethod) {
|
|
265
|
+
case Invitation.AuthMethod.SHARED_SECRET:
|
|
266
|
+
await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
|
|
267
|
+
break;
|
|
268
|
+
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
|
|
269
|
+
await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
|
|
331
270
|
break;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
|
|
335
|
-
if (attempt === MAX_OTP_ATTEMPTS) {
|
|
336
|
-
throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
|
|
337
|
-
} else {
|
|
338
|
-
log('retrying invalid code', { attempt });
|
|
339
|
-
authenticated.reset();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
271
|
}
|
|
343
272
|
}
|
|
344
273
|
|
|
@@ -425,4 +354,61 @@ export class InvitationsHandler {
|
|
|
425
354
|
|
|
426
355
|
return observable;
|
|
427
356
|
}
|
|
357
|
+
|
|
358
|
+
private async _handleGuestOtpAuth(
|
|
359
|
+
extension: InvitationGuestExtension,
|
|
360
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
361
|
+
authenticated: Trigger<string>,
|
|
362
|
+
options: { timeout: number },
|
|
363
|
+
) {
|
|
364
|
+
for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
|
|
365
|
+
log('guest waiting for authentication code...');
|
|
366
|
+
setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
367
|
+
const authCode = await authenticated.wait(options);
|
|
368
|
+
|
|
369
|
+
log('sending authentication request');
|
|
370
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
371
|
+
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
372
|
+
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
|
|
377
|
+
if (attempt === MAX_OTP_ATTEMPTS) {
|
|
378
|
+
throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
|
|
379
|
+
} else {
|
|
380
|
+
log('retrying invalid code', { attempt });
|
|
381
|
+
authenticated.reset();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async _handleGuestKpkAuth(
|
|
388
|
+
extension: InvitationGuestExtension,
|
|
389
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
390
|
+
invitation: Invitation,
|
|
391
|
+
introductionResponse: IntroductionResponse,
|
|
392
|
+
) {
|
|
393
|
+
if (invitation.guestKeypair?.privateKey == null) {
|
|
394
|
+
throw new Error('keypair missing in the invitation');
|
|
395
|
+
}
|
|
396
|
+
if (introductionResponse.challenge == null) {
|
|
397
|
+
throw new Error('challenge missing in the introduction');
|
|
398
|
+
}
|
|
399
|
+
log('sending authentication request');
|
|
400
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
401
|
+
const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
|
|
402
|
+
const response = await extension.rpc.InvitationHostService.authenticate({
|
|
403
|
+
signedChallenge: signature,
|
|
404
|
+
});
|
|
405
|
+
if (response.status !== AuthenticationResponse.Status.OK) {
|
|
406
|
+
throw new Error(`Authentication failed with code: ${response.status}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
428
409
|
}
|
|
410
|
+
|
|
411
|
+
export const createAdmissionKeypair = (): AdmissionKeypair => {
|
|
412
|
+
const keypair = createKeyPair();
|
|
413
|
+
return { publicKey: PublicKey.from(keypair.publicKey), privateKey: keypair.secretKey };
|
|
414
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Event, PushStream } from '@dxos/async';
|
|
6
|
+
import {
|
|
7
|
+
type AuthenticatingInvitation,
|
|
8
|
+
AUTHENTICATION_CODE_LENGTH,
|
|
9
|
+
CancellableInvitation,
|
|
10
|
+
INVITATION_TIMEOUT,
|
|
11
|
+
} from '@dxos/client-protocol';
|
|
12
|
+
import { Context } from '@dxos/context';
|
|
13
|
+
import { generatePasscode } from '@dxos/credentials';
|
|
14
|
+
import { hasInvitationExpired, type MetadataStore } from '@dxos/echo-pipeline';
|
|
15
|
+
import { invariant } from '@dxos/invariant';
|
|
16
|
+
import { PublicKey } from '@dxos/keys';
|
|
17
|
+
import { log } from '@dxos/log';
|
|
18
|
+
import {
|
|
19
|
+
type AcceptInvitationRequest,
|
|
20
|
+
type AuthenticationRequest,
|
|
21
|
+
Invitation,
|
|
22
|
+
} from '@dxos/protocols/proto/dxos/client/services';
|
|
23
|
+
|
|
24
|
+
import type { InvitationProtocol } from './invitation-protocol';
|
|
25
|
+
import { createAdmissionKeypair, type InvitationsHandler } from './invitations-handler';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entry point for creating and accepting invitations, keeps track of existing invitation set and
|
|
29
|
+
* emits events when the set changes.
|
|
30
|
+
*/
|
|
31
|
+
export class InvitationsManager {
|
|
32
|
+
private readonly _createInvitations = new Map<string, CancellableInvitation>();
|
|
33
|
+
private readonly _acceptInvitations = new Map<string, AuthenticatingInvitation>();
|
|
34
|
+
|
|
35
|
+
public readonly invitationCreated = new Event<Invitation>();
|
|
36
|
+
public readonly invitationAccepted = new Event<Invitation>();
|
|
37
|
+
public readonly removedCreated = new Event<Invitation>();
|
|
38
|
+
public readonly removedAccepted = new Event<Invitation>();
|
|
39
|
+
public readonly saved = new Event<Invitation>();
|
|
40
|
+
|
|
41
|
+
private readonly _persistentInvitationsLoadedEvent = new Event();
|
|
42
|
+
private _persistentInvitationsLoaded = false;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly _invitationsHandler: InvitationsHandler,
|
|
46
|
+
private readonly _getHandler: (invitation: Partial<Invitation> & Pick<Invitation, 'kind'>) => InvitationProtocol,
|
|
47
|
+
private readonly _metadataStore: MetadataStore,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
async createInvitation(options: Partial<Invitation> & Pick<Invitation, 'kind'>): Promise<CancellableInvitation> {
|
|
51
|
+
if (options.invitationId) {
|
|
52
|
+
const existingInvitation = this._createInvitations.get(options.invitationId);
|
|
53
|
+
if (existingInvitation) {
|
|
54
|
+
return existingInvitation;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handler = this._getHandler(options);
|
|
59
|
+
const invitation = this._createInvitation(handler, options);
|
|
60
|
+
const { ctx, stream, observableInvitation } = this._createObservableInvitation(handler, invitation);
|
|
61
|
+
|
|
62
|
+
this._createInvitations.set(invitation.invitationId, observableInvitation);
|
|
63
|
+
this.invitationCreated.emit(invitation);
|
|
64
|
+
// onComplete is called on cancel, expiration, or redemption of a single-use invitation
|
|
65
|
+
this._onInvitationComplete(observableInvitation, async () => {
|
|
66
|
+
this._createInvitations.delete(observableInvitation.get().invitationId);
|
|
67
|
+
this.removedCreated.emit(observableInvitation.get());
|
|
68
|
+
if (observableInvitation.get().persistent) {
|
|
69
|
+
await this._safeDeleteInvitation(observableInvitation.get());
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await this._persistIfRequired(handler, stream, invitation);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.catch(err);
|
|
77
|
+
await observableInvitation.cancel();
|
|
78
|
+
return observableInvitation;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this._invitationsHandler.handleInvitationFlow(ctx, stream, handler, observableInvitation.get());
|
|
82
|
+
|
|
83
|
+
return observableInvitation;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadPersistentInvitations(): Promise<{ invitations: Invitation[] }> {
|
|
87
|
+
if (this._persistentInvitationsLoaded) {
|
|
88
|
+
const invitations = this.getCreatedInvitations().filter((i) => i.persistent);
|
|
89
|
+
return { invitations };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const persistentInvitations = this._metadataStore.getInvitations();
|
|
93
|
+
// get saved persistent invitations, filter and remove from storage those that have expired.
|
|
94
|
+
const freshInvitations = persistentInvitations.filter((invitation) => !hasInvitationExpired(invitation));
|
|
95
|
+
|
|
96
|
+
const loadTasks = freshInvitations.map((persistentInvitation) => {
|
|
97
|
+
invariant(!this._createInvitations.get(persistentInvitation.invitationId), 'invitation already exists');
|
|
98
|
+
return this.createInvitation({ ...persistentInvitation, persistent: false });
|
|
99
|
+
});
|
|
100
|
+
const cInvitations = await Promise.all(loadTasks);
|
|
101
|
+
|
|
102
|
+
return { invitations: cInvitations.map((invitation) => invitation.get()) };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.catch(err);
|
|
105
|
+
return { invitations: [] };
|
|
106
|
+
} finally {
|
|
107
|
+
this._persistentInvitationsLoadedEvent.emit();
|
|
108
|
+
this._persistentInvitationsLoaded = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
acceptInvitation(request: AcceptInvitationRequest): AuthenticatingInvitation {
|
|
113
|
+
const options = request.invitation;
|
|
114
|
+
const existingInvitation = this._acceptInvitations.get(options.invitationId);
|
|
115
|
+
if (existingInvitation) {
|
|
116
|
+
return existingInvitation;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const handler = this._getHandler(options);
|
|
120
|
+
const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
|
|
121
|
+
this._acceptInvitations.set(invitation.get().invitationId, invitation);
|
|
122
|
+
this.invitationAccepted.emit(invitation.get());
|
|
123
|
+
|
|
124
|
+
this._onInvitationComplete(invitation, () => {
|
|
125
|
+
this._acceptInvitations.delete(invitation.get().invitationId);
|
|
126
|
+
this.removedAccepted.emit(invitation.get());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return invitation;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async authenticate({ invitationId, authCode }: AuthenticationRequest): Promise<void> {
|
|
133
|
+
log('authenticating...');
|
|
134
|
+
invariant(invitationId);
|
|
135
|
+
const observable = this._acceptInvitations.get(invitationId);
|
|
136
|
+
if (!observable) {
|
|
137
|
+
log.warn('invalid invitation', { invitationId });
|
|
138
|
+
} else {
|
|
139
|
+
await observable.authenticate(authCode);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async cancelInvitation({ invitationId }: { invitationId: string }): Promise<void> {
|
|
144
|
+
log('cancelInvitation...', { invitationId });
|
|
145
|
+
invariant(invitationId);
|
|
146
|
+
const created = this._createInvitations.get(invitationId);
|
|
147
|
+
if (created) {
|
|
148
|
+
// remove from storage before modifying in-memory state, higher chance of failing
|
|
149
|
+
if (created.get().persistent) {
|
|
150
|
+
await this._metadataStore.removeInvitation(invitationId);
|
|
151
|
+
}
|
|
152
|
+
await created.cancel();
|
|
153
|
+
this._createInvitations.delete(invitationId);
|
|
154
|
+
this.removedCreated.emit(created.get());
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const accepted = this._acceptInvitations.get(invitationId);
|
|
159
|
+
if (accepted) {
|
|
160
|
+
await accepted.cancel();
|
|
161
|
+
this._acceptInvitations.delete(invitationId);
|
|
162
|
+
this.removedAccepted.emit(accepted.get());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getCreatedInvitations(): Invitation[] {
|
|
167
|
+
return [...this._createInvitations.values()].map((i) => i.get());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getAcceptedInvitations(): Invitation[] {
|
|
171
|
+
return [...this._acceptInvitations.values()].map((i) => i.get());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onPersistentInvitationsLoaded(ctx: Context, callback: () => void) {
|
|
175
|
+
if (this._persistentInvitationsLoaded) {
|
|
176
|
+
callback();
|
|
177
|
+
} else {
|
|
178
|
+
this._persistentInvitationsLoadedEvent.once(ctx, () => callback());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private _createInvitation(protocol: InvitationProtocol, options?: Partial<Invitation>): Invitation {
|
|
183
|
+
const {
|
|
184
|
+
invitationId = PublicKey.random().toHex(),
|
|
185
|
+
type = Invitation.Type.INTERACTIVE,
|
|
186
|
+
authMethod = Invitation.AuthMethod.SHARED_SECRET,
|
|
187
|
+
state = Invitation.State.INIT,
|
|
188
|
+
timeout = INVITATION_TIMEOUT,
|
|
189
|
+
swarmKey = PublicKey.random(),
|
|
190
|
+
persistent = options?.authMethod !== Invitation.AuthMethod.KNOWN_PUBLIC_KEY, // default no not storing keypairs
|
|
191
|
+
created = new Date(),
|
|
192
|
+
guestKeypair = undefined,
|
|
193
|
+
lifetime = 86400, // 1 day,
|
|
194
|
+
multiUse = false,
|
|
195
|
+
} = options ?? {};
|
|
196
|
+
const authCode =
|
|
197
|
+
options?.authCode ??
|
|
198
|
+
(authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
invitationId,
|
|
202
|
+
type,
|
|
203
|
+
authMethod,
|
|
204
|
+
state,
|
|
205
|
+
swarmKey,
|
|
206
|
+
authCode,
|
|
207
|
+
timeout,
|
|
208
|
+
persistent: persistent && type !== Invitation.Type.DELEGATED, // delegated invitations are persisted in control feed
|
|
209
|
+
guestKeypair:
|
|
210
|
+
guestKeypair ?? (authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? createAdmissionKeypair() : undefined),
|
|
211
|
+
created,
|
|
212
|
+
lifetime,
|
|
213
|
+
multiUse,
|
|
214
|
+
delegationCredentialId: options?.delegationCredentialId,
|
|
215
|
+
...protocol.getInvitationContext(),
|
|
216
|
+
} satisfies Invitation;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private _createObservableInvitation(handler: InvitationProtocol, invitation: Invitation) {
|
|
220
|
+
const stream = new PushStream<Invitation>();
|
|
221
|
+
const ctx = new Context({
|
|
222
|
+
onError: (err) => {
|
|
223
|
+
stream.error(err);
|
|
224
|
+
void ctx.dispose();
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
ctx.onDispose(() => {
|
|
228
|
+
log('complete', { ...handler.toJSON() });
|
|
229
|
+
stream.complete();
|
|
230
|
+
});
|
|
231
|
+
const observableInvitation = new CancellableInvitation({
|
|
232
|
+
initialInvitation: invitation,
|
|
233
|
+
subscriber: stream.observable,
|
|
234
|
+
onCancel: async () => {
|
|
235
|
+
stream.next({ ...invitation, state: Invitation.State.CANCELLED });
|
|
236
|
+
await ctx.dispose();
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
return { ctx, stream, observableInvitation };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _persistIfRequired(
|
|
243
|
+
handler: InvitationProtocol,
|
|
244
|
+
changeStream: PushStream<Invitation>,
|
|
245
|
+
invitation: Invitation,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
if (invitation.type === Invitation.Type.DELEGATED && invitation.delegationCredentialId == null) {
|
|
248
|
+
const delegationCredentialId = await handler.delegate(invitation);
|
|
249
|
+
changeStream.next({ ...invitation, delegationCredentialId });
|
|
250
|
+
} else if (invitation.persistent) {
|
|
251
|
+
await this._metadataStore.addInvitation(invitation);
|
|
252
|
+
this.saved.emit(invitation);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async _safeDeleteInvitation(invitation: Invitation): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
await this._metadataStore.removeInvitation(invitation.invitationId);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
log.catch(err);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _onInvitationComplete(invitation: CancellableInvitation, callback: () => void) {
|
|
265
|
+
invitation.subscribe(
|
|
266
|
+
() => {},
|
|
267
|
+
() => {},
|
|
268
|
+
callback,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|