legion-crypt 1.3.0 → 1.4.1

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: 13bcc3fe80b97d57f54b1e5449f0319865ee5b05c5402ce9a5f3b3a30f89ad88
4
+ data.tar.gz: 1ba4f54c017ec83868defedd3fe4d7a3659b5d84f3afa030722dce9d9bb1211c
5
5
  SHA512:
6
- metadata.gz: 64e62d71d928dbd378aa7ce371af98b79c0a70cd6724461db87c7753f073014e8d0973975b947c31cdcda34f138f473afcf11d5026ec5332e7b5d619b91e72f8
7
- data.tar.gz: 42b44f3d2b203db36197dc399732790fe3e6cc6f9f00b9e3c3660fc1283cf6bbd519fbaab718ddaa44a404f5332e3d8f0eb24120d17ac41f2071df637fc61634
6
+ metadata.gz: 6f120ed5f85a929fe22e750e482253550000888afbc6633c989df680c7acdc93673303014d911871642a48f66ab05e6899ab773319b1a195c64736eb052fc204
7
+ data.tar.gz: 8a4d154360dca522f05982d986eb28d25212038c7ca37e94253a4a1a89b03a9a51a15e46e2dedc77f382b5b7f84fcca3c8924332c9dae230858a3c154e3e8b15
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.1] - 2026-03-16
6
+
7
+ ### Added
8
+ - `Legion::Crypt::MockVault` in-memory Vault mock for local development mode
9
+
10
+ ## [1.4.0] - 2026-03-16
11
+
12
+ ### Added
13
+ - `JwksClient` module: fetch, parse, and cache public keys from JWKS endpoints (TTL 3600s, thread-safe)
14
+ - `JWT.verify_with_jwks` for RS256 token verification against external identity providers (Entra ID, Bot Framework)
15
+ - Multi-issuer support via `issuers:` array parameter
16
+ - Audience validation via `audience:` parameter
17
+ - `Crypt.verify_external_token` convenience method
18
+
5
19
  ## [1.3.0] - 2026-03-16
6
20
 
7
21
  ### Added
data/CLAUDE.md CHANGED
@@ -34,8 +34,14 @@ Legion::Crypt (singleton module)
34
34
  ├── JWT # JSON Web Token operations
35
35
  │ ├── .issue # Create signed JWT (HS256 or RS256)
36
36
  │ ├── .verify # Verify and decode JWT
37
+ │ ├── .verify_with_jwks # Verify RS256 token via external JWKS endpoint (Entra ID, etc.)
37
38
  │ └── .decode # Decode without verification (inspection)
38
39
 
40
+ ├── JwksClient # External JWKS endpoint integration (thread-safe)
41
+ │ ├── .fetch_keys # Fetch and parse JWKS from a URL
42
+ │ ├── .find_key # Lookup key by kid (cache-first, re-fetch on miss)
43
+ │ └── .clear_cache # Clear the key cache
44
+
39
45
  ├── ClusterSecret # Cluster-wide shared secret management
40
46
  │ └── .cs # Generate/distribute cluster secret
41
47
 
@@ -46,6 +52,7 @@ Legion::Crypt (singleton module)
46
52
 
47
53
  ├── VaultRenewer # Background Vault token renewal thread
48
54
  ├── LeaseManager # Dynamic Vault lease lifecycle: fetch, cache, renew, rotate, push-back
55
+ ├── MockVault # In-memory Vault mock for local development mode
49
56
  ├── Settings # Default crypt config
50
57
  └── Version
51
58
  ```
@@ -57,6 +64,7 @@ Legion::Crypt (singleton module)
57
64
  - **Vault Conditional**: Vault module is only included if the `vault` gem is available
58
65
  - **Token Lifecycle**: VaultRenewer runs background thread for automatic token renewal
59
66
  - **JWT Dual Algorithm**: HS256 (symmetric, cluster secret) for intra-cluster tokens; RS256 (asymmetric, RSA keypair) for tokens verifiable without sharing the signing key
67
+ - **JWKS External Validation**: `JwksClient` fetches public keys from external identity provider JWKS endpoints (Entra ID, Bot Framework). Keys cached for 1 hour (CACHE_TTL=3600s), thread-safe via Mutex, automatic re-fetch on cache miss handles key rotation
60
68
 
61
69
  ## Default Settings
62
70
 
@@ -94,7 +102,8 @@ Dev dependencies: `legion-logging`, `legion-settings`
94
102
  |------|---------|
95
103
  | `lib/legion/crypt.rb` | Module entry, start/shutdown lifecycle |
96
104
  | `lib/legion/crypt/cipher.rb` | AES-256-CBC encrypt/decrypt, RSA key generation |
97
- | `lib/legion/crypt/jwt.rb` | JWT issue/verify/decode operations |
105
+ | `lib/legion/crypt/jwt.rb` | JWT issue/verify/decode/verify_with_jwks operations |
106
+ | `lib/legion/crypt/jwks_client.rb` | JWKS endpoint fetch, parse, cache (thread-safe, 1hr TTL) |
98
107
  | `lib/legion/crypt/vault.rb` | Vault read/write/connect/renew operations |
99
108
  | `lib/legion/crypt/cluster_secret.rb` | Cluster-wide shared secret management |
100
109
  | `lib/legion/crypt/vault_jwt_auth.rb` | Vault JWT auth backend: `.login`, `.login!`, `.worker_login`; raises `AuthError` on failure |
@@ -110,6 +119,7 @@ First service-level module initialized during `Legion::Service` startup (before
110
119
  2. Message encryption for `legion-transport` (optional `transport.messages.encrypt`)
111
120
  3. Cluster secret for inter-node encrypted communication
112
121
  4. JWT tokens for node authentication and task authorization
122
+ 5. External token verification for identity providers (Entra ID OIDC via JWKS)
113
123
 
114
124
  ### Vault JWT Auth Usage
115
125
 
@@ -144,6 +154,31 @@ decoded = Legion::Crypt::JWT.decode(token) # no verification, inspection only
144
154
  - `HS256` (default): Uses cluster secret. All cluster nodes can issue and verify.
145
155
  - `RS256`: Uses RSA keypair. Only the issuing node can sign; anyone with the public key can verify.
146
156
 
157
+ ### External Token Verification (JWKS)
158
+
159
+ Verify tokens from external identity providers using their public JWKS endpoints:
160
+
161
+ ```ruby
162
+ # Convenience method
163
+ claims = Legion::Crypt.verify_external_token(
164
+ token,
165
+ jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys',
166
+ issuers: ['https://login.microsoftonline.com/TENANT/v2.0'],
167
+ audience: 'app-client-id'
168
+ )
169
+
170
+ # Direct module usage
171
+ claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url)
172
+ ```
173
+
174
+ **Flow:** decode JWT header (unverified) to extract `kid` -> `JwksClient.find_key` fetches the matching public key from cache or JWKS endpoint -> verify JWT signature with the public key.
175
+
176
+ **Options:** `issuers:` (array, multi-issuer support), `audience:` (string), `verify_expiration:` (bool, default true).
177
+
178
+ **Error hierarchy:** `ExpiredTokenError`, `InvalidTokenError` (bad signature, wrong issuer, wrong audience), `DecodeError` (malformed token) — all inherit from `Legion::Crypt::JWT::Error`.
179
+
180
+ **Used by:** `lex-identity` Entra runner for Digital Worker OIDC token validation.
181
+
147
182
  ---
148
183
 
149
184
  **Maintained By**: Matthew Iverson (@Esity)
data/README.md CHANGED
@@ -41,6 +41,25 @@ claims = Legion::Crypt.verify_token(token, algorithm: 'RS256')
41
41
  decoded = Legion::Crypt::JWT.decode(token)
42
42
  ```
43
43
 
44
+ ### External Token Verification (JWKS)
45
+
46
+ Verify tokens from external identity providers (Entra ID, Bot Framework) using their public JWKS endpoints:
47
+
48
+ ```ruby
49
+ # Verify an Entra ID OIDC token
50
+ claims = Legion::Crypt.verify_external_token(
51
+ token,
52
+ jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys',
53
+ issuers: ['https://login.microsoftonline.com/TENANT/v2.0'],
54
+ audience: 'app-client-id'
55
+ )
56
+
57
+ # Or use the JWT module directly
58
+ claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url)
59
+ ```
60
+
61
+ Public keys are cached for 1 hour and automatically re-fetched on cache miss (handles key rotation).
62
+
44
63
  ## Configuration
45
64
 
46
65
  ```json
@@ -56,7 +75,8 @@ decoded = Legion::Crypt::JWT.decode(token)
56
75
  "renewer": true,
57
76
  "push_cluster_secret": true,
58
77
  "read_cluster_secret": true,
59
- "kv_path": "legion"
78
+ "kv_path": "legion",
79
+ "leases": {}
60
80
  },
61
81
  "jwt": {
62
82
  "enabled": true,
@@ -85,6 +105,47 @@ decoded = Legion::Crypt::JWT.decode(token)
85
105
 
86
106
  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
107
 
108
+ ### Dynamic Vault Leases
109
+
110
+ 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:
111
+
112
+ ```json
113
+ {
114
+ "crypt": {
115
+ "vault": {
116
+ "leases": {
117
+ "rabbitmq": { "path": "rabbitmq/creds/legion-role" },
118
+ "bedrock": { "path": "aws/creds/bedrock-role" },
119
+ "postgres": { "path": "database/creds/apollo-rw" }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Other settings files reference lease data using `lease://name#key`:
127
+
128
+ ```json
129
+ {
130
+ "transport": {
131
+ "connection": {
132
+ "username": "lease://rabbitmq#username",
133
+ "password": "lease://rabbitmq#password"
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ Both `username` and `password` come from a single Vault read — one lease, one credential pair. The `LeaseManager`:
140
+
141
+ - Fetches all leases at boot (during `Crypt.start`, before `resolve_secrets!`)
142
+ - Caches response data and lease metadata
143
+ - Renews leases in the background at 50% TTL
144
+ - Detects credential rotation and pushes new values into `Legion::Settings` in-place
145
+ - Revokes all leases on `Crypt.shutdown`
146
+
147
+ Lease names are stable across environments. The actual Vault paths are deployment-specific config.
148
+
88
149
  ## Requirements
89
150
 
90
151
  - 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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Crypt
5
+ module MockVault
6
+ @store = {}
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ def read(path)
11
+ @mutex.synchronize { @store[path]&.dup }
12
+ end
13
+
14
+ def write(path, data)
15
+ @mutex.synchronize { @store[path] = data.dup }
16
+ true
17
+ end
18
+
19
+ def delete(path)
20
+ @mutex.synchronize { @store.delete(path) }
21
+ true
22
+ end
23
+
24
+ def list(prefix)
25
+ @mutex.synchronize do
26
+ @store.keys.select { |k| k.start_with?(prefix) }
27
+ end
28
+ end
29
+
30
+ def reset!
31
+ @mutex.synchronize { @store.clear }
32
+ end
33
+
34
+ def connected?
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.3.0'
5
+ VERSION = '1.4.1'
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -59,8 +59,10 @@ 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
65
+ - lib/legion/crypt/mock_vault.rb
64
66
  - lib/legion/crypt/settings.rb
65
67
  - lib/legion/crypt/vault.rb
66
68
  - lib/legion/crypt/vault_jwt_auth.rb