ff1 1.2.0 → 1.2.4
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/README.md +214 -1
- data/examples/activerecord_usage.rb +144 -0
- data/lib/ff1/active_record/base.rb +80 -0
- data/lib/ff1/active_record/encryption.rb +167 -0
- data/lib/ff1/active_record/scopes.rb +206 -0
- data/lib/ff1/active_record/soft_delete.rb +192 -0
- data/lib/ff1/active_record.rb +125 -0
- data/lib/ff1/version.rb +1 -1
- data/lib/ff1.rb +20 -0
- data/lib/generators/ff1/install_generator.rb +101 -0
- data/lib/generators/ff1/templates/initializer.rb +55 -0
- data/lib/generators/ff1/templates/migration.rb.erb +15 -0
- data/spec/ff1_active_record_spec.rb +382 -0
- data/spec/generators/ff1/install_generator_spec.rb +250 -0
- metadata +70 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8bfddb48c4bbe23ccf35389b17907b81197ead09b9ce0615ef11656eb53bdca0
|
4
|
+
data.tar.gz: b214417aadd4146884cf7d7aba473d4cb05f89e7109d9f3fa54af002db736e78
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e82d9a62d5ad24acfa14c9bcaaaf4a5a5eb17780a73ba813d1361faf894ea23c02b13fbe4562620963abf36d8f51d8de5828dd52365859380acdc4a7ecbc24f4
|
7
|
+
data.tar.gz: dc546acd72fc9d0ba494b07db818d2d5d975d631a5ebb8eb9a40073b22cd0002c82539d542b0a3c73afba988eb87d0c1c362748361dbd373bae85efedc6c3a3d
|
data/README.md
CHANGED
@@ -14,8 +14,12 @@ The FF1 algorithm is one of two methods specified in NIST Special Publication 80
|
|
14
14
|
* **Dual-mode operation**: Reversible and Irreversible encryption
|
15
15
|
* Support for any radix from 2 to 65, 536
|
16
16
|
* **NEW: Full UTF-8 text encryption support** - encrypt arbitrary text while maintaining FF1 security properties
|
17
|
+
* **NEW: Rails/ActiveRecord integration** - seamless database encryption with GDPR compliance
|
17
18
|
* Tweak support for additional security
|
18
|
-
* GDPR "right to be forgotten" compliance
|
19
|
+
* GDPR "right to be forgotten" compliance with soft delete
|
20
|
+
* Automatic encryption/decryption in Rails models
|
21
|
+
* Rails migration generators and configuration
|
22
|
+
* Query scopes for active/deleted records
|
19
23
|
* Proper input validation and error handling
|
20
24
|
* Comprehensive test suite
|
21
25
|
* Thread-safe implementation
|
@@ -116,6 +120,215 @@ irreversibly_encrypted = irreversible_cipher.encrypt_text(sensitive_data)
|
|
116
120
|
# Cannot decrypt - data is permanently transformed while maintaining consistency
|
117
121
|
```
|
118
122
|
|
123
|
+
## ActiveRecord Integration (NEW!)
|
124
|
+
|
125
|
+
The gem now includes full Rails/ActiveRecord integration for seamless database encryption with GDPR compliance features.
|
126
|
+
|
127
|
+
### Installation for Rails
|
128
|
+
|
129
|
+
Add the gem to your Gemfile:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
gem 'ff1'
|
133
|
+
```
|
134
|
+
|
135
|
+
Run the generator to create the configuration file:
|
136
|
+
|
137
|
+
```bash
|
138
|
+
rails generate ff1:install
|
139
|
+
```
|
140
|
+
|
141
|
+
This creates `config/initializers/ff1.rb` with configuration options.
|
142
|
+
|
143
|
+
### Configuration
|
144
|
+
|
145
|
+
Configure FF1 in your Rails initializer:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
# config/initializers/ff1.rb
|
149
|
+
FF1::ActiveRecord.configure do |config|
|
150
|
+
# Required: Set your encryption key (store securely!)
|
151
|
+
config.global_key = Rails.application.credentials.ff1_encryption_key
|
152
|
+
# Or use environment variable:
|
153
|
+
# config.global_key = ENV['FF1_ENCRYPTION_KEY']&.b
|
154
|
+
|
155
|
+
# Optional: Set default encryption mode
|
156
|
+
config.default_mode = FF1::Modes::REVERSIBLE
|
157
|
+
|
158
|
+
# Optional: Customize column names for soft delete
|
159
|
+
config.deleted_at_column = :deleted_at
|
160
|
+
config.ff1_deleted_column = :ff1_deleted
|
161
|
+
end
|
162
|
+
```
|
163
|
+
|
164
|
+
### Basic Model Integration
|
165
|
+
|
166
|
+
Include `FF1::ActiveRecord` in your models and configure encrypted columns:
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
class User < ApplicationRecord
|
170
|
+
include FF1::ActiveRecord
|
171
|
+
|
172
|
+
# Encrypt email and phone with reversible encryption (can decrypt)
|
173
|
+
ff1_encrypt :email, :phone, mode: :reversible
|
174
|
+
|
175
|
+
# Encrypt SSN with irreversible encryption (cannot decrypt - GDPR compliant)
|
176
|
+
ff1_encrypt :ssn, mode: :irreversible
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
### Database Migration
|
181
|
+
|
182
|
+
Generate and run migration to add soft delete columns:
|
183
|
+
|
184
|
+
```bash
|
185
|
+
rails generate ff1:install User
|
186
|
+
rails db:migrate
|
187
|
+
```
|
188
|
+
|
189
|
+
This adds `deleted_at` and `ff1_deleted` columns to your users table.
|
190
|
+
|
191
|
+
### Usage Examples
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
# Create user - data is automatically encrypted before saving
|
195
|
+
user = User.create!(
|
196
|
+
name: 'John Doe',
|
197
|
+
email: 'john@example.com',
|
198
|
+
phone: '555-1234',
|
199
|
+
ssn: '123-45-6789'
|
200
|
+
)
|
201
|
+
|
202
|
+
# Access data - reversible columns decrypt automatically
|
203
|
+
puts user.email # => 'john@example.com' (decrypted)
|
204
|
+
puts user.phone # => '555-1234' (decrypted)
|
205
|
+
puts user.ssn # => '[ENCRYPTED]' (irreversible, cannot decrypt)
|
206
|
+
|
207
|
+
# Data is encrypted in the database
|
208
|
+
User.connection.select_value("SELECT email FROM users WHERE id = #{user.id}")
|
209
|
+
# => "8224999410799188" (encrypted format)
|
210
|
+
```
|
211
|
+
|
212
|
+
### GDPR Compliant Soft Delete
|
213
|
+
|
214
|
+
The integration provides GDPR-compliant "right to be forgotten" functionality:
|
215
|
+
|
216
|
+
```ruby
|
217
|
+
# Soft delete with irreversible encryption
|
218
|
+
user.destroy # Encrypts all sensitive data irreversibly, keeps record
|
219
|
+
|
220
|
+
# Check deletion status
|
221
|
+
user.ff1_deleted? # => true
|
222
|
+
user.ff1_deleted_at # => 2023-01-01 12:00:00 UTC
|
223
|
+
|
224
|
+
# Data is now irreversibly encrypted
|
225
|
+
user.email # => '[ENCRYPTED]'
|
226
|
+
user.phone # => '[ENCRYPTED]'
|
227
|
+
user.ssn # => '[ENCRYPTED]'
|
228
|
+
|
229
|
+
# Restore record (but data remains encrypted)
|
230
|
+
user.ff1_restore
|
231
|
+
user.ff1_deleted? # => false
|
232
|
+
|
233
|
+
# True hard delete if needed
|
234
|
+
user.ff1_hard_destroy!
|
235
|
+
```
|
236
|
+
|
237
|
+
### Query Scopes
|
238
|
+
|
239
|
+
Use built-in scopes to query active vs soft-deleted records:
|
240
|
+
|
241
|
+
```ruby
|
242
|
+
# Active (non-deleted) users only
|
243
|
+
User.ff1_active.count # => 150
|
244
|
+
User.ff1_active.where(...) # Chain with other scopes
|
245
|
+
|
246
|
+
# Soft-deleted users only
|
247
|
+
User.ff1_deleted.count # => 25
|
248
|
+
|
249
|
+
# All users (active + deleted)
|
250
|
+
User.ff1_all.count # => 175
|
251
|
+
|
252
|
+
# Recently deleted users
|
253
|
+
User.ff1_recently_deleted(within: 30.days)
|
254
|
+
|
255
|
+
# Old deleted users (for purging)
|
256
|
+
User.ff1_old_deleted(older_than: 1.year)
|
257
|
+
```
|
258
|
+
|
259
|
+
### Bulk Operations
|
260
|
+
|
261
|
+
```ruby
|
262
|
+
# Bulk soft delete with encryption
|
263
|
+
User.ff1_destroy_all(status: 'inactive') # Returns count of deleted records
|
264
|
+
|
265
|
+
# Purge old soft-deleted records
|
266
|
+
User.ff1_purge_deleted(older_than: 2.years) # Permanent deletion
|
267
|
+
|
268
|
+
# Batch encrypt existing data
|
269
|
+
User.ff1_encrypt_existing_data!(columns: [:email, :phone])
|
270
|
+
|
271
|
+
# Statistics
|
272
|
+
User.ff1_stats
|
273
|
+
# => {
|
274
|
+
# total_records: 175,
|
275
|
+
# active_records: 150,
|
276
|
+
# deleted_records: 25,
|
277
|
+
# encrypted_columns: [:email, :phone, :ssn],
|
278
|
+
# deletion_rate: 14.29
|
279
|
+
# }
|
280
|
+
```
|
281
|
+
|
282
|
+
### Advanced Configuration
|
283
|
+
|
284
|
+
```ruby
|
285
|
+
class User < ApplicationRecord
|
286
|
+
include FF1::ActiveRecord
|
287
|
+
|
288
|
+
# Per-column configuration
|
289
|
+
ff1_encrypt :email,
|
290
|
+
mode: :reversible,
|
291
|
+
key: Rails.application.credentials.user_email_key, # Custom key
|
292
|
+
radix: 256 # For text data
|
293
|
+
|
294
|
+
ff1_encrypt :credit_card_number,
|
295
|
+
mode: :reversible,
|
296
|
+
radix: 10 # For numeric data
|
297
|
+
|
298
|
+
ff1_encrypt :sensitive_notes,
|
299
|
+
mode: :irreversible, # Cannot be decrypted
|
300
|
+
radix: 256
|
301
|
+
end
|
302
|
+
```
|
303
|
+
|
304
|
+
### Thread Safety
|
305
|
+
|
306
|
+
The ActiveRecord integration is thread-safe and caches cipher instances for performance:
|
307
|
+
|
308
|
+
```ruby
|
309
|
+
# Safe for concurrent access
|
310
|
+
User.transaction do
|
311
|
+
User.create!(email: 'user1@example.com')
|
312
|
+
User.create!(email: 'user2@example.com')
|
313
|
+
end
|
314
|
+
```
|
315
|
+
|
316
|
+
### Performance Considerations
|
317
|
+
|
318
|
+
1. **Cipher Caching**: Ciphers are cached per column configuration for performance
|
319
|
+
2. **Bulk Operations**: Use `ff1_find_each` for processing large datasets
|
320
|
+
3. **Indexing**: Encrypted data cannot be indexed for searching - plan accordingly
|
321
|
+
4. **Query Performance**: Use the provided scopes which include proper indexes
|
322
|
+
|
323
|
+
### GDPR Compliance Features
|
324
|
+
|
325
|
+
The ActiveRecord integration specifically supports GDPR requirements:
|
326
|
+
|
327
|
+
1. **Right to be Forgotten**: `destroy` method irreversibly encrypts sensitive data
|
328
|
+
2. **Data Minimization**: Only configured columns are encrypted
|
329
|
+
3. **Audit Trail**: Soft delete maintains record for compliance tracking
|
330
|
+
4. **Data Portability**: Reversible encryption allows data export when legally required
|
331
|
+
|
119
332
|
### Key Requirements
|
120
333
|
|
121
334
|
The FF1 algorithm supports AES key lengths:
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Example of FF1 ActiveRecord integration usage
|
4
|
+
# This example demonstrates how to use the FF1 gem with Rails/ActiveRecord
|
5
|
+
# for database encryption with GDPR compliance features.
|
6
|
+
|
7
|
+
require 'bundler/setup'
|
8
|
+
require 'active_record'
|
9
|
+
require 'sqlite3'
|
10
|
+
require_relative '../lib/ff1'
|
11
|
+
|
12
|
+
# Configure ActiveRecord with in-memory SQLite database
|
13
|
+
ActiveRecord::Base.establish_connection(
|
14
|
+
adapter: 'sqlite3',
|
15
|
+
database: ':memory:'
|
16
|
+
)
|
17
|
+
|
18
|
+
# Create test schema
|
19
|
+
ActiveRecord::Schema.define do
|
20
|
+
create_table :users do |t|
|
21
|
+
t.string :name
|
22
|
+
t.string :email
|
23
|
+
t.string :phone
|
24
|
+
t.string :ssn
|
25
|
+
t.datetime :deleted_at
|
26
|
+
t.boolean :ff1_deleted, default: false
|
27
|
+
t.timestamps
|
28
|
+
end
|
29
|
+
|
30
|
+
create_table :posts do |t|
|
31
|
+
t.string :title
|
32
|
+
t.text :content
|
33
|
+
t.integer :user_id
|
34
|
+
t.datetime :deleted_at
|
35
|
+
t.boolean :ff1_deleted, default: false
|
36
|
+
t.timestamps
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Configure FF1
|
41
|
+
FF1::ActiveRecord.configure do |config|
|
42
|
+
config.global_key = SecureRandom.bytes(32)
|
43
|
+
config.default_mode = FF1::Modes::REVERSIBLE
|
44
|
+
end
|
45
|
+
|
46
|
+
# Define models with FF1 encryption
|
47
|
+
class User < ActiveRecord::Base
|
48
|
+
include FF1::ActiveRecord
|
49
|
+
|
50
|
+
# Encrypt email and phone with reversible encryption (can decrypt)
|
51
|
+
ff1_encrypt :email, :phone, mode: :reversible
|
52
|
+
|
53
|
+
# Encrypt SSN with irreversible encryption (cannot decrypt - GDPR compliant)
|
54
|
+
ff1_encrypt :ssn, mode: :irreversible
|
55
|
+
|
56
|
+
has_many :posts, dependent: :destroy
|
57
|
+
end
|
58
|
+
|
59
|
+
class Post < ActiveRecord::Base
|
60
|
+
include FF1::ActiveRecord
|
61
|
+
|
62
|
+
# Encrypt post content with reversible encryption
|
63
|
+
ff1_encrypt :content, mode: :reversible
|
64
|
+
|
65
|
+
belongs_to :user
|
66
|
+
end
|
67
|
+
|
68
|
+
# Demonstration
|
69
|
+
puts "🔐 FF1 ActiveRecord Integration Demo"
|
70
|
+
puts "=" * 50
|
71
|
+
|
72
|
+
# 1. Create a user with encrypted data
|
73
|
+
puts "\n1. Creating user with encrypted data..."
|
74
|
+
user = User.create!(
|
75
|
+
name: 'John Doe',
|
76
|
+
email: 'john@example.com',
|
77
|
+
phone: '555-1234',
|
78
|
+
ssn: '123-45-6789'
|
79
|
+
)
|
80
|
+
|
81
|
+
puts "✅ User created: #{user.name}"
|
82
|
+
puts " Email: #{user.email} (decrypted)"
|
83
|
+
puts " Phone: #{user.phone} (decrypted)"
|
84
|
+
puts " SSN: #{user.ssn} (irreversible - shows placeholder)"
|
85
|
+
|
86
|
+
# 2. Show encrypted data in database
|
87
|
+
puts "\n2. Data in database (encrypted):"
|
88
|
+
raw_data = User.connection.select_one("SELECT email, phone, ssn FROM users WHERE id = #{user.id}")
|
89
|
+
puts " Email in DB: #{raw_data['email']} (encrypted)"
|
90
|
+
puts " Phone in DB: #{raw_data['phone']} (encrypted)"
|
91
|
+
puts " SSN in DB: #{raw_data['ssn']} (encrypted)"
|
92
|
+
|
93
|
+
# 3. Create a post with encrypted content
|
94
|
+
puts "\n3. Creating post with encrypted content..."
|
95
|
+
post = Post.create!(
|
96
|
+
title: 'Public Title',
|
97
|
+
content: 'This is sensitive content that will be encrypted',
|
98
|
+
user: user
|
99
|
+
)
|
100
|
+
|
101
|
+
puts "✅ Post created: #{post.title}"
|
102
|
+
puts " Content: #{post.content} (decrypted)"
|
103
|
+
|
104
|
+
raw_post = Post.connection.select_one("SELECT content FROM posts WHERE id = #{post.id}")
|
105
|
+
puts " Content in DB: #{raw_post['content']} (encrypted)"
|
106
|
+
|
107
|
+
# 4. Demonstrate query scopes
|
108
|
+
puts "\n4. Query scopes:"
|
109
|
+
puts " Total users: #{User.ff1_all.count}"
|
110
|
+
puts " Active users: #{User.ff1_active.count}"
|
111
|
+
puts " Deleted users: #{User.ff1_deleted.count}"
|
112
|
+
|
113
|
+
# 5. GDPR-compliant soft delete
|
114
|
+
puts "\n5. GDPR-compliant soft delete..."
|
115
|
+
puts " Before deletion - email: #{user.email}"
|
116
|
+
user.destroy
|
117
|
+
|
118
|
+
puts "✅ User soft deleted with irreversible encryption"
|
119
|
+
puts " After deletion - email: #{user.email} (now shows [ENCRYPTED])"
|
120
|
+
puts " Deleted status: #{user.ff1_deleted?}"
|
121
|
+
puts " Deleted at: #{user.ff1_deleted_at}"
|
122
|
+
|
123
|
+
# 6. Query scopes after deletion
|
124
|
+
puts "\n6. Query scopes after deletion:"
|
125
|
+
puts " Total users: #{User.ff1_all.count}"
|
126
|
+
puts " Active users: #{User.ff1_active.count}"
|
127
|
+
puts " Deleted users: #{User.ff1_deleted.count}"
|
128
|
+
|
129
|
+
# 7. Statistics
|
130
|
+
puts "\n7. FF1 Statistics:"
|
131
|
+
stats = User.ff1_stats
|
132
|
+
stats.each do |key, value|
|
133
|
+
puts " #{key}: #{value}"
|
134
|
+
end
|
135
|
+
|
136
|
+
# 8. Restore demonstration
|
137
|
+
puts "\n8. Restore user (data remains encrypted)..."
|
138
|
+
user.ff1_restore
|
139
|
+
puts " Deleted status after restore: #{user.ff1_deleted?}"
|
140
|
+
puts " Email after restore: #{user.email} (still encrypted)"
|
141
|
+
|
142
|
+
puts "\n🎉 Demo completed successfully!"
|
143
|
+
puts "The FF1 ActiveRecord integration provides seamless encryption"
|
144
|
+
puts "with GDPR compliance through irreversible soft deletion."
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FF1
|
4
|
+
module ActiveRecord
|
5
|
+
# Base functionality for FF1 ActiveRecord integration
|
6
|
+
module Base
|
7
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
8
|
+
|
9
|
+
# Thread-safe cipher cache
|
10
|
+
if defined?(Concurrent::Hash)
|
11
|
+
CIPHER_CACHE = Concurrent::Hash.new
|
12
|
+
else
|
13
|
+
CIPHER_CACHE = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# Get or create a cipher for the given column configuration
|
19
|
+
def get_cipher_for_column(column, mode = nil)
|
20
|
+
config = ff1_config[column.to_sym]
|
21
|
+
return nil unless config
|
22
|
+
|
23
|
+
mode ||= config[:mode]
|
24
|
+
key = config[:key] || FF1::ActiveRecord.configuration.global_key
|
25
|
+
radix = config[:radix] || determine_radix_for_column(column)
|
26
|
+
|
27
|
+
raise FF1::Error, "No encryption key provided for column #{column}" unless key
|
28
|
+
|
29
|
+
cache_key = "#{self.class.name}:#{column}:#{mode}:#{key.hash}:#{radix}"
|
30
|
+
|
31
|
+
Base::CIPHER_CACHE[cache_key] ||= FF1::Cipher.new(key, radix, mode)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Determine appropriate radix for a column based on its type
|
35
|
+
def determine_radix_for_column(column)
|
36
|
+
column_type = self.class.columns_hash[column.to_s]&.type
|
37
|
+
|
38
|
+
case column_type
|
39
|
+
when :integer, :bigint, :decimal
|
40
|
+
10 # Numeric columns use base 10
|
41
|
+
when :string, :text
|
42
|
+
256 # Text columns use base 256 for full UTF-8 support
|
43
|
+
else
|
44
|
+
256 # Default to text mode for unknown types
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Generate a tweak for encryption based on model and column
|
49
|
+
def generate_tweak_for_column(column)
|
50
|
+
# Use model and column name only - don't include record ID to ensure consistency
|
51
|
+
# between encryption (before save) and decryption (after save)
|
52
|
+
"#{self.class.name}:#{column}"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Validate encryption key format and length
|
56
|
+
def validate_encryption_key(key)
|
57
|
+
return false unless key.is_a?(String)
|
58
|
+
return false unless [16, 24, 32].include?(key.bytesize)
|
59
|
+
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
# Handle errors during encryption/decryption
|
64
|
+
def handle_encryption_error(error, column, operation)
|
65
|
+
Rails.logger.error "FF1 #{operation} error for #{self.class.name}##{column}: #{error.message}" if defined?(Rails) && Rails.respond_to?(:logger)
|
66
|
+
|
67
|
+
case operation
|
68
|
+
when :encrypt
|
69
|
+
# For encryption errors, we might want to fail fast
|
70
|
+
raise FF1::Error, "Failed to encrypt #{column}: #{error.message}"
|
71
|
+
when :decrypt
|
72
|
+
# For decryption errors, we might want to return nil or the raw value
|
73
|
+
# depending on application requirements
|
74
|
+
Rails.logger.warn "Failed to decrypt #{column}, returning raw value" if defined?(Rails) && Rails.respond_to?(:logger)
|
75
|
+
read_attribute(column)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FF1
|
4
|
+
module ActiveRecord
|
5
|
+
# Handles automatic encryption and decryption of configured columns
|
6
|
+
module Encryption
|
7
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
8
|
+
|
9
|
+
included do
|
10
|
+
# Hook into ActiveRecord callbacks
|
11
|
+
before_save :encrypt_ff1_columns
|
12
|
+
after_save :clear_ff1_decrypted_cache
|
13
|
+
after_initialize :setup_ff1_attributes
|
14
|
+
after_find :setup_ff1_attributes
|
15
|
+
end
|
16
|
+
|
17
|
+
# Check if an encrypted attribute has changed
|
18
|
+
def ff1_attribute_changed?(attr_name)
|
19
|
+
if ff1_config.key?(attr_name.to_sym)
|
20
|
+
# Check if we have a pending decrypted value (indicating a change)
|
21
|
+
decrypted_var = "@ff1_decrypted_#{attr_name}"
|
22
|
+
instance_variable_defined?(decrypted_var)
|
23
|
+
else
|
24
|
+
false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# Encrypt all configured FF1 columns before saving
|
31
|
+
def encrypt_ff1_columns
|
32
|
+
return unless respond_to?(:ff1_config) && ff1_config
|
33
|
+
|
34
|
+
ff1_config.each do |column, config|
|
35
|
+
# Check if this attribute needs encryption
|
36
|
+
decrypted_var = "@ff1_decrypted_#{column}"
|
37
|
+
has_decrypted_value = instance_variable_defined?(decrypted_var)
|
38
|
+
decrypted_value = instance_variable_get(decrypted_var) if has_decrypted_value
|
39
|
+
|
40
|
+
# Only encrypt if we have a decrypted value set or this is a new record with a value
|
41
|
+
if has_decrypted_value && !decrypted_value.nil?
|
42
|
+
encrypted_value = encrypt_attribute_value(column, decrypted_value, config[:mode])
|
43
|
+
write_attribute(column, encrypted_value) if encrypted_value
|
44
|
+
elsif new_record?
|
45
|
+
raw_value = read_attribute(column)
|
46
|
+
if raw_value && !raw_value.empty?
|
47
|
+
encrypted_value = encrypt_attribute_value(column, raw_value, config[:mode])
|
48
|
+
write_attribute(column, encrypted_value) if encrypted_value
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Setup decrypted attribute values after initialization
|
55
|
+
def setup_ff1_attributes
|
56
|
+
return unless persisted?
|
57
|
+
|
58
|
+
ff1_config.each do |column, _config|
|
59
|
+
# Store the encrypted value separately so we can access decrypted values
|
60
|
+
encrypted_value = read_attribute(column)
|
61
|
+
instance_variable_set("@ff1_encrypted_#{column}", encrypted_value)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Clear cached decrypted values after save
|
66
|
+
def clear_ff1_decrypted_cache
|
67
|
+
ff1_config.keys.each do |column|
|
68
|
+
decrypted_var = "@ff1_decrypted_#{column}"
|
69
|
+
remove_instance_variable(decrypted_var) if instance_variable_defined?(decrypted_var)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Decrypt an attribute value
|
74
|
+
def decrypt_attribute(column)
|
75
|
+
return nil unless ff1_config.key?(column.to_sym)
|
76
|
+
|
77
|
+
config = ff1_config[column.to_sym]
|
78
|
+
decrypted_var = "@ff1_decrypted_#{column}"
|
79
|
+
|
80
|
+
# If in irreversible mode, always return placeholder
|
81
|
+
if config[:mode] == FF1::Modes::IRREVERSIBLE
|
82
|
+
instance_variable_set(decrypted_var, "[ENCRYPTED]")
|
83
|
+
return "[ENCRYPTED]"
|
84
|
+
end
|
85
|
+
|
86
|
+
# If record is soft-deleted, all data should be treated as irreversible
|
87
|
+
if ff1_deleted?
|
88
|
+
instance_variable_set(decrypted_var, "[ENCRYPTED]")
|
89
|
+
return "[ENCRYPTED]"
|
90
|
+
end
|
91
|
+
|
92
|
+
# Return cached decrypted value if available (only for reversible columns)
|
93
|
+
return instance_variable_get(decrypted_var) if instance_variable_defined?(decrypted_var)
|
94
|
+
|
95
|
+
encrypted_value = read_attribute(column)
|
96
|
+
return nil if encrypted_value.nil?
|
97
|
+
return '' if encrypted_value == ''
|
98
|
+
|
99
|
+
begin
|
100
|
+
cipher = get_cipher_for_column(column, FF1::Modes::REVERSIBLE)
|
101
|
+
tweak = generate_tweak_for_column(column)
|
102
|
+
|
103
|
+
decrypted_value = if text_column?(column)
|
104
|
+
cipher.decrypt_text(encrypted_value, tweak)
|
105
|
+
else
|
106
|
+
cipher.decrypt(encrypted_value, tweak)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Cache the decrypted value
|
110
|
+
instance_variable_set(decrypted_var, decrypted_value)
|
111
|
+
decrypted_value
|
112
|
+
rescue FF1::Error => e
|
113
|
+
# If decryption fails (possibly due to irreversible encryption), return placeholder
|
114
|
+
instance_variable_set(decrypted_var, "[ENCRYPTED]")
|
115
|
+
"[ENCRYPTED]"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Set an encrypted attribute value (stores for later encryption)
|
120
|
+
def set_encrypted_attribute(column, value)
|
121
|
+
# Clear any existing cached decrypted value
|
122
|
+
decrypted_var = "@ff1_decrypted_#{column}"
|
123
|
+
remove_instance_variable(decrypted_var) if instance_variable_defined?(decrypted_var)
|
124
|
+
|
125
|
+
if value.nil?
|
126
|
+
# Handle nil values
|
127
|
+
instance_variable_set(decrypted_var, nil)
|
128
|
+
write_attribute(column, nil)
|
129
|
+
else
|
130
|
+
# Store the plaintext value for immediate access and later encryption
|
131
|
+
instance_variable_set(decrypted_var, value)
|
132
|
+
|
133
|
+
# Mark the attribute as changed so ActiveRecord knows to save it
|
134
|
+
attribute_will_change!(column.to_s) if respond_to?(:attribute_will_change!)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Encrypt a single attribute value
|
139
|
+
def encrypt_attribute_value(column, value, mode = nil)
|
140
|
+
return nil if value.nil?
|
141
|
+
return '' if value == ''
|
142
|
+
|
143
|
+
config = ff1_config[column.to_sym]
|
144
|
+
mode ||= config[:mode]
|
145
|
+
|
146
|
+
begin
|
147
|
+
cipher = get_cipher_for_column(column, mode)
|
148
|
+
tweak = generate_tweak_for_column(column)
|
149
|
+
|
150
|
+
if text_column?(column)
|
151
|
+
cipher.encrypt_text(value.to_s, tweak)
|
152
|
+
else
|
153
|
+
cipher.encrypt(value.to_s, tweak)
|
154
|
+
end
|
155
|
+
rescue FF1::Error => e
|
156
|
+
handle_encryption_error(e, column, :encrypt)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Check if a column is a text-based column
|
161
|
+
def text_column?(column)
|
162
|
+
column_type = self.class.columns_hash[column.to_s]&.type
|
163
|
+
[:string, :text].include?(column_type)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|