epithet 0.1.0 → 1.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: c8d4c22a2ee0f83df400b54761a8e5c0a03df04390c6c9cdbb486a8c907f1812
4
- data.tar.gz: a8507f28f992f524284262d498b107cfb566f9fefb66c5d97e2f29322d7a9fe4
3
+ metadata.gz: 3695519b4c82955cf40a1d3b5109fbeaf3458085146500eea600c0fa8ce86bb0
4
+ data.tar.gz: f527803fc071230ae440b9e199f65ad890a0bf8778d643258b0c58b1a0f53b78
5
5
  SHA512:
6
- metadata.gz: bc64b08e053da076bab07709bed61f1ff0c41ebcdb59a3963f63b7846b897efa2b8a2de43330b8bb0a3d7b473d377dc676da2ef4331ebcd52d18749809b8b1c3
7
- data.tar.gz: 8495ae0d1ed045619ed752ef55cc240ff5180b6da68bf27f2cc858e6ec1a190eb93e10c5dc1e23665a363ee42448dc1df6d23f3522ab03813f6d2f48da322b44
6
+ metadata.gz: 725d659f196c53fc6ea438607dd9319b95b5aac8b61a68c5708b62f9eed26fd76012b3addb45a9a9880952c7275bbc2e5aace3390998c082e6dedd48c76fc174
7
+ data.tar.gz: aa15ac11d2ae8c1454ce5d086c25fbae5aee1957747e77dfcbdf626c470ddf438df69a3580eee98ecca7131243ed175d53c5edbc245b6b8c3c96b01742cf2914
data/CHANGELOG.md CHANGED
@@ -1,4 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0 - 2026-07-14
4
+
5
+ ### Breaking changes
6
+
7
+ - Treat out-of-range base58 strings as invalid instead of wrapping them.
8
+ - Reject separators that share codepoints with the encoding alphabet.
9
+ - Drop support for Rubies < 3.3.
10
+
11
+ ### Other changes
12
+
13
+ - Code style nitpicks.
14
+ - Support for 32-bit Rubies.
15
+ - Support for Ruby 4.0.
16
+ - Documentation improvements.
17
+ - A custom alphabet may be configured via `Epithet::Config`.
18
+ - Custom alphabets must be strictly ascending byte codepoints.
19
+
3
20
  ## 0.1.0 - 2025-12-22
4
21
  - Initial release.
data/SECURITY.md CHANGED
@@ -8,18 +8,21 @@ HMAC are by default derived with HKDF using an internal key generator that takes
8
8
  a passphrase via scrypt, salting generated keys by prefix and purpose.
9
9
 
10
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. Although
12
- it uses standard cryptographic primitives to do so, the design trade-off of the compact
13
- format means it is not intended to defeat nation-state security services, talented
14
- cryptographers, or even a well-resourced enterprise.
11
+ of casual tampering, and conversion to a compact, stable wire parameter format that is hard
12
+ to guess and hard to predict. Although it uses standard cryptographic primitives to do so,
13
+ the design trade-off of the compact format means it is not intended to defeat nation-state
14
+ security services, talented cryptographers, or even a well-resourced enterprise.
15
15
 
16
16
  The identifiers produced are intentionally deterministic i.e. replayable and reusable. For
17
17
  privacy, confidentiality, and authentication purposes they should therefore be considered
18
- equivalent to the plaintext integer they represent, and those purposes must still be addressed
18
+ equivalent to the plaintext integer they represent, and those concerns must still be addressed
19
19
  in the usual manner.
20
20
 
21
21
  The tamper detection is necessarily probabilistic, because the MAC is truncated.
22
22
 
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.
25
+
23
26
  If configuring alternative cipher and digest algorithms, note that only 128-bit block
24
27
  ciphers that function without IV/nonce requirements are accepted. Streaming ciphers
25
28
  (e.g. chacha20) or block ciphers in streaming modes (e.g. aes-256-ctr) must not be used;
@@ -37,5 +40,3 @@ Use Epithet at your own risk.
37
40
  If you think you've found a vulnerability in Epithet that compromises its design or behaviour, please
38
41
  [report it via a private advisory](https://github.com/inopinatus/epithet/security/advisories/new).
39
42
  Do not open a public issue or pull request.
40
-
41
-
data/examples/basic.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'epithet'
2
4
 
3
5
  def epithet_initialize
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Epithet
2
- # `= "0.1.0"`
3
- VERSION = "0.1.0"
4
+ # `= '1.0.0'`
5
+ VERSION = '1.0.0'
4
6
  end
data/lib/epithet.rb CHANGED
@@ -13,6 +13,8 @@ require 'openssl'
13
13
  # Pseudo-AEAD is via `AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7])` 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.
17
+ #
16
18
  # Subkeys for AES and HMAC are by default derived with HKDF using an internal key
17
19
  # generator that takes IKM from a passphrase via scrypt. An alternative key generator
18
20
  # may be injected via Config objects. Subkeys are salted by prefix and an optional
@@ -43,14 +45,14 @@ class Epithet
43
45
  #
44
46
  def initialize(prefix, config: Epithet.defaults)
45
47
  prefix = String(prefix)
46
- key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack("Q>Z*Q>Z*")
47
- @block58 = Block58.new(16)
48
+ key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack('Q>Z*Q>Z*')
49
+ @block58 = Block58.new(16, alphabet: config.alphabet)
48
50
  @prefix_s = "#{prefix}#{config.separator}"
49
51
 
50
52
  cipher_key_len = OpenSSL::Cipher.new(config.cipher).key_len
51
53
  digest_key_len = OpenSSL::Digest.new(config.digest).block_length
52
- cipher_key = config.keygen.generate("epithet:ecb", key_salt, cipher_key_len)
53
- digest_key = config.keygen.generate("epithet:mac", key_salt, digest_key_len)
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)
54
56
 
55
57
  @encryptor = OpenSSL::Cipher.new(config.cipher).encrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
56
58
  @decryptor = OpenSSL::Cipher.new(config.cipher).decrypt.tap { |c| c.key = cipher_key; c.padding = 0 }
@@ -60,7 +62,7 @@ class Epithet
60
62
  # Encode a 64-bit unsigned Integer to a prefixed Base58 string.
61
63
  # Raises ArgumentError on invalid values.
62
64
  def encode(id)
63
- raise ArgumentError, "not a 64-bit unsigned integer" unless Integer === id && id.size == 8 && id >= 0
65
+ raise ArgumentError, 'not a 64-bit unsigned integer' unless Integer === id && id.bit_length <= 64 && id >= 0
64
66
 
65
67
  e = @encryptor.dup
66
68
  h = @hmac.dup
@@ -75,10 +77,10 @@ class Epithet
75
77
  # Decode a prefixed or raw Base58 string to an Integer.
76
78
  #
77
79
  # Returns the Integer on success, nil if authentication fails.
78
- # Raises ArgumentError on invalid formats.
80
+ # Raises ArgumentError on invalid wire format (see Block58#valid?).
79
81
  def decode(s)
80
82
  s = s.delete_prefix(@prefix_s)
81
- raise ArgumentError, "unexpected format" unless @block58.valid?(s)
83
+ raise ArgumentError, 'unexpected format' unless @block58.valid?(s)
82
84
 
83
85
  d = @decryptor.dup
84
86
  h = @hmac.dup
@@ -119,7 +121,9 @@ class Epithet
119
121
  # * `:keygen` - Install an existing key generator
120
122
  # * `:cipher` - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard `aes-256-ecb`
121
123
  # * `:digest` - Must be >= 64 bits; omit for standard `sha256`
122
- # * `:separator` - Inserted as string between the prefix and the generated param. may be nil; omit for underscore.
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`.
123
127
  # * `:salt` - If supplied, stringified form is included in subkey derivation
124
128
  #
125
129
  # At minimum, one of `passphrase:` or `keygen:` is required.
@@ -149,20 +153,23 @@ class Epithet
149
153
  # acct_epithet = Epithet.new('acct', config: cfg)
150
154
  #
151
155
  def configure(opts) = @defaults = Config === opts ? opts : Config.new(opts)
152
- def defaults() = @defaults || raise(RuntimeError, "no Epithet defaults configured")
156
+ def defaults() = @defaults || raise('no Epithet defaults configured')
153
157
  end
154
158
 
155
159
  # Class for passing around preset configs. See Epithet::configure for options.
156
160
  class Config
157
- attr_reader :keygen, :salt, :separator, :cipher, :digest # :nodoc:
161
+ attr_reader :keygen, :salt, :separator, :alphabet, :cipher, :digest # :nodoc:
162
+
158
163
  def initialize(opts = {})
159
164
  opts = opts.dup
160
165
  @separator = String(opts.delete(:separator) { '_' })
161
166
  @salt = String(opts.delete(:salt))
167
+ @alphabet = String(opts.delete(:alphabet) { Block58::Alphabet })
162
168
  @cipher = opts.delete(:cipher) || 'aes-256-ecb'
163
169
  @digest = opts.delete(:digest) || 'sha256'
164
170
 
165
171
  cipher = OpenSSL::Cipher.new(@cipher)
172
+ raise ArgumentError, 'separator intersects alphabet' if @separator.bytes.intersect?(@alphabet.bytes)
166
173
  raise ArgumentError, "#{@cipher} not a 128-bit block cipher" if cipher.block_size != 16
167
174
  raise ArgumentError, "#{@cipher} requires an IV/nonce" if cipher.iv_len != 0
168
175
  raise ArgumentError, "#{@digest} produces < 64-bit digest" if OpenSSL::Digest.new(@digest).digest_length < 8
@@ -182,7 +189,7 @@ class Epithet
182
189
  # ```ruby
183
190
  # DEFAULT_SCRYPT_PARAMS = {
184
191
  # salt: 'epithet-default',
185
- # N: 1<<17,
192
+ # N: 1 << 17,
186
193
  # r: 8,
187
194
  # p: 1,
188
195
  # length: 32
@@ -190,16 +197,16 @@ class Epithet
190
197
  # ```
191
198
  DEFAULT_SCRYPT_PARAMS = {
192
199
  salt: 'epithet-default',
193
- N: 1<<17,
200
+ N: 1 << 17,
194
201
  r: 8,
195
202
  p: 1,
196
203
  length: 32
197
204
  }.freeze
198
205
 
199
206
  # Create a new key generator from either high-entropy key material, or a supplied passphrase.
200
- def initialize(ikm: nil, passphrase: nil, digest: "sha256", scrypt: DEFAULT_SCRYPT_PARAMS)
207
+ def initialize(ikm: nil, passphrase: nil, digest: 'sha256', scrypt: DEFAULT_SCRYPT_PARAMS)
201
208
  if (passphrase.nil? && ikm.nil?) || (!passphrase.nil? && !ikm.nil?)
202
- raise ArgumentError, "keygen requires either ikm or passphrase"
209
+ raise ArgumentError, 'keygen requires either ikm or passphrase'
203
210
  end
204
211
 
205
212
  @ikm = ikm || OpenSSL::KDF.scrypt(passphrase, **scrypt)
@@ -218,29 +225,35 @@ class Epithet
218
225
 
219
226
  # Fixed-length Base58 codec for a fixed-size block.
220
227
  class Block58
221
- # `= "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"`
222
- Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
228
+ # `= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'`
229
+ Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
223
230
 
224
231
  # Create a codec for a block size in bytes.
232
+ #
233
+ # The alphabet must be 58 distinct bytes in ascending order, so that
234
+ # lexicographic order agrees with numeric order.
225
235
  def initialize(block_size, alphabet: Alphabet)
226
236
  @alphabet = alphabet.b.freeze
227
- raise ArgumentError, "invalid alphabet length" unless @alphabet.bytesize == 58
237
+ raise ArgumentError, 'invalid alphabet length' unless @alphabet.bytesize == 58
238
+ raise ArgumentError, 'alphabet not strictly ascending' unless @alphabet.bytes.each_cons(2).all? { _2 > _1 }
228
239
  @size = ((block_size * 8) / Math.log2(58)).ceil(0)
229
240
  @charsel = @alphabet.gsub(/[\^\-\\]/, '\\\\\&').freeze
230
241
  @blank = @alphabet[0] * @size
231
242
  @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
232
244
  end
233
245
 
234
246
  def inspect
235
247
  "#<#{self.class}:#{'%#016x' % (object_id << 1)} size=#{@size} alphabet=#{@alphabet}>"
236
248
  end
237
249
 
238
- # Return true if the string has the right size and alphabet.
250
+ # Return true if the string is in range with the right size and alphabet.
239
251
  def valid?(s)
240
- String === s && s.bytesize == @size && s.count(@charsel) == @size
252
+ String === s && s.bytesize == @size && s <= @max && s.count(@charsel) == @size
241
253
  end
242
254
 
243
255
  # Encode a non-negative Integer to fixed-length Base58.
256
+ # Truncates if int >= 58**size.
244
257
  def i2s(int)
245
258
  # Using divmod+setbyte is faster than Integer#digits under YJIT,
246
259
  # and about equal in plain MRI.
@@ -257,8 +270,10 @@ class Epithet
257
270
  end
258
271
 
259
272
  # Decode a fixed-length Base58 string to an Integer.
260
- # Assumes the input passes `#valid?`.
273
+ # Assumes the input passes `#valid?`. Wraps at 58**size on the i2s round trip.
261
274
  def s2i(str)
275
+ # rubocop:disable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
276
+ #
262
277
  # By unrolling coefficients, this is ~8x faster than Horner's scheme
263
278
  #
264
279
  # str.each_byte.inject(0) { _1 * 58 + @lut[_2] }
@@ -293,6 +308,8 @@ class Epithet
293
308
  58 * lut.getbyte(str.getbyte(20)) +
294
309
  3364 * acc1 +
295
310
  1449225352009601191936 * acc0
311
+
312
+ # rubocop:enable Style/NumericLiterals, Lint/AmbiguousOperatorPrecedence, Layout
296
313
  end
297
314
  end
298
315
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: epithet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Goodall
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-12-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: minitest
@@ -52,6 +51,20 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
55
68
  description: Epithet generates stable, prefixed, Base58 identifiers from 64-bit integers
56
69
  using AES and HMAC.
57
70
  email:
@@ -76,7 +89,6 @@ metadata:
76
89
  changelog_uri: https://github.com/inopinatus/epithet/blob/main/CHANGELOG.md
77
90
  bug_tracker_uri: https://github.com/inopinatus/epithet/issues
78
91
  rubygems_mfa_required: 'true'
79
- post_install_message:
80
92
  rdoc_options: []
81
93
  require_paths:
82
94
  - lib
@@ -84,15 +96,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
96
  requirements:
85
97
  - - ">="
86
98
  - !ruby/object:Gem::Version
87
- version: '3'
99
+ version: '3.3'
88
100
  required_rubygems_version: !ruby/object:Gem::Requirement
89
101
  requirements:
90
102
  - - ">="
91
103
  - !ruby/object:Gem::Version
92
104
  version: '0'
93
105
  requirements: []
94
- rubygems_version: 3.0.3.1
95
- signing_key:
106
+ rubygems_version: 4.0.10
96
107
  specification_version: 4
97
108
  summary: External base58 identifiers with reversible, authenticated obfuscation.
98
109
  test_files: []