legion-crypt 1.4.0 → 1.4.2

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: 3bf2e0f0673b2c963e56d71c586d299a39c3711b0d3f49fe851375d78cebe07e
4
- data.tar.gz: 194d4c8b64922f5634dcf75607af87a0d83ba10efcb1c83421456bf7a3fddfc6
3
+ metadata.gz: 171a3a22eeb730be2a47dccb7f96ec1b4ffcf4df03792eae594f63b61106224a
4
+ data.tar.gz: 597ca3ea73e5864a572621312cf7ff0efea424c4e3e05c22e16f0044deb97753
5
5
  SHA512:
6
- metadata.gz: 33e4ea2d0ca6413f24099181f5ccf3b61ced2c33f079dd11029ded41095ceaedaf419b315e097ede586560e5478cec5be946dbe627d0ab68763c5354baa3617f
7
- data.tar.gz: 8985763ca6ee45362527ec0368175125ed95d6001dc4d0f43759017fe1c7de33f7c194be7501e8c9c19a12fe72ac3fa1ce2aca5e6c5fde8d39c2bc76999ec925
6
+ metadata.gz: fb76b8b671a10380aaccb82eb62f611f9998112bd95ec8a5b184679b1694204d3b7adb14b3c7f141ae072254671728bf2b8b1701c94291f0b285ccb335d0e15e
7
+ data.tar.gz: 8d0d58cb2c9d4fc543fc7d3be4628142405c388e1436211df59d1d26614c18d49f6e158ab25e440b643401c8c527de1160470a0623536a4f79040fb2f475a6fc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.2] - 2026-03-16
6
+
7
+ ### Added
8
+ - `Legion::Crypt::Ed25519`: Ed25519 key generation, signing, verification, Vault key storage
9
+ - `Legion::Crypt::PartitionKeys`: HKDF-based per-tenant key derivation with AES-256-GCM encrypt/decrypt
10
+ - `Legion::Crypt::Erasure`: cryptographic erasure via Vault master key deletion with event emission
11
+ - `Legion::Crypt::Attestation`: signed identity claims with Ed25519 signatures and freshness checking
12
+ - Dependency: `ed25519` gem ~> 1.3
13
+
14
+ ## [1.4.1] - 2026-03-16
15
+
16
+ ### Added
17
+ - `Legion::Crypt::MockVault` in-memory Vault mock for local development mode
18
+
5
19
  ## [1.4.0] - 2026-03-16
6
20
 
7
21
  ### Added
data/CLAUDE.md CHANGED
@@ -34,8 +34,14 @@ Legion::Crypt (singleton module)
34
34
  ├── JWT # JSON Web Token operations
35
35
  │ ├── .issue # Create signed JWT (HS256 or RS256)
36
36
  │ ├── .verify # Verify and decode JWT
37
+ │ ├── .verify_with_jwks # Verify RS256 token via external JWKS endpoint (Entra ID, etc.)
37
38
  │ └── .decode # Decode without verification (inspection)
38
39
 
40
+ ├── JwksClient # External JWKS endpoint integration (thread-safe)
41
+ │ ├── .fetch_keys # Fetch and parse JWKS from a URL
42
+ │ ├── .find_key # Lookup key by kid (cache-first, re-fetch on miss)
43
+ │ └── .clear_cache # Clear the key cache
44
+
39
45
  ├── ClusterSecret # Cluster-wide shared secret management
40
46
  │ └── .cs # Generate/distribute cluster secret
41
47
 
@@ -45,7 +51,12 @@ Legion::Crypt (singleton module)
45
51
  │ └── .worker_login # Issue a Legion JWT and authenticate to Vault in one step
46
52
 
47
53
  ├── VaultRenewer # Background Vault token renewal thread
54
+ ├── Ed25519 # Ed25519 key generation, signing, verification, Vault storage
55
+ ├── PartitionKeys # HKDF per-tenant key derivation, AES-256-GCM encrypt/decrypt
56
+ ├── Erasure # Cryptographic erasure via Vault master key deletion
57
+ ├── Attestation # Signed identity claims with Ed25519, freshness checking
48
58
  ├── LeaseManager # Dynamic Vault lease lifecycle: fetch, cache, renew, rotate, push-back
59
+ ├── MockVault # In-memory Vault mock for local development mode
49
60
  ├── Settings # Default crypt config
50
61
  └── Version
51
62
  ```
@@ -57,6 +68,7 @@ Legion::Crypt (singleton module)
57
68
  - **Vault Conditional**: Vault module is only included if the `vault` gem is available
58
69
  - **Token Lifecycle**: VaultRenewer runs background thread for automatic token renewal
59
70
  - **JWT Dual Algorithm**: HS256 (symmetric, cluster secret) for intra-cluster tokens; RS256 (asymmetric, RSA keypair) for tokens verifiable without sharing the signing key
71
+ - **JWKS External Validation**: `JwksClient` fetches public keys from external identity provider JWKS endpoints (Entra ID, Bot Framework). Keys cached for 1 hour (CACHE_TTL=3600s), thread-safe via Mutex, automatic re-fetch on cache miss handles key rotation
60
72
 
61
73
  ## Default Settings
62
74
 
@@ -83,6 +95,7 @@ Legion::Crypt (singleton module)
83
95
 
84
96
  | Gem | Purpose |
85
97
  |-----|---------|
98
+ | `ed25519` (~> 1.3) | Ed25519 key operations (pure Ruby) |
86
99
  | `jwt` (>= 2.7) | JSON Web Token encoding/decoding |
87
100
  | `vault` (>= 0.17) | HashiCorp Vault Ruby client |
88
101
 
@@ -94,13 +107,18 @@ Dev dependencies: `legion-logging`, `legion-settings`
94
107
  |------|---------|
95
108
  | `lib/legion/crypt.rb` | Module entry, start/shutdown lifecycle |
96
109
  | `lib/legion/crypt/cipher.rb` | AES-256-CBC encrypt/decrypt, RSA key generation |
97
- | `lib/legion/crypt/jwt.rb` | JWT issue/verify/decode operations |
110
+ | `lib/legion/crypt/jwt.rb` | JWT issue/verify/decode/verify_with_jwks operations |
111
+ | `lib/legion/crypt/jwks_client.rb` | JWKS endpoint fetch, parse, cache (thread-safe, 1hr TTL) |
98
112
  | `lib/legion/crypt/vault.rb` | Vault read/write/connect/renew operations |
99
113
  | `lib/legion/crypt/cluster_secret.rb` | Cluster-wide shared secret management |
100
114
  | `lib/legion/crypt/vault_jwt_auth.rb` | Vault JWT auth backend: `.login`, `.login!`, `.worker_login`; raises `AuthError` on failure |
101
115
  | `lib/legion/crypt/vault_renewer.rb` | Background Vault token renewal |
102
116
  | `lib/legion/crypt/lease_manager.rb` | Dynamic Vault lease lifecycle management |
103
117
  | `lib/legion/crypt/settings.rb` | Default configuration |
118
+ | `lib/legion/crypt/ed25519.rb` | Ed25519 key generation, signing, verification, Vault storage |
119
+ | `lib/legion/crypt/partition_keys.rb` | HKDF per-tenant key derivation with AES-256-GCM |
120
+ | `lib/legion/crypt/erasure.rb` | Cryptographic erasure via Vault master key deletion |
121
+ | `lib/legion/crypt/attestation.rb` | Signed identity claims with Ed25519 signatures |
104
122
  | `lib/legion/crypt/version.rb` | VERSION constant |
105
123
 
106
124
  ## Role in LegionIO
@@ -110,6 +128,7 @@ First service-level module initialized during `Legion::Service` startup (before
110
128
  2. Message encryption for `legion-transport` (optional `transport.messages.encrypt`)
111
129
  3. Cluster secret for inter-node encrypted communication
112
130
  4. JWT tokens for node authentication and task authorization
131
+ 5. External token verification for identity providers (Entra ID OIDC via JWKS)
113
132
 
114
133
  ### Vault JWT Auth Usage
115
134
 
@@ -144,6 +163,31 @@ decoded = Legion::Crypt::JWT.decode(token) # no verification, inspection only
144
163
  - `HS256` (default): Uses cluster secret. All cluster nodes can issue and verify.
145
164
  - `RS256`: Uses RSA keypair. Only the issuing node can sign; anyone with the public key can verify.
146
165
 
166
+ ### External Token Verification (JWKS)
167
+
168
+ Verify tokens from external identity providers using their public JWKS endpoints:
169
+
170
+ ```ruby
171
+ # Convenience method
172
+ claims = Legion::Crypt.verify_external_token(
173
+ token,
174
+ jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys',
175
+ issuers: ['https://login.microsoftonline.com/TENANT/v2.0'],
176
+ audience: 'app-client-id'
177
+ )
178
+
179
+ # Direct module usage
180
+ claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url)
181
+ ```
182
+
183
+ **Flow:** decode JWT header (unverified) to extract `kid` -> `JwksClient.find_key` fetches the matching public key from cache or JWKS endpoint -> verify JWT signature with the public key.
184
+
185
+ **Options:** `issuers:` (array, multi-issuer support), `audience:` (string), `verify_expiration:` (bool, default true).
186
+
187
+ **Error hierarchy:** `ExpiredTokenError`, `InvalidTokenError` (bad signature, wrong issuer, wrong audience), `DecodeError` (malformed token) — all inherit from `Legion::Crypt::JWT::Error`.
188
+
189
+ **Used by:** `lex-identity` Entra runner for Digital Worker OIDC token validation.
190
+
147
191
  ---
148
192
 
149
193
  **Maintained By**: Matthew Iverson (@Esity)
data/README.md CHANGED
@@ -41,6 +41,25 @@ claims = Legion::Crypt.verify_token(token, algorithm: 'RS256')
41
41
  decoded = Legion::Crypt::JWT.decode(token)
42
42
  ```
43
43
 
44
+ ### External Token Verification (JWKS)
45
+
46
+ Verify tokens from external identity providers (Entra ID, Bot Framework) using their public JWKS endpoints:
47
+
48
+ ```ruby
49
+ # Verify an Entra ID OIDC token
50
+ claims = Legion::Crypt.verify_external_token(
51
+ token,
52
+ jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys',
53
+ issuers: ['https://login.microsoftonline.com/TENANT/v2.0'],
54
+ audience: 'app-client-id'
55
+ )
56
+
57
+ # Or use the JWT module directly
58
+ claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url)
59
+ ```
60
+
61
+ Public keys are cached for 1 hour and automatically re-fetched on cache miss (handles key rotation).
62
+
44
63
  ## Configuration
45
64
 
46
65
  ```json
data/legion-crypt.gemspec CHANGED
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  'rubygems_mfa_required' => 'true'
26
26
  }
27
27
 
28
+ spec.add_dependency 'ed25519', '~> 1.3'
28
29
  spec.add_dependency 'jwt', '>= 2.7'
29
30
  spec.add_dependency 'vault', '>= 0.17'
30
31
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Crypt
7
+ module Attestation
8
+ class << self
9
+ def create(agent_id:, capabilities:, state:, private_key:)
10
+ claim = {
11
+ agent_id: agent_id,
12
+ capabilities: Array(capabilities),
13
+ state: state.to_s,
14
+ timestamp: Time.now.utc.iso8601,
15
+ nonce: SecureRandom.hex(16)
16
+ }
17
+
18
+ payload = Legion::JSON.dump(claim)
19
+ signature = Legion::Crypt::Ed25519.sign(payload, private_key)
20
+
21
+ { claim: claim, signature: signature.unpack1('H*'), payload: payload }
22
+ end
23
+
24
+ def verify(claim_hash:, signature_hex:, public_key:)
25
+ payload = Legion::JSON.dump(claim_hash)
26
+ signature = [signature_hex].pack('H*')
27
+ Legion::Crypt::Ed25519.verify(payload, signature, public_key)
28
+ end
29
+
30
+ def fresh?(claim_hash, max_age_seconds: 300)
31
+ timestamp = Time.parse(claim_hash[:timestamp])
32
+ Time.now.utc - timestamp < max_age_seconds
33
+ rescue StandardError
34
+ false
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ed25519'
4
+
5
+ module Legion
6
+ module Crypt
7
+ module Ed25519
8
+ class << self
9
+ def generate_keypair
10
+ signing_key = ::Ed25519::SigningKey.generate
11
+ {
12
+ private_key: signing_key.to_bytes,
13
+ public_key: signing_key.verify_key.to_bytes,
14
+ public_key_hex: signing_key.verify_key.to_bytes.unpack1('H*')
15
+ }
16
+ end
17
+
18
+ def sign(message, private_key_bytes)
19
+ signing_key = ::Ed25519::SigningKey.new(private_key_bytes)
20
+ signing_key.sign(message)
21
+ end
22
+
23
+ def verify(message, signature, public_key_bytes)
24
+ verify_key = ::Ed25519::VerifyKey.new(public_key_bytes)
25
+ verify_key.verify(signature, message)
26
+ true
27
+ rescue ::Ed25519::VerifyError
28
+ false
29
+ end
30
+
31
+ def store_keypair(agent_id:, keypair: nil)
32
+ keypair ||= generate_keypair
33
+ vault_path = "#{key_prefix}/#{agent_id}"
34
+ if defined?(Legion::Crypt::Vault)
35
+ Legion::Crypt::Vault.write(vault_path, {
36
+ private_key: keypair[:private_key].unpack1('H*'),
37
+ public_key: keypair[:public_key_hex]
38
+ })
39
+ end
40
+ keypair
41
+ end
42
+
43
+ def load_private_key(agent_id:)
44
+ vault_path = "#{key_prefix}/#{agent_id}"
45
+ data = Legion::Crypt::Vault.read(vault_path)
46
+ [data[:private_key]].pack('H*') if data&.dig(:private_key)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def key_prefix
54
+ begin
55
+ Legion::Settings[:crypt][:ed25519][:vault_key_prefix]
56
+ rescue StandardError
57
+ nil
58
+ end || 'secret/data/legion/keys'
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Crypt
5
+ module Erasure
6
+ class << self
7
+ def erase_tenant(tenant_id:)
8
+ key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
9
+
10
+ delete_vault_key(key_path) if defined?(Legion::Crypt::Vault)
11
+ Legion::Events.emit('crypt.tenant_erased', { tenant_id: tenant_id, erased_at: Time.now.utc }) if defined?(Legion::Events)
12
+ Legion::Logging.warn "[crypt] Tenant #{tenant_id} cryptographically erased" if defined?(Legion::Logging)
13
+
14
+ { erased: true, tenant_id: tenant_id, path: key_path }
15
+ rescue StandardError => e
16
+ { erased: false, tenant_id: tenant_id, error: e.message }
17
+ end
18
+
19
+ def verify_erasure(tenant_id:)
20
+ key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
21
+ data = Legion::Crypt::Vault.read(key_path)
22
+ { erased: data.nil?, tenant_id: tenant_id }
23
+ rescue StandardError
24
+ { erased: true, tenant_id: tenant_id }
25
+ end
26
+
27
+ private
28
+
29
+ def delete_vault_key(path)
30
+ ::Vault.logical.delete(path)
31
+ end
32
+
33
+ def tenant_prefix
34
+ begin
35
+ Legion::Settings[:crypt][:partition_keys][:vault_tenant_prefix]
36
+ rescue StandardError
37
+ nil
38
+ end || 'secret/data/legion/tenants'
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Crypt
5
+ module MockVault
6
+ @store = {}
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ def read(path)
11
+ @mutex.synchronize { @store[path]&.dup }
12
+ end
13
+
14
+ def write(path, data)
15
+ @mutex.synchronize { @store[path] = data.dup }
16
+ true
17
+ end
18
+
19
+ def delete(path)
20
+ @mutex.synchronize { @store.delete(path) }
21
+ true
22
+ end
23
+
24
+ def list(prefix)
25
+ @mutex.synchronize do
26
+ @store.keys.select { |k| k.start_with?(prefix) }
27
+ end
28
+ end
29
+
30
+ def reset!
31
+ @mutex.synchronize { @store.clear }
32
+ end
33
+
34
+ def connected?
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Legion
6
+ module Crypt
7
+ module PartitionKeys
8
+ class << self
9
+ def derive_key(master_key:, tenant_id:, context: nil)
10
+ context ||= begin
11
+ Legion::Settings[:crypt][:partition_keys][:derivation_context]
12
+ rescue StandardError
13
+ nil
14
+ end || 'legion-partition'
15
+ salt = OpenSSL::Digest::SHA256.digest(tenant_id.to_s)
16
+ OpenSSL::KDF.hkdf(master_key, salt: salt, info: context, length: 32, hash: 'SHA256')
17
+ end
18
+
19
+ def encrypt_for_tenant(plaintext:, tenant_id:, master_key:)
20
+ key = derive_key(master_key: master_key, tenant_id: tenant_id)
21
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
22
+ cipher.encrypt
23
+ cipher.key = key
24
+ iv = cipher.random_iv
25
+ ciphertext = cipher.update(plaintext) + cipher.final
26
+ auth_tag = cipher.auth_tag
27
+
28
+ { ciphertext: ciphertext, iv: iv, auth_tag: auth_tag }
29
+ end
30
+
31
+ def decrypt_for_tenant(ciphertext:, init_vector:, auth_tag:, tenant_id:, master_key:)
32
+ key = derive_key(master_key: master_key, tenant_id: tenant_id)
33
+ decipher = OpenSSL::Cipher.new('aes-256-gcm')
34
+ decipher.decrypt
35
+ decipher.key = key
36
+ decipher.iv = init_vector
37
+ decipher.auth_tag = auth_tag
38
+ decipher.update(ciphertext) + decipher.final
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.4.0'
5
+ VERSION = '1.4.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-crypt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ed25519
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: jwt
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -57,11 +71,16 @@ files:
57
71
  - README.md
58
72
  - legion-crypt.gemspec
59
73
  - lib/legion/crypt.rb
74
+ - lib/legion/crypt/attestation.rb
60
75
  - lib/legion/crypt/cipher.rb
61
76
  - lib/legion/crypt/cluster_secret.rb
77
+ - lib/legion/crypt/ed25519.rb
78
+ - lib/legion/crypt/erasure.rb
62
79
  - lib/legion/crypt/jwks_client.rb
63
80
  - lib/legion/crypt/jwt.rb
64
81
  - lib/legion/crypt/lease_manager.rb
82
+ - lib/legion/crypt/mock_vault.rb
83
+ - lib/legion/crypt/partition_keys.rb
65
84
  - lib/legion/crypt/settings.rb
66
85
  - lib/legion/crypt/vault.rb
67
86
  - lib/legion/crypt/vault_jwt_auth.rb