├── .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> receiveMessage({@required String phase}) async { 166 | while (true) { 167 | final response = await server.receive(type: 'message'); 168 | 169 | if (response['side'] == _side) { 170 | continue; 171 | } 172 | if (phase == null || response['phase'] == phase) { 173 | return response; 174 | } 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /lib/src/connections/peer_to_peer_connection.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:async/async.dart'; 5 | import 'package:collection/collection.dart'; 6 | import 'package:meta/meta.dart'; 7 | import 'package:pinenacl/secret.dart'; 8 | 9 | import '../utils.dart'; 10 | 11 | class PeerToPeerConnection { 12 | PeerToPeerConnection({@required this.socket, @required this.key}) 13 | : _incomingData = StreamQueue(socket); 14 | 15 | static Future establish({ 16 | @required Socket socket, 17 | @required Uint8List key, 18 | }) async { 19 | // print('Connection from ${socket.address.address}:${socket.port} to ' 20 | // '${socket.remoteAddress.address}:${socket.remotePort}'); 21 | final connection = PeerToPeerConnection(socket: socket, key: key); 22 | await connection.ensureEncryptionAndMeasureLatency(); 23 | return connection; 24 | } 25 | 26 | final Socket socket; 27 | final Uint8List key; 28 | final StreamQueue _incomingData; 29 | 30 | Future ensureEncryptionAndMeasureLatency() async { 31 | // Exchange messages containing random bytes. 32 | final randomBytes = Bytes.generateRandom(32); 33 | send(randomBytes); 34 | final otherRandomBytes = await receive(); 35 | 36 | // Exchange the other side's reversed random bytes. 37 | send(otherRandomBytes.reversed.toBytes()); 38 | final reversedBytes = await receive(); 39 | 40 | if (!DeepCollectionEquality().equals(randomBytes.reversed, reversedBytes)) { 41 | throw Exception('Other side didn\'t encrypt content using the same key ' 42 | 'as we. That is scary.'); 43 | } 44 | } 45 | 46 | void send(List message) { 47 | final encrypted = SecretBox(key).encrypt(message).toBytes(); 48 | print('Sending encrypted $encrypted'); 49 | socket.add(encrypted); 50 | } 51 | 52 | Future receive() async { 53 | final encrypted = await _incomingData.next; 54 | print('Received encrypted $encrypted'); 55 | return SecretBox(key).decrypt(EncryptedMessage.fromList(encrypted)); 56 | } 57 | 58 | Future close() => socket.close(); 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/connections/server_connection.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:async/async.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:portals/src/close_reason.dart'; 6 | import 'package:web_socket_channel/io.dart'; 7 | import 'package:web_socket_channel/web_socket_channel.dart'; 8 | 9 | import '../errors.dart'; 10 | 11 | /// A simple connection to the server. 12 | /// 13 | /// Initially, the portal connects to a server. Portals use this server to 14 | /// negotiate an end-to-end encrypted connection and exchange ip address 15 | /// inforamtion in order to be able to create a direct peer-to-peer connection. 16 | /// Portals use the Magic Wormhole protocol for communicating, so if you're 17 | /// wondering how the server works or you want to run your own server, check 18 | /// out the Magic Wormhole server repository: 19 | /// https://github.com/warner/magic-wormhole-mailbox-server 20 | class ServerConnection { 21 | ServerConnection({@required this.url}) 22 | : assert(url != null), 23 | assert(url.isNotEmpty); 24 | 25 | final String url; 26 | 27 | IOWebSocketChannel _server; 28 | bool get isConnected => _server != null; 29 | 30 | StreamQueue _incomingPackets; 31 | 32 | /// Connects to the server. 33 | Future connect() async { 34 | try { 35 | _server = IOWebSocketChannel.connect( 36 | url, 37 | pingInterval: Duration(minutes: 1), 38 | ); 39 | _incomingPackets = StreamQueue(_server.stream.cast()); 40 | } on WebSocketChannelException { 41 | throw PortalCannotConnectToServerException(url); 42 | } on FormatException { 43 | await close(CloseReason.invalidData()); 44 | throw PortalServerCorruptException('Portal sent a non-json packet.'); 45 | } 46 | } 47 | 48 | Future _onClosed() async { 49 | assert(_server != null); 50 | 51 | /*if (_server.closeCode != CloseReason.normal().rawWebsocketCode) { 52 | throw TODO: reconnect 53 | }*/ 54 | } 55 | 56 | /// Closes the connection to the server. 57 | Future close(CloseReason reason) async { 58 | await _server.sink.close(reason.rawWebsocketCode, reason.reason); 59 | } 60 | 61 | /// Sends a packet with the given [data] to the server. 62 | void send(Map data) { 63 | assert(data != null); 64 | _server.sink.add(json.encode(data)); 65 | } 66 | 67 | /// Receives a packet with the given [type] from the server. 68 | Future> receive({@required String type}) async { 69 | assert(type != null); 70 | assert(type.isNotEmpty); 71 | 72 | try { 73 | while (true) { 74 | // TODO: handle StateError: Bad state: No elements. 75 | final data = 76 | json.decode(await _incomingPackets.next) as Map; 77 | if (data['type'] == type) { 78 | return data; 79 | } 80 | } 81 | } on FormatException { 82 | throw PortalServerCorruptException('Portal sent a non-json packet.'); 83 | } on TypeError { 84 | await close(CloseReason.invalidData()); 85 | throw PortalServerCorruptException( 86 | 'The server sent a packet without a type.'); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/src/connections/signal.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:collection'; 3 | 4 | class Signal { 5 | final _waitQueue = Queue>(); 6 | 7 | Future waitForSignal() { 8 | final completer = Completer(); 9 | _waitQueue.add(completer); 10 | return completer.future; 11 | } 12 | 13 | void signal() { 14 | for (final completer in _waitQueue) { 15 | completer.complete(); 16 | } 17 | _waitQueue.clear(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/constants.dart: -------------------------------------------------------------------------------- 1 | import 'phrase_generators/words.dart'; 2 | 3 | const defaultMailboxServerUrl = 'ws://relay.magic-wormhole.io:4000/v1'; 4 | const defaultCodeGenerator = WordsPhraseGenerator(); 5 | -------------------------------------------------------------------------------- /lib/src/errors.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | import 'package:portals/src/close_reason.dart'; 3 | 4 | enum Mood { lonely, errorly, scared, happy } 5 | 6 | extension MoodToString on Mood { 7 | String toMoodString() { 8 | switch (this) { 9 | case Mood.lonely: 10 | return 'lonely'; 11 | case Mood.errorly: 12 | return 'errorly'; 13 | case Mood.scared: 14 | return 'scared'; 15 | case Mood.happy: 16 | return 'happy'; 17 | default: 18 | throw AssertionError('Unknown mood $this'); 19 | } 20 | } 21 | } 22 | 23 | class PortalException implements Exception { 24 | PortalException({ 25 | @required this.summary, 26 | @required this.description, 27 | @required this.suggestedFix, 28 | }); 29 | 30 | final String summary; 31 | final String description; 32 | final String suggestedFix; 33 | 34 | String get _summary => '$summary\n\n'; 35 | 36 | String get _description => '$description\n\n'; 37 | 38 | String get _suggestedFix => '$suggestedFix\n'; 39 | 40 | @override 41 | String toString() { 42 | return [ 43 | _summary, 44 | _description, 45 | _suggestedFix, 46 | ].join(); 47 | } 48 | } 49 | 50 | class PortalCannotConnectToServerException extends PortalException { 51 | PortalCannotConnectToServerException(String url) 52 | : super( 53 | summary: 'The portal cannot connect to the server.', 54 | description: 'Initially, the portal connects to a server. Portals ' 55 | 'use this server to negotiate an end-to-end encrypted ' 56 | 'connection and exchange ip address information in order to be ' 57 | 'able to create a peer-to-peer connection.\n' 58 | 'However, this portal can\'t connect to the server.', 59 | suggestedFix: 'First, make sure you have access to the internet. ' 60 | "The server that\'s used is hosted at $url. " 61 | 'Check if you can reach it manually.\n' 62 | 'For more reliable connections under big loads, consider ' 63 | 'running your own server. Portals use the Magic Wormhole ' 64 | 'protocol, so running a wormhole server as described at ' 65 | 'https://github.com/warner/magic-wormhole-mailbox-server ' 66 | 'should be sufficient.', 67 | ); 68 | } 69 | 70 | class PortalInternalServerErrorException extends PortalException { 71 | PortalInternalServerErrorException(dynamic error) 72 | : super( 73 | summary: 'The server notified us of a server-side error while we ' 74 | 'were connecting.', 75 | description: error, 76 | suggestedFix: 'For a more reliable server, consider running your ' 77 | 'own server as described at ' 78 | 'https://github.com/warner/magic-wormhole-mailbox-server.', 79 | ); 80 | } 81 | 82 | class PortalServerCorruptException extends PortalException { 83 | PortalServerCorruptException(String description) 84 | : super( 85 | summary: 'The server seems to be corrupt.', 86 | description: description, 87 | suggestedFix: 'For a more reliable server, consider running your ' 88 | 'own server as described at ' 89 | 'https://github.com/warner/magic-wormhole-mailbox-server.', 90 | ); 91 | } 92 | 93 | class OtherPortalCorruptException extends PortalException { 94 | OtherPortalCorruptException(String description) 95 | : super( 96 | summary: 'The other portal seems to be corrupt.', 97 | description: description, 98 | suggestedFix: '', 99 | ); 100 | } 101 | 102 | class PortalEncryptionFailedException extends PortalException { 103 | PortalEncryptionFailedException() 104 | : super( 105 | summary: 'The encryption for this portal failed.', 106 | description: 'During linking, portals try to negotiate a shared ' 107 | 'encryption key to use for further end-to-end encryption. That ' 108 | 'failed. Possibly someone tried to interfere.', 109 | suggestedFix: 'You could try again, thereby giving both the other ' 110 | 'legitimate portal and a possible attacker another chance.', 111 | ); 112 | } 113 | 114 | class PortalClosedAbnormallyException extends PortalException { 115 | final CloseReason reason; 116 | 117 | PortalClosedAbnormallyException(this.reason) 118 | : super( 119 | summary: 'The portal closed abnormally.', 120 | description: 121 | 'The other side closed the connection with the error code ' 122 | '${reason.rawWebsocketCode} saying "${reason.reason}".', 123 | suggestedFix: 'Handle the error gracefully or ', 124 | ); 125 | } 126 | 127 | /* 128 | ┅┅┅┅┅┅┅┅┅ PORTAL ERROR ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅ 129 | The following error occurred while opening a portal: 130 | The code generator is non-reversible. 131 | 132 | The code generator MyFancyCodeGenerator was asked to generate a code for the 133 | following input: 134 | nameplate: [21] 135 | key: [12, 21, 32, 34, 90, 0, 21, 21, ..., 21, 21, 43, 54, 11, 98, 8] 136 | It responded with the code "hello-there". 137 | However, when being asked to decode that code back into a nameplate and key, 138 | it responded with the following: 139 | nameplate: [21] 140 | key: [12, 21, 32, 34, 91, 0, 21, 21, ..., 21, 21, 43, 54, 11, 98, 8] 141 | Note that the key differs at the byte at position 4. 142 | 143 | Make sure that when letting the code generator generate a code for a given 144 | nameplate and key, it produces the same nameplate and key when given the 145 | generated code. 146 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅ 147 | */ 148 | -------------------------------------------------------------------------------- /lib/src/events.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | abstract class PortalEvent {} 6 | 7 | class PortalOpening extends PortalEvent {} 8 | 9 | class PortalServerReached extends PortalEvent {} 10 | 11 | class PortalOpened extends PortalEvent { 12 | PortalOpened({@required this.phrase}); 13 | 14 | final String phrase; 15 | } 16 | 17 | class PortalClosed extends PortalEvent {} 18 | 19 | class PortalLinked extends PortalEvent { 20 | PortalLinked({@required this.key}); 21 | 22 | final Uint8List key; 23 | } 24 | 25 | class PortalUnlinked extends PortalEvent {} 26 | 27 | class PortalConnecting extends PortalEvent {} 28 | 29 | class PortalConnected extends PortalEvent {} 30 | 31 | class PortalDataReceived extends PortalEvent {} 32 | -------------------------------------------------------------------------------- /lib/src/phrase_generators/hex.dart: -------------------------------------------------------------------------------- 1 | import '../utils.dart'; 2 | import 'phrase_generator.dart'; 3 | 4 | class HexPhraseGenerator implements PhraseGenerator { 5 | const HexPhraseGenerator(); 6 | 7 | @override 8 | PhrasePayload phraseToPayload(String phrase) { 9 | final dash = phrase.indexOf('-'); 10 | return PhrasePayload( 11 | nameplate: Bytes.fromHex(phrase.substring(0, dash)), 12 | key: Bytes.fromHex(phrase.substring(dash + 1)), 13 | ); 14 | } 15 | 16 | @override 17 | String payloadToPhrase(PhrasePayload payload) => 18 | '${payload.nameplate.toHex()}-${payload.key.toHex()}'; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/phrase_generators/phrase_generator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:collection/collection.dart'; 4 | import 'package:meta/meta.dart'; 5 | 6 | import '../utils.dart'; 7 | 8 | @immutable 9 | class PhrasePayload { 10 | PhrasePayload({@required this.nameplate, @required this.key}) 11 | : assert(nameplate != null), 12 | assert(nameplate.isNotEmpty), 13 | assert(key != null), 14 | assert(key.length == keyLength); 15 | 16 | static const keyLength = 2; 17 | 18 | final Uint8List nameplate; 19 | final Uint8List key; 20 | } 21 | 22 | abstract class PhraseGenerator { 23 | static Uint8List generateShortKey() => 24 | Bytes.generateRandom(PhrasePayload.keyLength); 25 | 26 | static void ensureGeneratorReversible({ 27 | @required PhraseGenerator generator, 28 | @required PhrasePayload payload, 29 | @required String generatedPhrase, 30 | }) { 31 | assert(generator != null); 32 | assert(payload != null); 33 | assert(generatedPhrase != null); 34 | 35 | final recreatedPayload = generator.phraseToPayload(generatedPhrase); 36 | final keyFits = 37 | DeepCollectionEquality().equals(payload.key, recreatedPayload.key); 38 | final nameplateFits = DeepCollectionEquality() 39 | .equals(payload.nameplate, recreatedPayload.nameplate); 40 | 41 | if (!keyFits || !nameplateFits) { 42 | throw AssertionError('The phrase generator is non-reversible.\n' 43 | 'It generated the phrase $generatedPhrase for the key ' 44 | '${payload.key} and the nameplate ${payload.nameplate}, but when ' 45 | 'asked to convert that phrase into the original key and nameplate, ' 46 | 'it returned the key ${recreatedPayload.key} and the nameplate ' 47 | '${recreatedPayload.nameplate}.' 48 | 'Note that ${(!keyFits && !nameplateFits) ? 'both' : keyFits ? 'the nameplates' : 'the keys'}' 49 | 'differ.'); 50 | } 51 | } 52 | 53 | String payloadToPhrase(PhrasePayload payload); 54 | PhrasePayload phraseToPayload(String phrase); 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/phrase_generators/words.dart: -------------------------------------------------------------------------------- 1 | import '../utils.dart'; 2 | import 'phrase_generator.dart'; 3 | 4 | class WordsPhraseGenerator implements PhraseGenerator { 5 | const WordsPhraseGenerator(); 6 | 7 | /// 256 adjectives chosen from various sources, including the top 1000 8 | /// english adjectives by usage, "Harry Potter" and "How I Met Your Mother". 9 | static const _adjectives = [ 10 | ...['red', 'green', 'blue', 'pink', 'yellow', 'orange', 'purple', 'black'], 11 | ...['white', 'brown', 'cute', 'fluffy', 'squishy', 'sunny', 'intelligent'], 12 | ...['creative', 'colorful', 'tired', 'stylish', 'thick', 'clear', 'basic'], 13 | ...['suitable', 'existing', 'boring', 'logical', 'distinct', 'reasonable'], 14 | ...['easy', 'free', 'full', 'good', 'great', 'fit', 'high', 'comfortable'], 15 | ...['little', 'new', 'old', 'public', 'right', 'strong', 'whole', 'angry'], 16 | ...['different', 'used', 'important', 'every', 'large', 'popular', 'safe'], 17 | ...['hot', 'useful', 'scared', 'healthy', 'hard', 'traditional', 'bloody'], 18 | ...['big', 'happy', 'helpful', 'nice', 'wonderful', 'impressive', 'local'], 19 | ...['serious', 'huge', 'rare', 'technical', 'typical', 'critical', 'ugly'], 20 | ...['electronic', 'global', 'yawning', 'relevant', 'capable', 'dangerous'], 21 | ...['dramatic', 'efficient', 'powerful', 'foreign', 'loving', 'realistic'], 22 | ...['mysterious', 'stumbling', 'legendary', 'yielding', 'near', 'shining'], 23 | ...['automatic', 'demanding', 'whomping', 'whirring', 'wonderous', 'sick'], 24 | ...['brilliant', 'massive', 'visible', 'melodic', 'pleasant', 'throbbing'], 25 | ...['friendly', 'lucky', 'hungry', 'hairy', 'sleeping', 'legal', 'normal'], 26 | ...['quick', 'metallic', 'terrible', 'sneezing', 'confident', 'conscious'], 27 | ...['thumping', 'guilty', 'decent', 'sparkling', 'beautiful', 'screaming'], 28 | ...['zooming', 'slurping', 'secure', 'connected', 'whooshing', 'familiar'], 29 | ...['walking', 'glorious', 'thinking', 'laughing', 'cooking', 'fantastic'], 30 | ...['physical', 'digital', 'single', 'working', 'warm', 'wet', 'positive'], 31 | ...['smart', 'stupid', 'ideal', 'swimming', 'honest', 'illegal', 'annual'], 32 | ...['sour', 'fluent', 'dancing', 'living', 'harmonic', 'precious', 'mean'], 33 | ...['pliant', 'proper', 'complex', 'content', 'regular', 'smooth', 'slow'], 34 | ...['amazing', 'busy', 'dead', 'round', 'sharp', 'wise', 'proud', 'light'], 35 | ...['snoring', 'yelling', 'lonely', 'gray', 'woofing', 'natural', 'solid'], 36 | ...['tight', 'deathly', 'reading', 'brave', 'talking', 'dirty', 'magical'], 37 | ...['fast', 'yummy', 'tasteful', 'grand', 'sneaking', 'chemical', 'beefy'], 38 | ...['wooden', 'pretty', 'classic', 'excellent', 'separate', 'sad', 'rich'], 39 | ...['loose', 'loud', 'quiet', 'former', 'empty', 'neat', 'silly', 'weird'], 40 | ...['mad', 'nervous', 'odd', 'tall', 'tiny', 'general', 'sweet', 'cloudy'], 41 | ...['sleepy', 'long', 'small', 'certain', 'common', 'ordinary', 'rickety'], 42 | ...['perfect', 'external', 'drawing', 'sensitive', 'late', 'dry', 'tough'], 43 | ...['nasty', 'bright', 'flat', 'young', 'heavy', 'fresh', 'secret', 'fun'], 44 | ...['thin', 'fine', 'dark', 'gross', 'soft', 'strange', 'rough', 'hollow'], 45 | ...['wild', 'crazy', 'lying', 'usual', 'funny', 'sudden', 'cool', 'clean'], 46 | ...['bad', 'holistic', 'fair', 'calm', 'bitter'], 47 | ]; 48 | 49 | /// 256 nouns chosen from various sources, including "Harry Potter", 50 | /// "Lord of the Rings", "Dirk Gently", "Final Space" and "Portal 2". 51 | static const _nouns = [ 52 | ...['cloud', 'wall', 'code', 'beard', 'bread', 'butter', 'crown', 'snake'], 53 | ...['unicorn', 'hair', 'wizard', 'gold', 'coin', 'elve', 'cloak', 'stone'], 54 | ...['nose', 'vinegar', 'coke', 'tea', 'chocolate', 'hill', 'snow', 'rain'], 55 | ...['butterfly', 'sunshine', 'spaceship', 'scyscraper', 'salad', 'shield'], 56 | ...['cookie', 'wand', 'island', 'planet', 'lamp', 'soup', 'stew', 'coast'], 57 | ...['astronaut', 'robot', 'tomb', 'nougat', 'present', 'quark', 'shimmer'], 58 | ...['pineapple', 'waterfall', 'teleporter', 'roundabout', 'spoon', 'kiwi'], 59 | ...['neurotoxin', 'brownie', 'cinnamon', 'pumpkin', 'counter', 'universe'], 60 | ...['deluminator', 'platform', 'fireplace', 'marshmallow', 'axe', 'knife'], 61 | ...['detective', 'whillow', 'bacteria', 'coconut', 'trapdoor', 'elevator'], 62 | ...['lightning', 'sticker', 'galaxy', 'armchair', 'potato', 'nail', 'egg'], 63 | ...['computer', 'banana', 'hobbit', 'lemon', 'vampire', 'treasure', 'cow'], 64 | ...['keyboard', 'blanket', 'window', 'burrito', 'pizza', 'knight', 'cage'], 65 | ...['shark', 'piano', 'shirt', 'doe', 'stick', 'donkey', 'dice', 'waffle'], 66 | ...['mountain', 'portal', 'horcrux', 'penguin', 'monster', 'bird', 'scar'], 67 | ...['cabinet', 'badger', 'goblin', 'hallow', 'eraser', 'device', 'camera'], 68 | ...['moon', 'sun', 'city', 'town', 'donut', 'hourglass', 'octopus', 'ice'], 69 | ...['sand', 'table', 'cup', 'spike', 'sword', 'vault', 'tooth', 'popcorn'], 70 | ...['gun', 'stair', 'onion', 'candy', 'dragon', 'chair', 'sugar', 'sushi'], 71 | ...['dwarf', 'ring', 'thing', 'tower', 'water', 'glass', 'floo', 'powder'], 72 | ...['house', 'cake', 'cube', 'wood', 'chicken', 'train', 'light', 'grave'], 73 | ...['mirror', 'hat', 'car', 'tree', 'pear', 'grapes', 'turret', 'picture'], 74 | ...['chain', 'bell', 'fire', 'steam', 'apple', 'peanut', 'mango', 'frame'], 75 | ...['lion', 'raven', 'claw', 'book', 'school', 'plant', 'flower', 'glove'], 76 | ...['noodle', 'tomato', 'pen', 'door', 'room', 'path', 'carrot', 'carpet'], 77 | ...['bottle', 'broom', 'castle', 'diary', 'rope', 'ink', 'basket', 'mars'], 78 | ...['bridge', 'hand', 'ball', 'square', 'spider', 'giant', 'clock', 'goo'], 79 | ...['alien', 'thunder', 'dust', 'map', 'earth', 'sensor', 'tape', 'smoke'], 80 | ...['ear', 'tear', 'closet', 'zombie', 'money', 'wind', 'crisps', 'photo'], 81 | ...['cactus', 'shadow', 'glue', 'yoghurt', 'chips', 'pig', 'bolt', 'atom'], 82 | ...['brick', 'cape', 'sock', 'hut', 'note', 'wolf', 'rat', 'maze', 'cave'], 83 | ...['mixer', 'ape', 'ghost', 'suit', 'air', 'letter', 'object', 'toaster'], 84 | ...['bed', 'paper', 'eye', 'bomb', 'orc', 'cat', 'dog', 'mouse', 'button'], 85 | ...['hippo', 'coala', 'paint', 'laser', 'bus', 'box'], 86 | ]; 87 | 88 | @override 89 | String payloadToPhrase(PhrasePayload payload) { 90 | // First, merge both Uint8Lists into one list. Because the key has a static 91 | // length, we can just concatenate the two lists and then translate each 92 | // the byte of the resulting list into words. 93 | final bytes = payload.nameplate + payload.key; 94 | 95 | final adjectives = 96 | bytes.sublist(0, bytes.length - 1).map((i) => _adjectives[i]); 97 | final noun = _nouns[bytes.last]; 98 | return [...adjectives, noun].join(' '); 99 | } 100 | 101 | @override 102 | PhrasePayload phraseToPayload(String phrase) { 103 | final words = phrase.split(' '); 104 | final adjectiveBytes = 105 | words.sublist(0, words.length - 1).map(_adjectives.indexOf); 106 | final nounByte = _nouns.indexOf(words.last); 107 | 108 | final bytes = [...adjectiveBytes, nounByte]; 109 | final keyOffset = bytes.length - PhrasePayload.keyLength; 110 | 111 | return PhrasePayload( 112 | nameplate: bytes.sublist(0, keyOffset).toBytes(), 113 | key: bytes.sublist(keyOffset).toBytes(), 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/src/portal.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:archive/archive.dart'; 5 | import 'package:binary/binary.dart'; 6 | import 'package:meta/meta.dart'; 7 | import 'package:pedantic/pedantic.dart'; 8 | 9 | import 'close_reason.dart'; 10 | import 'connections/mailbox_server_connection.dart'; 11 | import 'connections/server_connection.dart'; 12 | import 'errors.dart'; 13 | import 'phrase_generators/phrase_generator.dart'; 14 | import 'connections/dilated_connection.dart'; 15 | import 'connections/mailbox_connection.dart'; 16 | import 'constants.dart'; 17 | import 'events.dart'; 18 | import 'spake2/spake2.dart'; 19 | import 'utils.dart'; 20 | 21 | // There are todos in the doc comment sample code, so we ignore all todos in 22 | // this file. 23 | // ignore_for_file: todo 24 | /// Portals are strongly encrypted peer-to-peer connections. 25 | /// Inspired by [Magic Wormhole](https://github.com/warner/magic-wormhole/). 26 | /// 27 | /// To connect two devices, you need to create a portal on each of them. 28 | /// 29 | /// On the first device: 30 | /// ``` 31 | /// var portal = Portal(appId: 'my.app.example.com'); 32 | /// String phrase = await portal.open(); 33 | /// // TODO: Show the phrase to the user. 34 | /// String key = await portal.waitForLink(); 35 | /// await portal.waitUntilReady(); 36 | /// ``` 37 | /// On the second device: 38 | /// ``` 39 | /// var portal = Portal(appId: 'my.app.example.com'); 40 | /// // TODO: Let the user enter the phrase. 41 | /// String key = await portal.openAndLinkTo(phrase); 42 | /// await portal.waitUntilReady(); 43 | /// ``` 44 | class Portal { 45 | Portal({ 46 | @required this.appId, 47 | this.info = '', 48 | this.mailboxServerUrl = defaultMailboxServerUrl, 49 | this.phraseGenerator = defaultCodeGenerator, 50 | }) : assert(appId != null), 51 | assert(appId.isNotEmpty), 52 | assert(info != null), 53 | assert(mailboxServerUrl != null), 54 | assert(mailboxServerUrl.isNotEmpty) { 55 | _events = _eventController.stream.asBroadcastStream(); 56 | } 57 | 58 | /// The id of your app. 59 | /// 60 | /// This doesn't really just have to be a domain you own, but it should be 61 | /// unique to your application. You can only connect to portals with the same 62 | /// [appId]. Theoretically, you can also use the same [appId] for multiple 63 | /// applications, but that makes the [phrase]s potentially longer, because 64 | /// there are probably more users connecting concurrently. 65 | final String appId; 66 | 67 | /// Information about your portal. 68 | /// 69 | /// Before trying to establish a peer-to-peer connection, portals exchange 70 | /// this [info]. The other side's info is available at [remoteInfo]. 71 | /// This usually contains meta-information about the connection, like 72 | /// supported protocol versions or a human-readable display name of the user. 73 | final String info; 74 | 75 | /// The url of the mailbox server, which is used by portals to exchange 76 | /// information for direct peer-to-peer connections. 77 | /// 78 | /// If you are creating heavy loads, it's recommended that you run your own 79 | /// server. The server is just a plain old Magic Wormhole server, as can be 80 | /// seen at [the magic wormhole mailbox server repo](https://github.com/warner/magic-wormhole-mailbox-server). 81 | final String mailboxServerUrl; 82 | 83 | /// A converter between two Uint8Lists to a human-readable string. 84 | final PhraseGenerator phraseGenerator; 85 | 86 | /// The [info] of the other portal. 87 | String get remoteInfo => _remoteInfo; 88 | String _remoteInfo; 89 | 90 | /// A [String] that uniquely identifies this portal. 91 | String get phrase => _phrase; 92 | String _phrase; 93 | 94 | /// A [Uint8List] that represents the key used by both portals. 95 | /// 96 | /// Actually, this is a hash of the key, because the actual key is irrelevant 97 | /// for the rest of the app. 98 | Uint8List get key => _key; 99 | Uint8List _key; 100 | 101 | /// Events that this portal emits. 102 | Stream get events => _events; 103 | Stream _events; 104 | final _eventController = StreamController(); 105 | void _registerEvent(PortalEvent event) => _eventController.add(event); 106 | 107 | // Different layers of connections. 108 | ServerConnection _server; 109 | MailboxServerConnection _mailboxServer; 110 | MailboxConnection _mailbox; 111 | DilatedConnection _client; 112 | 113 | Future _setup([String phrase]) async { 114 | // Extract short key and nameplate from the phrase. 115 | _registerEvent(PortalOpening()); 116 | final payload = phrase?.toPhrasePayload(phraseGenerator); 117 | final shortKey = payload?.key ?? PhraseGenerator.generateShortKey(); 118 | var nameplate = payload?.nameplate?.utf8decoded; 119 | 120 | // Connect to the server. 121 | _server = ServerConnection(url: mailboxServerUrl); 122 | await _server.connect(); 123 | _registerEvent(PortalServerReached()); 124 | 125 | // Set up the mailbox server. 126 | _mailboxServer = MailboxServerConnection(server: _server, appId: appId); 127 | _mailboxServer.initialize(isFirstPortal: phrase == null); 128 | await _mailboxServer.bindAndWelcome(); 129 | nameplate ??= await _mailboxServer.allocateNameplate(); 130 | final mailbox = await _mailboxServer.claimNameplate(nameplate); 131 | await _mailboxServer.openMailbox(mailbox); 132 | 133 | // Create phrase. 134 | final phrasePayload = PhrasePayload( 135 | key: shortKey, 136 | nameplate: nameplate.utf8encoded, 137 | ); 138 | _phrase = phraseGenerator.payloadToPhrase(phrasePayload); 139 | ifInDebugMode(() { 140 | PhraseGenerator.ensureGeneratorReversible( 141 | generator: phraseGenerator, 142 | payload: phrasePayload, 143 | generatedPhrase: _phrase, 144 | ); 145 | }); 146 | _registerEvent(PortalOpened(phrase: _phrase)); 147 | 148 | // Create an encrypted connection over the mailbox and save its key hash. 149 | _mailbox = MailboxConnection(server: _mailboxServer, shortKey: shortKey); 150 | await _mailbox.initialize(); 151 | _remoteInfo = await _mailbox.exchangeInfo(info); 152 | _key = sha256(_mailbox.key); 153 | _registerEvent(PortalLinked(key: _key)); 154 | 155 | // Try several connections to the other client. 156 | _registerEvent(PortalConnecting()); 157 | _client = DilatedConnection(mailbox: _mailbox); 158 | await _client.establishConnection(); 159 | _registerEvent(PortalConnected()); 160 | } 161 | 162 | /// Opens this portal. 163 | Future open() async { 164 | unawaited(_setup()); 165 | return waitForPhrase(); 166 | } 167 | 168 | /// Opens this portal and links it to the given [phrase]. 169 | Future openAndLinkTo(String phrase) async { 170 | unawaited(_setup(phrase)); 171 | return waitForLink(); 172 | } 173 | 174 | Future waitForPhrase() async { 175 | if (phrase != null) return phrase; 176 | return (await events.whereType().first).phrase; 177 | } 178 | 179 | /// Waits for a link. 180 | Future waitForLink() async { 181 | if (key != null) return key; 182 | return (await events.whereType().first).key; 183 | } 184 | 185 | Future waitUntilReady() => events.whereType().first; 186 | 187 | /// Sends the given message to the linked portal. 188 | void send(dynamic message) { 189 | // For small simple objects compression adds unnecessary overhead. That's 190 | // why we only compress the message if that makes it smaller. The first 191 | // byte indicates whether the data is compressed (1) or not (0). 192 | final serialized = serialize(message); 193 | final compressed = GZipEncoder().encode(serialized); 194 | final data = (compressed.length < serialized.length) 195 | ? ([1] + compressed).toBytes() 196 | : ([0] + serialized).toBytes(); 197 | 198 | print('Sending data $data.'); 199 | _client.send(data); 200 | } 201 | 202 | /// Receives a message from the linked portal. 203 | Future receive() async { 204 | final data = await _client.receive(); 205 | 206 | print('Received data $data.'); 207 | final serialized = data[0] == 1 208 | ? GZipDecoder().decodeBytes(data.sublist(1)) 209 | : data.sublist(1); 210 | final message = deserialize(serialized); 211 | 212 | return message; 213 | } 214 | 215 | /// Closes this portal. 216 | Future close() async { 217 | _remoteInfo = null; 218 | _phrase = null; 219 | _key = null; 220 | 221 | final hadConnection = _client != null; 222 | 223 | _client.close(); 224 | _client = null; 225 | 226 | _mailbox = null; 227 | 228 | _mailboxServer.releaseNameplate(); 229 | _mailboxServer.closeMailbox(hadConnection ? Mood.happy : Mood.lonely); 230 | _mailboxServer = null; 231 | 232 | await _server.close(CloseReason.normal()); 233 | _server = null; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /lib/src/spake2/ed25519.dart: -------------------------------------------------------------------------------- 1 | /// An implementation of the Edwards 25519 group. 2 | /// 3 | /// The Edwards 25519 group (in short "Ed25519") is a group in the mathematical 4 | /// sense, which means it has [Element]s and [Scalar]s. You can multiply an 5 | /// [Element] with a [Scalar] (that's called scalar multiplication). There's 6 | /// also an identity [zero] element with x+0 = x. 7 | /// The Ed25519 group is cyclic and abelian, which means it has a finite amount 8 | /// of [Element]s and scalar multiplication is associative, 9 | /// i.e. n*(x+y) = n*x + n*y. 10 | /// Being cyclic comes with some cool perks. Most importantly, there's a [base] 11 | /// element (mathematically sometimes called "generator"). By repeatedly adding 12 | /// [base] to 0, we can construct every single element of the Ed25519 group. 13 | /// Because the group is cyclic, at some point we reach the point where we 14 | /// started, at 0. After that, the addition of [base] just loops around in the 15 | /// group. This happens after adding [base] to 0 for q=2^255-19 times. 16 | /// q is called the order of the group and Ed25519 got its name because of the 17 | /// order. 18 | /// 19 | /// So, what exactly are the [Element]s and [Scalar]s of the group? 20 | /// 21 | /// [Element]s are just [Point]s on this curve. From the y-coordinate, you can 22 | /// find out the x-coordinate. 23 | /// There are also [ExtendedPoint]s, which hold some additional information for 24 | /// convenience (and are thus four-dimensional). You can easily convert between 25 | /// normal [Point]s (also called "affine" points) and [ExtendedPoint]s. 26 | /// Extended coordinates have x, y, z and t properties and x=X/Z, y=Y/Z, 27 | /// x*y=T/Z. For more information, visit 28 | /// http://www.hyperelliptic.org/EFD/g1p/auto-twisted-extended-1.html. 29 | /// 30 | /// [Scalar]s are just [BigInt]s in the inclusive range from 0 to q-1. Because 31 | /// there are exactly as many [Scalar]s as [Element]s, there's a one-to-one 32 | /// mapping between them. It's trivial to go from a scalar to an element (just 33 | /// calculate 0+base*scalar), but it's hard (in the cryptographic sense) to go 34 | /// from element to scalar. 35 | 36 | import 'dart:math'; 37 | import 'dart:typed_data'; 38 | 39 | import 'utils.dart'; 40 | 41 | class Ed25519Exception implements Exception { 42 | Ed25519Exception(this.message); 43 | 44 | final String message; 45 | 46 | @override 47 | String toString() => message; 48 | } 49 | 50 | final q = 2.bi.pow(255) - 19.bi; // The order of the group. 51 | final l = 2.bi.pow(252) + '27742317777372353535851937790883648493'.bi; 52 | final d = -121665.bi * 121666.bi.inv; 53 | final i = BigInt.two.modPow((q - 1.bi) ~/ 4.bi, q); 54 | 55 | final by = 4.bi * 5.bi.inv; 56 | final bx = _xRecover(by); 57 | final b = Point(bx, by) % q; 58 | 59 | final base = Element(b.toExtended()); 60 | final zero = Element(Point(0.bi, 1.bi).toExtended()); 61 | 62 | /// Recovers the x-coordiante for the given y-coordinate. 63 | BigInt _xRecover(BigInt y) { 64 | final xx = (y.squared - 1.bi) * (d * y.squared + 1.bi).inv; 65 | var x = xx.modPow((q + 3.bi) ~/ 8.bi, q); 66 | 67 | if ((x.squared - xx) % q != 0.bi) { 68 | x = (x * i) % q; 69 | } 70 | if (x.isOdd) { 71 | x = q - x; 72 | } 73 | 74 | return x; 75 | } 76 | 77 | /// A two dimensional point. 78 | class Point { 79 | Point(this.x, this.y); 80 | 81 | final BigInt x, y; 82 | 83 | Point operator *(BigInt n) => Point(x * n, y * n); 84 | Point operator %(BigInt n) => Point(x % n, y % n); 85 | 86 | ExtendedPoint toExtended() => ExtendedPoint(x, y, 1.bi, x * y) % q; 87 | 88 | /// Whether this point is on the Edwards curve. 89 | bool get isOnCurve => 90 | (-x.squared + y.squared - 1.bi - d * x.squared * y.squared) % q == 0.bi; 91 | 92 | Uint8List toBytes() { 93 | // Points are encoded as 32-bytes little-endian, b255 is sign, b2b1b0 are 0. 94 | // MSB of ouput equals x.b0 = x&1. Rest of output is little-endian y. 95 | assert(y >= 0.bi); 96 | assert(y < (1.bi << 255)); 97 | 98 | final yForEncoding = (x & 1.bi != 0.bi) ? (y + (1.bi << 255)) : y; 99 | 100 | return Number(yForEncoding).toBytes(); 101 | } 102 | 103 | factory Point.fromBytes(Uint8List encoded) { 104 | assert(encoded.length == 32); 105 | 106 | final unclamped = Scalar.fromBytes(encoded.reversed.toBytes()); 107 | final clamp = (1.bi << 255) - 1.bi; 108 | final y = unclamped & clamp; // Clear MSB 109 | var x = _xRecover(y); 110 | 111 | if ((x & 1.bi != 0.bi) != (unclamped & (1.bi << 255) != 0.bi)) { 112 | x = q - x; 113 | } 114 | final point = Point(x, y); 115 | 116 | if (!point.isOnCurve) { 117 | throw Ed25519Exception('Decoding point that is not on curve.'); 118 | } 119 | return point; 120 | } 121 | 122 | @override 123 | String toString() => '($x, $y)'; 124 | } 125 | 126 | class ExtendedPoint { 127 | ExtendedPoint(this.x, this.y, this.z, this.t); 128 | 129 | final BigInt x, y, z, t; 130 | 131 | ExtendedPoint operator +(ExtendedPoint p) => 132 | ExtendedPoint(x + p.x, y + p.y, z + p.z, t + p.t); 133 | ExtendedPoint operator %(BigInt a) => 134 | ExtendedPoint(x % a, y % a, z % a, t % a); 135 | 136 | /// Converts this extended point into a normal ("affine") [Point]. 137 | Point toAffine() => Point(x, y) * z.inv % q; 138 | 139 | bool get isZero => x == 0.bi && y % q == z % q && y != 0.bi; 140 | 141 | @override 142 | String toString() => '($x, $y, $z, $t)'; 143 | } 144 | 145 | class Element extends ExtendedPoint { 146 | Element(ExtendedPoint p) : super(p.x, p.y, p.z, p.t); 147 | 148 | @override 149 | Element operator +(ExtendedPoint other) { 150 | assert(other is Element); 151 | 152 | final a = ((y - x) * (other.y - other.x)) % q; 153 | final b = ((y + x) * (other.y + other.x)) % q; 154 | final c = (t * 2.bi * d * other.t) % q; 155 | final e = z * 2.bi * other.z % q; 156 | final f = (b - a) % q; 157 | final g = (e - c) % q; 158 | final h = (e + c) % q; 159 | final i = (b + a) % q; 160 | 161 | return Element(ExtendedPoint(f * g, h * i, g * h, f * i) % q); 162 | } 163 | 164 | // Only works if [this != other] and if the order of the points is not 1, 2, 165 | // 4 or 8. But it's 10 % faster than the normal +. 166 | Element fastAdd(ExtendedPoint other) { 167 | final a = ((y - x) * (other.y + other.x)) % q; 168 | final b = ((y + x) * (other.y - other.x)) % q; 169 | final c = (z * 2.bi * other.t) % q; 170 | final e = (t * 2.bi * other.z) % q; 171 | final f = (e + c) % q; 172 | final g = (b - a) % q; 173 | final h = (b + a) % q; 174 | final i = (e - c) % q; 175 | return Element(ExtendedPoint(f * g, h * i, g * h, f * i) % q); 176 | } 177 | 178 | // Faster version of multiplying with 2. 179 | Element doubleElement() { 180 | final a = x.squared; 181 | final b = y.squared; 182 | final c = 2.bi * z.squared; 183 | final d = (-a) % q; 184 | final j = (x + y) % q; 185 | final e = (j.squared - a - b) % q; 186 | final g = (d + b) % q; 187 | final f = (g - c) % q; 188 | final h = (d - b) % q; 189 | return Element(ExtendedPoint(e * f, g * h, f * g, e * h) % q); 190 | } 191 | 192 | Element scalarMult(BigInt scalar) { 193 | scalar %= l; 194 | if (scalar == 0.bi) return zero; 195 | 196 | final a = scalarMult(scalar >> 1).doubleElement(); 197 | return (scalar & 1.bi != 0.bi) ? (a + this) : a; 198 | } 199 | 200 | Element fastScalarMult(BigInt scalar) { 201 | scalar %= l; 202 | if (scalar == 0.bi) return zero; 203 | 204 | final a = fastScalarMult(scalar >> 1).doubleElement(); 205 | return (scalar & 1.bi != 0.bi) ? a.fastAdd(this) : a; 206 | } 207 | 208 | Element negate() => Element(scalarMult(l - 2.bi)); 209 | 210 | Element operator -(Element other) => this + other.negate(); 211 | 212 | @override 213 | bool operator ==(Object other) => 214 | other is Element && other.toBytes().toString() == toBytes().toString(); 215 | 216 | Uint8List toBytes() => toAffine().toBytes(); 217 | 218 | /// This strictly only accepts elements in the right subgroup. 219 | factory Element.fromBytes(Uint8List bytes) { 220 | final p = Element(Point.fromBytes(bytes).toExtended()); 221 | if (p.isZero) { 222 | // || !p.fastScalarMult(l).isZero) { 223 | throw Ed25519Exception('Element is not in the right group.'); 224 | } 225 | // The point is in the expected 1*l subgroup, not in the 2/4/8 groups, or 226 | // in the 2*l/4*l/8*l groups. 227 | return p; 228 | } 229 | 230 | factory Element.arbitraryElement(Uint8List seed) { 231 | // We don't strictly need the uniformity provided by hashing to an 232 | // oversized string (128 bits more than the field size), then reducing down 233 | // to q. But it's comforting, and it's the same technique we use for 234 | // converting passwords/seeds to scalars (which _does_ need uniformity). 235 | final hSeed = expandArbitraryElementSeed(seed, 256 ~/ 8 + 16); 236 | final y = Number.fromBytes(hSeed.reversed.toBytes()) % q; 237 | 238 | // We try successive y values until we find a valid point. 239 | for (var plus = 0.bi;; plus += 1.bi) { 240 | final yPlus = (y + plus) % q; 241 | final x = _xRecover(yPlus); 242 | final pointA = Point(x, yPlus); 243 | 244 | // Only about 50 % of y coordinates map to valid curve points (I think 245 | // the other half gives you points on the "twist"). 246 | if (!pointA.isOnCurve) continue; 247 | 248 | final p = Element(pointA.toExtended()); 249 | // Even if the point is on our curve, it may not be in our particular 250 | // subgroup (with order = l). The curve has order 8*l, so an arbitrary 251 | // point could have order 1, 2, 4, 8, 1*l, 2*l, 4*l, 8*l (everything 252 | // which divides the group order). 253 | // I may be completely wrong about this, but my brief statistical tests 254 | // suggest it's not too far off that there are phi(x) points with order 255 | // x, so: 256 | // * 1 element of order 1: Point(0, 1). 257 | // * 1 element of order 2: Point(0, -1). 258 | // * 2 elements of order 4. 259 | // * 4 elements of order 8. 260 | // * l-1 elements of order l (including the [base]). 261 | // * l-1 elements of order 2*l. 262 | // * 2*(l-1) elements of order 4*l. 263 | // * 4*(l-1) elements of order 8*l. 264 | // 265 | // So, 50 % of random points will have order 8*l, 25 % will have order 266 | // 4*l, 13 % order 2*l, and 13 % will have our desired order 1*l (and a 267 | // vanishingly small fraction will have 1/2/4/8). If we multiply any of 268 | // the 8*l points by 2, we're sure to get an 4*l point (and multiplying a 269 | // 4*l point by 2 gives us a 2*l point, and so on). Multiplying a 1*l 270 | // point by 2 gives us a different 1*l point. So multiplying by 8 gets us 271 | // from almost any point into a uniform point on the correct 1*l 272 | // subgroup. 273 | final p8 = p.scalarMult(8.bi); 274 | 275 | // If we got really unlucky and picked one of the 8 low-order points, 276 | // multiplying by 8 will get us to the identity [zero], which we check 277 | // for explicitly. 278 | if (p8.isZero) continue; 279 | 280 | // We're finally in the right group. 281 | return Element(p8); 282 | } 283 | } 284 | } 285 | 286 | extension Scalar on BigInt { 287 | /// The inversion of this scalar. 288 | BigInt get inv => modPow(q - 2.bi, q); 289 | 290 | BigInt get squared => this * this; 291 | 292 | /// Scalars are encoded as 32-bytes little-endian. 293 | static BigInt fromBytes(Uint8List bytes) { 294 | assert(bytes.length == 32); 295 | return Number.fromBytes(bytes); 296 | } 297 | 298 | static BigInt clampedFromBytes(Uint8List bytes) { 299 | // Ed25519 private keys clamp the scalar to ensure two things: 300 | // - Integer value is in [L/2,L] to avoid small-logarithm non-wrap-around. 301 | // - Low-order 3 bits are zero, so a small-subgroup attack won't learn any 302 | // information. 303 | // Set the top two bits to 01, and the bottom three to 000. 304 | final unclamped = fromBytes(bytes); 305 | final andClamp = (1.bi << 254) - 1.bi - 7.bi; 306 | final orClamp = (1.bi << 254); 307 | final clamped = (unclamped & andClamp) | orClamp; 308 | return clamped; 309 | } 310 | 311 | Uint8List toBytes() { 312 | final clamped = this % l; 313 | assert(0.bi <= clamped); 314 | assert(clamped < 2.bi.pow(256)); 315 | return Number(clamped).toBytes(); 316 | } 317 | 318 | static BigInt random([Random random]) { 319 | random ??= Random.secure(); 320 | // Reduce the bias to a safe level by generating some extra bits. 321 | final oversized = Number.fromBytes(Uint8List.fromList([ 322 | for (var i = 0; i < 255; i++) random.nextInt(64), 323 | ])); 324 | return oversized % l; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /lib/src/spake2/hkdf.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:crypto/crypto.dart'; 4 | 5 | import 'utils.dart'; 6 | 7 | class HkdfException { 8 | HkdfException(this.message); 9 | 10 | final String message; 11 | 12 | @override 13 | String toString() => message; 14 | } 15 | 16 | Uint8List _extract(Uint8List salt, Uint8List inputKeyMaterial) { 17 | salt ??= List.filled(sha256.blockSize, 0).toBytes(); 18 | return Hmac(sha256, salt).convert(inputKeyMaterial).bytes; 19 | } 20 | 21 | Uint8List _expand(Uint8List key, Uint8List info, int length) { 22 | final hashLength = sha256.blockSize; 23 | 24 | if (length > 255 * hashLength) { 25 | throw HkdfException( 26 | 'Cannot expand to more than ${255 * hashLength} bytes using ' 27 | 'sha256, but length is $length.'); 28 | } 29 | 30 | var blocksNeeded = 2 * (length / hashLength).ceil(); 31 | var okM = []; 32 | var outputBlock = []; 33 | 34 | for (var i = 0; i < blocksNeeded; i++) { 35 | outputBlock = Hmac(sha256, key).convert(outputBlock + info + [i + 1]).bytes; 36 | okM.addAll(outputBlock); 37 | } 38 | final shortenedList = okM.length <= length ? okM : okM.sublist(0, length); 39 | return Uint8List.fromList(shortenedList); 40 | } 41 | 42 | class Hkdf { 43 | final Uint8List _prk; 44 | 45 | Hkdf(Uint8List salt, Uint8List inputKeyMaterial) 46 | : _prk = _extract(salt, inputKeyMaterial); 47 | 48 | Uint8List expand(Uint8List info, {int length = 32}) => 49 | _expand(_prk, info, length); 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/spake2/spake2.dart: -------------------------------------------------------------------------------- 1 | /// The Spake2 algorithm is a variation of the Diffie Hellman algorithm, but 2 | /// instead of just negotiating a secret key between two parties that formerly 3 | /// know nothing about each other, both parties know a shared secret with small 4 | /// entropy. 5 | /// Here's an explanation about how it works: 6 | /// https://copyninja.info/blog/golang_spake2_4.html 7 | 8 | import 'dart:convert'; 9 | import 'dart:isolate'; 10 | import 'dart:math'; 11 | import 'dart:typed_data'; 12 | 13 | import 'package:async/async.dart'; 14 | import 'package:crypto/crypto.dart' as crypto; 15 | import 'package:meta/meta.dart'; 16 | 17 | import 'ed25519.dart'; 18 | import 'utils.dart'; 19 | 20 | Uint8List sha256(List data) => crypto.sha256.convert(data).bytes.toBytes(); 21 | 22 | final s = Element.arbitraryElement(ascii.encode('symmetric')); 23 | 24 | /// This class manages one side of a spake2 key negotiation. 25 | class _Spake2 { 26 | final Uint8List id; 27 | final Uint8List password; 28 | final BigInt _pwScalar; 29 | BigInt _xyScalar; 30 | Element _xyElement; 31 | Uint8List _outboundMessage; 32 | Uint8List _inboundMessage; 33 | 34 | bool _started = false; 35 | bool _finished = false; 36 | 37 | _Spake2({@required this.id, @required this.password}) 38 | : assert(id != null), 39 | assert(id.isNotEmpty), 40 | assert(password != null), 41 | assert(password.isNotEmpty), 42 | _pwScalar = passwordToScalar(password, 32, l); 43 | 44 | Uint8List start([Random random]) { 45 | assert(!_started); 46 | _started = true; 47 | 48 | _xyScalar = Scalar.random(random); 49 | _xyElement = base.fastScalarMult(_xyScalar); 50 | final pwBlinding = myBlinding.fastScalarMult(_pwScalar); 51 | final messageElem = _xyElement + pwBlinding; 52 | _outboundMessage = messageElem.toBytes(); 53 | return _outboundMessage; 54 | } 55 | 56 | Element get myBlinding => s; 57 | Element get myUnblinding => s; 58 | 59 | Uint8List finish(Uint8List inboundMessage) { 60 | assert(_started); 61 | assert(!_finished); 62 | _finished = true; 63 | 64 | _inboundMessage = inboundMessage; 65 | 66 | final inboundElement = Element.fromBytes(inboundMessage.reversed.toBytes()); 67 | 68 | final pwUnblinding = myUnblinding.fastScalarMult(-_pwScalar); 69 | final kElem = (inboundElement + pwUnblinding).fastScalarMult(_xyScalar); 70 | final kBytes = kElem.toBytes(); 71 | 72 | final msg1 = _inboundMessage.reversed.toBytes(); 73 | final msg2 = _outboundMessage.reversed.toBytes(); 74 | 75 | // Since this function needs to deterministically produce the same key on 76 | // both clients and the inbound message of one client is the outbound 77 | // message of the other one (and vice versa), we sort the messages. 78 | final isFirstMsgSmaller = msg1 < msg2; 79 | final firstMsg = isFirstMsgSmaller ? msg1 : msg2; 80 | final secondMsg = isFirstMsgSmaller ? msg2 : msg1; 81 | 82 | final transcript = [ 83 | ...sha256(password), 84 | ...sha256(id), 85 | ...firstMsg, 86 | ...secondMsg, 87 | ...kBytes, 88 | ]; 89 | return sha256(transcript); 90 | } 91 | } 92 | 93 | extension _BytesSender on SendPort { 94 | void sendBytes(Uint8List list) => send(list.toList()); 95 | } 96 | 97 | extension _BytesReceiver on StreamQueue { 98 | Future receiveBytes() async => 99 | (await next as List).cast().toBytes(); 100 | } 101 | 102 | void _createSpake2(SendPort sendPort) async { 103 | final port = ReceivePort(); 104 | sendPort.send(port.sendPort); 105 | final receivePort = StreamQueue(port); 106 | 107 | // Setup a Spake2 instance with id and password. 108 | final id = await receivePort.receiveBytes(); 109 | final password = await receivePort.receiveBytes(); 110 | final spake = _Spake2(id: id, password: password); 111 | 112 | // Start the encryption. 113 | sendPort.sendBytes(spake.start()); 114 | 115 | // Finish the encryption. 116 | final inboundMessage = await receivePort.receiveBytes(); 117 | final key = spake.finish(inboundMessage); 118 | sendPort.send(key); 119 | port.close(); 120 | } 121 | 122 | class Spake2 { 123 | Spake2({@required this.id, @required this.password}); 124 | 125 | final Uint8List id; 126 | final Uint8List password; 127 | 128 | ReceivePort _port; 129 | StreamQueue _receivePort; 130 | SendPort _sendPort; 131 | 132 | Future start() async { 133 | _port = ReceivePort(); 134 | await Isolate.spawn(_createSpake2, _port.sendPort); 135 | _receivePort = StreamQueue(_port); 136 | _sendPort = await _receivePort.next as SendPort; 137 | 138 | // Send the id and password. 139 | _sendPort.sendBytes(id); 140 | _sendPort.sendBytes(password); 141 | return await _receivePort.receiveBytes(); 142 | } 143 | 144 | Future finish(Uint8List inboundMessage) async { 145 | _sendPort.sendBytes(inboundMessage); 146 | final key = await _receivePort.receiveBytes(); 147 | _port.close(); 148 | return key; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /lib/src/spake2/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import '../utils.dart'; 5 | import 'hkdf.dart'; 6 | 7 | export '../utils.dart'; 8 | 9 | extension ListComparator on List { 10 | /// Compares this list with the other one. The smaller list is the one with 11 | /// the smaller element at the first position where the elements of the two 12 | /// lists are not the same. 13 | bool operator <(List other) { 14 | assert(length == other.length); 15 | 16 | for (var i = 0; i < length; i++) { 17 | if (this[i] != other[i]) { 18 | return this[i] < other[i]; 19 | } 20 | } 21 | return false; // All elements are equal. 22 | } 23 | } 24 | 25 | extension Number on BigInt { 26 | static BigInt fromBytes(Uint8List bytes) => 27 | BigInt.parse(bytes.reversed.toBytes().toHex(), radix: 16); 28 | 29 | Uint8List toBytes() => 30 | Bytes(Bytes.fromHex(toRadixString(16).fillWithLeadingZeros(64)).reversed) 31 | .toBytes(); 32 | } 33 | 34 | extension IntToNumber on int { 35 | /// Turns this [int] into a [BigInt]. 36 | BigInt get bi => BigInt.from(this); 37 | } 38 | 39 | extension StringToNumber on String { 40 | /// Turns this [String] into a [BigInt]. 41 | BigInt get bi => BigInt.parse(this); 42 | } 43 | 44 | final _emptyBytes = Uint8List(0); 45 | 46 | Uint8List expandPassword(Uint8List data, int numBytes) => 47 | Hkdf(_emptyBytes, data).expand(ascii.encode('SPAKE2 pw'), length: numBytes); 48 | 49 | BigInt passwordToScalar(Uint8List password, int scalarSizeBytes, BigInt q) { 50 | // The oversized hash reduces bias in the result, so uniformly-random 51 | // passwords give nearly-uniform scalars. 52 | final oversized = expandPassword(password, scalarSizeBytes + 16); 53 | assert(oversized.length >= scalarSizeBytes); 54 | final i = Number.fromBytes(oversized.reversed.toBytes()); 55 | return i % q; 56 | } 57 | 58 | Uint8List expandArbitraryElementSeed(Uint8List data, int numBytes) => 59 | Hkdf(_emptyBytes, data).expand( 60 | ascii.encode('SPAKE2 arbitrary element'), 61 | length: numBytes, 62 | ); 63 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert' as convert; 2 | import 'dart:math' as math; 3 | import 'dart:math'; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:portals/src/phrase_generators/phrase_generator.dart'; 7 | 8 | extension Bytes on Iterable { 9 | String toHex() => 10 | map((byte) => byte.toRadixString(16).fillWithLeadingZeros(2)).join(''); 11 | 12 | static Uint8List fromHex(String hexString) { 13 | return [ 14 | for (var i = 0; i < hexString.length ~/ 2; i++) 15 | int.parse(hexString.substring(2 * i, 2 * i + 2), radix: 16), 16 | ].toBytes(); 17 | } 18 | 19 | /// Turns this [Iterable] into a [Uint8List]. 20 | Uint8List toBytes() => Uint8List.fromList(toList()); 21 | 22 | static Uint8List generateRandom(int length) { 23 | final random = Random.secure(); 24 | return [ 25 | for (var i = 0; i < length; i++) random.nextInt(256), 26 | ].toBytes(); 27 | } 28 | 29 | /// Returns the minimum of this list. 30 | int get min => reduce(math.min); 31 | } 32 | 33 | extension LeadingZeros on String { 34 | /// Fill this string with leading zeros, so that the total length is at least 35 | /// [length]. 36 | String fillWithLeadingZeros(int length) => 37 | '${[for (var i = length - this.length; i > 0; i--) '0'].join()}$this'; 38 | } 39 | 40 | extension FilterStreamByType on Stream { 41 | Stream whereType() => where((item) => item is S).cast(); 42 | } 43 | 44 | extension Utf8Decode on List { 45 | String get utf8decoded => convert.utf8.decode(this); 46 | } 47 | 48 | extension Utf8Encode on String { 49 | Uint8List get utf8encoded => convert.utf8.encode(this).toBytes(); 50 | } 51 | 52 | extension PhraseToPayload on String { 53 | PhrasePayload toPhrasePayload(PhraseGenerator generator) => 54 | generator.phraseToPayload(this); 55 | } 56 | 57 | void ifInDebugMode(void Function() run) { 58 | assert(() { 59 | run(); 60 | return true; 61 | }()); 62 | } 63 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | archive: 5 | dependency: "direct main" 6 | description: 7 | name: archive 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "2.0.13" 11 | args: 12 | dependency: transitive 13 | description: 14 | name: args 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "1.5.2" 18 | async: 19 | dependency: "direct main" 20 | description: 21 | name: async 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "2.4.0" 25 | bech32: 26 | dependency: transitive 27 | description: 28 | name: bech32 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "0.1.2" 32 | binary: 33 | dependency: "direct main" 34 | description: 35 | path: binary 36 | ref: HEAD 37 | resolved-ref: fa49154b4e9ab7657d563f7acf1b5be7aca7e674 38 | url: "git://github.com/marcelgarus/portals" 39 | source: git 40 | version: "0.0.1" 41 | charcode: 42 | dependency: transitive 43 | description: 44 | name: charcode 45 | url: "https://pub.dartlang.org" 46 | source: hosted 47 | version: "1.1.2" 48 | collection: 49 | dependency: "direct main" 50 | description: 51 | name: collection 52 | url: "https://pub.dartlang.org" 53 | source: hosted 54 | version: "1.14.12" 55 | convert: 56 | dependency: transitive 57 | description: 58 | name: convert 59 | url: "https://pub.dartlang.org" 60 | source: hosted 61 | version: "2.1.1" 62 | crypto: 63 | dependency: "direct main" 64 | description: 65 | name: crypto 66 | url: "https://pub.dartlang.org" 67 | source: hosted 68 | version: "2.1.3" 69 | fixnum: 70 | dependency: transitive 71 | description: 72 | name: fixnum 73 | url: "https://pub.dartlang.org" 74 | source: hosted 75 | version: "0.10.11" 76 | meta: 77 | dependency: "direct main" 78 | description: 79 | name: meta 80 | url: "https://pub.dartlang.org" 81 | source: hosted 82 | version: "1.1.8" 83 | path: 84 | dependency: transitive 85 | description: 86 | name: path 87 | url: "https://pub.dartlang.org" 88 | source: hosted 89 | version: "1.6.4" 90 | pedantic: 91 | dependency: "direct main" 92 | description: 93 | name: pedantic 94 | url: "https://pub.dartlang.org" 95 | source: hosted 96 | version: "1.9.0" 97 | pinenacl: 98 | dependency: "direct main" 99 | description: 100 | name: pinenacl 101 | url: "https://pub.dartlang.org" 102 | source: hosted 103 | version: "0.1.3-dev.1" 104 | stream_channel: 105 | dependency: transitive 106 | description: 107 | name: stream_channel 108 | url: "https://pub.dartlang.org" 109 | source: hosted 110 | version: "2.0.0" 111 | typed_data: 112 | dependency: transitive 113 | description: 114 | name: typed_data 115 | url: "https://pub.dartlang.org" 116 | source: hosted 117 | version: "1.1.6" 118 | web_socket_channel: 119 | dependency: "direct main" 120 | description: 121 | name: web_socket_channel 122 | url: "https://pub.dartlang.org" 123 | source: hosted 124 | version: "1.1.0" 125 | sdks: 126 | dart: ">=2.7.0 <3.0.0" 127 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: portals 2 | description: Portals are strongly encrypted peer-to-peer connections. Inspired by Magic Wormhole. 3 | version: 0.0.9 4 | homepage: https://github.com/marcelgarus/portals 5 | 6 | environment: 7 | sdk: ">=2.6.0 <3.0.0" 8 | 9 | dependencies: 10 | async: ^2.4.0 11 | binary: 12 | git: 13 | url: git://github.com/marcelgarus/portals 14 | path: binary 15 | meta: ^1.1.8 16 | web_socket_channel: ^1.1.0 17 | crypto: ^2.1.3 18 | pinenacl: ^0.1.3-dev 19 | collection: ^1.14.11 20 | pedantic: ^1.8.0 21 | archive: ^2.0.13 22 | -------------------------------------------------------------------------------- /relation_to_magic_wormhole.md: -------------------------------------------------------------------------------- 1 | # Relation to magic wormhole 2 | 3 | This package is heavily inspired by Magic Wormhole and is compatible with it in most of the lower layers of the architecure. 4 | 5 | The magic wormhole's mailbox server acts as the control connection. All the commands relating setup and meta-information go through this one. 6 | All actual data transfers go through data connections. 7 | 8 | 9 | --------------------------------------------------------------------------------