flagkit 1.0.1

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.
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Core
5
+ # Thread-safe in-memory cache with TTL and LRU eviction.
6
+ class Cache
7
+ # Represents a cached entry with expiration.
8
+ class Entry
9
+ attr_reader :value, :expires_at
10
+ attr_accessor :last_accessed_at
11
+
12
+ def initialize(value, ttl_seconds)
13
+ @value = value
14
+ @expires_at = Time.now + ttl_seconds
15
+ @last_accessed_at = Time.now
16
+ end
17
+
18
+ def expired?
19
+ Time.now > expires_at
20
+ end
21
+ end
22
+
23
+ attr_reader :ttl, :max_size
24
+
25
+ # @param ttl [Integer] Time to live in seconds
26
+ # @param max_size [Integer] Maximum number of entries
27
+ def initialize(ttl: 300, max_size: 1000)
28
+ @ttl = ttl
29
+ @max_size = max_size
30
+ @entries = {}
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Gets a value from the cache.
35
+ #
36
+ # @param key [String] The cache key
37
+ # @return [Object, nil] The cached value or nil
38
+ def get(key)
39
+ @mutex.synchronize do
40
+ entry = @entries[key]
41
+ return nil unless entry
42
+
43
+ if entry.expired?
44
+ @entries.delete(key)
45
+ return nil
46
+ end
47
+
48
+ entry.last_accessed_at = Time.now
49
+ entry.value
50
+ end
51
+ end
52
+
53
+ # Sets a value in the cache.
54
+ #
55
+ # @param key [String] The cache key
56
+ # @param value [Object] The value to cache
57
+ def set(key, value)
58
+ @mutex.synchronize do
59
+ evict_if_needed
60
+ @entries[key] = Entry.new(value, ttl)
61
+ end
62
+ end
63
+
64
+ # Checks if a key exists in the cache.
65
+ #
66
+ # @param key [String] The cache key
67
+ # @return [Boolean]
68
+ def has?(key)
69
+ @mutex.synchronize do
70
+ entry = @entries[key]
71
+ return false unless entry
72
+
73
+ if entry.expired?
74
+ @entries.delete(key)
75
+ return false
76
+ end
77
+
78
+ true
79
+ end
80
+ end
81
+
82
+ # Deletes a value from the cache.
83
+ #
84
+ # @param key [String] The cache key
85
+ # @return [Boolean] Whether the key existed
86
+ def delete(key)
87
+ @mutex.synchronize do
88
+ !@entries.delete(key).nil?
89
+ end
90
+ end
91
+
92
+ # Clears all entries from the cache.
93
+ def clear
94
+ @mutex.synchronize do
95
+ @entries.clear
96
+ end
97
+ end
98
+
99
+ # Returns the number of entries in the cache.
100
+ #
101
+ # @return [Integer]
102
+ def size
103
+ @mutex.synchronize do
104
+ cleanup_expired
105
+ @entries.size
106
+ end
107
+ end
108
+
109
+ # Returns all keys in the cache.
110
+ #
111
+ # @return [Array<String>]
112
+ def keys
113
+ @mutex.synchronize do
114
+ cleanup_expired
115
+ @entries.keys.dup
116
+ end
117
+ end
118
+
119
+ # Gets all values from the cache.
120
+ #
121
+ # @return [Hash]
122
+ def to_h
123
+ @mutex.synchronize do
124
+ cleanup_expired
125
+ @entries.transform_values(&:value)
126
+ end
127
+ end
128
+
129
+ # Sets multiple values in the cache.
130
+ #
131
+ # @param hash [Hash] The key-value pairs to cache
132
+ def set_all(hash)
133
+ @mutex.synchronize do
134
+ hash.each do |key, value|
135
+ evict_if_needed
136
+ @entries[key] = Entry.new(value, ttl)
137
+ end
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def cleanup_expired
144
+ @entries.delete_if { |_, entry| entry.expired? }
145
+ end
146
+
147
+ def evict_if_needed
148
+ return if @entries.size < max_size
149
+
150
+ cleanup_expired
151
+ return if @entries.size < max_size
152
+
153
+ # LRU eviction
154
+ lru_key = @entries.min_by { |_, entry| entry.last_accessed_at }&.first
155
+ @entries.delete(lru_key) if lru_key
156
+ end
157
+ end
158
+ end
159
+
160
+ # Alias for backward compatibility
161
+ Cache = Core::Cache
162
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module FlagKit
8
+ module Core
9
+ # Encrypted cache wrapper using AES-256-GCM.
10
+ #
11
+ # Wraps the standard Cache and encrypts/decrypts values transparently.
12
+ # Uses PBKDF2 to derive encryption key from API key.
13
+ #
14
+ # @example
15
+ # cache = EncryptedCache.new(api_key: "sdk_abc123", ttl: 300)
16
+ # cache.set("key", "secret value")
17
+ # cache.get("key") # => "secret value"
18
+ class EncryptedCache
19
+ # Encryption constants
20
+ ENCRYPTION_VERSION = 1
21
+ IV_LENGTH = 12 # 96 bits for GCM
22
+ TAG_LENGTH = 16 # 128 bits for GCM
23
+ KEY_LENGTH = 32 # 256 bits for AES-256
24
+ PBKDF2_ITERATIONS = 100_000
25
+ SALT = "FlagKit-v1-cache"
26
+
27
+ attr_reader :ttl, :max_size
28
+
29
+ # @param api_key [String] The API key used to derive encryption key
30
+ # @param ttl [Integer] Time to live in seconds
31
+ # @param max_size [Integer] Maximum number of entries
32
+ # @param logger [Object, nil] Optional logger instance
33
+ def initialize(api_key:, ttl: 300, max_size: 1000, logger: nil)
34
+ @ttl = ttl
35
+ @max_size = max_size
36
+ @logger = logger
37
+ @cache = Cache.new(ttl: ttl, max_size: max_size)
38
+ @derived_key = derive_key(api_key)
39
+ @encryption_available = !@derived_key.nil?
40
+ end
41
+
42
+ # Gets a value from the cache (decrypted).
43
+ #
44
+ # @param key [String] The cache key
45
+ # @return [Object, nil] The decrypted cached value or nil
46
+ def get(key)
47
+ encrypted = @cache.get(key)
48
+ return nil unless encrypted
49
+
50
+ decrypt(encrypted)
51
+ rescue StandardError => e
52
+ log(:warn, "Decryption failed for key '#{key}': #{e.message}")
53
+ nil
54
+ end
55
+
56
+ # Sets a value in the cache (encrypted).
57
+ #
58
+ # @param key [String] The cache key
59
+ # @param value [Object] The value to cache (will be JSON serialized)
60
+ def set(key, value)
61
+ encrypted = encrypt(value)
62
+ @cache.set(key, encrypted)
63
+ rescue StandardError => e
64
+ log(:warn, "Encryption failed for key '#{key}': #{e.message}")
65
+ # Fall back to unencrypted storage
66
+ @cache.set(key, JSON.generate({ _unencrypted: true, data: value }))
67
+ end
68
+
69
+ # Checks if a key exists in the cache.
70
+ #
71
+ # @param key [String] The cache key
72
+ # @return [Boolean]
73
+ def has?(key)
74
+ @cache.has?(key)
75
+ end
76
+
77
+ # Deletes a value from the cache.
78
+ #
79
+ # @param key [String] The cache key
80
+ # @return [Boolean] Whether the key existed
81
+ def delete(key)
82
+ @cache.delete(key)
83
+ end
84
+
85
+ # Clears all entries from the cache.
86
+ def clear
87
+ @cache.clear
88
+ end
89
+
90
+ # Returns the number of entries in the cache.
91
+ #
92
+ # @return [Integer]
93
+ def size
94
+ @cache.size
95
+ end
96
+
97
+ # Returns all keys in the cache.
98
+ #
99
+ # @return [Array<String>]
100
+ def keys
101
+ @cache.keys
102
+ end
103
+
104
+ # Gets all values from the cache (decrypted).
105
+ #
106
+ # @return [Hash]
107
+ def to_h
108
+ result = {}
109
+ keys.each do |key|
110
+ value = get(key)
111
+ result[key] = value unless value.nil?
112
+ end
113
+ result
114
+ end
115
+
116
+ # Sets multiple values in the cache (encrypted).
117
+ #
118
+ # @param hash [Hash] The key-value pairs to cache
119
+ def set_all(hash)
120
+ hash.each { |key, value| set(key, value) }
121
+ end
122
+
123
+ # Checks if encryption is available.
124
+ #
125
+ # @return [Boolean]
126
+ def encryption_available?
127
+ @encryption_available
128
+ end
129
+
130
+ private
131
+
132
+ # Derives the encryption key from the API key using PBKDF2.
133
+ #
134
+ # @param api_key [String] The API key
135
+ # @return [String, nil] The derived key or nil if derivation fails
136
+ def derive_key(api_key)
137
+ OpenSSL::KDF.pbkdf2_hmac(
138
+ api_key,
139
+ salt: SALT,
140
+ iterations: PBKDF2_ITERATIONS,
141
+ length: KEY_LENGTH,
142
+ hash: "SHA256"
143
+ )
144
+ rescue StandardError => e
145
+ log(:warn, "Key derivation failed: #{e.message}")
146
+ nil
147
+ end
148
+
149
+ # Encrypts data using AES-256-GCM.
150
+ #
151
+ # @param data [Object] The data to encrypt (will be JSON serialized)
152
+ # @return [String] The encrypted data as JSON string
153
+ def encrypt(data)
154
+ unless @encryption_available
155
+ # Fall back to unencrypted
156
+ return JSON.generate({ _unencrypted: true, data: data })
157
+ end
158
+
159
+ plaintext = JSON.generate(data)
160
+
161
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
162
+ cipher.encrypt
163
+ cipher.key = @derived_key
164
+ iv = cipher.random_iv
165
+ cipher.iv = iv
166
+
167
+ ciphertext = cipher.update(plaintext) + cipher.final
168
+ tag = cipher.auth_tag(TAG_LENGTH)
169
+
170
+ encrypted_data = {
171
+ iv: Base64.strict_encode64(iv),
172
+ data: Base64.strict_encode64(ciphertext),
173
+ tag: Base64.strict_encode64(tag),
174
+ version: ENCRYPTION_VERSION
175
+ }
176
+
177
+ JSON.generate(encrypted_data)
178
+ end
179
+
180
+ # Decrypts data using AES-256-GCM.
181
+ #
182
+ # @param encrypted [String] The encrypted data as JSON string
183
+ # @return [Object] The decrypted data
184
+ def decrypt(encrypted)
185
+ parsed = JSON.parse(encrypted)
186
+
187
+ # Handle unencrypted fallback data
188
+ if parsed["_unencrypted"]
189
+ return parsed["data"]
190
+ end
191
+
192
+ # Verify encryption version
193
+ unless parsed["version"] == ENCRYPTION_VERSION
194
+ log(:warn, "Unsupported encryption version: #{parsed['version']}")
195
+ return nil
196
+ end
197
+
198
+ unless @encryption_available
199
+ log(:warn, "Cannot decrypt: encryption not available")
200
+ return nil
201
+ end
202
+
203
+ iv = Base64.strict_decode64(parsed["iv"])
204
+ ciphertext = Base64.strict_decode64(parsed["data"])
205
+ tag = Base64.strict_decode64(parsed["tag"])
206
+
207
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
208
+ cipher.decrypt
209
+ cipher.key = @derived_key
210
+ cipher.iv = iv
211
+ cipher.auth_tag = tag
212
+
213
+ plaintext = cipher.update(ciphertext) + cipher.final
214
+ JSON.parse(plaintext)
215
+ end
216
+
217
+ def log(level, message)
218
+ return unless @logger
219
+
220
+ @logger.send(level, "[FlagKit::EncryptedCache] #{message}")
221
+ end
222
+ end
223
+ end
224
+
225
+ # Alias for backward compatibility
226
+ EncryptedCache = Core::EncryptedCache
227
+ end