legion-crypt 1.5.12 → 1.5.37

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: a4af81549d8e946acfc92ec4036496fce4af3d1be6fc10cba534298434f0c94a
4
- data.tar.gz: 7e70028fa24e9760ff791f49f88817b37c9ee009b21fdc7dae9382a49306e8ca
3
+ metadata.gz: 9037b8b6b87bdff6a7e839f7223a825984786e5be4a323c20ce2673eccf3e93a
4
+ data.tar.gz: 1548b784a608b1c9b71794d937bb5bd0f0bfcd74e8337b31132bc817909926d5
5
5
  SHA512:
6
- metadata.gz: 96269db0a9859015418df236468d0eff5b08490e1eb7bc55c7507199dda579924273979405d9e724b3d1237b7855ecc7b949fabdbc15dc378994ad19930112c9
7
- data.tar.gz: b112f3eb47b538549e842eaa8a766e15c1934650f010082ec892a4b05fa995746dcff25ffeed90a2e625e09ee4d38d21a9d399d9787c6fd6dd1598f6895fc28f
6
+ metadata.gz: da7ae3fb308f8ccd82687707d0e28954d208dfe4f874d37287df46c05d7e7cf3bb3e3b22e9793fd4fdcf9cf8c71bd07d0c331b6a579dc3dc39e15ec31ce24865
7
+ data.tar.gz: 20a76900d62030ace417f135ba1666d05cd8304bee62f4bac1fa1cb7cd4451a06e634e29220ea0c4b105e6b30e5fdb3187d8a7db5bef1c75a1e33bee258bb4fa
data/.rubocop.yml CHANGED
@@ -16,6 +16,9 @@ Layout/HashAlignment:
16
16
  Metrics/MethodLength:
17
17
  Max: 50
18
18
 
19
+ Metrics/ParameterLists:
20
+ CountKeywordArgs: false
21
+
19
22
  Metrics/ClassLength:
20
23
  Max: 1500
21
24
 
data/CHANGELOG.md CHANGED
@@ -1,6 +1,151 @@
1
1
  # Legion::Crypt
2
2
 
3
- ## [Unreleased]
3
+ ## [1.5.37] - 2026-10-30
4
+
5
+ ### Changed
6
+ - Use keyword arguments and accept unused extra keywords in methods introduced by this PR; migrate internal callers while preserving pre-existing method signatures and Ruby assignment protocols (#52).
7
+
8
+ ## [1.5.36] - 2026-10-30
9
+
10
+ ### Fixed
11
+ - Reject and remove expired credential cache entries from lease reads, settings publication and initialized counts, allowing `lease://` fallback after failed reacquisition while retaining lease ownership for cleanup (#52).
12
+
13
+ ## [1.5.35] - 2026-10-30
14
+
15
+ ### Fixed
16
+ - Skip unchanged settings destinations during lease handoff so TTL-only renewals preserve live consumer connections; credential changes still publish and reconnect transactionally (#52).
17
+
18
+ ## [1.5.34] - 2026-10-30
19
+
20
+ ### Fixed
21
+ - Transition pending renewal retries to fresh acquisition after lease expiry, including renewal timeouts, while preserving backoff and last-known lease ownership (#52).
22
+ - Revalidate captured lease ownership before reacquisition or retry publication so stale maintenance cannot replace a newer handoff (#52).
23
+
24
+ ## [1.5.33] - 2026-10-30
25
+
26
+ ### Fixed
27
+ - Resolve Vault-backed TLS material outside the client-cache lock, then revalidate settings and lifecycle generation before publishing a candidate (#52).
28
+ - Reject unresolved explicit trust/identity material and circular Vault TLS dependencies; close superseded or shutdown-interrupted client candidates (#52).
29
+
30
+ ## [1.5.32] - 2026-10-30
31
+
32
+ ### Fixed
33
+ - Replace token, expiry, renewability, connectivity and authentication strategy as one grant when LDAP, JWT or Kerberos login succeeds; restart maintenance with the new grant instead of the previous token's lifetime (#52).
34
+ - Reject authentication results from stopped lifecycles and resume managed JWT token renewal for both legacy and multi-cluster configurations (#52).
35
+
36
+ ## [1.5.31] - 2026-10-30
37
+
38
+ ### Fixed
39
+ - Publish LDAP, JWT, Kerberos and reauthentication tokens together with client-cache signatures under one lock, rejecting late results for replaced clients or changed configuration (#52).
40
+ - Reject token publication to clients that have already shut down (#52).
41
+
42
+ ## [1.5.30] - 2026-10-30
43
+
44
+ ### Fixed
45
+ - Preserve replaced Vault clients while lease and token owners still reference them; weakly track displaced clients and permanently close all surviving clients only after lifecycle cleanup (#52).
46
+
47
+ ## [1.5.29] - 2026-10-30
48
+
49
+ ### Fixed
50
+ - Revalidate pending lease operations under the per-lease lock and reject superseded retry publication, preventing stale acquisition or renewal work from undoing identity credential handoffs (#52).
51
+
52
+ ## [1.5.28] - 2026-10-30
53
+
54
+ ### Fixed
55
+ - Bootstrap a confirmed empty cluster before waiting for a peer secret, preserving the bounded discovery deadline and refusing generation when membership is unknown (#52).
56
+
57
+ ## [1.5.27] - 2026-10-30
58
+
59
+ ### Changed
60
+ - Replace vault-ruby with the core-owned Faraday and `Legion::JSON` Vault client, supporting JSON 3 without an extension dependency (#38).
61
+ - Migrate legacy and multi-cluster secrets, auth, leases, token renewal, PKI, and entity tracking to the same cached client contract; retain configured Kerberos namespaces and captured lease ownership (#38).
62
+ - Support peer and mutual TLS, current credential headers, typed HTTP errors, KV v2 envelopes, finite transport timeouts, and permanent client retirement (#38).
63
+
64
+ ### Fixed
65
+ - Do not report tenant erasure or emit an erasure event when Vault deletion returns a failure result (#38).
66
+ - Trim KV mount boundaries with single-character searches rather than a quantified suffix pattern, and initialize concurrency-spec cleanup state before setup can fail (#52).
67
+
68
+ ## [1.5.26] - 2026-10-29
69
+
70
+ ### Fixed
71
+ - Bound SPIFFE socket connection, handshake, request writes, flushes, and response reads with shared monotonic deadlines; close sockets when setup or exchange fails (#43).
72
+ - Reassemble gRPC prefixes and message bodies across HTTP/2 DATA frames and partial socket reads, returning only the declared message bytes (#43).
73
+ - Encode JWT-SVID audience lengths as canonical protobuf varints, including lengths of 128 bytes and larger (#43).
74
+
75
+ ## [1.5.25] - 2026-10-29
76
+
77
+ ### Fixed
78
+ - Start an expiry-aware renewal lifecycle immediately after LDAP login, retain token lifetime metadata, and renew through the authenticated cluster without retaining passwords or invoking Kerberos (#51).
79
+ - Preserve nonrenewable tokens until expiry, bound renewal retries and requests by remaining lifetime, and expose disconnected/login-required state after expiry or terminal renewal rejection (#51).
80
+ - Replace prior LDAP workers safely on repeated login, reject late login results after shutdown, and prevent expired multi-cluster credentials from falling back to the global Vault client (#51).
81
+
82
+ ## [1.5.24] - 2026-10-29
83
+
84
+ ### Fixed
85
+ - Retry initial certificate issuance and failed renewals with capped exponential backoff while retaining the last installed certificate (#49).
86
+ - Schedule certificate rotation from expiry and the configured renewal window, interrupt pending waits on shutdown, and discard late issuance from stopped workers (#49).
87
+
88
+ ## [1.5.23] - 2026-10-29
89
+
90
+ ### Fixed
91
+ - Retain failed lease acquisitions for capped exponential-backoff retry and schedule maintenance against lease expiry, waking promptly for newly registered short-TTL leases (#50).
92
+ - Make credential replacement transactional: preserve prior settings and lease ownership on failed reconnect, retire old IDs only after successful handoff, and retain failed cleanup for retry or shutdown (#50).
93
+ - Capture each lease's issuing Vault client, deduplicate revocation, prevent retired candidates from being reinstalled, and keep late renewals from recreating revoked ownership (#50).
94
+ - Scope timeout-spec cleanup to the override/example block so failed settings snapshots preserve the original error and leave settings unchanged (#52).
95
+
96
+ ## [1.5.22] - 2026-10-29
97
+
98
+ ### Fixed
99
+ - Rotate every unique lease-reference destination, including nested arrays, without coercing typed paths or mutating invalid destinations; report actual successful writes (#45).
100
+
101
+ ## [1.5.21] - 2026-10-29
102
+
103
+ ### Fixed
104
+ - Invalidate cached Vault clients when connection settings change, retire clients with bounded failure-isolated cleanup, and clear the cache across shutdown/start without discarding in-place token refreshes (#47).
105
+
106
+ ## [1.5.20] - 2026-10-29
107
+
108
+ ### Fixed
109
+ - Bound Vault maintenance and shutdown, isolate failed leases and sessions, and permit restart after cleanup failures (#44).
110
+ - Guard late lease, token, settings, and Kerberos-principal updates with lifecycle generations and cancellation-aware publication (#44).
111
+
112
+ ## [1.5.19] - 2026-10-29
113
+
114
+ ### Fixed
115
+ - Coalesce concurrent JWKS misses, bound unknown-key and failed-upstream refreshes with a per-URL cooldown, and preserve immediate first-miss rotation checks and cache expiry (#42).
116
+
117
+ ## [1.5.18] - 2026-10-29
118
+
119
+ ### Fixed
120
+ - Reject malformed JWT header shapes, key identifiers, and token algorithms through JWT-domain errors before JWKS lookup, while keeping caller configuration failures distinct (#46).
121
+
122
+ ## [1.5.17] - 2026-10-29
123
+
124
+ ### Fixed
125
+ - Resolve KV mounts from the Vault settings subtree on the composed Crypt API for both clustered and legacy clients (#48).
126
+
127
+ ## [1.5.16] - 2026-10-29
128
+
129
+ ### Fixed
130
+ - Gate cluster-secret generation on confirmed bootstrap eligibility, refresh derived keys when settings change, and terminate failed or cancelled initialization with bounded domain errors (#39).
131
+
132
+ ## [1.5.15] - 2026-10-29
133
+
134
+ ### Fixed
135
+ - Create saved RSA private keys with owner-only permissions and repair existing file permissions before writing, without changing save-disabled behavior (#41).
136
+
137
+ ## [1.5.14] - 2026-10-29
138
+
139
+ ### Fixed
140
+ - Require full 16-byte GCM authentication tags for message and tenant decryption, preventing truncated-tag forgery while preserving legacy ciphertext support (#40).
141
+
142
+ ## [1.5.13] - 2026-05-09
143
+
144
+ ### Removed
145
+ - Logging compat shims (`lib/legion/logging.rb` and `lib/legion/logging/helper.rb`) that redefined `Legion::Logging::Helper#log` with a `CompatLogger`, preventing TaggedLogger segment tags from rendering in log output for all modules loaded after crypt
146
+
147
+ ### Added
148
+ - `legion-json` gemspec dependency (was used but undeclared)
4
149
 
5
150
  ## [1.5.12] - 2026-04-27
6
151
 
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # legion-crypt
2
2
 
3
- Encryption, secrets management, JWT token management, and HashiCorp Vault integration for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides AES-256-CBC message encryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, Vault token lifecycle management, and multi-cluster Vault connectivity.
4
-
5
- **Version**: 1.5.6
3
+ Encryption, secrets management, JWT token management, and HashiCorp Vault integration for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides AES-256-GCM message encryption with legacy AES-256-CBC decryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, Vault token lifecycle management, and multi-cluster Vault connectivity.
6
4
 
7
5
  ## Installation
8
6
 
@@ -22,10 +20,20 @@ gem 'legion-crypt'
22
20
  require 'legion/crypt'
23
21
 
24
22
  Legion::Crypt.start
25
- Legion::Crypt.encrypt('this is my string')
26
- Legion::Crypt.decrypt(message)
23
+ encrypted = Legion::Crypt.encrypt('this is my string')
24
+ Legion::Crypt.decrypt(encrypted[:enciphered_message], encrypted[:iv])
27
25
  ```
28
26
 
27
+ Authenticated ciphertext requires a full 16-byte GCM tag. Saved RSA private keys are created with `0600` permissions; existing files are restricted before writing.
28
+
29
+ ### Cluster secret readiness
30
+
31
+ Discovery checks settings, Vault, and then peers. Random generation requires confirmed empty-cluster membership, the lite transport adapter, or standalone operation (no transport loaded or transport explicitly disabled). Disconnected or unknown cluster membership does not authorize a competing key.
32
+
33
+ `crypt.cluster_secret_timeout` bounds initialization (default: 5 seconds; finite and positive). `crypt.cluster_length` must be a positive integer (default: 32 bytes). Invalid secrets, discovery failures, and shutdown cancellation raise `Legion::Crypt::ClusterSecret::InitializationError`; encryption and decryption do not proceed with a missing key.
34
+
35
+ Use `Legion::Crypt.set_cluster_secret(secret)` to validate and install a received secret. Derived keys also detect settings replacement or mutation, so reloads cannot continue using the previous secret.
36
+
29
37
  ### JWT Tokens
30
38
 
31
39
  ```ruby
@@ -57,10 +65,10 @@ claims = Legion::Crypt.verify_external_token(
57
65
  )
58
66
 
59
67
  # Or use the JWT module directly
60
- claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url)
68
+ claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url, issuers: issuers, audience: audience)
61
69
  ```
62
70
 
63
- Public keys are cached for 1 hour and automatically re-fetched on cache miss (handles key rotation).
71
+ Public keys are cached for 1 hour. Concurrent misses for one URL share a refresh. The first unknown key ID triggers an immediate rotation check; subsequent missing-key lookups are throttled for 30 seconds per URL. Failed upstream lookups share the same cooldown, while still-valid cached keys remain usable. Expired caches and explicit `fetch_keys` calls refresh normally.
64
72
 
65
73
  ## Configuration
66
74
 
@@ -75,6 +83,7 @@ Public keys are cached for 1 hour and automatically re-fetched on cache miss (ha
75
83
  "connected": false,
76
84
  "renewer_time": 5,
77
85
  "renewer": true,
86
+ "operation_timeout": 5,
78
87
  "push_cluster_secret": true,
79
88
  "read_cluster_secret": true,
80
89
  "kv_path": "legion",
@@ -105,7 +114,25 @@ Public keys are cached for 1 hour and automatically re-fetched on cache miss (ha
105
114
 
106
115
  ### Vault Integration
107
116
 
108
- 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.
117
+ When `vault.token` is set (or via `VAULT_TOKEN_ID` env var), Crypt connects to Vault on `start`. Vault requests use the core-owned `Legion::Crypt::VaultClient`, built on Faraday and `Legion::JSON`; neither vault-ruby nor a LEX extension is required. Responses have symbol keys, including with JSON 3.
118
+
119
+ `Legion::Crypt.vault_client` returns the cached default client; pass a cluster name for an explicit client. Its logical, KV, and lease endpoints use keyword arguments and ignore extra keywords while still enforcing required arguments. Existing high-level Crypt and LeaseManager call signatures are unchanged:
120
+
121
+ ```ruby
122
+ client = Legion::Crypt.vault_client
123
+ lease = client.logical.read(path: 'database/creds/application')
124
+ lease.data # symbol-keyed credentials
125
+ client.sys.renew(lease_id: lease.lease_id)
126
+ client.kv(mount: 'legion').write(path: 'application', data: { enabled: true })
127
+ ```
128
+
129
+ The existing `Legion::Crypt.read`, `get`, `write`, and `delete` interfaces are unchanged. Logical reads preserve lease metadata; KV reads unwrap the KV v2 data envelope. Missing reads return `nil`, while HTTP failures raise `Legion::Crypt::Vault::HTTPClientError` (4xx), `HTTPServerError` (5xx), or their `HTTPError` base class with a numeric `code`. Redirects are not followed.
130
+
131
+ Standalone clients accept `address:`, `token:`, `namespace:`, `timeout:`, and `tls:`. TLS uses the existing `verify: 'peer'`, `'none'`, or `'mutual'` configuration with `ca:`, `cert:`, and `key:` material. CA, certificate, and key values can be PEM or file paths; client identities also accept OpenSSL objects. `shutdown` permanently retires a client, including retained endpoint handles.
132
+
133
+ Vault-backed TLS material can resolve through another connected cluster without holding the client-cache lock. Explicit `ca`, `cert`, or `key` references that cannot resolve fail client construction rather than silently falling back to system trust or dropping the identity. Circular TLS dependencies are rejected; bootstrap the source cluster with independently available trust material.
134
+
135
+ Lease acquisition, renewal, reissue, token maintenance, and session revocation use the finite-positive `crypt.vault.operation_timeout` budget (default: 5 seconds). Failures are isolated per item. Shutdown shares a 10-second deadline, invalidates worker generations before external cleanup, and resets lifecycle state even when revocation fails. Late callbacks cannot restore credentials or settings after their lifecycle stops.
109
136
 
110
137
  ### Dynamic Vault Leases
111
138
 
@@ -146,11 +173,23 @@ Both `username` and `password` come from a single Vault read — one lease, one
146
173
  - Detects credential rotation and pushes new values into `Legion::Settings` in-place
147
174
  - Revokes all leases on `Crypt.shutdown`
148
175
 
149
- Lease names are stable across environments. The actual Vault paths are deployment-specific config.
176
+ Failed acquisition remains pending with exponential backoff from 1 second to a 60-second cap. Maintenance wakes for the next renewal or retry deadline (10 ms minimum, 5 seconds maximum), rather than waiting five seconds before servicing short-TTL leases.
177
+
178
+ TTL-only renewals update lease lifetime without reconnecting consumers whose credentials and settings are unchanged. A renewal that returns rotated credentials still publishes the new values and verifies the consumer reconnect before committing.
179
+
180
+ Expired leases are reacquired rather than repeatedly renewed after timeout. `fetch`, `lease_data`, settings publication and initialized counts exclude expired cached credentials, so `lease://` fallback chains can proceed during an outage. Lease ownership remains tracked for cleanup even when expired credential data is discarded.
181
+
182
+ Credential replacement installs settings and cache, confirms the required consumer reconnect, then retires the previous lease. Failed handoffs restore the previous settings and cache; old and unconfirmed candidate IDs remain owned until safe cleanup or shutdown. Failed revocations are retried, using the Vault client that issued the lease. RabbitMQ must report an open session; PostgreSQL must support and successfully complete `reconnect_with_fresh_creds`; Redis must reconnect successfully and report connected when that status is available.
183
+
184
+ Lease names are stable across environments. The actual Vault paths are deployment-specific config. `active_leases` uses Symbol names; `fetch` and `lease_data` accept either Strings or Symbols.
185
+
186
+ `LeaseManager#register_ref(name, key, path)` accepts typed destination paths: Symbols for existing Hash keys and nonnegative Integers for existing Array indexes. A lease-data key can target multiple destinations; duplicate registrations are ignored. `push_to_settings` updates valid destinations, including nested arrays, and returns the number of successful writes. Invalid, missing, frozen, or out-of-bounds destinations are not modified. Transactional credential handoff requires every referenced destination to be writable before changing any of them.
150
187
 
151
188
  ## Multi-Cluster Vault
152
189
 
153
- `VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `::Vault::Client` instance.
190
+ `VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `Legion::Crypt::VaultClient` instance.
191
+
192
+ Clients are reused only while their endpoint, namespace, TLS verification, and token match the current configuration. Connection changes replace the cached client without disabling lease or token owners retaining its predecessor. Displaced clients are weakly tracked, so the cache does not keep unused clients alive; shutdown permanently closes all surviving clients after owner cleanup. In-place authentication token refreshes preserve the current client.
154
193
 
155
194
  ```json
156
195
  {
@@ -182,7 +221,7 @@ Lease names are stable across environments. The actual Vault paths are deploymen
182
221
  Legion::Crypt.ldap_login_all(username: 'user', password: 'pass')
183
222
 
184
223
  # Read from specific cluster
185
- Legion::Crypt.read('secret/data/mykey', cluster: :secondary)
224
+ Legion::Crypt.read('secret/data/mykey', cluster_name: :secondary)
186
225
 
187
226
  # Get a Vault client for a specific cluster
188
227
  client = Legion::Crypt.vault_client(:primary)
@@ -190,6 +229,12 @@ client = Legion::Crypt.vault_client(:primary)
190
229
 
191
230
  When `clusters` is empty, the legacy single-cluster path is used (backward compatible).
192
231
 
232
+ LDAP login starts token maintenance immediately, including after `Crypt.start`. Cluster state retains `lease_duration`, `renewable`, `expires_at`, and `relogin_required`; passwords are used only for the login request. Renewable tokens renew at 75% of their lifetime through the same cluster client. Transient failures retry within the remaining lifetime; nonrenewable tokens remain available until expiry. Expiry or a terminal renewal rejection marks the cluster disconnected and sets `relogin_required: true`. Authenticate again with `ldap_login` or `ldap_login_all`; LDAP never falls back to Kerberos authentication.
233
+
234
+ Repeated login stops the previous token worker before installing its replacement, and shutdown stops all token workers. With clusters configured, unavailable or expired clusters cannot fall back to the legacy global Vault client.
235
+
236
+ JWT and Kerberos `login!` use the same grant lifecycle: token, TTL, expiry, renewability and authentication strategy replace the previous state together. Existing token workers stop before publication and replacement workers use the new grant's lifetime. JWT tokens renew without Kerberos fallback; a login result completing after shutdown is rejected.
237
+
193
238
  ## Kerberos Authentication
194
239
 
195
240
  When `crypt.vault.auth_method` is set to `kerberos`, `Crypt.start` performs Kerberos auto-auth to Vault using `KerberosAuth`:
@@ -209,20 +254,29 @@ When `crypt.vault.auth_method` is set to `kerberos`, `Crypt.start` performs Kerb
209
254
  }
210
255
  ```
211
256
 
212
- The SPNEGO token is sent as an HTTP `Authorization` header (not JSON body). The Vault namespace is cleared before auth (Kerberos mount is at root) and restored after. Requires Homebrew MIT Kerberos (`brew install krb5`) on macOS — the system Heimdal library is not compatible.
257
+ The SPNEGO token is sent as an HTTP `Authorization` header (not JSON body). Kerberos auth uses the configured namespace, where the auth mount resides; configure the cluster with `"namespace": null` for a root-mounted auth method. The client namespace is not cleared or temporarily changed during authentication. Requires Homebrew MIT Kerberos (`brew install krb5`) on macOS — the system Heimdal library is not compatible.
213
258
 
214
259
  `TokenRenewer` keeps the Vault token alive: renews at 75% TTL, re-auths via Kerberos if renewal fails, uses exponential backoff.
215
260
 
261
+ ## SPIFFE Workload API
262
+
263
+ The local Unix-socket Workload API exchange has a shared 10-second monotonic deadline. Connection, HTTP/2 handshake, request writes, and flushes additionally share a 5-second setup budget; response reads use the remaining operation time. Availability probes are bounded to 5 seconds, and failed exchanges close acquired sockets.
264
+
265
+ Responses may split the gRPC prefix or body across HTTP/2 DATA frames and socket reads. JWT-SVID audiences use protobuf varint lengths, including audiences of 128 bytes or longer. X.509 fallback remains opt-in; JWT-SVID failures raise `Spiffe::SvidError`.
266
+
216
267
  ## mTLS
217
268
 
218
- `Crypt::Mtls` issues mTLS certificates from Vault PKI. `Crypt::CertRotation` runs a background thread renewing certs at 50% TTL. `Transport::Connection::Vault` applies tempfile-based Bunny mTLS. Feature-flagged via `security.mtls.enabled: false`.
269
+ `Crypt::Mtls` issues mTLS certificates from Vault PKI. When `security.mtls.enabled` is true, `Legion::Service` owns a `Crypt::CertRotation` worker. The worker renews at expiry minus the certificate lifetime multiplied by `security.mtls.renewal_window` (default `0.5`), rather than waiting for a fixed polling interval. Initial issuance and renewal failures retry with exponential backoff from 1 to 60 seconds; a failed renewal retains the last installed certificate. Shutdown interrupts scheduled waits, bounds in-flight issuance with the Vault operation timeout, and prevents late results from replacing the certificate.
270
+
271
+ `Transport::Connection::Vault` independently acquires and applies tempfile-based Bunny mTLS credentials. Certificate rotation does not itself close transport connections when a certificate expires. mTLS is disabled by default.
219
272
 
220
273
  ## Requirements
221
274
 
222
275
  - Ruby >= 3.4
223
276
  - `ed25519` (~> 1.3)
224
277
  - `jwt` gem (>= 2.7)
225
- - `vault` gem (>= 0.17, optional)
278
+ - `faraday` gem (~> 2.0)
279
+ - `legion-json` gem (>= 1.2.0; JSON 3 supported)
226
280
  - HashiCorp Vault (optional, for secrets management)
227
281
  - `gssapi` gem (optional, required for Kerberos auth)
228
282
 
data/legion-crypt.gemspec CHANGED
@@ -27,7 +27,8 @@ Gem::Specification.new do |spec|
27
27
 
28
28
  spec.add_dependency 'concurrent-ruby', '~> 1.3'
29
29
  spec.add_dependency 'ed25519', '~> 1.3'
30
+ spec.add_dependency 'faraday', '~> 2.0'
30
31
  spec.add_dependency 'jwt', '>= 2.7'
32
+ spec.add_dependency 'legion-json', '>= 1.2.0'
31
33
  spec.add_dependency 'legion-logging', '>= 1.5.0'
32
- spec.add_dependency 'vault', '>= 0.17'
33
34
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_operation'
4
5
 
5
6
  module Legion
6
7
  module Crypt
@@ -8,115 +9,127 @@ module Legion
8
9
  include Legion::Logging::Helper
9
10
 
10
11
  DEFAULT_CHECK_INTERVAL = 43_200 # 12 hours
12
+ MINIMUM_SLEEP = 0.01
13
+ INITIAL_RETRY_DELAY = 1
14
+ MAX_RETRY_DELAY = 60
11
15
 
12
16
  attr_reader :check_interval, :current_cert, :issued_at
13
17
 
14
18
  def initialize(check_interval: DEFAULT_CHECK_INTERVAL)
19
+ unless check_interval.is_a?(Numeric) && check_interval.finite? && check_interval.positive?
20
+ raise ArgumentError, 'Certificate check interval must be finite and positive'
21
+ end
22
+
15
23
  @check_interval = check_interval
16
24
  @current_cert = nil
17
25
  @issued_at = nil
18
26
  @running = false
19
27
  @thread = nil
20
28
  @mutex = Mutex.new
29
+ @wake = ConditionVariable.new
30
+ @generation = 0
31
+ @retry_delay = INITIAL_RETRY_DELAY
32
+ @next_attempt = 0
21
33
  end
22
34
 
23
35
  def start
24
36
  return unless Legion::Crypt::Mtls.enabled?
25
- return if running?
26
37
 
27
- @running = true
28
- @thread = Thread.new { rotation_loop }
38
+ @mutex.synchronize do
39
+ return if @thread&.alive?
40
+
41
+ @running = true
42
+ @generation += 1
43
+ generation = @generation
44
+ @thread = Thread.new { rotation_loop(generation: generation) }
45
+ end
29
46
  log.info('[mTLS] CertRotation started')
30
47
  end
31
48
 
32
- def stop
33
- @running = false
34
- begin
35
- @thread&.wakeup
36
- rescue ThreadError => e
37
- handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.stop')
38
- nil
39
- end
40
- @thread&.join(2)
41
- if @thread&.alive?
42
- log.warn '[mTLS] CertRotation thread did not stop within timeout'
43
- else
44
- @thread = nil
49
+ def stop(deadline: nil)
50
+ thread = @mutex.synchronize do
51
+ @running = false
52
+ @generation += 1
53
+ @wake.broadcast
54
+ @thread
45
55
  end
56
+ deadline ||= ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + Legion::Crypt::VaultOperation::SHUTDOWN_TIMEOUT
57
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) { thread.join } if thread && thread != Thread.current
58
+ @mutex.synchronize { @thread = nil if @thread.equal?(thread) && !thread&.alive? }
46
59
  log.info('[mTLS] CertRotation stopped')
60
+ rescue StandardError => e
61
+ handle_exception(e, level: :warn, operation: 'crypt.cert_rotation.stop')
47
62
  end
48
63
 
49
64
  def running?
50
- (@running && @thread&.alive?) || false
65
+ @mutex.synchronize { (@running && @thread&.alive?) || false }
51
66
  end
52
67
 
53
- def rotate!
54
- node_name = node_common_name
55
- new_cert = Legion::Crypt::Mtls.issue_cert(common_name: node_name)
56
- @mutex.synchronize do
68
+ def rotate!(generation: nil)
69
+ generation ||= @mutex.synchronize { @generation }
70
+ new_cert = Legion::Crypt::VaultOperation.with_timeout { Legion::Crypt::Mtls.issue_cert(common_name: node_common_name) }
71
+ issued_at = Time.now
72
+ raise 'Vault PKI returned an expired certificate' unless new_cert[:expiry] > issued_at
73
+
74
+ installed = @mutex.synchronize do
75
+ next false unless generation == @generation
76
+
57
77
  @current_cert = new_cert
58
- @issued_at = Time.now
78
+ @issued_at = issued_at
79
+ @retry_delay = INITIAL_RETRY_DELAY
80
+ @next_attempt = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + MINIMUM_SLEEP
81
+ @wake.broadcast
82
+ true
59
83
  end
84
+ return unless installed
85
+
60
86
  log.info("[mTLS] Certificate rotated: serial=#{new_cert[:serial]} expiry=#{new_cert[:expiry]}")
61
87
  emit_rotated_event(new_cert)
62
88
  new_cert
63
89
  end
64
90
 
65
91
  def needs_renewal?
66
- current_cert = nil
67
- issued_at = nil
68
- @mutex.synchronize do
69
- current_cert = @current_cert
70
- issued_at = @issued_at
71
- end
72
- return false if current_cert.nil? || issued_at.nil?
73
-
74
- expiry = current_cert[:expiry]
75
- total = expiry - issued_at
76
- return true if total <= 0
77
-
78
- remaining = expiry - Time.now
79
- fraction = remaining / total
80
- fraction < renewal_window
92
+ @mutex.synchronize { renewal_delay <= 0 }
81
93
  end
82
94
 
83
95
  private
84
96
 
85
- def rotation_loop
86
- rotate!
87
- rescue StandardError => e
88
- handle_exception(e, level: :error, operation: 'crypt.cert_rotation.rotation_loop')
89
- log.error("[mTLS] Initial rotation failed: #{e.message}")
90
- ensure
91
- loop_check
92
- end
93
-
94
- def loop_check
95
- while @running
96
- interruptible_sleep(@check_interval)
97
- next unless @running && needs_renewal?
97
+ def rotation_loop(generation:)
98
+ loop do
99
+ due = @mutex.synchronize do
100
+ return unless @running && generation == @generation
101
+
102
+ retry_wait = @next_attempt - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
103
+ wait = [retry_wait, renewal_delay].max
104
+ if wait.positive?
105
+ @wake.wait(@mutex, [wait, @check_interval].min.clamp(MINIMUM_SLEEP, Float::INFINITY))
106
+ false
107
+ else
108
+ true
109
+ end
110
+ end
111
+ next unless due
98
112
 
99
113
  begin
100
- rotate!
114
+ rotate!(generation: generation)
101
115
  rescue StandardError => e
102
- handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check')
103
- log.error("[mTLS] Rotation check failed: #{e.message}")
116
+ handle_exception(e, level: :error, operation: 'crypt.cert_rotation.rotation_loop')
117
+ @mutex.synchronize do
118
+ next unless @running && generation == @generation
119
+
120
+ @next_attempt = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @retry_delay
121
+ @retry_delay = [@retry_delay * 2, MAX_RETRY_DELAY].min
122
+ end
104
123
  end
105
124
  end
106
- rescue StandardError => e
107
- handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check')
108
- log.error("[mTLS] CertRotation loop error: #{e.message}")
109
- retry if @running
110
125
  end
111
126
 
112
- def interruptible_sleep(seconds)
113
- deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
114
- loop do
115
- remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
116
- break if remaining <= 0 || !@running
127
+ def renewal_delay
128
+ return 0 unless @current_cert && @issued_at
117
129
 
118
- sleep([remaining, 1.0].min)
119
- end
130
+ expiry = @current_cert[:expiry]
131
+ lifetime = expiry - @issued_at
132
+ expiry - Time.now - (lifetime * renewal_window)
120
133
  end
121
134
 
122
135
  def renewal_window
@@ -126,7 +139,12 @@ module Legion
126
139
  return 0.5 if security.nil?
127
140
 
128
141
  mtls = security[:mtls] || security['mtls'] || {}
129
- mtls[:renewal_window] || mtls['renewal_window'] || 0.5
142
+ window = mtls[:renewal_window] || mtls['renewal_window'] || 0.5
143
+ unless window.is_a?(Numeric) && window.finite? && window >= 0 && window < 1
144
+ raise ArgumentError, 'Certificate renewal window must be finite and between zero and one (exclusive)'
145
+ end
146
+
147
+ window
130
148
  rescue StandardError => e
131
149
  handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.renewal_window')
132
150
  0.5
@@ -37,7 +37,7 @@ module Legion
37
37
  end
38
38
 
39
39
  def decrypt(message, init_vector)
40
- secret = wait_for_cluster_secret
40
+ secret = cs
41
41
  result = if authenticated_ciphertext?(message)
42
42
  decrypt_authenticated(message, init_vector, secret)
43
43
  else
@@ -94,19 +94,6 @@ module Legion
94
94
 
95
95
  private
96
96
 
97
- def wait_for_cluster_secret
98
- loop do
99
- secret = cs
100
- return secret if secret.is_a?(String)
101
- break if Legion::Settings[:client][:shutting_down]
102
-
103
- log.debug('sleeping Legion::Crypt.decrypt due to CS not being set')
104
- sleep(0.5)
105
- end
106
-
107
- cs
108
- end
109
-
110
97
  def authenticated_ciphertext?(message)
111
98
  message.start_with?("#{AUTHENTICATED_PREFIX}:")
112
99
  end
@@ -121,11 +108,14 @@ module Legion
121
108
  message: message
122
109
  )
123
110
 
111
+ auth_tag = Base64.strict_decode64(encoded_auth_tag)
112
+ raise ArgumentError, 'invalid GCM authentication tag length' unless auth_tag.bytesize == 16
113
+
124
114
  decipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER)
125
115
  decipher.decrypt
126
116
  decipher.key = secret
127
117
  decipher.iv = Base64.strict_decode64(init_vector)
128
- decipher.auth_tag = Base64.strict_decode64(encoded_auth_tag)
118
+ decipher.auth_tag = auth_tag
129
119
  decipher.update(Base64.strict_decode64(encoded_ciphertext)) + decipher.final
130
120
  end
131
121