├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
├── example
└── README.md
├── lib
├── main.dart
├── portals.dart
└── src
│ ├── close_reason.dart
│ ├── connections
│ ├── dilated_connection.dart
│ ├── mailbox_connection.dart
│ ├── mailbox_server_connection.dart
│ ├── peer_to_peer_connection.dart
│ ├── server_connection.dart
│ └── signal.dart
│ ├── constants.dart
│ ├── errors.dart
│ ├── events.dart
│ ├── phrase_generators
│ ├── hex.dart
│ ├── phrase_generator.dart
│ └── words.dart
│ ├── portal.dart
│ ├── spake2
│ ├── ed25519.dart
│ ├── hkdf.dart
│ ├── spake2.dart
│ └── utils.dart
│ └── utils.dart
├── pubspec.lock
├── pubspec.yaml
└── relation_to_magic_wormhole.md
/.gitignore:
--------------------------------------------------------------------------------
1 | local/
2 |
3 | .dart_tool/
4 | .packages
5 | .packages
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.0.9 – 2020-01-13
2 |
3 | - Add binary serializer: Now you can send anything through portals!
4 | - Updated readme. It now contains a "How it works" section.
5 | - Fix analysis issues. (A file wasn't saved yet and showed no errors in the editor.)
6 |
7 | ## 0.0.8 – 2020-01-02
8 |
9 | - Revise this changelog.
10 | - Revise readme.
11 | - Add `waitForPhrase` helper method.
12 | - More code reusage in helper methods.
13 | - Offer getter for `key`.
14 | - Implement `close` method.
15 |
16 | ## 0.0.7 – 2019-12-31
17 |
18 | - Export all the necessary stuff from the package: Not only the `Portal`, but also several phrase generators, events and errors.
19 | - Make it impossible for both sides to choose the same side id.
20 |
21 | ## 0.0.6 – 2019-12-31
22 |
23 | - Don't transfer `Version`s anymore, but rather more generic info `String`s. Users can still exchange versions, but also human-readable display names or something like that. It just makes portals more flexible and the API surface easier to understand.
24 | - Remove `version` dependency.
25 | - Rename *code* to *phrase*. The term *code generator* conflicts with actual Dart code generators.
26 | - The reversibility of the `PhraseGenerator` now gets verified whenever a phrase gets generated in debug mode.
27 | - Added several utility methods and functions to make the code more readable and succinct.
28 | - The default phrase generator is now the new `WordsPhraseGenerator`, which turns both the nameplate and the key into a string of human-readable words.
29 |
30 | ## 0.0.5 – 2019-12-29
31 |
32 | - Laxen dependencies on `collection` so the package can be used together with Flutter.
33 | - Fix some analysis issues.
34 |
35 | ## 0.0.4 – 2019-12-29
36 |
37 | - Laxen dependencies on `pedantic` so the package can be used together with Flutter.
38 | - Fix `version` parameter in readme.
39 | - Fix some analysis issues.
40 |
41 | ## 0.0.3 – 2019-12-29
42 |
43 | - Add boilerplate for example.
44 | - Fix some analysis issues.
45 |
46 | ## 0.0.2 – 2019-12-29
47 |
48 | - Add this changelog.
49 | - Make package description longer.
50 | - Point to correct GitHub repository.
51 | - Add pedantic analysis.
52 | - Clean up readme.
53 | - Fix some analysis issues.
54 |
55 | ## 0.0.1 – 2019-12-29
56 |
57 | - Initial version. You can connect portals on devices that can see each other and then transfer bytes.
58 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 Marcel Garus. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without modification,
4 | are permitted provided that the following conditions are met:
5 |
6 | * Redistributions of source code must retain the above copyright
7 | notice, this list of conditions and the following disclaimer.
8 | * Redistributions in binary form must reproduce the above
9 | copyright notice, this list of conditions and the following
10 | disclaimer in the documentation and/or other materials provided
11 | with the distribution.
12 | * The name of Marcel Garus may not be used to endorse or promote products
13 | derived from this software without specific prior written permission.
14 |
15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ⚠️ **This package is still in technical preview**.
2 | The API may change substantially in the future and it's not safe to use this package in production yet – several features like reconnecting when the network is lost, or using a transfer server if the two devices can't see each other still need to be implemented.
3 |
4 | ---
5 |
6 | Portals are strongly encrypted peer-to-peer connections.
7 | Inspired by [Magic Wormhole](https://github.com/warner/magic-wormhole/).
8 |
9 | TODO: Flutter web & app demo
10 |
11 | ## Features
12 |
13 | ❤️ **Easy to use:**
14 | Portals connect by letting users transcribe short human-readable codes from one device to another.
15 | TODO: There's a beautiful pre-built UI for Flutter.
16 |
17 | 🔒 **Secure:**
18 | Strong end-to-end encryption using Spake2 is built in.
19 | Man-in-the-middle attacks are virtually impossible because both sides share a secret from the beginning.
20 |
21 | ⚡ **Fast:**
22 | Data is compressed and transferred using peer-to-peer connections whenever possible.
23 | That makes portals incredibly fast when used on the same wifi or in the same geographic area.
24 |
25 | 🎈 **Lightweight:**
26 | There are no native dependencies and portals use lightweight standardized WebSockets to communicate.
27 | That also means, portals work anywhere where Dart runs: on mobile, desktop & the web.
28 |
29 | ## How to use
30 |
31 | ### Create a portal
32 |
33 | To connect two devices, you need to create a portal on each of them.
34 |
35 | ```dart
36 | var portal = Portal(appId: 'github.com/marcelgarus/portals');
37 | ```
38 |
39 | The `appId` can be any arbitrary string used only by your application.
40 | It's recommended to use a url.
41 |
42 |
43 | Optionally you can pass in an `info` string containing meta-information like your app version or something else. It will be exchanged as soon as the portals are linked.
44 |
45 | ```dart
46 | var portal = Portal(
47 | appId: 'github.com/marcelgarus/portals',
48 | info: json.encode({ 'app_version': '1.0.0', ... }),
49 | );
50 | // Later, when linked:
51 | print(portal.remoteInfo);
52 | ```
53 |
54 | ### Set up the portal
55 |
56 |
57 | 💙 Flutter
58 |
59 | There's a beautiful pre-built UI for Flutter that you can find nowhere yet.
60 | TODO
61 |
62 |
63 |
64 | 🖥️ Command line
65 |
66 | TODO
67 |
68 |
69 |
70 | 🎯 Pure Dart
71 |
72 | On the first device, open the portal. It will return a phrase that uniquely identifies it among other portals using your `appId`:
73 |
74 | ```dart
75 | String phrase = await portal.open();
76 | // TODO: Show the phrase to the user.
77 | String key = await portal.waitForLink();
78 | ```
79 |
80 | Let the user transcribe the `phrase` to the second user in the real world.
81 | The second user can then link the two portals:
82 |
83 | ```dart
84 | // TODO: Let the user enter the phrase.
85 | String key = await portal.openAndLinkTo(phrase);
86 | ```
87 |
88 | Now the two portals are linked.
89 | Optionally, you can let the users compare the `key` to completely rule out man-in-the-middle attacks.
90 |
91 | In the background, both clients try to establish a peer-to-peer connection to each other.
92 | Wait for it on both sides by calling:
93 |
94 | ```dart
95 | await portal.waitUntilReady();
96 | ```
97 |
98 |
99 | ### Send stuff
100 |
101 | Anything that goes into one of the two portals comes out the other.
102 |
103 | ```dart
104 | portal.send(something);
105 | var somethingElse = await portal.receive();
106 | ```
107 |
108 | All primitive types are supported by default, including `int`, `double`, `bool`, `List`, `Map`, `Set`, `Duration`, `DateTime`, `RegExp`, `StackTrace`, `Uint8List` and many more.
109 | Under the hood, the binary serializer is used – see its documentation for more information.
110 | Here's a quick summary:
111 |
112 | TODO: The following doesn't work yet – adapters still need to be written by hand.
113 |
114 | To send arbitrary Dart objects, annotate them with `@BinaryType()` and the fields with `@BinaryField(id)`:
115 |
116 | ```dart
117 | part 'my_file.g.dart';
118 |
119 | @BinaryType()
120 | class MyClass {
121 | @BinaryField(0, defaultValue: [])
122 | List someThings;
123 |
124 | @BinaryField(1)
125 | Duration duration;
126 | }
127 | ```
128 |
129 | Then, run `pub run build_runner build` in the command line to generate the `AdapterForMyClass`. Finally, register it at the beginning of your `main` method:
130 |
131 | ```dart
132 | AdapterForMyClass().registerWithId(0);
133 | ```
134 |
135 | Now, you can send `MyClass`es through portals!
136 |
137 | ## How it works
138 |
139 | Sadly, true peer-to-peer connection establishment is impossible to realize – if you're looking for another running portal, you can't just try talking to all the devices in the internet.
140 | Also, it's not even guaranteed that two devices see each other – they might be in different wifis which usually block incoming connection attempts.
141 | That's why a central public server is needed for connection establishment. It merely offers clients the feature to leave messages for each other, thus it's called the *mailbox server*.
142 | By default, portals use the mailbox server at `ws://relay.magic-wormhole.io:4000/v1`, but especially if you generate a lot of traffic, you're welcome to [run your own server](https://github.com/warner/magic-wormhole-mailbox-server).
143 |
144 | The mailbox server manages multiple communication channels between clients, intuitively called *mailboxes*.
145 | The first portal asks for a new mailbox and gets a unique id identifying the mailbox on the server for the client's app id.
146 | The mailbox id and a randomly generated shared key are converted into a human-readable phrase that's shown to the user.
147 | The second portal lets the user input the same phrase.
148 | It then extracts both the mailbox id as well as the shared key.
149 | After connecting to the mailbox server and requesting to join the mailbox with the given id, both portals talk to each other over the mailbox server.
150 |
151 | That's when the encryption phase begins – to make transcribing the phrase as easy as possible, the shared key is pretty small.
152 | For a strong encryption, both portals will need to agree on a much larger key.
153 | Here's how it works:
154 | Imagine you can multiply numbers easily, but dividing is really hard. (Actually, elements of the Edwards curve group are used instead of numbers and they have these properties.)
155 | Having the small shared key *s*, portals generate huge random private keys *m* and *n*.
156 | They calculate *M = s×m* and *N = s×n* and exchange *M* and *N*.
157 | Then they multiply these with their private keys – the first portal calculates *kₘ = m×N* and the second one *kₙ = n×M*. Because *kₘ = m×N = m×s×n = n×s×m = n×M = kₙ*, both keys are equal.
158 | An attacker not knowing *s* can only observe *M* and *N* being exchanged, but can't derive the resulting key, making man-in-the-middle-attacks virtually impossible.
159 |
160 | Now, clients can use the mailbox to exchange encrypted messages.
161 | They exchange their ip addresses and try to connect to the other client.
162 | For each succeeding connection, they exchange a short message to verify that whoever is connected knows the encryption key.
163 | The first connection where this encryption verification succeeds gets chosen.
164 | Now, both portals can directly talk over an encrypted peer-to-peer connection.
165 |
166 | ## How it relates to Magic Wormhole
167 |
168 | The interface to the mailbox server conforms to the Magic Wormhole protocol.
169 | The rest doesn't.
170 |
--------------------------------------------------------------------------------
/analysis_options.yaml:
--------------------------------------------------------------------------------
1 | include: package:pedantic/analysis_options.1.8.0.yaml
2 |
3 | analyzer:
4 | exclude: [build/**]
5 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | The [Number Guesser](../examples/number_guesser) example is still a work in progress.
2 |
--------------------------------------------------------------------------------
/lib/main.dart:
--------------------------------------------------------------------------------
1 | import 'dart:isolate';
2 |
3 | import 'package:meta/meta.dart';
4 |
5 | import 'portals.dart';
6 | import 'src/utils.dart';
7 |
8 | const appId = 'example.com';
9 |
10 | void main() {
11 | portal();
12 | }
13 |
14 | void portal() async {
15 | final portal = Portal(appId: appId);
16 | final phrase = await portal.open();
17 |
18 | print(phrase);
19 | // await Isolate.spawn(otherMain, phrase);
20 |
21 | final key = await portal.waitForLink();
22 | print('Portal linked using key ${key.toHex()}.');
23 |
24 | await portal.waitUntilReady();
25 | print(await portal.receive());
26 | print(await portal.receive());
27 | }
28 |
29 | void otherMain(String phrase) async {
30 | final portal = Portal(appId: appId);
31 | print('Connecting to portal $phrase');
32 |
33 | final key = await portal.openAndLinkTo(phrase);
34 | print('Portal linked using key ${key.toHex()}.');
35 |
36 | await portal.waitUntilReady();
37 | await portal.send('Hi there.');
38 | await Future.delayed(Duration(seconds: 1));
39 | await portal.send(MyClass(
40 | id: 'hello',
41 | someNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
42 | ));
43 | }
44 |
45 | class MyClass {
46 | MyClass({@required this.id, @required this.someNumbers});
47 |
48 | final String id;
49 | final List someNumbers;
50 | }
51 |
52 | class AdapterForMyClass extends TypeAdapter> {
53 | const AdapterForMyClass();
54 |
55 | @override
56 | void write(BinaryWriter writer, MyClass obj) {
57 | writer
58 | ..writeNumberOfFields(2)
59 | ..writeFieldId(0)
60 | ..write(obj.id)
61 | ..writeFieldId(1)
62 | ..write(obj.someNumbers);
63 | }
64 |
65 | @override
66 | MyClass read(BinaryReader reader) {
67 | final numberOfFields = reader.readNumberOfFields();
68 | final fields = {
69 | for (var i = 0; i < numberOfFields; i++)
70 | reader.readFieldId(): reader.read(),
71 | };
72 |
73 | return MyClass(
74 | id: fields[0],
75 | someNumbers: fields[1],
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/lib/portals.dart:
--------------------------------------------------------------------------------
1 | export 'package:binary/binary.dart';
2 |
3 | export 'src/portal.dart';
4 | export 'src/errors.dart';
5 | export 'src/events.dart';
6 | export 'src/phrase_generators/phrase_generator.dart';
7 | export 'src/phrase_generators/hex.dart';
8 | export 'src/phrase_generators/words.dart';
9 |
--------------------------------------------------------------------------------
/lib/src/close_reason.dart:
--------------------------------------------------------------------------------
1 | import 'package:web_socket_channel/status.dart';
2 |
3 | class CloseReason {
4 | static const _codeOffset = 1040;
5 |
6 | final int rawWebsocketCode;
7 | bool get hasCustomCode => rawWebsocketCode >= _codeOffset;
8 | int get code => hasCustomCode ? rawWebsocketCode - _codeOffset : null;
9 |
10 | final String reason;
11 |
12 | CloseReason._(this.rawWebsocketCode, this.reason);
13 |
14 | CloseReason.normal([String reason])
15 | : this._(normalClosure, reason ?? 'Goodbye!');
16 |
17 | CloseReason.invalidData()
18 | : this.error(invalidFramePayloadData, 'Invalid data');
19 |
20 | CloseReason.error(int code, String reason)
21 | : this._(code + _codeOffset, reason);
22 | }
23 |
--------------------------------------------------------------------------------
/lib/src/connections/dilated_connection.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:convert';
3 | import 'dart:io';
4 | import 'dart:typed_data';
5 |
6 | import 'package:meta/meta.dart';
7 | import 'package:pedantic/pedantic.dart';
8 |
9 | import 'mailbox_connection.dart';
10 | import 'peer_to_peer_connection.dart';
11 | import 'signal.dart';
12 |
13 | class DilatedConnection {
14 | DilatedConnection({@required this.mailbox}) : assert(mailbox != null);
15 |
16 | final MailboxConnection mailbox;
17 |
18 | bool _isLeader;
19 | PeerToPeerConnection _connection;
20 | final foundOutWhetherIsLeader = Signal();
21 | final connectionFound = Signal();
22 |
23 | Future _foundConnection(PeerToPeerConnection candidate) async {
24 | if (_connection != null) {
25 | await candidate.close();
26 | return;
27 | }
28 | if (_isLeader == null) {
29 | await foundOutWhetherIsLeader.waitForSignal();
30 | }
31 | if (_isLeader) {
32 | _connection = candidate;
33 | _connection.send([42]);
34 | connectionFound.signal();
35 | } else {
36 | try {
37 | final response = await candidate.receive();
38 | assert(response.single == 42); // TODO: error handling
39 | _connection = candidate;
40 | connectionFound.signal();
41 | } on StateError {
42 | await candidate.close();
43 | }
44 | }
45 | }
46 |
47 | Future establishConnection() async {
48 | final serverAddresses = {
49 | if (NetworkInterface.listSupported)
50 | for (final interface in await NetworkInterface.list())
51 | for (final address in interface.addresses) address,
52 | };
53 | final servers = [
54 | for (final address in serverAddresses)
55 | await startServer(address, onConnected: (Socket socket) async {
56 | // print('Someone connected to our server at ${socket.port}');
57 | await _foundConnection(await PeerToPeerConnection.establish(
58 | socket: socket,
59 | key: mailbox.key,
60 | ));
61 | }),
62 | ];
63 |
64 | // Send information about the servers to the other portal so that it can
65 | // try to connect to them.
66 | final side = mailbox.side;
67 | mailbox.send(
68 | phase: 'dilate',
69 | message: json.encode({
70 | 'side': side,
71 | 'connection-hints': [
72 | for (final server in servers)
73 | {'address': server.address.address, 'port': server.port},
74 | ],
75 | }),
76 | );
77 |
78 | // Receive the server information from the other portal and connect to all
79 | // of them.
80 | final response = json.decode(await mailbox.receive(phase: 'dilate'));
81 | _isLeader = (response['side'] as String).compareTo(side) < 0;
82 | foundOutWhetherIsLeader.signal();
83 | final serversFromOtherPortal = response['connection-hints'];
84 |
85 | for (final server in serversFromOtherPortal) {
86 | final delay = server.containsKey('delay')
87 | ? Duration(milliseconds: int.parse(server['delay']))
88 | : Duration.zero;
89 | Future.delayed(delay, () async {
90 | await _foundConnection(await PeerToPeerConnection.establish(
91 | socket: await Socket.connect(server['address'], server['port']),
92 | key: mailbox.key,
93 | ));
94 | });
95 | }
96 |
97 | await connectionFound.waitForSignal();
98 | // print('Using connection $_connection with ip '
99 | // '${_connection.socket.address.address} from ${_connection.socket.port} '
100 | // 'to ${_connection.socket.remotePort}.');
101 | }
102 |
103 | static Future startServer(
104 | InternetAddress ip, {
105 | @required void Function(Socket socket) onConnected,
106 | }) async {
107 | final server = await ServerSocket.bind(ip, 0);
108 | unawaited(server.first.then(onConnected));
109 | return server;
110 | }
111 |
112 | Future _ensureConnectionEstablished() async {
113 | // TODO: make sure connection is established
114 | if (_connection == null || false) {
115 | _connection = null;
116 | await establishConnection();
117 | }
118 | }
119 |
120 | Future send(Uint8List message) async {
121 | await _ensureConnectionEstablished();
122 | await _connection.send(message);
123 | }
124 |
125 | Future receive() async {
126 | await _ensureConnectionEstablished();
127 | return await _connection.receive();
128 | }
129 |
130 | void close() {
131 | _connection.close();
132 | _connection = null;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/lib/src/connections/mailbox_connection.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:typed_data';
3 |
4 | import 'package:meta/meta.dart';
5 | import 'package:pinenacl/secret.dart';
6 |
7 | import '../errors.dart';
8 | import '../spake2/ed25519.dart';
9 | import '../spake2/hkdf.dart';
10 | import '../spake2/spake2.dart';
11 | import '../utils.dart';
12 | import 'mailbox_server_connection.dart';
13 |
14 | /// An encrypted connection over a mailbox.
15 | ///
16 | /// Clients connected to the same mailbox can talk to each other. This layer
17 | /// provides an abstraction on top to enable clients to negotiate a shared key
18 | /// and from there on send each other end-to-end encrypted messages.
19 | /// Spake2 allows this to happen: Both clients independently provide Spake2
20 | /// with a short shared secret key. Spake2 then generates a random internal
21 | /// Ed25519 point and returns a message that clients exchange and feed into
22 | /// their instance of the Spake2 algorithm. The result will be that both
23 | /// client's Spake2 algorithms deterministically generate the same long shared
24 | /// super-secure key that can be used for further end-to-end encryption.
25 | class MailboxConnection {
26 | MailboxConnection({
27 | @required this.server,
28 | @required this.shortKey,
29 | }) : assert(server != null),
30 | assert(shortKey != null);
31 |
32 | final MailboxServerConnection server;
33 | final Uint8List shortKey;
34 |
35 | String get side => server.side;
36 |
37 | Uint8List _key;
38 | Uint8List get key => _key;
39 |
40 | Future initialize() async {
41 | // Start the spake2 encryption process.
42 | final spake = Spake2(id: server.appId.utf8encoded, password: shortKey);
43 | final outbound = await spake.start();
44 |
45 | // Exchange secrets with the other portal.
46 | await server.sendMessage(
47 | phase: 'pake',
48 | message: json.encode({'pake_v1': outbound.toHex()}),
49 | );
50 | Map inboundMessage;
51 | try {
52 | inboundMessage = json.decode(
53 | (await server.receiveMessage(phase: 'pake'))['body'],
54 | );
55 | } on TypeError {
56 | throw OtherPortalCorruptException(
57 | 'The other portal sent a pake message without a body.');
58 | }
59 |
60 | // Finish the spake2 encryption process.
61 | try {
62 | final inboundBytes = Bytes.fromHex(inboundMessage['pake_v1']);
63 | _key = await spake.finish(inboundBytes);
64 | } on HkdfException {
65 | throw PortalEncryptionFailedException();
66 | } on Ed25519Exception {
67 | throw PortalEncryptionFailedException();
68 | }
69 | }
70 |
71 | /// Exchanges the info of this and the other portal. Infos are user defined
72 | /// and typically contain meta-information, like understood protocols or a
73 | /// human-readable name of each side.
74 | Future exchangeInfo(String myInfo) async {
75 | send(phase: 'versions', message: myInfo);
76 | final response = await receive(phase: 'versions');
77 |
78 | // We now have a confirmed secured connection with the other portal.
79 | // Release the nameplate for the mailbox so other portals can use it.
80 | server.releaseNameplate();
81 |
82 | // Return the response.
83 | return response;
84 | }
85 |
86 | /// Only messages from the same side and phase share a key.
87 | Uint8List _derivePhaseKey(String side, String phase) {
88 | final sideHash = sha256(ascii.encode(side)).toHex();
89 | final phaseHash = sha256(ascii.encode(phase)).toHex();
90 | final purpose = 'wormhole:phase:$sideHash$phaseHash';
91 | try {
92 | return Hkdf(null, _key)
93 | .expand(ascii.encode(purpose), length: SecretBox.keyLength);
94 | } on HkdfException {
95 | throw PortalEncryptionFailedException();
96 | }
97 | }
98 |
99 | void send({@required String phase, @required String message}) {
100 | // Encrypt and encode the message.
101 | final encrypted = SecretBox(_derivePhaseKey(server.side, phase))
102 | .encrypt(message.utf8encoded);
103 | server.sendMessage(phase: phase, message: encrypted.toHex());
104 | }
105 |
106 | Future receive({String phase}) async {
107 | // Receive an encrypted message from the other side and extract side and
108 | // body.
109 | final message = await server.receiveMessage(phase: phase);
110 | String side, body;
111 | try {
112 | side = message['side'];
113 | body = message['body'];
114 | } on TypeError {
115 | // TODO: non-set values in map are null and don't throw an error.
116 | throw OtherPortalCorruptException(
117 | 'Other portal sent a message without a side, phase or body.');
118 | }
119 |
120 | // Decode and decrypt the message.
121 | final decoded = Bytes.fromHex(body);
122 | try {
123 | final decrypted = SecretBox(_derivePhaseKey(side, phase))
124 | .decrypt(EncryptedMessage.fromList(decoded));
125 | return utf8.decode(decrypted);
126 | } on String {
127 | throw PortalEncryptionFailedException();
128 | }
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/lib/src/connections/mailbox_server_connection.dart:
--------------------------------------------------------------------------------
1 | import 'package:meta/meta.dart';
2 | import 'package:portals/src/spake2/utils.dart';
3 |
4 | import '../errors.dart';
5 | import 'server_connection.dart';
6 |
7 | /// A connection to a mailbox server.
8 | ///
9 | /// The server doesn't really do anything except offering "mailboxes", which
10 | /// clients can connect to. Mailboxes are identified using large ids. Clients
11 | /// can send messages to a mailbox, which will then get sent to everyone
12 | /// connected to that mailbox.
13 | /// In order to make the communication between the clients easier, there are
14 | /// nameplates. Nameplates are short strings that point to a single mailbox and
15 | /// can be claimed and released by clients.
16 | /// Typically, one client allocates and claims a nameplate. Then, the nameplate
17 | /// is transcribed to the second client, which also claims the nameplate to
18 | /// retrieve the id of the connected mailbox. Now, both clients can connect to
19 | /// the mailbox using the large id. Also, they can release the nameplate to
20 | /// allow other clients to reuse the nameplate for another mailbox.
21 | ///
22 | /// This class wraps a regular [ServerConnection] to add some utility methods
23 | /// for allocating, claiming and releasing nameplates, opening and closing
24 | /// mailboxes and sending and receiving messages.
25 | class MailboxServerConnection {
26 | MailboxServerConnection({
27 | @required this.server,
28 | @required this.appId,
29 | }) : assert(appId != null),
30 | assert(appId.isNotEmpty);
31 |
32 | final ServerConnection server;
33 | final String appId;
34 |
35 | /// Each client has a [side]. By default, we receive everything sent to the
36 | /// mailbox. We can use the side to filter out the messages that come from
37 | /// clients other than us.
38 | String _side;
39 | String get side => _side;
40 |
41 | void initialize({@required bool isFirstPortal}) {
42 | // If two clients have the same side, that's bad – they'll just ignore
43 | // everything. So, we choose a random two-byte string as our side that
44 | // contains a special ending based on whether this is the first portal.
45 | // That makes it impossible for two connecting portals to have the same
46 | // side.
47 | _side = [
48 | ...Bytes.generateRandom(1),
49 | isFirstPortal ? 42 : 43,
50 | ].toHex();
51 | }
52 |
53 | /// Binds this socket to the server by providing an app id and a side id,
54 | /// which we choose randomly.
55 | Future bindAndWelcome() async {
56 | assert(server.isConnected);
57 |
58 | server.send({'type': 'bind', 'appid': appId, 'side': _side});
59 |
60 | // Receive the welcome message.
61 | try {
62 | final welcomeMessage = await server.receive(type: 'welcome');
63 | final welcome = welcomeMessage['welcome'] as Map;
64 |
65 | // The welcome message can optionally contain an error message. If we
66 | // get one, we should terminate.
67 | if (welcome.containsKey('error')) {
68 | throw PortalInternalServerErrorException(welcome['error']);
69 | }
70 |
71 | // The welcome message can optionally contain a "motd" message with
72 | // information for developers, like notifications about performance
73 | // problems, scheduled downtime or the need for money donations to keep
74 | // the server running.
75 | assert(() {
76 | if (welcome.containsKey('motd')) {
77 | print('The mailbox server at ${server.url} sent the following '
78 | 'message:\n${welcome['motd']}');
79 | }
80 | return true;
81 | }());
82 | } on TypeError {
83 | throw PortalServerCorruptException(
84 | "The server's first packet didn't include a welcome message.");
85 | }
86 | }
87 |
88 | /// Allocates a new nameplate.
89 | Future allocateNameplate() async {
90 | assert(server.isConnected);
91 |
92 | server.send({'type': 'allocate'});
93 |
94 | final allocation = await server.receive(type: 'allocated');
95 | String nameplate;
96 | try {
97 | nameplate = allocation['nameplate'] as String;
98 | } on CastError {
99 | throw PortalServerCorruptException(
100 | 'The nameplate that the server responded with was not a string.');
101 | }
102 | if (nameplate == null) {
103 | throw PortalServerCorruptException(
104 | "The packet confirming the nameplate allocation didn't contain "
105 | 'the allocated nameplate.');
106 | }
107 | return nameplate;
108 | }
109 |
110 | /// Claims a nameplate, which means that the nameplate will stay attached to
111 | /// its mailbox until we release it.
112 | Future claimNameplate(String nameplate) async {
113 | assert(server.isConnected);
114 |
115 | server.send({'type': 'claim', 'nameplate': nameplate});
116 |
117 | final claim = await server.receive(type: 'claimed');
118 | String mailbox;
119 | try {
120 | mailbox = claim['mailbox'] as String;
121 | } on CastError {
122 | throw PortalServerCorruptException(
123 | 'The mailbox id that the server responded with was not a string.');
124 | }
125 | if (mailbox == null) {
126 | throw PortalServerCorruptException(
127 | "The packet confirming the claim of the nameplate didn't contain "
128 | 'the id of the mailbox that the nameplate points to.');
129 | }
130 | return mailbox;
131 | }
132 |
133 | /// Releases our nameplate.
134 | void releaseNameplate() {
135 | assert(server.isConnected);
136 |
137 | server.send({'type': 'release'});
138 | }
139 |
140 | /// Opens the mailbox attached to our nameplate.
141 | void openMailbox(String mailbox) {
142 | assert(server.isConnected);
143 | assert(mailbox != null);
144 |
145 | server.send({'type': 'open', 'mailbox': mailbox});
146 | }
147 |
148 | /// Closes the mailbox.
149 | void closeMailbox(Mood mood) {
150 | assert(mood != null);
151 |
152 | server.send({'type': 'close', 'mood': mood.toMoodString()});
153 | }
154 |
155 | /// Sends a message to the opened mailbox.
156 | void sendMessage({@required String phase, @required String message}) async {
157 | assert(server.isConnected);
158 | assert(phase != null);
159 | assert(message != null);
160 |
161 | server.send({'type': 'add', 'phase': phase, 'body': message});
162 | }
163 |
164 | /// Receive a message with the given [phase].
165 | Future