Class: Epithet
- Inherits:
-
Object
- Object
- Epithet
- Defined in:
- lib/epithet.rb,
lib/epithet/version.rb
Overview
Epithet, a tool for external identifiers.
Given a 64-bit value such as a database sequence ID, and a context-specific prefix (typically a model or table name), produces a replayable string parameter of consistent length, with modest obfuscation and authentication properties.
Pseudo-AEAD is via AES-256-ECB(id(8B) + HMAC-SHA256(id)[0,7]) with the result
base58 encoded for transmission and the contextual prefix prepended.
Encodings are canonical; a given configuration accepts exactly one string per id.
Subkeys for AES and HMAC are by default derived with HKDF using an internal key generator that takes IKM from a passphrase via scrypt. An alternative key generator may be injected via Config objects. Subkeys are salted by prefix and an optional additional salt, which may be useful for purpose separation or rotation.
Example usage:
# in setup-environment.sh
EPITHET_PASSPHRASE='example only'
# ... later, in Ruby ...
Epithet.configure(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
user_epithet = Epithet.new('user')
user_epithet.encode(1) #=> "user_DAG6Joc5JmgygTBuEo8a9K"
Defined Under Namespace
Classes: Block58, Config, Keygen
Constant Summary collapse
- VERSION =
= '1.1.0' '1.1.0'
Class Method Summary collapse
-
.configure(opts) ⇒ Object
Configure the library.
- .defaults ⇒ Object
Instance Method Summary collapse
-
#cteq(a, b) ⇒ Object
Constant time octet-string comparison.
-
#decode(s) ⇒ Object
Decode a prefixed or raw Base58 string to an Integer.
-
#encode(id) ⇒ Object
Encode a 64-bit unsigned Integer to a prefixed Base58 string.
-
#initialize(prefix, config: Epithet.defaults) ⇒ Epithet
constructor
Create an encoder/decoder.
Constructor Details
#initialize(prefix, config: Epithet.defaults) ⇒ Epithet
Create an encoder/decoder.
Setup could be moderately expensive due to key derivation; you are recommended to cache and reuse instances with equal parameters (e.g. setup once per model)
-
prefixis stringified, and may be nil, producing an empty prefix. The prefix is included in the salt for key generation. -
configis optional and intended for cases where you needed finer control than global defaults.
The simplest typical invocation is Epithet.new('prefix').
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/epithet.rb', line 46 def initialize(prefix, config: Epithet.defaults) prefix = String(prefix) key_salt = [prefix.bytesize, prefix, config.salt.bytesize, config.salt].pack('Q>Z*Q>Z*') @block58 = Block58.build(16, alphabet: config.alphabet) @prefix_s = "#{prefix}#{config.separator}" cipher_key_len = OpenSSL::Cipher.new(config.cipher).key_len digest_key_len = OpenSSL::Digest.new(config.digest).block_length cipher_key = config.keygen.generate('epithet:ecb', key_salt, cipher_key_len) digest_key = config.keygen.generate('epithet:mac', key_salt, digest_key_len) @encryptor = OpenSSL::Cipher.new(config.cipher).encrypt.tap { |c| c.key = cipher_key; c.padding = 0 } @decryptor = OpenSSL::Cipher.new(config.cipher).decrypt.tap { |c| c.key = cipher_key; c.padding = 0 } @hmac = OpenSSL::HMAC.new(digest_key, config.digest) end |
Class Method Details
.configure(opts) ⇒ Object
Configure the library.
Examples
# As it might appear in an initializer
Epithet.configure(
passphrase: ENV.fetch('EPITHET_PASSPHRASE'),
scrypt: { salt: "#{MyApp.name}/#{MyApp.env}" }
)
# Retaining already-configured passphrase but updating salt,
# and using a custom separator.
Epithet.configure(
keygen: Epithet.defaults.keygen,
salt: 'rotation-19',
separator: '-'
)
Options
:passphrase- Install new key generator with scrypt-derived key material:scrypt- Params for scrypt, merged overKeygen::DEFAULT_SCRYPT_PARAMS:keygen- Install an existing key generator:cipher- Must be a 128-bit block cipher in ECB mode or equivalent; omit for standardaes-256-ecb:digest- Must be >= 64 bits; omit for standardsha256:separator- String inserted between the prefix and the generated param; omit for standard_. May benil. Must not share bytes with the alphabet.:alphabet- Alphabet for the wire encoding; must be 58 strictly ascending bytes; omit forBlock58::Alphabet.:salt- If supplied, stringified form is included in subkey derivation
At minimum, one of passphrase: or keygen: is required.
Configuration via passphrase is recommended.
If passing an existing key generator, the object must respond to generate(info, salt, length)
and return a byte string suitable for use with OpenSSL cryptographic primitives.
See SECURITY.md for discussion of ciphers & digests.
Multiple configurations
You can produce & store configurations, thus:
cfg = Epithet::Config.new(
keygen: my_key_gen,
cipher: 'stronk-512-jcb',
digest: 'md7'
)
and either install this as default with
Epithet.configure(cfg)
or pass it to Epithet::new as
acct_epithet = Epithet.new('acct', config: cfg)
159 |
# File 'lib/epithet.rb', line 159 def configure(opts) = @defaults = Config === opts ? opts : Config.new(opts) |
.defaults ⇒ Object
160 |
# File 'lib/epithet.rb', line 160 def defaults() = @defaults || raise('no Epithet defaults configured') |
Instance Method Details
#cteq(a, b) ⇒ Object
Constant time octet-string comparison.
97 98 99 100 |
# File 'lib/epithet.rb', line 97 def cteq(a, b) # :nodoc: return false unless a.bytesize == b.bytesize OpenSSL.fixed_length_secure_compare(a, b) end |
#decode(s) ⇒ Object
Decode a prefixed or raw Base58 string to an Integer. Raw inputs are recognised by their exact payload length.
Returns the Integer on success, nil if authentication fails. Raises ArgumentError on invalid wire format (see Block58#valid?).
82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/epithet.rb', line 82 def decode(s) s = s.delete_prefix(@prefix_s) unless s.bytesize == @block58.size raise ArgumentError, 'unexpected format' unless @block58.valid?(s) d = @decryptor.dup h = @hmac.dup ct = @block58.s2i(s) block = d.update([ct >> 64, ct].pack('Q>2')) + d.final pt, m = block.unpack('a8a8') id = pt.unpack1('Q>') cteq([h.update(pt).digest].pack('a8'), m) ? id : nil end |
#encode(id) ⇒ Object
Encode a 64-bit unsigned Integer to a prefixed Base58 string. Raises ArgumentError on invalid values.
64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/epithet.rb', line 64 def encode(id) raise ArgumentError, 'not a 64-bit unsigned integer' unless Integer === id && id.bit_length <= 64 && id >= 0 e = @encryptor.dup h = @hmac.dup pt = [id].pack('Q>') m = h.update(pt).digest block = e.update([pt, m].pack('a8a8')) + e.final ct = block.unpack('Q>2').then { (_1 << 64) + _2 } @prefix_s + @block58.i2s(ct) end |