epithet 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3695519b4c82955cf40a1d3b5109fbeaf3458085146500eea600c0fa8ce86bb0
4
- data.tar.gz: f527803fc071230ae440b9e199f65ad890a0bf8778d643258b0c58b1a0f53b78
3
+ metadata.gz: 40b8e5a401923aeaaf82f8340d43747433ea1440f7de4b1628087c326a916622
4
+ data.tar.gz: bf139e16eecab4aa544981b15f28fb267f1e55e38ca37a0828c6836c064319c3
5
5
  SHA512:
6
- metadata.gz: 725d659f196c53fc6ea438607dd9319b95b5aac8b61a68c5708b62f9eed26fd76012b3addb45a9a9880952c7275bbc2e5aace3390998c082e6dedd48c76fc174
7
- data.tar.gz: aa15ac11d2ae8c1454ce5d086c25fbae5aee1957747e77dfcbdf626c470ddf438df69a3580eee98ecca7131243ed175d53c5edbc245b6b8c3c96b01742cf2914
6
+ metadata.gz: 324dec4e145ca1d5e8f3b9a22c3788ab3e9d1771d614c7dab5c94288888c59ce7235cbdd1a2dcd878015321f2264ae21702020b8bc9f8b6aaf3191bc50df9adc
7
+ data.tar.gz: 55f0b6b5bd0061c3545f9c644bfaec6eb3e7721d0df33b1217a52384da63b4ff96c0d5848f2d0340460bf3e3b8c6e9aef38cd9e677665e6abcfa09f1fd4a9024
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 - 2026-07-16
4
+
5
+ ### Breaking changes
6
+
7
+ - The subkey-derivation option `context:` replaces `salt:`, to avoid confusion with scrypt's salt parameter.
8
+ - Subkey derivation now binds the configured cipher and digest names (lowercased) into the HKDF info.
9
+ - Nil and empty-string prefixes now yield bare params with no separator.
10
+ - Config no longer exposes the alphabet string, it carries the Block58 codec around instead.
11
+ - Drop support for Rubies < 3.4.
12
+
13
+ ### Other changes
14
+
15
+ - Raise Epithet::FormatError (< ArgumentError) on invalid wire format
16
+ - Improve handling of string encodings
17
+ - Additional invariants
18
+ - Friendlier errors
19
+ - Documentation improvements
20
+
21
+ ## 1.1.0 - 2026-07-14
22
+
23
+ - Freeze config strings upon object initialization
24
+ - Merge custom scrypt params
25
+ - Block58 now defaults to a generic s2i that handles any block size
26
+ - Optimised 16-byte unrolled s2i selected via `Block58::build`
27
+ - Recognize unprefixed decodes by payload length
28
+ - Fix github CI warnings
29
+ - Write notes on salt & improve examples
30
+
3
31
  ## 1.0.0 - 2026-07-14
4
32
 
5
33
  ### Breaking changes
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Epithet
2
2
 
3
- Epithet generates stable, compact, purposefully prefixed Base58 identifiers from 64-bit integers for reversible obfuscation and tamper detection.
3
+ Epithet generates stable, compact, purposefully prefixed base58 identifiers from 64-bit integers for reversible obfuscation and tamper detection.
4
4
 
5
5
  * https://github.com/inopinatus/epithet
6
6
  * https://inopinatus.github.io/epithet/
@@ -21,13 +21,16 @@ 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' },
30
- salt: 'v1'
31
+ passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
32
+ scrypt: { salt: 'myapp/production' },
33
+ context: 'v1'
31
34
  )
32
35
  end
33
36
 
@@ -36,17 +39,17 @@ user_epithet = Epithet.new('user')
36
39
 
37
40
  id = 42
38
41
  param = user_epithet.encode(id)
39
- # => "user_VsuNnfEYQJJTJYE3n28jaY"
42
+ # => "user_GikJf7Y58t5sgqJpifjgZy"
40
43
 
41
44
  user_epithet.decode(param)
42
45
  # => 42
43
46
  ```
44
47
 
45
- Configuration at initialisation is recommended, because deriving key material from the passphrase uses scrypt, and is consequently expensive. The `salt:` is optional; it's included when deriving the subkey material for obfuscating and tamper resistance, and may be useful for additional context discrimination or during secrets rotation.
48
+ Configuration once at initialisation is recommended, because deriving key material from the passphrase uses scrypt, and is consequently expensive. The `context:` is optional; this parameter is included when deriving the subkey material for obfuscating and tamper resistance, and support purpose separation such as key rotation. The scrypt step is seasoned by a salt, which defaults to a fixed constant; set an application-specific salt, as in the example above, so that two applications inadvertently sharing a passphrase never derive the same keys.
46
49
 
47
50
  Refer to the Epithet rdoc for the full set of configuration options.
48
51
 
49
- Note that `decode` returns `nil` when authentication fails and raises ArgumentError on invalid formats.
52
+ Note that `decode` returns `nil` when authentication fails, and raises `Epithet::FormatError` (an ArgumentError) on invalid formats.
50
53
 
51
54
  ## Development
52
55
 
data/SECURITY.md CHANGED
@@ -2,39 +2,70 @@
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.
5
+ The primary construction is `AES-256-ECB(id(8B) + MSB_64(HMAC-SHA256(id)))` 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 context and binding them by algorithm.
9
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.
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
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.
16
+ An epithet should never be used as an authentication token, only as an object identifier. The
17
+ 64-bit tag is shorter than the minimum RFC 2104 §5 recommends for message authentication, and the
18
+ tamper detection is necessarily probabilistic because the MAC is truncated. After N independent
19
+ forgery attempts, expected success is approximately (N/2^{64}). The identifiers produced are
20
+ intentionally deterministic i.e. replayable and reusable. For privacy, confidentiality, and
21
+ authentication purposes they should be considered equivalent to the plaintext integer they
22
+ represent, and those concerns must still be addressed in the usual manner.
20
23
 
21
- The tamper detection is necessarily probabilistic, because the MAC is truncated.
24
+ If configuring alternative cipher algorithms, note that only 128-bit block ciphers that function
25
+ without IV/nonce requirements are accepted. Streaming ciphers (e.g. chacha20) or block ciphers in
26
+ streaming modes (e.g. aes-256-ctr) must not be used; no nonce/IV value is included in construction,
27
+ making them trivially vulnerable to known-plaintext attacks. These, CBC/OCB, and other IV/nonce
28
+ modes may also be rejected by Epithet's guardrails.
22
29
 
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.
30
+ If configuring alternative digest algorithms, note that any algorithm may be accepted that produces
31
+ at least 64 bits of output. HMAC does not rest on collision resistance, so even dated digests are
32
+ not trivially forgeable here, but algorithms other than the defaults step outside the supported
33
+ profile. If you must stray, we recommend staying within the SHA-2 family.
25
34
 
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.
35
+ Encodings are canonical, producing exactly one string per id, and Epithet will reject attempts to
36
+ decode a value exceeding the 128-bit block.
32
37
 
33
- A weak, guessable, or disclosed passphrase will compromise the obfuscation and
34
- tamper-detection properties.
38
+ A weak, guessable, or disclosed passphrase will compromise the obfuscation and tamper-detection
39
+ properties.
35
40
 
36
41
  Use Epithet at your own risk.
37
42
 
43
+ ## On seasoning
44
+
45
+ Epithet uses salt in two ways. Firstly, if the default key generator is in use, as part of the
46
+ setup-time scrypt operation turning the configured passphrase into initial keying material.
47
+ Secondly, for the HKDF extract phase to separate derived subkeys by some application-specific
48
+ division such as purpose or rotation epoch.
49
+
50
+ To avoid confusing the two uses, the HKDF salt is not referred to directly in Epithet's public API,
51
+ and is instead derived from the context and prefix parameters that are documented instead.
52
+
53
+ Epithet does not store or verify passwords; the scrypt salt, and the context & prefix parameters
54
+ used in the HKDF salt, are non-secret configuration and may be safely committed to source control.
55
+
56
+ ## On rotation
57
+
58
+ This gem provides a deterministic primitive; managing lifecycle, policy, and application-aware
59
+ responses to legacy identifiers is intentionally left to framework/application-specific adapters.
60
+ When implementing an adapter, the context parameter is recommended as the basis for key rotation.
61
+
62
+ ## Startup cost
63
+
64
+ Turning a passphrase into initial keying material is intrinsically expensive. The default scrypt
65
+ parameters (N=2^17, r=8) cost roughly 128 MiB of peak memory and a fraction of a second of CPU.
66
+ When deployed as indicated this is a boot-time cost, incurred once per configuration rather than
67
+ per encode/decode, but budget for it in memory-constrained deployments.
68
+
38
69
  ## Vulnerabilities
39
70
 
40
71
  If you think you've found a vulnerability in Epithet that compromises its design or behaviour, please
data/examples/basic.rb CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  require 'epithet'
4
4
 
5
+ # Using a random passphrase means that epithet identifiers are effectively
6
+ # ephemeral, since decoding is limited to the lifetime of this process.
5
7
  def epithet_initialize
6
8
  Epithet.configure(
7
- passphrase: ENV.fetch('EPITHET_PASSPHRASE') { 'example only' },
8
- salt: 'v1'
9
+ passphrase: ENV.fetch('EPITHET_PASSPHRASE') { Random.bytes(32) },
10
+ scrypt: { salt: 'myapp/production' },
11
+ context: 'v1'
9
12
  )
10
13
  end
11
14
 
@@ -13,6 +16,6 @@ epithet_initialize
13
16
  user_epithet = Epithet.new('user')
14
17
 
15
18
  id = Integer(ARGV.shift || 42)
16
- param = user_epithet.encode(id) #=> "user_VsuNnfEYQJJTJYE3n28jaY"
19
+ param = user_epithet.encode(id) #=> "user_KYM3B4d5ce1NNsv52rAoPg"
17
20
 
18
21
  puts "User(#{user_epithet.decode(param)}) => #{param}"
@@ -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
+ # `= '2.0.0'`
5
+ VERSION = '2.0.0'
6
6
  end
data/lib/epithet.rb CHANGED
@@ -10,56 +10,63 @@ require 'openssl'
10
10
  # prefix (typically a model or table name), produces a replayable string parameter of
11
11
  # consistent length, with modest obfuscation and authentication properties.
12
12
  #
13
- # Pseudo-AEAD is via `AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7])` with the result
13
+ # Pseudo-AEAD is via `AES-256-ECB(id(8B) + MSB_64(HMAC-SHA256(id)))` with the result
14
14
  # base58 encoded for transmission and the contextual prefix prepended.
15
15
  #
16
- # Encodings are canonical; a given configuration accepts exactly one string per id.
16
+ # Encodings are canonical; a given configuration produces exactly one string per id.
17
17
  #
18
18
  # Subkeys for AES and HMAC are by default derived with HKDF using an internal key
19
19
  # generator that takes IKM from a passphrase via scrypt. An alternative key generator
20
- # may be injected via Config objects. Subkeys are salted by prefix and an optional
21
- # additional salt, which may be useful for purpose separation or rotation.
20
+ # may be injected via Config objects. Subkeys are salted by prefix and an optional
21
+ # context string, which may be useful for purpose separation or rotation, and each
22
+ # subkey is bound to the configured name of the algorithm that consumes it.
22
23
  #
23
24
  # Example usage:
24
25
  #
25
26
  # # in setup-environment.sh
26
- # EPITHET_PASSPHRASE='example only'
27
+ # EPITHET_PASSPHRASE='example_only' ; export EPITHET_PASSPHRASE
27
28
  #
28
29
  # # ... later, in Ruby ...
29
30
  # Epithet.configure(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
30
31
  # user_epithet = Epithet.new('user')
31
- # user_epithet.encode(1) #=> "user_DAG6Joc5JmgygTBuEo8a9K"
32
+ # user_epithet.encode(1) #=> "user_NEwRoiarS9wdmiLmjEtti3"
32
33
  #
33
34
  class Epithet
35
+ # Raised by #decode when the input is not valid wire format.
36
+ FormatError = Class.new(ArgumentError)
37
+
34
38
  # Create an encoder/decoder.
35
39
  #
36
40
  # Setup could be moderately expensive due to key derivation; you are recommended to cache
37
- # and reuse instances with equal parameters (e.g. setup once per model)
41
+ # and reuse instances with equal parameters (e.g. setup the key generation once per runtime).
42
+ #
43
+ # * The stringified `prefix` is included in key derivation. It may be nil or empty, in which
44
+ # case the separator is ignored and a bare param will be produced.
38
45
  #
39
- # * `prefix` is stringified, and may be nil, producing an empty prefix.
40
- # The prefix is included in the salt for key generation.
46
+ # * `config` is optional and intended for cases where you need finer control than global defaults.
41
47
  #
42
- # * `config` is optional and intended for cases where you needed finer control than global defaults.
48
+ # Mixed character encodings across prefix & separator may raise Encoding::CompatibilityError.
43
49
  #
44
50
  # The simplest typical invocation is `Epithet.new('prefix')`.
45
51
  #
46
52
  def initialize(prefix, config: Epithet.defaults)
47
53
  prefix = String(prefix)
48
- key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack('Q>Z*Q>Z*')
49
- @block58 = Block58.new(16, alphabet: config.alphabet)
50
- @prefix_s = "#{prefix}#{config.separator}"
54
+ @prefix = prefix.empty? ? prefix : prefix + config.separator
55
+ @wire_prefix = @prefix.b
56
+ key_salt = [prefix.bytesize, prefix, config.context.bytesize, config.context].pack('Q>Z*Q>Z*')
57
+ @codec = config.codec
51
58
 
52
59
  cipher_key_len = OpenSSL::Cipher.new(config.cipher).key_len
53
60
  digest_key_len = OpenSSL::Digest.new(config.digest).block_length
54
- cipher_key = config.keygen.generate('epithet:ecb', key_salt, cipher_key_len)
55
- digest_key = config.keygen.generate('epithet:mac', key_salt, digest_key_len)
61
+ cipher_key = config.keygen.generate("epithet:cipher:#{config.cipher}", key_salt, cipher_key_len)
62
+ digest_key = config.keygen.generate("epithet:digest:#{config.digest}", key_salt, digest_key_len)
56
63
 
57
64
  @encryptor = OpenSSL::Cipher.new(config.cipher).encrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
58
65
  @decryptor = OpenSSL::Cipher.new(config.cipher).decrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
59
66
  @hmac = OpenSSL::HMAC.new(digest_key, config.digest)
60
67
  end
61
68
 
62
- # Encode a 64-bit unsigned Integer to a prefixed Base58 string.
69
+ # Encode a 64-bit unsigned integer to a prefixed base58 string.
63
70
  # Raises ArgumentError on invalid values.
64
71
  def encode(id)
65
72
  raise ArgumentError, 'not a 64-bit unsigned integer' unless Integer === id && id.bit_length <= 64 && id >= 0
@@ -71,20 +78,23 @@ class Epithet
71
78
  block = e.update([pt, m].pack('a8a8')) + e.final
72
79
  ct = block.unpack('Q>2').then { (_1 << 64) + _2 }
73
80
 
74
- @prefix_s + @block58.i2s(ct)
81
+ @prefix + @codec.i2s(ct)
75
82
  end
76
83
 
77
- # Decode a prefixed or raw Base58 string to an Integer.
84
+ # Decode a prefixed or raw base58 string to an integer. The input is
85
+ # stringified and read as bytes, whatever its encoding; raw inputs are
86
+ # recognised by their exact payload length.
78
87
  #
79
- # Returns the Integer on success, nil if authentication fails.
80
- # Raises ArgumentError on invalid wire format (see Block58#valid?).
88
+ # Returns the integer on success, nil if authentication fails.
89
+ # Raises FormatError on invalid wire format (see Block58#valid?).
81
90
  def decode(s)
82
- s = s.delete_prefix(@prefix_s)
83
- raise ArgumentError, 'unexpected format' unless @block58.valid?(s)
91
+ s = String(s).b
92
+ s = s.delete_prefix(@wire_prefix) unless s.bytesize == @codec.size
93
+ raise FormatError, 'unexpected format' unless @codec.valid?(s)
84
94
 
85
95
  d = @decryptor.dup
86
96
  h = @hmac.dup
87
- ct = @block58.s2i(s)
97
+ ct = @codec.s2i(s)
88
98
  block = d.update([ct >> 64, ct].pack('Q>2')) + d.final
89
99
  pt, m = block.unpack('a8a8')
90
100
  id = pt.unpack1('Q>')
@@ -104,30 +114,34 @@ class Epithet
104
114
  # #### Examples
105
115
  #
106
116
  # # As it might appear in an initializer
107
- # Epithet.configure(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
117
+ # Epithet.configure(
118
+ # passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
119
+ # scrypt: { salt: "#{MyApp.name}/#{MyApp.env}" }
120
+ # )
108
121
  #
109
- # # Retaining already-configured passphrase but updating salt,
122
+ # # Retaining already-configured passphrase but updating context,
110
123
  # # and using a custom separator.
111
124
  # Epithet.configure(
112
125
  # keygen: Epithet.defaults.keygen,
113
- # salt: 'rotation-19',
126
+ # context: 'rotation-19',
114
127
  # separator: '-'
115
128
  # )
116
129
  #
117
130
  # #### Options
118
131
  #
119
132
  # * `:passphrase` - Install new key generator with scrypt-derived key material
120
- # * `:scrypt` - Params for scrypt; omit to use `Keygen::DEFAULT_SCRYPT_PARAMS`
121
- # * `:keygen` - Install an existing key generator
133
+ # * `:scrypt` - Merge params for scrypt
134
+ # * `:keygen` - Use an existing key generator
122
135
  # * `:cipher` - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard `aes-256-ecb`
123
- # * `:digest` - Must be >= 64 bits; omit for standard `sha256`
124
- # * `:separator` - String inserted between the prefix and the generated param; omit for standard `_`.
125
- # May be `nil`. Must not share bytes with the alphabet.
126
- # * `:alphabet` - Alphabet for the wire encoding; must be 58 strictly ascending bytes; omit for `Block58::Alphabet`.
127
- # * `:salt` - If supplied, stringified form is included in subkey derivation
136
+ # * `:digest` - Must be >= 64 bits digest; omit for standard `sha256`
137
+ # * `:separator` - String inserted between the prefix and the generated param.
138
+ # Omit for standard `_`. May be `nil`. Must not share bytes with the alphabet.
139
+ # Not emitted when prefix is `nil` or empty.
140
+ # * `:alphabet` - Custom base58 alphabet for the wire encoding. Must be strictly ascending bytes.
141
+ # * `:context` - If supplied, string form is included in subkey derivation.
142
+ # Useful for purpose separation, or rotation epochs.
128
143
  #
129
144
  # At minimum, one of `passphrase:` or `keygen:` is required.
130
- # Configuration via `passphrase` is recommended.
131
145
  #
132
146
  # If passing an existing key generator, the object must respond to `generate(info, salt, length)`
133
147
  # and return a byte string suitable for use with OpenSSL cryptographic primitives.
@@ -158,27 +172,29 @@ class Epithet
158
172
 
159
173
  # Class for passing around preset configs. See Epithet::configure for options.
160
174
  class Config
161
- attr_reader :keygen, :salt, :separator, :alphabet, :cipher, :digest # :nodoc:
175
+ attr_reader :keygen, :context, :separator, :cipher, :digest, :codec # :nodoc:
162
176
 
163
177
  def initialize(opts = {})
164
178
  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'
179
+ @separator = -String(opts.delete(:separator) { '_' })
180
+ @context = -String(opts.delete(:context))
181
+ alphabet = String(opts.delete(:alphabet) { Block58::Alphabet })
182
+ @cipher = -(opts.delete(:cipher) || 'aes-256-ecb').downcase
183
+ @digest = -(opts.delete(:digest) || 'sha256').downcase
184
+ keygen, passphrase, scrypt = %i[keygen passphrase scrypt].map { opts.delete it }
170
185
 
171
186
  cipher = OpenSSL::Cipher.new(@cipher)
172
- raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(@alphabet.bytes)
187
+ raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(alphabet.bytes)
173
188
  raise ArgumentError, "#{@cipher} not a 128-bit block cipher" if cipher.block_size != 16
174
189
  raise ArgumentError, "#{@cipher} requires an IV/nonce" if cipher.iv_len != 0
175
190
  raise ArgumentError, "#{@digest} produces < 64-bit digest" if OpenSSL::Digest.new(@digest).digest_length < 8
176
-
177
- @keygen = opts.delete(:keygen) || Keygen.new(
178
- passphrase: opts.delete(:passphrase),
179
- digest: @digest,
180
- scrypt: opts.delete(:scrypt) || Keygen::DEFAULT_SCRYPT_PARAMS)
191
+ raise ArgumentError, 'use keygen: or passphrase:, not both' if keygen && (passphrase || scrypt)
192
+ raise ArgumentError, 'one of passphrase: or keygen: is required' unless keygen || passphrase
181
193
  raise ArgumentError, "unused option(s) #{opts.keys}" unless opts.empty?
194
+
195
+ @codec = Block58.build(cipher.block_size, alphabet:)
196
+ @keygen = keygen || Keygen.new(passphrase:, digest: @digest, scrypt:)
197
+ freeze
182
198
  end
183
199
  end
184
200
 
@@ -191,26 +207,27 @@ class Epithet
191
207
  # salt: 'epithet-default',
192
208
  # N: 1 << 17,
193
209
  # r: 8,
194
- # p: 1,
195
- # length: 32
210
+ # p: 1
196
211
  # }.freeze
197
212
  # ```
198
213
  DEFAULT_SCRYPT_PARAMS = {
199
214
  salt: 'epithet-default',
200
215
  N: 1 << 17,
201
216
  r: 8,
202
- p: 1,
203
- length: 32
217
+ p: 1
204
218
  }.freeze
205
219
 
206
220
  # 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)
221
+ # Supplied scrypt params, if any, are merged over DEFAULT_SCRYPT_PARAMS.
222
+ def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: {})
208
223
  if (passphrase.nil? && ikm.nil?) || (!passphrase.nil? && !ikm.nil?)
209
224
  raise ArgumentError, 'keygen requires either ikm or passphrase'
210
225
  end
226
+ raise ArgumentError, 'scrypt length is not configurable' if scrypt&.key?(:length)
211
227
 
212
- @ikm = ikm || OpenSSL::KDF.scrypt(passphrase, **scrypt)
213
- @digest = digest
228
+ @ikm = (ikm&.b || OpenSSL::KDF.scrypt(passphrase, **DEFAULT_SCRYPT_PARAMS, **scrypt, length: 32)).freeze
229
+ @digest = -String(digest)
230
+ freeze
214
231
  end
215
232
 
216
233
  def inspect
@@ -219,28 +236,41 @@ class Epithet
219
236
 
220
237
  # Derive a key via HKDF.
221
238
  def generate(info, salt, length)
222
- OpenSSL::KDF.hkdf(@ikm, hash: @digest, info: info, salt: salt, length: length)
239
+ OpenSSL::KDF.hkdf(@ikm, hash: @digest, info:, salt:, length:)
223
240
  end
224
241
  end
225
242
 
226
- # Fixed-length Base58 codec for a fixed-size block.
243
+ # Fixed-length base58 codec for a fixed-size block.
244
+ #
245
+ # Obtain codecs via Block58::build, which selects the fastest variant for
246
+ # the block size, an unrolled decoder for 16-byte blocks, or the generic
247
+ # chunked decoder otherwise.
227
248
  class Block58
228
249
  # `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
229
250
  Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
230
251
 
252
+ POW58 = Array.new(11) { 58**it }.freeze # :nodoc:
253
+
254
+ attr_reader :size
255
+
256
+ # Same as ::new but may select a tuned subclass for performance.
257
+ def self.build(block_size, ...) = (block_size == 16 ? Unrolled16 : self).new(block_size, ...)
258
+
231
259
  # Create a codec for a block size in bytes.
232
260
  #
233
261
  # The alphabet must be 58 distinct bytes in ascending order, so that
234
262
  # lexicographic order agrees with numeric order.
235
263
  def initialize(block_size, alphabet: Alphabet)
264
+ raise ArgumentError, 'invalid block size' unless Integer === block_size && block_size > 0
236
265
  @alphabet = alphabet.b.freeze
237
266
  raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
238
267
  raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
239
- @size = ((block_size * 8) / Math.log2(58)).ceil(0)
268
+ @size = ((block_size * 8) / Math.log2(58)).ceil
240
269
  @charsel = @alphabet.gsub(/[\^\-\\]/, '\\\\\&').freeze
241
270
  @blank = @alphabet[0] * @size
242
271
  @lut = @alphabet.each_byte.with_index.with_object("\0" * 256) { |(val, idx), lut| lut.setbyte(val, idx) }.freeze
243
- @max = i2s((1 << (block_size * 8)) - 1).freeze
272
+ @limit = 1 << (block_size * 8)
273
+ @max = i2s(@limit - 1).freeze
244
274
  end
245
275
 
246
276
  def inspect
@@ -248,13 +278,15 @@ class Epithet
248
278
  end
249
279
 
250
280
  # Return true if the string is in range with the right size and alphabet.
281
+ # The input is read as bytes, whatever its encoding.
251
282
  def valid?(s)
252
- String === s && s.bytesize == @size && s <= @max && s.count(@charsel) == @size
283
+ String === s && s.bytesize == @size && (s = s.b) <= @max && s.count(@charsel) == @size
253
284
  end
254
285
 
255
- # Encode a non-negative Integer to fixed-length Base58.
256
- # Truncates if int >= 58**size.
286
+ # Encode an acceptable integer to fixed-length base58.
257
287
  def i2s(int)
288
+ raise ArgumentError, 'integer out of block range' unless Integer === int && int >= 0 && int < @limit
289
+
258
290
  # Using divmod+setbyte is faster than Integer#digits under YJIT,
259
291
  # and about equal in plain MRI.
260
292
  alphabet = @alphabet
@@ -269,47 +301,81 @@ class Epithet
269
301
  out
270
302
  end
271
303
 
272
- # Decode a fixed-length Base58 string to an Integer.
273
- # Assumes the input passes `#valid?`. Wraps at 58**size on the i2s round trip.
304
+ # Decode a fixed-length base58 string to an integer.
305
+ # Assumes the input passes `#valid?`, behaviour undefined if it doesn't.
274
306
  def s2i(str)
275
- # rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
276
- #
277
- # By unrolling coefficients, this is ~8x faster than Horner's scheme
307
+ # Chunking intermediate results into 64-bit integers is ~5x faster
308
+ # under YJIT than Horner's scheme
278
309
  #
279
310
  # str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
280
311
  #
281
- # at computing the inner product when using YJIT, by chunking
282
- # intermediate results into 64-bit integers.
312
+ # at computing the inner product.
283
313
  lut = @lut
314
+ size = @size
315
+ pow = POW58
316
+ acc = 0
317
+ pos = 0
318
+ while pos < size
319
+ n = size - pos
320
+ n = 10 if n > 10
321
+ chunk = 0
322
+ i = 0
323
+ while i < n
324
+ chunk = (chunk * 58) + lut.getbyte(str.getbyte(pos))
325
+ pos += 1
326
+ i += 1
327
+ end
328
+ acc = (acc * pow[n]) + chunk
329
+ end
330
+ acc
331
+ end
332
+
333
+ # Specialised decoder for 16-byte blocks (22 digits) with a fully unrolled inner product.
334
+ class Unrolled16 < Block58
335
+ def initialize(...)
336
+ super
337
+ raise ArgumentError, 'unrolled codec requires a 16-byte block' unless @size == 22
338
+ end
284
339
 
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
340
+ # Decode a 22-digit base58 string to an integer.
341
+ # Assumes the input passes `#valid?`, behaviour undefined if it doesn't.
342
+ def s2i(str)
343
+ # rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
344
+ #
345
+ # By unrolling the chunks against literal coefficients, this tested with Ruby 4.0
346
+ # at ~1.5x faster under YJIT than the generic chunked Block58#s2i, and ~6x faster
347
+ # than Horner's scheme.
348
+ lut = @lut
349
+
350
+ acc0 = lut.getbyte(str.getbyte(0)) * 7427658739644928 +
351
+ lut.getbyte(str.getbyte(1)) * 128063081718016 +
352
+ lut.getbyte(str.getbyte(2)) * 2207984167552 +
353
+ lut.getbyte(str.getbyte(3)) * 38068692544 +
354
+ lut.getbyte(str.getbyte(4)) * 656356768 +
355
+ lut.getbyte(str.getbyte(5)) * 11316496 +
356
+ lut.getbyte(str.getbyte(6)) * 195112 +
357
+ lut.getbyte(str.getbyte(7)) * 3364 +
358
+ lut.getbyte(str.getbyte(8)) * 58 +
359
+ lut.getbyte(str.getbyte(9))
360
+
361
+ acc1 = lut.getbyte(str.getbyte(10)) * 7427658739644928 +
362
+ lut.getbyte(str.getbyte(11)) * 128063081718016 +
363
+ lut.getbyte(str.getbyte(12)) * 2207984167552 +
364
+ lut.getbyte(str.getbyte(13)) * 38068692544 +
365
+ lut.getbyte(str.getbyte(14)) * 656356768 +
366
+ lut.getbyte(str.getbyte(15)) * 11316496 +
367
+ lut.getbyte(str.getbyte(16)) * 195112 +
368
+ lut.getbyte(str.getbyte(17)) * 3364 +
369
+ lut.getbyte(str.getbyte(18)) * 58 +
370
+ lut.getbyte(str.getbyte(19))
371
+
372
+ lut.getbyte(str.getbyte(21)) +
373
+ lut.getbyte(str.getbyte(20)) * 58 +
374
+ acc1 * 3364 +
375
+ acc0 * 1449225352009601191936
376
+
377
+ # rubocop:enable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
378
+ end
313
379
  end
314
380
  end
315
381
  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: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Goodall
@@ -65,7 +65,7 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0'
68
- description: Epithet generates stable, prefixed, Base58 identifiers from 64-bit integers
68
+ description: Epithet generates stable, prefixed, base58 identifiers from 64-bit integers
69
69
  using AES and HMAC.
70
70
  email:
71
71
  - inopinatus@hey.com
@@ -96,7 +96,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
96
96
  requirements:
97
97
  - - ">="
98
98
  - !ruby/object:Gem::Version
99
- version: '3.3'
99
+ version: '3.4'
100
100
  required_rubygems_version: !ruby/object:Gem::Requirement
101
101
  requirements:
102
102
  - - ">="
@@ -105,5 +105,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
105
  requirements: []
106
106
  rubygems_version: 4.0.10
107
107
  specification_version: 4
108
- summary: External base58 identifiers with reversible, authenticated obfuscation.
108
+ summary: External base58 identifiers with reversible, tamper-evident obfuscation.
109
109
  test_files: []