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