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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3695519b4c82955cf40a1d3b5109fbeaf3458085146500eea600c0fa8ce86bb0
4
- data.tar.gz: f527803fc071230ae440b9e199f65ad890a0bf8778d643258b0c58b1a0f53b78
3
+ metadata.gz: 100d3ac2b640a77419d9d6f45228ed67251285ed6f0bf390b9249573d20db887
4
+ data.tar.gz: 5350064afb6799feec528888c475bcc0d8e403272f13332d00bdcd43b7bcd47c
5
5
  SHA512:
6
- metadata.gz: 725d659f196c53fc6ea438607dd9319b95b5aac8b61a68c5708b62f9eed26fd76012b3addb45a9a9880952c7275bbc2e5aace3390998c082e6dedd48c76fc174
7
- data.tar.gz: aa15ac11d2ae8c1454ce5d086c25fbae5aee1957747e77dfcbdf626c470ddf438df69a3580eee98ecca7131243ed175d53c5edbc245b6b8c3c96b01742cf2914
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') { 'example only' },
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
- base58 encoded for transmission and a contextual prefix prepended. Subkeys for AES and
7
- HMAC are by default derived with HKDF using an internal key generator that takes IKM from
8
- a passphrase via scrypt, salting generated keys by prefix and purpose.
9
-
10
- This library is intended for high-performance obfuscation of integer sequences, deflection
11
- of casual tampering, and conversion to a compact, stable wire parameter format that is hard
12
- to guess and hard to predict. Although it uses standard cryptographic primitives to do so,
13
- the design trade-off of the compact format means it is not intended to defeat nation-state
14
- security services, talented cryptographers, or even a well-resourced enterprise.
15
-
16
- The identifiers produced are intentionally deterministic i.e. replayable and reusable. For
17
- privacy, confidentiality, and authentication purposes they should therefore be considered
18
- equivalent to the plaintext integer they represent, and those concerns must still be addressed
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
- to decode a value exceeding the 128-bit block.
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 cipher and digest algorithms, note that only 128-bit block
27
- ciphers that function without IV/nonce requirements are accepted. Streaming ciphers
28
- (e.g. chacha20) or block ciphers in streaming modes (e.g. aes-256-ctr) must not be used;
29
- no nonce/IV value is included in construction, making them trivially vulnerable to
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
- tamper-detection properties.
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Epithet
4
- # `= '1.0.0'`
5
- VERSION = '1.0.0'
4
+ # `= '1.1.0'`
5
+ VERSION = '1.1.0'
6
6
  end
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.new(16, alphabet: config.alphabet)
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(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
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; omit to use `Keygen::DEFAULT_SCRYPT_PARAMS`
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) || Keygen::DEFAULT_SCRYPT_PARAMS)
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
- def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: DEFAULT_SCRYPT_PARAMS)
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 || OpenSSL::KDF.scrypt(passphrase, **scrypt)
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
- # rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
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 when using YJIT, by chunking
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
- acc0 = lut.getbyte(str.getbyte(0)) * 7427658739644928 +
286
- lut.getbyte(str.getbyte(1)) * 128063081718016 +
287
- lut.getbyte(str.getbyte(2)) * 2207984167552 +
288
- lut.getbyte(str.getbyte(3)) * 38068692544 +
289
- lut.getbyte(str.getbyte(4)) * 656356768 +
290
- lut.getbyte(str.getbyte(5)) * 11316496 +
291
- lut.getbyte(str.getbyte(6)) * 195112 +
292
- lut.getbyte(str.getbyte(7)) * 3364 +
293
- lut.getbyte(str.getbyte(8)) * 58 +
294
- lut.getbyte(str.getbyte(9))
295
-
296
- acc1 = lut.getbyte(str.getbyte(10)) * 7427658739644928 +
297
- lut.getbyte(str.getbyte(11)) * 128063081718016 +
298
- lut.getbyte(str.getbyte(12)) * 2207984167552 +
299
- lut.getbyte(str.getbyte(13)) * 38068692544 +
300
- lut.getbyte(str.getbyte(14)) * 656356768 +
301
- lut.getbyte(str.getbyte(15)) * 11316496 +
302
- lut.getbyte(str.getbyte(16)) * 195112 +
303
- lut.getbyte(str.getbyte(17)) * 3364 +
304
- lut.getbyte(str.getbyte(18)) * 58 +
305
- lut.getbyte(str.getbyte(19))
306
-
307
- lut.getbyte(str.getbyte(21)) +
308
- 58 * lut.getbyte(str.getbyte(20)) +
309
- 3364 * acc1 +
310
- 1449225352009601191936 * acc0
311
-
312
- # rubocop:enable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: epithet
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Goodall