@dxos/client-services 0.5.1-main.d514f85 → 0.5.1-main.d8ffef0
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-62C5DAOU.mjs → chunk-TDG7IYAA.mjs} +1296 -940
- package/dist/lib/browser/chunk-TDG7IYAA.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +31 -2
- 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 +29 -9
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-GGYOXDXO.cjs → chunk-SNX5TJ4Q.cjs} +1459 -1111
- package/dist/lib/node/chunk-SNX5TJ4Q.cjs.map +7 -0
- package/dist/lib/node/index.cjs +73 -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 +35 -15
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +2 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +39 -0
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/{invitation-extension.d.ts → invitation-host-extension.d.ts} +17 -31
- package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -0
- 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/invitation-topology.d.ts +37 -0
- package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts +19 -10
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +2 -0
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts +2 -1
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +1 -0
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/utils.d.ts +6 -0
- package/dist/types/src/packlets/invitations/utils.d.ts.map +1 -0
- package/dist/types/src/packlets/services/service-context.d.ts +8 -5
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
- package/dist/types/src/packlets/storage/level.d.ts +1 -2
- package/dist/types/src/packlets/storage/level.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts +2 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts +2 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/packlets/vault/shell-runtime.d.ts +10 -2
- 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 -35
- package/src/packlets/invitations/device-invitation-protocol.ts +5 -1
- package/src/packlets/invitations/invitation-guest-extenstion.ts +126 -0
- package/src/packlets/invitations/{invitation-extension.ts → invitation-host-extension.ts} +99 -105
- package/src/packlets/invitations/invitation-protocol.ts +7 -1
- package/src/packlets/invitations/invitation-topology.ts +87 -0
- package/src/packlets/invitations/invitations-handler.test.ts +361 -0
- package/src/packlets/invitations/invitations-handler.ts +246 -149
- package/src/packlets/invitations/invitations-manager.ts +42 -3
- package/src/packlets/invitations/space-invitation-protocol.ts +19 -1
- package/src/packlets/invitations/utils.ts +27 -0
- package/src/packlets/services/automerge-host.test.ts +3 -1
- package/src/packlets/services/service-context.ts +7 -6
- package/src/packlets/services/service-host.ts +3 -4
- package/src/packlets/spaces/data-space.ts +2 -1
- package/src/packlets/storage/level.ts +2 -2
- package/src/packlets/testing/invitation-utils.ts +23 -3
- package/src/packlets/testing/test-builder.ts +6 -3
- package/src/packlets/vault/shell-runtime.ts +40 -2
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-62C5DAOU.mjs.map +0 -7
- package/dist/lib/node/chunk-GGYOXDXO.cjs.map +0 -7
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +0 -1
|
@@ -2,31 +2,31 @@
|
|
|
2
2
|
// Copyright 2022 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
|
|
6
|
-
import {
|
|
7
|
-
import { Context } from '@dxos/context';
|
|
5
|
+
import { Mutex, type PushStream, scheduleTask, TimeoutError, type Trigger } from '@dxos/async';
|
|
6
|
+
import { INVITATION_TIMEOUT } from '@dxos/client-protocol';
|
|
7
|
+
import { type Context, ContextDisposedError } 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 {
|
|
13
|
-
createTeleportProtocolFactory,
|
|
14
|
-
type NetworkManager,
|
|
15
|
-
StarTopology,
|
|
16
|
-
type SwarmConnection,
|
|
17
|
-
} from '@dxos/network-manager';
|
|
12
|
+
import { createTeleportProtocolFactory, type NetworkManager, type SwarmConnection } from '@dxos/network-manager';
|
|
18
13
|
import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
|
|
19
14
|
import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
20
15
|
import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
21
16
|
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';
|
|
22
20
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
InvitationHostExtension,
|
|
26
|
-
isAuthenticationRequired,
|
|
27
|
-
MAX_OTP_ATTEMPTS,
|
|
28
|
-
} from './invitation-extension';
|
|
21
|
+
import { InvitationGuestExtension } from './invitation-guest-extenstion';
|
|
22
|
+
import { InvitationHostExtension, isAuthenticationRequired, MAX_OTP_ATTEMPTS } from './invitation-host-extension';
|
|
29
23
|
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,31 +35,35 @@ import { type InvitationProtocol } from './invitation-protocol';
|
|
|
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 challenge specified in the invitation.
|
|
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 or waits for more guests (multi use invitations).
|
|
41
41
|
*
|
|
42
42
|
* Guest
|
|
43
43
|
* - Joins the swarm with the topic.
|
|
44
44
|
* - Sends an introduction.
|
|
45
|
-
* -
|
|
45
|
+
* - Submits the challenge.
|
|
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
|
-
*
|
|
51
48
|
* ```
|
|
52
49
|
* [Guest] [Host]
|
|
53
50
|
* |------------------------------------Introduce-->|
|
|
54
51
|
* |-------------------------------[Authenticate]-->|
|
|
55
52
|
* |----------------------------------------Admit-->|
|
|
56
53
|
* ```
|
|
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
|
|
57
58
|
*/
|
|
58
59
|
export class InvitationsHandler {
|
|
59
60
|
/**
|
|
60
61
|
* @internal
|
|
61
62
|
*/
|
|
62
|
-
constructor(
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly _networkManager: NetworkManager,
|
|
65
|
+
private readonly _defaultTeleportParams?: Partial<TeleportParams>,
|
|
66
|
+
) {}
|
|
63
67
|
|
|
64
68
|
handleInvitationFlow(
|
|
65
69
|
ctx: Context,
|
|
@@ -67,18 +71,17 @@ export class InvitationsHandler {
|
|
|
67
71
|
protocol: InvitationProtocol,
|
|
68
72
|
invitation: Invitation,
|
|
69
73
|
): void {
|
|
74
|
+
const guardedState = this._createGuardedState(ctx, invitation, stream);
|
|
70
75
|
// Called for every connecting peer.
|
|
71
76
|
const createExtension = (): InvitationHostExtension => {
|
|
72
|
-
const extension = new InvitationHostExtension({
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
const extension = new InvitationHostExtension(guardedState.mutex, {
|
|
78
|
+
get activeInvitation() {
|
|
79
|
+
return ctx.disposed ? null : guardedState.current;
|
|
75
80
|
},
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
return invitation;
|
|
82
|
+
onStateUpdate: (newState: Invitation.State): Invitation => {
|
|
83
|
+
guardedState.set(extension, newState);
|
|
84
|
+
return guardedState.current;
|
|
82
85
|
},
|
|
83
86
|
|
|
84
87
|
admit: async (admissionRequest) => {
|
|
@@ -93,50 +96,62 @@ export class InvitationsHandler {
|
|
|
93
96
|
return admissionResponse;
|
|
94
97
|
} catch (err: any) {
|
|
95
98
|
// TODO(burdon): Generic RPC callback to report error to client.
|
|
96
|
-
|
|
99
|
+
guardedState.error(extension, err);
|
|
97
100
|
throw err; // Propagate error to guest.
|
|
98
101
|
}
|
|
99
102
|
},
|
|
100
103
|
|
|
101
|
-
onOpen: () => {
|
|
102
|
-
|
|
104
|
+
onOpen: (connectionCtx: Context, extensionsCtx: ExtensionContext) => {
|
|
105
|
+
let admitted = false;
|
|
106
|
+
connectionCtx.onDispose(() => {
|
|
107
|
+
if (!admitted) {
|
|
108
|
+
guardedState.error(extension, new ContextDisposedError());
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
scheduleTask(connectionCtx, async () => {
|
|
103
113
|
const traceId = PublicKey.random().toHex();
|
|
104
114
|
try {
|
|
105
115
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
|
|
106
116
|
log('connected', { ...protocol.toJSON() });
|
|
107
|
-
stream.next({ ...invitation, state: Invitation.State.CONNECTED });
|
|
108
117
|
const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
|
|
109
118
|
log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
|
|
110
|
-
|
|
119
|
+
guardedState.set(extension, Invitation.State.SUCCESS);
|
|
111
120
|
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
|
+
}
|
|
112
126
|
} catch (err: any) {
|
|
113
127
|
if (err instanceof TimeoutError) {
|
|
114
|
-
|
|
115
|
-
|
|
128
|
+
if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
|
|
129
|
+
log('timeout', { ...protocol.toJSON() });
|
|
130
|
+
}
|
|
116
131
|
} else {
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
if (guardedState.error(extension, err)) {
|
|
133
|
+
log.error('failed', err);
|
|
134
|
+
}
|
|
119
135
|
}
|
|
120
136
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// Wait for graceful close before disposing.
|
|
124
|
-
await swarmConnection.close();
|
|
125
|
-
await ctx.dispose();
|
|
126
|
-
}
|
|
137
|
+
// Close connection
|
|
138
|
+
extensionsCtx.close(err);
|
|
127
139
|
}
|
|
128
140
|
});
|
|
129
141
|
},
|
|
130
142
|
onError: (err) => {
|
|
131
143
|
if (err instanceof InvalidInvitationExtensionRoleError) {
|
|
144
|
+
log('invalid role', { ...err.context });
|
|
132
145
|
return;
|
|
133
146
|
}
|
|
134
147
|
if (err instanceof TimeoutError) {
|
|
135
|
-
|
|
136
|
-
|
|
148
|
+
if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
|
|
149
|
+
log('timeout', { err });
|
|
150
|
+
}
|
|
137
151
|
} else {
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
if (guardedState.error(extension, err)) {
|
|
153
|
+
log.error('failed', err);
|
|
154
|
+
}
|
|
140
155
|
}
|
|
141
156
|
},
|
|
142
157
|
});
|
|
@@ -153,7 +168,7 @@ export class InvitationsHandler {
|
|
|
153
168
|
async () => {
|
|
154
169
|
// ensure the swarm is closed before changing state and closing the stream.
|
|
155
170
|
await swarmConnection.close();
|
|
156
|
-
|
|
171
|
+
guardedState.set(null, Invitation.State.EXPIRED);
|
|
157
172
|
await ctx.dispose();
|
|
158
173
|
},
|
|
159
174
|
invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
|
|
@@ -162,100 +177,91 @@ export class InvitationsHandler {
|
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
let swarmConnection: SwarmConnection;
|
|
165
|
-
const invitationLabel =
|
|
166
|
-
'invitation host for ' +
|
|
167
|
-
(invitation.kind === Invitation.Kind.DEVICE ? 'device' : `space ${invitation.spaceKey?.truncate()}`);
|
|
168
180
|
scheduleTask(ctx, async () => {
|
|
169
|
-
|
|
170
|
-
|
|
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 });
|
|
181
|
+
swarmConnection = await this._joinSwarm(ctx, invitation, Options.Role.HOST, createExtension);
|
|
182
|
+
guardedState.set(null, Invitation.State.CONNECTING);
|
|
182
183
|
});
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
acceptInvitation(
|
|
187
|
+
ctx: Context,
|
|
188
|
+
stream: PushStream<Invitation>,
|
|
186
189
|
protocol: InvitationProtocol,
|
|
187
190
|
invitation: Invitation,
|
|
191
|
+
otpEnteredTrigger: Trigger<string>,
|
|
188
192
|
deviceProfile?: DeviceProfileDocument,
|
|
189
|
-
):
|
|
193
|
+
): void {
|
|
190
194
|
const { timeout = INVITATION_TIMEOUT } = invitation;
|
|
191
|
-
invariant(protocol);
|
|
192
195
|
|
|
193
196
|
if (deviceProfile) {
|
|
194
197
|
invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
195
198
|
}
|
|
196
|
-
const authenticated = new Trigger<string>();
|
|
197
199
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
let admitted = false;
|
|
200
|
+
const triedPeersIds = new ComplexSet(PublicKey.hash);
|
|
201
|
+
const guardedState = this._createGuardedState(ctx, invitation, stream);
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
const shouldCancelInvitationFlow = (extension: InvitationGuestExtension) => {
|
|
204
|
+
const isLockedByAnotherConnection = guardedState.mutex.isLocked() && !extension.hasFlowLock();
|
|
205
|
+
log('should cancel invitation flow', {
|
|
206
|
+
isLockedByAnotherConnection,
|
|
207
|
+
invitationType: Invitation.Type.DELEGATED,
|
|
208
|
+
triedPeers: triedPeersIds.size,
|
|
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;
|
|
208
216
|
};
|
|
209
217
|
|
|
210
|
-
|
|
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
|
-
});
|
|
222
|
-
|
|
223
|
-
ctx.onDispose(() => {
|
|
224
|
-
log('complete', { ...protocol.toJSON() });
|
|
225
|
-
stream.complete();
|
|
226
|
-
});
|
|
227
|
-
|
|
218
|
+
let admitted = false;
|
|
228
219
|
const createExtension = (): InvitationGuestExtension => {
|
|
229
|
-
|
|
220
|
+
const extension = new InvitationGuestExtension(guardedState.mutex, {
|
|
221
|
+
onStateUpdate: (newState: Invitation.State) => {
|
|
222
|
+
guardedState.set(extension, newState);
|
|
223
|
+
},
|
|
224
|
+
onOpen: (connectionCtx: Context, extensionCtx: ExtensionContext) => {
|
|
225
|
+
triedPeersIds.add(extensionCtx.remotePeerId);
|
|
226
|
+
|
|
227
|
+
if (admitted) {
|
|
228
|
+
extensionCtx.close();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
extensionCtx.onDispose(async () => {
|
|
234
|
-
log('extension disposed', { currentState });
|
|
232
|
+
connectionCtx.onDispose(async () => {
|
|
233
|
+
log('extension disposed', { admitted, currentState: guardedState.current.state });
|
|
235
234
|
if (!admitted) {
|
|
236
|
-
|
|
235
|
+
guardedState.error(extension, new ContextDisposedError());
|
|
236
|
+
if (shouldCancelInvitationFlow(extension)) {
|
|
237
|
+
await ctx.dispose();
|
|
238
|
+
}
|
|
237
239
|
}
|
|
238
240
|
});
|
|
239
241
|
|
|
240
|
-
scheduleTask(
|
|
242
|
+
scheduleTask(connectionCtx, async () => {
|
|
241
243
|
const traceId = PublicKey.random().toHex();
|
|
242
244
|
try {
|
|
243
245
|
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
|
-
}
|
|
248
246
|
|
|
249
|
-
scheduleTask(
|
|
247
|
+
scheduleTask(
|
|
248
|
+
connectionCtx,
|
|
249
|
+
() => {
|
|
250
|
+
guardedState.set(extension, Invitation.State.TIMEOUT);
|
|
251
|
+
extensionCtx.close();
|
|
252
|
+
},
|
|
253
|
+
timeout,
|
|
254
|
+
);
|
|
250
255
|
|
|
251
256
|
log('connected', { ...protocol.toJSON() });
|
|
252
|
-
|
|
257
|
+
guardedState.set(extension, Invitation.State.CONNECTED);
|
|
253
258
|
|
|
254
259
|
// 1. Introduce guest to host.
|
|
255
260
|
log('introduce', { ...protocol.toJSON() });
|
|
256
|
-
const introductionResponse = await extension.rpc.InvitationHostService.introduce(
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
const introductionResponse = await extension.rpc.InvitationHostService.introduce({
|
|
262
|
+
invitationId: invitation.invitationId,
|
|
263
|
+
...protocol.createIntroduction(),
|
|
264
|
+
});
|
|
259
265
|
log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
|
|
260
266
|
invitation.authMethod = introductionResponse.authMethod;
|
|
261
267
|
|
|
@@ -263,10 +269,20 @@ export class InvitationsHandler {
|
|
|
263
269
|
if (isAuthenticationRequired(invitation)) {
|
|
264
270
|
switch (invitation.authMethod) {
|
|
265
271
|
case Invitation.AuthMethod.SHARED_SECRET:
|
|
266
|
-
await this._handleGuestOtpAuth(
|
|
272
|
+
await this._handleGuestOtpAuth(
|
|
273
|
+
extension,
|
|
274
|
+
(state) => guardedState.set(extension, state),
|
|
275
|
+
otpEnteredTrigger,
|
|
276
|
+
{ timeout },
|
|
277
|
+
);
|
|
267
278
|
break;
|
|
268
279
|
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
|
|
269
|
-
await this._handleGuestKpkAuth(
|
|
280
|
+
await this._handleGuestKpkAuth(
|
|
281
|
+
extension,
|
|
282
|
+
(state) => guardedState.set(extension, state),
|
|
283
|
+
invitation,
|
|
284
|
+
introductionResponse,
|
|
285
|
+
);
|
|
270
286
|
break;
|
|
271
287
|
}
|
|
272
288
|
}
|
|
@@ -284,19 +300,22 @@ export class InvitationsHandler {
|
|
|
284
300
|
|
|
285
301
|
// 5. Success.
|
|
286
302
|
log('admitted by host', { ...protocol.toJSON() });
|
|
287
|
-
|
|
303
|
+
await guardedState.complete({
|
|
304
|
+
...guardedState.current,
|
|
305
|
+
...result,
|
|
306
|
+
state: Invitation.State.SUCCESS,
|
|
307
|
+
});
|
|
288
308
|
log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.end({ id: traceId }));
|
|
289
309
|
} catch (err: any) {
|
|
290
310
|
if (err instanceof TimeoutError) {
|
|
291
311
|
log('timeout', { ...protocol.toJSON() });
|
|
292
|
-
|
|
312
|
+
guardedState.set(extension, Invitation.State.TIMEOUT);
|
|
293
313
|
} else {
|
|
294
314
|
log('auth failed', err);
|
|
295
|
-
|
|
315
|
+
guardedState.error(extension, err);
|
|
296
316
|
}
|
|
317
|
+
extensionCtx.close(err);
|
|
297
318
|
log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.error({ id: traceId, error: err }));
|
|
298
|
-
} finally {
|
|
299
|
-
await ctx.dispose();
|
|
300
319
|
}
|
|
301
320
|
});
|
|
302
321
|
},
|
|
@@ -306,10 +325,10 @@ export class InvitationsHandler {
|
|
|
306
325
|
}
|
|
307
326
|
if (err instanceof TimeoutError) {
|
|
308
327
|
log('timeout', { ...protocol.toJSON() });
|
|
309
|
-
|
|
328
|
+
guardedState.set(extension, Invitation.State.TIMEOUT);
|
|
310
329
|
} else {
|
|
311
330
|
log('auth failed', err);
|
|
312
|
-
|
|
331
|
+
guardedState.error(extension, err);
|
|
313
332
|
}
|
|
314
333
|
},
|
|
315
334
|
});
|
|
@@ -321,53 +340,131 @@ export class InvitationsHandler {
|
|
|
321
340
|
const error = protocol.checkInvitation(invitation);
|
|
322
341
|
if (error) {
|
|
323
342
|
stream.error(error);
|
|
343
|
+
await ctx.dispose();
|
|
324
344
|
} else {
|
|
325
345
|
invariant(invitation.swarmKey);
|
|
326
|
-
|
|
327
|
-
|
|
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 });
|
|
346
|
+
await this._joinSwarm(ctx, invitation, Options.Role.GUEST, createExtension);
|
|
347
|
+
guardedState.set(null, Invitation.State.CONNECTING);
|
|
339
348
|
}
|
|
340
349
|
});
|
|
350
|
+
}
|
|
341
351
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
352
|
+
private async _joinSwarm(
|
|
353
|
+
ctx: Context,
|
|
354
|
+
invitation: Invitation,
|
|
355
|
+
role: Options.Role,
|
|
356
|
+
extensionFactory: () => TeleportExtension,
|
|
357
|
+
): Promise<SwarmConnection> {
|
|
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;
|
|
348
406
|
},
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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();
|
|
352
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;
|
|
422
|
+
},
|
|
423
|
+
error: (extension: InvitationExtension | null, error: any): boolean => {
|
|
424
|
+
if (isStateChangeAllowed(extension)) {
|
|
425
|
+
this._logStateUpdate(currentInvitation, extension, Invitation.State.ERROR);
|
|
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;
|
|
433
|
+
},
|
|
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),
|
|
353
442
|
});
|
|
443
|
+
}
|
|
354
444
|
|
|
355
|
-
|
|
445
|
+
private _isNotTerminal(currentState: Invitation.State): boolean {
|
|
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);
|
|
356
453
|
}
|
|
357
454
|
|
|
358
455
|
private async _handleGuestOtpAuth(
|
|
359
456
|
extension: InvitationGuestExtension,
|
|
360
|
-
setState: (newState:
|
|
457
|
+
setState: (newState: Invitation.State) => void,
|
|
361
458
|
authenticated: Trigger<string>,
|
|
362
459
|
options: { timeout: number },
|
|
363
460
|
) {
|
|
364
461
|
for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
|
|
365
462
|
log('guest waiting for authentication code...');
|
|
366
|
-
setState(
|
|
463
|
+
setState(Invitation.State.READY_FOR_AUTHENTICATION);
|
|
367
464
|
const authCode = await authenticated.wait(options);
|
|
368
465
|
|
|
369
466
|
log('sending authentication request');
|
|
370
|
-
setState(
|
|
467
|
+
setState(Invitation.State.AUTHENTICATING);
|
|
371
468
|
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
372
469
|
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
373
470
|
break;
|
|
@@ -386,7 +483,7 @@ export class InvitationsHandler {
|
|
|
386
483
|
|
|
387
484
|
private async _handleGuestKpkAuth(
|
|
388
485
|
extension: InvitationGuestExtension,
|
|
389
|
-
setState: (newState:
|
|
486
|
+
setState: (newState: Invitation.State) => void,
|
|
390
487
|
invitation: Invitation,
|
|
391
488
|
introductionResponse: IntroductionResponse,
|
|
392
489
|
) {
|
|
@@ -397,7 +494,7 @@ export class InvitationsHandler {
|
|
|
397
494
|
throw new Error('challenge missing in the introduction');
|
|
398
495
|
}
|
|
399
496
|
log('sending authentication request');
|
|
400
|
-
setState(
|
|
497
|
+
setState(Invitation.State.AUTHENTICATING);
|
|
401
498
|
const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
|
|
402
499
|
const response = await extension.rpc.InvitationHostService.authenticate({
|
|
403
500
|
signedChallenge: signature,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Event, PushStream } from '@dxos/async';
|
|
5
|
+
import { Event, PushStream, TimeoutError, Trigger } from '@dxos/async';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
AuthenticatingInvitation,
|
|
8
8
|
AUTHENTICATION_CODE_LENGTH,
|
|
9
9
|
CancellableInvitation,
|
|
10
10
|
INVITATION_TIMEOUT,
|
|
@@ -117,7 +117,8 @@ export class InvitationsManager {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const handler = this._getHandler(options);
|
|
120
|
-
const invitation = this.
|
|
120
|
+
const { ctx, invitation, stream, otpEnteredTrigger } = this._createObservableAcceptingInvitation(handler, options);
|
|
121
|
+
this._invitationsHandler.acceptInvitation(ctx, stream, handler, options, otpEnteredTrigger, request.deviceProfile);
|
|
121
122
|
this._acceptInvitations.set(invitation.get().invitationId, invitation);
|
|
122
123
|
this.invitationAccepted.emit(invitation.get());
|
|
123
124
|
|
|
@@ -149,6 +150,10 @@ export class InvitationsManager {
|
|
|
149
150
|
if (created.get().persistent) {
|
|
150
151
|
await this._metadataStore.removeInvitation(invitationId);
|
|
151
152
|
}
|
|
153
|
+
if (created.get().type === Invitation.Type.DELEGATED) {
|
|
154
|
+
const handler = this._getHandler(created.get());
|
|
155
|
+
await handler.cancelDelegation(created.get());
|
|
156
|
+
}
|
|
152
157
|
await created.cancel();
|
|
153
158
|
this._createInvitations.delete(invitationId);
|
|
154
159
|
this.removedCreated.emit(created.get());
|
|
@@ -239,6 +244,40 @@ export class InvitationsManager {
|
|
|
239
244
|
return { ctx, stream, observableInvitation };
|
|
240
245
|
}
|
|
241
246
|
|
|
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
|
+
|
|
242
281
|
private async _persistIfRequired(
|
|
243
282
|
handler: InvitationProtocol,
|
|
244
283
|
changeStream: PushStream<Invitation>,
|