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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7018b9c084879712ba6a2fdf0b64f19b1c78de8cb8b9377063f824a0f43314c
4
- data.tar.gz: 0d449cbe402ffbe5a45256801ea442ac03d7663f234f4678f52147e55214c0c0
3
+ metadata.gz: 3bf2e0f0673b2c963e56d71c586d299a39c3711b0d3f49fe851375d78cebe07e
4
+ data.tar.gz: 194d4c8b64922f5634dcf75607af87a0d83ba10efcb1c83421456bf7a3fddfc6
5
5
  SHA512:
6
- metadata.gz: 64e62d71d928dbd378aa7ce371af98b79c0a70cd6724461db87c7753f073014e8d0973975b947c31cdcda34f138f473afcf11d5026ec5332e7b5d619b91e72f8
7
- data.tar.gz: 42b44f3d2b203db36197dc399732790fe3e6cc6f9f00b9e3c3660fc1283cf6bbd519fbaab718ddaa44a404f5332e3d8f0eb24120d17ac41f2071df637fc61634
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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.3.0'
5
+ VERSION = '1.4.0'
6
6
  end
7
7
  end
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.3.0
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