tripwire-server 0.1.1 → 0.3.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 +4 -4
- data/README.md +48 -13
- data/lib/tripwire/server/client.rb +171 -16
- data/lib/tripwire/server/crypto_support.rb +49 -0
- data/lib/tripwire/server/gate_delivery.rb +298 -0
- data/lib/tripwire/server/sealed_token.rb +2 -0
- data/lib/tripwire/server/version.rb +1 -1
- data/lib/tripwire/server.rb +12 -0
- data/spec/README.md +37 -6
- data/spec/fixtures/api/fingerprints/detail.json +70 -0
- data/spec/fixtures/api/fingerprints/list.json +37 -0
- data/spec/fixtures/api/gate/agent-token-verify.json +12 -0
- data/spec/fixtures/api/gate/login-session-consume.json +10 -0
- data/spec/fixtures/api/gate/login-session-create.json +12 -0
- data/spec/fixtures/api/gate/registry-detail.json +45 -0
- data/spec/fixtures/api/gate/registry-list.json +47 -0
- data/spec/fixtures/api/gate/service-create.json +49 -0
- data/spec/fixtures/api/gate/service-detail.json +49 -0
- data/spec/fixtures/api/gate/service-disable.json +49 -0
- data/spec/fixtures/api/gate/service-update.json +49 -0
- data/spec/fixtures/api/gate/services-list.json +51 -0
- data/spec/fixtures/api/gate/session-ack.json +10 -0
- data/spec/fixtures/api/gate/session-create.json +13 -0
- data/spec/fixtures/api/gate/session-poll.json +36 -0
- data/spec/fixtures/api/sessions/detail.json +405 -0
- data/spec/fixtures/api/sessions/list.json +36 -0
- data/spec/fixtures/api/teams/api-key-create.json +21 -0
- data/spec/fixtures/api/teams/api-key-list.json +26 -0
- data/spec/fixtures/api/teams/api-key-revoke.json +20 -0
- data/spec/fixtures/api/teams/api-key-rotate.json +21 -0
- data/spec/fixtures/api/teams/team-create.json +14 -0
- data/spec/fixtures/api/teams/team-update.json +14 -0
- data/spec/fixtures/api/teams/team.json +14 -0
- data/spec/fixtures/errors/invalid-api-key.json +3 -3
- data/spec/fixtures/errors/missing-api-key.json +2 -2
- data/spec/fixtures/errors/not-found.json +4 -4
- data/spec/fixtures/errors/validation-error.json +6 -7
- data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +20 -0
- data/spec/fixtures/gate-delivery/delivery-request.json +9 -0
- data/spec/fixtures/gate-delivery/env-policy.json +40 -0
- data/spec/fixtures/gate-delivery/vector.v1.json +28 -0
- data/spec/fixtures/gate-delivery/webhook-signature.json +9 -0
- data/spec/fixtures/manifest.json +179 -0
- data/spec/fixtures/sealed-token/vector.v1.json +37 -24
- data/spec/openapi.json +4905 -779
- data/spec/sealed-token.md +36 -17
- metadata +36 -14
- data/spec/fixtures/public-api/fingerprints/detail.json +0 -40
- data/spec/fixtures/public-api/fingerprints/list.json +0 -31
- data/spec/fixtures/public-api/sessions/detail.json +0 -47
- data/spec/fixtures/public-api/sessions/list.json +0 -33
- data/spec/fixtures/public-api/teams/api-key-create.json +0 -18
- data/spec/fixtures/public-api/teams/api-key-list.json +0 -23
- data/spec/fixtures/public-api/teams/api-key-rotate.json +0 -18
- data/spec/fixtures/public-api/teams/team-create.json +0 -11
- data/spec/fixtures/public-api/teams/team-update.json +0 -11
- data/spec/fixtures/public-api/teams/team.json +0 -11
- /data/spec/fixtures/{public-api/teams/api-key-revoke.json → api/gate/agent-token-revoke.json} +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Tripwire
|
|
7
|
+
module Server
|
|
8
|
+
module GateDelivery
|
|
9
|
+
GATE_DELIVERY_VERSION = 1
|
|
10
|
+
GATE_DELIVERY_ALGORITHM = "x25519-hkdf-sha256/aes-256-gcm"
|
|
11
|
+
GATE_AGENT_TOKEN_ENV_SUFFIX = "_GATE_AGENT_TOKEN"
|
|
12
|
+
BLOCKED_GATE_ENV_VAR_KEYS = %w[
|
|
13
|
+
BASH_ENV
|
|
14
|
+
BROWSER
|
|
15
|
+
CDPATH
|
|
16
|
+
DYLD_INSERT_LIBRARIES
|
|
17
|
+
DYLD_LIBRARY_PATH
|
|
18
|
+
EDITOR
|
|
19
|
+
ENV
|
|
20
|
+
GIT_ASKPASS
|
|
21
|
+
GIT_SSH_COMMAND
|
|
22
|
+
HOME
|
|
23
|
+
LD_LIBRARY_PATH
|
|
24
|
+
LD_PRELOAD
|
|
25
|
+
NODE_OPTIONS
|
|
26
|
+
NODE_PATH
|
|
27
|
+
PATH
|
|
28
|
+
PERL5OPT
|
|
29
|
+
PERLLIB
|
|
30
|
+
PROMPT_COMMAND
|
|
31
|
+
PYTHONHOME
|
|
32
|
+
PYTHONPATH
|
|
33
|
+
PYTHONSTARTUP
|
|
34
|
+
RUBYLIB
|
|
35
|
+
RUBYOPT
|
|
36
|
+
SHELLOPTS
|
|
37
|
+
SSH_ASKPASS
|
|
38
|
+
VISUAL
|
|
39
|
+
XDG_CONFIG_HOME
|
|
40
|
+
].freeze
|
|
41
|
+
BLOCKED_GATE_ENV_VAR_PREFIXES = %w[NPM_CONFIG_ BUN_CONFIG_ GIT_CONFIG_].freeze
|
|
42
|
+
GATE_DELIVERY_HKDF_INFO = "tripwire-gate-delivery:v1".b.freeze
|
|
43
|
+
X25519_SPKI_PREFIX = ["302a300506032b656e032100"].pack("H*").freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def derive_gate_agent_token_env_key(service_id)
|
|
48
|
+
normalized = service_id.to_s.strip.gsub(/[^A-Za-z0-9]+/, "_").gsub(/^_+|_+$/, "").gsub(/_+/, "_").upcase
|
|
49
|
+
raise ArgumentError, "service_id is required to derive a Gate agent token env key" if normalized.empty?
|
|
50
|
+
|
|
51
|
+
"#{normalized}#{GATE_AGENT_TOKEN_ENV_SUFFIX}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def is_gate_managed_env_var_key(key)
|
|
55
|
+
key == "TRIPWIRE_AGENT_TOKEN" || key.to_s.end_with?(GATE_AGENT_TOKEN_ENV_SUFFIX)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def is_blocked_gate_env_var_key(key)
|
|
59
|
+
normalized = key.to_s.strip.upcase
|
|
60
|
+
BLOCKED_GATE_ENV_VAR_KEYS.include?(normalized) || BLOCKED_GATE_ENV_VAR_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def raw_x25519_public_key_from_key_object(public_key)
|
|
64
|
+
der = public_key.public_to_der
|
|
65
|
+
raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
|
|
66
|
+
raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
|
|
67
|
+
|
|
68
|
+
der.byteslice(X25519_SPKI_PREFIX.bytesize, 32)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def key_id_for_raw_x25519_public_key(raw_public_key)
|
|
72
|
+
raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
|
|
73
|
+
|
|
74
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(raw_public_key), padding: false)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_delivery_key_pair
|
|
78
|
+
CryptoSupport.ensure_supported_runtime!
|
|
79
|
+
private_key = OpenSSL::PKey.generate_key("X25519")
|
|
80
|
+
raw_public_key = raw_x25519_public_key_from_key_object(private_key.public_key)
|
|
81
|
+
{
|
|
82
|
+
delivery: {
|
|
83
|
+
version: GATE_DELIVERY_VERSION,
|
|
84
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
85
|
+
key_id: key_id_for_raw_x25519_public_key(raw_public_key),
|
|
86
|
+
public_key: Base64.urlsafe_encode64(raw_public_key, padding: false)
|
|
87
|
+
},
|
|
88
|
+
private_key: private_key
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def export_delivery_private_key_pkcs8(private_key)
|
|
93
|
+
CryptoSupport.ensure_supported_runtime!
|
|
94
|
+
Base64.urlsafe_encode64(private_key.private_to_der, padding: false)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def import_delivery_private_key_pkcs8(value)
|
|
98
|
+
CryptoSupport.ensure_supported_runtime!
|
|
99
|
+
OpenSSL::PKey.read(b64url_decode(value, "delivery.private_key_pkcs8"))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_gate_delivery_request(value)
|
|
103
|
+
candidate = symbolize(value)
|
|
104
|
+
raise ArgumentError, "delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
105
|
+
raise ArgumentError, "delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
106
|
+
raise ArgumentError, "delivery.public_key is required" if candidate[:public_key].to_s.empty?
|
|
107
|
+
raise ArgumentError, "delivery.key_id is required" if candidate[:key_id].to_s.empty?
|
|
108
|
+
|
|
109
|
+
raw_public_key = b64url_decode(candidate[:public_key], "delivery.public_key")
|
|
110
|
+
raise ArgumentError, "delivery.public_key must be a raw X25519 public key" unless raw_public_key.bytesize == 32
|
|
111
|
+
raise ArgumentError, "delivery.key_id does not match delivery.public_key" unless key_id_for_raw_x25519_public_key(raw_public_key) == candidate[:key_id]
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
version: GATE_DELIVERY_VERSION,
|
|
115
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
116
|
+
key_id: candidate[:key_id],
|
|
117
|
+
public_key: candidate[:public_key]
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def create_encrypted_delivery_response(input)
|
|
122
|
+
{
|
|
123
|
+
encrypted_delivery: encrypt_gate_delivery_payload(
|
|
124
|
+
input.fetch(:delivery),
|
|
125
|
+
{
|
|
126
|
+
version: GATE_DELIVERY_VERSION,
|
|
127
|
+
outputs: input.fetch(:outputs)
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def create_gate_approved_webhook_response(input)
|
|
134
|
+
create_encrypted_delivery_response(input)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_encrypted_gate_delivery_envelope(value)
|
|
138
|
+
candidate = symbolize(value)
|
|
139
|
+
raise ArgumentError, "encrypted_delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
140
|
+
raise ArgumentError, "encrypted_delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
141
|
+
%i[key_id ephemeral_public_key salt iv ciphertext tag].each do |field|
|
|
142
|
+
raise ArgumentError, "encrypted_delivery.#{field} is required" if candidate[field].to_s.empty?
|
|
143
|
+
end
|
|
144
|
+
raise ArgumentError, "encrypted_delivery.ephemeral_public_key must be 32 bytes" unless b64url_decode(candidate[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key").bytesize == 32
|
|
145
|
+
raise ArgumentError, "encrypted_delivery.salt must be 32 bytes" unless b64url_decode(candidate[:salt], "encrypted_delivery.salt").bytesize == 32
|
|
146
|
+
raise ArgumentError, "encrypted_delivery.iv must be 12 bytes" unless b64url_decode(candidate[:iv], "encrypted_delivery.iv").bytesize == 12
|
|
147
|
+
raise ArgumentError, "encrypted_delivery.tag must be 16 bytes" unless b64url_decode(candidate[:tag], "encrypted_delivery.tag").bytesize == 16
|
|
148
|
+
|
|
149
|
+
candidate
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def encrypt_gate_delivery_payload(delivery, payload)
|
|
153
|
+
CryptoSupport.ensure_supported_runtime!
|
|
154
|
+
validated_delivery = validate_gate_delivery_request(delivery)
|
|
155
|
+
payload = symbolize(payload)
|
|
156
|
+
raise ArgumentError, "Gate delivery payload version must be 1" unless payload[:version] == GATE_DELIVERY_VERSION
|
|
157
|
+
|
|
158
|
+
recipient_public_key = OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated_delivery[:public_key], "delivery.public_key"))
|
|
159
|
+
ephemeral_private_key = OpenSSL::PKey.generate_key("X25519")
|
|
160
|
+
shared_secret = ephemeral_private_key.derive(recipient_public_key)
|
|
161
|
+
salt = OpenSSL::Random.random_bytes(32)
|
|
162
|
+
iv = OpenSSL::Random.random_bytes(12)
|
|
163
|
+
key = OpenSSL::KDF.hkdf(shared_secret, salt: salt, info: GATE_DELIVERY_HKDF_INFO, length: 32, hash: "SHA256")
|
|
164
|
+
|
|
165
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
166
|
+
cipher.encrypt
|
|
167
|
+
cipher.key = key
|
|
168
|
+
cipher.iv = iv
|
|
169
|
+
ciphertext = cipher.update(JSON.generate(compact_payload(payload))) + cipher.final
|
|
170
|
+
tag = cipher.auth_tag
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
version: GATE_DELIVERY_VERSION,
|
|
174
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
175
|
+
key_id: validated_delivery[:key_id],
|
|
176
|
+
ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key.public_key), padding: false),
|
|
177
|
+
salt: Base64.urlsafe_encode64(salt, padding: false),
|
|
178
|
+
iv: Base64.urlsafe_encode64(iv, padding: false),
|
|
179
|
+
ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
|
|
180
|
+
tag: Base64.urlsafe_encode64(tag, padding: false)
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def decrypt_gate_delivery_envelope(private_key, envelope)
|
|
185
|
+
CryptoSupport.ensure_supported_runtime!
|
|
186
|
+
validated = validate_encrypted_gate_delivery_envelope(envelope)
|
|
187
|
+
shared_secret = private_key.derive(
|
|
188
|
+
OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key"))
|
|
189
|
+
)
|
|
190
|
+
key = OpenSSL::KDF.hkdf(
|
|
191
|
+
shared_secret,
|
|
192
|
+
salt: b64url_decode(validated[:salt], "encrypted_delivery.salt"),
|
|
193
|
+
info: GATE_DELIVERY_HKDF_INFO,
|
|
194
|
+
length: 32,
|
|
195
|
+
hash: "SHA256"
|
|
196
|
+
)
|
|
197
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
198
|
+
cipher.decrypt
|
|
199
|
+
cipher.key = key
|
|
200
|
+
cipher.iv = b64url_decode(validated[:iv], "encrypted_delivery.iv")
|
|
201
|
+
cipher.auth_tag = b64url_decode(validated[:tag], "encrypted_delivery.tag")
|
|
202
|
+
cipher.auth_data = ""
|
|
203
|
+
plaintext = cipher.update(b64url_decode(validated[:ciphertext], "encrypted_delivery.ciphertext")) + cipher.final
|
|
204
|
+
payload = JSON.parse(plaintext)
|
|
205
|
+
raise ArgumentError, "encrypted_delivery payload must be an object" unless payload.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
symbolize(payload).tap do |candidate|
|
|
208
|
+
raise ArgumentError, "encrypted_delivery payload version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
209
|
+
raise ArgumentError, "encrypted_delivery payload outputs must be an object" unless candidate[:outputs].is_a?(Hash)
|
|
210
|
+
candidate[:outputs].each do |key_name, item|
|
|
211
|
+
raise ArgumentError, "encrypted_delivery output #{key_name} must be a string" unless item.is_a?(String)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
raise ArgumentError, "encrypted_delivery decrypted to invalid JSON"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_gate_approved_webhook_payload(value)
|
|
219
|
+
candidate = symbolize(value)
|
|
220
|
+
raise ArgumentError, "event must be gate.session.approved" unless candidate[:event] == "gate.session.approved"
|
|
221
|
+
raise ArgumentError, "service_id is required" if candidate[:service_id].to_s.empty?
|
|
222
|
+
raise ArgumentError, "gate_session_id is required" if candidate[:gate_session_id].to_s.empty?
|
|
223
|
+
raise ArgumentError, "gate_account_id is required" if candidate[:gate_account_id].to_s.empty?
|
|
224
|
+
raise ArgumentError, "account_name is required" if candidate[:account_name].to_s.empty?
|
|
225
|
+
raise ArgumentError, "metadata must be an object or null" unless candidate[:metadata].nil? || candidate[:metadata].is_a?(Hash)
|
|
226
|
+
raise ArgumentError, "tripwire must be an object" unless candidate[:tripwire].is_a?(Hash)
|
|
227
|
+
verdict = candidate[:tripwire][:verdict]
|
|
228
|
+
raise ArgumentError, "tripwire.verdict is invalid" unless %w[bot human inconclusive].include?(verdict)
|
|
229
|
+
score = candidate[:tripwire][:score]
|
|
230
|
+
raise ArgumentError, "tripwire.score must be a number or null" unless score.nil? || score.is_a?(Numeric)
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
event: "gate.session.approved",
|
|
234
|
+
service_id: candidate[:service_id],
|
|
235
|
+
gate_session_id: candidate[:gate_session_id],
|
|
236
|
+
gate_account_id: candidate[:gate_account_id],
|
|
237
|
+
account_name: candidate[:account_name],
|
|
238
|
+
metadata: candidate[:metadata]&.dup,
|
|
239
|
+
tripwire: {
|
|
240
|
+
verdict: verdict,
|
|
241
|
+
score: score
|
|
242
|
+
},
|
|
243
|
+
delivery: validate_gate_delivery_request(candidate[:delivery])
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def verify_gate_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
248
|
+
parsed_timestamp = Integer(timestamp)
|
|
249
|
+
current = now_seconds || Time.now.to_i
|
|
250
|
+
return false if (current - parsed_timestamp).abs > max_age_seconds
|
|
251
|
+
|
|
252
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
253
|
+
secure_compare(expected, signature)
|
|
254
|
+
rescue ArgumentError
|
|
255
|
+
false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def symbolize(value)
|
|
259
|
+
case value
|
|
260
|
+
when Array
|
|
261
|
+
value.map { |item| symbolize(item) }
|
|
262
|
+
when Hash
|
|
263
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
264
|
+
memo[key.to_sym] = symbolize(item)
|
|
265
|
+
end
|
|
266
|
+
else
|
|
267
|
+
value
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
private_class_method :symbolize
|
|
271
|
+
|
|
272
|
+
def compact_payload(payload)
|
|
273
|
+
{
|
|
274
|
+
version: payload[:version],
|
|
275
|
+
outputs: payload[:outputs],
|
|
276
|
+
**(payload[:ack_token] ? { ack_token: payload[:ack_token] } : {})
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
private_class_method :compact_payload
|
|
280
|
+
|
|
281
|
+
def b64url_decode(value, label)
|
|
282
|
+
Base64.urlsafe_decode64(value.to_s)
|
|
283
|
+
rescue ArgumentError => error
|
|
284
|
+
raise ArgumentError, "Invalid #{label}: #{error.message}"
|
|
285
|
+
end
|
|
286
|
+
private_class_method :b64url_decode
|
|
287
|
+
|
|
288
|
+
def secure_compare(left, right)
|
|
289
|
+
return false unless left.bytesize == right.bytesize
|
|
290
|
+
|
|
291
|
+
result = 0
|
|
292
|
+
left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
|
|
293
|
+
result.zero?
|
|
294
|
+
end
|
|
295
|
+
private_class_method :secure_compare
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -12,6 +12,7 @@ module Tripwire
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
def verify_tripwire_token(sealed_token, secret_key = nil)
|
|
15
|
+
CryptoSupport.ensure_supported_runtime!
|
|
15
16
|
resolved_secret = secret_key || ENV["TRIPWIRE_SECRET_KEY"]
|
|
16
17
|
raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
|
|
17
18
|
|
|
@@ -30,6 +31,7 @@ module Tripwire
|
|
|
30
31
|
cipher.key = derive_key(resolved_secret)
|
|
31
32
|
cipher.iv = nonce
|
|
32
33
|
cipher.auth_tag = tag
|
|
34
|
+
cipher.auth_data = ""
|
|
33
35
|
|
|
34
36
|
compressed = cipher.update(ciphertext) + cipher.final
|
|
35
37
|
payload = JSON.parse(Zlib::Inflate.inflate(compressed))
|
data/lib/tripwire/server.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require_relative "server/version"
|
|
2
2
|
require_relative "server/errors"
|
|
3
|
+
require_relative "server/crypto_support"
|
|
3
4
|
require_relative "server/types"
|
|
4
5
|
require_relative "server/sealed_token"
|
|
6
|
+
require_relative "server/gate_delivery"
|
|
5
7
|
require_relative "server/client"
|
|
6
8
|
|
|
7
9
|
module Tripwire
|
|
@@ -15,5 +17,15 @@ module Tripwire
|
|
|
15
17
|
def safe_verify_tripwire_token(sealed_token, secret_key = nil)
|
|
16
18
|
SealedToken.safe_verify_tripwire_token(sealed_token, secret_key)
|
|
17
19
|
end
|
|
20
|
+
|
|
21
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
22
|
+
return GateDelivery.public_send(name, *args, **kwargs, &block) if GateDelivery.respond_to?(name)
|
|
23
|
+
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def respond_to_missing?(name, include_private = false)
|
|
28
|
+
GateDelivery.respond_to?(name) || super
|
|
29
|
+
end
|
|
18
30
|
end
|
|
19
31
|
end
|
data/spec/README.md
CHANGED
|
@@ -4,18 +4,24 @@ This directory is the authoritative cross-language contract for Tripwire server
|
|
|
4
4
|
|
|
5
5
|
It defines:
|
|
6
6
|
|
|
7
|
-
- the supported
|
|
7
|
+
- the supported server API surface
|
|
8
8
|
- the shared sealed token verification behavior
|
|
9
|
-
-
|
|
9
|
+
- the shared Gate delivery/webhook helper behavior
|
|
10
|
+
- golden fixtures for success, error, pagination, and helper flows
|
|
10
11
|
|
|
11
12
|
## Scope
|
|
12
13
|
|
|
13
|
-
Server SDKs include only customer-facing
|
|
14
|
+
Server SDKs include only customer-facing APIs:
|
|
14
15
|
|
|
15
16
|
- `/v1/sessions`
|
|
16
17
|
- `/v1/fingerprints`
|
|
17
18
|
- `/v1/teams`
|
|
18
19
|
- `/v1/teams/:teamId/api-keys`
|
|
20
|
+
- `/v1/gate/registry`
|
|
21
|
+
- `/v1/gate/services`
|
|
22
|
+
- `/v1/gate/sessions`
|
|
23
|
+
- `/v1/gate/login-sessions`
|
|
24
|
+
- `/v1/gate/agent-tokens/*`
|
|
19
25
|
|
|
20
26
|
Server SDKs do **not** include:
|
|
21
27
|
|
|
@@ -47,9 +53,24 @@ Every server SDK should expose these top-level capabilities:
|
|
|
47
53
|
- list
|
|
48
54
|
- revoke
|
|
49
55
|
- rotate
|
|
56
|
+
- Gate
|
|
57
|
+
- registry list/get
|
|
58
|
+
- services list/get/create/update/disable
|
|
59
|
+
- sessions create/poll/acknowledge
|
|
60
|
+
- login sessions create/consume
|
|
61
|
+
- agent tokens verify/revoke
|
|
50
62
|
- sealed token helpers
|
|
51
63
|
- strict verify
|
|
52
64
|
- safe verify
|
|
65
|
+
- Gate delivery/webhook helpers
|
|
66
|
+
- generate delivery keypair
|
|
67
|
+
- import/export delivery private key
|
|
68
|
+
- validate delivery request
|
|
69
|
+
- encrypt/decrypt delivery envelopes
|
|
70
|
+
- validate approved webhook payload
|
|
71
|
+
- verify webhook signature
|
|
72
|
+
- derive agent-token env keys
|
|
73
|
+
- check blocked/managed Gate env vars
|
|
53
74
|
|
|
54
75
|
## Shared Defaults
|
|
55
76
|
|
|
@@ -57,7 +78,7 @@ Every SDK should default to:
|
|
|
57
78
|
|
|
58
79
|
- `base_url = https://api.tripwirejs.com`
|
|
59
80
|
- `secret_key = env(TRIPWIRE_SECRET_KEY)`
|
|
60
|
-
- secret-key
|
|
81
|
+
- support for public, bearer-token, and secret-key auth as required by the Gate surface
|
|
61
82
|
- request timeout support
|
|
62
83
|
- no automatic retries
|
|
63
84
|
|
|
@@ -74,7 +95,7 @@ The underlying API responses remain cursor-based. SDKs may expose helper iterato
|
|
|
74
95
|
|
|
75
96
|
## Error Model
|
|
76
97
|
|
|
77
|
-
SDKs should parse
|
|
98
|
+
SDKs should parse API failures into structured errors with, at minimum:
|
|
78
99
|
|
|
79
100
|
- `status`
|
|
80
101
|
- `code`
|
|
@@ -99,6 +120,16 @@ Use both:
|
|
|
99
120
|
|
|
100
121
|
to validate correctness and failure behavior.
|
|
101
122
|
|
|
123
|
+
## Gate Delivery Helper Coverage
|
|
124
|
+
|
|
125
|
+
Use the shared fixtures in `fixtures/gate-delivery/` to validate:
|
|
126
|
+
|
|
127
|
+
- delivery request validation and key-id derivation
|
|
128
|
+
- envelope encrypt/decrypt roundtrips
|
|
129
|
+
- approved webhook payload validation
|
|
130
|
+
- webhook signature verification
|
|
131
|
+
- Gate env-var policy helpers
|
|
132
|
+
|
|
102
133
|
## Sync Model
|
|
103
134
|
|
|
104
135
|
This repo is the source of truth for the shared server SDK contract.
|
|
@@ -123,7 +154,7 @@ When changing any server SDK:
|
|
|
123
154
|
- `limit`
|
|
124
155
|
- `has_more`
|
|
125
156
|
- `next_cursor`
|
|
126
|
-
- preserve structured
|
|
157
|
+
- preserve structured API errors
|
|
127
158
|
- keep sealed token golden-vector coverage
|
|
128
159
|
- keep one live smoke suite per SDK
|
|
129
160
|
- only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"object": "visitor_fingerprint",
|
|
4
|
+
"id": "vid_456789abcdefghjkmnpqrstvwx",
|
|
5
|
+
"lifecycle": {
|
|
6
|
+
"first_seen_at": "2026-03-24T19:58:00.000Z",
|
|
7
|
+
"last_seen_at": "2026-03-24T20:00:05.000Z",
|
|
8
|
+
"seen_count": 3,
|
|
9
|
+
"expires_at": "2026-06-22T20:00:05.000Z"
|
|
10
|
+
},
|
|
11
|
+
"latest_request": {
|
|
12
|
+
"user_agent": "Mozilla/5.0",
|
|
13
|
+
"ip_address": "203.0.113.9"
|
|
14
|
+
},
|
|
15
|
+
"storage": {
|
|
16
|
+
"cookies": true,
|
|
17
|
+
"local_storage": true,
|
|
18
|
+
"indexed_db": true,
|
|
19
|
+
"service_worker": false,
|
|
20
|
+
"window_name": false
|
|
21
|
+
},
|
|
22
|
+
"anchors": {
|
|
23
|
+
"webgl_hash": null,
|
|
24
|
+
"parameters_hash": null,
|
|
25
|
+
"audio_hash": null
|
|
26
|
+
},
|
|
27
|
+
"components": {
|
|
28
|
+
"vector": [
|
|
29
|
+
1,
|
|
30
|
+
0,
|
|
31
|
+
1
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"activity": {
|
|
35
|
+
"sessions": [
|
|
36
|
+
{
|
|
37
|
+
"session_id": "sid_0123456789abcdefghjkmnpqrs",
|
|
38
|
+
"decision": {
|
|
39
|
+
"event_id": "evt_23456789abcdefghjkmnpqrstv",
|
|
40
|
+
"verdict": "human",
|
|
41
|
+
"risk_score": 9,
|
|
42
|
+
"phase": "behavioral",
|
|
43
|
+
"is_provisional": false,
|
|
44
|
+
"manipulation": {
|
|
45
|
+
"score": 0,
|
|
46
|
+
"verdict": "none"
|
|
47
|
+
},
|
|
48
|
+
"evaluation_duration_ms": 142,
|
|
49
|
+
"evaluated_at": "2026-03-24T20:00:05.000Z"
|
|
50
|
+
},
|
|
51
|
+
"request": {
|
|
52
|
+
"url": "https://example.com/signup",
|
|
53
|
+
"user_agent": "Mozilla/5.0",
|
|
54
|
+
"ip_address": "203.0.113.9",
|
|
55
|
+
"screen_size": "1440x900",
|
|
56
|
+
"is_touch_capable": false
|
|
57
|
+
},
|
|
58
|
+
"score_breakdown": {
|
|
59
|
+
"categories": {
|
|
60
|
+
"behavioral": 9
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"meta": {
|
|
68
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"object": "visitor_fingerprint",
|
|
5
|
+
"id": "vid_456789abcdefghjkmnpqrstvwx",
|
|
6
|
+
"lifecycle": {
|
|
7
|
+
"first_seen_at": "2026-03-24T19:58:00.000Z",
|
|
8
|
+
"last_seen_at": "2026-03-24T20:00:05.000Z",
|
|
9
|
+
"seen_count": 3,
|
|
10
|
+
"expires_at": "2026-06-22T20:00:05.000Z"
|
|
11
|
+
},
|
|
12
|
+
"latest_request": {
|
|
13
|
+
"user_agent": "Mozilla/5.0",
|
|
14
|
+
"ip_address": "203.0.113.9"
|
|
15
|
+
},
|
|
16
|
+
"storage": {
|
|
17
|
+
"cookies": true,
|
|
18
|
+
"local_storage": true,
|
|
19
|
+
"indexed_db": true,
|
|
20
|
+
"service_worker": false,
|
|
21
|
+
"window_name": false
|
|
22
|
+
},
|
|
23
|
+
"anchors": {
|
|
24
|
+
"webgl_hash": null,
|
|
25
|
+
"parameters_hash": null,
|
|
26
|
+
"audio_hash": null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"pagination": {
|
|
31
|
+
"limit": 50,
|
|
32
|
+
"has_more": false
|
|
33
|
+
},
|
|
34
|
+
"meta": {
|
|
35
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"valid": true,
|
|
4
|
+
"gate_account_id": "gacct_0123456789abcdefghjkmnpqrs",
|
|
5
|
+
"status": "active",
|
|
6
|
+
"created_at": "2026-04-01T12:00:00.000Z",
|
|
7
|
+
"expires_at": "2026-06-30T12:00:00.000Z"
|
|
8
|
+
},
|
|
9
|
+
"meta": {
|
|
10
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"object": "gate_login_session",
|
|
4
|
+
"id": "gate_1123456789abcdefghjkmnpqrs",
|
|
5
|
+
"status": "pending",
|
|
6
|
+
"consent_url": "https://tripwirejs.com/gate?session=gate_1123456789abcdefghjkmnpqrs",
|
|
7
|
+
"expires_at": "2026-04-04T20:20:00.000Z"
|
|
8
|
+
},
|
|
9
|
+
"meta": {
|
|
10
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"id": "tripwire",
|
|
4
|
+
"status": "active",
|
|
5
|
+
"discoverable": true,
|
|
6
|
+
"name": "Tripwire",
|
|
7
|
+
"description": "Detect AI agents and malicious users",
|
|
8
|
+
"website": "https://tripwirejs.com",
|
|
9
|
+
"dashboard_login_url": "https://dashboard.tripwirejs.com/auth/gate",
|
|
10
|
+
"env_vars": [
|
|
11
|
+
{
|
|
12
|
+
"name": "Publishable key",
|
|
13
|
+
"key": "TRIPWIRE_PUBLISHABLE_KEY",
|
|
14
|
+
"secret": false
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "Secret key",
|
|
18
|
+
"key": "TRIPWIRE_SECRET_KEY",
|
|
19
|
+
"secret": true
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"docs_url": "https://tripwirejs.com/docs",
|
|
23
|
+
"sdks": [
|
|
24
|
+
{
|
|
25
|
+
"label": "Node",
|
|
26
|
+
"install": "npm install @abxy/tripwire-server",
|
|
27
|
+
"url": "https://www.npmjs.com/package/@abxy/tripwire-server"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"branding": {
|
|
31
|
+
"logo_url": "https://tripwirejs.com/logo.png",
|
|
32
|
+
"primary_color": "#117BE7",
|
|
33
|
+
"secondary_color": "#0B5CAD",
|
|
34
|
+
"ascii_art": "TRIPWIRE",
|
|
35
|
+
"verified": true
|
|
36
|
+
},
|
|
37
|
+
"consent": {
|
|
38
|
+
"terms_url": "https://tripwirejs.com/terms",
|
|
39
|
+
"privacy_url": "https://tripwirejs.com/privacy"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"meta": {
|
|
43
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"id": "tripwire",
|
|
5
|
+
"status": "active",
|
|
6
|
+
"discoverable": true,
|
|
7
|
+
"name": "Tripwire",
|
|
8
|
+
"description": "Detect AI agents and malicious users",
|
|
9
|
+
"website": "https://tripwirejs.com",
|
|
10
|
+
"dashboard_login_url": "https://dashboard.tripwirejs.com/auth/gate",
|
|
11
|
+
"env_vars": [
|
|
12
|
+
{
|
|
13
|
+
"name": "Publishable key",
|
|
14
|
+
"key": "TRIPWIRE_PUBLISHABLE_KEY",
|
|
15
|
+
"secret": false
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Secret key",
|
|
19
|
+
"key": "TRIPWIRE_SECRET_KEY",
|
|
20
|
+
"secret": true
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"docs_url": "https://tripwirejs.com/docs",
|
|
24
|
+
"sdks": [
|
|
25
|
+
{
|
|
26
|
+
"label": "Node",
|
|
27
|
+
"install": "npm install @abxy/tripwire-server",
|
|
28
|
+
"url": "https://www.npmjs.com/package/@abxy/tripwire-server"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"branding": {
|
|
32
|
+
"logo_url": "https://tripwirejs.com/logo.png",
|
|
33
|
+
"primary_color": "#117BE7",
|
|
34
|
+
"secondary_color": "#0B5CAD",
|
|
35
|
+
"ascii_art": "TRIPWIRE",
|
|
36
|
+
"verified": true
|
|
37
|
+
},
|
|
38
|
+
"consent": {
|
|
39
|
+
"terms_url": "https://tripwirejs.com/terms",
|
|
40
|
+
"privacy_url": "https://tripwirejs.com/privacy"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"meta": {
|
|
45
|
+
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
46
|
+
}
|
|
47
|
+
}
|