mailcycle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # Checking a webhook came from Mailcycle.
5
+ #
6
+ # `Mailcycle-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
7
+ # keyed with the webhook's secret. Pass the raw body, before any parsing: a
8
+ # body that has been through a JSON round trip is a different string and
9
+ # will not verify.
10
+ module WebhookSignature
11
+ DEFAULT_TOLERANCE_SECONDS = 300
12
+
13
+ module_function
14
+
15
+ # The header Mailcycle would send for this body at this second.
16
+ #
17
+ # Here so a test suite can build a believable delivery without a live
18
+ # webhook, and so this implementation can be checked against the shared
19
+ # vectors rather than trusted.
20
+ def header(secret, raw_body, timestamp)
21
+ "t=#{timestamp},v1=#{Encoding.to_hex(sign(secret, raw_body, timestamp))}"
22
+ end
23
+
24
+ # True if the signature is Mailcycle's, recent, and over this exact body.
25
+ #
26
+ # `now` defaults to the system clock; pass it to test, or from a trusted
27
+ # time source.
28
+ def verify(secret, raw_body, header, tolerance: DEFAULT_TOLERANCE_SECONDS, now: Time.now.to_i)
29
+ return false unless header.is_a?(String)
30
+
31
+ timestamp = nil
32
+ given = nil
33
+ header.split(",").each do |part|
34
+ name, value = part.split("=", 2)
35
+ next if value.nil?
36
+
37
+ # Trim the name as well as the value, matching the other SDKs, so a
38
+ # header written with spaces around the keys still verifies.
39
+ case name.strip
40
+ when "t" then timestamp = value.strip.match?(/\A[+-]?[0-9]+\z/) ? value.strip.to_i : nil
41
+ when "v1" then given = value.strip
42
+ end
43
+ end
44
+
45
+ return false if timestamp.nil? || given.nil?
46
+ return false if (now - timestamp).abs > tolerance
47
+ return false unless given.bytesize == 64
48
+
49
+ signature = Encoding.from_hex(given)
50
+ return false if signature.nil?
51
+
52
+ Crypto::Primitives.constant_time_equal(sign(secret, raw_body, timestamp), signature)
53
+ end
54
+
55
+ def sign(secret, raw_body, timestamp)
56
+ Crypto::Primitives.hmac_sha256(secret.to_s.b, "#{timestamp}.".b + raw_body.to_s.b)
57
+ end
58
+ private_class_method :sign
59
+ end
60
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+ require "openssl"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ module Mailcycle
9
+ # The client half of RFC 6455, as much of it as the event stream uses.
10
+ #
11
+ # Ruby's standard library has no WebSocket, and the stream is too small a
12
+ # part of this library to take a dependency for. The protocol's client side
13
+ # is short: an HTTP upgrade, then frames, masked going out and unmasked
14
+ # coming in. Text and close frames are what the server sends, with pings
15
+ # answered as they arrive. TLS is OpenSSL's, with the peer's certificate and
16
+ # host name checked.
17
+ class WebSocket
18
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
19
+ # Generous for one event, which is a few hundred bytes of ids.
20
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024
21
+
22
+ OP_CONTINUATION = 0x0
23
+ OP_TEXT = 0x1
24
+ OP_BINARY = 0x2
25
+ OP_CLOSE = 0x8
26
+ OP_PING = 0x9
27
+ OP_PONG = 0xA
28
+
29
+ # The server answered the upgrade with something other than 101.
30
+ class Refused < StandardError
31
+ attr_reader :status
32
+
33
+ def initialize(status)
34
+ @status = status
35
+ super("the server answered #{status} to the upgrade")
36
+ end
37
+ end
38
+
39
+ # The connection broke, or the server spoke something that is not the
40
+ # protocol.
41
+ class Broken < StandardError; end
42
+
43
+ # What `read` returns: a whole text or binary message, or the server's
44
+ # close.
45
+ Frame = Struct.new(:type, :data, :code, :reason, keyword_init: true)
46
+
47
+ # Connects and upgrades. Raises Refused for an answer other than 101, and
48
+ # Broken or a socket error for anything else.
49
+ def self.connect(url, protocols: [], headers: {}, timeout: 15)
50
+ uri = URI.parse(url)
51
+ secure = uri.scheme == "wss"
52
+ raise Broken, "not a WebSocket address: #{url}" unless %w[ws wss].include?(uri.scheme) && uri.host
53
+
54
+ socket = Socket.tcp(uri.host, uri.port || (secure ? 443 : 80), connect_timeout: timeout)
55
+ socket = tls(socket, uri.host, timeout) if secure
56
+ client = new(socket)
57
+ client.__send__(:handshake, uri, protocols, headers, timeout)
58
+ client
59
+ rescue StandardError
60
+ socket&.close
61
+ raise
62
+ end
63
+
64
+ # TLS over a connected socket, with the certificate and host name checked.
65
+ def self.tls(socket, host, timeout)
66
+ context = OpenSSL::SSL::SSLContext.new
67
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
68
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, context)
69
+ ssl.hostname = host
70
+ ssl.sync_close = true
71
+ loop do
72
+ case ssl.connect_nonblock(exception: false)
73
+ when :wait_readable
74
+ raise Broken, "the TLS handshake timed out" unless socket.wait_readable(timeout)
75
+ when :wait_writable
76
+ raise Broken, "the TLS handshake timed out" unless socket.wait_writable(timeout)
77
+ else
78
+ break
79
+ end
80
+ end
81
+ ssl.post_connection_check(host)
82
+ ssl
83
+ rescue StandardError
84
+ socket.close
85
+ raise
86
+ end
87
+ private_class_method :tls
88
+
89
+ def initialize(socket)
90
+ @socket = socket
91
+ @buffer = +"".b
92
+ @write_lock = Mutex.new
93
+ @fragments = nil
94
+ @closed = false
95
+ end
96
+
97
+ # The subprotocol the server chose.
98
+ attr_reader :protocol
99
+
100
+ # The next message, or nil when `timeout` seconds pass without one. Pings
101
+ # are answered on the way.
102
+ def read(timeout: nil)
103
+ deadline = timeout && (monotonic + timeout)
104
+ loop do
105
+ frame = take_frame
106
+ if frame.nil?
107
+ remaining = deadline && (deadline - monotonic)
108
+ return nil if remaining && remaining <= 0
109
+
110
+ fill(remaining)
111
+ next
112
+ end
113
+ message = handle(*frame)
114
+ return message if message
115
+ end
116
+ end
117
+
118
+ def send_text(text)
119
+ write_frame(OP_TEXT, text.to_s.b)
120
+ end
121
+
122
+ # Sends a close frame, best effort, and closes the socket.
123
+ def close(code = 1000, reason = "")
124
+ return if @closed
125
+
126
+ begin
127
+ write_frame(OP_CLOSE, [code].pack("n") + reason.to_s.b)
128
+ rescue StandardError
129
+ # Already gone: closing is all that is left.
130
+ end
131
+ close_socket
132
+ end
133
+
134
+ def close_socket
135
+ @closed = true
136
+ @socket.close
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ def closed?
142
+ @closed
143
+ end
144
+
145
+ private
146
+
147
+ def monotonic
148
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ end
150
+
151
+ def handshake(uri, protocols, headers, timeout)
152
+ key = [Crypto::Primitives.random_bytes(16)].pack("m0")
153
+ path = uri.request_uri
154
+ host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
155
+ lines = [
156
+ "GET #{path} HTTP/1.1",
157
+ "Host: #{host}",
158
+ "Upgrade: websocket",
159
+ "Connection: Upgrade",
160
+ "Sec-WebSocket-Key: #{key}",
161
+ "Sec-WebSocket-Version: 13"
162
+ ]
163
+ lines << "Sec-WebSocket-Protocol: #{protocols.join(', ')}" unless protocols.empty?
164
+ headers.each { |name, value| lines << "#{name}: #{value}" }
165
+ @write_lock.synchronize { @socket.write("#{lines.join("\r\n")}\r\n\r\n") }
166
+
167
+ status, answer = read_response_head(timeout)
168
+ raise Refused, status unless status == 101
169
+
170
+ expected = [OpenSSL::Digest::SHA1.digest(key + GUID)].pack("m0")
171
+ raise Broken, "the server's handshake does not match" unless answer["sec-websocket-accept"] == expected
172
+
173
+ @protocol = answer["sec-websocket-protocol"]
174
+ return unless !protocols.empty? && !@protocol.nil? && !protocols.include?(@protocol)
175
+
176
+ raise Broken, "the server chose a subprotocol that was not offered"
177
+ end
178
+
179
+ def read_response_head(timeout)
180
+ deadline = monotonic + timeout
181
+ until (ends = @buffer.index("\r\n\r\n"))
182
+ raise Broken, "the upgrade answer is too long" if @buffer.bytesize > 64 * 1024
183
+
184
+ remaining = deadline - monotonic
185
+ raise Broken, "the upgrade timed out" if remaining <= 0
186
+
187
+ fill(remaining)
188
+ end
189
+ head = @buffer.byteslice(0, ends).force_encoding(::Encoding::BINARY)
190
+ @buffer = @buffer.byteslice(ends + 4, @buffer.bytesize - ends - 4)
191
+ status_line, *header_lines = head.split("\r\n")
192
+ status = status_line.to_s[%r{\AHTTP/1\.[01] ([0-9]{3})}, 1].to_i
193
+ answer = {}
194
+ header_lines.each do |line|
195
+ name, value = line.split(":", 2)
196
+ answer[name.strip.downcase] = value.to_s.strip if value
197
+ end
198
+ [status, answer]
199
+ end
200
+
201
+ # Reads whatever is there into the buffer, waiting up to `timeout`
202
+ # seconds (forever when nil). Raises Broken at the end of the stream.
203
+ def fill(timeout)
204
+ chunk = @socket.read_nonblock(64 * 1024, exception: false)
205
+ case chunk
206
+ when :wait_readable
207
+ @socket.to_io.wait_readable(timeout)
208
+ when :wait_writable
209
+ @socket.to_io.wait_writable(timeout)
210
+ when nil
211
+ raise Broken, "the connection closed"
212
+ else
213
+ @buffer << chunk
214
+ end
215
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
216
+ raise Broken, e.message
217
+ end
218
+
219
+ # One whole frame off the front of the buffer, or nil if it has not all
220
+ # arrived: `[fin, opcode, payload]`.
221
+ def take_frame
222
+ return nil if @buffer.bytesize < 2
223
+
224
+ first = @buffer.getbyte(0)
225
+ second = @buffer.getbyte(1)
226
+ raise Broken, "the server masked a frame" if second & 0x80 != 0
227
+ raise Broken, "a frame used reserved bits" if first & 0x70 != 0
228
+
229
+ length = second & 0x7f
230
+ offset = 2
231
+ if length == 126
232
+ return nil if @buffer.bytesize < 4
233
+
234
+ length = @buffer.byteslice(2, 2).unpack1("n")
235
+ offset = 4
236
+ elsif length == 127
237
+ return nil if @buffer.bytesize < 10
238
+
239
+ length = @buffer.byteslice(2, 8).unpack1("Q>")
240
+ offset = 10
241
+ end
242
+ raise Broken, "a frame is too large" if length > MAX_MESSAGE_BYTES
243
+ return nil if @buffer.bytesize < offset + length
244
+
245
+ payload = @buffer.byteslice(offset, length)
246
+ @buffer = @buffer.byteslice(offset + length, @buffer.bytesize - offset - length)
247
+ [first & 0x80 != 0, first & 0x0f, payload]
248
+ end
249
+
250
+ # A Frame for a whole message or a close, nil for anything handled here.
251
+ def handle(fin, opcode, payload)
252
+ case opcode
253
+ when OP_PING
254
+ write_frame(OP_PONG, payload)
255
+ nil
256
+ when OP_PONG
257
+ nil
258
+ when OP_CLOSE
259
+ code = payload.bytesize >= 2 ? payload.unpack1("n") : 1005
260
+ reason = if payload.bytesize > 2
261
+ payload.byteslice(2,
262
+ payload.bytesize - 2).force_encoding(::Encoding::UTF_8)
263
+ else
264
+ ""
265
+ end
266
+ Frame.new(type: :close, code: code, reason: reason.valid_encoding? ? reason : reason.scrub)
267
+ when OP_TEXT, OP_BINARY
268
+ raise Broken, "a new message began inside another" if @fragments
269
+
270
+ return message(opcode, payload) if fin
271
+
272
+ @fragments = [opcode, payload.dup]
273
+ nil
274
+ when OP_CONTINUATION
275
+ raise Broken, "a continuation with nothing to continue" unless @fragments
276
+
277
+ @fragments[1] << payload
278
+ raise Broken, "a message is too large" if @fragments[1].bytesize > MAX_MESSAGE_BYTES
279
+ return nil unless fin
280
+
281
+ opcode, data = @fragments
282
+ @fragments = nil
283
+ message(opcode, data)
284
+ else
285
+ raise Broken, "an unknown frame type #{opcode}"
286
+ end
287
+ end
288
+
289
+ def message(opcode, data)
290
+ return Frame.new(type: :binary, data: data) if opcode == OP_BINARY
291
+
292
+ text = data.force_encoding(::Encoding::UTF_8)
293
+ raise Broken, "a text frame that is not UTF-8" unless text.valid_encoding?
294
+
295
+ Frame.new(type: :text, data: text)
296
+ end
297
+
298
+ def write_frame(opcode, payload)
299
+ payload = payload.b
300
+ mask = Crypto::Primitives.random_bytes(4)
301
+ head = [0x80 | opcode].pack("C")
302
+ length = payload.bytesize
303
+ head << if length < 126
304
+ [0x80 | length].pack("C")
305
+ elsif length < 65_536
306
+ [0x80 | 126, length].pack("Cn")
307
+ else
308
+ [0x80 | 127, length].pack("CQ>")
309
+ end
310
+ @write_lock.synchronize { @socket.write(head + mask + masked(payload, mask)) }
311
+ end
312
+
313
+ def masked(payload, mask)
314
+ keys = mask.bytes
315
+ out = payload.bytes
316
+ out.each_index { |i| out[i] ^= keys[i & 3] }
317
+ out.pack("C*")
318
+ end
319
+ end
320
+ end
data/lib/mailcycle.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mailcycle for Ruby.
4
+ #
5
+ # Privacy-first email you can automate. Create addresses that receive mail,
6
+ # read and decrypt it on your own machine, send, and listen for events.
7
+ #
8
+ # require "mailcycle"
9
+ #
10
+ # mc = Mailcycle.sign_in(ENV.fetch("MAILCYCLE_PHRASE"))
11
+ # address = mc.addresses.create(label: "Sign-in codes")
12
+ # message = mc.messages.wait_for(address_id: address.id)
13
+ # puts "#{message.subject}: #{message.text}"
14
+ #
15
+ # The recovery phrase never leaves this machine. Mail is sealed to a key
16
+ # derived from it, so the API serves ciphertext it cannot read, and this
17
+ # library opens it locally. Lose the phrase and the mail is gone; nobody at
18
+ # Mailcycle can help.
19
+ #
20
+ # Documentation: https://docs.mailcycle.email/
21
+
22
+ require_relative "mailcycle/version"
23
+ require_relative "mailcycle/errors"
24
+ require_relative "mailcycle/encoding"
25
+ require_relative "mailcycle/crypto/primitives"
26
+ require_relative "mailcycle/crypto/wordlist"
27
+ require_relative "mailcycle/crypto/mnemonic"
28
+ require_relative "mailcycle/crypto/identity"
29
+ require_relative "mailcycle/crypto/cipher"
30
+ require_relative "mailcycle/crypto/sealed_box"
31
+ require_relative "mailcycle/crypto/asymmetric"
32
+ require_relative "mailcycle/crypto/auth_proof"
33
+ require_relative "mailcycle/keys"
34
+ require_relative "mailcycle/http"
35
+ require_relative "mailcycle/models"
36
+ require_relative "mailcycle/trackers"
37
+ require_relative "mailcycle/mail"
38
+ require_relative "mailcycle/webhook_signature"
39
+ require_relative "mailcycle/websocket"
40
+ require_relative "mailcycle/events"
41
+ require_relative "mailcycle/client"
42
+ require_relative "mailcycle/manage"
43
+ require_relative "mailcycle/pairing"
44
+
45
+ # The library's namespace, and shortcuts to what most programs need.
46
+ module Mailcycle
47
+ module_function
48
+
49
+ # Signs in with the recovery phrase. See Client.sign_in.
50
+ def sign_in(phrase, **options)
51
+ Client.sign_in(phrase, **options)
52
+ end
53
+
54
+ # Picks up a session from an earlier sign-in. See Client.resume.
55
+ def resume(session_token, phrase, **options)
56
+ Client.resume(session_token, phrase, **options)
57
+ end
58
+
59
+ # Uses an API key, with or without the phrase. See Client.with_api_key.
60
+ def with_api_key(api_key, phrase: nil, **options)
61
+ Client.with_api_key(api_key, phrase: phrase, **options)
62
+ end
63
+
64
+ # Creates an account and signs in. Returns `[client, phrase]`. See
65
+ # Client.create_account.
66
+ def create_account(phrase = nil, **options)
67
+ Client.create_account(phrase, **options)
68
+ end
69
+
70
+ # A new twelve-word recovery phrase, generated on this machine.
71
+ def generate_phrase
72
+ Client.generate_phrase
73
+ end
74
+
75
+ # nil for a good phrase, otherwise a PhraseProblem saying what is wrong.
76
+ def validate_phrase(phrase)
77
+ Crypto::Mnemonic.validate(phrase)
78
+ end
79
+
80
+ # True if a webhook delivery is Mailcycle's, recent, and over this exact
81
+ # body. Pass the body exactly as it arrived, before any parsing, and the
82
+ # `Mailcycle-Signature` header.
83
+ def verify_webhook_signature(secret, raw_body, header, tolerance: WebhookSignature::DEFAULT_TOLERANCE_SECONDS,
84
+ now: Time.now.to_i)
85
+ WebhookSignature.verify(secret, raw_body, header, tolerance: tolerance, now: now)
86
+ end
87
+
88
+ # The `Mailcycle-Signature` header Mailcycle would send for this body at
89
+ # this second, for building a believable delivery in a test.
90
+ def webhook_signature_header(secret, raw_body, timestamp)
91
+ WebhookSignature.header(secret, raw_body, timestamp)
92
+ end
93
+
94
+ # Reads a pairing QR code's text or a typed code. See
95
+ # Pairing.parse_scanned_value.
96
+ def parse_scanned_value(raw)
97
+ Pairing.parse_scanned_value(raw)
98
+ end
99
+
100
+ # The HTML with everything remote removed, and what was. See Trackers.
101
+ def strip_remote_content(html)
102
+ Trackers.strip_remote_content(html)
103
+ end
104
+
105
+ # A one-line summary of what was blocked, as the app shows above a message.
106
+ def summarize_blocked(blocked)
107
+ Trackers.summarize_blocked(blocked)
108
+ end
109
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailcycle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Northlab Studios Ltd
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: openssl
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ description: Create addresses that receive mail, read and decrypt it on your machine,
27
+ send, and listen for events.
28
+ email:
29
+ - hello@mailcycle.email
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - lib/mailcycle.rb
37
+ - lib/mailcycle/client.rb
38
+ - lib/mailcycle/crypto/asymmetric.rb
39
+ - lib/mailcycle/crypto/auth_proof.rb
40
+ - lib/mailcycle/crypto/cipher.rb
41
+ - lib/mailcycle/crypto/identity.rb
42
+ - lib/mailcycle/crypto/mnemonic.rb
43
+ - lib/mailcycle/crypto/primitives.rb
44
+ - lib/mailcycle/crypto/sealed_box.rb
45
+ - lib/mailcycle/crypto/wordlist.rb
46
+ - lib/mailcycle/encoding.rb
47
+ - lib/mailcycle/errors.rb
48
+ - lib/mailcycle/events.rb
49
+ - lib/mailcycle/http.rb
50
+ - lib/mailcycle/keys.rb
51
+ - lib/mailcycle/mail.rb
52
+ - lib/mailcycle/manage.rb
53
+ - lib/mailcycle/models.rb
54
+ - lib/mailcycle/pairing.rb
55
+ - lib/mailcycle/trackers.rb
56
+ - lib/mailcycle/version.rb
57
+ - lib/mailcycle/webhook_signature.rb
58
+ - lib/mailcycle/websocket.rb
59
+ homepage: https://mailcycle.email/
60
+ licenses:
61
+ - Apache-2.0
62
+ metadata:
63
+ homepage_uri: https://mailcycle.email/
64
+ documentation_uri: https://docs.mailcycle.email/
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '3.1'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.20
81
+ specification_version: 4
82
+ summary: Ruby client for the Mailcycle API.
83
+ test_files: []