Class: AceMQ::AMQP::Keyring

Inherits:
Object
  • Object
show all
Defined in:
lib/acemq/amqp/crypto.rb

Overview

The keys a service can write with and the keys it can read with.

Those are not the same set, and that asymmetry is the whole of key rotation. A service writes with exactly one key — the current one — and must be able to read with every key any message still in flight was written with. Rotating means adding a key and making it current, while the old ones stay readable until nothing written with them is left.

keys = Keyring.new(EncryptionKey.new("orders-2026-09", now),
                 EncryptionKey.new("orders-2026-06", june))

The first key is the one that writes. The order rotation happens in is: add the new key everywhere first, so every consumer can read it, and only then make it current somewhere.

A keyring is anything answering current and key_for, so one backed by a key management service is a small class rather than a fork of this one. Implementations should cache: key_for is called for every message decoded, and a key service charged per call will notice.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(*keys) ⇒ Keyring

Returns a new instance of Keyring.

Parameters:

Raises:

  • (ArgumentError)


181
182
183
184
185
186
187
188
# File 'lib/acemq/amqp/crypto.rb', line 181

def initialize(*keys)
  raise ArgumentError, "a keyring needs at least one key" if keys.empty?

  @lock = Mutex.new
  @keys = {}
  keys.each { |key| add(key) }
  @current = keys.first.id
end

Class Method Details

.check!(candidate) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Raises:

  • (ArgumentError) —

    when candidate is not a keyring



199
200
201
202
203
204
# File 'lib/acemq/amqp/crypto.rb', line 199

def self.check!(candidate)
  return candidate if candidate.respond_to?(:current) && candidate.respond_to?(:key_for)

  raise ArgumentError,
        "#{candidate.inspect} is not a keyring: it needs current and key_for"
end

.of(id, secret) ⇒ Keyring

A keyring holding one key, which both writes and reads.

Parameters:

  • id (String)
  • secret (String)

Returns:



195
# File 'lib/acemq/amqp/crypto.rb', line 195

def self.of(id, secret) = new(EncryptionKey.new(id, secret))

Instance Method Details

#add(key) ⇒ Keyring

Puts a key on the ring without making it the one used for writing.

Parameters:

Returns:



210
211
212
213
214
215
216
217
218
219
220
# File 'lib/acemq/amqp/crypto.rb', line 210

def add(key)
  unless key.is_a?(EncryptionKey)
    raise ArgumentError, "a keyring holds EncryptionKey, not #{key.class}"
  end

  @lock.synchronize do
    @keys[key.id] = key
    @current ||= key.id
  end
  self
end

#current ⇒ EncryptionKey

The key new messages are written with.

Consulted per message, so a keyring that reloads from a secret store can change what this returns and the next message uses the new key.

Returns:



243
244
245
246
247
248
249
250
# File 'lib/acemq/amqp/crypto.rb', line 243

def current
  @lock.synchronize do
    key = @keys[@current]
    raise ConfigurationError, "this keyring has no current key" if key.nil?

    key
  end
end

#ids ⇒ Array<String>

The identifiers, for a health endpoint or a line at start-up. Never the keys.

Returns:

  • (Array<String>)


279
# File 'lib/acemq/amqp/crypto.rb', line 279

def ids = @lock.synchronize { @keys.keys.sort }

#inspect ⇒ Object



282
# File 'lib/acemq/amqp/crypto.rb', line 282

def inspect = "#<AceMQ::AMQP::Keyring #{self}>"

#key_for(id) ⇒ EncryptionKey

The key a message named.

Parameters:

  • id (String) —

    an identifier read out of a message

Returns:

Raises:

  • (DecodeError) —

    when this keyring does not hold it. Fatal rather than retryable: the same bytes name the same absent key next time



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/acemq/amqp/crypto.rb', line 258

def key_for(id)
  @lock.synchronize do
    key = @keys[id.to_s]
    # Naming what is held is safe — identifiers travel in the clear
    # anyway — and it is the difference between "the key was retired
    # while messages were still queued" and "this message came from a
    # service using a different keyring", which have different fixes.
    if key.nil?
      raise DecodeError,
            "this message was encrypted with key #{id.to_s.inspect}, which is not on " \
            "this keyring; it holds #{@keys.keys.sort.join(", ")}"
    end

    key
  end
end

#to_s ⇒ Object



281
# File 'lib/acemq/amqp/crypto.rb', line 281

def to_s = "Keyring{current=#{@lock.synchronize { @current }}, holds=#{ids.join(", ")}}"

#use(id) ⇒ Keyring

Makes a key the one new messages are written with.

Parameters:

  • id (String)

Returns:



226
227
228
229
230
231
232
233
234
235
# File 'lib/acemq/amqp/crypto.rb', line 226

def use(id)
  @lock.synchronize do
    unless @keys.key?(id.to_s)
      raise ArgumentError, "there is no key #{id.to_s.inspect} on this keyring"
    end

    @current = id.to_s
  end
  self
end