@dxos/client-services 0.5.1-main.39e0ee2 → 0.5.1-main.3c7169d
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-S3SB2RSX.mjs → chunk-FRVAAUY2.mjs} +908 -1264
- package/dist/lib/browser/chunk-FRVAAUY2.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +2 -31
- 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 +8 -28
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-TQ5236LO.cjs → chunk-Y2YAZWVU.cjs} +1037 -1385
- package/dist/lib/node/chunk-Y2YAZWVU.cjs.map +7 -0
- package/dist/lib/node/index.cjs +44 -73
- 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 +14 -34
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +1 -2
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/{invitation-host-extension.d.ts → invitation-extension.d.ts} +31 -17
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +1 -6
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts +10 -19
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts +1 -2
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +0 -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 +3 -6
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts +1 -2
- package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/packlets/vault/shell-runtime.d.ts +2 -10
- package/dist/types/src/packlets/vault/shell-runtime.d.ts.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/package.json +36 -36
- package/src/packlets/invitations/device-invitation-protocol.ts +1 -5
- package/src/packlets/invitations/{invitation-host-extension.ts → invitation-extension.ts} +105 -99
- package/src/packlets/invitations/invitation-protocol.ts +1 -7
- package/src/packlets/invitations/invitations-handler.ts +149 -246
- package/src/packlets/invitations/invitations-manager.ts +3 -42
- package/src/packlets/invitations/space-invitation-protocol.ts +1 -19
- package/src/packlets/services/service-context.ts +3 -5
- package/src/packlets/testing/invitation-utils.ts +3 -23
- package/src/packlets/testing/test-builder.ts +1 -3
- package/src/packlets/vault/shell-runtime.ts +2 -40
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-S3SB2RSX.mjs.map +0 -7
- package/dist/lib/node/chunk-TQ5236LO.cjs.map +0 -7
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +0 -39
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +0 -1
- package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +0 -1
- package/dist/types/src/packlets/invitations/invitation-topology.d.ts +0 -37
- package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +0 -1
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +0 -2
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +0 -1
- package/dist/types/src/packlets/invitations/utils.d.ts +0 -6
- package/dist/types/src/packlets/invitations/utils.d.ts.map +0 -1
- package/src/packlets/invitations/invitation-guest-extenstion.ts +0 -126
- package/src/packlets/invitations/invitation-topology.ts +0 -87
- package/src/packlets/invitations/invitations-handler.test.ts +0 -361
- package/src/packlets/invitations/utils.ts +0 -27
|
@@ -2,31 +2,31 @@
|
|
|
2
2
|
// Copyright 2022 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { INVITATION_TIMEOUT } from '@dxos/client-protocol';
|
|
7
|
-
import {
|
|
5
|
+
import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
|
|
6
|
+
import { AuthenticatingInvitation, INVITATION_TIMEOUT } from '@dxos/client-protocol';
|
|
7
|
+
import { Context } from '@dxos/context';
|
|
8
8
|
import { createKeyPair, sign } from '@dxos/crypto';
|
|
9
9
|
import { invariant } from '@dxos/invariant';
|
|
10
10
|
import { PublicKey } from '@dxos/keys';
|
|
11
11
|
import { log } from '@dxos/log';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createTeleportProtocolFactory,
|
|
14
|
+
type NetworkManager,
|
|
15
|
+
StarTopology,
|
|
16
|
+
type SwarmConnection,
|
|
17
|
+
} from '@dxos/network-manager';
|
|
13
18
|
import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
|
|
14
19
|
import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
15
20
|
import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
16
21
|
import { AuthenticationResponse, type IntroductionResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
17
|
-
import { Options } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
18
|
-
import { type ExtensionContext, type TeleportExtension, type TeleportParams } from '@dxos/teleport';
|
|
19
|
-
import { ComplexSet } from '@dxos/util';
|
|
20
22
|
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
+
import {
|
|
24
|
+
InvitationGuestExtension,
|
|
25
|
+
InvitationHostExtension,
|
|
26
|
+
isAuthenticationRequired,
|
|
27
|
+
MAX_OTP_ATTEMPTS,
|
|
28
|
+
} from './invitation-extension';
|
|
23
29
|
import { type InvitationProtocol } from './invitation-protocol';
|
|
24
|
-
import { InvitationTopology } from './invitation-topology';
|
|
25
|
-
import { stateToString } from './utils';
|
|
26
|
-
|
|
27
|
-
const MAX_DELEGATED_INVITATION_HOST_TRIES = 3;
|
|
28
|
-
|
|
29
|
-
type InvitationExtension = InvitationHostExtension | InvitationGuestExtension;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Generic handler for Halo and Space invitations.
|
|
@@ -35,35 +35,31 @@ type InvitationExtension = InvitationHostExtension | InvitationGuestExtension;
|
|
|
35
35
|
* Host
|
|
36
36
|
* - Creates an invitation containing a swarm topic (which can be shared via a URL, QR code, or direct message).
|
|
37
37
|
* - Joins the swarm with the topic and waits for guest's introduction.
|
|
38
|
-
* - Wait for guest to authenticate with
|
|
38
|
+
* - Wait for guest to authenticate with OTP.
|
|
39
39
|
* - Waits for guest to present credentials (containing local device and feed keys).
|
|
40
|
-
* - Writes credentials to control feed then exits
|
|
40
|
+
* - Writes credentials to control feed then exits.
|
|
41
41
|
*
|
|
42
42
|
* Guest
|
|
43
43
|
* - Joins the swarm with the topic.
|
|
44
44
|
* - Sends an introduction.
|
|
45
|
-
* -
|
|
45
|
+
* - Sends authentication OTP.
|
|
46
46
|
* - If Space handler then creates a local cloned space (with genesis block).
|
|
47
47
|
* - Sends admission credentials.
|
|
48
|
+
*
|
|
49
|
+
* TODO(burdon): Show proxy/service relationship and reference design doc/diagram.
|
|
50
|
+
*
|
|
48
51
|
* ```
|
|
49
52
|
* [Guest] [Host]
|
|
50
53
|
* |------------------------------------Introduce-->|
|
|
51
54
|
* |-------------------------------[Authenticate]-->|
|
|
52
55
|
* |----------------------------------------Admit-->|
|
|
53
56
|
* ```
|
|
54
|
-
*
|
|
55
|
-
* TODO: consider refactoring using xstate making the logic separation more explicit:
|
|
56
|
-
* TODO: the flow logic should either be contained in invitations-handler or in extensions, not be split across
|
|
57
|
-
* TODO: potentially re-evaluate host-side API to allow multiple concurrent connection, so that mutex can be removed
|
|
58
57
|
*/
|
|
59
58
|
export class InvitationsHandler {
|
|
60
59
|
/**
|
|
61
60
|
* @internal
|
|
62
61
|
*/
|
|
63
|
-
constructor(
|
|
64
|
-
private readonly _networkManager: NetworkManager,
|
|
65
|
-
private readonly _defaultTeleportParams?: Partial<TeleportParams>,
|
|
66
|
-
) {}
|
|
62
|
+
constructor(private readonly _networkManager: NetworkManager) {}
|
|
67
63
|
|
|
68
64
|
handleInvitationFlow(
|
|
69
65
|
ctx: Context,
|
|
@@ -71,17 +67,18 @@ export class InvitationsHandler {
|
|
|
71
67
|
protocol: InvitationProtocol,
|
|
72
68
|
invitation: Invitation,
|
|
73
69
|
): void {
|
|
74
|
-
const guardedState = this._createGuardedState(ctx, invitation, stream);
|
|
75
70
|
// Called for every connecting peer.
|
|
76
71
|
const createExtension = (): InvitationHostExtension => {
|
|
77
|
-
const extension = new InvitationHostExtension(
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
const extension = new InvitationHostExtension({
|
|
73
|
+
onStateUpdate: (invitation) => {
|
|
74
|
+
stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
80
75
|
},
|
|
81
76
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
resolveInvitation: async ({ invitationId }) => {
|
|
78
|
+
if (invitationId && invitationId !== invitation.invitationId) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return invitation;
|
|
85
82
|
},
|
|
86
83
|
|
|
87
84
|
admit: async (admissionRequest) => {
|
|
@@ -96,62 +93,50 @@ export class InvitationsHandler {
|
|
|
96
93
|
return admissionResponse;
|
|
97
94
|
} catch (err: any) {
|
|
98
95
|
// TODO(burdon): Generic RPC callback to report error to client.
|
|
99
|
-
|
|
96
|
+
stream.error(err);
|
|
100
97
|
throw err; // Propagate error to guest.
|
|
101
98
|
}
|
|
102
99
|
},
|
|
103
100
|
|
|
104
|
-
onOpen: (
|
|
105
|
-
|
|
106
|
-
connectionCtx.onDispose(() => {
|
|
107
|
-
if (!admitted) {
|
|
108
|
-
guardedState.error(extension, new ContextDisposedError());
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
scheduleTask(connectionCtx, async () => {
|
|
101
|
+
onOpen: () => {
|
|
102
|
+
scheduleTask(ctx, async () => {
|
|
113
103
|
const traceId = PublicKey.random().toHex();
|
|
114
104
|
try {
|
|
115
105
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
|
|
116
106
|
log('connected', { ...protocol.toJSON() });
|
|
107
|
+
stream.next({ ...invitation, state: Invitation.State.CONNECTED });
|
|
117
108
|
const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
|
|
118
109
|
log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
|
|
119
|
-
|
|
110
|
+
stream.next({ ...invitation, state: Invitation.State.SUCCESS });
|
|
120
111
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
|
|
121
|
-
admitted = true;
|
|
122
|
-
|
|
123
|
-
if (!invitation.multiUse) {
|
|
124
|
-
await ctx.dispose();
|
|
125
|
-
}
|
|
126
112
|
} catch (err: any) {
|
|
127
113
|
if (err instanceof TimeoutError) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
114
|
+
log('timeout', { ...protocol.toJSON() });
|
|
115
|
+
stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
|
|
131
116
|
} else {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
117
|
+
log.error('failed', err);
|
|
118
|
+
stream.error(err);
|
|
135
119
|
}
|
|
136
120
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
|
|
137
|
-
|
|
138
|
-
|
|
121
|
+
} finally {
|
|
122
|
+
if (!invitation.multiUse) {
|
|
123
|
+
// Wait for graceful close before disposing.
|
|
124
|
+
await swarmConnection.close();
|
|
125
|
+
await ctx.dispose();
|
|
126
|
+
}
|
|
139
127
|
}
|
|
140
128
|
});
|
|
141
129
|
},
|
|
142
130
|
onError: (err) => {
|
|
143
131
|
if (err instanceof InvalidInvitationExtensionRoleError) {
|
|
144
|
-
log('invalid role', { ...err.context });
|
|
145
132
|
return;
|
|
146
133
|
}
|
|
147
134
|
if (err instanceof TimeoutError) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
135
|
+
log('timeout', { ...protocol.toJSON() });
|
|
136
|
+
stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
|
|
151
137
|
} else {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
138
|
+
log.error('failed', err);
|
|
139
|
+
stream.error(err);
|
|
155
140
|
}
|
|
156
141
|
},
|
|
157
142
|
});
|
|
@@ -168,7 +153,7 @@ export class InvitationsHandler {
|
|
|
168
153
|
async () => {
|
|
169
154
|
// ensure the swarm is closed before changing state and closing the stream.
|
|
170
155
|
await swarmConnection.close();
|
|
171
|
-
|
|
156
|
+
stream.next({ ...invitation, state: Invitation.State.EXPIRED });
|
|
172
157
|
await ctx.dispose();
|
|
173
158
|
},
|
|
174
159
|
invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
|
|
@@ -177,91 +162,100 @@ export class InvitationsHandler {
|
|
|
177
162
|
}
|
|
178
163
|
|
|
179
164
|
let swarmConnection: SwarmConnection;
|
|
165
|
+
const invitationLabel =
|
|
166
|
+
'invitation host for ' +
|
|
167
|
+
(invitation.kind === Invitation.Kind.DEVICE ? 'device' : `space ${invitation.spaceKey?.truncate()}`);
|
|
180
168
|
scheduleTask(ctx, async () => {
|
|
181
|
-
|
|
182
|
-
|
|
169
|
+
const topic = invitation.swarmKey!;
|
|
170
|
+
swarmConnection = await this._networkManager.joinSwarm({
|
|
171
|
+
topic,
|
|
172
|
+
peerId: topic,
|
|
173
|
+
protocolProvider: createTeleportProtocolFactory(async (teleport) => {
|
|
174
|
+
teleport.addExtension('dxos.halo.invitations', createExtension());
|
|
175
|
+
}),
|
|
176
|
+
topology: new StarTopology(topic),
|
|
177
|
+
label: invitationLabel,
|
|
178
|
+
});
|
|
179
|
+
ctx.onDispose(() => swarmConnection.close());
|
|
180
|
+
|
|
181
|
+
stream.next({ ...invitation, state: Invitation.State.CONNECTING });
|
|
183
182
|
});
|
|
184
183
|
}
|
|
185
184
|
|
|
186
185
|
acceptInvitation(
|
|
187
|
-
ctx: Context,
|
|
188
|
-
stream: PushStream<Invitation>,
|
|
189
186
|
protocol: InvitationProtocol,
|
|
190
187
|
invitation: Invitation,
|
|
191
|
-
otpEnteredTrigger: Trigger<string>,
|
|
192
188
|
deviceProfile?: DeviceProfileDocument,
|
|
193
|
-
):
|
|
189
|
+
): AuthenticatingInvitation {
|
|
194
190
|
const { timeout = INVITATION_TIMEOUT } = invitation;
|
|
191
|
+
invariant(protocol);
|
|
195
192
|
|
|
196
193
|
if (deviceProfile) {
|
|
197
194
|
invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
198
195
|
}
|
|
196
|
+
const authenticated = new Trigger<string>();
|
|
199
197
|
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
// TODO(dmaretskyi): Turn into state?
|
|
199
|
+
// Whether the Host has already admitted us and the remote connection is no longer needed.
|
|
200
|
+
let admitted = false;
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
});
|
|
210
|
-
if (isLockedByAnotherConnection) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
// for delegated invitations we might try with other hosts and will dispose either after
|
|
214
|
-
// a timeout or when the number of tries was exceeded
|
|
215
|
-
return invitation.type !== Invitation.Type.DELEGATED || triedPeersIds.size >= MAX_DELEGATED_INVITATION_HOST_TRIES;
|
|
202
|
+
let currentState: Invitation.State;
|
|
203
|
+
const stream = new PushStream<Invitation>();
|
|
204
|
+
const setState = (newData: Partial<Invitation>) => {
|
|
205
|
+
invariant(newData.state !== undefined);
|
|
206
|
+
currentState = newData.state;
|
|
207
|
+
stream.next({ ...invitation, ...newData });
|
|
216
208
|
};
|
|
217
209
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
210
|
+
const ctx = new Context({
|
|
211
|
+
onError: (err) => {
|
|
212
|
+
if (err instanceof TimeoutError) {
|
|
213
|
+
log('timeout', { ...protocol.toJSON() });
|
|
214
|
+
setState({ state: Invitation.State.TIMEOUT });
|
|
215
|
+
} else {
|
|
216
|
+
log.warn('auth failed', err);
|
|
217
|
+
stream.error(err);
|
|
218
|
+
}
|
|
219
|
+
void ctx.dispose();
|
|
220
|
+
},
|
|
221
|
+
});
|
|
226
222
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
223
|
+
ctx.onDispose(() => {
|
|
224
|
+
log('complete', { ...protocol.toJSON() });
|
|
225
|
+
stream.complete();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const createExtension = (): InvitationGuestExtension => {
|
|
229
|
+
let connectionCount = 0;
|
|
231
230
|
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
const extension = new InvitationGuestExtension({
|
|
232
|
+
onOpen: (extensionCtx) => {
|
|
233
|
+
extensionCtx.onDispose(async () => {
|
|
234
|
+
log('extension disposed', { currentState });
|
|
234
235
|
if (!admitted) {
|
|
235
|
-
|
|
236
|
-
if (shouldCancelInvitationFlow(extension)) {
|
|
237
|
-
await ctx.dispose();
|
|
238
|
-
}
|
|
236
|
+
stream.error(new Error('Remote peer disconnected.'));
|
|
239
237
|
}
|
|
240
238
|
});
|
|
241
239
|
|
|
242
|
-
scheduleTask(
|
|
240
|
+
scheduleTask(ctx, async () => {
|
|
243
241
|
const traceId = PublicKey.random().toHex();
|
|
244
242
|
try {
|
|
245
243
|
log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.begin({ id: traceId }));
|
|
244
|
+
// TODO(burdon): Bug where guest may create multiple connections.
|
|
245
|
+
if (++connectionCount > 1) {
|
|
246
|
+
throw new Error(`multiple connections detected: ${connectionCount}`);
|
|
247
|
+
}
|
|
246
248
|
|
|
247
|
-
scheduleTask(
|
|
248
|
-
connectionCtx,
|
|
249
|
-
() => {
|
|
250
|
-
guardedState.set(extension, Invitation.State.TIMEOUT);
|
|
251
|
-
extensionCtx.close();
|
|
252
|
-
},
|
|
253
|
-
timeout,
|
|
254
|
-
);
|
|
249
|
+
scheduleTask(ctx, () => ctx.raise(new TimeoutError(timeout)), timeout);
|
|
255
250
|
|
|
256
251
|
log('connected', { ...protocol.toJSON() });
|
|
257
|
-
|
|
252
|
+
setState({ state: Invitation.State.CONNECTED });
|
|
258
253
|
|
|
259
254
|
// 1. Introduce guest to host.
|
|
260
255
|
log('introduce', { ...protocol.toJSON() });
|
|
261
|
-
const introductionResponse = await extension.rpc.InvitationHostService.introduce(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
});
|
|
256
|
+
const introductionResponse = await extension.rpc.InvitationHostService.introduce(
|
|
257
|
+
protocol.createIntroduction(),
|
|
258
|
+
);
|
|
265
259
|
log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
|
|
266
260
|
invitation.authMethod = introductionResponse.authMethod;
|
|
267
261
|
|
|
@@ -269,20 +263,10 @@ export class InvitationsHandler {
|
|
|
269
263
|
if (isAuthenticationRequired(invitation)) {
|
|
270
264
|
switch (invitation.authMethod) {
|
|
271
265
|
case Invitation.AuthMethod.SHARED_SECRET:
|
|
272
|
-
await this._handleGuestOtpAuth(
|
|
273
|
-
extension,
|
|
274
|
-
(state) => guardedState.set(extension, state),
|
|
275
|
-
otpEnteredTrigger,
|
|
276
|
-
{ timeout },
|
|
277
|
-
);
|
|
266
|
+
await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
|
|
278
267
|
break;
|
|
279
268
|
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
|
|
280
|
-
await this._handleGuestKpkAuth(
|
|
281
|
-
extension,
|
|
282
|
-
(state) => guardedState.set(extension, state),
|
|
283
|
-
invitation,
|
|
284
|
-
introductionResponse,
|
|
285
|
-
);
|
|
269
|
+
await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
|
|
286
270
|
break;
|
|
287
271
|
}
|
|
288
272
|
}
|
|
@@ -300,22 +284,19 @@ export class InvitationsHandler {
|
|
|
300
284
|
|
|
301
285
|
// 5. Success.
|
|
302
286
|
log('admitted by host', { ...protocol.toJSON() });
|
|
303
|
-
|
|
304
|
-
...guardedState.current,
|
|
305
|
-
...result,
|
|
306
|
-
state: Invitation.State.SUCCESS,
|
|
307
|
-
});
|
|
287
|
+
setState({ ...result, target: invitation.target, state: Invitation.State.SUCCESS });
|
|
308
288
|
log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.end({ id: traceId }));
|
|
309
289
|
} catch (err: any) {
|
|
310
290
|
if (err instanceof TimeoutError) {
|
|
311
291
|
log('timeout', { ...protocol.toJSON() });
|
|
312
|
-
|
|
292
|
+
setState({ state: Invitation.State.TIMEOUT });
|
|
313
293
|
} else {
|
|
314
294
|
log('auth failed', err);
|
|
315
|
-
|
|
295
|
+
stream.error(err);
|
|
316
296
|
}
|
|
317
|
-
extensionCtx.close(err);
|
|
318
297
|
log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.error({ id: traceId, error: err }));
|
|
298
|
+
} finally {
|
|
299
|
+
await ctx.dispose();
|
|
319
300
|
}
|
|
320
301
|
});
|
|
321
302
|
},
|
|
@@ -325,10 +306,10 @@ export class InvitationsHandler {
|
|
|
325
306
|
}
|
|
326
307
|
if (err instanceof TimeoutError) {
|
|
327
308
|
log('timeout', { ...protocol.toJSON() });
|
|
328
|
-
|
|
309
|
+
setState({ state: Invitation.State.TIMEOUT });
|
|
329
310
|
} else {
|
|
330
311
|
log('auth failed', err);
|
|
331
|
-
|
|
312
|
+
stream.error(err);
|
|
332
313
|
}
|
|
333
314
|
},
|
|
334
315
|
});
|
|
@@ -340,131 +321,53 @@ export class InvitationsHandler {
|
|
|
340
321
|
const error = protocol.checkInvitation(invitation);
|
|
341
322
|
if (error) {
|
|
342
323
|
stream.error(error);
|
|
343
|
-
await ctx.dispose();
|
|
344
324
|
} else {
|
|
345
325
|
invariant(invitation.swarmKey);
|
|
346
|
-
|
|
347
|
-
|
|
326
|
+
const topic = invitation.swarmKey;
|
|
327
|
+
const swarmConnection = await this._networkManager.joinSwarm({
|
|
328
|
+
topic,
|
|
329
|
+
peerId: PublicKey.random(),
|
|
330
|
+
protocolProvider: createTeleportProtocolFactory(async (teleport) => {
|
|
331
|
+
teleport.addExtension('dxos.halo.invitations', createExtension());
|
|
332
|
+
}),
|
|
333
|
+
topology: new StarTopology(topic),
|
|
334
|
+
label: 'invitation guest',
|
|
335
|
+
});
|
|
336
|
+
ctx.onDispose(() => swarmConnection.close());
|
|
337
|
+
|
|
338
|
+
setState({ state: Invitation.State.CONNECTING });
|
|
348
339
|
}
|
|
349
340
|
});
|
|
350
|
-
}
|
|
351
341
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
let label: string;
|
|
359
|
-
if (role === Options.Role.GUEST) {
|
|
360
|
-
label = 'invitation guest';
|
|
361
|
-
} else if (invitation.kind === Invitation.Kind.DEVICE) {
|
|
362
|
-
label = 'invitation host for device';
|
|
363
|
-
} else {
|
|
364
|
-
label = `invitation host for space ${invitation.spaceKey?.truncate()}`;
|
|
365
|
-
}
|
|
366
|
-
const swarmConnection = await this._networkManager.joinSwarm({
|
|
367
|
-
topic: invitation.swarmKey,
|
|
368
|
-
peerId: PublicKey.random(),
|
|
369
|
-
protocolProvider: createTeleportProtocolFactory(async (teleport) => {
|
|
370
|
-
teleport.addExtension('dxos.halo.invitations', extensionFactory());
|
|
371
|
-
}, this._defaultTeleportParams),
|
|
372
|
-
topology: new InvitationTopology(role),
|
|
373
|
-
label,
|
|
374
|
-
});
|
|
375
|
-
ctx.onDispose(() => swarmConnection.close());
|
|
376
|
-
return swarmConnection;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* A utility object for serializing invitation state changes by multiple concurrent
|
|
381
|
-
* invitation flow connections.
|
|
382
|
-
*/
|
|
383
|
-
private _createGuardedState(ctx: Context, invitation: Invitation, stream: PushStream<Invitation>) {
|
|
384
|
-
// the mutex guards invitation flow on host and guest side, making sure only one flow is currently active
|
|
385
|
-
// deadlocks seem very unlikely because hosts don't initiate multiple connections
|
|
386
|
-
// even if this somehow happens that there are 2 guests (A, B) and 2 hosts (1, 2) and:
|
|
387
|
-
// A has lock for flow with 1, B has lock for flow with 2
|
|
388
|
-
// 1 has lock for flow with B, 2 has lock for flow with A
|
|
389
|
-
// there'll be a 10-second introduction timeout after which connection will be closed and deadlock broken
|
|
390
|
-
const mutex = new Mutex();
|
|
391
|
-
let lastActiveExtension: any = null;
|
|
392
|
-
let currentInvitation = { ...invitation };
|
|
393
|
-
const isStateChangeAllowed = (extension: InvitationExtension | null) => {
|
|
394
|
-
if (ctx.disposed || (extension !== null && mutex.isLocked() && !extension.hasFlowLock())) {
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
// don't allow transitions from a terminal state unless a new extension acquired mutex
|
|
398
|
-
// handles a case when error occurs (e.g. connection is closed) after we completed the flow
|
|
399
|
-
// successfully or already reported another error
|
|
400
|
-
return extension == null || lastActiveExtension !== extension || this._isNotTerminal(currentInvitation.state);
|
|
401
|
-
};
|
|
402
|
-
return {
|
|
403
|
-
mutex,
|
|
404
|
-
get current() {
|
|
405
|
-
return currentInvitation;
|
|
406
|
-
},
|
|
407
|
-
// disposing context prevents any further state updates
|
|
408
|
-
complete: (newState: Partial<Invitation>) => {
|
|
409
|
-
currentInvitation = { ...currentInvitation, ...newState };
|
|
410
|
-
stream.next(currentInvitation);
|
|
411
|
-
return ctx.dispose();
|
|
412
|
-
},
|
|
413
|
-
set: (extension: InvitationExtension | null, newState: Invitation.State): boolean => {
|
|
414
|
-
if (isStateChangeAllowed(extension)) {
|
|
415
|
-
this._logStateUpdate(currentInvitation, extension, newState);
|
|
416
|
-
currentInvitation = { ...currentInvitation, state: newState };
|
|
417
|
-
stream.next(currentInvitation);
|
|
418
|
-
lastActiveExtension = extension;
|
|
419
|
-
return true;
|
|
420
|
-
}
|
|
421
|
-
return false;
|
|
342
|
+
const observable = new AuthenticatingInvitation({
|
|
343
|
+
initialInvitation: invitation,
|
|
344
|
+
subscriber: stream.observable,
|
|
345
|
+
onCancel: async () => {
|
|
346
|
+
setState({ state: Invitation.State.CANCELLED });
|
|
347
|
+
await ctx.dispose();
|
|
422
348
|
},
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
currentInvitation = { ...currentInvitation, state: Invitation.State.ERROR };
|
|
427
|
-
stream.next(currentInvitation);
|
|
428
|
-
stream.error(error);
|
|
429
|
-
lastActiveExtension = extension;
|
|
430
|
-
return true;
|
|
431
|
-
}
|
|
432
|
-
return false;
|
|
349
|
+
onAuthenticate: async (code: string) => {
|
|
350
|
+
// TODO(burdon): Reset creates a race condition? Event?
|
|
351
|
+
authenticated.wake(code);
|
|
433
352
|
},
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
private _logStateUpdate(invitation: Invitation, actor: any, newState: Invitation.State) {
|
|
438
|
-
log('invitation state update', {
|
|
439
|
-
actor: actor?.constructor.name,
|
|
440
|
-
newState: stateToString(newState),
|
|
441
|
-
oldState: stateToString(invitation.state),
|
|
442
353
|
});
|
|
443
|
-
}
|
|
444
354
|
|
|
445
|
-
|
|
446
|
-
return ![
|
|
447
|
-
Invitation.State.SUCCESS,
|
|
448
|
-
Invitation.State.ERROR,
|
|
449
|
-
Invitation.State.CANCELLED,
|
|
450
|
-
Invitation.State.TIMEOUT,
|
|
451
|
-
Invitation.State.EXPIRED,
|
|
452
|
-
].includes(currentState);
|
|
355
|
+
return observable;
|
|
453
356
|
}
|
|
454
357
|
|
|
455
358
|
private async _handleGuestOtpAuth(
|
|
456
359
|
extension: InvitationGuestExtension,
|
|
457
|
-
setState: (newState: Invitation
|
|
360
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
458
361
|
authenticated: Trigger<string>,
|
|
459
362
|
options: { timeout: number },
|
|
460
363
|
) {
|
|
461
364
|
for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
|
|
462
365
|
log('guest waiting for authentication code...');
|
|
463
|
-
setState(Invitation.State.READY_FOR_AUTHENTICATION);
|
|
366
|
+
setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
464
367
|
const authCode = await authenticated.wait(options);
|
|
465
368
|
|
|
466
369
|
log('sending authentication request');
|
|
467
|
-
setState(Invitation.State.AUTHENTICATING);
|
|
370
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
468
371
|
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
469
372
|
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
470
373
|
break;
|
|
@@ -483,7 +386,7 @@ export class InvitationsHandler {
|
|
|
483
386
|
|
|
484
387
|
private async _handleGuestKpkAuth(
|
|
485
388
|
extension: InvitationGuestExtension,
|
|
486
|
-
setState: (newState: Invitation
|
|
389
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
487
390
|
invitation: Invitation,
|
|
488
391
|
introductionResponse: IntroductionResponse,
|
|
489
392
|
) {
|
|
@@ -494,7 +397,7 @@ export class InvitationsHandler {
|
|
|
494
397
|
throw new Error('challenge missing in the introduction');
|
|
495
398
|
}
|
|
496
399
|
log('sending authentication request');
|
|
497
|
-
setState(Invitation.State.AUTHENTICATING);
|
|
400
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
498
401
|
const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
|
|
499
402
|
const response = await extension.rpc.InvitationHostService.authenticate({
|
|
500
403
|
signedChallenge: signature,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Event, PushStream
|
|
5
|
+
import { Event, PushStream } from '@dxos/async';
|
|
6
6
|
import {
|
|
7
|
-
AuthenticatingInvitation,
|
|
7
|
+
type AuthenticatingInvitation,
|
|
8
8
|
AUTHENTICATION_CODE_LENGTH,
|
|
9
9
|
CancellableInvitation,
|
|
10
10
|
INVITATION_TIMEOUT,
|
|
@@ -117,8 +117,7 @@ export class InvitationsManager {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const handler = this._getHandler(options);
|
|
120
|
-
const
|
|
121
|
-
this._invitationsHandler.acceptInvitation(ctx, stream, handler, options, otpEnteredTrigger, request.deviceProfile);
|
|
120
|
+
const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
|
|
122
121
|
this._acceptInvitations.set(invitation.get().invitationId, invitation);
|
|
123
122
|
this.invitationAccepted.emit(invitation.get());
|
|
124
123
|
|
|
@@ -150,10 +149,6 @@ export class InvitationsManager {
|
|
|
150
149
|
if (created.get().persistent) {
|
|
151
150
|
await this._metadataStore.removeInvitation(invitationId);
|
|
152
151
|
}
|
|
153
|
-
if (created.get().type === Invitation.Type.DELEGATED) {
|
|
154
|
-
const handler = this._getHandler(created.get());
|
|
155
|
-
await handler.cancelDelegation(created.get());
|
|
156
|
-
}
|
|
157
152
|
await created.cancel();
|
|
158
153
|
this._createInvitations.delete(invitationId);
|
|
159
154
|
this.removedCreated.emit(created.get());
|
|
@@ -244,40 +239,6 @@ export class InvitationsManager {
|
|
|
244
239
|
return { ctx, stream, observableInvitation };
|
|
245
240
|
}
|
|
246
241
|
|
|
247
|
-
private _createObservableAcceptingInvitation(handler: InvitationProtocol, initialState: Invitation) {
|
|
248
|
-
const otpEnteredTrigger = new Trigger<string>();
|
|
249
|
-
const stream = new PushStream<Invitation>();
|
|
250
|
-
const ctx = new Context({
|
|
251
|
-
onError: (err) => {
|
|
252
|
-
if (err instanceof TimeoutError) {
|
|
253
|
-
log('timeout', { ...handler.toJSON() });
|
|
254
|
-
stream.next({ ...initialState, state: Invitation.State.TIMEOUT });
|
|
255
|
-
} else {
|
|
256
|
-
log.warn('auth failed', err);
|
|
257
|
-
stream.next({ ...initialState, state: Invitation.State.ERROR });
|
|
258
|
-
}
|
|
259
|
-
void ctx.dispose();
|
|
260
|
-
},
|
|
261
|
-
});
|
|
262
|
-
ctx.onDispose(() => {
|
|
263
|
-
log('complete', { ...handler.toJSON() });
|
|
264
|
-
stream.complete();
|
|
265
|
-
});
|
|
266
|
-
const invitation = new AuthenticatingInvitation({
|
|
267
|
-
initialInvitation: initialState,
|
|
268
|
-
subscriber: stream.observable,
|
|
269
|
-
onCancel: async () => {
|
|
270
|
-
stream.next({ ...initialState, state: Invitation.State.CANCELLED });
|
|
271
|
-
await ctx.dispose();
|
|
272
|
-
},
|
|
273
|
-
onAuthenticate: async (code: string) => {
|
|
274
|
-
// TODO(burdon): Reset creates a race condition? Event?
|
|
275
|
-
otpEnteredTrigger.wake(code);
|
|
276
|
-
},
|
|
277
|
-
});
|
|
278
|
-
return { ctx, invitation, stream, otpEnteredTrigger };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
242
|
private async _persistIfRequired(
|
|
282
243
|
handler: InvitationProtocol,
|
|
283
244
|
changeStream: PushStream<Invitation>,
|