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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb419b8f30f0937555fa2b515c66f6bca1e8dc042ab72eee05396b53fe02d3a6
4
- data.tar.gz: 1545b9bd453185c2656ca627adbe26f6e130d7d5da29491e30e780887e27415a
3
+ metadata.gz: 5d41b78e23e89f77b68f871a2454fb7d3ee87828a66e76fc95c6baca12127ff2
4
+ data.tar.gz: ba257476579665aa554305f16a98793b5a9b98726d90784c5d5ae4b643ba04e2
5
5
  SHA512:
6
- metadata.gz: 216b749d89ea86f9c80b01bf0186a7916dda90718f81bb52eb7c7699c323776a8a9e647e1c6486a8499e3488fab26c4b0d01f8f71849b0325b05514887bbb923
7
- data.tar.gz: 31c99e14cd9c805e1af6b66719c0eecc1c3ad1c42a59b205e7d969234e1ff473b9169d6fb3d9aeda5aac9805458118dfa2e079f82599f51d34054b0f8a0dcb5b
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
  ```
@@ -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.public_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.public_key), padding: false),
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
- VERSION = 0x01
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
- 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
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
 
@@ -1,5 +1,5 @@
1
1
  module Foil
2
2
  module Server
3
- VERSION = "0.3.3".freeze
3
+ VERSION = "0.3.5".freeze
4
4
  end
5
5
  end
@@ -3,6 +3,7 @@
3
3
  "object": "session",
4
4
  "id": "sid_0123456789abcdefghjkmnpqrs",
5
5
  "created_at": "2026-03-24T20:00:00.000Z",
6
+ "client_user_id": "user_123",
6
7
  "decision": {
7
8
  "event_id": "evt_23456789abcdefghjkmnpqrstv",
8
9
  "automation_status": "automated",
@@ -4,6 +4,7 @@
4
4
  "object": "session",
5
5
  "id": "sid_0123456789abcdefghjkmnpqrs",
6
6
  "created_at": "2026-03-24T20:00:00.000Z",
7
+ "client_user_id": "user_123",
7
8
  "latest_decision": {
8
9
  "event_id": "evt_23456789abcdefghjkmnpqrstv",
9
10
  "verdict": "human",
@@ -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": "Durable session readback endpoints."
17
+ "description": "Session readback endpoints."
18
18
  },
19
19
  {
20
20
  "name": "Visitor fingerprints",
21
- "description": "Durable visitor fingerprint readback endpoints."
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 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.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-05-22 00:00:00.000000000 Z
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