telnyx 5.38.0 → 5.38.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +1 -1
- data/lib/telnyx/errors.rb +10 -0
- data/lib/telnyx/resources/webhooks.rb +186 -3
- data/lib/telnyx/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18f02735965f6afbffaec63905bdc5d5b951c5cac8a5eb4ec550c1777933d485
|
|
4
|
+
data.tar.gz: 23493c86e9635ca193f9b0b6aac0b97c5003907bcc559108bdc0f544529516e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d456f5bc0c2a1cbc4a15cb6b434234894539c6e3327d15b35159decc0797d752b4381147521714d9031dd4b2941618fc03c7b079eb8ad4270b05381747c5721
|
|
7
|
+
data.tar.gz: 9a85318ee403c82111ced92fdcc0dee90a4ee266c9b657b52ed7b7270405408d06b5f74438861bedd29c47c76d0c099efb277434c02ebeb369ba52ad8d77fc73
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.38.1 (2026-02-24)
|
|
4
|
+
|
|
5
|
+
Full Changelog: [v5.38.0...v5.38.1](https://github.com/team-telnyx/telnyx-ruby/compare/v5.38.0...v5.38.1)
|
|
6
|
+
|
|
3
7
|
## 5.38.0 (2026-02-24)
|
|
4
8
|
|
|
5
9
|
Full Changelog: [v5.37.0...v5.38.0](https://github.com/team-telnyx/telnyx-ruby/compare/v5.37.0...v5.38.0)
|
data/README.md
CHANGED
data/lib/telnyx/errors.rb
CHANGED
|
@@ -224,5 +224,15 @@ module Telnyx
|
|
|
224
224
|
class InternalServerError < Telnyx::Errors::APIStatusError
|
|
225
225
|
HTTP_STATUS = (500..)
|
|
226
226
|
end
|
|
227
|
+
|
|
228
|
+
# Raised when webhook signature verification fails.
|
|
229
|
+
class WebhookVerificationError < Telnyx::Errors::Error
|
|
230
|
+
# @api private
|
|
231
|
+
#
|
|
232
|
+
# @param message [String]
|
|
233
|
+
def initialize(message:)
|
|
234
|
+
super(message)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
227
237
|
end
|
|
228
238
|
end
|
|
@@ -1,30 +1,213 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
3
7
|
module Telnyx
|
|
4
8
|
module Resources
|
|
9
|
+
# Telnyx webhook verification using ED25519 signatures.
|
|
10
|
+
#
|
|
11
|
+
# This class provides ED25519 signature verification for Telnyx webhooks,
|
|
12
|
+
# matching the implementation pattern used in the Python and Node SDKs.
|
|
13
|
+
#
|
|
14
|
+
# Example usage:
|
|
15
|
+
#
|
|
16
|
+
# client = Telnyx::Client.new(
|
|
17
|
+
# api_key: ENV["TELNYX_API_KEY"],
|
|
18
|
+
# public_key: ENV["TELNYX_PUBLIC_KEY"] # Base64 from Mission Control
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# # In your webhook handler:
|
|
22
|
+
# event = client.webhooks.unwrap(payload, headers)
|
|
23
|
+
#
|
|
5
24
|
class Webhooks
|
|
25
|
+
# Telnyx webhook signature headers (case-insensitive per HTTP spec)
|
|
26
|
+
SIGNATURE_HEADER = "telnyx-signature-ed25519"
|
|
27
|
+
TIMESTAMP_HEADER = "telnyx-timestamp"
|
|
28
|
+
|
|
29
|
+
# Tolerance for timestamp validation (5 minutes)
|
|
30
|
+
TIMESTAMP_TOLERANCE_SECONDS = 300
|
|
31
|
+
|
|
32
|
+
# Unwraps a webhook event from its JSON representation without verifying the signature.
|
|
33
|
+
#
|
|
6
34
|
# @param payload [String] The raw webhook payload as a string
|
|
7
35
|
#
|
|
8
|
-
# @return [Telnyx::Models::
|
|
36
|
+
# @return [Telnyx::Models::UnsafeUnwrapWebhookEvent]
|
|
9
37
|
def unsafe_unwrap(payload)
|
|
10
38
|
parsed = JSON.parse(payload, symbolize_names: true)
|
|
11
39
|
Telnyx::Internal::Type::Converter.coerce(Telnyx::Models::UnsafeUnwrapWebhookEvent, parsed)
|
|
12
40
|
end
|
|
13
41
|
|
|
42
|
+
# Unwraps a webhook event from its JSON representation, verifying the signature if headers are provided.
|
|
43
|
+
#
|
|
44
|
+
# When headers are provided and the client has a public_key configured, this method will verify
|
|
45
|
+
# the ED25519 signature to ensure the webhook came from Telnyx.
|
|
46
|
+
#
|
|
14
47
|
# @param payload [String] The raw webhook payload as a string
|
|
48
|
+
# @param headers [Hash, nil] Optional HTTP headers from the webhook request
|
|
49
|
+
# @param key [String, nil] Optional public key override (base64-encoded)
|
|
50
|
+
#
|
|
51
|
+
# @return [Telnyx::Models::UnwrapWebhookEvent]
|
|
15
52
|
#
|
|
16
|
-
# @
|
|
17
|
-
def unwrap(payload)
|
|
53
|
+
# @raise [Telnyx::Errors::WebhookVerificationError] If signature verification fails
|
|
54
|
+
def unwrap(payload, headers = nil, key: nil)
|
|
55
|
+
# Get public key from argument or client
|
|
56
|
+
public_key = key || @client.public_key
|
|
57
|
+
|
|
58
|
+
# If we have headers and a public key, verify the signature
|
|
59
|
+
if headers && !headers.empty? && public_key && !public_key.empty?
|
|
60
|
+
verify_signature(payload, headers, public_key)
|
|
61
|
+
end
|
|
62
|
+
|
|
18
63
|
parsed = JSON.parse(payload, symbolize_names: true)
|
|
19
64
|
Telnyx::Internal::Type::Converter.coerce(Telnyx::Models::UnwrapWebhookEvent, parsed)
|
|
20
65
|
end
|
|
21
66
|
|
|
67
|
+
# Verify webhook signature without parsing the payload.
|
|
68
|
+
#
|
|
69
|
+
# This method is consistent with the Node SDK's verify() method, allowing
|
|
70
|
+
# signature verification without parsing the webhook payload. The bang (!)
|
|
71
|
+
# indicates this method raises an exception on failure (Ruby convention).
|
|
72
|
+
#
|
|
73
|
+
# @param payload [String] The raw webhook payload
|
|
74
|
+
# @param headers [Hash] The webhook headers
|
|
75
|
+
# @param key [String, nil] Optional public key override (base64-encoded)
|
|
76
|
+
#
|
|
77
|
+
# @return [void]
|
|
78
|
+
#
|
|
79
|
+
# @raise [Telnyx::Errors::WebhookVerificationError] If verification fails or no public key available
|
|
80
|
+
def verify!(payload, headers, key: nil)
|
|
81
|
+
public_key = key || @client.public_key
|
|
82
|
+
|
|
83
|
+
unless public_key && !public_key.empty?
|
|
84
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
85
|
+
message: "No public key configured. Provide key parameter or configure client with public_key."
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
verify_signature(payload, headers, public_key)
|
|
90
|
+
end
|
|
91
|
+
|
|
22
92
|
# @api private
|
|
23
93
|
#
|
|
24
94
|
# @param client [Telnyx::Client]
|
|
25
95
|
def initialize(client:)
|
|
26
96
|
@client = client
|
|
27
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Get header value case-insensitively
|
|
102
|
+
#
|
|
103
|
+
# @param headers [Hash] The headers hash
|
|
104
|
+
# @param key [String] The header key to find
|
|
105
|
+
#
|
|
106
|
+
# @return [String, nil]
|
|
107
|
+
def get_header(headers, key)
|
|
108
|
+
key_lower = key.downcase
|
|
109
|
+
headers.each do |header_key, header_value|
|
|
110
|
+
return header_value if header_key.to_s.downcase == key_lower
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Verify the webhook signature using ED25519 cryptography
|
|
116
|
+
#
|
|
117
|
+
# @param payload [String] The raw webhook payload
|
|
118
|
+
# @param headers [Hash] The webhook headers
|
|
119
|
+
# @param public_key [String] The ED25519 public key (base64-encoded)
|
|
120
|
+
#
|
|
121
|
+
# @raise [Telnyx::Errors::WebhookVerificationError] If verification fails
|
|
122
|
+
def verify_signature(payload, headers, public_key)
|
|
123
|
+
# Extract required headers (case-insensitive)
|
|
124
|
+
signature_header = get_header(headers, SIGNATURE_HEADER)
|
|
125
|
+
timestamp_header = get_header(headers, TIMESTAMP_HEADER)
|
|
126
|
+
|
|
127
|
+
unless signature_header
|
|
128
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
129
|
+
message: "Missing required header: #{SIGNATURE_HEADER}"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
unless timestamp_header
|
|
134
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
135
|
+
message: "Missing required header: #{TIMESTAMP_HEADER}"
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Validate timestamp to prevent replay attacks (5 minute tolerance)
|
|
140
|
+
begin
|
|
141
|
+
webhook_time = Integer(timestamp_header)
|
|
142
|
+
current_time = Time.now.to_i
|
|
143
|
+
time_diff = (current_time - webhook_time).abs
|
|
144
|
+
|
|
145
|
+
if time_diff > TIMESTAMP_TOLERANCE_SECONDS
|
|
146
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
147
|
+
message: "Webhook timestamp is too old or too new (#{time_diff}s difference)"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
rescue ArgumentError
|
|
151
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
152
|
+
message: "Invalid timestamp format: #{timestamp_header}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Decode public key from base64
|
|
157
|
+
begin
|
|
158
|
+
public_key_bytes = Base64.strict_decode64(public_key)
|
|
159
|
+
|
|
160
|
+
if public_key_bytes.bytesize != 32
|
|
161
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
162
|
+
message: "Invalid public key: expected 32 bytes, got #{public_key_bytes.bytesize} bytes"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
rescue ArgumentError => e
|
|
166
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
167
|
+
message: "Invalid public key format: #{e.message}"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Decode signature from base64
|
|
172
|
+
begin
|
|
173
|
+
signature_bytes = Base64.strict_decode64(signature_header)
|
|
174
|
+
|
|
175
|
+
if signature_bytes.bytesize != 64
|
|
176
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
177
|
+
message: "Invalid signature length: expected 64 bytes, got #{signature_bytes.bytesize} bytes"
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
rescue ArgumentError => e
|
|
181
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
182
|
+
message: "Invalid signature format: #{e.message}"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Create the signed payload: timestamp|payload
|
|
187
|
+
signed_payload = "#{timestamp_header}|#{payload}"
|
|
188
|
+
|
|
189
|
+
# Build ED25519 public key for verification
|
|
190
|
+
# The raw 32-byte key needs to be wrapped in X.509 SubjectPublicKeyInfo format
|
|
191
|
+
# ED25519 OID: 1.3.101.112 = 06 03 2b 65 70
|
|
192
|
+
# X.509 SPKI header for ED25519: 30 2a 30 05 06 03 2b 65 70 03 21 00
|
|
193
|
+
ed25519_spki_header = [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00].pack("C*")
|
|
194
|
+
der_key = ed25519_spki_header + public_key_bytes
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
pkey = OpenSSL::PKey.read(der_key)
|
|
198
|
+
valid = pkey.verify(nil, signature_bytes, signed_payload)
|
|
199
|
+
|
|
200
|
+
unless valid
|
|
201
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
202
|
+
message: "Signature verification failed: signature does not match payload"
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
206
|
+
raise Telnyx::Errors::WebhookVerificationError.new(
|
|
207
|
+
message: "Signature verification failed: #{e.message}"
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
28
211
|
end
|
|
29
212
|
end
|
|
30
213
|
end
|
data/lib/telnyx/version.rb
CHANGED