tina4ruby 3.13.133 → 3.13.134
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 +7 -0
- data/README.md +2 -2
- data/lib/tina4/push.rb +195 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d0d4c7801e0766f00041e3d004a0318d7bd4e22d9b4a4d102d05516b89ebba1
|
|
4
|
+
data.tar.gz: 837234011a9a76e4d0733d8a05936bdb08a5bcb8681638679064b51d84dc2392
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfd2d4b058f1366920dd46b66f1d59f504f1b30cc649b71ecc7f5d8f8d15a385eaa19b5de9078768c0f935aba9a3115e8e10efe72806cfa53be5aca73f5c2caf
|
|
7
|
+
data.tar.gz: 178596fd8db63262bc35aeb25ae13e11c609b3f50a85bc06d91ce12063b9739973f80ce5783ec5a330d115b3b07e9389718f2316d8c53590b47867ad777b193d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,13 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.134
|
|
10
|
+
|
|
11
|
+
Feature 140 Web Push is now available with provider-neutral subscription delivery,
|
|
12
|
+
native result envelopes, fail-closed configuration, and the documented runtime
|
|
13
|
+
configuration. Developer skills now include the Web Push API and configuration
|
|
14
|
+
reference for all supported language stacks.
|
|
15
|
+
|
|
9
16
|
## 3.13.133
|
|
10
17
|
|
|
11
18
|
Maintainability pass, no behavior changes. Swagger.generate decomposed into cohesive
|
data/README.md
CHANGED
|
@@ -102,7 +102,7 @@ Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
|
102
102
|
| **Tina4 Ruby** | **10,243** | 0 | 55 |
|
|
103
103
|
| Sinatra | 9,548 | 5+ | ~4 |
|
|
104
104
|
|
|
105
|
-
Tina4 Ruby outperforms Sinatra while delivering **
|
|
105
|
+
Tina4 Ruby outperforms Sinatra while delivering **140 cataloged features vs ~4**, with zero runtime dependencies.
|
|
106
106
|
|
|
107
107
|
**Across all 4 Tina4 implementations:**
|
|
108
108
|
|
|
@@ -116,7 +116,7 @@ Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero
|
|
|
116
116
|
|
|
117
117
|
## Cross-Framework Parity
|
|
118
118
|
|
|
119
|
-
Tina4 ships identical features across four languages: same architecture, same conventions, same
|
|
119
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, the same 140 cataloged features:
|
|
120
120
|
|
|
121
121
|
| | Python | PHP | Ruby | Node.js |
|
|
122
122
|
|---|--------|-----|------|---------|
|
data/lib/tina4/push.rb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Tina4
|
|
10
|
+
class PushError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Provider-neutral Web Push sender using Ruby's stdlib OpenSSL and Net::HTTP.
|
|
13
|
+
# Web Push remains optional at use time; a Ruby build without OpenSSL fails
|
|
14
|
+
# loudly when the feature is selected rather than silently sending plaintext.
|
|
15
|
+
class Push
|
|
16
|
+
RECORD_SIZE = 4096
|
|
17
|
+
MAX_PAYLOAD = RECORD_SIZE - 17
|
|
18
|
+
|
|
19
|
+
def self.generate_vapid_keys
|
|
20
|
+
require_openssl
|
|
21
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
22
|
+
{
|
|
23
|
+
"publicKey" => b64(key.public_key.to_bn.to_s(2)),
|
|
24
|
+
"privateKey" => b64(key.private_key.to_s(2))
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
|
|
29
|
+
@subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
|
|
30
|
+
@public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
|
|
31
|
+
@private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
@urgency = urgency
|
|
34
|
+
if %w[0 false off no].include?(ENV.fetch("TINA4_WEB_PUSH", "").strip.downcase)
|
|
35
|
+
raise PushError, "Web Push is disabled by TINA4_WEB_PUSH"
|
|
36
|
+
end
|
|
37
|
+
configuration if [@subject, @public_key, @private_key].any? { |value| !value.empty? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def send(subscription, payload)
|
|
41
|
+
endpoint, uri = endpoint_for(subscription)
|
|
42
|
+
subject, public_key, private_key = configuration
|
|
43
|
+
public, private = vapid_keys(public_key, private_key)
|
|
44
|
+
deliver(endpoint, uri, subject, public_key, private, public, encrypt(payload_bytes(payload), subscription))
|
|
45
|
+
rescue URI::InvalidURIError => e
|
|
46
|
+
raise PushError, "Push subscription endpoint must be a valid URL: #{e.message}"
|
|
47
|
+
rescue Net::HTTPError, SocketError, SystemCallError => e
|
|
48
|
+
raise PushError, "Web Push request failed: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def endpoint_for(subscription)
|
|
54
|
+
endpoint = subscription.is_a?(Hash) ? (subscription["endpoint"] || subscription[:endpoint]) : nil
|
|
55
|
+
raise PushError, "A Web Push subscription with an endpoint is required" unless endpoint.is_a?(String) && !endpoint.empty?
|
|
56
|
+
uri = URI.parse(endpoint)
|
|
57
|
+
raise PushError, "Push subscription endpoint must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme) && uri.host
|
|
58
|
+
[endpoint, uri]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def vapid_keys(public_key, private_key)
|
|
62
|
+
public = decode(public_key, "TINA4_VAPID_PUBLIC")
|
|
63
|
+
private = decode(private_key, "TINA4_VAPID_PRIVATE")
|
|
64
|
+
raise PushError, "TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key" unless public.bytesize == 65 && public.getbyte(0) == 4
|
|
65
|
+
raise PushError, "TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key" unless private.bytesize == 32
|
|
66
|
+
begin
|
|
67
|
+
derived = private_key_from_raw(private, public).public_key.to_bn.to_s(2)
|
|
68
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
69
|
+
raise PushError, "TINA4_VAPID_PRIVATE is not a valid P-256 private key: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
raise PushError, "TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE" unless derived == public
|
|
72
|
+
[public, private]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deliver(endpoint, uri, subject, public_key, private, public, body)
|
|
76
|
+
request = Net::HTTP::Post.new(uri)
|
|
77
|
+
request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
|
|
78
|
+
request["Content-Encoding"] = "aes128gcm"
|
|
79
|
+
request["Content-Type"] = "application/octet-stream"
|
|
80
|
+
request["TTL"] = @ttl.to_i.to_s
|
|
81
|
+
request["Urgency"] = @urgency if @urgency && !@urgency.empty?
|
|
82
|
+
request.body = body
|
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
84
|
+
http.use_ssl = uri.scheme == "https"
|
|
85
|
+
response = http.start { |client| client.request(request) }
|
|
86
|
+
status = response.code.to_i
|
|
87
|
+
{ "ok" => status < 400, "status" => status, "dead" => [404, 410].include?(status), "retryable" => [408, 429].include?(status) || status >= 500, "endpoint" => endpoint, "response" => response.body.to_s }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def configuration
|
|
91
|
+
missing = []
|
|
92
|
+
missing << "TINA4_VAPID_SUBJECT" if @subject.empty?
|
|
93
|
+
missing << "TINA4_VAPID_PUBLIC" if @public_key.empty?
|
|
94
|
+
missing << "TINA4_VAPID_PRIVATE" if @private_key.empty?
|
|
95
|
+
raise PushError, "Web Push is configured but missing: #{missing.join(', ')}" unless missing.empty?
|
|
96
|
+
self.class.send(:require_openssl)
|
|
97
|
+
[@subject, @public_key, @private_key]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.require_openssl
|
|
101
|
+
return if defined?(OpenSSL::PKey::EC)
|
|
102
|
+
|
|
103
|
+
raise PushError, "Web Push requires Ruby's OpenSSL stdlib capability; rebuild Ruby with OpenSSL support"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.b64(value)
|
|
107
|
+
Base64.urlsafe_encode64(value, padding: false)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def decode(value, name)
|
|
111
|
+
raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
112
|
+
|
|
113
|
+
Base64.urlsafe_decode64(value)
|
|
114
|
+
rescue ArgumentError => e
|
|
115
|
+
raise PushError, "#{name} must be base64url encoded: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def payload_bytes(payload)
|
|
119
|
+
return payload.b if payload.is_a?(String)
|
|
120
|
+
JSON.generate(payload)
|
|
121
|
+
rescue JSON::GeneratorError => e
|
|
122
|
+
raise PushError, "Push payload is not JSON serializable: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def private_key_from_raw(private, public)
|
|
126
|
+
body = OpenSSL::ASN1::Sequence.new([
|
|
127
|
+
OpenSSL::ASN1::Integer.new(1),
|
|
128
|
+
OpenSSL::ASN1::OctetString.new(private),
|
|
129
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId.new("prime256v1")], 0, :CONTEXT_SPECIFIC),
|
|
130
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(public)], 1, :CONTEXT_SPECIFIC)
|
|
131
|
+
])
|
|
132
|
+
OpenSSL::PKey.read(body.to_der)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def public_key_from_raw(public)
|
|
136
|
+
algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"), OpenSSL::ASN1::ObjectId.new("prime256v1")])
|
|
137
|
+
OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(public)]).to_der)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def encrypt(payload, subscription)
|
|
141
|
+
raise PushError, "Push payload is too large; maximum is #{MAX_PAYLOAD} bytes" if payload.bytesize > MAX_PAYLOAD
|
|
142
|
+
keys = subscription.is_a?(Hash) ? (subscription["keys"] || subscription[:keys] || {}) : {}
|
|
143
|
+
p256dh = keys["p256dh"] || keys[:p256dh]
|
|
144
|
+
auth = keys["auth"] || keys[:auth]
|
|
145
|
+
client = decode(p256dh.to_s, "subscription.keys.p256dh")
|
|
146
|
+
auth_secret = decode(auth.to_s, "subscription.keys.auth")
|
|
147
|
+
raise PushError, "subscription.keys.p256dh must be a 65-byte P-256 public key" unless client.bytesize == 65 && client.getbyte(0) == 4
|
|
148
|
+
raise PushError, "subscription.keys.auth must be a 16-byte authentication secret" unless auth_secret.bytesize == 16
|
|
149
|
+
|
|
150
|
+
ephemeral = OpenSSL::PKey::EC.generate("prime256v1")
|
|
151
|
+
server = ephemeral.public_key.to_bn.to_s(2)
|
|
152
|
+
shared = ephemeral.derive(public_key_from_raw(client))
|
|
153
|
+
ikm = hkdf(hmac(auth_secret, shared), "WebPush: info\0" + client + server, 32)
|
|
154
|
+
salt = OpenSSL::Random.random_bytes(16)
|
|
155
|
+
prk = hmac(salt, ikm)
|
|
156
|
+
cek = hkdf(prk, "Content-Encoding: aes128gcm\0", 16)
|
|
157
|
+
nonce = hkdf(prk, "Content-Encoding: nonce\0", 12)
|
|
158
|
+
cipher = OpenSSL::Cipher.new("aes-128-gcm")
|
|
159
|
+
cipher.encrypt
|
|
160
|
+
cipher.key = cek
|
|
161
|
+
cipher.iv = nonce
|
|
162
|
+
ciphertext = cipher.update(payload + "\x02") + cipher.final
|
|
163
|
+
tag = cipher.auth_tag
|
|
164
|
+
salt + [RECORD_SIZE].pack("N") + [server.bytesize].pack("C") + server + ciphertext + tag
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def hmac(key, value)
|
|
168
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, key, value)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def hkdf(prk, info, length)
|
|
172
|
+
output = +""
|
|
173
|
+
previous = +""
|
|
174
|
+
counter = 1
|
|
175
|
+
while output.bytesize < length
|
|
176
|
+
previous = hmac(prk, previous + info + [counter].pack("C"))
|
|
177
|
+
output << previous
|
|
178
|
+
counter += 1
|
|
179
|
+
raise PushError, "HKDF output is too large" if counter > 255
|
|
180
|
+
end
|
|
181
|
+
output.byteslice(0, length)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def vapid_token(uri, subject, private, public)
|
|
185
|
+
aud = "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
|
|
186
|
+
header = self.class.send(:b64, JSON.generate({ typ: "JWT", alg: "ES256" }))
|
|
187
|
+
claims = self.class.send(:b64, JSON.generate({ aud: aud, exp: Time.now.to_i + 43_200, sub: subject }))
|
|
188
|
+
input = "#{header}.#{claims}"
|
|
189
|
+
signature = private_key_from_raw(private, public).sign(OpenSSL::Digest::SHA256.new, input)
|
|
190
|
+
asn = OpenSSL::ASN1.decode(signature)
|
|
191
|
+
raw = [asn.value[0].value.to_s(2), asn.value[1].value.to_s(2)].map { |value| value.rjust(32, "\0") }.join
|
|
192
|
+
"#{input}.#{self.class.send(:b64, raw)}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -169,6 +169,8 @@ module Tina4
|
|
|
169
169
|
autoload :MqttError, File.expand_path("tina4/mqtt", __dir__)
|
|
170
170
|
autoload :MqttTimeoutError, File.expand_path("tina4/mqtt", __dir__)
|
|
171
171
|
autoload :MqttMessage, File.expand_path("tina4/mqtt_message", __dir__)
|
|
172
|
+
autoload :Push, File.expand_path("tina4/push", __dir__)
|
|
173
|
+
autoload :PushError, File.expand_path("tina4/push", __dir__)
|
|
172
174
|
autoload :Testing, File.expand_path("tina4/testing", __dir__)
|
|
173
175
|
# Queue / Messenger / DocStore were the last three optional subsystems still
|
|
174
176
|
# loaded eagerly, so every `require "tina4"` paid for a queue backend, an
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.134
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -405,6 +405,7 @@ files:
|
|
|
405
405
|
- lib/tina4/public/js/tina4js.min.js
|
|
406
406
|
- lib/tina4/public/swagger/index.html
|
|
407
407
|
- lib/tina4/public/swagger/oauth2-redirect.html
|
|
408
|
+
- lib/tina4/push.rb
|
|
408
409
|
- lib/tina4/query_builder.rb
|
|
409
410
|
- lib/tina4/queue.rb
|
|
410
411
|
- lib/tina4/queue_backends/kafka_backend.rb
|