epithet 1.1.0 → 2.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: 100d3ac2b640a77419d9d6f45228ed67251285ed6f0bf390b9249573d20db887
4
- data.tar.gz: 5350064afb6799feec528888c475bcc0d8e403272f13332d00bdcd43b7bcd47c
3
+ metadata.gz: 8065749fc3d0aaf89280bc7238f8dfe30ce749bf9c09c84fbec0406bde779d1b
4
+ data.tar.gz: 8e42aeba4a5f3bae8cec60bac6719a1214f6750de8a8b33ce009af8d7c8dd20c
5
5
  SHA512:
6
- metadata.gz: 77e5c3acde924854471a36ede3d43e010fcf4f2bd7f4a49b75c753e7c80e4c0651355c0c33ec28a9d1c229abf14fd5da61b40c1eae2a23cbd31f8c8ef94cd9a5
7
- data.tar.gz: 21a8c95376177e30642bed7aefd1b1f87ccd5f0a00d489592c154ed1ecf86cdf076df57f04f98d01ad802b4ea1c62367b5afcc0fdd165501f95fe081ac10efd9
6
+ metadata.gz: 6946cf232b610670fd5cdd5c1293f1d3c882a4efa36f3e1a9971ce667f9291130372f2106b83cf8bbad16673d1b13fbb5e7b1475989a2b2dc7b201fc44347562
7
+ data.tar.gz: 3437d52226de8303e23db85c11dd803ac1556f267b067696822f864b17d8b1ed1b314ec421074da0766e23ecd2efb926c6cb58dfefc7d7f0d1272e804d3ddcce
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 - 2026-07-23
4
+
5
+ - Add support for JRuby and TruffleRuby
6
+ - Support multiple scrypt providers (OpenSSL, BouncyCastle, and the `scrypt` gem)
7
+ - Raise `Epithet::ConfigurationError` (< `RuntimeError`) if config missing
8
+ - Unknown cipher/digest names now raise `ArgumentError`
9
+ - Internal reorganisation into separate files
10
+ - Robustness improvements inc. broader CI coverage & concurrency hammer
11
+ - Documentation edits
12
+
13
+ ## 2.0.0 - 2026-07-16
14
+
15
+ ### Breaking changes
16
+
17
+ - The subkey-derivation option `context:` replaces `salt:`, to avoid confusion with scrypt's salt parameter.
18
+ - Subkey derivation now binds the configured cipher and digest names (lowercased) into the HKDF info.
19
+ - Nil and empty-string prefixes now yield bare params with no separator.
20
+ - Config no longer exposes the alphabet string, it carries the Block58 codec around instead.
21
+ - Drop support for Rubies < 3.4.
22
+
23
+ ### Other changes
24
+
25
+ - Raise Epithet::FormatError (< ArgumentError) on invalid wire format
26
+ - Improve handling of string encodings
27
+ - Additional invariants
28
+ - Friendlier errors
29
+ - Documentation improvements
30
+
3
31
  ## 1.1.0 - 2026-07-14
4
32
 
5
33
  - Freeze config strings upon object initialization
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,7 +21,7 @@ gem install epithet
21
21
 
22
22
  ## Usage
23
23
 
24
- With `EPITHET_PASSPHRASE="example only"`:
24
+ With `EPITHET_PASSPHRASE=example_only`:
25
25
 
26
26
  ```ruby
27
27
  require 'epithet'
@@ -29,7 +29,8 @@ require 'epithet'
29
29
  def epithet_initialize
30
30
  Epithet.configure(
31
31
  passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
32
- salt: 'v1'
32
+ scrypt: { salt: 'myapp/production' },
33
+ context: 'v1'
33
34
  )
34
35
  end
35
36
 
@@ -38,17 +39,21 @@ user_epithet = Epithet.new('user')
38
39
 
39
40
  id = 42
40
41
  param = user_epithet.encode(id)
41
- # => "user_VsuNnfEYQJJTJYE3n28jaY"
42
+ # => "user_GikJf7Y58t5sgqJpifjgZy"
42
43
 
43
44
  user_epithet.decode(param)
44
45
  # => 42
45
46
  ```
46
47
 
47
- 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 may be used for separation of purpose or 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.
48
49
 
49
- Refer to the Epithet rdoc for the full set of configuration options.
50
+ Refer to the [Epithet rdoc](https://inopinatus.github.io/epithet/) for the full set of configuration options.
50
51
 
51
- Note that `decode` returns `nil` when authentication fails and raises ArgumentError on invalid formats.
52
+ Note that `Epithet#decode` returns `nil` when authentication fails, and raises `Epithet::FormatError` (an ArgumentError) on invalid formats.
53
+
54
+ ## Platform support
55
+
56
+ Epithet supports standard Ruby (aka MRI/CRuby), JRuby, and TruffleRuby. It uses your platform's OpenSSL scrypt where available; on JRuby it falls back to the BouncyCastle provider bundled with jruby-openssl, and LibreSSL builds can fall back to the optional [scrypt gem](https://rubygems.org/gems/scrypt). To use the latter gem provider, your application must bundle and require `scrypt` before Epithet, we won't require it on your behalf. All three providers produce identical key material. To force a provider without a capability check, pass its class in the scrypt parameters, e.g. `scrypt: { provider: Epithet::Scrypt::BouncyCastle }`; omit `provider:` to select one automatically.
52
57
 
53
58
  ## Development
54
59
 
data/SECURITY.md CHANGED
@@ -1,50 +1,73 @@
1
1
  # Epithet security
2
2
 
3
- ## Cryptographic considerations
4
-
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
3
  This library is intended for high-performance obfuscation of integer sequences, deflection of casual
11
4
  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.
5
+ to predict. Epithet forms a single-block deterministic PRP with an embedded truncated tag, and
6
+ although it uses standard cryptographic primitives to do so, the design trade-off of the compact
7
+ format means it is not intended to defeat nation-state security services, talented cryptographers,
8
+ or even a well-resourced enterprise.
15
9
 
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.
10
+ ## Cryptographic considerations
19
11
 
20
- The tamper detection is necessarily probabilistic, because the MAC is truncated.
12
+ The primary construction is `AES-256-ECB(id(8B) + MSB_64(HMAC-SHA256(id)))` with the result base58
13
+ encoded for transmission and a contextual prefix prepended. Subkeys for AES and HMAC are by default
14
+ derived with HKDF using an internal key generator that takes IKM from a passphrase via scrypt,
15
+ salting generated keys by prefix and context and binding them by algorithm.
21
16
 
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.
17
+ An epithet should never be used as an authentication token, only as an object identifier. The
18
+ 64-bit tag is shorter than the minimum RFC 2104 §5 recommends for message authentication, and the
19
+ tamper detection is necessarily probabilistic because the MAC is truncated. After N independent
20
+ forgery attempts, expected success is approximately (N/2^{64}). The identifiers produced are
21
+ intentionally deterministic i.e. replayable and reusable. For privacy, confidentiality, and
22
+ authentication purposes they should be considered equivalent to the plaintext integer they
23
+ represent, and those concerns must still be addressed in the usual manner.
24
24
 
25
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
26
+ without IV/nonce requirements are accepted. Streaming ciphers (e.g. chacha20) or block ciphers in
27
27
  streaming modes (e.g. aes-256-ctr) must not be used; no nonce/IV value is included in construction,
28
28
  making them trivially vulnerable to known-plaintext attacks. These, CBC/OCB, and other IV/nonce
29
29
  modes may also be rejected by Epithet's guardrails.
30
30
 
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.
31
+ If configuring alternative digest algorithms, note that any algorithm may be accepted that produces
32
+ at least 64 bits of output. HMAC does not rest on collision resistance, so even dated digests are
33
+ not trivially forgeable here, but algorithms other than the defaults step outside the supported
34
+ profile. If you must stray, we recommend staying within the SHA-2 family.
35
+
36
+ Encodings are canonical, producing exactly one string per id, and Epithet will reject attempts to
37
+ decode a value exceeding the 128-bit block.
35
38
 
36
39
  A weak, guessable, or disclosed passphrase will compromise the obfuscation and tamper-detection
37
40
  properties.
38
41
 
39
- Use Epithet at your own risk.
42
+ To sum up: neither the design nor implementation of this library has received an independent
43
+ cryptographic review. It leaks equality, offers a 64-bit forgery bound, and supplies no
44
+ authorization or privacy boundary. Use Epithet at your own risk.
45
+
46
+ ## On seasoning
47
+
48
+ Epithet uses salt in two ways. Firstly, if the default key generator is in use, as part of the
49
+ setup-time scrypt operation turning the configured passphrase into initial keying material.
50
+ Secondly, for the HKDF extract phase to separate derived subkeys by some application-specific
51
+ division such as purpose or rotation epoch.
52
+
53
+ To avoid confusing the two uses, the HKDF salt is not referred to directly in Epithet's public API,
54
+ and is instead derived from the context and prefix parameters.
55
+
56
+ Epithet does not store or verify passwords; the scrypt salt, and the context & prefix parameters
57
+ used in the HKDF salt, are non-secret configuration and may be safely committed to source control.
58
+
59
+ ## On rotation
60
+
61
+ This gem provides a deterministic primitive; managing lifecycle, policy, and application-aware
62
+ responses to legacy identifiers is intentionally left to framework/application-specific adapters.
63
+ When implementing an adapter, the context parameter is recommended as the basis for key rotation.
40
64
 
41
- ## On salt
65
+ ## Startup cost
42
66
 
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.
67
+ Turning a passphrase into initial keying material is intrinsically expensive. The default scrypt
68
+ parameters (N=2^17, r=8) cost roughly 128 MiB of peak memory and a fraction of a second of CPU.
69
+ When deployed as indicated this is a boot-time cost, incurred once per configuration rather than
70
+ per encode/decode, but budget for it in memory-constrained deployments.
48
71
 
49
72
  ## Vulnerabilities
50
73
 
data/examples/basic.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require 'epithet'
4
5
 
6
+ # Using a random passphrase means that epithet identifiers are effectively
7
+ # ephemeral, since decoding is limited to the lifetime of this process.
5
8
  def epithet_initialize
6
9
  Epithet.configure(
7
- passphrase: ENV.fetch('EPITHET_PASSPHRASE') { 'example only' },
8
- salt: 'v1'
10
+ passphrase: ENV.fetch('EPITHET_PASSPHRASE') { SecureRandom.random_bytes(32) },
11
+ scrypt: { salt: 'myapp/production' },
12
+ context: 'v1'
9
13
  )
10
14
  end
11
15
 
@@ -13,6 +17,6 @@ epithet_initialize
13
17
  user_epithet = Epithet.new('user')
14
18
 
15
19
  id = Integer(ARGV.shift || 42)
16
- param = user_epithet.encode(id) #=> "user_VsuNnfEYQJJTJYE3n28jaY"
20
+ param = user_epithet.encode(id)
17
21
 
18
22
  puts "User(#{user_epithet.decode(param)}) => #{param}"
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Epithet
4
+ # Fixed-length base58 codec for a fixed-size block.
5
+ #
6
+ # Obtain codecs via Block58::build, which selects the fastest variant for
7
+ # the block size, an unrolled decoder for 16-byte blocks, or the generic
8
+ # chunked decoder otherwise.
9
+ class Block58
10
+ # `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
11
+ Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
12
+
13
+ POW58 = Array.new(11) { 58**it }.freeze # :nodoc:
14
+
15
+ attr_reader :size
16
+
17
+ # Same as ::new but may select a tuned subclass for performance.
18
+ def self.build(block_size, ...) = (block_size == 16 ? Unrolled16 : self).new(block_size, ...)
19
+
20
+ # Create a codec for a block size in bytes.
21
+ #
22
+ # The alphabet must be 58 distinct bytes in ascending order, so that
23
+ # lexicographic order agrees with numeric order.
24
+ def initialize(block_size, alphabet: Alphabet)
25
+ raise ArgumentError, 'invalid block size' unless Integer === block_size && block_size > 0
26
+ @alphabet = alphabet.b.freeze
27
+ raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
28
+ raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
29
+ @size = ((block_size * 8) / Math.log2(58)).ceil
30
+ @charsel = @alphabet.gsub(/[\^\-\\]/, '\\\\\&').freeze
31
+ @blank = (@alphabet[0] * @size).freeze
32
+ @lut = @alphabet.each_byte.with_index.with_object("\0" * 256) { |(val, idx), lut| lut.setbyte(val, idx) }.freeze
33
+ @limit = 1 << (block_size * 8)
34
+ @max = i2s(@limit - 1).freeze
35
+ end
36
+
37
+ def inspect
38
+ "#<#{self.class}:#{'%#016x' % (object_id << 1)} size=#{@size} alphabet=#{@alphabet}>"
39
+ end
40
+
41
+ # Return true if the string is in range with the right size and alphabet.
42
+ # The input is read as bytes, whatever its encoding.
43
+ def valid?(s)
44
+ String === s && s.bytesize == @size && (s = s.b) <= @max && s.count(@charsel) == @size
45
+ end
46
+
47
+ # Encode an acceptable integer to fixed-length base58.
48
+ def i2s(int)
49
+ raise ArgumentError, 'integer out of block range' unless Integer === int && int >= 0 && int < @limit
50
+
51
+ # Using divmod+setbyte is faster than Integer#digits under YJIT,
52
+ # and about equal in plain MRI.
53
+ alphabet = @alphabet
54
+ out = @blank.dup
55
+ idx = @size - 1
56
+ n = int
57
+ while idx >= 0 && n > 0
58
+ n, rem = n.divmod(58)
59
+ out.setbyte(idx, alphabet.getbyte(rem))
60
+ idx -= 1
61
+ end
62
+ out
63
+ end
64
+
65
+ # Decode a fixed-length base58 string to an integer.
66
+ # Assumes the input passes `#valid?`, behaviour undefined if it doesn't.
67
+ def s2i(str)
68
+ # Chunking intermediate results into 64-bit integers is ~5x faster
69
+ # under YJIT than Horner's scheme
70
+ #
71
+ # str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
72
+ #
73
+ # at computing the inner product.
74
+ lut = @lut
75
+ size = @size
76
+ pow = POW58
77
+ acc = 0
78
+ pos = 0
79
+ while pos < size
80
+ n = size - pos
81
+ n = 10 if n > 10
82
+ chunk = 0
83
+ i = 0
84
+ while i < n
85
+ chunk = (chunk * 58) + lut.getbyte(str.getbyte(pos))
86
+ pos += 1
87
+ i += 1
88
+ end
89
+ acc = (acc * pow[n]) + chunk
90
+ end
91
+ acc
92
+ end
93
+
94
+ # Specialised decoder for 16-byte blocks (22 digits) with a fully unrolled inner product.
95
+ class Unrolled16 < Block58
96
+ def initialize(...)
97
+ super
98
+ raise ArgumentError, 'unrolled codec requires a 16-byte block' unless @size == 22
99
+ end
100
+
101
+ # Decode a 22-digit base58 string to an integer.
102
+ # Assumes the input passes `#valid?`, behaviour undefined if it doesn't.
103
+ def s2i(str)
104
+ # rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
105
+ #
106
+ # By unrolling the chunks against literal coefficients, this tested with Ruby 4.0
107
+ # at ~1.5x faster under YJIT than the generic chunked Block58#s2i, and ~6x faster
108
+ # than Horner's scheme.
109
+ lut = @lut
110
+
111
+ acc0 = lut.getbyte(str.getbyte(0)) * 7427658739644928 +
112
+ lut.getbyte(str.getbyte(1)) * 128063081718016 +
113
+ lut.getbyte(str.getbyte(2)) * 2207984167552 +
114
+ lut.getbyte(str.getbyte(3)) * 38068692544 +
115
+ lut.getbyte(str.getbyte(4)) * 656356768 +
116
+ lut.getbyte(str.getbyte(5)) * 11316496 +
117
+ lut.getbyte(str.getbyte(6)) * 195112 +
118
+ lut.getbyte(str.getbyte(7)) * 3364 +
119
+ lut.getbyte(str.getbyte(8)) * 58 +
120
+ lut.getbyte(str.getbyte(9))
121
+
122
+ acc1 = lut.getbyte(str.getbyte(10)) * 7427658739644928 +
123
+ lut.getbyte(str.getbyte(11)) * 128063081718016 +
124
+ lut.getbyte(str.getbyte(12)) * 2207984167552 +
125
+ lut.getbyte(str.getbyte(13)) * 38068692544 +
126
+ lut.getbyte(str.getbyte(14)) * 656356768 +
127
+ lut.getbyte(str.getbyte(15)) * 11316496 +
128
+ lut.getbyte(str.getbyte(16)) * 195112 +
129
+ lut.getbyte(str.getbyte(17)) * 3364 +
130
+ lut.getbyte(str.getbyte(18)) * 58 +
131
+ lut.getbyte(str.getbyte(19))
132
+
133
+ lut.getbyte(str.getbyte(21)) +
134
+ lut.getbyte(str.getbyte(20)) * 58 +
135
+ acc1 * 3364 +
136
+ acc0 * 1449225352009601191936
137
+
138
+ # rubocop:enable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'epithet/block58'
5
+ require 'epithet/keygen'
6
+
7
+ class Epithet
8
+ # Class for passing around preset configs. See Epithet::configure for options.
9
+ class Config
10
+ attr_reader :keygen, :context, :separator, :cipher, :digest, :codec # :nodoc:
11
+
12
+ def initialize(opts = {})
13
+ opts = opts.dup
14
+ @separator = -String(opts.delete(:separator) { '_' })
15
+ @context = -String(opts.delete(:context))
16
+ alphabet = String(opts.delete(:alphabet) { Block58::Alphabet })
17
+ @cipher = -(opts.delete(:cipher) || 'aes-256-ecb').downcase
18
+ @digest = -(opts.delete(:digest) || 'sha256').downcase
19
+ keygen, passphrase, scrypt = %i[keygen passphrase scrypt].map { opts.delete it }
20
+
21
+ cipher = probe(OpenSSL::Cipher, @cipher)
22
+ digest = probe(OpenSSL::Digest, @digest)
23
+
24
+ raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(alphabet.bytes)
25
+ raise ArgumentError, "#{@cipher} not a 128-bit block cipher" if cipher.block_size != 16
26
+ raise ArgumentError, "#{@cipher} requires an IV/nonce" if cipher.iv_len != 0
27
+ raise ArgumentError, "#{@digest} produces < 64-bit digest" if digest.digest_length < 8
28
+ raise ArgumentError, 'use keygen: or passphrase:, not both' if keygen && (passphrase || scrypt)
29
+ raise ArgumentError, 'one of passphrase: or keygen: is required' unless keygen || passphrase
30
+ raise ArgumentError, "unused option(s) #{opts.keys}" unless opts.empty?
31
+
32
+ @codec = Block58.build(cipher.block_size, alphabet:)
33
+ @keygen = keygen || Keygen.new(passphrase:, digest: @digest, scrypt:)
34
+ freeze
35
+ end
36
+
37
+ # The openssl gem <4.0 raises bare RuntimeError for unrecognised algorithm
38
+ # names; jruby-openssl raises NotImplementedError.
39
+ def probe(kind, name) # :nodoc:
40
+ kind.new(name)
41
+ rescue OpenSSL::OpenSSLError, RuntimeError, NotImplementedError
42
+ raise ArgumentError, "unknown #{kind.name[/\w+\z/].downcase} #{name}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'epithet/scrypt'
5
+
6
+ class Epithet
7
+ # Key derivation helper
8
+ class Keygen
9
+ # Default parameters for scrypt.
10
+ #
11
+ # ```ruby
12
+ # DEFAULT_SCRYPT_PARAMS = {
13
+ # provider: Epithet::Scrypt,
14
+ # salt: 'epithet-default',
15
+ # N: 1 << 17,
16
+ # r: 8,
17
+ # p: 1,
18
+ # length: 32
19
+ # }.freeze
20
+ # ```
21
+ DEFAULT_SCRYPT_PARAMS = {
22
+ provider: Epithet::Scrypt,
23
+ salt: 'epithet-default',
24
+ N: 1 << 17,
25
+ r: 8,
26
+ p: 1,
27
+ length: 32
28
+ }.freeze
29
+
30
+ # Create a new key generator from a supplied passphrase, or from high-entropy initial key
31
+ # material if already prepared. The passphrase will be hashed with scrypt. Supplied scrypt
32
+ # params, if any, are merged over DEFAULT_SCRYPT_PARAMS, so this works:
33
+ #
34
+ # Epithet::Keygen.new(
35
+ # passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
36
+ # scrypt: { salt: "#{MyApp.name}/#{MyApp.env}" }
37
+ # )
38
+ #
39
+ # A scrypt provider will be chosen by `Epithet::Scrypt`. To override automatic selection
40
+ # and use a specific scrypt provider class, pass it as `provider` in the scrypt parameters:
41
+ #
42
+ # kg = Epithet::Keygen.new(passphrase: 'pw', scrypt: { provider: Epithet::Scrypt::OpenSSL })
43
+ #
44
+ # but this should be unnecessary in the common case.
45
+ def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: {})
46
+ raise ArgumentError, 'keygen requires either ikm or passphrase' unless passphrase.nil? ^ ikm.nil?
47
+
48
+ @ikm = (ikm&.b || build_scrypt(Hash(scrypt)).ikm(passphrase)).freeze
49
+ @digest = -String(digest)
50
+ freeze
51
+ end
52
+
53
+ def inspect
54
+ "#<#{self.class}:#{'%#016x' % (object_id << 1)} digest=#{@digest}>"
55
+ end
56
+
57
+ # Derive a key via HKDF.
58
+ def generate(info, salt, length)
59
+ OpenSSL::KDF.hkdf(@ikm, hash: @digest, info:, salt:, length:)
60
+ end
61
+
62
+ private
63
+
64
+ def build_scrypt(opts)
65
+ params = DEFAULT_SCRYPT_PARAMS.merge(opts)
66
+ params.delete(:provider).new(**params)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'monitor'
5
+
6
+ class Epithet
7
+ # Provider registry & classes for the scrypt implementations.
8
+ #
9
+ # A scrypt provider is any class that can be instantiated with the scrypt
10
+ # parameters `salt`, `N`, `r`, `p`, and `length`, has a predicate singleton
11
+ # method `auto?` indicating willingness to operate, and an instance method
12
+ # `ikm(passphrase)` that derives the key material. Automatic selection picks
13
+ # the most recently registered willing provider.
14
+ #
15
+ # Of the builtin providers, we prefer `OpenSSL::KDF.scrypt` when the platform
16
+ # supplies it, then JRuby's BouncyCastle implementation, and finally an
17
+ # already-loaded [`scrypt`](https://rubygems.org/gems/scrypt) gem. If none of
18
+ # these are available, selection falls through to a base class which raises
19
+ # `NotImplementedError` at the point of use.
20
+ #
21
+ # Epithet deliberately does not require the optional `scrypt` gem itself; an
22
+ # application relying on the `SCryptGem` provider should bundle and require
23
+ # `scrypt` before configuring Epithet.
24
+ #
25
+ # You may register a custom scrypt provider with the necessary signature, even
26
+ # after Epithet has loaded:
27
+ #
28
+ # class MyProvider < Epithet::Scrypt::Base
29
+ # def ikm(passphrase)
30
+ # #...
31
+ # end
32
+ # end
33
+ # Epithet::Scrypt.register(MyProvider)
34
+ #
35
+ # and this will be unconditionally preferred unless you also define a selective
36
+ # `auto?` method.
37
+ #
38
+ # Your `ikm` method should return `length` bytes of key material.
39
+ #
40
+ # Using this mechanism as a hook to deviate from the scrypt algorithm is not
41
+ # recommended; the better move would be to substitute a variant key
42
+ # generator in the `Epithet::Config` parameters, or supply IKM to an
43
+ # `Epithet::Keygen`.
44
+ module Scrypt
45
+ extend MonitorMixin
46
+
47
+ class << self
48
+ # The registered providers, in automatic-selection order.
49
+ def providers = synchronize { @providers }
50
+
51
+ # Registers `klass` ahead of the existing providers.
52
+ def register(klass) = synchronize { @providers = [klass, *@providers].freeze }
53
+
54
+ # Returns the first registered provider willing to operate.
55
+ def auto = providers.detect(&:auto?)
56
+
57
+ # Instantiates the automatically selected provider.
58
+ def new(...) = auto.new(...)
59
+ end
60
+
61
+ # :section: Providers
62
+ #
63
+ # ### `Base` class
64
+ #
65
+ # Defines the common `salt`, `N`, `r`, `p`, and `length` parameters. It is
66
+ # the last-resort provider: `ikm(passphrase)` raises `NotImplementedError`
67
+ # when no scrypt implementation is available.
68
+ #
69
+ # ### `OpenSSL` class
70
+ #
71
+ # The default, preferred provider. It uses `OpenSSL::KDF.scrypt` when the
72
+ # platform's OpenSSL supplies it.
73
+ #
74
+ # ### `BouncyCastle` class
75
+ #
76
+ # The JRuby fallback. JRuby's `openssl` is a BouncyCastle wrapper, but does
77
+ # not expose BouncyCastle's scrypt, so this provider invokes it directly.
78
+ #
79
+ # ### `SCryptGem` class
80
+ #
81
+ # A fallback for LibreSSL and other OpenSSLs that lack scrypt. It is
82
+ # available when the application has already loaded the optional
83
+ # [`scrypt`](https://rubygems.org/gems/scrypt) gem.
84
+ Base = Data.define(:salt, :N, :r, :p, :length) do # :nodoc: # rubocop:disable Naming/MethodName
85
+ def self.auto? = true
86
+ def ikm(passphrase) = raise NotImplementedError, 'no scrypt available'
87
+ end
88
+
89
+ class SCryptGem < Base # :nodoc:
90
+ def self.auto? = defined? ::SCrypt::Engine
91
+
92
+ def ikm(passphrase)
93
+ ::SCrypt::Engine.scrypt(passphrase, salt, self.N, r, p, length)
94
+ end
95
+ end
96
+
97
+ class BouncyCastle < Base # :nodoc:
98
+ def self.auto? = RUBY_ENGINE == 'jruby'
99
+
100
+ def ikm(passphrase)
101
+ String.from_java_bytes(
102
+ Java::OrgBouncycastleCryptoGenerators::SCrypt.generate(
103
+ passphrase.to_java_bytes, salt.to_java_bytes, self.N, r, p, length
104
+ )
105
+ )
106
+ end
107
+ end
108
+
109
+ class OpenSSL < Base # :nodoc:
110
+ def self.auto? = ::OpenSSL::KDF.respond_to? :scrypt
111
+
112
+ def ikm(passphrase)
113
+ ::OpenSSL::KDF.scrypt(passphrase, **to_h)
114
+ end
115
+ end
116
+
117
+ @providers = [OpenSSL, BouncyCastle, SCryptGem, Base].freeze
118
+ end
119
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Epithet
4
- # `= '1.1.0'`
5
- VERSION = '1.1.0'
4
+ # `= '2.1.0'`
5
+ VERSION = '2.1.0'
6
6
  end
data/lib/epithet.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  require 'epithet/version'
4
4
  require 'openssl'
5
+ require 'epithet/scrypt'
6
+ require 'epithet/keygen'
7
+ require 'epithet/block58'
8
+ require 'epithet/config'
5
9
 
6
10
  #
7
11
  # Epithet, a tool for external identifiers.
@@ -10,56 +14,67 @@ require 'openssl'
10
14
  # prefix (typically a model or table name), produces a replayable string parameter of
11
15
  # consistent length, with modest obfuscation and authentication properties.
12
16
  #
13
- # Pseudo-AEAD is via `AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7])` with the result
17
+ # Pseudo-AEAD is via `AES-256-ECB(id(8B) + MSB_64(HMAC-SHA256(id)))` with the result
14
18
  # base58 encoded for transmission and the contextual prefix prepended.
15
19
  #
16
- # Encodings are canonical; a given configuration accepts exactly one string per id.
20
+ # Encodings are canonical; a given configuration produces exactly one string per id.
17
21
  #
18
22
  # Subkeys for AES and HMAC are by default derived with HKDF using an internal key
19
23
  # 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.
24
+ # may be injected via Config objects. Subkeys are salted by prefix and an optional
25
+ # context string, which may be useful for purpose separation or rotation, and each
26
+ # subkey is bound to the configured name of the algorithm that consumes it.
22
27
  #
23
28
  # Example usage:
24
29
  #
25
30
  # # in setup-environment.sh
26
- # EPITHET_PASSPHRASE='example only'
31
+ # EPITHET_PASSPHRASE='example_only' ; export EPITHET_PASSPHRASE
27
32
  #
28
33
  # # ... later, in Ruby ...
29
34
  # Epithet.configure(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
30
35
  # user_epithet = Epithet.new('user')
31
- # user_epithet.encode(1) #=> "user_DAG6Joc5JmgygTBuEo8a9K"
36
+ # user_epithet.encode(1) #=> "user_NEwRoiarS9wdmiLmjEtti3"
32
37
  #
33
38
  class Epithet
39
+ # Raised by #decode when the input is not valid wire format.
40
+ FormatError = Class.new(ArgumentError)
41
+
42
+ # Raised if no defaults are configured.
43
+ ConfigurationError = Class.new(RuntimeError)
44
+
34
45
  # Create an encoder/decoder.
35
46
  #
36
47
  # 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)
48
+ # and reuse instances with equal parameters (e.g. setup the key generation once per runtime).
49
+ #
50
+ # * The stringified `prefix` is included in key derivation. It may be nil or empty, in which
51
+ # case the separator is ignored and a bare param will be produced.
38
52
  #
39
- # * `prefix` is stringified, and may be nil, producing an empty prefix.
40
- # The prefix is included in the salt for key generation.
53
+ # * `config` is optional and intended for cases where you need finer control than global defaults.
41
54
  #
42
- # * `config` is optional and intended for cases where you needed finer control than global defaults.
55
+ # Mixing incompatible character encodings across prefix/separator/payload may raise
56
+ # Encoding::CompatibilityError or Epithet::FormatError. Don't expect UTF-16LE to work.
43
57
  #
44
58
  # The simplest typical invocation is `Epithet.new('prefix')`.
45
59
  #
46
60
  def initialize(prefix, config: Epithet.defaults)
47
- prefix = String(prefix)
48
- key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack('Q>Z*Q>Z*')
49
- @block58 = Block58.build(16, alphabet: config.alphabet)
50
- @prefix_s = "#{prefix}#{config.separator}"
61
+ prefix = -String(prefix)
62
+ @prefix = prefix.empty? ? prefix : -(prefix + config.separator)
63
+ @wire_prefix = @prefix.b.freeze
64
+ key_salt = [prefix.bytesize, prefix, config.context.bytesize, config.context].pack('Q>Z*Q>Z*')
65
+ @codec = config.codec
51
66
 
52
67
  cipher_key_len = OpenSSL::Cipher.new(config.cipher).key_len
53
68
  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)
69
+ cipher_key = config.keygen.generate("epithet:cipher:#{config.cipher}", key_salt, cipher_key_len)
70
+ digest_key = config.keygen.generate("epithet:digest:#{config.digest}", key_salt, digest_key_len)
56
71
 
57
72
  @encryptor = OpenSSL::Cipher.new(config.cipher).encrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
58
73
  @decryptor = OpenSSL::Cipher.new(config.cipher).decrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
59
74
  @hmac = OpenSSL::HMAC.new(digest_key, config.digest)
60
75
  end
61
76
 
62
- # Encode a 64-bit unsigned Integer to a prefixed Base58 string.
77
+ # Encode a 64-bit unsigned integer to a prefixed base58 string.
63
78
  # Raises ArgumentError on invalid values.
64
79
  def encode(id)
65
80
  raise ArgumentError, 'not a 64-bit unsigned integer' unless Integer === id && id.bit_length <= 64 && id >= 0
@@ -71,21 +86,23 @@ class Epithet
71
86
  block = e.update([pt, m].pack('a8a8')) + e.final
72
87
  ct = block.unpack('Q>2').then { (_1 << 64) + _2 }
73
88
 
74
- @prefix_s + @block58.i2s(ct)
89
+ @prefix + @codec.i2s(ct)
75
90
  end
76
91
 
77
- # Decode a prefixed or raw Base58 string to an Integer. Raw inputs are
92
+ # Decode a prefixed or raw base58 string to an integer. The input is
93
+ # stringified and read as bytes, whatever its encoding; raw inputs are
78
94
  # recognised by their exact payload length.
79
95
  #
80
- # Returns the Integer on success, nil if authentication fails.
81
- # Raises ArgumentError on invalid wire format (see Block58#valid?).
96
+ # Returns the integer on success, nil if authentication fails.
97
+ # Raises FormatError on invalid wire format (see Block58#valid?).
82
98
  def decode(s)
83
- s = s.delete_prefix(@prefix_s) unless s.bytesize == @block58.size
84
- raise ArgumentError, 'unexpected format' unless @block58.valid?(s)
99
+ s = String(s).b
100
+ s = s.delete_prefix(@wire_prefix) unless s.bytesize == @codec.size
101
+ raise FormatError, 'unexpected format' unless @codec.valid?(s)
85
102
 
86
103
  d = @decryptor.dup
87
104
  h = @hmac.dup
88
- ct = @block58.s2i(s)
105
+ ct = @codec.s2i(s)
89
106
  block = d.update([ct >> 64, ct].pack('Q>2')) + d.final
90
107
  pt, m = block.unpack('a8a8')
91
108
  id = pt.unpack1('Q>')
@@ -110,31 +127,33 @@ class Epithet
110
127
  # scrypt: { salt: "#{MyApp.name}/#{MyApp.env}" }
111
128
  # )
112
129
  #
113
- # # Retaining already-configured passphrase but updating salt,
130
+ # # Retaining already-configured passphrase but updating context,
114
131
  # # and using a custom separator.
115
132
  # Epithet.configure(
116
133
  # keygen: Epithet.defaults.keygen,
117
- # salt: 'rotation-19',
134
+ # context: 'rotation-19',
118
135
  # separator: '-'
119
136
  # )
120
137
  #
121
138
  # #### Options
122
139
  #
123
140
  # * `:passphrase` - Install new key generator with scrypt-derived key material
124
- # * `:scrypt` - Params for scrypt, merged over `Keygen::DEFAULT_SCRYPT_PARAMS`
125
- # * `:keygen` - Install an existing key generator
141
+ # * `:scrypt` - Override scrypt parameters (cost, salt, provider etc); see Epithet::Scrypt
142
+ # * `:keygen` - Use an existing key generator
126
143
  # * `:cipher` - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard `aes-256-ecb`
127
- # * `:digest` - Must be >= 64 bits; omit for standard `sha256`
128
- # * `:separator` - String inserted between the prefix and the generated param; omit for standard `_`.
129
- # May be `nil`. Must not share bytes with the alphabet.
130
- # * `:alphabet` - Alphabet for the wire encoding; must be 58 strictly ascending bytes; omit for `Block58::Alphabet`.
131
- # * `:salt` - If supplied, stringified form is included in subkey derivation
144
+ # * `:digest` - Must be >= 64 bits digest; omit for standard `sha256`
145
+ # * `:separator` - String inserted between the prefix and the generated param.
146
+ # Omit for standard `_`. May be `nil`. Must not share bytes with the alphabet.
147
+ # Not emitted when prefix is `nil` or empty.
148
+ # * `:alphabet` - Custom base58 alphabet for the wire encoding. Must be strictly ascending bytes.
149
+ # * `:context` - If supplied, string form is included in subkey derivation.
150
+ # Useful for purpose separation, or rotation epochs.
132
151
  #
133
152
  # At minimum, one of `passphrase:` or `keygen:` is required.
134
- # Configuration via `passphrase` is recommended.
135
153
  #
136
154
  # If passing an existing key generator, the object must respond to `generate(info, salt, length)`
137
155
  # and return a byte string suitable for use with OpenSSL cryptographic primitives.
156
+ # Configuration sharing means an alternative keygen must support concurrent `generate` calls.
138
157
  #
139
158
  # See [`SECURITY.md`](SECURITY.md) for discussion of ciphers & digests.
140
159
  #
@@ -144,8 +163,8 @@ class Epithet
144
163
  #
145
164
  # cfg = Epithet::Config.new(
146
165
  # keygen: my_key_gen,
147
- # cipher: 'stronk-512-jcb',
148
- # digest: 'md7'
166
+ # cipher: 'camellia-256-ecb',
167
+ # digest: 'sha224'
149
168
  # )
150
169
  #
151
170
  # and either install this as default with
@@ -157,212 +176,6 @@ class Epithet
157
176
  # acct_epithet = Epithet.new('acct', config: cfg)
158
177
  #
159
178
  def configure(opts) = @defaults = Config === opts ? opts : Config.new(opts)
160
- def defaults() = @defaults || raise('no Epithet defaults configured')
161
- end
162
-
163
- # Class for passing around preset configs. See Epithet::configure for options.
164
- class Config
165
- attr_reader :keygen, :salt, :separator, :alphabet, :cipher, :digest # :nodoc:
166
-
167
- def initialize(opts = {})
168
- opts = opts.dup
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')
174
-
175
- cipher = OpenSSL::Cipher.new(@cipher)
176
- raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(@alphabet.bytes)
177
- raise ArgumentError, "#{@cipher} not a 128-bit block cipher" if cipher.block_size != 16
178
- raise ArgumentError, "#{@cipher} requires an IV/nonce" if cipher.iv_len != 0
179
- raise ArgumentError, "#{@digest} produces < 64-bit digest" if OpenSSL::Digest.new(@digest).digest_length < 8
180
-
181
- @keygen = opts.delete(:keygen) || Keygen.new(
182
- passphrase: opts.delete(:passphrase),
183
- digest: @digest,
184
- scrypt: opts.delete(:scrypt) || {})
185
- raise ArgumentError, "unused option(s) #{opts.keys}" unless opts.empty?
186
- freeze
187
- end
188
- end
189
-
190
- # Key derivation helper
191
- class Keygen
192
- # Default parameters for scrypt.
193
- #
194
- # ```ruby
195
- # DEFAULT_SCRYPT_PARAMS = {
196
- # salt: 'epithet-default',
197
- # N: 1 << 17,
198
- # r: 8,
199
- # p: 1,
200
- # length: 32
201
- # }.freeze
202
- # ```
203
- DEFAULT_SCRYPT_PARAMS = {
204
- salt: 'epithet-default',
205
- N: 1 << 17,
206
- r: 8,
207
- p: 1,
208
- length: 32
209
- }.freeze
210
-
211
- # Create a new key generator from either high-entropy key material, or a supplied passphrase.
212
- # Supplied scrypt params are merged over DEFAULT_SCRYPT_PARAMS.
213
- def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: {})
214
- if (passphrase.nil? && ikm.nil?) || (!passphrase.nil? && !ikm.nil?)
215
- raise ArgumentError, 'keygen requires either ikm or passphrase'
216
- end
217
-
218
- @ikm = (ikm ? ikm.b : OpenSSL::KDF.scrypt(passphrase, **DEFAULT_SCRYPT_PARAMS, **scrypt)).freeze
219
- @digest = -String(digest)
220
- freeze
221
- end
222
-
223
- def inspect
224
- "#<#{self.class}:#{'%#016x' % (object_id << 1)} digest=#{@digest}>"
225
- end
226
-
227
- # Derive a key via HKDF.
228
- def generate(info, salt, length)
229
- OpenSSL::KDF.hkdf(@ikm, hash: @digest, info: info, salt: salt, length: length)
230
- end
231
- end
232
-
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.
238
- class Block58
239
- # `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
240
- Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
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
-
249
- # Create a codec for a block size in bytes.
250
- #
251
- # The alphabet must be 58 distinct bytes in ascending order, so that
252
- # lexicographic order agrees with numeric order.
253
- def initialize(block_size, alphabet: Alphabet)
254
- raise ArgumentError, 'invalid block size' unless Integer === block_size && block_size > 0
255
- @alphabet = alphabet.b.freeze
256
- raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
257
- raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
258
- @size = ((block_size * 8) / Math.log2(58)).ceil(0)
259
- @charsel = @alphabet.gsub(/[\^\-\\]/, '\\\\\&').freeze
260
- @blank = @alphabet[0] * @size
261
- @lut = @alphabet.each_byte.with_index.with_object("\0" * 256) { |(val, idx), lut| lut.setbyte(val, idx) }.freeze
262
- @max = i2s((1 << (block_size * 8)) - 1).freeze
263
- end
264
-
265
- def inspect
266
- "#<#{self.class}:#{'%#016x' % (object_id << 1)} size=#{@size} alphabet=#{@alphabet}>"
267
- end
268
-
269
- # Return true if the string is in range with the right size and alphabet.
270
- def valid?(s)
271
- String === s && s.bytesize == @size && s <= @max && s.count(@charsel) == @size
272
- end
273
-
274
- # Encode a non-negative Integer to fixed-length Base58.
275
- # Truncates if int >= 58**size.
276
- def i2s(int)
277
- # Using divmod+setbyte is faster than Integer#digits under YJIT,
278
- # and about equal in plain MRI.
279
- alphabet = @alphabet
280
- out = @blank.dup
281
- idx = @size - 1
282
- n = int
283
- while idx >= 0 && n > 0
284
- n, rem = n.divmod(58)
285
- out.setbyte(idx, alphabet.getbyte(rem))
286
- idx -= 1
287
- end
288
- out
289
- end
290
-
291
- # Decode a fixed-length Base58 string to an Integer.
292
- # Assumes the input passes `#valid?`. Wraps at 58**size on the i2s round trip.
293
- def s2i(str)
294
- # Chunking intermediate results into 64-bit integers is ~5x faster
295
- # under YJIT than Horner's scheme
296
- #
297
- # str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
298
- #
299
- # at computing the inner product.
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
336
-
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
366
- end
179
+ def defaults() = @defaults || raise(ConfigurationError, 'no Epithet defaults configured')
367
180
  end
368
181
  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.1.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Goodall
@@ -37,20 +37,6 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
- - !ruby/object:Gem::Dependency
41
- name: rdoc
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: '7'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - ">="
52
- - !ruby/object:Gem::Version
53
- version: '7'
54
40
  - !ruby/object:Gem::Dependency
55
41
  name: rubocop
56
42
  requirement: !ruby/object:Gem::Requirement
@@ -65,7 +51,7 @@ dependencies:
65
51
  - - ">="
66
52
  - !ruby/object:Gem::Version
67
53
  version: '0'
68
- description: Epithet generates stable, prefixed, Base58 identifiers from 64-bit integers
54
+ description: Epithet generates stable, prefixed, base58 identifiers from 64-bit integers
69
55
  using AES and HMAC.
70
56
  email:
71
57
  - inopinatus@hey.com
@@ -79,6 +65,10 @@ files:
79
65
  - SECURITY.md
80
66
  - examples/basic.rb
81
67
  - lib/epithet.rb
68
+ - lib/epithet/block58.rb
69
+ - lib/epithet/config.rb
70
+ - lib/epithet/keygen.rb
71
+ - lib/epithet/scrypt.rb
82
72
  - lib/epithet/version.rb
83
73
  homepage: https://github.com/inopinatus/epithet
84
74
  licenses:
@@ -96,14 +86,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
96
86
  requirements:
97
87
  - - ">="
98
88
  - !ruby/object:Gem::Version
99
- version: '3.3'
89
+ version: '3.4'
100
90
  required_rubygems_version: !ruby/object:Gem::Requirement
101
91
  requirements:
102
92
  - - ">="
103
93
  - !ruby/object:Gem::Version
104
94
  version: '0'
105
95
  requirements: []
106
- rubygems_version: 4.0.10
96
+ rubygems_version: 4.0.16
107
97
  specification_version: 4
108
- summary: External base58 identifiers with reversible, authenticated obfuscation.
98
+ summary: External base58 identifiers with reversible, tamper-evident obfuscation.
109
99
  test_files: []