discord_store 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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ module DiscordStore
8
+ # Payload encryption.
9
+ #
10
+ # Discord's own Developer Terms require that end-user data stored off-platform
11
+ # be encrypted at rest, so a library that stores data *on* the platform has no
12
+ # excuse for shipping plaintext as the default. Encryption is also the only
13
+ # thing standing between your records and every member of the guild who can
14
+ # scroll up.
15
+ #
16
+ # AES-256-GCM, not the AES-256-CBC that earlier Discord filesystems used: GCM
17
+ # authenticates, so a member with Manage Messages who edits one of our messages
18
+ # produces a decryption failure rather than silently corrupted data.
19
+ module Cipher
20
+ # Envelope prefix, so the format can change without ambiguity later.
21
+ VERSION_TAG = "ds1"
22
+ SEPARATOR = "."
23
+ IV_BYTES = 12
24
+ TAG_BYTES = 16
25
+ ALGORITHM = "aes-256-gcm"
26
+
27
+ # Builds the cipher named by +config+.
28
+ #
29
+ # @param config [DiscordStore::Configuration]
30
+ # @return [#seal, #open]
31
+ def self.build(config)
32
+ case config.cipher
33
+ when :none then Null.new
34
+ when :aes_256_gcm then AES256GCM.new(config.secret_key_bytes)
35
+ else
36
+ raise ConfigurationError, "unknown cipher #{config.cipher.inspect}"
37
+ end
38
+ end
39
+
40
+ # Generates a fresh key, base64-encoded and ready to paste into an
41
+ # environment variable.
42
+ #
43
+ # @return [String]
44
+ def self.generate_key
45
+ Base64.strict_encode64(SecureRandom.bytes(32))
46
+ end
47
+
48
+ # Passthrough. Writes readable JSON into the channel: good for a log you
49
+ # want humans to read in the Discord client, wrong for anything else.
50
+ class Null
51
+ def seal(plaintext, aad: nil) = plaintext.to_s
52
+ def open(ciphertext, aad: nil) = ciphertext.to_s
53
+ def seal_binary(plaintext, aad: nil) = plaintext.to_s
54
+ def open_binary(ciphertext, aad: nil) = ciphertext.to_s
55
+ def encrypting? = false
56
+ def overhead = 0
57
+ def binary_overhead = 0
58
+ end
59
+
60
+ # Authenticated encryption over a compact, markdown-safe envelope:
61
+ #
62
+ # ds1.<iv>.<tag>.<ciphertext> (each field urlsafe base64, unpadded)
63
+ #
64
+ # Base64url is used rather than standard base64 because Discord renders
65
+ # message content as markdown, and the urlsafe alphabet contains no
66
+ # characters the renderer will try to interpret.
67
+ class AES256GCM
68
+ def initialize(key)
69
+ raise ConfigurationError, "encryption key is missing" if key.nil?
70
+ raise ConfigurationError, "encryption key must be 32 bytes" unless key.bytesize == 32
71
+
72
+ @key = key
73
+ end
74
+
75
+ def encrypting? = true
76
+
77
+ # Worst-case added length for a given plaintext size, so the packer can
78
+ # decide whether a record still fits in a message before encrypting it.
79
+ #
80
+ # @return [Integer]
81
+ def overhead
82
+ # version tag + 3 separators + base64(iv) + base64(tag), plus the ~4/3
83
+ # expansion of the ciphertext itself, which the packer accounts for.
84
+ VERSION_TAG.bytesize + 3 + b64_len(IV_BYTES) + b64_len(TAG_BYTES)
85
+ end
86
+
87
+ # @param plaintext [String]
88
+ # @param aad [String, nil] additional authenticated data; bound into the
89
+ # tag but not stored. Pass the channel ID to make a record undecryptable
90
+ # if it is moved to another channel.
91
+ # @return [String]
92
+ def seal(plaintext, aad: nil)
93
+ cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
94
+ cipher.key = @key
95
+ iv = cipher.random_iv
96
+ cipher.auth_data = aad.to_s
97
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
98
+
99
+ [VERSION_TAG, encode(iv), encode(cipher.auth_tag), encode(ciphertext)].join(SEPARATOR)
100
+ end
101
+
102
+ # @param ciphertext [String] an envelope produced by {#seal}
103
+ # @param aad [String, nil] must match the value passed to {#seal}
104
+ # @return [String]
105
+ # @raise [DecryptionError] if the envelope is malformed, was written with
106
+ # a different key, or has been tampered with
107
+ def open(ciphertext, aad: nil)
108
+ version, iv, tag, body = ciphertext.to_s.split(SEPARATOR, 4)
109
+
110
+ unless version == VERSION_TAG && iv && tag && body
111
+ raise DecryptionError, "not a #{VERSION_TAG} envelope"
112
+ end
113
+
114
+ cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
115
+ cipher.key = @key
116
+ cipher.iv = decode(iv)
117
+ cipher.auth_tag = decode(tag)
118
+ cipher.auth_data = aad.to_s
119
+ cipher.update(decode(body)) + cipher.final
120
+ rescue OpenSSL::Cipher::CipherError
121
+ raise DecryptionError,
122
+ "payload failed authentication: wrong key, wrong channel, or the " \
123
+ "message was edited by someone with Manage Messages"
124
+ rescue ArgumentError => e
125
+ raise DecryptionError, "malformed envelope: #{e.message}"
126
+ end
127
+
128
+ # Attachments are binary, so they skip base64 entirely.
129
+ #
130
+ # DS1B | iv (12 bytes) | tag (16 bytes) | ciphertext
131
+ #
132
+ # Text envelopes have to survive being message content, which is why they
133
+ # are base64. Paying that 33% on an attachment would mean uploading four
134
+ # bytes for every three stored, against a rate limit that is already the
135
+ # binding constraint.
136
+ BINARY_MAGIC = "DS1B"
137
+
138
+ # @param plaintext [String]
139
+ # @param aad [String, nil]
140
+ # @return [String] binary
141
+ def seal_binary(plaintext, aad: nil)
142
+ cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
143
+ cipher.key = @key
144
+ iv = cipher.random_iv
145
+ cipher.auth_data = aad.to_s
146
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
147
+
148
+ (+"").force_encoding(Encoding::BINARY) << BINARY_MAGIC << iv << cipher.auth_tag << ciphertext
149
+ end
150
+
151
+ # @param blob [String] binary, as produced by {#seal_binary}
152
+ # @param aad [String, nil]
153
+ # @return [String]
154
+ # @raise [DecryptionError]
155
+ def open_binary(blob, aad: nil)
156
+ iv, tag, body = split_binary(blob)
157
+
158
+ cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
159
+ cipher.key = @key
160
+ cipher.iv = iv
161
+ cipher.auth_tag = tag
162
+ cipher.auth_data = aad.to_s
163
+ cipher.update(body) + cipher.final
164
+ rescue OpenSSL::Cipher::CipherError
165
+ raise DecryptionError, "attachment failed authentication: wrong key, or the bytes were altered"
166
+ end
167
+
168
+ # @return [Integer] bytes added to a binary payload
169
+ def binary_overhead = BINARY_MAGIC.bytesize + IV_BYTES + TAG_BYTES
170
+
171
+ private
172
+
173
+ # @return [Array(String, String, String)] iv, tag and ciphertext
174
+ def split_binary(blob)
175
+ blob = blob.to_s.dup.force_encoding(Encoding::BINARY)
176
+ raise DecryptionError, "not a #{BINARY_MAGIC} payload" unless blob.start_with?(BINARY_MAGIC)
177
+
178
+ offset = BINARY_MAGIC.bytesize
179
+ iv = blob.byteslice(offset, IV_BYTES)
180
+ tag = blob.byteslice(offset + IV_BYTES, TAG_BYTES)
181
+ body = blob.byteslice(offset + IV_BYTES + TAG_BYTES, blob.bytesize) || +""
182
+
183
+ raise DecryptionError, "payload is truncated" if iv.nil? || tag.nil? || tag.bytesize < TAG_BYTES
184
+
185
+ [iv, tag, body]
186
+ end
187
+
188
+ def encode(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
189
+
190
+ def decode(string) = Base64.urlsafe_decode64(string)
191
+
192
+ def b64_len(bytes) = ((bytes * 4) / 3.0).ceil
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+
6
+ module DiscordStore
7
+ # Turns records into message payloads and back.
8
+ #
9
+ # A Discord message holds 2000 characters, and a request that carries one
10
+ # 80-byte record is a request that spent the same rate-limit permit as one
11
+ # carrying twenty. Since permits are the scarce resource, packing is not an
12
+ # optimisation here — it is most of the throughput.
13
+ #
14
+ # Wire format of a message:
15
+ #
16
+ # DS1 i 3 <- header: version, kind, record count
17
+ # <envelope> <- one record per line, base64url so a line break
18
+ # <envelope> can never appear inside one
19
+ # <envelope>
20
+ #
21
+ # A record too large to fit alongside anything else spills to an attachment
22
+ # and the message body becomes a stub:
23
+ #
24
+ # DS1 s 1
25
+ #
26
+ # Bigger than an attachment is the blob store's problem, not the log's.
27
+ class Codec
28
+ HEADER_PREFIX = "DS1"
29
+ KIND_INLINE = "i"
30
+ KIND_SPILL = "s"
31
+ SPILL_FILENAME = "records.ds1"
32
+ SEPARATOR = "\n"
33
+
34
+ # @param cipher [#seal, #open]
35
+ # @param content_budget [Integer] characters we are willing to use
36
+ # @param spill_limit [Integer, nil] max attachment bytes; nil means unlimited
37
+ def initialize(cipher:, content_budget: 1900, spill_limit: nil)
38
+ @cipher = cipher
39
+ @content_budget = content_budget
40
+ @spill_limit = spill_limit
41
+ end
42
+
43
+ # Packs records into as few messages as will hold them.
44
+ #
45
+ # @param records [Array<Log::Record>]
46
+ # @param aad [String, nil] bound into each envelope's authentication tag
47
+ # @param atomic [Boolean] when true, refuse to split the batch across
48
+ # messages: either it fits in one message or it raises. One message is one
49
+ # atomic append, so this is how a transaction gets all-or-nothing
50
+ # durability on a platform with no transactions.
51
+ # @return [Array<Hash>] each {content:, files:, records:}
52
+ # @raise [PayloadTooLargeError]
53
+ def pack(records, aad: nil, atomic: false)
54
+ messages = []
55
+ batch = Batch.new(inline_budget)
56
+
57
+ records.each do |record|
58
+ envelope = seal(record, aad)
59
+
60
+ if envelope.length > inline_budget
61
+ raise_if_atomic(atomic, "a single record exceeds one message")
62
+ messages << current_message(batch.drain) if batch.any?
63
+ messages << spill_message(record, envelope)
64
+ next
65
+ end
66
+
67
+ unless batch.fits?(envelope)
68
+ raise_if_atomic(atomic, "the batch does not fit in one message")
69
+ messages << current_message(batch.drain)
70
+ end
71
+
72
+ batch.add(record, envelope)
73
+ end
74
+
75
+ messages << current_message(batch.drain) if batch.any?
76
+ messages
77
+ end
78
+
79
+ # Reverses {#pack} for one Discord message.
80
+ #
81
+ # @param message [Hash] a Discord message object
82
+ # @param attachment_body [String, nil] the spilled bytes, if the message has
83
+ # a spill attachment the caller has already fetched
84
+ # @param aad [String, nil]
85
+ # @return [Array<Log::Record>]
86
+ def unpack(message, attachment_body: nil, aad: nil)
87
+ content = message["content"].to_s
88
+ header, body = split_header(content)
89
+ return [] if header.nil?
90
+
91
+ raw = if header[:kind] == KIND_SPILL
92
+ unless attachment_body
93
+ raise CorruptRecordError,
94
+ "message #{message["id"]} spilled to an attachment that was not fetched"
95
+ end
96
+
97
+ attachment_body
98
+ else
99
+ body
100
+ end
101
+
102
+ lines = raw.split(SEPARATOR).reject(&:empty?)
103
+
104
+ if header[:count] != lines.size
105
+ raise CorruptRecordError,
106
+ "message #{message["id"]} claims #{header[:count]} records but carries #{lines.size}"
107
+ end
108
+
109
+ lines.each_with_index.map do |envelope, position|
110
+ payload = JSON.parse(@cipher.open(envelope, aad: aad))
111
+ Log::Record.from_wire(payload, lsn: message["id"], position: position,
112
+ channel_id: message["channel_id"])
113
+ end
114
+ rescue JSON::ParserError => e
115
+ raise CorruptRecordError, "message #{message["id"]} holds invalid JSON: #{e.message}"
116
+ end
117
+
118
+ # Whether a message looks like something this library wrote. Cheap enough to
119
+ # run before doing any crypto.
120
+ #
121
+ # @param message [Hash]
122
+ # @return [Boolean]
123
+ def ours?(message)
124
+ message.is_a?(Hash) && message["content"].to_s.start_with?("#{HEADER_PREFIX} ")
125
+ end
126
+
127
+ # @param message [Hash]
128
+ # @return [Boolean] whether the payload lives in an attachment
129
+ def spilled?(message)
130
+ header, = split_header(message["content"].to_s)
131
+ header && header[:kind] == KIND_SPILL
132
+ end
133
+
134
+ # Accumulates records until the next one would overflow a message.
135
+ class Batch
136
+ def initialize(budget)
137
+ @budget = budget
138
+ @pairs = []
139
+ @size = 0
140
+ end
141
+
142
+ def any? = @pairs.any?
143
+
144
+ # @return [Boolean] whether +envelope+ still fits alongside what is held
145
+ def fits?(envelope)
146
+ return true if @pairs.empty?
147
+
148
+ @size + envelope.length + SEPARATOR.length <= @budget
149
+ end
150
+
151
+ def add(record, envelope)
152
+ @size += envelope.length + (@pairs.empty? ? 0 : SEPARATOR.length)
153
+ @pairs << [record, envelope]
154
+ end
155
+
156
+ # @return [Array] the accumulated pairs, resetting the batch
157
+ def drain
158
+ pairs = @pairs
159
+ @pairs = []
160
+ @size = 0
161
+ pairs
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ def seal(record, aad)
168
+ envelope = @cipher.seal(JSON.generate(record.to_wire), aad: aad)
169
+
170
+ if envelope.include?(SEPARATOR)
171
+ raise CorruptRecordError, "cipher produced a newline; envelopes must be single-line"
172
+ end
173
+
174
+ envelope
175
+ end
176
+
177
+ # Header plus the newline that follows it, plus one separator per record
178
+ # after the first. Computed against the worst case so a full batch can never
179
+ # overflow the hard 2000-character limit.
180
+ def inline_budget
181
+ @content_budget - (HEADER_PREFIX.length + 8)
182
+ end
183
+
184
+ def current_message(pairs)
185
+ records = pairs.map(&:first)
186
+ envelopes = pairs.map(&:last)
187
+
188
+ {
189
+ content: [header(KIND_INLINE, records.size), *envelopes].join(SEPARATOR),
190
+ files: [],
191
+ records: records
192
+ }
193
+ end
194
+
195
+ def spill_message(record, envelope)
196
+ if @spill_limit && envelope.bytesize > @spill_limit
197
+ raise PayloadTooLargeError,
198
+ "record is #{envelope.bytesize} bytes, over the #{@spill_limit}-byte attachment " \
199
+ "ceiling. Store it as a blob instead of a log record."
200
+ end
201
+
202
+ {
203
+ content: header(KIND_SPILL, 1),
204
+ files: [{ filename: SPILL_FILENAME, content: envelope, content_type: "application/octet-stream" }],
205
+ records: [record]
206
+ }
207
+ end
208
+
209
+ def header(kind, count) = "#{HEADER_PREFIX} #{kind} #{count}"
210
+
211
+ def split_header(content)
212
+ header_line, body = content.split(SEPARATOR, 2)
213
+ return [nil, nil] if header_line.nil?
214
+
215
+ prefix, kind, count = header_line.split(" ", 3)
216
+ return [nil, nil] unless prefix == HEADER_PREFIX
217
+ return [nil, nil] unless [KIND_INLINE, KIND_SPILL].include?(kind)
218
+
219
+ [{ kind: kind, count: count.to_i }, body.to_s]
220
+ end
221
+
222
+ def raise_if_atomic(atomic, reason)
223
+ return unless atomic
224
+
225
+ raise PayloadTooLargeError,
226
+ "#{reason}, and this batch was requested atomic. One Discord message is the " \
227
+ "largest thing that commits all-or-nothing; split the transaction or accept " \
228
+ "that replay may observe it partially applied."
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ # Process-wide settings. Most applications configure this once at boot:
5
+ #
6
+ # DiscordStore.configure do |c|
7
+ # c.i_understand_this_violates_discord_tos = true
8
+ # c.token = ENV.fetch("DISCORD_BOT_TOKEN")
9
+ # c.application_id = ENV.fetch("DISCORD_APPLICATION_ID")
10
+ # c.guild_id = ENV.fetch("DISCORD_GUILD_ID")
11
+ # c.secret_key = ENV.fetch("DISCORD_STORE_KEY") # 32 raw bytes, base64
12
+ # end
13
+ class Configuration
14
+ # Discord's documented global ceiling for a bot token, in requests/second.
15
+ # We aim below it by default; see #global_rate_limit.
16
+ DISCORD_GLOBAL_LIMIT = 50
17
+
18
+ # Maximum characters in a normal message's +content+ field.
19
+ MESSAGE_CONTENT_LIMIT = 2000
20
+
21
+ # Every setting that has a sensible default, and its default.
22
+ DEFAULTS = {
23
+ global_rate_limit: 45,
24
+ quota_timeout: 15.0,
25
+ max_retries: 5,
26
+ open_timeout: 5.0,
27
+ read_timeout: 30.0,
28
+ write_timeout: 30.0,
29
+ user_agent: nil,
30
+ api_base: "https://discord.com/api/v10",
31
+ cipher: :aes_256_gcm,
32
+ content_budget: 1900,
33
+ max_concurrent_transfers: 24,
34
+ chunk_size: nil,
35
+ delete_policy: :tombstone,
36
+ own_messages_only: true,
37
+ logger: nil
38
+ }.freeze
39
+
40
+ # --- Acknowledgement -----------------------------------------------------
41
+
42
+ # Storing application data in Discord messages violates the Discord
43
+ # Developer Terms of Service. Nothing in this library will make a network
44
+ # request until this is explicitly set to true.
45
+ attr_accessor :i_understand_this_violates_discord_tos
46
+
47
+ # --- Credentials and placement -------------------------------------------
48
+
49
+ # Bot token, without the "Bot " prefix.
50
+ attr_accessor :token
51
+
52
+ # The bot's application (user) ID. Used to enforce that we only ever read
53
+ # messages our own bot wrote. Strongly recommended; see #own_messages_only.
54
+ attr_accessor :application_id
55
+
56
+ # The guild that owns the channels we write to.
57
+ attr_accessor :guild_id
58
+
59
+ # Channels used for the append-only log. More than one channel multiplies
60
+ # write throughput, because Discord's harshest message limit is per-channel.
61
+ attr_accessor :log_channel_ids
62
+
63
+ # Channel used for mutable documents (the KV layer).
64
+ attr_accessor :document_channel_id
65
+
66
+ # Channels used for blob chunks (ActiveStorage). Sharded like the log.
67
+ attr_accessor :blob_channel_ids
68
+
69
+ # Channel holding blob manifests. Kept separate from chunks so that a
70
+ # manifest scan does not have to page past gigabytes of chunk messages.
71
+ attr_accessor :manifest_channel_id
72
+
73
+ # --- Crypto --------------------------------------------------------------
74
+
75
+ # 32 raw bytes, base64-encoded. Required unless +cipher+ is :none.
76
+ attr_accessor :secret_key
77
+
78
+ # :aes_256_gcm (default) or :none. :none writes readable JSON into the
79
+ # channel, which is useful when you want humans to read the data in the
80
+ # Discord client, and which forfeits the encryption-at-rest that Discord's
81
+ # own developer terms require of anyone storing end-user data.
82
+ attr_accessor :cipher
83
+
84
+ # --- Transport -----------------------------------------------------------
85
+
86
+ attr_accessor :global_rate_limit, :quota_timeout, :max_retries,
87
+ :open_timeout, :read_timeout, :write_timeout,
88
+ :user_agent, :api_base, :logger
89
+
90
+ # Refuse to read any message not authored by +application_id+. This is the
91
+ # line between a storage backend and a scraper, and it is enforced in code
92
+ # rather than in documentation. Leave it on.
93
+ attr_accessor :own_messages_only
94
+
95
+ # --- Encoding ------------------------------------------------------------
96
+
97
+ # How many characters of a message's content we are willing to fill. Kept
98
+ # under MESSAGE_CONTENT_LIMIT so that framing overhead can never push a
99
+ # message over the hard limit.
100
+ attr_accessor :content_budget
101
+
102
+ # Concurrency ceiling for blob chunk transfers.
103
+ attr_accessor :max_concurrent_transfers
104
+
105
+ # Bytes per blob chunk. Left nil to be discovered at runtime from the
106
+ # guild's boost tier, because Discord's attachment ceiling has moved
107
+ # several times and hardcoding it is how these libraries break.
108
+ attr_accessor :chunk_size
109
+
110
+ # What to do when a blob or record is deleted:
111
+ #
112
+ # :tombstone — edit the message to a tombstone marker, never delete.
113
+ # Cheapest, and survives the 14-day bulk-delete window.
114
+ # :bulk — bulk-delete where possible, tombstone the rest.
115
+ # :aggressive — delete everything, one request at a time if we must.
116
+ attr_accessor :delete_policy
117
+
118
+ def initialize
119
+ DEFAULTS.each { |name, value| public_send(:"#{name}=", value) }
120
+ @i_understand_this_violates_discord_tos = false
121
+ @log_channel_ids = []
122
+ @blob_channel_ids = []
123
+ end
124
+
125
+ # @raise [UnacknowledgedError] if the ToS acknowledgement is missing
126
+ # @raise [ConfigurationError] if a required setting is missing or invalid
127
+ # @return [void]
128
+ def validate!
129
+ raise UnacknowledgedError unless i_understand_this_violates_discord_tos
130
+ raise ConfigurationError, "token is required" if blank?(token)
131
+
132
+ if own_messages_only && blank?(application_id)
133
+ raise ConfigurationError,
134
+ "application_id is required while own_messages_only is enabled " \
135
+ "(it is what makes the check possible). Set it, or explicitly " \
136
+ "set own_messages_only = false and accept what that means."
137
+ end
138
+
139
+ validate_cipher!
140
+ validate_budget!
141
+ self
142
+ end
143
+
144
+ # The raw 32-byte encryption key.
145
+ #
146
+ # @return [String, nil]
147
+ def secret_key_bytes
148
+ return nil if cipher == :none
149
+ return nil if blank?(secret_key)
150
+
151
+ require "base64"
152
+ Base64.strict_decode64(secret_key.to_s)
153
+ rescue ArgumentError
154
+ raise ConfigurationError, "secret_key is not valid base64"
155
+ end
156
+
157
+ # Every channel this configuration knows about, deduplicated.
158
+ #
159
+ # @return [Array<String>]
160
+ def all_channel_ids
161
+ [
162
+ *log_channel_ids,
163
+ *blob_channel_ids,
164
+ document_channel_id,
165
+ manifest_channel_id
166
+ ].compact.map(&:to_s).uniq
167
+ end
168
+
169
+ private
170
+
171
+ def validate_cipher!
172
+ unless %i[aes_256_gcm none].include?(cipher)
173
+ raise ConfigurationError, "cipher must be :aes_256_gcm or :none, got #{cipher.inspect}"
174
+ end
175
+ return if cipher == :none
176
+
177
+ raise ConfigurationError, "secret_key is required unless cipher is :none" if blank?(secret_key)
178
+
179
+ bytes = secret_key_bytes
180
+ return if bytes && bytes.bytesize == 32
181
+
182
+ raise ConfigurationError,
183
+ "secret_key must decode to exactly 32 bytes, got #{bytes ? bytes.bytesize : 0}"
184
+ end
185
+
186
+ def validate_budget!
187
+ return if content_budget.positive? && content_budget < MESSAGE_CONTENT_LIMIT
188
+
189
+ raise ConfigurationError,
190
+ "content_budget must be between 1 and #{MESSAGE_CONTENT_LIMIT - 1}"
191
+ end
192
+
193
+ def blank?(value)
194
+ value.nil? || value.to_s.strip.empty?
195
+ end
196
+ end
197
+ end