rubycord 1.0.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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/lib/rubycord/allowed_mentions.rb +34 -0
  3. data/lib/rubycord/api/application.rb +200 -0
  4. data/lib/rubycord/api/channel.rb +597 -0
  5. data/lib/rubycord/api/interaction.rb +52 -0
  6. data/lib/rubycord/api/invite.rb +42 -0
  7. data/lib/rubycord/api/server.rb +557 -0
  8. data/lib/rubycord/api/user.rb +153 -0
  9. data/lib/rubycord/api/webhook.rb +138 -0
  10. data/lib/rubycord/api.rb +356 -0
  11. data/lib/rubycord/await.rb +49 -0
  12. data/lib/rubycord/bot.rb +1757 -0
  13. data/lib/rubycord/cache.rb +259 -0
  14. data/lib/rubycord/colour_rgb.rb +41 -0
  15. data/lib/rubycord/commands/command_bot.rb +519 -0
  16. data/lib/rubycord/commands/container.rb +110 -0
  17. data/lib/rubycord/commands/events.rb +9 -0
  18. data/lib/rubycord/commands/parser.rb +325 -0
  19. data/lib/rubycord/commands/rate_limiter.rb +142 -0
  20. data/lib/rubycord/container.rb +753 -0
  21. data/lib/rubycord/data/activity.rb +269 -0
  22. data/lib/rubycord/data/application.rb +48 -0
  23. data/lib/rubycord/data/attachment.rb +109 -0
  24. data/lib/rubycord/data/audit_logs.rb +343 -0
  25. data/lib/rubycord/data/channel.rb +996 -0
  26. data/lib/rubycord/data/component.rb +227 -0
  27. data/lib/rubycord/data/embed.rb +249 -0
  28. data/lib/rubycord/data/emoji.rb +80 -0
  29. data/lib/rubycord/data/integration.rb +120 -0
  30. data/lib/rubycord/data/interaction.rb +798 -0
  31. data/lib/rubycord/data/invite.rb +135 -0
  32. data/lib/rubycord/data/member.rb +370 -0
  33. data/lib/rubycord/data/message.rb +412 -0
  34. data/lib/rubycord/data/overwrite.rb +106 -0
  35. data/lib/rubycord/data/profile.rb +89 -0
  36. data/lib/rubycord/data/reaction.rb +31 -0
  37. data/lib/rubycord/data/recipient.rb +32 -0
  38. data/lib/rubycord/data/role.rb +246 -0
  39. data/lib/rubycord/data/server.rb +1002 -0
  40. data/lib/rubycord/data/user.rb +261 -0
  41. data/lib/rubycord/data/voice_region.rb +43 -0
  42. data/lib/rubycord/data/voice_state.rb +39 -0
  43. data/lib/rubycord/data/webhook.rb +232 -0
  44. data/lib/rubycord/data.rb +40 -0
  45. data/lib/rubycord/errors.rb +737 -0
  46. data/lib/rubycord/events/await.rb +46 -0
  47. data/lib/rubycord/events/bans.rb +58 -0
  48. data/lib/rubycord/events/channels.rb +186 -0
  49. data/lib/rubycord/events/generic.rb +126 -0
  50. data/lib/rubycord/events/guilds.rb +191 -0
  51. data/lib/rubycord/events/interactions.rb +480 -0
  52. data/lib/rubycord/events/invites.rb +123 -0
  53. data/lib/rubycord/events/lifetime.rb +29 -0
  54. data/lib/rubycord/events/members.rb +91 -0
  55. data/lib/rubycord/events/message.rb +337 -0
  56. data/lib/rubycord/events/presence.rb +127 -0
  57. data/lib/rubycord/events/raw.rb +45 -0
  58. data/lib/rubycord/events/reactions.rb +156 -0
  59. data/lib/rubycord/events/roles.rb +86 -0
  60. data/lib/rubycord/events/threads.rb +94 -0
  61. data/lib/rubycord/events/typing.rb +70 -0
  62. data/lib/rubycord/events/voice_server_update.rb +45 -0
  63. data/lib/rubycord/events/voice_state_update.rb +103 -0
  64. data/lib/rubycord/events/webhooks.rb +62 -0
  65. data/lib/rubycord/gateway.rb +867 -0
  66. data/lib/rubycord/id_object.rb +37 -0
  67. data/lib/rubycord/light/data.rb +60 -0
  68. data/lib/rubycord/light/integrations.rb +71 -0
  69. data/lib/rubycord/light/light_bot.rb +56 -0
  70. data/lib/rubycord/light.rb +6 -0
  71. data/lib/rubycord/logger.rb +118 -0
  72. data/lib/rubycord/paginator.rb +55 -0
  73. data/lib/rubycord/permissions.rb +251 -0
  74. data/lib/rubycord/version.rb +5 -0
  75. data/lib/rubycord/voice/encoder.rb +113 -0
  76. data/lib/rubycord/voice/network.rb +366 -0
  77. data/lib/rubycord/voice/sodium.rb +96 -0
  78. data/lib/rubycord/voice/voice_bot.rb +408 -0
  79. data/lib/rubycord/webhooks/builder.rb +100 -0
  80. data/lib/rubycord/webhooks/client.rb +132 -0
  81. data/lib/rubycord/webhooks/embeds.rb +248 -0
  82. data/lib/rubycord/webhooks/modal.rb +78 -0
  83. data/lib/rubycord/webhooks/version.rb +7 -0
  84. data/lib/rubycord/webhooks/view.rb +192 -0
  85. data/lib/rubycord/webhooks.rb +12 -0
  86. data/lib/rubycord/websocket.rb +70 -0
  87. data/lib/rubycord.rb +140 -0
  88. metadata +231 -0
@@ -0,0 +1,113 @@
1
+ # This makes opus an optional dependency
2
+ begin
3
+ require "opus-ruby"
4
+ OPUS_AVAILABLE = true
5
+ rescue LoadError
6
+ OPUS_AVAILABLE = false
7
+ end
8
+
9
+ # Discord voice chat support
10
+ module Rubycord::Voice
11
+ # This class conveniently abstracts opus and ffmpeg/avconv, for easy implementation of voice sending. It's not very
12
+ # useful for most users, but I guess it can be useful sometimes.
13
+ class Encoder
14
+ # Whether or not avconv should be used instead of ffmpeg. If possible, it is recommended to use ffmpeg instead,
15
+ # as it is better supported.
16
+ # @return [true, false] whether avconv should be used instead of ffmpeg.
17
+ attr_accessor :use_avconv
18
+
19
+ # @see VoiceBot#filter_volume=
20
+ # @return [Integer] the volume used as a filter to ffmpeg/avconv.
21
+ attr_accessor :filter_volume
22
+
23
+ # Create a new encoder
24
+ def initialize
25
+ sample_rate = 48_000
26
+ frame_size = 960
27
+ channels = 2
28
+ @filter_volume = 1
29
+
30
+ raise LoadError, "Opus unavailable - voice not supported! Please install opus for voice support to work." unless OPUS_AVAILABLE
31
+
32
+ @opus = Opus::Encoder.new(sample_rate, frame_size, channels)
33
+ end
34
+
35
+ # Set the opus encoding bitrate
36
+ # @param value [Integer] The new bitrate to use, in bits per second (so 64000 if you want 64 kbps)
37
+ def bitrate=(value)
38
+ @opus.bitrate = value
39
+ end
40
+
41
+ # Encodes the given buffer using opus.
42
+ # @param buffer [String] An unencoded PCM (s16le) buffer.
43
+ # @return [String] A buffer encoded using opus.
44
+ def encode(buffer)
45
+ @opus.encode(buffer, 1920)
46
+ end
47
+
48
+ # One frame of complete silence Opus encoded
49
+ OPUS_SILENCE = [0xF8, 0xFF, 0xFE].pack("C*").freeze
50
+
51
+ # Adjusts the volume of a given buffer of s16le PCM data.
52
+ # @param buf [String] An unencoded PCM (s16le) buffer.
53
+ # @param mult [Float] The volume multiplier, 1 for same volume.
54
+ # @return [String] The buffer with adjusted volume, s16le again
55
+ def adjust_volume(buf, mult)
56
+ # We don't need to adjust anything if the buf is nil so just return in that case
57
+ return unless buf
58
+
59
+ # buf is s16le so use 's<' for signed, 16 bit, LE
60
+ result = buf.unpack("s<*").map do |sample|
61
+ sample *= mult
62
+
63
+ # clamp to s16 range
64
+ sample.clamp(-32_768, 32_767)
65
+ end
66
+
67
+ # After modification, make it s16le again
68
+ result.pack("s<*")
69
+ end
70
+
71
+ # Encodes a given file (or rather, decodes it) using ffmpeg. This accepts pretty much any format, even videos with
72
+ # an audio track. For a list of supported formats, see https://ffmpeg.org/general.html#Audio-Codecs. It even accepts
73
+ # URLs, though encoding them is pretty slow - I recommend to make a stream of it and then use {#encode_io} instead.
74
+ # @param file [String] The path or URL to encode.
75
+ # @param options [String] ffmpeg options to pass after the -i flag
76
+ # @return [IO] the audio, encoded as s16le PCM
77
+ def encode_file(file, options = "")
78
+ command = ffmpeg_command(input: file, options: options)
79
+ IO.popen(command)
80
+ end
81
+
82
+ # Encodes an arbitrary IO audio stream using ffmpeg. Accepts pretty much any media format, even videos with audio
83
+ # tracks. For a list of supported audio formats, see https://ffmpeg.org/general.html#Audio-Codecs.
84
+ # @param io [IO] The stream to encode.
85
+ # @param options [String] ffmpeg options to pass after the -i flag
86
+ # @return [IO] the audio, encoded as s16le PCM
87
+ def encode_io(io, options = "")
88
+ command = ffmpeg_command(options: options)
89
+ IO.popen(command, in: io)
90
+ end
91
+
92
+ private
93
+
94
+ def ffmpeg_command(input: "-", options: null)
95
+ [
96
+ @use_avconv ? "avconv" : "ffmpeg",
97
+ "-loglevel", "0",
98
+ "-i", input,
99
+ "-f", "s16le",
100
+ "-ar", "48000",
101
+ "-ac", "2",
102
+ "pipe:1",
103
+ filter_volume_argument
104
+ ].concat(options.split).reject { |segment| segment.nil? || segment == "" }
105
+ end
106
+
107
+ def filter_volume_argument
108
+ return "" if @filter_volume == 1
109
+
110
+ @use_avconv ? "-vol #{(@filter_volume * 256).ceil}" : "-af volume=#{@filter_volume}"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,366 @@
1
+ require "websocket-client-simple"
2
+ require "socket"
3
+ require "json"
4
+
5
+ require "rubycord/websocket"
6
+
7
+ begin
8
+ LIBSODIUM_AVAILABLE = if ENV["RUBYCORD_NONACL"]
9
+ false
10
+ else
11
+ require "rubycord/voice/sodium"
12
+ end
13
+ rescue LoadError
14
+ puts "libsodium not available! You can continue to use rubycord as normal but voice support won't work.
15
+ Read https://github.com/dakurei-gems/rubycord/wiki/Installing-libsodium for more details."
16
+ LIBSODIUM_AVAILABLE = false
17
+ end
18
+
19
+ module Rubycord::Voice
20
+ # Signifies to Discord that encryption should be used
21
+ # @deprecated Discord now supports multiple encryption options.
22
+ # TODO: Resolve replacement for this constant.
23
+ ENCRYPTED_MODE = "xsalsa20_poly1305"
24
+
25
+ # Signifies to Discord that no encryption should be used
26
+ # @deprecated Discord no longer supports unencrypted voice communication.
27
+ PLAIN_MODE = "plain"
28
+
29
+ # Encryption modes supported by Discord
30
+ ENCRYPTION_MODES = %w[xsalsa20_poly1305_lite xsalsa20_poly1305_suffix xsalsa20_poly1305].freeze
31
+
32
+ # Represents a UDP connection to a voice server. This connection is used to send the actual audio data.
33
+ class VoiceUDP
34
+ # @return [true, false] whether or not UDP communications are encrypted.
35
+ # @deprecated Discord no longer supports unencrypted voice communication.
36
+ attr_accessor :encrypted
37
+ alias_method :encrypted?, :encrypted
38
+
39
+ # Sets the secret key used for encryption
40
+ attr_writer :secret_key
41
+
42
+ # The UDP encryption mode
43
+ attr_reader :mode
44
+
45
+ # @!visibility private
46
+ attr_writer :mode
47
+
48
+ # Creates a new UDP connection. Only creates a socket as the discovery reply may come before the data is
49
+ # initialized.
50
+ def initialize
51
+ @socket = UDPSocket.new
52
+ @encrypted = true
53
+ end
54
+
55
+ # Initializes the UDP socket with data obtained from opcode 2.
56
+ # @param ip [String] The IP address to connect to.
57
+ # @param port [Integer] The port to connect to.
58
+ # @param ssrc [Integer] The Super Secret Relay Code (SSRC). Discord uses this to identify different voice users
59
+ # on the same endpoint.
60
+ def connect(ip, port, ssrc)
61
+ @ip = ip
62
+ @port = port
63
+ @ssrc = ssrc
64
+ end
65
+
66
+ # Waits for a UDP discovery reply, and returns the sent data.
67
+ # @return [Array(String, Integer)] the IP and port received from the discovery reply.
68
+ def receive_discovery_reply
69
+ # Wait for a UDP message
70
+ message = @socket.recv(74)
71
+ ip = message[8..-3].delete("\0")
72
+ port = message[-2..].unpack1("n")
73
+ [ip, port]
74
+ end
75
+
76
+ # Makes an audio packet from a buffer and sends it to Discord.
77
+ # @param buf [String] The audio data to send, must be exactly one Opus frame
78
+ # @param sequence [Integer] The packet sequence number, incremented by one for subsequent packets
79
+ # @param time [Integer] When this packet should be played back, in no particular unit (essentially just the
80
+ # sequence number multiplied by 960)
81
+ def send_audio(buf, sequence, time)
82
+ # Header of the audio packet
83
+ header = [0x80, 0x78, sequence, time, @ssrc].pack("CCnNN")
84
+
85
+ nonce = generate_nonce(header)
86
+ buf = encrypt_audio(buf, nonce)
87
+
88
+ data = header + buf
89
+
90
+ # xsalsa20_poly1305 does not require an appended nonce
91
+ data += nonce unless @mode == "xsalsa20_poly1305"
92
+
93
+ send_packet(data)
94
+ end
95
+
96
+ # Sends the UDP discovery packet with the internally stored SSRC. Discord will send a reply afterwards which can
97
+ # be received using {#receive_discovery_reply}
98
+ def send_discovery
99
+ # Create empty packet
100
+ discovery_packet = ""
101
+
102
+ # Add Type request (0x1 = request, 0x2 = response)
103
+ discovery_packet += [0x1].pack("n")
104
+
105
+ # Add Length (excluding Type and itself = 70)
106
+ discovery_packet += [70].pack("n")
107
+
108
+ # Add SSRC
109
+ discovery_packet += [@ssrc].pack("N")
110
+
111
+ # Add 66 zeroes so the packet is 74 bytes long
112
+ discovery_packet += "\0" * 66
113
+
114
+ send_packet(discovery_packet)
115
+ end
116
+
117
+ private
118
+
119
+ # Encrypts audio data using libsodium
120
+ # @param buf [String] The encoded audio data to be encrypted
121
+ # @param nonce [String] The nonce to be used to encrypt the data
122
+ # @return [String] the audio data, encrypted
123
+ def encrypt_audio(buf, nonce)
124
+ raise "No secret key found, despite encryption being enabled!" unless @secret_key
125
+
126
+ secret_box = Rubycord::Voice::SecretBox.new(@secret_key)
127
+
128
+ # Nonces must be 24 bytes in length. We right pad with null bytes for poly1305 and poly1305_lite
129
+ secret_box.box(nonce.ljust(24, "\0"), buf)
130
+ end
131
+
132
+ def send_packet(packet)
133
+ @socket.send(packet, 0, @ip, @port)
134
+ end
135
+
136
+ # @param header [String] The header of the packet, to be used as the nonce
137
+ # @return [String]
138
+ # @note
139
+ # The nonce generated depends on the encryption mode.
140
+ # In xsalsa20_poly1305 the nonce is the header plus twelve null bytes for padding.
141
+ # In xsalsa20_poly1305_suffix, the nonce is 24 random bytes
142
+ # In xsalsa20_poly1305_lite, the nonce is an incremental 4 byte int.
143
+ def generate_nonce(header)
144
+ case @mode
145
+ when "xsalsa20_poly1305"
146
+ header
147
+ when "xsalsa20_poly1305_suffix"
148
+ Random.urandom(24)
149
+ when "xsalsa20_poly1305_lite"
150
+ case @lite_nonce
151
+ when nil, 0xff_ff_ff_ff
152
+ @lite_nonce = 0
153
+ else
154
+ @lite_nonce += 1
155
+ end
156
+ [@lite_nonce].pack("N")
157
+ else
158
+ raise "`#{@mode}' is not a supported encryption mode"
159
+ end
160
+ end
161
+ end
162
+
163
+ # Represents a websocket client connection to the voice server. The websocket connection (sometimes called vWS) is
164
+ # used to manage general data about the connection, such as sending the speaking packet, which determines the green
165
+ # circle around users on Discord, and obtaining UDP connection info.
166
+ class VoiceWS
167
+ # The version of the voice gateway that's supposed to be used.
168
+ VOICE_GATEWAY_VERSION = 4
169
+
170
+ # @return [VoiceUDP] the UDP voice connection over which the actual audio data is sent.
171
+ attr_reader :udp
172
+
173
+ # Makes a new voice websocket client, but doesn't connect it (see {#connect} for that)
174
+ # @param channel [Channel] The voice channel to connect to
175
+ # @param bot [Bot] The regular bot to which this vWS is bound
176
+ # @param token [String] The authentication token which is also used for REST requests
177
+ # @param session [String] The voice session ID Discord sends over the regular websocket
178
+ # @param endpoint [String] The endpoint URL to connect to
179
+ def initialize(channel, bot, token, session, endpoint)
180
+ raise "libsodium is unavailable - unable to create voice bot! Please read https://github.com/dakurei-gems/rubycord/wiki/Installing-libsodium" unless LIBSODIUM_AVAILABLE
181
+
182
+ @channel = channel
183
+ @bot = bot
184
+ @token = token
185
+ @session = session
186
+
187
+ @endpoint = endpoint.split(":").first
188
+
189
+ @udp = VoiceUDP.new
190
+ end
191
+
192
+ # Send a connection init packet (op 0)
193
+ # @param server_id [Integer] The ID of the server to connect to
194
+ # @param bot_user_id [Integer] The ID of the bot that is connecting
195
+ # @param session_id [String] The voice session ID
196
+ # @param token [String] The Discord authentication token
197
+ def send_init(server_id, bot_user_id, session_id, token)
198
+ @client.send({
199
+ op: 0,
200
+ d: {
201
+ server_id: server_id,
202
+ user_id: bot_user_id,
203
+ session_id: session_id,
204
+ token: token
205
+ }
206
+ }.to_json)
207
+ end
208
+
209
+ # Sends the UDP connection packet (op 1)
210
+ # @param ip [String] The IP to bind UDP to
211
+ # @param port [Integer] The port to bind UDP to
212
+ # @param mode [Object] Which mode to use for the voice connection
213
+ def send_udp_connection(ip, port, mode)
214
+ @client.send({
215
+ op: 1,
216
+ d: {
217
+ protocol: "udp",
218
+ data: {
219
+ address: ip,
220
+ port: port,
221
+ mode: mode
222
+ }
223
+ }
224
+ }.to_json)
225
+ end
226
+
227
+ # Send a heartbeat (op 3), has to be done every @heartbeat_interval seconds or the connection will terminate
228
+ def send_heartbeat
229
+ millis = Time.now.strftime("%s%L").to_i
230
+ @bot.debug("Sending voice heartbeat at #{millis}")
231
+
232
+ @client.send({
233
+ op: 3,
234
+ d: millis
235
+ }.to_json)
236
+ end
237
+
238
+ # Send a speaking packet (op 5). This determines the green circle around the avatar in the voice channel
239
+ # @param value [true, false, Integer] Whether or not the bot should be speaking, can also be a bitmask denoting audio type.
240
+ def send_speaking(value)
241
+ @bot.debug("Speaking: #{value}")
242
+ @client.send({
243
+ op: 5,
244
+ d: {
245
+ speaking: value,
246
+ delay: 0
247
+ }
248
+ }.to_json)
249
+ end
250
+
251
+ # Event handlers; public for websocket-simple to work correctly
252
+ # @!visibility private
253
+ def websocket_open
254
+ # Give the current thread a name ('Voice Web Socket Internal')
255
+ Thread.current[:rubycord_name] = "vws-i"
256
+
257
+ # Send the init packet
258
+ send_init(@channel.server.id, @bot.profile.id, @session, @token)
259
+ end
260
+
261
+ # @!visibility private
262
+ def websocket_message(msg)
263
+ @bot.debug("Received VWS message! #{msg}")
264
+ packet = JSON.parse(msg)
265
+
266
+ case packet["op"]
267
+ when 2
268
+ # Opcode 2 contains data to initialize the UDP connection
269
+ @ws_data = packet["d"]
270
+
271
+ @ssrc = @ws_data["ssrc"]
272
+ @port = @ws_data["port"]
273
+
274
+ @udp_mode = (ENCRYPTION_MODES & @ws_data["modes"]).first
275
+
276
+ @udp.connect(@ws_data["ip"], @port, @ssrc)
277
+ @udp.send_discovery
278
+ when 4
279
+ # Opcode 4 sends the secret key used for encryption
280
+ @ws_data = packet["d"]
281
+
282
+ @ready = true
283
+ @udp.secret_key = @ws_data["secret_key"].pack("C*")
284
+ @udp.mode = @ws_data["mode"]
285
+ when 8
286
+ # Opcode 8 contains the heartbeat interval.
287
+ @heartbeat_interval = packet["d"]["heartbeat_interval"]
288
+ end
289
+ end
290
+
291
+ # Communication goes like this:
292
+ # me discord
293
+ # | |
294
+ # websocket connect -> |
295
+ # | |
296
+ # | <- websocket opcode 2
297
+ # | |
298
+ # UDP discovery -> |
299
+ # | |
300
+ # | <- UDP reply packet
301
+ # | |
302
+ # websocket opcode 1 -> |
303
+ # | |
304
+ # ...
305
+ def connect
306
+ # Connect websocket
307
+ @thread = Thread.new do
308
+ Thread.current[:rubycord_name] = "vws"
309
+ init_ws
310
+ end
311
+
312
+ @bot.debug("Started websocket initialization, now waiting for UDP discovery reply")
313
+
314
+ # Now wait for opcode 2 and the resulting UDP reply packet
315
+ ip, port = @udp.receive_discovery_reply
316
+ @bot.debug("UDP discovery reply received! #{ip} #{port}")
317
+
318
+ # Send UDP init packet with received UDP data
319
+ send_udp_connection(ip, port, @udp_mode)
320
+
321
+ @bot.debug("Waiting for op 4 now")
322
+
323
+ # Wait for op 4, then finish
324
+ sleep 0.05 until @ready
325
+ end
326
+
327
+ # Disconnects the websocket and kills the thread
328
+ def destroy
329
+ @heartbeat_running = false
330
+ end
331
+
332
+ private
333
+
334
+ def heartbeat_loop
335
+ @heartbeat_running = true
336
+ while @heartbeat_running
337
+ if @heartbeat_interval
338
+ sleep @heartbeat_interval / 1000.0
339
+ send_heartbeat
340
+ else
341
+ # If no interval has been set yet, sleep a second and check again
342
+ sleep 1
343
+ end
344
+ end
345
+ end
346
+
347
+ def init_ws
348
+ host = "wss://#{@endpoint}:443/?v=#{VOICE_GATEWAY_VERSION}"
349
+ @bot.debug("Connecting VWS to host: #{host}")
350
+
351
+ # Connect the WS
352
+ @client = Rubycord::WebSocket.new(
353
+ host,
354
+ method(:websocket_open),
355
+ method(:websocket_message),
356
+ proc { |e| Rubycord::LOGGER.error "VWS error: #{e}" },
357
+ proc { |e| Rubycord::LOGGER.warn "VWS close: #{e}" }
358
+ )
359
+
360
+ @bot.debug("VWS connected")
361
+
362
+ # Block any further execution
363
+ heartbeat_loop
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,96 @@
1
+ require "ffi"
2
+
3
+ module Rubycord::Voice
4
+ # @!visibility private
5
+ module Sodium
6
+ extend ::FFI::Library
7
+
8
+ ffi_lib(["sodium", "libsodium.so.18", "libsodium.so.23"])
9
+
10
+ # Encryption & decryption
11
+ attach_function(:crypto_secretbox_xsalsa20poly1305, %i[pointer pointer ulong_long pointer pointer], :int)
12
+ attach_function(:crypto_secretbox_xsalsa20poly1305_open, %i[pointer pointer ulong_long pointer pointer], :int)
13
+
14
+ # Constants
15
+ attach_function(:crypto_secretbox_xsalsa20poly1305_keybytes, [], :size_t)
16
+ attach_function(:crypto_secretbox_xsalsa20poly1305_noncebytes, [], :size_t)
17
+ attach_function(:crypto_secretbox_xsalsa20poly1305_zerobytes, [], :size_t)
18
+ attach_function(:crypto_secretbox_xsalsa20poly1305_boxzerobytes, [], :size_t)
19
+ end
20
+
21
+ # Utility class for interacting with required `xsalsa20poly1305` functions for voice transmission
22
+ # @!visibility private
23
+ class SecretBox
24
+ # Exception raised when a key or nonce with invalid length is used
25
+ class LengthError < RuntimeError
26
+ end
27
+
28
+ # Exception raised when encryption or decryption fails
29
+ class CryptoError < RuntimeError
30
+ end
31
+
32
+ # Required key length
33
+ KEY_LENGTH = Sodium.crypto_secretbox_xsalsa20poly1305_keybytes
34
+
35
+ # Required nonce length
36
+ NONCE_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_noncebytes
37
+
38
+ # Zero byte padding for encryption
39
+ ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
40
+
41
+ # Zero byte padding for decryption
42
+ BOX_ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
43
+
44
+ # @param key [String] Crypto key of length {KEY_LENGTH}
45
+ def initialize(key)
46
+ raise(LengthError, "Key length") if key.bytesize != KEY_LENGTH
47
+
48
+ @key = key
49
+ end
50
+
51
+ # Encrypts a message using this box's key
52
+ # @param nonce [String] encryption nonce for this message
53
+ # @param message [String] message to be encrypted
54
+ def box(nonce, message)
55
+ raise(LengthError, "Nonce length") if nonce.bytesize != NONCE_BYTES
56
+
57
+ message_padded = prepend_zeroes(ZERO_BYTES, message)
58
+ buffer = zero_string(message_padded.bytesize)
59
+
60
+ success = Sodium.crypto_secretbox_xsalsa20poly1305(buffer, message_padded, message_padded.bytesize, nonce, @key)
61
+ raise(CryptoError, "Encryption failed (#{success})") unless success.zero?
62
+
63
+ remove_zeroes(BOX_ZERO_BYTES, buffer)
64
+ end
65
+
66
+ # Decrypts the given ciphertext using this box's key
67
+ # @param nonce [String] encryption nonce for this ciphertext
68
+ # @param ciphertext [String] ciphertext to decrypt
69
+ def open(nonce, ciphertext)
70
+ raise(LengthError, "Nonce length") if nonce.bytesize != NONCE_BYTES
71
+
72
+ ct_padded = prepend_zeroes(BOX_ZERO_BYTES, ciphertext)
73
+ buffer = zero_string(ct_padded.bytesize)
74
+
75
+ success = Sodium.crypto_secretbox_xsalsa20poly1305_open(buffer, ct_padded, ct_padded.bytesize, nonce, @key)
76
+ raise(CryptoError, "Decryption failed (#{success})") unless success.zero?
77
+
78
+ remove_zeroes(ZERO_BYTES, buffer)
79
+ end
80
+
81
+ private
82
+
83
+ def zero_string(size)
84
+ str = "\0" * size
85
+ str.force_encoding("ASCII-8BIT") if str.respond_to?(:force_encoding)
86
+ end
87
+
88
+ def prepend_zeroes(size, string)
89
+ zero_string(size) + string
90
+ end
91
+
92
+ def remove_zeroes(size, string)
93
+ string.slice!(size, string.bytesize - size)
94
+ end
95
+ end
96
+ end