epithet 1.0.0 → 1.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +3 -1
- data/SECURITY.md +36 -25
- data/lib/epithet/version.rb +2 -2
- data/lib/epithet.rb +100 -47
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 100d3ac2b640a77419d9d6f45228ed67251285ed6f0bf390b9249573d20db887
|
|
4
|
+
data.tar.gz: 5350064afb6799feec528888c475bcc0d8e403272f13332d00bdcd43b7bcd47c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77e5c3acde924854471a36ede3d43e010fcf4f2bd7f4a49b75c753e7c80e4c0651355c0c33ec28a9d1c229abf14fd5da61b40c1eae2a23cbd31f8c8ef94cd9a5
|
|
7
|
+
data.tar.gz: 21a8c95376177e30642bed7aefd1b1f87ccd5f0a00d489592c154ed1ecf86cdf076df57f04f98d01ad802b4ea1c62367b5afcc0fdd165501f95fe081ac10efd9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 - 2026-07-14
|
|
4
|
+
|
|
5
|
+
- Freeze config strings upon object initialization
|
|
6
|
+
- Merge custom scrypt params
|
|
7
|
+
- Block58 now defaults to a generic s2i that handles any block size
|
|
8
|
+
- Optimised 16-byte unrolled s2i selected via `Block58::build`
|
|
9
|
+
- Recognize unprefixed decodes by payload length
|
|
10
|
+
- Fix github CI warnings
|
|
11
|
+
- Write notes on salt & improve examples
|
|
12
|
+
|
|
3
13
|
## 1.0.0 - 2026-07-14
|
|
4
14
|
|
|
5
15
|
### Breaking changes
|
data/README.md
CHANGED
|
@@ -21,12 +21,14 @@ gem install epithet
|
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
|
+
With `EPITHET_PASSPHRASE="example only"`:
|
|
25
|
+
|
|
24
26
|
```ruby
|
|
25
27
|
require 'epithet'
|
|
26
28
|
|
|
27
29
|
def epithet_initialize
|
|
28
30
|
Epithet.configure(
|
|
29
|
-
passphrase: ENV.fetch('EPITHET_PASSPHRASE')
|
|
31
|
+
passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
|
|
30
32
|
salt: 'v1'
|
|
31
33
|
)
|
|
32
34
|
end
|
data/SECURITY.md
CHANGED
|
@@ -2,39 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
## Cryptographic considerations
|
|
4
4
|
|
|
5
|
-
The primary construction is `AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7])` with the result
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
This library is intended for high-performance obfuscation of integer sequences, deflection
|
|
11
|
-
|
|
12
|
-
to
|
|
13
|
-
the
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
The identifiers produced are intentionally deterministic i.e. replayable and reusable. For
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
in the usual manner.
|
|
5
|
+
The primary construction is `AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7])` with the result base58
|
|
6
|
+
encoded for transmission and a contextual prefix prepended. Subkeys for AES and HMAC are by default
|
|
7
|
+
derived with HKDF using an internal key generator that takes IKM from a passphrase via scrypt,
|
|
8
|
+
salting generated keys by prefix and purpose.
|
|
9
|
+
|
|
10
|
+
This library is intended for high-performance obfuscation of integer sequences, deflection of casual
|
|
11
|
+
tampering, and conversion to a compact, stable wire parameter format that is hard to guess and hard
|
|
12
|
+
to predict. Although it uses standard cryptographic primitives to do so, the design trade-off of
|
|
13
|
+
the compact format means it is not intended to defeat nation-state security services, talented
|
|
14
|
+
cryptographers, or even a well-resourced enterprise.
|
|
15
|
+
|
|
16
|
+
The identifiers produced are intentionally deterministic i.e. replayable and reusable. For privacy,
|
|
17
|
+
confidentiality, and authentication purposes they should therefore be considered equivalent to the
|
|
18
|
+
plaintext integer they represent, and those concerns must still be addressed in the usual manner.
|
|
20
19
|
|
|
21
20
|
The tamper detection is necessarily probabilistic, because the MAC is truncated.
|
|
22
21
|
|
|
23
|
-
Encodings are canonical, producing exactly one string per id, and Epithet will reject attempts
|
|
24
|
-
|
|
22
|
+
Encodings are canonical, producing exactly one string per id, and Epithet will reject attempts to
|
|
23
|
+
decode a value exceeding the 128-bit block.
|
|
24
|
+
|
|
25
|
+
If configuring alternative cipher algorithms, note that only 128-bit block ciphers that function
|
|
26
|
+
without IV/nonce requirements are accepted. Streaming ciphers (e.g. chacha20) or block ciphers in
|
|
27
|
+
streaming modes (e.g. aes-256-ctr) must not be used; no nonce/IV value is included in construction,
|
|
28
|
+
making them trivially vulnerable to known-plaintext attacks. These, CBC/OCB, and other IV/nonce
|
|
29
|
+
modes may also be rejected by Epithet's guardrails.
|
|
25
30
|
|
|
26
|
-
If configuring alternative
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
known-plaintext attacks. These, CBC/OCB, and other IV/nonce modes may also be rejected
|
|
31
|
-
by Epithet's guardrails.
|
|
31
|
+
If configuring alternative digest algorithms, note that any algorithm may be accepted whenever they
|
|
32
|
+
it produces at least 64 bits of output. HMAC does not rest on collision resistance, so even dated
|
|
33
|
+
digests are not trivially forgeable here, but algorithms other than the defaults step outside the
|
|
34
|
+
supported security profile. If you must stray, stay within the SHA-2 family.
|
|
32
35
|
|
|
33
|
-
A weak, guessable, or disclosed passphrase will compromise the obfuscation and
|
|
34
|
-
|
|
36
|
+
A weak, guessable, or disclosed passphrase will compromise the obfuscation and tamper-detection
|
|
37
|
+
properties.
|
|
35
38
|
|
|
36
39
|
Use Epithet at your own risk.
|
|
37
40
|
|
|
41
|
+
## On salt
|
|
42
|
+
|
|
43
|
+
Epithet uses salt in two ways. Firstly, as part of a scrypt operation to turn the configured
|
|
44
|
+
passphrase into initial keying material. Secondly, to supply an additional affordance to separate
|
|
45
|
+
derived subkeys by some application-specific division such as purpose or rotation epoch. Epithet
|
|
46
|
+
does not store or verify passwords; both uses of salt are non-secret configuration and may safely be
|
|
47
|
+
committed to source control.
|
|
48
|
+
|
|
38
49
|
## Vulnerabilities
|
|
39
50
|
|
|
40
51
|
If you think you've found a vulnerability in Epithet that compromises its design or behaviour, please
|
data/lib/epithet/version.rb
CHANGED
data/lib/epithet.rb
CHANGED
|
@@ -46,7 +46,7 @@ class Epithet
|
|
|
46
46
|
def initialize(prefix, config: Epithet.defaults)
|
|
47
47
|
prefix = String(prefix)
|
|
48
48
|
key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack('Q>Z*Q>Z*')
|
|
49
|
-
@block58 = Block58.
|
|
49
|
+
@block58 = Block58.build(16, alphabet: config.alphabet)
|
|
50
50
|
@prefix_s = "#{prefix}#{config.separator}"
|
|
51
51
|
|
|
52
52
|
cipher_key_len = OpenSSL::Cipher.new(config.cipher).key_len
|
|
@@ -74,12 +74,13 @@ class Epithet
|
|
|
74
74
|
@prefix_s + @block58.i2s(ct)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
# Decode a prefixed or raw Base58 string to an Integer.
|
|
77
|
+
# Decode a prefixed or raw Base58 string to an Integer. Raw inputs are
|
|
78
|
+
# recognised by their exact payload length.
|
|
78
79
|
#
|
|
79
80
|
# Returns the Integer on success, nil if authentication fails.
|
|
80
81
|
# Raises ArgumentError on invalid wire format (see Block58#valid?).
|
|
81
82
|
def decode(s)
|
|
82
|
-
s = s.delete_prefix(@prefix_s)
|
|
83
|
+
s = s.delete_prefix(@prefix_s) unless s.bytesize == @block58.size
|
|
83
84
|
raise ArgumentError, 'unexpected format' unless @block58.valid?(s)
|
|
84
85
|
|
|
85
86
|
d = @decryptor.dup
|
|
@@ -104,7 +105,10 @@ class Epithet
|
|
|
104
105
|
# #### Examples
|
|
105
106
|
#
|
|
106
107
|
# # As it might appear in an initializer
|
|
107
|
-
# Epithet.configure(
|
|
108
|
+
# Epithet.configure(
|
|
109
|
+
# passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
|
|
110
|
+
# scrypt: { salt: "#{MyApp.name}/#{MyApp.env}" }
|
|
111
|
+
# )
|
|
108
112
|
#
|
|
109
113
|
# # Retaining already-configured passphrase but updating salt,
|
|
110
114
|
# # and using a custom separator.
|
|
@@ -117,7 +121,7 @@ class Epithet
|
|
|
117
121
|
# #### Options
|
|
118
122
|
#
|
|
119
123
|
# * `:passphrase` - Install new key generator with scrypt-derived key material
|
|
120
|
-
# * `:scrypt` - Params for scrypt
|
|
124
|
+
# * `:scrypt` - Params for scrypt, merged over `Keygen::DEFAULT_SCRYPT_PARAMS`
|
|
121
125
|
# * `:keygen` - Install an existing key generator
|
|
122
126
|
# * `:cipher` - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard `aes-256-ecb`
|
|
123
127
|
# * `:digest` - Must be >= 64 bits; omit for standard `sha256`
|
|
@@ -162,11 +166,11 @@ class Epithet
|
|
|
162
166
|
|
|
163
167
|
def initialize(opts = {})
|
|
164
168
|
opts = opts.dup
|
|
165
|
-
@separator = String(opts.delete(:separator) { '_' })
|
|
166
|
-
@salt = String(opts.delete(:salt))
|
|
167
|
-
@alphabet = String(opts.delete(:alphabet) { Block58::Alphabet })
|
|
168
|
-
@cipher = opts.delete(:cipher) || 'aes-256-ecb'
|
|
169
|
-
@digest = opts.delete(:digest) || 'sha256'
|
|
169
|
+
@separator = -String(opts.delete(:separator) { '_' })
|
|
170
|
+
@salt = -String(opts.delete(:salt))
|
|
171
|
+
@alphabet = -String(opts.delete(:alphabet) { Block58::Alphabet })
|
|
172
|
+
@cipher = -(opts.delete(:cipher) || 'aes-256-ecb')
|
|
173
|
+
@digest = -(opts.delete(:digest) || 'sha256')
|
|
170
174
|
|
|
171
175
|
cipher = OpenSSL::Cipher.new(@cipher)
|
|
172
176
|
raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(@alphabet.bytes)
|
|
@@ -177,8 +181,9 @@ class Epithet
|
|
|
177
181
|
@keygen = opts.delete(:keygen) || Keygen.new(
|
|
178
182
|
passphrase: opts.delete(:passphrase),
|
|
179
183
|
digest: @digest,
|
|
180
|
-
scrypt: opts.delete(:scrypt) ||
|
|
184
|
+
scrypt: opts.delete(:scrypt) || {})
|
|
181
185
|
raise ArgumentError, "unused option(s) #{opts.keys}" unless opts.empty?
|
|
186
|
+
freeze
|
|
182
187
|
end
|
|
183
188
|
end
|
|
184
189
|
|
|
@@ -204,13 +209,15 @@ class Epithet
|
|
|
204
209
|
}.freeze
|
|
205
210
|
|
|
206
211
|
# Create a new key generator from either high-entropy key material, or a supplied passphrase.
|
|
207
|
-
|
|
212
|
+
# Supplied scrypt params are merged over DEFAULT_SCRYPT_PARAMS.
|
|
213
|
+
def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: {})
|
|
208
214
|
if (passphrase.nil? && ikm.nil?) || (!passphrase.nil? && !ikm.nil?)
|
|
209
215
|
raise ArgumentError, 'keygen requires either ikm or passphrase'
|
|
210
216
|
end
|
|
211
217
|
|
|
212
|
-
@ikm = ikm
|
|
213
|
-
@digest = digest
|
|
218
|
+
@ikm = (ikm ? ikm.b : OpenSSL::KDF.scrypt(passphrase, **DEFAULT_SCRYPT_PARAMS, **scrypt)).freeze
|
|
219
|
+
@digest = -String(digest)
|
|
220
|
+
freeze
|
|
214
221
|
end
|
|
215
222
|
|
|
216
223
|
def inspect
|
|
@@ -224,15 +231,27 @@ class Epithet
|
|
|
224
231
|
end
|
|
225
232
|
|
|
226
233
|
# Fixed-length Base58 codec for a fixed-size block.
|
|
234
|
+
#
|
|
235
|
+
# Obtain codecs via Block58::build, which selects the fastest variant for
|
|
236
|
+
# the block size, an unrolled decoder for 16-byte blocks, or the generic
|
|
237
|
+
# chunked decoder otherwise.
|
|
227
238
|
class Block58
|
|
228
239
|
# `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
|
|
229
240
|
Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
|
230
241
|
|
|
242
|
+
POW58 = Array.new(11) { 58**_1 }.freeze # :nodoc:
|
|
243
|
+
|
|
244
|
+
attr_reader :size
|
|
245
|
+
|
|
246
|
+
# Same as ::new but may select a tuned subclass for performance.
|
|
247
|
+
def self.build(block_size, ...) = (block_size == 16 ? Unrolled16 : self).new(block_size, ...)
|
|
248
|
+
|
|
231
249
|
# Create a codec for a block size in bytes.
|
|
232
250
|
#
|
|
233
251
|
# The alphabet must be 58 distinct bytes in ascending order, so that
|
|
234
252
|
# lexicographic order agrees with numeric order.
|
|
235
253
|
def initialize(block_size, alphabet: Alphabet)
|
|
254
|
+
raise ArgumentError, 'invalid block size' unless Integer === block_size && block_size > 0
|
|
236
255
|
@alphabet = alphabet.b.freeze
|
|
237
256
|
raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
|
|
238
257
|
raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
|
|
@@ -272,44 +291,78 @@ class Epithet
|
|
|
272
291
|
# Decode a fixed-length Base58 string to an Integer.
|
|
273
292
|
# Assumes the input passes `#valid?`. Wraps at 58**size on the i2s round trip.
|
|
274
293
|
def s2i(str)
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
# By unrolling coefficients, this is ~8x faster than Horner's scheme
|
|
294
|
+
# Chunking intermediate results into 64-bit integers is ~5x faster
|
|
295
|
+
# under YJIT than Horner's scheme
|
|
278
296
|
#
|
|
279
297
|
# str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
|
|
280
298
|
#
|
|
281
|
-
# at computing the inner product
|
|
282
|
-
# intermediate results into 64-bit integers.
|
|
299
|
+
# at computing the inner product.
|
|
283
300
|
lut = @lut
|
|
301
|
+
size = @size
|
|
302
|
+
pow = POW58
|
|
303
|
+
acc = 0
|
|
304
|
+
pos = 0
|
|
305
|
+
while pos < size
|
|
306
|
+
n = size - pos
|
|
307
|
+
n = 10 if n > 10
|
|
308
|
+
chunk = 0
|
|
309
|
+
i = 0
|
|
310
|
+
while i < n
|
|
311
|
+
chunk = (chunk * 58) + lut.getbyte(str.getbyte(pos))
|
|
312
|
+
pos += 1
|
|
313
|
+
i += 1
|
|
314
|
+
end
|
|
315
|
+
acc = (acc * pow[n]) + chunk
|
|
316
|
+
end
|
|
317
|
+
acc
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Specialised decoder for 16-byte blocks (22 digits) with a fully unrolled inner product.
|
|
321
|
+
class Unrolled16 < Block58
|
|
322
|
+
def initialize(...)
|
|
323
|
+
super
|
|
324
|
+
raise ArgumentError, 'unrolled codec requires a 16-byte block' unless @size == 22
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Decode a fixed-length Base58 string to an Integer.
|
|
328
|
+
# Assumes the input passes `#valid?`.
|
|
329
|
+
def s2i(str)
|
|
330
|
+
# rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
|
|
331
|
+
#
|
|
332
|
+
# By unrolling the chunks against literal coefficients, this is ~1.6x
|
|
333
|
+
# faster under YJIT than the generic chunked Block58#s2i, and ~8x
|
|
334
|
+
# faster than Horner's scheme.
|
|
335
|
+
lut = @lut
|
|
284
336
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
337
|
+
acc0 = lut.getbyte(str.getbyte(0)) * 7427658739644928 +
|
|
338
|
+
lut.getbyte(str.getbyte(1)) * 128063081718016 +
|
|
339
|
+
lut.getbyte(str.getbyte(2)) * 2207984167552 +
|
|
340
|
+
lut.getbyte(str.getbyte(3)) * 38068692544 +
|
|
341
|
+
lut.getbyte(str.getbyte(4)) * 656356768 +
|
|
342
|
+
lut.getbyte(str.getbyte(5)) * 11316496 +
|
|
343
|
+
lut.getbyte(str.getbyte(6)) * 195112 +
|
|
344
|
+
lut.getbyte(str.getbyte(7)) * 3364 +
|
|
345
|
+
lut.getbyte(str.getbyte(8)) * 58 +
|
|
346
|
+
lut.getbyte(str.getbyte(9))
|
|
347
|
+
|
|
348
|
+
acc1 = lut.getbyte(str.getbyte(10)) * 7427658739644928 +
|
|
349
|
+
lut.getbyte(str.getbyte(11)) * 128063081718016 +
|
|
350
|
+
lut.getbyte(str.getbyte(12)) * 2207984167552 +
|
|
351
|
+
lut.getbyte(str.getbyte(13)) * 38068692544 +
|
|
352
|
+
lut.getbyte(str.getbyte(14)) * 656356768 +
|
|
353
|
+
lut.getbyte(str.getbyte(15)) * 11316496 +
|
|
354
|
+
lut.getbyte(str.getbyte(16)) * 195112 +
|
|
355
|
+
lut.getbyte(str.getbyte(17)) * 3364 +
|
|
356
|
+
lut.getbyte(str.getbyte(18)) * 58 +
|
|
357
|
+
lut.getbyte(str.getbyte(19))
|
|
358
|
+
|
|
359
|
+
lut.getbyte(str.getbyte(21)) +
|
|
360
|
+
58 * lut.getbyte(str.getbyte(20)) +
|
|
361
|
+
3364 * acc1 +
|
|
362
|
+
1449225352009601191936 * acc0
|
|
363
|
+
|
|
364
|
+
# rubocop:enable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
|
|
365
|
+
end
|
|
313
366
|
end
|
|
314
367
|
end
|
|
315
368
|
end
|