foil-server 0.3.4 → 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: da4f138fa11fe95c669393d0cc561cae5579e63566cb76eea6ce39af012f84f3
4
- data.tar.gz: dc77719ea3fbc1ee8e5e4bf6473ef23cdc07be49184849573a197821b7e6b720
3
+ metadata.gz: 5d41b78e23e89f77b68f871a2454fb7d3ee87828a66e76fc95c6baca12127ff2
4
+ data.tar.gz: ba257476579665aa554305f16a98793b5a9b98726d90784c5d5ae4b643ba04e2
5
5
  SHA512:
6
- metadata.gz: 8a0baeff8b13ea7756aac49c718d241951d5affe0f5680bec246ce7ee4c6b204c220e5e5ff26060c24f003f954065cc0560c41def17da3aeaa2fb2af22c83e87
7
- data.tar.gz: 16fd177450ade1814ca13c381ba01545a07347f1d0fdfada56e43e8a40f893d2d3303715a4af1369d2b682f1be75bc1f51f3b9ba1dc7687b8fcc1a1e894929e4
6
+ metadata.gz: 5a979d1b334c0c7331fcd3f174b3ddece6f371d200ee7cd4ffe4287b8e651cadada1ae15cc495b14c849e290de9384ab53cc826c80e5d5e49b4ae56fde1659d3
7
+ data.tar.gz: 70a38a2b6466c9e184b67629e9d45cdce7d620e032737d8c5cad9d6c96eba590c7a58b80ed22573b976ae1fb2ebad215e70f9edfea8cee6f0f9bf0eb65784f85
@@ -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.4".freeze
3
+ VERSION = "0.3.5".freeze
4
4
  end
5
5
  end
@@ -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/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,7 +1,7 @@
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.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - ABXY Labs
@@ -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