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,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