tripwire-server 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +99 -0
- data/lib/tripwire/server/client.rb +265 -0
- data/lib/tripwire/server/errors.rb +21 -0
- data/lib/tripwire/server/sealed_token.rb +76 -0
- data/lib/tripwire/server/types.rb +5 -0
- data/lib/tripwire/server/version.rb +5 -0
- data/lib/tripwire/server.rb +19 -0
- data/spec/LICENSE +21 -0
- data/spec/README.md +129 -0
- data/spec/fixtures/errors/invalid-api-key.json +10 -0
- data/spec/fixtures/errors/missing-api-key.json +10 -0
- data/spec/fixtures/errors/not-found.json +10 -0
- data/spec/fixtures/errors/validation-error.json +21 -0
- data/spec/fixtures/public-api/fingerprints/detail.json +40 -0
- data/spec/fixtures/public-api/fingerprints/list.json +31 -0
- data/spec/fixtures/public-api/sessions/detail.json +47 -0
- data/spec/fixtures/public-api/sessions/list.json +33 -0
- data/spec/fixtures/public-api/teams/api-key-create.json +18 -0
- data/spec/fixtures/public-api/teams/api-key-list.json +23 -0
- data/spec/fixtures/public-api/teams/api-key-revoke.json +3 -0
- data/spec/fixtures/public-api/teams/api-key-rotate.json +18 -0
- data/spec/fixtures/public-api/teams/team-create.json +11 -0
- data/spec/fixtures/public-api/teams/team-update.json +11 -0
- data/spec/fixtures/public-api/teams/team.json +11 -0
- data/spec/fixtures/sealed-token/invalid.json +4 -0
- data/spec/fixtures/sealed-token/vector.v1.json +41 -0
- data/spec/openapi.json +1435 -0
- data/spec/sealed-token.md +95 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ea227f476d80187c6545059912b6a32a0adaa4f34bdc1bb47ee21e238c796515
|
|
4
|
+
data.tar.gz: c6790aad09a6b96e6fb25e8fe72e18797bd282e08207559967a817d6734d7a0f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4c7e6cbb34ca5c959e712ee227d74027a988a4805e5046c47a59f8241c023144787448890df131d370d5fe76d4d2dba50579ec53e5e5c1c9de504e8d97621ad2
|
|
7
|
+
data.tar.gz: afe4dd7afb7436c186a4c3da2cf261403e1b160a40e6a12eea55f4a9d3d8af5b070f9eb17b9821d8a031bd5e14daa5c8d27a3cd25858dcbd89a73935e7a132bd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ABXY Labs
|
|
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,99 @@
|
|
|
1
|
+
# Tripwire Ruby Library
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
The Tripwire Ruby library provides convenient access to the Tripwire API from applications written in Ruby. It includes a client for Sessions, Fingerprints, Teams, Team API key management, and sealed token verification.
|
|
8
|
+
|
|
9
|
+
The library also provides:
|
|
10
|
+
|
|
11
|
+
- a fast configuration path using `TRIPWIRE_SECRET_KEY`
|
|
12
|
+
- lazy helpers for cursor-based pagination
|
|
13
|
+
- structured API errors and built-in sealed token verification
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
See the [Tripwire docs](https://tripwirejs.com/docs) and [API reference](https://tripwirejs.com/docs/api-reference/introduction).
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
You don't need this source code unless you want to modify the gem. If you just want to use the package, run:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle add tripwire-server
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Ruby 2.6+
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
The library needs to be configured with your account's secret key. Set `TRIPWIRE_SECRET_KEY` in your environment or pass `secret_key` directly:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "tripwire/server"
|
|
37
|
+
|
|
38
|
+
client = Tripwire::Server::Client.new(secret_key: "sk_live_...")
|
|
39
|
+
|
|
40
|
+
page = client.sessions.list(verdict: "bot", limit: 25)
|
|
41
|
+
session = client.sessions.get("sid_123")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Sealed token verification
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
result = Tripwire::Server.safe_verify_tripwire_token(sealed_token, "sk_live_...")
|
|
48
|
+
|
|
49
|
+
if result[:ok]
|
|
50
|
+
puts "#{result[:data][:verdict]} #{result[:data][:score]}"
|
|
51
|
+
else
|
|
52
|
+
warn result[:error].message
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Pagination
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
client.sessions.iter(search: "signup").each do |session|
|
|
60
|
+
puts "#{session[:id]} #{session[:latestResult][:verdict]}"
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Fingerprints
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
fingerprint = client.fingerprints.get("vis_123")
|
|
68
|
+
puts fingerprint[:id]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Teams
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
team = client.teams.get("team_123")
|
|
75
|
+
updated = client.teams.update("team_123", name: "New Name")
|
|
76
|
+
|
|
77
|
+
puts updated[:name]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Team API keys
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
created = client.teams.api_keys.create("team_123", name: "Production")
|
|
84
|
+
client.teams.api_keys.revoke("team_123", created[:id])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Error handling
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
begin
|
|
91
|
+
client.sessions.list(limit: 999)
|
|
92
|
+
rescue Tripwire::Server::ApiError => error
|
|
93
|
+
warn "#{error.status} #{error.code} #{error.message}"
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Support
|
|
98
|
+
|
|
99
|
+
If you need help integrating Tripwire, start with [tripwirejs.com/docs](https://tripwirejs.com/docs).
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Tripwire
|
|
7
|
+
module Server
|
|
8
|
+
class Client
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.tripwirejs.com".freeze
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
11
|
+
SDK_CLIENT_HEADER = "tripwire-server-ruby/0.1.0".freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :sessions, :fingerprints, :teams, :timeout
|
|
14
|
+
|
|
15
|
+
def initialize(secret_key: ENV["TRIPWIRE_SECRET_KEY"], base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil, transport: nil)
|
|
16
|
+
raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if secret_key.nil? || secret_key.empty?
|
|
17
|
+
|
|
18
|
+
@secret_key = secret_key
|
|
19
|
+
@base_url = base_url
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@user_agent = user_agent
|
|
22
|
+
@transport = transport
|
|
23
|
+
|
|
24
|
+
@sessions = SessionsResource.new(self)
|
|
25
|
+
@fingerprints = FingerprintsResource.new(self)
|
|
26
|
+
@teams = TeamsResource.new(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def request_json(method, path, query: {}, body: nil, expect_content: true)
|
|
30
|
+
url = build_url(path, query)
|
|
31
|
+
headers = {
|
|
32
|
+
"Authorization" => "Bearer #{@secret_key}",
|
|
33
|
+
"Accept" => "application/json",
|
|
34
|
+
"X-Tripwire-Client" => SDK_CLIENT_HEADER
|
|
35
|
+
}
|
|
36
|
+
headers["User-Agent"] = @user_agent if @user_agent
|
|
37
|
+
headers["Content-Type"] = "application/json" if body
|
|
38
|
+
|
|
39
|
+
status, response_headers, response_body =
|
|
40
|
+
if @transport
|
|
41
|
+
@transport.call(method: method, url: url.to_s, headers: headers, body: body.nil? ? nil : JSON.dump(body))
|
|
42
|
+
else
|
|
43
|
+
perform_http_request(method, url, headers, body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
request_id = response_headers["x-request-id"] || response_headers["X-Request-Id"]
|
|
47
|
+
|
|
48
|
+
if status >= 400
|
|
49
|
+
payload = parse_json(response_body)
|
|
50
|
+
if payload[:error].is_a?(Hash)
|
|
51
|
+
error = payload[:error]
|
|
52
|
+
details = error[:details].is_a?(Hash) ? error[:details] : {}
|
|
53
|
+
raise ApiError.new(
|
|
54
|
+
status: status,
|
|
55
|
+
code: error[:code] || "request.failed",
|
|
56
|
+
message: error[:message] || response_body.to_s,
|
|
57
|
+
request_id: request_id || error[:requestId],
|
|
58
|
+
field_errors: details[:fieldErrors] || [],
|
|
59
|
+
docs_url: error[:docsUrl],
|
|
60
|
+
body: payload
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
raise ApiError.new(status: status, code: "request.failed", message: response_body.to_s, request_id: request_id, body: payload)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return {} unless expect_content
|
|
68
|
+
return {} if status == 204 || response_body.nil? || response_body.empty?
|
|
69
|
+
|
|
70
|
+
parse_json(response_body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def perform_http_request(method, url, headers, body)
|
|
74
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
75
|
+
http.use_ssl = (url.scheme == "https")
|
|
76
|
+
http.read_timeout = @timeout
|
|
77
|
+
http.open_timeout = @timeout
|
|
78
|
+
|
|
79
|
+
request_class = case method
|
|
80
|
+
when "GET" then Net::HTTP::Get
|
|
81
|
+
when "POST" then Net::HTTP::Post
|
|
82
|
+
when "PATCH" then Net::HTTP::Patch
|
|
83
|
+
when "DELETE" then Net::HTTP::Delete
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "Unsupported method #{method}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
request = request_class.new(url)
|
|
89
|
+
headers.each { |key, value| request[key] = value }
|
|
90
|
+
request.body = JSON.dump(body) if body
|
|
91
|
+
|
|
92
|
+
response = http.request(request)
|
|
93
|
+
[response.code.to_i, response.to_hash.transform_values { |value| Array(value).first }, response.body.to_s]
|
|
94
|
+
end
|
|
95
|
+
private :perform_http_request
|
|
96
|
+
|
|
97
|
+
def build_url(path, query)
|
|
98
|
+
url = URI.join(@base_url.end_with?("/") ? @base_url : "#{@base_url}/", path.sub(%r{\A/}, ""))
|
|
99
|
+
compact_query = query.each_with_object({}) do |(key, value), memo|
|
|
100
|
+
memo[key] = value unless value.nil? || value == ""
|
|
101
|
+
end
|
|
102
|
+
url.query = URI.encode_www_form(compact_query) unless compact_query.empty?
|
|
103
|
+
url
|
|
104
|
+
end
|
|
105
|
+
private :build_url
|
|
106
|
+
|
|
107
|
+
def parse_json(body)
|
|
108
|
+
data = JSON.parse(body)
|
|
109
|
+
deep_symbolize(data)
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
private :parse_json
|
|
114
|
+
|
|
115
|
+
def deep_symbolize(value)
|
|
116
|
+
case value
|
|
117
|
+
when Array
|
|
118
|
+
value.map { |item| deep_symbolize(item) }
|
|
119
|
+
when Hash
|
|
120
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
121
|
+
memo[key.to_sym] = deep_symbolize(item)
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
private :deep_symbolize
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class BaseResource
|
|
131
|
+
def initialize(client)
|
|
132
|
+
@client = client
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def list_result(payload)
|
|
138
|
+
ListResult.new(
|
|
139
|
+
items: payload[:data],
|
|
140
|
+
limit: payload.fetch(:pagination).fetch(:limit),
|
|
141
|
+
has_more: payload.fetch(:pagination).fetch(:hasMore),
|
|
142
|
+
next_cursor: payload.fetch(:pagination)[:nextCursor]
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class SessionsResource < BaseResource
|
|
148
|
+
def list(limit: nil, cursor: nil, verdict: nil, search: nil)
|
|
149
|
+
payload = @client.request_json("GET", "/v1/sessions", query: {
|
|
150
|
+
limit: limit,
|
|
151
|
+
cursor: cursor,
|
|
152
|
+
verdict: verdict,
|
|
153
|
+
search: search
|
|
154
|
+
})
|
|
155
|
+
list_result(payload)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get(session_id)
|
|
159
|
+
@client.request_json("GET", "/v1/sessions/#{CGI.escape(session_id)}")[:data]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def iter(limit: nil, verdict: nil, search: nil)
|
|
163
|
+
Enumerator.new do |yielder|
|
|
164
|
+
cursor = nil
|
|
165
|
+
loop do
|
|
166
|
+
page = list(limit: limit, cursor: cursor, verdict: verdict, search: search)
|
|
167
|
+
page.items.each { |item| yielder << item }
|
|
168
|
+
break unless page.has_more && page.next_cursor
|
|
169
|
+
|
|
170
|
+
cursor = page.next_cursor
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class FingerprintsResource < BaseResource
|
|
177
|
+
def list(limit: nil, cursor: nil, search: nil, sort: nil)
|
|
178
|
+
payload = @client.request_json("GET", "/v1/fingerprints", query: {
|
|
179
|
+
limit: limit,
|
|
180
|
+
cursor: cursor,
|
|
181
|
+
search: search,
|
|
182
|
+
sort: sort
|
|
183
|
+
})
|
|
184
|
+
list_result(payload)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def get(visitor_id)
|
|
188
|
+
@client.request_json("GET", "/v1/fingerprints/#{CGI.escape(visitor_id)}")[:data]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def iter(limit: nil, search: nil, sort: nil)
|
|
192
|
+
Enumerator.new do |yielder|
|
|
193
|
+
cursor = nil
|
|
194
|
+
loop do
|
|
195
|
+
page = list(limit: limit, cursor: cursor, search: search, sort: sort)
|
|
196
|
+
page.items.each { |item| yielder << item }
|
|
197
|
+
break unless page.has_more && page.next_cursor
|
|
198
|
+
|
|
199
|
+
cursor = page.next_cursor
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class ApiKeysResource < BaseResource
|
|
206
|
+
def create(team_id, name: nil, is_test: nil, allowed_origins: nil, rate_limit: nil)
|
|
207
|
+
payload = @client.request_json("POST", "/v1/teams/#{CGI.escape(team_id)}/api-keys", body: compact({
|
|
208
|
+
name: name,
|
|
209
|
+
isTest: is_test,
|
|
210
|
+
allowedOrigins: allowed_origins,
|
|
211
|
+
rateLimit: rate_limit
|
|
212
|
+
}))
|
|
213
|
+
payload[:data]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def list(team_id, limit: nil, cursor: nil)
|
|
217
|
+
payload = @client.request_json("GET", "/v1/teams/#{CGI.escape(team_id)}/api-keys", query: {
|
|
218
|
+
limit: limit,
|
|
219
|
+
cursor: cursor
|
|
220
|
+
})
|
|
221
|
+
list_result(payload)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def revoke(team_id, key_id)
|
|
225
|
+
@client.request_json("DELETE", "/v1/teams/#{CGI.escape(team_id)}/api-keys/#{CGI.escape(key_id)}", expect_content: false)
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def rotate(team_id, key_id)
|
|
230
|
+
payload = @client.request_json("POST", "/v1/teams/#{CGI.escape(team_id)}/api-keys/#{CGI.escape(key_id)}/rotations")
|
|
231
|
+
payload[:data]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def compact(hash)
|
|
237
|
+
hash.reject { |_key, value| value.nil? }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
class TeamsResource < BaseResource
|
|
242
|
+
attr_reader :api_keys
|
|
243
|
+
|
|
244
|
+
def initialize(client)
|
|
245
|
+
super(client)
|
|
246
|
+
@api_keys = ApiKeysResource.new(client)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def create(name:, slug:)
|
|
250
|
+
@client.request_json("POST", "/v1/teams", body: { name: name, slug: slug })[:data]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def get(team_id)
|
|
254
|
+
@client.request_json("GET", "/v1/teams/#{CGI.escape(team_id)}")[:data]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def update(team_id, name: nil, status: nil)
|
|
258
|
+
@client.request_json("PATCH", "/v1/teams/#{CGI.escape(team_id)}", body: {
|
|
259
|
+
name: name,
|
|
260
|
+
status: status
|
|
261
|
+
}.reject { |_key, value| value.nil? })[:data]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Tripwire
|
|
2
|
+
module Server
|
|
3
|
+
class ConfigurationError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class TokenVerificationError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ApiError < StandardError
|
|
8
|
+
attr_reader :status, :code, :request_id, :field_errors, :docs_url, :body
|
|
9
|
+
|
|
10
|
+
def initialize(status:, code:, message:, request_id: nil, field_errors: [], docs_url: nil, body: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status = status
|
|
13
|
+
@code = code
|
|
14
|
+
@request_id = request_id
|
|
15
|
+
@field_errors = field_errors
|
|
16
|
+
@docs_url = docs_url
|
|
17
|
+
@body = body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module Tripwire
|
|
8
|
+
module Server
|
|
9
|
+
module SealedToken
|
|
10
|
+
VERSION = 0x01
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def verify_tripwire_token(sealed_token, secret_key = nil)
|
|
15
|
+
resolved_secret = secret_key || ENV["TRIPWIRE_SECRET_KEY"]
|
|
16
|
+
raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
|
|
17
|
+
|
|
18
|
+
raw = Base64.decode64(sealed_token)
|
|
19
|
+
raise TokenVerificationError, "Tripwire token is too short." if raw.bytesize < 29
|
|
20
|
+
|
|
21
|
+
version = raw.getbyte(0)
|
|
22
|
+
raise TokenVerificationError, "Unsupported Tripwire token version: #{version}" if version != VERSION
|
|
23
|
+
|
|
24
|
+
nonce = raw.byteslice(1, 12)
|
|
25
|
+
ciphertext = raw.byteslice(13, raw.bytesize - 29)
|
|
26
|
+
tag = raw.byteslice(raw.bytesize - 16, 16)
|
|
27
|
+
|
|
28
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
29
|
+
cipher.decrypt
|
|
30
|
+
cipher.key = derive_key(resolved_secret)
|
|
31
|
+
cipher.iv = nonce
|
|
32
|
+
cipher.auth_tag = tag
|
|
33
|
+
|
|
34
|
+
compressed = cipher.update(ciphertext) + cipher.final
|
|
35
|
+
payload = JSON.parse(Zlib::Inflate.inflate(compressed))
|
|
36
|
+
deep_symbolize(payload)
|
|
37
|
+
rescue ConfigurationError, TokenVerificationError
|
|
38
|
+
raise
|
|
39
|
+
rescue StandardError => error
|
|
40
|
+
raise TokenVerificationError, "Failed to verify Tripwire token: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def safe_verify_tripwire_token(sealed_token, secret_key = nil)
|
|
44
|
+
{ ok: true, data: verify_tripwire_token(sealed_token, secret_key) }
|
|
45
|
+
rescue ConfigurationError, TokenVerificationError => error
|
|
46
|
+
{ ok: false, error: error }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def derive_key(secret_key_or_hash)
|
|
50
|
+
Digest::SHA256.digest("#{normalize_secret(secret_key_or_hash)}\0sealed-results")
|
|
51
|
+
end
|
|
52
|
+
private_class_method :derive_key
|
|
53
|
+
|
|
54
|
+
def normalize_secret(secret_key_or_hash)
|
|
55
|
+
return secret_key_or_hash.downcase if /\A[0-9a-fA-F]{64}\z/.match?(secret_key_or_hash)
|
|
56
|
+
|
|
57
|
+
Digest::SHA256.hexdigest(secret_key_or_hash)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :normalize_secret
|
|
60
|
+
|
|
61
|
+
def deep_symbolize(value)
|
|
62
|
+
case value
|
|
63
|
+
when Array
|
|
64
|
+
value.map { |item| deep_symbolize(item) }
|
|
65
|
+
when Hash
|
|
66
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
67
|
+
memo[key.to_sym] = deep_symbolize(item)
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :deep_symbolize
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative "server/version"
|
|
2
|
+
require_relative "server/errors"
|
|
3
|
+
require_relative "server/types"
|
|
4
|
+
require_relative "server/sealed_token"
|
|
5
|
+
require_relative "server/client"
|
|
6
|
+
|
|
7
|
+
module Tripwire
|
|
8
|
+
module Server
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def verify_tripwire_token(sealed_token, secret_key = nil)
|
|
12
|
+
SealedToken.verify_tripwire_token(sealed_token, secret_key)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def safe_verify_tripwire_token(sealed_token, secret_key = nil)
|
|
16
|
+
SealedToken.safe_verify_tripwire_token(sealed_token, secret_key)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/spec/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ABXY Labs
|
|
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/spec/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Server SDK Spec
|
|
2
|
+
|
|
3
|
+
This directory is the authoritative cross-language contract for Tripwire server SDKs.
|
|
4
|
+
|
|
5
|
+
It defines:
|
|
6
|
+
|
|
7
|
+
- the supported public server API surface
|
|
8
|
+
- the shared sealed token verification behavior
|
|
9
|
+
- golden fixtures for success, error, and pagination flows
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
Server SDKs include only customer-facing public APIs:
|
|
14
|
+
|
|
15
|
+
- `/v1/sessions`
|
|
16
|
+
- `/v1/fingerprints`
|
|
17
|
+
- `/v1/teams`
|
|
18
|
+
- `/v1/teams/:teamId/api-keys`
|
|
19
|
+
|
|
20
|
+
Server SDKs do **not** include:
|
|
21
|
+
|
|
22
|
+
- collect endpoints
|
|
23
|
+
- internal scoring APIs
|
|
24
|
+
- dashboard/internal APIs
|
|
25
|
+
- framework adapters
|
|
26
|
+
- retry middleware
|
|
27
|
+
- policy helpers like `shouldBlock`
|
|
28
|
+
|
|
29
|
+
## Required Namespaces
|
|
30
|
+
|
|
31
|
+
Every server SDK should expose these top-level capabilities:
|
|
32
|
+
|
|
33
|
+
- Sessions
|
|
34
|
+
- list
|
|
35
|
+
- get
|
|
36
|
+
- iterator / auto-pagination helper
|
|
37
|
+
- Fingerprints
|
|
38
|
+
- list
|
|
39
|
+
- get
|
|
40
|
+
- iterator / auto-pagination helper
|
|
41
|
+
- Teams
|
|
42
|
+
- create
|
|
43
|
+
- get
|
|
44
|
+
- update
|
|
45
|
+
- Team API keys
|
|
46
|
+
- create
|
|
47
|
+
- list
|
|
48
|
+
- revoke
|
|
49
|
+
- rotate
|
|
50
|
+
- sealed token helpers
|
|
51
|
+
- strict verify
|
|
52
|
+
- safe verify
|
|
53
|
+
|
|
54
|
+
## Shared Defaults
|
|
55
|
+
|
|
56
|
+
Every SDK should default to:
|
|
57
|
+
|
|
58
|
+
- `base_url = https://api.tripwirejs.com`
|
|
59
|
+
- `secret_key = env(TRIPWIRE_SECRET_KEY)`
|
|
60
|
+
- secret-key-only auth via `Authorization: Bearer <secret>`
|
|
61
|
+
- request timeout support
|
|
62
|
+
- no automatic retries
|
|
63
|
+
|
|
64
|
+
## Pagination Normalization
|
|
65
|
+
|
|
66
|
+
List APIs should normalize cursor pagination into these fields using each language's native style:
|
|
67
|
+
|
|
68
|
+
- `items`
|
|
69
|
+
- `limit`
|
|
70
|
+
- `has_more`
|
|
71
|
+
- `next_cursor`
|
|
72
|
+
|
|
73
|
+
The underlying API responses remain cursor-based. SDKs may expose helper iterators/enumerators/page walkers on top.
|
|
74
|
+
|
|
75
|
+
## Error Model
|
|
76
|
+
|
|
77
|
+
SDKs should parse public API failures into structured errors with, at minimum:
|
|
78
|
+
|
|
79
|
+
- `status`
|
|
80
|
+
- `code`
|
|
81
|
+
- `message`
|
|
82
|
+
- `request_id`
|
|
83
|
+
- `field_errors`
|
|
84
|
+
- `docs_url`
|
|
85
|
+
- parsed raw body
|
|
86
|
+
|
|
87
|
+
Use the fixtures in `fixtures/errors/` as the source of truth for error-shape behavior.
|
|
88
|
+
|
|
89
|
+
## Sealed Token Verification
|
|
90
|
+
|
|
91
|
+
`sealed-token.md` is the cross-language source of truth for verification behavior.
|
|
92
|
+
|
|
93
|
+
SDKs must implement verification natively in their own language runtime. They should not depend on another SDK implementation.
|
|
94
|
+
|
|
95
|
+
Use both:
|
|
96
|
+
|
|
97
|
+
- `fixtures/sealed-token/vector.v1.json`
|
|
98
|
+
- `fixtures/sealed-token/invalid.json`
|
|
99
|
+
|
|
100
|
+
to validate correctness and failure behavior.
|
|
101
|
+
|
|
102
|
+
## Sync Model
|
|
103
|
+
|
|
104
|
+
This repo is the source of truth for the shared server SDK contract.
|
|
105
|
+
|
|
106
|
+
Each language SDK repo carries a synced copy of this repository in `spec/`, and the Tripwire monorepo vendors this repository as a submodule at `sdk-spec/server`.
|
|
107
|
+
|
|
108
|
+
Keep the synced copies and the monorepo submodule pointer current before advancing them.
|
|
109
|
+
|
|
110
|
+
## SDK Authoring Checklist
|
|
111
|
+
|
|
112
|
+
When changing any server SDK:
|
|
113
|
+
|
|
114
|
+
- sync `spec/` before changing SDK code or tests
|
|
115
|
+
- do not expose collect or internal-only endpoints
|
|
116
|
+
- preserve the shared defaults:
|
|
117
|
+
- env-based secret key fallback
|
|
118
|
+
- `https://api.tripwirejs.com`
|
|
119
|
+
- request timeout support
|
|
120
|
+
- no automatic retries
|
|
121
|
+
- preserve pagination normalization:
|
|
122
|
+
- `items`
|
|
123
|
+
- `limit`
|
|
124
|
+
- `has_more`
|
|
125
|
+
- `next_cursor`
|
|
126
|
+
- preserve structured public API errors
|
|
127
|
+
- keep sealed token golden-vector coverage
|
|
128
|
+
- keep one live smoke suite per SDK
|
|
129
|
+
- only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"error": {
|
|
3
|
+
"code": "auth.invalid_api_key",
|
|
4
|
+
"message": "The provided API key is invalid. Check the key and retry.",
|
|
5
|
+
"status": 401,
|
|
6
|
+
"retryable": false,
|
|
7
|
+
"requestId": "req_invalid_api_key",
|
|
8
|
+
"docsUrl": "https://tripwire.com/docs/api-reference/introduction"
|
|
9
|
+
}
|
|
10
|
+
}
|