epithet 2.0.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: 40b8e5a401923aeaaf82f8340d43747433ea1440f7de4b1628087c326a916622
4
- data.tar.gz: bf139e16eecab4aa544981b15f28fb267f1e55e38ca37a0828c6836c064319c3
3
+ metadata.gz: 8065749fc3d0aaf89280bc7238f8dfe30ce749bf9c09c84fbec0406bde779d1b
4
+ data.tar.gz: 8e42aeba4a5f3bae8cec60bac6719a1214f6750de8a8b33ce009af8d7c8dd20c
5
5
  SHA512:
6
- metadata.gz: 324dec4e145ca1d5e8f3b9a22c3788ab3e9d1771d614c7dab5c94288888c59ce7235cbdd1a2dcd878015321f2264ae21702020b8bc9f8b6aaf3191bc50df9adc
7
- data.tar.gz: 55f0b6b5bd0061c3545f9c644bfaec6eb3e7721d0df33b1217a52384da63b4ff96c0d5848f2d0340460bf3e3b8c6e9aef38cd9e677665e6abcfa09f1fd4a9024
6
+ metadata.gz: 6946cf232b610670fd5cdd5c1293f1d3c882a4efa36f3e1a9971ce667f9291130372f2106b83cf8bbad16673d1b13fbb5e7b1475989a2b2dc7b201fc44347562
7
+ data.tar.gz: 3437d52226de8303e23db85c11dd803ac1556f267b067696822f864b17d8b1ed1b314ec421074da0766e23ecd2efb926c6cb58dfefc7d7f0d1272e804d3ddcce
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 2.0.0 - 2026-07-16
4
14
 
5
15
  ### Breaking changes
data/README.md CHANGED
@@ -45,11 +45,15 @@ user_epithet.decode(param)
45
45
  # => 42
46
46
  ```
47
47
 
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.
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.
49
49
 
50
- 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.
51
51
 
52
- Note that `decode` returns `nil` when authentication fails, and raises `Epithet::FormatError` (an 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.
53
57
 
54
58
  ## Development
55
59
 
data/SECURITY.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Epithet security
2
2
 
3
+ This library is intended for high-performance obfuscation of integer sequences, deflection of casual
4
+ tampering, and conversion to a compact, stable wire parameter format that is hard to guess and hard
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.
9
+
3
10
  ## Cryptographic considerations
4
11
 
5
12
  The primary construction is `AES-256-ECB(id(8B) + MSB_64(HMAC-SHA256(id)))` with the result base58
@@ -7,12 +14,6 @@ encoded for transmission and a contextual prefix prepended. Subkeys for AES and
7
14
  derived with HKDF using an internal key generator that takes IKM from a passphrase via scrypt,
8
15
  salting generated keys by prefix and context and binding them by algorithm.
9
16
 
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
17
  An epithet should never be used as an authentication token, only as an object identifier. The
17
18
  64-bit tag is shorter than the minimum RFC 2104 §5 recommends for message authentication, and the
18
19
  tamper detection is necessarily probabilistic because the MAC is truncated. After N independent
@@ -38,7 +39,9 @@ decode a value exceeding the 128-bit block.
38
39
  A weak, guessable, or disclosed passphrase will compromise the obfuscation and tamper-detection
39
40
  properties.
40
41
 
41
- 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.
42
45
 
43
46
  ## On seasoning
44
47
 
@@ -48,7 +51,7 @@ Secondly, for the HKDF extract phase to separate derived subkeys by some applica
48
51
  division such as purpose or rotation epoch.
49
52
 
50
53
  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.
54
+ and is instead derived from the context and prefix parameters.
52
55
 
53
56
  Epithet does not store or verify passwords; the scrypt salt, and the context & prefix parameters
54
57
  used in the HKDF salt, are non-secret configuration and may be safely committed to source control.
data/examples/basic.rb CHANGED
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require 'epithet'
4
5
 
5
6
  # Using a random passphrase means that epithet identifiers are effectively
6
7
  # ephemeral, since decoding is limited to the lifetime of this process.
7
8
  def epithet_initialize
8
9
  Epithet.configure(
9
- passphrase: ENV.fetch('EPITHET_PASSPHRASE') { Random.bytes(32) },
10
+ passphrase: ENV.fetch('EPITHET_PASSPHRASE') { SecureRandom.random_bytes(32) },
10
11
  scrypt: { salt: 'myapp/production' },
11
12
  context: 'v1'
12
13
  )
@@ -16,6 +17,6 @@ epithet_initialize
16
17
  user_epithet = Epithet.new('user')
17
18
 
18
19
  id = Integer(ARGV.shift || 42)
19
- param = user_epithet.encode(id) #=> "user_KYM3B4d5ce1NNsv52rAoPg"
20
+ param = user_epithet.encode(id)
20
21
 
21
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
- # `= '2.0.0'`
5
- VERSION = '2.0.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.
@@ -35,6 +39,9 @@ class Epithet
35
39
  # Raised by #decode when the input is not valid wire format.
36
40
  FormatError = Class.new(ArgumentError)
37
41
 
42
+ # Raised if no defaults are configured.
43
+ ConfigurationError = Class.new(RuntimeError)
44
+
38
45
  # Create an encoder/decoder.
39
46
  #
40
47
  # Setup could be moderately expensive due to key derivation; you are recommended to cache
@@ -45,14 +52,15 @@ class Epithet
45
52
  #
46
53
  # * `config` is optional and intended for cases where you need finer control than global defaults.
47
54
  #
48
- # Mixed character encodings across prefix & separator may raise Encoding::CompatibilityError.
55
+ # Mixing incompatible character encodings across prefix/separator/payload may raise
56
+ # Encoding::CompatibilityError or Epithet::FormatError. Don't expect UTF-16LE to work.
49
57
  #
50
58
  # The simplest typical invocation is `Epithet.new('prefix')`.
51
59
  #
52
60
  def initialize(prefix, config: Epithet.defaults)
53
- prefix = String(prefix)
54
- @prefix = prefix.empty? ? prefix : prefix + config.separator
55
- @wire_prefix = @prefix.b
61
+ prefix = -String(prefix)
62
+ @prefix = prefix.empty? ? prefix : -(prefix + config.separator)
63
+ @wire_prefix = @prefix.b.freeze
56
64
  key_salt = [prefix.bytesize, prefix, config.context.bytesize, config.context].pack('Q>Z*Q>Z*')
57
65
  @codec = config.codec
58
66
 
@@ -130,7 +138,7 @@ class Epithet
130
138
  # #### Options
131
139
  #
132
140
  # * `:passphrase` - Install new key generator with scrypt-derived key material
133
- # * `:scrypt` - Merge params for scrypt
141
+ # * `:scrypt` - Override scrypt parameters (cost, salt, provider etc); see Epithet::Scrypt
134
142
  # * `:keygen` - Use an existing key generator
135
143
  # * `:cipher` - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard `aes-256-ecb`
136
144
  # * `:digest` - Must be >= 64 bits digest; omit for standard `sha256`
@@ -145,6 +153,7 @@ class Epithet
145
153
  #
146
154
  # If passing an existing key generator, the object must respond to `generate(info, salt, length)`
147
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.
148
157
  #
149
158
  # See [`SECURITY.md`](SECURITY.md) for discussion of ciphers & digests.
150
159
  #
@@ -154,8 +163,8 @@ class Epithet
154
163
  #
155
164
  # cfg = Epithet::Config.new(
156
165
  # keygen: my_key_gen,
157
- # cipher: 'stronk-512-jcb',
158
- # digest: 'md7'
166
+ # cipher: 'camellia-256-ecb',
167
+ # digest: 'sha224'
159
168
  # )
160
169
  #
161
170
  # and either install this as default with
@@ -167,215 +176,6 @@ class Epithet
167
176
  # acct_epithet = Epithet.new('acct', config: cfg)
168
177
  #
169
178
  def configure(opts) = @defaults = Config === opts ? opts : Config.new(opts)
170
- def defaults() = @defaults || raise('no Epithet defaults configured')
171
- end
172
-
173
- # Class for passing around preset configs. See Epithet::configure for options.
174
- class Config
175
- attr_reader :keygen, :context, :separator, :cipher, :digest, :codec # :nodoc:
176
-
177
- def initialize(opts = {})
178
- opts = opts.dup
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 }
185
-
186
- cipher = OpenSSL::Cipher.new(@cipher)
187
- raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(alphabet.bytes)
188
- raise ArgumentError, "#{@cipher} not a 128-bit block cipher" if cipher.block_size != 16
189
- raise ArgumentError, "#{@cipher} requires an IV/nonce" if cipher.iv_len != 0
190
- raise ArgumentError, "#{@digest} produces < 64-bit digest" if OpenSSL::Digest.new(@digest).digest_length < 8
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
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
198
- end
199
- end
200
-
201
- # Key derivation helper
202
- class Keygen
203
- # Default parameters for scrypt.
204
- #
205
- # ```ruby
206
- # DEFAULT_SCRYPT_PARAMS = {
207
- # salt: 'epithet-default',
208
- # N: 1 << 17,
209
- # r: 8,
210
- # p: 1
211
- # }.freeze
212
- # ```
213
- DEFAULT_SCRYPT_PARAMS = {
214
- salt: 'epithet-default',
215
- N: 1 << 17,
216
- r: 8,
217
- p: 1
218
- }.freeze
219
-
220
- # Create a new key generator from either high-entropy key material, or a supplied passphrase.
221
- # Supplied scrypt params, if any, are merged over DEFAULT_SCRYPT_PARAMS.
222
- def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: {})
223
- if (passphrase.nil? && ikm.nil?) || (!passphrase.nil? && !ikm.nil?)
224
- raise ArgumentError, 'keygen requires either ikm or passphrase'
225
- end
226
- raise ArgumentError, 'scrypt length is not configurable' if scrypt&.key?(:length)
227
-
228
- @ikm = (ikm&.b || OpenSSL::KDF.scrypt(passphrase, **DEFAULT_SCRYPT_PARAMS, **scrypt, length: 32)).freeze
229
- @digest = -String(digest)
230
- freeze
231
- end
232
-
233
- def inspect
234
- "#<#{self.class}:#{'%#016x' % (object_id << 1)} digest=#{@digest}>"
235
- end
236
-
237
- # Derive a key via HKDF.
238
- def generate(info, salt, length)
239
- OpenSSL::KDF.hkdf(@ikm, hash: @digest, info:, salt:, length:)
240
- end
241
- end
242
-
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.
248
- class Block58
249
- # `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
250
- Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
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
-
259
- # Create a codec for a block size in bytes.
260
- #
261
- # The alphabet must be 58 distinct bytes in ascending order, so that
262
- # lexicographic order agrees with numeric order.
263
- def initialize(block_size, alphabet: Alphabet)
264
- raise ArgumentError, 'invalid block size' unless Integer === block_size && block_size > 0
265
- @alphabet = alphabet.b.freeze
266
- raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
267
- raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
268
- @size = ((block_size * 8) / Math.log2(58)).ceil
269
- @charsel = @alphabet.gsub(/[\^\-\\]/, '\\\\\&').freeze
270
- @blank = @alphabet[0] * @size
271
- @lut = @alphabet.each_byte.with_index.with_object("\0" * 256) { |(val, idx), lut| lut.setbyte(val, idx) }.freeze
272
- @limit = 1 << (block_size * 8)
273
- @max = i2s(@limit - 1).freeze
274
- end
275
-
276
- def inspect
277
- "#<#{self.class}:#{'%#016x' % (object_id << 1)} size=#{@size} alphabet=#{@alphabet}>"
278
- end
279
-
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.
282
- def valid?(s)
283
- String === s && s.bytesize == @size && (s = s.b) <= @max && s.count(@charsel) == @size
284
- end
285
-
286
- # Encode an acceptable integer to fixed-length base58.
287
- def i2s(int)
288
- raise ArgumentError, 'integer out of block range' unless Integer === int && int >= 0 && int < @limit
289
-
290
- # Using divmod+setbyte is faster than Integer#digits under YJIT,
291
- # and about equal in plain MRI.
292
- alphabet = @alphabet
293
- out = @blank.dup
294
- idx = @size - 1
295
- n = int
296
- while idx >= 0 && n > 0
297
- n, rem = n.divmod(58)
298
- out.setbyte(idx, alphabet.getbyte(rem))
299
- idx -= 1
300
- end
301
- out
302
- end
303
-
304
- # Decode a fixed-length base58 string to an integer.
305
- # Assumes the input passes `#valid?`, behaviour undefined if it doesn't.
306
- def s2i(str)
307
- # Chunking intermediate results into 64-bit integers is ~5x faster
308
- # under YJIT than Horner's scheme
309
- #
310
- # str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
311
- #
312
- # at computing the inner product.
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
339
-
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
379
- end
179
+ def defaults() = @defaults || raise(ConfigurationError, 'no Epithet defaults configured')
380
180
  end
381
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: 2.0.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
@@ -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:
@@ -103,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
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
98
  summary: External base58 identifiers with reversible, tamper-evident obfuscation.
109
99
  test_files: []