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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module Mailcycle
7
+ # Mailcycle's encryption, in the order an implementation needs it.
8
+ #
9
+ # The scheme is specified in `packages/crypto-vectors/README.md` and pinned by
10
+ # `packages/crypto-vectors/vectors.json`, which this library's test suite runs
11
+ # against. Nothing here should be changed without changing that file too.
12
+ module Crypto
13
+ # The primitives, borrowed rather than written again.
14
+ #
15
+ # The app implements SHA-256, HMAC, PBKDF2, ChaCha20 and X25519 in its own
16
+ # JavaScript because React Native has no WebCrypto. Ruby does not have that
17
+ # problem: OpenSSL has every one of them, better tested and faster. What is
18
+ # written here is only what is specific to Mailcycle, and HKDF-Expand, which
19
+ # is four lines of HMAC.
20
+ #
21
+ # The one thing worth spelling out is ChaCha20. Mailcycle's counter starts
22
+ # at zero, as RFC 8439 and `src/crypto/chacha20.ts` do. OpenSSL's `chacha20`
23
+ # takes a 16-byte IV that is the 32-bit little-endian block counter followed
24
+ # by the 12-byte nonce, so the IV here is four zero bytes and then the nonce.
25
+ module Primitives
26
+ NONCE_BYTES = 12
27
+ KEY_BYTES = 32
28
+
29
+ module_function
30
+
31
+ def sha256(data)
32
+ OpenSSL::Digest::SHA256.digest(data)
33
+ end
34
+
35
+ def hmac_sha256(key, message)
36
+ OpenSSL::HMAC.digest("SHA256", key, message)
37
+ end
38
+
39
+ def pbkdf2_sha256(password, salt, iterations, length)
40
+ OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: length, hash: "SHA256")
41
+ end
42
+
43
+ # HKDF-Expand-SHA256 (RFC 5869), with `prk` used as it is: no extract
44
+ # step. nil for a length outside 1 to 8160 bytes, or a PRK shorter than
45
+ # the hash, which the RFC rules out.
46
+ def hkdf_expand(prk, info, length)
47
+ return nil unless length.is_a?(Integer) && length.between?(1, 255 * 32)
48
+ return nil if prk.bytesize < 32
49
+
50
+ out = +"".b
51
+ block = "".b
52
+ counter = 1
53
+ while out.bytesize < length
54
+ block = hmac_sha256(prk, block + info.b + [counter].pack("C"))
55
+ out << block
56
+ counter += 1
57
+ end
58
+ out.byteslice(0, length)
59
+ end
60
+
61
+ def constant_time_equal(left, right)
62
+ left = left.b
63
+ right = right.b
64
+ return false unless left.bytesize == right.bytesize
65
+
66
+ OpenSSL.fixed_length_secure_compare(left, right)
67
+ end
68
+
69
+ # RFC 8439 ChaCha20, counter from zero. The same call decrypts.
70
+ def chacha20(key, nonce, data)
71
+ raise ArgumentError, "ChaCha20 key must be 32 bytes" unless key.bytesize == KEY_BYTES
72
+ raise ArgumentError, "ChaCha20 nonce must be 12 bytes" unless nonce.bytesize == NONCE_BYTES
73
+ return "".b if data.empty?
74
+
75
+ cipher = OpenSSL::Cipher.new("chacha20")
76
+ cipher.encrypt
77
+ cipher.key = key
78
+ cipher.iv = "\x00\x00\x00\x00".b + nonce.b
79
+ cipher.update(data.b) + cipher.final
80
+ end
81
+
82
+ # The X25519 public half. The scalar is clamped, so any 32 bytes work.
83
+ def public_key_from(private_key)
84
+ OpenSSL::PKey.new_raw_private_key("X25519", private_key.b).raw_public_key
85
+ end
86
+
87
+ # X25519.
88
+ #
89
+ # Returns all zeroes where the public key had small order and contributed
90
+ # nothing; OpenSSL refuses to derive that at all, and the refusal is
91
+ # reported the same way. Every caller here checks the result before using
92
+ # it, because encrypting under a shared secret the other side already
93
+ # knows is the difference between encrypting and appearing to.
94
+ def shared_secret(private_key, public_key)
95
+ mine = OpenSSL::PKey.new_raw_private_key("X25519", private_key.b)
96
+ theirs = OpenSSL::PKey.new_raw_public_key("X25519", public_key.b)
97
+ mine.derive(theirs)
98
+ rescue OpenSSL::PKey::PKeyError
99
+ ("\x00" * 32).b
100
+ end
101
+
102
+ def all_zero?(data)
103
+ data.each_byte.all?(&:zero?)
104
+ end
105
+
106
+ # Bytes from the operating system's CSPRNG.
107
+ #
108
+ # A failure here is not recoverable and must never be papered over with
109
+ # a weaker source: a predictable nonce or ephemeral key would break the
110
+ # encryption while leaving it looking like it worked.
111
+ def random_bytes(length)
112
+ SecureRandom.random_bytes(length)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ module Crypto
5
+ # Sealing to a public key: how inbound mail reaches an address the server
6
+ # cannot read.
7
+ #
8
+ # Ephemeral-static ECDH, in the shape of a NaCl sealed box:
9
+ #
10
+ # shared = X25519(ephemeral_private, recipient_public)
11
+ # secret = HMAC(shared, transcript | "/enc") | HMAC(shared, transcript | "/mac")
12
+ # box = ChaCha20(secret[0..32], nonce, plaintext)
13
+ # + HMAC(secret[32..64], nonce | ciphertext | aad)
14
+ #
15
+ # where `transcript` is `"mailcycle/v1/seal-to-public" | shared |
16
+ # ephemeral_public | recipient_public`. Binding both public keys is what
17
+ # stops a box sealed to one address being replayed as one sealed to
18
+ # another. The raw curve output is never used as a key: it is a point, not
19
+ # uniform bytes, which is what the HMAC step fixes.
20
+ #
21
+ # It is NOT forward secrecy. The recipient's key is
22
+ # `subkey(vault_key, "inbox-sealing/...")`: deterministic, permanent, and
23
+ # re-derivable from the recovery phrase for as long as that phrase exists.
24
+ # Anyone who ever obtains the phrase can decrypt every message ever sealed
25
+ # to that address, including ones captured years earlier. There is no
26
+ # ratchet and no key rotation.
27
+ #
28
+ # It also does NOT authenticate the sender. Anyone holding the address's
29
+ # public key can seal to it, which is what an email address is. A
30
+ # successful open says the ciphertext is intact, never who wrote it.
31
+ module SealedBoxes
32
+ DOMAIN = "mailcycle/v1/seal-to-public"
33
+
34
+ # A sealed box in raw bytes. Callers choose how to encode it.
35
+ RawSealedBox = Struct.new(:ephemeral_public_key, :nonce, :ciphertext, :tag, keyword_init: true)
36
+
37
+ module_function
38
+
39
+ def derive_secret(shared, ephemeral_public, recipient_public)
40
+ transcript = DOMAIN.b + shared.b + ephemeral_public.b + recipient_public.b
41
+ Primitives.hmac_sha256(shared, transcript + "/enc".b) + Primitives.hmac_sha256(shared, transcript + "/mac".b)
42
+ end
43
+ private_class_method :derive_secret
44
+
45
+ # Seals to a public key.
46
+ #
47
+ # Randomness is a parameter so the test vectors can pin it; everywhere
48
+ # else it is the operating system's CSPRNG.
49
+ def seal_raw(recipient_public_key, plaintext, aad, version, random: Primitives.method(:random_bytes))
50
+ unless recipient_public_key.bytesize == Primitives::KEY_BYTES
51
+ raise UnsupportedError, "Recipient public key must be 32 bytes."
52
+ end
53
+
54
+ ephemeral_private = random.call(Primitives::KEY_BYTES)
55
+ unless ephemeral_private.bytesize == Primitives::KEY_BYTES
56
+ raise UnsupportedError, "Random source returned short."
57
+ end
58
+
59
+ ephemeral_public = Primitives.public_key_from(ephemeral_private)
60
+ shared = Primitives.shared_secret(ephemeral_private, recipient_public_key)
61
+ # All zero means the recipient key had small order and contributed
62
+ # nothing. Refusing here is the difference between encrypting and
63
+ # appearing to.
64
+ raise UnsupportedError, "Recipient public key is not usable." if Primitives.all_zero?(shared)
65
+
66
+ secret = derive_secret(shared, ephemeral_public, recipient_public_key.b)
67
+ nonce = random.call(Primitives::NONCE_BYTES)
68
+ ciphertext = Primitives.chacha20(secret.byteslice(0, 32), nonce, plaintext)
69
+ tag = Primitives.hmac_sha256(secret.byteslice(32, 32), Cipher.mac_input(nonce, ciphertext, aad, version))
70
+ RawSealedBox.new(ephemeral_public_key: ephemeral_public, nonce: nonce, ciphertext: ciphertext, tag: tag)
71
+ end
72
+
73
+ def open_raw(private_key, box, aad, version)
74
+ unless private_key.bytesize == Primitives::KEY_BYTES &&
75
+ box.ephemeral_public_key.bytesize == Primitives::KEY_BYTES &&
76
+ box.nonce.bytesize == Primitives::NONCE_BYTES
77
+ raise DecryptionError
78
+ end
79
+
80
+ shared = Primitives.shared_secret(private_key, box.ephemeral_public_key)
81
+ raise DecryptionError if Primitives.all_zero?(shared)
82
+
83
+ secret = derive_secret(shared, box.ephemeral_public_key.b, Primitives.public_key_from(private_key))
84
+ expected = Primitives.hmac_sha256(secret.byteslice(32, 32),
85
+ Cipher.mac_input(box.nonce, box.ciphertext, aad, version))
86
+ # Verify before decrypting. Never act on unauthenticated ciphertext.
87
+ raise DecryptionError unless Primitives.constant_time_equal(expected, box.tag)
88
+
89
+ Primitives.chacha20(secret.byteslice(0, 32), box.nonce, box.ciphertext)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ module Crypto
5
+ # Mailcycle recovery-phrase wordlist.
6
+ #
7
+ # 2048 words, 11 bits each, so a twelve-word phrase carries 128 bits of
8
+ # entropy plus a 4-bit checksum. The same construction as BIP-39 but NOT
9
+ # BIP-39's list: every word here is 3-8 lowercase ASCII letters and no two
10
+ # share their first four, so a phrase survives being written down in a hurry.
11
+ #
12
+ # Frozen. A word's index is its value, so reordering, replacing or appending
13
+ # an entry invalidates every phrase ever generated from it. Kept identical to
14
+ # `src/crypto/wordlist.ts` in the app.
15
+ module Wordlist
16
+ # The list, in the order that gives each word its value.
17
+ WORDS = %w[
18
+ able about above absent absorb abstract accent accident account accuse ache achieve acid acorn
19
+ acquire across action actor actual adapt addict address adjust admit adopt adult advance
20
+ advice affair afford afraid after again agent agree ahead aim air alarm album alert alien
21
+ align alike alive allow almost alone alpha already also alter always amateur amazing amber
22
+ ambush among amount ample amused anchor ancient anger angle angry animal ankle announce annual
23
+ another answer antenna antique anxiety any apart apology appear apple april arch arctic area
24
+ arena argue arise armed armor army around arrange arrest arrive arrow art artist artwork
25
+ ashamed aside ask aspect asphalt assault asset assist assume asthma athlete atlas atom attack
26
+ attend attitude attract auction audit august aunt author auto autumn average avocado avoid
27
+ awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball
28
+ bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty
29
+ because become beef before begin behave behind believe below belt bench benefit berry best
30
+ betray better between beyond bicycle bid bike bind biology birch bird birth bitter black blade
31
+ blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body
32
+ boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain
33
+ brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze
34
+ broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker
35
+ burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake
36
+ call calm camera camp canal cancel candy cannon canoe canvas canyon capable capital captain
37
+ carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch cattle
38
+ caught cause caution cave cedar ceiling celery cement census century cereal certain chair
39
+ chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest
40
+ chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle
41
+ citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb
42
+ clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut
43
+ code coffee coil coin collect color column combine come comfort comic common company concert
44
+ conduct confirm congress connect consider control convince cook cool copper copy coral core
45
+ corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft
46
+ cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop
47
+ cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup
48
+ curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring
49
+ dash daughter dawn day deal debate debris decade december decide decline decorate decrease
50
+ deer defense define defy degree delay deliver demand demise denial dentist deny depart depend
51
+ deposit depth deputy derive describe desert design desk despair destroy detail detect develop
52
+ device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma
53
+ dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance
54
+ divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door
55
+ dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop
56
+ drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth
57
+ easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder
58
+ electric elegant element elephant elevator elite else embark embody embrace emerge emotion
59
+ employ empower empty enable enact end endless endorse enemy energy enforce engage engine
60
+ enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal
61
+ equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence
62
+ evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust
63
+ exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye
64
+ eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy
65
+ farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel
66
+ female fence fern festival fetch fever few fiber fiction field figure file film filter final
67
+ find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat
68
+ flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold
69
+ follow food foot force forest forget fork fortune forum forward fossil foster found fox
70
+ fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny
71
+ furnace fury future gadget gain galaxy gallery game gap garage garbage garden garlic garment
72
+ gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost giant gift
73
+ giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove
74
+ glow glue goat goddess gold good goose gorilla gospel gossip govern gown grab grace grain
75
+ grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess
76
+ guide guilt guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest
77
+ hat have hawk hazard head health heart heavy hedgehog height hello helmet help hen hero hidden
78
+ high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope
79
+ horn horror horse hospital host hotel hour hover hub huge human humble humor hundred hungry
80
+ hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal illness
81
+ image imitate immense immune impact impose improve impulse inch include income increase index
82
+ indicate indoor industry infant inflict inform inhale inherit initial inject injury inmate
83
+ inner innocent input inquiry insane insect inside inspire install intact interest into invest
84
+ invite involve iron island isolate issue item ivory ivy jacket jaguar jar jazz jealous jeans
85
+ jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen
86
+ keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife
87
+ knock know lab label labor ladder lady lake lamp language laptop large later latin laugh
88
+ laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend
89
+ leisure lemon lend length lens leopard lesson letter level liar liberty library license life
90
+ lift light like limb limit link lion liquid list little live lizard load loan lobster local
91
+ lock logic lonely long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch
92
+ luxury lyrics machine mad magic magnet maid mail main major make mammal man manage mandate
93
+ mango mansion manual maple marble march margin marine market marriage mask mass master match
94
+ material math matrix matter maximum maze meadow mean measure meat mechanic medal media melody
95
+ melt member memory mention menu mercy merge merit merry mesh message metal method middle
96
+ midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix
97
+ mixed mixture mobile model modify mom moment monitor monkey monster month moon moral more
98
+ morning mosquito moss mother motion motor mountain mouse move movie much muffin mule multiply
99
+ muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty
100
+ nation nature near neck need negative neglect neither nephew nerve nest net network neutral
101
+ never news next nice night noble noise nominee noodle normal north nose notable note nothing
102
+ notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain
103
+ obvious occur ocean october odor off offer office often oil okay old olive olympic omit once
104
+ one onion online only open opera opinion oppose option orange orbit orchard order ordinary
105
+ organ orient original orphan ostrich other outdoor outer output outside oval oven over own
106
+ owner oxygen oyster ozone pact paddle page pair palace palm panda panel panic panther paper
107
+ parade parent park parrot party pass patch path patient patrol pattern pause pave payment
108
+ peace peanut pear peasant pelican pen penalty pencil people pepper perfect permit person pet
109
+ phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe
110
+ pistol pitch pizza place planet plastic plate play please pledge pluck plug plum plunge poem
111
+ poet point polar pole police pond pony pool popular portion position possible post potato
112
+ pottery poverty powder power practice praise predict prefer prepare present pretty prevent
113
+ price pride primary print priority prison private prize problem process produce profit program
114
+ project promote proof property prosper protect proud provide public pudding pull pulp pulse
115
+ pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality
116
+ quantum quarter question quick quiet quit quiz quote rabbit raccoon race rack radar radio rail
117
+ rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real
118
+ reason rebel rebuild recall receive recipe record recycle reduce reflect reform refuse region
119
+ regret regular reject relax release relief rely remain remember remind remove render renew
120
+ rent reopen repair repeat replace report require rescue resemble resist resource response
121
+ result retire retreat return reunion reveal review reward rhythm rib ribbon rice rich ride
122
+ ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust
123
+ rocket romance roof rookie room rose rotate rough round route royal rubber rude rug rule run
124
+ runway rural sad saddle sadness safe sail salad salmon salon salt salute same sample sand
125
+ satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science
126
+ scissors scorpion scout scrap screen script scrub sea search season seat second secret section
127
+ security seed seek segment select sell seminar senior sense sentence series service session
128
+ settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship
129
+ shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle shy sibling sick side
130
+ siege sight sign silent silk silly silver similar simple since sing siren sister situate six
131
+ size skate sketch ski skill skin skirt skull slab slam sleep slender slice slide slight slim
132
+ slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer
133
+ social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul
134
+ sound soup source south space spare spatial spawn speak special speed spell spend sphere spice
135
+ spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square
136
+ squeeze squirrel stable stadium staff stage stairs stamp stand start state stay steak steel
137
+ stem step stereo stick still sting stock stomach stone stool story stove strategy street
138
+ strike strong struggle student stuff stumble style subject submit subway success such sudden
139
+ suffer sugar suggest suit summer sun sunny sunset super supply supreme sure surface surge
140
+ surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim swing
141
+ switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target
142
+ task taste tattoo taxi teach team tell ten tenant tennis tent term test text thank that theme
143
+ then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger
144
+ tilt timber time tiny tip tired tissue title toast tobacco today toddler toe together toilet
145
+ token tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado tortoise
146
+ toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash
147
+ travel tray treat tree trend trial tribe trick trigger trim trip trophy trouble truck true
148
+ truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty
149
+ twice twin twist two type typical ugly umbrella unable unaware uncle uncover under undo unfair
150
+ unfold unhappy uniform unique unit universe unknown unlock until unusual unveil update upgrade
151
+ uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum
152
+ vague valid valley valve van vanish vapor various vast vault vehicle velvet vendor venture
153
+ venue verb verify version very vessel veteran viable vibrant vicious victory video view
154
+ village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano
155
+ volume vote voyage wage wagon wait walk wall walnut want warfare warm warrior wash wasp waste
156
+ water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet
157
+ whale what wheat wheel when where whip whisper wide width wife wild will win window wine wing
158
+ wink winner winter wire wisdom wise wish witness wolf woman wonder wood wool word work world
159
+ worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero
160
+ zone zoo
161
+ ].map(&:freeze).freeze
162
+
163
+ INDEX = WORDS.each_with_index.to_h.freeze
164
+
165
+ # The index of a word, or nil if it is not in the list.
166
+ def self.index_of(word)
167
+ INDEX[word]
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # base64url without padding, decoded strictly.
5
+ #
6
+ # The permissive decoders accept several spellings of the same bytes, which
7
+ # matters here: these strings are compared, stored and fed to a MAC, and a
8
+ # server picks how they are spelled. So unknown characters are an error,
9
+ # padding is an error, a length that cannot come from whole bytes is an
10
+ # error, and the unused low bits of the final character must be zero. This is
11
+ # `fromBase64Url` in the app's `src/crypto/bytes.ts`, rule for rule.
12
+ #
13
+ # Every function here takes and returns binary strings for bytes.
14
+ module Encoding
15
+ ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
16
+ STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
17
+ VALUES = ALPHABET.each_char.with_index.to_h { |char, index| [char.ord, index] }.freeze
18
+ private_constant :VALUES
19
+
20
+ module_function
21
+
22
+ def encode(data, alphabet, pad)
23
+ bytes = data.b.bytes
24
+ out = +""
25
+ bytes.each_slice(3) do |chunk|
26
+ n = (chunk[0] << 16) | ((chunk[1] || 0) << 8) | (chunk[2] || 0)
27
+ take = chunk.length + 1
28
+ take.times { |i| out << alphabet[(n >> (18 - (6 * i))) & 0x3f] }
29
+ (4 - take).times { out << "=" } if pad
30
+ end
31
+ out
32
+ end
33
+ private_class_method :encode
34
+
35
+ # Bytes to base64url, unpadded.
36
+ def to_b64u(data)
37
+ encode(data, ALPHABET, false)
38
+ end
39
+
40
+ # Bytes to standard, padded base64, as a file's content goes to the API.
41
+ def to_b64(data)
42
+ encode(data, STANDARD_ALPHABET, true)
43
+ end
44
+
45
+ # base64url to bytes, refusing anything non-canonical.
46
+ def from_b64u(value)
47
+ raise Base64UrlError, "unexpected character" unless value.is_a?(String)
48
+
49
+ text = value.b
50
+ # One leftover character cannot encode any whole byte.
51
+ raise Base64UrlError, "truncated" if text.bytesize % 4 == 1
52
+
53
+ out = +"".b
54
+ buffer = 0
55
+ bits = 0
56
+ text.each_byte do |byte|
57
+ index = VALUES[byte]
58
+ raise Base64UrlError, "unexpected character" if index.nil?
59
+
60
+ buffer = (buffer << 6) | index
61
+ bits += 6
62
+ next unless bits >= 8
63
+
64
+ bits -= 8
65
+ out << ((buffer >> bits) & 0xff)
66
+ buffer &= (1 << bits) - 1
67
+ end
68
+
69
+ # Whatever is left is padding bits, and in a canonical encoding they are
70
+ # zero. Non-zero means two strings would decode to the same bytes.
71
+ raise Base64UrlError, "non-canonical trailing bits" if bits.positive? && buffer != 0
72
+
73
+ out
74
+ end
75
+
76
+ # Eight bytes, big-endian: the additional data's length, in a MAC input.
77
+ def be64(value)
78
+ [value].pack("Q>")
79
+ end
80
+
81
+ # Lowercase hex, for a webhook signature.
82
+ def to_hex(data)
83
+ data.b.unpack1("H*")
84
+ end
85
+
86
+ # Lowercase hex back to bytes, or nil if it is not exactly that.
87
+ def from_hex(value)
88
+ return nil unless value.is_a?(String) && value.bytesize.even? && value.match?(/\A[0-9a-f]*\z/)
89
+
90
+ [value].pack("H*")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # Every error this library raises.
5
+ #
6
+ # One family, so a caller rescues `Mailcycle::Error` once and is done. An API
7
+ # failure carries the API's own stable `code` (`plan_required`,
8
+ # `rate_limited`) rather than a message to match on.
9
+ class Error < StandardError
10
+ # The API's code, for the failures that have one.
11
+ def code
12
+ nil
13
+ end
14
+ end
15
+
16
+ # A request the API refused, or one that never reached it.
17
+ class ApiError < Error
18
+ # The HTTP status, or 0 when the request never got an answer.
19
+ attr_reader :status
20
+ # The API's stable, machine-readable code.
21
+ attr_reader :code
22
+ # The message as the API wrote it, without the code in front.
23
+ attr_reader :detail
24
+
25
+ def initialize(status, code, message)
26
+ @status = status
27
+ @code = code
28
+ @detail = message
29
+ super("#{code}: #{message}")
30
+ end
31
+ end
32
+
33
+ # Data that would not open with the key provided.
34
+ #
35
+ # Every way an open can fail arrives as this one, including a box that is not
36
+ # a box at all. The fields come from a server, so "malformed" is a state the
37
+ # server can choose, and a reader that crashed on it would let the server
38
+ # decide that a program fails instead of showing one record as unreadable.
39
+ class DecryptionError < Error
40
+ def initialize(message = "This data could not be decrypted with the key provided.")
41
+ super
42
+ end
43
+ end
44
+
45
+ # Text that is not canonical base64url.
46
+ class Base64UrlError < Error
47
+ def initialize(why)
48
+ super("Not canonical base64url: #{why}")
49
+ end
50
+ end
51
+
52
+ # A recovery phrase that is not one. `problem` says why.
53
+ class PhraseError < Error
54
+ attr_reader :problem
55
+
56
+ def initialize(problem)
57
+ @problem = problem
58
+ super(problem.to_s)
59
+ end
60
+ end
61
+
62
+ # A challenge the server offered that cannot be answered safely.
63
+ class AuthProofError < Error; end
64
+
65
+ # Something the caller asked for that this client cannot do.
66
+ class UnsupportedError < Error; end
67
+
68
+ # Why a recovery phrase was refused.
69
+ #
70
+ # `kind` is `:length` (with `count`, how many words there were),
71
+ # `:unknown_word` (with the `word` and its zero-based `index`), or
72
+ # `:checksum` (twelve known words that do not agree with their checksum).
73
+ PhraseProblem = Struct.new(:kind, :count, :word, :index, keyword_init: true) do
74
+ def to_s
75
+ case kind
76
+ when :length then "A recovery phrase is twelve words; this one has #{count}."
77
+ when :unknown_word then "Word #{index + 1} is not in the list: #{word.inspect}."
78
+ else "Those twelve words do not check out; one of them is wrong."
79
+ end
80
+ end
81
+ end
82
+ end