balloon_hashing 0.1.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/LICENSE +21 -0
- data/README.md +242 -0
- data/lib/balloon_hashing/base.rb +11 -0
- data/lib/balloon_hashing/core.rb +151 -0
- data/lib/balloon_hashing/hasher.rb +93 -0
- data/lib/balloon_hashing/password.rb +203 -0
- data/lib/balloon_hashing/version.rb +5 -0
- data/lib/balloon_hashing.rb +23 -0
- metadata +114 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4d752a3421f8ab1942af7d85563993197f1980819b91cce288264e8bb2876e2d
|
|
4
|
+
data.tar.gz: 741d2a2ee1ce2a9963f3eccf0e3dbe9afdb1f6f649dd11e539b38341f97fd332
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 35da97b6c18a7d8d956d89aba3cb8a38a61d200c9558bd384cff9e3d53abf530731f20bfd92ae9153b97d3c00a9f86514e4f23085588cb26f2bb1189d8e41914
|
|
7
|
+
data.tar.gz: 0bc74dc54f864811908e7e96a25d3e6ec282ce3fc65d3dbe5161a99cbb890c2be30994923338f8500b54571cdae243f0a197cc567b982b296969b781c8a10182
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suleyman Musayev
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Balloon Hashing
|
|
2
|
+
|
|
3
|
+
Pure Ruby implementation of the [Balloon Hashing](https://crypto.stanford.edu/balloon/) algorithm (Boneh, Corrigan-Gibbs, Schechter 2016) — a memory-hard password hashing function with provable security guarantees against sequential attacks.
|
|
4
|
+
|
|
5
|
+
## Why Balloon Hashing?
|
|
6
|
+
|
|
7
|
+
- **Memory-hard** — resistant to GPU and ASIC attacks by requiring large amounts of memory to compute
|
|
8
|
+
- **Provably secure** — the only password hashing function with formal proofs of memory-hardness
|
|
9
|
+
- **Simple design** — built on standard cryptographic primitives (SHA-256, SHA-512, BLAKE2b) with no custom block ciphers
|
|
10
|
+
- **Tunable** — independent space cost (`s_cost`) and time cost (`t_cost`) parameters let you balance security against performance
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add to your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "balloon_hashing"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install directly:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
gem install balloon_hashing
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Requirements:** Ruby >= 2.7.0, OpenSSL
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "balloon_hashing"
|
|
32
|
+
|
|
33
|
+
# Hash a password
|
|
34
|
+
hash = BalloonHashing.create("my password")
|
|
35
|
+
# => "$balloon$v=1$alg=sha256,s=1024,t=3$..."
|
|
36
|
+
|
|
37
|
+
# Verify a password
|
|
38
|
+
BalloonHashing.verify("my password", hash) #=> true
|
|
39
|
+
BalloonHashing.verify("wrong password", hash) #=> false
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Basic API
|
|
45
|
+
|
|
46
|
+
The simplest way to use the gem — module-level convenience methods with sensible defaults:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
hash = BalloonHashing.create("password")
|
|
50
|
+
BalloonHashing.verify("password", hash) #=> true
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Custom Parameters
|
|
54
|
+
|
|
55
|
+
Adjust the cost parameters and algorithm to fit your security requirements:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
hash = BalloonHashing.create(
|
|
59
|
+
"password",
|
|
60
|
+
s_cost: 2048, # space cost — number of blocks in memory (default: 1024)
|
|
61
|
+
t_cost: 4, # time cost — number of mixing rounds (default: 3)
|
|
62
|
+
algorithm: "sha512" # hash algorithm (default: "sha256")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
BalloonHashing.verify("password", hash) #=> true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Supported Algorithms
|
|
69
|
+
|
|
70
|
+
| Algorithm | Output Size | Notes |
|
|
71
|
+
|-----------|------------|-------|
|
|
72
|
+
| `sha256` | 32 bytes | Default, widely available |
|
|
73
|
+
| `sha512` | 64 bytes | Larger output, slightly slower |
|
|
74
|
+
| `blake2b` | 64 bytes | Fast, requires OpenSSL with BLAKE2 support |
|
|
75
|
+
|
|
76
|
+
### Instance-Based API with `Hasher`
|
|
77
|
+
|
|
78
|
+
For applications that need a configured hasher instance (e.g. different cost settings for different user roles):
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
hasher = BalloonHashing::Hasher.new(
|
|
82
|
+
s_cost: 2048,
|
|
83
|
+
t_cost: 4,
|
|
84
|
+
algorithm: "sha512"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
hash = hasher.create("password")
|
|
88
|
+
hasher.verify("password", hash) #=> true
|
|
89
|
+
hasher.cost_matches?(hash) #=> true
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Upgrading Cost Parameters (Rehash on Login)
|
|
93
|
+
|
|
94
|
+
When you increase cost parameters, existing hashes still verify but use the old settings. Use `needs_rehash?` to transparently upgrade hashes when users log in:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
hasher = BalloonHashing::Hasher.new(s_cost: 2048, t_cost: 4)
|
|
98
|
+
|
|
99
|
+
if hasher.verify(password, stored_hash)
|
|
100
|
+
if hasher.needs_rehash?(stored_hash)
|
|
101
|
+
# Re-hash with current (stronger) parameters
|
|
102
|
+
stored_hash = hasher.create(password)
|
|
103
|
+
save_to_database(stored_hash)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
log_user_in
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Strict Verification with `verify!`
|
|
111
|
+
|
|
112
|
+
If you want to ensure the hash was created with the hasher's exact configuration:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
hasher = BalloonHashing::Hasher.new(s_cost: 2048, t_cost: 4)
|
|
116
|
+
|
|
117
|
+
hasher.verify("password", hash) # Works regardless of hash parameters
|
|
118
|
+
hasher.verify!("password", hash) # Raises BalloonHashing::InvalidHash if parameters don't match
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Pepper (Server-Side Secret)
|
|
122
|
+
|
|
123
|
+
Add an optional server-side secret that is mixed into the hash via HMAC but never stored in the output string. This means a leaked database alone is not enough to crack passwords:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# Module-level API
|
|
127
|
+
hash = BalloonHashing.create("password", pepper: ENV["PASSWORD_PEPPER"])
|
|
128
|
+
BalloonHashing.verify("password", hash, pepper: ENV["PASSWORD_PEPPER"])
|
|
129
|
+
|
|
130
|
+
# Instance-based API — pepper is set once at construction
|
|
131
|
+
hasher = BalloonHashing::Hasher.new(s_cost: 2048, t_cost: 4, pepper: ENV["PASSWORD_PEPPER"])
|
|
132
|
+
hash = hasher.create("password")
|
|
133
|
+
hasher.verify("password", hash) #=> true
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
> **Note:** The pepper must be the same for both `create` and `verify`. If you lose the pepper, all existing hashes become unverifiable. Store it securely (e.g. environment variable, secrets manager) separate from the database.
|
|
137
|
+
|
|
138
|
+
### Low-Level Core API
|
|
139
|
+
|
|
140
|
+
For advanced use cases, you can call the core algorithm directly:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
raw_hash = BalloonHashing::Core.balloon(
|
|
144
|
+
"password", # password (String)
|
|
145
|
+
salt, # salt (String, random bytes)
|
|
146
|
+
1024, # s_cost (Integer)
|
|
147
|
+
3, # t_cost (Integer)
|
|
148
|
+
"sha256" # algorithm (String)
|
|
149
|
+
)
|
|
150
|
+
# => raw binary hash bytes
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This returns raw bytes and does not generate salts or produce encoded strings.
|
|
154
|
+
|
|
155
|
+
## Hash Format
|
|
156
|
+
|
|
157
|
+
Encoded hashes follow a structured, self-describing format:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
$balloon$v=1$alg=sha256,s=1024,t=3$<base64_salt>$<base64_hash>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| Field | Description |
|
|
164
|
+
|-------|-------------|
|
|
165
|
+
| `$balloon$` | Identifier prefix |
|
|
166
|
+
| `v=1` | Format version |
|
|
167
|
+
| `alg=sha256` | Hash algorithm |
|
|
168
|
+
| `s=1024` | Space cost |
|
|
169
|
+
| `t=3` | Time cost |
|
|
170
|
+
| `<base64_salt>` | Base64-encoded salt |
|
|
171
|
+
| `<base64_hash>` | Base64-encoded hash output |
|
|
172
|
+
|
|
173
|
+
The format is forward-compatible — unknown parameter keys are ignored during decoding, so future versions can add new parameters without breaking existing hashes.
|
|
174
|
+
|
|
175
|
+
## Cost Parameter Guidance
|
|
176
|
+
|
|
177
|
+
The right cost parameters depend on your hardware and latency budget. As a starting point:
|
|
178
|
+
|
|
179
|
+
| Use Case | `s_cost` | `t_cost` | Notes |
|
|
180
|
+
|----------|----------|----------|-------|
|
|
181
|
+
| Interactive login | 1024 | 3 | ~default, suitable for web apps |
|
|
182
|
+
| Higher security | 2048–4096 | 3–4 | More memory, similar latency |
|
|
183
|
+
| Offline/batch | 8192+ | 4+ | When latency is not a concern |
|
|
184
|
+
|
|
185
|
+
**`s_cost`** (space cost) controls the number of hash-sized blocks held in memory. Each block is 32 bytes (SHA-256) or 64 bytes (SHA-512/BLAKE2b), so `s_cost: 1024` with SHA-256 uses ~32 KB of memory.
|
|
186
|
+
|
|
187
|
+
**`t_cost`** (time cost) controls the number of mixing rounds over the buffer. Higher values increase computation time linearly.
|
|
188
|
+
|
|
189
|
+
Benchmark on your target hardware and choose the highest values that stay within your latency budget.
|
|
190
|
+
|
|
191
|
+
## Safety Limits
|
|
192
|
+
|
|
193
|
+
To prevent accidental denial-of-service from misconfigured parameters:
|
|
194
|
+
|
|
195
|
+
- `s_cost` is capped at `2^24` (~16 million blocks)
|
|
196
|
+
- `t_cost` is capped at `2^20` (~1 million rounds)
|
|
197
|
+
|
|
198
|
+
These limits apply to both direct calls and when decoding stored hashes, protecting against crafted hash strings with extreme values.
|
|
199
|
+
|
|
200
|
+
## Error Handling
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
BalloonHashing::Error # Base error class (inherits StandardError)
|
|
204
|
+
BalloonHashing::InvalidHash # Raised for malformed or invalid encoded hash strings
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`verify` and `cost_matches?` return `false` for invalid hashes rather than raising. If you need to distinguish "wrong password" from "corrupted hash", call the `Hasher#verify!` variant which raises `InvalidHash` on parameter mismatch.
|
|
208
|
+
|
|
209
|
+
`create` raises `ArgumentError` for invalid input (non-String password, unsupported algorithm, invalid cost values).
|
|
210
|
+
|
|
211
|
+
## Thread Safety
|
|
212
|
+
|
|
213
|
+
All public APIs are thread-safe:
|
|
214
|
+
|
|
215
|
+
- **`BalloonHashing::Core`** — each call allocates its own digest and buffer; no shared state
|
|
216
|
+
- **`BalloonHashing::Password`** — all methods are stateless module functions
|
|
217
|
+
- **`BalloonHashing::Hasher`** — instances are effectively immutable after construction
|
|
218
|
+
|
|
219
|
+
`Hasher` instances can be safely shared across threads and stored as constants or in application configuration.
|
|
220
|
+
|
|
221
|
+
## Security Notes
|
|
222
|
+
|
|
223
|
+
- **Constant-time comparison** — password verification uses `OpenSSL.fixed_length_secure_compare` to prevent timing attacks
|
|
224
|
+
- **Buffer zeroing** — the in-memory hash buffer is zeroed after use on a best-effort basis. Ruby's garbage collector may retain copies of intermediate data. For applications requiring stronger memory protection guarantees, consider a C extension wrapping `OPENSSL_cleanse` or `sodium_memzero`
|
|
225
|
+
- **Pepper via HMAC** — when a pepper is provided, it is mixed using HMAC-SHA256 (not simple concatenation), eliminating length-ambiguity attacks. The pepper is never included in the encoded hash output
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
git clone https://github.com/msuliq/balloon_hashing.git
|
|
231
|
+
cd balloon_hashing
|
|
232
|
+
bundle install
|
|
233
|
+
bundle exec rake test
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## References
|
|
237
|
+
|
|
238
|
+
- Boneh, D., Corrigan-Gibbs, H., & Schechter, S. (2016). [Balloon Hashing: A Memory-Hard Function Providing Provable Protection Against Sequential Attacks](https://eprint.iacr.org/2016/027.pdf). IACR Cryptology ePrint Archive.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module BalloonHashing
|
|
6
|
+
# Core implementation of the Balloon Hashing algorithm.
|
|
7
|
+
#
|
|
8
|
+
# Reference: Boneh, Corrigan-Gibbs, Schechter (2016)
|
|
9
|
+
# "Balloon Hashing: A Memory-Hard Function Providing Provable
|
|
10
|
+
# Protection Against Sequential Attacks"
|
|
11
|
+
#
|
|
12
|
+
# Thread safety: all public methods are stateless and safe to call
|
|
13
|
+
# concurrently from multiple threads. Each invocation allocates its
|
|
14
|
+
# own digest and buffer with no shared mutable state.
|
|
15
|
+
module Core
|
|
16
|
+
DELTA = 3 # Number of dependencies per block (from the paper)
|
|
17
|
+
MAX_S_COST = 2**24 # ~16M blocks — safety ceiling to prevent accidental OOM
|
|
18
|
+
MAX_T_COST = 2**20 # ~1M rounds — safety ceiling to prevent CPU freeze
|
|
19
|
+
|
|
20
|
+
SUPPORTED_ALGORITHMS = {
|
|
21
|
+
"sha256" => "SHA256",
|
|
22
|
+
"sha512" => "SHA512",
|
|
23
|
+
"blake2b" => "BLAKE2b512"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Check whether an algorithm name is supported.
|
|
29
|
+
#
|
|
30
|
+
# @param algorithm [String]
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def valid_algorithm?(algorithm)
|
|
33
|
+
SUPPORTED_ALGORITHMS.key?(algorithm.to_s.downcase)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validate an algorithm name, raising if unsupported.
|
|
37
|
+
#
|
|
38
|
+
# @param algorithm [String]
|
|
39
|
+
# @raise [ArgumentError] if the algorithm is not supported
|
|
40
|
+
# @return [void]
|
|
41
|
+
def validate_algorithm!(algorithm)
|
|
42
|
+
return if valid_algorithm?(algorithm)
|
|
43
|
+
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"Unsupported algorithm: #{algorithm}. " \
|
|
46
|
+
"Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Compute a Balloon hash.
|
|
50
|
+
#
|
|
51
|
+
# @param password [String] the password to hash
|
|
52
|
+
# @param salt [String] the salt (random bytes)
|
|
53
|
+
# @param s_cost [Integer] space cost (number of blocks in buffer)
|
|
54
|
+
# @param t_cost [Integer] time cost (number of mixing rounds)
|
|
55
|
+
# @param algorithm [String] hash algorithm ("sha256", "sha512", "blake2b")
|
|
56
|
+
# @return [String] raw hash bytes
|
|
57
|
+
def balloon(password, salt, s_cost, t_cost, algorithm = DEFAULT_ALGORITHM)
|
|
58
|
+
raise ArgumentError, "s_cost must be >= 1" if s_cost < 1
|
|
59
|
+
raise ArgumentError, "t_cost must be >= 1" if t_cost < 1
|
|
60
|
+
raise ArgumentError, "s_cost must be <= #{MAX_S_COST}" if s_cost > MAX_S_COST
|
|
61
|
+
raise ArgumentError, "t_cost must be <= #{MAX_T_COST}" if t_cost > MAX_T_COST
|
|
62
|
+
|
|
63
|
+
digest_name = resolve_algorithm(algorithm)
|
|
64
|
+
digest = OpenSSL::Digest.new(digest_name)
|
|
65
|
+
block_size = digest.digest_length
|
|
66
|
+
password = password.to_s.b
|
|
67
|
+
salt = salt.b
|
|
68
|
+
|
|
69
|
+
cnt = 0
|
|
70
|
+
|
|
71
|
+
# Step 1: Expand input into a flat binary buffer
|
|
72
|
+
buf = "\0".b * (s_cost * block_size)
|
|
73
|
+
|
|
74
|
+
buf[0, block_size] = hash_func(digest, int64le(cnt), password, salt)
|
|
75
|
+
cnt += 1
|
|
76
|
+
|
|
77
|
+
(1...s_cost).each do |m|
|
|
78
|
+
offset = m * block_size
|
|
79
|
+
prev_offset = (m - 1) * block_size
|
|
80
|
+
buf[offset, block_size] = hash_func(digest, int64le(cnt), buf[prev_offset, block_size])
|
|
81
|
+
cnt += 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Step 2: Mix buffer contents
|
|
85
|
+
t_cost.times do |t|
|
|
86
|
+
s_cost.times do |m|
|
|
87
|
+
offset = m * block_size
|
|
88
|
+
prev_offset = ((m - 1) % s_cost) * block_size
|
|
89
|
+
|
|
90
|
+
buf[offset, block_size] = hash_func(
|
|
91
|
+
digest, int64le(cnt), buf[prev_offset, block_size], buf[offset, block_size]
|
|
92
|
+
)
|
|
93
|
+
cnt += 1
|
|
94
|
+
|
|
95
|
+
# Hash in pseudorandomly chosen blocks
|
|
96
|
+
DELTA.times do |i|
|
|
97
|
+
idx_block = [t, m, i].pack("Q<Q<Q<")
|
|
98
|
+
other_index = hash_to_int(
|
|
99
|
+
hash_func(digest, int64le(cnt), salt, idx_block)
|
|
100
|
+
) % s_cost
|
|
101
|
+
other_offset = other_index * block_size
|
|
102
|
+
buf[offset, block_size] = hash_func(
|
|
103
|
+
digest, int64le(cnt), buf[offset, block_size], buf[other_offset, block_size]
|
|
104
|
+
)
|
|
105
|
+
cnt += 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Step 3: Extract output from last block
|
|
111
|
+
result = buf[(s_cost - 1) * block_size, block_size].dup
|
|
112
|
+
|
|
113
|
+
# Best-effort buffer zeroing. Ruby's GC may retain copies of
|
|
114
|
+
# intermediate string slices, and compaction can move memory without
|
|
115
|
+
# clearing the original location. For stronger guarantees, a C
|
|
116
|
+
# extension using OPENSSL_cleanse or sodium_memzero would be needed.
|
|
117
|
+
buf.replace("\0" * buf.bytesize)
|
|
118
|
+
|
|
119
|
+
result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hash arbitrary number of byte string inputs using the given digest.
|
|
123
|
+
def hash_func(digest, *inputs)
|
|
124
|
+
digest.reset
|
|
125
|
+
inputs.each { |input| digest.update(input) }
|
|
126
|
+
digest.digest
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Encode an integer as a little-endian 64-bit byte string.
|
|
130
|
+
def int64le(n)
|
|
131
|
+
[n].pack("Q<")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Convert a hash output (byte string) to a non-negative integer.
|
|
135
|
+
# Uses first 8 bytes to avoid BigInteger allocation.
|
|
136
|
+
def hash_to_int(bytes)
|
|
137
|
+
bytes.unpack1("Q>")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Resolve algorithm name to OpenSSL digest name.
|
|
141
|
+
def resolve_algorithm(algorithm)
|
|
142
|
+
SUPPORTED_ALGORITHMS.fetch(algorithm.to_s.downcase) do
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Unsupported algorithm: #{algorithm}. " \
|
|
145
|
+
"Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :hash_func, :int64le, :hash_to_int, :resolve_algorithm
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BalloonHashing
|
|
4
|
+
# Instance-based API with configurable defaults.
|
|
5
|
+
#
|
|
6
|
+
# Thread safety: instances are effectively immutable after construction.
|
|
7
|
+
# All instance variables are frozen, and the public methods delegate to
|
|
8
|
+
# the stateless Password module. Safe to share across threads.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# hasher = BalloonHashing::Hasher.new(s_cost: 2048, t_cost: 4, algorithm: "sha512")
|
|
12
|
+
# hash = hasher.create("password")
|
|
13
|
+
# hasher.verify("password", hash) #=> true
|
|
14
|
+
# hasher.cost_matches?(hash) #=> true
|
|
15
|
+
class Hasher
|
|
16
|
+
attr_reader :s_cost, :t_cost, :algorithm, :salt_length
|
|
17
|
+
|
|
18
|
+
def initialize(s_cost: DEFAULT_S_COST, t_cost: DEFAULT_T_COST,
|
|
19
|
+
algorithm: DEFAULT_ALGORITHM,
|
|
20
|
+
salt_length: DEFAULT_SALT_LENGTH, pepper: nil)
|
|
21
|
+
Core.validate_algorithm!(algorithm)
|
|
22
|
+
raise ArgumentError, "s_cost must be >= 1" if s_cost < 1
|
|
23
|
+
raise ArgumentError, "t_cost must be >= 1" if t_cost < 1
|
|
24
|
+
raise ArgumentError, "salt_length must be a positive Integer" unless salt_length.is_a?(Integer) && salt_length > 0
|
|
25
|
+
|
|
26
|
+
@s_cost = s_cost
|
|
27
|
+
@t_cost = t_cost
|
|
28
|
+
@algorithm = algorithm.to_s.downcase.freeze
|
|
29
|
+
@salt_length = salt_length
|
|
30
|
+
@pepper = pepper.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create(password, salt: nil)
|
|
34
|
+
Password.create(
|
|
35
|
+
password,
|
|
36
|
+
s_cost: @s_cost,
|
|
37
|
+
t_cost: @t_cost,
|
|
38
|
+
algorithm: @algorithm,
|
|
39
|
+
salt: salt,
|
|
40
|
+
salt_length: @salt_length,
|
|
41
|
+
pepper: @pepper
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Verify a password against an encoded hash string.
|
|
46
|
+
# This verifies using the parameters stored in the encoded hash,
|
|
47
|
+
# regardless of this hasher's configuration. A hash created with
|
|
48
|
+
# sha256 will verify correctly even if this hasher is configured
|
|
49
|
+
# for sha512. Use verify! if you want to enforce that the hash
|
|
50
|
+
# matches this hasher's configuration.
|
|
51
|
+
def verify(password, encoded)
|
|
52
|
+
Password.verify(password, encoded, pepper: @pepper)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Like verify, but also raises if the encoded hash does not match
|
|
56
|
+
# this hasher's cost configuration.
|
|
57
|
+
#
|
|
58
|
+
# @raise [BalloonHashing::InvalidHash] if the hash params don't match
|
|
59
|
+
# @return [Boolean] true if password matches
|
|
60
|
+
def verify!(password, encoded)
|
|
61
|
+
unless cost_matches?(encoded)
|
|
62
|
+
raise InvalidHash, "encoded hash does not match this hasher's configuration"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
verify(password, encoded)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if the encoded hash matches this hasher's configuration.
|
|
69
|
+
def cost_matches?(encoded)
|
|
70
|
+
Password.cost_matches?(
|
|
71
|
+
encoded,
|
|
72
|
+
s_cost: @s_cost,
|
|
73
|
+
t_cost: @t_cost,
|
|
74
|
+
algorithm: @algorithm
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if the encoded hash uses outdated parameters compared to
|
|
79
|
+
# this hasher's configuration. Useful for the rehash-on-login pattern:
|
|
80
|
+
#
|
|
81
|
+
# if hasher.verify(password, stored_hash) && hasher.needs_rehash?(stored_hash)
|
|
82
|
+
# stored_hash = hasher.create(password)
|
|
83
|
+
# end
|
|
84
|
+
def needs_rehash?(encoded)
|
|
85
|
+
!cost_matches?(encoded)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inspect
|
|
89
|
+
"#<#{self.class} s_cost=#{@s_cost} t_cost=#{@t_cost}" \
|
|
90
|
+
" algorithm=#{@algorithm} salt_length=#{@salt_length}>"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module BalloonHashing
|
|
7
|
+
# High-level password hashing API with salt generation and
|
|
8
|
+
# encoded hash string output.
|
|
9
|
+
#
|
|
10
|
+
# Hash format (v=1):
|
|
11
|
+
# $balloon$v=1$alg=sha256,s=1024,t=3$<base64_salt>$<base64_hash>
|
|
12
|
+
#
|
|
13
|
+
# Unknown parameter keys are ignored during decoding for forward
|
|
14
|
+
# compatibility — new parameters (e.g. parallelism) can be added in
|
|
15
|
+
# future versions without breaking existing hashes.
|
|
16
|
+
#
|
|
17
|
+
# Thread safety: all public methods are stateless and safe to call
|
|
18
|
+
# concurrently from multiple threads.
|
|
19
|
+
#
|
|
20
|
+
# Example:
|
|
21
|
+
# hash = BalloonHashing::Password.create("my password")
|
|
22
|
+
# BalloonHashing::Password.verify("my password", hash) #=> true
|
|
23
|
+
# BalloonHashing::Password.verify("wrong", hash) #=> false
|
|
24
|
+
module Password
|
|
25
|
+
PREFIX = "$balloon$"
|
|
26
|
+
FORMAT_VERSION = 1
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Hash a password with Balloon Hashing.
|
|
31
|
+
#
|
|
32
|
+
# @param password [String] the plaintext password
|
|
33
|
+
# @param s_cost [Integer] space cost (default: 1024)
|
|
34
|
+
# @param t_cost [Integer] time cost (default: 3)
|
|
35
|
+
# @param algorithm [String] "sha256", "sha512", or "blake2b"
|
|
36
|
+
# @param salt [String, nil] raw salt bytes, or nil to generate random
|
|
37
|
+
# @param salt_length [Integer] salt length in bytes if generating
|
|
38
|
+
# @param pepper [String, nil] optional server-side secret; mixed via
|
|
39
|
+
# HMAC so it is never stored in the encoded output
|
|
40
|
+
# @return [String] encoded hash string
|
|
41
|
+
# @raise [ArgumentError] if password is not a string or parameters are invalid
|
|
42
|
+
def create(password, s_cost: DEFAULT_S_COST, t_cost: DEFAULT_T_COST,
|
|
43
|
+
algorithm: DEFAULT_ALGORITHM, salt: nil,
|
|
44
|
+
salt_length: DEFAULT_SALT_LENGTH, pepper: nil)
|
|
45
|
+
validate_password!(password)
|
|
46
|
+
Core.validate_algorithm!(algorithm)
|
|
47
|
+
validate_salt_length!(salt_length) unless salt
|
|
48
|
+
|
|
49
|
+
salt ||= SecureRandom.random_bytes(salt_length)
|
|
50
|
+
input = peppered(password, pepper)
|
|
51
|
+
|
|
52
|
+
raw_hash = Core.balloon(input, salt, s_cost, t_cost, algorithm)
|
|
53
|
+
|
|
54
|
+
encode(algorithm, s_cost, t_cost, salt, raw_hash)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Verify a password against an encoded hash string.
|
|
58
|
+
#
|
|
59
|
+
# @param password [String] the plaintext password to check
|
|
60
|
+
# @param encoded [String] the encoded hash string from .create
|
|
61
|
+
# @param pepper [String, nil] the same pepper used during .create
|
|
62
|
+
# @return [Boolean] true if the password matches
|
|
63
|
+
def verify(password, encoded, pepper: nil)
|
|
64
|
+
validate_password!(password)
|
|
65
|
+
params = decode(encoded)
|
|
66
|
+
input = peppered(password, pepper)
|
|
67
|
+
|
|
68
|
+
raw_hash = Core.balloon(
|
|
69
|
+
input,
|
|
70
|
+
params[:salt],
|
|
71
|
+
params[:s_cost],
|
|
72
|
+
params[:t_cost],
|
|
73
|
+
params[:algorithm]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
secure_compare(raw_hash, params[:hash])
|
|
77
|
+
rescue InvalidHash
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check whether the encoded hash uses the given cost parameters.
|
|
82
|
+
# Useful for deciding whether to re-hash on next login.
|
|
83
|
+
#
|
|
84
|
+
# @param encoded [String] the encoded hash string
|
|
85
|
+
# @param s_cost [Integer] expected space cost
|
|
86
|
+
# @param t_cost [Integer] expected time cost
|
|
87
|
+
# @param algorithm [String] expected algorithm
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def cost_matches?(encoded, s_cost: DEFAULT_S_COST, t_cost: DEFAULT_T_COST,
|
|
90
|
+
algorithm: DEFAULT_ALGORITHM)
|
|
91
|
+
params = decode(encoded)
|
|
92
|
+
|
|
93
|
+
params[:s_cost] == s_cost &&
|
|
94
|
+
params[:t_cost] == t_cost &&
|
|
95
|
+
params[:algorithm] == algorithm.to_s.downcase
|
|
96
|
+
rescue InvalidHash
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Encode hash parameters and output into a string.
|
|
101
|
+
def encode(algorithm, s_cost, t_cost, salt, hash)
|
|
102
|
+
salt_b64 = Base64.strict_encode64(salt)
|
|
103
|
+
hash_b64 = Base64.strict_encode64(hash)
|
|
104
|
+
params = "alg=#{algorithm},s=#{s_cost},t=#{t_cost}"
|
|
105
|
+
"#{PREFIX}v=#{FORMAT_VERSION}$#{params}$#{salt_b64}$#{hash_b64}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Decode an encoded hash string into its components.
|
|
109
|
+
#
|
|
110
|
+
# @param encoded [String]
|
|
111
|
+
# @return [Hash] parsed parameters
|
|
112
|
+
# @raise [BalloonHashing::InvalidHash] if the string cannot be decoded
|
|
113
|
+
def decode(encoded)
|
|
114
|
+
raise InvalidHash, "expected a String, got #{encoded.class}" unless encoded.is_a?(String)
|
|
115
|
+
raise InvalidHash, "missing balloon prefix" unless encoded.start_with?(PREFIX)
|
|
116
|
+
|
|
117
|
+
parts = encoded.split("$")
|
|
118
|
+
# Parts: ["", "balloon", "v=1", "alg=sha256,s=1024,t=3", "<salt>", "<hash>"]
|
|
119
|
+
raise InvalidHash, "malformed hash string" unless parts.length == 6
|
|
120
|
+
|
|
121
|
+
version_str = parts[2]
|
|
122
|
+
raise InvalidHash, "unsupported version: #{version_str}" unless version_str == "v=#{FORMAT_VERSION}"
|
|
123
|
+
|
|
124
|
+
# Parse key=value pairs; unknown keys are ignored for forward compatibility
|
|
125
|
+
param_pairs = parts[3].split(",").each_with_object({}) do |pair, h|
|
|
126
|
+
k, v = pair.split("=", 2)
|
|
127
|
+
h[k] = v if k && v
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless param_pairs.key?("alg") && param_pairs.key?("s") && param_pairs.key?("t")
|
|
131
|
+
raise InvalidHash, "missing required parameters in hash string"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
s_cost = Integer(param_pairs["s"])
|
|
136
|
+
t_cost = Integer(param_pairs["t"])
|
|
137
|
+
rescue ArgumentError, TypeError => e
|
|
138
|
+
raise InvalidHash, "invalid cost parameter: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
algorithm = param_pairs["alg"]
|
|
141
|
+
|
|
142
|
+
validate_decoded_costs!(s_cost, t_cost)
|
|
143
|
+
unless Core.valid_algorithm?(algorithm)
|
|
144
|
+
raise InvalidHash, "unsupported algorithm in hash string: #{algorithm}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
salt = Base64.strict_decode64(parts[4])
|
|
149
|
+
hash = Base64.strict_decode64(parts[5])
|
|
150
|
+
rescue ArgumentError => e
|
|
151
|
+
raise InvalidHash, "invalid base64: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
algorithm: algorithm,
|
|
156
|
+
s_cost: s_cost,
|
|
157
|
+
t_cost: t_cost,
|
|
158
|
+
salt: salt,
|
|
159
|
+
hash: hash
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Constant-time comparison to prevent timing attacks.
|
|
164
|
+
def secure_compare(a, b)
|
|
165
|
+
return false unless a.bytesize == b.bytesize
|
|
166
|
+
|
|
167
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_password!(password)
|
|
171
|
+
raise ArgumentError, "password must be a String" unless password.is_a?(String)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_salt_length!(salt_length)
|
|
175
|
+
raise ArgumentError, "salt_length must be a positive Integer" unless salt_length.is_a?(Integer) && salt_length > 0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_decoded_costs!(s_cost, t_cost)
|
|
179
|
+
raise InvalidHash, "decoded s_cost must be >= 1" if s_cost < 1
|
|
180
|
+
raise InvalidHash, "decoded s_cost exceeds maximum (#{Core::MAX_S_COST})" if s_cost > Core::MAX_S_COST
|
|
181
|
+
raise InvalidHash, "decoded t_cost must be >= 1" if t_cost < 1
|
|
182
|
+
raise InvalidHash, "decoded t_cost exceeds maximum (#{Core::MAX_T_COST})" if t_cost > Core::MAX_T_COST
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Derive a peppered password using HMAC to avoid length-ambiguity
|
|
186
|
+
# issues with simple concatenation. The pepper is the HMAC key and
|
|
187
|
+
# the password is the message, producing a fixed-length binary output
|
|
188
|
+
# that is then used as the input to the balloon hash.
|
|
189
|
+
#
|
|
190
|
+
# HMAC-SHA256 is used regardless of the balloon algorithm choice.
|
|
191
|
+
# This is intentional: the HMAC here is a pre-mixing step, not the
|
|
192
|
+
# memory-hard hash itself. SHA256 provides a 256-bit output which is
|
|
193
|
+
# sufficient for all supported balloon algorithms.
|
|
194
|
+
def peppered(password, pepper)
|
|
195
|
+
return password unless pepper
|
|
196
|
+
|
|
197
|
+
OpenSSL::HMAC.digest("SHA256", pepper.to_s.b, password.to_s.b)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_class_method :encode, :decode, :secure_compare, :validate_password!,
|
|
201
|
+
:validate_salt_length!, :validate_decoded_costs!, :peppered
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "balloon_hashing/version"
|
|
4
|
+
require_relative "balloon_hashing/base"
|
|
5
|
+
require_relative "balloon_hashing/core"
|
|
6
|
+
require_relative "balloon_hashing/password"
|
|
7
|
+
require_relative "balloon_hashing/hasher"
|
|
8
|
+
|
|
9
|
+
module BalloonHashing
|
|
10
|
+
# Convenience method to hash a password.
|
|
11
|
+
#
|
|
12
|
+
# @see BalloonHashing::Password.create
|
|
13
|
+
def self.create(password, **options)
|
|
14
|
+
Password.create(password, **options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Convenience method to verify a password.
|
|
18
|
+
#
|
|
19
|
+
# @see BalloonHashing::Password.verify
|
|
20
|
+
def self.verify(password, encoded, **options)
|
|
21
|
+
Password.verify(password, encoded, **options)
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: balloon_hashing
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Suleyman Musayev
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: bundler
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: minitest
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '5.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '5.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
description: Pure Ruby implementation of the Balloon Hashing algorithm (Boneh, Corrigan-Gibbs,
|
|
70
|
+
Schechter 2016). Supports SHA-256, SHA-512, and BLAKE2b. Memory-hard with provable
|
|
71
|
+
protection against sequential attacks.
|
|
72
|
+
email:
|
|
73
|
+
- slmusayev@gmail.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- LICENSE
|
|
79
|
+
- README.md
|
|
80
|
+
- lib/balloon_hashing.rb
|
|
81
|
+
- lib/balloon_hashing/base.rb
|
|
82
|
+
- lib/balloon_hashing/core.rb
|
|
83
|
+
- lib/balloon_hashing/hasher.rb
|
|
84
|
+
- lib/balloon_hashing/password.rb
|
|
85
|
+
- lib/balloon_hashing/version.rb
|
|
86
|
+
homepage: https://github.com/msuliq/balloon_hashing
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
rubygems_mfa_required: 'true'
|
|
91
|
+
homepage_uri: https://github.com/msuliq/balloon_hashing
|
|
92
|
+
source_code_uri: https://github.com/msuliq/balloon_hashing
|
|
93
|
+
changelog_uri: https://github.com/msuliq/balloon_hashing/blob/master/CHANGELOG.md
|
|
94
|
+
post_install_message:
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 2.7.0
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 3.4.19
|
|
110
|
+
signing_key:
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: 'Balloon Hashing: a memory-hard password hashing function with provable security
|
|
113
|
+
guarantees.'
|
|
114
|
+
test_files: []
|