legion-crypt 1.5.13 → 1.5.38

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: 52409be9c9bdc3144775c70117e99a6d205bfe6df9485654ccaf86766d1705a8
4
- data.tar.gz: e050109245c71fb138170689e0a7f94e0df734d260f24b484261b8c92b432527
3
+ metadata.gz: 1d7d1cbd214ebc5bc7f8d109b958e3d307e3a4faaa81fdb2c47b527670ab720f
4
+ data.tar.gz: 4ea67b85552ddae267d4945be1586e7248a30b3dc38dfa2f280ae59311524614
5
5
  SHA512:
6
- metadata.gz: a00f255f040dcdd2bc22fa78f73a7a0dc0609648133ce068e9a1d899d700ac3bfa932d884c8ec41392cf9e3767a76e58a13ea8c88eda0597b7cf81be6d3fcb46
7
- data.tar.gz: 0aaa59d763953050f416aea1891c3d95f5019a42c627b47270d243c35b50414d6a93e2cba85a0ae7e5f73ee27c3f0049a4ec94463543e13e66da850c66e429c3
6
+ metadata.gz: 2af577b9a0c3c7e464e79f11bbd13e044d9e3e76520c2b4ee6ad27949135c0e873aa8aadfca33d67324061dd55d1f7f5f21ceb1715aa9a7d1d0f1fac77d74852
7
+ data.tar.gz: 4226e44bc08ebdc1928ce91630125db8e9a3c674e5ce64cf47ed7375706876388f9aeb3e93816402e41b10c0961bb7f19bd57dc6f64e826289333b27f558c36e
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,5 +1,150 @@
1
1
  # Legion::Crypt
2
2
 
3
+ ## [1.5.38] - 2026-10-31
4
+
5
+ ### Fixed
6
+ - Replace lease-backed settings bindings per destination and publish resolved values atomically, preserving other destinations and restoring assignments and binding metadata on failed publication or credential handoff (LegionIO/legion-settings#21).
7
+ - Track each binding's last published value through rotation, retain standalone settings roots, and follow the active loader when its settings root is rebuilt.
8
+
9
+ ## [1.5.37] - 2026-10-30
10
+
11
+ ### Changed
12
+ - 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).
13
+
14
+ ## [1.5.36] - 2026-10-30
15
+
16
+ ### Fixed
17
+ - 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).
18
+
19
+ ## [1.5.35] - 2026-10-30
20
+
21
+ ### Fixed
22
+ - Skip unchanged settings destinations during lease handoff so TTL-only renewals preserve live consumer connections; credential changes still publish and reconnect transactionally (#52).
23
+
24
+ ## [1.5.34] - 2026-10-30
25
+
26
+ ### Fixed
27
+ - Transition pending renewal retries to fresh acquisition after lease expiry, including renewal timeouts, while preserving backoff and last-known lease ownership (#52).
28
+ - Revalidate captured lease ownership before reacquisition or retry publication so stale maintenance cannot replace a newer handoff (#52).
29
+
30
+ ## [1.5.33] - 2026-10-30
31
+
32
+ ### Fixed
33
+ - Resolve Vault-backed TLS material outside the client-cache lock, then revalidate settings and lifecycle generation before publishing a candidate (#52).
34
+ - Reject unresolved explicit trust/identity material and circular Vault TLS dependencies; close superseded or shutdown-interrupted client candidates (#52).
35
+
36
+ ## [1.5.32] - 2026-10-30
37
+
38
+ ### Fixed
39
+ - 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).
40
+ - Reject authentication results from stopped lifecycles and resume managed JWT token renewal for both legacy and multi-cluster configurations (#52).
41
+
42
+ ## [1.5.31] - 2026-10-30
43
+
44
+ ### Fixed
45
+ - 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).
46
+ - Reject token publication to clients that have already shut down (#52).
47
+
48
+ ## [1.5.30] - 2026-10-30
49
+
50
+ ### Fixed
51
+ - 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).
52
+
53
+ ## [1.5.29] - 2026-10-30
54
+
55
+ ### Fixed
56
+ - 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).
57
+
58
+ ## [1.5.28] - 2026-10-30
59
+
60
+ ### Fixed
61
+ - Bootstrap a confirmed empty cluster before waiting for a peer secret, preserving the bounded discovery deadline and refusing generation when membership is unknown (#52).
62
+
63
+ ## [1.5.27] - 2026-10-30
64
+
65
+ ### Changed
66
+ - Replace vault-ruby with the core-owned Faraday and `Legion::JSON` Vault client, supporting JSON 3 without an extension dependency (#38).
67
+ - 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).
68
+ - Support peer and mutual TLS, current credential headers, typed HTTP errors, KV v2 envelopes, finite transport timeouts, and permanent client retirement (#38).
69
+
70
+ ### Fixed
71
+ - Do not report tenant erasure or emit an erasure event when Vault deletion returns a failure result (#38).
72
+ - 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).
73
+
74
+ ## [1.5.26] - 2026-10-29
75
+
76
+ ### Fixed
77
+ - Bound SPIFFE socket connection, handshake, request writes, flushes, and response reads with shared monotonic deadlines; close sockets when setup or exchange fails (#43).
78
+ - Reassemble gRPC prefixes and message bodies across HTTP/2 DATA frames and partial socket reads, returning only the declared message bytes (#43).
79
+ - Encode JWT-SVID audience lengths as canonical protobuf varints, including lengths of 128 bytes and larger (#43).
80
+
81
+ ## [1.5.25] - 2026-10-29
82
+
83
+ ### Fixed
84
+ - 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).
85
+ - 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).
86
+ - 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).
87
+
88
+ ## [1.5.24] - 2026-10-29
89
+
90
+ ### Fixed
91
+ - Retry initial certificate issuance and failed renewals with capped exponential backoff while retaining the last installed certificate (#49).
92
+ - Schedule certificate rotation from expiry and the configured renewal window, interrupt pending waits on shutdown, and discard late issuance from stopped workers (#49).
93
+
94
+ ## [1.5.23] - 2026-10-29
95
+
96
+ ### Fixed
97
+ - Retain failed lease acquisitions for capped exponential-backoff retry and schedule maintenance against lease expiry, waking promptly for newly registered short-TTL leases (#50).
98
+ - 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).
99
+ - Capture each lease's issuing Vault client, deduplicate revocation, prevent retired candidates from being reinstalled, and keep late renewals from recreating revoked ownership (#50).
100
+ - Scope timeout-spec cleanup to the override/example block so failed settings snapshots preserve the original error and leave settings unchanged (#52).
101
+
102
+ ## [1.5.22] - 2026-10-29
103
+
104
+ ### Fixed
105
+ - Rotate every unique lease-reference destination, including nested arrays, without coercing typed paths or mutating invalid destinations; report actual successful writes (#45).
106
+
107
+ ## [1.5.21] - 2026-10-29
108
+
109
+ ### Fixed
110
+ - 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).
111
+
112
+ ## [1.5.20] - 2026-10-29
113
+
114
+ ### Fixed
115
+ - Bound Vault maintenance and shutdown, isolate failed leases and sessions, and permit restart after cleanup failures (#44).
116
+ - Guard late lease, token, settings, and Kerberos-principal updates with lifecycle generations and cancellation-aware publication (#44).
117
+
118
+ ## [1.5.19] - 2026-10-29
119
+
120
+ ### Fixed
121
+ - 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).
122
+
123
+ ## [1.5.18] - 2026-10-29
124
+
125
+ ### Fixed
126
+ - Reject malformed JWT header shapes, key identifiers, and token algorithms through JWT-domain errors before JWKS lookup, while keeping caller configuration failures distinct (#46).
127
+
128
+ ## [1.5.17] - 2026-10-29
129
+
130
+ ### Fixed
131
+ - Resolve KV mounts from the Vault settings subtree on the composed Crypt API for both clustered and legacy clients (#48).
132
+
133
+ ## [1.5.16] - 2026-10-29
134
+
135
+ ### Fixed
136
+ - 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).
137
+
138
+ ## [1.5.15] - 2026-10-29
139
+
140
+ ### Fixed
141
+ - Create saved RSA private keys with owner-only permissions and repair existing file permissions before writing, without changing save-disabled behavior (#41).
142
+
143
+ ## [1.5.14] - 2026-10-29
144
+
145
+ ### Fixed
146
+ - Require full 16-byte GCM authentication tags for message and tenant decryption, preventing truncated-tag forgery while preserving legacy ciphertext support (#40).
147
+
3
148
  ## [1.5.13] - 2026-05-09
4
149
 
5
150
  ### Removed
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,29 @@ 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.
187
+
188
+ Starting with Crypt **1.5.38**, Settings **1.4.4** uses `LeaseManager#update_settings_refs(settings:, publish: nil)` to replace destination bindings and publish resolved settings atomically. Preparation runs under the lease-state mutex with a memory-only `reader.call(name:, key:)`; external secret reads must finish beforehand. The proposal contains typed `paths` to replace, `assignments` (`container`, `key`, `value`), and selected `references` (`name`, `key`, `path`, mutable `binding` containing `value`). Other destinations remain registered, and ambiguous writers are rejected.
189
+
190
+ `publish.call(proposal)` is restricted to in-memory pointer/metadata publication, not consumer callbacks or manager calls. Failed preparation, validation, or publication preserves prior assignments and bindings. Publication waits for an in-flight credential handoff to commit or roll back; successful rotations update each binding's tracked value, and failed handoffs restore both the target and its separately tracked previous value. Shutdown and reset clear bindings with the registry.
191
+
192
+ Release Crypt before Settings. Older `register_ref` callers remain supported, but source priority and reload provenance are handled by Settings, not by Crypt.
150
193
 
151
194
  ## Multi-Cluster Vault
152
195
 
153
- `VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `::Vault::Client` instance.
196
+ `VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `Legion::Crypt::VaultClient` instance.
197
+
198
+ 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
199
 
155
200
  ```json
156
201
  {
@@ -182,7 +227,7 @@ Lease names are stable across environments. The actual Vault paths are deploymen
182
227
  Legion::Crypt.ldap_login_all(username: 'user', password: 'pass')
183
228
 
184
229
  # Read from specific cluster
185
- Legion::Crypt.read('secret/data/mykey', cluster: :secondary)
230
+ Legion::Crypt.read('secret/data/mykey', cluster_name: :secondary)
186
231
 
187
232
  # Get a Vault client for a specific cluster
188
233
  client = Legion::Crypt.vault_client(:primary)
@@ -190,6 +235,12 @@ client = Legion::Crypt.vault_client(:primary)
190
235
 
191
236
  When `clusters` is empty, the legacy single-cluster path is used (backward compatible).
192
237
 
238
+ 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.
239
+
240
+ 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.
241
+
242
+ 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.
243
+
193
244
  ## Kerberos Authentication
194
245
 
195
246
  When `crypt.vault.auth_method` is set to `kerberos`, `Crypt.start` performs Kerberos auto-auth to Vault using `KerberosAuth`:
@@ -209,20 +260,29 @@ When `crypt.vault.auth_method` is set to `kerberos`, `Crypt.start` performs Kerb
209
260
  }
210
261
  ```
211
262
 
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.
263
+ 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
264
 
214
265
  `TokenRenewer` keeps the Vault token alive: renews at 75% TTL, re-auths via Kerberos if renewal fails, uses exponential backoff.
215
266
 
267
+ ## SPIFFE Workload API
268
+
269
+ 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.
270
+
271
+ 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`.
272
+
216
273
  ## mTLS
217
274
 
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`.
275
+ `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.
276
+
277
+ `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
278
 
220
279
  ## Requirements
221
280
 
222
281
  - Ruby >= 3.4
223
282
  - `ed25519` (~> 1.3)
224
283
  - `jwt` gem (>= 2.7)
225
- - `vault` gem (>= 0.17, optional)
284
+ - `faraday` gem (~> 2.0)
285
+ - `legion-json` gem (>= 1.2.0; JSON 3 supported)
226
286
  - HashiCorp Vault (optional, for secrets management)
227
287
  - `gssapi` gem (optional, required for Kerberos auth)
228
288
 
data/legion-crypt.gemspec CHANGED
@@ -27,8 +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'
31
32
  spec.add_dependency 'legion-json', '>= 1.2.0'
32
33
  spec.add_dependency 'legion-logging', '>= 1.5.0'
33
- spec.add_dependency 'vault', '>= 0.17'
34
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