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) + MSB_64(HMAC-SHA256(id))) with the result
base58 encoded for transmission and the contextual prefix prepended.
Encodings are canonical; a given configuration produces 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 context string, which may be useful for purpose separation or rotation, and each subkey is bound to the configured name of the algorithm that consumes it.
Example usage:
# in setup-environment.sh
EPITHET_PASSPHRASE='example_only' ; export EPITHET_PASSPHRASE
# ... later, in Ruby ...
Epithet.configure(passphrase: ENV.fetch('EPITHET_PASSPHRASE'))
user_epithet = Epithet.new('user')
user_epithet.encode(1) #=> "user_NEwRoiarS9wdmiLmjEtti3"
Defined Under Namespace
Classes: Block58, Config, Keygen
Constant Summary collapse
- FormatError =
Raised by #decode when the input is not valid wire format.
Class.new(ArgumentError)
- VERSION =
= '2.0.0' '2.0.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 the key generation once per runtime).
-
The stringified
prefixis included in key derivation. It may be nil or empty, in which case the separator is ignored and a bare param will be produced. -
configis optional and intended for cases where you need finer control than global defaults.
Mixed character encodings across prefix & separator may raise Encoding::CompatibilityError.
The simplest typical invocation is Epithet.new('prefix').
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/epithet.rb', line 52 def initialize(prefix, config: Epithet.defaults) prefix = String(prefix) @prefix = prefix.empty? ? prefix : prefix + config.separator @wire_prefix = @prefix.b key_salt = [prefix.bytesize, prefix, config.context.bytesize, config.context].pack('Q>Z*Q>Z*') @codec = config.codec 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:cipher:#{config.cipher}", key_salt, cipher_key_len) digest_key = config.keygen.generate("epithet:digest:#{config.digest}", 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 context,
# and using a custom separator.
Epithet.configure(
keygen: Epithet.defaults.keygen,
context: 'rotation-19',
separator: '-'
)
Options
:passphrase- Install new key generator with scrypt-derived key material:scrypt- Merge params for scrypt:keygen- Use 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 digest; 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. Not emitted when prefix isnilor empty.:alphabet- Custom base58 alphabet for the wire encoding. Must be strictly ascending bytes.:context- If supplied, string form is included in subkey derivation. Useful for purpose separation, or rotation epochs.
At minimum, one of passphrase: or keygen: is required.
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)
169 |
# File 'lib/epithet.rb', line 169 def configure(opts) = @defaults = Config === opts ? opts : Config.new(opts) |
.defaults ⇒ Object
170 |
# File 'lib/epithet.rb', line 170 def defaults() = @defaults || raise('no Epithet defaults configured') |
Instance Method Details
#cteq(a, b) ⇒ Object
Constant time octet-string comparison.
106 107 108 109 |
# File 'lib/epithet.rb', line 106 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. The input is stringified and read as bytes, whatever its encoding; raw inputs are recognised by their exact payload length.
Returns the integer on success, nil if authentication fails. Raises FormatError on invalid wire format (see Block58#valid?).
90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/epithet.rb', line 90 def decode(s) s = String(s).b s = s.delete_prefix(@wire_prefix) unless s.bytesize == @codec.size raise FormatError, 'unexpected format' unless @codec.valid?(s) d = @decryptor.dup h = @hmac.dup ct = @codec.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.
71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/epithet.rb', line 71 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 + @codec.i2s(ct) end |