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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BalloonHashing
4
+ class Error < StandardError; end
5
+ class InvalidHash < Error; end
6
+
7
+ DEFAULT_S_COST = 1024
8
+ DEFAULT_T_COST = 3
9
+ DEFAULT_ALGORITHM = "sha256"
10
+ DEFAULT_SALT_LENGTH = 16 # bytes
11
+ end
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BalloonHashing
4
+ VERSION = "0.1.0"
5
+ 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: []