foil-server 0.3.5 → 0.4.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 +24 -29
- data/lib/foil/server/client.rb +6 -155
- data/lib/foil/server/version.rb +1 -1
- data/lib/foil/server/webhooks.rb +71 -0
- data/lib/foil/server.rb +8 -6
- data/spec/README.md +14 -28
- data/spec/fixtures/api/organizations/api-key-create.json +1 -0
- data/spec/fixtures/api/organizations/api-key-list.json +1 -0
- data/spec/fixtures/api/organizations/api-key-revoke.json +1 -0
- data/spec/fixtures/api/organizations/api-key-rotate.json +1 -0
- data/spec/fixtures/api/organizations/api-key-update.json +1 -0
- data/spec/fixtures/api/sessions/detail.json +8 -1
- data/spec/fixtures/manifest.json +0 -85
- data/spec/fixtures/webhooks/signature.json +9 -0
- data/spec/openapi.json +1683 -6333
- metadata +4 -22
- data/lib/foil/server/gate_delivery.rb +0 -332
- data/spec/fixtures/api/gate/agent-token-revoke.json +0 -3
- data/spec/fixtures/api/gate/agent-token-verify.json +0 -12
- data/spec/fixtures/api/gate/login-session-consume.json +0 -10
- data/spec/fixtures/api/gate/login-session-create.json +0 -12
- data/spec/fixtures/api/gate/registry-detail.json +0 -45
- data/spec/fixtures/api/gate/registry-list.json +0 -47
- data/spec/fixtures/api/gate/service-create.json +0 -49
- data/spec/fixtures/api/gate/service-detail.json +0 -49
- data/spec/fixtures/api/gate/service-disable.json +0 -49
- data/spec/fixtures/api/gate/service-update.json +0 -49
- data/spec/fixtures/api/gate/services-list.json +0 -51
- data/spec/fixtures/api/gate/session-ack.json +0 -10
- data/spec/fixtures/api/gate/session-create.json +0 -13
- data/spec/fixtures/api/gate/session-poll.json +0 -36
- data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +0 -19
- data/spec/fixtures/gate-delivery/delivery-request.json +0 -9
- data/spec/fixtures/gate-delivery/env-policy.json +0 -40
- data/spec/fixtures/gate-delivery/vector.v1.json +0 -28
- data/spec/fixtures/gate-delivery/webhook-signature.json +0 -9
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: foil-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ABXY Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Customer-facing Ruby SDK for Foil Sessions, Fingerprints, Organizations,
|
|
14
14
|
and sealed token verification.
|
|
@@ -24,28 +24,14 @@ files:
|
|
|
24
24
|
- lib/foil/server/client.rb
|
|
25
25
|
- lib/foil/server/crypto_support.rb
|
|
26
26
|
- lib/foil/server/errors.rb
|
|
27
|
-
- lib/foil/server/gate_delivery.rb
|
|
28
27
|
- lib/foil/server/sealed_token.rb
|
|
29
28
|
- lib/foil/server/types.rb
|
|
30
29
|
- lib/foil/server/version.rb
|
|
30
|
+
- lib/foil/server/webhooks.rb
|
|
31
31
|
- spec/LICENSE
|
|
32
32
|
- spec/README.md
|
|
33
33
|
- spec/fixtures/api/fingerprints/detail.json
|
|
34
34
|
- spec/fixtures/api/fingerprints/list.json
|
|
35
|
-
- spec/fixtures/api/gate/agent-token-revoke.json
|
|
36
|
-
- spec/fixtures/api/gate/agent-token-verify.json
|
|
37
|
-
- spec/fixtures/api/gate/login-session-consume.json
|
|
38
|
-
- spec/fixtures/api/gate/login-session-create.json
|
|
39
|
-
- spec/fixtures/api/gate/registry-detail.json
|
|
40
|
-
- spec/fixtures/api/gate/registry-list.json
|
|
41
|
-
- spec/fixtures/api/gate/service-create.json
|
|
42
|
-
- spec/fixtures/api/gate/service-detail.json
|
|
43
|
-
- spec/fixtures/api/gate/service-disable.json
|
|
44
|
-
- spec/fixtures/api/gate/service-update.json
|
|
45
|
-
- spec/fixtures/api/gate/services-list.json
|
|
46
|
-
- spec/fixtures/api/gate/session-ack.json
|
|
47
|
-
- spec/fixtures/api/gate/session-create.json
|
|
48
|
-
- spec/fixtures/api/gate/session-poll.json
|
|
49
35
|
- spec/fixtures/api/organizations/api-key-create.json
|
|
50
36
|
- spec/fixtures/api/organizations/api-key-list.json
|
|
51
37
|
- spec/fixtures/api/organizations/api-key-revoke.json
|
|
@@ -60,15 +46,11 @@ files:
|
|
|
60
46
|
- spec/fixtures/errors/missing-api-key.json
|
|
61
47
|
- spec/fixtures/errors/not-found.json
|
|
62
48
|
- spec/fixtures/errors/validation-error.json
|
|
63
|
-
- spec/fixtures/gate-delivery/approved-webhook-payload.valid.json
|
|
64
|
-
- spec/fixtures/gate-delivery/delivery-request.json
|
|
65
|
-
- spec/fixtures/gate-delivery/env-policy.json
|
|
66
|
-
- spec/fixtures/gate-delivery/vector.v1.json
|
|
67
|
-
- spec/fixtures/gate-delivery/webhook-signature.json
|
|
68
49
|
- spec/fixtures/manifest.json
|
|
69
50
|
- spec/fixtures/sealed-token/invalid.json
|
|
70
51
|
- spec/fixtures/sealed-token/vector.v1.json
|
|
71
52
|
- spec/fixtures/sealed-token/vector.v2.json
|
|
53
|
+
- spec/fixtures/webhooks/signature.json
|
|
72
54
|
- spec/openapi.json
|
|
73
55
|
- spec/sealed-token.md
|
|
74
56
|
homepage: https://github.com/abxy-labs/foil-server-ruby
|
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
require "base64"
|
|
2
|
-
require "digest"
|
|
3
|
-
require "json"
|
|
4
|
-
require "openssl"
|
|
5
|
-
|
|
6
|
-
module Foil
|
|
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
|
-
WEBHOOK_EVENT_TYPES = %w[
|
|
43
|
-
session.fingerprint.calculated
|
|
44
|
-
session.result.persisted
|
|
45
|
-
gate.session.approved
|
|
46
|
-
webhook.test
|
|
47
|
-
].freeze
|
|
48
|
-
GATE_DELIVERY_HKDF_INFO = "foil-gate-delivery:v1".b.freeze
|
|
49
|
-
X25519_SPKI_PREFIX = ["302a300506032b656e032100"].pack("H*").freeze
|
|
50
|
-
|
|
51
|
-
module_function
|
|
52
|
-
|
|
53
|
-
def derive_gate_agent_token_env_key(service_id)
|
|
54
|
-
normalized = service_id.to_s.strip.gsub(/[^A-Za-z0-9]+/, "_").gsub(/^_+|_+$/, "").gsub(/_+/, "_").upcase
|
|
55
|
-
raise ArgumentError, "service_id is required to derive a Gate agent token env key" if normalized.empty?
|
|
56
|
-
|
|
57
|
-
return "FOIL#{GATE_AGENT_TOKEN_ENV_SUFFIX}" if ["FOIL", "FOIL"].include?(normalized)
|
|
58
|
-
|
|
59
|
-
"#{normalized}#{GATE_AGENT_TOKEN_ENV_SUFFIX}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def is_gate_managed_env_var_key(key)
|
|
63
|
-
key == "FOIL_AGENT_TOKEN" || key.to_s.end_with?(GATE_AGENT_TOKEN_ENV_SUFFIX)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def is_blocked_gate_env_var_key(key)
|
|
67
|
-
normalized = key.to_s.strip.upcase
|
|
68
|
-
BLOCKED_GATE_ENV_VAR_KEYS.include?(normalized) || BLOCKED_GATE_ENV_VAR_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def raw_x25519_public_key_from_key_object(public_key)
|
|
72
|
-
if public_key.respond_to?(:raw_public_key)
|
|
73
|
-
raw_public_key = public_key.raw_public_key
|
|
74
|
-
raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
|
|
75
|
-
|
|
76
|
-
return raw_public_key
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
der = public_key.public_to_der
|
|
80
|
-
raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
|
|
81
|
-
raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
|
|
82
|
-
|
|
83
|
-
der.byteslice(X25519_SPKI_PREFIX.bytesize, 32)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def key_id_for_raw_x25519_public_key(raw_public_key)
|
|
87
|
-
raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
|
|
88
|
-
|
|
89
|
-
Base64.urlsafe_encode64(Digest::SHA256.digest(raw_public_key), padding: false)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def create_delivery_key_pair
|
|
93
|
-
CryptoSupport.ensure_supported_runtime!
|
|
94
|
-
private_key = OpenSSL::PKey.generate_key("X25519")
|
|
95
|
-
raw_public_key = raw_x25519_public_key_from_key_object(private_key)
|
|
96
|
-
{
|
|
97
|
-
delivery: {
|
|
98
|
-
version: GATE_DELIVERY_VERSION,
|
|
99
|
-
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
100
|
-
key_id: key_id_for_raw_x25519_public_key(raw_public_key),
|
|
101
|
-
public_key: Base64.urlsafe_encode64(raw_public_key, padding: false)
|
|
102
|
-
},
|
|
103
|
-
private_key: private_key
|
|
104
|
-
}
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def export_delivery_private_key_pkcs8(private_key)
|
|
108
|
-
CryptoSupport.ensure_supported_runtime!
|
|
109
|
-
Base64.urlsafe_encode64(private_key.private_to_der, padding: false)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def import_delivery_private_key_pkcs8(value)
|
|
113
|
-
CryptoSupport.ensure_supported_runtime!
|
|
114
|
-
OpenSSL::PKey.read(b64url_decode(value, "delivery.private_key_pkcs8"))
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def validate_gate_delivery_request(value)
|
|
118
|
-
candidate = symbolize(value)
|
|
119
|
-
raise ArgumentError, "delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
120
|
-
raise ArgumentError, "delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
121
|
-
raise ArgumentError, "delivery.public_key is required" if candidate[:public_key].to_s.empty?
|
|
122
|
-
raise ArgumentError, "delivery.key_id is required" if candidate[:key_id].to_s.empty?
|
|
123
|
-
|
|
124
|
-
raw_public_key = b64url_decode(candidate[:public_key], "delivery.public_key")
|
|
125
|
-
raise ArgumentError, "delivery.public_key must be a raw X25519 public key" unless raw_public_key.bytesize == 32
|
|
126
|
-
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]
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
version: GATE_DELIVERY_VERSION,
|
|
130
|
-
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
131
|
-
key_id: candidate[:key_id],
|
|
132
|
-
public_key: candidate[:public_key]
|
|
133
|
-
}
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def create_encrypted_delivery_response(input)
|
|
137
|
-
{
|
|
138
|
-
encrypted_delivery: encrypt_gate_delivery_payload(
|
|
139
|
-
input.fetch(:delivery),
|
|
140
|
-
{
|
|
141
|
-
version: GATE_DELIVERY_VERSION,
|
|
142
|
-
outputs: input.fetch(:outputs)
|
|
143
|
-
}
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def create_gate_approved_webhook_response(input)
|
|
149
|
-
create_encrypted_delivery_response(input)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def validate_encrypted_gate_delivery_envelope(value)
|
|
153
|
-
candidate = symbolize(value)
|
|
154
|
-
raise ArgumentError, "encrypted_delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
155
|
-
raise ArgumentError, "encrypted_delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
156
|
-
%i[key_id ephemeral_public_key salt iv ciphertext tag].each do |field|
|
|
157
|
-
raise ArgumentError, "encrypted_delivery.#{field} is required" if candidate[field].to_s.empty?
|
|
158
|
-
end
|
|
159
|
-
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
|
|
160
|
-
raise ArgumentError, "encrypted_delivery.salt must be 32 bytes" unless b64url_decode(candidate[:salt], "encrypted_delivery.salt").bytesize == 32
|
|
161
|
-
raise ArgumentError, "encrypted_delivery.iv must be 12 bytes" unless b64url_decode(candidate[:iv], "encrypted_delivery.iv").bytesize == 12
|
|
162
|
-
raise ArgumentError, "encrypted_delivery.tag must be 16 bytes" unless b64url_decode(candidate[:tag], "encrypted_delivery.tag").bytesize == 16
|
|
163
|
-
|
|
164
|
-
candidate
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def encrypt_gate_delivery_payload(delivery, payload)
|
|
168
|
-
CryptoSupport.ensure_supported_runtime!
|
|
169
|
-
validated_delivery = validate_gate_delivery_request(delivery)
|
|
170
|
-
payload = symbolize(payload)
|
|
171
|
-
raise ArgumentError, "Gate delivery payload version must be 1" unless payload[:version] == GATE_DELIVERY_VERSION
|
|
172
|
-
|
|
173
|
-
recipient_public_key = OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated_delivery[:public_key], "delivery.public_key"))
|
|
174
|
-
ephemeral_private_key = OpenSSL::PKey.generate_key("X25519")
|
|
175
|
-
shared_secret = ephemeral_private_key.derive(recipient_public_key)
|
|
176
|
-
salt = OpenSSL::Random.random_bytes(32)
|
|
177
|
-
iv = OpenSSL::Random.random_bytes(12)
|
|
178
|
-
key = OpenSSL::KDF.hkdf(shared_secret, salt: salt, info: GATE_DELIVERY_HKDF_INFO, length: 32, hash: "SHA256")
|
|
179
|
-
|
|
180
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
181
|
-
cipher.encrypt
|
|
182
|
-
cipher.key = key
|
|
183
|
-
cipher.iv = iv
|
|
184
|
-
ciphertext = cipher.update(JSON.generate(compact_payload(payload))) + cipher.final
|
|
185
|
-
tag = cipher.auth_tag
|
|
186
|
-
|
|
187
|
-
{
|
|
188
|
-
version: GATE_DELIVERY_VERSION,
|
|
189
|
-
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
190
|
-
key_id: validated_delivery[:key_id],
|
|
191
|
-
ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key), padding: false),
|
|
192
|
-
salt: Base64.urlsafe_encode64(salt, padding: false),
|
|
193
|
-
iv: Base64.urlsafe_encode64(iv, padding: false),
|
|
194
|
-
ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
|
|
195
|
-
tag: Base64.urlsafe_encode64(tag, padding: false)
|
|
196
|
-
}
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def decrypt_gate_delivery_envelope(private_key, envelope)
|
|
200
|
-
CryptoSupport.ensure_supported_runtime!
|
|
201
|
-
validated = validate_encrypted_gate_delivery_envelope(envelope)
|
|
202
|
-
shared_secret = private_key.derive(
|
|
203
|
-
OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key"))
|
|
204
|
-
)
|
|
205
|
-
key = OpenSSL::KDF.hkdf(
|
|
206
|
-
shared_secret,
|
|
207
|
-
salt: b64url_decode(validated[:salt], "encrypted_delivery.salt"),
|
|
208
|
-
info: GATE_DELIVERY_HKDF_INFO,
|
|
209
|
-
length: 32,
|
|
210
|
-
hash: "SHA256"
|
|
211
|
-
)
|
|
212
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
213
|
-
cipher.decrypt
|
|
214
|
-
cipher.key = key
|
|
215
|
-
cipher.iv = b64url_decode(validated[:iv], "encrypted_delivery.iv")
|
|
216
|
-
cipher.auth_tag = b64url_decode(validated[:tag], "encrypted_delivery.tag")
|
|
217
|
-
cipher.auth_data = ""
|
|
218
|
-
plaintext = cipher.update(b64url_decode(validated[:ciphertext], "encrypted_delivery.ciphertext")) + cipher.final
|
|
219
|
-
payload = JSON.parse(plaintext)
|
|
220
|
-
raise ArgumentError, "encrypted_delivery payload must be an object" unless payload.is_a?(Hash)
|
|
221
|
-
|
|
222
|
-
symbolize(payload).tap do |candidate|
|
|
223
|
-
raise ArgumentError, "encrypted_delivery payload version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
224
|
-
raise ArgumentError, "encrypted_delivery payload outputs must be an object" unless candidate[:outputs].is_a?(Hash)
|
|
225
|
-
candidate[:outputs].each do |key_name, item|
|
|
226
|
-
raise ArgumentError, "encrypted_delivery output #{key_name} must be a string" unless item.is_a?(String)
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
rescue JSON::ParserError
|
|
230
|
-
raise ArgumentError, "encrypted_delivery decrypted to invalid JSON"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def validate_gate_approved_webhook_payload(value)
|
|
234
|
-
candidate = symbolize(value)
|
|
235
|
-
raise ArgumentError, "webhook payload must not include event; use the webhook event envelope type" if candidate.key?(:event)
|
|
236
|
-
raise ArgumentError, "service_id is required" if candidate[:service_id].to_s.empty?
|
|
237
|
-
raise ArgumentError, "gate_session_id is required" if candidate[:gate_session_id].to_s.empty?
|
|
238
|
-
raise ArgumentError, "gate_account_id is required" if candidate[:gate_account_id].to_s.empty?
|
|
239
|
-
raise ArgumentError, "account_name is required" if candidate[:account_name].to_s.empty?
|
|
240
|
-
raise ArgumentError, "metadata must be an object or null" unless candidate[:metadata].nil? || candidate[:metadata].is_a?(Hash)
|
|
241
|
-
raise ArgumentError, "foil must be an object" unless candidate[:foil].is_a?(Hash)
|
|
242
|
-
verdict = candidate[:foil][:verdict]
|
|
243
|
-
raise ArgumentError, "foil.verdict is invalid" unless %w[bot human inconclusive].include?(verdict)
|
|
244
|
-
score = candidate[:foil][:score]
|
|
245
|
-
raise ArgumentError, "foil.score must be a number or null" unless score.nil? || score.is_a?(Numeric)
|
|
246
|
-
|
|
247
|
-
{
|
|
248
|
-
service_id: candidate[:service_id],
|
|
249
|
-
gate_session_id: candidate[:gate_session_id],
|
|
250
|
-
gate_account_id: candidate[:gate_account_id],
|
|
251
|
-
account_name: candidate[:account_name],
|
|
252
|
-
metadata: candidate[:metadata]&.dup,
|
|
253
|
-
foil: {
|
|
254
|
-
verdict: verdict,
|
|
255
|
-
score: score
|
|
256
|
-
},
|
|
257
|
-
delivery: validate_gate_delivery_request(candidate[:delivery])
|
|
258
|
-
}
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def verify_gate_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
262
|
-
parsed_timestamp = Integer(timestamp)
|
|
263
|
-
current = now_seconds || Time.now.to_i
|
|
264
|
-
return false if (current - parsed_timestamp).abs > max_age_seconds
|
|
265
|
-
|
|
266
|
-
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
267
|
-
secure_compare(expected, signature)
|
|
268
|
-
rescue ArgumentError
|
|
269
|
-
false
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def parse_webhook_event(raw_body)
|
|
273
|
-
envelope = symbolize(JSON.parse(raw_body))
|
|
274
|
-
raise ArgumentError, "webhook event object must be webhook_event" unless envelope[:object] == "webhook_event"
|
|
275
|
-
raise ArgumentError, "webhook event id is required" if envelope[:id].to_s.empty?
|
|
276
|
-
raise ArgumentError, "webhook event type is required" if envelope[:type].to_s.empty?
|
|
277
|
-
raise ArgumentError, "unsupported webhook event type: #{envelope[:type]}" unless WEBHOOK_EVENT_TYPES.include?(envelope[:type])
|
|
278
|
-
raise ArgumentError, "webhook event created timestamp is required" if envelope[:created].to_s.empty?
|
|
279
|
-
raise ArgumentError, "webhook event data must be an object" unless envelope[:data].is_a?(Hash)
|
|
280
|
-
|
|
281
|
-
envelope[:data] = validate_gate_approved_webhook_payload(envelope[:data]) if envelope[:type] == "gate.session.approved"
|
|
282
|
-
envelope
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def verify_and_parse_webhook_event(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
286
|
-
unless verify_gate_webhook_signature(secret: secret, timestamp: timestamp, raw_body: raw_body, signature: signature, max_age_seconds: max_age_seconds, now_seconds: now_seconds)
|
|
287
|
-
raise ArgumentError, "Invalid Foil webhook signature"
|
|
288
|
-
end
|
|
289
|
-
parse_webhook_event(raw_body)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def symbolize(value)
|
|
293
|
-
case value
|
|
294
|
-
when Array
|
|
295
|
-
value.map { |item| symbolize(item) }
|
|
296
|
-
when Hash
|
|
297
|
-
value.each_with_object({}) do |(key, item), memo|
|
|
298
|
-
memo[key.to_sym] = symbolize(item)
|
|
299
|
-
end
|
|
300
|
-
else
|
|
301
|
-
value
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
private_class_method :symbolize
|
|
305
|
-
|
|
306
|
-
def compact_payload(payload)
|
|
307
|
-
{
|
|
308
|
-
version: payload[:version],
|
|
309
|
-
outputs: payload[:outputs],
|
|
310
|
-
**(payload[:ack_token] ? { ack_token: payload[:ack_token] } : {})
|
|
311
|
-
}
|
|
312
|
-
end
|
|
313
|
-
private_class_method :compact_payload
|
|
314
|
-
|
|
315
|
-
def b64url_decode(value, label)
|
|
316
|
-
Base64.urlsafe_decode64(value.to_s)
|
|
317
|
-
rescue ArgumentError => error
|
|
318
|
-
raise ArgumentError, "Invalid #{label}: #{error.message}"
|
|
319
|
-
end
|
|
320
|
-
private_class_method :b64url_decode
|
|
321
|
-
|
|
322
|
-
def secure_compare(left, right)
|
|
323
|
-
return false unless left.bytesize == right.bytesize
|
|
324
|
-
|
|
325
|
-
result = 0
|
|
326
|
-
left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
|
|
327
|
-
result.zero?
|
|
328
|
-
end
|
|
329
|
-
private_class_method :secure_compare
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
end
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": {
|
|
3
|
-
"object": "gate_login_session",
|
|
4
|
-
"id": "gate_1123456789abcdefghjkmnpqrs",
|
|
5
|
-
"status": "pending",
|
|
6
|
-
"consent_url": "https://usefoil.com/gate?session=gate_1123456789abcdefghjkmnpqrs",
|
|
7
|
-
"expires_at": "2026-04-04T20:20:00.000Z"
|
|
8
|
-
},
|
|
9
|
-
"meta": {
|
|
10
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
11
|
-
}
|
|
12
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": {
|
|
3
|
-
"id": "foil",
|
|
4
|
-
"status": "active",
|
|
5
|
-
"discoverable": true,
|
|
6
|
-
"name": "Foil",
|
|
7
|
-
"description": "Detect AI agents and malicious users",
|
|
8
|
-
"website": "https://usefoil.com",
|
|
9
|
-
"dashboard_login_url": "https://dashboard.usefoil.com/auth/gate",
|
|
10
|
-
"env_vars": [
|
|
11
|
-
{
|
|
12
|
-
"name": "Publishable key",
|
|
13
|
-
"key": "FOIL_PUBLISHABLE_KEY",
|
|
14
|
-
"secret": false
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"name": "Secret key",
|
|
18
|
-
"key": "FOIL_SECRET_KEY",
|
|
19
|
-
"secret": true
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"docs_url": "https://usefoil.com/docs",
|
|
23
|
-
"sdks": [
|
|
24
|
-
{
|
|
25
|
-
"label": "Node",
|
|
26
|
-
"install": "npm install @abxy/foil-server",
|
|
27
|
-
"url": "https://www.npmjs.com/package/@abxy/foil-server"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"branding": {
|
|
31
|
-
"logo_url": "https://usefoil.com/logo.png",
|
|
32
|
-
"primary_color": "#117BE7",
|
|
33
|
-
"secondary_color": "#0B5CAD",
|
|
34
|
-
"ascii_art": "FOIL",
|
|
35
|
-
"verified": true
|
|
36
|
-
},
|
|
37
|
-
"consent": {
|
|
38
|
-
"terms_url": "https://usefoil.com/terms",
|
|
39
|
-
"privacy_url": "https://usefoil.com/privacy"
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
"meta": {
|
|
43
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": [
|
|
3
|
-
{
|
|
4
|
-
"id": "foil",
|
|
5
|
-
"status": "active",
|
|
6
|
-
"discoverable": true,
|
|
7
|
-
"name": "Foil",
|
|
8
|
-
"description": "Detect AI agents and malicious users",
|
|
9
|
-
"website": "https://usefoil.com",
|
|
10
|
-
"dashboard_login_url": "https://dashboard.usefoil.com/auth/gate",
|
|
11
|
-
"env_vars": [
|
|
12
|
-
{
|
|
13
|
-
"name": "Publishable key",
|
|
14
|
-
"key": "FOIL_PUBLISHABLE_KEY",
|
|
15
|
-
"secret": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "Secret key",
|
|
19
|
-
"key": "FOIL_SECRET_KEY",
|
|
20
|
-
"secret": true
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"docs_url": "https://usefoil.com/docs",
|
|
24
|
-
"sdks": [
|
|
25
|
-
{
|
|
26
|
-
"label": "Node",
|
|
27
|
-
"install": "npm install @abxy/foil-server",
|
|
28
|
-
"url": "https://www.npmjs.com/package/@abxy/foil-server"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"branding": {
|
|
32
|
-
"logo_url": "https://usefoil.com/logo.png",
|
|
33
|
-
"primary_color": "#117BE7",
|
|
34
|
-
"secondary_color": "#0B5CAD",
|
|
35
|
-
"ascii_art": "FOIL",
|
|
36
|
-
"verified": true
|
|
37
|
-
},
|
|
38
|
-
"consent": {
|
|
39
|
-
"terms_url": "https://usefoil.com/terms",
|
|
40
|
-
"privacy_url": "https://usefoil.com/privacy"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
],
|
|
44
|
-
"meta": {
|
|
45
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": {
|
|
3
|
-
"object": "gate_service",
|
|
4
|
-
"id": "acme_prod",
|
|
5
|
-
"status": "active",
|
|
6
|
-
"discoverable": false,
|
|
7
|
-
"name": "Acme Production",
|
|
8
|
-
"description": "Acme production signup flow",
|
|
9
|
-
"website": "https://acme.example.com",
|
|
10
|
-
"dashboard_login_url": "https://dashboard.acme.example.com/auth/gate",
|
|
11
|
-
"env_vars": [
|
|
12
|
-
{
|
|
13
|
-
"name": "Publishable key",
|
|
14
|
-
"key": "ACME_PUBLISHABLE_KEY",
|
|
15
|
-
"secret": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "Secret key",
|
|
19
|
-
"key": "ACME_SECRET_KEY",
|
|
20
|
-
"secret": true
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"docs_url": "https://docs.acme.example.com/signup",
|
|
24
|
-
"sdks": [
|
|
25
|
-
{
|
|
26
|
-
"label": "Node",
|
|
27
|
-
"install": "npm install @acme/sdk",
|
|
28
|
-
"url": "https://www.npmjs.com/package/@acme/sdk"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"branding": {
|
|
32
|
-
"logo_url": "https://acme.example.com/logo.png",
|
|
33
|
-
"primary_color": "#117BE7",
|
|
34
|
-
"secondary_color": "#0B5CAD",
|
|
35
|
-
"ascii_art": "ACME",
|
|
36
|
-
"verified": false
|
|
37
|
-
},
|
|
38
|
-
"consent": {
|
|
39
|
-
"terms_url": "https://acme.example.com/terms",
|
|
40
|
-
"privacy_url": "https://acme.example.com/privacy"
|
|
41
|
-
},
|
|
42
|
-
"created_at": "2026-04-03T20:00:00.000Z",
|
|
43
|
-
"updated_at": "2026-04-03T20:00:00.000Z",
|
|
44
|
-
"webhook_endpoint_id": "we_0123456789abcdef0123456789abcdef"
|
|
45
|
-
},
|
|
46
|
-
"meta": {
|
|
47
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": {
|
|
3
|
-
"object": "gate_service",
|
|
4
|
-
"id": "acme_prod",
|
|
5
|
-
"status": "active",
|
|
6
|
-
"discoverable": false,
|
|
7
|
-
"name": "Acme Production",
|
|
8
|
-
"description": "Acme production signup flow",
|
|
9
|
-
"website": "https://acme.example.com",
|
|
10
|
-
"dashboard_login_url": "https://dashboard.acme.example.com/auth/gate",
|
|
11
|
-
"env_vars": [
|
|
12
|
-
{
|
|
13
|
-
"name": "Publishable key",
|
|
14
|
-
"key": "ACME_PUBLISHABLE_KEY",
|
|
15
|
-
"secret": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "Secret key",
|
|
19
|
-
"key": "ACME_SECRET_KEY",
|
|
20
|
-
"secret": true
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"docs_url": "https://docs.acme.example.com/signup",
|
|
24
|
-
"sdks": [
|
|
25
|
-
{
|
|
26
|
-
"label": "Node",
|
|
27
|
-
"install": "npm install @acme/sdk",
|
|
28
|
-
"url": "https://www.npmjs.com/package/@acme/sdk"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"branding": {
|
|
32
|
-
"logo_url": "https://acme.example.com/logo.png",
|
|
33
|
-
"primary_color": "#117BE7",
|
|
34
|
-
"secondary_color": "#0B5CAD",
|
|
35
|
-
"ascii_art": "ACME",
|
|
36
|
-
"verified": false
|
|
37
|
-
},
|
|
38
|
-
"consent": {
|
|
39
|
-
"terms_url": "https://acme.example.com/terms",
|
|
40
|
-
"privacy_url": "https://acme.example.com/privacy"
|
|
41
|
-
},
|
|
42
|
-
"created_at": "2026-04-03T20:00:00.000Z",
|
|
43
|
-
"updated_at": "2026-04-03T20:05:00.000Z",
|
|
44
|
-
"webhook_endpoint_id": "we_0123456789abcdef0123456789abcdef"
|
|
45
|
-
},
|
|
46
|
-
"meta": {
|
|
47
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"data": {
|
|
3
|
-
"object": "gate_service",
|
|
4
|
-
"id": "acme_prod",
|
|
5
|
-
"status": "disabled",
|
|
6
|
-
"discoverable": false,
|
|
7
|
-
"name": "Acme Production",
|
|
8
|
-
"description": "Acme production signup flow",
|
|
9
|
-
"website": "https://acme.example.com",
|
|
10
|
-
"dashboard_login_url": "https://dashboard.acme.example.com/auth/gate",
|
|
11
|
-
"env_vars": [
|
|
12
|
-
{
|
|
13
|
-
"name": "Publishable key",
|
|
14
|
-
"key": "ACME_PUBLISHABLE_KEY",
|
|
15
|
-
"secret": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "Secret key",
|
|
19
|
-
"key": "ACME_SECRET_KEY",
|
|
20
|
-
"secret": true
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"docs_url": "https://docs.acme.example.com/signup",
|
|
24
|
-
"sdks": [
|
|
25
|
-
{
|
|
26
|
-
"label": "Node",
|
|
27
|
-
"install": "npm install @acme/sdk",
|
|
28
|
-
"url": "https://www.npmjs.com/package/@acme/sdk"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"branding": {
|
|
32
|
-
"logo_url": "https://acme.example.com/logo.png",
|
|
33
|
-
"primary_color": "#117BE7",
|
|
34
|
-
"secondary_color": "#0B5CAD",
|
|
35
|
-
"ascii_art": "ACME",
|
|
36
|
-
"verified": false
|
|
37
|
-
},
|
|
38
|
-
"consent": {
|
|
39
|
-
"terms_url": "https://acme.example.com/terms",
|
|
40
|
-
"privacy_url": "https://acme.example.com/privacy"
|
|
41
|
-
},
|
|
42
|
-
"created_at": "2026-04-03T20:00:00.000Z",
|
|
43
|
-
"updated_at": "2026-04-03T20:15:00.000Z",
|
|
44
|
-
"webhook_endpoint_id": "we_0123456789abcdef0123456789abcdef"
|
|
45
|
-
},
|
|
46
|
-
"meta": {
|
|
47
|
-
"request_id": "req_0123456789abcdef0123456789abcdef"
|
|
48
|
-
}
|
|
49
|
-
}
|