Class: RosettAi::Mcp::Keyfile

Inherits:
Object
  • Object
show all
Defined in:
lib/rosett_ai/mcp/keyfile.rb

Overview

Multi-key authentication store with thread-safe reload.

Manages a YAML keyfile with salted hashes, expiry dates, and enable/disable flags. File permissions are enforced at 0600 or 0400.

Author:

  • hugo

  • claude

Constant Summary collapse

VERSION =
'1.0'
MAX_PERMISSIONS =
0o600
DEFAULT_FILENAME =
'rosett-ai-mcp-keys.yml'
EXPIRY_WARNING_DAYS =
7

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path) ⇒ Keyfile

Returns a new instance of Keyfile.

Parameters:

  • path (String)

    path to the keyfile



23
24
25
26
27
# File 'lib/rosett_ai/mcp/keyfile.rb', line 23

def initialize(path)
  @path = File.expand_path(path)
  @mutex = Mutex.new
  @keys = []
end

Class Method Details

.create_if_missing(path) ⇒ String

Create an empty keyfile if missing.

Parameters:

  • path (String)

    keyfile path

Returns:

  • (String)

    expanded path



131
132
133
134
135
136
137
138
139
140
# File 'lib/rosett_ai/mcp/keyfile.rb', line 131

def self.create_if_missing(path)
  expanded = File.expand_path(path)
  unless File.exist?(expanded)
    dir = File.dirname(expanded)
    FileUtils.mkdir_p(dir)
    File.write(expanded, { 'version' => VERSION, 'keys' => [] }.to_yaml)
    File.chmod(0o600, expanded)
  end
  expanded
end

Instance Method Details

#add_key(name:, key_hash:, expires_at: nil)

This method returns an undefined value.

Add a new key to the keyfile.

Parameters:

  • name (String)

    key name

  • key_hash (String)

    salted hash

  • expires_at (String, nil) (defaults to: nil)

    expiry date (ISO 8601)



81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/rosett_ai/mcp/keyfile.rb', line 81

def add_key(name:, key_hash:, expires_at: nil)
  @mutex.synchronize do
    @keys << {
      name: name,
      algo: 'sha256_salt',
      key_hash: key_hash,
      created_at: Time.now.strftime('%Y-%m-%d'),
      expires_at: expires_at,
      enabled: true
    }
    write_keyfile
  end
end

#enabled_keysArray<Hash>

List enabled keys (without hashes).

Returns:

  • (Array<Hash>)

    enabled key entries



69
70
71
72
73
# File 'lib/rosett_ai/mcp/keyfile.rb', line 69

def enabled_keys
  @mutex.synchronize do
    @keys.select { |k| k[:enabled] }.map { |k| safe_entry(k) }
  end
end

#find_key(plaintext) ⇒ Hash?

Find a key entry matching the given plaintext.

Parameters:

  • plaintext (String)

    the plaintext API key

Returns:

  • (Hash, nil)

    matched key entry or nil



55
56
57
58
59
60
61
62
63
64
# File 'lib/rosett_ai/mcp/keyfile.rb', line 55

def find_key(plaintext)
  @mutex.synchronize do
    @keys.find do |entry|
      next unless entry[:enabled]
      next if expired?(entry)

      KeyHasher.verify_key(plaintext, entry[:key_hash])
    end
  end
end

#load!self

Load and validate the keyfile.

Returns:

  • (self)

Raises:



33
34
35
36
37
38
39
40
# File 'lib/rosett_ai/mcp/keyfile.rb', line 33

def load!
  @mutex.synchronize do
    validate_file_exists
    validate_permissions
    parse_keyfile
  end
  self
end

#reload!

This method returns an undefined value.

Thread-safe reload (for SIGHUP handler).



45
46
47
48
49
# File 'lib/rosett_ai/mcp/keyfile.rb', line 45

def reload!
  load!
rescue StandardError => e
  warn "[rai-mcp] Keyfile reload failed: #{e.message}"
end

#revoke_key(name) ⇒ Boolean

Revoke (disable) a key by name.

Parameters:

  • name (String)

    key name

Returns:

  • (Boolean)

    true if key was found and revoked



99
100
101
102
103
104
105
106
107
108
# File 'lib/rosett_ai/mcp/keyfile.rb', line 99

def revoke_key(name)
  @mutex.synchronize do
    entry = @keys.find { |k| k[:name] == name }
    return false unless entry

    entry[:enabled] = false
    write_keyfile
    true
  end
end

#rotate_key(name) ⇒ Hash?

Rotate a key: revoke old, return new hash for replacement.

Parameters:

  • name (String)

    key name

Returns:

  • (Hash, nil)

    new key entry or nil if not found



114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/rosett_ai/mcp/keyfile.rb', line 114

def rotate_key(name)
  @mutex.synchronize do
    old = @keys.find { |k| k[:name] == name }
    return nil unless old

    old[:enabled] = false
    new_key, new_entry = build_replacement_key(name, old[:expires_at])
    @keys << new_entry
    write_keyfile
    { plaintext: new_key, entry: safe_entry(new_entry) }
  end
end