foil-server 0.3.3
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 +154 -0
- data/lib/foil/server/client.rb +472 -0
- data/lib/foil/server/crypto_support.rb +49 -0
- data/lib/foil/server/errors.rb +21 -0
- data/lib/foil/server/gate_delivery.rb +325 -0
- data/lib/foil/server/sealed_token.rb +78 -0
- data/lib/foil/server/types.rb +5 -0
- data/lib/foil/server/version.rb +5 -0
- data/lib/foil/server.rb +31 -0
- data/spec/LICENSE +21 -0
- data/spec/README.md +160 -0
- 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-revoke.json +3 -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/organizations/api-key-create.json +27 -0
- data/spec/fixtures/api/organizations/api-key-list.json +31 -0
- data/spec/fixtures/api/organizations/api-key-revoke.json +25 -0
- data/spec/fixtures/api/organizations/api-key-rotate.json +27 -0
- data/spec/fixtures/api/organizations/api-key-update.json +29 -0
- data/spec/fixtures/api/organizations/organization-create.json +14 -0
- data/spec/fixtures/api/organizations/organization-update.json +14 -0
- data/spec/fixtures/api/organizations/organization.json +14 -0
- data/spec/fixtures/api/sessions/detail.json +434 -0
- data/spec/fixtures/api/sessions/list.json +36 -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 +20 -0
- data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +19 -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 +185 -0
- data/spec/fixtures/sealed-token/invalid.json +4 -0
- data/spec/fixtures/sealed-token/vector.v1.json +54 -0
- data/spec/openapi.json +20482 -0
- data/spec/sealed-token.md +114 -0
- metadata +96 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Foil
|
|
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,325 @@
|
|
|
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
|
+
der = public_key.public_to_der
|
|
73
|
+
raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
|
|
74
|
+
raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
|
|
75
|
+
|
|
76
|
+
der.byteslice(X25519_SPKI_PREFIX.bytesize, 32)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def key_id_for_raw_x25519_public_key(raw_public_key)
|
|
80
|
+
raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
|
|
81
|
+
|
|
82
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(raw_public_key), padding: false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create_delivery_key_pair
|
|
86
|
+
CryptoSupport.ensure_supported_runtime!
|
|
87
|
+
private_key = OpenSSL::PKey.generate_key("X25519")
|
|
88
|
+
raw_public_key = raw_x25519_public_key_from_key_object(private_key.public_key)
|
|
89
|
+
{
|
|
90
|
+
delivery: {
|
|
91
|
+
version: GATE_DELIVERY_VERSION,
|
|
92
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
93
|
+
key_id: key_id_for_raw_x25519_public_key(raw_public_key),
|
|
94
|
+
public_key: Base64.urlsafe_encode64(raw_public_key, padding: false)
|
|
95
|
+
},
|
|
96
|
+
private_key: private_key
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def export_delivery_private_key_pkcs8(private_key)
|
|
101
|
+
CryptoSupport.ensure_supported_runtime!
|
|
102
|
+
Base64.urlsafe_encode64(private_key.private_to_der, padding: false)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def import_delivery_private_key_pkcs8(value)
|
|
106
|
+
CryptoSupport.ensure_supported_runtime!
|
|
107
|
+
OpenSSL::PKey.read(b64url_decode(value, "delivery.private_key_pkcs8"))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_gate_delivery_request(value)
|
|
111
|
+
candidate = symbolize(value)
|
|
112
|
+
raise ArgumentError, "delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
113
|
+
raise ArgumentError, "delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
114
|
+
raise ArgumentError, "delivery.public_key is required" if candidate[:public_key].to_s.empty?
|
|
115
|
+
raise ArgumentError, "delivery.key_id is required" if candidate[:key_id].to_s.empty?
|
|
116
|
+
|
|
117
|
+
raw_public_key = b64url_decode(candidate[:public_key], "delivery.public_key")
|
|
118
|
+
raise ArgumentError, "delivery.public_key must be a raw X25519 public key" unless raw_public_key.bytesize == 32
|
|
119
|
+
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]
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
version: GATE_DELIVERY_VERSION,
|
|
123
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
124
|
+
key_id: candidate[:key_id],
|
|
125
|
+
public_key: candidate[:public_key]
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create_encrypted_delivery_response(input)
|
|
130
|
+
{
|
|
131
|
+
encrypted_delivery: encrypt_gate_delivery_payload(
|
|
132
|
+
input.fetch(:delivery),
|
|
133
|
+
{
|
|
134
|
+
version: GATE_DELIVERY_VERSION,
|
|
135
|
+
outputs: input.fetch(:outputs)
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def create_gate_approved_webhook_response(input)
|
|
142
|
+
create_encrypted_delivery_response(input)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_encrypted_gate_delivery_envelope(value)
|
|
146
|
+
candidate = symbolize(value)
|
|
147
|
+
raise ArgumentError, "encrypted_delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
148
|
+
raise ArgumentError, "encrypted_delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
|
|
149
|
+
%i[key_id ephemeral_public_key salt iv ciphertext tag].each do |field|
|
|
150
|
+
raise ArgumentError, "encrypted_delivery.#{field} is required" if candidate[field].to_s.empty?
|
|
151
|
+
end
|
|
152
|
+
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
|
|
153
|
+
raise ArgumentError, "encrypted_delivery.salt must be 32 bytes" unless b64url_decode(candidate[:salt], "encrypted_delivery.salt").bytesize == 32
|
|
154
|
+
raise ArgumentError, "encrypted_delivery.iv must be 12 bytes" unless b64url_decode(candidate[:iv], "encrypted_delivery.iv").bytesize == 12
|
|
155
|
+
raise ArgumentError, "encrypted_delivery.tag must be 16 bytes" unless b64url_decode(candidate[:tag], "encrypted_delivery.tag").bytesize == 16
|
|
156
|
+
|
|
157
|
+
candidate
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def encrypt_gate_delivery_payload(delivery, payload)
|
|
161
|
+
CryptoSupport.ensure_supported_runtime!
|
|
162
|
+
validated_delivery = validate_gate_delivery_request(delivery)
|
|
163
|
+
payload = symbolize(payload)
|
|
164
|
+
raise ArgumentError, "Gate delivery payload version must be 1" unless payload[:version] == GATE_DELIVERY_VERSION
|
|
165
|
+
|
|
166
|
+
recipient_public_key = OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated_delivery[:public_key], "delivery.public_key"))
|
|
167
|
+
ephemeral_private_key = OpenSSL::PKey.generate_key("X25519")
|
|
168
|
+
shared_secret = ephemeral_private_key.derive(recipient_public_key)
|
|
169
|
+
salt = OpenSSL::Random.random_bytes(32)
|
|
170
|
+
iv = OpenSSL::Random.random_bytes(12)
|
|
171
|
+
key = OpenSSL::KDF.hkdf(shared_secret, salt: salt, info: GATE_DELIVERY_HKDF_INFO, length: 32, hash: "SHA256")
|
|
172
|
+
|
|
173
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
174
|
+
cipher.encrypt
|
|
175
|
+
cipher.key = key
|
|
176
|
+
cipher.iv = iv
|
|
177
|
+
ciphertext = cipher.update(JSON.generate(compact_payload(payload))) + cipher.final
|
|
178
|
+
tag = cipher.auth_tag
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
version: GATE_DELIVERY_VERSION,
|
|
182
|
+
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
183
|
+
key_id: validated_delivery[:key_id],
|
|
184
|
+
ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key.public_key), padding: false),
|
|
185
|
+
salt: Base64.urlsafe_encode64(salt, padding: false),
|
|
186
|
+
iv: Base64.urlsafe_encode64(iv, padding: false),
|
|
187
|
+
ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
|
|
188
|
+
tag: Base64.urlsafe_encode64(tag, padding: false)
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def decrypt_gate_delivery_envelope(private_key, envelope)
|
|
193
|
+
CryptoSupport.ensure_supported_runtime!
|
|
194
|
+
validated = validate_encrypted_gate_delivery_envelope(envelope)
|
|
195
|
+
shared_secret = private_key.derive(
|
|
196
|
+
OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key"))
|
|
197
|
+
)
|
|
198
|
+
key = OpenSSL::KDF.hkdf(
|
|
199
|
+
shared_secret,
|
|
200
|
+
salt: b64url_decode(validated[:salt], "encrypted_delivery.salt"),
|
|
201
|
+
info: GATE_DELIVERY_HKDF_INFO,
|
|
202
|
+
length: 32,
|
|
203
|
+
hash: "SHA256"
|
|
204
|
+
)
|
|
205
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
206
|
+
cipher.decrypt
|
|
207
|
+
cipher.key = key
|
|
208
|
+
cipher.iv = b64url_decode(validated[:iv], "encrypted_delivery.iv")
|
|
209
|
+
cipher.auth_tag = b64url_decode(validated[:tag], "encrypted_delivery.tag")
|
|
210
|
+
cipher.auth_data = ""
|
|
211
|
+
plaintext = cipher.update(b64url_decode(validated[:ciphertext], "encrypted_delivery.ciphertext")) + cipher.final
|
|
212
|
+
payload = JSON.parse(plaintext)
|
|
213
|
+
raise ArgumentError, "encrypted_delivery payload must be an object" unless payload.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
symbolize(payload).tap do |candidate|
|
|
216
|
+
raise ArgumentError, "encrypted_delivery payload version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
|
|
217
|
+
raise ArgumentError, "encrypted_delivery payload outputs must be an object" unless candidate[:outputs].is_a?(Hash)
|
|
218
|
+
candidate[:outputs].each do |key_name, item|
|
|
219
|
+
raise ArgumentError, "encrypted_delivery output #{key_name} must be a string" unless item.is_a?(String)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
raise ArgumentError, "encrypted_delivery decrypted to invalid JSON"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def validate_gate_approved_webhook_payload(value)
|
|
227
|
+
candidate = symbolize(value)
|
|
228
|
+
raise ArgumentError, "webhook payload must not include event; use the webhook event envelope type" if candidate.key?(:event)
|
|
229
|
+
raise ArgumentError, "service_id is required" if candidate[:service_id].to_s.empty?
|
|
230
|
+
raise ArgumentError, "gate_session_id is required" if candidate[:gate_session_id].to_s.empty?
|
|
231
|
+
raise ArgumentError, "gate_account_id is required" if candidate[:gate_account_id].to_s.empty?
|
|
232
|
+
raise ArgumentError, "account_name is required" if candidate[:account_name].to_s.empty?
|
|
233
|
+
raise ArgumentError, "metadata must be an object or null" unless candidate[:metadata].nil? || candidate[:metadata].is_a?(Hash)
|
|
234
|
+
raise ArgumentError, "foil must be an object" unless candidate[:foil].is_a?(Hash)
|
|
235
|
+
verdict = candidate[:foil][:verdict]
|
|
236
|
+
raise ArgumentError, "foil.verdict is invalid" unless %w[bot human inconclusive].include?(verdict)
|
|
237
|
+
score = candidate[:foil][:score]
|
|
238
|
+
raise ArgumentError, "foil.score must be a number or null" unless score.nil? || score.is_a?(Numeric)
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
service_id: candidate[:service_id],
|
|
242
|
+
gate_session_id: candidate[:gate_session_id],
|
|
243
|
+
gate_account_id: candidate[:gate_account_id],
|
|
244
|
+
account_name: candidate[:account_name],
|
|
245
|
+
metadata: candidate[:metadata]&.dup,
|
|
246
|
+
foil: {
|
|
247
|
+
verdict: verdict,
|
|
248
|
+
score: score
|
|
249
|
+
},
|
|
250
|
+
delivery: validate_gate_delivery_request(candidate[:delivery])
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def verify_gate_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
255
|
+
parsed_timestamp = Integer(timestamp)
|
|
256
|
+
current = now_seconds || Time.now.to_i
|
|
257
|
+
return false if (current - parsed_timestamp).abs > max_age_seconds
|
|
258
|
+
|
|
259
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
260
|
+
secure_compare(expected, signature)
|
|
261
|
+
rescue ArgumentError
|
|
262
|
+
false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def parse_webhook_event(raw_body)
|
|
266
|
+
envelope = symbolize(JSON.parse(raw_body))
|
|
267
|
+
raise ArgumentError, "webhook event object must be webhook_event" unless envelope[:object] == "webhook_event"
|
|
268
|
+
raise ArgumentError, "webhook event id is required" if envelope[:id].to_s.empty?
|
|
269
|
+
raise ArgumentError, "webhook event type is required" if envelope[:type].to_s.empty?
|
|
270
|
+
raise ArgumentError, "unsupported webhook event type: #{envelope[:type]}" unless WEBHOOK_EVENT_TYPES.include?(envelope[:type])
|
|
271
|
+
raise ArgumentError, "webhook event created timestamp is required" if envelope[:created].to_s.empty?
|
|
272
|
+
raise ArgumentError, "webhook event data must be an object" unless envelope[:data].is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
envelope[:data] = validate_gate_approved_webhook_payload(envelope[:data]) if envelope[:type] == "gate.session.approved"
|
|
275
|
+
envelope
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def verify_and_parse_webhook_event(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
279
|
+
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)
|
|
280
|
+
raise ArgumentError, "Invalid Foil webhook signature"
|
|
281
|
+
end
|
|
282
|
+
parse_webhook_event(raw_body)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def symbolize(value)
|
|
286
|
+
case value
|
|
287
|
+
when Array
|
|
288
|
+
value.map { |item| symbolize(item) }
|
|
289
|
+
when Hash
|
|
290
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
291
|
+
memo[key.to_sym] = symbolize(item)
|
|
292
|
+
end
|
|
293
|
+
else
|
|
294
|
+
value
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
private_class_method :symbolize
|
|
298
|
+
|
|
299
|
+
def compact_payload(payload)
|
|
300
|
+
{
|
|
301
|
+
version: payload[:version],
|
|
302
|
+
outputs: payload[:outputs],
|
|
303
|
+
**(payload[:ack_token] ? { ack_token: payload[:ack_token] } : {})
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
private_class_method :compact_payload
|
|
307
|
+
|
|
308
|
+
def b64url_decode(value, label)
|
|
309
|
+
Base64.urlsafe_decode64(value.to_s)
|
|
310
|
+
rescue ArgumentError => error
|
|
311
|
+
raise ArgumentError, "Invalid #{label}: #{error.message}"
|
|
312
|
+
end
|
|
313
|
+
private_class_method :b64url_decode
|
|
314
|
+
|
|
315
|
+
def secure_compare(left, right)
|
|
316
|
+
return false unless left.bytesize == right.bytesize
|
|
317
|
+
|
|
318
|
+
result = 0
|
|
319
|
+
left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
|
|
320
|
+
result.zero?
|
|
321
|
+
end
|
|
322
|
+
private_class_method :secure_compare
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module Foil
|
|
8
|
+
module Server
|
|
9
|
+
module SealedToken
|
|
10
|
+
VERSION = 0x01
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def verify_foil_token(sealed_token, secret_key = nil)
|
|
15
|
+
CryptoSupport.ensure_supported_runtime!
|
|
16
|
+
resolved_secret = secret_key || ENV["FOIL_SECRET_KEY"]
|
|
17
|
+
raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
|
|
18
|
+
|
|
19
|
+
raw = Base64.decode64(sealed_token)
|
|
20
|
+
raise TokenVerificationError, "Foil token is too short." if raw.bytesize < 29
|
|
21
|
+
|
|
22
|
+
version = raw.getbyte(0)
|
|
23
|
+
raise TokenVerificationError, "Unsupported Foil token version: #{version}" if version != VERSION
|
|
24
|
+
|
|
25
|
+
nonce = raw.byteslice(1, 12)
|
|
26
|
+
ciphertext = raw.byteslice(13, raw.bytesize - 29)
|
|
27
|
+
tag = raw.byteslice(raw.bytesize - 16, 16)
|
|
28
|
+
|
|
29
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
30
|
+
cipher.decrypt
|
|
31
|
+
cipher.key = derive_key(resolved_secret)
|
|
32
|
+
cipher.iv = nonce
|
|
33
|
+
cipher.auth_tag = tag
|
|
34
|
+
cipher.auth_data = ""
|
|
35
|
+
|
|
36
|
+
compressed = cipher.update(ciphertext) + cipher.final
|
|
37
|
+
payload = JSON.parse(Zlib::Inflate.inflate(compressed))
|
|
38
|
+
deep_symbolize(payload)
|
|
39
|
+
rescue ConfigurationError, TokenVerificationError
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => error
|
|
42
|
+
raise TokenVerificationError, "Failed to verify Foil token: #{error.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def safe_verify_foil_token(sealed_token, secret_key = nil)
|
|
46
|
+
{ ok: true, data: verify_foil_token(sealed_token, secret_key) }
|
|
47
|
+
rescue ConfigurationError, TokenVerificationError => error
|
|
48
|
+
{ ok: false, error: error }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def derive_key(secret_key_or_hash)
|
|
52
|
+
Digest::SHA256.digest("#{normalize_secret(secret_key_or_hash)}\0sealed-results")
|
|
53
|
+
end
|
|
54
|
+
private_class_method :derive_key
|
|
55
|
+
|
|
56
|
+
def normalize_secret(secret_key_or_hash)
|
|
57
|
+
return secret_key_or_hash.downcase if /\A[0-9a-fA-F]{64}\z/.match?(secret_key_or_hash)
|
|
58
|
+
|
|
59
|
+
Digest::SHA256.hexdigest(secret_key_or_hash)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :normalize_secret
|
|
62
|
+
|
|
63
|
+
def deep_symbolize(value)
|
|
64
|
+
case value
|
|
65
|
+
when Array
|
|
66
|
+
value.map { |item| deep_symbolize(item) }
|
|
67
|
+
when Hash
|
|
68
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
69
|
+
memo[key.to_sym] = deep_symbolize(item)
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
private_class_method :deep_symbolize
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/foil/server.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative "server/version"
|
|
2
|
+
require_relative "server/errors"
|
|
3
|
+
require_relative "server/crypto_support"
|
|
4
|
+
require_relative "server/types"
|
|
5
|
+
require_relative "server/sealed_token"
|
|
6
|
+
require_relative "server/gate_delivery"
|
|
7
|
+
require_relative "server/client"
|
|
8
|
+
|
|
9
|
+
module Foil
|
|
10
|
+
module Server
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def verify_foil_token(sealed_token, secret_key = nil)
|
|
14
|
+
SealedToken.verify_foil_token(sealed_token, secret_key)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def safe_verify_foil_token(sealed_token, secret_key = nil)
|
|
18
|
+
SealedToken.safe_verify_foil_token(sealed_token, secret_key)
|
|
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
|
|
30
|
+
end
|
|
31
|
+
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,160 @@
|
|
|
1
|
+
# Server SDK Spec
|
|
2
|
+
|
|
3
|
+
This directory is the authoritative cross-language contract for Foil server SDKs.
|
|
4
|
+
|
|
5
|
+
It defines:
|
|
6
|
+
|
|
7
|
+
- the supported server API surface
|
|
8
|
+
- the shared sealed token verification behavior
|
|
9
|
+
- the shared Gate delivery/webhook helper behavior
|
|
10
|
+
- golden fixtures for success, error, pagination, and helper flows
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
Server SDKs include only customer-facing APIs:
|
|
15
|
+
|
|
16
|
+
- `/v1/sessions`
|
|
17
|
+
- `/v1/fingerprints`
|
|
18
|
+
- `/v1/organizations`
|
|
19
|
+
- `/v1/organizations/:organizationId/api-keys`
|
|
20
|
+
- `/v1/gate/registry`
|
|
21
|
+
- `/v1/gate/services`
|
|
22
|
+
- `/v1/gate/sessions`
|
|
23
|
+
- `/v1/gate/login-sessions`
|
|
24
|
+
- `/v1/gate/agent-tokens/*`
|
|
25
|
+
|
|
26
|
+
Server SDKs do **not** include:
|
|
27
|
+
|
|
28
|
+
- collect endpoints
|
|
29
|
+
- internal scoring APIs
|
|
30
|
+
- dashboard/internal APIs
|
|
31
|
+
- framework adapters
|
|
32
|
+
- retry middleware
|
|
33
|
+
- policy helpers like `shouldBlock`
|
|
34
|
+
|
|
35
|
+
## Required Namespaces
|
|
36
|
+
|
|
37
|
+
Every server SDK should expose these top-level capabilities:
|
|
38
|
+
|
|
39
|
+
- Sessions
|
|
40
|
+
- list
|
|
41
|
+
- get
|
|
42
|
+
- iterator / auto-pagination helper
|
|
43
|
+
- Fingerprints
|
|
44
|
+
- list
|
|
45
|
+
- get
|
|
46
|
+
- iterator / auto-pagination helper
|
|
47
|
+
- Organizations
|
|
48
|
+
- create
|
|
49
|
+
- get
|
|
50
|
+
- update
|
|
51
|
+
- Organization API keys
|
|
52
|
+
- create
|
|
53
|
+
- list
|
|
54
|
+
- revoke
|
|
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
|
|
62
|
+
- sealed token helpers
|
|
63
|
+
- strict verify
|
|
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
|
|
74
|
+
|
|
75
|
+
## Shared Defaults
|
|
76
|
+
|
|
77
|
+
Every SDK should default to:
|
|
78
|
+
|
|
79
|
+
- `base_url = https://api.usefoil.com`
|
|
80
|
+
- `secret_key = env(FOIL_SECRET_KEY)`
|
|
81
|
+
- support for public, bearer-token, and secret-key auth as required by the Gate surface
|
|
82
|
+
- request timeout support
|
|
83
|
+
- no automatic retries
|
|
84
|
+
|
|
85
|
+
## Pagination Normalization
|
|
86
|
+
|
|
87
|
+
List APIs should normalize cursor pagination into these fields using each language's native style:
|
|
88
|
+
|
|
89
|
+
- `items`
|
|
90
|
+
- `limit`
|
|
91
|
+
- `has_more`
|
|
92
|
+
- `next_cursor`
|
|
93
|
+
|
|
94
|
+
The underlying API responses remain cursor-based. SDKs may expose helper iterators/enumerators/page walkers on top.
|
|
95
|
+
|
|
96
|
+
## Error Model
|
|
97
|
+
|
|
98
|
+
SDKs should parse API failures into structured errors with, at minimum:
|
|
99
|
+
|
|
100
|
+
- `status`
|
|
101
|
+
- `code`
|
|
102
|
+
- `message`
|
|
103
|
+
- `request_id`
|
|
104
|
+
- `field_errors`
|
|
105
|
+
- `docs_url`
|
|
106
|
+
- parsed raw body
|
|
107
|
+
|
|
108
|
+
Use the fixtures in `fixtures/errors/` as the source of truth for error-shape behavior.
|
|
109
|
+
|
|
110
|
+
## Sealed Token Verification
|
|
111
|
+
|
|
112
|
+
`sealed-token.md` is the cross-language source of truth for verification behavior.
|
|
113
|
+
|
|
114
|
+
SDKs must implement verification natively in their own language runtime. They should not depend on another SDK implementation.
|
|
115
|
+
|
|
116
|
+
Use both:
|
|
117
|
+
|
|
118
|
+
- `fixtures/sealed-token/vector.v1.json`
|
|
119
|
+
- `fixtures/sealed-token/invalid.json`
|
|
120
|
+
|
|
121
|
+
to validate correctness and failure behavior.
|
|
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
|
+
|
|
133
|
+
## Sync Model
|
|
134
|
+
|
|
135
|
+
This repo is the source of truth for the shared server SDK contract.
|
|
136
|
+
|
|
137
|
+
Each language SDK repo carries a synced copy of this repository in `spec/`, and the Foil monorepo vendors this repository as a submodule at `sdk-spec/server`.
|
|
138
|
+
|
|
139
|
+
Keep the synced copies and the monorepo submodule pointer current before advancing them.
|
|
140
|
+
|
|
141
|
+
## SDK Authoring Checklist
|
|
142
|
+
|
|
143
|
+
When changing any server SDK:
|
|
144
|
+
|
|
145
|
+
- sync `spec/` before changing SDK code or tests
|
|
146
|
+
- do not expose collect or internal-only endpoints
|
|
147
|
+
- preserve the shared defaults:
|
|
148
|
+
- env-based secret key fallback
|
|
149
|
+
- `https://api.usefoil.com`
|
|
150
|
+
- request timeout support
|
|
151
|
+
- no automatic retries
|
|
152
|
+
- preserve pagination normalization:
|
|
153
|
+
- `items`
|
|
154
|
+
- `limit`
|
|
155
|
+
- `has_more`
|
|
156
|
+
- `next_cursor`
|
|
157
|
+
- preserve structured API errors
|
|
158
|
+
- keep sealed token golden-vector coverage
|
|
159
|
+
- keep one live smoke suite per SDK
|
|
160
|
+
- only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
|