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 +4 -4
- data/lib/foil/server/sealed_token.rb +83 -16
- data/lib/foil/server/version.rb +1 -1
- data/spec/fixtures/sealed-token/vector.v2.json +59 -0
- data/spec/sealed-token.md +47 -18
- metadata +2 -1
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
|
|
@@ -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
|
@@ -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
|
|
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,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
|
+
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
|