legion-crypt 1.3.0 → 1.4.0
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/CHANGELOG.md +9 -0
- data/README.md +43 -1
- data/lib/legion/crypt/jwks_client.rb +102 -0
- data/lib/legion/crypt/jwt.rb +56 -1
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +4 -0
- 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: 3bf2e0f0673b2c963e56d71c586d299a39c3711b0d3f49fe851375d78cebe07e
|
|
4
|
+
data.tar.gz: 194d4c8b64922f5634dcf75607af87a0d83ba10efcb1c83421456bf7a3fddfc6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33e4ea2d0ca6413f24099181f5ccf3b61ced2c33f079dd11029ded41095ceaedaf419b315e097ede586560e5478cec5be946dbe627d0ab68763c5354baa3617f
|
|
7
|
+
data.tar.gz: 8985763ca6ee45362527ec0368175125ed95d6001dc4d0f43759017fe1c7de33f7c194be7501e8c9c19a12fe72ac3fa1ce2aca5e6c5fde8d39c2bc76999ec925
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-03-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `JwksClient` module: fetch, parse, and cache public keys from JWKS endpoints (TTL 3600s, thread-safe)
|
|
9
|
+
- `JWT.verify_with_jwks` for RS256 token verification against external identity providers (Entra ID, Bot Framework)
|
|
10
|
+
- Multi-issuer support via `issuers:` array parameter
|
|
11
|
+
- Audience validation via `audience:` parameter
|
|
12
|
+
- `Crypt.verify_external_token` convenience method
|
|
13
|
+
|
|
5
14
|
## [1.3.0] - 2026-03-16
|
|
6
15
|
|
|
7
16
|
### Added
|
data/README.md
CHANGED
|
@@ -56,7 +56,8 @@ decoded = Legion::Crypt::JWT.decode(token)
|
|
|
56
56
|
"renewer": true,
|
|
57
57
|
"push_cluster_secret": true,
|
|
58
58
|
"read_cluster_secret": true,
|
|
59
|
-
"kv_path": "legion"
|
|
59
|
+
"kv_path": "legion",
|
|
60
|
+
"leases": {}
|
|
60
61
|
},
|
|
61
62
|
"jwt": {
|
|
62
63
|
"enabled": true,
|
|
@@ -85,6 +86,47 @@ decoded = Legion::Crypt::JWT.decode(token)
|
|
|
85
86
|
|
|
86
87
|
When `vault.token` is set (or via `VAULT_TOKEN_ID` env var), Crypt connects to Vault on `start`. The background `VaultRenewer` thread keeps the token alive. Vault is an optional runtime dependency — the Vault module is only included if the `vault` gem is available.
|
|
87
88
|
|
|
89
|
+
### Dynamic Vault Leases
|
|
90
|
+
|
|
91
|
+
The `LeaseManager` handles dynamic secrets from any Vault secrets engine (database, RabbitMQ, AWS, PKI, etc.). Define named leases in crypt settings — each lease maps a stable name to a Vault path:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"crypt": {
|
|
96
|
+
"vault": {
|
|
97
|
+
"leases": {
|
|
98
|
+
"rabbitmq": { "path": "rabbitmq/creds/legion-role" },
|
|
99
|
+
"bedrock": { "path": "aws/creds/bedrock-role" },
|
|
100
|
+
"postgres": { "path": "database/creds/apollo-rw" }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Other settings files reference lease data using `lease://name#key`:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"transport": {
|
|
112
|
+
"connection": {
|
|
113
|
+
"username": "lease://rabbitmq#username",
|
|
114
|
+
"password": "lease://rabbitmq#password"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Both `username` and `password` come from a single Vault read — one lease, one credential pair. The `LeaseManager`:
|
|
121
|
+
|
|
122
|
+
- Fetches all leases at boot (during `Crypt.start`, before `resolve_secrets!`)
|
|
123
|
+
- Caches response data and lease metadata
|
|
124
|
+
- Renews leases in the background at 50% TTL
|
|
125
|
+
- Detects credential rotation and pushes new values into `Legion::Settings` in-place
|
|
126
|
+
- Revokes all leases on `Crypt.shutdown`
|
|
127
|
+
|
|
128
|
+
Lease names are stable across environments. The actual Vault paths are deployment-specific config.
|
|
129
|
+
|
|
88
130
|
## Requirements
|
|
89
131
|
|
|
90
132
|
- Ruby >= 3.4
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'jwt'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Crypt
|
|
11
|
+
module JwksClient
|
|
12
|
+
CACHE_TTL = 3600
|
|
13
|
+
|
|
14
|
+
@cache = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def fetch_keys(jwks_url)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
response = http_get(jwks_url)
|
|
21
|
+
jwks_data = parse_response(response)
|
|
22
|
+
keys = parse_jwks(jwks_data)
|
|
23
|
+
|
|
24
|
+
@cache[jwks_url] = { keys: keys, fetched_at: Time.now }
|
|
25
|
+
keys
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_key(jwks_url, kid)
|
|
30
|
+
cached = @mutex.synchronize { @cache[jwks_url] }
|
|
31
|
+
|
|
32
|
+
if cached && !expired?(cached[:fetched_at])
|
|
33
|
+
key = cached[:keys][kid]
|
|
34
|
+
return key if key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Re-fetch once on cache miss or expiry
|
|
38
|
+
keys = fetch_keys(jwks_url)
|
|
39
|
+
key = keys[kid]
|
|
40
|
+
return key if key
|
|
41
|
+
|
|
42
|
+
raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear_cache
|
|
46
|
+
@mutex.synchronize { @cache = {} }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def expired?(fetched_at)
|
|
52
|
+
Time.now - fetched_at > CACHE_TTL
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def http_get(url)
|
|
56
|
+
uri = URI.parse(url)
|
|
57
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
58
|
+
http.use_ssl = uri.scheme == 'https'
|
|
59
|
+
http.open_timeout = 10
|
|
60
|
+
http.read_timeout = 10
|
|
61
|
+
|
|
62
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
63
|
+
response = http.request(request)
|
|
64
|
+
|
|
65
|
+
raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
66
|
+
|
|
67
|
+
response.body
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" unless e.is_a?(Legion::Crypt::JWT::Error)
|
|
70
|
+
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_response(body)
|
|
75
|
+
parsed = ::JSON.parse(body)
|
|
76
|
+
raise Legion::Crypt::JWT::Error, 'invalid JWKS response: missing keys' unless parsed.is_a?(Hash) && parsed['keys'].is_a?(Array)
|
|
77
|
+
|
|
78
|
+
parsed
|
|
79
|
+
rescue ::JSON::ParserError => e
|
|
80
|
+
raise Legion::Crypt::JWT::Error, "invalid JWKS response: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_jwks(jwks_data)
|
|
84
|
+
keys = {}
|
|
85
|
+
|
|
86
|
+
jwks_data['keys'].each do |jwk_hash|
|
|
87
|
+
kid = jwk_hash['kid']
|
|
88
|
+
next unless kid
|
|
89
|
+
|
|
90
|
+
jwk = ::JWT::JWK.new(jwk_hash)
|
|
91
|
+
keys[kid] = jwk.public_key
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Skip malformed keys, continue with valid ones
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
keys
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/legion/crypt/jwt.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'jwt'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require 'legion/crypt/jwks_client'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module Crypt
|
|
@@ -59,6 +60,60 @@ module Legion
|
|
|
59
60
|
raise DecodeError, "failed to decode token: #{e.message}"
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
def self.verify_with_jwks(token, jwks_url:, **opts)
|
|
64
|
+
header = decode_header(token)
|
|
65
|
+
kid = header['kid']
|
|
66
|
+
algorithm = header['alg'] || 'RS256'
|
|
67
|
+
|
|
68
|
+
raise InvalidTokenError, 'token header missing kid' unless kid
|
|
69
|
+
|
|
70
|
+
validate_algorithm!(algorithm)
|
|
71
|
+
|
|
72
|
+
public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid)
|
|
73
|
+
|
|
74
|
+
verify_expiration = opts.fetch(:verify_expiration, true)
|
|
75
|
+
issuers = opts[:issuers]
|
|
76
|
+
audience = opts[:audience]
|
|
77
|
+
|
|
78
|
+
decode_opts = {
|
|
79
|
+
algorithm: algorithm,
|
|
80
|
+
verify_expiration: verify_expiration
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if issuers
|
|
84
|
+
decode_opts[:verify_iss] = true
|
|
85
|
+
decode_opts[:iss] = issuers
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if audience
|
|
89
|
+
decode_opts[:verify_aud] = true
|
|
90
|
+
decode_opts[:aud] = audience
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
payload, _header = ::JWT.decode(token, public_key, true, decode_opts)
|
|
94
|
+
symbolize_keys(payload)
|
|
95
|
+
rescue ::JWT::ExpiredSignature
|
|
96
|
+
raise ExpiredTokenError, 'token has expired'
|
|
97
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
98
|
+
raise InvalidTokenError, 'token signature verification failed'
|
|
99
|
+
rescue ::JWT::InvalidIssuerError
|
|
100
|
+
raise InvalidTokenError, 'token issuer not allowed'
|
|
101
|
+
rescue ::JWT::InvalidAudError
|
|
102
|
+
raise InvalidTokenError, 'token audience mismatch'
|
|
103
|
+
rescue ::JWT::DecodeError => e
|
|
104
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.decode_header(token)
|
|
108
|
+
parts = token.to_s.split('.')
|
|
109
|
+
raise DecodeError, 'invalid token format' unless parts.size == 3
|
|
110
|
+
|
|
111
|
+
header_json = Base64.urlsafe_decode64(parts[0])
|
|
112
|
+
::JSON.parse(header_json)
|
|
113
|
+
rescue ::JSON::ParserError, ArgumentError => e
|
|
114
|
+
raise DecodeError, "failed to decode token header: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
62
117
|
def self.validate_algorithm!(algorithm)
|
|
63
118
|
return if SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
64
119
|
|
|
@@ -69,7 +124,7 @@ module Legion
|
|
|
69
124
|
hash.transform_keys(&:to_sym)
|
|
70
125
|
end
|
|
71
126
|
|
|
72
|
-
private_class_method :validate_algorithm!, :symbolize_keys
|
|
127
|
+
private_class_method :validate_algorithm!, :symbolize_keys, :decode_header
|
|
73
128
|
end
|
|
74
129
|
end
|
|
75
130
|
end
|
data/lib/legion/crypt/version.rb
CHANGED
data/lib/legion/crypt.rb
CHANGED
|
@@ -64,6 +64,10 @@ module Legion
|
|
|
64
64
|
issuer: jwt[:issuer])
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def verify_external_token(token, jwks_url:, **)
|
|
68
|
+
Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url, **)
|
|
69
|
+
end
|
|
70
|
+
|
|
67
71
|
def shutdown
|
|
68
72
|
Legion::Crypt::LeaseManager.instance.shutdown
|
|
69
73
|
shutdown_renewer
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-crypt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/legion/crypt.rb
|
|
60
60
|
- lib/legion/crypt/cipher.rb
|
|
61
61
|
- lib/legion/crypt/cluster_secret.rb
|
|
62
|
+
- lib/legion/crypt/jwks_client.rb
|
|
62
63
|
- lib/legion/crypt/jwt.rb
|
|
63
64
|
- lib/legion/crypt/lease_manager.rb
|
|
64
65
|
- lib/legion/crypt/settings.rb
|