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 =

Returns Current version of the rosett-ai gem.

Returns:

  • (String)

    Current version of the rosett-ai gem.

'1.0'
MAX_PERMISSIONS =

Returns Maximum number of permissions per key.

Returns:

  • (Object)

    Maximum number of permissions per key.

0o600
DEFAULT_FILENAME =

Returns Default filename for the MCP keyfile.

Returns:

  • (String)

    Default filename for the MCP keyfile.

'rosett-ai-mcp-keys.yml'
EXPIRY_WARNING_DAYS =

Returns Days before expiry to show warnings.

Returns:

  • (Integer)

    Days before expiry to show warnings.

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



27
28
29
30
31
# File 'lib/rosett_ai/mcp/keyfile.rb', line 27

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



135
136
137
138
139
140
141
142
143
144
# File 'lib/rosett_ai/mcp/keyfile.rb', line 135

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) ⇒ void

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)



85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/rosett_ai/mcp/keyfile.rb', line 85

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



73
74
75
76
77
# File 'lib/rosett_ai/mcp/keyfile.rb', line 73

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



59
60
61
62
63
64
65
66
67
68
# File 'lib/rosett_ai/mcp/keyfile.rb', line 59

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:



37
38
39
40
41
42
43
44
# File 'lib/rosett_ai/mcp/keyfile.rb', line 37

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

#reload!void

This method returns an undefined value.

Thread-safe reload (for SIGHUP handler).



49
50
51
52
53
# File 'lib/rosett_ai/mcp/keyfile.rb', line 49

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



103
104
105
106
107
108
109
110
111
112
# File 'lib/rosett_ai/mcp/keyfile.rb', line 103

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



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/rosett_ai/mcp/keyfile.rb', line 118

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