secretbox 1.0.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 +7 -0
- data/.github/workflows/ci.yml +44 -0
- data/.gitignore +10 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +214 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/Rakefile +25 -0
- data/lib/secretbox/version.rb +3 -0
- data/lib/secretbox.rb +75 -0
- data/secretbox.gemspec +36 -0
- data/spec/coverage_helper.rb +3 -0
- data/spec/minitest_helper.rb +5 -0
- data/spec/secretbox_spec.rb +92 -0
- metadata +151 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 66063a036d91ef89f2e4a2bcb89d2cb0d9f4592da4b06d602208f731732ba77f
|
|
4
|
+
data.tar.gz: 427a24a4ba19958be04169fa67d8471e8b754e037edd00c6e4e1cdc2e2b84b22
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d95aeceb5ab531a3f0486a21f370d6cd21be61ded0fb3220f6e6d3fe8ddd22721c7511ba42fc5fa5419e388ff01f2249b0857d218e1d9790cb8119f7acabb678
|
|
7
|
+
data.tar.gz: 7941981ed45a43b06452698a21e754decec6e59293fa22532d08e33758f7ea1f9f879ee6cf95b2e843187cf70fdee80c0ab850c5093c8ec1ab90c5f77b8da918
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
ruby-version: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4']
|
|
17
|
+
include:
|
|
18
|
+
- ruby-version: '4.0'
|
|
19
|
+
allow-failure: true
|
|
20
|
+
- ruby-version: ruby-head
|
|
21
|
+
allow-failure: true
|
|
22
|
+
- ruby-version: jruby-head
|
|
23
|
+
allow-failure: true
|
|
24
|
+
|
|
25
|
+
continue-on-error: ${{ matrix.allow-failure || false }}
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
|
31
|
+
uses: ruby/setup-ruby@v1
|
|
32
|
+
with:
|
|
33
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
34
|
+
bundler-cache: true
|
|
35
|
+
|
|
36
|
+
- name: Run tests
|
|
37
|
+
run: bundle exec rake spec
|
|
38
|
+
|
|
39
|
+
- name: Upload coverage to Codecov
|
|
40
|
+
if: matrix.ruby-version == '3.4'
|
|
41
|
+
uses: codecov/codecov-action@v5
|
|
42
|
+
with:
|
|
43
|
+
files: coverage/.resultset.json
|
|
44
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
data/.gitignore
ADDED
data/.ruby-gemset
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
secretbox
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.2.1
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Secretbox Agent Guidelines
|
|
2
|
+
|
|
3
|
+
Guidelines for agents working on the secretbox gem.
|
|
4
|
+
|
|
5
|
+
## Ruby Version Support
|
|
6
|
+
|
|
7
|
+
The gem maintains **backward compatibility** from Ruby **2.3.8** to **3.4+**.
|
|
8
|
+
|
|
9
|
+
- **2.3.8–3.3**: Fully supported (tested in CI)
|
|
10
|
+
- **3.4+**: Fully supported (base64 added as explicit dependency)
|
|
11
|
+
- **4.0, JRuby-head**: Experimental (allow-failure in CI)
|
|
12
|
+
|
|
13
|
+
When adding features or fixing bugs:
|
|
14
|
+
1. Test locally with `RUBY_VERSION >= 2.3.8`
|
|
15
|
+
2. Never use syntax newer than Ruby 2.3 (e.g., safe navigation `&.`, pattern matching)
|
|
16
|
+
3. All dependencies must work on Ruby 2.3.8+
|
|
17
|
+
4. Special handling for Ruby 3.4+ (see `secretbox.gemspec` for `base64` conditional dependency)
|
|
18
|
+
|
|
19
|
+
## Code Style & Conventions
|
|
20
|
+
|
|
21
|
+
### No Comments
|
|
22
|
+
Do not add comments to explain code. The code must be self-documenting through:
|
|
23
|
+
- Meaningful method and variable names
|
|
24
|
+
- Clear algorithm flow
|
|
25
|
+
- Tests that serve as living documentation
|
|
26
|
+
|
|
27
|
+
Existing comments are minimal by design. Keep it that way.
|
|
28
|
+
|
|
29
|
+
### Private Methods
|
|
30
|
+
- Use `private` keyword to mark internal methods
|
|
31
|
+
- Access instance state through explicit `attr_reader` (never directly access `@key` outside the class)
|
|
32
|
+
- See `lib/secretbox.rb` for the pattern
|
|
33
|
+
|
|
34
|
+
### Constants
|
|
35
|
+
- Define at class level in UPPERCASE
|
|
36
|
+
- Freeze strings: `.freeze`
|
|
37
|
+
- Group related constants together (ALGORITHM, KEY_SIZE, IV_SIZE, TAG_SIZE)
|
|
38
|
+
|
|
39
|
+
### Error Classes
|
|
40
|
+
- Inherit from `StandardError`
|
|
41
|
+
- Define nested under the main class
|
|
42
|
+
- Use existing: `Secretbox::InvalidKeyError`, `Secretbox::DecryptionError`
|
|
43
|
+
- Add only if new error condition cannot fit existing types
|
|
44
|
+
|
|
45
|
+
### Method Signatures
|
|
46
|
+
- Use keyword arguments for optional parameters (e.g., `auth_data: ''`)
|
|
47
|
+
- Default empty string `''` for `auth_data`, not `nil`
|
|
48
|
+
- Type validation happens early via `validate_string!`
|
|
49
|
+
|
|
50
|
+
## Testing
|
|
51
|
+
|
|
52
|
+
- Test framework: **Minitest** with `minitest-great_expectations` DSL
|
|
53
|
+
- Test file location: `spec/secretbox_spec.rb`
|
|
54
|
+
- Pattern: `describe`, `let`, `it` with `.must_*` assertions
|
|
55
|
+
- Cover: happy path, errors, edge cases, type validation, tampering
|
|
56
|
+
|
|
57
|
+
Run tests:
|
|
58
|
+
```bash
|
|
59
|
+
bundle exec rake spec
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Test filtering:
|
|
63
|
+
```bash
|
|
64
|
+
bundle exec rake spec NAME=encrypts # runs tests matching "encrypts"
|
|
65
|
+
bundle exec rake spec TEST=spec/secretbox_spec.rb:12 # runs line 12
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Version Management
|
|
69
|
+
|
|
70
|
+
Versioning follows [Semantic Versioning](https://semver.org):
|
|
71
|
+
|
|
72
|
+
- **MAJOR.MINOR.PATCH** format
|
|
73
|
+
- Located in `lib/secretbox/version.rb`
|
|
74
|
+
- `Secretbox::VERSION = '1.0.0'`
|
|
75
|
+
|
|
76
|
+
Increment:
|
|
77
|
+
- **MAJOR**: Breaking changes (e.g., change method signature)
|
|
78
|
+
- **MINOR**: New features, backward compatible
|
|
79
|
+
- **PATCH**: Bug fixes
|
|
80
|
+
|
|
81
|
+
Update version **before** committing changes.
|
|
82
|
+
|
|
83
|
+
## Git Conventions
|
|
84
|
+
|
|
85
|
+
### Commit Messages
|
|
86
|
+
All commits use **Conventional Commits** format:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
type(scope): subject
|
|
90
|
+
|
|
91
|
+
[optional body]
|
|
92
|
+
|
|
93
|
+
[optional footer]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Types:**
|
|
97
|
+
- `feat`: New feature
|
|
98
|
+
- `fix`: Bug fix
|
|
99
|
+
- `refactor`: Code restructuring (no behavior change)
|
|
100
|
+
- `test`: Test additions/updates
|
|
101
|
+
- `ci`: CI/CD workflow changes
|
|
102
|
+
- `chore`: Dependency updates, tooling
|
|
103
|
+
- `docs`: Documentation (though secretbox minimizes this)
|
|
104
|
+
|
|
105
|
+
**Scope:** (optional but recommended)
|
|
106
|
+
- `encrypt`, `decrypt`, `validation`, `compat`, `deps`
|
|
107
|
+
|
|
108
|
+
**Subject:**
|
|
109
|
+
- Lowercase, imperative mood ("add" not "adds" or "added")
|
|
110
|
+
- No period at the end
|
|
111
|
+
- Max 50 characters
|
|
112
|
+
|
|
113
|
+
**Examples:**
|
|
114
|
+
```
|
|
115
|
+
feat(encrypt): add support for empty plaintext
|
|
116
|
+
fix(decrypt): handle tampered ciphertext edge case
|
|
117
|
+
refactor: rename internal variable for clarity
|
|
118
|
+
test(validation): add type error cases
|
|
119
|
+
ci: add Ruby 3.4 to test matrix
|
|
120
|
+
chore(deps): update minitest to 5.20
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Branch Naming
|
|
124
|
+
While not enforced, follow this convention:
|
|
125
|
+
- `feat/description` for features
|
|
126
|
+
- `fix/description` for bug fixes
|
|
127
|
+
- `refactor/description` for refactoring
|
|
128
|
+
|
|
129
|
+
## File Structure
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
secretbox/
|
|
133
|
+
├── lib/secretbox.rb # Main class (no nested require)
|
|
134
|
+
├── lib/secretbox/version.rb # Version constant only
|
|
135
|
+
├── spec/secretbox_spec.rb # All tests in one file
|
|
136
|
+
├── spec/minitest_helper.rb # Setup (minimal)
|
|
137
|
+
├── spec/coverage_helper.rb # SimpleCov config
|
|
138
|
+
├── secretbox.gemspec # Gem metadata & dependencies
|
|
139
|
+
├── AGENT.md # This file
|
|
140
|
+
├── README.md # Usage guide
|
|
141
|
+
├── LICENSE.txt # MIT
|
|
142
|
+
└── .github/workflows/ci.yml # CI matrix
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Keep nested `lib/` structure minimal — only add new files if truly modular.
|
|
146
|
+
|
|
147
|
+
## Dependencies
|
|
148
|
+
|
|
149
|
+
### Runtime
|
|
150
|
+
- `base64`: Ruby 3.4+ only (OpenSSL handles it < 3.4)
|
|
151
|
+
|
|
152
|
+
### Development
|
|
153
|
+
- `rake`, `minitest`, `minitest-great_expectations`, `minitest-colorin`, `minitest-line`, `simplecov`
|
|
154
|
+
- Ruby 3.4+ only: `mutex_m`, `ostruct`
|
|
155
|
+
|
|
156
|
+
When adding dependencies:
|
|
157
|
+
1. Add to `secretbox.gemspec`
|
|
158
|
+
2. Document why (comment in gemspec)
|
|
159
|
+
3. Ensure Ruby 2.3.8 compatibility
|
|
160
|
+
4. Test in CI (already covers 2.3–3.4)
|
|
161
|
+
|
|
162
|
+
## Key Implementation Details
|
|
163
|
+
|
|
164
|
+
### Ciphertext Format
|
|
165
|
+
Base64-encoded: `iv (12 bytes) + auth_tag (16 bytes) + encrypted_data`
|
|
166
|
+
|
|
167
|
+
This format is **not changing**. If you need to support multiple formats, add a version byte inside the raw bytes.
|
|
168
|
+
|
|
169
|
+
### Algorithm
|
|
170
|
+
- **Cipher:** AES-256-GCM
|
|
171
|
+
- **IV size:** 12 bytes (nonce) — always random
|
|
172
|
+
- **Key size:** 32 bytes (256 bits)
|
|
173
|
+
- **Auth tag size:** 16 bytes (128 bits)
|
|
174
|
+
- **Auth data:** Authenticated but not encrypted
|
|
175
|
+
|
|
176
|
+
### Validation
|
|
177
|
+
- `validate_string!`: Type check (raises `TypeError`)
|
|
178
|
+
- `decode_base64!`: Decoding and error wrapping (raises `InvalidKeyError` or `TypeError`)
|
|
179
|
+
- Never validate inside the cipher calls — do it upfront
|
|
180
|
+
|
|
181
|
+
## Before Committing
|
|
182
|
+
|
|
183
|
+
1. ✅ Run tests: `bundle exec rake spec`
|
|
184
|
+
2. ✅ All tests pass on Ruby 2.3.8, 3.2, 3.4
|
|
185
|
+
3. ✅ Update `lib/secretbox/version.rb` if behavior changes
|
|
186
|
+
4. ✅ Commit with **Conventional Commits** format
|
|
187
|
+
5. ✅ No comments added to code
|
|
188
|
+
6. ✅ All new methods have test coverage
|
|
189
|
+
|
|
190
|
+
## Running Locally
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
cd /path/to/secretbox
|
|
194
|
+
|
|
195
|
+
# Install dependencies
|
|
196
|
+
bundle install
|
|
197
|
+
|
|
198
|
+
# Run tests
|
|
199
|
+
bundle exec rake spec
|
|
200
|
+
|
|
201
|
+
# Run tests with coverage
|
|
202
|
+
COVERAGE=true bundle exec rake spec
|
|
203
|
+
|
|
204
|
+
# Open IRB console with secretbox loaded
|
|
205
|
+
bundle exec rake console
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Questions?
|
|
209
|
+
|
|
210
|
+
Refer to:
|
|
211
|
+
- Implementation: `lib/secretbox.rb`
|
|
212
|
+
- Tests: `spec/secretbox_spec.rb`
|
|
213
|
+
- Gem metadata: `secretbox.gemspec`
|
|
214
|
+
- CI config: `.github/workflows/ci.yml`
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Gabriel Naiman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Secretbox
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/secretbox)
|
|
4
|
+
[](https://github.com/gabynaiman/secretbox/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
AES-256-GCM authenticated encryption for sensitive data.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add this line to your application's Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'secretbox'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
And then execute:
|
|
17
|
+
|
|
18
|
+
$ bundle
|
|
19
|
+
|
|
20
|
+
Or install it yourself as:
|
|
21
|
+
|
|
22
|
+
$ gem install secretbox
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Generate a key
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
key = Secretbox.generate_key # => base64 String (32 random bytes)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Store the key in an environment variable (e.g. `SECRETBOX_KEY`).
|
|
33
|
+
|
|
34
|
+
### Encrypt and decrypt
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
secretbox = Secretbox.new(ENV.fetch('SECRETBOX_KEY'))
|
|
38
|
+
|
|
39
|
+
ciphertext = secretbox.encrypt('secret message')
|
|
40
|
+
secretbox.decrypt(ciphertext) # => 'secret message'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### With authenticated associated data (AAD)
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
ciphertext = secretbox.encrypt('secret', auth_data: 'user:42')
|
|
47
|
+
secretbox.decrypt(ciphertext, auth_data: 'user:42') # => 'secret'
|
|
48
|
+
|
|
49
|
+
secretbox.decrypt(ciphertext, auth_data: 'user:99') # raises Secretbox::DecryptionError
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `auth_data` is not encrypted but is authenticated — any tampering with it
|
|
53
|
+
causes decryption to fail. Use it to bind ciphertext to context (e.g. a record ID).
|
|
54
|
+
|
|
55
|
+
### Encrypting structured data
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
settings = { api_key: 'abc123', token: 'xyz' }
|
|
59
|
+
ciphertext = secretbox.encrypt(JSON.generate(settings))
|
|
60
|
+
|
|
61
|
+
JSON.parse(secretbox.decrypt(ciphertext), symbolize_names: true) # => { api_key: 'abc123', token: 'xyz' }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
| Error | When |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `Secretbox::InvalidKeyError` | Key is not valid base64 or not 32 bytes |
|
|
69
|
+
| `Secretbox::DecryptionError` | Ciphertext is tampered, key is wrong, or auth_data mismatches |
|
|
70
|
+
| `TypeError` | plaintext/ciphertext/auth_data is not a String |
|
|
71
|
+
|
|
72
|
+
## Ciphertext format
|
|
73
|
+
|
|
74
|
+
Base64-encoded: `iv (12 bytes) + auth_tag (16 bytes) + encrypted_data`
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'bundler/gem_tasks'
|
|
2
|
+
require 'rake/testtask'
|
|
3
|
+
|
|
4
|
+
Rake::TestTask.new(:spec) do |t|
|
|
5
|
+
t.libs << 'spec'
|
|
6
|
+
t.libs << 'lib'
|
|
7
|
+
t.pattern = 'spec/**/*_spec.rb'
|
|
8
|
+
t.verbose = false
|
|
9
|
+
t.warning = false
|
|
10
|
+
t.loader = nil if ENV['TEST']
|
|
11
|
+
ENV['TEST'], ENV['LINE'] = ENV['TEST'].split(':') if ENV['TEST'] && !ENV['LINE']
|
|
12
|
+
t.options = ''
|
|
13
|
+
t.options << "--name=/#{ENV['NAME']}/ " if ENV['NAME']
|
|
14
|
+
t.options << "-l #{ENV['LINE']} " if ENV['LINE'] && ENV['TEST']
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
task default: :spec
|
|
18
|
+
|
|
19
|
+
desc 'IRB console'
|
|
20
|
+
task :console do
|
|
21
|
+
require 'irb'
|
|
22
|
+
require 'secretbox'
|
|
23
|
+
ARGV.clear
|
|
24
|
+
IRB.start
|
|
25
|
+
end
|
data/lib/secretbox.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
require_relative 'secretbox/version'
|
|
6
|
+
|
|
7
|
+
class Secretbox
|
|
8
|
+
|
|
9
|
+
ALGORITHM = 'aes-256-gcm'.freeze
|
|
10
|
+
KEY_SIZE = 32
|
|
11
|
+
IV_SIZE = 12
|
|
12
|
+
TAG_SIZE = 16
|
|
13
|
+
|
|
14
|
+
class InvalidKeyError < StandardError; end
|
|
15
|
+
class DecryptionError < StandardError; end
|
|
16
|
+
|
|
17
|
+
def self.generate_key
|
|
18
|
+
Base64.strict_encode64(SecureRandom.random_bytes(KEY_SIZE))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(key)
|
|
22
|
+
validate_string! key, :key
|
|
23
|
+
@key = decode_base64! key, :key, error: InvalidKeyError
|
|
24
|
+
raise InvalidKeyError, "key must be #{KEY_SIZE} bytes" unless @key.bytesize == KEY_SIZE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def encrypt(plaintext, auth_data: '')
|
|
28
|
+
validate_string! plaintext, :plaintext
|
|
29
|
+
validate_string! auth_data, :auth_data
|
|
30
|
+
|
|
31
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).tap(&:encrypt)
|
|
32
|
+
iv = cipher.random_iv
|
|
33
|
+
cipher.key = key
|
|
34
|
+
cipher.auth_data = auth_data
|
|
35
|
+
|
|
36
|
+
encrypted = cipher.update(plaintext) + cipher.final
|
|
37
|
+
|
|
38
|
+
Base64.strict_encode64(iv + cipher.auth_tag + encrypted)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def decrypt(ciphertext, auth_data: '')
|
|
42
|
+
validate_string! ciphertext, :ciphertext
|
|
43
|
+
validate_string! auth_data, :auth_data
|
|
44
|
+
|
|
45
|
+
raw = decode_base64! ciphertext, :ciphertext
|
|
46
|
+
iv = raw[0, IV_SIZE]
|
|
47
|
+
auth_tag = raw[IV_SIZE, TAG_SIZE]
|
|
48
|
+
encrypted = raw[(IV_SIZE + TAG_SIZE)..-1]
|
|
49
|
+
|
|
50
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).tap(&:decrypt)
|
|
51
|
+
cipher.key = key
|
|
52
|
+
cipher.iv = iv
|
|
53
|
+
cipher.auth_tag = auth_tag
|
|
54
|
+
cipher.auth_data = auth_data
|
|
55
|
+
|
|
56
|
+
cipher.update(encrypted) + cipher.final
|
|
57
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
58
|
+
raise DecryptionError, e.message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
attr_reader :key
|
|
64
|
+
|
|
65
|
+
def validate_string!(value, name)
|
|
66
|
+
raise TypeError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decode_base64!(value, name, error: TypeError)
|
|
70
|
+
Base64.strict_decode64(value)
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
raise error, "#{name} is not valid base64"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
end
|
data/secretbox.gemspec
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
+
require 'secretbox/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = 'secretbox'
|
|
8
|
+
spec.version = Secretbox::VERSION
|
|
9
|
+
spec.authors = ['Gabriel Naiman']
|
|
10
|
+
spec.email = ['gabynaiman@gmail.com']
|
|
11
|
+
spec.summary = 'AES-256-GCM authenticated encryption for sensitive data'
|
|
12
|
+
spec.description = 'AES-256-GCM authenticated encryption for sensitive data'
|
|
13
|
+
spec.homepage = 'https://github.com/gabynaiman/secretbox'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
|
|
16
|
+
spec.required_ruby_version = '>= 2.3.8'
|
|
17
|
+
|
|
18
|
+
spec.files = `git ls-files -z`.split("\x0")
|
|
19
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
20
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
21
|
+
spec.require_paths = ['lib']
|
|
22
|
+
|
|
23
|
+
spec.add_dependency 'base64' if RUBY_VERSION >= '3.4'
|
|
24
|
+
|
|
25
|
+
spec.add_development_dependency 'rake', '~> 12.0'
|
|
26
|
+
spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
|
|
27
|
+
spec.add_development_dependency 'minitest-great_expectations'
|
|
28
|
+
spec.add_development_dependency 'minitest-colorin', '~> 0.1'
|
|
29
|
+
spec.add_development_dependency 'minitest-line', '~> 0.6'
|
|
30
|
+
spec.add_development_dependency 'simplecov', '~> 0.12'
|
|
31
|
+
|
|
32
|
+
if RUBY_VERSION >= '3.4'
|
|
33
|
+
spec.add_development_dependency 'mutex_m'
|
|
34
|
+
spec.add_development_dependency 'ostruct'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Secretbox do
|
|
4
|
+
|
|
5
|
+
let(:key) { Secretbox.generate_key }
|
|
6
|
+
let(:secretbox) { Secretbox.new(key) }
|
|
7
|
+
|
|
8
|
+
it 'encrypts and decrypts a string' do
|
|
9
|
+
plaintext = 'secret message'
|
|
10
|
+
secretbox.decrypt(secretbox.encrypt(plaintext)).must_equal plaintext
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'encrypts and decrypts a JSON string' do
|
|
14
|
+
plaintext = JSON.generate({ token: 'abc123', expires_at: 9999999999, expires: true })
|
|
15
|
+
secretbox.decrypt(secretbox.encrypt(plaintext)).must_equal plaintext
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'produces different ciphertexts for the same input' do
|
|
19
|
+
plaintext = 'same message'
|
|
20
|
+
ciphertext1 = secretbox.encrypt(plaintext)
|
|
21
|
+
ciphertext2 = secretbox.encrypt(plaintext)
|
|
22
|
+
|
|
23
|
+
ciphertext1.wont_equal ciphertext2
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'ciphertext does not contain the plaintext' do
|
|
27
|
+
plaintext = 'sensitive data'
|
|
28
|
+
ciphertext = secretbox.encrypt(plaintext)
|
|
29
|
+
|
|
30
|
+
ciphertext.wont_include plaintext
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'encrypts and decrypts with auth_data' do
|
|
34
|
+
plaintext = 'secret message'
|
|
35
|
+
ciphertext = secretbox.encrypt(plaintext, auth_data: 'context')
|
|
36
|
+
|
|
37
|
+
secretbox.decrypt(ciphertext, auth_data: 'context').must_equal plaintext
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'raises TypeError when plaintext is not a String' do
|
|
41
|
+
proc { secretbox.encrypt(1) }.must_raise TypeError
|
|
42
|
+
proc { secretbox.encrypt(true) }.must_raise TypeError
|
|
43
|
+
proc { secretbox.encrypt(nil) }.must_raise TypeError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'raises TypeError when encrypt auth_data is not a String' do
|
|
47
|
+
proc { secretbox.encrypt('text', auth_data: 1) }.must_raise TypeError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'raises TypeError when ciphertext is not a String' do
|
|
51
|
+
proc { secretbox.decrypt(1) }.must_raise TypeError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'raises TypeError when decrypt auth_data is not a String' do
|
|
55
|
+
ciphertext = secretbox.encrypt('secret')
|
|
56
|
+
proc { secretbox.decrypt(ciphertext, auth_data: 1) }.must_raise TypeError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'raises InvalidKeyError when the key is not valid base64' do
|
|
60
|
+
proc { Secretbox.new('not-valid!!!') }.must_raise Secretbox::InvalidKeyError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'raises InvalidKeyError when the key is not 32 bytes' do
|
|
64
|
+
proc { Secretbox.new(Base64.strict_encode64('tooshort')) }.must_raise Secretbox::InvalidKeyError
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'raises TypeError when the ciphertext is not valid base64' do
|
|
68
|
+
proc { secretbox.decrypt('not-valid-base64!!!') }.must_raise TypeError
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'raises DecryptionError when decrypting with a different key' do
|
|
72
|
+
other_secretbox = Secretbox.new(Secretbox.generate_key)
|
|
73
|
+
ciphertext = secretbox.encrypt('secret')
|
|
74
|
+
|
|
75
|
+
proc { other_secretbox.decrypt(ciphertext) }.must_raise Secretbox::DecryptionError
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'raises DecryptionError when decrypting with a different auth_data' do
|
|
79
|
+
ciphertext = secretbox.encrypt('secret', auth_data: 'context')
|
|
80
|
+
|
|
81
|
+
proc { secretbox.decrypt(ciphertext, auth_data: 'other') }.must_raise Secretbox::DecryptionError
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'raises DecryptionError when the ciphertext is tampered' do
|
|
85
|
+
raw = Base64.strict_decode64(secretbox.encrypt('secret'))
|
|
86
|
+
raw[-1] = (raw[-1].ord ^ 0xFF).chr
|
|
87
|
+
tampered = Base64.strict_encode64(raw)
|
|
88
|
+
|
|
89
|
+
proc { secretbox.decrypt(tampered) }.must_raise Secretbox::DecryptionError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: secretbox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Gabriel Naiman
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rake
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '12.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '12.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '5.11'
|
|
37
|
+
type: :development
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - "~>"
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '5.0'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.11'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: minitest-great_expectations
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: minitest-colorin
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.1'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.1'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: minitest-line
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.6'
|
|
82
|
+
type: :development
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.6'
|
|
89
|
+
- !ruby/object:Gem::Dependency
|
|
90
|
+
name: simplecov
|
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.12'
|
|
96
|
+
type: :development
|
|
97
|
+
prerelease: false
|
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.12'
|
|
103
|
+
description: AES-256-GCM authenticated encryption for sensitive data
|
|
104
|
+
email:
|
|
105
|
+
- gabynaiman@gmail.com
|
|
106
|
+
executables: []
|
|
107
|
+
extensions: []
|
|
108
|
+
extra_rdoc_files: []
|
|
109
|
+
files:
|
|
110
|
+
- ".github/workflows/ci.yml"
|
|
111
|
+
- ".gitignore"
|
|
112
|
+
- ".ruby-gemset"
|
|
113
|
+
- ".ruby-version"
|
|
114
|
+
- AGENTS.md
|
|
115
|
+
- Gemfile
|
|
116
|
+
- LICENSE.txt
|
|
117
|
+
- README.md
|
|
118
|
+
- Rakefile
|
|
119
|
+
- lib/secretbox.rb
|
|
120
|
+
- lib/secretbox/version.rb
|
|
121
|
+
- secretbox.gemspec
|
|
122
|
+
- spec/coverage_helper.rb
|
|
123
|
+
- spec/minitest_helper.rb
|
|
124
|
+
- spec/secretbox_spec.rb
|
|
125
|
+
homepage: https://github.com/gabynaiman/secretbox
|
|
126
|
+
licenses:
|
|
127
|
+
- MIT
|
|
128
|
+
metadata: {}
|
|
129
|
+
post_install_message:
|
|
130
|
+
rdoc_options: []
|
|
131
|
+
require_paths:
|
|
132
|
+
- lib
|
|
133
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: 2.3.8
|
|
138
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '0'
|
|
143
|
+
requirements: []
|
|
144
|
+
rubygems_version: 3.4.6
|
|
145
|
+
signing_key:
|
|
146
|
+
specification_version: 4
|
|
147
|
+
summary: AES-256-GCM authenticated encryption for sensitive data
|
|
148
|
+
test_files:
|
|
149
|
+
- spec/coverage_helper.rb
|
|
150
|
+
- spec/minitest_helper.rb
|
|
151
|
+
- spec/secretbox_spec.rb
|