foil-server 0.3.4 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -29
  3. data/lib/foil/server/client.rb +6 -155
  4. data/lib/foil/server/sealed_token.rb +83 -16
  5. data/lib/foil/server/version.rb +1 -1
  6. data/lib/foil/server/webhooks.rb +71 -0
  7. data/lib/foil/server.rb +8 -6
  8. data/spec/README.md +14 -28
  9. data/spec/fixtures/api/organizations/api-key-create.json +1 -0
  10. data/spec/fixtures/api/organizations/api-key-list.json +1 -0
  11. data/spec/fixtures/api/organizations/api-key-revoke.json +1 -0
  12. data/spec/fixtures/api/organizations/api-key-rotate.json +1 -0
  13. data/spec/fixtures/api/organizations/api-key-update.json +1 -0
  14. data/spec/fixtures/api/sessions/detail.json +8 -1
  15. data/spec/fixtures/manifest.json +0 -85
  16. data/spec/fixtures/sealed-token/vector.v2.json +59 -0
  17. data/spec/fixtures/webhooks/signature.json +9 -0
  18. data/spec/openapi.json +1683 -6333
  19. data/spec/sealed-token.md +47 -18
  20. metadata +5 -22
  21. data/lib/foil/server/gate_delivery.rb +0 -332
  22. data/spec/fixtures/api/gate/agent-token-revoke.json +0 -3
  23. data/spec/fixtures/api/gate/agent-token-verify.json +0 -12
  24. data/spec/fixtures/api/gate/login-session-consume.json +0 -10
  25. data/spec/fixtures/api/gate/login-session-create.json +0 -12
  26. data/spec/fixtures/api/gate/registry-detail.json +0 -45
  27. data/spec/fixtures/api/gate/registry-list.json +0 -47
  28. data/spec/fixtures/api/gate/service-create.json +0 -49
  29. data/spec/fixtures/api/gate/service-detail.json +0 -49
  30. data/spec/fixtures/api/gate/service-disable.json +0 -49
  31. data/spec/fixtures/api/gate/service-update.json +0 -49
  32. data/spec/fixtures/api/gate/services-list.json +0 -51
  33. data/spec/fixtures/api/gate/session-ack.json +0 -10
  34. data/spec/fixtures/api/gate/session-create.json +0 -13
  35. data/spec/fixtures/api/gate/session-poll.json +0 -36
  36. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +0 -19
  37. data/spec/fixtures/gate-delivery/delivery-request.json +0 -9
  38. data/spec/fixtures/gate-delivery/env-policy.json +0 -40
  39. data/spec/fixtures/gate-delivery/vector.v1.json +0 -28
  40. data/spec/fixtures/gate-delivery/webhook-signature.json +0 -9
data/spec/sealed-token.md CHANGED
@@ -11,20 +11,44 @@ This document is the language-agnostic contract for verifying those tokens in pu
11
11
  - Confidentiality and integrity: AES-256-GCM
12
12
  - Compression: zlib deflate/inflate
13
13
 
14
- ## Payload format
14
+ ## Payload formats
15
15
 
16
- After base64 decoding, the byte layout is:
16
+ After base64 decoding, use the first byte to select the token version. Reject
17
+ any version other than `0x01` or `0x02`.
18
+
19
+ ### Version `0x01`
20
+
21
+ The legacy single-recipient byte layout is:
17
22
 
18
23
  - `version` - 1 byte
19
24
  - `nonce` - 12 bytes
20
25
  - `ciphertext` - variable length
21
26
  - `tag` - 16 bytes
22
27
 
23
- Current version:
24
-
25
- - `0x01`
26
-
27
- Reject any token whose version byte is not `0x01`.
28
+ ### Version `0x02`
29
+
30
+ The multi-recipient envelope used for independently issued and rotating secret
31
+ keys has this byte layout:
32
+
33
+ - `version` - 1 byte (`0x02`)
34
+ - `recipient_count` - unsigned 16-bit big-endian integer, 1 through 256
35
+ - `payload_nonce` - 12 bytes
36
+ - `payload_ciphertext_length` - unsigned 32-bit big-endian integer
37
+ - `payload_ciphertext` - zlib-compressed JSON encrypted with a random 32-byte content key
38
+ - `payload_tag` - 16 bytes
39
+ - one fixed-size recipient entry per recipient:
40
+ - `recipient_id` - `sha256(UTF8(normalized_secret) || 0x00 || UTF8("sealed-results-recipient-id"))`; `0x00` is one NUL byte, and this domain-separated identifier must never expose `normalized_secret`
41
+ - `wrap_nonce` - 12 bytes
42
+ - `wrapped_content_key` - 32 bytes
43
+ - `wrap_tag` - 16 bytes
44
+
45
+ The payload cipher authenticates
46
+ `UTF8("foil-sealed-results-v2") || 0x00 || UTF8("payload") || 0x00 || header || ordered_recipient_ids`
47
+ as AAD, where each `0x00` is one NUL byte. This binds the complete recipient
48
+ set to the payload. Each content-key wrapper uses the existing derived token key
49
+ and authenticates
50
+ `UTF8("foil-sealed-results-v2") || 0x00 || UTF8("recipient") || 0x00 || recipient_id`
51
+ as AAD.
28
52
 
29
53
  ## Secret normalization
30
54
 
@@ -42,22 +66,27 @@ Normalization rules:
42
66
 
43
67
  Derive the AES key as:
44
68
 
45
- - `sha256(normalized_secret + "\0sealed-results")`
69
+ - `sha256(UTF8(normalized_secret) || 0x00 || UTF8("sealed-results"))`, where `0x00` is one NUL byte
46
70
 
47
71
  Use the raw 32-byte digest as the AES-256-GCM key.
48
72
 
49
73
  ## Verification steps
50
74
 
51
- 1. Base64 decode the token
52
- 2. Parse the version byte, nonce, ciphertext, and tag
53
- 3. Normalize the caller's secret material
54
- 4. Derive the AES-256-GCM key
55
- 5. Decrypt using:
56
- - algorithm: `aes-256-gcm`
57
- - nonce: parsed 12-byte nonce
58
- - tag: parsed 16-byte authentication tag
59
- 6. Inflate the decrypted bytes with zlib
60
- 7. Parse the inflated UTF-8 JSON payload
75
+ 1. Base64 decode the token.
76
+ 2. Parse the version byte.
77
+ 3. Normalize the caller's secret material and derive the AES-256-GCM token key.
78
+ 4. For version `0x01`:
79
+ - parse the 12-byte nonce, ciphertext, and 16-byte authentication tag
80
+ - decrypt the ciphertext with the derived token key, parsed nonce, and tag
81
+ 5. For version `0x02`:
82
+ - parse and bounds-check the header, payload ciphertext, payload tag, and exactly `recipient_count` fixed-size recipient entries
83
+ - compute the caller's `recipient_id` and select its matching entry; reject the token if no entry matches
84
+ - build the wrapper AAD from the fixed prefix, NUL separators, and matching `recipient_id`
85
+ - decrypt `wrapped_content_key` with the derived token key, matching `wrap_nonce`, `wrap_tag`, and wrapper AAD
86
+ - concatenate every recipient entry's `recipient_id` in encoded order and build the payload AAD from the fixed prefix, NUL separators, encoded header, and ordered IDs
87
+ - decrypt `payload_ciphertext` with the unwrapped 32-byte content key, `payload_nonce`, `payload_tag`, and payload AAD
88
+ 6. Inflate the decrypted payload bytes with zlib.
89
+ 7. Parse the inflated UTF-8 JSON payload.
61
90
 
62
91
  Any failure in decoding, parsing, authentication, decompression, or JSON parsing must be treated as verification failure.
63
92
 
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.3.4
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-07-20 00:00:00.000000000 Z
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,14 +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
52
+ - spec/fixtures/sealed-token/vector.v2.json
53
+ - spec/fixtures/webhooks/signature.json
71
54
  - spec/openapi.json
72
55
  - spec/sealed-token.md
73
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,3 +0,0 @@
1
- {
2
- "status": 204
3
- }
@@ -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,10 +0,0 @@
1
- {
2
- "data": {
3
- "object": "gate_dashboard_login",
4
- "gate_account_id": "gacct_0123456789abcdefghjkmnpqrs",
5
- "account_name": "my-project"
6
- },
7
- "meta": {
8
- "request_id": "req_0123456789abcdef0123456789abcdef"
9
- }
10
- }
@@ -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
- }