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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +208 -0
- data/lib/mailcycle/client.rb +803 -0
- data/lib/mailcycle/crypto/asymmetric.rb +126 -0
- data/lib/mailcycle/crypto/auth_proof.rb +58 -0
- data/lib/mailcycle/crypto/cipher.rb +136 -0
- data/lib/mailcycle/crypto/identity.rb +170 -0
- data/lib/mailcycle/crypto/mnemonic.rb +126 -0
- data/lib/mailcycle/crypto/primitives.rb +116 -0
- data/lib/mailcycle/crypto/sealed_box.rb +93 -0
- data/lib/mailcycle/crypto/wordlist.rb +171 -0
- data/lib/mailcycle/encoding.rb +93 -0
- data/lib/mailcycle/errors.rb +82 -0
- data/lib/mailcycle/events.rb +310 -0
- data/lib/mailcycle/http.rb +113 -0
- data/lib/mailcycle/keys.rb +81 -0
- data/lib/mailcycle/mail.rb +143 -0
- data/lib/mailcycle/manage.rb +202 -0
- data/lib/mailcycle/models.rb +243 -0
- data/lib/mailcycle/pairing.rb +338 -0
- data/lib/mailcycle/trackers.rb +312 -0
- data/lib/mailcycle/version.rb +5 -0
- data/lib/mailcycle/webhook_signature.rb +60 -0
- data/lib/mailcycle/websocket.rb +320 -0
- data/lib/mailcycle.rb +109 -0
- metadata +83 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailcycle
|
|
4
|
+
module Crypto
|
|
5
|
+
# The wire shape the mail server writes into a message's content.
|
|
6
|
+
AsymmetricBox = Struct.new(:v, :epk, :n, :c, :t, keyword_init: true) do
|
|
7
|
+
# The box a wire value describes, or nil if it is not one.
|
|
8
|
+
def self.from_wire(value)
|
|
9
|
+
return nil unless value.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
fields = value.values_at("epk", "n", "c", "t")
|
|
12
|
+
return nil unless fields.all?(String)
|
|
13
|
+
|
|
14
|
+
version = value.fetch("v", 1)
|
|
15
|
+
return nil unless version.is_a?(Integer) && version.between?(0, 255)
|
|
16
|
+
|
|
17
|
+
epk, n, c, t = fields
|
|
18
|
+
new(v: version, epk: epk, n: n, c: c, t: t)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def version
|
|
22
|
+
v == 2 ? 2 : 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_wire
|
|
26
|
+
{ "v" => v, "epk" => epk, "n" => n, "c" => c, "t" => t }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_json(*args)
|
|
30
|
+
to_wire.to_json(*args)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The wire shapes around SealedBoxes: the JSON box the API stores for a
|
|
35
|
+
# message, and the fixed-width framing an attachment body uses in object
|
|
36
|
+
# storage.
|
|
37
|
+
module Asymmetric
|
|
38
|
+
# "MCA1" and "MCA2": Mailcycle attachment, by version. The layout is the
|
|
39
|
+
# same; the magic is the only thing that says whether the MAC framed the
|
|
40
|
+
# additional data.
|
|
41
|
+
MAGIC_V1 = "MCA1".b
|
|
42
|
+
MAGIC_V2 = "MCA2".b
|
|
43
|
+
ATTACHMENT_HEADER = 4 + Primitives::KEY_BYTES + 12 + 32
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def seal_to_public_key(recipient_public_key, plaintext, aad, version = Cipher::SEAL_VERSION)
|
|
48
|
+
box = SealedBoxes.seal_raw(recipient_public_key, plaintext, aad, version)
|
|
49
|
+
AsymmetricBox.new(
|
|
50
|
+
v: version,
|
|
51
|
+
epk: Mailcycle::Encoding.to_b64u(box.ephemeral_public_key),
|
|
52
|
+
n: Mailcycle::Encoding.to_b64u(box.nonce),
|
|
53
|
+
c: Mailcycle::Encoding.to_b64u(box.ciphertext),
|
|
54
|
+
t: Mailcycle::Encoding.to_b64u(box.tag)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def open_with_private_key(private_key, box, aad)
|
|
59
|
+
SealedBoxes.open_raw(private_key, decode(box), aad, box.version)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def open_json_with_private_key(private_key, box, aad)
|
|
63
|
+
Crypto.parse_json(open_with_private_key(private_key, box, aad))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def decode(box)
|
|
67
|
+
epk, nonce, ciphertext, tag = [box.epk, box.n, box.c, box.t].map { |f| Mailcycle::Encoding.from_b64u(f) }
|
|
68
|
+
SealedBoxes::RawSealedBox.new(ephemeral_public_key: epk, nonce: nonce, ciphertext: ciphertext, tag: tag)
|
|
69
|
+
rescue Base64UrlError
|
|
70
|
+
raise DecryptionError
|
|
71
|
+
end
|
|
72
|
+
private_class_method :decode
|
|
73
|
+
|
|
74
|
+
# Opens mail the mail server sealed for one address.
|
|
75
|
+
#
|
|
76
|
+
# The additional data is the address id AND the message id, matching
|
|
77
|
+
# what the server seals with. The address binding stops a ciphertext
|
|
78
|
+
# being moved between mailboxes; the message binding stops it being
|
|
79
|
+
# replayed within one. Without the second, a server holding no key could
|
|
80
|
+
# re-file an old message as a new arrival.
|
|
81
|
+
def open_inbound_mail(vault_key, address_id, message_id, box)
|
|
82
|
+
pair = Identity.address_key_pair(vault_key, address_id)
|
|
83
|
+
raise DecryptionError if pair.nil?
|
|
84
|
+
|
|
85
|
+
open_with_private_key(pair[0], box, "#{address_id}:#{message_id}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Opens an attachment body.
|
|
89
|
+
#
|
|
90
|
+
# Raw bytes rather than JSON, because object storage holds bytes and
|
|
91
|
+
# base64 would cost a third of the size for nothing. The layout is
|
|
92
|
+
# fixed-width, so this slices rather than parses:
|
|
93
|
+
#
|
|
94
|
+
# magic(4) | ephemeral public key(32) | nonce(12) | tag(32) | ciphertext
|
|
95
|
+
#
|
|
96
|
+
# The additional data is the address id and the object's own key, so an
|
|
97
|
+
# object served from the wrong row fails the MAC instead of handing back
|
|
98
|
+
# the wrong file under the right name.
|
|
99
|
+
def open_attachment_with_key(private_key, address_id, object_key, data)
|
|
100
|
+
data = data.b
|
|
101
|
+
raise DecryptionError if data.bytesize < ATTACHMENT_HEADER
|
|
102
|
+
|
|
103
|
+
version = case data.byteslice(0, 4)
|
|
104
|
+
when MAGIC_V1 then 1
|
|
105
|
+
when MAGIC_V2 then 2
|
|
106
|
+
else raise DecryptionError
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
box = SealedBoxes::RawSealedBox.new(
|
|
110
|
+
ephemeral_public_key: data.byteslice(4, 32),
|
|
111
|
+
nonce: data.byteslice(36, 12),
|
|
112
|
+
tag: data.byteslice(48, 32),
|
|
113
|
+
ciphertext: data.byteslice(ATTACHMENT_HEADER, data.bytesize - ATTACHMENT_HEADER)
|
|
114
|
+
)
|
|
115
|
+
SealedBoxes.open_raw(private_key, box, "#{address_id}:#{object_key}", version)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def open_attachment(vault_key, address_id, object_key, data)
|
|
119
|
+
pair = Identity.address_key_pair(vault_key, address_id)
|
|
120
|
+
raise DecryptionError if pair.nil?
|
|
121
|
+
|
|
122
|
+
open_attachment_with_key(pair[0], address_id, object_key, data)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailcycle
|
|
4
|
+
module Crypto
|
|
5
|
+
# Proving an account without handing over its secret.
|
|
6
|
+
#
|
|
7
|
+
# The account holds an X25519 keypair derived from the seed. Only the
|
|
8
|
+
# public half is uploaded, once, at account creation. Unlocking is a
|
|
9
|
+
# challenge-response and the private half never leaves this machine:
|
|
10
|
+
#
|
|
11
|
+
# 1. The client asks for a challenge, naming the account id.
|
|
12
|
+
# 2. The server mints a single-use nonce AND an ephemeral X25519 keypair,
|
|
13
|
+
# keeps the private half, and returns the nonce with the public half.
|
|
14
|
+
# 3. The client computes `X25519(account_private, ephemeral_public)` and
|
|
15
|
+
# answers with an HMAC over the nonce under that shared secret.
|
|
16
|
+
# 4. The server computes `X25519(ephemeral_private, account_public)`, which
|
|
17
|
+
# is the same value, and compares in constant time.
|
|
18
|
+
#
|
|
19
|
+
# Nothing secret is transmitted in either direction, and the answer is
|
|
20
|
+
# worthless afterwards because the nonce is consumed and the ephemeral key
|
|
21
|
+
# discarded.
|
|
22
|
+
#
|
|
23
|
+
# What is given up is third-party verifiability: an HMAC under a shared
|
|
24
|
+
# secret proves possession to THIS server and to nobody else, because the
|
|
25
|
+
# server can compute the same value. That is the correct shape for a login
|
|
26
|
+
# and the wrong shape for a signature, so nothing here should ever be
|
|
27
|
+
# repurposed as one.
|
|
28
|
+
module AuthProof
|
|
29
|
+
DOMAIN = "mailcycle/v1/auth-proof"
|
|
30
|
+
# Confirming the account's erasure, so an unlock proof cannot stand in for it.
|
|
31
|
+
DELETE_DOMAIN = "mailcycle/v1/delete-proof"
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# The answer to a challenge.
|
|
36
|
+
#
|
|
37
|
+
# The account id is inside the MAC as well as the nonce, so a proof
|
|
38
|
+
# captured for one account cannot be presented for another even if a
|
|
39
|
+
# server reused a nonce across them.
|
|
40
|
+
def prove_account(account_private_key, ephemeral_public_key_b64u, nonce, account_id, domain = DOMAIN)
|
|
41
|
+
begin
|
|
42
|
+
ephemeral_public_key = Mailcycle::Encoding.from_b64u(ephemeral_public_key_b64u)
|
|
43
|
+
rescue Base64UrlError
|
|
44
|
+
raise AuthProofError, "The server offered a malformed challenge key."
|
|
45
|
+
end
|
|
46
|
+
raise AuthProofError, "The server offered a malformed challenge key." unless ephemeral_public_key.bytesize == 32
|
|
47
|
+
|
|
48
|
+
shared = Primitives.shared_secret(account_private_key, ephemeral_public_key)
|
|
49
|
+
# A server that sent a small-order point would get back a MAC under a
|
|
50
|
+
# secret it already knows, which proves nothing and would let it accept
|
|
51
|
+
# anyone.
|
|
52
|
+
raise AuthProofError, "The server offered an unusable challenge key." if Primitives.all_zero?(shared)
|
|
53
|
+
|
|
54
|
+
Mailcycle::Encoding.to_b64u(Primitives.hmac_sha256(shared, "#{domain}:#{nonce}:#{account_id}"))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mailcycle
|
|
6
|
+
module Crypto
|
|
7
|
+
# Authenticated encryption: ChaCha20 for confidentiality, HMAC-SHA-256 for
|
|
8
|
+
# integrity, composed encrypt-then-MAC.
|
|
9
|
+
#
|
|
10
|
+
# ChaCha20-Poly1305 would be the textbook pairing. Mailcycle does not use it
|
|
11
|
+
# because the app's JavaScript cannot implement Poly1305 safely, and the
|
|
12
|
+
# format is the format everywhere or it is nothing. Encrypt-then-MAC with
|
|
13
|
+
# HMAC-SHA-256 is provably secure given independent keys, which is why one
|
|
14
|
+
# 64-byte secret is split into an encryption key and a separate MAC key
|
|
15
|
+
# rather than used for both.
|
|
16
|
+
#
|
|
17
|
+
# Versions:
|
|
18
|
+
#
|
|
19
|
+
# 1. The tag covers `nonce | ciphertext | additional data`, which does not
|
|
20
|
+
# say where the ciphertext ends, so bytes can be moved across that
|
|
21
|
+
# boundary and the tag still verifies.
|
|
22
|
+
# 2. The additional data's length, eight bytes big-endian, is appended,
|
|
23
|
+
# which makes the split unambiguous.
|
|
24
|
+
#
|
|
25
|
+
# Every reader must open both. New boxes are written as version 2 since
|
|
26
|
+
# 2026-09-19.
|
|
27
|
+
module Cipher
|
|
28
|
+
# Which version new boxes are written as.
|
|
29
|
+
SEAL_VERSION = 2
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Splits a 64-byte secret into the two independent keys the scheme needs.
|
|
34
|
+
def derive_subkeys(secret)
|
|
35
|
+
raise DecryptionError unless secret.is_a?(String) && secret.bytesize == 64
|
|
36
|
+
|
|
37
|
+
[secret.byteslice(0, 32), secret.byteslice(32, 32)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# What the tag covers, which is the only difference between the versions.
|
|
41
|
+
def mac_input(nonce, ciphertext, aad, version)
|
|
42
|
+
out = nonce.b + ciphertext.b + aad.b
|
|
43
|
+
out << Mailcycle::Encoding.be64(aad.bytesize) unless version == 1
|
|
44
|
+
out
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def seal(secret, plaintext, aad, version = SEAL_VERSION)
|
|
48
|
+
encryption, mac = derive_subkeys(secret)
|
|
49
|
+
nonce = Primitives.random_bytes(Primitives::NONCE_BYTES)
|
|
50
|
+
ciphertext = Primitives.chacha20(encryption, nonce, plaintext.b)
|
|
51
|
+
tag = Primitives.hmac_sha256(mac, mac_input(nonce, ciphertext, aad, version))
|
|
52
|
+
SealedBox.new(v: version, n: Mailcycle::Encoding.to_b64u(nonce),
|
|
53
|
+
c: Mailcycle::Encoding.to_b64u(ciphertext), t: Mailcycle::Encoding.to_b64u(tag))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def open(secret, box, aad)
|
|
57
|
+
encryption, mac = derive_subkeys(secret)
|
|
58
|
+
nonce, ciphertext, tag = decode(box)
|
|
59
|
+
|
|
60
|
+
# Shape first, then authenticity, then decrypt. A wrong-length nonce
|
|
61
|
+
# could not produce a valid tag anyway, but the cheap structural check
|
|
62
|
+
# belongs before the cryptographic one rather than after it.
|
|
63
|
+
raise DecryptionError unless nonce.bytesize == Primitives::NONCE_BYTES
|
|
64
|
+
|
|
65
|
+
expected = Primitives.hmac_sha256(mac, mac_input(nonce, ciphertext, aad, box.version))
|
|
66
|
+
# Verify before decrypting. Never act on unauthenticated ciphertext.
|
|
67
|
+
raise DecryptionError unless Primitives.constant_time_equal(expected, tag)
|
|
68
|
+
|
|
69
|
+
Primitives.chacha20(encryption, nonce, ciphertext)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode(box)
|
|
73
|
+
[box.n, box.c, box.t].map { |field| Mailcycle::Encoding.from_b64u(field) }
|
|
74
|
+
rescue Base64UrlError
|
|
75
|
+
raise DecryptionError
|
|
76
|
+
end
|
|
77
|
+
private_class_method :decode
|
|
78
|
+
|
|
79
|
+
def seal_json(secret, value, aad, version = SEAL_VERSION)
|
|
80
|
+
seal(secret, JSON.generate(value), aad, version)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A box whose plaintext is not JSON is unreadable, not a crash.
|
|
84
|
+
#
|
|
85
|
+
# That is reachable without the key: in version 1 the MAC does not frame
|
|
86
|
+
# the boundary between ciphertext and additional data, so a server can
|
|
87
|
+
# move bytes across it and leave the plaintext malformed.
|
|
88
|
+
def open_json(secret, box, aad)
|
|
89
|
+
Crypto.parse_json(Cipher.open(secret, box, aad))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A sealed payload, in the wire shape the API uses.
|
|
94
|
+
SealedBox = Struct.new(:v, :n, :c, :t, keyword_init: true) do
|
|
95
|
+
# The box a wire value describes, or nil if it is not one.
|
|
96
|
+
def self.from_wire(value)
|
|
97
|
+
return nil unless value.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
n, c, t = value.values_at("n", "c", "t")
|
|
100
|
+
return nil unless [n, c, t].all?(String)
|
|
101
|
+
|
|
102
|
+
version = value.fetch("v", 1)
|
|
103
|
+
return nil unless version.is_a?(Integer) && version.between?(0, 255)
|
|
104
|
+
|
|
105
|
+
new(v: version, n: n, c: c, t: t)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The version this box says it is.
|
|
109
|
+
#
|
|
110
|
+
# A box claiming the wrong one computes a different tag and fails, so
|
|
111
|
+
# this is not something a server can talk a reader into.
|
|
112
|
+
def version
|
|
113
|
+
v == 2 ? 2 : 1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def to_wire
|
|
117
|
+
{ "v" => v, "n" => n, "c" => c, "t" => t }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def to_json(*args)
|
|
121
|
+
to_wire.to_json(*args)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# JSON text to a value, or a DecryptionError for anything that is not
|
|
126
|
+
# UTF-8 JSON.
|
|
127
|
+
def self.parse_json(bytes)
|
|
128
|
+
text = bytes.dup.force_encoding(::Encoding::UTF_8)
|
|
129
|
+
raise DecryptionError unless text.valid_encoding?
|
|
130
|
+
|
|
131
|
+
JSON.parse(text)
|
|
132
|
+
rescue JSON::ParserError
|
|
133
|
+
raise DecryptionError
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailcycle
|
|
4
|
+
module Crypto
|
|
5
|
+
# Account identity derived from a recovery phrase.
|
|
6
|
+
#
|
|
7
|
+
# Everything below comes out of one 64-byte root seed, which never leaves
|
|
8
|
+
# this machine. The split matters, so it is spelled out rather than left
|
|
9
|
+
# implicit:
|
|
10
|
+
#
|
|
11
|
+
# - `account_id`, public: the ONLY identifier the server learns. An opaque
|
|
12
|
+
# hash. It carries no email, no name, no device, and nothing that links
|
|
13
|
+
# two accounts to one person.
|
|
14
|
+
# - `auth_secret`, private: the old proof of possession. Still derived,
|
|
15
|
+
# because an account created before the keypair existed was registered
|
|
16
|
+
# with its hash.
|
|
17
|
+
# - `auth_key`, private: the X25519 key that proves this account now. Only
|
|
18
|
+
# its public half is ever uploaded, once.
|
|
19
|
+
# - `vault_key`, private: encrypts mail, address labels and device names.
|
|
20
|
+
# Never transmitted in any form. Lose the phrase and the mail is gone;
|
|
21
|
+
# nobody at Mailcycle can help.
|
|
22
|
+
#
|
|
23
|
+
# The domain-separation strings are part of the format. Changing one
|
|
24
|
+
# changes every account id and every key derived after it.
|
|
25
|
+
module Identity
|
|
26
|
+
LABEL_PREFIX = "mailcycle/v1/"
|
|
27
|
+
LABEL_PREFIX_V2 = "mailcycle/v2/"
|
|
28
|
+
MAX_EPOCH = (1 << 31) - 1
|
|
29
|
+
SAFE_LABEL = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
30
|
+
|
|
31
|
+
AccountIdentity = Struct.new(:account_id, :auth_secret, :auth_key, :auth_public_key, :vault_key,
|
|
32
|
+
keyword_init: true) do
|
|
33
|
+
def inspect
|
|
34
|
+
# Never the key material, however convenient that would be in a log.
|
|
35
|
+
"#<Mailcycle::Crypto::Identity::AccountIdentity account_id=#{account_id.inspect}>"
|
|
36
|
+
end
|
|
37
|
+
alias_method :to_s, :inspect
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# A labelled subkey of a seed. The labels are part of the format.
|
|
43
|
+
def subkey(seed, label, length)
|
|
44
|
+
first = Primitives.hmac_sha256(seed, "#{LABEL_PREFIX}#{label}")
|
|
45
|
+
return first.byteslice(0, length) if length <= first.bytesize
|
|
46
|
+
|
|
47
|
+
second = Primitives.hmac_sha256(seed, "#{LABEL_PREFIX}#{label}/2")
|
|
48
|
+
(first + second).byteslice(0, length)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A labelled subkey under the version 2 derivation: HKDF-Expand-SHA256
|
|
52
|
+
# (RFC 5869) with the key as the PRK and `mailcycle/v2/<label>` as the
|
|
53
|
+
# info.
|
|
54
|
+
#
|
|
55
|
+
# `subkey` concatenates its label and makes its second block by appending
|
|
56
|
+
# `/2`, so one label's second block can be another label's first. Here
|
|
57
|
+
# the counter sits outside the label, so two labels share output only if
|
|
58
|
+
# they are the same label. Only new kinds of key use it, so no account id
|
|
59
|
+
# or existing key changes. nil for a length outside 1 to 8160 bytes.
|
|
60
|
+
def subkey_v2(key, label, length)
|
|
61
|
+
Primitives.hkdf_expand(key, "#{LABEL_PREFIX_V2}#{label}", length)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Refuses an identifier that could reshape the derivation label around it.
|
|
65
|
+
#
|
|
66
|
+
# `subkey` builds its second block by appending `/2`, and the label is
|
|
67
|
+
# concatenated rather than length-prefixed. So for an id `X`, the MAC
|
|
68
|
+
# half of `address_key(X)` is also the ENCRYPTION half of
|
|
69
|
+
# `address_key("X/2")`. A server that handed one account the ids `X` and
|
|
70
|
+
# `X/2` could recover keystream from the second and hold the first's MAC
|
|
71
|
+
# key.
|
|
72
|
+
#
|
|
73
|
+
# Length-prefixing inside `subkey` is the deeper fix and would change
|
|
74
|
+
# every key this product has ever derived, account ids included.
|
|
75
|
+
# Rejecting the characters that make the collision reachable costs
|
|
76
|
+
# nothing and closes it, because the attack needs a separator inside the
|
|
77
|
+
# id.
|
|
78
|
+
def safe_label?(id)
|
|
79
|
+
id.is_a?(String) && id.match?(SAFE_LABEL)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def identity_from_seed(seed)
|
|
83
|
+
auth_key = subkey(seed, "auth-key", 32)
|
|
84
|
+
AccountIdentity.new(
|
|
85
|
+
account_id: "acct_#{Mailcycle::Encoding.to_hex(subkey(seed, 'account-id', 16))}",
|
|
86
|
+
auth_secret: subkey(seed, "auth", 32),
|
|
87
|
+
auth_key: auth_key,
|
|
88
|
+
auth_public_key: Mailcycle::Encoding.to_b64u(Primitives.public_key_from(auth_key)),
|
|
89
|
+
vault_key: subkey(seed, "vault", 64)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def identity_from_phrase(phrase, passphrase = "")
|
|
94
|
+
identity_from_seed(Mnemonic.phrase_to_seed(phrase, passphrase))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The verifier the server is allowed to store.
|
|
98
|
+
#
|
|
99
|
+
# It is a hash, so a database dump does not let an attacker authenticate
|
|
100
|
+
# anywhere else and cannot be run backwards into the phrase or the vault
|
|
101
|
+
# key.
|
|
102
|
+
def auth_verifier(auth_secret)
|
|
103
|
+
Mailcycle::Encoding.to_b64u(Primitives.sha256(auth_secret))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The symmetric key for one address, 64 bytes, or nil for an unsafe id.
|
|
107
|
+
#
|
|
108
|
+
# Per address, so a key handed to one device unlocks that device's mail
|
|
109
|
+
# and nothing else, and removing a device does not mean re-encrypting the
|
|
110
|
+
# rest.
|
|
111
|
+
def address_key(vault_key, address_id)
|
|
112
|
+
return nil unless safe_label?(address_id)
|
|
113
|
+
|
|
114
|
+
subkey(vault_key, "inbox/#{address_id}", 64)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The address's X25519 `[private, public]` pair, derived and never
|
|
118
|
+
# stored, or nil for an unsafe id.
|
|
119
|
+
#
|
|
120
|
+
# Separate from `address_key` and under a different label: the symmetric
|
|
121
|
+
# key seals mail the account's own devices deliver, and this pair is what
|
|
122
|
+
# lets a mail server seal what arrives from outside without ever being
|
|
123
|
+
# able to read it. One secret for both roles would mean handing a paired
|
|
124
|
+
# device a key that also opens inbound mail for an address it was never
|
|
125
|
+
# assigned.
|
|
126
|
+
def address_key_pair(vault_key, address_id)
|
|
127
|
+
return nil unless safe_label?(address_id)
|
|
128
|
+
|
|
129
|
+
private_key = subkey(vault_key, "inbox-sealing/#{address_id}", 32)
|
|
130
|
+
[private_key, Primitives.public_key_from(private_key)]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def valid_epoch?(epoch)
|
|
134
|
+
epoch.is_a?(Integer) && epoch.between?(0, MAX_EPOCH)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The address's symmetric key at a key epoch.
|
|
138
|
+
#
|
|
139
|
+
# Epoch 0 is `address_key`, the key every address has had from the
|
|
140
|
+
# start. Later epochs come from `subkey_v2`: under `subkey`,
|
|
141
|
+
# `inbox/<id>/2` would be the second half of epoch 0's key. Old epochs
|
|
142
|
+
# stay derivable, so mail sealed under them still opens.
|
|
143
|
+
def address_key_at(vault_key, address_id, epoch)
|
|
144
|
+
return nil unless valid_epoch?(epoch)
|
|
145
|
+
return address_key(vault_key, address_id) if epoch.zero?
|
|
146
|
+
return nil unless safe_label?(address_id)
|
|
147
|
+
|
|
148
|
+
subkey_v2(vault_key, "inbox/#{address_id}/#{epoch}", 64)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The address's X25519 pair at a key epoch. Epoch 0 is `address_key_pair`.
|
|
152
|
+
def address_key_pair_at(vault_key, address_id, epoch)
|
|
153
|
+
return nil unless valid_epoch?(epoch)
|
|
154
|
+
return address_key_pair(vault_key, address_id) if epoch.zero?
|
|
155
|
+
return nil unless safe_label?(address_id)
|
|
156
|
+
|
|
157
|
+
private_key = subkey_v2(vault_key, "inbox-sealing/#{address_id}/#{epoch}", 32)
|
|
158
|
+
return nil if private_key.nil?
|
|
159
|
+
|
|
160
|
+
[private_key, Primitives.public_key_from(private_key)]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Short, human-comparable fingerprint, as the app shows when pairing.
|
|
164
|
+
def fingerprint(account_id)
|
|
165
|
+
digest = Mailcycle::Encoding.to_hex(Primitives.sha256(account_id))[0, 12].upcase
|
|
166
|
+
"#{digest[0, 4]}-#{digest[4, 4]}-#{digest[8, 4]}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailcycle
|
|
4
|
+
module Crypto
|
|
5
|
+
# Recovery phrases.
|
|
6
|
+
#
|
|
7
|
+
# A Mailcycle account is a twelve-word phrase. Nothing server-side can
|
|
8
|
+
# reset it.
|
|
9
|
+
#
|
|
10
|
+
# Encoding, as BIP-39 does it but over Mailcycle's own Wordlist: 128 bits
|
|
11
|
+
# of entropy, plus a 4-bit checksum taken from the first bits of
|
|
12
|
+
# SHA-256(entropy), split into twelve 11-bit groups, each an index into the
|
|
13
|
+
# list.
|
|
14
|
+
module Mnemonic
|
|
15
|
+
PHRASE_WORD_COUNT = 12
|
|
16
|
+
ENTROPY_BITS = 128
|
|
17
|
+
CHECKSUM_BITS = ENTROPY_BITS / 32
|
|
18
|
+
|
|
19
|
+
# Derivation cost.
|
|
20
|
+
#
|
|
21
|
+
# Changing it changes every account id ever derived, so this number is
|
|
22
|
+
# part of the format, not a tuning knob. Stretching buys little over a
|
|
23
|
+
# 128-bit phrase and earns its keep only for the optional passphrase,
|
|
24
|
+
# which may be low entropy.
|
|
25
|
+
SEED_ITERATIONS = 120_000
|
|
26
|
+
SEED_BYTES = 64
|
|
27
|
+
SEED_SALT_PREFIX = "mailcycle-seed"
|
|
28
|
+
|
|
29
|
+
# What JavaScript's `\s` matches, which is what the app splits on.
|
|
30
|
+
JS_WHITESPACE = "\t\n\v\f\r -
"
|
|
31
|
+
NOT_A_LETTER = /[^a-z#{JS_WHITESPACE}]/
|
|
32
|
+
WHITESPACE_RUN = /[#{JS_WHITESPACE}]+/
|
|
33
|
+
private_constant :NOT_A_LETTER, :WHITESPACE_RUN
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Lowercases, turns everything that is not a letter into a space, and
|
|
38
|
+
# splits on whitespace, as `normalizePhrase` in the app does.
|
|
39
|
+
def normalize(phrase)
|
|
40
|
+
text = phrase.to_s.dup.force_encoding(::Encoding::UTF_8).scrub
|
|
41
|
+
text.downcase.gsub(NOT_A_LETTER, " ").split(WHITESPACE_RUN).reject(&:empty?)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def entropy_to_phrase(entropy)
|
|
45
|
+
raise ArgumentError, "a phrase needs exactly 16 bytes of entropy" unless entropy.bytesize == ENTROPY_BITS / 8
|
|
46
|
+
|
|
47
|
+
checksum = Primitives.sha256(entropy).getbyte(0) >> (8 - CHECKSUM_BITS)
|
|
48
|
+
words = []
|
|
49
|
+
buffer = 0
|
|
50
|
+
bits = 0
|
|
51
|
+
entropy.each_byte do |byte|
|
|
52
|
+
buffer = (buffer << 8) | byte
|
|
53
|
+
bits += 8
|
|
54
|
+
while bits >= 11
|
|
55
|
+
bits -= 11
|
|
56
|
+
words << Wordlist::WORDS[(buffer >> bits) & 0x7ff]
|
|
57
|
+
buffer &= (1 << bits) - 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 128 bits leaves 7 over; the 4 checksum bits complete the last word.
|
|
62
|
+
words << Wordlist::WORDS[((buffer << CHECKSUM_BITS) | checksum) & 0x7ff]
|
|
63
|
+
words
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A fresh twelve-word phrase from the operating system's CSPRNG, as words.
|
|
67
|
+
def generate
|
|
68
|
+
entropy_to_phrase(Primitives.random_bytes(ENTROPY_BITS / 8))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Full validation, checksum included. nil when the phrase is good,
|
|
72
|
+
# otherwise a PhraseProblem.
|
|
73
|
+
#
|
|
74
|
+
# A single mistyped word fails here roughly fifteen times out of sixteen,
|
|
75
|
+
# which is what the checksum is for: it turns "your mail is gone" into
|
|
76
|
+
# "word 7 is wrong".
|
|
77
|
+
def validate(phrase)
|
|
78
|
+
words = normalize(phrase)
|
|
79
|
+
return PhraseProblem.new(kind: :length, count: words.length) if words.length != PHRASE_WORD_COUNT
|
|
80
|
+
|
|
81
|
+
indices = []
|
|
82
|
+
words.each_with_index do |word, position|
|
|
83
|
+
index = Wordlist.index_of(word)
|
|
84
|
+
return PhraseProblem.new(kind: :unknown_word, word: word, index: position) if index.nil?
|
|
85
|
+
|
|
86
|
+
indices << index
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
entropy = Array.new(ENTROPY_BITS / 8, 0)
|
|
90
|
+
buffer = 0
|
|
91
|
+
bits = 0
|
|
92
|
+
written = 0
|
|
93
|
+
indices.each do |index|
|
|
94
|
+
buffer = (buffer * 2048) + index
|
|
95
|
+
bits += 11
|
|
96
|
+
while bits >= 8 && written < entropy.length
|
|
97
|
+
bits -= 8
|
|
98
|
+
entropy[written] = (buffer >> bits) & 0xff
|
|
99
|
+
buffer %= 1 << bits
|
|
100
|
+
written += 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
expected = Primitives.sha256(entropy.pack("C*")).getbyte(0) >> (8 - CHECKSUM_BITS)
|
|
105
|
+
return PhraseProblem.new(kind: :checksum) if (buffer & ((1 << CHECKSUM_BITS) - 1)) != expected
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def valid_word?(word)
|
|
111
|
+
!Wordlist.index_of(word.to_s.downcase).nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Phrase to 64-byte root seed.
|
|
115
|
+
#
|
|
116
|
+
# The optional passphrase is BIP-39's "25th word": it changes which
|
|
117
|
+
# account the phrase opens, and nothing anywhere records whether one was
|
|
118
|
+
# used.
|
|
119
|
+
def phrase_to_seed(phrase, passphrase = "")
|
|
120
|
+
words = normalize(phrase).join(" ")
|
|
121
|
+
salt = SEED_SALT_PREFIX.b + passphrase.to_s.b
|
|
122
|
+
Primitives.pbkdf2_sha256(words, salt, SEED_ITERATIONS, SEED_BYTES)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|