foil-server 0.3.3 → 0.3.5
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 +2 -0
- data/lib/foil/server/client.rb +12 -0
- data/lib/foil/server/gate_delivery.rb +9 -2
- data/lib/foil/server/sealed_token.rb +83 -16
- data/lib/foil/server/version.rb +1 -1
- data/spec/fixtures/api/sessions/detail.json +1 -0
- data/spec/fixtures/api/sessions/list.json +1 -0
- data/spec/fixtures/manifest.json +6 -0
- data/spec/fixtures/sealed-token/vector.v2.json +59 -0
- data/spec/openapi.json +141 -4
- data/spec/sealed-token.md +47 -18
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d41b78e23e89f77b68f871a2454fb7d3ee87828a66e76fc95c6baca12127ff2
|
|
4
|
+
data.tar.gz: ba257476579665aa554305f16a98793b5a9b98726d90784c5d5ae4b643ba04e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a979d1b334c0c7331fcd3f174b3ddece6f371d200ee7cd4ffe4287b8e651cadada1ae15cc495b14c849e290de9384ab53cc826c80e5d5e49b4ae56fde1659d3
|
|
7
|
+
data.tar.gz: 70a38a2b6466c9e184b67629e9d45cdce7d620e032737d8c5cad9d6c96eba590c7a58b80ed22573b976ae1fb2ebad215e70f9edfea8cee6f0f9bf0eb65784f85
|
data/README.md
CHANGED
|
@@ -42,6 +42,8 @@ client = Foil::Server::Client.new(secret_key: "sk_live_...")
|
|
|
42
42
|
|
|
43
43
|
page = client.sessions.list(verdict: "bot", limit: 25)
|
|
44
44
|
session = client.sessions.get("sid_0123456789abcdefghjkmnpqrs")
|
|
45
|
+
client.sessions.attach_client_user("sid_0123456789abcdefghjkmnpqrs", "user_123")
|
|
46
|
+
client.sessions.clear_client_user("sid_0123456789abcdefghjkmnpqrs")
|
|
45
47
|
|
|
46
48
|
puts "#{session[:decision][:automation_status]} #{session[:highlights].first&.fetch(:summary, nil)}"
|
|
47
49
|
```
|
data/lib/foil/server/client.rb
CHANGED
|
@@ -177,6 +177,18 @@ module Foil
|
|
|
177
177
|
@client.request_json("GET", "/v1/sessions/#{CGI.escape(session_id)}")[:data]
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
def attach_client_user(session_id, client_user_id)
|
|
181
|
+
@client.request_json("PATCH", "/v1/sessions/#{CGI.escape(session_id)}", body: {
|
|
182
|
+
client_user_id: client_user_id
|
|
183
|
+
})[:data]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def clear_client_user(session_id)
|
|
187
|
+
@client.request_json("PATCH", "/v1/sessions/#{CGI.escape(session_id)}", body: {
|
|
188
|
+
client_user_id: nil
|
|
189
|
+
})[:data]
|
|
190
|
+
end
|
|
191
|
+
|
|
180
192
|
def iter(limit: nil, verdict: nil, search: nil)
|
|
181
193
|
Enumerator.new do |yielder|
|
|
182
194
|
cursor = nil
|
|
@@ -69,6 +69,13 @@ module Foil
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
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
|
+
|
|
72
79
|
der = public_key.public_to_der
|
|
73
80
|
raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
|
|
74
81
|
raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
|
|
@@ -85,7 +92,7 @@ module Foil
|
|
|
85
92
|
def create_delivery_key_pair
|
|
86
93
|
CryptoSupport.ensure_supported_runtime!
|
|
87
94
|
private_key = OpenSSL::PKey.generate_key("X25519")
|
|
88
|
-
raw_public_key = raw_x25519_public_key_from_key_object(private_key
|
|
95
|
+
raw_public_key = raw_x25519_public_key_from_key_object(private_key)
|
|
89
96
|
{
|
|
90
97
|
delivery: {
|
|
91
98
|
version: GATE_DELIVERY_VERSION,
|
|
@@ -181,7 +188,7 @@ module Foil
|
|
|
181
188
|
version: GATE_DELIVERY_VERSION,
|
|
182
189
|
algorithm: GATE_DELIVERY_ALGORITHM,
|
|
183
190
|
key_id: validated_delivery[:key_id],
|
|
184
|
-
ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key
|
|
191
|
+
ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key), padding: false),
|
|
185
192
|
salt: Base64.urlsafe_encode64(salt, padding: false),
|
|
186
193
|
iv: Base64.urlsafe_encode64(iv, padding: false),
|
|
187
194
|
ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
|
|
@@ -7,7 +7,17 @@ require "zlib"
|
|
|
7
7
|
module Foil
|
|
8
8
|
module Server
|
|
9
9
|
module SealedToken
|
|
10
|
-
|
|
10
|
+
LEGACY_VERSION = 0x01
|
|
11
|
+
MULTI_RECIPIENT_VERSION = 0x02
|
|
12
|
+
NONCE_BYTES = 12
|
|
13
|
+
TAG_BYTES = 16
|
|
14
|
+
CONTENT_KEY_BYTES = 32
|
|
15
|
+
RECIPIENT_ID_BYTES = 32
|
|
16
|
+
MAX_RECIPIENTS = 256
|
|
17
|
+
V2_HEADER_BYTES = 1 + 2 + NONCE_BYTES + 4
|
|
18
|
+
V2_RECIPIENT_BYTES = RECIPIENT_ID_BYTES + NONCE_BYTES + CONTENT_KEY_BYTES + TAG_BYTES
|
|
19
|
+
V2_PAYLOAD_AAD_PREFIX = "foil-sealed-results-v2\0payload\0".b
|
|
20
|
+
V2_WRAP_AAD_PREFIX = "foil-sealed-results-v2\0recipient\0".b
|
|
11
21
|
|
|
12
22
|
module_function
|
|
13
23
|
|
|
@@ -19,21 +29,7 @@ module Foil
|
|
|
19
29
|
raw = Base64.decode64(sealed_token)
|
|
20
30
|
raise TokenVerificationError, "Foil token is too short." if raw.bytesize < 29
|
|
21
31
|
|
|
22
|
-
|
|
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
|
|
32
|
+
compressed = decrypt_payload(raw, resolved_secret)
|
|
37
33
|
payload = JSON.parse(Zlib::Inflate.inflate(compressed))
|
|
38
34
|
deep_symbolize(payload)
|
|
39
35
|
rescue ConfigurationError, TokenVerificationError
|
|
@@ -53,6 +49,77 @@ module Foil
|
|
|
53
49
|
end
|
|
54
50
|
private_class_method :derive_key
|
|
55
51
|
|
|
52
|
+
def decrypt_gcm(ciphertext, key, nonce, tag, aad = "".b)
|
|
53
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
54
|
+
cipher.decrypt
|
|
55
|
+
cipher.key = key
|
|
56
|
+
cipher.iv = nonce
|
|
57
|
+
cipher.auth_tag = tag
|
|
58
|
+
cipher.auth_data = aad
|
|
59
|
+
cipher.update(ciphertext) + cipher.final
|
|
60
|
+
end
|
|
61
|
+
private_class_method :decrypt_gcm
|
|
62
|
+
|
|
63
|
+
def decrypt_payload(raw, secret_key)
|
|
64
|
+
version = raw.getbyte(0)
|
|
65
|
+
if version == LEGACY_VERSION
|
|
66
|
+
return decrypt_gcm(
|
|
67
|
+
raw.byteslice(13, raw.bytesize - 29),
|
|
68
|
+
derive_key(secret_key),
|
|
69
|
+
raw.byteslice(1, NONCE_BYTES),
|
|
70
|
+
raw.byteslice(raw.bytesize - TAG_BYTES, TAG_BYTES)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
raise TokenVerificationError, "Unsupported Foil token version: #{version}" unless version == MULTI_RECIPIENT_VERSION
|
|
74
|
+
raise TokenVerificationError, "Foil token is too short." if raw.bytesize < V2_HEADER_BYTES + TAG_BYTES + V2_RECIPIENT_BYTES
|
|
75
|
+
|
|
76
|
+
recipient_count = raw.byteslice(1, 2).unpack1("n")
|
|
77
|
+
raise TokenVerificationError, "Foil token has an invalid recipient count." unless recipient_count.between?(1, MAX_RECIPIENTS)
|
|
78
|
+
|
|
79
|
+
payload_length = raw.byteslice(15, 4).unpack1("N")
|
|
80
|
+
payload_start = V2_HEADER_BYTES
|
|
81
|
+
payload_tag_start = payload_start + payload_length
|
|
82
|
+
recipients_start = payload_tag_start + TAG_BYTES
|
|
83
|
+
expected_length = recipients_start + (recipient_count * V2_RECIPIENT_BYTES)
|
|
84
|
+
raise TokenVerificationError, "Foil token has an invalid length." if payload_length < 1 || expected_length != raw.bytesize
|
|
85
|
+
|
|
86
|
+
expected_id = Digest::SHA256.digest("#{normalize_secret(secret_key)}\0sealed-results-recipient-id")
|
|
87
|
+
recipient_ids = recipient_count.times.map do |index|
|
|
88
|
+
raw.byteslice(
|
|
89
|
+
recipients_start + (index * V2_RECIPIENT_BYTES),
|
|
90
|
+
RECIPIENT_ID_BYTES
|
|
91
|
+
)
|
|
92
|
+
end.join
|
|
93
|
+
content_key = nil
|
|
94
|
+
recipient_count.times do |index|
|
|
95
|
+
entry_start = recipients_start + (index * V2_RECIPIENT_BYTES)
|
|
96
|
+
recipient_id = raw.byteslice(entry_start, RECIPIENT_ID_BYTES)
|
|
97
|
+
next unless OpenSSL.fixed_length_secure_compare(recipient_id, expected_id)
|
|
98
|
+
|
|
99
|
+
nonce_start = entry_start + RECIPIENT_ID_BYTES
|
|
100
|
+
wrapped_key_start = nonce_start + NONCE_BYTES
|
|
101
|
+
tag_start = wrapped_key_start + CONTENT_KEY_BYTES
|
|
102
|
+
content_key = decrypt_gcm(
|
|
103
|
+
raw.byteslice(wrapped_key_start, CONTENT_KEY_BYTES),
|
|
104
|
+
derive_key(secret_key),
|
|
105
|
+
raw.byteslice(nonce_start, NONCE_BYTES),
|
|
106
|
+
raw.byteslice(tag_start, TAG_BYTES),
|
|
107
|
+
V2_WRAP_AAD_PREFIX + recipient_id
|
|
108
|
+
)
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
raise TokenVerificationError, "Secret key is not a recipient of this Foil token." unless content_key&.bytesize == CONTENT_KEY_BYTES
|
|
112
|
+
|
|
113
|
+
decrypt_gcm(
|
|
114
|
+
raw.byteslice(payload_start, payload_length),
|
|
115
|
+
content_key,
|
|
116
|
+
raw.byteslice(3, NONCE_BYTES),
|
|
117
|
+
raw.byteslice(payload_tag_start, TAG_BYTES),
|
|
118
|
+
V2_PAYLOAD_AAD_PREFIX + raw.byteslice(0, V2_HEADER_BYTES) + recipient_ids
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
private_class_method :decrypt_payload
|
|
122
|
+
|
|
56
123
|
def normalize_secret(secret_key_or_hash)
|
|
57
124
|
return secret_key_or_hash.downcase if /\A[0-9a-fA-F]{64}\z/.match?(secret_key_or_hash)
|
|
58
125
|
|
data/lib/foil/server/version.rb
CHANGED
data/spec/fixtures/manifest.json
CHANGED
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
"method": "get",
|
|
37
37
|
"status": "200"
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
"file": "api/sessions/detail.json",
|
|
41
|
+
"path": "/v1/sessions/{sessionId}",
|
|
42
|
+
"method": "patch",
|
|
43
|
+
"status": "200"
|
|
44
|
+
},
|
|
39
45
|
{
|
|
40
46
|
"file": "api/fingerprints/list.json",
|
|
41
47
|
"path": "/v1/fingerprints",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"secretKeys": [
|
|
3
|
+
"sk_live_fixture_secret",
|
|
4
|
+
"sk_live_fixture_secondary"
|
|
5
|
+
],
|
|
6
|
+
"secretHashes": [
|
|
7
|
+
"c173e2c1467e446744e3b6aba8fc07e639008fdf3c4b82e32dceef9b41217dc3",
|
|
8
|
+
"7b7f1904e9f78f93fdaf5cb739b84a549f379774dce8e48d2e7318a38a71619d"
|
|
9
|
+
],
|
|
10
|
+
"token": "AgACkGnTY6/gdAPOSejTAAABueItTz8WCgMBzOUeaG8XiLTGYNZ6/n+XKodAEavsB4nr/HXdLMPNin6k99VSMt4pm7xQDgq00NHf4pcUX98bVtrD9McjaPVhN0PQOcbwBncRqEXPQDLvdevFp6SvNcThP7F0LNnCN4WikARAYD/5hoJcfi3qpehcDRxIkFWXy9Rn5D7WnSAY1Ef2/2v3wT/aP2D89xjs9xIGmavfGNJaUq3eXRMdddQYaB1V5wtmRBZIkgePubsbcCptBtgma3ZObp5A8cdb9PQFvNxBR/B2ttKupMQ/uiVN05/RNPHziKm+aada8+gPwRJR8PDNbNy/TEA8L82iB8LgCMmPnqyVefqm+VoNYa/AmDefcWrBogQ7lIB49KezbRiXIbZ5RiAPq2VgqUTHq2cRAN6BprH89g2FajBs5I7wj7nTQwtPu74FRu3BRHF9k8Vgf4A4jdocn4sU/GCH5Gm1x7d1Rxd8T4OkyQtJ1YaRLwksf/lkttqoOD+fZ3twJxXtYKVKt0wmavVlMUl5+IP3Yrl8SdfmGENMyfkjjuhfPq/TpA00PDoB4RXf2MAh6fTVF+sVw7vXndQ8tYMLFjBgLx2y++W6LT+glvHhROf3LWnk3EgrhhB6FxEpmfuogp2y3pZ8fkMaKplOFFfw0AnVDRbCqx+0u6P9JiQmV0hUl4LHn7rqPNPbtv7adX778RptvMjWG87rwFiU/whhVWbIoF4P0W8081lVDTEe9V3LRNd9t7U9Ca/pIowEzXe5X5rWrlqm/CtCYLzT6apTMLXuacGYBJY7VfY+8uXG0WymZ8GfcMUrFpvF8bwPMs0nPaq3i1Jm+zVy9QMz9pl6xTKwN2CwfIFeHOp8",
|
|
11
|
+
"payload": {
|
|
12
|
+
"object": "session_verification",
|
|
13
|
+
"session_id": "sid_0123456789abcdefghjkmnpqrs",
|
|
14
|
+
"decision": {
|
|
15
|
+
"event_id": "evt_0123456789abcdefghjkmnpqrs",
|
|
16
|
+
"verdict": "human",
|
|
17
|
+
"risk_score": 7,
|
|
18
|
+
"phase": "behavioral",
|
|
19
|
+
"is_provisional": false,
|
|
20
|
+
"manipulation": {
|
|
21
|
+
"score": 0,
|
|
22
|
+
"verdict": "none"
|
|
23
|
+
},
|
|
24
|
+
"evaluation_duration_ms": 123,
|
|
25
|
+
"evaluated_at": "2026-03-24T20:00:00.000Z"
|
|
26
|
+
},
|
|
27
|
+
"request": {
|
|
28
|
+
"user_agent": "Mozilla/5.0",
|
|
29
|
+
"url": "https://example.com/signup",
|
|
30
|
+
"screen_size": "1440x900",
|
|
31
|
+
"is_touch_capable": false,
|
|
32
|
+
"ip_address": "203.0.113.9"
|
|
33
|
+
},
|
|
34
|
+
"visitor_fingerprint": {
|
|
35
|
+
"object": "visitor_fingerprint",
|
|
36
|
+
"id": "vid_0123456789abcdefghjkmnpqrs",
|
|
37
|
+
"confidence": 94,
|
|
38
|
+
"identified_at": "2026-03-24T19:59:59.000Z"
|
|
39
|
+
},
|
|
40
|
+
"signals": [
|
|
41
|
+
{
|
|
42
|
+
"id": "E6",
|
|
43
|
+
"category": "environment",
|
|
44
|
+
"confidence": "strong",
|
|
45
|
+
"score": 20
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"score_breakdown": {
|
|
49
|
+
"categories": {
|
|
50
|
+
"behavioral": 9,
|
|
51
|
+
"environment": 20
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"attribution": {
|
|
55
|
+
"bot": null
|
|
56
|
+
},
|
|
57
|
+
"embed": null
|
|
58
|
+
}
|
|
59
|
+
}
|
data/spec/openapi.json
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"tags": [
|
|
15
15
|
{
|
|
16
16
|
"name": "Sessions",
|
|
17
|
-
"description": "
|
|
17
|
+
"description": "Session readback endpoints."
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"name": "Visitor fingerprints",
|
|
21
|
-
"description": "
|
|
21
|
+
"description": "Visitor fingerprint readback endpoints."
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"name": "Organizations",
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"object": "session",
|
|
104
104
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
105
105
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
106
|
+
"client_user_id": "user_123",
|
|
106
107
|
"latest_decision": {
|
|
107
108
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
108
109
|
"verdict": "human",
|
|
@@ -279,6 +280,7 @@
|
|
|
279
280
|
"object": "session",
|
|
280
281
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
281
282
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
283
|
+
"client_user_id": "user_123",
|
|
282
284
|
"decision": {
|
|
283
285
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
284
286
|
"automation_status": "automated",
|
|
@@ -908,6 +910,87 @@
|
|
|
908
910
|
}
|
|
909
911
|
},
|
|
910
912
|
"description": "Requires the `sessions:read` secret-key scope."
|
|
913
|
+
},
|
|
914
|
+
"patch": {
|
|
915
|
+
"tags": [
|
|
916
|
+
"Sessions"
|
|
917
|
+
],
|
|
918
|
+
"operationId": "updateSession",
|
|
919
|
+
"summary": "Update a session",
|
|
920
|
+
"parameters": [
|
|
921
|
+
{
|
|
922
|
+
"name": "sessionId",
|
|
923
|
+
"in": "path",
|
|
924
|
+
"required": true,
|
|
925
|
+
"schema": {
|
|
926
|
+
"$ref": "#/components/schemas/SessionId",
|
|
927
|
+
"example": "sid_87wfetm98myh9awj0dpt28a3kc"
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
],
|
|
931
|
+
"requestBody": {
|
|
932
|
+
"required": true,
|
|
933
|
+
"content": {
|
|
934
|
+
"application/json": {
|
|
935
|
+
"schema": {
|
|
936
|
+
"$ref": "#/components/schemas/UpdateSessionRequest"
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
"responses": {
|
|
942
|
+
"200": {
|
|
943
|
+
"description": "Updated session detail response.",
|
|
944
|
+
"content": {
|
|
945
|
+
"application/json": {
|
|
946
|
+
"schema": {
|
|
947
|
+
"$ref": "#/components/schemas/SessionDetailResponse"
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
"401": {
|
|
953
|
+
"description": "Missing or invalid API key.",
|
|
954
|
+
"content": {
|
|
955
|
+
"application/json": {
|
|
956
|
+
"schema": {
|
|
957
|
+
"$ref": "#/components/schemas/ApiErrorEnvelope"
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
"403": {
|
|
963
|
+
"description": "Secret key missing the required `sessions:update` scope.",
|
|
964
|
+
"content": {
|
|
965
|
+
"application/json": {
|
|
966
|
+
"schema": {
|
|
967
|
+
"$ref": "#/components/schemas/ApiErrorEnvelope"
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
"404": {
|
|
973
|
+
"description": "Session not found.",
|
|
974
|
+
"content": {
|
|
975
|
+
"application/json": {
|
|
976
|
+
"schema": {
|
|
977
|
+
"$ref": "#/components/schemas/ApiErrorEnvelope"
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
"422": {
|
|
983
|
+
"description": "Invalid update payload.",
|
|
984
|
+
"content": {
|
|
985
|
+
"application/json": {
|
|
986
|
+
"schema": {
|
|
987
|
+
"$ref": "#/components/schemas/ApiErrorEnvelope"
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
"description": "Set or clear the customer-supplied `client_user_id` for a session. Requires the `sessions:update` secret-key scope."
|
|
911
994
|
}
|
|
912
995
|
},
|
|
913
996
|
"/v1/fingerprints": {
|
|
@@ -2139,6 +2222,7 @@
|
|
|
2139
2222
|
"environment": "live",
|
|
2140
2223
|
"scopes": [
|
|
2141
2224
|
"sessions:read",
|
|
2225
|
+
"sessions:update",
|
|
2142
2226
|
"fingerprints:read"
|
|
2143
2227
|
]
|
|
2144
2228
|
}
|
|
@@ -7292,6 +7376,7 @@
|
|
|
7292
7376
|
"*",
|
|
7293
7377
|
"sessions:list",
|
|
7294
7378
|
"sessions:read",
|
|
7379
|
+
"sessions:update",
|
|
7295
7380
|
"fingerprints:list",
|
|
7296
7381
|
"fingerprints:read",
|
|
7297
7382
|
"organizations:create",
|
|
@@ -7674,6 +7759,7 @@
|
|
|
7674
7759
|
"*",
|
|
7675
7760
|
"sessions:list",
|
|
7676
7761
|
"sessions:read",
|
|
7762
|
+
"sessions:update",
|
|
7677
7763
|
"fingerprints:list",
|
|
7678
7764
|
"fingerprints:read",
|
|
7679
7765
|
"organizations:create",
|
|
@@ -7703,6 +7789,7 @@
|
|
|
7703
7789
|
"environment": "live",
|
|
7704
7790
|
"scopes": [
|
|
7705
7791
|
"sessions:read",
|
|
7792
|
+
"sessions:update",
|
|
7706
7793
|
"fingerprints:read"
|
|
7707
7794
|
]
|
|
7708
7795
|
},
|
|
@@ -10343,6 +10430,7 @@
|
|
|
10343
10430
|
"*",
|
|
10344
10431
|
"sessions:list",
|
|
10345
10432
|
"sessions:read",
|
|
10433
|
+
"sessions:update",
|
|
10346
10434
|
"fingerprints:list",
|
|
10347
10435
|
"fingerprints:read",
|
|
10348
10436
|
"organizations:create",
|
|
@@ -11725,7 +11813,8 @@
|
|
|
11725
11813
|
"device",
|
|
11726
11814
|
"analysis_coverage",
|
|
11727
11815
|
"signals_fired",
|
|
11728
|
-
"client_telemetry"
|
|
11816
|
+
"client_telemetry",
|
|
11817
|
+
"client_user_id"
|
|
11729
11818
|
],
|
|
11730
11819
|
"properties": {
|
|
11731
11820
|
"object": {
|
|
@@ -11744,6 +11833,15 @@
|
|
|
11744
11833
|
"format": "date-time",
|
|
11745
11834
|
"example": "2026-03-24T20:00:00.000Z"
|
|
11746
11835
|
},
|
|
11836
|
+
"client_user_id": {
|
|
11837
|
+
"type": [
|
|
11838
|
+
"string",
|
|
11839
|
+
"null"
|
|
11840
|
+
],
|
|
11841
|
+
"maxLength": 256,
|
|
11842
|
+
"description": "Customer-supplied identifier for the end user associated with this Foil session. Set with PATCH /v1/sessions/{sessionId}.",
|
|
11843
|
+
"example": "user_123"
|
|
11844
|
+
},
|
|
11747
11845
|
"decision": {
|
|
11748
11846
|
"$ref": "#/components/schemas/SessionDetailDecision",
|
|
11749
11847
|
"example": {
|
|
@@ -12647,6 +12745,7 @@
|
|
|
12647
12745
|
"object": "session",
|
|
12648
12746
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
12649
12747
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
12748
|
+
"client_user_id": "user_123",
|
|
12650
12749
|
"decision": {
|
|
12651
12750
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
12652
12751
|
"automation_status": "automated",
|
|
@@ -13379,6 +13478,7 @@
|
|
|
13379
13478
|
"object": "session",
|
|
13380
13479
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
13381
13480
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
13481
|
+
"client_user_id": "user_123",
|
|
13382
13482
|
"decision": {
|
|
13383
13483
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
13384
13484
|
"automation_status": "automated",
|
|
@@ -13903,6 +14003,7 @@
|
|
|
13903
14003
|
"object": "session",
|
|
13904
14004
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
13905
14005
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
14006
|
+
"client_user_id": "user_123",
|
|
13906
14007
|
"decision": {
|
|
13907
14008
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
13908
14009
|
"automation_status": "automated",
|
|
@@ -15263,6 +15364,7 @@
|
|
|
15263
15364
|
"object": "session",
|
|
15264
15365
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
15265
15366
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
15367
|
+
"client_user_id": "user_123",
|
|
15266
15368
|
"latest_decision": {
|
|
15267
15369
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
15268
15370
|
"verdict": "human",
|
|
@@ -15289,6 +15391,7 @@
|
|
|
15289
15391
|
"object": "session",
|
|
15290
15392
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
15291
15393
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
15394
|
+
"client_user_id": "user_123",
|
|
15292
15395
|
"latest_decision": {
|
|
15293
15396
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
15294
15397
|
"verdict": "human",
|
|
@@ -15332,6 +15435,7 @@
|
|
|
15332
15435
|
"object": "session",
|
|
15333
15436
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
15334
15437
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
15438
|
+
"client_user_id": "user_123",
|
|
15335
15439
|
"latest_decision": {
|
|
15336
15440
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
15337
15441
|
"verdict": "human",
|
|
@@ -18169,7 +18273,8 @@
|
|
|
18169
18273
|
"id",
|
|
18170
18274
|
"created_at",
|
|
18171
18275
|
"latest_decision",
|
|
18172
|
-
"visitor_fingerprint"
|
|
18276
|
+
"visitor_fingerprint",
|
|
18277
|
+
"client_user_id"
|
|
18173
18278
|
],
|
|
18174
18279
|
"properties": {
|
|
18175
18280
|
"object": {
|
|
@@ -18188,6 +18293,15 @@
|
|
|
18188
18293
|
"format": "date-time",
|
|
18189
18294
|
"example": "2026-03-24T20:00:00.000Z"
|
|
18190
18295
|
},
|
|
18296
|
+
"client_user_id": {
|
|
18297
|
+
"type": [
|
|
18298
|
+
"string",
|
|
18299
|
+
"null"
|
|
18300
|
+
],
|
|
18301
|
+
"maxLength": 256,
|
|
18302
|
+
"description": "Customer-supplied identifier for the end user associated with this Foil session. Set with PATCH /v1/sessions/{sessionId}.",
|
|
18303
|
+
"example": "user_123"
|
|
18304
|
+
},
|
|
18191
18305
|
"latest_decision": {
|
|
18192
18306
|
"$ref": "#/components/schemas/Decision",
|
|
18193
18307
|
"example": {
|
|
@@ -18232,6 +18346,7 @@
|
|
|
18232
18346
|
"object": "session",
|
|
18233
18347
|
"id": "sid_87wfetm98myh9awj0dpt28a3kc",
|
|
18234
18348
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
18349
|
+
"client_user_id": "user_123",
|
|
18235
18350
|
"latest_decision": {
|
|
18236
18351
|
"event_id": "evt_srwqc0yqhz4bxvtrdtf69q7vkr",
|
|
18237
18352
|
"verdict": "human",
|
|
@@ -18314,6 +18429,7 @@
|
|
|
18314
18429
|
"*",
|
|
18315
18430
|
"sessions:list",
|
|
18316
18431
|
"sessions:read",
|
|
18432
|
+
"sessions:update",
|
|
18317
18433
|
"fingerprints:list",
|
|
18318
18434
|
"fingerprints:read",
|
|
18319
18435
|
"organizations:create",
|
|
@@ -18495,6 +18611,27 @@
|
|
|
18495
18611
|
"status": "active"
|
|
18496
18612
|
}
|
|
18497
18613
|
},
|
|
18614
|
+
"UpdateSessionRequest": {
|
|
18615
|
+
"type": "object",
|
|
18616
|
+
"additionalProperties": false,
|
|
18617
|
+
"required": [
|
|
18618
|
+
"client_user_id"
|
|
18619
|
+
],
|
|
18620
|
+
"properties": {
|
|
18621
|
+
"client_user_id": {
|
|
18622
|
+
"type": [
|
|
18623
|
+
"string",
|
|
18624
|
+
"null"
|
|
18625
|
+
],
|
|
18626
|
+
"maxLength": 256,
|
|
18627
|
+
"description": "Customer-supplied identifier for the end user associated with this Foil session. Set with PATCH /v1/sessions/{sessionId}.",
|
|
18628
|
+
"example": "user_123"
|
|
18629
|
+
}
|
|
18630
|
+
},
|
|
18631
|
+
"example": {
|
|
18632
|
+
"client_user_id": "user_123"
|
|
18633
|
+
}
|
|
18634
|
+
},
|
|
18498
18635
|
"UpdateWebhookEndpointRequest": {
|
|
18499
18636
|
"type": "object",
|
|
18500
18637
|
"additionalProperties": false,
|
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
|
|
14
|
+
## Payload formats
|
|
15
15
|
|
|
16
|
-
After base64 decoding, the byte
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
53
|
-
3. Normalize the caller's secret material
|
|
54
|
-
4.
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
- tag
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
version: 0.3.5
|
|
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-07-20 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.
|
|
@@ -68,6 +68,7 @@ files:
|
|
|
68
68
|
- spec/fixtures/manifest.json
|
|
69
69
|
- spec/fixtures/sealed-token/invalid.json
|
|
70
70
|
- spec/fixtures/sealed-token/vector.v1.json
|
|
71
|
+
- spec/fixtures/sealed-token/vector.v2.json
|
|
71
72
|
- spec/openapi.json
|
|
72
73
|
- spec/sealed-token.md
|
|
73
74
|
homepage: https://github.com/abxy-labs/foil-server-ruby
|