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 +4 -4
- data/CHANGELOG.md +17 -0
- data/SECURITY.md +8 -7
- data/examples/basic.rb +2 -0
- data/lib/epithet/version.rb +4 -2
- data/lib/epithet.rb +37 -20
- metadata +18 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3695519b4c82955cf40a1d3b5109fbeaf3458085146500eea600c0fa8ce86bb0
|
|
4
|
+
data.tar.gz: f527803fc071230ae440b9e199f65ad890a0bf8778d643258b0c58b1a0f53b78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
12
|
-
it uses standard cryptographic primitives to do so,
|
|
13
|
-
format means it is not intended to defeat nation-state
|
|
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
|
|
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
data/lib/epithet/version.rb
CHANGED
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(
|
|
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(
|
|
53
|
-
digest_key = config.keygen.generate(
|
|
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,
|
|
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
|
|
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,
|
|
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` -
|
|
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(
|
|
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:
|
|
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,
|
|
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
|
-
# `=
|
|
222
|
-
Alphabet =
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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: []
|