Class: Epithet

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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)

  • prefix is stringified, and may be nil, producing an empty prefix. The prefix is included in the salt for key generation.

  • config is 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 over Keygen::DEFAULT_SCRYPT_PARAMS
  • :keygen - Install an existing key generator
  • :cipher - Must be a 128-bit block cipher in ECB mode or equivalent; omit for standard aes-256-ecb
  • :digest - Must be >= 64 bits; omit for standard sha256
  • :separator - String inserted between the prefix and the generated param; omit for standard _. May be nil. Must not share bytes with the alphabet.
  • :alphabet - Alphabet for the wire encoding; must be 58 strictly ascending bytes; omit for Block58::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)

.defaultsObject



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?).

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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