Module: Mailkite

Defined in:
lib/mailkite.rb

Defined Under Namespace

Classes: Client, Error

Constant Summary collapse

VERSION =
"0.4.0"
DEFAULT_BASE_URL =
"https://api.mailkite.dev"
DEFAULT_TOLERANCE_MS =

Reject webhook events older than this (ms) to block replays. Pass 0 to disable.

5 * 60 * 1000

Class Method Summary collapse

Class Method Details

.decrypt(envelope, private_key) ⇒ Object

Decrypt a MailKite at-rest envelope (JSON string) with the matching RSA private key (PEM), returning the original UTF-8 plaintext. Local only.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/mailkite.rb', line 97

def self.decrypt(envelope, private_key)
  env = envelope.is_a?(String) ? JSON.parse(envelope) : envelope
  priv = OpenSSL::PKey.read(private_key)

  iv = Base64.strict_decode64(env["iv"])
  wrapped = Base64.strict_decode64(env["wrappedKey"])
  ct = Base64.strict_decode64(env["ciphertext"])
  body = ct[0...-16]
  tag = ct[-16..]

  raw_key = priv.decrypt(wrapped, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.decrypt
  cipher.key = raw_key
  cipher.iv = iv
  cipher.auth_tag = tag
  (cipher.update(body) + cipher.final).force_encoding("UTF-8")
end

.encrypt(plaintext, public_key) ⇒ Object

Encrypt a UTF-8 string to a customer RSA public key (SPKI/PEM), producing the MailKite at-rest envelope as a compact JSON string. Hybrid scheme: a fresh AES-256-GCM content key encrypts the data, then the raw key is wrapped with RSA-OAEP (SHA-256). Local only — no network.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/mailkite.rb', line 68

def self.encrypt(plaintext, public_key)
  pub = OpenSSL::PKey.read(public_key)
  fp = OpenSSL::Digest::SHA256.hexdigest(pub.to_der)

  raw_key = OpenSSL::Random.random_bytes(32)
  iv = OpenSSL::Random.random_bytes(12)

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.encrypt
  cipher.key = raw_key
  cipher.iv = iv
  body = cipher.update(plaintext.to_s.dup.force_encoding("UTF-8")) + cipher.final
  ciphertext = body + cipher.auth_tag # GCM ct with 16-byte tag appended

  wrapped = pub.encrypt(raw_key, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")

  JSON.generate({
    "v" => 1,
    "keyAlg" => "RSA-OAEP-256",
    "fp" => fp,
    "enc" => "A256GCM",
    "iv" => Base64.strict_encode64(iv),
    "wrappedKey" => Base64.strict_encode64(wrapped),
    "ciphertext" => Base64.strict_encode64(ciphertext),
  })
end

.reply_okObject

The canonical body to return from a webhook handler so MailKite marks the delivery acknowledged. Returns the exact string ‘“status”:“ok”`.



60
61
62
# File 'lib/mailkite.rb', line 60

def self.reply_ok
  '{"status":"ok"}'
end

.secure_compare(a, b) ⇒ Object

Length-independent constant-time string compare (no openssl >= 2.2 needed).



50
51
52
53
54
55
56
# File 'lib/mailkite.rb', line 50

def self.secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  diff = 0
  a.bytes.each_with_index { |byte, i| diff |= byte ^ b.getbyte(i) }
  diff.zero?
end

.verify_webhook(signature, payload, secret, tolerance_ms = DEFAULT_TOLERANCE_MS) ⇒ Object

Verify an ‘x-mailkite-signature` header on an inbound webhook delivery. Local HMAC-SHA256 check — no network call. Pass the raw, unparsed body.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/mailkite.rb', line 25

def self.verify_webhook(signature, payload, secret, tolerance_ms = DEFAULT_TOLERANCE_MS)
  return false unless signature.is_a?(String) && !signature.empty?

  parts = {}
  signature.split(",").each do |seg|
    i = seg.index("=")
    next unless i
    parts[seg[0...i].strip] = seg[(i + 1)..].strip
  end
  t = parts["t"]
  v1 = parts["v1"]
  return false if t.nil? || v1.nil? || t.empty? || v1.empty? || !t.match?(/\A-?\d+\z/)

  # The t in the header is milliseconds since the epoch.
  if tolerance_ms && tolerance_ms > 0
    return false if ((Time.now.to_f * 1000) - t.to_i).abs > tolerance_ms
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{payload}")
  secure_compare(expected, v1)
rescue StandardError
  false
end