legion-crypt 1.4.29 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile +1 -0
- data/legion-crypt.gemspec +1 -0
- data/lib/legion/crypt/attestation.rb +18 -8
- data/lib/legion/crypt/cert_rotation.rb +54 -42
- data/lib/legion/crypt/cipher.rb +106 -15
- data/lib/legion/crypt/cluster_secret.rb +40 -38
- data/lib/legion/crypt/ed25519.rb +58 -18
- data/lib/legion/crypt/erasure.rb +21 -8
- data/lib/legion/crypt/jwks_client.rb +37 -9
- data/lib/legion/crypt/jwt.rb +75 -31
- data/lib/legion/crypt/kerberos_auth.rb +23 -13
- data/lib/legion/crypt/ldap_auth.rb +12 -4
- data/lib/legion/crypt/lease_manager.rb +126 -73
- data/lib/legion/crypt/mtls.rb +14 -1
- data/lib/legion/crypt/partition_keys.rb +15 -5
- data/lib/legion/crypt/settings.rb +16 -10
- data/lib/legion/crypt/spiffe/identity_helpers.rb +18 -11
- data/lib/legion/crypt/spiffe/svid_rotation.rb +23 -33
- data/lib/legion/crypt/spiffe/workload_api_client.rb +61 -17
- data/lib/legion/crypt/spiffe.rb +18 -4
- data/lib/legion/crypt/tls.rb +14 -10
- data/lib/legion/crypt/token_renewer.rb +29 -26
- data/lib/legion/crypt/vault.rb +57 -45
- data/lib/legion/crypt/vault_cluster.rb +35 -17
- data/lib/legion/crypt/vault_jwt_auth.rb +17 -4
- data/lib/legion/crypt/vault_kerberos_auth.rb +11 -1
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +69 -32
- data/lib/legion/logging/helper.rb +98 -0
- data/lib/legion/logging.rb +58 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b7ffab5835f3a3ac600a9bfe87e1e015e362d07d5814c956c13cc7552d23775
|
|
4
|
+
data.tar.gz: 18179a5915360c9f22151ec6a08ac813e580c2c2fb36dffd95b6f42aa9a4c242
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cce5f0d26e384c890c7117f0e710a9ff0f27da5794e8c2c9f05b3bdd81cacf3a551ffaaa9f31a75fea68e512918370b6deb8870c30cd010ea2da622a87c04611
|
|
7
|
+
data.tar.gz: acac22b254e94ccfd8ead507e014c7a619b715f8aab8000454d2e428633096ea3b251e21ab51ba67b31c879d6b07733670eb4cff1bf657ec553892262f72d199
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Legion::Crypt
|
|
2
2
|
|
|
3
|
+
## [1.5.0] - 2026-04-02
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Cluster-secret Vault synchronization now uses the documented `push_cluster_secret` setting, stores the new secret before pushing it, aligns Vault read/write path and field names, and invalidates cached derived key material on rotation
|
|
7
|
+
- External JWT/JWKS verification now fails closed on missing issuer or audience expectations, keeps reserved claims library-controlled, requires HTTPS JWKS transport, and avoids repeated fresh-cache re-fetches for unknown `kid` values
|
|
8
|
+
- Shared symmetric encryption now emits authenticated AES-256-GCM payloads for new ciphertexts while preserving decrypt compatibility with legacy AES-256-CBC payloads
|
|
9
|
+
- RSA keypair helper encryption now uses explicit OAEP padding for new ciphertexts while preserving decrypt compatibility with legacy PKCS#1 v1.5 payloads
|
|
10
|
+
- Multi-cluster Vault auth and helper routing now update live LDAP client tokens, keep per-cluster LDAP state local to the addressed cluster, sync the top-level Vault connected flag from cluster connectivity checks, allow explicit cluster-targeted helper calls, refresh lease metadata on renewal, and schedule short-lived token renewals before expiry
|
|
11
|
+
- SPIFFE X.509 fetch now fails closed by default, uses an explicit `allow_x509_fallback` development switch for self-signed fallback SVIDs, tags returned SVIDs with their source, and sends a valid 9-byte HTTP/2 SETTINGS frame during Workload API connection setup
|
|
12
|
+
- Ed25519 helper key persistence now uses the supported `Legion::Crypt` API with normalized KV paths, and tenant erasure verification no longer reports success when Vault access itself failed
|
|
13
|
+
- Background worker lifecycle is now serialized and cooperative: repeated `Legion::Crypt.start` calls no longer spawn duplicate workers, renewal/rotation threads no longer use `Thread#kill`, and timed-out joins keep their live thread references instead of dropping them
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Adopted `Legion::Logging::Helper` across `lib/` so library logs use structured component tagging instead of direct `Legion::Logging.*` calls
|
|
17
|
+
- Expanded `info`/`debug`/`error` coverage across crypt, Vault, JWT, lease, mTLS, SPIFFE, and auth flows to make background actions and failures visible without exposing secrets
|
|
18
|
+
- Replaced manual rescue logging with `handle_exception(...)` across library code paths and left Sinatra/API integration untouched for a later pass
|
|
19
|
+
- Removed remaining `log_info`/`log_warn`/`log_debug` wrapper methods in `lib/` so helper-backed logging is used directly throughout the library
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Runtime dependency on `legion-logging`
|
|
23
|
+
- Compatibility shim for `Legion::Logging::Helper` so `handle_exception` and shared `log` access are available consistently during the uplift
|
|
24
|
+
|
|
3
25
|
## [1.4.29] - 2026-03-31
|
|
4
26
|
|
|
5
27
|
### Changed
|
data/Gemfile
CHANGED
data/legion-crypt.gemspec
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'legion/logging/helper'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Crypt
|
|
7
8
|
module Attestation
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
8
11
|
class << self
|
|
9
12
|
def create(agent_id:, capabilities:, state:, private_key:)
|
|
10
13
|
claim = {
|
|
@@ -17,30 +20,37 @@ module Legion
|
|
|
17
20
|
|
|
18
21
|
payload = Legion::JSON.dump(claim)
|
|
19
22
|
signature = Legion::Crypt::Ed25519.sign(payload, private_key)
|
|
20
|
-
|
|
23
|
+
log.info "Attestation created for agent #{agent_id}, state=#{state}"
|
|
21
24
|
|
|
22
25
|
{ claim: claim, signature: signature.unpack1('H*'), payload: payload }
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
handle_exception(e, level: :error, operation: 'crypt.attestation.create', agent_id: agent_id, state: state)
|
|
28
|
+
raise
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def verify(claim_hash:, signature_hex:, public_key:)
|
|
26
32
|
payload = Legion::JSON.dump(claim_hash)
|
|
27
33
|
signature = [signature_hex].pack('H*')
|
|
28
34
|
result = Legion::Crypt::Ed25519.verify(payload, signature, public_key)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
end
|
|
35
|
+
agent_id = claim_hash[:agent_id] || claim_hash['agent_id']
|
|
36
|
+
if result
|
|
37
|
+
log.info "Attestation verified for agent #{agent_id}"
|
|
38
|
+
else
|
|
39
|
+
log.warn "Attestation verification failed for agent #{agent_id}"
|
|
35
40
|
end
|
|
36
41
|
result
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
handle_exception(e, level: :warn, operation: 'crypt.attestation.verify',
|
|
44
|
+
agent_id: claim_hash[:agent_id] || claim_hash['agent_id'])
|
|
45
|
+
raise
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
def fresh?(claim_hash, max_age_seconds: 300)
|
|
40
49
|
timestamp = Time.parse(claim_hash[:timestamp])
|
|
41
50
|
Time.now.utc - timestamp < max_age_seconds
|
|
42
51
|
rescue StandardError => e
|
|
43
|
-
|
|
52
|
+
handle_exception(e, level: :warn, operation: 'crypt.attestation.fresh?',
|
|
53
|
+
agent_id: claim_hash[:agent_id] || claim_hash['agent_id'])
|
|
44
54
|
false
|
|
45
55
|
end
|
|
46
56
|
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Crypt
|
|
5
7
|
class CertRotation
|
|
8
|
+
include Legion::Logging::Helper
|
|
9
|
+
|
|
6
10
|
DEFAULT_CHECK_INTERVAL = 43_200 # 12 hours
|
|
7
11
|
|
|
8
12
|
attr_reader :check_interval, :current_cert, :issued_at
|
|
@@ -13,6 +17,7 @@ module Legion
|
|
|
13
17
|
@issued_at = nil
|
|
14
18
|
@running = false
|
|
15
19
|
@thread = nil
|
|
20
|
+
@mutex = Mutex.new
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
def start
|
|
@@ -21,17 +26,24 @@ module Legion
|
|
|
21
26
|
|
|
22
27
|
@running = true
|
|
23
28
|
@thread = Thread.new { rotation_loop }
|
|
24
|
-
|
|
29
|
+
log.info('[mTLS] CertRotation started')
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def stop
|
|
28
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)
|
|
29
41
|
if @thread&.alive?
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
log.warn '[mTLS] CertRotation thread did not stop within timeout'
|
|
43
|
+
else
|
|
44
|
+
@thread = nil
|
|
32
45
|
end
|
|
33
|
-
|
|
34
|
-
log_debug('[mTLS] CertRotation stopped')
|
|
46
|
+
log.info('[mTLS] CertRotation stopped')
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
def running?
|
|
@@ -41,18 +53,26 @@ module Legion
|
|
|
41
53
|
def rotate!
|
|
42
54
|
node_name = node_common_name
|
|
43
55
|
new_cert = Legion::Crypt::Mtls.issue_cert(common_name: node_name)
|
|
44
|
-
@
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@current_cert = new_cert
|
|
58
|
+
@issued_at = Time.now
|
|
59
|
+
end
|
|
60
|
+
log.info("[mTLS] Certificate rotated: serial=#{new_cert[:serial]} expiry=#{new_cert[:expiry]}")
|
|
47
61
|
emit_rotated_event(new_cert)
|
|
48
62
|
new_cert
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
def needs_renewal?
|
|
52
|
-
|
|
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?
|
|
53
73
|
|
|
54
|
-
expiry =
|
|
55
|
-
total = expiry -
|
|
74
|
+
expiry = current_cert[:expiry]
|
|
75
|
+
total = expiry - issued_at
|
|
56
76
|
return true if total <= 0
|
|
57
77
|
|
|
58
78
|
remaining = expiry - Time.now
|
|
@@ -65,27 +85,40 @@ module Legion
|
|
|
65
85
|
def rotation_loop
|
|
66
86
|
rotate!
|
|
67
87
|
rescue StandardError => e
|
|
68
|
-
|
|
88
|
+
handle_exception(e, level: :error, operation: 'crypt.cert_rotation.rotation_loop')
|
|
89
|
+
log.error("[mTLS] Initial rotation failed: #{e.message}")
|
|
69
90
|
ensure
|
|
70
91
|
loop_check
|
|
71
92
|
end
|
|
72
93
|
|
|
73
94
|
def loop_check
|
|
74
95
|
while @running
|
|
75
|
-
|
|
96
|
+
interruptible_sleep(@check_interval)
|
|
76
97
|
next unless @running && needs_renewal?
|
|
77
98
|
|
|
78
99
|
begin
|
|
79
100
|
rotate!
|
|
80
101
|
rescue StandardError => e
|
|
81
|
-
|
|
102
|
+
handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check')
|
|
103
|
+
log.error("[mTLS] Rotation check failed: #{e.message}")
|
|
82
104
|
end
|
|
83
105
|
end
|
|
84
106
|
rescue StandardError => e
|
|
85
|
-
|
|
107
|
+
handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check')
|
|
108
|
+
log.error("[mTLS] CertRotation loop error: #{e.message}")
|
|
86
109
|
retry if @running
|
|
87
110
|
end
|
|
88
111
|
|
|
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
|
|
117
|
+
|
|
118
|
+
sleep([remaining, 1.0].min)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
89
122
|
def renewal_window
|
|
90
123
|
return 0.5 unless defined?(Legion::Settings)
|
|
91
124
|
|
|
@@ -94,7 +127,8 @@ module Legion
|
|
|
94
127
|
|
|
95
128
|
mtls = security[:mtls] || security['mtls'] || {}
|
|
96
129
|
mtls[:renewal_window] || mtls['renewal_window'] || 0.5
|
|
97
|
-
rescue StandardError
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.renewal_window')
|
|
98
132
|
0.5
|
|
99
133
|
end
|
|
100
134
|
|
|
@@ -103,7 +137,8 @@ module Legion
|
|
|
103
137
|
|
|
104
138
|
name = Legion::Settings[:client]&.dig(:name) || Legion::Settings[:client]&.dig('name')
|
|
105
139
|
name || 'legion.internal'
|
|
106
|
-
rescue StandardError
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.node_common_name')
|
|
107
142
|
'legion.internal'
|
|
108
143
|
end
|
|
109
144
|
|
|
@@ -112,31 +147,8 @@ module Legion
|
|
|
112
147
|
|
|
113
148
|
Legion::Events.emit('cert.rotated', serial: cert[:serial], expiry: cert[:expiry])
|
|
114
149
|
rescue StandardError => e
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def log_info(msg)
|
|
119
|
-
if defined?(Legion::Logging)
|
|
120
|
-
Legion::Logging.info(msg)
|
|
121
|
-
else
|
|
122
|
-
$stdout.puts(msg)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def log_debug(msg)
|
|
127
|
-
if defined?(Legion::Logging)
|
|
128
|
-
Legion::Logging.debug(msg)
|
|
129
|
-
else
|
|
130
|
-
$stdout.puts("[DEBUG] #{msg}")
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def log_warn(msg)
|
|
135
|
-
if defined?(Legion::Logging)
|
|
136
|
-
Legion::Logging.warn(msg)
|
|
137
|
-
else
|
|
138
|
-
warn("[WARN] #{msg}")
|
|
139
|
-
end
|
|
150
|
+
handle_exception(e, level: :warn, operation: 'crypt.cert_rotation.emit_rotated_event')
|
|
151
|
+
log.warn("[mTLS] Event emit failed: #{e.message}")
|
|
140
152
|
end
|
|
141
153
|
end
|
|
142
154
|
end
|
data/lib/legion/crypt/cipher.rb
CHANGED
|
@@ -1,43 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'legion/logging/helper'
|
|
4
5
|
require 'legion/crypt/cluster_secret'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module Crypt
|
|
8
9
|
module Cipher
|
|
10
|
+
AUTHENTICATED_CIPHER = 'aes-256-gcm'
|
|
11
|
+
LEGACY_CIPHER = 'aes-256-cbc'
|
|
12
|
+
AUTHENTICATED_PREFIX = 'gcm'
|
|
13
|
+
RSA_OAEP_PREFIX = 'oaep'
|
|
14
|
+
RSA_OAEP_PADDING = OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
|
|
15
|
+
RSA_LEGACY_PADDING = OpenSSL::PKey::RSA::PKCS1_PADDING
|
|
16
|
+
|
|
9
17
|
include Legion::Crypt::ClusterSecret
|
|
18
|
+
include Legion::Logging::Helper
|
|
10
19
|
|
|
11
20
|
def encrypt(message)
|
|
12
|
-
cipher = OpenSSL::Cipher.new(
|
|
21
|
+
cipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER)
|
|
13
22
|
cipher.encrypt
|
|
14
23
|
cipher.key = cs
|
|
15
24
|
iv = cipher.random_iv
|
|
16
|
-
|
|
25
|
+
ciphertext = cipher.update(message) + cipher.final
|
|
26
|
+
encoded_ciphertext = Base64.strict_encode64(ciphertext)
|
|
27
|
+
encoded_auth_tag = Base64.strict_encode64(cipher.auth_tag)
|
|
28
|
+
result = {
|
|
29
|
+
enciphered_message: "#{AUTHENTICATED_PREFIX}:#{encoded_ciphertext}:#{encoded_auth_tag}",
|
|
30
|
+
iv: Base64.strict_encode64(iv)
|
|
31
|
+
}
|
|
32
|
+
log.debug 'Cipher encrypt completed'
|
|
33
|
+
result
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
handle_exception(e, level: :error, operation: 'crypt.cipher.encrypt')
|
|
36
|
+
raise
|
|
17
37
|
end
|
|
18
38
|
|
|
19
39
|
def decrypt(message, init_vector)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
secret = wait_for_cluster_secret
|
|
41
|
+
result = if authenticated_ciphertext?(message)
|
|
42
|
+
decrypt_authenticated(message, init_vector, secret)
|
|
43
|
+
else
|
|
44
|
+
decrypt_legacy(message, init_vector, secret)
|
|
45
|
+
end
|
|
46
|
+
log.debug 'Cipher decrypt completed'
|
|
47
|
+
result
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
handle_exception(e, level: :error, operation: 'crypt.cipher.decrypt')
|
|
50
|
+
raise
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
def encrypt_from_keypair(message:, pub_key: public_key)
|
|
34
54
|
rsa_public_key = OpenSSL::PKey::RSA.new(pub_key)
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
encrypted_message = rsa_public_key.public_encrypt(message, RSA_OAEP_PADDING)
|
|
57
|
+
encoded_message = "#{RSA_OAEP_PREFIX}:#{Base64.strict_encode64(encrypted_message)}"
|
|
58
|
+
log.debug 'Cipher keypair encryption completed'
|
|
59
|
+
encoded_message
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
handle_exception(e, level: :error, operation: 'crypt.cipher.encrypt_from_keypair')
|
|
62
|
+
raise
|
|
37
63
|
end
|
|
38
64
|
|
|
39
65
|
def decrypt_from_keypair(message:, **_opts)
|
|
40
|
-
|
|
66
|
+
decrypted_message = if rsa_oaep_ciphertext?(message)
|
|
67
|
+
decrypt_oaep_from_keypair(message)
|
|
68
|
+
else
|
|
69
|
+
decrypt_legacy_from_keypair(message)
|
|
70
|
+
end
|
|
71
|
+
log.debug 'Cipher keypair decryption completed'
|
|
72
|
+
decrypted_message
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
handle_exception(e, level: :error, operation: 'crypt.cipher.decrypt_from_keypair')
|
|
75
|
+
raise
|
|
41
76
|
end
|
|
42
77
|
|
|
43
78
|
def public_key
|
|
@@ -46,10 +81,66 @@ module Legion
|
|
|
46
81
|
|
|
47
82
|
def private_key
|
|
48
83
|
@private_key ||= if Legion::Settings[:crypt][:read_private_key] && File.exist?('./legionio.key')
|
|
84
|
+
log.info 'Cipher loading RSA private key from disk'
|
|
49
85
|
OpenSSL::PKey::RSA.new File.read './legionio.key'
|
|
50
86
|
else
|
|
87
|
+
log.info 'Cipher generating RSA private key'
|
|
51
88
|
OpenSSL::PKey::RSA.new 2048
|
|
52
89
|
end
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
handle_exception(e, level: :error, operation: 'crypt.cipher.private_key')
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
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
|
+
def authenticated_ciphertext?(message)
|
|
111
|
+
message.start_with?("#{AUTHENTICATED_PREFIX}:")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def decrypt_authenticated(message, init_vector, secret)
|
|
115
|
+
_, encoded_ciphertext, encoded_auth_tag = message.split(':', 3)
|
|
116
|
+
|
|
117
|
+
decipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER)
|
|
118
|
+
decipher.decrypt
|
|
119
|
+
decipher.key = secret
|
|
120
|
+
decipher.iv = Base64.strict_decode64(init_vector)
|
|
121
|
+
decipher.auth_tag = Base64.strict_decode64(encoded_auth_tag)
|
|
122
|
+
decipher.update(Base64.strict_decode64(encoded_ciphertext)) + decipher.final
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decrypt_legacy(message, init_vector, secret)
|
|
126
|
+
decipher = OpenSSL::Cipher.new(LEGACY_CIPHER)
|
|
127
|
+
decipher.decrypt
|
|
128
|
+
decipher.key = secret
|
|
129
|
+
decipher.iv = Base64.decode64(init_vector)
|
|
130
|
+
decipher.update(Base64.decode64(message)) + decipher.final
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def rsa_oaep_ciphertext?(message)
|
|
134
|
+
message.start_with?("#{RSA_OAEP_PREFIX}:")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def decrypt_oaep_from_keypair(message)
|
|
138
|
+
_, encoded_message = message.split(':', 2)
|
|
139
|
+
private_key.private_decrypt(Base64.strict_decode64(encoded_message), RSA_OAEP_PADDING)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def decrypt_legacy_from_keypair(message)
|
|
143
|
+
private_key.private_decrypt(Base64.decode64(message), RSA_LEGACY_PADDING)
|
|
53
144
|
end
|
|
54
145
|
end
|
|
55
146
|
end
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'legion/logging/helper'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Crypt
|
|
7
8
|
module ClusterSecret
|
|
9
|
+
include Legion::Logging::Helper
|
|
10
|
+
|
|
8
11
|
def find_cluster_secret
|
|
9
12
|
%i[from_settings from_vault from_transport generate_secure_random].each do |method|
|
|
10
13
|
result = send(method)
|
|
11
14
|
next if result.nil?
|
|
12
15
|
|
|
13
16
|
unless validate_hex(result)
|
|
14
|
-
|
|
17
|
+
log.warn("Legion::Crypt.#{method} gave a value but it isn't a valid hex")
|
|
15
18
|
next
|
|
16
19
|
end
|
|
17
20
|
|
|
@@ -22,18 +25,22 @@ module Legion
|
|
|
22
25
|
|
|
23
26
|
key = generate_secure_random
|
|
24
27
|
set_cluster_secret(key)
|
|
28
|
+
log.info 'Cluster secret generated locally because this node is the only member'
|
|
25
29
|
key
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def from_vault
|
|
29
|
-
return nil unless
|
|
33
|
+
return nil unless Legion::Crypt.respond_to?(:get) && Legion::Crypt.respond_to?(:exist?)
|
|
30
34
|
return nil unless Legion::Settings[:crypt][:vault][:read_cluster_secret]
|
|
31
35
|
return nil unless Legion::Settings[:crypt][:vault][:connected]
|
|
32
|
-
return nil unless Legion::Crypt.exist?(
|
|
36
|
+
return nil unless Legion::Crypt.exist?(cluster_secret_vault_path)
|
|
37
|
+
|
|
38
|
+
data = Legion::Crypt.get(cluster_secret_vault_path)
|
|
39
|
+
return nil unless data.is_a?(Hash)
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
data[:cluster_secret] || data['cluster_secret']
|
|
35
42
|
rescue StandardError => e
|
|
36
|
-
|
|
43
|
+
handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.from_vault')
|
|
37
44
|
nil
|
|
38
45
|
end
|
|
39
46
|
|
|
@@ -46,8 +53,8 @@ module Legion
|
|
|
46
53
|
return nil unless Legion::Settings[:transport][:connected]
|
|
47
54
|
|
|
48
55
|
require 'legion/transport/messages/request_cluster_secret'
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
log.info 'Requesting cluster secret via public key'
|
|
57
|
+
log.warn 'cluster_secret already set but we are requesting a new value' unless from_settings.nil?
|
|
51
58
|
start = Time.now
|
|
52
59
|
Legion::Transport::Messages::RequestClusterSecret.new.publish
|
|
53
60
|
sleep_time = 0.001
|
|
@@ -57,20 +64,14 @@ module Legion
|
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
unless from_settings.nil?
|
|
60
|
-
|
|
67
|
+
log.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms"
|
|
61
68
|
return from_settings
|
|
62
69
|
end
|
|
63
70
|
|
|
64
|
-
|
|
71
|
+
log.error 'Cluster secret is still unknown!'
|
|
65
72
|
nil
|
|
66
73
|
rescue StandardError => e
|
|
67
|
-
|
|
68
|
-
Legion::Logging.log_exception(e, lex: 'crypt', component_type: :helper)
|
|
69
|
-
elsif defined?(Legion::Logging) && Legion::Logging.respond_to?(:error)
|
|
70
|
-
Legion::Logging.error "from_transport failed: #{e.class}=#{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
|
|
71
|
-
else
|
|
72
|
-
warn "from_transport failed: #{e.class}=#{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
|
|
73
|
-
end
|
|
74
|
+
handle_exception(e, level: :error, operation: 'crypt.cluster_secret.from_transport')
|
|
74
75
|
nil
|
|
75
76
|
end
|
|
76
77
|
|
|
@@ -79,32 +80,36 @@ module Legion
|
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def settings_push_vault
|
|
82
|
-
Legion::Settings[:crypt][:vault]
|
|
83
|
+
vault_settings = Legion::Settings[:crypt][:vault]
|
|
84
|
+
return vault_settings[:push_cluster_secret] unless vault_settings[:push_cluster_secret].nil?
|
|
85
|
+
|
|
86
|
+
vault_settings.fetch(:push_cs_to_vault, false)
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
def only_member?
|
|
86
90
|
Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero?
|
|
87
91
|
rescue StandardError => e
|
|
88
|
-
|
|
92
|
+
handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.only_member?')
|
|
89
93
|
nil
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def set_cluster_secret(value, push_to_vault = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
93
|
-
raise TypeError unless
|
|
97
|
+
raise TypeError unless validate_hex(value)
|
|
94
98
|
|
|
99
|
+
Legion::Settings[:crypt][:cluster_secret] = value
|
|
100
|
+
@cs = nil
|
|
95
101
|
Legion::Settings[:crypt][:cs_encrypt_ready] = true
|
|
96
102
|
push_cs_to_vault if push_to_vault && settings_push_vault
|
|
97
|
-
|
|
98
|
-
Legion::Settings[:crypt][:cluster_secret] = value
|
|
103
|
+
log.info "Cluster secret loaded into settings push_to_vault=#{push_to_vault}"
|
|
99
104
|
end
|
|
100
105
|
|
|
101
106
|
def push_cs_to_vault
|
|
102
107
|
return false unless Legion::Settings[:crypt][:vault][:connected] && Legion::Settings[:crypt][:cluster_secret]
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
Legion::Crypt.write(
|
|
109
|
+
log.info 'Pushing Cluster Secret to Vault'
|
|
110
|
+
Legion::Crypt.write(cluster_secret_vault_path, cluster_secret: Legion::Settings[:crypt][:cluster_secret])
|
|
106
111
|
rescue StandardError => e
|
|
107
|
-
|
|
112
|
+
handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.push_cs_to_vault')
|
|
108
113
|
false
|
|
109
114
|
end
|
|
110
115
|
|
|
@@ -113,7 +118,7 @@ module Legion
|
|
|
113
118
|
end
|
|
114
119
|
|
|
115
120
|
def secret_length
|
|
116
|
-
Legion::Settings[:crypt][:cluster_lenth] || 32
|
|
121
|
+
Legion::Settings[:crypt][:cluster_length] || Legion::Settings[:crypt][:cluster_lenth] || 32
|
|
117
122
|
end
|
|
118
123
|
|
|
119
124
|
def generate_secure_random(length = secret_length)
|
|
@@ -123,26 +128,23 @@ module Legion
|
|
|
123
128
|
def cs
|
|
124
129
|
@cs ||= Digest::SHA256.digest(find_cluster_secret)
|
|
125
130
|
rescue StandardError => e
|
|
126
|
-
|
|
127
|
-
Legion::Logging.log_exception(e, lex: 'crypt', component_type: :helper)
|
|
128
|
-
elsif defined?(Legion::Logging) && Legion::Logging.respond_to?(:error)
|
|
129
|
-
backtrace = Array(e.backtrace).first(10).join("\n")
|
|
130
|
-
Legion::Logging.error "Legion::Crypt::ClusterSecret#cs failed: #{e.class}: #{e.message}\n#{backtrace}"
|
|
131
|
-
elsif defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
|
|
132
|
-
backtrace = Array(e.backtrace).first(10).join("\n")
|
|
133
|
-
Legion::Logging.warn "Legion::Crypt::ClusterSecret#cs failed: #{e.class}: #{e.message}\n#{backtrace}"
|
|
134
|
-
else
|
|
135
|
-
backtrace = Array(e.backtrace).first(10).join("\n")
|
|
136
|
-
::Kernel.warn "Legion::Crypt::ClusterSecret#cs failed: #{e.class}: #{e.message}\n#{backtrace}"
|
|
137
|
-
end
|
|
131
|
+
handle_exception(e, level: :error, operation: 'crypt.cluster_secret.cs')
|
|
138
132
|
nil
|
|
139
133
|
end
|
|
140
134
|
|
|
141
135
|
def validate_hex(value, length = secret_length)
|
|
142
136
|
return false unless value.is_a?(String)
|
|
143
137
|
return false if value.empty?
|
|
138
|
+
return false unless value.match?(/\A\h+\z/)
|
|
139
|
+
|
|
140
|
+
expected_length = length.to_i * 2
|
|
141
|
+
return true if expected_length.zero?
|
|
142
|
+
|
|
143
|
+
value.length == expected_length
|
|
144
|
+
end
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
def cluster_secret_vault_path
|
|
147
|
+
'crypt'
|
|
146
148
|
end
|
|
147
149
|
end
|
|
148
150
|
end
|