coffrify 0.2.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/lib/coffrify/client.rb +154 -0
- data/lib/coffrify/errors.rb +18 -0
- data/lib/coffrify/event_catalog.rb +56 -0
- data/lib/coffrify/version.rb +3 -0
- data/lib/coffrify/webhook.rb +130 -0
- data/lib/coffrify.rb +28 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7045ff71ac4b8f5fd52ef0bff58665f4b68041d352c0cff4c02d7309f3e7fe3b
|
|
4
|
+
data.tar.gz: fd2dd9783511a0c72928ff9c1ec6ee9885c500f8ce698f62b289138ac174add8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b12face4179600ad371f25e4590e964ef7de5d9d1704931f39db2452341aa35e23a696d2af1501625340b084f073aea6d4397dec73effd2e5a049d1ae364a30
|
|
7
|
+
data.tar.gz: c196306ce5ed50b891afb73c87b6486e2e601450af04da2ef395b3b6a5e1884198bfe4bcb6040bfae8ec19ca8f2ffcd427ad53cea5209d2fd4316748f9933d80
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coffrify
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Coffrify — Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [Coffrify](https://coffrify.com), encrypted file transfer infrastructure.
|
|
4
|
+
|
|
5
|
+
Pure stdlib (`net/http`, `openssl`, `json`). **Zero runtime dependencies.** Requires Ruby 2.7+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install coffrify
|
|
11
|
+
# or in a Gemfile:
|
|
12
|
+
# gem "coffrify", "~> 0.1"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "coffrify"
|
|
19
|
+
|
|
20
|
+
coffrify = Coffrify::Client.new(api_key: ENV.fetch("COFFRIFY_API_KEY"))
|
|
21
|
+
|
|
22
|
+
# Create a transfer
|
|
23
|
+
transfer = coffrify.transfers.create(
|
|
24
|
+
[{ name: "rapport.pdf", size: 1_240_000, mime_type: "application/pdf" }],
|
|
25
|
+
expires_in_hours: 72,
|
|
26
|
+
max_downloads: 10,
|
|
27
|
+
password: "s3cret!",
|
|
28
|
+
)
|
|
29
|
+
puts transfer["share_url"]
|
|
30
|
+
|
|
31
|
+
# List
|
|
32
|
+
puts coffrify.transfers.list(limit: 5)
|
|
33
|
+
|
|
34
|
+
# Webhooks
|
|
35
|
+
hook = coffrify.webhooks.create(
|
|
36
|
+
name: "Production hook",
|
|
37
|
+
url: "https://app.example.com/hooks/coffrify",
|
|
38
|
+
events: ["transfer.created", "transfer.downloaded", "transfer.scan_infected"],
|
|
39
|
+
)
|
|
40
|
+
puts "Save this secret: #{hook['secret']}"
|
|
41
|
+
|
|
42
|
+
# Catalog of every supported event
|
|
43
|
+
coffrify.webhooks.events["data"].each { |e| puts "#{e['type']} (#{e['family']})" }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Webhook signature verification
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require "sinatra"
|
|
50
|
+
require "coffrify"
|
|
51
|
+
|
|
52
|
+
post "/hooks/coffrify" do
|
|
53
|
+
raw = request.body.read
|
|
54
|
+
sig = request.env["HTTP_X_COFFRIFY_SIGNATURE"]
|
|
55
|
+
v = Coffrify::Webhook.verify(raw, sig, ENV.fetch("COFFRIFY_WEBHOOK_SECRET"))
|
|
56
|
+
halt 400, v[:reason] unless v[:valid]
|
|
57
|
+
|
|
58
|
+
case v[:event]["type"]
|
|
59
|
+
when "transfer.downloaded" then handle_download(v[:event])
|
|
60
|
+
when "transfer.scan_infected" then quarantine!(v[:event])
|
|
61
|
+
end
|
|
62
|
+
status 200
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The header has the form `t=<timestamp>,v1=<hmac_hex>`. Verification:
|
|
67
|
+
|
|
68
|
+
1. Confirms the timestamp is within ±5 minutes (replay protection).
|
|
69
|
+
2. Recomputes `HMAC-SHA256(secret, "<timestamp>.<raw_body>")` in constant time.
|
|
70
|
+
|
|
71
|
+
## API key rotation
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
result = coffrify.api_keys.rotate("ak_…", grace_days: 7)
|
|
75
|
+
puts "New key (save now): #{result['new_key']}"
|
|
76
|
+
puts "Old key auto-revokes at: #{result['grace_until']}"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module Coffrify
|
|
2
|
+
class Client
|
|
3
|
+
DEFAULT_API_URL = "https://api.coffrify.com".freeze
|
|
4
|
+
DEFAULT_TIMEOUT = 30
|
|
5
|
+
|
|
6
|
+
attr_reader :api_key, :api_url, :workspace_id, :timeout
|
|
7
|
+
|
|
8
|
+
def initialize(api_key:, api_url: DEFAULT_API_URL, workspace_id: nil, timeout: DEFAULT_TIMEOUT)
|
|
9
|
+
raise ArgumentError, "api_key must start with 'cfy_'" unless api_key.is_a?(String) && api_key.start_with?("cfy_")
|
|
10
|
+
@api_key = api_key
|
|
11
|
+
@api_url = api_url
|
|
12
|
+
@workspace_id = workspace_id
|
|
13
|
+
@timeout = timeout
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# ── Resources ────────────────────────────────────────────────────────
|
|
17
|
+
def transfers; @transfers ||= Transfers.new(self); end
|
|
18
|
+
def webhooks; @webhooks ||= Webhooks.new(self); end
|
|
19
|
+
def api_keys; @api_keys ||= ApiKeys.new(self); end
|
|
20
|
+
def audit; @audit ||= Audit.new(self); end
|
|
21
|
+
|
|
22
|
+
# ── HTTP plumbing ────────────────────────────────────────────────────
|
|
23
|
+
def request(method, path, body: nil, query: nil, headers: {})
|
|
24
|
+
uri = URI.parse("#{api_url}/v1#{path}")
|
|
25
|
+
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
|
26
|
+
|
|
27
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
28
|
+
http.use_ssl = uri.scheme == "https"
|
|
29
|
+
http.read_timeout = timeout
|
|
30
|
+
http.open_timeout = timeout
|
|
31
|
+
|
|
32
|
+
req = build_request(method, uri, body, headers)
|
|
33
|
+
attempts = 0
|
|
34
|
+
begin
|
|
35
|
+
attempts += 1
|
|
36
|
+
resp = http.request(req)
|
|
37
|
+
handle_response(resp)
|
|
38
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, IOError, Errno::ECONNRESET => e
|
|
39
|
+
if attempts < 3
|
|
40
|
+
sleep(0.5 * (2 ** (attempts - 1)))
|
|
41
|
+
retry
|
|
42
|
+
end
|
|
43
|
+
raise Error, "Network error after #{attempts} attempts: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_request(method, uri, body, extra_headers)
|
|
50
|
+
cls = case method.to_s.upcase
|
|
51
|
+
when "GET" then Net::HTTP::Get
|
|
52
|
+
when "POST" then Net::HTTP::Post
|
|
53
|
+
when "PATCH" then Net::HTTP::Patch
|
|
54
|
+
when "DELETE" then Net::HTTP::Delete
|
|
55
|
+
else raise ArgumentError, "Unsupported method: #{method}"
|
|
56
|
+
end
|
|
57
|
+
req = cls.new(uri.request_uri)
|
|
58
|
+
req["Authorization"] = "Bearer #{api_key}"
|
|
59
|
+
req["User-Agent"] = "coffrify-ruby/#{VERSION}"
|
|
60
|
+
req["Content-Type"] = "application/json"
|
|
61
|
+
req["Accept"] = "application/json"
|
|
62
|
+
req["X-Coffrify-Workspace-Id"] = workspace_id if workspace_id
|
|
63
|
+
# Idempotency for non-GET
|
|
64
|
+
req["Idempotency-Key"] = "rb_#{SecureRandom.uuid}" unless method.to_s.upcase == "GET"
|
|
65
|
+
extra_headers.each { |k, v| req[k] = v }
|
|
66
|
+
req.body = body.to_json if body
|
|
67
|
+
req
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_response(resp)
|
|
71
|
+
status = resp.code.to_i
|
|
72
|
+
body = resp.body && !resp.body.empty? ? (JSON.parse(resp.body) rescue resp.body) : nil
|
|
73
|
+
return body if status >= 200 && status < 300
|
|
74
|
+
|
|
75
|
+
message = body.is_a?(Hash) ? (body["message"] || body["error"] || resp.message) : resp.message
|
|
76
|
+
code = body.is_a?(Hash) ? body["code"] : nil
|
|
77
|
+
klass = case status
|
|
78
|
+
when 401, 403 then AuthError
|
|
79
|
+
when 404 then NotFoundError
|
|
80
|
+
when 422, 400 then ValidationError
|
|
81
|
+
when 429 then RateLimitError
|
|
82
|
+
else ApiError
|
|
83
|
+
end
|
|
84
|
+
raise klass.new(status: status, message: message.to_s, code: code, details: body)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ── Resources ──────────────────────────────────────────────────────────
|
|
89
|
+
class Transfers
|
|
90
|
+
def initialize(client); @c = client; end
|
|
91
|
+
|
|
92
|
+
def list(limit: 20, offset: 0, status: nil)
|
|
93
|
+
q = { limit: limit, offset: offset }
|
|
94
|
+
q[:status] = status if status
|
|
95
|
+
@c.request(:get, "/transfers", query: q)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get(id); @c.request(:get, "/transfers/#{escape(id)}"); end
|
|
99
|
+
def delete(id); @c.request(:delete, "/transfers/#{escape(id)}"); end
|
|
100
|
+
|
|
101
|
+
def create(files, **options)
|
|
102
|
+
@c.request(:post, "/transfers", body: { files: files, **options })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
def escape(s); URI.encode_www_form_component(s); end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Webhooks
|
|
110
|
+
def initialize(client); @c = client; end
|
|
111
|
+
|
|
112
|
+
def list; @c.request(:get, "/webhooks"); end
|
|
113
|
+
def get(id); @c.request(:get, "/webhooks/#{escape(id)}"); end
|
|
114
|
+
def create(**body); @c.request(:post, "/webhooks", body: body); end
|
|
115
|
+
def update(id, **b); @c.request(:patch, "/webhooks", body: b.merge(id: id)); end
|
|
116
|
+
def delete(id); @c.request(:delete, "/webhooks", body: { id: id }); end
|
|
117
|
+
def test(id); @c.request(:post, "/webhooks/#{escape(id)}/test"); end
|
|
118
|
+
def deliveries(id, limit: 20)
|
|
119
|
+
@c.request(:get, "/webhooks/#{escape(id)}/deliveries", query: { limit: limit })
|
|
120
|
+
end
|
|
121
|
+
def replay(delivery_id)
|
|
122
|
+
@c.request(:post, "/webhooks/deliveries/#{escape(delivery_id)}/replay")
|
|
123
|
+
end
|
|
124
|
+
def events
|
|
125
|
+
@c.request(:get, "/webhooks/events")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
def escape(s); URI.encode_www_form_component(s); end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class ApiKeys
|
|
133
|
+
def initialize(client); @c = client; end
|
|
134
|
+
def list; @c.request(:get, "/api-keys"); end
|
|
135
|
+
def create(**body); @c.request(:post, "/api-keys", body: body); end
|
|
136
|
+
def revoke(id); @c.request(:delete, "/api-keys/#{escape(id)}"); end
|
|
137
|
+
def rotate(id, grace_days: 7)
|
|
138
|
+
@c.request(:post, "/api-keys/#{escape(id)}/rotate", body: { grace_days: grace_days })
|
|
139
|
+
end
|
|
140
|
+
private
|
|
141
|
+
def escape(s); URI.encode_www_form_component(s); end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class Audit
|
|
145
|
+
def initialize(client); @c = client; end
|
|
146
|
+
def list(action: nil, since: nil, until_: nil, limit: 100)
|
|
147
|
+
q = { limit: limit }
|
|
148
|
+
q[:action] = action if action
|
|
149
|
+
q[:since] = since if since
|
|
150
|
+
q[:until] = until_ if until_
|
|
151
|
+
@c.request(:get, "/audit", query: q)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Coffrify
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ApiError < Error
|
|
5
|
+
attr_reader :status, :code, :details
|
|
6
|
+
def initialize(status:, message:, code: nil, details: nil)
|
|
7
|
+
super(message)
|
|
8
|
+
@status = status
|
|
9
|
+
@code = code
|
|
10
|
+
@details = details
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class AuthError < ApiError; end
|
|
15
|
+
class RateLimitError < ApiError; end
|
|
16
|
+
class NotFoundError < ApiError; end
|
|
17
|
+
class ValidationError < ApiError; end
|
|
18
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Coffrify
|
|
2
|
+
# Static event catalog mirroring the JS SDK / REST `GET /v1/webhooks/events`.
|
|
3
|
+
EVENT_CATALOG = [
|
|
4
|
+
# Transfer
|
|
5
|
+
{ type: "transfer.created", family: "transfer", stability: "stable" },
|
|
6
|
+
{ type: "transfer.downloaded", family: "transfer", stability: "stable" },
|
|
7
|
+
{ type: "transfer.expired", family: "transfer", stability: "stable" },
|
|
8
|
+
{ type: "transfer.deleted", family: "transfer", stability: "stable" },
|
|
9
|
+
{ type: "transfer.cloned", family: "transfer", stability: "stable" },
|
|
10
|
+
{ type: "transfer.scanned", family: "transfer", stability: "stable" },
|
|
11
|
+
{ type: "transfer.scan_clean", family: "transfer", stability: "stable", required_plan: "pro" },
|
|
12
|
+
{ type: "transfer.scan_infected", family: "transfer", stability: "stable", required_plan: "pro" },
|
|
13
|
+
{ type: "transfer.scan_quarantined", family: "transfer", stability: "stable", required_plan: "pro" },
|
|
14
|
+
{ type: "transfer.limit_reached", family: "transfer", stability: "stable" },
|
|
15
|
+
{ type: "transfer.password_failed", family: "transfer", stability: "stable" },
|
|
16
|
+
{ type: "transfer.geo_blocked", family: "transfer", stability: "stable", required_plan: "ultra" },
|
|
17
|
+
{ type: "transfer.preview_opened", family: "transfer", stability: "beta" },
|
|
18
|
+
{ type: "transfer.email_sent", family: "transfer", stability: "stable" },
|
|
19
|
+
{ type: "transfer.e2e_created", family: "transfer", stability: "stable" },
|
|
20
|
+
# Workspace
|
|
21
|
+
{ type: "workspace.created", family: "workspace", stability: "stable" },
|
|
22
|
+
{ type: "workspace.plan_changed", family: "workspace", stability: "stable" },
|
|
23
|
+
{ type: "workspace.payment_succeeded", family: "workspace", stability: "stable" },
|
|
24
|
+
{ type: "workspace.payment_failed", family: "workspace", stability: "stable" },
|
|
25
|
+
{ type: "workspace.usage_limit_warning", family: "workspace", stability: "stable" },
|
|
26
|
+
{ type: "workspace.usage_limit_reached", family: "workspace", stability: "stable" },
|
|
27
|
+
# Members
|
|
28
|
+
{ type: "member.invited", family: "member", stability: "stable" },
|
|
29
|
+
{ type: "member.accepted", family: "member", stability: "stable" },
|
|
30
|
+
{ type: "member.removed", family: "member", stability: "stable" },
|
|
31
|
+
# API keys / tokens
|
|
32
|
+
{ type: "api_key.created", family: "api_key", stability: "stable" },
|
|
33
|
+
{ type: "api_key.revoked", family: "api_key", stability: "stable" },
|
|
34
|
+
{ type: "api_key.rotated", family: "api_key", stability: "stable" },
|
|
35
|
+
{ type: "api_key.expired", family: "api_key", stability: "stable" },
|
|
36
|
+
{ type: "api_key.suspicious_usage", family: "api_key", stability: "beta", required_plan: "ultra" },
|
|
37
|
+
{ type: "api_token.created", family: "api_token", stability: "stable" },
|
|
38
|
+
{ type: "api_token.used", family: "api_token", stability: "beta" },
|
|
39
|
+
{ type: "api_token.expired", family: "api_token", stability: "stable" },
|
|
40
|
+
# Webhook self
|
|
41
|
+
{ type: "webhook.delivery_failed_final", family: "webhook", stability: "stable" },
|
|
42
|
+
{ type: "webhook.endpoint_disabled", family: "webhook", stability: "stable" },
|
|
43
|
+
# SCIM/SAML
|
|
44
|
+
{ type: "scim.user_provisioned", family: "scim", stability: "stable", required_plan: "enterprise" },
|
|
45
|
+
{ type: "scim.user_deprovisioned", family: "scim", stability: "stable", required_plan: "enterprise" },
|
|
46
|
+
{ type: "saml.login_succeeded", family: "saml", stability: "stable", required_plan: "enterprise" },
|
|
47
|
+
{ type: "saml.login_failed", family: "saml", stability: "stable", required_plan: "enterprise" },
|
|
48
|
+
# Audit/GDPR
|
|
49
|
+
{ type: "audit.exported", family: "audit", stability: "stable" },
|
|
50
|
+
{ type: "audit.policy_violated", family: "audit", stability: "beta", required_plan: "ultra" },
|
|
51
|
+
{ type: "gdpr.deletion_requested", family: "gdpr", stability: "stable", required_plan: "pro" },
|
|
52
|
+
{ type: "gdpr.export_requested", family: "gdpr", stability: "stable", required_plan: "pro" },
|
|
53
|
+
# System
|
|
54
|
+
{ type: "ping", family: "system", stability: "stable" },
|
|
55
|
+
].freeze
|
|
56
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "json"
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Coffrify
|
|
6
|
+
# Webhook signature verification.
|
|
7
|
+
#
|
|
8
|
+
# Two formats are auto-detected by `verify_from_headers`:
|
|
9
|
+
#
|
|
10
|
+
# * **Standard Webhooks** (https://www.standardwebhooks.com/) — preferred.
|
|
11
|
+
# headers: webhook-id / webhook-timestamp / webhook-signature
|
|
12
|
+
# signed payload: "<id>.<ts>.<raw_body>"
|
|
13
|
+
# signature : "v1,<base64>" (space-separated for rotation).
|
|
14
|
+
#
|
|
15
|
+
# * **Coffrify legacy** — single header `X-Coffrify-Signature: t=<ts>,v1=<hex>`.
|
|
16
|
+
# signed payload : "<ts>.<raw_body>"
|
|
17
|
+
#
|
|
18
|
+
# `secret` accepts a String OR an Array of Strings — pass multiple values to
|
|
19
|
+
# support rotation grace windows. Returns
|
|
20
|
+
# { valid:, event:, reason:, matched_secret_index: }
|
|
21
|
+
module Webhook
|
|
22
|
+
DEFAULT_TOLERANCE_SECONDS = 300 # 5 minutes
|
|
23
|
+
SIGNATURE_VERSION = "v1".freeze
|
|
24
|
+
|
|
25
|
+
# Resolve a Coffrify secret to its raw key bytes.
|
|
26
|
+
# "whsec_<hex>" → bytes via hex decode (Standard Webhooks convention).
|
|
27
|
+
# Anything else returned as-is.
|
|
28
|
+
def self.resolve_key(secret)
|
|
29
|
+
if secret.is_a?(String) && secret.start_with?("whsec_") && secret.length > 8
|
|
30
|
+
hex = secret[6..]
|
|
31
|
+
return [hex].pack("H*") if hex =~ /\A[0-9a-f]+\z/i && hex.length.even?
|
|
32
|
+
end
|
|
33
|
+
secret
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.normalise_secrets(secret)
|
|
37
|
+
arr = secret.is_a?(Array) ? secret : [secret]
|
|
38
|
+
arr.compact.reject { |s| !s.is_a?(String) || s.empty? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Legacy Coffrify header verification.
|
|
42
|
+
def self.verify(raw_body, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
|
|
43
|
+
secrets = normalise_secrets(secret)
|
|
44
|
+
return result(false, reason: "missing signature") if signature_header.nil? || signature_header.empty?
|
|
45
|
+
return result(false, reason: "missing secret") if secrets.empty?
|
|
46
|
+
|
|
47
|
+
parts = signature_header.split(",").map { |p| p.strip.split("=", 2) }.to_h
|
|
48
|
+
timestamp = parts["t"]
|
|
49
|
+
sig_hex = parts[SIGNATURE_VERSION]
|
|
50
|
+
return result(false, reason: "malformed signature header") if timestamp.nil? || sig_hex.nil?
|
|
51
|
+
|
|
52
|
+
ts = timestamp.to_i
|
|
53
|
+
return result(false, reason: "timestamp out of tolerance") if (Time.now.to_i - ts).abs > tolerance_seconds
|
|
54
|
+
|
|
55
|
+
signed = "#{timestamp}.#{raw_body}"
|
|
56
|
+
provided = sig_hex.downcase
|
|
57
|
+
secrets.each_with_index do |s, i|
|
|
58
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", resolve_key(s), signed)
|
|
59
|
+
if secure_compare(expected, provided)
|
|
60
|
+
event = JSON.parse(raw_body) rescue nil
|
|
61
|
+
return result(true, event: event, matched_secret_index: i)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
result(false, reason: "signature mismatch")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Auto-detect verification from a request headers hash (case-insensitive).
|
|
68
|
+
# Standard Webhooks is preferred when its three headers are present.
|
|
69
|
+
def self.verify_from_headers(raw_body, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
|
|
70
|
+
secrets = normalise_secrets(secret)
|
|
71
|
+
return result(false, reason: "missing secret") if secrets.empty?
|
|
72
|
+
|
|
73
|
+
lookup = lambda do |name|
|
|
74
|
+
target = name.downcase
|
|
75
|
+
pair = headers.find { |k, _| k.to_s.downcase == target }
|
|
76
|
+
pair && pair[1].is_a?(Array) ? pair[1].first : (pair && pair[1])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
std_id = lookup.call("webhook-id")
|
|
80
|
+
std_ts = lookup.call("webhook-timestamp")
|
|
81
|
+
std_sig = lookup.call("webhook-signature")
|
|
82
|
+
|
|
83
|
+
if std_id && std_ts && std_sig
|
|
84
|
+
return result(false, reason: "malformed timestamp") unless std_ts.to_s =~ /\A\d+\z/
|
|
85
|
+
ts = std_ts.to_i
|
|
86
|
+
return result(false, reason: "timestamp out of tolerance") if (Time.now.to_i - ts).abs > tolerance_seconds
|
|
87
|
+
|
|
88
|
+
candidates = std_sig.to_s.split(/\s+/).filter_map do |piece|
|
|
89
|
+
next nil unless piece.start_with?("#{SIGNATURE_VERSION},")
|
|
90
|
+
begin
|
|
91
|
+
Base64.strict_decode64(piece[(SIGNATURE_VERSION.length + 1)..])
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
return result(false, reason: "malformed signature") if candidates.empty?
|
|
97
|
+
|
|
98
|
+
message = "#{std_id}.#{ts}.#{raw_body}"
|
|
99
|
+
secrets.each_with_index do |s, i|
|
|
100
|
+
expected = OpenSSL::HMAC.digest("SHA256", resolve_key(s), message)
|
|
101
|
+
candidates.each do |c|
|
|
102
|
+
if secure_compare(expected, c)
|
|
103
|
+
event = JSON.parse(raw_body) rescue nil
|
|
104
|
+
return result(true, event: event, matched_secret_index: i)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
return result(false, reason: "signature mismatch")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Fall back to legacy.
|
|
112
|
+
legacy = lookup.call("x-coffrify-signature")
|
|
113
|
+
return verify(raw_body, legacy, secrets, tolerance_seconds: tolerance_seconds) if legacy
|
|
114
|
+
result(false, reason: "malformed")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.result(valid, event: nil, reason: nil, matched_secret_index: nil)
|
|
118
|
+
{ valid: valid, event: event, reason: reason, matched_secret_index: matched_secret_index }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Constant-time comparison to thwart timing attacks
|
|
122
|
+
def self.secure_compare(a, b)
|
|
123
|
+
return false if a.bytesize != b.bytesize
|
|
124
|
+
l = a.unpack("C*")
|
|
125
|
+
res = 0
|
|
126
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
127
|
+
res == 0
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/coffrify.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Coffrify Ruby SDK
|
|
2
|
+
# https://coffrify.com | https://docs.coffrify.dev
|
|
3
|
+
#
|
|
4
|
+
# Pure stdlib — no runtime dependencies. Requires Ruby 2.7+.
|
|
5
|
+
#
|
|
6
|
+
# Quickstart:
|
|
7
|
+
# require "coffrify"
|
|
8
|
+
# coffrify = Coffrify::Client.new(api_key: ENV.fetch("COFFRIFY_API_KEY"))
|
|
9
|
+
# transfer = coffrify.transfers.create(
|
|
10
|
+
# [{ name: "report.pdf", size: 1_240_000, mime_type: "application/pdf" }],
|
|
11
|
+
# expires_in_hours: 72, max_downloads: 10
|
|
12
|
+
# )
|
|
13
|
+
# puts transfer["share_url"]
|
|
14
|
+
|
|
15
|
+
require "net/http"
|
|
16
|
+
require "uri"
|
|
17
|
+
require "json"
|
|
18
|
+
require "openssl"
|
|
19
|
+
require "securerandom"
|
|
20
|
+
|
|
21
|
+
require_relative "coffrify/version"
|
|
22
|
+
require_relative "coffrify/errors"
|
|
23
|
+
require_relative "coffrify/client"
|
|
24
|
+
require_relative "coffrify/webhook"
|
|
25
|
+
require_relative "coffrify/event_catalog"
|
|
26
|
+
|
|
27
|
+
module Coffrify
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: coffrify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Coffrify
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.20'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.20'
|
|
27
|
+
description: Send transfers, manage webhooks, verify signatures, query audit logs
|
|
28
|
+
from Ruby. Webhooks signed with HMAC-SHA256, retries 72h.
|
|
29
|
+
email: support@coffrify.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- lib/coffrify.rb
|
|
37
|
+
- lib/coffrify/client.rb
|
|
38
|
+
- lib/coffrify/errors.rb
|
|
39
|
+
- lib/coffrify/event_catalog.rb
|
|
40
|
+
- lib/coffrify/version.rb
|
|
41
|
+
- lib/coffrify/webhook.rb
|
|
42
|
+
homepage: https://coffrify.com
|
|
43
|
+
licenses:
|
|
44
|
+
- MIT
|
|
45
|
+
metadata:
|
|
46
|
+
documentation_uri: https://docs.coffrify.dev
|
|
47
|
+
source_code_uri: https://github.com/coffrify/coffrify-ruby
|
|
48
|
+
bug_tracker_uri: https://github.com/coffrify/coffrify-ruby/issues
|
|
49
|
+
post_install_message:
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '2.7'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 3.4.19
|
|
65
|
+
signing_key:
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: Official Ruby SDK for Coffrify — encrypted file transfer infrastructure.
|
|
68
|
+
test_files: []
|