Module: LocalVault::SessionCache

Defined in:
lib/localvault/session_cache.rb

Overview

Caches derived master keys to avoid re-prompting passphrase on every command.

On macOS, uses the system Keychain (service “localvault”, account = vault name). Falls back to file-based cache at ~/.localvault/.sessions/ (mode 0600) when Keychain is unavailable (CI, sandboxed, Linux).

Entries expire after DEFAULT_TTL_HOURS (8 hours). Expired entries are cleaned up on read.

Stored payload format: “<base64_key>|<expiry_unix_ts>”

Examples:

SessionCache.set("production", master_key)
SessionCache.get("production")  # => master_key bytes (or nil if expired)
SessionCache.clear("production")

Constant Summary collapse

DEFAULT_TTL_HOURS =
8
KEYCHAIN_SERVICE =
"localvault"

Class Method Summary collapse

Class Method Details

.clear(vault_name) ⇒ void

This method returns an undefined value.

Remove the cached master key for a single vault.

Parameters:

  • vault_name (String)

    the vault name to clear



66
67
68
# File 'lib/localvault/session_cache.rb', line 66

def self.clear(vault_name)
  keychain_delete(vault_name)
end

.clear_allvoid

This method returns an undefined value.

Remove cached master keys for all known vaults.



73
74
75
# File 'lib/localvault/session_cache.rb', line 73

def self.clear_all
  Store.list_vaults.each { |name| clear(name) }
end

.get(vault_name) ⇒ String?

Retrieve a cached master key for the given vault.

Returns nil if no entry exists or the entry has expired. Expired entries are automatically cleaned up.

Parameters:

  • vault_name (String)

    the vault name to look up

Returns:

  • (String, nil)

    raw master key bytes, or nil if not cached/expired



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/localvault/session_cache.rb', line 32

def self.get(vault_name)
  payload = keychain_get(vault_name)
  return nil unless payload

  key_b64, expiry_str = payload.split("|", 2)
  return nil unless key_b64 && expiry_str

  expiry = expiry_str.to_i
  if Time.now.to_i >= expiry
    clear(vault_name)  # clean up expired entry
    return nil
  end

  Base64.strict_decode64(key_b64)
rescue ArgumentError
  nil
end

.keychain_delete(vault_name) ⇒ Object



133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/localvault/session_cache.rb', line 133

def self.keychain_delete(vault_name)
  if macos?
    system(
      "security", "delete-generic-password",
      "-a", vault_name,
      "-s", KEYCHAIN_SERVICE,
      out: File::NULL, err: File::NULL
    )
  end
  # Always clean file fallback
  FileUtils.rm_f(session_file(vault_name))
end

.keychain_get(vault_name) ⇒ Object



96
97
98
99
100
101
102
103
104
# File 'lib/localvault/session_cache.rb', line 96

def self.keychain_get(vault_name)
  if macos?
    out = `security find-generic-password -a #{Shellwords.escape(vault_name)} -s #{Shellwords.escape(KEYCHAIN_SERVICE)} -w 2>/dev/null`.chomp
    return out if $?.success? && !out.empty?
  end
  # File fallback (Linux, or macOS when Keychain unavailable)
  file = session_file(vault_name)
  File.exist?(file) ? File.read(file).strip : nil
end

.keychain_set(vault_name, payload) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/localvault/session_cache.rb', line 106

def self.keychain_set(vault_name, payload)
  if macos?
    keychain_delete(vault_name)
    success = system(
      "security", "add-generic-password",
      "-a", vault_name,
      "-s", KEYCHAIN_SERVICE,
      "-w", payload,
      out: File::NULL, err: File::NULL
    )
    # Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
    unless success
      file = session_file(vault_name)
      File.write(file, payload)
      # chmod unconditionally: File.write's perm: only applies on creation,
      # so an existing looser-mode file would otherwise keep its perms and
      # leak the master key.
      File.chmod(0o600, file)
    end
  else
    keychain_delete(vault_name)
    file = session_file(vault_name)
    File.write(file, payload)
    File.chmod(0o600, file)
  end
end

.macos?Boolean

Returns:

  • (Boolean)


79
80
81
# File 'lib/localvault/session_cache.rb', line 79

def self.macos?
  RUBY_PLATFORM.include?("darwin")
end

.session_file(vault_name) ⇒ Object



92
93
94
# File 'lib/localvault/session_cache.rb', line 92

def self.session_file(vault_name)
  File.join(sessions_dir, vault_name.gsub(/[^a-zA-Z0-9_\-]/, "_"))
end

.sessions_dirObject



83
84
85
86
87
88
89
90
# File 'lib/localvault/session_cache.rb', line 83

def self.sessions_dir
  dir = File.join(
    ENV.fetch("LOCALVAULT_HOME", File.expand_path("~/.localvault")),
    ".sessions"
  )
  FileUtils.mkdir_p(dir, mode: 0o700)
  dir
end

.set(vault_name, master_key, ttl_hours: DEFAULT_TTL_HOURS) ⇒ void

This method returns an undefined value.

Cache a master key for the given vault with a time-to-live.

Parameters:

  • vault_name (String)

    the vault name to cache

  • master_key (String)

    raw master key bytes to store

  • ttl_hours (Integer) (defaults to: DEFAULT_TTL_HOURS)

    hours until expiry (default: 8)



56
57
58
59
60
# File 'lib/localvault/session_cache.rb', line 56

def self.set(vault_name, master_key, ttl_hours: DEFAULT_TTL_HOURS)
  expiry  = Time.now.to_i + (ttl_hours * 3600).to_i
  payload = "#{Base64.strict_encode64(master_key)}|#{expiry}"
  keychain_set(vault_name, payload)
end