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 +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +145 -0
- data/README.md +74 -14
- data/legion-crypt.gemspec +1 -1
- data/lib/legion/crypt/cert_rotation.rb +83 -65
- data/lib/legion/crypt/cipher.rb +5 -15
- data/lib/legion/crypt/cluster_secret.rb +71 -19
- data/lib/legion/crypt/erasure.rb +3 -10
- data/lib/legion/crypt/jwks_client.rb +43 -31
- data/lib/legion/crypt/jwt.rb +8 -7
- data/lib/legion/crypt/kerberos_auth.rb +18 -8
- data/lib/legion/crypt/ldap_auth.rb +14 -13
- data/lib/legion/crypt/lease_manager.rb +648 -333
- data/lib/legion/crypt/mtls.rb +3 -6
- data/lib/legion/crypt/partition_keys.rb +2 -0
- data/lib/legion/crypt/settings.rb +2 -1
- data/lib/legion/crypt/spiffe/workload_api_client.rb +83 -71
- data/lib/legion/crypt/token_renewer.rb +212 -82
- data/lib/legion/crypt/vault.rb +113 -83
- data/lib/legion/crypt/vault_client.rb +355 -0
- data/lib/legion/crypt/vault_cluster.rb +161 -39
- data/lib/legion/crypt/vault_entity.rb +21 -34
- data/lib/legion/crypt/vault_jwt_auth.rb +9 -20
- data/lib/legion/crypt/vault_kerberos_auth.rb +11 -14
- data/lib/legion/crypt/vault_operation.rb +27 -0
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +116 -63
- metadata +17 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d7d1cbd214ebc5bc7f8d109b958e3d307e3a4faaa81fdb2c47b527670ab720f
|
|
4
|
+
data.tar.gz: 4ea67b85552ddae267d4945be1586e7248a30b3dc38dfa2f280ae59311524614
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2af577b9a0c3c7e464e79f11bbd13e044d9e3e76520c2b4ee6ad27949135c0e873aa8aadfca33d67324061dd55d1f7f5f21ceb1715aa9a7d1d0f1fac77d74852
|
|
7
|
+
data.tar.gz: 4226e44bc08ebdc1928ce91630125db8e9a3c674e5ce64cf47ed7375706876388f9aeb3e93816402e41b10c0961bb7f19bd57dc6f64e826289333b27f558c36e
|
data/.rubocop.yml
CHANGED
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-
|
|
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(
|
|
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
|
|
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`.
|
|
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
|
-
|
|
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
|
|
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',
|
|
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).
|
|
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. `
|
|
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
|
-
- `
|
|
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
|
-
@
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
55
|
-
new_cert = Legion::Crypt::Mtls.issue_cert(common_name:
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
103
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
data/lib/legion/crypt/cipher.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Legion
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def decrypt(message, init_vector)
|
|
40
|
-
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 =
|
|
118
|
+
decipher.auth_tag = auth_tag
|
|
129
119
|
decipher.update(Base64.strict_decode64(encoded_ciphertext)) + decipher.final
|
|
130
120
|
end
|
|
131
121
|
|